Files
comiaunicaty/backend/app/routers/auth.py

322 lines
15 KiB
Python

from __future__ import annotations
from datetime import timedelta
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.core.config import get_settings
from app.core.security import hash_token, short_code, token_urlsafe, utc_now
from app.db.base import get_db
from app.models import (
DeviceLinkCode,
Group,
HomeDevice,
HomeProfile,
Member,
MemberDevice,
MemberInvite,
RecoveryMethod,
)
from app.schemas import DeviceLinkCodeIn, DeviceLinkComplete, DeviceLinkStart, InviteClaim, PasskeyStub, RecoveryConsume, RecoveryRequest
from app.services.auth import (
CurrentContext,
audit,
create_session,
ensure_home_profile,
get_current_context,
get_members_for_context,
get_optional_context,
set_session_cookies,
)
from app.services.passkeys import passkey_provider
from app.services.serializers import device_dict, group_dict, member_dict, profile_dict
router = APIRouter(prefix="/api", tags=["auth"])
def _invite_or_404(db: Session, raw_token: str) -> MemberInvite:
invite = db.scalar(select(MemberInvite).where(MemberInvite.token_hash == hash_token(raw_token)))
now = utc_now()
expired = invite and invite.expires_at and (invite.expires_at < (now if invite.expires_at.tzinfo else now.replace(tzinfo=None)))
if not invite or invite.revoked_at or expired or invite.use_count >= invite.max_uses:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail={"error": {"code": "invite_unavailable", "message": "This invite is no longer available.", "details": {}}},
)
return invite
@router.get("/health")
def health() -> dict:
return {"ok": True, "name": get_settings().app_name}
@router.get("/me")
def me(ctx: CurrentContext = Depends(get_optional_context), db: Session = Depends(get_db)) -> dict:
memberships = []
for member in get_members_for_context(db, ctx):
group = member.group
memberships.append({"member": member_dict(member), "group": group_dict(group)})
return {
"authenticated": ctx.authenticated,
"profile": profile_dict(ctx.home_profile, ctx.member),
"member": member_dict(ctx.member) if ctx.member else None,
"memberships": memberships,
"dev_mode": get_settings().dev_mode,
}
@router.post("/auth/dev/demo-session")
def dev_demo_session(response: Response, request: Request, db: Session = Depends(get_db)) -> dict:
settings = get_settings()
if not settings.dev_mode:
raise HTTPException(status_code=404, detail={"error": {"code": "not_found", "message": "Not found.", "details": {}}})
profile = db.scalar(select(HomeProfile).where(HomeProfile.primary_display_name == "Anna Müller"))
if not profile:
raise HTTPException(status_code=409, detail={"error": {"code": "seed_missing", "message": "Run python -m app.db.seed first.", "details": {}}})
device = HomeDevice(
home_profile_id=profile.id,
label="Demo browser",
user_agent_summary=request.headers.get("user-agent", "")[:255],
trust_level="verified",
)
db.add(device)
session, csrf_token = create_session(db, home_profile=profile, home_device=device)
audit(db, action="dev_demo_session", resource_type="home_profile", resource_id=profile.id)
db.commit()
set_session_cookies(response, session, csrf_token)
return {"profile": profile_dict(profile), "csrf_token": csrf_token}
@router.get("/join/{token}/preview")
def invite_preview(token: str, db: Session = Depends(get_db)) -> dict:
invite = _invite_or_404(db, token)
group = invite and db.get(Group, invite.group_id)
if invite.member_id:
member = db.get(Member, invite.member_id)
if member and member.status == "invited":
member.status = "opened"
invite.opened_count += 1
from app.models import Announcement, Event
from app.services.serializers import announcement_dict, event_dict
announcements = [
announcement_dict(item, group)
for item in db.scalars(select(Announcement).where(Announcement.group_id == group.id, Announcement.official.is_(True)).limit(3)).all()
]
events = [event_dict(item, group) for item in db.scalars(select(Event).where(Event.group_id == group.id).order_by(Event.starts_at).limit(3)).all()]
db.commit()
return {
"group": group_dict(group),
"invite": {"label": invite.label, "expires_at": invite.expires_at.isoformat() if invite.expires_at else None, "role": invite.permission_role},
"preview": {"announcements": announcements, "events": events},
}
@router.post("/auth/invite/{token}/claim")
def claim_invite(token: str, payload: InviteClaim, response: Response, request: Request, db: Session = Depends(get_db)) -> dict:
invite = _invite_or_404(db, token)
group = db.get(Group, invite.group_id)
member = db.get(Member, invite.member_id) if invite.member_id else None
if member is None:
member = Member(
group_id=group.id,
display_name=payload.display_name,
role=invite.permission_role,
status="joined",
joined_at=utc_now(),
last_seen_at=utc_now(),
)
db.add(member)
db.flush()
else:
member.display_name = payload.display_name
member.role = invite.permission_role if member.role == "guest" else member.role
member.status = "joined"
member.joined_at = member.joined_at or utc_now()
invite.use_count += 1
if invite.use_count >= invite.max_uses:
invite.consumed_at = utc_now()
device = MemberDevice(
member_id=member.id,
label=payload.device_label,
user_agent_summary=request.headers.get("user-agent", "")[:255],
trust_level="claimed_browser",
)
db.add(device)
session, csrf_token = create_session(db, home_profile=member.home_profile, member=member, member_device=device)
audit(db, action="invite_claimed", resource_type="invite", resource_id=invite.id, details={"group_id": group.id})
db.commit()
set_session_cookies(response, session, csrf_token)
return {
"member": member_dict(member),
"group": group_dict(group),
"csrf_token": csrf_token,
"next_steps": ["save_access", "enable_notifications"],
}
@router.post("/auth/recovery/request")
def recovery_request(payload: RecoveryRequest, ctx: CurrentContext = Depends(get_current_context), db: Session = Depends(get_db)) -> dict:
profile = ensure_home_profile(db, ctx)
raw = token_urlsafe(32)
method = RecoveryMethod(
home_profile_id=profile.id,
kind="email",
value_hash=hash_token(payload.email.lower()),
display_hint=payload.email[:2] + "***" + payload.email[payload.email.rfind("@") :],
recovery_token_hash=hash_token(raw),
recovery_expires_at=utc_now() + timedelta(minutes=30),
)
db.add(method)
audit(db, ctx=ctx, action="recovery_requested", resource_type="home_profile", resource_id=profile.id)
db.commit()
result = {"ok": True, "message": "Check your email for a recovery link."}
if get_settings().dev_mode:
result.update({"dev_code": raw, "dev_link": f"{get_settings().frontend_origin}/me?recover={raw}"})
print(f"[dev recovery] {payload.email}: {raw}")
return result
@router.post("/auth/recovery/consume")
def recovery_consume(payload: RecoveryConsume, response: Response, request: Request, db: Session = Depends(get_db)) -> dict:
method = db.scalar(select(RecoveryMethod).where(RecoveryMethod.recovery_token_hash == hash_token(payload.recovery_code)))
now = utc_now()
expired = method and method.recovery_expires_at and method.recovery_expires_at < (
now if method.recovery_expires_at.tzinfo else now.replace(tzinfo=None)
)
if not method or method.revoked_at or expired:
raise HTTPException(status_code=400, detail={"error": {"code": "recovery_invalid", "message": "This recovery link has expired.", "details": {}}})
profile = db.get(HomeProfile, method.home_profile_id)
method.verified_at = method.verified_at or utc_now()
method.recovery_token_hash = None
device = HomeDevice(
home_profile_id=profile.id,
label=payload.device_label,
user_agent_summary=request.headers.get("user-agent", "")[:255],
trust_level="verified",
)
db.add(device)
session, csrf_token = create_session(db, home_profile=profile, home_device=device)
audit(db, action="recovery_consumed", resource_type="home_profile", resource_id=profile.id)
db.commit()
set_session_cookies(response, session, csrf_token)
return {"profile": profile_dict(profile), "csrf_token": csrf_token}
@router.post("/auth/device-link/start")
def device_link_start(payload: DeviceLinkStart, request: Request, db: Session = Depends(get_db)) -> dict:
code = short_code()
pending = DeviceLinkCode(
code_hash=hash_token(code),
requested_device_label=payload.device_label,
requested_user_agent=request.headers.get("user-agent", "")[:255],
expires_at=utc_now() + timedelta(minutes=10),
)
db.add(pending)
db.commit()
return {"pairing_id": pending.id, "code": code, "expires_at": pending.expires_at.isoformat()}
@router.post("/auth/device-link/approve")
def device_link_approve(payload: DeviceLinkCodeIn, ctx: CurrentContext = Depends(get_current_context), db: Session = Depends(get_db)) -> dict:
pending = db.scalar(select(DeviceLinkCode).where(DeviceLinkCode.code_hash == hash_token(payload.code.upper().replace(" ", ""))))
now = utc_now()
expired = pending and pending.expires_at < (now if pending.expires_at.tzinfo else now.replace(tzinfo=None))
if not pending or expired or pending.completed_at:
raise HTTPException(status_code=400, detail={"error": {"code": "pairing_invalid", "message": "That link code is not active.", "details": {}}})
pending.approved_by_home_profile_id = ctx.home_profile.id if ctx.home_profile else None
pending.approved_by_member_id = ctx.member.id if ctx.member else None
pending.approved_at = utc_now()
audit(db, ctx=ctx, action="device_link_approved", resource_type="device_link", resource_id=pending.id)
db.commit()
return {"ok": True, "device_label": pending.requested_device_label}
@router.post("/auth/device-link/complete")
def device_link_complete(payload: DeviceLinkComplete, response: Response, request: Request, db: Session = Depends(get_db)) -> dict:
pending = db.scalar(select(DeviceLinkCode).where(DeviceLinkCode.code_hash == hash_token(payload.code.upper().replace(" ", ""))))
now = utc_now()
expired = pending and pending.expires_at < (now if pending.expires_at.tzinfo else now.replace(tzinfo=None))
if not pending or expired or not pending.approved_at or pending.completed_at:
raise HTTPException(status_code=400, detail={"error": {"code": "pairing_not_ready", "message": "This link code has not been approved yet.", "details": {}}})
profile = db.get(HomeProfile, pending.approved_by_home_profile_id) if pending.approved_by_home_profile_id else None
member = db.get(Member, pending.approved_by_member_id) if pending.approved_by_member_id else None
if profile:
device = HomeDevice(
home_profile_id=profile.id,
label=payload.device_label or pending.requested_device_label,
user_agent_summary=request.headers.get("user-agent", "")[:255],
trust_level="claimed_browser",
)
db.add(device)
session, csrf_token = create_session(db, home_profile=profile, home_device=device)
elif member:
member_device = MemberDevice(
member_id=member.id,
label=payload.device_label or pending.requested_device_label,
user_agent_summary=request.headers.get("user-agent", "")[:255],
trust_level="claimed_browser",
)
db.add(member_device)
session, csrf_token = create_session(db, member=member, member_device=member_device)
else:
raise HTTPException(status_code=400, detail={"error": {"code": "pairing_invalid", "message": "No approving device was found.", "details": {}}})
pending.completed_at = utc_now()
audit(db, action="device_link_completed", resource_type="device_link", resource_id=pending.id)
db.commit()
set_session_cookies(response, session, csrf_token)
return {"ok": True, "csrf_token": csrf_token}
@router.get("/me/devices")
def devices(ctx: CurrentContext = Depends(get_current_context), db: Session = Depends(get_db)) -> dict:
items = []
current_id = ctx.home_device.id if ctx.home_device else (ctx.member_device.id if ctx.member_device else None)
if ctx.home_profile:
items.extend([device_dict(item, current_id) for item in db.scalars(select(HomeDevice).where(HomeDevice.home_profile_id == ctx.home_profile.id)).all()])
member_ids = [member.id for member in get_members_for_context(db, ctx)]
if member_ids:
items.extend([device_dict(item, current_id) for item in db.scalars(select(MemberDevice).where(MemberDevice.member_id.in_(member_ids))).all()])
elif ctx.member:
items.extend([device_dict(item, current_id) for item in db.scalars(select(MemberDevice).where(MemberDevice.member_id == ctx.member.id)).all()])
return {"devices": items}
@router.delete("/me/devices/{device_id}")
def revoke_device(device_id: str, ctx: CurrentContext = Depends(get_current_context), db: Session = Depends(get_db)) -> dict:
device = None
if ctx.home_profile:
device = db.scalar(select(HomeDevice).where(HomeDevice.id == device_id, HomeDevice.home_profile_id == ctx.home_profile.id))
if not device and ctx.member:
device = db.scalar(select(MemberDevice).where(MemberDevice.id == device_id, MemberDevice.member_id == ctx.member.id))
if not device:
raise HTTPException(status_code=404, detail={"error": {"code": "not_found", "message": "Device not found.", "details": {}}})
device.revoked_at = utc_now()
audit(db, ctx=ctx, action="device_revoked", resource_type="device", resource_id=device_id)
db.commit()
return {"ok": True}
@router.post("/auth/passkeys/register/options")
def passkey_register_options(payload: PasskeyStub) -> dict:
return passkey_provider.registration_options(payload.display_name)
@router.post("/auth/passkeys/register/verify")
def passkey_register_verify(payload: dict) -> dict:
return passkey_provider.verify_registration(payload)
@router.post("/auth/passkeys/login/options")
def passkey_login_options() -> dict:
return passkey_provider.login_options()
@router.post("/auth/passkeys/login/verify")
def passkey_login_verify(payload: dict) -> dict:
return passkey_provider.verify_login(payload)