Skip to content

Commit 46b0fc1

Browse files
authored
Merge pull request #122 from ComputerScienceHouse/cole-dev
Swap Twilio SMS with Twilio Verify
2 parents 9600c05 + 60f6c14 commit 46b0fc1

11 files changed

Lines changed: 104 additions & 95 deletions

File tree

config.env.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@
3131

3232
SQLALCHEMY_DATABASE_URI = os.environ.get(
3333
"DATABASE_URI",
34-
"postgresql://selfservice:supersecretpassword@localhost:5433/selfservice"
34+
"postgresql://selfservice:supersecretpassword@localhost:5433/selfservice",
3535
)
3636
SQLALCHEMY_TRACK_MODIFICATIONS = False
3737

@@ -44,5 +44,4 @@
4444

4545
TWILIO_SID = os.environ.get("TWILIO_SID", "")
4646
TWILIO_TOKEN = os.environ.get("TWILIO_TOKEN", "")
47-
TWILIO_NUMBER = os.environ.get("TWILIO_NUMBER", "")
4847
TWILIO_SERVICE_SID = os.environ.get("TWILIO_SERVICE_SID", "")

docker-compose.yaml

Lines changed: 0 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,5 @@
11
version: '2'
22
services:
3-
self-service:
4-
build:
5-
context: .
6-
develop:
7-
watch:
8-
- action: sync+restart
9-
path: ./selfservice
10-
target: /opt/selfservice/selfservice
11-
env_file:
12-
- .env
13-
ports:
14-
- "8080:8080"
15-
16-
173
postgres:
184
image: docker.io/postgres:17
195
container_name: selfservice-postgres

gunicorn_conf.py

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1+
"""Gunicorn configuration for self-service application."""
2+
13
import os
2-
import subprocess
34

45
from flask_sqlalchemy import SQLAlchemy
56
from flask_migrate import Migrate, upgrade
@@ -8,16 +9,17 @@
89
app = Flask(__name__)
910

1011
if os.path.exists(os.path.join(os.getcwd(), "config.py")):
11-
app.config.from_pyfile(os.path.join(os.getcwd(), "config.py"))
12+
app.config.from_pyfile(os.path.join(os.getcwd(), "config.py"))
1213
else:
13-
app.config.from_pyfile(os.path.join(os.getcwd(), "config.env.py"))
14+
app.config.from_pyfile(os.path.join(os.getcwd(), "config.env.py"))
1415

1516
# Create the database session and import models.
1617
db = SQLAlchemy(app)
17-
from selfservice.models import *
18+
from selfservice.models import * # noqa: F403,E402 # pylint: disable=wrong-import-position,unused-wildcard-import,wildcard-import
1819
migrate = Migrate(app, db)
1920

20-
def on_starting(server):
21-
if not os.path.exists(os.path.join(os.getcwd(), "data.db")):
22-
with app.app_context():
23-
upgrade()
21+
22+
def on_starting(_server): # pylint: disable=missing-function-docstring
23+
if not os.path.exists(os.path.join(os.getcwd(), "data.db")):
24+
with app.app_context():
25+
upgrade()
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
"""save phone number
2+
3+
Revision ID: ada3c91a553e
4+
Revises: 92c9d8ea5b74
5+
Create Date: 2026-02-18 21:07:12.041639
6+
7+
"""
8+
9+
from alembic import op
10+
import sqlalchemy as sa
11+
12+
13+
# revision identifiers, used by Alembic.
14+
revision = "ada3c91a553e"
15+
down_revision = "92c9d8ea5b74"
16+
branch_labels = None
17+
depends_on = None
18+
19+
20+
def upgrade():
21+
op.alter_column(
22+
"phone_codes", "code", new_column_name="phone_number", type_=sa.String(12)
23+
)
24+
op.drop_constraint("phone_codes_pkey", "phone_codes", type_="primary")
25+
26+
27+
def downgrade():
28+
op.create_primary_key("phone_codes_pkey", "phone_codes", ["code"])
29+
op.alter_column(
30+
"phone_codes", "phone_number", new_column_name="code", type_=sa.String(6)
31+
)

requirements.in

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,3 +18,4 @@ xkcdpass~=1.20.0
1818
gunicorn~=23.0.0
1919
black~=25.12.0
2020
pylint~=4.0.4
21+
phonenumbers~=9.0.26

requirements.txt

Lines changed: 22 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -16,35 +16,35 @@ alembic==1.18.4
1616
# via flask-migrate
1717
annotated-types==0.7.0
1818
# via pydantic
19-
anyio==4.12.1
19+
anyio==4.13.0
2020
# via httpx
2121
astroid==4.0.4
2222
# via pylint
2323
async-property==0.2.2
2424
# via python-keycloak
25-
attrs==25.4.0
25+
attrs==26.1.0
2626
# via aiohttp
2727
black==25.12.0
2828
# via -r requirements.in
2929
blinker==1.9.0
3030
# via
3131
# flask
3232
# sentry-sdk
33-
certifi==2026.1.4
33+
certifi==2026.2.25
3434
# via
3535
# httpcore
3636
# httpx
3737
# requests
3838
# sentry-sdk
3939
cffi==2.0.0
4040
# via cryptography
41-
charset-normalizer==3.4.4
41+
charset-normalizer==3.4.6
4242
# via requests
4343
click==8.3.1
4444
# via
4545
# black
4646
# flask
47-
cryptography==46.0.5
47+
cryptography==46.0.6
4848
# via
4949
# jwcrypto
5050
# oic
@@ -60,7 +60,7 @@ dill==0.4.1
6060
# via pylint
6161
dnspython==2.8.0
6262
# via srvlookup
63-
flask==3.1.2
63+
flask==3.1.3
6464
# via
6565
# -r requirements.in
6666
# flask-limiter
@@ -90,7 +90,7 @@ frozenlist==1.8.0
9090
# aiosignal
9191
future==1.0.0
9292
# via pyjwkest
93-
greenlet==3.3.1
93+
greenlet==3.3.2
9494
# via sqlalchemy
9595
gunicorn==23.0.0
9696
# via -r requirements.in
@@ -108,7 +108,7 @@ idna==3.11
108108
# yarl
109109
importlib-resources==6.5.2
110110
# via flask-pyoidc
111-
isort==7.0.0
111+
isort==8.0.1
112112
# via pylint
113113
itsdangerous==2.2.0
114114
# via flask
@@ -152,9 +152,11 @@ passlib==1.7.4
152152
# via -r requirements.in
153153
pathspec==1.0.4
154154
# via black
155+
phonenumbers==9.0.26
156+
# via -r requirements.in
155157
pillow==12.1.1
156158
# via flask-qrcode
157-
platformdirs==4.9.2
159+
platformdirs==4.9.4
158160
# via
159161
# black
160162
# pylint
@@ -164,7 +166,7 @@ propcache==0.4.1
164166
# yarl
165167
psycopg2-binary==2.9.11
166168
# via -r requirements.in
167-
pyasn1==0.6.2
169+
pyasn1==0.6.3
168170
# via
169171
# pyasn1-modules
170172
# python-ldap
@@ -180,17 +182,17 @@ pydantic==2.12.5
180182
# via pydantic-settings
181183
pydantic-core==2.41.5
182184
# via pydantic
183-
pydantic-settings==2.13.0
185+
pydantic-settings==2.13.1
184186
# via oic
185187
pyjwkest==1.4.4
186188
# via oic
187-
pyjwt==2.11.0
189+
pyjwt==2.12.1
188190
# via twilio
189-
pylint==4.0.4
191+
pylint==4.0.5
190192
# via -r requirements.in
191193
pyotp==2.9.0
192194
# via -r requirements.in
193-
python-dotenv==1.2.1
195+
python-dotenv==1.2.2
194196
# via pydantic-settings
195197
python-freeipa==1.0.10
196198
# via -r requirements.in
@@ -202,7 +204,7 @@ pytokens==0.4.1
202204
# via black
203205
qrcode==8.2
204206
# via flask-qrcode
205-
requests==2.32.5
207+
requests==2.33.0
206208
# via
207209
# flask-pyoidc
208210
# flask-xcaptcha
@@ -214,11 +216,11 @@ requests==2.32.5
214216
# twilio
215217
requests-toolbelt==1.0.0
216218
# via python-keycloak
217-
sentry-sdk==2.53.0
219+
sentry-sdk==2.56.0
218220
# via -r requirements.in
219221
six==1.17.0
220222
# via pyjwkest
221-
sqlalchemy==2.0.46
223+
sqlalchemy==2.0.48
222224
# via
223225
# alembic
224226
# flask-sqlalchemy
@@ -250,11 +252,11 @@ urllib3==2.6.3
250252
# via
251253
# requests
252254
# sentry-sdk
253-
werkzeug==3.1.5
255+
werkzeug==3.1.7
254256
# via flask
255-
wrapt==2.1.1
257+
wrapt==2.1.2
256258
# via deprecated
257259
xkcdpass==1.20.0
258260
# via -r requirements.in
259-
yarl==1.22.0
261+
yarl==1.23.0
260262
# via aiohttp

selfservice/blueprints/recovery.py

Lines changed: 28 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,23 +3,24 @@
33
"""
44

55
import datetime
6-
import uuid
76
import logging
7+
import uuid
88

9+
import phonenumbers
910
from flask import Blueprint, render_template, request, redirect, flash
11+
from flask import current_app
1012
from flask import session as flask_session
13+
from twilio.rest import Client
1114

15+
from selfservice import db, auth, xcaptcha, ldap, version, OIDC_PROVIDER
16+
from selfservice.models import RecoverySession, PhoneVerification, ResetToken
1217
from selfservice.utilities.general import email_recovery, phone_recovery
18+
from selfservice.utilities.ldap import verif_methods, get_members
1319
from selfservice.utilities.reset import (
1420
generate_token,
15-
generate_pin,
1621
passwd_reset,
1722
TokenAlreadyExists,
1823
)
19-
from selfservice.utilities.ldap import verif_methods, get_members
20-
21-
from selfservice.models import RecoverySession, PhoneVerification, ResetToken
22-
from selfservice import db, auth, xcaptcha, ldap, version, OIDC_PROVIDER
2324

2425
LOG = logging.getLogger(__name__)
2526

@@ -37,7 +38,6 @@ def create_session():
3738
return render_template("recovery.html", version=version)
3839

3940
if xcaptcha.verify():
40-
4141
# If we can't find an account, flash error.
4242
try:
4343
member = ldap.get_member(request.form["username"], True)
@@ -160,8 +160,16 @@ def method_selection(recovery_id, method):
160160
return redirect("/recovery")
161161

162162
elif method == "phone":
163+
formatted_phone = phonenumbers.format_number(
164+
phonenumbers.parse(methods["phone"][index]["data"], "US"),
165+
phonenumbers.PhoneNumberFormat.E164,
166+
)
167+
163168
try:
164-
token = generate_pin(session)
169+
# Create the object in the database.
170+
reset = PhoneVerification(session=session.id, phone_number=formatted_phone)
171+
db.session.add(reset)
172+
db.session.commit()
165173
except TokenAlreadyExists:
166174
flash(
167175
"This session has already been used to generate a "
@@ -171,7 +179,7 @@ def method_selection(recovery_id, method):
171179
return redirect("/recovery")
172180

173181
try:
174-
phone_recovery(phone=methods["phone"][index]["data"], token=token)
182+
phone_recovery(phone=formatted_phone)
175183
return render_template(
176184
"phone.html",
177185
recovery_id=session.id,
@@ -190,9 +198,18 @@ def verify_phone(recovery_id):
190198
Check the provided verification code against our stored code.
191199
"""
192200
session = RecoverySession.query.filter_by(id=recovery_id).first()
193-
token = PhoneVerification.query.filter_by(session=recovery_id).first()
201+
phone = PhoneVerification.query.filter_by(session=recovery_id).first()
202+
203+
service_sid = current_app.config.get("TWILIO_SERVICE_SID")
204+
client = Client(
205+
current_app.config.get("TWILIO_SID"), current_app.config.get("TWILIO_TOKEN")
206+
)
207+
208+
verification_check = client.verify.v2.services(
209+
service_sid
210+
).verification_checks.create(to=phone.phone_number, code=request.form["verify"])
194211

195-
if request.form["verify"] == token.code:
212+
if verification_check.status == "approved":
196213
token = ResetToken.query.filter_by(session=recovery_id).first()
197214
if not token:
198215
token = generate_token(session)

selfservice/models.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ class PhoneVerification(db.Model):
6969
"""
7070

7171
__tablename__ = "phone_codes"
72-
code = Column(String(6), primary_key=True)
72+
phone_number = Column(String(12), primary_key=True)
7373
session = Column(String(36), ForeignKey("session.id"))
7474

7575

selfservice/utilities/general.py

Lines changed: 7 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,15 @@
33
"""
44

55
import smtplib
6+
import logging
67

78
from email.mime.text import MIMEText
89
from email.utils import formatdate
910
from twilio.rest import Client
1011
from flask import current_app
1112

13+
LOG = logging.getLogger(__name__)
14+
1215

1316
def email_recovery(username, address, token):
1417
"""
@@ -42,24 +45,16 @@ def email_recovery(username, address, token):
4245
server.quit()
4346

4447

45-
def phone_recovery(phone, token):
48+
def phone_recovery(phone):
4649
"""
4750
Use Twilio to send token.
4851
"""
49-
from_number = current_app.config.get("TWILIO_NUMBER")
5052
service_sid = current_app.config.get("TWILIO_SERVICE_SID")
5153
client = Client(
5254
current_app.config.get("TWILIO_SID"), current_app.config.get("TWILIO_TOKEN")
5355
)
5456

55-
# REMOVE ME
56-
client.http_client.logger = current_app.logger
57-
print(f"twilio client: {client}")
58-
# REMOVE ME
59-
60-
body = f"Your CSH account recovery PIN is: {token}"
61-
62-
m = client.messages.create(
63-
to=phone, from_=from_number, body=body, messaging_service_sid=service_sid
57+
verification = client.verify.v2.services(service_sid).verifications.create(
58+
channel="sms", to=phone
6459
)
65-
print(m)
60+
LOG.info("Verification sent: %s", verification)

0 commit comments

Comments
 (0)