Add passkey (WebAuthn) authentication with self-registration
Enable users to sign up and sign in using passkeys (biometrics/security keys) without needing a manually-created Authentik account. The existing SSO login remains as an alternative. Backend: - Add WebAuthn registration/authentication endpoints via py-webauthn - Issue HS256 JWTs for passkey users, with Redis-backed challenge storage - Dual JWT verification in auth middleware (issuer-based routing: passkey HS256 vs Authentik RS256) - PasskeyCredential model + migration making user.password nullable - UserRepository with full CRUD for users and credentials Frontend: - AuthUser type abstraction unifying OIDC and passkey users - Passkey service using @simplewebauthn/browser for WebAuthn ceremonies - LoginModal redesigned with Sign In / Sign Up tabs - Type migration from oidc-client-ts User to AuthUser across all services and components
This commit is contained in:
parent
95c0ddc4c6
commit
a8b7eace48
26 changed files with 1229 additions and 129 deletions
|
|
@ -6,6 +6,7 @@ import logging.config
|
|||
from typing import Annotated, Optional
|
||||
from api.auth import get_current_user
|
||||
from api.config import DEV_TIER_ORIGINS, PROD_TIER_ORIGINS
|
||||
from api.passkey_routes import passkey_router
|
||||
from dotenv import load_dotenv
|
||||
from fastapi import Depends, FastAPI, Query
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
|
@ -62,6 +63,7 @@ def get_query_parameters(
|
|||
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(passkey_router)
|
||||
app.mount("/metrics", metrics_app)
|
||||
meter = get_meter(__name__)
|
||||
request_counter = meter.create_counter(
|
||||
|
|
|
|||
|
|
@ -1,17 +1,22 @@
|
|||
from api.config import AUTHENTIK_URL, OIDC_CACHE_TTL, OIDC_CLIENT_ID, OIDC_METADATA_URL
|
||||
from api.config import (
|
||||
AUTHENTIK_URL,
|
||||
OIDC_CACHE_TTL,
|
||||
OIDC_CLIENT_ID,
|
||||
OIDC_METADATA_URL,
|
||||
JWT_SECRET,
|
||||
JWT_ALGORITHM,
|
||||
JWT_ISSUER,
|
||||
)
|
||||
from cachetools import TTLCache
|
||||
from fastapi import Depends, HTTPException
|
||||
from fastapi.security import OAuth2AuthorizationCodeBearer
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
from httpx import AsyncClient
|
||||
import jwt
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
# OAuth2 Scheme
|
||||
oauth2_scheme = OAuth2AuthorizationCodeBearer(
|
||||
authorizationUrl=f"{AUTHENTIK_URL}/application/o/authorize/",
|
||||
tokenUrl=f"{AUTHENTIK_URL}/application/o/token/",
|
||||
)
|
||||
# HTTPBearer scheme (provider-agnostic, works for both OIDC and passkey JWTs)
|
||||
http_bearer = HTTPBearer()
|
||||
|
||||
JWKS_CACHE = TTLCache(maxsize=1, ttl=OIDC_CACHE_TTL)
|
||||
OIDC_METADATA_CACHE = TTLCache(maxsize=1, ttl=OIDC_CACHE_TTL)
|
||||
|
|
@ -23,7 +28,7 @@ class User(BaseModel):
|
|||
name: str
|
||||
|
||||
|
||||
async def get_oidc_metadata():
|
||||
async def get_oidc_metadata() -> dict: # type: ignore[type-arg]
|
||||
if "oidc_metadata" not in OIDC_METADATA_CACHE:
|
||||
async with AsyncClient() as client:
|
||||
resp = await client.get(OIDC_METADATA_URL, follow_redirects=True)
|
||||
|
|
@ -43,21 +48,51 @@ async def get_cached_jwks_client() -> jwt.PyJWKClient:
|
|||
return JWKS_CACHE["jwks_client"]
|
||||
|
||||
|
||||
async def get_current_user(token: str = Depends(oauth2_scheme)) -> User:
|
||||
try:
|
||||
# Fetch JWKS keys from Authentik
|
||||
metadata = await get_oidc_metadata()
|
||||
signing_key = (await get_cached_jwks_client()).get_signing_key_from_jwt(token)
|
||||
async def _verify_authentik_token(token: str) -> User:
|
||||
"""Verify a token issued by Authentik (RS256 via JWKS)."""
|
||||
metadata = await get_oidc_metadata()
|
||||
signing_key = (await get_cached_jwks_client()).get_signing_key_from_jwt(token)
|
||||
|
||||
# Decode and verify JWT
|
||||
payload = jwt.decode(
|
||||
token,
|
||||
signing_key,
|
||||
algorithms=["RS256"],
|
||||
audience=OIDC_CLIENT_ID,
|
||||
issuer=metadata["issuer"],
|
||||
options={"verify_exp": False},
|
||||
payload = jwt.decode(
|
||||
token,
|
||||
signing_key,
|
||||
algorithms=["RS256"],
|
||||
audience=OIDC_CLIENT_ID,
|
||||
issuer=metadata["issuer"],
|
||||
options={"verify_exp": False},
|
||||
)
|
||||
return User(**payload)
|
||||
|
||||
|
||||
def _verify_passkey_token(token: str) -> User:
|
||||
"""Verify a token issued by the passkey service (HS256)."""
|
||||
payload = jwt.decode(
|
||||
token,
|
||||
JWT_SECRET,
|
||||
algorithms=[JWT_ALGORITHM],
|
||||
issuer=JWT_ISSUER,
|
||||
)
|
||||
return User(
|
||||
sub=payload["sub"],
|
||||
email=payload["email"],
|
||||
name=payload.get("name", payload["email"]),
|
||||
)
|
||||
|
||||
|
||||
async def get_current_user(
|
||||
credentials: HTTPAuthorizationCredentials = Depends(http_bearer),
|
||||
) -> User:
|
||||
token = credentials.credentials
|
||||
try:
|
||||
# Peek at unverified issuer to route verification
|
||||
unverified = jwt.decode(
|
||||
token, options={"verify_signature": False, "verify_exp": False}
|
||||
)
|
||||
return User(**payload)
|
||||
issuer = unverified.get("iss", "")
|
||||
|
||||
if issuer == JWT_ISSUER:
|
||||
return _verify_passkey_token(token)
|
||||
else:
|
||||
return await _verify_authentik_token(token)
|
||||
except jwt.PyJWTError as e:
|
||||
raise HTTPException(status_code=401, detail=f"Invalid token: {e}")
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
from datetime import timedelta
|
||||
import os
|
||||
|
||||
|
||||
# Authentik OIDC Configuration
|
||||
|
|
@ -14,3 +15,14 @@ OIDC_CACHE_TTL = timedelta(
|
|||
|
||||
DEV_TIER_ORIGINS = ["https://localhost/"]
|
||||
PROD_TIER_ORIGINS = ["https://wrongmove.viktorbarzin.me/"]
|
||||
|
||||
# WebAuthn / Passkey Configuration
|
||||
WEBAUTHN_RP_ID = os.getenv("WEBAUTHN_RP_ID", "localhost")
|
||||
WEBAUTHN_RP_NAME = os.getenv("WEBAUTHN_RP_NAME", "Wrongmove")
|
||||
WEBAUTHN_ORIGIN = os.getenv("WEBAUTHN_ORIGIN", "https://localhost")
|
||||
|
||||
# JWT Configuration (for passkey-issued tokens)
|
||||
JWT_SECRET = os.getenv("JWT_SECRET", "change-me-in-production")
|
||||
JWT_ALGORITHM = os.getenv("JWT_ALGORITHM", "HS256")
|
||||
JWT_EXPIRATION_HOURS = int(os.getenv("JWT_EXPIRATION_HOURS", "24"))
|
||||
JWT_ISSUER = os.getenv("JWT_ISSUER", "wrongmove")
|
||||
|
|
|
|||
93
crawler/api/passkey_routes.py
Normal file
93
crawler/api/passkey_routes.py
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
import logging
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel, EmailStr
|
||||
|
||||
from database import engine
|
||||
from repositories.user_repository import UserRepository
|
||||
from services import passkey_service
|
||||
|
||||
logger = logging.getLogger("uvicorn")
|
||||
|
||||
passkey_router = APIRouter(prefix="/api/passkey", tags=["passkey"])
|
||||
|
||||
|
||||
class RegisterBeginRequest(BaseModel):
|
||||
email: EmailStr
|
||||
|
||||
|
||||
class RegisterBeginResponse(BaseModel):
|
||||
options: dict # type: ignore[type-arg]
|
||||
session_id: str
|
||||
|
||||
|
||||
class CeremonyCompleteRequest(BaseModel):
|
||||
session_id: str
|
||||
credential: dict # type: ignore[type-arg]
|
||||
|
||||
|
||||
class AuthTokenResponse(BaseModel):
|
||||
token: str
|
||||
|
||||
|
||||
class LoginBeginResponse(BaseModel):
|
||||
options: dict # type: ignore[type-arg]
|
||||
session_id: str
|
||||
|
||||
|
||||
@passkey_router.post("/register/begin", response_model=RegisterBeginResponse)
|
||||
async def register_begin(body: RegisterBeginRequest) -> RegisterBeginResponse:
|
||||
"""Start passkey registration ceremony."""
|
||||
try:
|
||||
user_repo = UserRepository(engine)
|
||||
options, session_id = passkey_service.begin_registration(
|
||||
body.email, user_repo
|
||||
)
|
||||
return RegisterBeginResponse(options=options, session_id=session_id)
|
||||
except Exception as e:
|
||||
logger.error(f"Registration begin failed: {e}")
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
|
||||
@passkey_router.post("/register/complete", response_model=AuthTokenResponse)
|
||||
async def register_complete(body: CeremonyCompleteRequest) -> AuthTokenResponse:
|
||||
"""Complete passkey registration ceremony."""
|
||||
try:
|
||||
user_repo = UserRepository(engine)
|
||||
token = passkey_service.complete_registration(
|
||||
body.session_id, body.credential, user_repo
|
||||
)
|
||||
return AuthTokenResponse(token=token)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error(f"Registration complete failed: {e}")
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
|
||||
@passkey_router.post("/login/begin", response_model=LoginBeginResponse)
|
||||
async def login_begin() -> LoginBeginResponse:
|
||||
"""Start passkey authentication ceremony."""
|
||||
try:
|
||||
user_repo = UserRepository(engine)
|
||||
options, session_id = passkey_service.begin_authentication(user_repo)
|
||||
return LoginBeginResponse(options=options, session_id=session_id)
|
||||
except Exception as e:
|
||||
logger.error(f"Login begin failed: {e}")
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
|
||||
@passkey_router.post("/login/complete", response_model=AuthTokenResponse)
|
||||
async def login_complete(body: CeremonyCompleteRequest) -> AuthTokenResponse:
|
||||
"""Complete passkey authentication ceremony."""
|
||||
try:
|
||||
user_repo = UserRepository(engine)
|
||||
token = passkey_service.complete_authentication(
|
||||
body.session_id, body.credential, user_repo
|
||||
)
|
||||
return AuthTokenResponse(token=token)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error(f"Login complete failed: {e}")
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
Loading…
Add table
Add a link
Reference in a new issue