Skip to content

Commit

Permalink
Write docs, implement some documented features.
Browse files Browse the repository at this point in the history
  • Loading branch information
Varbin committed Jan 19, 2024
1 parent 5df4ace commit a3a1415
Show file tree
Hide file tree
Showing 24 changed files with 8,601 additions and 124 deletions.
5 changes: 4 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,14 @@ FROM python:3.12-slim-bookworm

WORKDIR /opt

ENV CLASS="gevent"
ENV WORKERS="4"

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()"]
ENTRYPOINT ["gunicorn", "-b", "0.0.0.0:8000", "-k", "${CLASS}", "-w", "${WORKERS}", "devicepasswords:create_app()"]
53 changes: 2 additions & 51 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,62 +8,13 @@ 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'
```
[Read the docs](https://devicepasswords.readthedocs.io/)

## 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
If you use a "public IdP" (such as Microsoft or Google), restrict app access
to your tenant.

64 changes: 61 additions & 3 deletions devicepasswords/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import http
import json
import secrets
import sys
import time
Expand All @@ -12,6 +13,7 @@
from .db import db, init_db, Session, User, Token
from .devpwd import DevicePasswords
from .oidc import OIDC
from .pwdhash import hasher
from .smgmt import valid_session, update_session, destroy_session, \
new_session

Expand All @@ -23,13 +25,20 @@ def create_app():
app.config["OIDC_CLAIM_EMAIL_VERIFIED"] = "email_verified"
app.config["OIDC_CLAIM_USERNAME"] = "preferred_username"
app.config["OIDC_SCOPE"] = "openid email profile"
app.config["PASSWORD_HASH"] = "plaintext"
app.config["UI_HEADING"] = "Device passwords"
app.config["UI_HEADING_SUB"] = ""
app.config["UI_SHOW_SUBJECT"] = True
app.config["UI_SHOW_LAST_USED"] = True
app.config["UI_NO_AWOO"] = False
app.config["MAX_EXPIRATION_DAYS"] = 0

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)
app.logger.warning(f"{var} not set.")
exit(1)

if not app.config.get("SECRET_KEY"):
Expand All @@ -40,6 +49,15 @@ def create_app():
init_db(app)

oidc = OIDC.from_app(app)
oidc.refresh_config()
# Manual overwriting keys here
# Some IdPs may have different keys for consumer and business accounts,
# and Oracle ICS instances would need authentication.
if keyfile := app.config.get("OIDC_CERTS"):
with open(keyfile) as kf:
oidc.set_keys(json.load(kf))
else:
oidc.refresh_keys()
oidc.refresh = True # Automatically reload in background.
device_passwords = DevicePasswords.from_app(app)

Expand Down Expand Up @@ -107,9 +125,12 @@ def logout():
abort(403)

app.logger.warning("%s logged out", session["email"])

token = session["token"]
email = session["email"]
session.clear()

if sid := session.get("sid"):
destroy_session(None, sid)

if logout_url := oidc.get_logout_url(
token, email, url_for("index", _external=True)
Expand All @@ -122,6 +143,43 @@ def logout():
def ping():
return {"pong": valid_session(oidc)}

@app.route("/api/logout-frontchannel")
def frontchannel_logout():
"""
Implement a 'front channel' logout that is initiated on the IdP page.
Is only active, if the IdP supports it.
"""

# Old standard:
# https://openid.net/specs/openid-connect-logout-1_0-04.html
# New standard:
# https://openid.net/specs/openid-connect-frontchannel-1_0.html
# That is why there are to variables ^^
if (not oidc.config.get("http_logout_supported") and
not oidc.config.get("frontchannel_logout_supported")):
return abort(400, "Feature not supported.")

sid = request.args.get("sid")
iss = request.args.get("iss")

if (oidc.config.get("logout_session_supported") or
oidc.config.get("frontchannel_logout_session_supported")):
if not sid or not iss:
return abort(400, "Missing session")

if iss and sid:
if iss != oidc.config.get("iss"):
return abort(400, "Invalid issuer.")
destroy_session(None, sid)
else:
if sid := session.get("sid"):
destroy_session(None, sid)
else:
session.clear()

return ""


@app.route("/api/logout-backchannel", methods=["POST"])
def backchannel_logout():
"""
Expand Down Expand Up @@ -198,7 +256,7 @@ def tokens():
t = Token(
user=session["sub"],
name=name,
token=token_value,
token=hasher.hash(token_value),
expires=expires
)
db.session.add(t)
Expand Down
27 changes: 17 additions & 10 deletions devicepasswords/oidc.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@

Redeemed = namedtuple('Redeemed', ['id_token', 'expires_in',
'refresh_token', 'refresh_token_expires_in',
'claims']
'claims', 'profile']
)


Expand All @@ -36,9 +36,6 @@ def __init__(self, configuration_url: str, client_id: str, client_secret: str):

self.session = requests.Session()

self.refresh_config()
self.refresh_keys()

@property
def refresh(self):
return self._refresh
Expand All @@ -63,11 +60,14 @@ def refresh_keys(self) -> list:
:raise ExceptionGroup of JWK errors, or a value error.
"""
keys = []
exceptions = []
cert_response = self.session.get(self.config["jwks_uri"])
cert_response.raise_for_status()
certs = cert_response.json()["keys"]
self.set_keys(cert_response.json())

def set_keys(self, obj):
keys = []
exceptions = []
certs = obj["keys"]
for cert in certs:
try:
key = jwk.construct(cert)
Expand Down Expand Up @@ -126,12 +126,19 @@ def _redeem(self, token_data) -> tuple[Redeemed, Exception | None]:
if e is not None:
return Redeemed(None, 0, None, 0, {}), e

profile = {}
if at := token_json.get("access_token"):
profile = requests.get(
self.config["userinfo_endpoint"],
headers={"Authorization": f"Bearer {at}"}
).json()

return Redeemed(
token_json["id_token"],
token_json.get("expires_in", 0),
token_json.get("refresh_token"),
token_json.get("refresh_expires_in"),
claims
claims, profile
), None

def redeem_refresh(self, code) -> tuple[Redeemed, Exception | None]:
Expand Down Expand Up @@ -162,8 +169,8 @@ def get_login_uri(self, state, redirect_uri) -> str:
query["client_id"] = self.client_id
if "form_post" in self.config.get("response_modes_supported", ()):
query["response_mode"] = "form_post"
else:
query["response_mode"] = "query"
#else:
# query["response_mode"] = "query"
return auth_url._replace(query=urlencode(query)).geturl()

def get_logout_url(self, id_token, email, post_logout) -> str|None:
Expand Down
15 changes: 15 additions & 0 deletions devicepasswords/pwdhash.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
from passlib.context import CryptContext


hasher = CryptContext(
schemes=[
"plaintext",
"ldap_md5", "ldap_sha1", "ldap_salted_md5", "ldap_salted_sha1",
"ldap_salted_sha256", "ldap_salted_sha512",
"ldap_sha1_crypt", "ldap_sha256_crypt", "ldap_sha512_crypt",
"ldap_bcrypt", "roundup_plaintext",
"scram", "nthash",
"bcrypt", "scrypt", "argon2",
"md5_crypt", "sha1_crypt", "sha256_crypt", "sha512_crypt"
],
)
46 changes: 30 additions & 16 deletions devicepasswords/smgmt.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,13 +51,13 @@ def valid_session(oidc: OIDC) -> bool:
sinfo.refresh_token = redeemed.refresh_token
if redeemed.expires_in:
sinfo.refresh_token_expiration = (
datetime.now() +
timedelta(seconds=redeemed.refresh_token_expires_in)
datetime.now() +
timedelta(seconds=redeemed.refresh_token_expires_in)
)
else:
sinfo.refresh_token_expiration = None

update_session(redeemed.id_token, redeemed.claims)
update_session(redeemed.id_token, redeemed.claims, redeemed.profile)
db.session.add(sinfo)
db.session.commit()

Expand All @@ -72,7 +72,7 @@ def destroy_session(sub=None, sid=None):
db.session.delete(sess)
elif sub:
for sess in db.session.execute(
db.select(Session).filter_by(sub=sub)
db.select(Session).filter_by(sub=sub)
).scalars() or []:
db.session.delete(sess)

Expand All @@ -83,7 +83,7 @@ def new_session(redeemed: Redeemed):
session.clear()
session["state"] = secrets.token_urlsafe(16)

update_session(redeemed.id_token, redeemed.claims)
update_session(redeemed.id_token, redeemed.claims, redeemed.profile)
if not (sid := redeemed.claims.get("sid")):
return
sess = db.session.get(Session, sid) or Session(sid=sid)
Expand All @@ -92,26 +92,31 @@ def new_session(redeemed: Redeemed):
sess.refresh_token = redeemed.refresh_token
if redeemed.refresh_token_expires_in:
sess.refresh_token_expiration = datetime.now() + \
timedelta(seconds=redeemed.refresh_token_expires_in)
timedelta(
seconds=redeemed.refresh_token_expires_in)

db.session.add(sess)
db.session.commit()


def update_session(id_token, claims):
def update_session(id_token, claims, profile):
"""Update the id_token and the claims of the current message."""
app = current_app

session["token"] = id_token

required = [
"sub", app.config["OIDC_CLAIM_EMAIL"],
app.config["OIDC_CLAIM_USERNAME"],
]
if app.config.get("OIDC_CLAIM_VERIFIED"):
required.append(app.config["OIDC_CLAIM_VERIFIED"])
# Validate required claims:
print(claims)
for claim in required:
if not claims.get(claim):
if not claims.get(claim) and not (
app.config.get("OIDC_CLAIMS_FROM_PROFILE") and
profile.get(claim)
):
abort(403,
"Missing claim \"%s\". Contact your administrator."
% claim)
Expand All @@ -121,16 +126,25 @@ def update_session(id_token, claims):
session[claim] = claims[claim]

session["sub"] = claims["sub"]
session["email"] = claims[app.config["OIDC_CLAIM_EMAIL"]]
session["preferred_username"] = claims[
app.config["OIDC_CLAIM_USERNAME"]]
# Not required, but useful. sid is required for back-channel logout.
for claim in ["picture", "name", "sid"]:
if claims.get("sid"):
session["sid"] = claims["sid"]

# Some IdPs only return the (for us mandatory data) in the profile.
session["email"] = (claims.get(app.config["OIDC_CLAIM_EMAIL"]) or
profile.get(app.config["OIDC_CLAIM_EMAIL"]))
session["preferred_username"] = (
claims.get(app.config["OIDC_CLAIM_USERNAME"]) or
profile.get(app.config["OIDC_CLAIM_USERNAME"])
)
# Not required, but useful.
for claim in ["picture", "name"]:
if claims.get(claim):
# Will safely be loaded from profile
session[claim] = claims[claim]
session["token"] = id_token
elif profile.get(claim):
session[claim] = profile[claim]

if app.config.get("OIDC_CLAIM_VERIFIED"):
if not claims.get(app.config["OIDC_CLAIM_VERIFIED"]):
session.clear()
abort(403, "Email not verified")

Loading

0 comments on commit a3a1415

Please sign in to comment.