Major refactor: HTTP-only cookies, passkey management, and UI improvements
- Refactor session management from WebSocket tokens to HTTP-only cookies - Move user/credential endpoints from WebSocket to HTTP REST API - Add comprehensive passkey management (add/delete with safety checks) - Implement AAGUID-based authenticator info with icons and names - Add human-readable date formatting and clean grid layout - Create modular architecture with session_manager, api_handlers, aaguid_manager
This commit is contained in:
parent
9c2b7cf450
commit
eb56c000e8
65
passkeyauth/aaguid_manager.py
Normal file
65
passkeyauth/aaguid_manager.py
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
"""
|
||||||
|
AAGUID (Authenticator Attestation GUID) management for WebAuthn credentials.
|
||||||
|
|
||||||
|
This module provides functionality to:
|
||||||
|
- Load AAGUID data from JSON file
|
||||||
|
- Look up authenticator information by AAGUID
|
||||||
|
- Return only relevant AAGUID data for user credentials
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
# Path to the AAGUID JSON file
|
||||||
|
AAGUID_FILE = Path(__file__).parent / "combined_aaguid.json"
|
||||||
|
|
||||||
|
|
||||||
|
class AAGUIDManager:
|
||||||
|
"""Manages AAGUID data and lookups."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.aaguid_data: dict[str, dict] = {}
|
||||||
|
self.load_aaguid_data()
|
||||||
|
|
||||||
|
def load_aaguid_data(self) -> None:
|
||||||
|
"""Load AAGUID data from the JSON file."""
|
||||||
|
try:
|
||||||
|
with open(AAGUID_FILE, encoding="utf-8") as f:
|
||||||
|
self.aaguid_data = json.load(f)
|
||||||
|
except (FileNotFoundError, json.JSONDecodeError) as e:
|
||||||
|
print(f"Warning: Could not load AAGUID data: {e}")
|
||||||
|
self.aaguid_data = {}
|
||||||
|
|
||||||
|
def get_authenticator_info(self, aaguid: str) -> Optional[dict]:
|
||||||
|
"""Get authenticator information for a specific AAGUID."""
|
||||||
|
return self.aaguid_data.get(aaguid)
|
||||||
|
|
||||||
|
def get_relevant_aaguids(self, aaguids: set[str]) -> dict[str, dict]:
|
||||||
|
"""
|
||||||
|
Get AAGUID information only for the provided set of AAGUIDs.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
aaguids: Set of AAGUID strings that the user has credentials for
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary mapping AAGUID to authenticator information for only
|
||||||
|
the AAGUIDs that the user has and that we have data for
|
||||||
|
"""
|
||||||
|
relevant = {}
|
||||||
|
for aaguid in aaguids:
|
||||||
|
if aaguid in self.aaguid_data:
|
||||||
|
relevant[aaguid] = self.aaguid_data[aaguid]
|
||||||
|
return relevant
|
||||||
|
|
||||||
|
|
||||||
|
# Global AAGUID manager instance
|
||||||
|
_aaguid_manager: Optional[AAGUIDManager] = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_aaguid_manager() -> AAGUIDManager:
|
||||||
|
"""Get the global AAGUID manager instance."""
|
||||||
|
global _aaguid_manager
|
||||||
|
if _aaguid_manager is None:
|
||||||
|
_aaguid_manager = AAGUIDManager()
|
||||||
|
return _aaguid_manager
|
247
passkeyauth/api_handlers.py
Normal file
247
passkeyauth/api_handlers.py
Normal file
@ -0,0 +1,247 @@
|
|||||||
|
"""
|
||||||
|
API endpoints for user management and session handling.
|
||||||
|
|
||||||
|
This module contains all the HTTP API endpoints for:
|
||||||
|
- User information retrieval
|
||||||
|
- User credentials management
|
||||||
|
- Session token validation and refresh
|
||||||
|
- Login/logout functionality
|
||||||
|
"""
|
||||||
|
|
||||||
|
from fastapi import Request, Response
|
||||||
|
|
||||||
|
from .aaguid_manager import get_aaguid_manager
|
||||||
|
from .db import connect
|
||||||
|
from .jwt_manager import refresh_session_token, validate_session_token
|
||||||
|
from .session_manager import (
|
||||||
|
clear_session_cookie,
|
||||||
|
get_current_user,
|
||||||
|
get_session_token_from_auth_header_or_body,
|
||||||
|
get_session_token_from_request,
|
||||||
|
set_session_cookie,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_user_info(request: Request) -> dict:
|
||||||
|
"""Get user information from session cookie."""
|
||||||
|
try:
|
||||||
|
user = await get_current_user(request)
|
||||||
|
if not user:
|
||||||
|
return {"error": "Not authenticated"}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "success",
|
||||||
|
"user": {
|
||||||
|
"user_id": str(user.user_id),
|
||||||
|
"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,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
return {"error": f"Failed to get user info: {str(e)}"}
|
||||||
|
|
||||||
|
|
||||||
|
async def get_user_credentials(request: Request) -> dict:
|
||||||
|
"""Get all credentials for a user using session cookie."""
|
||||||
|
try:
|
||||||
|
user = await get_current_user(request)
|
||||||
|
if not user:
|
||||||
|
return {"error": "Not authenticated"}
|
||||||
|
|
||||||
|
# Get current session credential ID
|
||||||
|
current_credential_id = None
|
||||||
|
session_token = get_session_token_from_request(request)
|
||||||
|
if session_token:
|
||||||
|
token_data = validate_session_token(session_token)
|
||||||
|
if token_data:
|
||||||
|
current_credential_id = token_data.get("credential_id")
|
||||||
|
|
||||||
|
async with connect() as db:
|
||||||
|
# Get all credentials for the user
|
||||||
|
credential_ids = await db.get_credentials_by_user_id(user.user_id.bytes)
|
||||||
|
|
||||||
|
credentials = []
|
||||||
|
user_aaguids = set()
|
||||||
|
|
||||||
|
for cred_id in credential_ids:
|
||||||
|
try:
|
||||||
|
stored_cred = await db.get_credential_by_id(cred_id)
|
||||||
|
|
||||||
|
# Convert AAGUID to string format
|
||||||
|
aaguid_str = str(stored_cred.aaguid)
|
||||||
|
user_aaguids.add(aaguid_str)
|
||||||
|
|
||||||
|
# Check if this is the current session credential
|
||||||
|
is_current_session = (
|
||||||
|
current_credential_id == stored_cred.credential_id
|
||||||
|
)
|
||||||
|
|
||||||
|
credentials.append(
|
||||||
|
{
|
||||||
|
"credential_id": stored_cred.credential_id.hex(),
|
||||||
|
"aaguid": aaguid_str,
|
||||||
|
"created_at": stored_cred.created_at.isoformat(),
|
||||||
|
"last_used": stored_cred.last_used.isoformat()
|
||||||
|
if stored_cred.last_used
|
||||||
|
else None,
|
||||||
|
"last_verified": stored_cred.last_verified.isoformat()
|
||||||
|
if stored_cred.last_verified
|
||||||
|
else None,
|
||||||
|
"sign_count": stored_cred.sign_count,
|
||||||
|
"is_current_session": is_current_session,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
except ValueError:
|
||||||
|
# Skip invalid credentials
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Get AAGUID information for only the AAGUIDs that the user has
|
||||||
|
aaguid_manager = get_aaguid_manager()
|
||||||
|
aaguid_info = aaguid_manager.get_relevant_aaguids(user_aaguids)
|
||||||
|
|
||||||
|
# Sort credentials by creation date (earliest first, most recently created last)
|
||||||
|
credentials.sort(key=lambda cred: cred["created_at"])
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "success",
|
||||||
|
"credentials": credentials,
|
||||||
|
"aaguid_info": aaguid_info,
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
return {"error": f"Failed to get credentials: {str(e)}"}
|
||||||
|
|
||||||
|
|
||||||
|
async def refresh_token(request: Request, response: Response) -> dict:
|
||||||
|
"""Refresh the session token."""
|
||||||
|
try:
|
||||||
|
session_token = get_session_token_from_request(request)
|
||||||
|
if not session_token:
|
||||||
|
return {"error": "No session token found"}
|
||||||
|
|
||||||
|
# Validate and refresh the token
|
||||||
|
new_token = refresh_session_token(session_token)
|
||||||
|
|
||||||
|
if new_token:
|
||||||
|
set_session_cookie(response, new_token)
|
||||||
|
return {"status": "success", "refreshed": True}
|
||||||
|
else:
|
||||||
|
clear_session_cookie(response)
|
||||||
|
return {"error": "Invalid or expired session token"}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return {"error": f"Failed to refresh token: {str(e)}"}
|
||||||
|
|
||||||
|
|
||||||
|
async def validate_token(request: Request) -> dict:
|
||||||
|
"""Validate a session token and return user info."""
|
||||||
|
try:
|
||||||
|
session_token = get_session_token_from_request(request)
|
||||||
|
if not session_token:
|
||||||
|
return {"error": "No session token found"}
|
||||||
|
|
||||||
|
# Validate the session token
|
||||||
|
token_data = validate_session_token(session_token)
|
||||||
|
if not token_data:
|
||||||
|
return {"error": "Invalid or expired session token"}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "success",
|
||||||
|
"valid": True,
|
||||||
|
"user_id": str(token_data["user_id"]),
|
||||||
|
"credential_id": token_data["credential_id"].hex(),
|
||||||
|
"issued_at": token_data["issued_at"],
|
||||||
|
"expires_at": token_data["expires_at"],
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return {"error": f"Failed to validate token: {str(e)}"}
|
||||||
|
|
||||||
|
|
||||||
|
async def logout(response: Response) -> dict:
|
||||||
|
"""Log out the current user by clearing the session cookie."""
|
||||||
|
clear_session_cookie(response)
|
||||||
|
return {"status": "success", "message": "Logged out successfully"}
|
||||||
|
|
||||||
|
|
||||||
|
async def set_session(request: Request, response: Response) -> dict:
|
||||||
|
"""Set session cookie using JWT token from request body or Authorization header."""
|
||||||
|
try:
|
||||||
|
session_token = await get_session_token_from_auth_header_or_body(request)
|
||||||
|
|
||||||
|
if not session_token:
|
||||||
|
return {"error": "No session token provided"}
|
||||||
|
|
||||||
|
# Validate the session token
|
||||||
|
token_data = validate_session_token(session_token)
|
||||||
|
if not token_data:
|
||||||
|
return {"error": "Invalid or expired session token"}
|
||||||
|
|
||||||
|
# Set the HTTP-only cookie
|
||||||
|
set_session_cookie(response, session_token)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "success",
|
||||||
|
"message": "Session cookie set successfully",
|
||||||
|
"user_id": str(token_data["user_id"]),
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return {"error": f"Failed to set session: {str(e)}"}
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_credential(request: Request) -> dict:
|
||||||
|
"""Delete a specific credential for the current user."""
|
||||||
|
try:
|
||||||
|
user = await get_current_user(request)
|
||||||
|
if not user:
|
||||||
|
return {"error": "Not authenticated"}
|
||||||
|
|
||||||
|
# Get the credential ID from the request body
|
||||||
|
try:
|
||||||
|
body = await request.json()
|
||||||
|
credential_id = body.get("credential_id")
|
||||||
|
if not credential_id:
|
||||||
|
return {"error": "credential_id is required"}
|
||||||
|
except Exception:
|
||||||
|
return {"error": "Invalid request body"}
|
||||||
|
|
||||||
|
# Convert credential_id from hex string to bytes
|
||||||
|
try:
|
||||||
|
credential_id_bytes = bytes.fromhex(credential_id)
|
||||||
|
except ValueError:
|
||||||
|
return {"error": "Invalid credential_id format"}
|
||||||
|
|
||||||
|
async with connect() as db:
|
||||||
|
# First, verify the credential belongs to the current user
|
||||||
|
try:
|
||||||
|
stored_cred = await db.get_credential_by_id(credential_id_bytes)
|
||||||
|
if stored_cred.user_id != user.user_id:
|
||||||
|
return {"error": "Credential not found or access denied"}
|
||||||
|
except ValueError:
|
||||||
|
return {"error": "Credential not found"}
|
||||||
|
|
||||||
|
# Check if this is the current session credential
|
||||||
|
session_token = get_session_token_from_request(request)
|
||||||
|
if session_token:
|
||||||
|
token_data = validate_session_token(session_token)
|
||||||
|
if (
|
||||||
|
token_data
|
||||||
|
and token_data.get("credential_id") == credential_id_bytes
|
||||||
|
):
|
||||||
|
return {"error": "Cannot delete current session credential"}
|
||||||
|
|
||||||
|
# Get user's remaining credentials count
|
||||||
|
remaining_credentials = await db.get_credentials_by_user_id(
|
||||||
|
user.user_id.bytes
|
||||||
|
)
|
||||||
|
if len(remaining_credentials) <= 1:
|
||||||
|
return {"error": "Cannot delete last remaining credential"}
|
||||||
|
|
||||||
|
# Delete the credential
|
||||||
|
await db.delete_credential(credential_id_bytes)
|
||||||
|
|
||||||
|
return {"status": "success", "message": "Credential deleted successfully"}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return {"error": f"Failed to delete credential: {str(e)}"}
|
1
passkeyauth/combined_aaguid.json
Normal file
1
passkeyauth/combined_aaguid.json
Normal file
File diff suppressed because one or more lines are too long
@ -54,22 +54,11 @@ SQL_STORE_CREDENTIAL = """
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
SQL_GET_CREDENTIAL_BY_ID = """
|
SQL_GET_CREDENTIAL_BY_ID = """
|
||||||
SELECT credential_id, user_id, aaguid, public_key, sign_count, created_at, last_used, last_verified
|
SELECT * FROM credentials WHERE credential_id = ?
|
||||||
FROM credentials
|
|
||||||
WHERE credential_id = ?
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
SQL_GET_USER_CREDENTIALS = """
|
SQL_GET_USER_CREDENTIALS = """
|
||||||
SELECT c.credential_id
|
SELECT credential_id FROM credentials WHERE user_id = ?
|
||||||
FROM credentials c
|
|
||||||
JOIN users u ON c.user_id = u.user_id
|
|
||||||
WHERE u.user_id = ?
|
|
||||||
"""
|
|
||||||
|
|
||||||
SQL_UPDATE_CREDENTIAL_SIGN_COUNT = """
|
|
||||||
UPDATE credentials
|
|
||||||
SET sign_count = ?, last_used = CURRENT_TIMESTAMP
|
|
||||||
WHERE credential_id = ?
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
SQL_UPDATE_CREDENTIAL = """
|
SQL_UPDATE_CREDENTIAL = """
|
||||||
@ -78,11 +67,13 @@ SQL_UPDATE_CREDENTIAL = """
|
|||||||
WHERE credential_id = ?
|
WHERE credential_id = ?
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
SQL_DELETE_CREDENTIAL = """
|
||||||
|
DELETE FROM credentials WHERE credential_id = ?
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class User:
|
class User:
|
||||||
"""User data model."""
|
|
||||||
|
|
||||||
user_id: UUID
|
user_id: UUID
|
||||||
user_name: str
|
user_name: str
|
||||||
created_at: datetime | None = None
|
created_at: datetime | None = None
|
||||||
@ -118,8 +109,8 @@ class DB:
|
|||||||
return User(
|
return User(
|
||||||
user_id=UUID(bytes=row[0]),
|
user_id=UUID(bytes=row[0]),
|
||||||
user_name=row[1],
|
user_name=row[1],
|
||||||
created_at=row[2],
|
created_at=_convert_datetime(row[2]),
|
||||||
last_seen=row[3],
|
last_seen=_convert_datetime(row[3]),
|
||||||
)
|
)
|
||||||
raise ValueError("User not found")
|
raise ValueError("User not found")
|
||||||
|
|
||||||
@ -127,7 +118,12 @@ class DB:
|
|||||||
"""Create a new user and return the User dataclass."""
|
"""Create a new user and return the User dataclass."""
|
||||||
await self.conn.execute(
|
await self.conn.execute(
|
||||||
SQL_CREATE_USER,
|
SQL_CREATE_USER,
|
||||||
(user.user_id.bytes, user.user_name, user.created_at, user.last_seen),
|
(
|
||||||
|
user.user_id.bytes,
|
||||||
|
user.user_name,
|
||||||
|
user.created_at or datetime.now(),
|
||||||
|
user.last_seen,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
async def create_credential(self, credential: StoredCredential) -> None:
|
async def create_credential(self, credential: StoredCredential) -> None:
|
||||||
@ -159,9 +155,9 @@ class DB:
|
|||||||
aaguid=UUID(bytes=row[2]),
|
aaguid=UUID(bytes=row[2]),
|
||||||
public_key=row[3],
|
public_key=row[3],
|
||||||
sign_count=row[4],
|
sign_count=row[4],
|
||||||
created_at=row[5],
|
created_at=datetime.fromisoformat(row[5]),
|
||||||
last_used=row[6],
|
last_used=_convert_datetime(row[6]),
|
||||||
last_verified=row[7],
|
last_verified=_convert_datetime(row[7]),
|
||||||
)
|
)
|
||||||
raise ValueError("Credential not registered")
|
raise ValueError("Credential not registered")
|
||||||
|
|
||||||
@ -192,3 +188,13 @@ class DB:
|
|||||||
"UPDATE users SET last_seen = ? WHERE user_id = ?",
|
"UPDATE users SET last_seen = ? WHERE user_id = ?",
|
||||||
(credential.last_used, user_id),
|
(credential.last_used, user_id),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def delete_credential(self, credential_id: bytes) -> None:
|
||||||
|
"""Delete a credential by its ID."""
|
||||||
|
await self.conn.execute(SQL_DELETE_CREDENTIAL, (credential_id,))
|
||||||
|
await self.conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _convert_datetime(val):
|
||||||
|
"""Convert string from SQLite to datetime object (pass through None)."""
|
||||||
|
return val and datetime.fromisoformat(val)
|
||||||
|
132
passkeyauth/jwt_manager.py
Normal file
132
passkeyauth/jwt_manager.py
Normal file
@ -0,0 +1,132 @@
|
|||||||
|
"""
|
||||||
|
JWT session management for WebAuthn authentication.
|
||||||
|
|
||||||
|
This module provides JWT token generation and validation for managing user sessions
|
||||||
|
after successful WebAuthn authentication. Tokens contain user ID and credential ID
|
||||||
|
for session validation.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import secrets
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
import jwt
|
||||||
|
|
||||||
|
SECRET_FILE = Path("server-secret.bin")
|
||||||
|
|
||||||
|
|
||||||
|
def load_or_create_secret() -> bytes:
|
||||||
|
"""Load JWT secret from file or create a new one."""
|
||||||
|
if SECRET_FILE.exists():
|
||||||
|
return SECRET_FILE.read_bytes()
|
||||||
|
else:
|
||||||
|
# Generate a new 32-byte secret
|
||||||
|
secret = secrets.token_bytes(32)
|
||||||
|
SECRET_FILE.write_bytes(secret)
|
||||||
|
return secret
|
||||||
|
|
||||||
|
|
||||||
|
class JWTManager:
|
||||||
|
"""Manages JWT tokens for user sessions."""
|
||||||
|
|
||||||
|
def __init__(self, secret_key: bytes, algorithm: str = "HS256"):
|
||||||
|
self.secret_key = secret_key
|
||||||
|
self.algorithm = algorithm
|
||||||
|
self.token_expiry = timedelta(hours=24) # Tokens expire after 24 hours
|
||||||
|
|
||||||
|
def create_token(self, user_id: UUID, credential_id: bytes) -> str:
|
||||||
|
"""
|
||||||
|
Create a JWT token for a user session.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: The user's UUID
|
||||||
|
credential_id: The credential ID used for authentication
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JWT token string
|
||||||
|
"""
|
||||||
|
now = datetime.utcnow()
|
||||||
|
payload = {
|
||||||
|
"user_id": str(user_id),
|
||||||
|
"credential_id": credential_id.hex(),
|
||||||
|
"iat": now,
|
||||||
|
"exp": now + self.token_expiry,
|
||||||
|
"iss": "passkeyauth",
|
||||||
|
}
|
||||||
|
|
||||||
|
return jwt.encode(payload, self.secret_key, algorithm=self.algorithm)
|
||||||
|
|
||||||
|
def validate_token(self, token: str) -> Optional[dict]:
|
||||||
|
"""
|
||||||
|
Validate a JWT token and return the payload.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
token: JWT token string
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with user_id and credential_id, or None if invalid
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
payload = jwt.decode(
|
||||||
|
token,
|
||||||
|
self.secret_key,
|
||||||
|
algorithms=[self.algorithm],
|
||||||
|
issuer="passkeyauth",
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"user_id": UUID(payload["user_id"]),
|
||||||
|
"credential_id": bytes.fromhex(payload["credential_id"]),
|
||||||
|
"issued_at": payload["iat"],
|
||||||
|
"expires_at": payload["exp"],
|
||||||
|
}
|
||||||
|
except jwt.ExpiredSignatureError:
|
||||||
|
return None
|
||||||
|
except jwt.InvalidTokenError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def refresh_token(self, token: str) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
Refresh a JWT token if it's still valid.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
token: Current JWT token
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
New JWT token string, or None if the current token is invalid
|
||||||
|
"""
|
||||||
|
payload = self.validate_token(token)
|
||||||
|
if payload is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return self.create_token(payload["user_id"], payload["credential_id"])
|
||||||
|
|
||||||
|
|
||||||
|
# Global JWT manager instance
|
||||||
|
_jwt_manager: Optional[JWTManager] = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_jwt_manager() -> JWTManager:
|
||||||
|
"""Get the global JWT manager instance."""
|
||||||
|
global _jwt_manager
|
||||||
|
if _jwt_manager is None:
|
||||||
|
secret = load_or_create_secret()
|
||||||
|
_jwt_manager = JWTManager(secret)
|
||||||
|
return _jwt_manager
|
||||||
|
|
||||||
|
|
||||||
|
def create_session_token(user_id: UUID, credential_id: bytes) -> str:
|
||||||
|
"""Create a session token for a user."""
|
||||||
|
return get_jwt_manager().create_token(user_id, credential_id)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_session_token(token: str) -> Optional[dict]:
|
||||||
|
"""Validate a session token."""
|
||||||
|
return get_jwt_manager().validate_token(token)
|
||||||
|
|
||||||
|
|
||||||
|
def refresh_session_token(token: str) -> Optional[str]:
|
||||||
|
"""Refresh a session token."""
|
||||||
|
return get_jwt_manager().refresh_token(token)
|
@ -10,18 +10,31 @@ This module provides a simple WebAuthn implementation that:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from uuid import UUID, uuid4
|
from uuid import UUID, uuid4
|
||||||
|
|
||||||
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
|
from fastapi import FastAPI, Request, Response, WebSocket, WebSocketDisconnect
|
||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
|
||||||
|
from .api_handlers import (
|
||||||
|
delete_credential,
|
||||||
|
get_user_credentials,
|
||||||
|
get_user_info,
|
||||||
|
logout,
|
||||||
|
refresh_token,
|
||||||
|
set_session,
|
||||||
|
validate_token,
|
||||||
|
)
|
||||||
from .db import User, connect
|
from .db import User, connect
|
||||||
|
from .jwt_manager import create_session_token
|
||||||
from .passkey import Passkey
|
from .passkey import Passkey
|
||||||
|
from .session_manager import get_user_from_cookie_string
|
||||||
|
|
||||||
STATIC_DIR = Path(__file__).parent.parent / "static"
|
STATIC_DIR = Path(__file__).parent.parent / "static"
|
||||||
|
|
||||||
|
|
||||||
passkey = Passkey(
|
passkey = Passkey(
|
||||||
rp_id="localhost",
|
rp_id="localhost",
|
||||||
rp_name="Passkey Auth",
|
rp_name="Passkey Auth",
|
||||||
@ -55,14 +68,66 @@ async def websocket_register_new(ws: WebSocket):
|
|||||||
# Store the user in the database
|
# Store the user in the database
|
||||||
async with connect() as db:
|
async with connect() as db:
|
||||||
await db.conn.execute("BEGIN")
|
await db.conn.execute("BEGIN")
|
||||||
await db.create_user(User(user_id, user_name))
|
await db.create_user(User(user_id, user_name, created_at=datetime.now()))
|
||||||
await db.create_credential(credential)
|
await db.create_credential(credential)
|
||||||
|
|
||||||
await ws.send_json({"status": "success", "user_id": str(user_id)})
|
# Create a session token for the new user
|
||||||
|
session_token = create_session_token(user_id, credential.credential_id)
|
||||||
|
|
||||||
|
await ws.send_json(
|
||||||
|
{
|
||||||
|
"status": "success",
|
||||||
|
"user_id": str(user_id),
|
||||||
|
"session_token": session_token,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
except ValueError as e:
|
||||||
|
await ws.send_json({"error": str(e)})
|
||||||
except WebSocketDisconnect:
|
except WebSocketDisconnect:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@app.websocket("/ws/add_credential")
|
||||||
|
async def websocket_register_add(ws: WebSocket):
|
||||||
|
"""Register a new credential for an existing user."""
|
||||||
|
await ws.accept()
|
||||||
|
try:
|
||||||
|
# Authenticate user via cookie
|
||||||
|
cookie_header = ws.headers.get("cookie", "")
|
||||||
|
user_id = await get_user_from_cookie_string(cookie_header)
|
||||||
|
|
||||||
|
if not user_id:
|
||||||
|
await ws.send_json({"error": "Authentication required"})
|
||||||
|
return
|
||||||
|
|
||||||
|
# Get user information to get the user_name
|
||||||
|
async with connect() as db:
|
||||||
|
user = await db.get_user_by_user_id(user_id.bytes)
|
||||||
|
user_name = user.user_name
|
||||||
|
|
||||||
|
# WebAuthn registration
|
||||||
|
credential = await register_chat(ws, user_id, user_name)
|
||||||
|
print(f"New credential for user {user_id}: {credential}")
|
||||||
|
# Store the new credential in the database
|
||||||
|
async with connect() as db:
|
||||||
|
await db.create_credential(credential)
|
||||||
|
|
||||||
|
await ws.send_json(
|
||||||
|
{
|
||||||
|
"status": "success",
|
||||||
|
"user_id": str(user_id),
|
||||||
|
"credential_id": credential.credential_id.hex(),
|
||||||
|
"message": "New credential added successfully",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
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, user_name: str):
|
async def register_chat(ws: WebSocket, user_id: UUID, 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(
|
||||||
@ -71,6 +136,7 @@ async def register_chat(ws: WebSocket, user_id: UUID, user_name: str):
|
|||||||
)
|
)
|
||||||
await ws.send_json(options)
|
await ws.send_json(options)
|
||||||
response = await ws.receive_json()
|
response = await ws.receive_json()
|
||||||
|
print(response)
|
||||||
return passkey.reg_verify(response, challenge, user_id)
|
return passkey.reg_verify(response, challenge, user_id)
|
||||||
|
|
||||||
|
|
||||||
@ -87,14 +153,69 @@ async def websocket_authenticate(ws: WebSocket):
|
|||||||
stored_cred = await db.get_credential_by_id(credential.raw_id)
|
stored_cred = await db.get_credential_by_id(credential.raw_id)
|
||||||
# Verify the credential matches the stored data
|
# Verify the credential matches the stored data
|
||||||
await passkey.auth_verify(credential, challenge, stored_cred)
|
await passkey.auth_verify(credential, challenge, stored_cred)
|
||||||
await db.update_credential(stored_cred)
|
# Update both credential and user's last_seen timestamp
|
||||||
await ws.send_json({"status": "success"})
|
await db.login(stored_cred.user_id.bytes, stored_cred)
|
||||||
|
|
||||||
|
# Create a session token for the authenticated user
|
||||||
|
session_token = create_session_token(
|
||||||
|
stored_cred.user_id, stored_cred.credential_id
|
||||||
|
)
|
||||||
|
|
||||||
|
await ws.send_json(
|
||||||
|
{
|
||||||
|
"status": "success",
|
||||||
|
"user_id": str(stored_cred.user_id),
|
||||||
|
"session_token": session_token,
|
||||||
|
}
|
||||||
|
)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
await ws.send_json({"error": str(e)})
|
await ws.send_json({"error": str(e)})
|
||||||
except WebSocketDisconnect:
|
except WebSocketDisconnect:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/user-info")
|
||||||
|
async def api_get_user_info(request: Request):
|
||||||
|
"""Get user information from session cookie."""
|
||||||
|
return await get_user_info(request)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/user-credentials")
|
||||||
|
async def api_get_user_credentials(request: Request):
|
||||||
|
"""Get all credentials for a user using session cookie."""
|
||||||
|
return await get_user_credentials(request)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/refresh-token")
|
||||||
|
async def api_refresh_token(request: Request, response: Response):
|
||||||
|
"""Refresh the session token."""
|
||||||
|
return await refresh_token(request, response)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/validate-token")
|
||||||
|
async def api_validate_token(request: Request):
|
||||||
|
"""Validate a session token and return user info."""
|
||||||
|
return await validate_token(request)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/logout")
|
||||||
|
async def api_logout(response: Response):
|
||||||
|
"""Log out the current user by clearing the session cookie."""
|
||||||
|
return await logout(response)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/set-session")
|
||||||
|
async def api_set_session(request: Request, response: Response):
|
||||||
|
"""Set session cookie using JWT token from request body or Authorization header."""
|
||||||
|
return await set_session(request, response)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/delete-credential")
|
||||||
|
async def api_delete_credential(request: Request):
|
||||||
|
"""Delete a specific credential for the current user."""
|
||||||
|
return await delete_credential(request)
|
||||||
|
|
||||||
|
|
||||||
# Serve static files
|
# Serve static files
|
||||||
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
|
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
|
||||||
|
|
||||||
|
@ -28,6 +28,7 @@ from webauthn.helpers import (
|
|||||||
)
|
)
|
||||||
from webauthn.helpers.cose import COSEAlgorithmIdentifier
|
from webauthn.helpers.cose import COSEAlgorithmIdentifier
|
||||||
from webauthn.helpers.structs import (
|
from webauthn.helpers.structs import (
|
||||||
|
AttestationConveyancePreference,
|
||||||
AuthenticationCredential,
|
AuthenticationCredential,
|
||||||
AuthenticatorSelectionCriteria,
|
AuthenticatorSelectionCriteria,
|
||||||
PublicKeyCredentialDescriptor,
|
PublicKeyCredentialDescriptor,
|
||||||
@ -109,6 +110,7 @@ class Passkey:
|
|||||||
rp_name=self.rp_name,
|
rp_name=self.rp_name,
|
||||||
user_id=user_id.bytes,
|
user_id=user_id.bytes,
|
||||||
user_name=user_name,
|
user_name=user_name,
|
||||||
|
attestation=AttestationConveyancePreference.DIRECT,
|
||||||
authenticator_selection=AuthenticatorSelectionCriteria(
|
authenticator_selection=AuthenticatorSelectionCriteria(
|
||||||
resident_key=ResidentKeyRequirement.REQUIRED,
|
resident_key=ResidentKeyRequirement.REQUIRED,
|
||||||
user_verification=UserVerificationRequirement.PREFERRED,
|
user_verification=UserVerificationRequirement.PREFERRED,
|
||||||
|
107
passkeyauth/session_manager.py
Normal file
107
passkeyauth/session_manager.py
Normal file
@ -0,0 +1,107 @@
|
|||||||
|
"""
|
||||||
|
Session management for WebAuthn authentication.
|
||||||
|
|
||||||
|
This module provides session management functionality including:
|
||||||
|
- Getting current user from session cookies
|
||||||
|
- Setting and clearing HTTP-only cookies
|
||||||
|
- Session validation and token handling
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Optional
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import Request, Response
|
||||||
|
|
||||||
|
from .db import User, connect
|
||||||
|
from .jwt_manager import validate_session_token
|
||||||
|
|
||||||
|
COOKIE_NAME = "session_token"
|
||||||
|
COOKIE_MAX_AGE = 86400 # 24 hours
|
||||||
|
|
||||||
|
|
||||||
|
async def get_current_user(request: Request) -> Optional[User]:
|
||||||
|
"""Get the current user from the session cookie."""
|
||||||
|
session_token = request.cookies.get(COOKIE_NAME)
|
||||||
|
if not session_token:
|
||||||
|
return None
|
||||||
|
|
||||||
|
token_data = validate_session_token(session_token)
|
||||||
|
if not token_data:
|
||||||
|
return None
|
||||||
|
|
||||||
|
async with connect() as db:
|
||||||
|
try:
|
||||||
|
user = await db.get_user_by_user_id(token_data["user_id"].bytes)
|
||||||
|
return user
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def set_session_cookie(response: Response, session_token: str) -> None:
|
||||||
|
"""Set the session token as an HTTP-only cookie."""
|
||||||
|
response.set_cookie(
|
||||||
|
key=COOKIE_NAME,
|
||||||
|
value=session_token,
|
||||||
|
max_age=COOKIE_MAX_AGE,
|
||||||
|
httponly=True,
|
||||||
|
secure=False, # Set to True in production with HTTPS
|
||||||
|
samesite="lax",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def clear_session_cookie(response: Response) -> None:
|
||||||
|
"""Clear the session cookie."""
|
||||||
|
response.delete_cookie(key=COOKIE_NAME)
|
||||||
|
|
||||||
|
|
||||||
|
def get_session_token_from_request(request: Request) -> Optional[str]:
|
||||||
|
"""Extract session token from request cookies."""
|
||||||
|
return request.cookies.get(COOKIE_NAME)
|
||||||
|
|
||||||
|
|
||||||
|
async def validate_session_from_request(request: Request) -> Optional[dict]:
|
||||||
|
"""Validate session token from request and return token data."""
|
||||||
|
session_token = get_session_token_from_request(request)
|
||||||
|
if not session_token:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return validate_session_token(session_token)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_session_token_from_auth_header_or_body(request: Request) -> Optional[str]:
|
||||||
|
"""Extract session token from Authorization header or request body."""
|
||||||
|
# Try to get token from Authorization header first
|
||||||
|
auth_header = request.headers.get("Authorization")
|
||||||
|
if auth_header and auth_header.startswith("Bearer "):
|
||||||
|
return auth_header[7:] # Remove "Bearer " prefix
|
||||||
|
|
||||||
|
# Try to get from request body
|
||||||
|
try:
|
||||||
|
body = await request.json()
|
||||||
|
return body.get("session_token")
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def get_user_from_cookie_string(cookie_header: str) -> Optional[UUID]:
|
||||||
|
"""Parse cookie header and return user ID if valid session exists."""
|
||||||
|
if not cookie_header:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Parse cookies from header (simple implementation)
|
||||||
|
cookies = {}
|
||||||
|
for cookie in cookie_header.split(";"):
|
||||||
|
cookie = cookie.strip()
|
||||||
|
if "=" in cookie:
|
||||||
|
name, value = cookie.split("=", 1)
|
||||||
|
cookies[name] = value
|
||||||
|
|
||||||
|
session_token = cookies.get(COOKIE_NAME)
|
||||||
|
if not session_token:
|
||||||
|
return None
|
||||||
|
|
||||||
|
token_data = validate_session_token(session_token)
|
||||||
|
if not token_data:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return token_data["user_id"]
|
@ -17,6 +17,7 @@ dependencies = [
|
|||||||
"base64url>=1.0.0",
|
"base64url>=1.0.0",
|
||||||
"aiosqlite>=0.19.0",
|
"aiosqlite>=0.19.0",
|
||||||
"uuid7-standard>=1.0.0",
|
"uuid7-standard>=1.0.0",
|
||||||
|
"pyjwt>=2.8.0",
|
||||||
]
|
]
|
||||||
requires-python = ">=3.10"
|
requires-python = ">=3.10"
|
||||||
|
|
||||||
|
448
static/app.js
448
static/app.js
@ -1,54 +1,434 @@
|
|||||||
const { startRegistration, startAuthentication } = SimpleWebAuthnBrowser
|
const { startRegistration, startAuthentication } = SimpleWebAuthnBrowser
|
||||||
|
|
||||||
|
// Global state
|
||||||
|
let currentUser = null
|
||||||
|
let currentCredentials = []
|
||||||
|
let aaguidInfo = {}
|
||||||
|
|
||||||
|
// Session management - now using HTTP-only cookies
|
||||||
|
async function validateStoredToken() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/validate-token', {
|
||||||
|
method: 'GET',
|
||||||
|
credentials: 'include'
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await response.json()
|
||||||
|
|
||||||
|
if (result.status === 'success') {
|
||||||
|
return true
|
||||||
|
} else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
} 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', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${sessionToken}`,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
credentials: 'include'
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await response.json()
|
||||||
|
if (result.error) {
|
||||||
|
throw new Error(result.error)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`Failed to set session cookie: ${error.message}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// View management
|
||||||
|
function showView(viewId) {
|
||||||
|
document.querySelectorAll('.view').forEach(view => view.classList.remove('active'))
|
||||||
|
document.getElementById(viewId).classList.add('active')
|
||||||
|
}
|
||||||
|
|
||||||
|
function showLoginView() {
|
||||||
|
showView('loginView')
|
||||||
|
clearStatus('loginStatus')
|
||||||
|
}
|
||||||
|
|
||||||
|
function showRegisterView() {
|
||||||
|
showView('registerView')
|
||||||
|
clearStatus('registerStatus')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update dashboard view to load user info
|
||||||
|
function showDashboardView() {
|
||||||
|
showView('dashboardView')
|
||||||
|
clearStatus('dashboardStatus')
|
||||||
|
loadUserInfo().then(() => {
|
||||||
|
updateUserInfo()
|
||||||
|
loadCredentials()
|
||||||
|
}).catch(error => {
|
||||||
|
showStatus('dashboardStatus', `Failed to load user info: ${error.message}`, 'error')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status management
|
||||||
|
function showStatus(elementId, message, type = 'info') {
|
||||||
|
const statusEl = document.getElementById(elementId)
|
||||||
|
statusEl.innerHTML = `<div class="status ${type}">${message}</div>`
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearStatus(elementId) {
|
||||||
|
document.getElementById(elementId).innerHTML = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
// User registration
|
||||||
async function register(user_name) {
|
async function register(user_name) {
|
||||||
const ws = await aWebSocket('/ws/new_user_registration')
|
try {
|
||||||
ws.send(JSON.stringify({user_name}))
|
const ws = await aWebSocket('/ws/new_user_registration')
|
||||||
// Registration chat
|
ws.send(JSON.stringify({user_name}))
|
||||||
const optionsJSON = JSON.parse(await ws.recv())
|
|
||||||
if (optionsJSON.error) throw new Error(optionsJSON.error)
|
// Registration chat
|
||||||
ws.send(JSON.stringify(await startRegistration({optionsJSON})))
|
const optionsJSON = JSON.parse(await ws.recv())
|
||||||
const result = JSON.parse(await ws.recv())
|
if (optionsJSON.error) throw new Error(optionsJSON.error)
|
||||||
if (result.error) throw new Error(`Server: ${result.error}`)
|
|
||||||
|
showStatus('registerStatus', 'Save 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()
|
||||||
|
|
||||||
|
// Set session cookie using the JWT token
|
||||||
|
await setSessionCookie(result.session_token)
|
||||||
|
|
||||||
|
// Set current user from registration result
|
||||||
|
currentUser = {
|
||||||
|
user_id: result.user_id,
|
||||||
|
user_name: user_name,
|
||||||
|
last_seen: new Date().toISOString()
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
} catch (error) {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// User authentication
|
||||||
async function authenticate() {
|
async function authenticate() {
|
||||||
// Authentication chat
|
try {
|
||||||
const ws = await aWebSocket('/ws/authenticate')
|
const ws = await aWebSocket('/ws/authenticate')
|
||||||
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)
|
||||||
await ws.send(JSON.stringify(await startAuthentication({optionsJSON})))
|
|
||||||
const result = JSON.parse(await ws.recv())
|
showStatus('loginStatus', 'Please touch your authenticator...', 'info')
|
||||||
if (result.error) throw new Error(`Server: ${result.error}`)
|
|
||||||
return result
|
const authResponse = await startAuthentication({optionsJSON})
|
||||||
|
await ws.send(JSON.stringify(authResponse))
|
||||||
|
|
||||||
|
const result = JSON.parse(await ws.recv())
|
||||||
|
if (result.error) throw new Error(`Server: ${result.error}`)
|
||||||
|
|
||||||
|
ws.close()
|
||||||
|
|
||||||
|
// Set session cookie using the JWT token
|
||||||
|
await setSessionCookie(result.session_token)
|
||||||
|
|
||||||
|
// Authentication successful, now get user info using HTTP endpoint
|
||||||
|
const userResponse = await fetch('/api/user-info', {
|
||||||
|
method: 'GET',
|
||||||
|
credentials: 'include'
|
||||||
|
})
|
||||||
|
|
||||||
|
const userInfo = await userResponse.json()
|
||||||
|
if (userInfo.error) throw new Error(`Server: ${userInfo.error}`)
|
||||||
|
|
||||||
|
currentUser = userInfo.user
|
||||||
|
|
||||||
|
return result
|
||||||
|
} catch (error) {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
(function() {
|
// Load user credentials
|
||||||
|
async function loadCredentials() {
|
||||||
|
try {
|
||||||
|
showStatus('dashboardStatus', 'Loading credentials...', 'info')
|
||||||
|
|
||||||
|
const response = await fetch('/api/user-credentials', {
|
||||||
|
method: 'GET',
|
||||||
|
credentials: 'include'
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await response.json()
|
||||||
|
if (result.error) throw new Error(`Server: ${result.error}`)
|
||||||
|
|
||||||
|
currentCredentials = result.credentials
|
||||||
|
aaguidInfo = result.aaguid_info || {}
|
||||||
|
updateCredentialList()
|
||||||
|
clearStatus('dashboardStatus')
|
||||||
|
} catch (error) {
|
||||||
|
showStatus('dashboardStatus', `Failed to load credentials: ${error.message}`, 'error')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load user info using HTTP endpoint
|
||||||
|
async function loadUserInfo() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/user-info', {
|
||||||
|
method: 'GET',
|
||||||
|
credentials: 'include'
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await response.json()
|
||||||
|
if (result.error) throw new Error(`Server: ${result.error}`)
|
||||||
|
|
||||||
|
currentUser = result.user
|
||||||
|
} catch (error) {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update user info display
|
||||||
|
function updateUserInfo() {
|
||||||
|
const userInfoEl = document.getElementById('userInfo')
|
||||||
|
if (currentUser) {
|
||||||
|
userInfoEl.innerHTML = `
|
||||||
|
<h3>👤 ${currentUser.user_name}</h3>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update credential list display
|
||||||
|
function updateCredentialList() {
|
||||||
|
const credentialListEl = document.getElementById('credentialList')
|
||||||
|
|
||||||
|
if (currentCredentials.length === 0) {
|
||||||
|
credentialListEl.innerHTML = '<p>No passkeys found.</p>'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
credentialListEl.innerHTML = currentCredentials.map(cred => {
|
||||||
|
// Get authenticator information from AAGUID
|
||||||
|
const authInfo = aaguidInfo[cred.aaguid]
|
||||||
|
const authName = authInfo ? authInfo.name : 'Unknown Authenticator'
|
||||||
|
|
||||||
|
// Determine which icon to use based on current theme (you can implement theme detection)
|
||||||
|
const isDarkMode = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||||
|
const iconKey = isDarkMode ? 'icon_dark' : 'icon_light'
|
||||||
|
const authIcon = authInfo && authInfo[iconKey] ? authInfo[iconKey] : null
|
||||||
|
|
||||||
|
// Check if this is the current session credential
|
||||||
|
const isCurrentSession = cred.is_current_session || false
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="credential-item${isCurrentSession ? ' current-session' : ''}">
|
||||||
|
<div class="credential-header">
|
||||||
|
<div class="credential-icon">
|
||||||
|
${authIcon ? `<img src="${authIcon}" alt="${authName}" class="auth-icon" width="32" height="32">` : '<span class="auth-emoji">🔑</span>'}
|
||||||
|
</div>
|
||||||
|
<div class="credential-info">
|
||||||
|
<h4>${authName}</h4>
|
||||||
|
</div>
|
||||||
|
<div class="credential-dates">
|
||||||
|
<span class="date-label">Created:</span>
|
||||||
|
<span class="date-value">${formatHumanReadableDate(cred.created_at)}</span>
|
||||||
|
<span class="date-label">Last used:</span>
|
||||||
|
<span class="date-value">${formatHumanReadableDate(cred.last_used)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="credential-actions">
|
||||||
|
<button onclick="deleteCredential('${cred.credential_id}')"
|
||||||
|
class="btn-delete-credential"
|
||||||
|
${isCurrentSession ? 'disabled title="Cannot delete current session credential"' : ''}>
|
||||||
|
🗑️
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
}).join('')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to format dates in a human-readable way
|
||||||
|
function formatHumanReadableDate(dateString) {
|
||||||
|
if (!dateString) return 'Never'
|
||||||
|
|
||||||
|
const date = new Date(dateString)
|
||||||
|
const now = new Date()
|
||||||
|
const diffMs = now - date
|
||||||
|
const diffHours = Math.floor(diffMs / (1000 * 60 * 60))
|
||||||
|
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24))
|
||||||
|
|
||||||
|
if (diffHours < 1) {
|
||||||
|
return 'Just now'
|
||||||
|
} else if (diffHours < 24) {
|
||||||
|
return `${diffHours} hour${diffHours === 1 ? '' : 's'} ago`
|
||||||
|
} else if (diffDays <= 7) {
|
||||||
|
return `${diffDays} day${diffDays === 1 ? '' : 's'} ago`
|
||||||
|
} else {
|
||||||
|
// For dates older than 7 days, show just the date without time
|
||||||
|
return date.toLocaleDateString()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Logout
|
||||||
|
async function logout() {
|
||||||
|
try {
|
||||||
|
await fetch('/api/logout', {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include'
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Logout error:', error)
|
||||||
|
}
|
||||||
|
|
||||||
|
currentUser = null
|
||||||
|
currentCredentials = []
|
||||||
|
aaguidInfo = {}
|
||||||
|
showLoginView()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user is already logged in on page load
|
||||||
|
async function checkExistingSession() {
|
||||||
|
if (await validateStoredToken()) {
|
||||||
|
showDashboardView()
|
||||||
|
} else {
|
||||||
|
showLoginView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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', `Failed to add new passkey: ${error.message}`, '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
|
||||||
|
checkExistingSession()
|
||||||
|
|
||||||
|
// Registration form
|
||||||
const regForm = document.getElementById('registrationForm')
|
const regForm = document.getElementById('registrationForm')
|
||||||
const regSubmitBtn = regForm.querySelector('button[type="submit"]')
|
const regSubmitBtn = regForm.querySelector('button[type="submit"]')
|
||||||
regForm.addEventListener('submit', ev => {
|
|
||||||
|
regForm.addEventListener('submit', async (ev) => {
|
||||||
ev.preventDefault()
|
ev.preventDefault()
|
||||||
regSubmitBtn.disabled = true
|
regSubmitBtn.disabled = true
|
||||||
|
clearStatus('registerStatus')
|
||||||
|
|
||||||
const user_name = (new FormData(regForm)).get('username')
|
const user_name = (new FormData(regForm)).get('username')
|
||||||
register(user_name).then(() => {
|
|
||||||
alert(`Registration successful for ${user_name}!`)
|
try {
|
||||||
}).catch(err => {
|
showStatus('registerStatus', 'Starting registration...', 'info')
|
||||||
alert(`Registration failed: ${err.message}`)
|
await register(user_name)
|
||||||
}).finally(() => {
|
showStatus('registerStatus', `Registration successful for ${user_name}!`, 'success')
|
||||||
|
|
||||||
|
// Auto-login after successful registration
|
||||||
|
setTimeout(() => {
|
||||||
|
showDashboardView()
|
||||||
|
}, 1500)
|
||||||
|
} catch (err) {
|
||||||
|
showStatus('registerStatus', `Registration failed: ${err.message}`, 'error')
|
||||||
|
} finally {
|
||||||
regSubmitBtn.disabled = false
|
regSubmitBtn.disabled = false
|
||||||
})
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Authentication form
|
||||||
const authForm = document.getElementById('authenticationForm')
|
const authForm = document.getElementById('authenticationForm')
|
||||||
const authSubmitBtn = authForm.querySelector('button[type="submit"]')
|
const authSubmitBtn = authForm.querySelector('button[type="submit"]')
|
||||||
authForm.addEventListener('submit', ev => {
|
|
||||||
|
authForm.addEventListener('submit', async (ev) => {
|
||||||
ev.preventDefault()
|
ev.preventDefault()
|
||||||
authSubmitBtn.disabled = true
|
authSubmitBtn.disabled = true
|
||||||
authenticate().then(result => {
|
clearStatus('loginStatus')
|
||||||
alert(`Authentication successful!`)
|
|
||||||
}).catch(err => {
|
try {
|
||||||
alert(`Authentication failed: ${err.message}`)
|
showStatus('loginStatus', 'Starting authentication...', 'info')
|
||||||
}).finally(() => {
|
await authenticate()
|
||||||
|
showStatus('loginStatus', 'Authentication successful!', 'success')
|
||||||
|
|
||||||
|
// Navigate to dashboard
|
||||||
|
setTimeout(() => {
|
||||||
|
showDashboardView()
|
||||||
|
}, 1000)
|
||||||
|
} catch (err) {
|
||||||
|
showStatus('loginStatus', `Authentication failed: ${err.message}`, 'error')
|
||||||
|
} finally {
|
||||||
authSubmitBtn.disabled = false
|
authSubmitBtn.disabled = false
|
||||||
})
|
}
|
||||||
})
|
})
|
||||||
})()
|
})
|
||||||
|
@ -1,65 +1,290 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
<title>WebAuthn Registration Demo</title>
|
<title>Passkey Authentication</title>
|
||||||
<script src="https://unpkg.com/@simplewebauthn/browser/dist/bundle/index.umd.min.js"></script>
|
<script src="https://unpkg.com/@simplewebauthn/browser/dist/bundle/index.umd.min.js"></script>
|
||||||
<script src="/static/awaitable-websocket.js"></script>
|
<script src="/static/awaitable-websocket.js"></script>
|
||||||
<style>
|
<style>
|
||||||
body {
|
body {
|
||||||
font-family: Arial, sans-serif;
|
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||||
max-width: 600px;
|
margin: 0;
|
||||||
margin: 50px auto;
|
padding: 0;
|
||||||
padding: 20px;
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
}
|
}
|
||||||
.container {
|
.container {
|
||||||
text-align: center;
|
|
||||||
background: white;
|
background: white;
|
||||||
padding: 30px;
|
padding: 40px;
|
||||||
border-radius: 10px;
|
border-radius: 15px;
|
||||||
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
box-shadow: 0 10px 30px rgba(0,0,0,0.2);
|
||||||
|
width: 100%;
|
||||||
|
max-width: 400px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.view {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.view.active {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
color: #333;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
font-weight: 300;
|
||||||
|
font-size: 28px;
|
||||||
|
}
|
||||||
|
h2 {
|
||||||
|
color: #555;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
font-weight: 400;
|
||||||
|
font-size: 22px;
|
||||||
}
|
}
|
||||||
input[type="text"] {
|
input[type="text"] {
|
||||||
padding: 10px;
|
width: 100%;
|
||||||
border: 2px solid #ddd;
|
padding: 15px;
|
||||||
border-radius: 5px;
|
border: 2px solid #e1e5e9;
|
||||||
|
border-radius: 8px;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
margin: 10px;
|
margin-bottom: 20px;
|
||||||
width: 250px;
|
box-sizing: border-box;
|
||||||
|
transition: border-color 0.3s ease;
|
||||||
|
}
|
||||||
|
input[type="text"]:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #667eea;
|
||||||
}
|
}
|
||||||
button {
|
button {
|
||||||
padding: 12px 24px;
|
width: 100%;
|
||||||
margin: 10px;
|
padding: 15px;
|
||||||
font-size: 16px;
|
margin-bottom: 15px;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 500;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
background: #007bff;
|
|
||||||
color: white;
|
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 5px;
|
border-radius: 8px;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
.btn-primary {
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
.btn-primary:hover:not(:disabled) {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 5px 15px rgba(102, 126, 234, 0.4);
|
||||||
|
}
|
||||||
|
.btn-secondary {
|
||||||
|
background: transparent;
|
||||||
|
color: #667eea;
|
||||||
|
border: 2px solid #667eea;
|
||||||
|
}
|
||||||
|
.btn-secondary:hover:not(:disabled) {
|
||||||
|
background: #667eea;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
.btn-danger {
|
||||||
|
background: #dc3545;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
.btn-danger:hover:not(:disabled) {
|
||||||
|
background: #c82333;
|
||||||
}
|
}
|
||||||
button:disabled {
|
button:disabled {
|
||||||
background: #ccc;
|
background: #ccc !important;
|
||||||
|
cursor: not-allowed !important;
|
||||||
|
transform: none !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
}
|
||||||
|
.status {
|
||||||
|
padding: 10px;
|
||||||
|
margin: 15px 0;
|
||||||
|
border-radius: 5px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.status.success {
|
||||||
|
background: #d4edda;
|
||||||
|
color: #155724;
|
||||||
|
border: 1px solid #c3e6cb;
|
||||||
|
}
|
||||||
|
.status.error {
|
||||||
|
background: #f8d7da;
|
||||||
|
color: #721c24;
|
||||||
|
border: 1px solid #f5c6cb;
|
||||||
|
}
|
||||||
|
.status.info {
|
||||||
|
background: #d1ecf1;
|
||||||
|
color: #0c5460;
|
||||||
|
border: 1px solid #bee5eb;
|
||||||
|
}
|
||||||
|
.credential-list {
|
||||||
|
max-height: 300px;
|
||||||
|
overflow-y: auto;
|
||||||
|
margin: 20px 0;
|
||||||
|
}
|
||||||
|
.credential-item {
|
||||||
|
background: #f8f9fa;
|
||||||
|
border: 1px solid #e9ecef;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 15px;
|
||||||
|
margin: 10px 0;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.credential-item.current-session {
|
||||||
|
border: 2px solid #007bff;
|
||||||
|
background: #f8f9ff;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 123, 255, 0.2);
|
||||||
|
}
|
||||||
|
.credential-item.current-session .credential-info h4 {
|
||||||
|
color: #0056b3;
|
||||||
|
}
|
||||||
|
.credential-header {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 32px 1fr auto auto;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.credential-icon {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.auth-icon {
|
||||||
|
border-radius: 4px;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
}
|
||||||
|
.auth-emoji {
|
||||||
|
font-size: 24px;
|
||||||
|
display: block;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.credential-info {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.credential-info h4 {
|
||||||
|
margin: 0;
|
||||||
|
color: #333;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
.credential-dates {
|
||||||
|
text-align: right;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-left: 20px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto auto;
|
||||||
|
gap: 5px 10px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.date-label {
|
||||||
|
color: #666;
|
||||||
|
font-weight: normal;
|
||||||
|
font-size: 12px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
.date-value {
|
||||||
|
color: #333;
|
||||||
|
font-size: 12px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.user-info {
|
||||||
|
background: #e7f3ff;
|
||||||
|
border: 1px solid #bee5eb;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 15px;
|
||||||
|
margin: 20px 0;
|
||||||
|
}
|
||||||
|
.user-info h3 {
|
||||||
|
margin: 0 0 10px 0;
|
||||||
|
color: #0c5460;
|
||||||
|
}
|
||||||
|
.user-info p {
|
||||||
|
margin: 5px 0;
|
||||||
|
color: #0c5460;
|
||||||
|
}
|
||||||
|
.toggle-link {
|
||||||
|
color: #667eea;
|
||||||
|
text-decoration: underline;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.toggle-link:hover {
|
||||||
|
color: #764ba2;
|
||||||
|
}
|
||||||
|
.hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.credential-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.btn-delete-credential {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 16px;
|
||||||
|
color: #dc3545;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
}
|
||||||
|
.btn-delete-credential:hover:not(:disabled) {
|
||||||
|
background-color: #f8d7da;
|
||||||
|
}
|
||||||
|
.btn-delete-credential:disabled {
|
||||||
|
opacity: 0.3;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<h1>WebAuthn Demo</h1>
|
<!-- Login View -->
|
||||||
|
<div id="loginView" class="view active">
|
||||||
<div style="margin-bottom: 30px;">
|
<h1>🔐 Passkey Login</h1>
|
||||||
<h2>Register</h2>
|
<div id="loginStatus"></div>
|
||||||
<form id="registrationForm">
|
<form id="authenticationForm">
|
||||||
<input type="text" name="username" placeholder="Username" required>
|
<button type="submit" class="btn-primary">Login with Your Device</button>
|
||||||
<br>
|
|
||||||
<button type="submit">Register Passkey</button>
|
|
||||||
</form>
|
</form>
|
||||||
|
<p class="toggle-link" onclick="showRegisterView()">
|
||||||
|
Don't have an account? Register here
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<!-- Register View -->
|
||||||
<h2>Authenticate</h2>
|
<div id="registerView" class="view">
|
||||||
<form id="authenticationForm">
|
<h1>🔐 Create Account</h1>
|
||||||
<button type="submit">Authenticate with Passkey</button>
|
<div id="registerStatus"></div>
|
||||||
|
<form id="registrationForm">
|
||||||
|
<input type="text" name="username" placeholder="Enter username" required>
|
||||||
|
<button type="submit" class="btn-primary">Register Passkey</button>
|
||||||
</form>
|
</form>
|
||||||
|
<p class="toggle-link" onclick="showLoginView()">
|
||||||
|
Already have an account? Login here
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Dashboard View -->
|
||||||
|
<div id="dashboardView" class="view">
|
||||||
|
<h1>👋 Welcome!</h1>
|
||||||
|
<div id="userInfo" class="user-info"></div>
|
||||||
|
<div id="dashboardStatus"></div>
|
||||||
|
|
||||||
|
<h2>Your Passkeys</h2>
|
||||||
|
<div id="credentialList" class="credential-list">
|
||||||
|
<p>Loading credentials...</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button onclick="addNewCredential()" class="btn-primary">
|
||||||
|
Add New Passkey
|
||||||
|
</button>
|
||||||
|
<button onclick="logout()" class="btn-danger">
|
||||||
|
Logout
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
Loading…
x
Reference in New Issue
Block a user