feat: add authentication routes with session login, WebAuthn, and credential management
Implement Phase 4 auth routes: password login/logout, WebAuthn registration and authentication, magic link registration, and credential management pages with HTMX. Includes session middleware, Jinja2 templates, vendored HTMX, and last-credential guardrails. 120 tests passing.
This commit is contained in:
parent
f7ed2cf54d
commit
e15dcc4745
23 changed files with 1440 additions and 2 deletions
|
|
@ -1,11 +1,21 @@
|
|||
import secrets
|
||||
from collections.abc import AsyncIterator
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import aiosqlite
|
||||
from fastapi import FastAPI
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from starlette.middleware.sessions import SessionMiddleware
|
||||
|
||||
from fastapi_oidc_op.authn.password import PasswordService
|
||||
from fastapi_oidc_op.authn.routes import router as authn_router
|
||||
from fastapi_oidc_op.authn.webauthn import WebAuthnService
|
||||
from fastapi_oidc_op.config import Settings, StorageBackend
|
||||
from fastapi_oidc_op.invite.service import MagicLinkService
|
||||
from fastapi_oidc_op.manage.routes import router as manage_router
|
||||
from fastapi_oidc_op.store.sqlite.migrations import run_migrations
|
||||
from fastapi_oidc_op.store.sqlite.repositories import (
|
||||
SQLiteCredentialRepository,
|
||||
|
|
@ -13,7 +23,8 @@ from fastapi_oidc_op.store.sqlite.repositories import (
|
|||
SQLiteUserRepository,
|
||||
)
|
||||
|
||||
MIGRATIONS_DIR = Path(__file__).parent / "store" / "sqlite" / "migrations"
|
||||
PACKAGE_DIR = Path(__file__).parent
|
||||
MIGRATIONS_DIR = PACKAGE_DIR / "store" / "sqlite" / "migrations"
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
|
|
@ -30,6 +41,22 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
|||
app.state.user_repo = SQLiteUserRepository(db)
|
||||
app.state.credential_repo = SQLiteCredentialRepository(db)
|
||||
app.state.magic_link_repo = SQLiteMagicLinkRepository(db)
|
||||
|
||||
# Auth services
|
||||
app.state.password_service = PasswordService()
|
||||
|
||||
rp_id = urlparse(settings.issuer).hostname or "localhost"
|
||||
app.state.webauthn_service = WebAuthnService(
|
||||
rp_id=rp_id,
|
||||
rp_name=app.title,
|
||||
origin=settings.issuer,
|
||||
)
|
||||
|
||||
app.state.magic_link_service = MagicLinkService(
|
||||
repo=app.state.magic_link_repo,
|
||||
ttl=settings.invite_ttl,
|
||||
)
|
||||
|
||||
yield
|
||||
await db.close()
|
||||
else:
|
||||
|
|
@ -50,6 +77,20 @@ def create_app(settings: Settings | None = None) -> FastAPI:
|
|||
|
||||
app.state.settings = settings
|
||||
|
||||
# Session middleware
|
||||
session_secret = settings.session_secret or secrets.token_hex(32)
|
||||
app.add_middleware(SessionMiddleware, secret_key=session_secret) # type: ignore[arg-type]
|
||||
|
||||
# Templates
|
||||
app.state.templates = Jinja2Templates(directory=str(PACKAGE_DIR / "templates"))
|
||||
|
||||
# Static files
|
||||
app.mount("/static", StaticFiles(directory=str(PACKAGE_DIR / "static")), name="static")
|
||||
|
||||
# Routers
|
||||
app.include_router(authn_router)
|
||||
app.include_router(manage_router)
|
||||
|
||||
@app.get("/health")
|
||||
async def health() -> dict[str, str]:
|
||||
return {"status": "ok"}
|
||||
|
|
|
|||
154
src/fastapi_oidc_op/authn/routes.py
Normal file
154
src/fastapi_oidc_op/authn/routes.py
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
from fastapi import APIRouter, Form, Request, Response
|
||||
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
|
||||
from fido2.webauthn import (
|
||||
AttestedCredentialData,
|
||||
AuthenticationResponse,
|
||||
PublicKeyCredentialDescriptor,
|
||||
PublicKeyCredentialType,
|
||||
)
|
||||
|
||||
from fastapi_oidc_op.models import User
|
||||
from fastapi_oidc_op.userid import generate_unique_userid
|
||||
|
||||
router = APIRouter(tags=["authn"])
|
||||
|
||||
|
||||
@router.get("/login", response_class=HTMLResponse)
|
||||
async def login_page(request: Request) -> HTMLResponse:
|
||||
templates = request.app.state.templates
|
||||
return templates.TemplateResponse(request, "login.html")
|
||||
|
||||
|
||||
@router.post("/login/password", response_class=HTMLResponse)
|
||||
async def login_password(
|
||||
request: Request,
|
||||
username: str = Form(),
|
||||
password: str = Form(),
|
||||
) -> Response:
|
||||
user_repo = request.app.state.user_repo
|
||||
cred_repo = request.app.state.credential_repo
|
||||
password_service = request.app.state.password_service
|
||||
|
||||
error_html = '<div role="alert">Invalid username or password</div>'
|
||||
|
||||
user = await user_repo.get_by_username(username)
|
||||
if user is None:
|
||||
return HTMLResponse(error_html)
|
||||
|
||||
credential = await cred_repo.get_password_by_user(user.userid)
|
||||
if credential is None:
|
||||
return HTMLResponse(error_html)
|
||||
|
||||
if not password_service.verify(credential.password_hash, password):
|
||||
return HTMLResponse(error_html)
|
||||
|
||||
request.session["userid"] = user.userid
|
||||
request.session["username"] = user.username
|
||||
|
||||
response = Response()
|
||||
response.headers["HX-Redirect"] = "/manage/credentials"
|
||||
return response
|
||||
|
||||
|
||||
@router.post("/logout")
|
||||
async def logout(request: Request) -> Response:
|
||||
request.session.clear()
|
||||
response = Response()
|
||||
response.headers["HX-Redirect"] = "/login"
|
||||
return response
|
||||
|
||||
|
||||
@router.get("/register/{token}")
|
||||
async def register_magic_link(request: Request, token: str) -> Response:
|
||||
magic_link_service = request.app.state.magic_link_service
|
||||
user_repo = request.app.state.user_repo
|
||||
|
||||
link = await magic_link_service.validate(token)
|
||||
if link is None:
|
||||
return HTMLResponse("<p>Invalid or expired registration link.</p>", status_code=400)
|
||||
|
||||
userid = await generate_unique_userid(user_repo)
|
||||
user = User(userid=userid, username=link.username, groups=["users"])
|
||||
await user_repo.create(user)
|
||||
|
||||
await magic_link_service.mark_used(token)
|
||||
|
||||
request.session["userid"] = user.userid
|
||||
request.session["username"] = user.username
|
||||
|
||||
return RedirectResponse("/manage/credentials?setup=1", status_code=303)
|
||||
|
||||
|
||||
@router.post("/login/webauthn/begin")
|
||||
async def login_webauthn_begin(
|
||||
request: Request,
|
||||
username: str = Form(),
|
||||
) -> Response:
|
||||
user_repo = request.app.state.user_repo
|
||||
cred_repo = request.app.state.credential_repo
|
||||
webauthn_service = request.app.state.webauthn_service
|
||||
|
||||
error_html = '<div role="alert">Invalid username or password</div>'
|
||||
|
||||
user = await user_repo.get_by_username(username)
|
||||
if user is None:
|
||||
return HTMLResponse(error_html)
|
||||
|
||||
webauthn_creds = await cred_repo.get_webauthn_by_user(user.userid)
|
||||
if not webauthn_creds:
|
||||
return HTMLResponse(error_html)
|
||||
|
||||
descriptors = [
|
||||
PublicKeyCredentialDescriptor(type=PublicKeyCredentialType.PUBLIC_KEY, id=cred.credential_id)
|
||||
for cred in webauthn_creds
|
||||
]
|
||||
|
||||
options, state = webauthn_service.begin_authentication(credentials=descriptors)
|
||||
|
||||
request.session["webauthn_login_state"] = state
|
||||
request.session["webauthn_login_userid"] = user.userid
|
||||
return JSONResponse(options)
|
||||
|
||||
|
||||
@router.post("/login/webauthn/complete")
|
||||
async def login_webauthn_complete(request: Request) -> Response:
|
||||
webauthn_service = request.app.state.webauthn_service
|
||||
user_repo = request.app.state.user_repo
|
||||
cred_repo = request.app.state.credential_repo
|
||||
|
||||
state = request.session.pop("webauthn_login_state", None)
|
||||
userid = request.session.pop("webauthn_login_userid", None)
|
||||
|
||||
if state is None or userid is None:
|
||||
return HTMLResponse('<div role="alert">Authentication session expired</div>', status_code=400)
|
||||
|
||||
webauthn_creds = await cred_repo.get_webauthn_by_user(userid)
|
||||
credentials = [AttestedCredentialData(cred.public_key) for cred in webauthn_creds]
|
||||
|
||||
body = await request.json()
|
||||
|
||||
try:
|
||||
webauthn_service.complete_authentication(state, credentials, body)
|
||||
except Exception:
|
||||
return HTMLResponse('<div role="alert">Authentication failed</div>')
|
||||
|
||||
# Extract sign count from the response and update
|
||||
auth_response = AuthenticationResponse.from_dict(body)
|
||||
new_counter = auth_response.response.authenticator_data.counter
|
||||
matched_credential_id = auth_response.raw_id
|
||||
|
||||
stored = await cred_repo.get_webauthn_by_credential_id(matched_credential_id)
|
||||
if stored is not None:
|
||||
stored.sign_count = new_counter
|
||||
await cred_repo.update_webauthn(stored)
|
||||
|
||||
user = await user_repo.get_by_userid(userid)
|
||||
if user is None:
|
||||
return HTMLResponse('<div role="alert">User not found</div>', status_code=400)
|
||||
|
||||
request.session["userid"] = user.userid
|
||||
request.session["username"] = user.username
|
||||
|
||||
response = Response()
|
||||
response.headers["HX-Redirect"] = "/manage/credentials"
|
||||
return response
|
||||
|
|
@ -29,6 +29,9 @@ class Settings(BaseSettings):
|
|||
# Management RP
|
||||
manage_client_id: str = "manage-app"
|
||||
|
||||
# Session
|
||||
session_secret: str | None = None # If None, a random secret is generated per process
|
||||
|
||||
# Magic links
|
||||
invite_ttl: int = 86400 # seconds
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
from fastapi import Request
|
||||
from fastapi import HTTPException, Request
|
||||
|
||||
from fastapi_oidc_op.store.protocols import (
|
||||
CredentialRepository,
|
||||
|
|
@ -17,3 +17,24 @@ def get_credential_repo(request: Request) -> CredentialRepository:
|
|||
|
||||
def get_magic_link_repo(request: Request) -> MagicLinkRepository:
|
||||
return request.app.state.magic_link_repo
|
||||
|
||||
|
||||
def get_session_user(request: Request) -> tuple[str, str] | None:
|
||||
"""Extract (userid, username) from session, or None if not logged in."""
|
||||
userid = request.session.get("userid")
|
||||
username = request.session.get("username")
|
||||
if userid and username:
|
||||
return (userid, username)
|
||||
return None
|
||||
|
||||
|
||||
def require_session_user(request: Request) -> tuple[str, str]:
|
||||
"""Like get_session_user but raises HTTPException(401) if not logged in.
|
||||
|
||||
Routes that need a redirect-to-login behavior should catch this or
|
||||
use get_session_user and redirect manually.
|
||||
"""
|
||||
result = get_session_user(request)
|
||||
if result is None:
|
||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||
return result
|
||||
|
|
|
|||
166
src/fastapi_oidc_op/manage/routes.py
Normal file
166
src/fastapi_oidc_op/manage/routes.py
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
from base64 import urlsafe_b64decode
|
||||
|
||||
from fastapi import APIRouter, Form, Request, Response
|
||||
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
|
||||
from fido2.webauthn import PublicKeyCredentialDescriptor, PublicKeyCredentialType
|
||||
|
||||
from fastapi_oidc_op.dependencies import get_session_user
|
||||
from fastapi_oidc_op.models import PasswordCredential, WebAuthnCredential
|
||||
|
||||
router = APIRouter(prefix="/manage", tags=["manage"])
|
||||
|
||||
|
||||
async def _count_credentials(cred_repo: object, userid: str) -> int:
|
||||
"""Count total credentials (password + webauthn) for a user."""
|
||||
webauthn = await cred_repo.get_webauthn_by_user(userid) # type: ignore[union-attr]
|
||||
password = await cred_repo.get_password_by_user(userid) # type: ignore[union-attr]
|
||||
return len(webauthn) + (1 if password else 0)
|
||||
|
||||
|
||||
@router.get("/credentials", response_class=HTMLResponse)
|
||||
async def credentials_page(request: Request) -> Response:
|
||||
session_user = get_session_user(request)
|
||||
if session_user is None:
|
||||
return RedirectResponse("/login", status_code=303)
|
||||
|
||||
userid, username = session_user
|
||||
cred_repo = request.app.state.credential_repo
|
||||
|
||||
webauthn_credentials = await cred_repo.get_webauthn_by_user(userid)
|
||||
password_credential = await cred_repo.get_password_by_user(userid)
|
||||
setup = request.query_params.get("setup")
|
||||
|
||||
templates = request.app.state.templates
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"manage/credentials.html",
|
||||
{
|
||||
"username": username,
|
||||
"webauthn_credentials": webauthn_credentials,
|
||||
"has_password": password_credential is not None,
|
||||
"setup": setup,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/credentials/password", response_class=HTMLResponse)
|
||||
async def set_password(
|
||||
request: Request,
|
||||
password: str = Form(),
|
||||
confirm: str = Form(),
|
||||
) -> Response:
|
||||
session_user = get_session_user(request)
|
||||
if session_user is None:
|
||||
return RedirectResponse("/login", status_code=303)
|
||||
|
||||
userid, _username = session_user
|
||||
cred_repo = request.app.state.credential_repo
|
||||
password_service = request.app.state.password_service
|
||||
|
||||
if password != confirm:
|
||||
return HTMLResponse('<div role="alert">Passwords do not match</div>')
|
||||
|
||||
if len(password) < 8:
|
||||
return HTMLResponse('<div role="alert">Password must be at least 8 characters</div>')
|
||||
|
||||
password_hash = password_service.hash(password)
|
||||
|
||||
existing = await cred_repo.get_password_by_user(userid)
|
||||
if existing is not None:
|
||||
await cred_repo.delete_password(userid)
|
||||
|
||||
await cred_repo.create_password(PasswordCredential(user_id=userid, password_hash=password_hash))
|
||||
|
||||
return HTMLResponse('<div role="status">Password updated successfully</div>')
|
||||
|
||||
|
||||
@router.delete("/credentials/password", response_class=HTMLResponse)
|
||||
async def delete_password(request: Request) -> Response:
|
||||
session_user = get_session_user(request)
|
||||
if session_user is None:
|
||||
return RedirectResponse("/login", status_code=303)
|
||||
|
||||
userid, _username = session_user
|
||||
cred_repo = request.app.state.credential_repo
|
||||
|
||||
count = await _count_credentials(cred_repo, userid)
|
||||
if count <= 1:
|
||||
return HTMLResponse('<div role="alert">Cannot remove your last credential</div>')
|
||||
|
||||
await cred_repo.delete_password(userid)
|
||||
return HTMLResponse('<div role="status">Password removed</div>')
|
||||
|
||||
|
||||
@router.post("/credentials/webauthn/begin")
|
||||
async def webauthn_begin(request: Request) -> Response:
|
||||
session_user = get_session_user(request)
|
||||
if session_user is None:
|
||||
return RedirectResponse("/login", status_code=303)
|
||||
|
||||
userid, username = session_user
|
||||
cred_repo = request.app.state.credential_repo
|
||||
webauthn_service = request.app.state.webauthn_service
|
||||
|
||||
# Build exclude list from existing credentials
|
||||
existing = await cred_repo.get_webauthn_by_user(userid)
|
||||
descriptors = [
|
||||
PublicKeyCredentialDescriptor(type=PublicKeyCredentialType.PUBLIC_KEY, id=cred.credential_id)
|
||||
for cred in existing
|
||||
]
|
||||
|
||||
options, state = webauthn_service.begin_registration(
|
||||
user_id=userid.encode(),
|
||||
username=username,
|
||||
existing_credentials=descriptors,
|
||||
)
|
||||
|
||||
request.session["webauthn_register_state"] = state
|
||||
return JSONResponse(options)
|
||||
|
||||
|
||||
@router.post("/credentials/webauthn/complete")
|
||||
async def webauthn_complete(request: Request) -> Response:
|
||||
session_user = get_session_user(request)
|
||||
if session_user is None:
|
||||
return RedirectResponse("/login", status_code=303)
|
||||
|
||||
userid, _username = session_user
|
||||
cred_repo = request.app.state.credential_repo
|
||||
webauthn_service = request.app.state.webauthn_service
|
||||
|
||||
state = request.session.pop("webauthn_register_state", None)
|
||||
if state is None:
|
||||
return HTMLResponse('<div role="alert">Registration session expired</div>', status_code=400)
|
||||
|
||||
body = await request.json()
|
||||
result = webauthn_service.complete_registration(state, body)
|
||||
|
||||
cred = WebAuthnCredential(
|
||||
user_id=userid,
|
||||
credential_id=result.credential_data.credential_id,
|
||||
public_key=bytes(result.credential_data),
|
||||
)
|
||||
await cred_repo.create_webauthn(cred)
|
||||
|
||||
return HTMLResponse('<div role="status">Security key added</div>')
|
||||
|
||||
|
||||
@router.delete("/credentials/webauthn/{credential_id_b64}")
|
||||
async def delete_webauthn(request: Request, credential_id_b64: str) -> Response:
|
||||
session_user = get_session_user(request)
|
||||
if session_user is None:
|
||||
return RedirectResponse("/login", status_code=303)
|
||||
|
||||
userid, _username = session_user
|
||||
cred_repo = request.app.state.credential_repo
|
||||
|
||||
# Decode base64url credential_id (add padding if needed)
|
||||
padded = credential_id_b64 + "=" * (-len(credential_id_b64) % 4)
|
||||
credential_id = urlsafe_b64decode(padded)
|
||||
|
||||
count = await _count_credentials(cred_repo, userid)
|
||||
if count <= 1:
|
||||
return HTMLResponse('<div role="alert">Cannot remove your last credential</div>')
|
||||
|
||||
await cred_repo.delete_webauthn(userid, credential_id)
|
||||
return HTMLResponse('<div role="status">Security key removed</div>')
|
||||
1
src/fastapi_oidc_op/static/htmx.min.js
vendored
Normal file
1
src/fastapi_oidc_op/static/htmx.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
134
src/fastapi_oidc_op/static/style.css
Normal file
134
src/fastapi_oidc_op/static/style.css
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
:root {
|
||||
--bg: #fdfdfd;
|
||||
--fg: #1a1a1a;
|
||||
--accent: #2563eb;
|
||||
--accent-fg: #fff;
|
||||
--border: #d1d5db;
|
||||
--error-bg: #fef2f2;
|
||||
--error-fg: #991b1b;
|
||||
--success-bg: #f0fdf4;
|
||||
--success-fg: #166534;
|
||||
--radius: 0.375rem;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--bg: #111;
|
||||
--fg: #e5e5e5;
|
||||
--accent: #60a5fa;
|
||||
--accent-fg: #111;
|
||||
--border: #404040;
|
||||
--error-bg: #450a0a;
|
||||
--error-fg: #fca5a5;
|
||||
--success-bg: #052e16;
|
||||
--success-fg: #86efac;
|
||||
}
|
||||
}
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
max-width: 40rem;
|
||||
margin: 2rem auto;
|
||||
padding: 0 1rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.skip-link {
|
||||
position: absolute;
|
||||
left: -9999px;
|
||||
top: 0;
|
||||
background: var(--accent);
|
||||
color: var(--accent-fg);
|
||||
padding: 0.5rem 1rem;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.skip-link:focus {
|
||||
left: 0;
|
||||
}
|
||||
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
margin-bottom: 0.25rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
input[type="text"],
|
||||
input[type="password"],
|
||||
input[type="email"] {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 0.5rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
font-size: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
button {
|
||||
padding: 0.5rem 1rem;
|
||||
border: none;
|
||||
border-radius: var(--radius);
|
||||
background: var(--accent);
|
||||
color: var(--accent-fg);
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
[role="alert"] {
|
||||
background: var(--error-bg);
|
||||
color: var(--error-fg);
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: var(--radius);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
[role="status"] {
|
||||
background: var(--success-bg);
|
||||
color: var(--success-fg);
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: var(--radius);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
}
|
||||
149
src/fastapi_oidc_op/static/webauthn.js
Normal file
149
src/fastapi_oidc_op/static/webauthn.js
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
// WebAuthn helper functions for registration and authentication
|
||||
|
||||
function base64urlToBytes(s) {
|
||||
s = s.replace(/-/g, '+').replace(/_/g, '/');
|
||||
while (s.length % 4) s += '=';
|
||||
const raw = atob(s);
|
||||
const bytes = new Uint8Array(raw.length);
|
||||
for (let i = 0; i < raw.length; i++) bytes[i] = raw.charCodeAt(i);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function bytesToBase64url(bytes) {
|
||||
const raw = String.fromCharCode(...new Uint8Array(bytes));
|
||||
return btoa(raw).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||
}
|
||||
|
||||
async function beginRegistration() {
|
||||
const statusEl = document.getElementById('webauthn-status');
|
||||
|
||||
try {
|
||||
// Step 1: Get options from server
|
||||
const beginRes = await fetch('/manage/credentials/webauthn/begin', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
if (!beginRes.ok) {
|
||||
if (statusEl) statusEl.innerHTML = '<div role="alert">Failed to start registration</div>';
|
||||
return;
|
||||
}
|
||||
const options = await beginRes.json();
|
||||
|
||||
// Step 2: Convert base64url fields to ArrayBuffers for WebAuthn API
|
||||
const publicKey = options.publicKey;
|
||||
publicKey.challenge = base64urlToBytes(publicKey.challenge);
|
||||
publicKey.user.id = base64urlToBytes(publicKey.user.id);
|
||||
if (publicKey.excludeCredentials) {
|
||||
publicKey.excludeCredentials = publicKey.excludeCredentials.map(function (c) {
|
||||
return { ...c, id: base64urlToBytes(c.id) };
|
||||
});
|
||||
}
|
||||
|
||||
// Step 3: Call browser WebAuthn API
|
||||
const credential = await navigator.credentials.create({ publicKey: publicKey });
|
||||
|
||||
// Step 4: Encode response for server
|
||||
const attestationResponse = credential.response;
|
||||
const body = {
|
||||
id: bytesToBase64url(credential.rawId),
|
||||
rawId: bytesToBase64url(credential.rawId),
|
||||
type: credential.type,
|
||||
response: {
|
||||
clientDataJSON: bytesToBase64url(attestationResponse.clientDataJSON),
|
||||
attestationObject: bytesToBase64url(attestationResponse.attestationObject),
|
||||
},
|
||||
};
|
||||
|
||||
// Step 5: Send to server
|
||||
const completeRes = await fetch('/manage/credentials/webauthn/complete', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (completeRes.ok) {
|
||||
// Reload to show updated credential list
|
||||
window.location.reload();
|
||||
} else {
|
||||
const text = await completeRes.text();
|
||||
if (statusEl) statusEl.innerHTML = text;
|
||||
}
|
||||
} catch (err) {
|
||||
if (statusEl) statusEl.innerHTML = '<div role="alert">Registration failed: ' + err.message + '</div>';
|
||||
}
|
||||
}
|
||||
|
||||
async function beginAuthentication(username) {
|
||||
const statusEl = document.getElementById('webauthn-login-status');
|
||||
const form = new FormData();
|
||||
form.append('username', username);
|
||||
|
||||
try {
|
||||
// Step 1: Get options from server
|
||||
const beginRes = await fetch('/login/webauthn/begin', {
|
||||
method: 'POST',
|
||||
body: form,
|
||||
});
|
||||
if (!beginRes.ok) {
|
||||
const text = await beginRes.text();
|
||||
if (statusEl) statusEl.innerHTML = text;
|
||||
return;
|
||||
}
|
||||
const options = await beginRes.json();
|
||||
|
||||
// Step 2: Convert base64url fields to ArrayBuffers
|
||||
const publicKey = options.publicKey;
|
||||
publicKey.challenge = base64urlToBytes(publicKey.challenge);
|
||||
if (publicKey.allowCredentials) {
|
||||
publicKey.allowCredentials = publicKey.allowCredentials.map(function (c) {
|
||||
return { ...c, id: base64urlToBytes(c.id) };
|
||||
});
|
||||
}
|
||||
|
||||
// Step 3: Call browser WebAuthn API
|
||||
const assertion = await navigator.credentials.get({ publicKey: publicKey });
|
||||
|
||||
// Step 4: Encode response for server
|
||||
const assertionResponse = assertion.response;
|
||||
const body = {
|
||||
id: bytesToBase64url(assertion.rawId),
|
||||
rawId: bytesToBase64url(assertion.rawId),
|
||||
type: assertion.type,
|
||||
response: {
|
||||
clientDataJSON: bytesToBase64url(assertionResponse.clientDataJSON),
|
||||
authenticatorData: bytesToBase64url(assertionResponse.authenticatorData),
|
||||
signature: bytesToBase64url(assertionResponse.signature),
|
||||
userHandle: assertionResponse.userHandle ? bytesToBase64url(assertionResponse.userHandle) : null,
|
||||
},
|
||||
};
|
||||
|
||||
// Step 5: Send to server
|
||||
const completeRes = await fetch('/login/webauthn/complete', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (completeRes.ok) {
|
||||
const data = await completeRes.json();
|
||||
if (data.redirect) {
|
||||
window.location.href = data.redirect;
|
||||
} else {
|
||||
window.location.href = '/manage/credentials';
|
||||
}
|
||||
} else {
|
||||
const text = await completeRes.text();
|
||||
if (statusEl) statusEl.innerHTML = text;
|
||||
}
|
||||
} catch (err) {
|
||||
if (statusEl) statusEl.innerHTML = '<div role="alert">Authentication failed: ' + err.message + '</div>';
|
||||
}
|
||||
}
|
||||
|
||||
// Wire up the registration button
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
const registerBtn = document.getElementById('webauthn-register-btn');
|
||||
if (registerBtn) {
|
||||
registerBtn.addEventListener('click', beginRegistration);
|
||||
}
|
||||
});
|
||||
18
src/fastapi_oidc_op/templates/base.html
Normal file
18
src/fastapi_oidc_op/templates/base.html
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{% block title %}FastAPI OIDC OP{% endblock %}</title>
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<a class="skip-link" href="#main">Skip to content</a>
|
||||
<main id="main" tabindex="-1">
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
<div aria-live="polite" aria-atomic="true" class="sr-only" id="live"></div>
|
||||
<script src="/static/htmx.min.js" defer></script>
|
||||
{% block scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
35
src/fastapi_oidc_op/templates/login.html
Normal file
35
src/fastapi_oidc_op/templates/login.html
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Login — FastAPI OIDC OP{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1>Sign in</h1>
|
||||
|
||||
<div id="login-error"></div>
|
||||
|
||||
<section>
|
||||
<h2>Password</h2>
|
||||
<form hx-post="/login/password" hx-target="#login-error" hx-swap="innerHTML">
|
||||
<div>
|
||||
<label for="username">Username</label>
|
||||
<input type="text" id="username" name="username" required autocomplete="username">
|
||||
</div>
|
||||
<div>
|
||||
<label for="password">Password</label>
|
||||
<input type="password" id="password" name="password" required autocomplete="current-password">
|
||||
</div>
|
||||
<button type="submit">Sign in</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Security key</h2>
|
||||
<form id="webauthn-login-form">
|
||||
<div>
|
||||
<label for="webauthn-username">Username</label>
|
||||
<input type="text" id="webauthn-username" name="username" required autocomplete="username">
|
||||
</div>
|
||||
<button type="button" id="webauthn-login-btn">Sign in with security key</button>
|
||||
</form>
|
||||
</section>
|
||||
{% endblock %}
|
||||
58
src/fastapi_oidc_op/templates/manage/credentials.html
Normal file
58
src/fastapi_oidc_op/templates/manage/credentials.html
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Credentials — FastAPI OIDC OP{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1>Credentials</h1>
|
||||
|
||||
{% if setup %}
|
||||
<div role="status">
|
||||
<p><strong>Welcome, {{ username }}!</strong> Set up your credentials to secure your account.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<section>
|
||||
<h2>Security keys</h2>
|
||||
<div id="webauthn-list">
|
||||
{% if webauthn_credentials %}
|
||||
<ul>
|
||||
{% for cred in webauthn_credentials %}
|
||||
<li>
|
||||
{{ cred.device_name or "Security key" }}
|
||||
<small>(added {{ cred.created_at.strftime('%Y-%m-%d') }})</small>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<p>No security keys registered.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
<button type="button" id="webauthn-register-btn">Add security key</button>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Password</h2>
|
||||
<div id="password-section">
|
||||
{% if has_password %}
|
||||
<p>Password is set.</p>
|
||||
{% else %}
|
||||
<p>No password set.</p>
|
||||
{% endif %}
|
||||
<form hx-post="/manage/credentials/password" hx-target="#password-section" hx-swap="innerHTML">
|
||||
<div>
|
||||
<label for="password">{{ "New password" if has_password else "Set password" }}</label>
|
||||
<input type="password" id="password" name="password" required minlength="8" autocomplete="new-password">
|
||||
</div>
|
||||
<div>
|
||||
<label for="confirm">Confirm password</label>
|
||||
<input type="password" id="confirm" name="confirm" required minlength="8" autocomplete="new-password">
|
||||
</div>
|
||||
<button type="submit">{{ "Change password" if has_password else "Set password" }}</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script src="/static/webauthn.js" defer></script>
|
||||
{% endblock %}
|
||||
Loading…
Add table
Add a link
Reference in a new issue