From 591ea626bf2e8455e683dca522adb3ad5e7cf931 Mon Sep 17 00:00:00 2001 From: Leo Vasanko Date: Fri, 3 Oct 2025 18:31:54 -0600 Subject: [PATCH] Add host-based authentication, UTC timestamps, session management, and secure cookies; fix styling issues; refactor to remove module; update database schema for sessions and reset tokens. --- API.md | 1 + frontend/src/App.vue | 37 +- frontend/src/admin/AdminUserDetail.vue | 43 ++- frontend/src/assets/style.css | 146 ++++++-- frontend/src/components/CredentialList.vue | 150 +------- frontend/src/components/DeviceLinkView.vue | 79 ++--- frontend/src/components/ProfileView.vue | 156 ++++---- .../src/components/RegistrationLinkModal.vue | 88 ++++- frontend/src/components/SessionList.vue | 64 ++++ frontend/src/stores/auth.js | 62 +++- frontend/src/utils/helpers.js | 20 +- passkey/authsession.py | 86 +++-- passkey/bootstrap.py | 11 +- passkey/config.py | 7 + passkey/db/__init__.py | 119 +++++-- passkey/db/sql.py | 314 ++++++++++++---- passkey/fastapi/admin.py | 335 +++++++++++++++--- passkey/fastapi/api.py | 260 +++++++++++--- passkey/fastapi/authz.py | 9 +- passkey/fastapi/mainapp.py | 6 +- passkey/fastapi/reset.py | 7 +- passkey/fastapi/session.py | 12 +- passkey/fastapi/ws.py | 28 +- passkey/sansio.py | 6 +- passkey/util/hostutil.py | 16 +- passkey/util/permutil.py | 8 +- passkey/util/tokens.py | 19 + passkey/util/useragent.py | 10 + pyproject.toml | 1 + 29 files changed, 1489 insertions(+), 611 deletions(-) create mode 100644 frontend/src/components/SessionList.vue create mode 100644 passkey/config.py create mode 100644 passkey/util/useragent.py diff --git a/API.md b/API.md index 80cbf3e..ddc1c26 100644 --- a/API.md +++ b/API.md @@ -12,6 +12,7 @@ POST /auth/api/logout - Logout and delete session POST /auth/api/set-session - Set session cookie from Authorization header POST /auth/api/create-link - Create device addition link DELETE /auth/api/credential/{uuid} - Delete specific credential +DELETE /auth/api/session/{session_id} - Terminate an active session POST /auth/api/validate - Session validation and renewal endpoint (fetch regularly) GET /auth/api/forward - Authentication validation for Caddy/Nginx - On success returns `204 No Content` with [user info](Headers.md) diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 55edb02..0eed397 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -2,13 +2,10 @@
- -

Loading...

@@ -23,14 +20,11 @@ import { useAuthStore } from '@/stores/auth' import StatusMessage from '@/components/StatusMessage.vue' import LoginView from '@/components/LoginView.vue' import ProfileView from '@/components/ProfileView.vue' -import DeviceLinkView from '@/components/DeviceLinkView.vue' const store = useAuthStore() const initialized = ref(false) onMounted(async () => { - // Load branding / settings first (non-blocking for auth flow) await store.loadSettings() - // Was an error message passed in the URL hash? const message = location.hash.substring(1) if (message) { store.showMessage(decodeURIComponent(message), 'error') @@ -48,31 +42,8 @@ onMounted(async () => { diff --git a/frontend/src/admin/AdminUserDetail.vue b/frontend/src/admin/AdminUserDetail.vue index 37d0f34..4594467 100644 --- a/frontend/src/admin/AdminUserDetail.vue +++ b/frontend/src/admin/AdminUserDetail.vue @@ -3,6 +3,7 @@ import { ref } from 'vue' import UserBasicInfo from '@/components/UserBasicInfo.vue' import CredentialList from '@/components/CredentialList.vue' import RegistrationLinkModal from '@/components/RegistrationLinkModal.vue' +import SessionList from '@/components/SessionList.vue' import { useAuthStore } from '@/stores/auth' const props = defineProps({ @@ -57,18 +58,45 @@ function handleDelete(credential) { />
{{ userDetail.error }}
-
- +
-

Use the token dialog to register a new credential for the member.

@@ -77,9 +105,10 @@ function handleDelete(credential) { diff --git a/frontend/src/components/DeviceLinkView.vue b/frontend/src/components/DeviceLinkView.vue index 78f8bdd..81c70b4 100644 --- a/frontend/src/components/DeviceLinkView.vue +++ b/frontend/src/components/DeviceLinkView.vue @@ -5,74 +5,39 @@

📱 Add Another Device

Generate a one-time link to set up passkeys on a new device.

-
-
- -
- -
-
-
+ +
+ +
diff --git a/frontend/src/components/ProfileView.vue b/frontend/src/components/ProfileView.vue index 9face71..7870177 100644 --- a/frontend/src/components/ProfileView.vue +++ b/frontend/src/components/ProfileView.vue @@ -15,7 +15,7 @@ :created-at="authStore.userInfo.user.created_at" :last-seen="authStore.userInfo.user.last_seen" :loading="authStore.isLoading" - update-endpoint="/auth/api/user/display-name" + update-endpoint="/auth/api/user-display-name" @saved="authStore.loadUserInfo()" @edit-name="openNameDialog" /> @@ -35,25 +35,19 @@ @delete="handleDelete" />
- - + +
-
-
- -
-
+ -

Edit Display Name

+ +
+
+ +
+

Immediately revokes access for every device and browser signed in to your account.

+
+
@@ -76,35 +85,25 @@ import CredentialList from '@/components/CredentialList.vue' import UserBasicInfo from '@/components/UserBasicInfo.vue' import Modal from '@/components/Modal.vue' import NameEditForm from '@/components/NameEditForm.vue' +import SessionList from '@/components/SessionList.vue' +import RegistrationLinkModal from '@/components/RegistrationLinkModal.vue' import { useAuthStore } from '@/stores/auth' import passkey from '@/utils/passkey' const authStore = useAuthStore() const updateInterval = ref(null) const showNameDialog = ref(false) +const showRegLink = ref(false) const newName = ref('') const saving = ref(false) -watch(showNameDialog, (newVal) => { - if (newVal) { - newName.value = authStore.userInfo?.user?.user_name || '' - } -}) +watch(showNameDialog, (newVal) => { if (newVal) newName.value = authStore.userInfo?.user?.user_name || '' }) onMounted(() => { - updateInterval.value = setInterval(() => { - // Trigger Vue reactivity to update formatDate fields - if (authStore.userInfo) { - authStore.userInfo = { ...authStore.userInfo } - } - }, 60000) // Update every minute + updateInterval.value = setInterval(() => { if (authStore.userInfo) authStore.userInfo = { ...authStore.userInfo } }, 60000) }) -onUnmounted(() => { - if (updateInterval.value) { - clearInterval(updateInterval.value) - } -}) +onUnmounted(() => { if (updateInterval.value) clearInterval(updateInterval.value) }) const addNewCredential = async () => { try { @@ -116,9 +115,7 @@ const addNewCredential = async () => { } catch (error) { console.error('Failed to add new passkey:', error) authStore.showMessage(error.message, 'error') - } finally { - authStore.isLoading = false - } + } finally { authStore.isLoading = false } } const handleDelete = async (credential) => { @@ -128,80 +125,55 @@ const handleDelete = async (credential) => { try { await authStore.deleteCredential(credentialId) authStore.showMessage('Passkey deleted successfully!', 'success', 3000) - } catch (error) { - authStore.showMessage(`Failed to delete passkey: ${error.message}`, 'error') + } catch (error) { authStore.showMessage(`Failed to delete passkey: ${error.message}`, 'error') } +} + +const sessions = computed(() => authStore.userInfo?.sessions || []) +const terminatingSessions = ref({}) + +const terminateSession = async (session) => { + const sessionId = session?.id + if (!sessionId) return + terminatingSessions.value = { ...terminatingSessions.value, [sessionId]: true } + try { await authStore.terminateSession(sessionId) } + catch (error) { authStore.showMessage(error.message || 'Failed to terminate session', 'error', 5000) } + finally { + const next = { ...terminatingSessions.value } + delete next[sessionId] + terminatingSessions.value = next } } -const logout = async () => { - await authStore.logout() -} - -const openNameDialog = () => { - newName.value = authStore.userInfo?.user?.user_name || '' - showNameDialog.value = true -} - +const logoutEverywhere = async () => { await authStore.logoutEverywhere() } +const openNameDialog = () => { newName.value = authStore.userInfo?.user?.user_name || ''; showNameDialog.value = true } const isAdmin = computed(() => !!(authStore.userInfo?.is_global_admin || authStore.userInfo?.is_org_admin)) - -const breadcrumbEntries = computed(() => { - const entries = [{ label: 'Auth', href: authStore.uiHref() }] - if (isAdmin.value) entries.push({ label: 'Admin', href: authStore.adminHomeHref() }) - return entries -}) +const breadcrumbEntries = computed(() => { const entries = [{ label: 'Auth', href: authStore.uiHref() }]; if (isAdmin.value) entries.push({ label: 'Admin', href: authStore.adminHomeHref() }); return entries }) const saveName = async () => { const name = newName.value.trim() - if (!name) { - authStore.showMessage('Name cannot be empty', 'error') - return - } + if (!name) { authStore.showMessage('Name cannot be empty', 'error'); return } try { saving.value = true - const res = await fetch('/auth/api/user/display-name', { - method: 'PUT', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ display_name: name }) - }) + const res = await fetch('/auth/api/user/display-name', { method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ display_name: name }) }) const data = await res.json() if (!res.ok || data.detail) throw new Error(data.detail || 'Update failed') - showNameDialog.value = false + showNameDialog.value = false await authStore.loadUserInfo() authStore.showMessage('Name updated successfully!', 'success', 3000) - } catch (e) { - authStore.showMessage(e.message || 'Failed to update name', 'error') - } finally { - saving.value = false - } + } catch (e) { authStore.showMessage(e.message || 'Failed to update name', 'error') } + finally { saving.value = false } } diff --git a/frontend/src/components/RegistrationLinkModal.vue b/frontend/src/components/RegistrationLinkModal.vue index 23f995c..e0683dc 100644 --- a/frontend/src/components/RegistrationLinkModal.vue +++ b/frontend/src/components/RegistrationLinkModal.vue @@ -1,8 +1,10 @@ diff --git a/frontend/src/components/SessionList.vue b/frontend/src/components/SessionList.vue new file mode 100644 index 0000000..503234a --- /dev/null +++ b/frontend/src/components/SessionList.vue @@ -0,0 +1,64 @@ + + + \ No newline at end of file diff --git a/frontend/src/stores/auth.js b/frontend/src/stores/auth.js index 3bc05d9..e80be53 100644 --- a/frontend/src/stores/auth.js +++ b/frontend/src/stores/auth.js @@ -4,7 +4,7 @@ import { register, authenticate } from '@/utils/passkey' export const useAuthStore = defineStore('auth', { state: () => ({ // Auth State - userInfo: null, // Contains the full user info response: {user, credentials, aaguid_info, session_type, authenticated} + userInfo: null, // Contains the full user info response: {user, credentials, aaguid_info} settings: null, // Server provided settings (/auth/settings) isLoading: false, @@ -91,8 +91,7 @@ export const useAuthStore = defineStore('auth', { }, selectView() { if (!this.userInfo) this.currentView = 'login' - else if (this.userInfo.authenticated) this.currentView = 'profile' - else this.currentView = 'login' + else this.currentView = 'profile' }, async loadUserInfo() { const response = await fetch('/auth/api/user-info', { method: 'POST' }) @@ -134,9 +133,44 @@ export const useAuthStore = defineStore('auth', { await this.loadUserInfo() }, + async terminateSession(sessionId) { + try { + const res = await fetch(`/auth/api/session/${sessionId}`, { method: 'DELETE' }) + let payload = null + try { + payload = await res.json() + } catch (_) { + // ignore JSON parse errors + } + if (!res.ok || payload?.detail) { + const message = payload?.detail || 'Failed to terminate session' + throw new Error(message) + } + if (payload?.current_session_terminated) { + sessionStorage.clear() + location.reload() + return + } + await this.loadUserInfo() + this.showMessage('Session terminated', 'success', 2500) + } catch (error) { + console.error('Terminate session error:', error) + throw error + } + }, async logout() { try { - await fetch('/auth/api/logout', {method: 'POST'}) + const res = await fetch('/auth/api/logout', {method: 'POST'}) + if (!res.ok) { + let message = 'Logout failed' + try { + const data = await res.json() + if (data?.detail) message = data.detail + } catch (_) { + // ignore JSON parse errors + } + throw new Error(message) + } sessionStorage.clear() location.reload() } catch (error) { @@ -144,5 +178,25 @@ export const useAuthStore = defineStore('auth', { this.showMessage(error.message, 'error') } }, + async logoutEverywhere() { + try { + const res = await fetch('/auth/api/logout-all', {method: 'POST'}) + if (!res.ok) { + let message = 'Logout failed' + try { + const data = await res.json() + if (data?.detail) message = data.detail + } catch (_) { + // ignore JSON parse errors + } + throw new Error(message) + } + sessionStorage.clear() + location.reload() + } catch (error) { + console.error('Logout-all error:', error) + this.showMessage(error.message, 'error') + } + }, } }) diff --git a/frontend/src/utils/helpers.js b/frontend/src/utils/helpers.js index 9b38ff3..200a299 100644 --- a/frontend/src/utils/helpers.js +++ b/frontend/src/utils/helpers.js @@ -5,16 +5,18 @@ export function formatDate(dateString) { const date = new Date(dateString) const now = new Date() - const diffMs = now - date - const diffMinutes = Math.floor(diffMs / (1000 * 60)) - const diffHours = Math.floor(diffMs / (1000 * 60 * 60)) - const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24)) + const diffMs = date - now // Changed to date - now for future/past + const isFuture = diffMs > 0 + const absDiffMs = Math.abs(diffMs) + const diffMinutes = Math.round(absDiffMs / (1000 * 60)) + const diffHours = Math.round(absDiffMs / (1000 * 60 * 60)) + const diffDays = Math.round(absDiffMs / (1000 * 60 * 60 * 24)) - if (diffMs < 0 || diffDays > 7) return date.toLocaleDateString() - if (diffMinutes === 0) return 'Just now' - if (diffMinutes < 60) return diffMinutes === 1 ? 'a minute ago' : `${diffMinutes} minutes ago` - if (diffHours < 24) return diffHours === 1 ? 'an hour ago' : `${diffHours} hours ago` - return diffDays === 1 ? 'a day ago' : `${diffDays} days ago` + if (absDiffMs < 1000 * 60) return 'Now' + if (diffMinutes <= 60) return isFuture ? `In ${diffMinutes} minute${diffMinutes === 1 ? '' : 's'}` : diffMinutes === 1 ? 'a minute ago' : `${diffMinutes} minutes ago` + if (diffHours <= 24) return isFuture ? `In ${diffHours} hour${diffHours === 1 ? '' : 's'}` : diffHours === 1 ? 'an hour ago' : `${diffHours} hours ago` + if (diffDays <= 14) return isFuture ? `In ${diffDays} day${diffDays === 1 ? '' : 's'}` : diffDays === 1 ? 'a day ago' : `${diffDays} days ago` + return date.toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric' }) } export function getCookie(name) { diff --git a/passkey/authsession.py b/passkey/authsession.py index 27abac2..4de957f 100644 --- a/passkey/authsession.py +++ b/passkey/authsession.py @@ -8,61 +8,107 @@ independent of any web framework: - Credential management """ -from datetime import datetime, timedelta +from datetime import datetime, timezone from uuid import UUID -from .db import Session +from .config import SESSION_LIFETIME +from .db import ResetToken, Session from .globals import db +from .util import hostutil from .util.tokens import create_token, reset_key, session_key -EXPIRES = timedelta(hours=24) +EXPIRES = SESSION_LIFETIME def expires() -> datetime: - return datetime.now() + EXPIRES + return datetime.now(timezone.utc) + EXPIRES -async def create_session(user_uuid: UUID, credential_uuid: UUID, info: dict) -> str: +def reset_expires() -> datetime: + from .config import RESET_LIFETIME + + return datetime.now(timezone.utc) + RESET_LIFETIME + + +def session_expiry(session: Session) -> datetime: + """Calculate the expiration timestamp for a session (UTC aware).""" + # After migration all renewed timestamps are timezone-aware UTC + return session.renewed + EXPIRES + + +async def create_session( + user_uuid: UUID, + credential_uuid: UUID, + *, + host: str, + ip: str, + user_agent: str, +) -> str: """Create a new session and return a session token.""" + normalized_host = hostutil.normalize_host(host) + if not normalized_host: + raise ValueError("Host required for session creation") token = create_token() + now = datetime.now(timezone.utc) await db.instance.create_session( user_uuid=user_uuid, credential_uuid=credential_uuid, key=session_key(token), - expires=datetime.now() + EXPIRES, - info=info, + host=normalized_host, + ip=ip, + user_agent=user_agent, + renewed=now, ) return token -async def get_reset(token: str) -> Session: +async def get_reset(token: str) -> ResetToken: """Validate a credential reset token. Returns None if the token is not well formed (i.e. it is another type of token).""" - session = await db.instance.get_session(reset_key(token)) - if not session: + record = await db.instance.get_reset_token(reset_key(token)) + if not record: raise ValueError("Invalid or expired session token") - return session + if record.expiry < datetime.now(timezone.utc): + await db.instance.delete_reset_token(record.key) + raise ValueError("Invalid or expired session token") + return record -async def get_session(token: str) -> Session: +async def get_session(token: str, host: str | None = None) -> Session: """Validate a session token and return session data if valid.""" session = await db.instance.get_session(session_key(token)) if not session: raise ValueError("Invalid or expired session token") + if session_expiry(session) < datetime.now(timezone.utc): + await db.instance.delete_session(session.key) + raise ValueError("Invalid or expired session token") + if host is not None: + normalized_host = hostutil.normalize_host(host) + if not normalized_host: + raise ValueError("Invalid host") + if session.host is None: + await db.instance.set_session_host(session.key, normalized_host) + session.host = normalized_host + elif session.host != normalized_host: + raise ValueError("Invalid or expired session token") return session -async def refresh_session_token(token: str): +async def refresh_session_token(token: str, *, ip: str, user_agent: str): """Refresh a session extending its expiry.""" - # Get the current session - s = await db.instance.update_session( - session_key(token), datetime.now() + EXPIRES, {} + session_record = await db.instance.get_session(session_key(token)) + if not session_record: + raise ValueError("Session not found or expired") + updated = await db.instance.update_session( + session_key(token), + ip=ip, + user_agent=user_agent, + renewed=datetime.now(timezone.utc), ) - - if not s: + if not updated: raise ValueError("Session not found or expired") -async def delete_credential(credential_uuid: UUID, auth: str): +async def delete_credential(credential_uuid: UUID, auth: str, host: str | None = None): """Delete a specific credential for the current user.""" - s = await get_session(auth) + s = await get_session(auth, host=host) await db.instance.delete_credential(credential_uuid, s.user_uuid) diff --git a/passkey/bootstrap.py b/passkey/bootstrap.py index d0c08a1..3ee1a0b 100644 --- a/passkey/bootstrap.py +++ b/passkey/bootstrap.py @@ -8,7 +8,7 @@ generating a reset link for initial admin setup. import asyncio import logging -from datetime import datetime +from datetime import datetime, timezone import uuid7 @@ -41,11 +41,12 @@ ADMIN_RESET_MESSAGE = """\ async def _create_and_log_admin_reset_link(user_uuid, message, session_type) -> str: """Create an admin reset link and log it with the provided message.""" token = passphrase.generate() - await globals.db.instance.create_session( + expiry = authsession.reset_expires() + await globals.db.instance.create_reset_token( user_uuid=user_uuid, key=tokens.reset_key(token), - expires=authsession.expires(), - info={"type": session_type}, + expiry=expiry, + token_type=session_type, ) reset_link = hostutil.reset_link_url(token) logger.info(ADMIN_RESET_MESSAGE, message, reset_link) @@ -90,7 +91,7 @@ async def bootstrap_system( uuid=uuid7.create(), display_name=user_name or "Admin", role_uuid=role.uuid, - created_at=datetime.now(), + created_at=datetime.now(timezone.utc), visits=0, ) await globals.db.instance.create_user(user) diff --git a/passkey/config.py b/passkey/config.py new file mode 100644 index 0000000..7859330 --- /dev/null +++ b/passkey/config.py @@ -0,0 +1,7 @@ +from datetime import timedelta + +# Shared configuration constants for session management. +SESSION_LIFETIME = timedelta(hours=24) + +# Lifetime for reset links created by admins +RESET_LIFETIME = timedelta(days=14) diff --git a/passkey/db/__init__.py b/passkey/db/__init__.py index 358f3cb..8e95bbb 100644 --- a/passkey/db/__init__.py +++ b/passkey/db/__init__.py @@ -63,9 +63,27 @@ class Credential: class Session: key: bytes user_uuid: UUID - expires: datetime - info: dict - credential_uuid: UUID | None = None + credential_uuid: UUID + host: str + ip: str + user_agent: str + renewed: datetime + + def metadata(self) -> dict: + """Return session metadata for backwards compatibility.""" + return { + "ip": self.ip, + "user_agent": self.user_agent, + "renewed": self.renewed.isoformat(), + } + + +@dataclass +class ResetToken: + key: bytes + user_uuid: UUID + expiry: datetime + token_type: str @dataclass @@ -146,9 +164,11 @@ class DatabaseInterface(ABC): self, user_uuid: UUID, key: bytes, - expires: datetime, - info: dict, - credential_uuid: UUID | None = None, + credential_uuid: UUID, + host: str, + ip: str, + user_agent: str, + renewed: datetime, ) -> None: """Create a new session.""" @@ -162,14 +182,50 @@ class DatabaseInterface(ABC): @abstractmethod async def update_session( - self, key: bytes, expires: datetime, info: dict + self, + key: bytes, + *, + ip: str, + user_agent: str, + renewed: datetime, ) -> Session | None: - """Update session expiry and info.""" + """Update session metadata and touch renewed timestamp.""" + + @abstractmethod + async def set_session_host(self, key: bytes, host: str) -> None: + """Bind a session to a specific host if not already set.""" + + @abstractmethod + async def list_sessions_for_user(self, user_uuid: UUID) -> list[Session]: + """Return all sessions for a user (including other hosts).""" @abstractmethod async def cleanup(self) -> None: """Called periodically to clean up expired records.""" + @abstractmethod + async def delete_sessions_for_user(self, user_uuid: UUID) -> None: + """Delete all sessions belonging to the provided user.""" + + # Reset token operations + @abstractmethod + async def create_reset_token( + self, + user_uuid: UUID, + key: bytes, + expiry: datetime, + token_type: str, + ) -> None: + """Create a reset token for a user.""" + + @abstractmethod + async def get_reset_token(self, key: bytes) -> ResetToken | None: + """Retrieve a reset token by key.""" + + @abstractmethod + async def delete_reset_token(self, key: bytes) -> None: + """Delete a reset token by key.""" + # Organization operations @abstractmethod async def create_organization(self, org: Org) -> None: @@ -315,36 +371,41 @@ class DatabaseInterface(ABC): """Create a new user and their first credential in a transaction.""" @abstractmethod - async def get_session_context(self, session_key: bytes) -> SessionContext | None: + async def get_session_context( + self, session_key: bytes, host: str | None = None + ) -> SessionContext | None: """Get complete session context including user, organization, role, and permissions.""" - # Combined atomic operations - @abstractmethod - async def create_credential_session( - self, - user_uuid: UUID, - credential: Credential, - reset_key: bytes | None, - session_key: bytes, - session_expires: datetime, - session_info: dict, - display_name: str | None = None, - ) -> None: - """Atomically add a credential and create a session. + # Combined atomic operations + @abstractmethod + async def create_credential_session( + self, + user_uuid: UUID, + credential: Credential, + reset_key: bytes | None, + session_key: bytes, + *, + display_name: str | None = None, + host: str | None = None, + ip: str | None = None, + user_agent: str | None = None, + ) -> None: + """Atomically add a credential and create a session. - Steps (single transaction): - 1. Insert credential - 2. Optionally delete old session (e.g. reset token) if provided - 3. Optionally update user's display name - 4. Insert new session referencing the credential - 5. Update user's last_seen and increment visits (treat as a login) - """ + Steps (single transaction): + 1. Insert credential + 2. Optionally delete old reset token if provided + 3. Optionally update user's display name + 4. Insert new session referencing the credential + 5. Update user's last_seen and increment visits (treat as a login) + """ __all__ = [ "User", "Credential", "Session", + "ResetToken", "SessionContext", "Org", "Role", diff --git a/passkey/db/sql.py b/passkey/db/sql.py index 6856a92..57ef29b 100644 --- a/passkey/db/sql.py +++ b/passkey/db/sql.py @@ -6,7 +6,7 @@ for managing users and credentials in a WebAuthn authentication system. """ from contextlib import asynccontextmanager -from datetime import datetime +from datetime import datetime, timezone from uuid import UUID from sqlalchemy import ( @@ -19,18 +19,21 @@ from sqlalchemy import ( event, insert, select, + text, update, ) -from sqlalchemy.dialects.sqlite import BLOB, JSON +from sqlalchemy.dialects.sqlite import BLOB from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column +from ..config import SESSION_LIFETIME from ..globals import db from . import ( Credential, DatabaseInterface, Org, Permission, + ResetToken, Role, Session, SessionContext, @@ -40,6 +43,14 @@ from . import ( DB_PATH = "sqlite+aiosqlite:///passkey-auth.sqlite" +def _normalize_dt(value: datetime | None) -> datetime | None: + if value is None: + return None + if value.tzinfo is None: + return value.replace(tzinfo=timezone.utc) + return value.astimezone(timezone.utc) + + async def init(*args, **kwargs): db.instance = DB() await db.instance.init_db() @@ -98,8 +109,12 @@ class UserModel(Base): role_uuid: Mapped[bytes] = mapped_column( LargeBinary(16), ForeignKey("roles.uuid", ondelete="CASCADE"), nullable=False ) - created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.now) - last_seen: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=lambda: datetime.now(timezone.utc) + ) + last_seen: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) visits: Mapped[int] = mapped_column(Integer, nullable=False, default=0) def as_dataclass(self) -> User: @@ -107,8 +122,8 @@ class UserModel(Base): uuid=UUID(bytes=self.uuid), display_name=self.display_name, role_uuid=UUID(bytes=self.role_uuid), - created_at=self.created_at, - last_seen=self.last_seen, + created_at=_normalize_dt(self.created_at) or self.created_at, + last_seen=_normalize_dt(self.last_seen) or self.last_seen, visits=self.visits, ) @@ -118,7 +133,7 @@ class UserModel(Base): uuid=user.uuid.bytes, display_name=user.display_name, role_uuid=user.role_uuid.bytes, - created_at=user.created_at or datetime.now(), + created_at=user.created_at or datetime.now(timezone.utc), last_seen=user.last_seen, visits=user.visits, ) @@ -137,9 +152,29 @@ class CredentialModel(Base): 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) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=lambda: datetime.now(timezone.utc) + ) + # Columns declared timezone-aware going forward; legacy rows may still be naive in storage + last_used: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + last_verified: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + + def as_dataclass(self): # type: ignore[override] + return Credential( + uuid=UUID(bytes=self.uuid), + credential_id=self.credential_id, + user_uuid=UUID(bytes=self.user_uuid), + aaguid=UUID(bytes=self.aaguid), + public_key=self.public_key, + sign_count=self.sign_count, + created_at=_normalize_dt(self.created_at) or self.created_at, + last_used=_normalize_dt(self.last_used) or self.last_used, + last_verified=_normalize_dt(self.last_verified) or self.last_verified, + ) class SessionModel(Base): @@ -147,23 +182,31 @@ class SessionModel(Base): key: Mapped[bytes] = mapped_column(LargeBinary(16), primary_key=True) user_uuid: Mapped[bytes] = mapped_column( - LargeBinary(16), ForeignKey("users.uuid", ondelete="CASCADE") + LargeBinary(16), ForeignKey("users.uuid", ondelete="CASCADE"), nullable=False ) - credential_uuid: Mapped[bytes | None] = mapped_column( - LargeBinary(16), ForeignKey("credentials.uuid", ondelete="CASCADE") + credential_uuid: Mapped[bytes] = mapped_column( + LargeBinary(16), + ForeignKey("credentials.uuid", ondelete="CASCADE"), + nullable=False, + ) + host: Mapped[str] = mapped_column(String, nullable=False) + ip: Mapped[str] = mapped_column(String(64), nullable=False) + user_agent: Mapped[str] = mapped_column(String(512), nullable=False) + renewed: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + default=lambda: datetime.now(timezone.utc), + nullable=False, ) - expires: Mapped[datetime] = mapped_column(DateTime, nullable=False) - info: Mapped[dict] = mapped_column(JSON, default=dict) def as_dataclass(self): return Session( key=self.key, user_uuid=UUID(bytes=self.user_uuid), - credential_uuid=( - UUID(bytes=self.credential_uuid) if self.credential_uuid else None - ), - expires=self.expires, - info=self.info, + credential_uuid=UUID(bytes=self.credential_uuid), + host=self.host, + ip=self.ip, + user_agent=self.user_agent, + renewed=_normalize_dt(self.renewed) or self.renewed, ) @staticmethod @@ -171,9 +214,30 @@ class SessionModel(Base): return SessionModel( key=session.key, user_uuid=session.user_uuid.bytes, - credential_uuid=session.credential_uuid and session.credential_uuid.bytes, - expires=session.expires, - info=session.info, + credential_uuid=session.credential_uuid.bytes, + host=session.host, + ip=session.ip, + user_agent=session.user_agent, + renewed=session.renewed, + ) + + +class ResetTokenModel(Base): + __tablename__ = "reset_tokens" + + key: Mapped[bytes] = mapped_column(LargeBinary(16), primary_key=True) + user_uuid: Mapped[bytes] = mapped_column( + LargeBinary(16), ForeignKey("users.uuid", ondelete="CASCADE"), nullable=False + ) + token_type: Mapped[str] = mapped_column(String, nullable=False) + expiry: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + + def as_dataclass(self) -> ResetToken: + return ResetToken( + key=self.key, + user_uuid=UUID(bytes=self.user_uuid), + token_type=self.token_type, + expiry=_normalize_dt(self.expiry) or self.expiry, ) @@ -257,6 +321,58 @@ class DB(DatabaseInterface): """Initialize database tables.""" async with self.engine.begin() as conn: await conn.run_sync(Base.metadata.create_all) + result = await conn.execute(text("PRAGMA table_info('sessions')")) + columns = {row[1] for row in result} + expected = { + "key", + "user_uuid", + "credential_uuid", + "host", + "ip", + "user_agent", + "renewed", + } + needs_recreate = False + if columns and columns != expected: + await conn.execute(text("DROP TABLE sessions")) + needs_recreate = True + result = await conn.execute(text("PRAGMA table_info('reset_tokens')")) + if not list(result): + needs_recreate = True + if needs_recreate: + await conn.run_sync(Base.metadata.create_all) + # Run one-time migration to add UTC tzinfo to any naive datetimes + await self._migrate_naive_datetimes() + + async def _migrate_naive_datetimes(self) -> None: + """Attach UTC tzinfo to any legacy naive datetime rows. + + SQLite stores datetimes as text; older rows may have been inserted naive. + We treat naive timestamps as already UTC and rewrite them in ISO8601 with Z. + """ + # Helper SQL fragment for detecting naive (no timezone offset) for ISO strings + # We only update rows whose textual representation lacks a 'Z' or '+' sign. + async with self.session() as session: + # Users + for model, fields in [ + (UserModel, ["created_at", "last_seen"]), + (CredentialModel, ["created_at", "last_used", "last_verified"]), + (SessionModel, ["renewed"]), + (ResetTokenModel, ["expiry"]), + ]: + stmt = select(model) + result = await session.execute(stmt) + rows = result.scalars().all() + dirty = False + for row in rows: + for fname in fields: + value = getattr(row, fname, None) + if isinstance(value, datetime) and value.tzinfo is None: + setattr(row, fname, value.replace(tzinfo=timezone.utc)) + dirty = True + if dirty: + # SQLAlchemy autoflush/commit in context manager will persist + pass async def get_user_by_uuid(self, user_uuid: UUID) -> User: async with self.session() as session: @@ -409,9 +525,11 @@ class DB(DatabaseInterface): credential: Credential, reset_key: bytes | None, session_key: bytes, - session_expires: datetime, - session_info: dict, + *, display_name: str | None = None, + host: str | None = None, + ip: str | None = None, + user_agent: str | None = None, ) -> None: """Atomic credential + (optional old session delete) + (optional rename) + new session.""" async with self.session() as session: @@ -434,10 +552,10 @@ class DB(DatabaseInterface): last_verified=credential.last_verified, ) ) - # Delete old session if provided + # Delete old reset token if provided if reset_key: await session.execute( - delete(SessionModel).where(SessionModel.key == reset_key) + delete(ResetTokenModel).where(ResetTokenModel.key == reset_key) ) # Optional rename if display_name: @@ -452,8 +570,9 @@ class DB(DatabaseInterface): key=session_key, user_uuid=user_uuid.bytes, credential_uuid=credential.uuid.bytes, - expires=session_expires, - info=session_info, + host=host, + ip=ip, + user_agent=user_agent, ) ) # Login side-effects: update user analytics (last_seen + visits increment) @@ -476,17 +595,21 @@ class DB(DatabaseInterface): self, user_uuid: UUID, key: bytes, - expires: datetime, - info: dict, - credential_uuid: UUID | None = None, + credential_uuid: UUID, + host: str, + ip: str, + user_agent: str, + renewed: datetime, ) -> None: async with self.session() as session: session_model = SessionModel( key=key, user_uuid=user_uuid.bytes, - credential_uuid=credential_uuid.bytes if credential_uuid else None, - expires=expires, - info=info, + credential_uuid=credential_uuid.bytes, + host=host, + ip=ip, + user_agent=user_agent, + renewed=renewed, ) session.add(session_model) @@ -497,29 +620,88 @@ class DB(DatabaseInterface): session_model = result.scalar_one_or_none() if session_model: - return Session( - key=session_model.key, - user_uuid=UUID(bytes=session_model.user_uuid), - credential_uuid=UUID(bytes=session_model.credential_uuid) - if session_model.credential_uuid - else None, - expires=session_model.expires, - info=session_model.info or {}, - ) + return session_model.as_dataclass() return None async def delete_session(self, key: bytes) -> None: async with self.session() as session: await session.execute(delete(SessionModel).where(SessionModel.key == key)) - async def update_session(self, key: bytes, expires: datetime, info: dict) -> None: + async def delete_sessions_for_user(self, user_uuid: UUID) -> None: async with self.session() as session: await session.execute( - update(SessionModel) - .where(SessionModel.key == key) - .values(expires=expires, info=info) + delete(SessionModel).where(SessionModel.user_uuid == user_uuid.bytes) ) + async def create_reset_token( + self, + user_uuid: UUID, + key: bytes, + expiry: datetime, + token_type: str, + ) -> None: + async with self.session() as session: + model = ResetTokenModel( + key=key, + user_uuid=user_uuid.bytes, + token_type=token_type, + expiry=expiry, + ) + session.add(model) + + async def get_reset_token(self, key: bytes) -> ResetToken | None: + async with self.session() as session: + stmt = select(ResetTokenModel).where(ResetTokenModel.key == key) + result = await session.execute(stmt) + model = result.scalar_one_or_none() + return model.as_dataclass() if model else None + + async def delete_reset_token(self, key: bytes) -> None: + async with self.session() as session: + await session.execute( + delete(ResetTokenModel).where(ResetTokenModel.key == key) + ) + + async def update_session( + self, + key: bytes, + *, + ip: str, + user_agent: str, + renewed: datetime, + ) -> Session | None: + async with self.session() as session: + model = await session.get(SessionModel, key) + if not model: + return None + model.ip = ip + model.user_agent = user_agent + model.renewed = renewed + await session.flush() + return model.as_dataclass() + + async def set_session_host(self, key: bytes, host: str) -> None: + async with self.session() as session: + model = await session.get(SessionModel, key) + if model and model.host is None: + model.host = host + await session.flush() + + async def list_sessions_for_user(self, user_uuid: UUID) -> list[Session]: + async with self.session() as session: + stmt = ( + select(SessionModel) + .where(SessionModel.user_uuid == user_uuid.bytes) + .order_by(SessionModel.renewed.desc()) + ) + result = await session.execute(stmt) + session_models = [ + model + for model in result.scalars().all() + if model.key.startswith(b"sess") + ] + return [model.as_dataclass() for model in session_models] + # Organization operations async def create_organization(self, org: Org) -> None: async with self.session() as session: @@ -1115,11 +1297,18 @@ class DB(DatabaseInterface): async def cleanup(self) -> None: async with self.session() as session: - current_time = datetime.now() - stmt = delete(SessionModel).where(SessionModel.expires < current_time) - await session.execute(stmt) + current_time = datetime.now(timezone.utc) + session_threshold = current_time - SESSION_LIFETIME + await session.execute( + delete(SessionModel).where(SessionModel.renewed < session_threshold) + ) + await session.execute( + delete(ResetTokenModel).where(ResetTokenModel.expiry < current_time) + ) - async def get_session_context(self, session_key: bytes) -> SessionContext | None: + async def get_session_context( + self, session_key: bytes, host: str | None = None + ) -> SessionContext | None: """Get complete session context including user, organization, role, and permissions. Uses efficient JOINs to retrieve all related data in a single database query. @@ -1156,15 +1345,18 @@ class DB(DatabaseInterface): session_model, user_model, role_model, org_model, _ = first_row # Create the session object - session_obj = Session( - key=session_model.key, - user_uuid=UUID(bytes=session_model.user_uuid), - credential_uuid=UUID(bytes=session_model.credential_uuid) - if session_model.credential_uuid - else None, - expires=session_model.expires, - info=session_model.info or {}, - ) + if host is not None: + if session_model.host is None: + await session.execute( + update(SessionModel) + .where(SessionModel.key == session_key) + .values(host=host) + ) + session_model.host = host + elif session_model.host != host: + return None + + session_obj = session_model.as_dataclass() # Create the user object user_obj = user_model.as_dataclass() diff --git a/passkey/fastapi/admin.py b/passkey/fastapi/admin.py index 5d6817c..4bde091 100644 --- a/passkey/fastapi/admin.py +++ b/passkey/fastapi/admin.py @@ -1,12 +1,22 @@ import logging +from datetime import timezone from uuid import UUID, uuid4 from fastapi import Body, Cookie, FastAPI, HTTPException, Request from fastapi.responses import FileResponse, JSONResponse -from ..authsession import expires +from ..authsession import reset_expires from ..globals import db -from ..util import frontend, hostutil, passphrase, permutil, querysafe, tokens +from ..util import ( + frontend, + hostutil, + passphrase, + permutil, + querysafe, + tokens, + useragent, +) +from ..util.tokens import encode_session_key, session_key from . import authz app = FastAPI() @@ -24,9 +34,14 @@ async def general_exception_handler(_request, exc: Exception): @app.get("/") -async def adminapp(auth=Cookie(None)): +async def adminapp(request: Request, auth=Cookie(None, alias="__Host-auth")): try: - await authz.verify(auth, ["auth:admin", "auth:org:*"], match=permutil.has_any) + await authz.verify( + auth, + ["auth:admin", "auth:org:*"], + match=permutil.has_any, + host=request.headers.get("host"), + ) return FileResponse(frontend.file("admin/index.html")) except HTTPException as e: return FileResponse(frontend.file("index.html"), status_code=e.status_code) @@ -36,8 +51,13 @@ async def adminapp(auth=Cookie(None)): @app.get("/orgs") -async def admin_list_orgs(auth=Cookie(None)): - ctx = await authz.verify(auth, ["auth:admin", "auth:org:*"], match=permutil.has_any) +async def admin_list_orgs(request: Request, auth=Cookie(None, alias="__Host-auth")): + ctx = await authz.verify( + auth, + ["auth:admin", "auth:org:*"], + match=permutil.has_any, + host=request.headers.get("host"), + ) orgs = await db.instance.list_organizations() if "auth:admin" not in ctx.role.permissions: orgs = [o for o in orgs if f"auth:org:{o.uuid}" in ctx.role.permissions] @@ -73,8 +93,12 @@ async def admin_list_orgs(auth=Cookie(None)): @app.post("/orgs") -async def admin_create_org(payload: dict = Body(...), auth=Cookie(None)): - await authz.verify(auth, ["auth:admin"]) +async def admin_create_org( + request: Request, payload: dict = Body(...), auth=Cookie(None, alias="__Host-auth") +): + await authz.verify( + auth, ["auth:admin"], host=request.headers.get("host"), match=permutil.has_all + ) from ..db import Org as OrgDC # local import to avoid cycles from ..db import Role as RoleDC # local import to avoid cycles @@ -99,10 +123,16 @@ async def admin_create_org(payload: dict = Body(...), auth=Cookie(None)): @app.put("/orgs/{org_uuid}") async def admin_update_org( - org_uuid: UUID, payload: dict = Body(...), auth=Cookie(None) + org_uuid: UUID, + request: Request, + payload: dict = Body(...), + auth=Cookie(None, alias="__Host-auth"), ): ctx = await authz.verify( - auth, ["auth:admin", f"auth:org:{org_uuid}"], match=permutil.has_any + auth, + ["auth:admin", f"auth:org:{org_uuid}"], + match=permutil.has_any, + host=request.headers.get("host"), ) from ..db import Org as OrgDC # local import to avoid cycles @@ -129,9 +159,14 @@ async def admin_update_org( @app.delete("/orgs/{org_uuid}") -async def admin_delete_org(org_uuid: UUID, auth=Cookie(None)): +async def admin_delete_org( + org_uuid: UUID, request: Request, auth=Cookie(None, alias="__Host-auth") +): ctx = await authz.verify( - auth, ["auth:admin", f"auth:org:{org_uuid}"], match=permutil.has_any + auth, + ["auth:admin", f"auth:org:{org_uuid}"], + match=permutil.has_any, + host=request.headers.get("host"), ) if ctx.org.uuid == org_uuid: raise ValueError("Cannot delete the organization you belong to") @@ -156,18 +191,28 @@ async def admin_delete_org(org_uuid: UUID, auth=Cookie(None)): @app.post("/orgs/{org_uuid}/permission") async def admin_add_org_permission( - org_uuid: UUID, permission_id: str, auth=Cookie(None) + org_uuid: UUID, + permission_id: str, + request: Request, + auth=Cookie(None, alias="__Host-auth"), ): - await authz.verify(auth, ["auth:admin"]) + await authz.verify( + auth, ["auth:admin"], host=request.headers.get("host"), match=permutil.has_all + ) await db.instance.add_permission_to_organization(str(org_uuid), permission_id) return {"status": "ok"} @app.delete("/orgs/{org_uuid}/permission") async def admin_remove_org_permission( - org_uuid: UUID, permission_id: str, auth=Cookie(None) + org_uuid: UUID, + permission_id: str, + request: Request, + auth=Cookie(None, alias="__Host-auth"), ): - await authz.verify(auth, ["auth:admin"]) + await authz.verify( + auth, ["auth:admin"], host=request.headers.get("host"), match=permutil.has_all + ) await db.instance.remove_permission_from_organization(str(org_uuid), permission_id) return {"status": "ok"} @@ -177,10 +222,16 @@ async def admin_remove_org_permission( @app.post("/orgs/{org_uuid}/roles") async def admin_create_role( - org_uuid: UUID, payload: dict = Body(...), auth=Cookie(None) + org_uuid: UUID, + request: Request, + payload: dict = Body(...), + auth=Cookie(None, alias="__Host-auth"), ): await authz.verify( - auth, ["auth:admin", f"auth:org:{org_uuid}"], match=permutil.has_any + auth, + ["auth:admin", f"auth:org:{org_uuid}"], + match=permutil.has_any, + host=request.headers.get("host"), ) from ..db import Role as RoleDC @@ -205,11 +256,18 @@ async def admin_create_role( @app.put("/orgs/{org_uuid}/roles/{role_uuid}") async def admin_update_role( - org_uuid: UUID, role_uuid: UUID, payload: dict = Body(...), auth=Cookie(None) + org_uuid: UUID, + role_uuid: UUID, + request: Request, + payload: dict = Body(...), + auth=Cookie(None, alias="__Host-auth"), ): # Verify caller is global admin or admin of provided org ctx = await authz.verify( - auth, ["auth:admin", f"auth:org:{org_uuid}"], match=permutil.has_any + auth, + ["auth:admin", f"auth:org:{org_uuid}"], + match=permutil.has_any, + host=request.headers.get("host"), ) role = await db.instance.get_role(role_uuid) if role.org_uuid != org_uuid: @@ -247,9 +305,17 @@ async def admin_update_role( @app.delete("/orgs/{org_uuid}/roles/{role_uuid}") -async def admin_delete_role(org_uuid: UUID, role_uuid: UUID, auth=Cookie(None)): +async def admin_delete_role( + org_uuid: UUID, + role_uuid: UUID, + request: Request, + auth=Cookie(None, alias="__Host-auth"), +): ctx = await authz.verify( - auth, ["auth:admin", f"auth:org:{org_uuid}"], match=permutil.has_any + auth, + ["auth:admin", f"auth:org:{org_uuid}"], + match=permutil.has_any, + host=request.headers.get("host"), ) role = await db.instance.get_role(role_uuid) if role.org_uuid != org_uuid: @@ -268,10 +334,16 @@ async def admin_delete_role(org_uuid: UUID, role_uuid: UUID, auth=Cookie(None)): @app.post("/orgs/{org_uuid}/users") async def admin_create_user( - org_uuid: UUID, payload: dict = Body(...), auth=Cookie(None) + org_uuid: UUID, + request: Request, + payload: dict = Body(...), + auth=Cookie(None, alias="__Host-auth"), ): await authz.verify( - auth, ["auth:admin", f"auth:org:{org_uuid}"], match=permutil.has_any + auth, + ["auth:admin", f"auth:org:{org_uuid}"], + match=permutil.has_any, + host=request.headers.get("host"), ) display_name = payload.get("display_name") role_name = payload.get("role") @@ -297,10 +369,17 @@ async def admin_create_user( @app.put("/orgs/{org_uuid}/users/{user_uuid}/role") async def admin_update_user_role( - org_uuid: UUID, user_uuid: UUID, payload: dict = Body(...), auth=Cookie(None) + org_uuid: UUID, + user_uuid: UUID, + request: Request, + payload: dict = Body(...), + auth=Cookie(None, alias="__Host-auth"), ): ctx = await authz.verify( - auth, ["auth:admin", f"auth:org:{org_uuid}"], match=permutil.has_any + auth, + ["auth:admin", f"auth:org:{org_uuid}"], + match=permutil.has_any, + host=request.headers.get("host"), ) new_role = payload.get("role") if not new_role: @@ -334,7 +413,10 @@ async def admin_update_user_role( @app.post("/orgs/{org_uuid}/users/{user_uuid}/create-link") async def admin_create_user_registration_link( - org_uuid: UUID, user_uuid: UUID, request: Request, auth=Cookie(None) + org_uuid: UUID, + user_uuid: UUID, + request: Request, + auth=Cookie(None, alias="__Host-auth"), ): try: user_org, _role_name = await db.instance.get_user_organization(user_uuid) @@ -343,7 +425,10 @@ async def admin_create_user_registration_link( if user_org.uuid != org_uuid: raise HTTPException(status_code=404, detail="User not found in organization") ctx = await authz.verify( - auth, ["auth:admin", f"auth:org:{org_uuid}"], match=permutil.has_any + auth, + ["auth:admin", f"auth:org:{org_uuid}"], + match=permutil.has_any, + host=request.headers.get("host"), ) if ( "auth:admin" not in ctx.role.permissions @@ -351,20 +436,33 @@ async def admin_create_user_registration_link( ): raise HTTPException(status_code=403, detail="Insufficient permissions") token = passphrase.generate() - await db.instance.create_session( + expiry = reset_expires() + await db.instance.create_reset_token( user_uuid=user_uuid, key=tokens.reset_key(token), - expires=expires(), - info={"type": "device addition", "created_by_admin": True}, + expiry=expiry, + token_type="device addition", ) url = hostutil.reset_link_url( token, request.url.scheme, request.headers.get("host") ) - return {"url": url, "expires": expires().isoformat()} + return { + "url": url, + "expires": ( + expiry.astimezone(timezone.utc).isoformat().replace("+00:00", "Z") + if expiry.tzinfo + else expiry.replace(tzinfo=timezone.utc).isoformat().replace("+00:00", "Z") + ), + } @app.get("/orgs/{org_uuid}/users/{user_uuid}") -async def admin_get_user_detail(org_uuid: UUID, user_uuid: UUID, auth=Cookie(None)): +async def admin_get_user_detail( + org_uuid: UUID, + user_uuid: UUID, + request: Request, + auth=Cookie(None, alias="__Host-auth"), +): try: user_org, role_name = await db.instance.get_user_organization(user_uuid) except ValueError: @@ -372,7 +470,10 @@ async def admin_get_user_detail(org_uuid: UUID, user_uuid: UUID, auth=Cookie(Non if user_org.uuid != org_uuid: raise HTTPException(status_code=404, detail="User not found in organization") ctx = await authz.verify( - auth, ["auth:admin", f"auth:org:{org_uuid}"], match=permutil.has_any + auth, + ["auth:admin", f"auth:org:{org_uuid}"], + match=permutil.has_any, + host=request.headers.get("host"), ) if ( "auth:admin" not in ctx.role.permissions @@ -394,9 +495,41 @@ async def admin_get_user_detail(org_uuid: UUID, user_uuid: UUID, auth=Cookie(Non { "credential_uuid": str(c.uuid), "aaguid": aaguid_str, - "created_at": c.created_at.isoformat(), - "last_used": c.last_used.isoformat() if c.last_used else None, - "last_verified": c.last_verified.isoformat() + "created_at": ( + c.created_at.astimezone(timezone.utc) + .isoformat() + .replace("+00:00", "Z") + if c.created_at.tzinfo + else c.created_at.replace(tzinfo=timezone.utc) + .isoformat() + .replace("+00:00", "Z") + ), + "last_used": ( + c.last_used.astimezone(timezone.utc) + .isoformat() + .replace("+00:00", "Z") + if c.last_used and c.last_used.tzinfo + else ( + c.last_used.replace(tzinfo=timezone.utc) + .isoformat() + .replace("+00:00", "Z") + if c.last_used + else None + ) + ), + "last_verified": ( + c.last_verified.astimezone(timezone.utc) + .isoformat() + .replace("+00:00", "Z") + if c.last_verified and c.last_verified.tzinfo + else ( + c.last_verified.replace(tzinfo=timezone.utc) + .isoformat() + .replace("+00:00", "Z") + if c.last_verified + else None + ) + ) if c.last_verified else None, "sign_count": c.sign_count, @@ -405,21 +538,77 @@ async def admin_get_user_detail(org_uuid: UUID, user_uuid: UUID, auth=Cookie(Non from .. import aaguid as aaguid_mod aaguid_info = aaguid_mod.filter(aaguids) + + # Get sessions for the user + normalized_request_host = hostutil.normalize_host(request.headers.get("host")) + session_records = await db.instance.list_sessions_for_user(user_uuid) + current_session_key = session_key(auth) + sessions_payload: list[dict] = [] + for entry in session_records: + sessions_payload.append( + { + "id": encode_session_key(entry.key), + "host": entry.host, + "ip": entry.ip, + "user_agent": useragent.compact_user_agent(entry.user_agent), + "last_renewed": ( + entry.renewed.astimezone(timezone.utc) + .isoformat() + .replace("+00:00", "Z") + if entry.renewed.tzinfo + else entry.renewed.replace(tzinfo=timezone.utc) + .isoformat() + .replace("+00:00", "Z") + ), + "is_current": entry.key == current_session_key, + "is_current_host": bool( + normalized_request_host + and entry.host + and entry.host == normalized_request_host + ), + } + ) + return { "display_name": user.display_name, "org": {"display_name": user_org.display_name}, "role": role_name, "visits": user.visits, - "created_at": user.created_at.isoformat() if user.created_at else None, - "last_seen": user.last_seen.isoformat() if user.last_seen else None, + "created_at": ( + user.created_at.astimezone(timezone.utc).isoformat().replace("+00:00", "Z") + if user.created_at and user.created_at.tzinfo + else ( + user.created_at.replace(tzinfo=timezone.utc) + .isoformat() + .replace("+00:00", "Z") + if user.created_at + else None + ) + ), + "last_seen": ( + user.last_seen.astimezone(timezone.utc).isoformat().replace("+00:00", "Z") + if user.last_seen and user.last_seen.tzinfo + else ( + user.last_seen.replace(tzinfo=timezone.utc) + .isoformat() + .replace("+00:00", "Z") + if user.last_seen + else None + ) + ), "credentials": creds, "aaguid_info": aaguid_info, + "sessions": sessions_payload, } @app.put("/orgs/{org_uuid}/users/{user_uuid}/display-name") async def admin_update_user_display_name( - org_uuid: UUID, user_uuid: UUID, payload: dict = Body(...), auth=Cookie(None) + org_uuid: UUID, + user_uuid: UUID, + request: Request, + payload: dict = Body(...), + auth=Cookie(None, alias="__Host-auth"), ): try: user_org, _role_name = await db.instance.get_user_organization(user_uuid) @@ -428,7 +617,10 @@ async def admin_update_user_display_name( if user_org.uuid != org_uuid: raise HTTPException(status_code=404, detail="User not found in organization") ctx = await authz.verify( - auth, ["auth:admin", f"auth:org:{org_uuid}"], match=permutil.has_any + auth, + ["auth:admin", f"auth:org:{org_uuid}"], + match=permutil.has_any, + host=request.headers.get("host"), ) if ( "auth:admin" not in ctx.role.permissions @@ -446,7 +638,11 @@ async def admin_update_user_display_name( @app.delete("/orgs/{org_uuid}/users/{user_uuid}/credentials/{credential_uuid}") async def admin_delete_user_credential( - org_uuid: UUID, user_uuid: UUID, credential_uuid: UUID, auth=Cookie(None) + org_uuid: UUID, + user_uuid: UUID, + credential_uuid: UUID, + request: Request, + auth=Cookie(None, alias="__Host-auth"), ): try: user_org, _role_name = await db.instance.get_user_organization(user_uuid) @@ -455,7 +651,10 @@ async def admin_delete_user_credential( if user_org.uuid != org_uuid: raise HTTPException(status_code=404, detail="User not found in organization") ctx = await authz.verify( - auth, ["auth:admin", f"auth:org:{org_uuid}"], match=permutil.has_any + auth, + ["auth:admin", f"auth:org:{org_uuid}"], + match=permutil.has_any, + host=request.headers.get("host"), ) if ( "auth:admin" not in ctx.role.permissions @@ -470,8 +669,15 @@ async def admin_delete_user_credential( @app.get("/permissions") -async def admin_list_permissions(auth=Cookie(None)): - ctx = await authz.verify(auth, ["auth:admin", "auth:org:*"], match=permutil.has_any) +async def admin_list_permissions( + request: Request, auth=Cookie(None, alias="__Host-auth") +): + ctx = await authz.verify( + auth, + ["auth:admin", "auth:org:*"], + match=permutil.has_any, + host=request.headers.get("host"), + ) perms = await db.instance.list_permissions() # Global admins see all permissions @@ -485,8 +691,14 @@ async def admin_list_permissions(auth=Cookie(None)): @app.post("/permissions") -async def admin_create_permission(payload: dict = Body(...), auth=Cookie(None)): - await authz.verify(auth, ["auth:admin"]) +async def admin_create_permission( + request: Request, + payload: dict = Body(...), + auth=Cookie(None, alias="__Host-auth"), +): + await authz.verify( + auth, ["auth:admin"], host=request.headers.get("host"), match=permutil.has_all + ) from ..db import Permission as PermDC perm_id = payload.get("id") @@ -500,9 +712,14 @@ async def admin_create_permission(payload: dict = Body(...), auth=Cookie(None)): @app.put("/permission") async def admin_update_permission( - permission_id: str, display_name: str, auth=Cookie(None) + permission_id: str, + display_name: str, + request: Request, + auth=Cookie(None, alias="__Host-auth"), ): - await authz.verify(auth, ["auth:admin"]) + await authz.verify( + auth, ["auth:admin"], host=request.headers.get("host"), match=permutil.has_all + ) from ..db import Permission as PermDC if not display_name: @@ -515,8 +732,14 @@ async def admin_update_permission( @app.post("/permission/rename") -async def admin_rename_permission(payload: dict = Body(...), auth=Cookie(None)): - await authz.verify(auth, ["auth:admin"]) +async def admin_rename_permission( + request: Request, + payload: dict = Body(...), + auth=Cookie(None, alias="__Host-auth"), +): + await authz.verify( + auth, ["auth:admin"], host=request.headers.get("host"), match=permutil.has_all + ) old_id = payload.get("old_id") new_id = payload.get("new_id") display_name = payload.get("display_name") @@ -540,8 +763,14 @@ async def admin_rename_permission(payload: dict = Body(...), auth=Cookie(None)): @app.delete("/permission") -async def admin_delete_permission(permission_id: str, auth=Cookie(None)): - await authz.verify(auth, ["auth:admin"]) +async def admin_delete_permission( + permission_id: str, + request: Request, + auth=Cookie(None, alias="__Host-auth"), +): + await authz.verify( + auth, ["auth:admin"], host=request.headers.get("host"), match=permutil.has_all + ) querysafe.assert_safe(permission_id, field="permission_id") # Sanity check: prevent deleting critical permissions diff --git a/passkey/fastapi/api.py b/passkey/fastapi/api.py index b0af8b0..d1d3cbd 100644 --- a/passkey/fastapi/api.py +++ b/passkey/fastapi/api.py @@ -1,6 +1,6 @@ import logging from contextlib import suppress -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone from uuid import UUID from fastapi import ( @@ -16,7 +16,7 @@ from fastapi import ( from fastapi.responses import JSONResponse from fastapi.security import HTTPBearer -from passkey.util import frontend +from passkey.util import frontend, useragent from .. import aaguid from ..authsession import ( @@ -26,11 +26,12 @@ from ..authsession import ( get_reset, get_session, refresh_session_token, + session_expiry, ) from ..globals import db from ..globals import passkey as global_passkey from ..util import hostutil, passphrase, permutil, tokens -from ..util.tokens import session_key +from ..util.tokens import decode_session_key, encode_session_key, session_key from . import authz, session bearer_auth = HTTPBearer(auto_error=True) @@ -56,7 +57,10 @@ async def general_exception_handler(_request: Request, exc: Exception): @app.post("/validate") async def validate_token( - response: Response, perm: list[str] = Query([]), auth=Cookie(None) + request: Request, + response: Response, + perm: list[str] = Query([]), + auth=Cookie(None, alias="__Host-auth"), ): """Validate the current session and extend its expiry. @@ -64,13 +68,18 @@ async def validate_token( renewed max-age. This keeps active users logged in without needing a separate refresh endpoint. """ - ctx = await authz.verify(auth, perm) + ctx = await authz.verify(auth, perm, host=request.headers.get("host")) renewed = False if auth: - consumed = EXPIRES - (ctx.session.expires - datetime.now()) + current_expiry = session_expiry(ctx.session) + consumed = EXPIRES - (current_expiry - datetime.now()) if not timedelta(0) < consumed < _REFRESH_INTERVAL: try: - await refresh_session_token(auth) + await refresh_session_token( + auth, + ip=request.client.host if request.client else "", + user_agent=request.headers.get("user-agent") or "", + ) session.set_session_cookie(response, auth) renewed = True except ValueError: @@ -84,7 +93,11 @@ async def validate_token( @app.get("/forward") -async def forward_authentication(perm: list[str] = Query([]), auth=Cookie(None)): +async def forward_authentication( + request: Request, + perm: list[str] = Query([]), + auth=Cookie(None, alias="__Host-auth"), +): """Forward auth validation for Caddy/Nginx. Query Params: @@ -94,7 +107,7 @@ async def forward_authentication(perm: list[str] = Query([]), auth=Cookie(None)) Failure (unauthenticated / unauthorized): 4xx JSON body with detail. """ try: - ctx = await authz.verify(auth, perm) + ctx = await authz.verify(auth, perm, host=request.headers.get("host")) role_permissions = set(ctx.role.permissions or []) if ctx.permissions: role_permissions.update(permission.id for permission in ctx.permissions) @@ -107,7 +120,17 @@ async def forward_authentication(perm: list[str] = Query([]), auth=Cookie(None)) "Remote-Org-Name": ctx.org.display_name, "Remote-Role": str(ctx.role.uuid), "Remote-Role-Name": ctx.role.display_name, - "Remote-Session-Expires": ctx.session.expires.isoformat(), + "Remote-Session-Expires": ( + session_expiry(ctx.session) + .astimezone(timezone.utc) + .isoformat() + .replace("+00:00", "Z") + if session_expiry(ctx.session).tzinfo + else session_expiry(ctx.session) + .replace(tzinfo=timezone.utc) + .isoformat() + .replace("+00:00", "Z") + ), "Remote-Credential": str(ctx.session.credential_uuid), } return Response(status_code=204, headers=remote_headers) @@ -129,34 +152,43 @@ async def get_settings(): @app.post("/user-info") -async def api_user_info(reset: str | None = None, auth=Cookie(None)): +async def api_user_info( + request: Request, reset: str | None = None, auth=Cookie(None, alias="__Host-auth") +): authenticated = False + session_record = None + reset_token = None try: if reset: if not passphrase.is_well_formed(reset): raise ValueError("Invalid reset token") - s = await get_reset(reset) + reset_token = await get_reset(reset) + target_user_uuid = reset_token.user_uuid else: if auth is None: raise ValueError("Authentication Required") - s = await get_session(auth) + session_record = await get_session(auth, host=request.headers.get("host")) authenticated = True + target_user_uuid = session_record.user_uuid except ValueError as e: raise HTTPException(401, str(e)) - u = await db.instance.get_user_by_uuid(s.user_uuid) + u = await db.instance.get_user_by_uuid(target_user_uuid) - if not authenticated: # minimal response for reset tokens + if not authenticated and reset_token: # minimal response for reset tokens return { "authenticated": False, - "session_type": s.info.get("type"), + "session_type": reset_token.token_type, "user": {"user_uuid": str(u.uuid), "user_name": u.display_name}, } - assert authenticated and auth is not None + assert auth is not None + assert session_record is not None - ctx = await permutil.session_context(auth) - credential_ids = await db.instance.get_credentials_by_user_uuid(s.user_uuid) + ctx = await permutil.session_context(auth, request.headers.get("host")) + credential_ids = await db.instance.get_credentials_by_user_uuid( + session_record.user_uuid + ) credentials: list[dict] = [] user_aaguids: set[str] = set() for cred_id in credential_ids: @@ -170,13 +202,45 @@ async def api_user_info(reset: str | None = None, auth=Cookie(None)): { "credential_uuid": str(c.uuid), "aaguid": aaguid_str, - "created_at": c.created_at.isoformat(), - "last_used": c.last_used.isoformat() if c.last_used else None, - "last_verified": c.last_verified.isoformat() + "created_at": ( + c.created_at.astimezone(timezone.utc) + .isoformat() + .replace("+00:00", "Z") + if c.created_at.tzinfo + else c.created_at.replace(tzinfo=timezone.utc) + .isoformat() + .replace("+00:00", "Z") + ), + "last_used": ( + c.last_used.astimezone(timezone.utc) + .isoformat() + .replace("+00:00", "Z") + if c.last_used and c.last_used.tzinfo + else ( + c.last_used.replace(tzinfo=timezone.utc) + .isoformat() + .replace("+00:00", "Z") + if c.last_used + else None + ) + ), + "last_verified": ( + c.last_verified.astimezone(timezone.utc) + .isoformat() + .replace("+00:00", "Z") + if c.last_verified and c.last_verified.tzinfo + else ( + c.last_verified.replace(tzinfo=timezone.utc) + .isoformat() + .replace("+00:00", "Z") + if c.last_verified + else None + ) + ) if c.last_verified else None, "sign_count": c.sign_count, - "is_current_session": s.credential_uuid == c.uuid, + "is_current_session": session_record.credential_uuid == c.uuid, } ) credentials.sort(key=lambda cred: cred["created_at"]) @@ -204,14 +268,62 @@ async def api_user_info(reset: str | None = None, auth=Cookie(None)): p.startswith("auth:org:") for p in (role_info["permissions"] or []) ) + normalized_request_host = hostutil.normalize_host(request.headers.get("host")) + session_records = await db.instance.list_sessions_for_user(session_record.user_uuid) + current_session_key = session_key(auth) + sessions_payload: list[dict] = [] + for entry in session_records: + sessions_payload.append( + { + "id": encode_session_key(entry.key), + "host": entry.host, + "ip": entry.ip, + "user_agent": useragent.compact_user_agent(entry.user_agent), + "last_renewed": ( + entry.renewed.astimezone(timezone.utc) + .isoformat() + .replace("+00:00", "Z") + if entry.renewed.tzinfo + else entry.renewed.replace(tzinfo=timezone.utc) + .isoformat() + .replace("+00:00", "Z") + ), + "is_current": entry.key == current_session_key, + "is_current_host": bool( + normalized_request_host + and entry.host + and entry.host == normalized_request_host + ), + } + ) + return { "authenticated": True, - "session_type": s.info.get("type"), "user": { "user_uuid": str(u.uuid), "user_name": u.display_name, - "created_at": u.created_at.isoformat() if u.created_at else None, - "last_seen": u.last_seen.isoformat() if u.last_seen else None, + "created_at": ( + u.created_at.astimezone(timezone.utc).isoformat().replace("+00:00", "Z") + if u.created_at and u.created_at.tzinfo + else ( + u.created_at.replace(tzinfo=timezone.utc) + .isoformat() + .replace("+00:00", "Z") + if u.created_at + else None + ) + ), + "last_seen": ( + u.last_seen.astimezone(timezone.utc).isoformat().replace("+00:00", "Z") + if u.last_seen and u.last_seen.tzinfo + else ( + u.last_seen.replace(tzinfo=timezone.utc) + .isoformat() + .replace("+00:00", "Z") + if u.last_seen + else None + ) + ), "visits": u.visits, }, "org": org_info, @@ -221,14 +333,17 @@ async def api_user_info(reset: str | None = None, auth=Cookie(None)): "is_org_admin": is_org_admin, "credentials": credentials, "aaguid_info": aaguid_info, + "sessions": sessions_payload, } @app.put("/user/display-name") -async def user_update_display_name(payload: dict = Body(...), auth=Cookie(None)): +async def user_update_display_name( + request: Request, payload: dict = Body(...), auth=Cookie(None, alias="__Host-auth") +): if not auth: raise HTTPException(status_code=401, detail="Authentication Required") - s = await get_session(auth) + s = await get_session(auth, host=request.headers.get("host")) new_name = (payload.get("display_name") or "").strip() if not new_name: raise HTTPException(status_code=400, detail="display_name required") @@ -239,18 +354,76 @@ async def user_update_display_name(payload: dict = Body(...), auth=Cookie(None)) @app.post("/logout") -async def api_logout(response: Response, auth=Cookie(None)): +async def api_logout( + request: Request, response: Response, auth=Cookie(None, alias="__Host-auth") +): if not auth: return {"message": "Already logged out"} + try: + await get_session(auth, host=request.headers.get("host")) + except ValueError: + response.delete_cookie(session.AUTH_COOKIE_NAME, path="/") + return {"message": "Already logged out"} with suppress(Exception): await db.instance.delete_session(session_key(auth)) - response.delete_cookie("auth") + response.delete_cookie(session.AUTH_COOKIE_NAME, path="/") return {"message": "Logged out successfully"} +@app.post("/logout-all") +async def api_logout_all( + request: Request, response: Response, auth=Cookie(None, alias="__Host-auth") +): + if not auth: + return {"message": "Already logged out"} + try: + s = await get_session(auth, host=request.headers.get("host")) + except ValueError: + response.delete_cookie(session.AUTH_COOKIE_NAME, path="/") + raise HTTPException(status_code=401, detail="Session expired") + await db.instance.delete_sessions_for_user(s.user_uuid) + response.delete_cookie(session.AUTH_COOKIE_NAME, path="/") + return {"message": "Logged out from all hosts"} + + +@app.delete("/session/{session_id}") +async def api_delete_session( + request: Request, + response: Response, + session_id: str, + auth=Cookie(None, alias="__Host-auth"), +): + if not auth: + raise HTTPException(status_code=401, detail="Authentication Required") + try: + current_session = await get_session(auth, host=request.headers.get("host")) + except ValueError as exc: + response.delete_cookie(session.AUTH_COOKIE_NAME, path="/") + raise HTTPException(status_code=401, detail="Session expired") from exc + + try: + target_key = decode_session_key(session_id) + except ValueError as exc: + raise HTTPException( + status_code=400, detail="Invalid session identifier" + ) from exc + + target_session = await db.instance.get_session(target_key) + if not target_session or target_session.user_uuid != current_session.user_uuid: + raise HTTPException(status_code=404, detail="Session not found") + + await db.instance.delete_session(target_key) + current_terminated = target_key == session_key(auth) + if current_terminated: + response.delete_cookie(session.AUTH_COOKIE_NAME, path="/") + return {"status": "ok", "current_session_terminated": current_terminated} + + @app.post("/set-session") -async def api_set_session(response: Response, auth=Depends(bearer_auth)): - user = await get_session(auth.credentials) +async def api_set_session( + request: Request, response: Response, auth=Depends(bearer_auth) +): + user = await get_session(auth.credentials, host=request.headers.get("host")) session.set_session_cookie(response, auth.credentials) return { "message": "Session cookie set successfully", @@ -259,20 +432,23 @@ async def api_set_session(response: Response, auth=Depends(bearer_auth)): @app.delete("/credential/{uuid}") -async def api_delete_credential(uuid: UUID, auth: str = Cookie(None)): - await delete_credential(uuid, auth) +async def api_delete_credential( + request: Request, uuid: UUID, auth: str = Cookie(None, alias="__Host-auth") +): + await delete_credential(uuid, auth, host=request.headers.get("host")) return {"message": "Credential deleted successfully"} @app.post("/create-link") -async def api_create_link(request: Request, auth=Cookie(None)): - s = await get_session(auth) +async def api_create_link(request: Request, auth=Cookie(None, alias="__Host-auth")): + s = await get_session(auth, host=request.headers.get("host")) token = passphrase.generate() - await db.instance.create_session( + expiry = expires() + await db.instance.create_reset_token( user_uuid=s.user_uuid, key=tokens.reset_key(token), - expires=expires(), - info=session.infodict(request, "device addition"), + expiry=expiry, + token_type="device addition", ) url = hostutil.reset_link_url( token, request.url.scheme, request.headers.get("host") @@ -280,5 +456,9 @@ async def api_create_link(request: Request, auth=Cookie(None)): return { "message": "Registration link generated successfully", "url": url, - "expires": expires().isoformat(), + "expires": ( + expiry.astimezone(timezone.utc).isoformat().replace("+00:00", "Z") + if expiry.tzinfo + else expiry.replace(tzinfo=timezone.utc).isoformat().replace("+00:00", "Z") + ), } diff --git a/passkey/fastapi/authz.py b/passkey/fastapi/authz.py index bbdd83d..90920f4 100644 --- a/passkey/fastapi/authz.py +++ b/passkey/fastapi/authz.py @@ -7,7 +7,12 @@ from ..util import permutil logger = logging.getLogger(__name__) -async def verify(auth: str | None, perm: list[str], match=permutil.has_all): +async def verify( + auth: str | None, + perm: list[str], + match=permutil.has_all, + host: str | None = None, +): """Validate session token and optional list of required permissions. Returns the session context. @@ -19,7 +24,7 @@ async def verify(auth: str | None, perm: list[str], match=permutil.has_all): if not auth: raise HTTPException(status_code=401, detail="Authentication required") - ctx = await permutil.session_context(auth) + ctx = await permutil.session_context(auth, host) if not ctx: raise HTTPException(status_code=401, detail="Session not found") diff --git a/passkey/fastapi/mainapp.py b/passkey/fastapi/mainapp.py index 23069ae..e617055 100644 --- a/passkey/fastapi/mainapp.py +++ b/passkey/fastapi/mainapp.py @@ -2,7 +2,7 @@ import logging import os from contextlib import asynccontextmanager -from fastapi import Cookie, FastAPI, HTTPException +from fastapi import Cookie, FastAPI, HTTPException, Request from fastapi.responses import FileResponse, RedirectResponse from fastapi.staticfiles import StaticFiles @@ -70,8 +70,8 @@ async def admin_root_redirect(): @app.get("/admin/", include_in_schema=False) -async def admin_root(auth=Cookie(None)): - return await admin.adminapp(auth) # Delegate to handler of /auth/admin/ +async def admin_root(request: Request, auth=Cookie(None, alias="__Host-auth")): + return await admin.adminapp(request, auth) # Delegate to handler of /auth/admin/ @app.get("/{reset}") diff --git a/passkey/fastapi/reset.py b/passkey/fastapi/reset.py index 05bc675..8f71bf7 100644 --- a/passkey/fastapi/reset.py +++ b/passkey/fastapi/reset.py @@ -63,11 +63,12 @@ async def _resolve_targets(query: str | None): async def _create_reset(user, role_name: str): token = passphrase.generate() - await _g.db.instance.create_session( + expiry = _authsession.reset_expires() + await _g.db.instance.create_reset_token( user_uuid=user.uuid, key=_tokens.reset_key(token), - expires=_authsession.expires(), - info={"type": "manual reset", "role": role_name}, + expiry=expiry, + token_type="manual reset", ) return hostutil.reset_link_url(token), token diff --git a/passkey/fastapi/session.py b/passkey/fastapi/session.py index 78948a7..290a440 100644 --- a/passkey/fastapi/session.py +++ b/passkey/fastapi/session.py @@ -12,22 +12,26 @@ from fastapi import Request, Response, WebSocket from ..authsession import EXPIRES +AUTH_COOKIE_NAME = "__Host-auth" + def infodict(request: Request | WebSocket, type: str) -> dict: """Extract client information from request.""" return { - "ip": request.client.host if request.client else "", - "user_agent": request.headers.get("user-agent", "")[:500], - "type": type, + "ip": request.client.host if request.client else None, + "user_agent": request.headers.get("user-agent", "")[:500] or None, + "session_type": type, } def set_session_cookie(response: Response, token: str) -> None: """Set the session token as an HTTP-only cookie.""" response.set_cookie( - key="auth", + key=AUTH_COOKIE_NAME, value=token, max_age=int(EXPIRES.total_seconds()), httponly=True, secure=True, + path="/", + samesite="lax", ) diff --git a/passkey/fastapi/ws.py b/passkey/fastapi/ws.py index 5a7dd0f..f9277d3 100644 --- a/passkey/fastapi/ws.py +++ b/passkey/fastapi/ws.py @@ -5,9 +5,9 @@ from uuid import UUID from fastapi import Cookie, FastAPI, WebSocket, WebSocketDisconnect from webauthn.helpers.exceptions import InvalidAuthenticationResponse -from ..authsession import create_session, expires, get_reset, get_session +from ..authsession import create_session, get_reset, get_session from ..globals import db, passkey -from ..util import passphrase +from ..util import hostutil, passphrase from ..util.tokens import create_token, session_key from .session import infodict @@ -56,7 +56,10 @@ async def register_chat( @app.websocket("/register") @websocket_error_handler async def websocket_register_add( - ws: WebSocket, reset: str | None = None, name: str | None = None, auth=Cookie(None) + ws: WebSocket, + reset: str | None = None, + name: str | None = None, + auth=Cookie(None, alias="__Host-auth"), ): """Register a new credential for an existing user. @@ -65,6 +68,9 @@ async def websocket_register_add( - Reset token supplied as ?reset=... (auth cookie ignored) """ origin = ws.headers["origin"] + host = hostutil.normalize_host(ws.headers.get("host")) + if host is None: + raise ValueError("Missing host header") if reset is not None: if not passphrase.is_well_formed(reset): raise ValueError("Invalid reset token") @@ -72,7 +78,7 @@ async def websocket_register_add( else: if not auth: raise ValueError("Authentication Required") - s = await get_session(auth) + s = await get_session(auth, host=host) user_uuid = s.user_uuid # Get user information and determine effective user_name for this registration @@ -89,14 +95,16 @@ async def websocket_register_add( # Create a new session and store everything in database token = create_token() + metadata = infodict(ws, "authenticated") await db.instance.create_credential_session( # type: ignore[attr-defined] user_uuid=user_uuid, credential=credential, reset_key=(s.key if reset is not None else None), session_key=session_key(token), - session_expires=expires(), - session_info=infodict(ws, "authenticated"), display_name=user_name, + host=host, + ip=metadata.get("ip"), + user_agent=metadata.get("user_agent"), ) auth = token @@ -115,6 +123,9 @@ async def websocket_register_add( @websocket_error_handler async def websocket_authenticate(ws: WebSocket): origin = ws.headers["origin"] + host = hostutil.normalize_host(ws.headers.get("host")) + if host is None: + raise ValueError("Missing host header") options, challenge = passkey.instance.auth_generate_options() await ws.send_json(options) # Wait for the client to use his authenticator to authenticate @@ -128,10 +139,13 @@ async def websocket_authenticate(ws: WebSocket): # Create a session token for the authenticated user assert stored_cred.uuid is not None + metadata = infodict(ws, "auth") token = await create_session( user_uuid=stored_cred.user_uuid, - info=infodict(ws, "auth"), credential_uuid=stored_cred.uuid, + host=host, + ip=metadata.get("ip") or "", + user_agent=metadata.get("user_agent") or "", ) await ws.send_json( diff --git a/passkey/sansio.py b/passkey/sansio.py index 28904e6..9acb99a 100644 --- a/passkey/sansio.py +++ b/passkey/sansio.py @@ -8,7 +8,7 @@ This module provides a unified interface for WebAuthn operations including: """ import json -from datetime import datetime +from datetime import datetime, timezone from urllib.parse import urlparse from uuid import UUID @@ -163,7 +163,7 @@ class Passkey: aaguid=UUID(registration.aaguid), public_key=registration.credential_public_key, sign_count=registration.sign_count, - created_at=datetime.now(), + created_at=datetime.now(timezone.utc), ) ### Authentication Methods ### @@ -227,7 +227,7 @@ class Passkey: credential_current_sign_count=stored_cred.sign_count, ) stored_cred.sign_count = verification.new_sign_count - now = datetime.now() + now = datetime.now(timezone.utc) stored_cred.last_used = now if verification.user_verified: stored_cred.last_verified = now diff --git a/passkey/util/hostutil.py b/passkey/util/hostutil.py index 2493638..0f23746 100644 --- a/passkey/util/hostutil.py +++ b/passkey/util/hostutil.py @@ -2,7 +2,7 @@ import os from functools import lru_cache -from urllib.parse import urlparse +from urllib.parse import urlparse, urlsplit from ..globals import passkey as global_passkey @@ -70,3 +70,17 @@ def reset_link_url( def reload_config() -> None: _load_config.cache_clear() + + +def normalize_host(raw_host: str | None) -> str | None: + """Normalize a Host header or hostname by stripping port and lowercasing.""" + if not raw_host: + return None + candidate = raw_host.strip() + if not candidate: + return None + # Ensure urlsplit can parse bare hosts (prepend //) + parsed = urlsplit(candidate if "//" in candidate else f"//{candidate}") + host = parsed.hostname or parsed.path or "" + host = host.strip("[]") # Remove IPv6 brackets if present + return host.lower() if host else None diff --git a/passkey/util/permutil.py b/passkey/util/permutil.py index a6e3397..82a1a8c 100644 --- a/passkey/util/permutil.py +++ b/passkey/util/permutil.py @@ -4,6 +4,7 @@ from collections.abc import Sequence from fnmatch import fnmatchcase from ..globals import db +from .hostutil import normalize_host from .tokens import session_key __all__ = ["has_any", "has_all", "session_context"] @@ -24,5 +25,8 @@ def has_all(ctx, patterns: Sequence[str]) -> bool: return all(_match(ctx.role.permissions, patterns)) if ctx else False -async def session_context(auth: str | None): - return await db.instance.get_session_context(session_key(auth)) if auth else None +async def session_context(auth: str | None, host: str | None = None): + if not auth: + return None + normalized_host = normalize_host(host) if host else None + return await db.instance.get_session_context(session_key(auth), normalized_host) diff --git a/passkey/util/tokens.py b/passkey/util/tokens.py index bd3b09f..5a94c5c 100644 --- a/passkey/util/tokens.py +++ b/passkey/util/tokens.py @@ -15,6 +15,25 @@ def session_key(token: str) -> bytes: return b"sess" + base64.urlsafe_b64decode(token) +def encode_session_key(key: bytes) -> str: + """Encode an opaque session key for external representation.""" + return base64.urlsafe_b64encode(key).decode().rstrip("=") + + +def decode_session_key(encoded: str) -> bytes: + """Decode an opaque session key from its public representation.""" + if not encoded: + raise ValueError("Invalid session identifier") + padding = "=" * (-len(encoded) % 4) + try: + raw = base64.urlsafe_b64decode(encoded + padding) + except Exception as exc: # pragma: no cover - defensive + raise ValueError("Invalid session identifier") from exc + if not raw.startswith(b"sess"): + raise ValueError("Invalid session identifier") + return raw + + def reset_key(passphrase: str) -> bytes: if not is_well_formed(passphrase): raise ValueError( diff --git a/passkey/util/useragent.py b/passkey/util/useragent.py new file mode 100644 index 0000000..418d004 --- /dev/null +++ b/passkey/util/useragent.py @@ -0,0 +1,10 @@ +import user_agents + + +def compact_user_agent(ua: str | None) -> str: + if not ua: + return "-" + u = user_agents.parse(ua) + ver = u.browser.version_string.split(".")[0] + dev = u.device.family if u.device.family not in ["Other", "Mac"] else "" + return f"{u.browser.family}/{ver} {u.os.family} {dev}".strip() diff --git a/pyproject.toml b/pyproject.toml index 5e4f46d..93ed8a8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,6 +18,7 @@ dependencies = [ "aiosqlite>=0.19.0", "uuid7-standard>=1.0.0", "pyjwt>=2.8.0", + "user-agents>=2.2.0", ] requires-python = ">=3.10"