Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
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