844 lines
34 KiB
Python
844 lines
34 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 date, datetime, timedelta, timezone
|
|
from io import BytesIO
|
|
from typing import Literal
|
|
|
|
import qrcode
|
|
from fastapi import APIRouter, Depends, Request, Response, UploadFile
|
|
from pydantic import BaseModel, EmailStr, Field
|
|
|
|
from .. import storage
|
|
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_email_change_confirmation, send_magic_link_email, send_two_factor_code_email
|
|
from ..errors import app_error, translate_db_errors
|
|
|
|
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 MyTournament(BaseModel):
|
|
"""Én rad = ett lag brukeren er ROSTRET på (ADR-031, 'Mine runder').
|
|
Bevisst utenfor omfang v1: turneringer der brukeren kun er PÅMELDT
|
|
(`tournament_registration`) men ikke ennå rostret på et lag."""
|
|
|
|
organization_id: str
|
|
organization_name: str
|
|
tournament_id: str
|
|
tournament_name: str
|
|
status: str
|
|
team_id: str
|
|
team_name: str
|
|
team_color: str | None
|
|
next_session_at: datetime | None
|
|
|
|
|
|
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]
|
|
# Personlig profil (ADR-031) -- ETT sett per konto, atskilt fra de
|
|
# org-scopede `player`-radene (se ADR-031 for hvorfor de ikke slås
|
|
# sammen til én ting).
|
|
first_name: str | None
|
|
last_name: str | None
|
|
birth_date: date | None
|
|
gender: str | None
|
|
handicap_index: float | None
|
|
home_club: str | None
|
|
mobile_country_code: str | None
|
|
mobile_number: str | None
|
|
avatar_url: str | None
|
|
my_tournaments: list[MyTournament]
|
|
|
|
|
|
@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.
|
|
#
|
|
# ADR-031: samme N+1-mønster brukt for "Mine runder", nå over org-er
|
|
# funnet via player_organizations_for_user() (migrasjon 015) i stedet
|
|
# for organization_membership -- en bruker kan være SPILLER i en org
|
|
# helt uavhengig av om de er MEDLEM der.
|
|
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,
|
|
first_name, last_name, birth_date, gender,
|
|
handicap_index::float AS handicap_index, home_club,
|
|
mobile_country_code, mobile_number, avatar_key
|
|
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,
|
|
)
|
|
player_org_rows = await conn.fetch(
|
|
"SELECT organization_id::text AS organization_id FROM player_organizations_for_user($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"]))
|
|
|
|
my_tournaments: list[MyTournament] = []
|
|
for p in player_org_rows:
|
|
org_id = p["organization_id"]
|
|
async with org_connection(org_id) as org_conn:
|
|
org_name = await org_conn.fetchval("SELECT name FROM organization WHERE id = $1", org_id)
|
|
rows = await org_conn.fetch(
|
|
"""
|
|
SELECT t.id::text AS tournament_id, t.name AS tournament_name,
|
|
t.status::text AS status,
|
|
tm.id::text AS team_id, tm.name AS team_name, tm.color AS team_color,
|
|
(SELECT min(s.scheduled_at) FROM session s
|
|
WHERE s.tournament_id = t.id AND s.scheduled_at > now()) AS next_session_at
|
|
FROM player pl
|
|
JOIN team_roster tr ON tr.player_id = pl.id
|
|
JOIN team tm ON tm.id = tr.team_id
|
|
JOIN tournament t ON t.id = tm.tournament_id
|
|
WHERE pl.user_id = $1
|
|
ORDER BY t.created_at DESC
|
|
""",
|
|
user.user_id,
|
|
)
|
|
for r in rows:
|
|
my_tournaments.append(
|
|
MyTournament(organization_id=org_id, organization_name=org_name, **dict(r))
|
|
)
|
|
|
|
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,
|
|
first_name=user_row["first_name"],
|
|
last_name=user_row["last_name"],
|
|
birth_date=user_row["birth_date"],
|
|
gender=user_row["gender"],
|
|
handicap_index=user_row["handicap_index"],
|
|
home_club=user_row["home_club"],
|
|
mobile_country_code=user_row["mobile_country_code"],
|
|
mobile_number=user_row["mobile_number"],
|
|
avatar_url=storage.public_url(user_row["avatar_key"]) if user_row["avatar_key"] else None,
|
|
my_tournaments=my_tournaments,
|
|
)
|
|
|
|
|
|
class ProfileUpdate(BaseModel):
|
|
"""Personlig profil (ADR-031) -- PATCH-semantikk via exclude_unset, som
|
|
resten av appen. Et felt sendt eksplisitt som `null` NULLES (f.eks.
|
|
fjern fødselsdato), et UTELATT felt endres ikke -- vanlig
|
|
"sletting" av et enkeltfelt trenger derfor ingen egen DELETE-vei her,
|
|
kun avatar (binært innhold) har sin egen under."""
|
|
|
|
first_name: str | None = Field(default=None, max_length=100)
|
|
last_name: str | None = Field(default=None, max_length=100)
|
|
birth_date: date | None = None
|
|
gender: str | None = Field(default=None, pattern="^[mfx]$")
|
|
handicap_index: float | None = None
|
|
home_club: str | None = Field(default=None, max_length=200)
|
|
# Landsnummer som eget felt (f.eks. "+47"), ikke sammensatt med selve
|
|
# nummeret -- lar frontend tilby en egen landsnummer-velger uten å
|
|
# måtte parse/splitte en fritekststreng i etterkant.
|
|
mobile_country_code: str | None = Field(default=None, max_length=5)
|
|
mobile_number: str | None = Field(default=None, max_length=30)
|
|
|
|
|
|
@router.patch("/profile", response_model=Me)
|
|
async def update_profile(
|
|
body: ProfileUpdate,
|
|
user: CurrentUser = Depends(get_current_user),
|
|
) -> Me:
|
|
updates = body.model_dump(exclude_unset=True)
|
|
if not updates:
|
|
raise app_error(400, "VALIDATION_FAILED", "Ingen felt å oppdatere.")
|
|
|
|
set_clauses = [f"{key} = ${i}" for i, key in enumerate(updates, start=1)]
|
|
values = list(updates.values())
|
|
values.append(user.user_id)
|
|
|
|
async with plain_connection() as conn:
|
|
await conn.execute(
|
|
f"UPDATE app_user SET {', '.join(set_clauses)} WHERE id = ${len(values)}",
|
|
*values,
|
|
)
|
|
return await me(user)
|
|
|
|
|
|
@router.post("/profile/avatar", response_model=Me)
|
|
async def upload_avatar(
|
|
file: UploadFile,
|
|
user: CurrentUser = Depends(get_current_user),
|
|
) -> Me:
|
|
"""Samme ekte multipart->AVIF-mønster som tournament.hero_image_key
|
|
(ADR-018 MinIO-runden) -- se app/storage.py."""
|
|
if file.content_type not in storage.ALLOWED_INPUT_CONTENT_TYPES:
|
|
raise app_error(400, "VALIDATION_FAILED", "Ustøttet bildeformat.")
|
|
|
|
raw = await file.read(storage.MAX_UPLOAD_BYTES + 1)
|
|
if len(raw) > storage.MAX_UPLOAD_BYTES:
|
|
raise app_error(400, "VALIDATION_FAILED", "Bildet er for stort (maks 8 MB).")
|
|
|
|
try:
|
|
key = await storage.upload_image("avatars", user.user_id, raw)
|
|
except storage.InvalidImageError:
|
|
raise app_error(400, "VALIDATION_FAILED", "Filen er ikke et gyldig bilde.")
|
|
|
|
async with plain_connection() as conn:
|
|
await conn.execute("UPDATE app_user SET avatar_key = $1 WHERE id = $2", key, user.user_id)
|
|
return await me(user)
|
|
|
|
|
|
@router.delete("/profile/avatar", response_model=Me)
|
|
async def remove_avatar(user: CurrentUser = Depends(get_current_user)) -> Me:
|
|
async with plain_connection() as conn:
|
|
await conn.execute("UPDATE app_user SET avatar_key = NULL WHERE id = $1", user.user_id)
|
|
return await me(user)
|
|
|
|
|
|
# --- Verifisert e-postbytte -------------------------------------------------
|
|
# E-post er innloggings-identifikatoren (magic-link-mål) -- kan IKKE endres
|
|
# med en enkel PATCH slik de andre profilfeltene kan, uten å risikere at en
|
|
# skrivefeil eller en kapret sesjon stjeler kontoen for godt. To steg, samme
|
|
# token-hash-og-utløp-mønster som magic_link_token (004): (1) bevis at DU
|
|
# eier kontoen i dag (krever en gyldig sesjon), (2) bevis at du eier den NYE
|
|
# adressen (klikk lenken sendt DIT) -- ingen av de to alene er nok.
|
|
|
|
|
|
class EmailChangeRequest(BaseModel):
|
|
new_email: EmailStr
|
|
|
|
|
|
@router.post("/profile/email")
|
|
async def request_email_change(
|
|
body: EmailChangeRequest,
|
|
user: CurrentUser = Depends(get_current_user),
|
|
) -> dict:
|
|
new_email = body.new_email.lower()
|
|
async with plain_connection() as conn:
|
|
taken = await conn.fetchval("SELECT id FROM app_user WHERE email = $1", new_email)
|
|
if taken is not None:
|
|
raise app_error(409, "DUPLICATE", "Denne e-postadressen er allerede i bruk av en annen konto.")
|
|
|
|
raw_token = secrets.token_urlsafe(32)
|
|
expires_at = datetime.now(timezone.utc) + timedelta(minutes=settings.MAGIC_LINK_MAX_AGE_MINUTES)
|
|
await conn.execute(
|
|
"INSERT INTO email_change_token (user_id, new_email, token_hash, expires_at) VALUES ($1, $2, $3, $4)",
|
|
user.user_id,
|
|
new_email,
|
|
_hash_secret(raw_token),
|
|
expires_at,
|
|
)
|
|
|
|
if settings.DEV_LOG_MAGIC_LINKS:
|
|
print(f"[DEV] E-postbytte-lenke for {new_email}: {raw_token}", flush=True)
|
|
elif settings.SMTP_CONFIGURED:
|
|
try:
|
|
await send_email_change_confirmation(new_email, raw_token)
|
|
except Exception:
|
|
traceback.print_exc()
|
|
return {"status": "ok"}
|
|
|
|
|
|
class EmailChangeConfirm(BaseModel):
|
|
token: str
|
|
|
|
|
|
@router.post("/profile/email/confirm", response_model=Me)
|
|
async def confirm_email_change(body: EmailChangeConfirm) -> Me:
|
|
token_hash = _hash_secret(body.token)
|
|
async with plain_connection() as conn:
|
|
# Atomisk forbruk, samme mønster som verify_magic_link.
|
|
token_row = await conn.fetchrow(
|
|
"""
|
|
UPDATE email_change_token
|
|
SET consumed_at = now()
|
|
WHERE token_hash = $1 AND consumed_at IS NULL AND expires_at > now()
|
|
RETURNING user_id::text AS user_id, new_email::text AS new_email
|
|
""",
|
|
token_hash,
|
|
)
|
|
if token_row is None:
|
|
raise app_error(401, "INVALID_EMAIL_CHANGE_LINK", "Lenken er ugyldig, brukt eller utløpt.")
|
|
|
|
# Kan ha blitt tatt av en annen konto i tiden MELLOM forespørsel og
|
|
# bekreftelse -- sjekket på nytt her, ikke bare ved forespørselen.
|
|
taken = await conn.fetchval(
|
|
"SELECT id FROM app_user WHERE email = $1 AND id <> $2",
|
|
token_row["new_email"],
|
|
token_row["user_id"],
|
|
)
|
|
if taken is not None:
|
|
raise app_error(
|
|
409, "DUPLICATE", "Denne e-postadressen ble tatt i bruk av en annen konto i mellomtiden."
|
|
)
|
|
|
|
async with translate_db_errors():
|
|
await conn.execute(
|
|
"UPDATE app_user SET email = $1 WHERE id = $2", token_row["new_email"], token_row["user_id"]
|
|
)
|
|
return await me(CurrentUser(user_id=token_row["user_id"]))
|