Compare commits
3 Commits
eb56c000e8
...
0f71f80446
Author | SHA1 | Date | |
---|---|---|---|
![]() |
0f71f80446 | ||
![]() |
52520c18b1 | ||
![]() |
1c9044054a |
2
main.py
2
main.py
@ -191,7 +191,7 @@ async def get_index():
|
||||
<html>
|
||||
<head>
|
||||
<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>
|
||||
body { font-family: Arial, sans-serif; max-width: 600px; margin: 50px auto; padding: 20px; }
|
||||
.container { text-align: center; }
|
||||
|
@ -10,8 +10,8 @@ This module contains all the HTTP API endpoints for:
|
||||
|
||||
from fastapi import Request, Response
|
||||
|
||||
from . import db
|
||||
from .aaguid_manager import get_aaguid_manager
|
||||
from .db import connect
|
||||
from .jwt_manager import refresh_session_token, validate_session_token
|
||||
from .session_manager import (
|
||||
clear_session_cookie,
|
||||
@ -36,6 +36,7 @@ async def get_user_info(request: Request) -> dict:
|
||||
"user_name": user.user_name,
|
||||
"created_at": user.created_at.isoformat() if user.created_at else None,
|
||||
"last_seen": user.last_seen.isoformat() if user.last_seen else None,
|
||||
"visits": user.visits,
|
||||
},
|
||||
}
|
||||
except Exception as e:
|
||||
@ -57,9 +58,8 @@ async def get_user_credentials(request: Request) -> dict:
|
||||
if token_data:
|
||||
current_credential_id = token_data.get("credential_id")
|
||||
|
||||
async with connect() as db:
|
||||
# Get all credentials for the user
|
||||
credential_ids = await db.get_credentials_by_user_id(user.user_id.bytes)
|
||||
credential_ids = await db.get_user_credentials(user.user_id)
|
||||
|
||||
credentials = []
|
||||
user_aaguids = set()
|
||||
@ -73,9 +73,7 @@ async def get_user_credentials(request: Request) -> dict:
|
||||
user_aaguids.add(aaguid_str)
|
||||
|
||||
# Check if this is the current session credential
|
||||
is_current_session = (
|
||||
current_credential_id == stored_cred.credential_id
|
||||
)
|
||||
is_current_session = current_credential_id == stored_cred.credential_id
|
||||
|
||||
credentials.append(
|
||||
{
|
||||
@ -212,7 +210,6 @@ async def delete_credential(request: Request) -> dict:
|
||||
except ValueError:
|
||||
return {"error": "Invalid credential_id format"}
|
||||
|
||||
async with connect() as db:
|
||||
# First, verify the credential belongs to the current user
|
||||
try:
|
||||
stored_cred = await db.get_credential_by_id(credential_id_bytes)
|
||||
@ -225,21 +222,16 @@ async def delete_credential(request: Request) -> dict:
|
||||
session_token = get_session_token_from_request(request)
|
||||
if session_token:
|
||||
token_data = validate_session_token(session_token)
|
||||
if (
|
||||
token_data
|
||||
and token_data.get("credential_id") == credential_id_bytes
|
||||
):
|
||||
if token_data and token_data.get("credential_id") == credential_id_bytes:
|
||||
return {"error": "Cannot delete current session credential"}
|
||||
|
||||
# Get user's remaining credentials count
|
||||
remaining_credentials = await db.get_credentials_by_user_id(
|
||||
user.user_id.bytes
|
||||
)
|
||||
remaining_credentials = await db.get_user_credentials(user.user_id)
|
||||
if len(remaining_credentials) <= 1:
|
||||
return {"error": "Cannot delete last remaining 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"}
|
||||
|
||||
|
@ -1,75 +1,84 @@
|
||||
"""
|
||||
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.
|
||||
"""
|
||||
|
||||
import secrets
|
||||
from contextlib import asynccontextmanager
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timedelta
|
||||
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
|
||||
|
||||
DB_PATH = "webauthn.db"
|
||||
DB_PATH = "sqlite+aiosqlite:///webauthn.db"
|
||||
|
||||
# SQL Statements
|
||||
SQL_CREATE_USERS = """
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
user_id BINARY(16) PRIMARY KEY NOT NULL,
|
||||
user_name TEXT NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
last_seen TIMESTAMP NULL
|
||||
|
||||
# SQLAlchemy Models
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
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 (
|
||||
credential_id BINARY(64) PRIMARY KEY NOT NULL,
|
||||
user_id BINARY(16) NOT NULL,
|
||||
aaguid BINARY(16) NOT NULL,
|
||||
public_key BLOB NOT NULL,
|
||||
sign_count INTEGER NOT NULL,
|
||||
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
|
||||
|
||||
class CredentialModel(Base):
|
||||
__tablename__ = "credentials"
|
||||
|
||||
credential_id: Mapped[bytes] = mapped_column(LargeBinary(64), primary_key=True)
|
||||
user_id: Mapped[bytes] = mapped_column(
|
||||
LargeBinary(16), ForeignKey("users.user_id", ondelete="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 = """
|
||||
SELECT * FROM users WHERE user_id = ?
|
||||
"""
|
||||
# Relationship to user
|
||||
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 = """
|
||||
INSERT INTO credentials (credential_id, user_id, aaguid, public_key, sign_count, created_at, last_used, last_verified)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
"""
|
||||
class ResetTokenModel(Base):
|
||||
__tablename__ = "reset_tokens"
|
||||
|
||||
SQL_GET_CREDENTIAL_BY_ID = """
|
||||
SELECT * FROM credentials WHERE credential_id = ?
|
||||
"""
|
||||
token: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||
user_id: Mapped[bytes] = mapped_column(
|
||||
LargeBinary(16), ForeignKey("users.user_id", ondelete="CASCADE")
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.now)
|
||||
|
||||
SQL_GET_USER_CREDENTIALS = """
|
||||
SELECT credential_id FROM credentials WHERE user_id = ?
|
||||
"""
|
||||
|
||||
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 = ?
|
||||
"""
|
||||
# Relationship to user
|
||||
user: Mapped["UserModel"] = relationship("UserModel")
|
||||
|
||||
|
||||
@dataclass
|
||||
@ -78,123 +87,302 @@ class User:
|
||||
user_name: str
|
||||
created_at: datetime | None = None
|
||||
last_seen: datetime | None = None
|
||||
visits: int = 0
|
||||
|
||||
|
||||
@dataclass
|
||||
class ResetToken:
|
||||
token: str
|
||||
user_id: UUID
|
||||
created_at: datetime
|
||||
|
||||
|
||||
# Global engine and session factory
|
||||
engine = create_async_engine(DB_PATH, echo=False)
|
||||
async_session_factory = async_sessionmaker(engine, expire_on_commit=False)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def connect():
|
||||
conn = await aiosqlite.connect(DB_PATH)
|
||||
try:
|
||||
yield DB(conn)
|
||||
await conn.commit()
|
||||
finally:
|
||||
await conn.close()
|
||||
"""Context manager for database connections."""
|
||||
async with async_session_factory() as session:
|
||||
yield DB(session)
|
||||
await session.commit()
|
||||
|
||||
|
||||
class DB:
|
||||
def __init__(self, conn: aiosqlite.Connection):
|
||||
self.conn = conn
|
||||
def __init__(self, session: AsyncSession):
|
||||
self.session = session
|
||||
|
||||
async def init_db(self) -> None:
|
||||
"""Initialize database tables."""
|
||||
await self.conn.execute(SQL_CREATE_USERS)
|
||||
await self.conn.execute(SQL_CREATE_CREDENTIALS)
|
||||
await self.conn.commit()
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
# Database operation functions that work with a connection
|
||||
async def get_user_by_user_id(self, user_id: bytes) -> User:
|
||||
async def get_user_by_user_id(self, user_id: UUID) -> User:
|
||||
"""Get user record by WebAuthn user ID."""
|
||||
async with self.conn.execute(SQL_GET_USER_BY_USER_ID, (user_id,)) as cursor:
|
||||
row = await cursor.fetchone()
|
||||
if row:
|
||||
stmt = select(UserModel).where(UserModel.user_id == user_id.bytes)
|
||||
result = await self.session.execute(stmt)
|
||||
user_model = result.scalar_one_or_none()
|
||||
|
||||
if user_model:
|
||||
return User(
|
||||
user_id=UUID(bytes=row[0]),
|
||||
user_name=row[1],
|
||||
created_at=_convert_datetime(row[2]),
|
||||
last_seen=_convert_datetime(row[3]),
|
||||
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,
|
||||
)
|
||||
raise ValueError("User not found")
|
||||
|
||||
async def create_user(self, user: User) -> None:
|
||||
"""Create a new user and return the User dataclass."""
|
||||
await self.conn.execute(
|
||||
SQL_CREATE_USER,
|
||||
(
|
||||
user.user_id.bytes,
|
||||
user.user_name,
|
||||
user.created_at or datetime.now(),
|
||||
user.last_seen,
|
||||
),
|
||||
"""Create a new user."""
|
||||
user_model = UserModel(
|
||||
user_id=user.user_id.bytes,
|
||||
user_name=user.user_name,
|
||||
created_at=user.created_at or datetime.now(),
|
||||
last_seen=user.last_seen,
|
||||
visits=user.visits,
|
||||
)
|
||||
self.session.add(user_model)
|
||||
await self.session.flush()
|
||||
|
||||
async def create_credential(self, credential: StoredCredential) -> None:
|
||||
"""Store a credential for a user."""
|
||||
await self.conn.execute(
|
||||
SQL_STORE_CREDENTIAL,
|
||||
(
|
||||
credential.credential_id,
|
||||
credential.user_id.bytes,
|
||||
credential.aaguid.bytes,
|
||||
credential.public_key,
|
||||
credential.sign_count,
|
||||
credential.created_at,
|
||||
credential.last_used,
|
||||
credential.last_verified,
|
||||
),
|
||||
credential_model = CredentialModel(
|
||||
credential_id=credential.credential_id,
|
||||
user_id=credential.user_id.bytes,
|
||||
aaguid=credential.aaguid.bytes,
|
||||
public_key=credential.public_key,
|
||||
sign_count=credential.sign_count,
|
||||
created_at=credential.created_at,
|
||||
last_used=credential.last_used,
|
||||
last_verified=credential.last_verified,
|
||||
)
|
||||
self.session.add(credential_model)
|
||||
await self.session.flush()
|
||||
|
||||
async def get_credential_by_id(self, credential_id: bytes) -> StoredCredential:
|
||||
"""Get credential by credential ID."""
|
||||
async with self.conn.execute(
|
||||
SQL_GET_CREDENTIAL_BY_ID, (credential_id,)
|
||||
) as cursor:
|
||||
row = await cursor.fetchone()
|
||||
if row:
|
||||
stmt = select(CredentialModel).where(
|
||||
CredentialModel.credential_id == credential_id
|
||||
)
|
||||
result = await self.session.execute(stmt)
|
||||
credential_model = result.scalar_one_or_none()
|
||||
|
||||
if credential_model:
|
||||
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]),
|
||||
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: bytes) -> list[bytes]:
|
||||
async def get_credentials_by_user_id(self, user_id: UUID) -> list[bytes]:
|
||||
"""Get all credential IDs for a user."""
|
||||
async with self.conn.execute(SQL_GET_USER_CREDENTIALS, (user_id,)) as cursor:
|
||||
rows = await cursor.fetchall()
|
||||
return [row[0] for row in rows]
|
||||
stmt = select(CredentialModel.credential_id).where(
|
||||
CredentialModel.user_id == user_id.bytes
|
||||
)
|
||||
result = await self.session.execute(stmt)
|
||||
return [row[0] for row in result.fetchall()]
|
||||
|
||||
async def update_credential(self, credential: StoredCredential) -> None:
|
||||
"""Update the sign count, created_at, last_used, and last_verified for a credential."""
|
||||
await self.conn.execute(
|
||||
SQL_UPDATE_CREDENTIAL,
|
||||
(
|
||||
credential.sign_count,
|
||||
credential.created_at,
|
||||
credential.last_used,
|
||||
credential.last_verified,
|
||||
credential.credential_id,
|
||||
),
|
||||
stmt = (
|
||||
update(CredentialModel)
|
||||
.where(CredentialModel.credential_id == credential.credential_id)
|
||||
.values(
|
||||
sign_count=credential.sign_count,
|
||||
created_at=credential.created_at,
|
||||
last_used=credential.last_used,
|
||||
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."""
|
||||
await self.conn.execute("BEGIN")
|
||||
async with self.session.begin():
|
||||
# Update credential
|
||||
await self.update_credential(credential)
|
||||
await self.conn.execute(
|
||||
"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:
|
||||
"""Delete a credential by its ID."""
|
||||
await self.conn.execute(SQL_DELETE_CREDENTIAL, (credential_id,))
|
||||
await self.conn.commit()
|
||||
stmt = delete(CredentialModel).where(
|
||||
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):
|
||||
"""Convert string from SQLite to datetime object (pass through None)."""
|
||||
return val and datetime.fromisoformat(val)
|
||||
# Standalone functions that handle database connections internally
|
||||
async def init_database() -> None:
|
||||
"""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)
|
||||
|
@ -18,6 +18,7 @@ from fastapi import FastAPI, Request, Response, WebSocket, WebSocketDisconnect
|
||||
from fastapi.responses import FileResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from . import db
|
||||
from .api_handlers import (
|
||||
delete_credential,
|
||||
get_user_credentials,
|
||||
@ -27,9 +28,10 @@ from .api_handlers import (
|
||||
set_session,
|
||||
validate_token,
|
||||
)
|
||||
from .db import User, connect
|
||||
from .db import User
|
||||
from .jwt_manager import create_session_token
|
||||
from .passkey import Passkey
|
||||
from .reset_handlers import create_device_addition_link, validate_device_addition_token
|
||||
from .session_manager import get_user_from_cookie_string
|
||||
|
||||
STATIC_DIR = Path(__file__).parent.parent / "static"
|
||||
@ -44,8 +46,7 @@ passkey = Passkey(
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
async with connect() as db:
|
||||
await db.init_db()
|
||||
await db.init_database()
|
||||
yield
|
||||
|
||||
|
||||
@ -65,11 +66,11 @@ async def websocket_register_new(ws: WebSocket):
|
||||
# WebAuthn registration
|
||||
credential = await register_chat(ws, user_id, user_name)
|
||||
|
||||
# Store the user in the database
|
||||
async with connect() as db:
|
||||
await db.conn.execute("BEGIN")
|
||||
await db.create_user(User(user_id, user_name, created_at=datetime.now()))
|
||||
await db.create_credential(credential)
|
||||
# Store the user and credential in the database
|
||||
await db.create_user_and_credential(
|
||||
User(user_id, user_name, created_at=datetime.now()),
|
||||
credential,
|
||||
)
|
||||
|
||||
# Create a session token for the new user
|
||||
session_token = create_session_token(user_id, credential.credential_id)
|
||||
@ -101,16 +102,15 @@ async def websocket_register_add(ws: WebSocket):
|
||||
return
|
||||
|
||||
# Get user information to get the user_name
|
||||
async with connect() as db:
|
||||
user = await db.get_user_by_user_id(user_id.bytes)
|
||||
user = await db.get_user_by_id(user_id)
|
||||
user_name = user.user_name
|
||||
challenge_ids = await db.get_user_credentials(user_id)
|
||||
|
||||
# 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}")
|
||||
# Store the new credential in the database
|
||||
async with connect() as db:
|
||||
await db.create_credential(credential)
|
||||
await db.create_credential_for_user(credential)
|
||||
|
||||
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)}"})
|
||||
|
||||
|
||||
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."""
|
||||
options, challenge = passkey.reg_generate_options(
|
||||
user_id=user_id,
|
||||
user_name=user_name,
|
||||
credential_ids=credential_ids,
|
||||
)
|
||||
await ws.send_json(options)
|
||||
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):
|
||||
await ws.accept()
|
||||
try:
|
||||
options, challenge = await passkey.auth_generate_options()
|
||||
options, challenge = passkey.auth_generate_options()
|
||||
await ws.send_json(options)
|
||||
# Wait for the client to use his authenticator to authenticate
|
||||
credential = passkey.auth_parse(await ws.receive_json())
|
||||
async with connect() as db:
|
||||
# Fetch from the database by credential ID
|
||||
stored_cred = await db.get_credential_by_id(credential.raw_id)
|
||||
# Verify the credential matches the stored data
|
||||
await passkey.auth_verify(credential, challenge, stored_cred)
|
||||
passkey.auth_verify(credential, challenge, stored_cred)
|
||||
# Update both credential and user's last_seen timestamp
|
||||
await db.login(stored_cred.user_id.bytes, stored_cred)
|
||||
await db.login_user(stored_cred.user_id, stored_cred)
|
||||
|
||||
# Create a session token for the authenticated user
|
||||
session_token = create_session_token(
|
||||
@ -216,14 +279,66 @@ async def api_delete_credential(request: Request):
|
||||
return await delete_credential(request)
|
||||
|
||||
|
||||
@app.post("/api/create-device-link")
|
||||
async def api_create_device_link(request: Request):
|
||||
"""Create a device addition link for the authenticated user."""
|
||||
return await create_device_addition_link(request)
|
||||
|
||||
|
||||
@app.post("/api/validate-device-token")
|
||||
async def api_validate_device_token(request: Request):
|
||||
"""Validate a device addition token."""
|
||||
return await validate_device_addition_token(request)
|
||||
|
||||
|
||||
# Serve static files
|
||||
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
|
||||
|
||||
|
||||
@app.get("/")
|
||||
async def get_index():
|
||||
"""Serve the main HTML page"""
|
||||
return FileResponse(STATIC_DIR / "index.html")
|
||||
"""Redirect to login page"""
|
||||
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():
|
||||
|
@ -155,7 +155,7 @@ class Passkey:
|
||||
|
||||
### Authentication Methods ###
|
||||
|
||||
async def auth_generate_options(
|
||||
def auth_generate_options(
|
||||
self,
|
||||
*,
|
||||
user_verification_required=False,
|
||||
@ -178,7 +178,7 @@ class Passkey:
|
||||
user_verification=(
|
||||
UserVerificationRequirement.REQUIRED
|
||||
if user_verification_required
|
||||
else UserVerificationRequirement.PREFERRED
|
||||
else UserVerificationRequirement.DISCOURAGED
|
||||
),
|
||||
allow_credentials=_convert_credential_ids(credential_ids),
|
||||
**authopts,
|
||||
@ -188,7 +188,7 @@ class Passkey:
|
||||
def auth_parse(self, response: dict | str) -> AuthenticationCredential:
|
||||
return parse_authentication_credential_json(response)
|
||||
|
||||
async def auth_verify(
|
||||
def auth_verify(
|
||||
self,
|
||||
credential: AuthenticationCredential,
|
||||
expected_challenge: bytes,
|
||||
|
9
passkeyauth/passphrase.py
Normal file
9
passkeyauth/passphrase.py
Normal file
@ -0,0 +1,9 @@
|
||||
import secrets
|
||||
|
||||
from .wordlist import words
|
||||
|
||||
|
||||
def generate(n=4, sep="."):
|
||||
"""Generate a password of random words without repeating any word."""
|
||||
wl = list(words)
|
||||
return sep.join(wl.pop(secrets.randbelow(len(wl))) for i in range(n))
|
104
passkeyauth/reset_handlers.py
Normal file
104
passkeyauth/reset_handlers.py
Normal file
@ -0,0 +1,104 @@
|
||||
"""
|
||||
Device addition API handlers for WebAuthn authentication.
|
||||
|
||||
This module provides endpoints for authenticated users to:
|
||||
- Generate device addition links with human-readable tokens
|
||||
- Validate device addition tokens
|
||||
- Add new passkeys to existing accounts via tokens
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from fastapi import Request
|
||||
|
||||
from . import db
|
||||
from .passphrase import generate
|
||||
from .session_manager import get_current_user
|
||||
|
||||
|
||||
async def create_device_addition_link(request: Request) -> dict:
|
||||
"""Create a device addition link for the authenticated user."""
|
||||
try:
|
||||
# Require authentication
|
||||
user = await get_current_user(request)
|
||||
if not user:
|
||||
return {"error": "Authentication required"}
|
||||
|
||||
# Generate a human-readable token
|
||||
token = generate(n=4, sep="-") # e.g., "able-ocean-forest-dawn"
|
||||
|
||||
# Create reset token in database
|
||||
await db.create_reset_token(user.user_id, token)
|
||||
|
||||
# Generate the device addition link with pretty URL
|
||||
addition_link = f"http://localhost:8000/reset/{token}"
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"message": "Device addition link generated successfully",
|
||||
"addition_link": addition_link,
|
||||
"token": token,
|
||||
"expires_in_hours": 24,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
return {"error": f"Failed to create device addition link: {str(e)}"}
|
||||
|
||||
|
||||
async def validate_device_addition_token(request: Request) -> dict:
|
||||
"""Validate a device addition token and return user info."""
|
||||
try:
|
||||
body = await request.json()
|
||||
token = body.get("token")
|
||||
|
||||
if not token:
|
||||
return {"error": "Device addition token is required"}
|
||||
|
||||
# Get reset token
|
||||
reset_token = await db.get_reset_token(token)
|
||||
if not reset_token:
|
||||
return {"error": "Invalid or expired device addition token"}
|
||||
|
||||
# Check if token is expired (24 hours)
|
||||
expiry_time = reset_token.created_at + timedelta(hours=24)
|
||||
if datetime.now() > expiry_time:
|
||||
return {"error": "Device addition token has expired"}
|
||||
|
||||
# Get user info
|
||||
user = await db.get_user_by_id(reset_token.user_id)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"valid": True,
|
||||
"user_id": str(user.user_id),
|
||||
"user_name": user.user_name,
|
||||
"token": token,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
return {"error": f"Failed to validate device addition token: {str(e)}"}
|
||||
|
||||
|
||||
async def use_device_addition_token(token: str) -> dict:
|
||||
"""Delete a device addition token after successful use."""
|
||||
try:
|
||||
# Get reset token first to validate it exists and is not expired
|
||||
reset_token = await db.get_reset_token(token)
|
||||
if not reset_token:
|
||||
return {"error": "Invalid or expired device addition token"}
|
||||
|
||||
# Check if token is expired (24 hours)
|
||||
expiry_time = reset_token.created_at + timedelta(hours=24)
|
||||
if datetime.now() > expiry_time:
|
||||
return {"error": "Device addition token has expired"}
|
||||
|
||||
# Delete the token (it's now used)
|
||||
await db.delete_reset_token(token)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"message": "Device addition token used successfully",
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
return {"error": f"Failed to use device addition token: {str(e)}"}
|
@ -12,7 +12,7 @@ from uuid import UUID
|
||||
|
||||
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
|
||||
|
||||
COOKIE_NAME = "session_token"
|
||||
@ -29,9 +29,8 @@ async def get_current_user(request: Request) -> Optional[User]:
|
||||
if not token_data:
|
||||
return None
|
||||
|
||||
async with connect() as db:
|
||||
try:
|
||||
user = await db.get_user_by_user_id(token_data["user_id"].bytes)
|
||||
user = await get_user_by_id(token_data["user_id"])
|
||||
return user
|
||||
except Exception:
|
||||
return None
|
||||
|
54
passkeyauth/wordlist.py
Normal file
54
passkeyauth/wordlist.py
Normal file
@ -0,0 +1,54 @@
|
||||
# A custom list of 1024 common 3-6 letter words, with unique 3-prefixes and no prefix words, entropy 2.1b/letter 10b/word
|
||||
words: list = """
|
||||
able about absent abuse access acid across act adapt add adjust admit adult advice affair afraid again age agree ahead
|
||||
aim air aisle alarm album alert alien all almost alone alpha also alter always amazed among amused anchor angle animal
|
||||
ankle annual answer any apart appear april arch are argue army around array art ascent ash ask aspect assume asthma atom
|
||||
attack audit august aunt author avoid away awful axis baby back bad bag ball bamboo bank bar base battle beach become
|
||||
beef before begin behind below bench best better beyond bid bike bind bio birth bitter black bleak blind blood blue
|
||||
board body boil bomb bone book border boss bottom bounce bowl box boy brain bread bring brown brush bubble buck budget
|
||||
build bulk bundle burden bus but buyer buzz cable cache cage cake call came can car case catch cause cave celery cement
|
||||
census cereal change check child choice chunk cigar circle city civil class clean client close club coast code coffee
|
||||
coil cold come cool copy core cost cotton couch cover coyote craft cream crime cross cruel cry cube cue cult cup curve
|
||||
custom cute cycle dad damage danger daring dash dawn day deal debate decide deer define degree deity delay demand denial
|
||||
depth derive design detail device dial dice die differ dim dinner direct dish divert dizzy doctor dog dollar domain
|
||||
donate door dose double dove draft dream drive drop drum dry duck dumb dune during dust dutch dwarf eager early east
|
||||
echo eco edge edit effort egg eight either elbow elder elite else embark emerge emily employ enable end enemy engine
|
||||
enjoy enlist enough enrich ensure entire envy equal era erode error erupt escape essay estate ethics evil evoke exact
|
||||
excess exist exotic expect extent eye fabric face fade faith fall family fan far father fault feel female fence fetch
|
||||
fever few fiber field figure file find first fish fit fix flat flesh flight float fluid fly foam focus fog foil follow
|
||||
food force fossil found fox frame fresh friend frog fruit fuel fun fury future gadget gain galaxy game gap garden gas
|
||||
gate gauge gaze genius ghost giant gift giggle ginger girl give glass glide globe glue goal god gold good gospel govern
|
||||
gown grant great grid group grunt guard guess guide gulf gun gym habit hair half hammer hand happy hard hat have hawk
|
||||
hay hazard head hedge height help hen hero hidden high hill hint hip hire hobby hockey hold home honey hood hope horse
|
||||
host hotel hour hover how hub huge human hungry hurt hybrid ice icon idea idle ignore ill image immune impact income
|
||||
index infant inhale inject inmate inner input inside into invest iron island issue italy item ivory jacket jaguar james
|
||||
jar jazz jeans jelly jewel job joe joke joy judge juice july jump june just kansas kate keep kernel key kick kid kind
|
||||
kiss kit kiwi knee knife know labor lady lag lake lamp laptop large later laugh lava law layer lazy leader left legal
|
||||
lemon length lesson letter level liar libya lid life light like limit line lion liquid list little live lizard load
|
||||
local logic long loop lost loud love low loyal lucky lumber lunch lust luxury lyrics mad magic main major make male
|
||||
mammal man map market mass matter maze mccoy meadow media meet melt member men mercy mesh method middle milk mimic mind
|
||||
mirror miss mix mobile model mom monkey moon more mother mouse move much muffin mule must mutual myself myth naive name
|
||||
napkin narrow nasty nation near neck need nephew nerve nest net never news next nice night noble noise noodle normal
|
||||
nose note novel now number nurse nut oak obey object oblige obtain occur ocean odor off often oil okay old olive omit
|
||||
once one onion online open opium oppose option orange orbit order organ orient orphan other outer oval oven own oxygen
|
||||
oyster ozone pact paddle page pair palace panel paper parade past path pause pave paw pay peace pen people pepper permit
|
||||
pet philip phone phrase piano pick piece pig pilot pink pipe pistol pitch pizza place please pluck poem point polar pond
|
||||
pool post pot pound powder praise prefer price profit public pull punch pupil purity push put puzzle qatar quasi queen
|
||||
quite quoted rabbit race radio rail rally ramp range rapid rare rather raven raw razor real rebel recall red reform
|
||||
region reject relief remain rent reopen report result return review reward rhythm rib rich ride rifle right ring riot
|
||||
ripple risk ritual river road robot rocket room rose rotate round row royal rubber rude rug rule run rural sad safe sage
|
||||
sail salad same santa sauce save say scale scene school scope screen scuba sea second seed self semi sense series settle
|
||||
seven shadow she ship shock shrimp shy sick side siege sign silver simple since siren sister six size skate sketch ski
|
||||
skull slab sleep slight slogan slush small smile smooth snake sniff snow soap soccer soda soft solid son soon sort south
|
||||
space speak sphere spirit split spoil spring spy square state step still story strong stuff style submit such sudden
|
||||
suffer sugar suit summer sun supply sure swamp sweet switch sword symbol syntax syria system table tackle tag tail talk
|
||||
tank tape target task tattoo taxi team tell ten term test text that theme this three thumb tibet ticket tide tight tilt
|
||||
time tiny tip tired tissue title toast today toe toilet token tomato tone tool top torch toss total toward toy trade
|
||||
tree trial trophy true try tube tumble tunnel turn twenty twice two type ugly unable uncle under unfair unique unlock
|
||||
until unveil update uphold upon upper upset urban urge usage use usual vacuum vague valid van vapor vast vault vein
|
||||
velvet vendor very vessel viable video view villa violin virus visit vital vivid vocal voice volume vote voyage wage
|
||||
wait wall want war wash water wave way wealth web weird were west wet what when whip wide wife will window wire wish
|
||||
wolf woman wonder wood work wrap wreck write wrong xander xbox xerox xray yang yard year yellow yes yin york you zane
|
||||
zara zebra zen zero zippo zone zoo zorro zulu
|
||||
""".split()
|
||||
assert len(words) == 1024 # Exactly 10 bits of entropy per word
|
@ -15,6 +15,7 @@ dependencies = [
|
||||
"websockets>=12.0",
|
||||
"webauthn>=1.11.1",
|
||||
"base64url>=1.0.0",
|
||||
"sqlalchemy[asyncio]>=2.0.0",
|
||||
"aiosqlite>=0.19.0",
|
||||
"uuid7-standard>=1.0.0",
|
||||
"pyjwt>=2.8.0",
|
||||
|
93
static/add-device.html
Normal file
93
static/add-device.html
Normal 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>
|
284
static/app.js
284
static/app.js
@ -5,7 +5,10 @@ let currentUser = null
|
||||
let currentCredentials = []
|
||||
let aaguidInfo = {}
|
||||
|
||||
// Session management - now using HTTP-only cookies
|
||||
// ========================================
|
||||
// Session Management
|
||||
// ========================================
|
||||
|
||||
async function validateStoredToken() {
|
||||
try {
|
||||
const response = await fetch('/api/validate-token', {
|
||||
@ -14,18 +17,12 @@ async function validateStoredToken() {
|
||||
})
|
||||
|
||||
const result = await response.json()
|
||||
|
||||
if (result.status === 'success') {
|
||||
return true
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
return result.status === 'success'
|
||||
} catch (error) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to set session cookie using JWT token
|
||||
async function setSessionCookie(sessionToken) {
|
||||
try {
|
||||
const response = await fetch('/api/set-session', {
|
||||
@ -48,35 +45,64 @@ async function setSessionCookie(sessionToken) {
|
||||
}
|
||||
}
|
||||
|
||||
// View management
|
||||
// ========================================
|
||||
// View Management
|
||||
// ========================================
|
||||
|
||||
function showView(viewId) {
|
||||
document.querySelectorAll('.view').forEach(view => view.classList.remove('active'))
|
||||
document.getElementById(viewId).classList.add('active')
|
||||
const targetView = document.getElementById(viewId)
|
||||
if (targetView) {
|
||||
targetView.classList.add('active')
|
||||
}
|
||||
}
|
||||
|
||||
function showLoginView() {
|
||||
if (window.location.pathname !== '/auth/login') {
|
||||
window.location.href = '/auth/login'
|
||||
return
|
||||
}
|
||||
showView('loginView')
|
||||
clearStatus('loginStatus')
|
||||
}
|
||||
|
||||
function showRegisterView() {
|
||||
if (window.location.pathname !== '/auth/register') {
|
||||
window.location.href = '/auth/register'
|
||||
return
|
||||
}
|
||||
showView('registerView')
|
||||
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() {
|
||||
showView('dashboardView')
|
||||
clearStatus('dashboardStatus')
|
||||
if (window.location.pathname !== '/auth/profile') {
|
||||
window.location.href = '/auth/profile'
|
||||
return
|
||||
}
|
||||
showView('profileView')
|
||||
clearStatus('profileStatus')
|
||||
loadUserInfo().then(() => {
|
||||
updateUserInfo()
|
||||
loadCredentials()
|
||||
}).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') {
|
||||
const statusEl = document.getElementById(elementId)
|
||||
statusEl.innerHTML = `<div class="status ${type}">${message}</div>`
|
||||
@ -86,6 +112,106 @@ function clearStatus(elementId) {
|
||||
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
|
||||
async function register(user_name) {
|
||||
try {
|
||||
@ -162,7 +288,8 @@ async function authenticate() {
|
||||
// Load user credentials
|
||||
async function loadCredentials() {
|
||||
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', {
|
||||
method: 'GET',
|
||||
@ -175,9 +302,10 @@ async function loadCredentials() {
|
||||
currentCredentials = result.credentials
|
||||
aaguidInfo = result.aaguid_info || {}
|
||||
updateCredentialList()
|
||||
clearStatus('dashboardStatus')
|
||||
clearStatus(statusElement)
|
||||
} 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) {
|
||||
userInfoEl.innerHTML = `
|
||||
<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,94 +425,58 @@ async function logout() {
|
||||
currentUser = null
|
||||
currentCredentials = []
|
||||
aaguidInfo = {}
|
||||
showLoginView()
|
||||
window.location.href = '/auth/login'
|
||||
}
|
||||
|
||||
// Check if user is already logged in on page load
|
||||
async function checkExistingSession() {
|
||||
if (await validateStoredToken()) {
|
||||
showDashboardView()
|
||||
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 {
|
||||
showLoginView()
|
||||
// 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'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add new credential for logged-in user
|
||||
async function addNewCredential() {
|
||||
try {
|
||||
showStatus('dashboardStatus', 'Starting new passkey registration...', 'info')
|
||||
|
||||
const ws = await aWebSocket('/ws/add_credential')
|
||||
|
||||
// Registration chat - no need to send user data since we're authenticated
|
||||
const optionsJSON = JSON.parse(await ws.recv())
|
||||
if (optionsJSON.error) throw new Error(optionsJSON.error)
|
||||
|
||||
showStatus('dashboardStatus', 'Save new passkey to your authenticator...', 'info')
|
||||
|
||||
const registrationResponse = await startRegistration({optionsJSON})
|
||||
ws.send(JSON.stringify(registrationResponse))
|
||||
|
||||
const result = JSON.parse(await ws.recv())
|
||||
if (result.error) throw new Error(`Server: ${result.error}`)
|
||||
|
||||
ws.close()
|
||||
|
||||
showStatus('dashboardStatus', 'New passkey added successfully!', 'success')
|
||||
|
||||
// Refresh credentials list to show the new credential
|
||||
await loadCredentials()
|
||||
clearStatus('dashboardStatus')
|
||||
|
||||
} catch (error) {
|
||||
showStatus('dashboardStatus', `Failed to add new passkey: ${error.message}`, 'error')
|
||||
}
|
||||
}
|
||||
|
||||
// Delete credential
|
||||
async function deleteCredential(credentialId) {
|
||||
if (!confirm('Are you sure you want to delete this passkey? This action cannot be undone.')) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
showStatus('dashboardStatus', 'Deleting passkey...', 'info')
|
||||
|
||||
const response = await fetch('/api/delete-credential', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({
|
||||
credential_id: credentialId
|
||||
})
|
||||
})
|
||||
|
||||
const result = await response.json()
|
||||
|
||||
if (result.error) {
|
||||
throw new Error(result.error)
|
||||
}
|
||||
|
||||
showStatus('dashboardStatus', 'Passkey deleted successfully!', 'success')
|
||||
|
||||
// Refresh credentials list
|
||||
await loadCredentials()
|
||||
clearStatus('dashboardStatus')
|
||||
|
||||
} catch (error) {
|
||||
showStatus('dashboardStatus', `Failed to delete passkey: ${error.message}`, 'error')
|
||||
}
|
||||
// Initialize the app based on current page
|
||||
function initializeApp() {
|
||||
checkExistingSession()
|
||||
}
|
||||
|
||||
// Form event handlers
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Check for existing session on page load
|
||||
checkExistingSession()
|
||||
initializeApp()
|
||||
|
||||
// Registration form
|
||||
const regForm = document.getElementById('registrationForm')
|
||||
if (regForm) {
|
||||
const regSubmitBtn = regForm.querySelector('button[type="submit"]')
|
||||
|
||||
regForm.addEventListener('submit', async (ev) => {
|
||||
@ -398,7 +493,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
|
||||
// Auto-login after successful registration
|
||||
setTimeout(() => {
|
||||
showDashboardView()
|
||||
window.location.href = '/auth/profile'
|
||||
}, 1500)
|
||||
} catch (err) {
|
||||
showStatus('registerStatus', `Registration failed: ${err.message}`, 'error')
|
||||
@ -406,9 +501,11 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
regSubmitBtn.disabled = false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Authentication form
|
||||
const authForm = document.getElementById('authenticationForm')
|
||||
if (authForm) {
|
||||
const authSubmitBtn = authForm.querySelector('button[type="submit"]')
|
||||
|
||||
authForm.addEventListener('submit', async (ev) => {
|
||||
@ -421,9 +518,9 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
await authenticate()
|
||||
showStatus('loginStatus', 'Authentication successful!', 'success')
|
||||
|
||||
// Navigate to dashboard
|
||||
// Navigate to profile
|
||||
setTimeout(() => {
|
||||
showDashboardView()
|
||||
window.location.href = '/auth/profile'
|
||||
}, 1000)
|
||||
} catch (err) {
|
||||
showStatus('loginStatus', `Authentication failed: ${err.message}`, 'error')
|
||||
@ -431,4 +528,5 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
authSubmitBtn.disabled = false
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
106
static/dashboard.html
Normal file
106
static/dashboard.html
Normal 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>
|
@ -2,244 +2,10 @@
|
||||
<html>
|
||||
<head>
|
||||
<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>
|
||||
<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>
|
||||
<body>
|
||||
<div class="container">
|
||||
@ -282,10 +48,43 @@
|
||||
<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>
|
||||
</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>
|
||||
|
||||
<script src="static/app.js"></script>
|
||||
|
28
static/login.html
Normal file
28
static/login.html
Normal 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
33
static/login.js
Normal 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
211
static/profile.html
Normal 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
115
static/profile.js
Normal 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
4
static/qrcodejs/.gitignore
vendored
Normal file
@ -0,0 +1,4 @@
|
||||
.DS_Store
|
||||
|
||||
.idea
|
||||
.project
|
14
static/qrcodejs/LICENSE
Normal file
14
static/qrcodejs/LICENSE
Normal 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
46
static/qrcodejs/README.md
Normal 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
|
||||
|
||||
[](https://bitdeli.com/free "Bitdeli Badge")
|
||||
|
18
static/qrcodejs/bower.json
Normal file
18
static/qrcodejs/bower.json
Normal 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"
|
||||
]
|
||||
}
|
47
static/qrcodejs/index-svg.html
Normal file
47
static/qrcodejs/index-svg.html
Normal 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>
|
44
static/qrcodejs/index.html
Normal file
44
static/qrcodejs/index.html
Normal 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
37
static/qrcodejs/index.svg
Normal 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
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
614
static/qrcodejs/qrcode.js
Normal 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
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
29
static/register.html
Normal 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
35
static/register.js
Normal 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
185
static/reset.html
Normal 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
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
343
static/style.css
Normal 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
103
static/util.js
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user