A lot of cleanup, restructuring project directory.
This commit is contained in:
234
passkey/fastapi/api_handlers.py
Normal file
234
passkey/fastapi/api_handlers.py
Normal file
@@ -0,0 +1,234 @@
|
||||
"""
|
||||
API endpoints for user management and session handling.
|
||||
|
||||
This module contains all the HTTP API endpoints for:
|
||||
- User information retrieval
|
||||
- User credentials management
|
||||
- Session token validation and refresh
|
||||
- Login/logout functionality
|
||||
"""
|
||||
|
||||
from fastapi import Request, Response
|
||||
|
||||
from .. import aaguid
|
||||
from ..db import sql
|
||||
from ..util.jwt import refresh_session_token, validate_session_token
|
||||
from .session_manager import (
|
||||
clear_session_cookie,
|
||||
get_current_user,
|
||||
get_session_token_from_bearer,
|
||||
get_session_token_from_cookie,
|
||||
set_session_cookie,
|
||||
)
|
||||
|
||||
|
||||
async def get_user_info(request: Request) -> dict:
|
||||
"""Get user information from session cookie."""
|
||||
try:
|
||||
user = await get_current_user(request)
|
||||
if not user:
|
||||
return {"error": "Not authenticated"}
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"user": {
|
||||
"user_id": str(user.user_id),
|
||||
"user_name": user.user_name,
|
||||
"created_at": user.created_at.isoformat() if user.created_at else None,
|
||||
"last_seen": user.last_seen.isoformat() if user.last_seen else None,
|
||||
"visits": user.visits,
|
||||
},
|
||||
}
|
||||
except Exception as e:
|
||||
return {"error": f"Failed to get user info: {str(e)}"}
|
||||
|
||||
|
||||
async def get_user_credentials(request: Request) -> dict:
|
||||
"""Get all credentials for a user using session cookie."""
|
||||
try:
|
||||
user = await get_current_user(request)
|
||||
if not user:
|
||||
return {"error": "Not authenticated"}
|
||||
|
||||
# Get current session credential ID
|
||||
current_credential_id = None
|
||||
session_token = get_session_token_from_cookie(request)
|
||||
if session_token:
|
||||
token_data = validate_session_token(session_token)
|
||||
if token_data:
|
||||
current_credential_id = token_data.get("credential_id")
|
||||
|
||||
# Get all credentials for the user
|
||||
credential_ids = await sql.get_user_credentials(user.user_id)
|
||||
|
||||
credentials = []
|
||||
user_aaguids = set()
|
||||
|
||||
for cred_id in credential_ids:
|
||||
stored_cred = await sql.get_credential_by_id(cred_id)
|
||||
|
||||
# Convert AAGUID to string format
|
||||
aaguid_str = str(stored_cred.aaguid)
|
||||
user_aaguids.add(aaguid_str)
|
||||
|
||||
# Check if this is the current session credential
|
||||
is_current_session = current_credential_id == stored_cred.credential_id
|
||||
|
||||
credentials.append(
|
||||
{
|
||||
"credential_id": stored_cred.credential_id.hex(),
|
||||
"aaguid": aaguid_str,
|
||||
"created_at": stored_cred.created_at.isoformat(),
|
||||
"last_used": stored_cred.last_used.isoformat()
|
||||
if stored_cred.last_used
|
||||
else None,
|
||||
"last_verified": stored_cred.last_verified.isoformat()
|
||||
if stored_cred.last_verified
|
||||
else None,
|
||||
"sign_count": stored_cred.sign_count,
|
||||
"is_current_session": is_current_session,
|
||||
}
|
||||
)
|
||||
|
||||
# Get AAGUID information for only the AAGUIDs that the user has
|
||||
aaguid_info = aaguid.filter(user_aaguids)
|
||||
|
||||
# Sort credentials by creation date (earliest first, most recently created last)
|
||||
credentials.sort(key=lambda cred: cred["created_at"])
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"credentials": credentials,
|
||||
"aaguid_info": aaguid_info,
|
||||
}
|
||||
except Exception as e:
|
||||
return {"error": f"Failed to get credentials: {str(e)}"}
|
||||
|
||||
|
||||
async def refresh_token(request: Request, response: Response) -> dict:
|
||||
"""Refresh the session token."""
|
||||
try:
|
||||
session_token = get_session_token_from_cookie(request)
|
||||
if not session_token:
|
||||
return {"error": "No session token found"}
|
||||
|
||||
# Validate and refresh the token
|
||||
new_token = refresh_session_token(session_token)
|
||||
|
||||
if new_token:
|
||||
set_session_cookie(response, new_token)
|
||||
return {"status": "success", "refreshed": True}
|
||||
else:
|
||||
clear_session_cookie(response)
|
||||
return {"error": "Invalid or expired session token"}
|
||||
|
||||
except Exception as e:
|
||||
return {"error": f"Failed to refresh token: {str(e)}"}
|
||||
|
||||
|
||||
async def validate_token(request: Request) -> dict:
|
||||
"""Validate a session token and return user info."""
|
||||
try:
|
||||
session_token = get_session_token_from_cookie(request)
|
||||
if not session_token:
|
||||
return {"error": "No session token found"}
|
||||
|
||||
# Validate the session token
|
||||
token_data = validate_session_token(session_token)
|
||||
if not token_data:
|
||||
return {"error": "Invalid or expired session token"}
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"valid": True,
|
||||
"user_id": str(token_data["user_id"]),
|
||||
"credential_id": token_data["credential_id"].hex(),
|
||||
"issued_at": token_data["issued_at"],
|
||||
"expires_at": token_data["expires_at"],
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
return {"error": f"Failed to validate token: {str(e)}"}
|
||||
|
||||
|
||||
async def logout(response: Response) -> dict:
|
||||
"""Log out the current user by clearing the session cookie."""
|
||||
clear_session_cookie(response)
|
||||
return {"status": "success", "message": "Logged out successfully"}
|
||||
|
||||
|
||||
async def set_session(request: Request, response: Response) -> dict:
|
||||
"""Set session cookie using JWT token from request body or Authorization header."""
|
||||
try:
|
||||
session_token = await get_session_token_from_bearer(request)
|
||||
|
||||
if not session_token:
|
||||
return {"error": "No session token provided"}
|
||||
|
||||
# Validate the session token
|
||||
token_data = validate_session_token(session_token)
|
||||
if not token_data:
|
||||
return {"error": "Invalid or expired session token"}
|
||||
|
||||
# Set the HTTP-only cookie
|
||||
set_session_cookie(response, session_token)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"message": "Session cookie set successfully",
|
||||
"user_id": str(token_data["user_id"]),
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
return {"error": f"Failed to set session: {str(e)}"}
|
||||
|
||||
|
||||
async def delete_credential(request: Request) -> dict:
|
||||
"""Delete a specific credential for the current user."""
|
||||
try:
|
||||
user = await get_current_user(request)
|
||||
if not user:
|
||||
return {"error": "Not authenticated"}
|
||||
|
||||
# Get the credential ID from the request body
|
||||
try:
|
||||
body = await request.json()
|
||||
credential_id = body.get("credential_id")
|
||||
if not credential_id:
|
||||
return {"error": "credential_id is required"}
|
||||
except Exception:
|
||||
return {"error": "Invalid request body"}
|
||||
|
||||
# Convert credential_id from hex string to bytes
|
||||
try:
|
||||
credential_id_bytes = bytes.fromhex(credential_id)
|
||||
except ValueError:
|
||||
return {"error": "Invalid credential_id format"}
|
||||
|
||||
# First, verify the credential belongs to the current user
|
||||
try:
|
||||
stored_cred = await sql.get_credential_by_id(credential_id_bytes)
|
||||
if stored_cred.user_id != user.user_id:
|
||||
return {"error": "Credential not found or access denied"}
|
||||
except ValueError:
|
||||
return {"error": "Credential not found"}
|
||||
|
||||
# Check if this is the current session credential
|
||||
session_token = get_session_token_from_cookie(request)
|
||||
if session_token:
|
||||
token_data = validate_session_token(session_token)
|
||||
if token_data and token_data.get("credential_id") == credential_id_bytes:
|
||||
return {"error": "Cannot delete current session credential"}
|
||||
|
||||
# Get user's remaining credentials count
|
||||
remaining_credentials = await sql.get_user_credentials(user.user_id)
|
||||
if len(remaining_credentials) <= 1:
|
||||
return {"error": "Cannot delete last remaining credential"}
|
||||
|
||||
# Delete the credential
|
||||
await sql.delete_user_credential(credential_id_bytes)
|
||||
|
||||
return {"status": "success", "message": "Credential deleted successfully"}
|
||||
|
||||
except Exception as e:
|
||||
return {"error": f"Failed to delete credential: {str(e)}"}
|
||||
186
passkey/fastapi/main.py
Normal file
186
passkey/fastapi/main.py
Normal file
@@ -0,0 +1,186 @@
|
||||
"""
|
||||
Minimal FastAPI WebAuthn server with WebSocket support for passkey registration and authentication.
|
||||
|
||||
This module provides a simple WebAuthn implementation that:
|
||||
- Uses WebSocket for real-time communication
|
||||
- Supports Resident Keys (discoverable credentials) for passwordless authentication
|
||||
- Maintains challenges locally per connection
|
||||
- Uses async SQLite database for persistent storage of users and credentials
|
||||
- Enables true passwordless authentication where users don't need to enter a user_name
|
||||
"""
|
||||
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import (
|
||||
FastAPI,
|
||||
Request,
|
||||
Response,
|
||||
)
|
||||
from fastapi import (
|
||||
Path as FastAPIPath,
|
||||
)
|
||||
from fastapi.responses import (
|
||||
FileResponse,
|
||||
RedirectResponse,
|
||||
)
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from ..db import sql
|
||||
from .api_handlers import (
|
||||
delete_credential,
|
||||
get_user_credentials,
|
||||
get_user_info,
|
||||
logout,
|
||||
refresh_token,
|
||||
set_session,
|
||||
validate_token,
|
||||
)
|
||||
from .reset_handlers import create_device_addition_link, validate_device_addition_token
|
||||
from .ws_handlers import ws_app
|
||||
|
||||
STATIC_DIR = Path(__file__).parent.parent / "frontend-build"
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
await sql.init_database()
|
||||
yield
|
||||
|
||||
|
||||
app = FastAPI(title="Passkey Auth", lifespan=lifespan)
|
||||
|
||||
# Mount the WebSocket subapp
|
||||
app.mount("/auth/ws", ws_app)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@app.get("/auth/user-info")
|
||||
async def api_get_user_info(request: Request):
|
||||
"""Get user information from session cookie."""
|
||||
return await get_user_info(request)
|
||||
|
||||
|
||||
@app.get("/auth/user-credentials")
|
||||
async def api_get_user_credentials(request: Request):
|
||||
"""Get all credentials for a user using session cookie."""
|
||||
return await get_user_credentials(request)
|
||||
|
||||
|
||||
@app.post("/auth/refresh-token")
|
||||
async def api_refresh_token(request: Request, response: Response):
|
||||
"""Refresh the session token."""
|
||||
return await refresh_token(request, response)
|
||||
|
||||
|
||||
@app.get("/auth/validate-token")
|
||||
async def api_validate_token(request: Request):
|
||||
"""Validate a session token and return user info."""
|
||||
return await validate_token(request)
|
||||
|
||||
|
||||
@app.get("/auth/forward-auth")
|
||||
async def forward_authentication(request: Request):
|
||||
"""A verification endpoint to use with Caddy forward_auth or Nginx auth_request."""
|
||||
result = await validate_token(request)
|
||||
if result.get("status") != "success":
|
||||
# Serve the index.html of the authentication app if not authenticated
|
||||
return FileResponse(
|
||||
STATIC_DIR / "index.html",
|
||||
status_code=401,
|
||||
headers={"www-authenticate": "PrivateToken"},
|
||||
)
|
||||
|
||||
# If authenticated, return a success response
|
||||
return Response(
|
||||
status_code=204,
|
||||
headers={"x-auth-user-id": result["user_id"]},
|
||||
)
|
||||
|
||||
|
||||
@app.post("/auth/logout")
|
||||
async def api_logout(response: Response):
|
||||
"""Log out the current user by clearing the session cookie."""
|
||||
return await logout(response)
|
||||
|
||||
|
||||
@app.post("/auth/set-session")
|
||||
async def api_set_session(request: Request, response: Response):
|
||||
"""Set session cookie using JWT token from request body or Authorization header."""
|
||||
return await set_session(request, response)
|
||||
|
||||
|
||||
@app.post("/auth/delete-credential")
|
||||
async def api_delete_credential(request: Request):
|
||||
"""Delete a specific credential for the current user."""
|
||||
return await delete_credential(request)
|
||||
|
||||
|
||||
@app.post("/auth/create-device-link")
|
||||
async def api_create_device_link(request: Request):
|
||||
"""Create a device addition link for the authenticated user."""
|
||||
return await create_device_addition_link(request)
|
||||
|
||||
|
||||
@app.post("/auth/validate-device-token")
|
||||
async def api_validate_device_token(request: Request):
|
||||
"""Validate a device addition token."""
|
||||
return await validate_device_addition_token(request)
|
||||
|
||||
|
||||
@app.get("/auth/{passphrase}")
|
||||
async def reset_authentication(
|
||||
passphrase: str = FastAPIPath(pattern=r"^\w+(\.\w+){2,}$"),
|
||||
):
|
||||
response = RedirectResponse(url="/", status_code=303)
|
||||
response.set_cookie(
|
||||
key="auth-token",
|
||||
value=passphrase,
|
||||
httponly=False,
|
||||
secure=True,
|
||||
samesite="strict",
|
||||
max_age=2,
|
||||
)
|
||||
return response
|
||||
|
||||
|
||||
# Serve static files
|
||||
app.mount(
|
||||
"/auth/assets", StaticFiles(directory=STATIC_DIR / "assets"), name="static assets"
|
||||
)
|
||||
|
||||
|
||||
@app.get("/auth")
|
||||
async def redirect_to_index():
|
||||
"""Serve the main authentication app."""
|
||||
return FileResponse(STATIC_DIR / "index.html")
|
||||
|
||||
|
||||
# Catch-all route for SPA - serve index.html for all non-API routes
|
||||
@app.get("/{path:path}")
|
||||
async def spa_handler(request: Request, path: str):
|
||||
"""Serve the Vue SPA for all routes (except API and static)"""
|
||||
if "text/html" not in request.headers.get("accept", ""):
|
||||
return Response(content="Not Found", status_code=404)
|
||||
return FileResponse(STATIC_DIR / "index.html")
|
||||
|
||||
|
||||
def main():
|
||||
"""Entry point for the application"""
|
||||
import uvicorn
|
||||
|
||||
uvicorn.run(
|
||||
"passkey.fastapi.main:app",
|
||||
host="localhost",
|
||||
port=4401,
|
||||
reload=True,
|
||||
log_level="info",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
logging.basicConfig(level=logging.DEBUG)
|
||||
main()
|
||||
103
passkey/fastapi/reset_handlers.py
Normal file
103
passkey/fastapi/reset_handlers.py
Normal file
@@ -0,0 +1,103 @@
|
||||
"""
|
||||
Device addition API handlers for WebAuthn authentication.
|
||||
|
||||
This module provides endpoints for authenticated users to:
|
||||
- Generate device addition links with human-readable tokens
|
||||
- Validate device addition tokens
|
||||
- Add new passkeys to existing accounts via tokens
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from fastapi import Request
|
||||
|
||||
from ..db import sql
|
||||
from ..util.passphrase import generate
|
||||
from .session_manager import get_current_user
|
||||
|
||||
|
||||
async def create_device_addition_link(request: Request) -> dict:
|
||||
"""Create a device addition link for the authenticated user."""
|
||||
try:
|
||||
# Require authentication
|
||||
user = await get_current_user(request)
|
||||
if not user:
|
||||
return {"error": "Authentication required"}
|
||||
|
||||
# Generate a human-readable token
|
||||
token = generate(n=4, sep=".") # e.g., "able-ocean-forest-dawn"
|
||||
|
||||
# Create reset token in database
|
||||
await sql.create_reset_token(user.user_id, token)
|
||||
|
||||
# Generate the device addition link with pretty URL
|
||||
addition_link = f"{request.headers.get('origin', '')}/auth/{token}"
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"message": "Device addition link generated successfully",
|
||||
"addition_link": addition_link,
|
||||
"expires_in_hours": 24,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
return {"error": f"Failed to create device addition link: {str(e)}"}
|
||||
|
||||
|
||||
async def validate_device_addition_token(request: Request) -> dict:
|
||||
"""Validate a device addition token and return user info."""
|
||||
try:
|
||||
body = await request.json()
|
||||
token = body.get("token")
|
||||
|
||||
if not token:
|
||||
return {"error": "Device addition token is required"}
|
||||
|
||||
# Get reset token
|
||||
reset_token = await sql.get_reset_token(token)
|
||||
if not reset_token:
|
||||
return {"error": "Invalid or expired device addition token"}
|
||||
|
||||
# Check if token is expired (24 hours)
|
||||
expiry_time = reset_token.created_at + timedelta(hours=24)
|
||||
if datetime.now() > expiry_time:
|
||||
return {"error": "Device addition token has expired"}
|
||||
|
||||
# Get user info
|
||||
user = await sql.get_user_by_id(reset_token.user_id)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"valid": True,
|
||||
"user_id": str(user.user_id),
|
||||
"user_name": user.user_name,
|
||||
"token": token,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
return {"error": f"Failed to validate device addition token: {str(e)}"}
|
||||
|
||||
|
||||
async def use_device_addition_token(token: str) -> dict:
|
||||
"""Delete a device addition token after successful use."""
|
||||
try:
|
||||
# Get reset token first to validate it exists and is not expired
|
||||
reset_token = await sql.get_reset_token(token)
|
||||
if not reset_token:
|
||||
return {"error": "Invalid or expired device addition token"}
|
||||
|
||||
# Check if token is expired (24 hours)
|
||||
expiry_time = reset_token.created_at + timedelta(hours=24)
|
||||
if datetime.now() > expiry_time:
|
||||
return {"error": "Device addition token has expired"}
|
||||
|
||||
# Delete the token (it's now used)
|
||||
await sql.delete_reset_token(token)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"message": "Device addition token used successfully",
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
return {"error": f"Failed to use device addition token: {str(e)}"}
|
||||
98
passkey/fastapi/session_manager.py
Normal file
98
passkey/fastapi/session_manager.py
Normal file
@@ -0,0 +1,98 @@
|
||||
"""
|
||||
Session management for WebAuthn authentication.
|
||||
|
||||
This module provides session management functionality including:
|
||||
- Getting current user from session cookies
|
||||
- Setting and clearing HTTP-only cookies
|
||||
- Session validation and token handling
|
||||
"""
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import Request, Response
|
||||
|
||||
from ..db.sql import User, get_user_by_id
|
||||
from ..util.jwt import validate_session_token
|
||||
|
||||
COOKIE_NAME = "auth"
|
||||
COOKIE_MAX_AGE = 86400 # 24 hours
|
||||
|
||||
|
||||
async def get_current_user(request: Request) -> User | None:
|
||||
"""Get the current user from the session cookie."""
|
||||
session_token = request.cookies.get(COOKIE_NAME)
|
||||
if not session_token:
|
||||
return None
|
||||
|
||||
token_data = validate_session_token(session_token)
|
||||
if not token_data:
|
||||
return None
|
||||
|
||||
try:
|
||||
user = await get_user_by_id(token_data["user_id"])
|
||||
return user
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def set_session_cookie(response: Response, session_token: str) -> None:
|
||||
"""Set the session token as an HTTP-only cookie."""
|
||||
response.set_cookie(
|
||||
key=COOKIE_NAME,
|
||||
value=session_token,
|
||||
max_age=COOKIE_MAX_AGE,
|
||||
httponly=True,
|
||||
secure=True,
|
||||
samesite="lax",
|
||||
)
|
||||
|
||||
|
||||
def clear_session_cookie(response: Response) -> None:
|
||||
"""Clear the session cookie."""
|
||||
response.delete_cookie(key=COOKIE_NAME)
|
||||
|
||||
|
||||
def get_session_token_from_cookie(request: Request) -> str | None:
|
||||
"""Extract session token from request cookies."""
|
||||
return request.cookies.get(COOKIE_NAME)
|
||||
|
||||
|
||||
async def validate_session_from_request(request: Request) -> dict | None:
|
||||
"""Validate session token from request and return token data."""
|
||||
session_token = get_session_token_from_cookie(request)
|
||||
if not session_token:
|
||||
return None
|
||||
|
||||
return validate_session_token(session_token)
|
||||
|
||||
|
||||
async def get_session_token_from_bearer(request: Request) -> str | None:
|
||||
"""Extract session token from Authorization header or request body."""
|
||||
# Try to get token from Authorization header first
|
||||
auth_header = request.headers.get("Authorization")
|
||||
if auth_header and auth_header.startswith("Bearer "):
|
||||
return auth_header.removeprefix("Bearer ")
|
||||
|
||||
|
||||
async def get_user_from_cookie_string(cookie_header: str) -> UUID | None:
|
||||
"""Parse cookie header and return user ID if valid session exists."""
|
||||
if not cookie_header:
|
||||
return None
|
||||
|
||||
# Parse cookies from header (simple implementation)
|
||||
cookies = {}
|
||||
for cookie in cookie_header.split(";"):
|
||||
cookie = cookie.strip()
|
||||
if "=" in cookie:
|
||||
name, value = cookie.split("=", 1)
|
||||
cookies[name] = value
|
||||
|
||||
session_token = cookies.get(COOKIE_NAME)
|
||||
if not session_token:
|
||||
return None
|
||||
|
||||
token_data = validate_session_token(session_token)
|
||||
if not token_data:
|
||||
return None
|
||||
|
||||
return token_data["user_id"]
|
||||
219
passkey/fastapi/ws_handlers.py
Normal file
219
passkey/fastapi/ws_handlers.py
Normal file
@@ -0,0 +1,219 @@
|
||||
"""
|
||||
WebSocket handlers for passkey authentication operations.
|
||||
|
||||
This module contains all WebSocket endpoints for:
|
||||
- User registration
|
||||
- Adding credentials to existing users
|
||||
- Device credential addition via token
|
||||
- Authentication
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from uuid import UUID
|
||||
|
||||
import uuid7
|
||||
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
|
||||
from webauthn.helpers.exceptions import InvalidAuthenticationResponse
|
||||
|
||||
from ..db import sql
|
||||
from ..db.sql import User
|
||||
from ..sansio import Passkey
|
||||
from ..util.jwt import create_session_token
|
||||
from .session_manager import get_user_from_cookie_string
|
||||
|
||||
# Create a FastAPI subapp for WebSocket endpoints
|
||||
ws_app = FastAPI()
|
||||
|
||||
# Initialize the passkey instance
|
||||
passkey = Passkey(
|
||||
rp_id="localhost",
|
||||
rp_name="Passkey Auth",
|
||||
)
|
||||
|
||||
|
||||
async def register_chat(
|
||||
ws: WebSocket,
|
||||
user_id: UUID,
|
||||
user_name: str,
|
||||
credential_ids: list[bytes] | None = None,
|
||||
origin: str | None = None,
|
||||
):
|
||||
"""Generate registration options and send them to the client."""
|
||||
options, challenge = passkey.reg_generate_options(
|
||||
user_id=user_id,
|
||||
user_name=user_name,
|
||||
credential_ids=credential_ids,
|
||||
origin=origin,
|
||||
)
|
||||
await ws.send_json(options)
|
||||
response = await ws.receive_json()
|
||||
return passkey.reg_verify(response, challenge, user_id, origin=origin)
|
||||
|
||||
|
||||
@ws_app.websocket("/register_new")
|
||||
async def websocket_register_new(ws: WebSocket, user_name: str):
|
||||
"""Register a new user and with a new passkey credential."""
|
||||
await ws.accept()
|
||||
origin = ws.headers.get("origin")
|
||||
try:
|
||||
user_id = uuid7.create()
|
||||
|
||||
# WebAuthn registration
|
||||
credential = await register_chat(ws, user_id, user_name, origin=origin)
|
||||
|
||||
# Store the user and credential in the database
|
||||
await sql.create_user_and_credential(
|
||||
User(user_id, user_name, created_at=datetime.now()),
|
||||
credential,
|
||||
)
|
||||
|
||||
# Create a session token for the new user
|
||||
session_token = create_session_token(user_id, credential.credential_id)
|
||||
|
||||
await ws.send_json(
|
||||
{
|
||||
"status": "success",
|
||||
"user_id": str(user_id),
|
||||
"session_token": session_token,
|
||||
}
|
||||
)
|
||||
except ValueError as e:
|
||||
await ws.send_json({"error": str(e)})
|
||||
except WebSocketDisconnect:
|
||||
pass
|
||||
except Exception:
|
||||
logging.exception("Internal Server Error")
|
||||
await ws.send_json({"error": "Internal Server Error"})
|
||||
|
||||
|
||||
@ws_app.websocket("/add_credential")
|
||||
async def websocket_register_add(ws: WebSocket):
|
||||
"""Register a new credential for an existing user."""
|
||||
await ws.accept()
|
||||
origin = ws.headers.get("origin")
|
||||
try:
|
||||
# Authenticate user via cookie
|
||||
cookie_header = ws.headers.get("cookie", "")
|
||||
user_id = await get_user_from_cookie_string(cookie_header)
|
||||
|
||||
if not user_id:
|
||||
await ws.send_json({"error": "Authentication required"})
|
||||
return
|
||||
|
||||
# Get user information to get the user_name
|
||||
user = await sql.get_user_by_id(user_id)
|
||||
user_name = user.user_name
|
||||
challenge_ids = await sql.get_user_credentials(user_id)
|
||||
|
||||
# WebAuthn registration
|
||||
credential = await register_chat(
|
||||
ws, user_id, user_name, challenge_ids, origin=origin
|
||||
)
|
||||
# Store the new credential in the database
|
||||
await sql.create_credential_for_user(credential)
|
||||
|
||||
await ws.send_json(
|
||||
{
|
||||
"status": "success",
|
||||
"user_id": str(user_id),
|
||||
"credential_id": credential.credential_id.hex(),
|
||||
"message": "New credential added successfully",
|
||||
}
|
||||
)
|
||||
except ValueError as e:
|
||||
await ws.send_json({"error": str(e)})
|
||||
except WebSocketDisconnect:
|
||||
pass
|
||||
except Exception:
|
||||
logging.exception("Internal Server Error")
|
||||
await ws.send_json({"error": "Internal Server Error"})
|
||||
|
||||
|
||||
@ws_app.websocket("/add_device_credential")
|
||||
async def websocket_add_device_credential(ws: WebSocket, token: str):
|
||||
"""Add a new credential for an existing user via device addition token."""
|
||||
await ws.accept()
|
||||
origin = ws.headers.get("origin")
|
||||
try:
|
||||
reset_token = await sql.get_reset_token(token)
|
||||
if not reset_token:
|
||||
await ws.send_json({"error": "Invalid or expired device addition token"})
|
||||
return
|
||||
|
||||
# Check if token is expired (24 hours)
|
||||
expiry_time = reset_token.created_at + timedelta(hours=24)
|
||||
if datetime.now() > expiry_time:
|
||||
await ws.send_json({"error": "Device addition token has expired"})
|
||||
return
|
||||
|
||||
# Get user information
|
||||
user = await sql.get_user_by_id(reset_token.user_id)
|
||||
|
||||
# WebAuthn registration
|
||||
# Fetch challenge IDs for the user
|
||||
challenge_ids = await sql.get_user_credentials(reset_token.user_id)
|
||||
|
||||
credential = await register_chat(
|
||||
ws, reset_token.user_id, user.user_name, challenge_ids, origin=origin
|
||||
)
|
||||
|
||||
# Store the new credential in the database
|
||||
await sql.create_credential_for_user(credential)
|
||||
|
||||
# Delete the device addition token (it's now used)
|
||||
await sql.delete_reset_token(token)
|
||||
|
||||
await ws.send_json(
|
||||
{
|
||||
"status": "success",
|
||||
"user_id": str(reset_token.user_id),
|
||||
"credential_id": credential.credential_id.hex(),
|
||||
"message": "New credential added successfully via device addition token",
|
||||
}
|
||||
)
|
||||
except ValueError as e:
|
||||
await ws.send_json({"error": str(e)})
|
||||
except WebSocketDisconnect:
|
||||
pass
|
||||
except Exception:
|
||||
logging.exception("Internal Server Error")
|
||||
await ws.send_json({"error": "Internal Server Error"})
|
||||
|
||||
|
||||
@ws_app.websocket("/authenticate")
|
||||
async def websocket_authenticate(ws: WebSocket):
|
||||
await ws.accept()
|
||||
origin = ws.headers.get("origin")
|
||||
try:
|
||||
options, challenge = passkey.auth_generate_options()
|
||||
await ws.send_json(options)
|
||||
# Wait for the client to use his authenticator to authenticate
|
||||
credential = passkey.auth_parse(await ws.receive_json())
|
||||
# Fetch from the database by credential ID
|
||||
stored_cred = await sql.get_credential_by_id(credential.raw_id)
|
||||
# Verify the credential matches the stored data
|
||||
passkey.auth_verify(credential, challenge, stored_cred, origin=origin)
|
||||
# Update both credential and user's last_seen timestamp
|
||||
await sql.login_user(stored_cred.user_id, stored_cred)
|
||||
|
||||
# Create a session token for the authenticated user
|
||||
session_token = create_session_token(
|
||||
stored_cred.user_id, stored_cred.credential_id
|
||||
)
|
||||
|
||||
await ws.send_json(
|
||||
{
|
||||
"status": "success",
|
||||
"user_id": str(stored_cred.user_id),
|
||||
"session_token": session_token,
|
||||
}
|
||||
)
|
||||
except (ValueError, InvalidAuthenticationResponse) as e:
|
||||
logging.exception("ValueError")
|
||||
await ws.send_json({"error": str(e)})
|
||||
except WebSocketDisconnect:
|
||||
pass
|
||||
except Exception:
|
||||
logging.exception("Internal Server Error")
|
||||
await ws.send_json({"error": "Internal Server Error"})
|
||||
Reference in New Issue
Block a user