teecup/app/routers/auth.py
Erol Haagenrud 89a803140e Update Todos
Legge til argon2-cffi, pyotp, qrcode i requirements.txt

Skrive migrasjon 012 (passord, 2FA, superadmin, org-invitasjoner)

app/auth.py: sesjonsstadier, passord-hashing, TOTP-hjelpere

app/routers/auth.py: passord-innlogging, 2FA-oppsett/verifisering

app/email.py: 2FA-kode og invitasjons-maler

app/routers/organizations.py: invitasjoner, medlemskapsstyring, superadmin-sti

Frontend: login-form passord-modus + 2FA-skjermer

Frontend: kontoinnstillinger + org-medlemsstyring-skjerm

Ekte typesjekket frontend-build

Scratch-verifisere hele auth-løpet grundig (backend)

Deploy mot ekte teecup_db/containere + oppdatere .md-filer
Backend og frontend er grundig scratch-verifisert — inkludert tre reelle bugs jeg fant og fikset underveis (en UUID-serialiseringsfeil i magic-link-innlogging, og to tilfeller av en uendelig 2FA-løkke der en nettopp bekreftet kode ble sjekket på nytt). Alle sikkerhetsvern testet eksplisitt: siste-eier-vern, blokkert selv-forfremmelse, admin kan ikke gi eierskap, superadmin fungerer/avvises riktig, tvungen 2FA for nye eiere, passord med spesialtegn/mellomrom, og bakoverkompatibilitet med eksisterende magic-link-flyt.

Klar til utrulling mot ekte systemer:

Migrasjon: 012_password_2fa_and_org_invitations.sql mot ekte teecup_db
Redeploy: både teecup_api og teecup_frontend
AskUserQuestion
2026-07-19 10:36:50 +02:00

610 lines
24 KiB
Python

"""
Autentisering: magic-link/passord-forespørsel/-verifisering, 2FA, utlogging,
"hvem er jeg" (ADR-009, ADR-021).
Sikkerhetsmønster (se ADR-009/ADR-021):
- request-link svarer ALLTID identisk, uansett om e-posten finnes eller
nettopp fikk en lenke -- unngår at endepunktet kan brukes til å sjekke
hvilke e-poster som har konto (enumerering). login-password svarer
identisk (401 INVALID_CREDENTIALS) uansett om e-posten finnes, mangler
passord, eller passordet er feil -- samme prinsipp.
- Kun SHA-256-hashen av magic-link-token/2FA-kode lagres, aldri klartekst.
Passord hashes med Argon2id (app/auth.py sin hash_password), ikke
SHA-256 -- se ADR-021 Beslutning B.
- app_user opprettes FØRST når en gyldig, uforbrukt magic-link-token løses
inn (IKKE når lenken bare forespørres) -- hindrer massopprettelse av
kontoer for e-poster man ikke eier. Passord kan kun SETTES av en
allerede innlogget bruker (set-password), aldri ved kontoopprettelse.
- Token-/kode-forbruk er ÉN atomisk UPDATE ... RETURNING (ikke
les-sjekk-skriv) for å hindre at to samtidige forsøk begge lykkes.
- Levering: ekte SMTP-utsending (app/email.py) hvis settings.SMTP_CONFIGURED,
ellers dev-only konsoll-logging bak settings.DEV_LOG_MAGIC_LINKS (aldri på
som standard). En driftsfeil i selve utsendingen logges server-side og
lekker ALDRI til klienten som en annen respons.
To-stegs innlogging (ADR-021 Beslutning D/E): primær-autentisering
(verify-link ELLER login-password) utsteder ALDRI direkte en full sesjon --
den går alltid via `_issue_login_result()`, som avgjør om 2FA kreves
(brukeren har selv slått det på) eller må settes opp FØRST (org-eier/admin
uten 2FA konfigurert), og utsteder riktig sesjons-STADIE (se app/auth.py).
i18n (se ADR-015): `locale` sendes av klienten ved forespørsel, lagres på
token-raden, og brukes BÅDE til å velge språk på selve e-posten OG til å
sette en HELT NY brukers `preferred_locale`. En EKSISTERENDE bruker som ber
om en ny lenke får IKKE sin lagrede preferanse overskrevet.
"""
import base64
import hashlib
import secrets
import traceback
from datetime import datetime, timedelta, timezone
from io import BytesIO
from typing import Literal
import qrcode
from fastapi import APIRouter, Depends, Request, Response
from pydantic import BaseModel, EmailStr, Field
from ..auth import (
CurrentUser,
PendingUser,
SESSION_COOKIE_NAME,
create_session_token,
generate_totp_secret,
get_current_or_enrolling_user,
get_current_user,
get_pending_user,
hash_password,
should_use_secure_cookies,
totp_provisioning_uri,
user_requires_2fa_enrollment,
verify_password,
verify_totp_code,
)
from ..config import settings
from ..db import org_connection, plain_connection
from ..email import send_magic_link_email, send_two_factor_code_email
from ..errors import app_error
router = APIRouter(prefix="/auth", tags=["auth"])
_GENERIC_RESPONSE = {
"status": "ok",
"detail": "Hvis e-posten er gyldig, er en innloggingslenke sendt.",
}
def _hash_secret(raw: str) -> str:
"""Generisk SHA-256-hash -- brukt for BÅDE magic-link-tokens og
2FA-engangskoder (begge er kortlevde, engangs hemmeligheter av samme
trusselklasse). Passord bruker Argon2id i stedet (app/auth.py)."""
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
def _set_session_cookie(response: Response, request: Request, token: str, stage: str) -> None:
max_age = settings.SESSION_MAX_AGE_SECONDS if stage == "full" else 5 * 60
response.set_cookie(
SESSION_COOKIE_NAME,
token,
max_age=max_age,
httponly=True,
samesite="lax",
secure=should_use_secure_cookies(request),
path="/",
)
def _build_qr_data_uri(data: str) -> str:
img = qrcode.make(data)
buf = BytesIO()
img.save(buf, format="PNG")
b64 = base64.b64encode(buf.getvalue()).decode("ascii")
return f"data:image/png;base64,{b64}"
class MagicLinkRequest(BaseModel):
email: EmailStr
locale: Literal["nb", "en"] = "nb"
@router.post("/request-link")
async def request_magic_link(body: MagicLinkRequest) -> dict:
email = body.email.lower()
now = datetime.now(timezone.utc)
cooldown_cutoff = now - timedelta(seconds=settings.MAGIC_LINK_COOLDOWN_SECONDS)
async with plain_connection() as conn:
recent = await conn.fetchval(
"""
SELECT EXISTS (
SELECT 1 FROM magic_link_token
WHERE email = $1 AND consumed_at IS NULL AND created_at > $2
)
""",
email,
cooldown_cutoff,
)
if not recent:
# Ugyldiggjør tidligere uforbrukte lenker for denne e-posten --
# begrenser vinduet en eldre, lekket lenke kan misbrukes i.
await conn.execute(
"UPDATE magic_link_token SET consumed_at = now() WHERE email = $1 AND consumed_at IS NULL",
email,
)
raw_token = secrets.token_urlsafe(32)
expires_at = now + timedelta(minutes=settings.MAGIC_LINK_MAX_AGE_MINUTES)
await conn.execute(
"INSERT INTO magic_link_token (email, token_hash, expires_at, locale) VALUES ($1, $2, $3, $4)",
email,
_hash_secret(raw_token),
expires_at,
body.locale,
)
if settings.DEV_LOG_MAGIC_LINKS:
print(f"[DEV] Magic link for {email} ({body.locale}): {raw_token}", flush=True)
elif settings.SMTP_CONFIGURED:
try:
await send_magic_link_email(email, raw_token, body.locale)
except Exception:
# Se modul-docstring: driftsfeil skal ALDRI endre klientens
# respons. Token-raden består og utløper naturlig hvis den
# aldri når frem.
traceback.print_exc()
else:
print(
f"[ADVARSEL] Verken DEV_LOG_MAGIC_LINKS eller SMTP er konfigurert "
f"-- ingen lenke ble sendt for {email}.",
flush=True,
)
return _GENERIC_RESPONSE
class MagicLinkVerify(BaseModel):
token: str
display_name: str | None = None
class SessionUser(BaseModel):
id: str
email: str
display_name: str
preferred_locale: str
class LoginResult(BaseModel):
"""Felles svarform for verify-link, login-password OG 2fa/verify (ADR-021
Beslutning E) -- akkurat ÉN av de tre utfallene:
- "success": full sesjon utstedt, `user` er fylt ut.
- "2fa_required": brukeren har selv slått på 2FA -- `two_factor_method`
forteller klienten hvilken kode-innsending som trengs.
- "2fa_setup_required": org-eier/admin uten 2FA -- klienten må vise
oppsett-flyten FØR noe annet blir tilgjengelig."""
status: Literal["success", "2fa_required", "2fa_setup_required"]
user: SessionUser | None = None
two_factor_method: Literal["totp", "email"] | None = None
async def _issue_login_result(response: Response, request: Request, user_id: str) -> LoginResult:
async with plain_connection() as conn:
user_row = await conn.fetchrow(
"""
SELECT id::text AS id, email::text AS email, display_name, preferred_locale,
two_factor_method
FROM app_user WHERE id = $1
""",
user_id,
)
needs_enrollment = await user_requires_2fa_enrollment(conn, user_id)
two_factor_method = user_row["two_factor_method"]
if two_factor_method:
token = create_session_token(user_id, stage="pending_2fa")
_set_session_cookie(response, request, token, "pending_2fa")
return LoginResult(status="2fa_required", two_factor_method=two_factor_method)
if needs_enrollment:
token = create_session_token(user_id, stage="must_enroll_2fa")
_set_session_cookie(response, request, token, "must_enroll_2fa")
return LoginResult(status="2fa_setup_required")
token = create_session_token(user_id, stage="full")
_set_session_cookie(response, request, token, "full")
return LoginResult(
status="success",
user=SessionUser(
id=user_row["id"],
email=user_row["email"],
display_name=user_row["display_name"],
preferred_locale=user_row["preferred_locale"],
),
)
@router.post("/verify-link", response_model=LoginResult)
async def verify_magic_link(body: MagicLinkVerify, response: Response, request: Request) -> LoginResult:
token_hash = _hash_secret(body.token)
async with plain_connection() as conn:
# Atomisk forbruk: ÉN setning, ikke les-så-sjekk-så-skriv -- to
# samtidige forsøk med samme lenke kan da ikke begge lykkes.
token_row = await conn.fetchrow(
"""
UPDATE magic_link_token
SET consumed_at = now()
WHERE token_hash = $1 AND consumed_at IS NULL AND expires_at > now()
RETURNING email, locale
""",
token_hash,
)
if token_row is None:
raise app_error(401, "INVALID_MAGIC_LINK", "Lenken er ugyldig, brukt eller utløpt.")
email = token_row["email"]
placeholder_name = email.split("@")[0]
user_id = await conn.fetchval(
"""
INSERT INTO app_user (email, display_name, preferred_locale)
VALUES ($1, $2, $3)
ON CONFLICT (email) WHERE email IS NOT NULL DO NOTHING
RETURNING id
""",
email,
body.display_name or placeholder_name,
token_row["locale"],
)
if user_id is None:
# En annen samtidig verifisering for samme e-post vant innsettingen
# (eller brukeren fantes allerede) -- preferred_locale røres IKKE
# her, kun ved førstegangsopprettelse (se modul-docstring).
user_id = await conn.fetchval("SELECT id FROM app_user WHERE email = $1", email)
# Koble enhver player-rad (ADR-017) OG enhver ventende
# organisasjonsinvitasjon (ADR-022) med matchende e-post til denne
# nå-innloggede kontoen. Trygt å kjøre på HVER innlogging -- begge
# er idempotente, berører kun rader som ennå ikke er koblet/forbrukt.
await conn.execute("SELECT link_player_by_email($1, $2)", user_id, email)
await conn.execute("SELECT accept_pending_invitations_by_email($1, $2)", user_id, email)
# user_id er en asyncpg UUID her (ingen ::text-cast i spørringene over) --
# create_session_token() JSON-serialiserer JWT-payloaden, som krasjer på
# en rå UUID. Fant under scratch-testing: str() FØR den sendes videre.
return await _issue_login_result(response, request, str(user_id))
class PasswordLoginRequest(BaseModel):
email: EmailStr
password: str
@router.post("/login-password", response_model=LoginResult)
async def login_with_password(body: PasswordLoginRequest, response: Response, request: Request) -> LoginResult:
email = body.email.lower()
async with plain_connection() as conn:
user_row = await conn.fetchrow(
"SELECT id::text AS id, password_hash FROM app_user WHERE email = $1", email
)
# Identisk feil uansett årsak (e-post finnes ikke / intet passord satt /
# feil passord) -- unngår enumerering, samme prinsipp som magic-link.
if (
user_row is None
or user_row["password_hash"] is None
or not verify_password(body.password, user_row["password_hash"])
):
raise app_error(401, "INVALID_CREDENTIALS", "E-post eller passord er feil.")
await conn.execute("SELECT link_player_by_email($1, $2)", user_row["id"], email)
await conn.execute("SELECT accept_pending_invitations_by_email($1, $2)", user_row["id"], email)
return await _issue_login_result(response, request, user_row["id"])
class SetPasswordRequest(BaseModel):
# Bevisst INGEN restriksjon på tegnsett -- spesialtegn/mellomrom skal
# fungere korrekt (Argon2id, se app/auth.py). max_length er kun en
# fornuftig DoS-grense mot ekstremt lange innsendinger, ikke en reell
# begrensning på passfraser.
password: str = Field(min_length=8, max_length=200)
@router.post("/set-password")
async def set_password(body: SetPasswordRequest, user: CurrentUser = Depends(get_current_user)) -> dict:
hashed = hash_password(body.password)
async with plain_connection() as conn:
await conn.execute("UPDATE app_user SET password_hash = $1 WHERE id = $2", hashed, user.user_id)
return {"status": "ok"}
@router.post("/remove-password")
async def remove_password(user: CurrentUser = Depends(get_current_user)) -> dict:
async with plain_connection() as conn:
await conn.execute("UPDATE app_user SET password_hash = NULL WHERE id = $1", user.user_id)
return {"status": "ok"}
# ---------------------------------------------------------------------------
# 2FA: verifisering av en PÅGÅENDE innlogging (ADR-021 Beslutning C/E)
# ---------------------------------------------------------------------------
async def _send_two_factor_email_code(conn, user_id: str, email: str, locale: str) -> None:
# Ugyldiggjør tidligere uforbrukte koder for denne brukeren -- samme
# "begrens vinduet en gammel kode kan misbrukes i"-mønster som magic-link.
await conn.execute(
"UPDATE two_factor_code SET consumed_at = now() WHERE user_id = $1 AND consumed_at IS NULL",
user_id,
)
code = f"{secrets.randbelow(1_000_000):06d}"
await conn.execute(
"INSERT INTO two_factor_code (user_id, code_hash, expires_at) VALUES ($1, $2, $3)",
user_id,
_hash_secret(code),
datetime.now(timezone.utc) + timedelta(minutes=5),
)
if settings.DEV_LOG_MAGIC_LINKS:
print(f"[DEV] 2FA-kode for {email}: {code}", flush=True)
elif settings.SMTP_CONFIGURED:
try:
await send_two_factor_code_email(email, code, locale)
except Exception:
traceback.print_exc()
@router.post("/2fa/email/request")
async def request_two_factor_email_code(pending: PendingUser = Depends(get_pending_user)) -> dict:
async with plain_connection() as conn:
user_row = await conn.fetchrow(
"SELECT email::text AS email, preferred_locale, two_factor_method FROM app_user WHERE id = $1",
pending.user_id,
)
if user_row["two_factor_method"] != "email":
raise app_error(400, "VALIDATION_FAILED", "Denne kontoen bruker ikke e-post som 2FA-metode.")
await _send_two_factor_email_code(conn, pending.user_id, user_row["email"], user_row["preferred_locale"])
return {"status": "sent"}
class TwoFactorVerify(BaseModel):
code: str
@router.post("/2fa/verify", response_model=LoginResult)
async def verify_two_factor(
body: TwoFactorVerify,
response: Response,
request: Request,
pending: PendingUser = Depends(get_pending_user),
) -> LoginResult:
async with plain_connection() as conn:
method = await conn.fetchval(
"SELECT two_factor_method FROM app_user WHERE id = $1", pending.user_id
)
valid = False
if method == "totp":
secret = await conn.fetchval(
"SELECT totp_secret FROM app_user WHERE id = $1", pending.user_id
)
valid = secret is not None and verify_totp_code(secret, body.code.strip())
elif method == "email":
row = await conn.fetchrow(
"""
UPDATE two_factor_code SET consumed_at = now()
WHERE user_id = $1 AND code_hash = $2 AND consumed_at IS NULL AND expires_at > now()
RETURNING id
""",
pending.user_id,
_hash_secret(body.code.strip()),
)
valid = row is not None
if not valid:
raise app_error(401, "INVALID_2FA_CODE", "Feil eller utløpt kode.")
# Utsted en FULL sesjon direkte -- IKKE via _issue_login_result(), som
# ville sett two_factor_method fortsatt satt og krevd EN NY runde med
# 2FA for akkurat denne innloggingen (uendelig løkke). Samme fiks som
# 2fa/setup/confirm, se kommentaren der -- fant begge under
# scratch-testing.
async with plain_connection() as conn:
user_row = await conn.fetchrow(
"""
SELECT id::text AS id, email::text AS email, display_name, preferred_locale
FROM app_user WHERE id = $1
""",
pending.user_id,
)
token = create_session_token(pending.user_id, stage="full")
_set_session_cookie(response, request, token, "full")
return LoginResult(
status="success",
user=SessionUser(
id=user_row["id"],
email=user_row["email"],
display_name=user_row["display_name"],
preferred_locale=user_row["preferred_locale"],
),
)
# ---------------------------------------------------------------------------
# 2FA: OPPSETT (frivillig fra kontoinnstillinger, ELLER tvunget for
# org-eier/admin -- begge veier deler samme logikk, se
# get_current_or_enrolling_user i app/auth.py)
# ---------------------------------------------------------------------------
class TwoFactorSetupStart(BaseModel):
method: Literal["totp", "email"]
class TwoFactorSetupStartResult(BaseModel):
status: Literal["totp_ready", "email_sent"]
secret: str | None = None
otpauth_uri: str | None = None
qr_code_data_uri: str | None = None
@router.post("/2fa/setup/start", response_model=TwoFactorSetupStartResult)
async def start_two_factor_setup(
body: TwoFactorSetupStart, user: CurrentUser = Depends(get_current_or_enrolling_user)
) -> TwoFactorSetupStartResult:
async with plain_connection() as conn:
user_row = await conn.fetchrow(
"SELECT email::text AS email, preferred_locale FROM app_user WHERE id = $1", user.user_id
)
if body.method == "totp":
# Ingenting lagres ennå -- secreten rundtures til klienten og
# ekkoes tilbake i /2fa/setup/confirm, som er FØRSTE gang noe
# persisteres. Unngår en halvferdig 2FA-tilstand i databasen
# hvis brukeren forlater oppsettet uten å fullføre.
secret = generate_totp_secret()
uri = totp_provisioning_uri(secret, user_row["email"] or user.user_id)
return TwoFactorSetupStartResult(
status="totp_ready",
secret=secret,
otpauth_uri=uri,
qr_code_data_uri=_build_qr_data_uri(uri),
)
await _send_two_factor_email_code(conn, user.user_id, user_row["email"], user_row["preferred_locale"])
return TwoFactorSetupStartResult(status="email_sent")
class TwoFactorSetupConfirm(BaseModel):
method: Literal["totp", "email"]
code: str
# Kun for totp -- klienten ekkoer secreten fra /2fa/setup/start tilbake,
# se kommentaren der.
secret: str | None = None
@router.post("/2fa/setup/confirm", response_model=SessionUser)
async def confirm_two_factor_setup(
body: TwoFactorSetupConfirm,
response: Response,
request: Request,
user: CurrentUser = Depends(get_current_or_enrolling_user),
) -> SessionUser:
if body.method == "totp":
if not body.secret or not verify_totp_code(body.secret, body.code.strip()):
raise app_error(401, "INVALID_2FA_CODE", "Feil kode.")
async with plain_connection() as conn:
await conn.execute(
"UPDATE app_user SET two_factor_method = 'totp', totp_secret = $1 WHERE id = $2",
body.secret,
user.user_id,
)
else:
async with plain_connection() as conn:
row = await conn.fetchrow(
"""
UPDATE two_factor_code SET consumed_at = now()
WHERE user_id = $1 AND code_hash = $2 AND consumed_at IS NULL AND expires_at > now()
RETURNING id
""",
user.user_id,
_hash_secret(body.code.strip()),
)
if row is None:
raise app_error(401, "INVALID_2FA_CODE", "Feil eller utløpt kode.")
await conn.execute(
"UPDATE app_user SET two_factor_method = 'email' WHERE id = $1", user.user_id
)
# Oppsettet er fullført -- utsted en FULL sesjon direkte, IKKE via
# _issue_login_result() sin vanlige gjenbrukte 2FA-sjekk: den ville nå
# (feilaktig) sett two_factor_method nettopp satt og krevd EN NY runde
# med 2FA-verifisering for akkurat denne innloggingen, selv om brukeren
# bokstavelig talt akkurat beviste koden i confirm-kallet over. Fant
# under scratch-testing (AssertionError -- resultatet ble "2fa_required"
# i stedet for "success").
async with plain_connection() as conn:
user_row = await conn.fetchrow(
"""
SELECT id::text AS id, email::text AS email, display_name, preferred_locale
FROM app_user WHERE id = $1
""",
user.user_id,
)
token = create_session_token(user.user_id, stage="full")
_set_session_cookie(response, request, token, "full")
return SessionUser(**dict(user_row))
@router.post("/2fa/disable")
async def disable_two_factor(user: CurrentUser = Depends(get_current_user)) -> dict:
async with plain_connection() as conn:
await conn.execute(
"UPDATE app_user SET two_factor_method = NULL, totp_secret = NULL WHERE id = $1",
user.user_id,
)
return {"status": "ok"}
@router.post("/logout")
async def logout(response: Response) -> dict:
response.delete_cookie(SESSION_COOKIE_NAME, path="/")
return {"status": "ok"}
class MyOrg(BaseModel):
organization_id: str
name: str
role: str
class Me(BaseModel):
id: str
email: str
display_name: str
preferred_locale: str
has_password: bool
two_factor_method: Literal["totp", "email"] | None
organizations: list[MyOrg]
@router.get("/me", response_model=Me)
async def me(user: CurrentUser = Depends(get_current_user)) -> Me:
# MERK: `organization` (org_self-policyen) kan IKKE joines direkte her via
# plain_connection() -- migrasjon 005 fikset kun tomstreng-krasjen, den
# endret ikke at org_self krever en MATCHENDE app.current_org for å vise
# en rad i det hele tatt (riktig RLS-oppførsel, ikke en bug). En bruker
# kan tilhøre flere organisasjoner samtidig, så det finnes ingen ÉN
# kontekst å sette for en tverr-org-spørring som denne. Løsningen er
# derfor å slå opp hvert org-navn ETT OM GANGEN gjennom org_connection()
# (som setter riktig kontekst for akkurat den ene raden) -- N+1 spørringer,
# men N er antall organisasjoner brukeren tilhører (typisk 1-3), og dette
# er den eneste måten å gjøre det på uten å endre selve RLS-modellen.
async with plain_connection() as conn:
user_row = await conn.fetchrow(
"""
SELECT id::text AS id, email::text AS email, display_name, preferred_locale,
(password_hash IS NOT NULL) AS has_password, two_factor_method
FROM app_user WHERE id = $1
""",
user.user_id,
)
membership_rows = await conn.fetch(
"SELECT organization_id::text AS organization_id, role FROM organization_membership WHERE user_id = $1",
user.user_id,
)
organizations = []
for m in membership_rows:
async with org_connection(m["organization_id"]) as org_conn:
name = await org_conn.fetchval(
"SELECT name FROM organization WHERE id = $1", m["organization_id"]
)
organizations.append(MyOrg(organization_id=m["organization_id"], name=name, role=m["role"]))
return Me(
id=user_row["id"],
email=user_row["email"],
display_name=user_row["display_name"],
preferred_locale=user_row["preferred_locale"],
has_password=user_row["has_password"],
two_factor_method=user_row["two_factor_method"],
organizations=organizations,
)