Skip to content

Commit 0f37015

Browse files
awolfdenAdam Wolfman
andauthored
Add Python Flask MFA example application (#7)
* Add factor list via session * Add SMS MFA workflow and routing * Update enroll factor flow to include totp * Add totp auth flow * Finalize UI for both MFA types * Run black to reformat app.py * Update README for mfa app Co-authored-by: Adam Wolfman <[email protected]>
1 parent 2a5b7ac commit 0f37015

File tree

13 files changed

+974
-0
lines changed

13 files changed

+974
-0
lines changed

python-flask-mfa-example/.gitignore

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
# Byte-compiled / optimized / DLL files
2+
__pycache__/
3+
*.py[cod]
4+
*$py.class
5+
6+
# C extensions
7+
*.so
8+
9+
# Distribution / packaging
10+
.Python
11+
build/
12+
develop-eggs/
13+
dist/
14+
downloads/
15+
eggs/
16+
.eggs/
17+
lib/
18+
lib64/
19+
parts/
20+
sdist/
21+
var/
22+
wheels/
23+
pip-wheel-metadata/
24+
share/python-wheels/
25+
*.egg-info/
26+
.installed.cfg
27+
*.egg
28+
MANIFEST
29+
30+
# PyInstaller
31+
# Usually these files are written by a python script from a template
32+
# before PyInstaller builds the exe, so as to inject date/other infos into it.
33+
*.manifest
34+
*.spec
35+
36+
# Installer logs
37+
pip-log.txt
38+
pip-delete-this-directory.txt
39+
40+
# Unit test / coverage reports
41+
htmlcov/
42+
.tox/
43+
.nox/
44+
.coverage
45+
.coverage.*
46+
.cache
47+
nosetests.xml
48+
coverage.xml
49+
*.cover
50+
*.py,cover
51+
.hypothesis/
52+
.pytest_cache/
53+
54+
# Translations
55+
*.mo
56+
*.pot
57+
58+
# Django stuff:
59+
*.log
60+
local_settings.py
61+
db.sqlite3
62+
db.sqlite3-journal
63+
64+
# Flask stuff:
65+
instance/
66+
.webassets-cache
67+
68+
# Scrapy stuff:
69+
.scrapy
70+
71+
# Sphinx documentation
72+
docs/_build/
73+
74+
# PyBuilder
75+
target/
76+
77+
# Jupyter Notebook
78+
.ipynb_checkpoints
79+
80+
# IPython
81+
profile_default/
82+
ipython_config.py
83+
84+
# pyenv
85+
.python-version
86+
87+
# pipenv
88+
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
89+
# However, in case of collaboration, if having platform-specific dependencies or dependencies
90+
# having no cross-platform support, pipenv may install dependencies that don't work, or not
91+
# install all needed dependencies.
92+
#Pipfile.lock
93+
94+
# PEP 582; used by e.g. github.com/David-OConnor/pyflow
95+
__pypackages__/
96+
97+
# Celery stuff
98+
celerybeat-schedule
99+
celerybeat.pid
100+
101+
# SageMath parsed files
102+
*.sage.py
103+
104+
# Environments
105+
.env
106+
.venv
107+
env/
108+
venv/
109+
ENV/
110+
env.bak/
111+
venv.bak/
112+
113+
# Spyder project settings
114+
.spyderproject
115+
.spyproject
116+
117+
# Rope project settings
118+
.ropeproject
119+
120+
# mkdocs documentation
121+
/site
122+
123+
# mypy
124+
.mypy_cache/
125+
.dmypy.json
126+
dmypy.json
127+
128+
# Pyre type checker
129+
.pyre/

python-flask-mfa-example/LICENSE

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
MIT License
2+
3+
Copyright (c) 2020 WorkOS
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy
6+
of this software and associated documentation files (the "Software"), to deal
7+
in the Software without restriction, including without limitation the rights
8+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
copies of the Software, and to permit persons to whom the Software is
10+
furnished to do so, subject to the following conditions:
11+
12+
The above copyright notice and this permission notice shall be included in all
13+
copies or substantial portions of the Software.
14+
15+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+
SOFTWARE.

python-flask-mfa-example/README.md

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
# python-flask-mfa-example
2+
An example Flask application demonstrating how to use the [WorkOS MFA API](https://workos.com/docs/mfa/guide) using the [Python SDK](https://github.com/workos-inc/workos-python) to authenticate users.
3+
4+
## Prerequisites
5+
- Python 3.6+
6+
7+
8+
## Flask Project Setup
9+
10+
1. Clone the main git repo for these Python example apps using your preferred secure method (HTTPS or SSH).
11+
```bash
12+
# HTTPS
13+
$ git clone https://github.com/workos/python-flask-example-applications.git
14+
```
15+
16+
or
17+
18+
```bash
19+
# SSH
20+
$ git clone [email protected]:workos/python-flask-example-applications.git
21+
```
22+
23+
2. Navigate to the sso app within the cloned repo.
24+
```bash
25+
$ cd python-flask-example-applications/python-flask-mfa-example
26+
```
27+
28+
3. Create and source a Python virtual environment. You should then see `(env)` at the beginning of your command-line prompt.
29+
```bash
30+
$ python3 -m venv env
31+
$ source env/bin/activate
32+
(env) $
33+
```
34+
35+
4. Install the cloned app's dependencies.
36+
```bash
37+
(env) $ pip install -r requirements.txt
38+
```
39+
40+
5. Obtain and make note of the following values. In the next step, these will be set as environment variables.
41+
- Your [WorkOS API key](https://dashboard.workos.com/api-keys)
42+
- Your [SSO-specific, WorkOS Client ID](https://dashboard.workos.com/configuration)
43+
44+
6. Ensure you're in the root directory for the example app, `python-flask-sso-example/`. Create a `.env` file to securely store the environment variables. Open this file with the Nano text editor. (This file is listed in this repo's `.gitignore` file, so your sensitive information will not be checked into version control.)
45+
```bash
46+
(env) $ touch .env
47+
(env) $ nano .env
48+
```
49+
50+
7. Once the Nano text editor opens, you can directly edit the `.env` file by listing the environment variables:
51+
```bash
52+
WORKOS_API_KEY=<value found in step 6>
53+
WORKOS_CLIENT_ID=<value found in step 6>
54+
APP_SECRET_KEY=<any string value you\'d like>
55+
```
56+
57+
To exit the Nano text editor, type `CTRL + x`. When prompted to "Save modified buffer", type `Y`, then press the `Enter` or `Return` key.
58+
59+
8. Source the environment variables so they are accessible to the operating system.
60+
```bash
61+
(env) $ source .env
62+
```
63+
64+
You can ensure the environment variables were set correctly by running the following commands. The output should match the corresponding values.
65+
```bash
66+
(env) $ echo $WORKOS_API_KEY
67+
(env) $ echo $WORKOS_CLIENT_ID
68+
(env) $ echo $APP_SECRET_KEY
69+
```
70+
71+
72+
9. The final setup step is to start the server.
73+
```bash
74+
(env) $ flask run
75+
```
76+
77+
If you are using Mac OS Monterey, port 5000 is not available and you'll need to start the app on a different port with this slightly different command.
78+
```bash
79+
(env) $ flask run -p 5001
80+
```
81+
82+
You'll know the server is running when you see no errors in the CLI, and output similar to the following is displayed:
83+
84+
```bash
85+
* Tip: There are .env or .flaskenv files present. Do "pip install python-dotenv" to use them.
86+
* Environment: production
87+
WARNING: This is a development server. Do not use it in a production deployment.
88+
Use a production WSGI server instead.
89+
* Debug mode: off
90+
* Running on http://127.0.0.1:5000/ (Press CTRL+C to quit)
91+
```
92+
93+
Navigate to `localhost:5000`, or `localhost:5001` depending on which port you launched the server, in your web browser. You should see a "Login" button. If you click this link, you'll be redirected to an HTTP `404` page because we haven't set up SSO yet!
94+
95+
You can stop the local Flask server for now by entering `CTRL + c` on the command line.
96+
97+
98+
## Using the MFA application
99+
100+
11. This application is meant to showcase the MFA API and how to interact with it using the WorkOS Python SDK. It is not meant to show a real-life example of how MFA should be implemented.
101+
102+
The app supports two types of MFA flows, SMS and Time-based One Time Password (TOTP).
103+
104+
SMS: The SMS flow requires you to send a code via text message. You can customize this message, but the message must include the string "{{code}}". This string of characters tells the WorkOS API to generate a random code that will be populated automatically. If "{{code}}" is not included in the message, the authentication cannot be completed.
105+
106+
TOTP: This type of authentication requires the use of a 3rd party authentication app (1Password, Authy, Google Authenticator, Microsoft Authenticator, Duo, etc). Scan the QR code from the Factor Details page to create the corresponding factor in the 3rd party app, then enter the time-based password when prompted in this MFA application.
107+
108+
109+
110+
## Need help?
111+
112+
First, make sure to reference the MFA docs at https://workos.com/docs/mfa/guide.
113+
114+
If you get stuck and aren't able to resolve the issue by reading our API reference or tutorials, you can reach out to us at [email protected] and we'll lend a hand.

python-flask-mfa-example/app.py

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
import os
2+
from flask import Flask, session, redirect, render_template, request, url_for
3+
import json
4+
import workos
5+
6+
7+
# Flask Setup
8+
DEBUG = False
9+
app = Flask(__name__)
10+
app.secret_key = os.getenv("APP_SECRET_KEY")
11+
12+
# WorkOS Setup
13+
14+
workos.api_key = os.getenv("WORKOS_API_KEY")
15+
workos.project_id = os.getenv("WORKOS_CLIENT_ID")
16+
workos.base_api_url = "http://localhost:7000/" if DEBUG else workos.base_api_url
17+
18+
19+
@app.route("/")
20+
def home():
21+
if session["factor_list"]:
22+
return render_template("list_factors.html", factors=session["factor_list"])
23+
return render_template(
24+
"list_factors.html",
25+
)
26+
27+
28+
@app.route("/enroll_factor_details", methods=["GET"])
29+
def enroll_factor_details():
30+
return render_template("enroll_factor.html")
31+
32+
33+
@app.route("/enroll_factor", methods=["POST"])
34+
def enroll_factor():
35+
factor_type = request.form.get("type")
36+
totp_issuer = request.form.get("totp_issuer")
37+
totp_user = request.form.get("totp_user")
38+
phone_number = request.form.get("phone_number")
39+
40+
if factor_type == "sms":
41+
factor_type = "sms"
42+
new_factor = workos.client.mfa.enroll_factor(
43+
type=factor_type, phone_number=phone_number
44+
)
45+
46+
if factor_type == "totp":
47+
factor_type = "totp"
48+
new_factor = workos.client.mfa.enroll_factor(
49+
type=factor_type, totp_issuer=totp_issuer, totp_user=totp_user
50+
)
51+
52+
session["factor_list"].append(new_factor)
53+
session.modified = True
54+
return redirect("/")
55+
56+
57+
@app.route("/factor_detail")
58+
def factor_detail():
59+
factorId = request.args.get("id")
60+
for factor in session["factor_list"]:
61+
if factor["id"] == factorId:
62+
fullFactor = factor
63+
64+
phone_number = "-"
65+
if factor["type"] == "sms":
66+
phone_number = factor["sms"]["phone_number"]
67+
68+
if factor["type"] == "totp":
69+
session["current_factor_qr"] = factor["totp"]["qr_code"]
70+
71+
session["current_factor"] = fullFactor["id"]
72+
session["current_factor_type"] = fullFactor["type"]
73+
session.modified = True
74+
return render_template(
75+
"factor_detail.html",
76+
factor=fullFactor,
77+
phone_number=phone_number,
78+
qr_code=session["current_factor_qr"],
79+
)
80+
81+
82+
@app.route("/challenge_factor", methods=["POST"])
83+
def challenge_factor():
84+
if session["current_factor_type"] == "sms":
85+
message = request.form["sms_message"]
86+
session["sms_message"] = message
87+
88+
challenge = workos.client.mfa.challenge_factor(
89+
authentication_factor_id=session["current_factor"],
90+
sms_template=message,
91+
)
92+
93+
if session["current_factor_type"] == "totp":
94+
authentication_factor_id = session["current_factor"]
95+
challenge = workos.client.mfa.challenge_factor(
96+
authentication_factor_id=authentication_factor_id,
97+
)
98+
99+
session["challenge_id"] = challenge["id"]
100+
session.modified = True
101+
return render_template("challenge_factor.html")
102+
103+
104+
@app.route("/verify_factor", methods=["POST"])
105+
def verify_factor():
106+
code = request.form["code"]
107+
challenge_id = session["challenge_id"]
108+
verify_factor = workos.client.mfa.verify_factor(
109+
authentication_challenge_id=challenge_id,
110+
code=code,
111+
)
112+
113+
return render_template(
114+
"challenge_success.html",
115+
challenge=verify_factor["challenge"],
116+
valid=verify_factor["valid"],
117+
type=session["current_factor_type"],
118+
)
119+
120+
121+
@app.route("/clear_session", methods=["GET"])
122+
def clear_session():
123+
session["factor_list"] = []
124+
session["challenge_id"] = ""
125+
session["current_factor"] = ""
126+
session["current_factor_type"] = ""
127+
return redirect("/")
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
Flask==2.0.0
2+
workos==1.11.0
3+
python-dotenv
Loading
Loading

0 commit comments

Comments
 (0)