Search, chat and research over parliamentary speeches and documents
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

179 lines
6.2 KiB

"""Account endpoints for optional zero-knowledge login.
The client does all password work locally (PBKDF2 → auth key + wrapping key);
these routes only ever see the derived auth key and opaque wrapped blobs.
See backend/services/auth.py for the primitives.
"""
from __future__ import annotations
import logging
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel, Field
from postgres_client import pg
from backend.services import auth as auth_svc
log = logging.getLogger("riksdagen.auth")
router = APIRouter(prefix="/api/auth", tags=["auth"])
KDF_MIN_ITERATIONS = 100_000
class SignupRequest(BaseModel):
username: str = Field(..., min_length=3, max_length=32)
auth_key: str = Field(..., min_length=20, max_length=200)
kdf_salt: str = Field(..., min_length=8, max_length=64)
kdf_iterations: int = Field(default=600_000, ge=KDF_MIN_ITERATIONS, le=5_000_000)
wrapped_dek: str = Field(..., min_length=10, max_length=500)
class LoginRequest(BaseModel):
username: str = Field(..., min_length=1, max_length=64)
auth_key: str = Field(..., min_length=20, max_length=200)
class ChangePasswordRequest(BaseModel):
auth_key: str = Field(..., min_length=20, max_length=200)
new_auth_key: str = Field(..., min_length=20, max_length=200)
new_kdf_salt: str = Field(..., min_length=8, max_length=64)
new_kdf_iterations: int = Field(default=600_000, ge=KDF_MIN_ITERATIONS, le=5_000_000)
new_wrapped_dek: str = Field(..., min_length=10, max_length=500)
class AuthResponse(BaseModel):
token: str
user_id: str
username: str
wrapped_dek: str
kdf_salt: str
kdf_iterations: int
@router.post("/signup", response_model=AuthResponse, status_code=201)
def signup(payload: SignupRequest, request: Request) -> AuthResponse:
auth_svc.throttle(request)
username = payload.username.strip().lower()
if not auth_svc.USERNAME_RE.match(username):
raise HTTPException(
status_code=400,
detail="Användarnamn: 3–32 tecken, a-z, 0-9, punkt, bindestreck, understreck",
)
existing = pg.execute("SELECT 1 FROM users WHERE username = %s", (username,))
if existing:
raise HTTPException(status_code=409, detail="Användarnamnet är upptaget")
rows = pg.execute(
"""
INSERT INTO users (username, auth_hash, kdf_salt, kdf_iterations, wrapped_dek)
VALUES (%s, %s, %s, %s, %s)
RETURNING id::text AS id
""",
(
username,
auth_svc.hash_auth_key(payload.auth_key),
payload.kdf_salt,
payload.kdf_iterations,
payload.wrapped_dek,
),
)
user_id = rows[0]["id"]
token = auth_svc.mint_token(user_id)
log.info("new account: %s", username)
return AuthResponse(
token=token,
user_id=user_id,
username=username,
wrapped_dek=payload.wrapped_dek,
kdf_salt=payload.kdf_salt,
kdf_iterations=payload.kdf_iterations,
)
@router.get("/prelogin")
def prelogin(username: str, request: Request) -> dict:
"""KDF parameters for a username. Unknown usernames get a deterministic
fake salt so responses don't reveal whether an account exists."""
auth_svc.throttle(request)
username = username.strip().lower()
rows = pg.execute(
"SELECT kdf_salt, kdf_iterations FROM users WHERE username = %s", (username,)
)
if rows:
return {"kdf_salt": rows[0]["kdf_salt"], "kdf_iterations": rows[0]["kdf_iterations"]}
return {"kdf_salt": auth_svc.fake_kdf_salt(username), "kdf_iterations": 600_000}
@router.post("/login", response_model=AuthResponse)
def login(payload: LoginRequest, request: Request) -> AuthResponse:
auth_svc.throttle(request)
username = payload.username.strip().lower()
rows = pg.execute(
"""
SELECT id::text AS id, auth_hash, kdf_salt, kdf_iterations, wrapped_dek
FROM users WHERE username = %s
""",
(username,),
)
if not rows or not auth_svc.verify_auth_key(payload.auth_key, rows[0]["auth_hash"]):
raise HTTPException(status_code=401, detail="Fel användarnamn eller lösenord")
row = rows[0]
token = auth_svc.mint_token(row["id"])
return AuthResponse(
token=token,
user_id=row["id"],
username=username,
wrapped_dek=row["wrapped_dek"],
kdf_salt=row["kdf_salt"],
kdf_iterations=row["kdf_iterations"],
)
@router.post("/logout", status_code=204, response_model=None)
def logout(token: Optional[str] = Depends(auth_svc.bearer_token)) -> None:
if token:
auth_svc.revoke_token(token)
@router.get("/me")
def me(user: dict = Depends(auth_svc.get_current_user)) -> dict:
rows = pg.execute(
"SELECT wrapped_dek, kdf_salt, kdf_iterations FROM users WHERE id = %s",
(user["user_id"],),
)
if not rows:
raise HTTPException(status_code=404, detail="Kontot finns inte längre")
return {"user_id": user["user_id"], "username": user["username"], **rows[0]}
@router.post("/change-password", status_code=204, response_model=None)
def change_password(
payload: ChangePasswordRequest,
request: Request,
user: dict = Depends(auth_svc.get_current_user),
token: Optional[str] = Depends(auth_svc.bearer_token),
) -> None:
"""Re-wrap flow: the client re-wraps the same DEK under the new password's
KEK, so stored content needs no re-encryption. Other sessions are logged out."""
auth_svc.throttle(request)
rows = pg.execute("SELECT auth_hash FROM users WHERE id = %s", (user["user_id"],))
if not rows or not auth_svc.verify_auth_key(payload.auth_key, rows[0]["auth_hash"]):
raise HTTPException(status_code=401, detail="Fel lösenord")
pg.execute_void(
"""
UPDATE users
SET auth_hash = %s, kdf_salt = %s, kdf_iterations = %s, wrapped_dek = %s
WHERE id = %s
""",
(
auth_svc.hash_auth_key(payload.new_auth_key),
payload.new_kdf_salt,
payload.new_kdf_iterations,
payload.new_wrapped_dek,
user["user_id"],
),
)
if token:
auth_svc.revoke_other_tokens(user["user_id"], token)