backend: complete P0 session rotation hardening
This commit is contained in:
4
TODO.md
4
TODO.md
@@ -4,10 +4,10 @@ Concrete follow-up work for Jellomator, prioritized by implementation risk and u
|
|||||||
|
|
||||||
## P0 - Security and Reliability
|
## P0 - Security and Reliability
|
||||||
|
|
||||||
- [ ] Add session expiry and rotation.
|
- [x] Add session expiry and rotation.
|
||||||
- [x] Add `expires_at` and `last_seen_at` to `sessions`.
|
- [x] Add `expires_at` and `last_seen_at` to `sessions`.
|
||||||
- [x] Reject expired tokens in `current_user`.
|
- [x] Reject expired tokens in `current_user`.
|
||||||
- [ ] Rotate session token on login and periodically on use.
|
- [x] Rotate session token on login and periodically on use.
|
||||||
- [x] Harden auth endpoints.
|
- [x] Harden auth endpoints.
|
||||||
- [x] Add login rate limiting by IP + username pair.
|
- [x] Add login rate limiting by IP + username pair.
|
||||||
- [x] Add brute-force lockout window with clear error message.
|
- [x] Add brute-force lockout window with clear error message.
|
||||||
|
|||||||
@@ -194,22 +194,21 @@ def current_user(request: Request, response: Response | None = None):
|
|||||||
should_rotate = (now - datetime.fromisoformat(last_seen_at)).total_seconds() >= SESSION_ROTATE_SECONDS
|
should_rotate = (now - datetime.fromisoformat(last_seen_at)).total_seconds() >= SESSION_ROTATE_SECONDS
|
||||||
except ValueError:
|
except ValueError:
|
||||||
should_rotate = True
|
should_rotate = True
|
||||||
if should_rotate:
|
if should_rotate and response is not None:
|
||||||
new_token = secrets.token_urlsafe(32)
|
new_token = secrets.token_urlsafe(32)
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"update sessions set token=%s,last_seen_at=%s,expires_at=%s where token=%s",
|
"update sessions set token=%s,last_seen_at=%s,expires_at=%s where token=%s",
|
||||||
(new_token, now_iso, new_expires_at, token),
|
(new_token, now_iso, new_expires_at, token),
|
||||||
)
|
)
|
||||||
if response is not None:
|
response.set_cookie(
|
||||||
response.set_cookie(
|
SESSION_COOKIE,
|
||||||
SESSION_COOKIE,
|
new_token,
|
||||||
new_token,
|
httponly=True,
|
||||||
httponly=True,
|
samesite="lax",
|
||||||
samesite="lax",
|
secure=SESSION_COOKIE_SECURE,
|
||||||
secure=SESSION_COOKIE_SECURE,
|
max_age=SESSION_TTL_SECONDS,
|
||||||
max_age=SESSION_TTL_SECONDS,
|
path="/",
|
||||||
path="/",
|
)
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"update sessions set last_seen_at=%s,expires_at=%s where token=%s",
|
"update sessions set last_seen_at=%s,expires_at=%s where token=%s",
|
||||||
@@ -358,6 +357,10 @@ def login(request: Request, inp: LoginIn):
|
|||||||
raise HTTPException(401, "Invalid credentials")
|
raise HTTPException(401, "Invalid credentials")
|
||||||
login_attempts.pop(key, None)
|
login_attempts.pop(key, None)
|
||||||
login_lockouts.pop(key, None)
|
login_lockouts.pop(key, None)
|
||||||
|
old_token = request.cookies.get(SESSION_COOKIE)
|
||||||
|
if old_token:
|
||||||
|
with c.cursor() as cur:
|
||||||
|
cur.execute("delete from sessions where token=%s", (old_token,))
|
||||||
token = secrets.token_urlsafe(32)
|
token = secrets.token_urlsafe(32)
|
||||||
with c.cursor() as cur:
|
with c.cursor() as cur:
|
||||||
now = utc_now_iso()
|
now = utc_now_iso()
|
||||||
|
|||||||
Reference in New Issue
Block a user