feat: email-first login with allow-check and access forward

This commit is contained in:
Jonas Miederer 2026-09-12 11:20:55 +02:00
parent 82f7ff8b02
commit b533549e45
8 changed files with 224 additions and 21 deletions

View file

@ -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]: