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 fastapi.security import HTTPBearer
|
||||||
|
|
||||||
from .. import aaguid
|
from .. import aaguid
|
||||||
|
from ..authsession import delete_credential, get_session
|
||||||
from ..db import db
|
from ..db import db
|
||||||
from ..util.tokens import session_key
|
from ..util.tokens import session_key
|
||||||
from . import session
|
from . import session
|
||||||
@ -28,7 +29,7 @@ def register_api_routes(app: FastAPI):
|
|||||||
async def validate_token(request: Request, response: Response, auth=Cookie(None)):
|
async def validate_token(request: Request, response: Response, auth=Cookie(None)):
|
||||||
"""Lightweight token validation endpoint."""
|
"""Lightweight token validation endpoint."""
|
||||||
try:
|
try:
|
||||||
s = await session.get_session(auth)
|
s = await get_session(auth)
|
||||||
return {
|
return {
|
||||||
"status": "success",
|
"status": "success",
|
||||||
"valid": True,
|
"valid": True,
|
||||||
@ -41,7 +42,7 @@ def register_api_routes(app: FastAPI):
|
|||||||
async def api_user_info(auth=Cookie(None)):
|
async def api_user_info(auth=Cookie(None)):
|
||||||
"""Get full user information for the authenticated user."""
|
"""Get full user information for the authenticated user."""
|
||||||
try:
|
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)
|
u = await db.instance.get_user_by_user_uuid(s.user_uuid)
|
||||||
# Get all credentials for the user
|
# Get all credentials for the user
|
||||||
credential_ids = await db.instance.get_credentials_by_user_uuid(s.user_uuid)
|
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)):
|
async def api_set_session(response: Response, auth=Depends(bearer_auth)):
|
||||||
"""Set session cookie from Authorization header. Fetched after login by WebSocket."""
|
"""Set session cookie from Authorization header. Fetched after login by WebSocket."""
|
||||||
try:
|
try:
|
||||||
user = await session.get_session(auth.credentials)
|
user = await get_session(auth.credentials)
|
||||||
if not user:
|
if not user:
|
||||||
raise ValueError("Invalid Authorization header.")
|
raise ValueError("Invalid Authorization header.")
|
||||||
session.set_session_cookie(response, auth.credentials)
|
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)):
|
async def api_delete_credential(uuid: UUID, auth: str = Cookie(None)):
|
||||||
"""Delete a specific credential for the current user."""
|
"""Delete a specific credential for the current user."""
|
||||||
try:
|
try:
|
||||||
await session.delete_credential(uuid, auth)
|
await delete_credential(uuid, auth)
|
||||||
return {"status": "success", "message": "Credential deleted successfully"}
|
return {"status": "success", "message": "Credential deleted successfully"}
|
||||||
|
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
|
@ -1,5 +1,4 @@
|
|||||||
import contextlib
|
import contextlib
|
||||||
import logging
|
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@ -9,7 +8,8 @@ from fastapi.responses import (
|
|||||||
)
|
)
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
|
||||||
from . import session, ws
|
from ..authsession import get_session
|
||||||
|
from . import ws
|
||||||
from .api import register_api_routes
|
from .api import register_api_routes
|
||||||
from .reset import register_reset_routes
|
from .reset import register_reset_routes
|
||||||
|
|
||||||
@ -38,7 +38,7 @@ register_reset_routes(app)
|
|||||||
async def forward_authentication(request: Request, auth=Cookie(None)):
|
async def forward_authentication(request: Request, auth=Cookie(None)):
|
||||||
"""A validation endpoint to use with Caddy forward_auth or Nginx auth_request."""
|
"""A validation endpoint to use with Caddy forward_auth or Nginx auth_request."""
|
||||||
with contextlib.suppress(ValueError):
|
with contextlib.suppress(ValueError):
|
||||||
s = await session.get_session(auth)
|
s = await get_session(auth)
|
||||||
# If authenticated, return a success response
|
# If authenticated, return a success response
|
||||||
if s.info and s.info["type"] == "authenticated":
|
if s.info and s.info["type"] == "authenticated":
|
||||||
return Response(
|
return Response(
|
||||||
@ -66,21 +66,3 @@ app.mount(
|
|||||||
async def redirect_to_index():
|
async def redirect_to_index():
|
||||||
"""Serve the main authentication app."""
|
"""Serve the main authentication app."""
|
||||||
return FileResponse(STATIC_DIR / "index.html")
|
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 import Cookie, HTTPException, Request
|
||||||
from fastapi.responses import RedirectResponse
|
from fastapi.responses import RedirectResponse
|
||||||
|
|
||||||
|
from ..authsession import expires, get_session
|
||||||
from ..db import db
|
from ..db import db
|
||||||
from ..util import passphrase, tokens
|
from ..util import passphrase, tokens
|
||||||
from . import session
|
from . import session
|
||||||
@ -16,14 +17,14 @@ def register_reset_routes(app):
|
|||||||
"""Create a device addition link for the authenticated user."""
|
"""Create a device addition link for the authenticated user."""
|
||||||
try:
|
try:
|
||||||
# Require authentication
|
# Require authentication
|
||||||
s = await session.get_session(auth)
|
s = await get_session(auth)
|
||||||
|
|
||||||
# Generate a human-readable token
|
# Generate a human-readable token
|
||||||
token = passphrase.generate() # e.g., "cross.rotate.yin.note.evoke"
|
token = passphrase.generate() # e.g., "cross.rotate.yin.note.evoke"
|
||||||
await db.instance.create_session(
|
await db.instance.create_session(
|
||||||
user_uuid=s.user_uuid,
|
user_uuid=s.user_uuid,
|
||||||
key=tokens.reset_key(token),
|
key=tokens.reset_key(token),
|
||||||
expires=session.expires(),
|
expires=expires(),
|
||||||
info=session.infodict(request, "device addition"),
|
info=session.infodict(request, "device addition"),
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -35,7 +36,7 @@ def register_reset_routes(app):
|
|||||||
"status": "success",
|
"status": "success",
|
||||||
"message": "Registration link generated successfully",
|
"message": "Registration link generated successfully",
|
||||||
"url": url,
|
"url": url,
|
||||||
"expires": session.expires().isoformat(),
|
"expires": expires().isoformat(),
|
||||||
}
|
}
|
||||||
|
|
||||||
except ValueError:
|
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:
|
This module provides FastAPI-specific session management functionality:
|
||||||
- Getting current user from session cookies
|
- Extracting client information from FastAPI requests
|
||||||
- Setting and clearing HTTP-only cookies
|
- Setting and clearing HTTP-only cookies via FastAPI Response objects
|
||||||
- Session validation and token handling
|
|
||||||
- Device addition token management
|
Generic session management functions have been moved to authsession.py
|
||||||
- Device addition route handlers
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from datetime import datetime, timedelta
|
|
||||||
from uuid import UUID
|
|
||||||
|
|
||||||
from fastapi import Request, Response, WebSocket
|
from fastapi import Request, Response, WebSocket
|
||||||
|
|
||||||
from ..db import Session, db
|
from ..authsession import EXPIRES
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
def infodict(request: Request | WebSocket, type: str) -> dict:
|
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:
|
def set_session_cookie(response: Response, token: str) -> None:
|
||||||
"""Set the session token as an HTTP-only cookie."""
|
"""Set the session token as an HTTP-only cookie."""
|
||||||
response.set_cookie(
|
response.set_cookie(
|
||||||
@ -83,9 +32,3 @@ def set_session_cookie(response: Response, token: str) -> None:
|
|||||||
secure=True,
|
secure=True,
|
||||||
path="/auth/",
|
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 fastapi import Cookie, FastAPI, Query, WebSocket, WebSocketDisconnect
|
||||||
from webauthn.helpers.exceptions import InvalidAuthenticationResponse
|
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 ..db import User, db
|
||||||
from ..sansio import Passkey
|
from ..sansio import Passkey
|
||||||
from ..util.tokens import create_token, session_key
|
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
|
# Create a FastAPI subapp for WebSocket endpoints
|
||||||
app = FastAPI()
|
app = FastAPI()
|
||||||
@ -74,7 +73,7 @@ async def websocket_register_new(
|
|||||||
await db.instance.create_session(
|
await db.instance.create_session(
|
||||||
user_uuid=user_uuid,
|
user_uuid=user_uuid,
|
||||||
key=session_key(token),
|
key=session_key(token),
|
||||||
expires=datetime.now() + session.EXPIRES,
|
expires=datetime.now() + EXPIRES,
|
||||||
info=infodict(ws, "authenticated"),
|
info=infodict(ws, "authenticated"),
|
||||||
credential_uuid=credential.uuid,
|
credential_uuid=credential.uuid,
|
||||||
)
|
)
|
||||||
@ -102,7 +101,7 @@ async def websocket_register_add(ws: WebSocket, auth=Cookie(None)):
|
|||||||
await ws.accept()
|
await ws.accept()
|
||||||
origin = ws.headers.get("origin")
|
origin = ws.headers.get("origin")
|
||||||
try:
|
try:
|
||||||
s = await session.get_session(auth, reset_allowed=True)
|
s = await get_session(auth, reset_allowed=True)
|
||||||
user_uuid = s.user_uuid
|
user_uuid = s.user_uuid
|
||||||
|
|
||||||
# Get user information to get the user_name
|
# Get user information to get the user_name
|
||||||
|
@ -36,7 +36,7 @@ ignore = ["E501"] # Line too long
|
|||||||
known-first-party = ["passkey"]
|
known-first-party = ["passkey"]
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
serve = "passkey.main:main"
|
passkey-auth = "passkey.fastapi.__main__:main"
|
||||||
|
|
||||||
[tool.hatch.build]
|
[tool.hatch.build]
|
||||||
artifacts = ["passkeyauth/frontend-static"]
|
artifacts = ["passkeyauth/frontend-static"]
|
||||||
|
Loading…
x
Reference in New Issue
Block a user