136 lines
5.8 KiB
Python
136 lines
5.8 KiB
Python
|
|
"""
|
||
|
|
Kontosammenslåing ("Del 2" av flere-e-postadresser, ADR-080) -- selvbetjent:
|
||
|
|
initiativtakeren (en aktiv økt) ber om å slå en ANNEN, egen konto sammen med
|
||
|
|
den de er logget inn på. Krever bevis for eierskap av BEGGE kontoer: den
|
||
|
|
aktive økten beviser eierskap av initiativtakerens konto, en lenke sendt til
|
||
|
|
MÅL-kontoens e-post (samme token-i-lenke-mønster som ADR-032 sin e-postbytte)
|
||
|
|
beviser eierskap av den andre.
|
||
|
|
|
||
|
|
Selve beregningen/utførelsen ligger i app/account_merge.py (ren, uten
|
||
|
|
FastAPI-avhengigheter) -- denne filen er kun HTTP-laget: validering,
|
||
|
|
token-håndtering, e-postutsending.
|
||
|
|
"""
|
||
|
|
|
||
|
|
import secrets
|
||
|
|
import traceback
|
||
|
|
from datetime import datetime, timedelta, timezone
|
||
|
|
|
||
|
|
from fastapi import APIRouter, Depends
|
||
|
|
from pydantic import BaseModel, EmailStr
|
||
|
|
|
||
|
|
from ..account_merge import MergePreview, MergeResult, compute_merge_preview, execute_merge
|
||
|
|
from ..auth import CurrentUser, get_current_user, resolve_user_id_by_email
|
||
|
|
from ..config import settings
|
||
|
|
from ..db import plain_connection
|
||
|
|
from ..email import send_account_merge_confirmation
|
||
|
|
from ..errors import app_error
|
||
|
|
from .auth import _hash_secret
|
||
|
|
|
||
|
|
router = APIRouter(prefix="/auth/account-merge", tags=["account-merge"])
|
||
|
|
|
||
|
|
|
||
|
|
class AccountMergeEmailRequest(BaseModel):
|
||
|
|
email: EmailStr
|
||
|
|
|
||
|
|
|
||
|
|
async def _resolve_target_or_error(conn, requester_id: str, email: str) -> str:
|
||
|
|
target_id = await resolve_user_id_by_email(conn, email)
|
||
|
|
if target_id is None:
|
||
|
|
raise app_error(404, "NOT_FOUND", "Fant ingen konto med denne e-posten.")
|
||
|
|
if target_id == requester_id:
|
||
|
|
raise app_error(400, "VALIDATION_FAILED", "Dette er allerede din egen konto.")
|
||
|
|
return target_id
|
||
|
|
|
||
|
|
|
||
|
|
@router.post("/preview", response_model=MergePreview)
|
||
|
|
async def preview_account_merge(
|
||
|
|
body: AccountMergeEmailRequest, user: CurrentUser = Depends(get_current_user)
|
||
|
|
) -> MergePreview:
|
||
|
|
async with plain_connection() as conn:
|
||
|
|
target_id = await _resolve_target_or_error(conn, user.user_id, body.email.lower())
|
||
|
|
return await compute_merge_preview(user.user_id, target_id)
|
||
|
|
|
||
|
|
|
||
|
|
@router.post("/request")
|
||
|
|
async def request_account_merge(
|
||
|
|
body: AccountMergeEmailRequest, user: CurrentUser = Depends(get_current_user)
|
||
|
|
) -> dict:
|
||
|
|
email = body.email.lower()
|
||
|
|
async with plain_connection() as conn:
|
||
|
|
target_id = await _resolve_target_or_error(conn, user.user_id, email)
|
||
|
|
target_row = await conn.fetchrow(
|
||
|
|
"SELECT email::text AS email, display_name FROM app_user WHERE id = $1", target_id
|
||
|
|
)
|
||
|
|
initiator_row = await conn.fetchrow(
|
||
|
|
"SELECT display_name, email::text AS email FROM app_user WHERE id = $1", user.user_id
|
||
|
|
)
|
||
|
|
|
||
|
|
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 account_merge_token (initiator_user_id, target_user_id, token_hash, expires_at) "
|
||
|
|
"VALUES ($1, $2, $3, $4)",
|
||
|
|
user.user_id,
|
||
|
|
target_id,
|
||
|
|
_hash_secret(raw_token),
|
||
|
|
expires_at,
|
||
|
|
)
|
||
|
|
|
||
|
|
# Lenken sendes til MÅL-e-posten som faktisk ble oppgitt (kan være
|
||
|
|
# taperens primær- ELLER en av dens sekundæradresser) -- beviser
|
||
|
|
# eierskap av akkurat DEN adressen, tilstrekkelig siden begge allerede
|
||
|
|
# er verifiserte adresser på taper-kontoen.
|
||
|
|
if settings.DEV_LOG_MAGIC_LINKS:
|
||
|
|
print(f"[DEV] Kontosammenslåing-lenke for {email}: {raw_token}", flush=True)
|
||
|
|
elif settings.SMTP_CONFIGURED:
|
||
|
|
try:
|
||
|
|
await send_account_merge_confirmation(
|
||
|
|
email, raw_token, initiator_row["display_name"], initiator_row["email"] or ""
|
||
|
|
)
|
||
|
|
except Exception:
|
||
|
|
traceback.print_exc()
|
||
|
|
return {"status": "ok", "detail": "Hvis e-posten tilhører en annen konto, er en bekreftelseslenke sendt."}
|
||
|
|
|
||
|
|
|
||
|
|
@router.get("/token/{token}", response_model=MergePreview)
|
||
|
|
async def get_account_merge_token(token: str) -> MergePreview:
|
||
|
|
"""Validerer token (IKKE konsumert/utløpt) og returnerer en FERSK
|
||
|
|
forhåndsvisning -- konsumerer IKKE token, kun /confirm gjør det. Ingen
|
||
|
|
sesjon påkrevd (samme som e-postbytte-bekreftelse, kan åpnes på en
|
||
|
|
annen enhet enn den som ba om sammenslåingen)."""
|
||
|
|
token_hash = _hash_secret(token)
|
||
|
|
async with plain_connection() as conn:
|
||
|
|
token_row = await conn.fetchrow(
|
||
|
|
"SELECT initiator_user_id::text AS initiator_user_id, target_user_id::text AS target_user_id "
|
||
|
|
"FROM account_merge_token WHERE token_hash = $1 AND consumed_at IS NULL AND expires_at > now()",
|
||
|
|
token_hash,
|
||
|
|
)
|
||
|
|
if token_row is None:
|
||
|
|
raise app_error(401, "INVALID_MERGE_LINK", "Lenken er ugyldig, brukt eller utløpt.")
|
||
|
|
return await compute_merge_preview(token_row["initiator_user_id"], token_row["target_user_id"])
|
||
|
|
|
||
|
|
|
||
|
|
class AccountMergeConfirm(BaseModel):
|
||
|
|
token: str
|
||
|
|
|
||
|
|
|
||
|
|
@router.post("/confirm", response_model=MergeResult)
|
||
|
|
async def confirm_account_merge(body: AccountMergeConfirm) -> MergeResult:
|
||
|
|
token_hash = _hash_secret(body.token)
|
||
|
|
async with plain_connection() as conn:
|
||
|
|
# Atomisk forbruk: ÉN setning, ikke les-så-sjekk-så-skriv -- to
|
||
|
|
# samtidige bekreftelser av samme lenke kan da ikke begge lykkes
|
||
|
|
# (samme prinsipp som magic-link/e-postbytte).
|
||
|
|
token_row = await conn.fetchrow(
|
||
|
|
"""
|
||
|
|
UPDATE account_merge_token
|
||
|
|
SET consumed_at = now()
|
||
|
|
WHERE token_hash = $1 AND consumed_at IS NULL AND expires_at > now()
|
||
|
|
RETURNING initiator_user_id::text AS initiator_user_id, target_user_id::text AS target_user_id
|
||
|
|
""",
|
||
|
|
token_hash,
|
||
|
|
)
|
||
|
|
if token_row is None:
|
||
|
|
raise app_error(401, "INVALID_MERGE_LINK", "Lenken er ugyldig, brukt eller utløpt.")
|
||
|
|
return await execute_merge(token_row["initiator_user_id"], token_row["target_user_id"])
|