diff --git a/passkey/fastapi/api.py b/passkey/fastapi/api.py index 6054113..ea50c58 100644 --- a/passkey/fastapi/api.py +++ b/passkey/fastapi/api.py @@ -1,10 +1,8 @@ import logging from contextlib import suppress from datetime import datetime, timedelta, timezone -from uuid import UUID from fastapi import ( - Body, Cookie, Depends, FastAPI, @@ -21,8 +19,6 @@ from passkey.util import frontend, useragent from .. import aaguid from ..authsession import ( EXPIRES, - delete_credential, - expires, get_reset, get_session, refresh_session_token, @@ -30,14 +26,16 @@ from ..authsession import ( ) from ..globals import db from ..globals import passkey as global_passkey -from ..util import hostutil, passphrase, permutil, tokens -from ..util.tokens import decode_session_key, encode_session_key, session_key -from . import authz, session +from ..util import hostutil, passphrase, permutil +from ..util.tokens import encode_session_key, session_key +from . import authz, session, user bearer_auth = HTTPBearer(auto_error=True) app = FastAPI() +app.mount("/user", user.app) + @app.exception_handler(HTTPException) async def http_exception_handler(_request: Request, exc: HTTPException): @@ -361,28 +359,6 @@ async def api_user_info( } -@app.put("/user/display-name") -async def user_update_display_name( - request: Request, - response: Response, - payload: dict = Body(...), - auth=Cookie(None, alias="__Host-auth"), -): - if not auth: - raise HTTPException(status_code=401, detail="Authentication Required") - try: - s = await get_session(auth, host=request.headers.get("host")) - except ValueError as e: - raise HTTPException(status_code=401, detail="Session expired") from e - new_name = (payload.get("display_name") or "").strip() - if not new_name: - raise HTTPException(status_code=400, detail="display_name required") - if len(new_name) > 64: - raise HTTPException(status_code=400, detail="display_name too long") - await db.instance.update_user_display_name(s.user_uuid, new_name) - return {"status": "ok"} - - @app.post("/logout") async def api_logout( request: Request, response: Response, auth=Cookie(None, alias="__Host-auth") @@ -399,53 +375,6 @@ async def api_logout( return {"message": "Logged out successfully"} -@app.post("/user/logout-all") -async def api_logout_all( - request: Request, response: Response, auth=Cookie(None, alias="__Host-auth") -): - if not auth: - return {"message": "Already logged out"} - try: - s = await get_session(auth, host=request.headers.get("host")) - except ValueError: - raise HTTPException(status_code=401, detail="Session expired") - await db.instance.delete_sessions_for_user(s.user_uuid) - session.clear_session_cookie(response) - return {"message": "Logged out from all hosts"} - - -@app.delete("/user/session/{session_id}") -async def api_delete_session( - request: Request, - response: Response, - session_id: str, - auth=Cookie(None, alias="__Host-auth"), -): - if not auth: - raise HTTPException(status_code=401, detail="Authentication Required") - try: - current_session = await get_session(auth, host=request.headers.get("host")) - except ValueError as exc: - raise HTTPException(status_code=401, detail="Session expired") from exc - - try: - target_key = decode_session_key(session_id) - except ValueError as exc: - raise HTTPException( - status_code=400, detail="Invalid session identifier" - ) from exc - - target_session = await db.instance.get_session(target_key) - if not target_session or target_session.user_uuid != current_session.user_uuid: - raise HTTPException(status_code=404, detail="Session not found") - - await db.instance.delete_session(target_key) - current_terminated = target_key == session_key(auth) - if current_terminated: - session.clear_session_cookie(response) # explicit because 200 - return {"status": "ok", "current_session_terminated": current_terminated} - - @app.post("/set-session") async def api_set_session( request: Request, response: Response, auth=Depends(bearer_auth) @@ -456,49 +385,3 @@ async def api_set_session( "message": "Session cookie set successfully", "user_uuid": str(user.user_uuid), } - - -@app.delete("/user/credential/{uuid}") -async def api_delete_credential( - request: Request, - response: Response, - uuid: UUID, - auth: str = Cookie(None, alias="__Host-auth"), -): - try: - await delete_credential(uuid, auth, host=request.headers.get("host")) - except ValueError as e: - raise HTTPException(status_code=401, detail="Session expired") from e - return {"message": "Credential deleted successfully"} - - -@app.post("/user/create-link") -async def api_create_link( - request: Request, - response: Response, - auth=Cookie(None, alias="__Host-auth"), -): - try: - s = await get_session(auth, host=request.headers.get("host")) - except ValueError as e: - raise HTTPException(status_code=401, detail="Session expired") from e - token = passphrase.generate() - expiry = expires() - await db.instance.create_reset_token( - user_uuid=s.user_uuid, - key=tokens.reset_key(token), - expiry=expiry, - token_type="device addition", - ) - url = hostutil.reset_link_url( - token, request.url.scheme, request.headers.get("host") - ) - return { - "message": "Registration link generated successfully", - "url": url, - "expires": ( - expiry.astimezone(timezone.utc).isoformat().replace("+00:00", "Z") - if expiry.tzinfo - else expiry.replace(tzinfo=timezone.utc).isoformat().replace("+00:00", "Z") - ), - } diff --git a/passkey/fastapi/user.py b/passkey/fastapi/user.py new file mode 100644 index 0000000..9aa81b9 --- /dev/null +++ b/passkey/fastapi/user.py @@ -0,0 +1,138 @@ +from datetime import timezone +from uuid import UUID + +from fastapi import ( + Body, + Cookie, + FastAPI, + HTTPException, + Request, + Response, +) + +from ..authsession import ( + delete_credential, + expires, + get_session, +) +from ..globals import db +from ..util import hostutil, passphrase, tokens +from ..util.tokens import decode_session_key, session_key +from . import session + +app = FastAPI() + + +@app.put("/display-name") +async def user_update_display_name( + request: Request, + response: Response, + payload: dict = Body(...), + auth=Cookie(None, alias="__Host-auth"), +): + if not auth: + raise HTTPException(status_code=401, detail="Authentication Required") + try: + s = await get_session(auth, host=request.headers.get("host")) + except ValueError as e: + raise HTTPException(status_code=401, detail="Session expired") from e + new_name = (payload.get("display_name") or "").strip() + if not new_name: + raise HTTPException(status_code=400, detail="display_name required") + if len(new_name) > 64: + raise HTTPException(status_code=400, detail="display_name too long") + await db.instance.update_user_display_name(s.user_uuid, new_name) + return {"status": "ok"} + + +@app.post("/logout-all") +async def api_logout_all( + request: Request, response: Response, auth=Cookie(None, alias="__Host-auth") +): + if not auth: + return {"message": "Already logged out"} + try: + s = await get_session(auth, host=request.headers.get("host")) + except ValueError: + raise HTTPException(status_code=401, detail="Session expired") + await db.instance.delete_sessions_for_user(s.user_uuid) + session.clear_session_cookie(response) + return {"message": "Logged out from all hosts"} + + +@app.delete("/session/{session_id}") +async def api_delete_session( + request: Request, + response: Response, + session_id: str, + auth=Cookie(None, alias="__Host-auth"), +): + if not auth: + raise HTTPException(status_code=401, detail="Authentication Required") + try: + current_session = await get_session(auth, host=request.headers.get("host")) + except ValueError as exc: + raise HTTPException(status_code=401, detail="Session expired") from exc + + try: + target_key = decode_session_key(session_id) + except ValueError as exc: + raise HTTPException( + status_code=400, detail="Invalid session identifier" + ) from exc + + target_session = await db.instance.get_session(target_key) + if not target_session or target_session.user_uuid != current_session.user_uuid: + raise HTTPException(status_code=404, detail="Session not found") + + await db.instance.delete_session(target_key) + current_terminated = target_key == session_key(auth) + if current_terminated: + session.clear_session_cookie(response) # explicit because 200 + return {"status": "ok", "current_session_terminated": current_terminated} + + +@app.delete("/credential/{uuid}") +async def api_delete_credential( + request: Request, + response: Response, + uuid: UUID, + auth: str = Cookie(None, alias="__Host-auth"), +): + try: + await delete_credential(uuid, auth, host=request.headers.get("host")) + except ValueError as e: + raise HTTPException(status_code=401, detail="Session expired") from e + return {"message": "Credential deleted successfully"} + + +@app.post("/create-link") +async def api_create_link( + request: Request, + response: Response, + auth=Cookie(None, alias="__Host-auth"), +): + try: + s = await get_session(auth, host=request.headers.get("host")) + except ValueError as e: + raise HTTPException(status_code=401, detail="Session expired") from e + token = passphrase.generate() + expiry = expires() + await db.instance.create_reset_token( + user_uuid=s.user_uuid, + key=tokens.reset_key(token), + expiry=expiry, + token_type="device addition", + ) + url = hostutil.reset_link_url( + token, request.url.scheme, request.headers.get("host") + ) + return { + "message": "Registration link generated successfully", + "url": url, + "expires": ( + expiry.astimezone(timezone.utc).isoformat().replace("+00:00", "Z") + if expiry.tzinfo + else expiry.replace(tzinfo=timezone.utc).isoformat().replace("+00:00", "Z") + ), + }