From 371deec06e63828fb6b2c8d155b0bd12bc299dca Mon Sep 17 00:00:00 2001 From: Cole Stowell Date: Fri, 27 Mar 2026 23:56:21 -0400 Subject: [PATCH 1/5] feat: twilio verify! --- .../ada3c91a553e_save_phone_number.py | 31 ++++++++++++++ requirements.in | 1 + requirements.txt | 42 ++++++++++--------- selfservice/blueprints/recovery.py | 31 +++++++++++--- selfservice/models.py | 2 +- selfservice/utilities/general.py | 19 ++++----- selfservice/utilities/reset.py | 7 +++- 7 files changed, 93 insertions(+), 40 deletions(-) create mode 100644 migrations/versions/ada3c91a553e_save_phone_number.py 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 c3c86dc..513d1b4 100644 --- a/selfservice/blueprints/recovery.py +++ b/selfservice/blueprints/recovery.py @@ -2,6 +2,7 @@ Flask blueprint for handling identity verification and account recovery. """ +import phonenumbers import uuid import logging @@ -16,6 +17,8 @@ TokenAlreadyExists, ) from selfservice.utilities.ldap import verif_methods, get_members +from twilio.rest import Client +from flask import current_app from selfservice.models import RecoverySession, PhoneVerification, ResetToken from selfservice import db, auth, xcaptcha, ldap, version, OIDC_PROVIDER @@ -36,7 +39,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) @@ -159,8 +161,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 " @@ -170,7 +180,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, @@ -189,9 +199,20 @@ 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() + + print(phone.phone_number) + + 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 e0f4cce..c299cf4 100644 --- a/selfservice/models.py +++ b/selfservice/models.py @@ -50,7 +50,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 791540b..abaf159 100644 --- a/selfservice/utilities/general.py +++ b/selfservice/utilities/general.py @@ -3,6 +3,7 @@ """ import smtplib +import logging from datetime import datetime, timedelta from email.mime.text import MIMEText @@ -10,6 +11,8 @@ from twilio.rest import Client from flask import current_app +LOG = logging.getLogger(__name__) + def is_expired(timestamp, minutes): """ @@ -56,24 +59,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(f"Verification sent: {verification}") diff --git a/selfservice/utilities/reset.py b/selfservice/utilities/reset.py index bbb9442..b375011 100644 --- a/selfservice/utilities/reset.py +++ b/selfservice/utilities/reset.py @@ -80,6 +80,8 @@ def generate_pin(session): session -- Instance of RecoverySession model """ + # TODO: do this too + # Generate a random UUID for reset token. token = f"{random.randrange(1, 10**6):06}" @@ -88,7 +90,7 @@ def generate_pin(session): if previous: raise TokenAlreadyExists() - # Create the object in the database. + # Create the object in the database. reset = PhoneVerification(code=token, session=session.id) db.session.add(reset) db.session.commit() @@ -136,11 +138,12 @@ def passwd_reset(username, password): # FreeIPA automatically expires the password set through the previous # method, so we need to use their password change API to get past that. - requests.post( + res = requests.post( f"https://{ldap_uri}/ipa/session/change_password", data={"user": username, "old_password": password, "new_password": password}, timeout=30, ) + print(res) def passwd_change(username, old_pw, new_pw): From 5c0541828c5e55a4460c9f35a26df34ea0865fcf Mon Sep 17 00:00:00 2001 From: Cole Stowell Date: Fri, 27 Mar 2026 23:57:41 -0400 Subject: [PATCH 2/5] fix: unused --- selfservice/blueprints/recovery.py | 1 - selfservice/utilities/reset.py | 29 +---------------------------- 2 files changed, 1 insertion(+), 29 deletions(-) diff --git a/selfservice/blueprints/recovery.py b/selfservice/blueprints/recovery.py index 513d1b4..4f26828 100644 --- a/selfservice/blueprints/recovery.py +++ b/selfservice/blueprints/recovery.py @@ -12,7 +12,6 @@ from selfservice.utilities.general import is_expired, email_recovery, phone_recovery from selfservice.utilities.reset import ( generate_token, - generate_pin, passwd_reset, TokenAlreadyExists, ) diff --git a/selfservice/utilities/reset.py b/selfservice/utilities/reset.py index b375011..601d37b 100644 --- a/selfservice/utilities/reset.py +++ b/selfservice/utilities/reset.py @@ -72,32 +72,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 - """ - - # TODO: do this too - - # 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. @@ -138,12 +112,11 @@ def passwd_reset(username, password): # FreeIPA automatically expires the password set through the previous # method, so we need to use their password change API to get past that. - res = requests.post( + requests.post( f"https://{ldap_uri}/ipa/session/change_password", data={"user": username, "old_password": password, "new_password": password}, timeout=30, ) - print(res) def passwd_change(username, old_pw, new_pw): From 0a5592df41845178c84a5ca6c505e7223a988bd6 Mon Sep 17 00:00:00 2001 From: Cole Stowell Date: Sat, 28 Mar 2026 00:19:27 -0400 Subject: [PATCH 3/5] fix: TWILIO_NUMBER is kil --- config.env.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) 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", "") From 3f93387e6e125198a7a5fc703c0b059cb62f71a7 Mon Sep 17 00:00:00 2001 From: Cole Stowell Date: Sat, 28 Mar 2026 00:37:24 -0400 Subject: [PATCH 4/5] fix: lint --- gunicorn_conf.py | 18 ++++++++++-------- selfservice/blueprints/recovery.py | 15 +++++++-------- selfservice/utilities/general.py | 2 +- selfservice/utilities/reset.py | 3 +-- 4 files changed, 19 insertions(+), 19 deletions(-) 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/selfservice/blueprints/recovery.py b/selfservice/blueprints/recovery.py index f3a3652..634bb13 100644 --- a/selfservice/blueprints/recovery.py +++ b/selfservice/blueprints/recovery.py @@ -2,26 +2,25 @@ Flask blueprint for handling identity verification and account recovery. """ -import phonenumbers 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, passwd_reset, TokenAlreadyExists, ) -from selfservice.utilities.ldap import verif_methods, get_members -from twilio.rest import Client -from flask import current_app - -from selfservice.models import RecoverySession, PhoneVerification, ResetToken -from selfservice import db, auth, xcaptcha, ldap, version, OIDC_PROVIDER LOG = logging.getLogger(__name__) diff --git a/selfservice/utilities/general.py b/selfservice/utilities/general.py index b320a30..4c26c85 100644 --- a/selfservice/utilities/general.py +++ b/selfservice/utilities/general.py @@ -57,4 +57,4 @@ def phone_recovery(phone): verification = client.verify.v2.services(service_sid).verifications.create( channel="sms", to=phone ) - LOG.info(f"Verification sent: {verification}") + LOG.info("Verification sent: %s", verification) diff --git a/selfservice/utilities/reset.py b/selfservice/utilities/reset.py index 760ff03..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 From 0f28a8104fc6cf4f5b2959145de0f320ca3e9bb0 Mon Sep 17 00:00:00 2001 From: Cole Stowell Date: Sat, 28 Mar 2026 02:10:03 -0400 Subject: [PATCH 5/5] fix: remove print --- selfservice/blueprints/recovery.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/selfservice/blueprints/recovery.py b/selfservice/blueprints/recovery.py index 634bb13..2091bd0 100644 --- a/selfservice/blueprints/recovery.py +++ b/selfservice/blueprints/recovery.py @@ -200,8 +200,6 @@ def verify_phone(recovery_id): session = RecoverySession.query.filter_by(id=recovery_id).first() phone = PhoneVerification.query.filter_by(session=recovery_id).first() - print(phone.phone_number) - service_sid = current_app.config.get("TWILIO_SERVICE_SID") client = Client( current_app.config.get("TWILIO_SID"), current_app.config.get("TWILIO_TOKEN")