322 lines
15 KiB
Python
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)
|