Compare commits

...

3 Commits

Author SHA1 Message Date
Leo Vasanko
0f71f80446 Refactoring in progress, needs cleanup. 2025-07-07 15:00:02 -06:00
Leo Vasanko
52520c18b1 Mostly working, saving. 2025-07-07 13:19:39 -06:00
Leo Vasanko
1c9044054a More refactoring. Prevent registering another key on the same authenticator for the same user. 2025-07-07 11:20:28 -06:00
34 changed files with 3081 additions and 612 deletions

View File

@ -191,7 +191,7 @@ async def get_index():
<html> <html>
<head> <head>
<title>WebAuthn Registration Demo</title> <title>WebAuthn Registration Demo</title>
<script src="https://unpkg.com/@simplewebauthn/browser/dist/bundle/index.umd.min.js"></script> <script src="/static/simplewebauthn-browser.min.js"></script>
<style> <style>
body { font-family: Arial, sans-serif; max-width: 600px; margin: 50px auto; padding: 20px; } body { font-family: Arial, sans-serif; max-width: 600px; margin: 50px auto; padding: 20px; }
.container { text-align: center; } .container { text-align: center; }

View File

@ -10,8 +10,8 @@ This module contains all the HTTP API endpoints for:
from fastapi import Request, Response from fastapi import Request, Response
from . import db
from .aaguid_manager import get_aaguid_manager from .aaguid_manager import get_aaguid_manager
from .db import connect
from .jwt_manager import refresh_session_token, validate_session_token from .jwt_manager import refresh_session_token, validate_session_token
from .session_manager import ( from .session_manager import (
clear_session_cookie, clear_session_cookie,
@ -36,6 +36,7 @@ async def get_user_info(request: Request) -> dict:
"user_name": user.user_name, "user_name": user.user_name,
"created_at": user.created_at.isoformat() if user.created_at else None, "created_at": user.created_at.isoformat() if user.created_at else None,
"last_seen": user.last_seen.isoformat() if user.last_seen else None, "last_seen": user.last_seen.isoformat() if user.last_seen else None,
"visits": user.visits,
}, },
} }
except Exception as e: except Exception as e:
@ -57,57 +58,54 @@ async def get_user_credentials(request: Request) -> dict:
if token_data: if token_data:
current_credential_id = token_data.get("credential_id") current_credential_id = token_data.get("credential_id")
async with connect() as db: # Get all credentials for the user
# Get all credentials for the user credential_ids = await db.get_user_credentials(user.user_id)
credential_ids = await db.get_credentials_by_user_id(user.user_id.bytes)
credentials = [] credentials = []
user_aaguids = set() user_aaguids = set()
for cred_id in credential_ids: for cred_id in credential_ids:
try: try:
stored_cred = await db.get_credential_by_id(cred_id) stored_cred = await db.get_credential_by_id(cred_id)
# Convert AAGUID to string format # Convert AAGUID to string format
aaguid_str = str(stored_cred.aaguid) aaguid_str = str(stored_cred.aaguid)
user_aaguids.add(aaguid_str) user_aaguids.add(aaguid_str)
# Check if this is the current session credential # Check if this is the current session credential
is_current_session = ( is_current_session = current_credential_id == stored_cred.credential_id
current_credential_id == stored_cred.credential_id
)
credentials.append( credentials.append(
{ {
"credential_id": stored_cred.credential_id.hex(), "credential_id": stored_cred.credential_id.hex(),
"aaguid": aaguid_str, "aaguid": aaguid_str,
"created_at": stored_cred.created_at.isoformat(), "created_at": stored_cred.created_at.isoformat(),
"last_used": stored_cred.last_used.isoformat() "last_used": stored_cred.last_used.isoformat()
if stored_cred.last_used if stored_cred.last_used
else None, else None,
"last_verified": stored_cred.last_verified.isoformat() "last_verified": stored_cred.last_verified.isoformat()
if stored_cred.last_verified if stored_cred.last_verified
else None, else None,
"sign_count": stored_cred.sign_count, "sign_count": stored_cred.sign_count,
"is_current_session": is_current_session, "is_current_session": is_current_session,
} }
) )
except ValueError: except ValueError:
# Skip invalid credentials # Skip invalid credentials
continue continue
# Get AAGUID information for only the AAGUIDs that the user has # Get AAGUID information for only the AAGUIDs that the user has
aaguid_manager = get_aaguid_manager() aaguid_manager = get_aaguid_manager()
aaguid_info = aaguid_manager.get_relevant_aaguids(user_aaguids) aaguid_info = aaguid_manager.get_relevant_aaguids(user_aaguids)
# Sort credentials by creation date (earliest first, most recently created last) # Sort credentials by creation date (earliest first, most recently created last)
credentials.sort(key=lambda cred: cred["created_at"]) credentials.sort(key=lambda cred: cred["created_at"])
return { return {
"status": "success", "status": "success",
"credentials": credentials, "credentials": credentials,
"aaguid_info": aaguid_info, "aaguid_info": aaguid_info,
} }
except Exception as e: except Exception as e:
return {"error": f"Failed to get credentials: {str(e)}"} return {"error": f"Failed to get credentials: {str(e)}"}
@ -212,36 +210,30 @@ async def delete_credential(request: Request) -> dict:
except ValueError: except ValueError:
return {"error": "Invalid credential_id format"} return {"error": "Invalid credential_id format"}
async with connect() as db: # First, verify the credential belongs to the current user
# First, verify the credential belongs to the current user try:
try: stored_cred = await db.get_credential_by_id(credential_id_bytes)
stored_cred = await db.get_credential_by_id(credential_id_bytes) if stored_cred.user_id != user.user_id:
if stored_cred.user_id != user.user_id: return {"error": "Credential not found or access denied"}
return {"error": "Credential not found or access denied"} except ValueError:
except ValueError: return {"error": "Credential not found"}
return {"error": "Credential not found"}
# Check if this is the current session credential # Check if this is the current session credential
session_token = get_session_token_from_request(request) session_token = get_session_token_from_request(request)
if session_token: if session_token:
token_data = validate_session_token(session_token) token_data = validate_session_token(session_token)
if ( if token_data and token_data.get("credential_id") == credential_id_bytes:
token_data return {"error": "Cannot delete current session credential"}
and token_data.get("credential_id") == credential_id_bytes
):
return {"error": "Cannot delete current session credential"}
# Get user's remaining credentials count # Get user's remaining credentials count
remaining_credentials = await db.get_credentials_by_user_id( remaining_credentials = await db.get_user_credentials(user.user_id)
user.user_id.bytes if len(remaining_credentials) <= 1:
) return {"error": "Cannot delete last remaining credential"}
if len(remaining_credentials) <= 1:
return {"error": "Cannot delete last remaining credential"}
# Delete the credential # Delete the credential
await db.delete_credential(credential_id_bytes) await db.delete_user_credential(credential_id_bytes)
return {"status": "success", "message": "Credential deleted successfully"} return {"status": "success", "message": "Credential deleted successfully"}
except Exception as e: except Exception as e:
return {"error": f"Failed to delete credential: {str(e)}"} return {"error": f"Failed to delete credential: {str(e)}"}

View File

@ -1,75 +1,84 @@
""" """
Async database implementation for WebAuthn passkey authentication. Async database implementation for WebAuthn passkey authentication.
This module provides an async database layer using dataclasses and aiosqlite This module provides an async database layer using SQLAlchemy async mode
for managing users and credentials in a WebAuthn authentication system. for managing users and credentials in a WebAuthn authentication system.
""" """
import secrets
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime from datetime import datetime, timedelta
from uuid import UUID from uuid import UUID
import aiosqlite from sqlalchemy import (
DateTime,
ForeignKey,
Integer,
LargeBinary,
String,
delete,
select,
update,
)
from sqlalchemy.dialects.sqlite import BLOB
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
from .passkey import StoredCredential from .passkey import StoredCredential
DB_PATH = "webauthn.db" DB_PATH = "sqlite+aiosqlite:///webauthn.db"
# SQL Statements
SQL_CREATE_USERS = """ # SQLAlchemy Models
CREATE TABLE IF NOT EXISTS users ( class Base(DeclarativeBase):
user_id BINARY(16) PRIMARY KEY NOT NULL, pass
user_name TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
last_seen TIMESTAMP NULL class UserModel(Base):
__tablename__ = "users"
user_id: Mapped[bytes] = mapped_column(LargeBinary(16), primary_key=True)
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(
"CredentialModel", back_populates="user", cascade="all, delete-orphan"
) )
"""
SQL_CREATE_CREDENTIALS = """
CREATE TABLE IF NOT EXISTS credentials ( class CredentialModel(Base):
credential_id BINARY(64) PRIMARY KEY NOT NULL, __tablename__ = "credentials"
user_id BINARY(16) NOT NULL,
aaguid BINARY(16) NOT NULL, credential_id: Mapped[bytes] = mapped_column(LargeBinary(64), primary_key=True)
public_key BLOB NOT NULL, user_id: Mapped[bytes] = mapped_column(
sign_count INTEGER NOT NULL, LargeBinary(16), ForeignKey("users.user_id", ondelete="CASCADE")
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
last_used TIMESTAMP NULL,
last_verified TIMESTAMP NULL,
FOREIGN KEY (user_id) REFERENCES users (user_id) ON DELETE CASCADE
) )
""" aaguid: Mapped[bytes] = mapped_column(LargeBinary(16), nullable=False)
public_key: Mapped[bytes] = mapped_column(BLOB, nullable=False)
sign_count: Mapped[int] = mapped_column(Integer, nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.now)
last_used: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
last_verified: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
SQL_GET_USER_BY_USER_ID = """ # Relationship to user
SELECT * FROM users WHERE user_id = ? user: Mapped["UserModel"] = relationship("UserModel", back_populates="credentials")
"""
SQL_CREATE_USER = """
INSERT INTO users (user_id, user_name, created_at, last_seen) VALUES (?, ?, ?, ?)
"""
SQL_STORE_CREDENTIAL = """ class ResetTokenModel(Base):
INSERT INTO credentials (credential_id, user_id, aaguid, public_key, sign_count, created_at, last_used, last_verified) __tablename__ = "reset_tokens"
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
"""
SQL_GET_CREDENTIAL_BY_ID = """ token: Mapped[str] = mapped_column(String(64), primary_key=True)
SELECT * FROM credentials WHERE credential_id = ? user_id: Mapped[bytes] = mapped_column(
""" LargeBinary(16), ForeignKey("users.user_id", ondelete="CASCADE")
)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.now)
SQL_GET_USER_CREDENTIALS = """ # Relationship to user
SELECT credential_id FROM credentials WHERE user_id = ? user: Mapped["UserModel"] = relationship("UserModel")
"""
SQL_UPDATE_CREDENTIAL = """
UPDATE credentials
SET sign_count = ?, created_at = ?, last_used = ?, last_verified = ?
WHERE credential_id = ?
"""
SQL_DELETE_CREDENTIAL = """
DELETE FROM credentials WHERE credential_id = ?
"""
@dataclass @dataclass
@ -78,123 +87,302 @@ class User:
user_name: str user_name: str
created_at: datetime | None = None created_at: datetime | None = None
last_seen: 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
engine = create_async_engine(DB_PATH, echo=False)
async_session_factory = async_sessionmaker(engine, expire_on_commit=False)
@asynccontextmanager @asynccontextmanager
async def connect(): async def connect():
conn = await aiosqlite.connect(DB_PATH) """Context manager for database connections."""
try: async with async_session_factory() as session:
yield DB(conn) yield DB(session)
await conn.commit() await session.commit()
finally:
await conn.close()
class DB: class DB:
def __init__(self, conn: aiosqlite.Connection): def __init__(self, session: AsyncSession):
self.conn = conn self.session = session
async def init_db(self) -> None: async def init_db(self) -> None:
"""Initialize database tables.""" """Initialize database tables."""
await self.conn.execute(SQL_CREATE_USERS) async with engine.begin() as conn:
await self.conn.execute(SQL_CREATE_CREDENTIALS) await conn.run_sync(Base.metadata.create_all)
await self.conn.commit()
# Database operation functions that work with a connection async def get_user_by_user_id(self, user_id: UUID) -> User:
async def get_user_by_user_id(self, user_id: bytes) -> User:
"""Get user record by WebAuthn user ID.""" """Get user record by WebAuthn user ID."""
async with self.conn.execute(SQL_GET_USER_BY_USER_ID, (user_id,)) as cursor: stmt = select(UserModel).where(UserModel.user_id == user_id.bytes)
row = await cursor.fetchone() result = await self.session.execute(stmt)
if row: user_model = result.scalar_one_or_none()
return User(
user_id=UUID(bytes=row[0]), if user_model:
user_name=row[1], return User(
created_at=_convert_datetime(row[2]), user_id=UUID(bytes=user_model.user_id),
last_seen=_convert_datetime(row[3]), user_name=user_model.user_name,
) created_at=user_model.created_at,
raise ValueError("User not found") last_seen=user_model.last_seen,
visits=user_model.visits,
)
raise ValueError("User not found")
async def create_user(self, user: User) -> None: async def create_user(self, user: User) -> None:
"""Create a new user and return the User dataclass.""" """Create a new user."""
await self.conn.execute( user_model = UserModel(
SQL_CREATE_USER, user_id=user.user_id.bytes,
( user_name=user.user_name,
user.user_id.bytes, created_at=user.created_at or datetime.now(),
user.user_name, last_seen=user.last_seen,
user.created_at or datetime.now(), visits=user.visits,
user.last_seen,
),
) )
self.session.add(user_model)
await self.session.flush()
async def create_credential(self, credential: StoredCredential) -> None: async def create_credential(self, credential: StoredCredential) -> None:
"""Store a credential for a user.""" """Store a credential for a user."""
await self.conn.execute( credential_model = CredentialModel(
SQL_STORE_CREDENTIAL, credential_id=credential.credential_id,
( user_id=credential.user_id.bytes,
credential.credential_id, aaguid=credential.aaguid.bytes,
credential.user_id.bytes, public_key=credential.public_key,
credential.aaguid.bytes, sign_count=credential.sign_count,
credential.public_key, created_at=credential.created_at,
credential.sign_count, last_used=credential.last_used,
credential.created_at, last_verified=credential.last_verified,
credential.last_used,
credential.last_verified,
),
) )
self.session.add(credential_model)
await self.session.flush()
async def get_credential_by_id(self, credential_id: bytes) -> StoredCredential: async def get_credential_by_id(self, credential_id: bytes) -> StoredCredential:
"""Get credential by credential ID.""" """Get credential by credential ID."""
async with self.conn.execute( stmt = select(CredentialModel).where(
SQL_GET_CREDENTIAL_BY_ID, (credential_id,) CredentialModel.credential_id == credential_id
) as cursor: )
row = await cursor.fetchone() result = await self.session.execute(stmt)
if row: credential_model = result.scalar_one_or_none()
return StoredCredential(
credential_id=row[0],
user_id=UUID(bytes=row[1]),
aaguid=UUID(bytes=row[2]),
public_key=row[3],
sign_count=row[4],
created_at=datetime.fromisoformat(row[5]),
last_used=_convert_datetime(row[6]),
last_verified=_convert_datetime(row[7]),
)
raise ValueError("Credential not registered")
async def get_credentials_by_user_id(self, user_id: bytes) -> list[bytes]: if credential_model:
return StoredCredential(
credential_id=credential_model.credential_id,
user_id=UUID(bytes=credential_model.user_id),
aaguid=UUID(bytes=credential_model.aaguid),
public_key=credential_model.public_key,
sign_count=credential_model.sign_count,
created_at=credential_model.created_at,
last_used=credential_model.last_used,
last_verified=credential_model.last_verified,
)
raise ValueError("Credential not registered")
async def get_credentials_by_user_id(self, user_id: UUID) -> list[bytes]:
"""Get all credential IDs for a user.""" """Get all credential IDs for a user."""
async with self.conn.execute(SQL_GET_USER_CREDENTIALS, (user_id,)) as cursor: stmt = select(CredentialModel.credential_id).where(
rows = await cursor.fetchall() CredentialModel.user_id == user_id.bytes
return [row[0] for row in rows] )
result = await self.session.execute(stmt)
return [row[0] for row in result.fetchall()]
async def update_credential(self, credential: StoredCredential) -> None: async def update_credential(self, credential: StoredCredential) -> None:
"""Update the sign count, created_at, last_used, and last_verified for a credential.""" """Update the sign count, created_at, last_used, and last_verified for a credential."""
await self.conn.execute( stmt = (
SQL_UPDATE_CREDENTIAL, update(CredentialModel)
( .where(CredentialModel.credential_id == credential.credential_id)
credential.sign_count, .values(
credential.created_at, sign_count=credential.sign_count,
credential.last_used, created_at=credential.created_at,
credential.last_verified, last_used=credential.last_used,
credential.credential_id, last_verified=credential.last_verified,
), )
) )
await self.session.execute(stmt)
async def login(self, user_id: bytes, credential: StoredCredential) -> None: async def login(self, user_id: UUID, credential: StoredCredential) -> None:
"""Update the last_seen timestamp for a user and the credential record used for logging in.""" """Update the last_seen timestamp for a user and the credential record used for logging in."""
await self.conn.execute("BEGIN") async with self.session.begin():
await self.update_credential(credential) # Update credential
await self.conn.execute( await self.update_credential(credential)
"UPDATE users SET last_seen = ? WHERE user_id = ?",
(credential.last_used, user_id), # 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)
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)
async def delete_credential(self, credential_id: bytes) -> None: async def delete_credential(self, credential_id: bytes) -> None:
"""Delete a credential by its ID.""" """Delete a credential by its ID."""
await self.conn.execute(SQL_DELETE_CREDENTIAL, (credential_id,)) stmt = delete(CredentialModel).where(
await self.conn.commit() CredentialModel.credential_id == credential_id
)
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
def _convert_datetime(val): # Standalone functions that handle database connections internally
"""Convert string from SQLite to datetime object (pass through None).""" async def init_database() -> None:
return val and datetime.fromisoformat(val) """Initialize database tables."""
async with connect() as db:
await db.init_db()
async def create_user_and_credential(user: User, credential: StoredCredential) -> None:
"""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)
async def get_user_by_id(user_id: UUID) -> User:
"""Get user record by WebAuthn user ID."""
async with connect() as db:
return await db.get_user_by_user_id(user_id)
async def create_credential_for_user(credential: StoredCredential) -> None:
"""Store a credential for an existing user."""
async with connect() as db:
await db.create_credential(credential)
async def get_credential_by_id(credential_id: bytes) -> StoredCredential:
"""Get credential by credential ID."""
async with connect() as db:
return await db.get_credential_by_id(credential_id)
async def get_user_credentials(user_id: UUID) -> list[bytes]:
"""Get all credential IDs for a user."""
async with connect() as db:
return await db.get_credentials_by_user_id(user_id)
async def login_user(user_id: UUID, credential: StoredCredential) -> None:
"""Update the last_seen timestamp for a user and the credential record used for logging in."""
async with connect() as db:
await db.login(user_id, credential)
async def delete_user_credential(credential_id: bytes) -> None:
"""Delete a credential by its ID."""
async with connect() as db:
await db.delete_credential(credential_id)
async def create_new_session(user_id: UUID, credential: StoredCredential) -> None:
"""Create a new session for a user by incrementing visits and updating last_seen."""
async with connect() as db:
await db.create_new_session(user_id, credential)
async def create_reset_token(user_id: UUID, token: str | None = None) -> str:
"""Create a reset token for a user."""
async with connect() as db:
return await db.create_reset_token(user_id, token)
async def get_reset_token(token: str) -> ResetToken | None:
"""Get reset token by token string."""
async with connect() as db:
return await db.get_reset_token(token)
async def delete_reset_token(token: str) -> None:
"""Delete a reset token (used after successful credential addition)."""
async with connect() as db:
await db.delete_reset_token(token)
async def cleanup_expired_tokens() -> None:
"""Remove expired reset tokens (older than 24 hours)."""
async with connect() as db:
await db.cleanup_expired_tokens()
async def get_user_by_username(user_name: str) -> User | None:
"""Get user by username."""
async with connect() as db:
return await db.get_user_by_username(user_name)

View File

@ -18,6 +18,7 @@ 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 . import db
from .api_handlers import ( from .api_handlers import (
delete_credential, delete_credential,
get_user_credentials, get_user_credentials,
@ -27,9 +28,10 @@ from .api_handlers import (
set_session, set_session,
validate_token, validate_token,
) )
from .db import User, connect from .db import User
from .jwt_manager import create_session_token from .jwt_manager import create_session_token
from .passkey import Passkey 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 from .session_manager import get_user_from_cookie_string
STATIC_DIR = Path(__file__).parent.parent / "static" STATIC_DIR = Path(__file__).parent.parent / "static"
@ -44,8 +46,7 @@ passkey = Passkey(
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
async with connect() as db: await db.init_database()
await db.init_db()
yield yield
@ -65,11 +66,11 @@ async def websocket_register_new(ws: WebSocket):
# WebAuthn registration # WebAuthn registration
credential = await register_chat(ws, user_id, user_name) credential = await register_chat(ws, user_id, user_name)
# Store the user in the database # Store the user and credential in the database
async with connect() as db: await db.create_user_and_credential(
await db.conn.execute("BEGIN") User(user_id, user_name, created_at=datetime.now()),
await db.create_user(User(user_id, user_name, created_at=datetime.now())) credential,
await db.create_credential(credential) )
# Create a session token for the new user # Create a session token for the new user
session_token = create_session_token(user_id, credential.credential_id) session_token = create_session_token(user_id, credential.credential_id)
@ -101,16 +102,15 @@ async def websocket_register_add(ws: WebSocket):
return return
# Get user information to get the user_name # Get user information to get the user_name
async with connect() as db: user = await db.get_user_by_id(user_id)
user = await db.get_user_by_user_id(user_id.bytes) user_name = user.user_name
user_name = user.user_name challenge_ids = await db.get_user_credentials(user_id)
# WebAuthn registration # WebAuthn registration
credential = await register_chat(ws, user_id, user_name) credential = await register_chat(ws, user_id, user_name, challenge_ids)
print(f"New credential for user {user_id}: {credential}") print(f"New credential for user {user_id}: {credential}")
# Store the new credential in the database # Store the new credential in the database
async with connect() as db: await db.create_credential_for_user(credential)
await db.create_credential(credential)
await ws.send_json( await ws.send_json(
{ {
@ -128,11 +128,75 @@ async def websocket_register_add(ws: WebSocket):
await ws.send_json({"error": f"Server error: {str(e)}"}) await ws.send_json({"error": f"Server error: {str(e)}"})
async def register_chat(ws: WebSocket, user_id: UUID, user_name: str): @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,
user_name: str,
credential_ids: list[bytes] | None = None,
):
"""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,
user_name=user_name, user_name=user_name,
credential_ids=credential_ids,
) )
await ws.send_json(options) await ws.send_json(options)
response = await ws.receive_json() response = await ws.receive_json()
@ -144,17 +208,16 @@ async def register_chat(ws: WebSocket, user_id: UUID, user_name: str):
async def websocket_authenticate(ws: WebSocket): async def websocket_authenticate(ws: WebSocket):
await ws.accept() await ws.accept()
try: try:
options, challenge = await passkey.auth_generate_options() options, challenge = 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_parse(await ws.receive_json()) credential = passkey.auth_parse(await ws.receive_json())
async with connect() as db: # Fetch from the database by credential ID
# Fetch from the database by credential ID 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 passkey.auth_verify(credential, challenge, stored_cred)
await passkey.auth_verify(credential, challenge, stored_cred) # Update both credential and user's last_seen timestamp
# Update both credential and user's last_seen timestamp await db.login_user(stored_cred.user_id, stored_cred)
await db.login(stored_cred.user_id.bytes, stored_cred)
# Create a session token for the authenticated user # Create a session token for the authenticated user
session_token = create_session_token( session_token = create_session_token(
@ -216,14 +279,66 @@ async def api_delete_credential(request: Request):
return await delete_credential(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 # Serve static files
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static") app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
@app.get("/") @app.get("/")
async def get_index(): async def get_index():
"""Serve the main HTML page""" """Redirect to login page"""
return FileResponse(STATIC_DIR / "index.html") from fastapi.responses import RedirectResponse
return RedirectResponse(url="/auth/login", status_code=302)
@app.get("/auth/login")
async def get_login_page():
"""Serve the login page"""
return FileResponse(STATIC_DIR / "login.html")
@app.get("/auth/register")
async def get_register_page():
"""Serve the register page"""
return FileResponse(STATIC_DIR / "register.html")
@app.get("/auth/dashboard")
async def get_dashboard_page():
"""Redirect to profile (dashboard is now profile)"""
from fastapi.responses import RedirectResponse
return RedirectResponse(url="/auth/profile", status_code=302)
@app.get("/auth/profile")
async def get_profile_page():
"""Serve the profile page"""
return FileResponse(STATIC_DIR / "profile.html")
@app.get("/auth/reset")
async def get_reset_page_without_token():
"""Serve the reset page without a token"""
return FileResponse(STATIC_DIR / "reset.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(): def main():

View File

@ -155,7 +155,7 @@ class Passkey:
### Authentication Methods ### ### Authentication Methods ###
async def auth_generate_options( def auth_generate_options(
self, self,
*, *,
user_verification_required=False, user_verification_required=False,
@ -178,7 +178,7 @@ class Passkey:
user_verification=( user_verification=(
UserVerificationRequirement.REQUIRED UserVerificationRequirement.REQUIRED
if user_verification_required if user_verification_required
else UserVerificationRequirement.PREFERRED else UserVerificationRequirement.DISCOURAGED
), ),
allow_credentials=_convert_credential_ids(credential_ids), allow_credentials=_convert_credential_ids(credential_ids),
**authopts, **authopts,
@ -188,7 +188,7 @@ class Passkey:
def auth_parse(self, response: dict | str) -> AuthenticationCredential: def auth_parse(self, response: dict | str) -> AuthenticationCredential:
return parse_authentication_credential_json(response) return parse_authentication_credential_json(response)
async def auth_verify( def auth_verify(
self, self,
credential: AuthenticationCredential, credential: AuthenticationCredential,
expected_challenge: bytes, expected_challenge: bytes,

View File

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

View File

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

View File

@ -12,7 +12,7 @@ from uuid import UUID
from fastapi import Request, Response from fastapi import Request, Response
from .db import User, connect from .db import User, get_user_by_id
from .jwt_manager import validate_session_token from .jwt_manager import validate_session_token
COOKIE_NAME = "session_token" COOKIE_NAME = "session_token"
@ -29,12 +29,11 @@ async def get_current_user(request: Request) -> Optional[User]:
if not token_data: if not token_data:
return None return None
async with connect() as db: try:
try: user = await get_user_by_id(token_data["user_id"])
user = await db.get_user_by_user_id(token_data["user_id"].bytes) return user
return user except Exception:
except Exception: return None
return None
def set_session_cookie(response: Response, session_token: str) -> None: def set_session_cookie(response: Response, session_token: str) -> None:

54
passkeyauth/wordlist.py Normal file
View File

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

View File

@ -15,6 +15,7 @@ dependencies = [
"websockets>=12.0", "websockets>=12.0",
"webauthn>=1.11.1", "webauthn>=1.11.1",
"base64url>=1.0.0", "base64url>=1.0.0",
"sqlalchemy[asyncio]>=2.0.0",
"aiosqlite>=0.19.0", "aiosqlite>=0.19.0",
"uuid7-standard>=1.0.0", "uuid7-standard>=1.0.0",
"pyjwt>=2.8.0", "pyjwt>=2.8.0",

93
static/add-device.html Normal file
View File

@ -0,0 +1,93 @@
<!DOCTYPE html>
<html>
<head>
<title>Add Device - Passkey Authentication</title>
<link rel="stylesheet" href="/static/style.css">
<script src="/static/simplewebauthn-browser.min.js"></script>
<script src="/static/qrcodejs/qrcode.min.js"></script>
<script src="/static/awaitable-websocket.js"></script>
</head>
<body>
<div class="container">
<!-- Device Addition View -->
<div id="deviceAdditionView" class="view active">
<h1>📱 Add Device</h1>
<div id="deviceAdditionStatus"></div>
<div id="deviceLinkSection">
<h2>Device Addition Link</h2>
<div class="token-info">
<p><strong>Share this link to add this account to another device:</strong></p>
<div class="qr-container">
<div id="qrCode" class="qr-code"></div>
<p><small>Scan this QR code with your other device</small></p>
</div>
<div class="link-container">
<p class="link-text" id="deviceLinkText">Loading...</p>
<button class="copy-button" onclick="copyDeviceLink()">Copy Link</button>
</div>
<p><small>⚠️ This link expires in 24 hours and can only be used once.</small></p>
<p><strong>Human-readable code:</strong> <code id="deviceToken"></code></p>
</div>
</div>
<button onclick="window.location.href='/auth/profile'" class="btn-secondary">
Back to Profile
</button>
</div>
</div>
<script src="/static/app.js"></script>
<script>
// Initialize the device addition view when page loads
document.addEventListener('DOMContentLoaded', function() {
initializeApp();
// Auto-generate device link when page loads
generateDeviceLink();
});
// Generate device link function
function generateDeviceLink() {
clearStatus('deviceAdditionStatus');
showStatus('deviceAdditionStatus', 'Generating device link...', 'info');
fetch('/api/create-device-link', {
method: 'POST',
credentials: 'include'
})
.then(response => response.json())
.then(result => {
if (result.error) throw new Error(result.error);
// Update UI with the link
document.getElementById('deviceLinkText').textContent = result.addition_link;
document.getElementById('deviceToken').textContent = result.token;
// Store link globally for copy function
window.currentDeviceLink = result.addition_link;
// Generate QR code
const qrCodeEl = document.getElementById('qrCode');
qrCodeEl.innerHTML = '';
new QRCode(qrCodeEl, {
text: result.addition_link,
width: 200,
height: 200,
colorDark: '#000000',
colorLight: '#ffffff',
correctLevel: QRCode.CorrectLevel.M
});
showStatus('deviceAdditionStatus', 'Device link generated successfully!', 'success');
})
.catch(error => {
console.error('Error generating device link:', error);
showStatus('deviceAdditionStatus', `Failed to generate device link: ${error.message}`, 'error');
});
}
</script>
</body>
</html>

View File

@ -5,7 +5,10 @@ let currentUser = null
let currentCredentials = [] let currentCredentials = []
let aaguidInfo = {} let aaguidInfo = {}
// Session management - now using HTTP-only cookies // ========================================
// Session Management
// ========================================
async function validateStoredToken() { async function validateStoredToken() {
try { try {
const response = await fetch('/api/validate-token', { const response = await fetch('/api/validate-token', {
@ -14,18 +17,12 @@ async function validateStoredToken() {
}) })
const result = await response.json() const result = await response.json()
return result.status === 'success'
if (result.status === 'success') {
return true
} else {
return false
}
} catch (error) { } catch (error) {
return false return false
} }
} }
// Helper function to set session cookie using JWT token
async function setSessionCookie(sessionToken) { async function setSessionCookie(sessionToken) {
try { try {
const response = await fetch('/api/set-session', { const response = await fetch('/api/set-session', {
@ -48,35 +45,64 @@ async function setSessionCookie(sessionToken) {
} }
} }
// View management // ========================================
// View Management
// ========================================
function showView(viewId) { function showView(viewId) {
document.querySelectorAll('.view').forEach(view => view.classList.remove('active')) document.querySelectorAll('.view').forEach(view => view.classList.remove('active'))
document.getElementById(viewId).classList.add('active') const targetView = document.getElementById(viewId)
if (targetView) {
targetView.classList.add('active')
}
} }
function showLoginView() { function showLoginView() {
if (window.location.pathname !== '/auth/login') {
window.location.href = '/auth/login'
return
}
showView('loginView') showView('loginView')
clearStatus('loginStatus') clearStatus('loginStatus')
} }
function showRegisterView() { function showRegisterView() {
if (window.location.pathname !== '/auth/register') {
window.location.href = '/auth/register'
return
}
showView('registerView') showView('registerView')
clearStatus('registerStatus') clearStatus('registerStatus')
} }
// Update dashboard view to load user info function showDeviceAdditionView() {
// This function is no longer needed as device addition is now a dialog
// Redirect to profile page if someone tries to access the old route
if (window.location.pathname === '/auth/add-device') {
window.location.href = '/auth/profile'
return
}
}
function showDashboardView() { function showDashboardView() {
showView('dashboardView') if (window.location.pathname !== '/auth/profile') {
clearStatus('dashboardStatus') window.location.href = '/auth/profile'
return
}
showView('profileView')
clearStatus('profileStatus')
loadUserInfo().then(() => { loadUserInfo().then(() => {
updateUserInfo() updateUserInfo()
loadCredentials() loadCredentials()
}).catch(error => { }).catch(error => {
showStatus('dashboardStatus', `Failed to load user info: ${error.message}`, 'error') showStatus('profileStatus', `Failed to load user info: ${error.message}`, 'error')
}) })
} }
// Status management // ========================================
// Status Management
// ========================================
function showStatus(elementId, message, type = 'info') { function showStatus(elementId, message, type = 'info') {
const statusEl = document.getElementById(elementId) const statusEl = document.getElementById(elementId)
statusEl.innerHTML = `<div class="status ${type}">${message}</div>` statusEl.innerHTML = `<div class="status ${type}">${message}</div>`
@ -86,6 +112,106 @@ function clearStatus(elementId) {
document.getElementById(elementId).innerHTML = '' document.getElementById(elementId).innerHTML = ''
} }
// ========================================
// Device Addition & QR Code
// ========================================
async function copyDeviceLink() {
try {
if (window.currentDeviceLink) {
await navigator.clipboard.writeText(window.currentDeviceLink)
const copyButton = document.querySelector('.copy-button')
const originalText = copyButton.textContent
copyButton.textContent = 'Copied!'
copyButton.style.background = '#28a745'
setTimeout(() => {
copyButton.textContent = originalText
copyButton.style.background = '#28a745'
}, 2000)
}
} catch (error) {
console.error('Failed to copy link:', error)
const linkText = document.getElementById('deviceLinkText')
const range = document.createRange()
range.selectNode(linkText)
window.getSelection().removeAllRanges()
window.getSelection().addRange(range)
}
}
// ========================================
// WebAuthn Operations
// ========================================
async function register(user_name) {
const ws = await aWebSocket('/ws/new_user_registration')
ws.send(JSON.stringify({ user_name }))
const optionsJSON = JSON.parse(await ws.recv())
if (optionsJSON.error) throw new Error(optionsJSON.error)
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}`)
await setSessionCookie(result.session_token)
ws.close()
}
async function authenticate() {
const ws = await aWebSocket('/ws/authenticate')
const optionsJSON = JSON.parse(await ws.recv())
if (optionsJSON.error) throw new Error(optionsJSON.error)
const authenticationResponse = await startAuthentication({ optionsJSON })
ws.send(JSON.stringify(authenticationResponse))
const result = JSON.parse(await ws.recv())
if (result.error) throw new Error(`Server: ${result.error}`)
await setSessionCookie(result.session_token)
ws.close()
}
async function addNewCredential() {
try {
showStatus('dashboardStatus', 'Adding new passkey...', 'info')
const ws = await aWebSocket('/ws/add_credential')
const optionsJSON = JSON.parse(await ws.recv())
if (optionsJSON.error) throw new Error(optionsJSON.error)
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')
setTimeout(() => {
loadCredentials()
clearStatus('dashboardStatus')
}, 2000)
} catch (error) {
showStatus('dashboardStatus', `Failed to add passkey: ${error.message}`, 'error')
}
}
// ========================================
// User Data Management
// ========================================
// User registration // User registration
async function register(user_name) { async function register(user_name) {
try { try {
@ -162,7 +288,8 @@ async function authenticate() {
// Load user credentials // Load user credentials
async function loadCredentials() { async function loadCredentials() {
try { try {
showStatus('dashboardStatus', 'Loading credentials...', 'info') const statusElement = document.getElementById('profileStatus') ? 'profileStatus' : 'dashboardStatus'
showStatus(statusElement, 'Loading credentials...', 'info')
const response = await fetch('/api/user-credentials', { const response = await fetch('/api/user-credentials', {
method: 'GET', method: 'GET',
@ -175,9 +302,10 @@ async function loadCredentials() {
currentCredentials = result.credentials currentCredentials = result.credentials
aaguidInfo = result.aaguid_info || {} aaguidInfo = result.aaguid_info || {}
updateCredentialList() updateCredentialList()
clearStatus('dashboardStatus') clearStatus(statusElement)
} catch (error) { } catch (error) {
showStatus('dashboardStatus', `Failed to load credentials: ${error.message}`, 'error') const statusElement = document.getElementById('profileStatus') ? 'profileStatus' : 'dashboardStatus'
showStatus(statusElement, `Failed to load credentials: ${error.message}`, 'error')
} }
} }
@ -204,6 +332,9 @@ function updateUserInfo() {
if (currentUser) { if (currentUser) {
userInfoEl.innerHTML = ` userInfoEl.innerHTML = `
<h3>👤 ${currentUser.user_name}</h3> <h3>👤 ${currentUser.user_name}</h3>
<p><strong>Visits:</strong> ${currentUser.visits || 0}</p>
<p><strong>Member since:</strong> ${currentUser.created_at ? formatHumanReadableDate(currentUser.created_at) : 'N/A'}</p>
<p><strong>Last seen:</strong> ${currentUser.last_seen ? formatHumanReadableDate(currentUser.last_seen) : 'N/A'}</p>
` `
} }
} }
@ -294,141 +425,108 @@ async function logout() {
currentUser = null currentUser = null
currentCredentials = [] currentCredentials = []
aaguidInfo = {} aaguidInfo = {}
showLoginView() window.location.href = '/auth/login'
} }
// Check if user is already logged in on page load // Check if user is already logged in on page load
async function checkExistingSession() { async function checkExistingSession() {
if (await validateStoredToken()) { const isLoggedIn = await validateStoredToken()
showDashboardView() const path = window.location.pathname
} else {
showLoginView() // Protected routes that require authentication
} const protectedRoutes = ['/auth/profile']
}
if (isLoggedIn) {
// Add new credential for logged-in user // User is logged in
async function addNewCredential() { if (path === '/auth/login' || path === '/auth/register' || path === '/') {
try { // Redirect to profile if accessing login/register pages while logged in
showStatus('dashboardStatus', 'Starting new passkey registration...', 'info') window.location.href = '/auth/profile'
} else if (path === '/auth/add-device') {
const ws = await aWebSocket('/ws/add_credential') // Redirect old add-device route to profile
window.location.href = '/auth/profile'
// Registration chat - no need to send user data since we're authenticated } else if (protectedRoutes.includes(path)) {
const optionsJSON = JSON.parse(await ws.recv()) // Stay on current protected page and load user data
if (optionsJSON.error) throw new Error(optionsJSON.error) if (path === '/auth/profile') {
loadUserInfo().then(() => {
showStatus('dashboardStatus', 'Save new passkey to your authenticator...', 'info') updateUserInfo()
loadCredentials()
const registrationResponse = await startRegistration({optionsJSON}) }).catch(error => {
ws.send(JSON.stringify(registrationResponse)) showStatus('profileStatus', `Failed to load user info: ${error.message}`, 'error')
})
const result = JSON.parse(await ws.recv()) }
if (result.error) throw new Error(`Server: ${result.error}`) }
} else {
ws.close() // User is not logged in
if (protectedRoutes.includes(path) || path === '/auth/add-device') {
showStatus('dashboardStatus', 'New passkey added successfully!', 'success') // Redirect to login if accessing protected pages without authentication
window.location.href = '/auth/login'
// 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')
} }
} }
// Initialize the app based on current page
function initializeApp() {
checkExistingSession()
}
// Form event handlers // Form event handlers
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
// Check for existing session on page load // Check for existing session on page load
checkExistingSession() initializeApp()
// Registration form // Registration form
const regForm = document.getElementById('registrationForm') const regForm = document.getElementById('registrationForm')
const regSubmitBtn = regForm.querySelector('button[type="submit"]') if (regForm) {
const regSubmitBtn = regForm.querySelector('button[type="submit"]')
regForm.addEventListener('submit', async (ev) => {
ev.preventDefault()
regSubmitBtn.disabled = true
clearStatus('registerStatus')
const user_name = (new FormData(regForm)).get('username') regForm.addEventListener('submit', async (ev) => {
ev.preventDefault()
try { regSubmitBtn.disabled = true
showStatus('registerStatus', 'Starting registration...', 'info') clearStatus('registerStatus')
await register(user_name)
showStatus('registerStatus', `Registration successful for ${user_name}!`, 'success')
// Auto-login after successful registration const user_name = (new FormData(regForm)).get('username')
setTimeout(() => {
showDashboardView() try {
}, 1500) showStatus('registerStatus', 'Starting registration...', 'info')
} catch (err) { await register(user_name)
showStatus('registerStatus', `Registration failed: ${err.message}`, 'error') showStatus('registerStatus', `Registration successful for ${user_name}!`, 'success')
} finally {
regSubmitBtn.disabled = false // Auto-login after successful registration
} setTimeout(() => {
}) window.location.href = '/auth/profile'
}, 1500)
} catch (err) {
showStatus('registerStatus', `Registration failed: ${err.message}`, 'error')
} finally {
regSubmitBtn.disabled = false
}
})
}
// Authentication form // Authentication form
const authForm = document.getElementById('authenticationForm') const authForm = document.getElementById('authenticationForm')
const authSubmitBtn = authForm.querySelector('button[type="submit"]') if (authForm) {
const authSubmitBtn = authForm.querySelector('button[type="submit"]')
authForm.addEventListener('submit', async (ev) => {
ev.preventDefault()
authSubmitBtn.disabled = true
clearStatus('loginStatus')
try { authForm.addEventListener('submit', async (ev) => {
showStatus('loginStatus', 'Starting authentication...', 'info') ev.preventDefault()
await authenticate() authSubmitBtn.disabled = true
showStatus('loginStatus', 'Authentication successful!', 'success') clearStatus('loginStatus')
// Navigate to dashboard try {
setTimeout(() => { showStatus('loginStatus', 'Starting authentication...', 'info')
showDashboardView() await authenticate()
}, 1000) showStatus('loginStatus', 'Authentication successful!', 'success')
} catch (err) {
showStatus('loginStatus', `Authentication failed: ${err.message}`, 'error') // Navigate to profile
} finally { setTimeout(() => {
authSubmitBtn.disabled = false window.location.href = '/auth/profile'
} }, 1000)
}) } catch (err) {
showStatus('loginStatus', `Authentication failed: ${err.message}`, 'error')
} finally {
authSubmitBtn.disabled = false
}
})
}
}) })

106
static/dashboard.html Normal file
View File

@ -0,0 +1,106 @@
<!DOCTYPE html>
<html>
<head>
<title>Dashboard - Passkey Authentication</title>
<link rel="stylesheet" href="/static/style.css">
<script src="/static/simplewebauthn-browser.min.js"></script>
<script src="/static/qrcodejs/qrcode.min.js"></script>
<script src="/static/awaitable-websocket.js"></script>
</head>
<body>
<div class="container">
<!-- Dashboard View -->
<div id="dashboardView" class="view active">
<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="generateAndShowDeviceLink()" class="btn-secondary">
Generate Device Link
</button>
<button onclick="logout()" class="btn-danger">
Logout
</button>
<!-- Device Addition Section -->
<div id="deviceLinkSection" style="display: none;">
<h2>Device Addition Link</h2>
<div class="token-info">
<p><strong>Share this link to add this account to another device:</strong></p>
<div class="qr-container">
<div id="qrCode" class="qr-code"></div>
<p><small>Scan this QR code with your other device</small></p>
</div>
<div class="link-container">
<p class="link-text" id="deviceLinkText">Loading...</p>
<button class="copy-button" onclick="copyDeviceLink()">Copy Link</button>
</div>
<p><small>⚠️ This link expires in 24 hours and can only be used once.</small></p>
<p><strong>Human-readable code:</strong> <code id="deviceToken"></code></p>
</div>
</div>
</div>
</div>
<script src="/static/app.js"></script>
<script>
// Initialize the dashboard view when page loads
document.addEventListener('DOMContentLoaded', function() {
initializeApp();
});
// Override the generateAndShowDeviceLink function to show the device link section
function generateAndShowDeviceLink() {
clearStatus('dashboardStatus');
fetch('/api/create-device-link', {
method: 'POST',
credentials: 'include'
})
.then(response => response.json())
.then(result => {
if (result.error) throw new Error(result.error);
// Update UI with the link
document.getElementById('deviceLinkText').textContent = result.addition_link;
document.getElementById('deviceToken').textContent = result.token;
// Store link globally for copy function
window.currentDeviceLink = result.addition_link;
// Generate QR code
const qrCodeEl = document.getElementById('qrCode');
qrCodeEl.innerHTML = '';
new QRCode(qrCodeEl, {
text: result.addition_link,
width: 200,
height: 200,
colorDark: '#000000',
colorLight: '#ffffff',
correctLevel: QRCode.CorrectLevel.M
});
// Show the device link section
document.getElementById('deviceLinkSection').style.display = 'block';
showStatus('dashboardStatus', 'Device link generated successfully!', 'success');
})
.catch(error => {
console.error('Error generating device link:', error);
showStatus('dashboardStatus', `Failed to generate device link: ${error.message}`, 'error');
});
}
</script>
</body>
</html>

View File

@ -2,244 +2,10 @@
<html> <html>
<head> <head>
<title>Passkey Authentication</title> <title>Passkey Authentication</title>
<script src="https://unpkg.com/@simplewebauthn/browser/dist/bundle/index.umd.min.js"></script> <link rel="stylesheet" href="/static/style.css">
<script src="/static/simplewebauthn-browser.min.js"></script>
<script src="/static/qrcodejs/qrcode.min.js"></script>
<script src="/static/awaitable-websocket.js"></script> <script src="/static/awaitable-websocket.js"></script>
<style>
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
margin: 0;
padding: 0;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}
.container {
background: white;
padding: 40px;
border-radius: 15px;
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"] {
width: 100%;
padding: 15px;
border: 2px solid #e1e5e9;
border-radius: 8px;
font-size: 16px;
margin-bottom: 20px;
box-sizing: border-box;
transition: border-color 0.3s ease;
}
input[type="text"]:focus {
outline: none;
border-color: #667eea;
}
button {
width: 100%;
padding: 15px;
margin-bottom: 15px;
font-size: 16px;
font-weight: 500;
cursor: pointer;
border: none;
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 {
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;
}
</style>
</head> </head>
<body> <body>
<div class="container"> <div class="container">
@ -282,10 +48,43 @@
<button onclick="addNewCredential()" class="btn-primary"> <button onclick="addNewCredential()" class="btn-primary">
Add New Passkey Add New Passkey
</button> </button>
<button onclick="generateAndShowDeviceLink()" class="btn-secondary">
Generate Device Link
</button>
<button onclick="logout()" class="btn-danger"> <button onclick="logout()" class="btn-danger">
Logout Logout
</button> </button>
</div> </div>
<!-- Device Addition View -->
<div id="deviceAdditionView" class="view">
<h1>📱 Add Device</h1>
<div id="deviceAdditionStatus"></div>
<div id="deviceLinkSection">
<h2>Device Addition Link</h2>
<div class="token-info">
<p><strong>Share this link to add this account to another device:</strong></p>
<div class="qr-container">
<div id="qrCode" class="qr-code"></div>
<p><small>Scan this QR code with your other device</small></p>
</div>
<div class="link-container">
<p class="link-text" id="deviceLinkText">Loading...</p>
<button class="copy-button" onclick="copyDeviceLink()">Copy Link</button>
</div>
<p><small>⚠️ This link expires in 24 hours and can only be used once.</small></p>
<p><strong>Human-readable code:</strong> <code id="deviceToken"></code></p>
</div>
</div>
<button onclick="showDashboardView()" class="btn-secondary">
Back to Dashboard
</button>
</div>
</div> </div>
<script src="static/app.js"></script> <script src="static/app.js"></script>

28
static/login.html Normal file
View File

@ -0,0 +1,28 @@
<!DOCTYPE html>
<html>
<head>
<title>Login - Passkey Authentication</title>
<link rel="stylesheet" href="/static/style.css">
<script src="/static/simplewebauthn-browser.min.js"></script>
<script src="/static/awaitable-websocket.js"></script>
</head>
<body>
<div class="container">
<!-- Login View -->
<div id="loginView" class="view active">
<h1>🔐 Passkey Login</h1>
<div id="loginStatus"></div>
<form id="authenticationForm">
<button type="submit" class="btn-primary">Login with Your Device</button>
</form>
<p class="toggle-link" onclick="window.location.href='/auth/register'">
Don't have an account? Register here
</p>
</div>
</div>
<script src="/static/app.js"></script>
<script src="/static/util.js"></script>
<script src="/static/login.js"></script>
</body>
</html>

33
static/login.js Normal file
View File

@ -0,0 +1,33 @@
// Login page specific functionality
document.addEventListener('DOMContentLoaded', function() {
// Initialize the app
initializeApp();
// Authentication form handler
const authForm = document.getElementById('authenticationForm');
if (authForm) {
const authSubmitBtn = authForm.querySelector('button[type="submit"]');
authForm.addEventListener('submit', async (ev) => {
ev.preventDefault();
authSubmitBtn.disabled = true;
clearStatus('loginStatus');
try {
showStatus('loginStatus', 'Starting authentication...', 'info');
await authenticate();
showStatus('loginStatus', 'Authentication successful!', 'success');
// Navigate to profile
setTimeout(() => {
window.location.href = '/auth/profile';
}, 1000);
} catch (err) {
showStatus('loginStatus', `Authentication failed: ${err.message}`, 'error');
} finally {
authSubmitBtn.disabled = false;
}
});
}
});

211
static/profile.html Normal file
View File

@ -0,0 +1,211 @@
<!DOCTYPE html>
<html>
<head>
<title>Profile - Passkey Authentication</title>
<link rel="stylesheet" href="/static/style.css">
<script src="/static/simplewebauthn-browser.min.js"></script>
<script src="/static/qrcodejs/qrcode.min.js"></script>
<script src="/static/awaitable-websocket.js"></script>
<style>
/* Dialog backdrop and blur effects */
.dialog-backdrop {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(4px);
z-index: 998;
display: none;
}
.dialog-backdrop.active {
display: block;
}
.container.dialog-open {
filter: blur(2px);
pointer-events: none;
user-select: none;
}
/* Dialog styling */
#deviceLinkDialog {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 999;
background: white;
border: none;
border-radius: 8px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
padding: 2rem;
max-width: 500px;
width: 90%;
max-height: 90vh;
overflow-y: auto;
}
#deviceLinkDialog::backdrop {
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(4px);
}
/* Dark mode dialog styling */
@media (prefers-color-scheme: dark) {
#deviceLinkDialog {
background: #1a1a1a;
color: white;
}
}
/* Prevent scrolling when dialog is open */
body.dialog-open {
overflow: hidden;
}
</style>
</head>
<body>
<div class="container">
<!-- Profile View -->
<div id="profileView" class="view active">
<h1>👋 Welcome!</h1>
<div id="userInfo" class="user-info"></div>
<div id="profileStatus"></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="openDeviceLinkDialog()" class="btn-secondary">
Generate Device Link
</button>
<button onclick="logout()" class="btn-danger">
Logout
</button>
</div>
<!-- Device Link Dialog -->
<dialog id="deviceLinkDialog">
<h1>📱 Add Device</h1>
<div id="deviceAdditionStatus"></div>
<div id="deviceLinkSection">
<h2>Device Addition Link</h2>
<div class="token-info">
<p><strong>Share this link to add this account to another device:</strong></p>
<div class="qr-container">
<div id="qrCode" class="qr-code"></div>
<p><small>Scan this QR code with your other device</small></p>
</div>
<div class="link-container">
<p class="link-text" id="deviceLinkText">Loading...</p>
<button class="copy-button" onclick="copyDeviceLink()">Copy Link</button>
</div>
<p><small>⚠️ This link expires in 24 hours and can only be used once.</small></p>
</div>
</div>
<button onclick="closeDeviceLinkDialog()" class="btn-secondary">
Close
</button>
</dialog>
</div>
<script src="/static/app.js"></script>
<script>
// Initialize the profile view when page loads
document.addEventListener('DOMContentLoaded', function() {
initializeApp();
});
// Open device link dialog
function openDeviceLinkDialog() {
const dialog = document.getElementById('deviceLinkDialog');
const container = document.querySelector('.container');
const body = document.body;
// Add blur and disable effects
container.classList.add('dialog-open');
body.classList.add('dialog-open');
dialog.showModal();
generateDeviceLink();
}
// Close device link dialog
function closeDeviceLinkDialog() {
const dialog = document.getElementById('deviceLinkDialog');
const container = document.querySelector('.container');
const body = document.body;
// Remove blur and disable effects
container.classList.remove('dialog-open');
body.classList.remove('dialog-open');
dialog.close();
}
// Generate device link function
function generateDeviceLink() {
clearStatus('deviceAdditionStatus');
showStatus('deviceAdditionStatus', 'Generating device link...', 'info');
fetch('/api/create-device-link', {
method: 'POST',
credentials: 'include'
})
.then(response => response.json())
.then(result => {
if (result.error) throw new Error(result.error);
// Update UI with the link
document.getElementById('deviceLinkText').textContent = result.addition_link;
// Store link globally for copy function
window.currentDeviceLink = result.addition_link;
// Generate QR code
const qrCodeEl = document.getElementById('qrCode');
qrCodeEl.innerHTML = '';
new QRCode(qrCodeEl, {
text: result.addition_link,
width: 200,
height: 200,
colorDark: '#000000',
colorLight: '#ffffff',
correctLevel: QRCode.CorrectLevel.M
});
})
.catch(error => {
console.error('Error generating device link:', error);
showStatus('deviceAdditionStatus', `Failed to generate device link: ${error.message}`, 'error');
});
}
// Close dialog when clicking outside
document.getElementById('deviceLinkDialog').addEventListener('click', function(e) {
if (e.target === this) {
closeDeviceLinkDialog();
}
});
// Close dialog when pressing Escape key
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape' && document.getElementById('deviceLinkDialog').open) {
closeDeviceLinkDialog();
}
});
</script>
</body>
</html>

115
static/profile.js Normal file
View File

@ -0,0 +1,115 @@
// Profile page specific functionality
document.addEventListener('DOMContentLoaded', function() {
// Initialize the app
initializeApp();
// Setup dialog event handlers
setupDialogHandlers();
});
// Setup dialog event handlers
function setupDialogHandlers() {
// Close dialog when clicking outside
const dialog = document.getElementById('deviceLinkDialog');
if (dialog) {
dialog.addEventListener('click', function(e) {
if (e.target === this) {
closeDeviceLinkDialog();
}
});
}
// Close dialog when pressing Escape key
document.addEventListener('keydown', function(e) {
const dialog = document.getElementById('deviceLinkDialog');
if (e.key === 'Escape' && dialog && dialog.open) {
closeDeviceLinkDialog();
}
});
}
// Open device link dialog
function openDeviceLinkDialog() {
const dialog = document.getElementById('deviceLinkDialog');
const container = document.querySelector('.container');
const body = document.body;
if (dialog && container && body) {
// Add blur and disable effects
container.classList.add('dialog-open');
body.classList.add('dialog-open');
dialog.showModal();
generateDeviceLink();
}
}
// Close device link dialog
function closeDeviceLinkDialog() {
const dialog = document.getElementById('deviceLinkDialog');
const container = document.querySelector('.container');
const body = document.body;
if (dialog && container && body) {
// Remove blur and disable effects
container.classList.remove('dialog-open');
body.classList.remove('dialog-open');
dialog.close();
}
}
// Generate device link function
function generateDeviceLink() {
clearStatus('deviceAdditionStatus');
showStatus('deviceAdditionStatus', 'Generating device link...', 'info');
fetch('/api/create-device-link', {
method: 'POST',
credentials: 'include'
})
.then(response => response.json())
.then(result => {
if (result.error) throw new Error(result.error);
// Update UI with the link
const deviceLinkText = document.getElementById('deviceLinkText');
const deviceToken = document.getElementById('deviceToken');
if (deviceLinkText) {
deviceLinkText.textContent = result.addition_link;
}
if (deviceToken) {
deviceToken.textContent = result.token;
}
// Store link globally for copy function
window.currentDeviceLink = result.addition_link;
// Generate QR code
const qrCodeEl = document.getElementById('qrCode');
if (qrCodeEl && typeof QRCode !== 'undefined') {
qrCodeEl.innerHTML = '';
new QRCode(qrCodeEl, {
text: result.addition_link,
width: 200,
height: 200,
colorDark: '#000000',
colorLight: '#ffffff',
correctLevel: QRCode.CorrectLevel.M
});
}
showStatus('deviceAdditionStatus', 'Device link generated successfully!', 'success');
})
.catch(error => {
console.error('Error generating device link:', error);
showStatus('deviceAdditionStatus', `Failed to generate device link: ${error.message}`, 'error');
});
}
// Make functions available globally for onclick handlers
window.openDeviceLinkDialog = openDeviceLinkDialog;
window.closeDeviceLinkDialog = closeDeviceLinkDialog;

4
static/qrcodejs/.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
.DS_Store
.idea
.project

14
static/qrcodejs/LICENSE Normal file
View File

@ -0,0 +1,14 @@
The MIT License (MIT)
---------------------
Copyright (c) 2012 davidshimjs
Permission is hereby granted, free of charge,
to any person obtaining a copy of this software and associated documentation files (the "Software"),
to deal in the Software without restriction,
including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense,
and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so,
subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

46
static/qrcodejs/README.md Normal file
View File

@ -0,0 +1,46 @@
# QRCode.js
QRCode.js is javascript library for making QRCode. QRCode.js supports Cross-browser with HTML5 Canvas and table tag in DOM.
QRCode.js has no dependencies.
## Basic Usages
```
<div id="qrcode"></div>
<script type="text/javascript">
new QRCode(document.getElementById("qrcode"), "http://jindo.dev.naver.com/collie");
</script>
```
or with some options
```
<div id="qrcode"></div>
<script type="text/javascript">
var qrcode = new QRCode(document.getElementById("qrcode"), {
text: "http://jindo.dev.naver.com/collie",
width: 128,
height: 128,
colorDark : "#000000",
colorLight : "#ffffff",
correctLevel : QRCode.CorrectLevel.H
});
</script>
```
and you can use some methods
```
qrcode.clear(); // clear the code.
qrcode.makeCode("http://naver.com"); // make another code.
```
## Browser Compatibility
IE6~10, Chrome, Firefox, Safari, Opera, Mobile Safari, Android, Windows Mobile, ETC.
## License
MIT License
## Contact
twitter @davidshimjs
[![Bitdeli Badge](https://d2weczhvl823v0.cloudfront.net/davidshimjs/qrcodejs/trend.png)](https://bitdeli.com/free "Bitdeli Badge")

View File

@ -0,0 +1,18 @@
{
"name": "qrcode.js",
"version": "0.0.1",
"homepage": "https://github.com/davidshimjs/qrcodejs",
"authors": [
"Sangmin Shim", "Sangmin Shim <ssm0123@gmail.com> (http://jaguarjs.com)"
],
"description": "Cross-browser QRCode generator for javascript",
"main": "qrcode.js",
"ignore": [
"bower_components",
"node_modules",
"index.html",
"index.svg",
"jquery.min.js",
"qrcode.min.js"
]
}

View File

@ -0,0 +1,47 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="ko" lang="ko">
<head>
<title>Cross-Browser QRCode generator for Javascript</title>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<meta name="viewport" content="width=device-width,initial-scale=1,user-scalable=no" />
<script type="text/javascript" src="jquery.min.js"></script>
<script type="text/javascript" src="qrcode.js"></script>
</head>
<body>
<input id="text" type="text" value="http://jindo.dev.naver.com/collie" style="width:80%" />
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<g id="qrcode"/>
</svg>
<script type="text/javascript">
var qrcode = new QRCode(document.getElementById("qrcode"), {
width : 100,
height : 100,
useSVG: true
});
function makeCode () {
var elText = document.getElementById("text");
if (!elText.value) {
alert("Input a text");
elText.focus();
return;
}
qrcode.makeCode(elText.value);
}
makeCode();
$("#text").
on("blur", function () {
makeCode();
}).
on("keydown", function (e) {
if (e.keyCode == 13) {
makeCode();
}
});
</script>
</body>
</html>

View File

@ -0,0 +1,44 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="ko" lang="ko">
<head>
<title>Cross-Browser QRCode generator for Javascript</title>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<meta name="viewport" content="width=device-width,initial-scale=1,user-scalable=no" />
<script type="text/javascript" src="jquery.min.js"></script>
<script type="text/javascript" src="qrcode.js"></script>
</head>
<body>
<input id="text" type="text" value="http://jindo.dev.naver.com/collie" style="width:80%" /><br />
<div id="qrcode" style="width:100px; height:100px; margin-top:15px;"></div>
<script type="text/javascript">
var qrcode = new QRCode(document.getElementById("qrcode"), {
width : 100,
height : 100
});
function makeCode () {
var elText = document.getElementById("text");
if (!elText.value) {
alert("Input a text");
elText.focus();
return;
}
qrcode.makeCode(elText.value);
}
makeCode();
$("#text").
on("blur", function () {
makeCode();
}).
on("keydown", function (e) {
if (e.keyCode == 13) {
makeCode();
}
});
</script>
</body>

37
static/qrcodejs/index.svg Normal file
View File

@ -0,0 +1,37 @@
<?xml version="1.0" standalone="yes"?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="-50 0 200 100">
<g id="qrcode"/>
<foreignObject x="-50" y="0" width="100" height="100">
<body xmlns="http://www.w3.org/1999/xhtml" style="padding:0; margin:0">
<div style="padding:inherit; margin:inherit; height:100%">
<textarea id="text" style="height:100%; width:100%; position:absolute; margin:inherit; padding:inherit">james</textarea>
</div>
<script type="application/ecmascript" src="qrcode.js"></script>
<script type="application/ecmascript">
var elem = document.getElementById("qrcode");
var qrcode = new QRCode(elem, {
width : 100,
height : 100
});
function makeCode () {
var elText = document.getElementById("text");
if (elText.value === "") {
//alert("Input a text");
//elText.focus();
return;
}
qrcode.makeCode(elText.value);
}
makeCode();
document.getElementById("text").onkeyup = function (e) {
makeCode();
};
</script>
</body>
</foreignObject>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

2
static/qrcodejs/jquery.min.js vendored Normal file

File diff suppressed because one or more lines are too long

614
static/qrcodejs/qrcode.js Normal file
View File

@ -0,0 +1,614 @@
/**
* @fileoverview
* - Using the 'QRCode for Javascript library'
* - Fixed dataset of 'QRCode for Javascript library' for support full-spec.
* - this library has no dependencies.
*
* @author davidshimjs
* @see <a href="http://www.d-project.com/" target="_blank">http://www.d-project.com/</a>
* @see <a href="http://jeromeetienne.github.com/jquery-qrcode/" target="_blank">http://jeromeetienne.github.com/jquery-qrcode/</a>
*/
var QRCode;
(function () {
//---------------------------------------------------------------------
// QRCode for JavaScript
//
// Copyright (c) 2009 Kazuhiko Arase
//
// URL: http://www.d-project.com/
//
// Licensed under the MIT license:
// http://www.opensource.org/licenses/mit-license.php
//
// The word "QR Code" is registered trademark of
// DENSO WAVE INCORPORATED
// http://www.denso-wave.com/qrcode/faqpatent-e.html
//
//---------------------------------------------------------------------
function QR8bitByte(data) {
this.mode = QRMode.MODE_8BIT_BYTE;
this.data = data;
this.parsedData = [];
// Added to support UTF-8 Characters
for (var i = 0, l = this.data.length; i < l; i++) {
var byteArray = [];
var code = this.data.charCodeAt(i);
if (code > 0x10000) {
byteArray[0] = 0xF0 | ((code & 0x1C0000) >>> 18);
byteArray[1] = 0x80 | ((code & 0x3F000) >>> 12);
byteArray[2] = 0x80 | ((code & 0xFC0) >>> 6);
byteArray[3] = 0x80 | (code & 0x3F);
} else if (code > 0x800) {
byteArray[0] = 0xE0 | ((code & 0xF000) >>> 12);
byteArray[1] = 0x80 | ((code & 0xFC0) >>> 6);
byteArray[2] = 0x80 | (code & 0x3F);
} else if (code > 0x80) {
byteArray[0] = 0xC0 | ((code & 0x7C0) >>> 6);
byteArray[1] = 0x80 | (code & 0x3F);
} else {
byteArray[0] = code;
}
this.parsedData.push(byteArray);
}
this.parsedData = Array.prototype.concat.apply([], this.parsedData);
if (this.parsedData.length != this.data.length) {
this.parsedData.unshift(191);
this.parsedData.unshift(187);
this.parsedData.unshift(239);
}
}
QR8bitByte.prototype = {
getLength: function (buffer) {
return this.parsedData.length;
},
write: function (buffer) {
for (var i = 0, l = this.parsedData.length; i < l; i++) {
buffer.put(this.parsedData[i], 8);
}
}
};
function QRCodeModel(typeNumber, errorCorrectLevel) {
this.typeNumber = typeNumber;
this.errorCorrectLevel = errorCorrectLevel;
this.modules = null;
this.moduleCount = 0;
this.dataCache = null;
this.dataList = [];
}
QRCodeModel.prototype={addData:function(data){var newData=new QR8bitByte(data);this.dataList.push(newData);this.dataCache=null;},isDark:function(row,col){if(row<0||this.moduleCount<=row||col<0||this.moduleCount<=col){throw new Error(row+","+col);}
return this.modules[row][col];},getModuleCount:function(){return this.moduleCount;},make:function(){this.makeImpl(false,this.getBestMaskPattern());},makeImpl:function(test,maskPattern){this.moduleCount=this.typeNumber*4+17;this.modules=new Array(this.moduleCount);for(var row=0;row<this.moduleCount;row++){this.modules[row]=new Array(this.moduleCount);for(var col=0;col<this.moduleCount;col++){this.modules[row][col]=null;}}
this.setupPositionProbePattern(0,0);this.setupPositionProbePattern(this.moduleCount-7,0);this.setupPositionProbePattern(0,this.moduleCount-7);this.setupPositionAdjustPattern();this.setupTimingPattern();this.setupTypeInfo(test,maskPattern);if(this.typeNumber>=7){this.setupTypeNumber(test);}
if(this.dataCache==null){this.dataCache=QRCodeModel.createData(this.typeNumber,this.errorCorrectLevel,this.dataList);}
this.mapData(this.dataCache,maskPattern);},setupPositionProbePattern:function(row,col){for(var r=-1;r<=7;r++){if(row+r<=-1||this.moduleCount<=row+r)continue;for(var c=-1;c<=7;c++){if(col+c<=-1||this.moduleCount<=col+c)continue;if((0<=r&&r<=6&&(c==0||c==6))||(0<=c&&c<=6&&(r==0||r==6))||(2<=r&&r<=4&&2<=c&&c<=4)){this.modules[row+r][col+c]=true;}else{this.modules[row+r][col+c]=false;}}}},getBestMaskPattern:function(){var minLostPoint=0;var pattern=0;for(var i=0;i<8;i++){this.makeImpl(true,i);var lostPoint=QRUtil.getLostPoint(this);if(i==0||minLostPoint>lostPoint){minLostPoint=lostPoint;pattern=i;}}
return pattern;},createMovieClip:function(target_mc,instance_name,depth){var qr_mc=target_mc.createEmptyMovieClip(instance_name,depth);var cs=1;this.make();for(var row=0;row<this.modules.length;row++){var y=row*cs;for(var col=0;col<this.modules[row].length;col++){var x=col*cs;var dark=this.modules[row][col];if(dark){qr_mc.beginFill(0,100);qr_mc.moveTo(x,y);qr_mc.lineTo(x+cs,y);qr_mc.lineTo(x+cs,y+cs);qr_mc.lineTo(x,y+cs);qr_mc.endFill();}}}
return qr_mc;},setupTimingPattern:function(){for(var r=8;r<this.moduleCount-8;r++){if(this.modules[r][6]!=null){continue;}
this.modules[r][6]=(r%2==0);}
for(var c=8;c<this.moduleCount-8;c++){if(this.modules[6][c]!=null){continue;}
this.modules[6][c]=(c%2==0);}},setupPositionAdjustPattern:function(){var pos=QRUtil.getPatternPosition(this.typeNumber);for(var i=0;i<pos.length;i++){for(var j=0;j<pos.length;j++){var row=pos[i];var col=pos[j];if(this.modules[row][col]!=null){continue;}
for(var r=-2;r<=2;r++){for(var c=-2;c<=2;c++){if(r==-2||r==2||c==-2||c==2||(r==0&&c==0)){this.modules[row+r][col+c]=true;}else{this.modules[row+r][col+c]=false;}}}}}},setupTypeNumber:function(test){var bits=QRUtil.getBCHTypeNumber(this.typeNumber);for(var i=0;i<18;i++){var mod=(!test&&((bits>>i)&1)==1);this.modules[Math.floor(i/3)][i%3+this.moduleCount-8-3]=mod;}
for(var i=0;i<18;i++){var mod=(!test&&((bits>>i)&1)==1);this.modules[i%3+this.moduleCount-8-3][Math.floor(i/3)]=mod;}},setupTypeInfo:function(test,maskPattern){var data=(this.errorCorrectLevel<<3)|maskPattern;var bits=QRUtil.getBCHTypeInfo(data);for(var i=0;i<15;i++){var mod=(!test&&((bits>>i)&1)==1);if(i<6){this.modules[i][8]=mod;}else if(i<8){this.modules[i+1][8]=mod;}else{this.modules[this.moduleCount-15+i][8]=mod;}}
for(var i=0;i<15;i++){var mod=(!test&&((bits>>i)&1)==1);if(i<8){this.modules[8][this.moduleCount-i-1]=mod;}else if(i<9){this.modules[8][15-i-1+1]=mod;}else{this.modules[8][15-i-1]=mod;}}
this.modules[this.moduleCount-8][8]=(!test);},mapData:function(data,maskPattern){var inc=-1;var row=this.moduleCount-1;var bitIndex=7;var byteIndex=0;for(var col=this.moduleCount-1;col>0;col-=2){if(col==6)col--;while(true){for(var c=0;c<2;c++){if(this.modules[row][col-c]==null){var dark=false;if(byteIndex<data.length){dark=(((data[byteIndex]>>>bitIndex)&1)==1);}
var mask=QRUtil.getMask(maskPattern,row,col-c);if(mask){dark=!dark;}
this.modules[row][col-c]=dark;bitIndex--;if(bitIndex==-1){byteIndex++;bitIndex=7;}}}
row+=inc;if(row<0||this.moduleCount<=row){row-=inc;inc=-inc;break;}}}}};QRCodeModel.PAD0=0xEC;QRCodeModel.PAD1=0x11;QRCodeModel.createData=function(typeNumber,errorCorrectLevel,dataList){var rsBlocks=QRRSBlock.getRSBlocks(typeNumber,errorCorrectLevel);var buffer=new QRBitBuffer();for(var i=0;i<dataList.length;i++){var data=dataList[i];buffer.put(data.mode,4);buffer.put(data.getLength(),QRUtil.getLengthInBits(data.mode,typeNumber));data.write(buffer);}
var totalDataCount=0;for(var i=0;i<rsBlocks.length;i++){totalDataCount+=rsBlocks[i].dataCount;}
if(buffer.getLengthInBits()>totalDataCount*8){throw new Error("code length overflow. ("
+buffer.getLengthInBits()
+">"
+totalDataCount*8
+")");}
if(buffer.getLengthInBits()+4<=totalDataCount*8){buffer.put(0,4);}
while(buffer.getLengthInBits()%8!=0){buffer.putBit(false);}
while(true){if(buffer.getLengthInBits()>=totalDataCount*8){break;}
buffer.put(QRCodeModel.PAD0,8);if(buffer.getLengthInBits()>=totalDataCount*8){break;}
buffer.put(QRCodeModel.PAD1,8);}
return QRCodeModel.createBytes(buffer,rsBlocks);};QRCodeModel.createBytes=function(buffer,rsBlocks){var offset=0;var maxDcCount=0;var maxEcCount=0;var dcdata=new Array(rsBlocks.length);var ecdata=new Array(rsBlocks.length);for(var r=0;r<rsBlocks.length;r++){var dcCount=rsBlocks[r].dataCount;var ecCount=rsBlocks[r].totalCount-dcCount;maxDcCount=Math.max(maxDcCount,dcCount);maxEcCount=Math.max(maxEcCount,ecCount);dcdata[r]=new Array(dcCount);for(var i=0;i<dcdata[r].length;i++){dcdata[r][i]=0xff&buffer.buffer[i+offset];}
offset+=dcCount;var rsPoly=QRUtil.getErrorCorrectPolynomial(ecCount);var rawPoly=new QRPolynomial(dcdata[r],rsPoly.getLength()-1);var modPoly=rawPoly.mod(rsPoly);ecdata[r]=new Array(rsPoly.getLength()-1);for(var i=0;i<ecdata[r].length;i++){var modIndex=i+modPoly.getLength()-ecdata[r].length;ecdata[r][i]=(modIndex>=0)?modPoly.get(modIndex):0;}}
var totalCodeCount=0;for(var i=0;i<rsBlocks.length;i++){totalCodeCount+=rsBlocks[i].totalCount;}
var data=new Array(totalCodeCount);var index=0;for(var i=0;i<maxDcCount;i++){for(var r=0;r<rsBlocks.length;r++){if(i<dcdata[r].length){data[index++]=dcdata[r][i];}}}
for(var i=0;i<maxEcCount;i++){for(var r=0;r<rsBlocks.length;r++){if(i<ecdata[r].length){data[index++]=ecdata[r][i];}}}
return data;};var QRMode={MODE_NUMBER:1<<0,MODE_ALPHA_NUM:1<<1,MODE_8BIT_BYTE:1<<2,MODE_KANJI:1<<3};var QRErrorCorrectLevel={L:1,M:0,Q:3,H:2};var QRMaskPattern={PATTERN000:0,PATTERN001:1,PATTERN010:2,PATTERN011:3,PATTERN100:4,PATTERN101:5,PATTERN110:6,PATTERN111:7};var QRUtil={PATTERN_POSITION_TABLE:[[],[6,18],[6,22],[6,26],[6,30],[6,34],[6,22,38],[6,24,42],[6,26,46],[6,28,50],[6,30,54],[6,32,58],[6,34,62],[6,26,46,66],[6,26,48,70],[6,26,50,74],[6,30,54,78],[6,30,56,82],[6,30,58,86],[6,34,62,90],[6,28,50,72,94],[6,26,50,74,98],[6,30,54,78,102],[6,28,54,80,106],[6,32,58,84,110],[6,30,58,86,114],[6,34,62,90,118],[6,26,50,74,98,122],[6,30,54,78,102,126],[6,26,52,78,104,130],[6,30,56,82,108,134],[6,34,60,86,112,138],[6,30,58,86,114,142],[6,34,62,90,118,146],[6,30,54,78,102,126,150],[6,24,50,76,102,128,154],[6,28,54,80,106,132,158],[6,32,58,84,110,136,162],[6,26,54,82,110,138,166],[6,30,58,86,114,142,170]],G15:(1<<10)|(1<<8)|(1<<5)|(1<<4)|(1<<2)|(1<<1)|(1<<0),G18:(1<<12)|(1<<11)|(1<<10)|(1<<9)|(1<<8)|(1<<5)|(1<<2)|(1<<0),G15_MASK:(1<<14)|(1<<12)|(1<<10)|(1<<4)|(1<<1),getBCHTypeInfo:function(data){var d=data<<10;while(QRUtil.getBCHDigit(d)-QRUtil.getBCHDigit(QRUtil.G15)>=0){d^=(QRUtil.G15<<(QRUtil.getBCHDigit(d)-QRUtil.getBCHDigit(QRUtil.G15)));}
return((data<<10)|d)^QRUtil.G15_MASK;},getBCHTypeNumber:function(data){var d=data<<12;while(QRUtil.getBCHDigit(d)-QRUtil.getBCHDigit(QRUtil.G18)>=0){d^=(QRUtil.G18<<(QRUtil.getBCHDigit(d)-QRUtil.getBCHDigit(QRUtil.G18)));}
return(data<<12)|d;},getBCHDigit:function(data){var digit=0;while(data!=0){digit++;data>>>=1;}
return digit;},getPatternPosition:function(typeNumber){return QRUtil.PATTERN_POSITION_TABLE[typeNumber-1];},getMask:function(maskPattern,i,j){switch(maskPattern){case QRMaskPattern.PATTERN000:return(i+j)%2==0;case QRMaskPattern.PATTERN001:return i%2==0;case QRMaskPattern.PATTERN010:return j%3==0;case QRMaskPattern.PATTERN011:return(i+j)%3==0;case QRMaskPattern.PATTERN100:return(Math.floor(i/2)+Math.floor(j/3))%2==0;case QRMaskPattern.PATTERN101:return(i*j)%2+(i*j)%3==0;case QRMaskPattern.PATTERN110:return((i*j)%2+(i*j)%3)%2==0;case QRMaskPattern.PATTERN111:return((i*j)%3+(i+j)%2)%2==0;default:throw new Error("bad maskPattern:"+maskPattern);}},getErrorCorrectPolynomial:function(errorCorrectLength){var a=new QRPolynomial([1],0);for(var i=0;i<errorCorrectLength;i++){a=a.multiply(new QRPolynomial([1,QRMath.gexp(i)],0));}
return a;},getLengthInBits:function(mode,type){if(1<=type&&type<10){switch(mode){case QRMode.MODE_NUMBER:return 10;case QRMode.MODE_ALPHA_NUM:return 9;case QRMode.MODE_8BIT_BYTE:return 8;case QRMode.MODE_KANJI:return 8;default:throw new Error("mode:"+mode);}}else if(type<27){switch(mode){case QRMode.MODE_NUMBER:return 12;case QRMode.MODE_ALPHA_NUM:return 11;case QRMode.MODE_8BIT_BYTE:return 16;case QRMode.MODE_KANJI:return 10;default:throw new Error("mode:"+mode);}}else if(type<41){switch(mode){case QRMode.MODE_NUMBER:return 14;case QRMode.MODE_ALPHA_NUM:return 13;case QRMode.MODE_8BIT_BYTE:return 16;case QRMode.MODE_KANJI:return 12;default:throw new Error("mode:"+mode);}}else{throw new Error("type:"+type);}},getLostPoint:function(qrCode){var moduleCount=qrCode.getModuleCount();var lostPoint=0;for(var row=0;row<moduleCount;row++){for(var col=0;col<moduleCount;col++){var sameCount=0;var dark=qrCode.isDark(row,col);for(var r=-1;r<=1;r++){if(row+r<0||moduleCount<=row+r){continue;}
for(var c=-1;c<=1;c++){if(col+c<0||moduleCount<=col+c){continue;}
if(r==0&&c==0){continue;}
if(dark==qrCode.isDark(row+r,col+c)){sameCount++;}}}
if(sameCount>5){lostPoint+=(3+sameCount-5);}}}
for(var row=0;row<moduleCount-1;row++){for(var col=0;col<moduleCount-1;col++){var count=0;if(qrCode.isDark(row,col))count++;if(qrCode.isDark(row+1,col))count++;if(qrCode.isDark(row,col+1))count++;if(qrCode.isDark(row+1,col+1))count++;if(count==0||count==4){lostPoint+=3;}}}
for(var row=0;row<moduleCount;row++){for(var col=0;col<moduleCount-6;col++){if(qrCode.isDark(row,col)&&!qrCode.isDark(row,col+1)&&qrCode.isDark(row,col+2)&&qrCode.isDark(row,col+3)&&qrCode.isDark(row,col+4)&&!qrCode.isDark(row,col+5)&&qrCode.isDark(row,col+6)){lostPoint+=40;}}}
for(var col=0;col<moduleCount;col++){for(var row=0;row<moduleCount-6;row++){if(qrCode.isDark(row,col)&&!qrCode.isDark(row+1,col)&&qrCode.isDark(row+2,col)&&qrCode.isDark(row+3,col)&&qrCode.isDark(row+4,col)&&!qrCode.isDark(row+5,col)&&qrCode.isDark(row+6,col)){lostPoint+=40;}}}
var darkCount=0;for(var col=0;col<moduleCount;col++){for(var row=0;row<moduleCount;row++){if(qrCode.isDark(row,col)){darkCount++;}}}
var ratio=Math.abs(100*darkCount/moduleCount/moduleCount-50)/5;lostPoint+=ratio*10;return lostPoint;}};var QRMath={glog:function(n){if(n<1){throw new Error("glog("+n+")");}
return QRMath.LOG_TABLE[n];},gexp:function(n){while(n<0){n+=255;}
while(n>=256){n-=255;}
return QRMath.EXP_TABLE[n];},EXP_TABLE:new Array(256),LOG_TABLE:new Array(256)};for(var i=0;i<8;i++){QRMath.EXP_TABLE[i]=1<<i;}
for(var i=8;i<256;i++){QRMath.EXP_TABLE[i]=QRMath.EXP_TABLE[i-4]^QRMath.EXP_TABLE[i-5]^QRMath.EXP_TABLE[i-6]^QRMath.EXP_TABLE[i-8];}
for(var i=0;i<255;i++){QRMath.LOG_TABLE[QRMath.EXP_TABLE[i]]=i;}
function QRPolynomial(num,shift){if(num.length==undefined){throw new Error(num.length+"/"+shift);}
var offset=0;while(offset<num.length&&num[offset]==0){offset++;}
this.num=new Array(num.length-offset+shift);for(var i=0;i<num.length-offset;i++){this.num[i]=num[i+offset];}}
QRPolynomial.prototype={get:function(index){return this.num[index];},getLength:function(){return this.num.length;},multiply:function(e){var num=new Array(this.getLength()+e.getLength()-1);for(var i=0;i<this.getLength();i++){for(var j=0;j<e.getLength();j++){num[i+j]^=QRMath.gexp(QRMath.glog(this.get(i))+QRMath.glog(e.get(j)));}}
return new QRPolynomial(num,0);},mod:function(e){if(this.getLength()-e.getLength()<0){return this;}
var ratio=QRMath.glog(this.get(0))-QRMath.glog(e.get(0));var num=new Array(this.getLength());for(var i=0;i<this.getLength();i++){num[i]=this.get(i);}
for(var i=0;i<e.getLength();i++){num[i]^=QRMath.gexp(QRMath.glog(e.get(i))+ratio);}
return new QRPolynomial(num,0).mod(e);}};function QRRSBlock(totalCount,dataCount){this.totalCount=totalCount;this.dataCount=dataCount;}
QRRSBlock.RS_BLOCK_TABLE=[[1,26,19],[1,26,16],[1,26,13],[1,26,9],[1,44,34],[1,44,28],[1,44,22],[1,44,16],[1,70,55],[1,70,44],[2,35,17],[2,35,13],[1,100,80],[2,50,32],[2,50,24],[4,25,9],[1,134,108],[2,67,43],[2,33,15,2,34,16],[2,33,11,2,34,12],[2,86,68],[4,43,27],[4,43,19],[4,43,15],[2,98,78],[4,49,31],[2,32,14,4,33,15],[4,39,13,1,40,14],[2,121,97],[2,60,38,2,61,39],[4,40,18,2,41,19],[4,40,14,2,41,15],[2,146,116],[3,58,36,2,59,37],[4,36,16,4,37,17],[4,36,12,4,37,13],[2,86,68,2,87,69],[4,69,43,1,70,44],[6,43,19,2,44,20],[6,43,15,2,44,16],[4,101,81],[1,80,50,4,81,51],[4,50,22,4,51,23],[3,36,12,8,37,13],[2,116,92,2,117,93],[6,58,36,2,59,37],[4,46,20,6,47,21],[7,42,14,4,43,15],[4,133,107],[8,59,37,1,60,38],[8,44,20,4,45,21],[12,33,11,4,34,12],[3,145,115,1,146,116],[4,64,40,5,65,41],[11,36,16,5,37,17],[11,36,12,5,37,13],[5,109,87,1,110,88],[5,65,41,5,66,42],[5,54,24,7,55,25],[11,36,12],[5,122,98,1,123,99],[7,73,45,3,74,46],[15,43,19,2,44,20],[3,45,15,13,46,16],[1,135,107,5,136,108],[10,74,46,1,75,47],[1,50,22,15,51,23],[2,42,14,17,43,15],[5,150,120,1,151,121],[9,69,43,4,70,44],[17,50,22,1,51,23],[2,42,14,19,43,15],[3,141,113,4,142,114],[3,70,44,11,71,45],[17,47,21,4,48,22],[9,39,13,16,40,14],[3,135,107,5,136,108],[3,67,41,13,68,42],[15,54,24,5,55,25],[15,43,15,10,44,16],[4,144,116,4,145,117],[17,68,42],[17,50,22,6,51,23],[19,46,16,6,47,17],[2,139,111,7,140,112],[17,74,46],[7,54,24,16,55,25],[34,37,13],[4,151,121,5,152,122],[4,75,47,14,76,48],[11,54,24,14,55,25],[16,45,15,14,46,16],[6,147,117,4,148,118],[6,73,45,14,74,46],[11,54,24,16,55,25],[30,46,16,2,47,17],[8,132,106,4,133,107],[8,75,47,13,76,48],[7,54,24,22,55,25],[22,45,15,13,46,16],[10,142,114,2,143,115],[19,74,46,4,75,47],[28,50,22,6,51,23],[33,46,16,4,47,17],[8,152,122,4,153,123],[22,73,45,3,74,46],[8,53,23,26,54,24],[12,45,15,28,46,16],[3,147,117,10,148,118],[3,73,45,23,74,46],[4,54,24,31,55,25],[11,45,15,31,46,16],[7,146,116,7,147,117],[21,73,45,7,74,46],[1,53,23,37,54,24],[19,45,15,26,46,16],[5,145,115,10,146,116],[19,75,47,10,76,48],[15,54,24,25,55,25],[23,45,15,25,46,16],[13,145,115,3,146,116],[2,74,46,29,75,47],[42,54,24,1,55,25],[23,45,15,28,46,16],[17,145,115],[10,74,46,23,75,47],[10,54,24,35,55,25],[19,45,15,35,46,16],[17,145,115,1,146,116],[14,74,46,21,75,47],[29,54,24,19,55,25],[11,45,15,46,46,16],[13,145,115,6,146,116],[14,74,46,23,75,47],[44,54,24,7,55,25],[59,46,16,1,47,17],[12,151,121,7,152,122],[12,75,47,26,76,48],[39,54,24,14,55,25],[22,45,15,41,46,16],[6,151,121,14,152,122],[6,75,47,34,76,48],[46,54,24,10,55,25],[2,45,15,64,46,16],[17,152,122,4,153,123],[29,74,46,14,75,47],[49,54,24,10,55,25],[24,45,15,46,46,16],[4,152,122,18,153,123],[13,74,46,32,75,47],[48,54,24,14,55,25],[42,45,15,32,46,16],[20,147,117,4,148,118],[40,75,47,7,76,48],[43,54,24,22,55,25],[10,45,15,67,46,16],[19,148,118,6,149,119],[18,75,47,31,76,48],[34,54,24,34,55,25],[20,45,15,61,46,16]];QRRSBlock.getRSBlocks=function(typeNumber,errorCorrectLevel){var rsBlock=QRRSBlock.getRsBlockTable(typeNumber,errorCorrectLevel);if(rsBlock==undefined){throw new Error("bad rs block @ typeNumber:"+typeNumber+"/errorCorrectLevel:"+errorCorrectLevel);}
var length=rsBlock.length/3;var list=[];for(var i=0;i<length;i++){var count=rsBlock[i*3+0];var totalCount=rsBlock[i*3+1];var dataCount=rsBlock[i*3+2];for(var j=0;j<count;j++){list.push(new QRRSBlock(totalCount,dataCount));}}
return list;};QRRSBlock.getRsBlockTable=function(typeNumber,errorCorrectLevel){switch(errorCorrectLevel){case QRErrorCorrectLevel.L:return QRRSBlock.RS_BLOCK_TABLE[(typeNumber-1)*4+0];case QRErrorCorrectLevel.M:return QRRSBlock.RS_BLOCK_TABLE[(typeNumber-1)*4+1];case QRErrorCorrectLevel.Q:return QRRSBlock.RS_BLOCK_TABLE[(typeNumber-1)*4+2];case QRErrorCorrectLevel.H:return QRRSBlock.RS_BLOCK_TABLE[(typeNumber-1)*4+3];default:return undefined;}};function QRBitBuffer(){this.buffer=[];this.length=0;}
QRBitBuffer.prototype={get:function(index){var bufIndex=Math.floor(index/8);return((this.buffer[bufIndex]>>>(7-index%8))&1)==1;},put:function(num,length){for(var i=0;i<length;i++){this.putBit(((num>>>(length-i-1))&1)==1);}},getLengthInBits:function(){return this.length;},putBit:function(bit){var bufIndex=Math.floor(this.length/8);if(this.buffer.length<=bufIndex){this.buffer.push(0);}
if(bit){this.buffer[bufIndex]|=(0x80>>>(this.length%8));}
this.length++;}};var QRCodeLimitLength=[[17,14,11,7],[32,26,20,14],[53,42,32,24],[78,62,46,34],[106,84,60,44],[134,106,74,58],[154,122,86,64],[192,152,108,84],[230,180,130,98],[271,213,151,119],[321,251,177,137],[367,287,203,155],[425,331,241,177],[458,362,258,194],[520,412,292,220],[586,450,322,250],[644,504,364,280],[718,560,394,310],[792,624,442,338],[858,666,482,382],[929,711,509,403],[1003,779,565,439],[1091,857,611,461],[1171,911,661,511],[1273,997,715,535],[1367,1059,751,593],[1465,1125,805,625],[1528,1190,868,658],[1628,1264,908,698],[1732,1370,982,742],[1840,1452,1030,790],[1952,1538,1112,842],[2068,1628,1168,898],[2188,1722,1228,958],[2303,1809,1283,983],[2431,1911,1351,1051],[2563,1989,1423,1093],[2699,2099,1499,1139],[2809,2213,1579,1219],[2953,2331,1663,1273]];
function _isSupportCanvas() {
return typeof CanvasRenderingContext2D != "undefined";
}
// android 2.x doesn't support Data-URI spec
function _getAndroid() {
var android = false;
var sAgent = navigator.userAgent;
if (/android/i.test(sAgent)) { // android
android = true;
var aMat = sAgent.toString().match(/android ([0-9]\.[0-9])/i);
if (aMat && aMat[1]) {
android = parseFloat(aMat[1]);
}
}
return android;
}
var svgDrawer = (function() {
var Drawing = function (el, htOption) {
this._el = el;
this._htOption = htOption;
};
Drawing.prototype.draw = function (oQRCode) {
var _htOption = this._htOption;
var _el = this._el;
var nCount = oQRCode.getModuleCount();
var nWidth = Math.floor(_htOption.width / nCount);
var nHeight = Math.floor(_htOption.height / nCount);
this.clear();
function makeSVG(tag, attrs) {
var el = document.createElementNS('http://www.w3.org/2000/svg', tag);
for (var k in attrs)
if (attrs.hasOwnProperty(k)) el.setAttribute(k, attrs[k]);
return el;
}
var svg = makeSVG("svg" , {'viewBox': '0 0 ' + String(nCount) + " " + String(nCount), 'width': '100%', 'height': '100%', 'fill': _htOption.colorLight});
svg.setAttributeNS("http://www.w3.org/2000/xmlns/", "xmlns:xlink", "http://www.w3.org/1999/xlink");
_el.appendChild(svg);
svg.appendChild(makeSVG("rect", {"fill": _htOption.colorLight, "width": "100%", "height": "100%"}));
svg.appendChild(makeSVG("rect", {"fill": _htOption.colorDark, "width": "1", "height": "1", "id": "template"}));
for (var row = 0; row < nCount; row++) {
for (var col = 0; col < nCount; col++) {
if (oQRCode.isDark(row, col)) {
var child = makeSVG("use", {"x": String(col), "y": String(row)});
child.setAttributeNS("http://www.w3.org/1999/xlink", "href", "#template")
svg.appendChild(child);
}
}
}
};
Drawing.prototype.clear = function () {
while (this._el.hasChildNodes())
this._el.removeChild(this._el.lastChild);
};
return Drawing;
})();
var useSVG = document.documentElement.tagName.toLowerCase() === "svg";
// Drawing in DOM by using Table tag
var Drawing = useSVG ? svgDrawer : !_isSupportCanvas() ? (function () {
var Drawing = function (el, htOption) {
this._el = el;
this._htOption = htOption;
};
/**
* Draw the QRCode
*
* @param {QRCode} oQRCode
*/
Drawing.prototype.draw = function (oQRCode) {
var _htOption = this._htOption;
var _el = this._el;
var nCount = oQRCode.getModuleCount();
var nWidth = Math.floor(_htOption.width / nCount);
var nHeight = Math.floor(_htOption.height / nCount);
var aHTML = ['<table style="border:0;border-collapse:collapse;">'];
for (var row = 0; row < nCount; row++) {
aHTML.push('<tr>');
for (var col = 0; col < nCount; col++) {
aHTML.push('<td style="border:0;border-collapse:collapse;padding:0;margin:0;width:' + nWidth + 'px;height:' + nHeight + 'px;background-color:' + (oQRCode.isDark(row, col) ? _htOption.colorDark : _htOption.colorLight) + ';"></td>');
}
aHTML.push('</tr>');
}
aHTML.push('</table>');
_el.innerHTML = aHTML.join('');
// Fix the margin values as real size.
var elTable = _el.childNodes[0];
var nLeftMarginTable = (_htOption.width - elTable.offsetWidth) / 2;
var nTopMarginTable = (_htOption.height - elTable.offsetHeight) / 2;
if (nLeftMarginTable > 0 && nTopMarginTable > 0) {
elTable.style.margin = nTopMarginTable + "px " + nLeftMarginTable + "px";
}
};
/**
* Clear the QRCode
*/
Drawing.prototype.clear = function () {
this._el.innerHTML = '';
};
return Drawing;
})() : (function () { // Drawing in Canvas
function _onMakeImage() {
this._elImage.src = this._elCanvas.toDataURL("image/png");
this._elImage.style.display = "block";
this._elCanvas.style.display = "none";
}
// Android 2.1 bug workaround
// http://code.google.com/p/android/issues/detail?id=5141
if (this._android && this._android <= 2.1) {
var factor = 1 / window.devicePixelRatio;
var drawImage = CanvasRenderingContext2D.prototype.drawImage;
CanvasRenderingContext2D.prototype.drawImage = function (image, sx, sy, sw, sh, dx, dy, dw, dh) {
if (("nodeName" in image) && /img/i.test(image.nodeName)) {
for (var i = arguments.length - 1; i >= 1; i--) {
arguments[i] = arguments[i] * factor;
}
} else if (typeof dw == "undefined") {
arguments[1] *= factor;
arguments[2] *= factor;
arguments[3] *= factor;
arguments[4] *= factor;
}
drawImage.apply(this, arguments);
};
}
/**
* Check whether the user's browser supports Data URI or not
*
* @private
* @param {Function} fSuccess Occurs if it supports Data URI
* @param {Function} fFail Occurs if it doesn't support Data URI
*/
function _safeSetDataURI(fSuccess, fFail) {
var self = this;
self._fFail = fFail;
self._fSuccess = fSuccess;
// Check it just once
if (self._bSupportDataURI === null) {
var el = document.createElement("img");
var fOnError = function() {
self._bSupportDataURI = false;
if (self._fFail) {
self._fFail.call(self);
}
};
var fOnSuccess = function() {
self._bSupportDataURI = true;
if (self._fSuccess) {
self._fSuccess.call(self);
}
};
el.onabort = fOnError;
el.onerror = fOnError;
el.onload = fOnSuccess;
el.src = "data:image/gif;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAHElEQVQI12P4//8/w38GIAXDIBKE0DHxgljNBAAO9TXL0Y4OHwAAAABJRU5ErkJggg=="; // the Image contains 1px data.
return;
} else if (self._bSupportDataURI === true && self._fSuccess) {
self._fSuccess.call(self);
} else if (self._bSupportDataURI === false && self._fFail) {
self._fFail.call(self);
}
};
/**
* Drawing QRCode by using canvas
*
* @constructor
* @param {HTMLElement} el
* @param {Object} htOption QRCode Options
*/
var Drawing = function (el, htOption) {
this._bIsPainted = false;
this._android = _getAndroid();
this._htOption = htOption;
this._elCanvas = document.createElement("canvas");
this._elCanvas.width = htOption.width;
this._elCanvas.height = htOption.height;
el.appendChild(this._elCanvas);
this._el = el;
this._oContext = this._elCanvas.getContext("2d");
this._bIsPainted = false;
this._elImage = document.createElement("img");
this._elImage.alt = "Scan me!";
this._elImage.style.display = "none";
this._el.appendChild(this._elImage);
this._bSupportDataURI = null;
};
/**
* Draw the QRCode
*
* @param {QRCode} oQRCode
*/
Drawing.prototype.draw = function (oQRCode) {
var _elImage = this._elImage;
var _oContext = this._oContext;
var _htOption = this._htOption;
var nCount = oQRCode.getModuleCount();
var nWidth = _htOption.width / nCount;
var nHeight = _htOption.height / nCount;
var nRoundedWidth = Math.round(nWidth);
var nRoundedHeight = Math.round(nHeight);
_elImage.style.display = "none";
this.clear();
for (var row = 0; row < nCount; row++) {
for (var col = 0; col < nCount; col++) {
var bIsDark = oQRCode.isDark(row, col);
var nLeft = col * nWidth;
var nTop = row * nHeight;
_oContext.strokeStyle = bIsDark ? _htOption.colorDark : _htOption.colorLight;
_oContext.lineWidth = 1;
_oContext.fillStyle = bIsDark ? _htOption.colorDark : _htOption.colorLight;
_oContext.fillRect(nLeft, nTop, nWidth, nHeight);
// 안티 앨리어싱 방지 처리
_oContext.strokeRect(
Math.floor(nLeft) + 0.5,
Math.floor(nTop) + 0.5,
nRoundedWidth,
nRoundedHeight
);
_oContext.strokeRect(
Math.ceil(nLeft) - 0.5,
Math.ceil(nTop) - 0.5,
nRoundedWidth,
nRoundedHeight
);
}
}
this._bIsPainted = true;
};
/**
* Make the image from Canvas if the browser supports Data URI.
*/
Drawing.prototype.makeImage = function () {
if (this._bIsPainted) {
_safeSetDataURI.call(this, _onMakeImage);
}
};
/**
* Return whether the QRCode is painted or not
*
* @return {Boolean}
*/
Drawing.prototype.isPainted = function () {
return this._bIsPainted;
};
/**
* Clear the QRCode
*/
Drawing.prototype.clear = function () {
this._oContext.clearRect(0, 0, this._elCanvas.width, this._elCanvas.height);
this._bIsPainted = false;
};
/**
* @private
* @param {Number} nNumber
*/
Drawing.prototype.round = function (nNumber) {
if (!nNumber) {
return nNumber;
}
return Math.floor(nNumber * 1000) / 1000;
};
return Drawing;
})();
/**
* Get the type by string length
*
* @private
* @param {String} sText
* @param {Number} nCorrectLevel
* @return {Number} type
*/
function _getTypeNumber(sText, nCorrectLevel) {
var nType = 1;
var length = _getUTF8Length(sText);
for (var i = 0, len = QRCodeLimitLength.length; i <= len; i++) {
var nLimit = 0;
switch (nCorrectLevel) {
case QRErrorCorrectLevel.L :
nLimit = QRCodeLimitLength[i][0];
break;
case QRErrorCorrectLevel.M :
nLimit = QRCodeLimitLength[i][1];
break;
case QRErrorCorrectLevel.Q :
nLimit = QRCodeLimitLength[i][2];
break;
case QRErrorCorrectLevel.H :
nLimit = QRCodeLimitLength[i][3];
break;
}
if (length <= nLimit) {
break;
} else {
nType++;
}
}
if (nType > QRCodeLimitLength.length) {
throw new Error("Too long data");
}
return nType;
}
function _getUTF8Length(sText) {
var replacedText = encodeURI(sText).toString().replace(/\%[0-9a-fA-F]{2}/g, 'a');
return replacedText.length + (replacedText.length != sText ? 3 : 0);
}
/**
* @class QRCode
* @constructor
* @example
* new QRCode(document.getElementById("test"), "http://jindo.dev.naver.com/collie");
*
* @example
* var oQRCode = new QRCode("test", {
* text : "http://naver.com",
* width : 128,
* height : 128
* });
*
* oQRCode.clear(); // Clear the QRCode.
* oQRCode.makeCode("http://map.naver.com"); // Re-create the QRCode.
*
* @param {HTMLElement|String} el target element or 'id' attribute of element.
* @param {Object|String} vOption
* @param {String} vOption.text QRCode link data
* @param {Number} [vOption.width=256]
* @param {Number} [vOption.height=256]
* @param {String} [vOption.colorDark="#000000"]
* @param {String} [vOption.colorLight="#ffffff"]
* @param {QRCode.CorrectLevel} [vOption.correctLevel=QRCode.CorrectLevel.H] [L|M|Q|H]
*/
QRCode = function (el, vOption) {
this._htOption = {
width : 256,
height : 256,
typeNumber : 4,
colorDark : "#000000",
colorLight : "#ffffff",
correctLevel : QRErrorCorrectLevel.H
};
if (typeof vOption === 'string') {
vOption = {
text : vOption
};
}
// Overwrites options
if (vOption) {
for (var i in vOption) {
this._htOption[i] = vOption[i];
}
}
if (typeof el == "string") {
el = document.getElementById(el);
}
if (this._htOption.useSVG) {
Drawing = svgDrawer;
}
this._android = _getAndroid();
this._el = el;
this._oQRCode = null;
this._oDrawing = new Drawing(this._el, this._htOption);
if (this._htOption.text) {
this.makeCode(this._htOption.text);
}
};
/**
* Make the QRCode
*
* @param {String} sText link data
*/
QRCode.prototype.makeCode = function (sText) {
this._oQRCode = new QRCodeModel(_getTypeNumber(sText, this._htOption.correctLevel), this._htOption.correctLevel);
this._oQRCode.addData(sText);
this._oQRCode.make();
this._el.title = sText;
this._oDrawing.draw(this._oQRCode);
this.makeImage();
};
/**
* Make the Image from Canvas element
* - It occurs automatically
* - Android below 3 doesn't support Data-URI spec.
*
* @private
*/
QRCode.prototype.makeImage = function () {
if (typeof this._oDrawing.makeImage == "function" && (!this._android || this._android >= 3)) {
this._oDrawing.makeImage();
}
};
/**
* Clear the QRCode
*/
QRCode.prototype.clear = function () {
this._oDrawing.clear();
};
/**
* @name QRCode.CorrectLevel
*/
QRCode.CorrectLevel = QRErrorCorrectLevel;
})();

1
static/qrcodejs/qrcode.min.js vendored Normal file

File diff suppressed because one or more lines are too long

29
static/register.html Normal file
View File

@ -0,0 +1,29 @@
<!DOCTYPE html>
<html>
<head>
<title>Register - Passkey Authentication</title>
<link rel="stylesheet" href="/static/style.css">
<script src="/static/simplewebauthn-browser.min.js"></script>
<script src="/static/awaitable-websocket.js"></script>
</head>
<body>
<div class="container">
<!-- Register View -->
<div id="registerView" class="view active">
<h1>🔐 Create Account</h1>
<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>
<p class="toggle-link" onclick="window.location.href='/auth/login'">
Already have an account? Login here
</p>
</div>
</div>
<script src="/static/app.js"></script>
<script src="/static/util.js"></script>
<script src="/static/register.js"></script>
</body>
</html>

35
static/register.js Normal file
View File

@ -0,0 +1,35 @@
// Register page specific functionality
document.addEventListener('DOMContentLoaded', function() {
// Initialize the app
initializeApp();
// Registration form handler
const regForm = document.getElementById('registrationForm');
if (regForm) {
const regSubmitBtn = regForm.querySelector('button[type="submit"]');
regForm.addEventListener('submit', async (ev) => {
ev.preventDefault();
regSubmitBtn.disabled = true;
clearStatus('registerStatus');
const user_name = (new FormData(regForm)).get('username');
try {
showStatus('registerStatus', 'Starting registration...', 'info');
await register(user_name);
showStatus('registerStatus', `Registration successful for ${user_name}!`, 'success');
// Auto-login after successful registration
setTimeout(() => {
window.location.href = '/auth/profile';
}, 1500);
} catch (err) {
showStatus('registerStatus', `Registration failed: ${err.message}`, 'error');
} finally {
regSubmitBtn.disabled = false;
}
});
}
});

185
static/reset.html Normal file
View File

@ -0,0 +1,185 @@
<!DOCTYPE html>
<html>
<head>
<title>Add Device - Passkey Authentication</title>
<link rel="stylesheet" href="/static/style.css">
<script src="/static/simplewebauthn-browser.min.js"></script>
<script src="/static/qrcodejs/qrcode.min.js"></script>
<script src="/static/awaitable-websocket.js"></script>
</head>
<body>
<div class="container">
<!-- Request Reset View -->
<div id="requestView" class="view">
<h1>🔓 Add Device</h1>
<p>This page is for adding a new device to an existing account. You need a device addition link to proceed.</p>
<div id="requestStatus" class="status info" style="display: block;">
<strong>How to get a device addition link:</strong><br>
1. Log into your account on a device you already have<br>
2. Click "Generate Device Link" in your dashboard<br>
3. Copy the link or scan the QR code to add this device
</div>
<p class="toggle-link" onclick="window.location.href='/'">
Back to Login
</p>
</div>
<!-- Add Passkey View -->
<div id="addPasskeyView" class="view">
<h1>🔑 Add New Passkey</h1>
<div id="userInfo" class="token-info">
<p><strong>Account:</strong> <span id="userName"></span></p>
<p><small>You are about to add a new passkey to this account.</small></p>
</div>
<div id="addPasskeyStatus" class="status"></div>
<button id="addPasskeyBtn" class="btn-primary">Add New Passkey</button>
<p class="toggle-link" onclick="window.location.href='/'">
Back to Login
</p>
</div>
<!-- Success Complete View -->
<div id="completeView" class="view">
<h1>🎉 Passkey Added Successfully!</h1>
<p>Your new passkey has been added to your account. You can now use it to log in.</p>
<button onclick="window.location.href='/'" class="btn-primary">Go to Login</button>
</div>
</div>
<script>
const { startRegistration } = SimpleWebAuthnBrowser;
// Global state
let currentToken = null;
let currentUser = null;
// View management
function showView(viewId) {
document.querySelectorAll('.view').forEach(view => {
view.classList.remove('active');
});
document.getElementById(viewId).classList.add('active');
}
function showAddPasskeyView() {
showView('addPasskeyView');
clearStatus('addPasskeyStatus');
}
function showCompleteView() {
showView('completeView');
}
// Status management
function showStatus(elementId, message, type = 'info') {
const statusEl = document.getElementById(elementId);
statusEl.textContent = message;
statusEl.className = `status ${type}`;
statusEl.style.display = 'block';
}
function clearStatus(elementId) {
const statusEl = document.getElementById(elementId);
statusEl.style.display = 'none';
}
// Validate reset token and show add passkey view
async function validateTokenAndShowAddView(token) {
try {
const response = await fetch('/api/validate-device-token', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ token })
});
const result = await response.json();
if (result.error) {
throw new Error(result.error);
}
currentToken = token;
currentUser = result;
document.getElementById('userName').textContent = result.user_name;
showAddPasskeyView();
} catch (error) {
showStatus('addPasskeyStatus', `Error: ${error.message}`, 'error');
}
}
// Add new passkey via reset token
async function addPasskeyWithToken(token) {
try {
const ws = await aWebSocket('/ws/add_device_credential');
// Send token to server
ws.send(JSON.stringify({ token }));
// Get registration options
const optionsJSON = JSON.parse(await ws.recv());
if (optionsJSON.error) throw new Error(optionsJSON.error);
showStatus('addPasskeyStatus', '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();
showCompleteView();
} catch (error) {
showStatus('addPasskeyStatus', `Failed to add passkey: ${error.message}`, 'error');
}
}
// Check URL path for token on page load
function checkUrlParams() {
const path = window.location.pathname;
const pathParts = path.split('/');
// Check if URL is in format /reset/token
if (pathParts.length >= 3 && pathParts[1] === 'reset') {
const token = pathParts[2];
if (token) {
validateTokenAndShowAddView(token);
}
}
}
// Form event handlers
document.addEventListener('DOMContentLoaded', function() {
// Check for token in URL
checkUrlParams();
// Add passkey button
const addPasskeyBtn = document.getElementById('addPasskeyBtn');
addPasskeyBtn.addEventListener('click', async () => {
if (!currentToken) {
showStatus('addPasskeyStatus', 'No valid device addition token found', 'error');
return;
}
addPasskeyBtn.disabled = true;
clearStatus('addPasskeyStatus');
try {
showStatus('addPasskeyStatus', 'Starting passkey registration...', 'info');
await addPasskeyWithToken(currentToken);
} catch (err) {
showStatus('addPasskeyStatus', `Registration failed: ${err.message}`, 'error');
} finally {
addPasskeyBtn.disabled = false;
}
});
});
</script>
</body>
</html>

2
static/simplewebauthn-browser.min.js vendored Normal file

File diff suppressed because one or more lines are too long

343
static/style.css Normal file
View File

@ -0,0 +1,343 @@
/* Passkey Authentication - Main Styles */
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
margin: 0;
padding: 0;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}
.container {
background: white;
padding: 40px;
border-radius: 15px;
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"] {
width: 100%;
padding: 15px;
border: 2px solid #e1e5e9;
border-radius: 8px;
font-size: 16px;
margin-bottom: 20px;
box-sizing: border-box;
transition: border-color 0.3s ease;
}
input[type="text"]:focus {
outline: none;
border-color: #667eea;
}
button {
width: 100%;
padding: 15px;
margin-bottom: 15px;
font-size: 16px;
font-weight: 500;
cursor: pointer;
border: none;
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 {
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;
}
.token-info {
background: #f5f5f5;
padding: 15px;
border-radius: 8px;
margin: 15px 0;
text-align: left;
}
.token-info strong {
color: #333;
}
.token-info code {
background: #e9ecef;
padding: 4px 8px;
border-radius: 4px;
font-family: monospace;
}
.qr-container {
display: flex;
flex-direction: column;
align-items: center;
margin: 20px 0;
}
.qr-code {
border: 1px solid #ddd;
border-radius: 8px;
padding: 10px;
background: white;
margin: 10px 0;
}
.link-container {
background: #f8f9fa;
border: 1px solid #e9ecef;
border-radius: 8px;
padding: 15px;
margin: 10px 0;
word-break: break-all;
}
.link-container .link-text {
font-family: monospace;
font-size: 14px;
color: #495057;
margin: 0;
}
.copy-button {
background: #28a745;
color: white;
border: none;
padding: 8px 16px;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
margin-top: 10px;
}
.copy-button:hover {
background: #218838;
}

103
static/util.js Normal file
View File

@ -0,0 +1,103 @@
// Shared utility functions for all views
// Initialize the app based on current page
function initializeApp() {
checkExistingSession();
}
// Show status message
function showStatus(elementId, message, type = 'info') {
const statusEl = document.getElementById(elementId);
if (statusEl) {
statusEl.innerHTML = `<div class="status ${type}">${message}</div>`;
}
}
// Clear status message
function clearStatus(elementId) {
const statusEl = document.getElementById(elementId);
if (statusEl) {
statusEl.innerHTML = '';
}
}
// Check if user is already logged in on page load
async function checkExistingSession() {
const isLoggedIn = await validateStoredToken();
const path = window.location.pathname;
// Protected routes that require authentication
const protectedRoutes = ['/auth/profile'];
if (isLoggedIn) {
// User is logged in
if (path === '/auth/login' || path === '/auth/register' || path === '/') {
// Redirect to profile if accessing login/register pages while logged in
window.location.href = '/auth/profile';
} else if (path === '/auth/add-device') {
// Redirect old add-device route to profile
window.location.href = '/auth/profile';
} else if (protectedRoutes.includes(path)) {
// Stay on current protected page and load user data
if (path === '/auth/profile') {
loadUserInfo().then(() => {
updateUserInfo();
loadCredentials();
}).catch(error => {
showStatus('profileStatus', `Failed to load user info: ${error.message}`, 'error');
});
}
}
} else {
// User is not logged in
if (protectedRoutes.includes(path) || path === '/auth/add-device') {
// Redirect to login if accessing protected pages without authentication
window.location.href = '/auth/login';
}
}
}
// Validate stored token
async function validateStoredToken() {
try {
const response = await fetch('/api/validate-token', {
method: 'GET',
credentials: 'include'
});
const result = await response.json();
return result.status === 'success';
} catch (error) {
return false;
}
}
// Copy device link to clipboard
async function copyDeviceLink() {
try {
if (window.currentDeviceLink) {
await navigator.clipboard.writeText(window.currentDeviceLink);
const copyButton = document.querySelector('.copy-button');
if (copyButton) {
const originalText = copyButton.textContent;
copyButton.textContent = 'Copied!';
copyButton.style.background = '#28a745';
setTimeout(() => {
copyButton.textContent = originalText;
copyButton.style.background = '#28a745';
}, 2000);
}
}
} catch (error) {
console.error('Failed to copy link:', error);
const linkText = document.getElementById('deviceLinkText');
if (linkText) {
const range = document.createRange();
range.selectNode(linkText);
window.getSelection().removeAllRanges();
window.getSelection().addRange(range);
}
}
}