Skip to content

Commit de8ed3c

Browse files
committed
Docs restructuring, server side sessions.
1 parent 2f95ae1 commit de8ed3c

26 files changed

Lines changed: 515 additions & 168 deletions

README.md

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,24 @@
1-
# Simple device password management
1+
<h1><img src="docs/images/icon.svg" height="40"> Device password management</h1>
2+
<h3>Individual passwords for service without 2FA</h3>
3+
4+
<hr>
5+
<a href="https://github.com/Varbin/devicepasswords/actions/workflows/docker-image.yml">
6+
<img src="https://github.com/Varbin/devicepasswords/actions/workflows/docker-image.yml/badge.svg" alt="Docker Container CI">
7+
</a>
8+
9+
<a href='https://devicepasswords.readthedocs.io/?badge=latest'>
10+
<img src='https://readthedocs.org/projects/devicepasswords/badge/?version=latest' alt='Documentation Status' />
11+
</a>
12+
<hr>
213

3-
![Screenshot](docs/example.png)
414

515
Device passwords fix the gap for accessing resources when clients do not support
616
the companies single-sign on protocol. The most prominent example is e-mail,
7-
where OAuth requires both server and client integration.
17+
where OAuth requires both server and client integration, which is usually not feasible.
818

919
This software allows users to manage their own device passwords.
1020

11-
[Read the docs](https://devicepasswords.readthedocs.io/)
21+
![Screenshot](docs/example.png)
1222

1323
## Caveats
1424

contrib/freeradius/docker-compose.yml

Whitespace-only changes.

devicepasswords/__init__.py

Lines changed: 26 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,13 @@
99
import secrets
1010
import sys
1111
import time
12-
import uuid
1312
from datetime import date, timedelta, datetime
1413

1514
from flask import Flask, session, redirect, render_template, request, \
1615
url_for, abort
17-
from sqlalchemy import Uuid
16+
from flask_session import Session
1817

19-
from .db import db, init_db, Session, User, Token
18+
from .db import db, init_db, Revoked, User, Token
2019
from .devpwd import DevicePasswords
2120
from .oidc import OIDC
2221
from .pwdhash import hasher
@@ -44,14 +43,16 @@ def create_app():
4443
app.config["UI_SHOW_SUBJECT"] = True
4544
app.config["UI_SHOW_LAST_USED"] = True
4645
app.config["UI_NO_AWOO"] = False
46+
app.config["SESSION_TYPE"] = "sqlalchemy"
47+
app.config["SESSION_SQLALCHEMY"] = db
4748

4849
app.config.from_prefixed_env("DP")
4950

5051
for var in ["OIDC_DISCOVERY_URL", "OIDC_CLIENT_ID", "OIDC_CLIENT_SECRET",
5152
"SQLALCHEMY_DATABASE_URI"]:
5253
if not app.config.get(var):
5354
app.logger.error(f"{var} not set.")
54-
exit(1)
55+
raise ValueError(f"{var} not set.")
5556

5657
try:
5758
hasher.hash("", scheme=app.config["PASSWORD_HASH"])
@@ -60,7 +61,7 @@ def create_app():
6061
f"Invalid password hash {app.config['PASSWORD_HASH']}",
6162
exc_info=sys.exc_info()
6263
)
63-
exit(1)
64+
raise
6465

6566
try:
6667
int(app.config["PASSWORD_MAX_EXPIRATION_DAYS"])
@@ -70,27 +71,26 @@ def create_app():
7071
app.config['PASSWORD_MAX_EXPIRATION_DAYS'],
7172
exc_info=sys.exc_info()
7273
)
73-
exit(1)
74-
75-
if not app.config.get("SECRET_KEY"):
76-
app.logger.warning("No secret key set, generating a fresh one. "
77-
"Set one for a load balanced setup.")
78-
app.config["SECRET_KEY"] = secrets.token_bytes(32)
74+
raise
7975

8076
init_db(app)
77+
_ = Session(app)
8178

8279
oidc = OIDC.from_app(app)
8380
for i in range(5):
8481
time.sleep(2**i-1)
8582
try:
8683
oidc.refresh_config()
87-
except:
84+
except Exception as e:
85+
last = e
8886
app.logger.warning(f"Cannot connect to IdP (try {i+1}/5)",
8987
exc_info=sys.exc_info())
9088
else:
9189
break
9290
else:
9391
app.logger.error("Cannot connect to IdP try (5/5).")
92+
raise last
93+
9494
# Manual overwriting keys here
9595
# Some IdPs may have different keys for consumer and business accounts,
9696
# and Oracle ICS instances would need authentication.
@@ -106,7 +106,6 @@ def create_app():
106106
def index():
107107
"""Render the web interface or login."""
108108
if not valid_session(oidc):
109-
session.clear()
110109
session["state"] = secrets.token_urlsafe(16)
111110
return redirect(oidc.get_login_uri(
112111
session["state"], url_for("login", _external=True)
@@ -117,6 +116,7 @@ def index():
117116
@app.route("/login", methods=["GET", "POST"])
118117
def login():
119118
"""Handle OpenID connect responses."""
119+
print(session)
120120
if request.method == "GET":
121121
args = request.args
122122
else:
@@ -170,7 +170,9 @@ def logout():
170170
email = session["email"]
171171

172172
if sid := session.get("sid"):
173-
destroy_session(None, sid)
173+
destroy_session(sid)
174+
else:
175+
session.clear()
174176

175177
if logout_url := oidc.get_logout_url(
176178
token, email, url_for("index", _external=True)
@@ -210,12 +212,11 @@ def frontchannel_logout():
210212
if iss and sid:
211213
if iss != oidc.config.get("iss"):
212214
return abort(400, "Invalid issuer.")
213-
destroy_session(None, sid)
214-
else:
215-
if sid := session.get("sid"):
216-
destroy_session(None, sid)
217-
else:
218-
session.clear()
215+
destroy_session(sid)
216+
elif sid:
217+
destroy_session(sid)
218+
219+
session.clear()
219220

220221
return ""
221222

@@ -250,11 +251,15 @@ def backchannel_logout():
250251
if not sid and not sub:
251252
app.logger.info("Sid or sub not present")
252253
abort(400)
254+
if not sid:
255+
app.logger.info("Sid not present (sub=%s)", sub)
256+
return ""
253257

254258
app.logger.info("Backchannel logout (sid=%s, sub=%s)",
255259
sid or '-', sub or '-')
256260

257-
destroy_session(sub, sid)
261+
262+
destroy_session(sid)
258263
return ""
259264

260265
@app.route("/api/tokens", methods=["GET", "POST", "DELETE"])

devicepasswords/db.py

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -50,15 +50,17 @@ class Log(db.Model):
5050
tokenId: Mapped[Uuid] = mapped_column(ForeignKey("tokens.id"))
5151

5252

53-
class Session(db.Model):
54-
__tablename__ = "session"
53+
class Revoked(db.Model):
54+
__tablename__ = "revoked"
5555

5656
sid: Mapped[str] = mapped_column(String, primary_key=True)
57-
sub: Mapped[str] = mapped_column(String, nullable=False)
58-
id_token: Mapped[str] = mapped_column(String, nullable=False)
59-
refresh_token: Mapped[str] = mapped_column(String, nullable=True)
60-
refresh_token_expiration: Mapped[datetime] = mapped_column(DateTime,
61-
nullable=True)
57+
58+
#sub: Mapped[str] = mapped_column(String, nullable=False)
59+
#id_token: Mapped[str] = mapped_column(String, nullable=False)
60+
#refresh_token: Mapped[str] = mapped_column(String, nullable=True)
61+
#refresh_token_expiration: Mapped[datetime] = mapped_column(DateTime,
62+
# nullable=True)
63+
6264

6365
def init_db(app: Flask):
6466
db.init_app(app)

devicepasswords/smgmt.py

Lines changed: 23 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
from flask import current_app, abort, session
1111

1212
from . import OIDC
13-
from .db import db, Session
13+
from .db import db, Revoked
1414
from .oidc import Redeemed
1515

1616
logger = logging.getLogger(__name__)
@@ -26,21 +26,18 @@ def valid_session(oidc: OIDC) -> bool:
2626
if not (sid := session.get("sid")):
2727
return not expired
2828

29-
# Session not in database
30-
if (sinfo := db.session.get(Session, sid)) is None:
29+
if db.session.get(Revoked, sid) is not None:
3130
return False
32-
if not expired:
33-
return True
3431

35-
# Session is expired and cannot be refreshed in any way.
36-
if expired and (not sinfo.refresh_token or (
37-
sinfo.refresh_token_expiration is not None and
38-
sinfo.refresh_token_expiration < datetime.now()
32+
if expired and (
33+
not session.get("refresh_token") or (
34+
session.get("refresh_token_expiration") is not None and
35+
session.get("refresh_token_expiration") < datetime.now()
3936
)):
4037
return False
4138

4239
current_app.logger.info("Refreshing session (sid=%s)" % sid)
43-
redeemed, e = oidc.redeem_refresh(sinfo.refresh_token)
40+
redeemed, e = oidc.redeem_refresh(session.get("refresh_token"))
4441
if e is not None:
4542
current_app.logger.warning(
4643
"Refreshing failed (sid=%s)" % sid,
@@ -51,19 +48,17 @@ def valid_session(oidc: OIDC) -> bool:
5148

5249
current_app.logger.info("Refreshing successful (sid=%s)" % sid)
5350

54-
sinfo.id_token = redeemed.id_token
55-
sinfo.refresh_token = redeemed.refresh_token
51+
session["id_token"] = redeemed.id_token
52+
session["refresh_token"] = redeemed.refresh_token
5653
if redeemed.expires_in:
57-
sinfo.refresh_token_expiration = (
54+
session["refresh_token_expiration"] = (
5855
datetime.now() +
5956
timedelta(seconds=redeemed.refresh_token_expires_in)
6057
)
6158
else:
62-
sinfo.refresh_token_expiration = None
59+
session["refresh_token_expiration"] = None
6360

6461
update_session(redeemed.id_token, redeemed.claims, redeemed.profile)
65-
db.session.add(sinfo)
66-
db.session.commit()
6762

6863
return True
6964

@@ -72,35 +67,20 @@ def destroy_session(sub=None, sid=None):
7267
session.clear()
7368

7469
if sid:
75-
if (sess := db.session.get(Session, sid)) is not None:
76-
db.session.delete(sess)
77-
elif sub:
78-
for sess in db.session.execute(
79-
db.select(Session).filter_by(sub=sub)
80-
).scalars() or []:
81-
db.session.delete(sess)
82-
83-
db.session.commit()
70+
db.session.add(Revoked(sid=sid))
71+
db.session.commit()
8472

8573

8674
def new_session(redeemed: Redeemed):
87-
session.clear()
8875
session["state"] = secrets.token_urlsafe(16)
89-
90-
update_session(redeemed.id_token, redeemed.claims, redeemed.profile)
91-
if not (sid := redeemed.claims.get("sid")):
92-
return
93-
sess = db.session.get(Session, sid) or Session(sid=sid)
94-
sess.sub = redeemed.claims.get("sub")
95-
sess.id_token = redeemed.id_token
96-
sess.refresh_token = redeemed.refresh_token
76+
session["id_token"] = redeemed.id_token
77+
session["refresh_token"] = redeemed.refresh_token
78+
session["sid"] = redeemed.claims.get("sid")
9779
if redeemed.refresh_token_expires_in:
98-
sess.refresh_token_expiration = datetime.now() + \
99-
timedelta(
100-
seconds=redeemed.refresh_token_expires_in)
101-
102-
db.session.add(sess)
103-
db.session.commit()
80+
session["refresh_token_expiration"] \
81+
= (datetime.now() +
82+
timedelta(seconds=redeemed.refresh_token_expires_in))
83+
update_session(redeemed.id_token, redeemed.claims, redeemed.profile)
10484

10585

10686
def update_session(id_token, claims, profile):
@@ -110,7 +90,7 @@ def update_session(id_token, claims, profile):
11090
session["token"] = id_token
11191

11292
required = [
113-
"sub", app.config["OIDC_CLAIM_EMAIL"],
93+
"exp", "iss", "sub", app.config["OIDC_CLAIM_EMAIL"],
11494
app.config["OIDC_CLAIM_USERNAME"],
11595
]
11696
if app.config.get("OIDC_CLAIM_VERIFIED"):
@@ -125,11 +105,10 @@ def update_session(id_token, claims, profile):
125105
"Missing claim \"%s\". Contact your administrator."
126106
% claim)
127107

128-
# Always present (more or less)
129-
for claim in ["exp", "iss"]:
108+
# Always present
109+
for claim in ["exp", "sub"]:
130110
session[claim] = claims[claim]
131111

132-
session["sub"] = claims["sub"]
133112
if claims.get("sid"):
134113
session["sid"] = claims["sid"]
135114

devicepasswords/static/icon.png

-5.56 KB
Binary file not shown.

0 commit comments

Comments
 (0)