Mostly working, saving.
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
9
passkeyauth/passphrase.py
Normal file
9
passkeyauth/passphrase.py
Normal 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))
|
||||
104
passkeyauth/reset_handlers.py
Normal file
104
passkeyauth/reset_handlers.py
Normal 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
54
passkeyauth/wordlist.py
Normal 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
|
||||
Reference in New Issue
Block a user