diff --git a/PLAN_NEXT.md b/PLAN_NEXT.md index f162c60..7261b30 100644 --- a/PLAN_NEXT.md +++ b/PLAN_NEXT.md @@ -127,6 +127,14 @@ workflows shot auto-selects `intake_form` to show the generated RJSF form. Vite dev `server.watch` polling enabled (Windows bind-mount events often never reach the Linux watcher → stale modules otherwise). +## 0.5 Email-first login (`POST /api/auth/magic/request`) + +`/login` without `?token=` shows an email form (+ "Hier anfragen" link). +Allowed = approved user row or `ADMIN_EMAILS` (admin auto-created on first +request); anything else → 403 and the frontend forwards to +`/access?email=…` (prefilled). Rate-limited (5/hour), token by mail only, +delivery failures never 500 (see §0.4 hardening). + ## 1. Locked decisions (do not re-ask) * Auth: SMTP magic-link, 15-min single-use tokens (`secrets.token_urlsafe`, diff --git a/backend/app/auth/api.py b/backend/app/auth/api.py index 153668b..b1f6c81 100644 --- a/backend/app/auth/api.py +++ b/backend/app/auth/api.py @@ -21,6 +21,8 @@ from .schemas import ( AccessRequestRead, AdminBootstrapRequest, MagicConsumeRequest, + MagicRequestRead, + MagicRequestRequest, MeRead, MessageRead, ) @@ -120,6 +122,26 @@ def reject_request( return _read_request(request) +@auth_router.post("/magic/request", response_model=MagicRequestRead) +def request_magic( + data: MagicRequestRequest, + session: Session = Depends(get_db), +): + """Email-first login: allowed emails get a magic link mailed. + + 403 means "not allowed" — the frontend forwards those to /access. + The token travels by mail only, never in the response. + """ + try: + user, token = service.request_magic_link(session, data.email) + except service.ForbiddenError as exc: + raise HTTPException(status_code=403, detail=str(exc)) from exc + except service.RateLimitedError as exc: + raise HTTPException(status_code=429, detail=str(exc)) from exc + email_sent = mail.send_magic_link(user.email, mail.magic_link(token)) + return MagicRequestRead(message="login link sent", email_sent=email_sent) + + @auth_router.post("/magic/consume", response_model=MeRead) def consume_magic( data: MagicConsumeRequest, diff --git a/backend/app/auth/schemas.py b/backend/app/auth/schemas.py index cee9e35..e7be2d1 100644 --- a/backend/app/auth/schemas.py +++ b/backend/app/auth/schemas.py @@ -33,6 +33,15 @@ class MagicConsumeRequest(BaseModel): token: str = Field(min_length=1, max_length=200) +class MagicRequestRequest(BaseModel): + email: str = Field(min_length=3, max_length=320) + + +class MagicRequestRead(BaseModel): + message: str + email_sent: bool + + class MeRead(BaseModel): id: UUID email: str diff --git a/backend/app/auth/service.py b/backend/app/auth/service.py index bf13261..ac78524 100644 --- a/backend/app/auth/service.py +++ b/backend/app/auth/service.py @@ -24,6 +24,8 @@ SESSION_TTL = timedelta(days=30) # Public request endpoint: at most this many requests per email per hour. REQUESTS_PER_EMAIL_PER_HOUR = 5 +# Same for magic-link requests (email bombing / token spam protection). +MAGIC_LINKS_PER_EMAIL_PER_HOUR = 5 class AuthError(Exception): @@ -164,6 +166,39 @@ def issue_magic_token(session: Session, user: User) -> str: return token +def recent_token_count(session: Session, user: User, hours: int = 1) -> int: + since = utcnow() - timedelta(hours=hours) + return session.exec( + select(func.count()) + .select_from(MagicLinkToken) + .where( + MagicLinkToken.user_id == user.id, + MagicLinkToken.created_at >= since, + ) + ).one() + + +def request_magic_link(session: Session, email: str) -> tuple[User, str]: + """Self-service login link for an allowed email. + + Allowed = approved before (has a User row) or listed in ADMIN_EMAILS + (admin user is created on first request). Anything else raises + ForbiddenError — the frontend forwards those to the access form. + Note: 403-vs-200 distinguishes registered emails by design here. + """ + email = normalize_email(email) + user = get_user_by_email(session, email) + if user is None: + if not is_admin_email(email): + raise ForbiddenError("email is not allowed to log in") + user = _get_or_create_user(session, "", email) + session.commit() + session.refresh(user) + if recent_token_count(session, user) >= MAGIC_LINKS_PER_EMAIL_PER_HOUR: + raise RateLimitedError("too many login links, try again later") + return user, issue_magic_token(session, user) + + def approve_access_request( session: Session, request_id: UUID, decided_by: str ) -> tuple[AccessRequest, User, str]: diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py index d29be8c..02bde95 100644 --- a/backend/tests/test_auth.py +++ b/backend/tests/test_auth.py @@ -268,3 +268,51 @@ async def test_approve_survives_delivery_failure(client, session, monkeypatch): body = approved.json() assert body["request"]["status"] == "approved" assert body["email_sent"] is False + + +async def test_magic_request_for_known_user(client, session): + _get_or_make_user(session, MEMBER) + response = await client.post( + "/api/auth/magic/request", json={"email": MEMBER} + ) + assert response.status_code == 200, response.text + body = response.json() + assert body["message"] == "login link sent" + assert body["email_sent"] is False # no SMTP in tests + assert "token" not in response.text + tokens = session.exec( + select(MagicLinkToken).where( + MagicLinkToken.user_id + == service.get_user_by_email(session, MEMBER).id + ) + ).all() + assert len(tokens) == 1 + + +async def test_magic_request_unknown_email_403(client): + response = await client.post( + "/api/auth/magic/request", json={"email": "stranger@example.com"} + ) + assert response.status_code == 403 + + +async def test_magic_request_admin_email_creates_admin(client, session): + response = await client.post( + "/api/auth/magic/request", json={"email": ADMIN} + ) + assert response.status_code == 200, response.text + user = service.get_user_by_email(session, ADMIN) + assert user is not None and user.role == "admin" + + +async def test_magic_request_rate_limit(client, session): + _get_or_make_user(session, MEMBER) + for _ in range(service.MAGIC_LINKS_PER_EMAIL_PER_HOUR): + response = await client.post( + "/api/auth/magic/request", json={"email": MEMBER} + ) + assert response.status_code == 200, response.text + limited = await client.post( + "/api/auth/magic/request", json={"email": MEMBER} + ) + assert limited.status_code == 429 diff --git a/frontend/src/api.ts b/frontend/src/api.ts index d913761..3746cd6 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -193,6 +193,11 @@ export const api = { method: "POST", body: JSON.stringify({ token }), }), + requestMagicLink: (email: string) => + req<{ message: string; email_sent: boolean }>("/api/auth/magic/request", { + method: "POST", + body: JSON.stringify({ email }), + }), logout: () => req<{ message: string }>("/api/auth/logout", { method: "POST" }), requestAccess: (name: string, email: string, reason: string) => diff --git a/frontend/src/pages/AccessPage.tsx b/frontend/src/pages/AccessPage.tsx index 8248d6f..23fbaf9 100644 --- a/frontend/src/pages/AccessPage.tsx +++ b/frontend/src/pages/AccessPage.tsx @@ -1,4 +1,5 @@ import { useState } from "react"; +import { useSearchParams } from "react-router-dom"; import { Alert, Button, @@ -13,8 +14,9 @@ import { IconAlertCircle, IconCheck } from "@tabler/icons-react"; import { ApiError, api } from "../api"; export default function AccessPage() { + const [params] = useSearchParams(); const [name, setName] = useState(""); - const [email, setEmail] = useState(""); + const [email, setEmail] = useState(params.get("email") ?? ""); const [reason, setReason] = useState(""); const [done, setDone] = useState(false); const [error, setError] = useState(""); diff --git a/frontend/src/pages/LoginPage.tsx b/frontend/src/pages/LoginPage.tsx index 5113ac9..b7557e0 100644 --- a/frontend/src/pages/LoginPage.tsx +++ b/frontend/src/pages/LoginPage.tsx @@ -1,19 +1,36 @@ import { useEffect, useState } from "react"; -import { Link, Navigate, useSearchParams } from "react-router-dom"; -import { Alert, Anchor, Card, Center, Loader, Title } from "@mantine/core"; -import { IconAlertCircle } from "@tabler/icons-react"; +import { Link, Navigate, useNavigate, useSearchParams } from "react-router-dom"; +import { + Alert, + Anchor, + Button, + Card, + Center, + Loader, + Stack, + Text, + TextInput, + Title, +} from "@mantine/core"; +import { IconAlertCircle, IconCheck, IconMail } from "@tabler/icons-react"; import { ApiError, api } from "../api"; import { useAuth } from "../auth"; export default function LoginPage() { const [params] = useSearchParams(); + const navigate = useNavigate(); const token = params.get("token") ?? ""; const { me, refresh } = useAuth(); + const [email, setEmail] = useState(""); + const [sent, setSent] = useState(false); const [error, setError] = useState(""); const [busy, setBusy] = useState(!!token); useEffect(() => { - if (!token) return; + if (!token) { + setBusy(false); + return; + } setBusy(true); api .consumeMagic(token) @@ -28,6 +45,31 @@ export default function LoginPage() { .finally(() => setBusy(false)); }, [token, refresh]); + async function onSubmit(e: React.FormEvent) { + e.preventDefault(); + setBusy(true); + setError(""); + try { + await api.requestMagicLink(email.trim()); + setSent(true); + } catch (err) { + if (err instanceof ApiError && err.status === 403) { + // Not allowed yet — forward to the access request form. + navigate(`/access?email=${encodeURIComponent(email.trim())}`); + return; + } + setError( + err instanceof ApiError + ? err.status === 429 + ? "Zu viele Links angefordert — bitte später erneut versuchen." + : `${err.status}: ${err.message}` + : String(err), + ); + } finally { + setBusy(false); + } + } + if (me) return ; return ( @@ -36,23 +78,55 @@ export default function LoginPage() { Login - {busy && ( -
- -
- )} - {error && ( - }> - {error} - - )} - {!token && !error && ( - - - Zugang anfragen - {" "} - — der Login-Link kommt per E-Mail. + {token ? ( + <> + {busy && ( +
+ +
+ )} + {error && ( + }> + {error} + + )} + + ) : sent ? ( + } title="Unterwegs"> + Falls die Adresse freigegeben ist, kommt der Login-Link per + E-Mail. Bitte auch den Spam-Ordner prüfen. + ) : ( +
void onSubmit(e)}> + + {error && ( + }> + {error} + + )} + setEmail(e.target.value)} + required + maxLength={320} + /> + + + Noch keinen Account?{" "} + + Hier anfragen + + + +
)}