inital commit, very early alpha stage
This commit is contained in:
321
backend/app/routers/auth.py
Normal file
321
backend/app/routers/auth.py
Normal file
@@ -0,0 +1,321 @@
|
||||
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)
|
||||
Reference in New Issue
Block a user