Refactoring done, bugs gone.

This commit is contained in:
Leo Vasanko 2025-07-03 18:46:05 -06:00
parent 58f7ac61db
commit 1b7fa16cc0
5 changed files with 213 additions and 176 deletions

View File

@ -1,149 +1,167 @@
import sqlite3 """
Async database implementation for WebAuthn passkey authentication.
This module provides an async database layer using dataclasses and aiosqlite
for managing users and credentials in a WebAuthn authentication system.
"""
from dataclasses import dataclass
from datetime import datetime
from typing import Optional
import aiosqlite
DB_PATH = "webauthn.db" DB_PATH = "webauthn.db"
# SQL Statements
def init_database(): SQL_CREATE_USERS = """
"""Initialize the SQLite database with required tables"""
conn = sqlite3.connect(DB_PATH)
cursor = conn.cursor()
# Create users table
cursor.execute(
"""
CREATE TABLE IF NOT EXISTS users ( CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT, user_id BINARY(16) PRIMARY KEY NOT NULL,
username TEXT UNIQUE NOT NULL, user_name TEXT NOT NULL,
user_id BLOB NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) )
""" """
)
# Create credentials table SQL_CREATE_CREDENTIALS = """
cursor.execute(
"""
CREATE TABLE IF NOT EXISTS credentials ( CREATE TABLE IF NOT EXISTS credentials (
id INTEGER PRIMARY KEY AUTOINCREMENT, credential_id BINARY(64) PRIMARY KEY NOT NULL,
user_id INTEGER NOT NULL, user_id BINARY(16) NOT NULL,
credential_id BLOB NOT NULL, aaguid BINARY(16) NOT NULL,
public_key BLOB NOT NULL, public_key BLOB NOT NULL,
sign_count INTEGER DEFAULT 0, sign_count INTEGER DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users (id), last_used TIMESTAMP NULL,
UNIQUE(credential_id) FOREIGN KEY (user_id) REFERENCES users (user_id) ON DELETE CASCADE
)
"""
) )
"""
conn.commit() SQL_GET_USER_BY_USER_ID = """
conn.close() SELECT * FROM users WHERE user_id = ?
"""
SQL_CREATE_USER = """
INSERT INTO users (user_id, user_name) VALUES (?, ?)
"""
def get_user_by_username(username: str) -> dict | None: SQL_STORE_CREDENTIAL = """
"""Get user record by username""" INSERT INTO credentials (credential_id, user_id, aaguid, public_key, sign_count)
conn = sqlite3.connect(DB_PATH) VALUES (?, ?, ?, ?, ?)
cursor = conn.cursor() """
cursor.execute(
"SELECT id, username, user_id FROM users WHERE username = ?", (username,)
)
row = cursor.fetchone()
conn.close()
if row: SQL_GET_CREDENTIAL_BY_ID = """
return {"id": row[0], "username": row[1], "user_id": row[2]} SELECT credential_id, user_id, aaguid, public_key, sign_count, created_at, last_used
return None FROM credentials
WHERE credential_id = ?
"""
SQL_GET_USER_CREDENTIALS = """
def get_user_by_user_id(user_id: bytes) -> dict | None:
"""Get user record by WebAuthn user ID"""
conn = sqlite3.connect(DB_PATH)
cursor = conn.cursor()
cursor.execute(
"SELECT id, username, user_id FROM users WHERE user_id = ?", (user_id,)
)
row = cursor.fetchone()
conn.close()
if row:
return {"id": row[0], "username": row[1], "user_id": row[2]}
return None
def create_user(username: str, user_id: bytes) -> int:
"""Create a new user and return the user ID"""
conn = sqlite3.connect(DB_PATH)
cursor = conn.cursor()
cursor.execute(
"INSERT INTO users (username, user_id) VALUES (?, ?)", (username, user_id)
)
user_db_id = cursor.lastrowid
conn.commit()
conn.close()
if user_db_id is None:
raise RuntimeError("Failed to create user")
return user_db_id
def store_credential(user_db_id: int, credential_id: bytes, public_key: bytes) -> None:
"""Store a credential for a user"""
conn = sqlite3.connect(DB_PATH)
cursor = conn.cursor()
cursor.execute(
"INSERT INTO credentials (user_id, credential_id, public_key) VALUES (?, ?, ?)",
(user_db_id, credential_id, public_key),
)
conn.commit()
conn.close()
def get_credential_by_id(credential_id: bytes) -> dict | None:
"""Get credential by credential ID"""
conn = sqlite3.connect(DB_PATH)
cursor = conn.cursor()
cursor.execute(
"""
SELECT c.public_key, c.sign_count, u.username
FROM credentials c
JOIN users u ON c.user_id = u.id
WHERE c.credential_id = ?
""",
(credential_id,),
)
row = cursor.fetchone()
conn.close()
if row:
return {"public_key": row[0], "sign_count": row[1], "username": row[2]}
return None
def get_user_credentials(username: str) -> list[bytes]:
"""Get all credential IDs for a user"""
conn = sqlite3.connect(DB_PATH)
cursor = conn.cursor()
cursor.execute(
"""
SELECT c.credential_id SELECT c.credential_id
FROM credentials c FROM credentials c
JOIN users u ON c.user_id = u.id JOIN users u ON c.user_id = u.user_id
WHERE u.username = ? WHERE u.user_name = ?
""", """
(username,),
SQL_UPDATE_CREDENTIAL_SIGN_COUNT = """
UPDATE credentials
SET sign_count = ?, last_used = CURRENT_TIMESTAMP
WHERE credential_id = ?
"""
@dataclass
class User:
"""User data model."""
user_id: bytes = b""
user_name: str = ""
created_at: Optional[datetime] = None
@dataclass
class Credential:
"""Credential data model."""
credential_id: bytes = b""
user_id: bytes = b""
aaguid: bytes = b""
public_key: bytes = b""
sign_count: int = 0
created_at: Optional[datetime] = None
last_used: Optional[datetime] = None
class Database:
"""Async database handler for WebAuthn operations."""
def __init__(self, db_path: str = DB_PATH):
self.db_path = db_path
async def init_database(self):
"""Initialize the SQLite database with required tables."""
async with aiosqlite.connect(self.db_path) as conn:
await conn.execute(SQL_CREATE_USERS)
await conn.execute(SQL_CREATE_CREDENTIALS)
await conn.commit()
async def get_user_by_user_id(self, user_id: bytes) -> User:
"""Get user record by WebAuthn user ID."""
async with aiosqlite.connect(self.db_path) as conn:
async with conn.execute(SQL_GET_USER_BY_USER_ID, (user_id,)) as cursor:
row = await cursor.fetchone()
if row:
return User(user_id=row[0], user_name=row[1], created_at=row[2])
raise ValueError("User not found")
async def create_user(self, user_id: bytes, user_name: str) -> User:
"""Create a new user and return the User dataclass."""
async with aiosqlite.connect(self.db_path) as conn:
await conn.execute(SQL_CREATE_USER, (user_id, user_name))
await conn.commit()
return User(user_id=user_id, user_name=user_name)
async def store_credential(self, credential: Credential) -> None:
"""Store a credential for a user."""
async with aiosqlite.connect(self.db_path) as conn:
await conn.execute(
SQL_STORE_CREDENTIAL,
(
credential.credential_id,
credential.user_id,
credential.aaguid,
credential.public_key,
credential.sign_count,
),
) )
rows = cursor.fetchall() await conn.commit()
conn.close()
return [row[0] for row in rows] async def get_credential_by_id(self, credential_id: bytes) -> Credential:
"""Get credential by credential ID."""
async with aiosqlite.connect(self.db_path) as conn:
def update_credential_sign_count(credential_id: bytes, sign_count: int) -> None: async with conn.execute(
"""Update the sign count for a credential""" SQL_GET_CREDENTIAL_BY_ID, (credential_id,)
conn = sqlite3.connect(DB_PATH) ) as cursor:
cursor = conn.cursor() row = await cursor.fetchone()
cursor.execute( if row:
"UPDATE credentials SET sign_count = ? WHERE credential_id = ?", return Credential(
(sign_count, credential_id), credential_id=row[0],
user_id=row[1],
aaguid=row[2],
public_key=row[3],
sign_count=row[4],
created_at=row[5],
last_used=row[6],
) )
conn.commit() raise ValueError("Credential not found")
conn.close()
async def update_credential(self, credential: Credential) -> None:
"""Update the sign count for a credential."""
async with aiosqlite.connect(self.db_path) as conn:
await conn.execute(
SQL_UPDATE_CREDENTIAL_SIGN_COUNT,
(credential.sign_count, credential.credential_id),
)
await conn.commit()
# Global database instance
db = Database()

View File

@ -5,19 +5,19 @@ This module provides a simple WebAuthn implementation that:
- Uses WebSocket for real-time communication - Uses WebSocket for real-time communication
- Supports Resident Keys (discoverable credentials) for passwordless authentication - Supports Resident Keys (discoverable credentials) for passwordless authentication
- Maintains challenges locally per connection - Maintains challenges locally per connection
- Uses SQLite database for persistent storage of users and credentials - Uses async SQLite database for persistent storage of users and credentials
- Enables true passwordless authentication where users don't need to enter a username - Enables true passwordless authentication where users don't need to enter a user_name
""" """
from pathlib import Path from pathlib import Path
import db
import uuid7 import uuid7
from fastapi import FastAPI, WebSocket, WebSocketDisconnect from fastapi import FastAPI, WebSocket, WebSocketDisconnect
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from passkeyauth.passkey import Passkey from .db import Credential, db
from .passkey import Passkey
STATIC_DIR = Path(__file__).parent.parent / "static" STATIC_DIR = Path(__file__).parent.parent / "static"
@ -30,6 +30,12 @@ passkey = Passkey(
app = FastAPI(title="Passkey Auth") app = FastAPI(title="Passkey Auth")
@app.on_event("startup")
async def startup_event():
"""Initialize database on startup."""
await db.init_database()
@app.websocket("/ws/new_user_registration") @app.websocket("/ws/new_user_registration")
async def websocket_register_new(ws: WebSocket): async def websocket_register_new(ws: WebSocket):
"""Register a new user and with a new passkey credential.""" """Register a new user and with a new passkey credential."""
@ -38,40 +44,53 @@ async def websocket_register_new(ws: WebSocket):
form = await ws.receive_json() form = await ws.receive_json()
user_id = uuid7.create().bytes user_id = uuid7.create().bytes
user_name = form["user_name"] user_name = form["user_name"]
await register_chat(ws, user_id, username)
# Generate registration options and handle registration
credential, verified = await register_chat(ws, user_id, user_name)
# Store the user in the database # Store the user in the database
await db.create_user(user_name, user_id) await db.create_user(user_id, user_name)
await db.store_credential(
Credential(
credential_id=credential.raw_id,
user_id=user_id,
aaguid=b"", # verified.aaguid,
public_key=verified.credential_public_key,
sign_count=verified.sign_count,
)
)
await ws.send_json({"status": "success", "user_id": user_id.hex()}) await ws.send_json({"status": "success", "user_id": user_id.hex()})
except WebSocketDisconnect: except WebSocketDisconnect:
pass pass
async def register_chat(ws: WebSocket, user_id: bytes, username: str): async def register_chat(ws: WebSocket, user_id: bytes, user_name: str):
"""Generate registration options and send them to the client.""" """Generate registration options and send them to the client."""
options, challenge = passkey.reg_generate_options( options, challenge = passkey.reg_generate_options(
user_id=user_id, user_id=user_id,
username=username, user_name=user_name,
) )
await ws.send_text(options) await ws.send_json(options)
# Wait for the client to use his authenticator to register # Wait for the client to use his authenticator to register
credential = passkey.reg_credential(await ws.receive_json()) credential = passkey.reg_credential(await ws.receive_json())
passkey.reg_verify(credential, challenge) verified_registration = passkey.reg_verify(credential, challenge)
return credential, verified_registration
@app.websocket("/ws/authenticate") @app.websocket("/ws/authenticate")
async def websocket_authenticate(ws: WebSocket): async def websocket_authenticate(ws: WebSocket):
await ws.accept() await ws.accept()
try: try:
options = passkey.auth_generate_options() options, challenge = await passkey.auth_generate_options()
await ws.send_json(options) await ws.send_json(options)
# Wait for the client to use his authenticator to authenticate # Wait for the client to use his authenticator to authenticate
credential = passkey.auth_credential(await ws.receive_json()) credential = passkey.auth_credential(await ws.receive_json())
# Fetch from the database by credential ID # Fetch from the database by credential ID
stored_cred = await db.fetch_credential(credential.raw_id) stored_cred = await db.get_credential_by_id(credential.raw_id)
# Verify the credential matches the stored data, that is also updated # Verify the credential matches the stored data
passkey.auth_verify(credential, stored_cred) _ = await passkey.auth_verify(credential, challenge, stored_cred)
# Update the credential in the database
await db.update_credential(stored_cred) await db.update_credential(stored_cred)
await ws.send_json({"status": "success"})
except WebSocketDisconnect: except WebSocketDisconnect:
pass pass
@ -99,8 +118,5 @@ def main():
) )
# Initialize database on startup
db.init_database()
if __name__ == "__main__": if __name__ == "__main__":
main() main()

View File

@ -7,6 +7,8 @@ This module provides a unified interface for WebAuthn operations including:
- Credential validation - Credential validation
""" """
import json
from webauthn import ( from webauthn import (
generate_authentication_options, generate_authentication_options,
generate_registration_options, generate_registration_options,
@ -53,32 +55,32 @@ class Passkey:
self.origin = origin self.origin = origin
self.supported_pub_key_algs = supported_pub_key_algs or [ self.supported_pub_key_algs = supported_pub_key_algs or [
COSEAlgorithmIdentifier.EDDSA, COSEAlgorithmIdentifier.EDDSA,
# COSEAlgorithmIdentifier.ECDSA_SHA_256, COSEAlgorithmIdentifier.ECDSA_SHA_256,
# COSEAlgorithmIdentifier.RSASSA_PKCS1_v1_5_SHA_256, COSEAlgorithmIdentifier.RSASSA_PKCS1_v1_5_SHA_256,
] ]
### Registration Methods ### ### Registration Methods ###
def reg_generate_options( def reg_generate_options(
self, user_id: bytes, username: str, display_name="", **regopts self, user_id: bytes, user_name: str, display_name="", **regopts
) -> tuple[str, bytes]: ) -> tuple[dict, bytes]:
""" """
Generate registration options for WebAuthn registration. Generate registration options for WebAuthn registration.
Args: Args:
user_id: The user ID as bytes user_id: The user ID as bytes
username: The username user_name: The username
display_name: The display name (defaults to username if empty) display_name: The display name (defaults to user_name if empty)
Returns: Returns:
JSON string containing registration options JSON dict containing options to be sent to client, challenge bytes to store
""" """
options = generate_registration_options( options = generate_registration_options(
rp_id=self.rp_id, rp_id=self.rp_id,
rp_name=self.rp_name, rp_name=self.rp_name,
user_id=user_id, user_id=user_id,
user_name=username, user_name=user_name,
user_display_name=display_name or username, user_display_name=display_name or user_name,
authenticator_selection=AuthenticatorSelectionCriteria( authenticator_selection=AuthenticatorSelectionCriteria(
resident_key=ResidentKeyRequirement.REQUIRED, resident_key=ResidentKeyRequirement.REQUIRED,
user_verification=UserVerificationRequirement.PREFERRED, user_verification=UserVerificationRequirement.PREFERRED,
@ -86,7 +88,7 @@ class Passkey:
supported_pub_key_algs=self.supported_pub_key_algs, supported_pub_key_algs=self.supported_pub_key_algs,
**regopts, **regopts,
) )
return options_to_json(options), options.challenge return json.loads(options_to_json(options)), options.challenge
@staticmethod @staticmethod
def reg_credential(credential: dict | str) -> RegistrationCredential: def reg_credential(credential: dict | str) -> RegistrationCredential:
@ -119,14 +121,14 @@ class Passkey:
async def auth_generate_options( async def auth_generate_options(
self, user_verification_required=False, **kwopts self, user_verification_required=False, **kwopts
) -> str: ) -> tuple[dict, bytes]:
""" """
Generate authentication options for WebAuthn authentication. Generate authentication options for WebAuthn authentication.
Args: Args:
user_verification_required: The user will have to re-enter PIN or use biometrics for this operation. Useful when accessing security settings etc. user_verification_required: The user will have to re-enter PIN or use biometrics for this operation. Useful when accessing security settings etc.
Returns: Returns:
JSON string containing authentication options Tuple of (JSON to be sent to client, challenge bytes to store)
""" """
options = generate_authentication_options( options = generate_authentication_options(
rp_id=self.rp_id, rp_id=self.rp_id,
@ -137,7 +139,7 @@ class Passkey:
), ),
**kwopts, **kwopts,
) )
return options_to_json(options) return json.loads(options_to_json(options)), options.challenge
@staticmethod @staticmethod
def auth_credential(credential: dict | str) -> AuthenticationCredential: def auth_credential(credential: dict | str) -> AuthenticationCredential:
@ -148,7 +150,7 @@ class Passkey:
self, self,
credential: AuthenticationCredential, credential: AuthenticationCredential,
expected_challenge: bytes, expected_challenge: bytes,
stored_cred: dict, stored_cred,
): ):
""" """
Verify authentication response against locally stored credential data. Verify authentication response against locally stored credential data.
@ -159,8 +161,8 @@ class Passkey:
expected_challenge=expected_challenge, expected_challenge=expected_challenge,
expected_origin=self.origin, expected_origin=self.origin,
expected_rp_id=self.rp_id, expected_rp_id=self.rp_id,
credential_public_key=stored_cred["public_key"], credential_public_key=stored_cred.public_key,
credential_current_sign_count=stored_cred["sign_count"], credential_current_sign_count=stored_cred.sign_count,
) )
stored_cred["sign_count"] = verification.new_sign_count stored_cred.sign_count = verification.new_sign_count
return verification return verification

View File

@ -15,6 +15,8 @@ dependencies = [
"websockets>=12.0", "websockets>=12.0",
"webauthn>=1.11.1", "webauthn>=1.11.1",
"base64url>=1.0.0", "base64url>=1.0.0",
"aiosqlite>=0.19.0",
"uuid7-standard>=1.0.0",
] ]
requires-python = ">=3.10" requires-python = ">=3.10"

View File

@ -1,9 +1,9 @@
const { startRegistration, startAuthentication } = SimpleWebAuthnBrowser const { startRegistration, startAuthentication } = SimpleWebAuthnBrowser
async function register(username) { async function register(user_name) {
const ws = await aWebSocket('/ws/new_user_registration')
ws.send(JSON.stringify({user_name}))
// Registration chat // Registration chat
const ws = await aWebSocket('/ws/register')
ws.send(username)
const optionsJSON = JSON.parse(await ws.recv()) const optionsJSON = JSON.parse(await ws.recv())
if (optionsJSON.error) throw new Error(optionsJSON.error) if (optionsJSON.error) throw new Error(optionsJSON.error)
ws.send(JSON.stringify(await startRegistration({optionsJSON}))) ws.send(JSON.stringify(await startRegistration({optionsJSON})))
@ -14,10 +14,9 @@ async function register(username) {
async function authenticate() { async function authenticate() {
// Authentication chat // Authentication chat
const ws = await aWebSocket('/ws/authenticate') const ws = await aWebSocket('/ws/authenticate')
ws.send('') // Send empty string to trigger authentication
const optionsJSON = JSON.parse(await ws.recv()) const optionsJSON = JSON.parse(await ws.recv())
if (optionsJSON.error) throw new Error(optionsJSON.error) if (optionsJSON.error) throw new Error(optionsJSON.error)
ws.send(JSON.stringify(await startAuthentication({optionsJSON}))) await ws.send(JSON.stringify(await startAuthentication({optionsJSON})))
const result = JSON.parse(await ws.recv()) const result = JSON.parse(await ws.recv())
if (result.error) throw new Error(`Server: ${result.error}`) if (result.error) throw new Error(`Server: ${result.error}`)
return result return result
@ -29,9 +28,9 @@ async function authenticate() {
regForm.addEventListener('submit', ev => { regForm.addEventListener('submit', ev => {
ev.preventDefault() ev.preventDefault()
regSubmitBtn.disabled = true regSubmitBtn.disabled = true
const username = (new FormData(regForm)).get('username') const user_name = (new FormData(regForm)).get('username')
register(username).then(() => { register(user_name).then(() => {
alert(`Registration successful for ${username}!`) alert(`Registration successful for ${user_name}!`)
}).catch(err => { }).catch(err => {
alert(`Registration failed: ${err.message}`) alert(`Registration failed: ${err.message}`)
}).finally(() => { }).finally(() => {
@ -45,7 +44,7 @@ async function authenticate() {
ev.preventDefault() ev.preventDefault()
authSubmitBtn.disabled = true authSubmitBtn.disabled = true
authenticate().then(result => { authenticate().then(result => {
alert(`Authentication successful! Welcome ${result.username}`) alert(`Authentication successful!`)
}).catch(err => { }).catch(err => {
alert(`Authentication failed: ${err.message}`) alert(`Authentication failed: ${err.message}`)
}).finally(() => { }).finally(() => {