diff --git a/passkeyauth/api_handlers.py b/passkeyauth/api_handlers.py index 25d3947..fcc84b8 100644 --- a/passkeyauth/api_handlers.py +++ b/passkeyauth/api_handlers.py @@ -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: diff --git a/passkeyauth/db.py b/passkeyauth/db.py index 30c2338..37151b6 100644 --- a/passkeyauth/db.py +++ b/passkeyauth/db.py @@ -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) diff --git a/passkeyauth/main.py b/passkeyauth/main.py index 9625b2f..5979188 100644 --- a/passkeyauth/main.py +++ b/passkeyauth/main.py @@ -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 diff --git a/passkeyauth/passphrase.py b/passkeyauth/passphrase.py new file mode 100644 index 0000000..9c2b055 --- /dev/null +++ b/passkeyauth/passphrase.py @@ -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)) diff --git a/passkeyauth/reset_handlers.py b/passkeyauth/reset_handlers.py new file mode 100644 index 0000000..e51144f --- /dev/null +++ b/passkeyauth/reset_handlers.py @@ -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)}"} diff --git a/passkeyauth/wordlist.py b/passkeyauth/wordlist.py new file mode 100644 index 0000000..d7a1e3a --- /dev/null +++ b/passkeyauth/wordlist.py @@ -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 diff --git a/static/app.js b/static/app.js index f83a8fd..79628d1 100644 --- a/static/app.js +++ b/static/app.js @@ -5,7 +5,10 @@ let currentUser = null let currentCredentials = [] let aaguidInfo = {} -// Session management - now using HTTP-only cookies +// ======================================== +// Session Management +// ======================================== + async function validateStoredToken() { try { const response = await fetch('/api/validate-token', { @@ -14,18 +17,12 @@ async function validateStoredToken() { }) const result = await response.json() - - if (result.status === 'success') { - return true - } else { - return false - } + return result.status === 'success' } catch (error) { return false } } -// Helper function to set session cookie using JWT token async function setSessionCookie(sessionToken) { try { const response = await fetch('/api/set-session', { @@ -48,7 +45,10 @@ async function setSessionCookie(sessionToken) { } } -// View management +// ======================================== +// View Management +// ======================================== + function showView(viewId) { document.querySelectorAll('.view').forEach(view => view.classList.remove('active')) document.getElementById(viewId).classList.add('active') @@ -64,7 +64,11 @@ function showRegisterView() { clearStatus('registerStatus') } -// Update dashboard view to load user info +function showDeviceAdditionView() { + showView('deviceAdditionView') + clearStatus('deviceAdditionStatus') +} + function showDashboardView() { showView('dashboardView') clearStatus('dashboardStatus') @@ -76,7 +80,10 @@ function showDashboardView() { }) } -// Status management +// ======================================== +// Status Management +// ======================================== + function showStatus(elementId, message, type = 'info') { const statusEl = document.getElementById(elementId) statusEl.innerHTML = `
Visits: ${currentUser.visits || 0}
+Member since: ${currentUser.created_at ? formatHumanReadableDate(currentUser.created_at) : 'N/A'}
+Last seen: ${currentUser.last_seen ? formatHumanReadableDate(currentUser.last_seen) : 'N/A'}
` } } @@ -306,75 +471,6 @@ async function checkExistingSession() { } } -// Add new credential for logged-in user -async function addNewCredential() { - try { - showStatus('dashboardStatus', 'Starting new passkey registration...', 'info') - - const ws = await aWebSocket('/ws/add_credential') - - // Registration chat - no need to send user data since we're authenticated - const optionsJSON = JSON.parse(await ws.recv()) - if (optionsJSON.error) throw new Error(optionsJSON.error) - - showStatus('dashboardStatus', 'Save new passkey to your authenticator...', 'info') - - const registrationResponse = await startRegistration({optionsJSON}) - ws.send(JSON.stringify(registrationResponse)) - - const result = JSON.parse(await ws.recv()) - if (result.error) throw new Error(`Server: ${result.error}`) - - ws.close() - - showStatus('dashboardStatus', 'New passkey added successfully!', 'success') - - // Refresh credentials list to show the new credential - await loadCredentials() - clearStatus('dashboardStatus') - - } catch (error) { - showStatus('dashboardStatus', 'Registration cancelled', 'error') - } -} - -// Delete credential -async function deleteCredential(credentialId) { - if (!confirm('Are you sure you want to delete this passkey? This action cannot be undone.')) { - return - } - - try { - showStatus('dashboardStatus', 'Deleting passkey...', 'info') - - const response = await fetch('/api/delete-credential', { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - credentials: 'include', - body: JSON.stringify({ - credential_id: credentialId - }) - }) - - const result = await response.json() - - if (result.error) { - throw new Error(result.error) - } - - showStatus('dashboardStatus', 'Passkey deleted successfully!', 'success') - - // Refresh credentials list - await loadCredentials() - clearStatus('dashboardStatus') - - } catch (error) { - showStatus('dashboardStatus', `Failed to delete passkey: ${error.message}`, 'error') - } -} - // Form event handlers document.addEventListener('DOMContentLoaded', function() { // Check for existing session on page load diff --git a/static/index.html b/static/index.html index cc2f900..ae351b1 100644 --- a/static/index.html +++ b/static/index.html @@ -2,244 +2,10 @@Share this link to add this account to another device:
+ +Scan this QR code with your other device
+Loading...
+ +⚠️ This link expires in 24 hours and can only be used once.
+Human-readable code:
t |