feat: email-first login with allow-check and access forward
This commit is contained in:
parent
82f7ff8b02
commit
b533549e45
8 changed files with 224 additions and 21 deletions
|
|
@ -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
|
Vite dev `server.watch` polling enabled (Windows bind-mount events often
|
||||||
never reach the Linux watcher → stale modules otherwise).
|
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)
|
## 1. Locked decisions (do not re-ask)
|
||||||
|
|
||||||
* Auth: SMTP magic-link, 15-min single-use tokens (`secrets.token_urlsafe`,
|
* Auth: SMTP magic-link, 15-min single-use tokens (`secrets.token_urlsafe`,
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,8 @@ from .schemas import (
|
||||||
AccessRequestRead,
|
AccessRequestRead,
|
||||||
AdminBootstrapRequest,
|
AdminBootstrapRequest,
|
||||||
MagicConsumeRequest,
|
MagicConsumeRequest,
|
||||||
|
MagicRequestRead,
|
||||||
|
MagicRequestRequest,
|
||||||
MeRead,
|
MeRead,
|
||||||
MessageRead,
|
MessageRead,
|
||||||
)
|
)
|
||||||
|
|
@ -120,6 +122,26 @@ def reject_request(
|
||||||
return _read_request(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)
|
@auth_router.post("/magic/consume", response_model=MeRead)
|
||||||
def consume_magic(
|
def consume_magic(
|
||||||
data: MagicConsumeRequest,
|
data: MagicConsumeRequest,
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,15 @@ class MagicConsumeRequest(BaseModel):
|
||||||
token: str = Field(min_length=1, max_length=200)
|
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):
|
class MeRead(BaseModel):
|
||||||
id: UUID
|
id: UUID
|
||||||
email: str
|
email: str
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,8 @@ SESSION_TTL = timedelta(days=30)
|
||||||
|
|
||||||
# Public request endpoint: at most this many requests per email per hour.
|
# Public request endpoint: at most this many requests per email per hour.
|
||||||
REQUESTS_PER_EMAIL_PER_HOUR = 5
|
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):
|
class AuthError(Exception):
|
||||||
|
|
@ -164,6 +166,39 @@ def issue_magic_token(session: Session, user: User) -> str:
|
||||||
return token
|
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(
|
def approve_access_request(
|
||||||
session: Session, request_id: UUID, decided_by: str
|
session: Session, request_id: UUID, decided_by: str
|
||||||
) -> tuple[AccessRequest, User, str]:
|
) -> tuple[AccessRequest, User, str]:
|
||||||
|
|
|
||||||
|
|
@ -268,3 +268,51 @@ async def test_approve_survives_delivery_failure(client, session, monkeypatch):
|
||||||
body = approved.json()
|
body = approved.json()
|
||||||
assert body["request"]["status"] == "approved"
|
assert body["request"]["status"] == "approved"
|
||||||
assert body["email_sent"] is False
|
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
|
||||||
|
|
|
||||||
|
|
@ -193,6 +193,11 @@ export const api = {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({ token }),
|
body: JSON.stringify({ token }),
|
||||||
}),
|
}),
|
||||||
|
requestMagicLink: (email: string) =>
|
||||||
|
req<{ message: string; email_sent: boolean }>("/api/auth/magic/request", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ email }),
|
||||||
|
}),
|
||||||
logout: () =>
|
logout: () =>
|
||||||
req<{ message: string }>("/api/auth/logout", { method: "POST" }),
|
req<{ message: string }>("/api/auth/logout", { method: "POST" }),
|
||||||
requestAccess: (name: string, email: string, reason: string) =>
|
requestAccess: (name: string, email: string, reason: string) =>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { useSearchParams } from "react-router-dom";
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
Button,
|
Button,
|
||||||
|
|
@ -13,8 +14,9 @@ import { IconAlertCircle, IconCheck } from "@tabler/icons-react";
|
||||||
import { ApiError, api } from "../api";
|
import { ApiError, api } from "../api";
|
||||||
|
|
||||||
export default function AccessPage() {
|
export default function AccessPage() {
|
||||||
|
const [params] = useSearchParams();
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
const [email, setEmail] = useState("");
|
const [email, setEmail] = useState(params.get("email") ?? "");
|
||||||
const [reason, setReason] = useState("");
|
const [reason, setReason] = useState("");
|
||||||
const [done, setDone] = useState(false);
|
const [done, setDone] = useState(false);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,36 @@
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Link, Navigate, useSearchParams } from "react-router-dom";
|
import { Link, Navigate, useNavigate, useSearchParams } from "react-router-dom";
|
||||||
import { Alert, Anchor, Card, Center, Loader, Title } from "@mantine/core";
|
import {
|
||||||
import { IconAlertCircle } from "@tabler/icons-react";
|
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 { ApiError, api } from "../api";
|
||||||
import { useAuth } from "../auth";
|
import { useAuth } from "../auth";
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
const [params] = useSearchParams();
|
const [params] = useSearchParams();
|
||||||
|
const navigate = useNavigate();
|
||||||
const token = params.get("token") ?? "";
|
const token = params.get("token") ?? "";
|
||||||
const { me, refresh } = useAuth();
|
const { me, refresh } = useAuth();
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [sent, setSent] = useState(false);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [busy, setBusy] = useState(!!token);
|
const [busy, setBusy] = useState(!!token);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!token) return;
|
if (!token) {
|
||||||
|
setBusy(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
api
|
api
|
||||||
.consumeMagic(token)
|
.consumeMagic(token)
|
||||||
|
|
@ -28,6 +45,31 @@ export default function LoginPage() {
|
||||||
.finally(() => setBusy(false));
|
.finally(() => setBusy(false));
|
||||||
}, [token, refresh]);
|
}, [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 <Navigate to="/app/workflows" replace />;
|
if (me) return <Navigate to="/app/workflows" replace />;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -36,23 +78,55 @@ export default function LoginPage() {
|
||||||
<Title order={2} mb="md">
|
<Title order={2} mb="md">
|
||||||
Login
|
Login
|
||||||
</Title>
|
</Title>
|
||||||
{busy && (
|
{token ? (
|
||||||
<Center>
|
<>
|
||||||
<Loader />
|
{busy && (
|
||||||
</Center>
|
<Center>
|
||||||
)}
|
<Loader />
|
||||||
{error && (
|
</Center>
|
||||||
<Alert color="red" icon={<IconAlertCircle size={18} />}>
|
)}
|
||||||
{error}
|
{error && (
|
||||||
</Alert>
|
<Alert color="red" icon={<IconAlertCircle size={18} />}>
|
||||||
)}
|
{error}
|
||||||
{!token && !error && (
|
</Alert>
|
||||||
<Alert color="blue" title="Kein Link vorhanden">
|
)}
|
||||||
<Anchor component={Link} to="/access">
|
</>
|
||||||
Zugang anfragen
|
) : sent ? (
|
||||||
</Anchor>{" "}
|
<Alert color="green" icon={<IconCheck size={18} />} title="Unterwegs">
|
||||||
— der Login-Link kommt per E-Mail.
|
Falls die Adresse freigegeben ist, kommt der Login-Link per
|
||||||
|
E-Mail. Bitte auch den Spam-Ordner prüfen.
|
||||||
</Alert>
|
</Alert>
|
||||||
|
) : (
|
||||||
|
<form onSubmit={(e) => void onSubmit(e)}>
|
||||||
|
<Stack>
|
||||||
|
{error && (
|
||||||
|
<Alert color="red" icon={<IconAlertCircle size={18} />}>
|
||||||
|
{error}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
<TextInput
|
||||||
|
label="E-Mail"
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
required
|
||||||
|
maxLength={320}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
loading={busy}
|
||||||
|
leftSection={<IconMail size={16} />}
|
||||||
|
>
|
||||||
|
Login-Link anfordern
|
||||||
|
</Button>
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
Noch keinen Account?{" "}
|
||||||
|
<Anchor component={Link} to="/access">
|
||||||
|
Hier anfragen
|
||||||
|
</Anchor>
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
</form>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
</Center>
|
</Center>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue