Skip to content

Commit

Permalink
Initial commit.
Browse files Browse the repository at this point in the history
  • Loading branch information
Varbin committed Jan 16, 2024
0 parents commit 74f7bc4
Show file tree
Hide file tree
Showing 15 changed files with 9,012 additions and 0 deletions.
12 changes: 12 additions & 0 deletions Dockerfile
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()"]
69 changes: 69 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
# Simple device password management

![Screenshot](docs/example.png)

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.

229 changes: 229 additions & 0 deletions devicepasswords/__init__.py
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
60 changes: 60 additions & 0 deletions devicepasswords/db.py
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()
23 changes: 23 additions & 0 deletions devicepasswords/devpwd.py
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()])
Loading

0 comments on commit 74f7bc4

Please sign in to comment.