Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions config.env.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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", "")
18 changes: 10 additions & 8 deletions gunicorn_conf.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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()
31 changes: 31 additions & 0 deletions migrations/versions/ada3c91a553e_save_phone_number.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
"""save phone number

Revision ID: ada3c91a553e
Revises: fdb69cd98e19
Comment thread
costowell marked this conversation as resolved.
Outdated
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)
)
1 change: 1 addition & 0 deletions requirements.in
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,4 @@ xkcdpass~=1.20.0
gunicorn~=23.0.0
black~=25.12.0
pylint~=4.0.4
phonenumbers~=9.0.26
42 changes: 22 additions & 20 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -16,35 +16,35 @@ 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
blinker==1.9.0
# via
# flask
# sentry-sdk
certifi==2026.1.4
certifi==2026.2.25
# via
# httpcore
# httpx
# requests
# 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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
39 changes: 28 additions & 11 deletions selfservice/blueprints/recovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)

Expand All @@ -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)
Expand Down Expand Up @@ -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 "
Expand All @@ -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,
Expand All @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion selfservice/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"))


Expand Down
19 changes: 7 additions & 12 deletions selfservice/utilities/general.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
"""
Expand Down Expand Up @@ -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)
Loading
Loading