diff --git a/config.env.py b/config.env.py index 2e75836..61a939a 100644 --- a/config.env.py +++ b/config.env.py @@ -31,7 +31,7 @@ SQLALCHEMY_DATABASE_URI = os.environ.get( "DATABASE_URI", - "postgresql://selfservice:supersecretpassword@localhost:5433/selfservice" + "postgresql://selfservice:supersecretpassword@localhost:5433/selfservice", ) SQLALCHEMY_TRACK_MODIFICATIONS = False @@ -44,5 +44,4 @@ TWILIO_SID = os.environ.get("TWILIO_SID", "") TWILIO_TOKEN = os.environ.get("TWILIO_TOKEN", "") -TWILIO_NUMBER = os.environ.get("TWILIO_NUMBER", "") TWILIO_SERVICE_SID = os.environ.get("TWILIO_SERVICE_SID", "") diff --git a/gunicorn_conf.py b/gunicorn_conf.py index d4ef037..38ff373 100644 --- a/gunicorn_conf.py +++ b/gunicorn_conf.py @@ -1,5 +1,6 @@ +"""Gunicorn configuration for self-service application.""" + import os -import subprocess from flask_sqlalchemy import SQLAlchemy from flask_migrate import Migrate, upgrade @@ -8,16 +9,17 @@ app = Flask(__name__) if os.path.exists(os.path.join(os.getcwd(), "config.py")): - app.config.from_pyfile(os.path.join(os.getcwd(), "config.py")) + app.config.from_pyfile(os.path.join(os.getcwd(), "config.py")) else: - app.config.from_pyfile(os.path.join(os.getcwd(), "config.env.py")) + app.config.from_pyfile(os.path.join(os.getcwd(), "config.env.py")) # Create the database session and import models. db = SQLAlchemy(app) -from selfservice.models import * +from selfservice.models import * # noqa: F403,E402 # pylint: disable=wrong-import-position,unused-wildcard-import,wildcard-import migrate = Migrate(app, db) -def on_starting(server): - if not os.path.exists(os.path.join(os.getcwd(), "data.db")): - with app.app_context(): - upgrade() + +def on_starting(_server): # pylint: disable=missing-function-docstring + if not os.path.exists(os.path.join(os.getcwd(), "data.db")): + with app.app_context(): + upgrade() diff --git a/migrations/versions/ada3c91a553e_save_phone_number.py b/migrations/versions/ada3c91a553e_save_phone_number.py new file mode 100644 index 0000000..9042a93 --- /dev/null +++ b/migrations/versions/ada3c91a553e_save_phone_number.py @@ -0,0 +1,31 @@ +"""save phone number + +Revision ID: ada3c91a553e +Revises: fdb69cd98e19 +Create Date: 2026-02-18 21:07:12.041639 + +""" + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = "ada3c91a553e" +down_revision = "fdb69cd98e19" +branch_labels = None +depends_on = None + + +def upgrade(): + op.alter_column( + "phone_codes", "code", new_column_name="phone_number", type_=sa.String(12) + ) + op.drop_constraint("phone_codes_pkey", "phone_codes", type_="primary") + + +def downgrade(): + op.create_primary_key("phone_codes_pkey", "phone_codes", ["code"]) + op.alter_column( + "phone_codes", "phone_number", new_column_name="code", type_=sa.String(6) + ) diff --git a/requirements.in b/requirements.in index 9388fca..de7f9c5 100644 --- a/requirements.in +++ b/requirements.in @@ -18,3 +18,4 @@ xkcdpass~=1.20.0 gunicorn~=23.0.0 black~=25.12.0 pylint~=4.0.4 +phonenumbers~=9.0.26 diff --git a/requirements.txt b/requirements.txt index c6be871..69d62fd 100644 --- a/requirements.txt +++ b/requirements.txt @@ -16,13 +16,13 @@ alembic==1.18.4 # via flask-migrate annotated-types==0.7.0 # via pydantic -anyio==4.12.1 +anyio==4.13.0 # via httpx astroid==4.0.4 # via pylint async-property==0.2.2 # via python-keycloak -attrs==25.4.0 +attrs==26.1.0 # via aiohttp black==25.12.0 # via -r requirements.in @@ -30,7 +30,7 @@ blinker==1.9.0 # via # flask # sentry-sdk -certifi==2026.1.4 +certifi==2026.2.25 # via # httpcore # httpx @@ -38,13 +38,13 @@ certifi==2026.1.4 # sentry-sdk cffi==2.0.0 # via cryptography -charset-normalizer==3.4.4 +charset-normalizer==3.4.6 # via requests click==8.3.1 # via # black # flask -cryptography==46.0.5 +cryptography==46.0.6 # via # jwcrypto # oic @@ -60,7 +60,7 @@ dill==0.4.1 # via pylint dnspython==2.8.0 # via srvlookup -flask==3.1.2 +flask==3.1.3 # via # -r requirements.in # flask-limiter @@ -90,7 +90,7 @@ frozenlist==1.8.0 # aiosignal future==1.0.0 # via pyjwkest -greenlet==3.3.1 +greenlet==3.3.2 # via sqlalchemy gunicorn==23.0.0 # via -r requirements.in @@ -108,7 +108,7 @@ idna==3.11 # yarl importlib-resources==6.5.2 # via flask-pyoidc -isort==7.0.0 +isort==8.0.1 # via pylint itsdangerous==2.2.0 # via flask @@ -152,9 +152,11 @@ passlib==1.7.4 # via -r requirements.in pathspec==1.0.4 # via black +phonenumbers==9.0.26 + # via -r requirements.in pillow==12.1.1 # via flask-qrcode -platformdirs==4.9.2 +platformdirs==4.9.4 # via # black # pylint @@ -164,7 +166,7 @@ propcache==0.4.1 # yarl psycopg2-binary==2.9.11 # via -r requirements.in -pyasn1==0.6.2 +pyasn1==0.6.3 # via # pyasn1-modules # python-ldap @@ -180,17 +182,17 @@ pydantic==2.12.5 # via pydantic-settings pydantic-core==2.41.5 # via pydantic -pydantic-settings==2.13.0 +pydantic-settings==2.13.1 # via oic pyjwkest==1.4.4 # via oic -pyjwt==2.11.0 +pyjwt==2.12.1 # via twilio -pylint==4.0.4 +pylint==4.0.5 # via -r requirements.in pyotp==2.9.0 # via -r requirements.in -python-dotenv==1.2.1 +python-dotenv==1.2.2 # via pydantic-settings python-freeipa==1.0.10 # via -r requirements.in @@ -202,7 +204,7 @@ pytokens==0.4.1 # via black qrcode==8.2 # via flask-qrcode -requests==2.32.5 +requests==2.33.0 # via # flask-pyoidc # flask-xcaptcha @@ -214,11 +216,11 @@ requests==2.32.5 # twilio requests-toolbelt==1.0.0 # via python-keycloak -sentry-sdk==2.53.0 +sentry-sdk==2.56.0 # via -r requirements.in six==1.17.0 # via pyjwkest -sqlalchemy==2.0.46 +sqlalchemy==2.0.48 # via # alembic # flask-sqlalchemy @@ -250,11 +252,11 @@ urllib3==2.6.3 # via # requests # sentry-sdk -werkzeug==3.1.5 +werkzeug==3.1.7 # via flask -wrapt==2.1.1 +wrapt==2.1.2 # via deprecated xkcdpass==1.20.0 # via -r requirements.in -yarl==1.22.0 +yarl==1.23.0 # via aiohttp diff --git a/selfservice/blueprints/recovery.py b/selfservice/blueprints/recovery.py index 586deef..2091bd0 100644 --- a/selfservice/blueprints/recovery.py +++ b/selfservice/blueprints/recovery.py @@ -3,23 +3,24 @@ """ import datetime -import uuid import logging +import uuid +import phonenumbers from flask import Blueprint, render_template, request, redirect, flash +from flask import current_app from flask import session as flask_session +from twilio.rest import Client +from selfservice import db, auth, xcaptcha, ldap, version, OIDC_PROVIDER +from selfservice.models import RecoverySession, PhoneVerification, ResetToken from selfservice.utilities.general import email_recovery, phone_recovery +from selfservice.utilities.ldap import verif_methods, get_members from selfservice.utilities.reset import ( generate_token, - generate_pin, passwd_reset, TokenAlreadyExists, ) -from selfservice.utilities.ldap import verif_methods, get_members - -from selfservice.models import RecoverySession, PhoneVerification, ResetToken -from selfservice import db, auth, xcaptcha, ldap, version, OIDC_PROVIDER LOG = logging.getLogger(__name__) @@ -37,7 +38,6 @@ def create_session(): return render_template("recovery.html", version=version) if xcaptcha.verify(): - # If we can't find an account, flash error. try: member = ldap.get_member(request.form["username"], True) @@ -160,8 +160,16 @@ def method_selection(recovery_id, method): return redirect("/recovery") elif method == "phone": + formatted_phone = phonenumbers.format_number( + phonenumbers.parse(methods["phone"][index]["data"], "US"), + phonenumbers.PhoneNumberFormat.E164, + ) + try: - token = generate_pin(session) + # Create the object in the database. + reset = PhoneVerification(session=session.id, phone_number=formatted_phone) + db.session.add(reset) + db.session.commit() except TokenAlreadyExists: flash( "This session has already been used to generate a " @@ -171,7 +179,7 @@ def method_selection(recovery_id, method): return redirect("/recovery") try: - phone_recovery(phone=methods["phone"][index]["data"], token=token) + phone_recovery(phone=formatted_phone) return render_template( "phone.html", recovery_id=session.id, @@ -190,9 +198,18 @@ def verify_phone(recovery_id): Check the provided verification code against our stored code. """ session = RecoverySession.query.filter_by(id=recovery_id).first() - token = PhoneVerification.query.filter_by(session=recovery_id).first() + phone = PhoneVerification.query.filter_by(session=recovery_id).first() + + service_sid = current_app.config.get("TWILIO_SERVICE_SID") + client = Client( + current_app.config.get("TWILIO_SID"), current_app.config.get("TWILIO_TOKEN") + ) + + verification_check = client.verify.v2.services( + service_sid + ).verification_checks.create(to=phone.phone_number, code=request.form["verify"]) - if request.form["verify"] == token.code: + if verification_check.status == "approved": token = ResetToken.query.filter_by(session=recovery_id).first() if not token: token = generate_token(session) diff --git a/selfservice/models.py b/selfservice/models.py index 0210d38..32b6387 100644 --- a/selfservice/models.py +++ b/selfservice/models.py @@ -69,7 +69,7 @@ class PhoneVerification(db.Model): """ __tablename__ = "phone_codes" - code = Column(String(6), primary_key=True) + phone_number = Column(String(12), primary_key=True) session = Column(String(36), ForeignKey("session.id")) diff --git a/selfservice/utilities/general.py b/selfservice/utilities/general.py index a6351bd..4c26c85 100644 --- a/selfservice/utilities/general.py +++ b/selfservice/utilities/general.py @@ -3,12 +3,15 @@ """ import smtplib +import logging from email.mime.text import MIMEText from email.utils import formatdate from twilio.rest import Client from flask import current_app +LOG = logging.getLogger(__name__) + def email_recovery(username, address, token): """ @@ -42,24 +45,16 @@ def email_recovery(username, address, token): server.quit() -def phone_recovery(phone, token): +def phone_recovery(phone): """ Use Twilio to send token. """ - from_number = current_app.config.get("TWILIO_NUMBER") service_sid = current_app.config.get("TWILIO_SERVICE_SID") client = Client( current_app.config.get("TWILIO_SID"), current_app.config.get("TWILIO_TOKEN") ) - # REMOVE ME - client.http_client.logger = current_app.logger - print(f"twilio client: {client}") - # REMOVE ME - - body = f"Your CSH account recovery PIN is: {token}" - - m = client.messages.create( - to=phone, from_=from_number, body=body, messaging_service_sid=service_sid + verification = client.verify.v2.services(service_sid).verifications.create( + channel="sms", to=phone ) - print(m) + LOG.info("Verification sent: %s", verification) diff --git a/selfservice/utilities/reset.py b/selfservice/utilities/reset.py index 063386a..b4718b5 100644 --- a/selfservice/utilities/reset.py +++ b/selfservice/utilities/reset.py @@ -2,13 +2,12 @@ Functions relating to the verification of users and subsequent account resets. """ -import random import uuid import requests import ldap import srvlookup -from selfservice.models import ResetToken, PhoneVerification +from selfservice.models import ResetToken from selfservice import db, app @@ -75,30 +74,6 @@ def generate_token(session): return token -def generate_pin(session): - """ - Generate a six-digit pin for SMS verification. - - Keyword arguments: - session -- Instance of RecoverySession model - """ - - # Generate a random UUID for reset token. - token = f"{random.randrange(1, 10**6):06}" - - # Verify that this session creates only one token. - previous = ResetToken.query.filter_by(session=session.id).first() - if previous: - raise TokenAlreadyExists() - - # Create the object in the database. - reset = PhoneVerification(code=token, session=session.id) - db.session.add(reset) - db.session.commit() - - return token - - def valid_token(token_id): """ Ensure that the token provided is still valid.