Separated session management from its FastAPI-dependent parts, creating authsession.py on main level.
Startup/main/scripts cleanup, now runs with passkey-auth command that takes CLI arguments.
This commit is contained in:
parent
b58b7d5350
commit
7f8f77ae1e
67
authsession.py
Normal file
67
authsession.py
Normal file
@ -0,0 +1,67 @@
|
||||
"""
|
||||
Core session management for WebAuthn authentication.
|
||||
|
||||
This module provides generic session management functionality that is
|
||||
independent of any web framework:
|
||||
- Session creation and validation
|
||||
- Token handling and refresh
|
||||
- Credential management
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from uuid import UUID
|
||||
|
||||
from passkey.db import Session, db
|
||||
from passkey.util import passphrase
|
||||
from passkey.util.tokens import create_token, reset_key, session_key
|
||||
|
||||
EXPIRES = timedelta(hours=24)
|
||||
|
||||
|
||||
def expires() -> datetime:
|
||||
return datetime.now() + EXPIRES
|
||||
|
||||
|
||||
async def create_session(user_uuid: UUID, info: dict, credential_uuid: UUID) -> str:
|
||||
"""Create a new session and return a session token."""
|
||||
token = create_token()
|
||||
await db.instance.create_session(
|
||||
user_uuid=user_uuid,
|
||||
key=session_key(token),
|
||||
expires=datetime.now() + EXPIRES,
|
||||
info=info,
|
||||
credential_uuid=credential_uuid,
|
||||
)
|
||||
return token
|
||||
|
||||
|
||||
async def get_session(token: str, reset_allowed=False) -> Session:
|
||||
"""Validate a session token and return session data if valid."""
|
||||
if passphrase.is_well_formed(token):
|
||||
if not reset_allowed:
|
||||
raise ValueError("Reset link is not allowed for this endpoint")
|
||||
key = reset_key(token)
|
||||
else:
|
||||
key = session_key(token)
|
||||
|
||||
session = await db.instance.get_session(key)
|
||||
if not session:
|
||||
raise ValueError("Invalid or expired session token")
|
||||
return session
|
||||
|
||||
|
||||
async def refresh_session_token(token: str):
|
||||
"""Refresh a session extending its expiry."""
|
||||
# Get the current session
|
||||
s = await db.instance.update_session(
|
||||
session_key(token), datetime.now() + EXPIRES, {}
|
||||
)
|
||||
|
||||
if not s:
|
||||
raise ValueError("Session not found or expired")
|
||||
|
||||
|
||||
async def delete_credential(credential_uuid: UUID, auth: str):
|
||||
"""Delete a specific credential for the current user."""
|
||||
s = await get_session(auth)
|
||||
await db.instance.delete_credential(credential_uuid, s.user_uuid)
|
67
passkey/authsession.py
Normal file
67
passkey/authsession.py
Normal file
@ -0,0 +1,67 @@
|
||||
"""
|
||||
Core session management for WebAuthn authentication.
|
||||
|
||||
This module provides generic session management functionality that is
|
||||
independent of any web framework:
|
||||
- Session creation and validation
|
||||
- Token handling and refresh
|
||||
- Credential management
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from uuid import UUID
|
||||
|
||||
from .db import Session, db
|
||||
from .util import passphrase
|
||||
from .util.tokens import create_token, reset_key, session_key
|
||||
|
||||
EXPIRES = timedelta(hours=24)
|
||||
|
||||
|
||||
def expires() -> datetime:
|
||||
return datetime.now() + EXPIRES
|
||||
|
||||
|
||||
async def create_session(user_uuid: UUID, info: dict, credential_uuid: UUID) -> str:
|
||||
"""Create a new session and return a session token."""
|
||||
token = create_token()
|
||||
await db.instance.create_session(
|
||||
user_uuid=user_uuid,
|
||||
key=session_key(token),
|
||||
expires=datetime.now() + EXPIRES,
|
||||
info=info,
|
||||
credential_uuid=credential_uuid,
|
||||
)
|
||||
return token
|
||||
|
||||
|
||||
async def get_session(token: str, reset_allowed=False) -> Session:
|
||||
"""Validate a session token and return session data if valid."""
|
||||
if passphrase.is_well_formed(token):
|
||||
if not reset_allowed:
|
||||
raise ValueError("Reset link is not allowed for this endpoint")
|
||||
key = reset_key(token)
|
||||
else:
|
||||
key = session_key(token)
|
||||
|
||||
session = await db.instance.get_session(key)
|
||||
if not session:
|
||||
raise ValueError("Invalid or expired session token")
|
||||
return session
|
||||
|
||||
|
||||
async def refresh_session_token(token: str):
|
||||
"""Refresh a session extending its expiry."""
|
||||
# Get the current session
|
||||
s = await db.instance.update_session(
|
||||
session_key(token), datetime.now() + EXPIRES, {}
|
||||
)
|
||||
|
||||
if not s:
|
||||
raise ValueError("Session not found or expired")
|
||||
|
||||
|
||||
async def delete_credential(credential_uuid: UUID, auth: str):
|
||||
"""Delete a specific credential for the current user."""
|
||||
s = await get_session(auth)
|
||||
await db.instance.delete_credential(credential_uuid, s.user_uuid)
|
3
passkey/fastapi/__init__.py
Normal file
3
passkey/fastapi/__init__.py
Normal file
@ -0,0 +1,3 @@
|
||||
from .mainapp import app
|
||||
|
||||
__all__ = ["app"]
|
32
passkey/fastapi/__main__.py
Normal file
32
passkey/fastapi/__main__.py
Normal file
@ -0,0 +1,32 @@
|
||||
import argparse
|
||||
|
||||
import uvicorn
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Run the passkey authentication server"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--host", default="localhost", help="Host to bind to (default: localhost)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--port", type=int, default=4401, help="Port to bind to (default: 4401)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--dev", action="store_true", help="Enable development mode with auto-reload"
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
uvicorn.run(
|
||||
"passkey.fastapi:app",
|
||||
host=args.host,
|
||||
port=args.port,
|
||||
reload=args.dev,
|
||||
log_level="debug" if args.dev else "info",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
@ -14,6 +14,7 @@ from fastapi import Cookie, Depends, FastAPI, Request, Response
|
||||
from fastapi.security import HTTPBearer
|
||||
|
||||
from .. import aaguid
|
||||
from ..authsession import delete_credential, get_session
|
||||
from ..db import db
|
||||
from ..util.tokens import session_key
|
||||
from . import session
|
||||
@ -28,7 +29,7 @@ def register_api_routes(app: FastAPI):
|
||||
async def validate_token(request: Request, response: Response, auth=Cookie(None)):
|
||||
"""Lightweight token validation endpoint."""
|
||||
try:
|
||||
s = await session.get_session(auth)
|
||||
s = await get_session(auth)
|
||||
return {
|
||||
"status": "success",
|
||||
"valid": True,
|
||||
@ -41,7 +42,7 @@ def register_api_routes(app: FastAPI):
|
||||
async def api_user_info(auth=Cookie(None)):
|
||||
"""Get full user information for the authenticated user."""
|
||||
try:
|
||||
s = await session.get_session(auth, reset_allowed=True)
|
||||
s = await get_session(auth, reset_allowed=True)
|
||||
u = await db.instance.get_user_by_user_uuid(s.user_uuid)
|
||||
# Get all credentials for the user
|
||||
credential_ids = await db.instance.get_credentials_by_user_uuid(s.user_uuid)
|
||||
@ -110,7 +111,7 @@ def register_api_routes(app: FastAPI):
|
||||
async def api_set_session(response: Response, auth=Depends(bearer_auth)):
|
||||
"""Set session cookie from Authorization header. Fetched after login by WebSocket."""
|
||||
try:
|
||||
user = await session.get_session(auth.credentials)
|
||||
user = await get_session(auth.credentials)
|
||||
if not user:
|
||||
raise ValueError("Invalid Authorization header.")
|
||||
session.set_session_cookie(response, auth.credentials)
|
||||
@ -130,7 +131,7 @@ def register_api_routes(app: FastAPI):
|
||||
async def api_delete_credential(uuid: UUID, auth: str = Cookie(None)):
|
||||
"""Delete a specific credential for the current user."""
|
||||
try:
|
||||
await session.delete_credential(uuid, auth)
|
||||
await delete_credential(uuid, auth)
|
||||
return {"status": "success", "message": "Credential deleted successfully"}
|
||||
|
||||
except ValueError as e:
|
||||
|
@ -1,5 +1,4 @@
|
||||
import contextlib
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
|
||||
@ -9,7 +8,8 @@ from fastapi.responses import (
|
||||
)
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from . import session, ws
|
||||
from ..authsession import get_session
|
||||
from . import ws
|
||||
from .api import register_api_routes
|
||||
from .reset import register_reset_routes
|
||||
|
||||
@ -38,7 +38,7 @@ register_reset_routes(app)
|
||||
async def forward_authentication(request: Request, auth=Cookie(None)):
|
||||
"""A validation endpoint to use with Caddy forward_auth or Nginx auth_request."""
|
||||
with contextlib.suppress(ValueError):
|
||||
s = await session.get_session(auth)
|
||||
s = await get_session(auth)
|
||||
# If authenticated, return a success response
|
||||
if s.info and s.info["type"] == "authenticated":
|
||||
return Response(
|
||||
@ -66,21 +66,3 @@ app.mount(
|
||||
async def redirect_to_index():
|
||||
"""Serve the main authentication app."""
|
||||
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()
|
@ -3,6 +3,7 @@ import logging
|
||||
from fastapi import Cookie, HTTPException, Request
|
||||
from fastapi.responses import RedirectResponse
|
||||
|
||||
from ..authsession import expires, get_session
|
||||
from ..db import db
|
||||
from ..util import passphrase, tokens
|
||||
from . import session
|
||||
@ -16,14 +17,14 @@ def register_reset_routes(app):
|
||||
"""Create a device addition link for the authenticated user."""
|
||||
try:
|
||||
# Require authentication
|
||||
s = await session.get_session(auth)
|
||||
s = await get_session(auth)
|
||||
|
||||
# Generate a human-readable token
|
||||
token = passphrase.generate() # e.g., "cross.rotate.yin.note.evoke"
|
||||
await db.instance.create_session(
|
||||
user_uuid=s.user_uuid,
|
||||
key=tokens.reset_key(token),
|
||||
expires=session.expires(),
|
||||
expires=expires(),
|
||||
info=session.infodict(request, "device addition"),
|
||||
)
|
||||
|
||||
@ -35,7 +36,7 @@ def register_reset_routes(app):
|
||||
"status": "success",
|
||||
"message": "Registration link generated successfully",
|
||||
"url": url,
|
||||
"expires": session.expires().isoformat(),
|
||||
"expires": expires().isoformat(),
|
||||
}
|
||||
|
||||
except ValueError:
|
||||
|
@ -1,28 +1,16 @@
|
||||
"""
|
||||
Session management for WebAuthn authentication.
|
||||
FastAPI-specific 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
|
||||
- Device addition token management
|
||||
- Device addition route handlers
|
||||
This module provides FastAPI-specific session management functionality:
|
||||
- Extracting client information from FastAPI requests
|
||||
- Setting and clearing HTTP-only cookies via FastAPI Response objects
|
||||
|
||||
Generic session management functions have been moved to authsession.py
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import Request, Response, WebSocket
|
||||
|
||||
from ..db import Session, db
|
||||
from ..util import passphrase
|
||||
from ..util.tokens import create_token, reset_key, session_key
|
||||
|
||||
EXPIRES = timedelta(hours=24)
|
||||
|
||||
|
||||
def expires() -> datetime:
|
||||
return datetime.now() + EXPIRES
|
||||
from ..authsession import EXPIRES
|
||||
|
||||
|
||||
def infodict(request: Request | WebSocket, type: str) -> dict:
|
||||
@ -34,45 +22,6 @@ def infodict(request: Request | WebSocket, type: str) -> dict:
|
||||
}
|
||||
|
||||
|
||||
async def create_session(user_uuid: UUID, info: dict, credential_uuid: UUID) -> str:
|
||||
"""Create a new session and return a session token."""
|
||||
token = create_token()
|
||||
await db.instance.create_session(
|
||||
user_uuid=user_uuid,
|
||||
key=session_key(token),
|
||||
expires=datetime.now() + EXPIRES,
|
||||
info=info,
|
||||
credential_uuid=credential_uuid,
|
||||
)
|
||||
return token
|
||||
|
||||
|
||||
async def get_session(token: str, reset_allowed=False) -> Session:
|
||||
"""Validate a session token and return session data if valid."""
|
||||
if passphrase.is_well_formed(token):
|
||||
if not reset_allowed:
|
||||
raise ValueError("Reset link is not allowed for this endpoint")
|
||||
key = reset_key(token)
|
||||
else:
|
||||
key = session_key(token)
|
||||
|
||||
session = await db.instance.get_session(key)
|
||||
if not session:
|
||||
raise ValueError("Invalid or expired session token")
|
||||
return session
|
||||
|
||||
|
||||
async def refresh_session_token(token: str):
|
||||
"""Refresh a session extending its expiry."""
|
||||
# Get the current session
|
||||
s = await db.instance.update_session(
|
||||
session_key(token), datetime.now() + EXPIRES, {}
|
||||
)
|
||||
|
||||
if not s:
|
||||
raise ValueError("Session not found or expired")
|
||||
|
||||
|
||||
def set_session_cookie(response: Response, token: str) -> None:
|
||||
"""Set the session token as an HTTP-only cookie."""
|
||||
response.set_cookie(
|
||||
@ -83,9 +32,3 @@ def set_session_cookie(response: Response, token: str) -> None:
|
||||
secure=True,
|
||||
path="/auth/",
|
||||
)
|
||||
|
||||
|
||||
async def delete_credential(credential_uuid: UUID, auth: str):
|
||||
"""Delete a specific credential for the current user."""
|
||||
s = await get_session(auth)
|
||||
await db.instance.delete_credential(credential_uuid, s.user_uuid)
|
||||
|
@ -16,12 +16,11 @@ import uuid7
|
||||
from fastapi import Cookie, FastAPI, Query, WebSocket, WebSocketDisconnect
|
||||
from webauthn.helpers.exceptions import InvalidAuthenticationResponse
|
||||
|
||||
from passkey.fastapi import session
|
||||
|
||||
from ..authsession import EXPIRES, create_session, get_session
|
||||
from ..db import User, db
|
||||
from ..sansio import Passkey
|
||||
from ..util.tokens import create_token, session_key
|
||||
from .session import create_session, infodict
|
||||
from .session import infodict
|
||||
|
||||
# Create a FastAPI subapp for WebSocket endpoints
|
||||
app = FastAPI()
|
||||
@ -74,7 +73,7 @@ async def websocket_register_new(
|
||||
await db.instance.create_session(
|
||||
user_uuid=user_uuid,
|
||||
key=session_key(token),
|
||||
expires=datetime.now() + session.EXPIRES,
|
||||
expires=datetime.now() + EXPIRES,
|
||||
info=infodict(ws, "authenticated"),
|
||||
credential_uuid=credential.uuid,
|
||||
)
|
||||
@ -102,7 +101,7 @@ async def websocket_register_add(ws: WebSocket, auth=Cookie(None)):
|
||||
await ws.accept()
|
||||
origin = ws.headers.get("origin")
|
||||
try:
|
||||
s = await session.get_session(auth, reset_allowed=True)
|
||||
s = await get_session(auth, reset_allowed=True)
|
||||
user_uuid = s.user_uuid
|
||||
|
||||
# Get user information to get the user_name
|
||||
|
@ -36,7 +36,7 @@ ignore = ["E501"] # Line too long
|
||||
known-first-party = ["passkey"]
|
||||
|
||||
[project.scripts]
|
||||
serve = "passkey.main:main"
|
||||
passkey-auth = "passkey.fastapi.__main__:main"
|
||||
|
||||
[tool.hatch.build]
|
||||
artifacts = ["passkeyauth/frontend-static"]
|
||||
|
Loading…
x
Reference in New Issue
Block a user