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
610 lines
24 KiB
Python
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,
|
|
)
|