Grunnmur for oversettelse (cookie-basert, ingen URL-endring), ny /hjelp-side med oversikt+FAQ på norsk og engelsk, og Deltakere+ Rundedeltakelse slått sammen til én bred tabell i Spillere-steget. Resten av appens ~2200 strenger og 479 backend-feilkoder er bevisst utsatt til senere, avgrensede runder -- se FEATURE_BACKLOG.md. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1281 lines
54 KiB
Python
1281 lines
54 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,
|
|
resolve_user_id_by_email,
|
|
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_new_account_alert_email,
|
|
send_secondary_email_verification,
|
|
send_two_factor_code_email,
|
|
)
|
|
from ..errors import app_error, translate_db_errors
|
|
from ..rate_limit import RateLimiter
|
|
|
|
router = APIRouter(prefix="/auth", tags=["auth"])
|
|
|
|
_GENERIC_RESPONSE = {
|
|
"status": "ok",
|
|
"detail": "Hvis e-posten er gyldig, er en innloggingslenke sendt.",
|
|
}
|
|
|
|
# Rate limiting (sikkerhetsgjennomgang 2026-08-09, se app/rate_limit.py for
|
|
# begrunnelse/forbehold). IP-baserte grenser brukes der ressursen som
|
|
# beskyttes ikke er entydig knyttet til én konto (request-link/2fa-email-
|
|
# request kan gjelde en HVILKEN SOM HELST e-post, login-password likeledes).
|
|
# 2fa/verify og 2fa/setup/confirm brute-forcer derimot EN BESTEMT konto sin
|
|
# kode -- der er nøkkelen den faktiske (pending-/innloggede) bruker-ID-en i
|
|
# stedet for IP, se .check(...)-kallene i selve endepunktene, siden en
|
|
# angriper med en gyldig pending-sesjon ellers kunne omgått IP-basert
|
|
# begrensning ved å bytte IP mellom forsøk.
|
|
_login_password_limiter = RateLimiter(max_requests=10, window_seconds=600)
|
|
_request_link_limiter = RateLimiter(max_requests=5, window_seconds=600)
|
|
_two_factor_email_request_limiter = RateLimiter(max_requests=5, window_seconds=600)
|
|
_two_factor_verify_limiter = RateLimiter(
|
|
max_requests=8, window_seconds=300, message="For mange feilede forsøk. Vent litt og prøv igjen."
|
|
)
|
|
_two_factor_setup_confirm_limiter = RateLimiter(
|
|
max_requests=8, window_seconds=300, message="For mange feilede forsøk. Vent litt og prøv igjen."
|
|
)
|
|
|
|
|
|
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}"
|
|
|
|
|
|
async def _link_round_participants_by_email(conn, user_id: str, email: str) -> None:
|
|
"""Kobler frittstående-runde-gjester (round_participant.guest_email) til
|
|
denne nå-innloggede kontoen (2026-08-03) -- samme "kjør trygt på HVER
|
|
innlogging"-idempotens som link_player_by_email (WHERE user_id IS NULL),
|
|
men INGEN SECURITY DEFINER-bro trengs: round/round_participant har ingen
|
|
RLS (ADR-033 Beslutning A, plain_connection()), så en rett UPDATE er nok.
|
|
|
|
Bevisst IKKE en blind kobling -- se ADR-038-tillitsresonnementet: en
|
|
FREMMED kan i prinsippet ha registrert deg som gjest med feil/dårlig
|
|
score. `exclude_from_handicap = true` settes derfor alltid på nykoblede
|
|
rader, uansett hva raden opprinnelig hadde -- personen må selv slå den
|
|
PÅ (update_participant) for at runden skal telle mot faktisk HCP.
|
|
`NOT EXISTS`-vaktet mot en allerede eksisterende ekte deltaker-rad for
|
|
(round_id, user_id) -- ville ellers brutt UNIQUE-indeksen fra migrasjon
|
|
027 hvis personen tilfeldigvis også er lagt til som ekte spiller på
|
|
SAMME runde. guest_name/guest_first_name/guest_last_name nullstilles
|
|
ved kobling (samme "nøyaktig ett av user_id/guest_first_name"-invariant
|
|
som CHECK-constrainten på round_participant håndhever ved INSERT) --
|
|
`gender` derimot IKKE: den er NOT NULL og brukt til tee-rating-oppslag
|
|
for ALLE deltakere, ikke bare gjester (reelt funn under scratch-
|
|
testing -- en første versjon nullet den også, brøt NOT NULL-
|
|
constrainten). Beholder gjestens opprinnelig snapshottede kjønn
|
|
uendret; update_participant sin egen guest_only_fields-sjekk låser
|
|
uansett feltet mot videre endring så snart user_id er satt."""
|
|
await conn.execute(
|
|
"""
|
|
UPDATE round_participant AS rp
|
|
SET user_id = $1,
|
|
exclude_from_handicap = true,
|
|
guest_name = NULL, guest_first_name = NULL, guest_last_name = NULL
|
|
WHERE rp.user_id IS NULL
|
|
AND lower(rp.guest_email) = lower($2)
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM round_participant rp2
|
|
WHERE rp2.round_id = rp.round_id AND rp2.user_id = $1
|
|
)
|
|
""",
|
|
user_id,
|
|
email,
|
|
)
|
|
|
|
|
|
class MagicLinkRequest(BaseModel):
|
|
email: EmailStr
|
|
locale: Literal["nb", "en"] = "nb"
|
|
|
|
|
|
@router.post("/request-link")
|
|
async def request_magic_link(body: MagicLinkRequest, _rl: None = Depends(_request_link_limiter)) -> 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"]
|
|
|
|
# Primær-så-sekundær-oppslag (FEATURE_BACKLOG.md "Én person, flere
|
|
# e-postadresser") løses til EIERENS eksisterende konto -- må
|
|
# sjekkes FØR en eventuell ny konto opprettes under, ellers ville
|
|
# en innlogging på en sekundær adresse (som med vilje ALDRI står i
|
|
# app_user.email) stille opprettet en helt ny, separat, feilaktig
|
|
# konto.
|
|
resolved_user_id = await resolve_user_id_by_email(conn, email)
|
|
# Driftsvarsel (2026-07-30, reist av bruker): mottakeren + selve
|
|
# kontoens navn trengs ETTER at connection-blokken er lukket
|
|
# (e-postutsending skal aldri skje mens en transaksjon/tilkobling
|
|
# holdes åpen) -- fanget her, brukt lenger ned.
|
|
is_new_account = False
|
|
new_account_display_name = None
|
|
|
|
if resolved_user_id is not None:
|
|
user_id = resolved_user_id
|
|
else:
|
|
placeholder_name = email.split("@")[0]
|
|
new_account_display_name = body.display_name or placeholder_name
|
|
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,
|
|
new_account_display_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ørstegangs-
|
|
# opprettelse (se modul-docstring). Ikke en ny konto --
|
|
# ingen driftsvarsel.
|
|
user_id = await conn.fetchval("SELECT id FROM app_user WHERE email = $1", email)
|
|
else:
|
|
is_new_account = True
|
|
|
|
# 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)
|
|
await _link_round_participants_by_email(conn, user_id, email)
|
|
|
|
# Driftsvarsel til appens eier ved en HELT NY konto (2026-07-30) --
|
|
# BEVISST etter at connection-blokken er lukket, samme "e-post skal
|
|
# aldri sendes mens en tilkobling/transaksjon holdes åpen"-prinsipp som
|
|
# resten av appen. En feil her skal ALDRI endre selve innloggings-
|
|
# responsen -- fanget og logget, ikke kastet videre.
|
|
if is_new_account:
|
|
if settings.DEV_LOG_MAGIC_LINKS:
|
|
print(f"[DEV] Ny konto opprettet: {email} ({new_account_display_name})", flush=True)
|
|
elif settings.SMTP_CONFIGURED:
|
|
try:
|
|
await send_new_account_alert_email(
|
|
settings.NEW_ACCOUNT_ALERT_EMAIL, email, new_account_display_name
|
|
)
|
|
except Exception:
|
|
traceback.print_exc()
|
|
|
|
# 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,
|
|
_rl: None = Depends(_login_password_limiter),
|
|
) -> LoginResult:
|
|
email = body.email.lower()
|
|
async with plain_connection() as conn:
|
|
# Primær-så-sekundær-oppslag (FEATURE_BACKLOG.md "Én person, flere
|
|
# e-postadresser") -- passordet ligger uansett på selve kontoen,
|
|
# ikke adressen, så slå opp eieren og fortsett normalt.
|
|
resolved_user_id = await resolve_user_id_by_email(conn, email)
|
|
user_row = (
|
|
await conn.fetchrow(
|
|
"SELECT id::text AS id, password_hash FROM app_user WHERE id = $1", resolved_user_id
|
|
)
|
|
if resolved_user_id is not None
|
|
else None
|
|
)
|
|
# 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)
|
|
await _link_round_participants_by_email(conn, 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),
|
|
_rl: None = Depends(_two_factor_email_request_limiter),
|
|
) -> 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:
|
|
# Nøkkel på pending.user_id, ikke IP -- se begrunnelse ved limiter-
|
|
# definisjonene lenger opp i filen.
|
|
_two_factor_verify_limiter.check(pending.user_id)
|
|
|
|
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:
|
|
# Nøkkel på user.user_id, ikke IP -- se begrunnelse ved limiter-
|
|
# definisjonene lenger opp i filen.
|
|
_two_factor_setup_confirm_limiter.check(user.user_id)
|
|
|
|
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.
|
|
|
|
`my_session_id`/`my_match_id` (2026-07-21, deltaker-tilgang-runden):
|
|
én av brukerens egne matcher (foretrekker en ikke-avgjort fremfor en
|
|
avgjort, ellers vilkårlig) -- lar frontend lenke direkte til lagets
|
|
chat/scorekort uten at brukeren selv må navigere seg dit via
|
|
program-/blind draw-skjermene (som fortsatt krever org-medlemskap for
|
|
andre formål enn nettopp dette). `None` hvis brukeren ikke er lagt til
|
|
i noen match ennå."""
|
|
|
|
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
|
|
my_session_id: str | None
|
|
my_match_id: str | None
|
|
|
|
|
|
class SecondaryEmailOut(BaseModel):
|
|
id: str
|
|
email: str
|
|
|
|
|
|
# Kølle-bag (2026-07-24, ADR-031-mønster): fast, ikke-utvidbar liste --
|
|
# samme liste brukes i migrasjon 022 sin CHECK-constraint (bakstopper) og
|
|
# frontend sin avkrysningsliste. Maks 14 (den ekte golfregelen), håndhevet
|
|
# under i ProfileUpdate.
|
|
BAG_CLUBS: list[str] = [
|
|
"Driver", "Mini-driver", "Driving iron", "3-wood", "4-wood", "5-wood",
|
|
"7-wood", "9-wood", "2-hybrid", "3-hybrid", "4-hybrid", "5-hybrid",
|
|
"1-iron", "2-iron", "3-iron", "4-iron", "5-iron", "6-iron", "7-iron",
|
|
"8-iron", "9-iron", "Pitching wedge", "Gap wedge", "Sand wedge",
|
|
"Lob wedge", "Ultra-lob wedge", "Chipper", "Putter",
|
|
]
|
|
BagClub = Literal[tuple(BAG_CLUBS)] # type: ignore[valid-type]
|
|
|
|
|
|
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
|
|
# Faktisk (beregnet) HCP, ADR-038 -- WHS Handicap Index regnet
|
|
# automatisk fra beste 8 av de ≤20 nyeste tellende Score
|
|
# Differential-ene fra frittstående runder. Aldri direkte redigerbar
|
|
# -- kun `handicap_index` over er det (manuelt satt, brukt til
|
|
# beregning i runder som i dag). `None` betyr færre enn 3 tellende
|
|
# runder ennå, ikke en feil.
|
|
computed_handicap_index: float | None
|
|
computed_handicap_index_updated_at: str | None
|
|
home_club: str | None
|
|
country: str | None
|
|
bio: str | None
|
|
mobile_country_code: str | None
|
|
mobile_number: str | None
|
|
bag_clubs: list[str]
|
|
avatar_url: str | None
|
|
# Obligatorisk-felt-sjekk (2026-07-22): true når alle obligatoriske
|
|
# profilfelt er utfylt (fornavn/etternavn/fødselsdato/kjønn/hcp/
|
|
# hjemmeklubb/land) -- beskrivelse og bilde er bevisst IKKE med, de er
|
|
# valgfrie. Frontend bruker denne til å tvinge en ny/ufullstendig
|
|
# bruker gjennom profil-fullføring FØR resten av appen vises.
|
|
profile_complete: bool
|
|
my_tournaments: list[MyTournament]
|
|
secondary_emails: list[SecondaryEmailOut]
|
|
|
|
|
|
@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,
|
|
computed_handicap_index::float AS computed_handicap_index,
|
|
computed_handicap_index_updated_at,
|
|
home_club, country, bio,
|
|
mobile_country_code, mobile_number, bag_clubs, 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,
|
|
)
|
|
secondary_email_rows = await conn.fetch(
|
|
"SELECT id::text AS id, email::text AS email FROM user_secondary_email WHERE user_id = $1 ORDER BY created_at",
|
|
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,
|
|
mymatch.session_id AS my_session_id, mymatch.match_id AS my_match_id
|
|
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
|
|
LEFT JOIN LATERAL (
|
|
SELECT m.id::text AS match_id, m.session_id::text AS session_id
|
|
FROM match_participant mp
|
|
JOIN match m ON m.id = mp.match_id
|
|
WHERE mp.team_roster_id = tr.id
|
|
ORDER BY (m.points_side_a IS NULL) DESC, m.created_at DESC
|
|
LIMIT 1
|
|
) mymatch ON true
|
|
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))
|
|
)
|
|
|
|
profile_complete = all(
|
|
user_row[field] is not None
|
|
for field in ("first_name", "last_name", "birth_date", "gender", "handicap_index", "home_club", "country")
|
|
)
|
|
|
|
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"],
|
|
computed_handicap_index=user_row["computed_handicap_index"],
|
|
computed_handicap_index_updated_at=(
|
|
user_row["computed_handicap_index_updated_at"].isoformat()
|
|
if user_row["computed_handicap_index_updated_at"] else None
|
|
),
|
|
home_club=user_row["home_club"],
|
|
country=user_row["country"],
|
|
bio=user_row["bio"],
|
|
mobile_country_code=user_row["mobile_country_code"],
|
|
mobile_number=user_row["mobile_number"],
|
|
bag_clubs=list(user_row["bag_clubs"]) if user_row["bag_clubs"] else [],
|
|
avatar_url=storage.public_url(user_row["avatar_key"]) if user_row["avatar_key"] else None,
|
|
profile_complete=profile_complete,
|
|
my_tournaments=my_tournaments,
|
|
secondary_emails=[SecondaryEmailOut(id=r["id"], email=r["email"]) for r in secondary_email_rows],
|
|
)
|
|
|
|
|
|
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]$")
|
|
# WHS-maksimum er 54.0 -- en fersk spiller uten offisiell HCP ennå skal
|
|
# bruke nettopp dette som utgangspunkt (frontend forhåndsutfyller
|
|
# skjemaet med 54), ikke la feltet stå tomt. Nedre grense er romslig
|
|
# nok til å dekke reelle plusshandicap.
|
|
handicap_index: float | None = Field(default=None, ge=-10, le=54)
|
|
home_club: str | None = Field(default=None, max_length=200)
|
|
country: str | None = Field(default=None, max_length=100)
|
|
bio: str | None = Field(default=None, max_length=500)
|
|
# 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)
|
|
# Kølle-bag (2026-07-24): maks 14 køller -- den ekte golfregelen, ikke
|
|
# en vilkårlig UI-begrensning. Se BAG_CLUBS for den fulle, faste listen.
|
|
bag_clubs: list[BagClub] | None = Field(default=None, max_length=14)
|
|
# i18n (ADR-094) -- preferred_locale kunne FØR kun settes ved
|
|
# førstegangs-signup (ADR-015, magic_link_token.locale), aldri endres
|
|
# etterpå. language-switcher.tsx PATCHer dette feltet slik at valget
|
|
# følger brukeren til neste enhet/økt, ikke bare gjeldende nettleser
|
|
# (som en cookie alene ville gitt).
|
|
preferred_locale: str | None = Field(default=None, pattern="^(nb|en)$")
|
|
|
|
|
|
@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.")
|
|
|
|
# Aldersgrense (2026-08-10, eksplisitt brukerkrav etter juridisk/
|
|
# personvern-vurdering, se ARCHITECTURE_DECISIONS.md): TeeCup skal
|
|
# ALDRI la noen under 13 år registrere seg (GDPR/personopplysningsloven
|
|
# -- under 13 krever verifiserbart foreldresamtykke, som appen ikke har
|
|
# støtte for). Håndhevet HER, server-side, ikke bare i skjemaet --
|
|
# klientside-validering alene er ingen reell grense.
|
|
if body.birth_date is not None:
|
|
today = date.today()
|
|
age = today.year - body.birth_date.year - (
|
|
(today.month, today.day) < (body.birth_date.month, body.birth_date.day)
|
|
)
|
|
if age < 13:
|
|
raise app_error(
|
|
400, "UNDER_MINIMUM_AGE", "Du må være minst 13 år for å bruke TeeCup."
|
|
)
|
|
|
|
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:
|
|
# HCP-historikk (FEATURE_BACKLOG.md, ADR-031 sitt "naturlig neste
|
|
# steg"-punkt): les gjeldende verdi FØR den overskrives, slik at vi
|
|
# kan avgjøre om dette faktisk er en ENDRING (ikke bare et PATCH-
|
|
# kall som gjentar samme verdi) FØR vi logger en ny historikk-rad.
|
|
old_hcp = None
|
|
if "handicap_index" in updates:
|
|
old_hcp = await conn.fetchval("SELECT handicap_index FROM app_user WHERE id = $1", user.user_id)
|
|
|
|
await conn.execute(
|
|
f"UPDATE app_user SET {', '.join(set_clauses)} WHERE id = ${len(values)}",
|
|
*values,
|
|
)
|
|
|
|
# `display_name` er brukt BREDT (org-medlemslister, invitasjons-
|
|
# e-post, dashbord-hilsen) og settes i dag KUN fra e-postens
|
|
# lokaldel ved kontoopprettelse (verify_magic_link) -- ADR-031s
|
|
# profil-fullføring la til atskilte first_name/last_name-felt, men
|
|
# rørte aldri display_name. Konsekvens (rapportert av bruker
|
|
# 2026-07-25, egen konto): en bruker med fullstendig utfylt profil
|
|
# viste fortsatt e-post-avledet plassholdernavn overalt. Fikset her
|
|
# ved å synkronisere display_name automatisk hver gang for-/
|
|
# etternavn endres -- KUN når begge faktisk er satt etterpå (unngår
|
|
# et halvferdig "Fornavn None"-navn ved delvis utfylling).
|
|
if "first_name" in updates or "last_name" in updates:
|
|
name_row = await conn.fetchrow(
|
|
"SELECT first_name, last_name FROM app_user WHERE id = $1", user.user_id
|
|
)
|
|
if name_row and name_row["first_name"] and name_row["last_name"]:
|
|
full_name = f"{name_row['first_name']} {name_row['last_name']}".strip()
|
|
await conn.execute("UPDATE app_user SET display_name = $1 WHERE id = $2", full_name, user.user_id)
|
|
|
|
if "handicap_index" in updates:
|
|
new_hcp = updates["handicap_index"]
|
|
# Kun faktiske tallverdier logges (ikke nullstilling) -- en
|
|
# "HCP fjernet"-hendelse gir ingen mening i en verdi-over-tid-
|
|
# historikk.
|
|
changed = new_hcp is not None and (old_hcp is None or float(old_hcp) != float(new_hcp))
|
|
if changed:
|
|
await conn.execute(
|
|
"INSERT INTO handicap_history (user_id, handicap_index) VALUES ($1, $2)",
|
|
user.user_id,
|
|
new_hcp,
|
|
)
|
|
return await me(user)
|
|
|
|
|
|
@router.get("/profile/handicap-history")
|
|
async def get_handicap_history(user: CurrentUser = Depends(get_current_user)) -> list[dict]:
|
|
async with plain_connection() as conn:
|
|
rows = await conn.fetch(
|
|
"""
|
|
SELECT handicap_index::float AS handicap_index, recorded_at, source
|
|
FROM handicap_history WHERE user_id = $1
|
|
ORDER BY recorded_at
|
|
""",
|
|
user.user_id,
|
|
)
|
|
return [
|
|
{"handicap_index": r["handicap_index"], "recorded_at": r["recorded_at"].isoformat(), "source": r["source"]}
|
|
for r in rows
|
|
]
|
|
|
|
|
|
@router.post("/profile/handicap/apply-computed", response_model=Me)
|
|
async def apply_computed_handicap(user: CurrentUser = Depends(get_current_user)) -> Me:
|
|
"""ADR-038 Beslutning B -- eksplisitt "Bruk som mitt HCP"-handling.
|
|
Kopierer gjeldende computed_handicap_index inn i det manuelt satte
|
|
handicap_index. INGEN automatisk synkronisering noen vei ellers --
|
|
de to tallene lever uavhengig helt til brukeren selv trykker denne."""
|
|
async with plain_connection() as conn:
|
|
computed = await conn.fetchval(
|
|
"SELECT computed_handicap_index::float FROM app_user WHERE id = $1", user.user_id
|
|
)
|
|
if computed is None:
|
|
raise app_error(400, "VALIDATION_FAILED", "Ingen faktisk HCP er beregnet ennå.")
|
|
await conn.execute("UPDATE app_user SET handicap_index = $2 WHERE id = $1", user.user_id, computed)
|
|
await conn.execute(
|
|
"INSERT INTO handicap_history (user_id, handicap_index, source) VALUES ($1, $2, 'manual')",
|
|
user.user_id, computed,
|
|
)
|
|
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"]))
|
|
|
|
|
|
# --- Sekundær e-postadresse (kun det enkle tilfellet -- se FEATURE_BACKLOG.md
|
|
# "Én person, flere e-postadresser" for hvorfor ekte konto-sammenslåing er
|
|
# BEVISST utenfor omfang her) -------------------------------------------------
|
|
# Samme to-stegs bevis-eierskap-mønster som e-postbytte over, men ADDITIVT
|
|
# (legger til en ny rad i user_secondary_email) i stedet for å erstatte
|
|
# app_user.email. En verifisert sekundær e-post kan deretter brukes til
|
|
# innlogging (magic-link OG passord, se verify_magic_link/login_with_password)
|
|
# -- den løses til DENNE kontoen, aldri til en ny, separat konto.
|
|
|
|
|
|
class SecondaryEmailRequest(BaseModel):
|
|
email: EmailStr
|
|
|
|
|
|
@router.post("/secondary-email")
|
|
async def request_secondary_email(
|
|
body: SecondaryEmailRequest,
|
|
user: CurrentUser = Depends(get_current_user),
|
|
) -> dict:
|
|
email = body.email.lower()
|
|
async with plain_connection() as conn:
|
|
primary = await conn.fetchval("SELECT id FROM app_user WHERE email = $1", email)
|
|
if primary is not None:
|
|
raise app_error(
|
|
409,
|
|
"DUPLICATE",
|
|
"Denne adressen er allerede en konto sin hovedadresse. Bruk "
|
|
"\"Slå sammen med en annen konto\" lenger ned for å slå de to "
|
|
"kontoene sammen i stedet.",
|
|
)
|
|
existing_secondary = await conn.fetchval(
|
|
"SELECT user_id::text FROM user_secondary_email WHERE email = $1", email
|
|
)
|
|
if existing_secondary is not None:
|
|
code = "DUPLICATE" if existing_secondary != user.user_id else "ALREADY_ADDED"
|
|
raise app_error(
|
|
409,
|
|
code,
|
|
"Denne adressen er allerede lagt til"
|
|
+ ("på kontoen din." if existing_secondary == user.user_id else " 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 secondary_email_token (user_id, email, token_hash, expires_at) VALUES ($1, $2, $3, $4)",
|
|
user.user_id,
|
|
email,
|
|
_hash_secret(raw_token),
|
|
expires_at,
|
|
)
|
|
|
|
if settings.DEV_LOG_MAGIC_LINKS:
|
|
print(f"[DEV] Sekundær e-post-lenke for {email}: {raw_token}", flush=True)
|
|
elif settings.SMTP_CONFIGURED:
|
|
try:
|
|
await send_secondary_email_verification(email, raw_token)
|
|
except Exception:
|
|
traceback.print_exc()
|
|
return {"status": "ok"}
|
|
|
|
|
|
class SecondaryEmailConfirm(BaseModel):
|
|
token: str
|
|
|
|
|
|
@router.post("/secondary-email/confirm", response_model=Me)
|
|
async def confirm_secondary_email(body: SecondaryEmailConfirm) -> Me:
|
|
token_hash = _hash_secret(body.token)
|
|
async with plain_connection() as conn:
|
|
token_row = await conn.fetchrow(
|
|
"""
|
|
UPDATE secondary_email_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, email::text AS email
|
|
""",
|
|
token_hash,
|
|
)
|
|
if token_row is None:
|
|
raise app_error(401, "INVALID_SECONDARY_EMAIL_LINK", "Lenken er ugyldig, brukt eller utløpt.")
|
|
|
|
# Re-sjekk begge unikhetsbetingelsene -- adressen kan ha blitt tatt
|
|
# (som hovedadresse ELLER som en annen kontos sekundæradresse) i
|
|
# tiden MELLOM forespørsel og bekreftelse.
|
|
taken_primary = await conn.fetchval("SELECT id FROM app_user WHERE email = $1", token_row["email"])
|
|
if taken_primary is not None:
|
|
raise app_error(409, "DUPLICATE", "Denne adressen ble tatt i bruk av en konto i mellomtiden.")
|
|
taken_secondary = await conn.fetchval(
|
|
"SELECT user_id FROM user_secondary_email WHERE email = $1", token_row["email"]
|
|
)
|
|
if taken_secondary is not None:
|
|
raise app_error(409, "DUPLICATE", "Denne adressen ble lagt til av en annen konto i mellomtiden.")
|
|
|
|
async with translate_db_errors():
|
|
await conn.execute(
|
|
"INSERT INTO user_secondary_email (user_id, email) VALUES ($1, $2)",
|
|
token_row["user_id"],
|
|
token_row["email"],
|
|
)
|
|
return await me(CurrentUser(user_id=token_row["user_id"]))
|
|
|
|
|
|
@router.delete("/secondary-email/{secondary_email_id}", status_code=204)
|
|
async def delete_secondary_email(
|
|
secondary_email_id: str,
|
|
user: CurrentUser = Depends(get_current_user),
|
|
) -> None:
|
|
async with plain_connection() as conn:
|
|
row = await conn.fetchrow(
|
|
"SELECT user_id::text AS user_id FROM user_secondary_email WHERE id = $1", secondary_email_id
|
|
)
|
|
if row is None:
|
|
raise app_error(404, "NOT_FOUND", "Adressen finnes ikke.")
|
|
if row["user_id"] != user.user_id:
|
|
raise app_error(403, "NOT_OWNER", "Denne adressen tilhører ikke deg.")
|
|
await conn.execute("DELETE FROM user_secondary_email WHERE id = $1", secondary_email_id)
|