-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
0 parents
commit 74f7bc4
Showing
15 changed files
with
9,012 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,12 @@ | ||
FROM python:3.12-slim-bookworm | ||
|
||
WORKDIR /opt | ||
|
||
COPY requirements.txt ./requirements.txt | ||
RUN pip install -r requirements.txt | ||
|
||
COPY devicepasswords/ devicepasswords | ||
COPY wordlist.txt . | ||
COPY wordlist-de.txt . | ||
|
||
ENTRYPOINT ["gunicorn", "-b", "0.0.0.0:8000", "devicepasswords:create_app()"] |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,69 @@ | ||
# Simple device password management | ||
|
||
 | ||
|
||
Device passwords fix the gap for accessing resources when clients do not support | ||
the companies single-sign on protocol. The most prominent example is e-mail, | ||
where OAuth requires both server and client integration. | ||
|
||
This software allows users to manage their own device passwords. | ||
|
||
## Configuration | ||
|
||
Configuration is based on environment variables. | ||
|
||
| Variable | Meaning | Default | | ||
|--------------------------------|----------------------------------------------------------------------------------------------------------------------------------|--------------------------------------------------------------------| | ||
| `DP_SECRET_KEY` | Secret key for signing the session cookies. | *Random generated*, set a fixed key one for a load balanced setup. | | ||
| `DP_SQLALCHEMY_DATABASE_URI` | URL to the database (any supported scheme supported by SQLAlchemy) | *None* (Mandatory) | | ||
| `DP_OIDC_DISCOVERY_URL` | [Discovery endpoint](https://openid.net/specs/openid-connect-discovery-1_0.html) of your identity provider. | *None* (Mandatory to set) | | ||
| `DP_OIDC_CLIENT_ID` | Registered client id of the registered app. | *None* (Mandatory to set) | | ||
| `DP_OIDC_CLIENT_SECRET` | Client secret of the registered app. | *None* (Recommended) | | ||
| `DP_OIDC_SCOPE` | Scope to request, must include *openid*. | openid email profile | | ||
| `DP_OIDC_CLAIM_EMAIL` | In what claim the user's mail address is found. | email | | ||
| `DP_OIDC_CLAIM_EMAIL_VERIFIED` | What claim to check if the email has been verified. Set empty to accept all emails. | email_verified | | ||
| `DP_OIDC_CLAIM_USERNAME` | In what claim the preferred username is found. In case your IdP does not hand out them, you may use the same value as for email. | preferred_username | | ||
| `DP_OIDC_REQUIRED_CLAIM` | Require the given claim to be present to allow user access. | *None* | | ||
| `DP_OIDC_REQUIRED_CLAIM_VALUE` | Require the required claim to have a specific value. | *None* | | ||
| `DP_OIDC_GROUP_MEMBERSHIP` | Require the given group membership to allow access. | *None* | | ||
| `DP_OIDC_GROUP_CLAIM` | The group claim. The claim must be JSON array. | groups | | | ||
| `DP_WORDLIST` | Path to the wordlist for the generated passwords. The container ships with *wordlist.txt* and *wordlist-de.txt*. | wordlist.txt | | ||
|
||
The docker container contains drivers for sqlite, MySQL/MariaDB, Postgres and | ||
(Microsoft) SQL Server by default. | ||
|
||
## Database schema | ||
|
||
For client integrations, two tables are relevant: `users` and `tokens`. | ||
|
||
`users`: | ||
- primary (text): value of the *sub* claim | ||
- user (text): value of the configured username claim | ||
- email (text): value of the email claim | ||
`tokens`: | ||
- primary (int): Internal identifier of the token | ||
- user (text): value of the configured username claim | ||
- name (text): user set name of the token | ||
- token (text): the device password, see the configuration for configuring hashing | ||
- expires (datetime, nullable): user configured expiration data | ||
|
||
Use an `OUTER JOIN` to combine both tables. | ||
For dovecot, you can use the following query | ||
(assuming an unhashed setup): | ||
|
||
```sql | ||
SELECT email as user, token as password | ||
FROM users | ||
LEFT OUTER JOIN tokens t ON users."primary" = t.user | ||
WHERE user = '%n' | ||
``` | ||
|
||
## Caveats | ||
|
||
- Only a single identity provider is supported. | ||
- Username and e-mail-address of a user is updated (only) on user login. | ||
- It is assumed the identity provider controls access to its apps and user registration. | ||
- The application assumes email-adresses and usernames are unique for your IdP. | ||
If you use a "public IdP" (such as Microsoft or Google), estrict app access | ||
to your tenant. | ||
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,229 @@ | ||
import http | ||
import secrets | ||
import sys | ||
import time | ||
import traceback | ||
from datetime import datetime, date | ||
|
||
import requests.exceptions | ||
from flask import Flask, session, redirect, render_template, request, \ | ||
url_for, abort, current_app | ||
|
||
from .db import db, init_db, Session, User, Token | ||
from .devpwd import DevicePasswords | ||
from .oidc import OIDC | ||
from .smgmt import valid_session, update_session, destroy_session, \ | ||
new_session | ||
|
||
|
||
def create_app(): | ||
app = Flask(__name__, instance_relative_config=True) | ||
app.config["WORDLIST"] = "wordlist.txt" | ||
app.config["OIDC_CLAIM_EMAIL"] = "email" | ||
app.config["OIDC_CLAIM_EMAIL_VERIFIED"] = "email_verified" | ||
app.config["OIDC_CLAIM_USERNAME"] = "preferred_username" | ||
app.config["OIDC_SCOPE"] = "openid email profile" | ||
|
||
app.config.from_prefixed_env("DP") | ||
|
||
for var in ["OIDC_DISCOVERY_URL", "OIDC_CLIENT_ID", "OIDC_CLIENT_SECRET", | ||
"SQLALCHEMY_DATABASE_URI"]: | ||
if not app.config.get(var): | ||
print(f"{var} not set.", file=sys.stderr) | ||
exit(1) | ||
|
||
if not app.config.get("SECRET_KEY"): | ||
print("No secret key set, generating a fresh one. " | ||
"Set one for a load balanced setup.") | ||
app.config["SECRET_KEY"] = secrets.token_bytes(32) | ||
|
||
init_db(app) | ||
|
||
oidc = OIDC.from_app(app) | ||
oidc.refresh = True # Automatically reload in background. | ||
device_passwords = DevicePasswords.from_app(app) | ||
|
||
@app.route("/") | ||
def index(): | ||
"""Render the web interface or login.""" | ||
if not valid_session(oidc): | ||
session.clear() | ||
session["state"] = secrets.token_urlsafe(16) | ||
return redirect(oidc.get_login_uri( | ||
session["state"], url_for("login", _external=True) | ||
)) | ||
|
||
return render_template("index.html") | ||
|
||
@app.route("/login", methods=["GET", "POST"]) | ||
def login(): | ||
"""Handle OpenID connect responses.""" | ||
if request.method == "GET": | ||
args = request.args | ||
else: | ||
args = request.form | ||
|
||
if session.get("state") != args.get("state"): | ||
abort(400) | ||
|
||
if not (code := args.get("code")): | ||
abort(400) | ||
|
||
redeemed, e = oidc.redeem_code( | ||
code, url_for("login", _external=True) | ||
) | ||
if e is not None: | ||
app.logger.error("Cannot redeem code.", exc_info=( | ||
type(e), e, e.__traceback__ | ||
)) | ||
if isinstance(e, IOError): | ||
abort(http.HTTPStatus.BAD_GATEWAY) | ||
abort(400) | ||
|
||
new_session(redeemed) | ||
|
||
app.logger.info( | ||
"User %s logged in (sub=%s, username=%s, sid=%s)", | ||
session["email"], | ||
session["sub"], | ||
session["preferred_username"], | ||
session.get("sid", "-") | ||
) | ||
|
||
user = db.session.get(User, session["sub"]) or User( | ||
primary=session["sub"]) | ||
user.username = session["preferred_username"] | ||
user.email = session["email"] | ||
db.session.add(user) | ||
db.session.commit() | ||
|
||
return redirect(url_for("index")) | ||
|
||
@app.route("/logout") | ||
def logout(): | ||
"""Clear the session.""" | ||
if session.get("state") != request.args.get("state"): | ||
app.logger.warning("Invalid session.") | ||
abort(403) | ||
|
||
app.logger.warning("%s logged out", session["email"]) | ||
token = session["token"] | ||
email = session["email"] | ||
session.clear() | ||
|
||
if logout_url := oidc.get_logout_url( | ||
token, email, url_for("index", _external=True) | ||
): | ||
return redirect(logout_url) | ||
|
||
return redirect(url_for("index")) | ||
|
||
@app.route("/api/ping") | ||
def ping(): | ||
return {"pong": valid_session(oidc)} | ||
|
||
@app.route("/api/logout-backchannel", methods=["POST"]) | ||
def backchannel_logout(): | ||
""" | ||
Implement a 'back channel' logout that can be called by the | ||
IdP on user logout. | ||
""" | ||
if not (token := request.form.get("logout_token")): | ||
abort(400) | ||
|
||
claims, e = oidc.validate_token(token, typ="Logout") | ||
if e is not None: | ||
app.logger.error("Cannot validate logout token", exc_info=( | ||
type(e), e, e.__traceback__ | ||
)) | ||
app.logger.info("Cannot validate logout token.") | ||
abort(400) | ||
|
||
if claims.get("events", {}).get( | ||
"http://schemas.openid.net/event/backchannel-logout") is None: | ||
app.logger.info("No logout event.") | ||
abort(400) | ||
if claims.get("nonce"): | ||
app.logger.info("Nonce present.") | ||
abort(400) | ||
# We skip a bit here, as we only support a single IdP | ||
sid = claims.get("sid") | ||
sub = claims.get("sub") | ||
if not sid and not sub: | ||
app.logger.info("Sid or sub not present") | ||
abort(400) | ||
|
||
app.logger.info("Backchannel logout (sid=%s, sub=%s)", | ||
sid or '-', sub or '-') | ||
|
||
destroy_session(sub, sid) | ||
return "" | ||
|
||
@app.route("/api/tokens", methods=["GET", "POST", "DELETE"]) | ||
def tokens(): | ||
"""Token endpoint.""" | ||
if not valid_session(oidc): | ||
app.logger.warning("Invalid session.") | ||
session.clear() | ||
abort(403) | ||
|
||
match request.method: | ||
case "GET": | ||
user_tokens = db.session.execute( | ||
db.select(Token) | ||
.filter_by(user=session["sub"]) | ||
).scalars() or [] | ||
return [ | ||
{ | ||
"primary": token.primary, | ||
"name": token.name, | ||
"expires": token.expires, | ||
} for token in user_tokens | ||
] | ||
|
||
case "POST": | ||
if request.form.get("state") != session["state"]: | ||
print(request.form.get("state"), session["state"]) | ||
app.logger.warning("Invalid CSRF token") | ||
abort(403) | ||
|
||
if not (name := request.form.get("name")): | ||
abort(400) | ||
if expires := request.form.get("expires"): | ||
expires = date.fromisoformat(expires) | ||
else: | ||
expires = None | ||
|
||
token_value = device_passwords.generate() | ||
t = Token( | ||
user=session["sub"], | ||
name=name, | ||
token=token_value, | ||
expires=expires | ||
) | ||
db.session.add(t) | ||
db.session.commit() | ||
return { | ||
"status": "ok", | ||
"name": name, | ||
"secret": token_value, | ||
} | ||
case "DELETE": | ||
token = db.session.execute( | ||
db.select(Token) | ||
.filter_by( | ||
user=session["sub"], | ||
primary=request.form.get("id")) | ||
).scalar_one_or_none() | ||
if token is None: | ||
abort(404) | ||
db.session.delete(token) | ||
db.session.commit() | ||
return { | ||
"primary": token.primary, | ||
"name": token.name, | ||
} | ||
case _: | ||
abort(400) | ||
|
||
return app |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,60 @@ | ||
from datetime import datetime | ||
from typing import List | ||
|
||
from flask import current_app, Flask | ||
from flask_sqlalchemy import SQLAlchemy | ||
|
||
from sqlalchemy import Integer, String, ForeignKey, DateTime | ||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship | ||
|
||
|
||
class Base(DeclarativeBase): | ||
pass | ||
|
||
|
||
db = SQLAlchemy(model_class=Base) | ||
|
||
|
||
class User(db.Model): | ||
"""Table of users.""" | ||
__tablename__ = "users" | ||
|
||
primary: Mapped[str] = mapped_column(String, primary_key=True) | ||
username: Mapped[str] = mapped_column(String, unique=True, nullable=False) | ||
email: Mapped[str] = mapped_column(String, unique=True, nullable=False) | ||
|
||
tokens: Mapped[List["Token"]] = relationship() | ||
|
||
|
||
class Token(db.Model): | ||
__tablename__ = "tokens" | ||
|
||
primary: Mapped[int] = mapped_column(Integer, primary_key=True) | ||
user: Mapped[str] = mapped_column(ForeignKey("users.primary")) | ||
name: Mapped[str] = mapped_column(String, nullable=False) | ||
token: Mapped[str] = mapped_column(String, nullable=False) | ||
expires: Mapped[datetime] = mapped_column(DateTime, nullable=True) | ||
|
||
|
||
class Log(db.Model): | ||
__tablename__ = "logs" | ||
|
||
primary: Mapped[int] = mapped_column(Integer, primary_key=True) | ||
date: Mapped[datetime] = mapped_column(DateTime, nullable=False) | ||
token: Mapped[int] = mapped_column(ForeignKey("tokens.primary")) | ||
|
||
|
||
class Session(db.Model): | ||
__tablename__ = "session" | ||
|
||
sid: Mapped[str] = mapped_column(String, primary_key=True) | ||
sub: Mapped[str] = mapped_column(String, nullable=False) | ||
id_token: Mapped[str] = mapped_column(String, nullable=False) | ||
refresh_token: Mapped[str] = mapped_column(String, nullable=True) | ||
refresh_token_expiration: Mapped[datetime] = mapped_column(DateTime, | ||
nullable=True) | ||
|
||
def init_db(app: Flask): | ||
db.init_app(app) | ||
with app.app_context(): | ||
db.create_all() |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,23 @@ | ||
import random | ||
|
||
from flask import current_app, Flask | ||
|
||
wordlist = [] | ||
|
||
|
||
class DevicePasswords: | ||
def __init__(self, words: list[str, ...]): | ||
self.secure = random.SystemRandom() | ||
self.words = words | ||
|
||
def generate(self) -> str: | ||
suffix = str(self.secure.randint(0, 99999)).rjust(5, '0') | ||
|
||
return "-".join( | ||
self.secure.choice(self.words).strip() for _ in range(4) | ||
) + "-" + suffix | ||
|
||
@classmethod | ||
def from_app(cls, app: Flask): | ||
with open(app.config["WORDLIST"]) as wl: | ||
return cls([line.strip() for line in wl.readlines()]) |
Oops, something went wrong.