129 lines
3.9 KiB
Python
129 lines
3.9 KiB
Python
"""
|
|
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
|
|
"""
|
|
|
|
from contextlib import asynccontextmanager
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
from uuid import UUID
|
|
|
|
import uuid7
|
|
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
|
|
from fastapi.responses import FileResponse
|
|
from fastapi.staticfiles import StaticFiles
|
|
|
|
from .db import Credential, User, db
|
|
from .passkey import Passkey
|
|
|
|
STATIC_DIR = Path(__file__).parent.parent / "static"
|
|
|
|
passkey = Passkey(
|
|
rp_id="localhost",
|
|
rp_name="Passkey Auth",
|
|
origin="http://localhost:8000",
|
|
)
|
|
|
|
|
|
@asynccontextmanager
|
|
async def lifespan(app: FastAPI):
|
|
await db.init_database()
|
|
yield
|
|
|
|
|
|
app = FastAPI(title="Passkey Auth", lifespan=lifespan)
|
|
|
|
|
|
@app.websocket("/ws/new_user_registration")
|
|
async def websocket_register_new(ws: WebSocket):
|
|
"""Register a new user and with a new passkey credential."""
|
|
await ws.accept()
|
|
try:
|
|
form = await ws.receive_json()
|
|
now = datetime.now()
|
|
user_id = uuid7.create(now).bytes
|
|
user_name = form["user_name"]
|
|
|
|
# Generate registration options and handle registration
|
|
credential, verified = await register_chat(ws, user_id, user_name)
|
|
|
|
# Store the user in the database
|
|
await db.create_user(User(user_id, user_name, now))
|
|
await db.store_credential(
|
|
Credential(
|
|
credential_id=credential.raw_id,
|
|
user_id=user_id,
|
|
aaguid=UUID(verified.aaguid),
|
|
public_key=verified.credential_public_key,
|
|
sign_count=verified.sign_count,
|
|
created_at=now,
|
|
)
|
|
)
|
|
await ws.send_json({"status": "success", "user_id": user_id.hex()})
|
|
except WebSocketDisconnect:
|
|
pass
|
|
|
|
|
|
async def register_chat(ws: WebSocket, user_id: bytes, user_name: str):
|
|
"""Generate registration options and send them to the client."""
|
|
options, challenge = passkey.reg_generate_options(
|
|
user_id=user_id,
|
|
user_name=user_name,
|
|
)
|
|
await ws.send_json(options)
|
|
# Wait for the client to use his authenticator to register
|
|
credential = passkey.reg_credential(await ws.receive_json())
|
|
verified_registration = passkey.reg_verify(credential, challenge)
|
|
return credential, verified_registration
|
|
|
|
|
|
@app.websocket("/ws/authenticate")
|
|
async def websocket_authenticate(ws: WebSocket):
|
|
await ws.accept()
|
|
try:
|
|
options, challenge = await passkey.auth_generate_options()
|
|
await ws.send_json(options)
|
|
# Wait for the client to use his authenticator to authenticate
|
|
credential = passkey.auth_credential(await ws.receive_json())
|
|
# Fetch from the database by credential ID
|
|
stored_cred = await db.get_credential_by_id(credential.raw_id)
|
|
# Verify the credential matches the stored data
|
|
_ = await passkey.auth_verify(credential, challenge, stored_cred)
|
|
await db.update_credential(stored_cred)
|
|
await ws.send_json({"status": "success"})
|
|
except WebSocketDisconnect:
|
|
pass
|
|
|
|
|
|
# Serve static files
|
|
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
|
|
|
|
|
|
@app.get("/")
|
|
async def get_index():
|
|
"""Serve the main HTML page"""
|
|
return FileResponse(STATIC_DIR / "index.html")
|
|
|
|
|
|
def main():
|
|
"""Entry point for the application"""
|
|
import uvicorn
|
|
|
|
uvicorn.run(
|
|
"passkeyauth.main:app",
|
|
host="0.0.0.0",
|
|
port=8000,
|
|
reload=True,
|
|
log_level="info",
|
|
)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|