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
|
|
@ -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]:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue