Mostly working, saving.

This commit is contained in:
Leo Vasanko
2025-07-07 13:19:39 -06:00
parent 1c9044054a
commit 52520c18b1
20 changed files with 1948 additions and 319 deletions

View File

@@ -36,6 +36,7 @@ async def get_user_info(request: Request) -> dict:
"user_name": user.user_name,
"created_at": user.created_at.isoformat() if user.created_at else None,
"last_seen": user.last_seen.isoformat() if user.last_seen else None,
"visits": user.visits,
},
}
except Exception as e:

View File

@@ -5,9 +5,10 @@ This module provides an async database layer using SQLAlchemy async mode
for managing users and credentials in a WebAuthn authentication system.
"""
import secrets
from contextlib import asynccontextmanager
from dataclasses import dataclass
from datetime import datetime
from datetime import datetime, timedelta
from uuid import UUID
from sqlalchemy import (
@@ -41,6 +42,7 @@ class UserModel(Base):
user_name: Mapped[str] = mapped_column(String, nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.now)
last_seen: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
visits: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
# Relationship to credentials
credentials: Mapped[list["CredentialModel"]] = relationship(
@@ -66,12 +68,33 @@ class CredentialModel(Base):
user: Mapped["UserModel"] = relationship("UserModel", back_populates="credentials")
class ResetTokenModel(Base):
__tablename__ = "reset_tokens"
token: Mapped[str] = mapped_column(String(64), primary_key=True)
user_id: Mapped[bytes] = mapped_column(
LargeBinary(16), ForeignKey("users.user_id", ondelete="CASCADE")
)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.now)
# Relationship to user
user: Mapped["UserModel"] = relationship("UserModel")
@dataclass
class User:
user_id: UUID
user_name: str
created_at: datetime | None = None
last_seen: datetime | None = None
visits: int = 0
@dataclass
class ResetToken:
token: str
user_id: UUID
created_at: datetime
# Global engine and session factory
@@ -108,6 +131,7 @@ class DB:
user_name=user_model.user_name,
created_at=user_model.created_at,
last_seen=user_model.last_seen,
visits=user_model.visits,
)
raise ValueError("User not found")
@@ -118,6 +142,7 @@ class DB:
user_name=user.user_name,
created_at=user.created_at or datetime.now(),
last_seen=user.last_seen,
visits=user.visits,
)
self.session.add(user_model)
await self.session.flush()
@@ -186,11 +211,27 @@ class DB:
# Update credential
await self.update_credential(credential)
# Update user's last_seen
# Update user's last_seen and increment visits
stmt = (
update(UserModel)
.where(UserModel.user_id == user_id.bytes)
.values(last_seen=credential.last_used)
.values(last_seen=credential.last_used, visits=UserModel.visits + 1)
)
await self.session.execute(stmt)
async def create_new_session(
self, user_id: UUID, credential: StoredCredential
) -> None:
"""Create a new session for a user by incrementing visits and updating last_seen."""
async with self.session.begin():
# Update credential
await self.update_credential(credential)
# Update user's last_seen and increment visits
stmt = (
update(UserModel)
.where(UserModel.user_id == user_id.bytes)
.values(last_seen=credential.last_used, visits=UserModel.visits + 1)
)
await self.session.execute(stmt)
@@ -202,6 +243,61 @@ class DB:
await self.session.execute(stmt)
await self.session.commit()
async def create_reset_token(self, user_id: UUID, token: str | None = None) -> str:
"""Create a new reset token for a user."""
if token is None:
token = secrets.token_urlsafe(32)
reset_token_model = ResetTokenModel(
token=token,
user_id=user_id.bytes,
created_at=datetime.now(),
)
self.session.add(reset_token_model)
await self.session.flush()
return token
async def get_reset_token(self, token: str) -> ResetToken | None:
"""Get reset token by token string."""
stmt = select(ResetTokenModel).where(ResetTokenModel.token == token)
result = await self.session.execute(stmt)
token_model = result.scalar_one_or_none()
if token_model:
return ResetToken(
token=token_model.token,
user_id=UUID(bytes=token_model.user_id),
created_at=token_model.created_at,
)
return None
async def delete_reset_token(self, token: str) -> None:
"""Delete a reset token (used after successful credential addition)."""
stmt = delete(ResetTokenModel).where(ResetTokenModel.token == token)
await self.session.execute(stmt)
async def cleanup_expired_tokens(self) -> None:
"""Remove expired reset tokens (older than 24 hours)."""
expiry_time = datetime.now() - timedelta(hours=24)
stmt = delete(ResetTokenModel).where(ResetTokenModel.created_at < expiry_time)
await self.session.execute(stmt)
async def get_user_by_username(self, user_name: str) -> User | None:
"""Get user by username."""
stmt = select(UserModel).where(UserModel.user_name == user_name)
result = await self.session.execute(stmt)
user_model = result.scalar_one_or_none()
if user_model:
return User(
user_id=UUID(bytes=user_model.user_id),
user_name=user_model.user_name,
created_at=user_model.created_at,
last_seen=user_model.last_seen,
visits=user_model.visits,
)
return None
# Standalone functions that handle database connections internally
async def init_database() -> None:
@@ -214,6 +310,8 @@ async def create_user_and_credential(user: User, credential: StoredCredential) -
"""Create a new user and their first credential in a single transaction."""
async with connect() as db:
await db.session.begin()
# Set visits to 1 for the new user since they're creating their first session
user.visits = 1
await db.create_user(user)
await db.create_credential(credential)
@@ -252,3 +350,39 @@ async def delete_user_credential(credential_id: bytes) -> None:
"""Delete a credential by its ID."""
async with connect() as db:
await db.delete_credential(credential_id)
async def create_new_session(user_id: UUID, credential: StoredCredential) -> None:
"""Create a new session for a user by incrementing visits and updating last_seen."""
async with connect() as db:
await db.create_new_session(user_id, credential)
async def create_reset_token(user_id: UUID, token: str | None = None) -> str:
"""Create a reset token for a user."""
async with connect() as db:
return await db.create_reset_token(user_id, token)
async def get_reset_token(token: str) -> ResetToken | None:
"""Get reset token by token string."""
async with connect() as db:
return await db.get_reset_token(token)
async def delete_reset_token(token: str) -> None:
"""Delete a reset token (used after successful credential addition)."""
async with connect() as db:
await db.delete_reset_token(token)
async def cleanup_expired_tokens() -> None:
"""Remove expired reset tokens (older than 24 hours)."""
async with connect() as db:
await db.cleanup_expired_tokens()
async def get_user_by_username(user_name: str) -> User | None:
"""Get user by username."""
async with connect() as db:
return await db.get_user_by_username(user_name)

View File

@@ -31,6 +31,7 @@ from .api_handlers import (
from .db import User
from .jwt_manager import create_session_token
from .passkey import Passkey
from .reset_handlers import create_device_addition_link, validate_device_addition_token
from .session_manager import get_user_from_cookie_string
STATIC_DIR = Path(__file__).parent.parent / "static"
@@ -127,6 +128,64 @@ async def websocket_register_add(ws: WebSocket):
await ws.send_json({"error": f"Server error: {str(e)}"})
@app.websocket("/ws/add_device_credential")
async def websocket_add_device_credential(ws: WebSocket):
"""Add a new credential for an existing user via device addition token."""
await ws.accept()
try:
# Get device addition token from client
message = await ws.receive_json()
token = message.get("token")
if not token:
await ws.send_json({"error": "Device addition token is required"})
return
# Validate device addition token
reset_token = await db.get_reset_token(token)
if not reset_token:
await ws.send_json({"error": "Invalid or expired device addition token"})
return
# Check if token is expired (24 hours)
from datetime import timedelta
expiry_time = reset_token.created_at + timedelta(hours=24)
if datetime.now() > expiry_time:
await ws.send_json({"error": "Device addition token has expired"})
return
# Get user information
user = await db.get_user_by_id(reset_token.user_id)
challenge_ids = await db.get_user_credentials(reset_token.user_id)
# WebAuthn registration
credential = await register_chat(
ws, reset_token.user_id, user.user_name, challenge_ids
)
# Store the new credential in the database
await db.create_credential_for_user(credential)
# Delete the device addition token (it's now used)
await db.delete_reset_token(token)
await ws.send_json(
{
"status": "success",
"user_id": str(reset_token.user_id),
"credential_id": credential.credential_id.hex(),
"message": "New credential added successfully via device addition token",
}
)
except ValueError as e:
await ws.send_json({"error": str(e)})
except WebSocketDisconnect:
pass
except Exception as e:
await ws.send_json({"error": f"Server error: {str(e)}"})
async def register_chat(
ws: WebSocket,
user_id: UUID,
@@ -220,6 +279,18 @@ async def api_delete_credential(request: Request):
return await delete_credential(request)
@app.post("/api/create-device-link")
async def api_create_device_link(request: Request):
"""Create a device addition link for the authenticated user."""
return await create_device_addition_link(request)
@app.post("/api/validate-device-token")
async def api_validate_device_token(request: Request):
"""Validate a device addition token."""
return await validate_device_addition_token(request)
# Serve static files
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
@@ -230,6 +301,12 @@ async def get_index():
return FileResponse(STATIC_DIR / "index.html")
@app.get("/reset/{token}")
async def get_reset_page(token: str):
"""Serve the reset page with the token in URL"""
return FileResponse(STATIC_DIR / "reset.html")
def main():
"""Entry point for the application"""
import uvicorn

View File

@@ -0,0 +1,9 @@
import secrets
from .wordlist import words
def generate(n=4, sep="."):
"""Generate a password of random words without repeating any word."""
wl = list(words)
return sep.join(wl.pop(secrets.randbelow(len(wl))) for i in range(n))

View File

@@ -0,0 +1,104 @@
"""
Device addition API handlers for WebAuthn authentication.
This module provides endpoints for authenticated users to:
- Generate device addition links with human-readable tokens
- Validate device addition tokens
- Add new passkeys to existing accounts via tokens
"""
from datetime import datetime, timedelta
from fastapi import Request
from . import db
from .passphrase import generate
from .session_manager import get_current_user
async def create_device_addition_link(request: Request) -> dict:
"""Create a device addition link for the authenticated user."""
try:
# Require authentication
user = await get_current_user(request)
if not user:
return {"error": "Authentication required"}
# Generate a human-readable token
token = generate(n=4, sep="-") # e.g., "able-ocean-forest-dawn"
# Create reset token in database
await db.create_reset_token(user.user_id, token)
# Generate the device addition link with pretty URL
addition_link = f"http://localhost:8000/reset/{token}"
return {
"status": "success",
"message": "Device addition link generated successfully",
"addition_link": addition_link,
"token": token,
"expires_in_hours": 24,
}
except Exception as e:
return {"error": f"Failed to create device addition link: {str(e)}"}
async def validate_device_addition_token(request: Request) -> dict:
"""Validate a device addition token and return user info."""
try:
body = await request.json()
token = body.get("token")
if not token:
return {"error": "Device addition token is required"}
# Get reset token
reset_token = await db.get_reset_token(token)
if not reset_token:
return {"error": "Invalid or expired device addition token"}
# Check if token is expired (24 hours)
expiry_time = reset_token.created_at + timedelta(hours=24)
if datetime.now() > expiry_time:
return {"error": "Device addition token has expired"}
# Get user info
user = await db.get_user_by_id(reset_token.user_id)
return {
"status": "success",
"valid": True,
"user_id": str(user.user_id),
"user_name": user.user_name,
"token": token,
}
except Exception as e:
return {"error": f"Failed to validate device addition token: {str(e)}"}
async def use_device_addition_token(token: str) -> dict:
"""Delete a device addition token after successful use."""
try:
# Get reset token first to validate it exists and is not expired
reset_token = await db.get_reset_token(token)
if not reset_token:
return {"error": "Invalid or expired device addition token"}
# Check if token is expired (24 hours)
expiry_time = reset_token.created_at + timedelta(hours=24)
if datetime.now() > expiry_time:
return {"error": "Device addition token has expired"}
# Delete the token (it's now used)
await db.delete_reset_token(token)
return {
"status": "success",
"message": "Device addition token used successfully",
}
except Exception as e:
return {"error": f"Failed to use device addition token: {str(e)}"}

54
passkeyauth/wordlist.py Normal file
View File

@@ -0,0 +1,54 @@
# A custom list of 1024 common 3-6 letter words, with unique 3-prefixes and no prefix words, entropy 2.1b/letter 10b/word
words: list = """
able about absent abuse access acid across act adapt add adjust admit adult advice affair afraid again age agree ahead
aim air aisle alarm album alert alien all almost alone alpha also alter always amazed among amused anchor angle animal
ankle annual answer any apart appear april arch are argue army around array art ascent ash ask aspect assume asthma atom
attack audit august aunt author avoid away awful axis baby back bad bag ball bamboo bank bar base battle beach become
beef before begin behind below bench best better beyond bid bike bind bio birth bitter black bleak blind blood blue
board body boil bomb bone book border boss bottom bounce bowl box boy brain bread bring brown brush bubble buck budget
build bulk bundle burden bus but buyer buzz cable cache cage cake call came can car case catch cause cave celery cement
census cereal change check child choice chunk cigar circle city civil class clean client close club coast code coffee
coil cold come cool copy core cost cotton couch cover coyote craft cream crime cross cruel cry cube cue cult cup curve
custom cute cycle dad damage danger daring dash dawn day deal debate decide deer define degree deity delay demand denial
depth derive design detail device dial dice die differ dim dinner direct dish divert dizzy doctor dog dollar domain
donate door dose double dove draft dream drive drop drum dry duck dumb dune during dust dutch dwarf eager early east
echo eco edge edit effort egg eight either elbow elder elite else embark emerge emily employ enable end enemy engine
enjoy enlist enough enrich ensure entire envy equal era erode error erupt escape essay estate ethics evil evoke exact
excess exist exotic expect extent eye fabric face fade faith fall family fan far father fault feel female fence fetch
fever few fiber field figure file find first fish fit fix flat flesh flight float fluid fly foam focus fog foil follow
food force fossil found fox frame fresh friend frog fruit fuel fun fury future gadget gain galaxy game gap garden gas
gate gauge gaze genius ghost giant gift giggle ginger girl give glass glide globe glue goal god gold good gospel govern
gown grant great grid group grunt guard guess guide gulf gun gym habit hair half hammer hand happy hard hat have hawk
hay hazard head hedge height help hen hero hidden high hill hint hip hire hobby hockey hold home honey hood hope horse
host hotel hour hover how hub huge human hungry hurt hybrid ice icon idea idle ignore ill image immune impact income
index infant inhale inject inmate inner input inside into invest iron island issue italy item ivory jacket jaguar james
jar jazz jeans jelly jewel job joe joke joy judge juice july jump june just kansas kate keep kernel key kick kid kind
kiss kit kiwi knee knife know labor lady lag lake lamp laptop large later laugh lava law layer lazy leader left legal
lemon length lesson letter level liar libya lid life light like limit line lion liquid list little live lizard load
local logic long loop lost loud love low loyal lucky lumber lunch lust luxury lyrics mad magic main major make male
mammal man map market mass matter maze mccoy meadow media meet melt member men mercy mesh method middle milk mimic mind
mirror miss mix mobile model mom monkey moon more mother mouse move much muffin mule must mutual myself myth naive name
napkin narrow nasty nation near neck need nephew nerve nest net never news next nice night noble noise noodle normal
nose note novel now number nurse nut oak obey object oblige obtain occur ocean odor off often oil okay old olive omit
once one onion online open opium oppose option orange orbit order organ orient orphan other outer oval oven own oxygen
oyster ozone pact paddle page pair palace panel paper parade past path pause pave paw pay peace pen people pepper permit
pet philip phone phrase piano pick piece pig pilot pink pipe pistol pitch pizza place please pluck poem point polar pond
pool post pot pound powder praise prefer price profit public pull punch pupil purity push put puzzle qatar quasi queen
quite quoted rabbit race radio rail rally ramp range rapid rare rather raven raw razor real rebel recall red reform
region reject relief remain rent reopen report result return review reward rhythm rib rich ride rifle right ring riot
ripple risk ritual river road robot rocket room rose rotate round row royal rubber rude rug rule run rural sad safe sage
sail salad same santa sauce save say scale scene school scope screen scuba sea second seed self semi sense series settle
seven shadow she ship shock shrimp shy sick side siege sign silver simple since siren sister six size skate sketch ski
skull slab sleep slight slogan slush small smile smooth snake sniff snow soap soccer soda soft solid son soon sort south
space speak sphere spirit split spoil spring spy square state step still story strong stuff style submit such sudden
suffer sugar suit summer sun supply sure swamp sweet switch sword symbol syntax syria system table tackle tag tail talk
tank tape target task tattoo taxi team tell ten term test text that theme this three thumb tibet ticket tide tight tilt
time tiny tip tired tissue title toast today toe toilet token tomato tone tool top torch toss total toward toy trade
tree trial trophy true try tube tumble tunnel turn twenty twice two type ugly unable uncle under unfair unique unlock
until unveil update uphold upon upper upset urban urge usage use usual vacuum vague valid van vapor vast vault vein
velvet vendor very vessel viable video view villa violin virus visit vital vivid vocal voice volume vote voyage wage
wait wall want war wash water wave way wealth web weird were west wet what when whip wide wife will window wire wish
wolf woman wonder wood work wrap wreck write wrong xander xbox xerox xray yang yard year yellow yes yin york you zane
zara zebra zen zero zippo zone zoo zorro zulu
""".split()
assert len(words) == 1024 # Exactly 10 bits of entropy per word