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.

This commit is contained in:
Leo Vasanko
2025-10-03 18:31:54 -06:00
parent 963ab06664
commit 591ea626bf
29 changed files with 1489 additions and 611 deletions

1
API.md
View File

@@ -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/set-session - Set session cookie from Authorization header
POST /auth/api/create-link - Create device addition link POST /auth/api/create-link - Create device addition link
DELETE /auth/api/credential/{uuid} - Delete specific credential 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) POST /auth/api/validate - Session validation and renewal endpoint (fetch regularly)
GET /auth/api/forward - Authentication validation for Caddy/Nginx GET /auth/api/forward - Authentication validation for Caddy/Nginx
- On success returns `204 No Content` with [user info](Headers.md) - On success returns `204 No Content` with [user info](Headers.md)

View File

@@ -2,13 +2,10 @@
<div class="app-shell"> <div class="app-shell">
<StatusMessage /> <StatusMessage />
<main class="app-main"> <main class="app-main">
<!-- Only render views after authentication status is determined -->
<template v-if="initialized"> <template v-if="initialized">
<LoginView v-if="store.currentView === 'login'" /> <LoginView v-if="store.currentView === 'login'" />
<ProfileView v-if="store.currentView === 'profile'" /> <ProfileView v-if="store.currentView === 'profile'" />
<DeviceLinkView v-if="store.currentView === 'device-link'" />
</template> </template>
<!-- Show loading state while determining auth status -->
<div v-else class="loading-container"> <div v-else class="loading-container">
<div class="loading-spinner"></div> <div class="loading-spinner"></div>
<p>Loading...</p> <p>Loading...</p>
@@ -23,14 +20,11 @@ import { useAuthStore } from '@/stores/auth'
import StatusMessage from '@/components/StatusMessage.vue' import StatusMessage from '@/components/StatusMessage.vue'
import LoginView from '@/components/LoginView.vue' import LoginView from '@/components/LoginView.vue'
import ProfileView from '@/components/ProfileView.vue' import ProfileView from '@/components/ProfileView.vue'
import DeviceLinkView from '@/components/DeviceLinkView.vue'
const store = useAuthStore() const store = useAuthStore()
const initialized = ref(false) const initialized = ref(false)
onMounted(async () => { onMounted(async () => {
// Load branding / settings first (non-blocking for auth flow)
await store.loadSettings() await store.loadSettings()
// Was an error message passed in the URL hash?
const message = location.hash.substring(1) const message = location.hash.substring(1)
if (message) { if (message) {
store.showMessage(decodeURIComponent(message), 'error') store.showMessage(decodeURIComponent(message), 'error')
@@ -48,31 +42,8 @@ onMounted(async () => {
</script> </script>
<style scoped> <style scoped>
.loading-container { .loading-container { display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100vh; gap: 1rem; }
display: flex; .loading-spinner { width: 40px; height: 40px; border: 4px solid var(--color-border); border-top: 4px solid var(--color-primary); border-radius: 50%; animation: spin 1s linear infinite; }
flex-direction: column; @keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
align-items: center; .loading-container p { color: var(--color-text-muted); margin: 0; }
justify-content: center;
height: 100vh;
gap: 1rem;
}
.loading-spinner {
width: 40px;
height: 40px;
border: 4px solid var(--color-border);
border-top: 4px solid var(--color-primary);
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.loading-container p {
color: var(--color-text-muted);
margin: 0;
}
</style> </style>

View File

@@ -3,6 +3,7 @@ import { ref } from 'vue'
import UserBasicInfo from '@/components/UserBasicInfo.vue' import UserBasicInfo from '@/components/UserBasicInfo.vue'
import CredentialList from '@/components/CredentialList.vue' import CredentialList from '@/components/CredentialList.vue'
import RegistrationLinkModal from '@/components/RegistrationLinkModal.vue' import RegistrationLinkModal from '@/components/RegistrationLinkModal.vue'
import SessionList from '@/components/SessionList.vue'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
const props = defineProps({ const props = defineProps({
@@ -57,18 +58,45 @@ function handleDelete(credential) {
/> />
<div v-else-if="userDetail?.error" class="error small">{{ userDetail.error }}</div> <div v-else-if="userDetail?.error" class="error small">{{ userDetail.error }}</div>
<template v-if="userDetail && !userDetail.error"> <template v-if="userDetail && !userDetail.error">
<h3 class="cred-title">Registered Passkeys</h3> <div class="registration-actions">
<CredentialList :credentials="userDetail.credentials" :aaguid-info="userDetail.aaguid_info" :allow-delete="true" @delete="handleDelete" /> <button
class="btn-secondary reg-token-btn"
@click="$emit('generateUserRegistrationLink', selectedUser)"
:disabled="loading"
>Generate Registration Token</button>
<p class="matrix-hint muted">
Generate a one-time registration link so this user can register or add another passkey.
Copy the link from the dialog and send it to the user, or have the user scan the QR code on their device.
</p>
</div>
<section class="section-block" data-section="registered-passkeys">
<div class="section-header">
<h2>Registered Passkeys</h2>
</div>
<div class="section-body">
<CredentialList
:credentials="userDetail.credentials"
:aaguid-info="userDetail.aaguid_info"
:allow-delete="true"
@delete="handleDelete"
/>
</div>
</section>
<SessionList
:sessions="userDetail.sessions || []"
:allow-terminate="false"
:empty-message="'This user has no active sessions.'"
:section-description="'View the active sessions for this user.'"
/>
</template> </template>
<div class="actions"> <div class="actions ancillary-actions">
<button @click="$emit('generateUserRegistrationLink', selectedUser)">Generate Registration Token</button>
<button v-if="selectedOrg" @click="$emit('openOrg', selectedOrg)" class="icon-btn" title="Back to Org"></button> <button v-if="selectedOrg" @click="$emit('openOrg', selectedOrg)" class="icon-btn" title="Back to Org"></button>
</div> </div>
<p class="matrix-hint muted">Use the token dialog to register a new credential for the member.</p>
<RegistrationLinkModal <RegistrationLinkModal
v-if="showRegModal" v-if="showRegModal"
:endpoint="`/auth/admin/orgs/${selectedUser.org_uuid}/users/${selectedUser.uuid}/create-link`" :endpoint="`/auth/admin/orgs/${selectedUser.org_uuid}/users/${selectedUser.uuid}/create-link`"
:auto-copy="false" :auto-copy="false"
:user-name="userDetail?.display_name || selectedUser.display_name"
@close="$emit('closeRegModal')" @close="$emit('closeRegModal')"
@copied="onLinkCopied" @copied="onLinkCopied"
/> />
@@ -77,9 +105,10 @@ function handleDelete(credential) {
<style scoped> <style scoped>
.user-detail { display: flex; flex-direction: column; gap: var(--space-lg); } .user-detail { display: flex; flex-direction: column; gap: var(--space-lg); }
.cred-title { font-size: 1.25rem; font-weight: 600; color: var(--color-heading); margin-bottom: var(--space-md); }
.actions { display: flex; flex-wrap: wrap; gap: var(--space-sm); align-items: center; } .actions { display: flex; flex-wrap: wrap; gap: var(--space-sm); align-items: center; }
.actions button { width: auto; } .ancillary-actions { margin-top: -0.5rem; }
.reg-token-btn { align-self: flex-start; }
.registration-actions { display: flex; flex-direction: column; gap: 0.5rem; }
.icon-btn { background: none; border: none; color: var(--color-text-muted); padding: 0.2rem; border-radius: var(--radius-sm); cursor: pointer; transition: background 0.2s ease, color 0.2s ease; } .icon-btn { background: none; border: none; color: var(--color-text-muted); padding: 0.2rem; border-radius: var(--radius-sm); cursor: pointer; transition: background 0.2s ease, color 0.2s ease; }
.icon-btn:hover { color: var(--color-heading); background: var(--color-surface-muted); } .icon-btn:hover { color: var(--color-heading); background: var(--color-surface-muted); }
.matrix-hint { font-size: 0.8rem; color: var(--color-text-muted); } .matrix-hint { font-size: 0.8rem; color: var(--color-text-muted); }

View File

@@ -1,4 +1,3 @@
/* Passkey Authentication Unified Layout */
:root { :root {
color-scheme: light dark; color-scheme: light dark;
@@ -440,60 +439,110 @@ th {
color: var(--color-text); color: var(--color-text);
} }
.credential-list { :root { --card-width: 22rem; }
.record-list,
.credential-list,
.session-list {
width: 100%; width: 100%;
display: grid; display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); grid-auto-flow: row;
grid-template-columns: repeat(auto-fit, var(--card-width));
justify-content: start;
gap: 1rem 1.25rem; gap: 1rem 1.25rem;
align-items: stretch; align-items: stretch;
margin: 0 auto;
max-width: calc(var(--card-width) * 4 + 3 * 1.25rem);
} }
.credential-item { @media (max-width: 1100px) {
.record-list,
.credential-list,
.session-list { max-width: calc(var(--card-width) * 3 + 2 * 1.25rem); }
}
@media (max-width: 720px) {
/* Keep record-list responsive, but leave credential-list and session-list as fixed-width grid */
.record-list { display: flex; flex-direction: column; max-width: 100%; }
}
.record-item,
.credential-item,
.session-item {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.75rem; gap: 0.75rem;
padding: 0.85rem 1rem; padding: 1rem;
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
border-radius: var(--radius-sm); border-radius: var(--radius-md);
background: var(--color-surface); background: var(--color-surface);
height: 100%; height: 100%;
transition: border-color 0.2s ease, box-shadow 0.2s ease, transform 0.2s ease;
position: relative;
} }
.credential-item.current-session { .record-item:hover,
border-color: var(--color-accent); .credential-item:hover,
background: rgba(37, 99, 235, 0.08); .session-item:hover {
border-color: var(--color-border-strong);
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.12);
transform: translateY(-1px);
} }
.credential-header { .record-item.is-current,
.credential-item.current-session,
.session-item.is-current { border-color: var(--color-accent); }
.item-top {
display: flex; display: flex;
align-items: center;
gap: 1rem; gap: 1rem;
align-items: flex-start;
flex-wrap: wrap;
flex: 1 1 auto;
} }
.credential-icon { .item-icon {
width: 40px; width: 40px;
height: 40px; height: 40px;
display: grid; display: grid;
place-items: center; place-items: center;
background: var(--color-surface-subtle, transparent);
border-radius: var(--radius-sm);
border: 1px solid var(--color-border);
flex-shrink: 0;
} }
.credential-info { .auth-icon {
flex: 1 1 auto; border-radius: var(--radius-sm);
} }
.credential-info h4 { .item-title {
flex: 1;
margin: 0; margin: 0;
font-size: 1rem; font-size: 1rem;
font-weight: 600; font-weight: 600;
color: var(--color-heading); color: var(--color-heading);
} }
.item-actions {
flex-shrink: 0;
display: flex;
gap: 0.5rem;
align-items: center;
}
.item-actions .badge + .btn-card-delete { margin-left: 0.25rem; }
.item-actions .badge + .badge { margin-left: 0.25rem; }
.item-details {
margin-left: calc(40px + 1rem);
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.credential-dates { .credential-dates {
display: grid; display: grid;
grid-auto-flow: row; grid-auto-flow: row;
grid-template-columns: auto 1fr; grid-template-columns: 7rem 1fr;
gap: 0.35rem 0.5rem; gap: 0.35rem 0.5rem;
font-size: 0.75rem; font-size: 0.75rem;
color: var(--color-text-muted); color: var(--color-text-muted);
@@ -509,27 +558,59 @@ th {
color: var(--color-text); color: var(--color-text);
} }
.credential-actions { .btn-card-delete { background: transparent; border: none; color: var(--color-danger); padding: 0.35rem 0.5rem; font-size: 1.05rem; line-height: 1; border-radius: var(--radius-sm); cursor: pointer; display: inline-flex; align-items: center; justify-content: center; }
margin-left: auto; .btn-card-delete:hover:not(:disabled) { background: rgba(220, 38, 38, 0.08); }
.btn-card-delete:disabled { opacity: 0.4; cursor: not-allowed; }
.session-emoji {
font-size: 1.2rem;
}
.session-details {
font-size: 0.9rem;
color: var(--color-text-muted);
}
.session-badges {
display: flex; display: flex;
align-items: center; gap: var(--space-xs);
} }
.btn-delete-credential { .badge {
background: transparent; padding: 0.2rem 0.5rem;
border: none; border-radius: var(--radius-sm);
color: var(--color-danger); font-size: 0.8rem;
padding: 0.25rem 0.35rem; font-weight: 500;
font-size: 1.05rem;
} }
.btn-delete-credential:hover:not(:disabled) { .badge-current {
background: rgba(220, 38, 38, 0.08); background: var(--color-accent);
color: var(--color-accent-contrast);
box-shadow: 0 0 0 1px var(--color-accent) inset;
} }
.btn-delete-credential:disabled { .badge:not(.badge-current) {
opacity: 0.35; background: var(--color-surface-subtle);
cursor: not-allowed; color: var(--color-text-muted);
border: 1px solid var(--color-border);
}
.session-meta-info {
font-size: 0.8rem;
color: var(--color-text-muted);
font-family: monospace;
}
.empty-state {
text-align: center;
padding: var(--space-lg);
color: var(--color-text-muted);
}
.empty-state p {
margin: 0;
} }
.user-info { .user-info {
@@ -597,7 +678,6 @@ th {
} }
} }
/* Dialog styles for auth views */
.dialog-backdrop { .dialog-backdrop {
position: fixed; position: fixed;
top: 0; top: 0;

View File

@@ -8,8 +8,8 @@
:key="credential.credential_uuid" :key="credential.credential_uuid"
:class="['credential-item', { 'current-session': credential.is_current_session } ]" :class="['credential-item', { 'current-session': credential.is_current_session } ]"
> >
<div class="credential-header"> <div class="item-top">
<div class="credential-icon"> <div class="item-icon">
<img <img
v-if="getCredentialAuthIcon(credential)" v-if="getCredentialAuthIcon(credential)"
:src="getCredentialAuthIcon(credential)" :src="getCredentialAuthIcon(credential)"
@@ -20,24 +20,28 @@
> >
<span v-else class="auth-emoji">🔑</span> <span v-else class="auth-emoji">🔑</span>
</div> </div>
<div class="credential-info"> <h4 class="item-title">{{ getCredentialAuthName(credential) }}</h4>
<h4>{{ getCredentialAuthName(credential) }}</h4> <div class="item-actions">
</div> <span v-if="credential.is_current_session" class="badge badge-current">Current</span>
<div class="credential-dates">
<span class="date-label">Created:</span>
<span class="date-value">{{ formatDate(credential.created_at) }}</span>
<span class="date-label" v-if="credential.last_used">Last used:</span>
<span class="date-value" v-if="credential.last_used">{{ formatDate(credential.last_used) }}</span>
</div>
<div class="credential-actions" v-if="allowDelete">
<button <button
v-if="allowDelete"
@click="$emit('delete', credential)" @click="$emit('delete', credential)"
class="btn-delete-credential" class="btn-card-delete"
:disabled="credential.is_current_session" :disabled="credential.is_current_session"
:title="credential.is_current_session ? 'Cannot delete current session credential' : 'Delete passkey'" :title="credential.is_current_session ? 'Cannot delete current session credential' : 'Delete passkey'"
>🗑</button> >🗑</button>
</div> </div>
</div> </div>
<div class="item-details">
<div class="credential-dates">
<span class="date-label">Created:</span>
<span class="date-value">{{ formatDate(credential.created_at) }}</span>
<span class="date-label">Last used:</span>
<span class="date-value">{{ formatDate(credential.last_used) }}</span>
<span class="date-label">Last verified:</span>
<span class="date-value">{{ formatDate(credential.last_verified) }}</span>
</div>
</div>
</div> </div>
</template> </template>
</div> </div>
@@ -67,121 +71,3 @@ const getCredentialAuthIcon = (credential) => {
return info[iconKey] || null return info[iconKey] || null
} }
</script> </script>
<style scoped>
.credential-list {
width: 100%;
margin-top: var(--space-sm);
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 1rem 1.25rem;
align-items: stretch;
}
.credential-item {
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
padding: 0.85rem 1rem;
background: var(--color-surface);
display: flex;
flex-direction: column;
gap: 0.75rem;
width: 28rem;
height: 100%;
transition: border-color 0.2s ease, box-shadow 0.2s ease, transform 0.2s ease;
}
.credential-item:hover {
border-color: var(--color-border-strong);
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.12);
transform: translateY(-1px);
}
.credential-item.current-session {
border-color: var(--color-accent);
background: rgba(37, 99, 235, 0.08);
}
.credential-header {
display: flex;
align-items: flex-start;
gap: 1rem;
flex-wrap: wrap;
flex: 1 1 auto;
}
.credential-icon {
width: 40px;
height: 40px;
display: grid;
place-items: center;
background: var(--color-surface-subtle, transparent);
border-radius: var(--radius-sm);
border: 1px solid var(--color-border);
}
.auth-icon {
border-radius: var(--radius-sm);
}
.credential-info {
flex: 1 1 150px;
min-width: 0;
}
.credential-info h4 {
margin: 0;
font-size: 1rem;
font-weight: 600;
color: var(--color-heading);
}
.credential-dates {
display: grid;
grid-auto-flow: row;
grid-template-columns: auto 1fr;
gap: 0.35rem 0.5rem;
font-size: 0.75rem;
align-items: center;
color: var(--color-text-muted);
}
.date-label {
font-weight: 600;
}
.date-value {
color: var(--color-text);
}
.credential-actions {
margin-left: auto;
display: flex;
align-items: center;
}
.btn-delete-credential {
background: none;
border: none;
cursor: pointer;
font-size: 1rem;
color: var(--color-danger);
padding: 0.25rem 0.35rem;
border-radius: var(--radius-sm);
}
.btn-delete-credential:hover:not(:disabled) {
background: rgba(220, 38, 38, 0.08);
}
.btn-delete-credential:disabled {
opacity: 0.35;
cursor: not-allowed;
}
@media (max-width: 600px) {
.credential-list {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -5,74 +5,39 @@
<h1>📱 Add Another Device</h1> <h1>📱 Add Another Device</h1>
<p class="view-lede">Generate a one-time link to set up passkeys on a new device.</p> <p class="view-lede">Generate a one-time link to set up passkeys on a new device.</p>
</header> </header>
<section class="section-block"> <RegistrationLinkModal
<div class="section-body"> inline
<div class="device-link-section"> :endpoint="'/auth/api/create-link'"
<div class="qr-container"> :user-name="userName"
<a :href="url" class="qr-link" @click="copyLink"> :auto-copy="false"
<canvas ref="qrCanvas" class="qr-code"></canvas> :prefix-copy-with-user-name="!!userName"
<p v-if="url"> show-close-in-inline
{{ url.replace(/^[^:]+:\/\//, '') }} @copied="onCopied"
</p> />
<p v-else> <div class="button-row" style="margin-top:1rem;">
<em>Generating link...</em> <button @click="authStore.currentView = 'profile'" class="btn-secondary">Back to Profile</button>
</p>
</a>
<p>
<strong>Scan and visit the URL on another device.</strong><br>
<small> Expires in 24 hours and can only be used once.</small>
</p>
</div> </div>
</div> </div>
<div class="button-row">
<button @click="authStore.currentView = 'profile'" class="btn-secondary">
Back to Profile
</button>
</div>
</div>
</section>
</div>
</section> </section>
</template> </template>
<script setup> <script setup>
import { ref, onMounted, nextTick } from 'vue' import { ref, onMounted } from 'vue'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import QRCode from 'qrcode/lib/browser' import RegistrationLinkModal from '@/components/RegistrationLinkModal.vue'
const authStore = useAuthStore() const authStore = useAuthStore()
const url = ref(null) const userName = ref(null)
const qrCanvas = ref(null) const onCopied = () => {
authStore.showMessage('Link copied to clipboard!', 'success', 2500)
const copyLink = async (event) => {
event.preventDefault()
if (url.value) {
await navigator.clipboard.writeText(url.value)
authStore.showMessage('Link copied to clipboard!')
authStore.currentView = 'profile' authStore.currentView = 'profile'
} }
}
async function drawQr() {
if (!url.value || !qrCanvas.value) return
await nextTick()
QRCode.toCanvas(qrCanvas.value, url.value, { scale: 8 }, (error) => {
if (error) console.error('Failed to generate QR code:', error)
})
}
onMounted(async () => { onMounted(async () => {
try { // Extract optional admin-provided query parameters (?user=Name&emoji=😀)
const response = await fetch('/auth/api/create-link', { method: 'POST' }) const params = new URLSearchParams(location.search)
const result = await response.json() const qUser = params.get('user')
if (result.detail) throw new Error(result.detail) if (qUser) userName.value = qUser.trim()
url.value = result.url
await drawQr()
} catch (error) {
authStore.showMessage(`Failed to create device link: ${error.message}`, 'error')
authStore.currentView = 'profile'
}
}) })
</script> </script>

View File

@@ -15,7 +15,7 @@
:created-at="authStore.userInfo.user.created_at" :created-at="authStore.userInfo.user.created_at"
:last-seen="authStore.userInfo.user.last_seen" :last-seen="authStore.userInfo.user.last_seen"
:loading="authStore.isLoading" :loading="authStore.isLoading"
update-endpoint="/auth/api/user/display-name" update-endpoint="/auth/api/user-display-name"
@saved="authStore.loadUserInfo()" @saved="authStore.loadUserInfo()"
@edit-name="openNameDialog" @edit-name="openNameDialog"
/> />
@@ -35,25 +35,19 @@
@delete="handleDelete" @delete="handleDelete"
/> />
<div class="button-row"> <div class="button-row">
<button @click="addNewCredential" class="btn-primary"> <button @click="addNewCredential" class="btn-primary">Add New Passkey</button>
Add New Passkey <button @click="showRegLink = true" class="btn-secondary">Add Another Device</button>
</button>
<button @click="authStore.currentView = 'device-link'" class="btn-secondary">
Add Another Device
</button>
</div> </div>
</div> </div>
</section> </section>
<section class="section-block"> <SessionList
<div class="button-row"> :sessions="sessions"
<button @click="logout" class="btn-danger logout-button"> :terminating-sessions="terminatingSessions"
Logout @terminate="terminateSession"
</button> section-description="Review where you're signed in and end any sessions you no longer recognize."
</div> />
</section>
<!-- Name Edit Dialog -->
<Modal v-if="showNameDialog" @close="showNameDialog = false"> <Modal v-if="showNameDialog" @close="showNameDialog = false">
<h3>Edit Display Name</h3> <h3>Edit Display Name</h3>
<form @submit.prevent="saveName" class="modal-form"> <form @submit.prevent="saveName" class="modal-form">
@@ -65,6 +59,21 @@
/> />
</form> </form>
</Modal> </Modal>
<section class="section-block">
<div class="button-row logout-row single">
<button @click="logoutEverywhere" class="btn-danger logout-button">Logout all sessions</button>
</div>
<p class="logout-note">Immediately revokes access for every device and browser signed in to your account.</p>
</section>
<RegistrationLinkModal
v-if="showRegLink"
:endpoint="'/auth/api/create-link'"
:auto-copy="false"
:prefix-copy-with-user-name="false"
@close="showRegLink = false"
@copied="showRegLink = false; authStore.showMessage('Link copied to clipboard!', 'success', 2500)"
/>
</div> </div>
</section> </section>
</template> </template>
@@ -76,35 +85,25 @@ import CredentialList from '@/components/CredentialList.vue'
import UserBasicInfo from '@/components/UserBasicInfo.vue' import UserBasicInfo from '@/components/UserBasicInfo.vue'
import Modal from '@/components/Modal.vue' import Modal from '@/components/Modal.vue'
import NameEditForm from '@/components/NameEditForm.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 { useAuthStore } from '@/stores/auth'
import passkey from '@/utils/passkey' import passkey from '@/utils/passkey'
const authStore = useAuthStore() const authStore = useAuthStore()
const updateInterval = ref(null) const updateInterval = ref(null)
const showNameDialog = ref(false) const showNameDialog = ref(false)
const showRegLink = ref(false)
const newName = ref('') const newName = ref('')
const saving = ref(false) const saving = ref(false)
watch(showNameDialog, (newVal) => { watch(showNameDialog, (newVal) => { if (newVal) newName.value = authStore.userInfo?.user?.user_name || '' })
if (newVal) {
newName.value = authStore.userInfo?.user?.user_name || ''
}
})
onMounted(() => { onMounted(() => {
updateInterval.value = setInterval(() => { updateInterval.value = setInterval(() => { if (authStore.userInfo) authStore.userInfo = { ...authStore.userInfo } }, 60000)
// Trigger Vue reactivity to update formatDate fields
if (authStore.userInfo) {
authStore.userInfo = { ...authStore.userInfo }
}
}, 60000) // Update every minute
}) })
onUnmounted(() => { onUnmounted(() => { if (updateInterval.value) clearInterval(updateInterval.value) })
if (updateInterval.value) {
clearInterval(updateInterval.value)
}
})
const addNewCredential = async () => { const addNewCredential = async () => {
try { try {
@@ -116,9 +115,7 @@ const addNewCredential = async () => {
} catch (error) { } catch (error) {
console.error('Failed to add new passkey:', error) console.error('Failed to add new passkey:', error)
authStore.showMessage(error.message, 'error') authStore.showMessage(error.message, 'error')
} finally { } finally { authStore.isLoading = false }
authStore.isLoading = false
}
} }
const handleDelete = async (credential) => { const handleDelete = async (credential) => {
@@ -128,80 +125,55 @@ const handleDelete = async (credential) => {
try { try {
await authStore.deleteCredential(credentialId) await authStore.deleteCredential(credentialId)
authStore.showMessage('Passkey deleted successfully!', 'success', 3000) authStore.showMessage('Passkey deleted successfully!', 'success', 3000)
} catch (error) { } catch (error) { authStore.showMessage(`Failed to delete passkey: ${error.message}`, '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 () => { const logoutEverywhere = async () => { await authStore.logoutEverywhere() }
await authStore.logout() const openNameDialog = () => { newName.value = authStore.userInfo?.user?.user_name || ''; showNameDialog.value = true }
}
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 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 saveName = async () => {
const name = newName.value.trim() const name = newName.value.trim()
if (!name) { if (!name) { authStore.showMessage('Name cannot be empty', 'error'); return }
authStore.showMessage('Name cannot be empty', 'error')
return
}
try { try {
saving.value = true saving.value = true
const res = await fetch('/auth/api/user/display-name', { const res = await fetch('/auth/api/user/display-name', { method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ display_name: name }) })
method: 'PUT',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ display_name: name })
})
const data = await res.json() const data = await res.json()
if (!res.ok || data.detail) throw new Error(data.detail || 'Update failed') if (!res.ok || data.detail) throw new Error(data.detail || 'Update failed')
showNameDialog.value = false showNameDialog.value = false
await authStore.loadUserInfo() await authStore.loadUserInfo()
authStore.showMessage('Name updated successfully!', 'success', 3000) authStore.showMessage('Name updated successfully!', 'success', 3000)
} catch (e) { } catch (e) { authStore.showMessage(e.message || 'Failed to update name', 'error') }
authStore.showMessage(e.message || 'Failed to update name', 'error') finally { saving.value = false }
} finally {
saving.value = false
}
} }
</script> </script>
<style scoped> <style scoped>
.view-lede { .view-lede { margin: 0; color: var(--color-text-muted); font-size: 1rem; }
margin: 0; .section-header { display: flex; flex-direction: column; gap: 0.4rem; }
color: var(--color-text-muted); .section-description { margin: 0; color: var(--color-text-muted); }
font-size: 1rem; .empty-state { margin: 0; color: var(--color-text-muted); text-align: center; padding: 1rem 0; }
} .logout-button { align-self: flex-start; }
.logout-row { gap: 1rem; }
.section-header { .logout-row.single { justify-content: flex-start; }
display: flex; .logout-note { margin: 0.75rem 0 0; color: var(--color-text-muted); font-size: 0.875rem; }
flex-direction: column; @media (max-width: 720px) { .logout-button { width: 100%; } }
gap: 0.4rem;
}
.section-description {
margin: 0;
color: var(--color-text-muted);
}
.logout-button {
align-self: flex-start;
}
@media (max-width: 720px) {
.logout-button {
width: 100%;
}
}
</style> </style>

View File

@@ -1,8 +1,10 @@
<template> <template>
<div class="dialog-overlay" @keydown.esc.prevent="$emit('close')"> <div v-if="!inline" class="dialog-overlay" @keydown.esc.prevent="$emit('close')">
<div class="device-dialog" role="dialog" aria-modal="true" aria-labelledby="regTitle"> <div class="device-dialog" role="dialog" aria-modal="true" aria-labelledby="regTitle">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:10px;"> <div class="reg-header-row">
<h2 id="regTitle" style="margin:0; font-size:1.25rem;">📱 Device Registration Link</h2> <h2 id="regTitle" class="reg-title">
📱 <span v-if="userName">Registration for {{ userName }}</span><span v-else>Device Registration Link</span>
</h2>
<button class="icon-btn" @click="$emit('close')" aria-label="Close"></button> <button class="icon-btn" @click="$emit('close')" aria-label="Close"></button>
</div> </div>
<div class="device-link-section"> <div class="device-link-section">
@@ -14,28 +16,62 @@
<div v-else> <div v-else>
<em>Generating link...</em> <em>Generating link...</em>
</div> </div>
<p> <p class="reg-help">
<strong>Scan and visit the URL on another device.</strong><br> <span v-if="userName">The user should open this link on the device where they want to register.</span>
<small> Expires in 24 hours and one-time use.</small> <span v-else>Open or scan this link on the device you wish to register to your account.</span>
<br><small>{{ expirationMessage }}</small>
</p> </p>
<div v-if="expires" style="font-size:12px; margin-top:6px;">Expires: {{ new Date(expires).toLocaleString() }}</div>
</div> </div>
</div> </div>
<div style="display:flex; justify-content:flex-end; gap:.5rem; margin-top:10px;"> <div class="reg-actions">
<button class="btn-secondary" @click="$emit('close')">Close</button> <button class="btn-secondary" @click="$emit('close')">Close</button>
<button class="btn-primary" :disabled="!url" @click="copy">Copy Link</button> <button class="btn-primary" :disabled="!url" @click="copy">Copy Link</button>
</div> </div>
</div> </div>
</div> </div>
<div v-else class="registration-inline-wrapper">
<div class="registration-inline-block section-block">
<div class="section-header">
<h2 class="inline-heading">📱 <span v-if="userName">Registration for {{ userName }}</span><span v-else>Device Registration Link</span></h2>
</div>
<div class="section-body">
<div class="device-link-section">
<div class="qr-container">
<a v-if="url" :href="url" @click.prevent="copy" class="qr-link">
<canvas ref="qrCanvas" class="qr-code"></canvas>
<p>{{ displayUrl }}</p>
</a>
<div v-else>
<em>Generating link...</em>
</div>
<p class="reg-help">
<span v-if="userName">The user should open this link on the device where they want to register.</span>
<span v-else>Open this link on the device you wish to connect with.</span>
<br><small>{{ expirationMessage }}</small>
</p>
</div>
</div>
<div class="button-row" style="margin-top:1rem;">
<button class="btn-primary" :disabled="!url" @click="copy">Copy Link</button>
<button v-if="showCloseInInline" class="btn-secondary" @click="$emit('close')">Close</button>
</div>
</div>
</div>
</div>
</template> </template>
<script setup> <script setup>
import { ref, onMounted, watch, computed, nextTick } from 'vue' import { ref, onMounted, watch, computed, nextTick } from 'vue'
import QRCode from 'qrcode/lib/browser' import QRCode from 'qrcode/lib/browser'
import { formatDate } from '@/utils/helpers'
const props = defineProps({ const props = defineProps({
endpoint: { type: String, required: true }, // POST endpoint returning {url, expires} endpoint: { type: String, required: true },
autoCopy: { type: Boolean, default: true } autoCopy: { type: Boolean, default: true },
userName: { type: String, default: null },
inline: { type: Boolean, default: false },
showCloseInInline: { type: Boolean, default: false },
prefixCopyWithUserName: { type: Boolean, default: false }
}) })
const emit = defineEmits(['close','generated','copied']) const emit = defineEmits(['close','generated','copied'])
@@ -46,6 +82,16 @@ const qrCanvas = ref(null)
const displayUrl = computed(() => url.value ? url.value.replace(/^[^:]+:\/\//,'') : '') const displayUrl = computed(() => url.value ? url.value.replace(/^[^:]+:\/\//,'') : '')
const expirationMessage = computed(() => {
if (!expires.value) return '⚠️ Expires soon and can only be used once.'
const timeStr = formatDate(expires.value)
if (timeStr.startsWith('In ')) {
return `⚠️ Expires ${timeStr.substring(3)} and can only be used once.`
} else {
return `⚠️ Expires ${timeStr} and can only be used once.`
}
})
async function fetchLink() { async function fetchLink() {
try { try {
const res = await fetch(props.endpoint, { method: 'POST' }) const res = await fetch(props.endpoint, { method: 'POST' })
@@ -73,15 +119,35 @@ async function drawQR() {
async function copy() { async function copy() {
if (!url.value) return if (!url.value) return
try { await navigator.clipboard.writeText(url.value); emit('copied', url.value); emit('close') } catch (_) { /* ignore */ } let text = url.value
if (props.prefixCopyWithUserName && props.userName) {
text = `${props.userName} ${text}`
}
try {
await navigator.clipboard.writeText(text)
emit('copied', text)
if (!props.inline) emit('close')
} catch (_) {
/* ignore */
}
} }
onMounted(fetchLink) onMounted(fetchLink)
watch(url, () => drawQR(), { flush: 'post' }) watch(url, () => drawQR(), { flush: 'post' })
</script> </script>
<style scoped> <style scoped>
.icon-btn { background:none; border:none; cursor:pointer; font-size:1rem; opacity:.6; } .icon-btn { background:none; border:none; cursor:pointer; font-size:1rem; opacity:.6; }
.icon-btn:hover { opacity:1; } .icon-btn:hover { opacity:1; }
/* Minimal extra styling; main look comes from global styles */ /* Minimal extra styling; main look comes from global styles */
.qr-link { text-decoration:none; color:inherit; } .qr-link { text-decoration:none; color:inherit; }
.reg-header-row { display:flex; justify-content:space-between; align-items:center; gap:.75rem; margin-bottom:.75rem; }
.reg-title { margin:0; font-size:1.25rem; font-weight:600; }
.device-dialog { background: var(--color-surface); padding: 1.25rem 1.25rem 1rem; border-radius: var(--radius-md); max-width:480px; width:100%; box-shadow:0 6px 28px rgba(0,0,0,.25); }
.qr-container { display:flex; flex-direction:column; align-items:center; gap:.5rem; }
.qr-code { display:block; }
.reg-help { margin-top:.5rem; margin-bottom:.75rem; font-size:.85rem; line-height:1.25rem; text-align:center; }
.reg-actions { display:flex; justify-content:flex-end; gap:.5rem; margin-top:.25rem; }
.registration-inline-block .qr-container { align-items:flex-start; }
.registration-inline-block .reg-help { text-align:left; }
</style> </style>

View File

@@ -0,0 +1,64 @@
<template>
<section class="section-block" data-component="session-list-section">
<div class="section-header">
<h2>Active Sessions</h2>
<p class="section-description">{{ sectionDescription }}</p>
</div>
<div class="section-body">
<div :class="['session-list']">
<template v-if="Array.isArray(sessions) && sessions.length">
<div
v-for="session in sessions"
:key="session.id"
:class="['session-item', { 'is-current': session.is_current }]"
>
<div class="item-top">
<div class="item-icon">
<span class="session-emoji">🌐</span>
</div>
<h4 class="item-title">{{ sessionHostLabel(session) }}</h4>
<div class="item-actions">
<span v-if="session.is_current" class="badge badge-current">Current</span>
<span v-else-if="session.is_current_host" class="badge">This host</span>
<button
v-if="allowTerminate"
@click="$emit('terminate', session)"
class="btn-card-delete"
:disabled="isTerminating(session.id)"
:title="isTerminating(session.id) ? 'Terminating...' : 'Terminate session'"
>🗑</button>
</div>
</div>
<div class="item-details">
<div class="session-details">Last used: {{ formatDate(session.last_renewed) }}</div>
<div class="session-meta-info">{{ session.user_agent }} {{ session.ip }}</div>
</div>
</div>
</template>
<div v-else class="empty-state"><p>{{ emptyMessage }}</p></div>
</div>
</div>
</section>
</template>
<script setup>
import { } from 'vue'
import { formatDate } from '@/utils/helpers'
const props = defineProps({
sessions: { type: Array, default: () => [] },
allowTerminate: { type: Boolean, default: true },
emptyMessage: { type: String, default: 'You currently have no other active sessions.' },
sectionDescription: { type: String, default: "Review where you're signed in and end any sessions you no longer recognize." },
terminatingSessions: { type: Object, default: () => ({}) }
})
const emit = defineEmits(['terminate'])
const isTerminating = (sessionId) => !!props.terminatingSessions[sessionId]
const sessionHostLabel = (session) => {
if (!session || !session.host) return 'Unbound host'
return session.host
}
</script>

View File

@@ -4,7 +4,7 @@ import { register, authenticate } from '@/utils/passkey'
export const useAuthStore = defineStore('auth', { export const useAuthStore = defineStore('auth', {
state: () => ({ state: () => ({
// 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) settings: null, // Server provided settings (/auth/settings)
isLoading: false, isLoading: false,
@@ -91,8 +91,7 @@ export const useAuthStore = defineStore('auth', {
}, },
selectView() { selectView() {
if (!this.userInfo) this.currentView = 'login' if (!this.userInfo) this.currentView = 'login'
else if (this.userInfo.authenticated) this.currentView = 'profile' else this.currentView = 'profile'
else this.currentView = 'login'
}, },
async loadUserInfo() { async loadUserInfo() {
const response = await fetch('/auth/api/user-info', { method: 'POST' }) const response = await fetch('/auth/api/user-info', { method: 'POST' })
@@ -134,9 +133,44 @@ export const useAuthStore = defineStore('auth', {
await this.loadUserInfo() 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() { async logout() {
try { 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() sessionStorage.clear()
location.reload() location.reload()
} catch (error) { } catch (error) {
@@ -144,5 +178,25 @@ export const useAuthStore = defineStore('auth', {
this.showMessage(error.message, 'error') 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')
}
},
} }
}) })

View File

@@ -5,16 +5,18 @@ export function formatDate(dateString) {
const date = new Date(dateString) const date = new Date(dateString)
const now = new Date() const now = new Date()
const diffMs = now - date const diffMs = date - now // Changed to date - now for future/past
const diffMinutes = Math.floor(diffMs / (1000 * 60)) const isFuture = diffMs > 0
const diffHours = Math.floor(diffMs / (1000 * 60 * 60)) const absDiffMs = Math.abs(diffMs)
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24)) 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 (absDiffMs < 1000 * 60) return 'Now'
if (diffMinutes === 0) return 'Just now' if (diffMinutes <= 60) return isFuture ? `In ${diffMinutes} minute${diffMinutes === 1 ? '' : 's'}` : diffMinutes === 1 ? 'a minute ago' : `${diffMinutes} minutes ago`
if (diffMinutes < 60) return 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 (diffHours < 24) return 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 diffDays === 1 ? 'a day ago' : `${diffDays} days ago` return date.toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric' })
} }
export function getCookie(name) { export function getCookie(name) {

View File

@@ -8,61 +8,107 @@ independent of any web framework:
- Credential management - Credential management
""" """
from datetime import datetime, timedelta from datetime import datetime, timezone
from uuid import UUID from uuid import UUID
from .db import Session from .config import SESSION_LIFETIME
from .db import ResetToken, Session
from .globals import db from .globals import db
from .util import hostutil
from .util.tokens import create_token, reset_key, session_key from .util.tokens import create_token, reset_key, session_key
EXPIRES = timedelta(hours=24) EXPIRES = SESSION_LIFETIME
def expires() -> datetime: 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.""" """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() token = create_token()
now = datetime.now(timezone.utc)
await db.instance.create_session( await db.instance.create_session(
user_uuid=user_uuid, user_uuid=user_uuid,
credential_uuid=credential_uuid, credential_uuid=credential_uuid,
key=session_key(token), key=session_key(token),
expires=datetime.now() + EXPIRES, host=normalized_host,
info=info, ip=ip,
user_agent=user_agent,
renewed=now,
) )
return token 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).""" """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)) record = await db.instance.get_reset_token(reset_key(token))
if not session: if not record:
raise ValueError("Invalid or expired session token") 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.""" """Validate a session token and return session data if valid."""
session = await db.instance.get_session(session_key(token)) session = await db.instance.get_session(session_key(token))
if not session: if not session:
raise ValueError("Invalid or expired session token") 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 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.""" """Refresh a session extending its expiry."""
# Get the current session session_record = await db.instance.get_session(session_key(token))
s = await db.instance.update_session( if not session_record:
session_key(token), datetime.now() + EXPIRES, {} 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 updated:
if not s:
raise ValueError("Session not found or expired") 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.""" """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) await db.instance.delete_credential(credential_uuid, s.user_uuid)

View File

@@ -8,7 +8,7 @@ generating a reset link for initial admin setup.
import asyncio import asyncio
import logging import logging
from datetime import datetime from datetime import datetime, timezone
import uuid7 import uuid7
@@ -41,11 +41,12 @@ ADMIN_RESET_MESSAGE = """\
async def _create_and_log_admin_reset_link(user_uuid, message, session_type) -> str: 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.""" """Create an admin reset link and log it with the provided message."""
token = passphrase.generate() token = passphrase.generate()
await globals.db.instance.create_session( expiry = authsession.reset_expires()
await globals.db.instance.create_reset_token(
user_uuid=user_uuid, user_uuid=user_uuid,
key=tokens.reset_key(token), key=tokens.reset_key(token),
expires=authsession.expires(), expiry=expiry,
info={"type": session_type}, token_type=session_type,
) )
reset_link = hostutil.reset_link_url(token) reset_link = hostutil.reset_link_url(token)
logger.info(ADMIN_RESET_MESSAGE, message, reset_link) logger.info(ADMIN_RESET_MESSAGE, message, reset_link)
@@ -90,7 +91,7 @@ async def bootstrap_system(
uuid=uuid7.create(), uuid=uuid7.create(),
display_name=user_name or "Admin", display_name=user_name or "Admin",
role_uuid=role.uuid, role_uuid=role.uuid,
created_at=datetime.now(), created_at=datetime.now(timezone.utc),
visits=0, visits=0,
) )
await globals.db.instance.create_user(user) await globals.db.instance.create_user(user)

7
passkey/config.py Normal file
View File

@@ -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)

View File

@@ -63,9 +63,27 @@ class Credential:
class Session: class Session:
key: bytes key: bytes
user_uuid: UUID user_uuid: UUID
expires: datetime credential_uuid: UUID
info: dict host: str
credential_uuid: UUID | None = None 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 @dataclass
@@ -146,9 +164,11 @@ class DatabaseInterface(ABC):
self, self,
user_uuid: UUID, user_uuid: UUID,
key: bytes, key: bytes,
expires: datetime, credential_uuid: UUID,
info: dict, host: str,
credential_uuid: UUID | None = None, ip: str,
user_agent: str,
renewed: datetime,
) -> None: ) -> None:
"""Create a new session.""" """Create a new session."""
@@ -162,14 +182,50 @@ class DatabaseInterface(ABC):
@abstractmethod @abstractmethod
async def update_session( async def update_session(
self, key: bytes, expires: datetime, info: dict self,
key: bytes,
*,
ip: str,
user_agent: str,
renewed: datetime,
) -> Session | None: ) -> 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 @abstractmethod
async def cleanup(self) -> None: async def cleanup(self) -> None:
"""Called periodically to clean up expired records.""" """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 # Organization operations
@abstractmethod @abstractmethod
async def create_organization(self, org: Org) -> None: async def create_organization(self, org: Org) -> None:
@@ -315,7 +371,9 @@ class DatabaseInterface(ABC):
"""Create a new user and their first credential in a transaction.""" """Create a new user and their first credential in a transaction."""
@abstractmethod @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.""" """Get complete session context including user, organization, role, and permissions."""
# Combined atomic operations # Combined atomic operations
@@ -326,15 +384,17 @@ class DatabaseInterface(ABC):
credential: Credential, credential: Credential,
reset_key: bytes | None, reset_key: bytes | None,
session_key: bytes, session_key: bytes,
session_expires: datetime, *,
session_info: dict,
display_name: str | None = None, display_name: str | None = None,
host: str | None = None,
ip: str | None = None,
user_agent: str | None = None,
) -> None: ) -> None:
"""Atomically add a credential and create a session. """Atomically add a credential and create a session.
Steps (single transaction): Steps (single transaction):
1. Insert credential 1. Insert credential
2. Optionally delete old session (e.g. reset token) if provided 2. Optionally delete old reset token if provided
3. Optionally update user's display name 3. Optionally update user's display name
4. Insert new session referencing the credential 4. Insert new session referencing the credential
5. Update user's last_seen and increment visits (treat as a login) 5. Update user's last_seen and increment visits (treat as a login)
@@ -345,6 +405,7 @@ __all__ = [
"User", "User",
"Credential", "Credential",
"Session", "Session",
"ResetToken",
"SessionContext", "SessionContext",
"Org", "Org",
"Role", "Role",

View File

@@ -6,7 +6,7 @@ for managing users and credentials in a WebAuthn authentication system.
""" """
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from datetime import datetime from datetime import datetime, timezone
from uuid import UUID from uuid import UUID
from sqlalchemy import ( from sqlalchemy import (
@@ -19,18 +19,21 @@ from sqlalchemy import (
event, event,
insert, insert,
select, select,
text,
update, 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.ext.asyncio import async_sessionmaker, create_async_engine
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
from ..config import SESSION_LIFETIME
from ..globals import db from ..globals import db
from . import ( from . import (
Credential, Credential,
DatabaseInterface, DatabaseInterface,
Org, Org,
Permission, Permission,
ResetToken,
Role, Role,
Session, Session,
SessionContext, SessionContext,
@@ -40,6 +43,14 @@ from . import (
DB_PATH = "sqlite+aiosqlite:///passkey-auth.sqlite" 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): async def init(*args, **kwargs):
db.instance = DB() db.instance = DB()
await db.instance.init_db() await db.instance.init_db()
@@ -98,8 +109,12 @@ class UserModel(Base):
role_uuid: Mapped[bytes] = mapped_column( role_uuid: Mapped[bytes] = mapped_column(
LargeBinary(16), ForeignKey("roles.uuid", ondelete="CASCADE"), nullable=False LargeBinary(16), ForeignKey("roles.uuid", ondelete="CASCADE"), nullable=False
) )
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.now) created_at: Mapped[datetime] = mapped_column(
last_seen: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) 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) visits: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
def as_dataclass(self) -> User: def as_dataclass(self) -> User:
@@ -107,8 +122,8 @@ class UserModel(Base):
uuid=UUID(bytes=self.uuid), uuid=UUID(bytes=self.uuid),
display_name=self.display_name, display_name=self.display_name,
role_uuid=UUID(bytes=self.role_uuid), role_uuid=UUID(bytes=self.role_uuid),
created_at=self.created_at, created_at=_normalize_dt(self.created_at) or self.created_at,
last_seen=self.last_seen, last_seen=_normalize_dt(self.last_seen) or self.last_seen,
visits=self.visits, visits=self.visits,
) )
@@ -118,7 +133,7 @@ class UserModel(Base):
uuid=user.uuid.bytes, uuid=user.uuid.bytes,
display_name=user.display_name, display_name=user.display_name,
role_uuid=user.role_uuid.bytes, 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, last_seen=user.last_seen,
visits=user.visits, visits=user.visits,
) )
@@ -137,9 +152,29 @@ class CredentialModel(Base):
aaguid: Mapped[bytes] = mapped_column(LargeBinary(16), nullable=False) aaguid: Mapped[bytes] = mapped_column(LargeBinary(16), nullable=False)
public_key: Mapped[bytes] = mapped_column(BLOB, nullable=False) public_key: Mapped[bytes] = mapped_column(BLOB, nullable=False)
sign_count: Mapped[int] = mapped_column(Integer, nullable=False) sign_count: Mapped[int] = mapped_column(Integer, nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.now) created_at: Mapped[datetime] = mapped_column(
last_used: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
last_verified: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) )
# 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): class SessionModel(Base):
@@ -147,23 +182,31 @@ class SessionModel(Base):
key: Mapped[bytes] = mapped_column(LargeBinary(16), primary_key=True) key: Mapped[bytes] = mapped_column(LargeBinary(16), primary_key=True)
user_uuid: Mapped[bytes] = mapped_column( 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( credential_uuid: Mapped[bytes] = mapped_column(
LargeBinary(16), ForeignKey("credentials.uuid", ondelete="CASCADE") 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): def as_dataclass(self):
return Session( return Session(
key=self.key, key=self.key,
user_uuid=UUID(bytes=self.user_uuid), user_uuid=UUID(bytes=self.user_uuid),
credential_uuid=( credential_uuid=UUID(bytes=self.credential_uuid),
UUID(bytes=self.credential_uuid) if self.credential_uuid else None host=self.host,
), ip=self.ip,
expires=self.expires, user_agent=self.user_agent,
info=self.info, renewed=_normalize_dt(self.renewed) or self.renewed,
) )
@staticmethod @staticmethod
@@ -171,9 +214,30 @@ class SessionModel(Base):
return SessionModel( return SessionModel(
key=session.key, key=session.key,
user_uuid=session.user_uuid.bytes, user_uuid=session.user_uuid.bytes,
credential_uuid=session.credential_uuid and session.credential_uuid.bytes, credential_uuid=session.credential_uuid.bytes,
expires=session.expires, host=session.host,
info=session.info, 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.""" """Initialize database tables."""
async with self.engine.begin() as conn: async with self.engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all) 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 def get_user_by_uuid(self, user_uuid: UUID) -> User:
async with self.session() as session: async with self.session() as session:
@@ -409,9 +525,11 @@ class DB(DatabaseInterface):
credential: Credential, credential: Credential,
reset_key: bytes | None, reset_key: bytes | None,
session_key: bytes, session_key: bytes,
session_expires: datetime, *,
session_info: dict,
display_name: str | None = None, display_name: str | None = None,
host: str | None = None,
ip: str | None = None,
user_agent: str | None = None,
) -> None: ) -> None:
"""Atomic credential + (optional old session delete) + (optional rename) + new session.""" """Atomic credential + (optional old session delete) + (optional rename) + new session."""
async with self.session() as session: async with self.session() as session:
@@ -434,10 +552,10 @@ class DB(DatabaseInterface):
last_verified=credential.last_verified, last_verified=credential.last_verified,
) )
) )
# Delete old session if provided # Delete old reset token if provided
if reset_key: if reset_key:
await session.execute( await session.execute(
delete(SessionModel).where(SessionModel.key == reset_key) delete(ResetTokenModel).where(ResetTokenModel.key == reset_key)
) )
# Optional rename # Optional rename
if display_name: if display_name:
@@ -452,8 +570,9 @@ class DB(DatabaseInterface):
key=session_key, key=session_key,
user_uuid=user_uuid.bytes, user_uuid=user_uuid.bytes,
credential_uuid=credential.uuid.bytes, credential_uuid=credential.uuid.bytes,
expires=session_expires, host=host,
info=session_info, ip=ip,
user_agent=user_agent,
) )
) )
# Login side-effects: update user analytics (last_seen + visits increment) # Login side-effects: update user analytics (last_seen + visits increment)
@@ -476,17 +595,21 @@ class DB(DatabaseInterface):
self, self,
user_uuid: UUID, user_uuid: UUID,
key: bytes, key: bytes,
expires: datetime, credential_uuid: UUID,
info: dict, host: str,
credential_uuid: UUID | None = None, ip: str,
user_agent: str,
renewed: datetime,
) -> None: ) -> None:
async with self.session() as session: async with self.session() as session:
session_model = SessionModel( session_model = SessionModel(
key=key, key=key,
user_uuid=user_uuid.bytes, user_uuid=user_uuid.bytes,
credential_uuid=credential_uuid.bytes if credential_uuid else None, credential_uuid=credential_uuid.bytes,
expires=expires, host=host,
info=info, ip=ip,
user_agent=user_agent,
renewed=renewed,
) )
session.add(session_model) session.add(session_model)
@@ -497,29 +620,88 @@ class DB(DatabaseInterface):
session_model = result.scalar_one_or_none() session_model = result.scalar_one_or_none()
if session_model: if session_model:
return Session( return session_model.as_dataclass()
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 None return None
async def delete_session(self, key: bytes) -> None: async def delete_session(self, key: bytes) -> None:
async with self.session() as session: async with self.session() as session:
await session.execute(delete(SessionModel).where(SessionModel.key == key)) 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: async with self.session() as session:
await session.execute( await session.execute(
update(SessionModel) delete(SessionModel).where(SessionModel.user_uuid == user_uuid.bytes)
.where(SessionModel.key == key)
.values(expires=expires, info=info)
) )
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 # Organization operations
async def create_organization(self, org: Org) -> None: async def create_organization(self, org: Org) -> None:
async with self.session() as session: async with self.session() as session:
@@ -1115,11 +1297,18 @@ class DB(DatabaseInterface):
async def cleanup(self) -> None: async def cleanup(self) -> None:
async with self.session() as session: async with self.session() as session:
current_time = datetime.now() current_time = datetime.now(timezone.utc)
stmt = delete(SessionModel).where(SessionModel.expires < current_time) session_threshold = current_time - SESSION_LIFETIME
await session.execute(stmt) 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. """Get complete session context including user, organization, role, and permissions.
Uses efficient JOINs to retrieve all related data in a single database query. 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 session_model, user_model, role_model, org_model, _ = first_row
# Create the session object # Create the session object
session_obj = Session( if host is not None:
key=session_model.key, if session_model.host is None:
user_uuid=UUID(bytes=session_model.user_uuid), await session.execute(
credential_uuid=UUID(bytes=session_model.credential_uuid) update(SessionModel)
if session_model.credential_uuid .where(SessionModel.key == session_key)
else None, .values(host=host)
expires=session_model.expires,
info=session_model.info or {},
) )
session_model.host = host
elif session_model.host != host:
return None
session_obj = session_model.as_dataclass()
# Create the user object # Create the user object
user_obj = user_model.as_dataclass() user_obj = user_model.as_dataclass()

View File

@@ -1,12 +1,22 @@
import logging import logging
from datetime import timezone
from uuid import UUID, uuid4 from uuid import UUID, uuid4
from fastapi import Body, Cookie, FastAPI, HTTPException, Request from fastapi import Body, Cookie, FastAPI, HTTPException, Request
from fastapi.responses import FileResponse, JSONResponse from fastapi.responses import FileResponse, JSONResponse
from ..authsession import expires from ..authsession import reset_expires
from ..globals import db 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 from . import authz
app = FastAPI() app = FastAPI()
@@ -24,9 +34,14 @@ async def general_exception_handler(_request, exc: Exception):
@app.get("/") @app.get("/")
async def adminapp(auth=Cookie(None)): async def adminapp(request: Request, auth=Cookie(None, alias="__Host-auth")):
try: 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")) return FileResponse(frontend.file("admin/index.html"))
except HTTPException as e: except HTTPException as e:
return FileResponse(frontend.file("index.html"), status_code=e.status_code) return FileResponse(frontend.file("index.html"), status_code=e.status_code)
@@ -36,8 +51,13 @@ async def adminapp(auth=Cookie(None)):
@app.get("/orgs") @app.get("/orgs")
async def admin_list_orgs(auth=Cookie(None)): 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) ctx = await authz.verify(
auth,
["auth:admin", "auth:org:*"],
match=permutil.has_any,
host=request.headers.get("host"),
)
orgs = await db.instance.list_organizations() orgs = await db.instance.list_organizations()
if "auth:admin" not in ctx.role.permissions: if "auth:admin" not in ctx.role.permissions:
orgs = [o for o in orgs if f"auth:org:{o.uuid}" 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") @app.post("/orgs")
async def admin_create_org(payload: dict = Body(...), auth=Cookie(None)): async def admin_create_org(
await authz.verify(auth, ["auth:admin"]) 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 Org as OrgDC # local import to avoid cycles
from ..db import Role as RoleDC # 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}") @app.put("/orgs/{org_uuid}")
async def admin_update_org( 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( 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 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}") @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( 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: if ctx.org.uuid == org_uuid:
raise ValueError("Cannot delete the organization you belong to") 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") @app.post("/orgs/{org_uuid}/permission")
async def admin_add_org_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) await db.instance.add_permission_to_organization(str(org_uuid), permission_id)
return {"status": "ok"} return {"status": "ok"}
@app.delete("/orgs/{org_uuid}/permission") @app.delete("/orgs/{org_uuid}/permission")
async def admin_remove_org_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) await db.instance.remove_permission_from_organization(str(org_uuid), permission_id)
return {"status": "ok"} return {"status": "ok"}
@@ -177,10 +222,16 @@ async def admin_remove_org_permission(
@app.post("/orgs/{org_uuid}/roles") @app.post("/orgs/{org_uuid}/roles")
async def admin_create_role( 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( 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 from ..db import Role as RoleDC
@@ -205,11 +256,18 @@ async def admin_create_role(
@app.put("/orgs/{org_uuid}/roles/{role_uuid}") @app.put("/orgs/{org_uuid}/roles/{role_uuid}")
async def admin_update_role( 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 # Verify caller is global admin or admin of provided org
ctx = await authz.verify( 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) role = await db.instance.get_role(role_uuid)
if role.org_uuid != org_uuid: if role.org_uuid != org_uuid:
@@ -247,9 +305,17 @@ async def admin_update_role(
@app.delete("/orgs/{org_uuid}/roles/{role_uuid}") @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( 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) role = await db.instance.get_role(role_uuid)
if role.org_uuid != org_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") @app.post("/orgs/{org_uuid}/users")
async def admin_create_user( 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( 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") display_name = payload.get("display_name")
role_name = payload.get("role") role_name = payload.get("role")
@@ -297,10 +369,17 @@ async def admin_create_user(
@app.put("/orgs/{org_uuid}/users/{user_uuid}/role") @app.put("/orgs/{org_uuid}/users/{user_uuid}/role")
async def admin_update_user_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( 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") new_role = payload.get("role")
if not new_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") @app.post("/orgs/{org_uuid}/users/{user_uuid}/create-link")
async def admin_create_user_registration_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: try:
user_org, _role_name = await db.instance.get_user_organization(user_uuid) 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: if user_org.uuid != org_uuid:
raise HTTPException(status_code=404, detail="User not found in organization") raise HTTPException(status_code=404, detail="User not found in organization")
ctx = await authz.verify( 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 ( if (
"auth:admin" not in ctx.role.permissions "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") raise HTTPException(status_code=403, detail="Insufficient permissions")
token = passphrase.generate() token = passphrase.generate()
await db.instance.create_session( expiry = reset_expires()
await db.instance.create_reset_token(
user_uuid=user_uuid, user_uuid=user_uuid,
key=tokens.reset_key(token), key=tokens.reset_key(token),
expires=expires(), expiry=expiry,
info={"type": "device addition", "created_by_admin": True}, token_type="device addition",
) )
url = hostutil.reset_link_url( url = hostutil.reset_link_url(
token, request.url.scheme, request.headers.get("host") 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}") @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: try:
user_org, role_name = await db.instance.get_user_organization(user_uuid) user_org, role_name = await db.instance.get_user_organization(user_uuid)
except ValueError: 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: if user_org.uuid != org_uuid:
raise HTTPException(status_code=404, detail="User not found in organization") raise HTTPException(status_code=404, detail="User not found in organization")
ctx = await authz.verify( 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 ( if (
"auth:admin" not in ctx.role.permissions "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), "credential_uuid": str(c.uuid),
"aaguid": aaguid_str, "aaguid": aaguid_str,
"created_at": c.created_at.isoformat(), "created_at": (
"last_used": c.last_used.isoformat() if c.last_used else None, c.created_at.astimezone(timezone.utc)
"last_verified": c.last_verified.isoformat() .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 if c.last_verified
else None, else None,
"sign_count": c.sign_count, "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 from .. import aaguid as aaguid_mod
aaguid_info = aaguid_mod.filter(aaguids) 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 { return {
"display_name": user.display_name, "display_name": user.display_name,
"org": {"display_name": user_org.display_name}, "org": {"display_name": user_org.display_name},
"role": role_name, "role": role_name,
"visits": user.visits, "visits": user.visits,
"created_at": user.created_at.isoformat() if user.created_at else None, "created_at": (
"last_seen": user.last_seen.isoformat() if user.last_seen else None, 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, "credentials": creds,
"aaguid_info": aaguid_info, "aaguid_info": aaguid_info,
"sessions": sessions_payload,
} }
@app.put("/orgs/{org_uuid}/users/{user_uuid}/display-name") @app.put("/orgs/{org_uuid}/users/{user_uuid}/display-name")
async def admin_update_user_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: try:
user_org, _role_name = await db.instance.get_user_organization(user_uuid) 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: if user_org.uuid != org_uuid:
raise HTTPException(status_code=404, detail="User not found in organization") raise HTTPException(status_code=404, detail="User not found in organization")
ctx = await authz.verify( 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 ( if (
"auth:admin" not in ctx.role.permissions "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}") @app.delete("/orgs/{org_uuid}/users/{user_uuid}/credentials/{credential_uuid}")
async def admin_delete_user_credential( 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: try:
user_org, _role_name = await db.instance.get_user_organization(user_uuid) 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: if user_org.uuid != org_uuid:
raise HTTPException(status_code=404, detail="User not found in organization") raise HTTPException(status_code=404, detail="User not found in organization")
ctx = await authz.verify( 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 ( if (
"auth:admin" not in ctx.role.permissions "auth:admin" not in ctx.role.permissions
@@ -470,8 +669,15 @@ async def admin_delete_user_credential(
@app.get("/permissions") @app.get("/permissions")
async def admin_list_permissions(auth=Cookie(None)): async def admin_list_permissions(
ctx = await authz.verify(auth, ["auth:admin", "auth:org:*"], match=permutil.has_any) 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() perms = await db.instance.list_permissions()
# Global admins see all permissions # Global admins see all permissions
@@ -485,8 +691,14 @@ async def admin_list_permissions(auth=Cookie(None)):
@app.post("/permissions") @app.post("/permissions")
async def admin_create_permission(payload: dict = Body(...), auth=Cookie(None)): async def admin_create_permission(
await authz.verify(auth, ["auth:admin"]) 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 from ..db import Permission as PermDC
perm_id = payload.get("id") perm_id = payload.get("id")
@@ -500,9 +712,14 @@ async def admin_create_permission(payload: dict = Body(...), auth=Cookie(None)):
@app.put("/permission") @app.put("/permission")
async def admin_update_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 from ..db import Permission as PermDC
if not display_name: if not display_name:
@@ -515,8 +732,14 @@ async def admin_update_permission(
@app.post("/permission/rename") @app.post("/permission/rename")
async def admin_rename_permission(payload: dict = Body(...), auth=Cookie(None)): async def admin_rename_permission(
await authz.verify(auth, ["auth:admin"]) 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") old_id = payload.get("old_id")
new_id = payload.get("new_id") new_id = payload.get("new_id")
display_name = payload.get("display_name") display_name = payload.get("display_name")
@@ -540,8 +763,14 @@ async def admin_rename_permission(payload: dict = Body(...), auth=Cookie(None)):
@app.delete("/permission") @app.delete("/permission")
async def admin_delete_permission(permission_id: str, auth=Cookie(None)): async def admin_delete_permission(
await authz.verify(auth, ["auth:admin"]) 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") querysafe.assert_safe(permission_id, field="permission_id")
# Sanity check: prevent deleting critical permissions # Sanity check: prevent deleting critical permissions

View File

@@ -1,6 +1,6 @@
import logging import logging
from contextlib import suppress from contextlib import suppress
from datetime import datetime, timedelta from datetime import datetime, timedelta, timezone
from uuid import UUID from uuid import UUID
from fastapi import ( from fastapi import (
@@ -16,7 +16,7 @@ from fastapi import (
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from fastapi.security import HTTPBearer from fastapi.security import HTTPBearer
from passkey.util import frontend from passkey.util import frontend, useragent
from .. import aaguid from .. import aaguid
from ..authsession import ( from ..authsession import (
@@ -26,11 +26,12 @@ from ..authsession import (
get_reset, get_reset,
get_session, get_session,
refresh_session_token, refresh_session_token,
session_expiry,
) )
from ..globals import db from ..globals import db
from ..globals import passkey as global_passkey from ..globals import passkey as global_passkey
from ..util import hostutil, passphrase, permutil, tokens 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 from . import authz, session
bearer_auth = HTTPBearer(auto_error=True) bearer_auth = HTTPBearer(auto_error=True)
@@ -56,7 +57,10 @@ async def general_exception_handler(_request: Request, exc: Exception):
@app.post("/validate") @app.post("/validate")
async def validate_token( 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. """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 renewed max-age. This keeps active users logged in without needing a separate
refresh endpoint. refresh endpoint.
""" """
ctx = await authz.verify(auth, perm) ctx = await authz.verify(auth, perm, host=request.headers.get("host"))
renewed = False renewed = False
if auth: 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: if not timedelta(0) < consumed < _REFRESH_INTERVAL:
try: 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) session.set_session_cookie(response, auth)
renewed = True renewed = True
except ValueError: except ValueError:
@@ -84,7 +93,11 @@ async def validate_token(
@app.get("/forward") @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. """Forward auth validation for Caddy/Nginx.
Query Params: 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. Failure (unauthenticated / unauthorized): 4xx JSON body with detail.
""" """
try: 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 []) role_permissions = set(ctx.role.permissions or [])
if ctx.permissions: if ctx.permissions:
role_permissions.update(permission.id for permission in 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-Org-Name": ctx.org.display_name,
"Remote-Role": str(ctx.role.uuid), "Remote-Role": str(ctx.role.uuid),
"Remote-Role-Name": ctx.role.display_name, "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), "Remote-Credential": str(ctx.session.credential_uuid),
} }
return Response(status_code=204, headers=remote_headers) return Response(status_code=204, headers=remote_headers)
@@ -129,34 +152,43 @@ async def get_settings():
@app.post("/user-info") @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 authenticated = False
session_record = None
reset_token = None
try: try:
if reset: if reset:
if not passphrase.is_well_formed(reset): if not passphrase.is_well_formed(reset):
raise ValueError("Invalid reset token") raise ValueError("Invalid reset token")
s = await get_reset(reset) reset_token = await get_reset(reset)
target_user_uuid = reset_token.user_uuid
else: else:
if auth is None: if auth is None:
raise ValueError("Authentication Required") raise ValueError("Authentication Required")
s = await get_session(auth) session_record = await get_session(auth, host=request.headers.get("host"))
authenticated = True authenticated = True
target_user_uuid = session_record.user_uuid
except ValueError as e: except ValueError as e:
raise HTTPException(401, str(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 { return {
"authenticated": False, "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}, "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) ctx = await permutil.session_context(auth, request.headers.get("host"))
credential_ids = await db.instance.get_credentials_by_user_uuid(s.user_uuid) credential_ids = await db.instance.get_credentials_by_user_uuid(
session_record.user_uuid
)
credentials: list[dict] = [] credentials: list[dict] = []
user_aaguids: set[str] = set() user_aaguids: set[str] = set()
for cred_id in credential_ids: 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), "credential_uuid": str(c.uuid),
"aaguid": aaguid_str, "aaguid": aaguid_str,
"created_at": c.created_at.isoformat(), "created_at": (
"last_used": c.last_used.isoformat() if c.last_used else None, c.created_at.astimezone(timezone.utc)
"last_verified": c.last_verified.isoformat() .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 if c.last_verified
else None, else None,
"sign_count": c.sign_count, "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"]) 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 []) 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 { return {
"authenticated": True, "authenticated": True,
"session_type": s.info.get("type"),
"user": { "user": {
"user_uuid": str(u.uuid), "user_uuid": str(u.uuid),
"user_name": u.display_name, "user_name": u.display_name,
"created_at": u.created_at.isoformat() if u.created_at else None, "created_at": (
"last_seen": u.last_seen.isoformat() if u.last_seen else None, 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, "visits": u.visits,
}, },
"org": org_info, "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, "is_org_admin": is_org_admin,
"credentials": credentials, "credentials": credentials,
"aaguid_info": aaguid_info, "aaguid_info": aaguid_info,
"sessions": sessions_payload,
} }
@app.put("/user/display-name") @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: if not auth:
raise HTTPException(status_code=401, detail="Authentication Required") 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() new_name = (payload.get("display_name") or "").strip()
if not new_name: if not new_name:
raise HTTPException(status_code=400, detail="display_name required") 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") @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: if not auth:
return {"message": "Already logged out"} 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): with suppress(Exception):
await db.instance.delete_session(session_key(auth)) 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"} 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") @app.post("/set-session")
async def api_set_session(response: Response, auth=Depends(bearer_auth)): async def api_set_session(
user = await get_session(auth.credentials) 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) session.set_session_cookie(response, auth.credentials)
return { return {
"message": "Session cookie set successfully", "message": "Session cookie set successfully",
@@ -259,20 +432,23 @@ async def api_set_session(response: Response, auth=Depends(bearer_auth)):
@app.delete("/credential/{uuid}") @app.delete("/credential/{uuid}")
async def api_delete_credential(uuid: UUID, auth: str = Cookie(None)): async def api_delete_credential(
await delete_credential(uuid, auth) 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"} return {"message": "Credential deleted successfully"}
@app.post("/create-link") @app.post("/create-link")
async def api_create_link(request: Request, auth=Cookie(None)): async def api_create_link(request: Request, auth=Cookie(None, alias="__Host-auth")):
s = await get_session(auth) s = await get_session(auth, host=request.headers.get("host"))
token = passphrase.generate() token = passphrase.generate()
await db.instance.create_session( expiry = expires()
await db.instance.create_reset_token(
user_uuid=s.user_uuid, user_uuid=s.user_uuid,
key=tokens.reset_key(token), key=tokens.reset_key(token),
expires=expires(), expiry=expiry,
info=session.infodict(request, "device addition"), token_type="device addition",
) )
url = hostutil.reset_link_url( url = hostutil.reset_link_url(
token, request.url.scheme, request.headers.get("host") token, request.url.scheme, request.headers.get("host")
@@ -280,5 +456,9 @@ async def api_create_link(request: Request, auth=Cookie(None)):
return { return {
"message": "Registration link generated successfully", "message": "Registration link generated successfully",
"url": url, "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")
),
} }

View File

@@ -7,7 +7,12 @@ from ..util import permutil
logger = logging.getLogger(__name__) 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. """Validate session token and optional list of required permissions.
Returns the session context. Returns the session context.
@@ -19,7 +24,7 @@ async def verify(auth: str | None, perm: list[str], match=permutil.has_all):
if not auth: if not auth:
raise HTTPException(status_code=401, detail="Authentication required") raise HTTPException(status_code=401, detail="Authentication required")
ctx = await permutil.session_context(auth) ctx = await permutil.session_context(auth, host)
if not ctx: if not ctx:
raise HTTPException(status_code=401, detail="Session not found") raise HTTPException(status_code=401, detail="Session not found")

View File

@@ -2,7 +2,7 @@ import logging
import os import os
from contextlib import asynccontextmanager 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.responses import FileResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
@@ -70,8 +70,8 @@ async def admin_root_redirect():
@app.get("/admin/", include_in_schema=False) @app.get("/admin/", include_in_schema=False)
async def admin_root(auth=Cookie(None)): async def admin_root(request: Request, auth=Cookie(None, alias="__Host-auth")):
return await admin.adminapp(auth) # Delegate to handler of /auth/admin/ return await admin.adminapp(request, auth) # Delegate to handler of /auth/admin/
@app.get("/{reset}") @app.get("/{reset}")

View File

@@ -63,11 +63,12 @@ async def _resolve_targets(query: str | None):
async def _create_reset(user, role_name: str): async def _create_reset(user, role_name: str):
token = passphrase.generate() token = passphrase.generate()
await _g.db.instance.create_session( expiry = _authsession.reset_expires()
await _g.db.instance.create_reset_token(
user_uuid=user.uuid, user_uuid=user.uuid,
key=_tokens.reset_key(token), key=_tokens.reset_key(token),
expires=_authsession.expires(), expiry=expiry,
info={"type": "manual reset", "role": role_name}, token_type="manual reset",
) )
return hostutil.reset_link_url(token), token return hostutil.reset_link_url(token), token

View File

@@ -12,22 +12,26 @@ from fastapi import Request, Response, WebSocket
from ..authsession import EXPIRES from ..authsession import EXPIRES
AUTH_COOKIE_NAME = "__Host-auth"
def infodict(request: Request | WebSocket, type: str) -> dict: def infodict(request: Request | WebSocket, type: str) -> dict:
"""Extract client information from request.""" """Extract client information from request."""
return { return {
"ip": request.client.host if request.client else "", "ip": request.client.host if request.client else None,
"user_agent": request.headers.get("user-agent", "")[:500], "user_agent": request.headers.get("user-agent", "")[:500] or None,
"type": type, "session_type": type,
} }
def set_session_cookie(response: Response, token: str) -> None: def set_session_cookie(response: Response, token: str) -> None:
"""Set the session token as an HTTP-only cookie.""" """Set the session token as an HTTP-only cookie."""
response.set_cookie( response.set_cookie(
key="auth", key=AUTH_COOKIE_NAME,
value=token, value=token,
max_age=int(EXPIRES.total_seconds()), max_age=int(EXPIRES.total_seconds()),
httponly=True, httponly=True,
secure=True, secure=True,
path="/",
samesite="lax",
) )

View File

@@ -5,9 +5,9 @@ from uuid import UUID
from fastapi import Cookie, FastAPI, WebSocket, WebSocketDisconnect from fastapi import Cookie, FastAPI, WebSocket, WebSocketDisconnect
from webauthn.helpers.exceptions import InvalidAuthenticationResponse 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 ..globals import db, passkey
from ..util import passphrase from ..util import hostutil, passphrase
from ..util.tokens import create_token, session_key from ..util.tokens import create_token, session_key
from .session import infodict from .session import infodict
@@ -56,7 +56,10 @@ async def register_chat(
@app.websocket("/register") @app.websocket("/register")
@websocket_error_handler @websocket_error_handler
async def websocket_register_add( 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. """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) - Reset token supplied as ?reset=... (auth cookie ignored)
""" """
origin = ws.headers["origin"] 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 reset is not None:
if not passphrase.is_well_formed(reset): if not passphrase.is_well_formed(reset):
raise ValueError("Invalid reset token") raise ValueError("Invalid reset token")
@@ -72,7 +78,7 @@ async def websocket_register_add(
else: else:
if not auth: if not auth:
raise ValueError("Authentication Required") raise ValueError("Authentication Required")
s = await get_session(auth) s = await get_session(auth, host=host)
user_uuid = s.user_uuid user_uuid = s.user_uuid
# Get user information and determine effective user_name for this registration # 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 # Create a new session and store everything in database
token = create_token() token = create_token()
metadata = infodict(ws, "authenticated")
await db.instance.create_credential_session( # type: ignore[attr-defined] await db.instance.create_credential_session( # type: ignore[attr-defined]
user_uuid=user_uuid, user_uuid=user_uuid,
credential=credential, credential=credential,
reset_key=(s.key if reset is not None else None), reset_key=(s.key if reset is not None else None),
session_key=session_key(token), session_key=session_key(token),
session_expires=expires(),
session_info=infodict(ws, "authenticated"),
display_name=user_name, display_name=user_name,
host=host,
ip=metadata.get("ip"),
user_agent=metadata.get("user_agent"),
) )
auth = token auth = token
@@ -115,6 +123,9 @@ async def websocket_register_add(
@websocket_error_handler @websocket_error_handler
async def websocket_authenticate(ws: WebSocket): async def websocket_authenticate(ws: WebSocket):
origin = ws.headers["origin"] 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() options, challenge = passkey.instance.auth_generate_options()
await ws.send_json(options) await ws.send_json(options)
# Wait for the client to use his authenticator to authenticate # Wait for the client to use his authenticator to authenticate
@@ -128,10 +139,13 @@ async def websocket_authenticate(ws: WebSocket):
# Create a session token for the authenticated user # Create a session token for the authenticated user
assert stored_cred.uuid is not None assert stored_cred.uuid is not None
metadata = infodict(ws, "auth")
token = await create_session( token = await create_session(
user_uuid=stored_cred.user_uuid, user_uuid=stored_cred.user_uuid,
info=infodict(ws, "auth"),
credential_uuid=stored_cred.uuid, credential_uuid=stored_cred.uuid,
host=host,
ip=metadata.get("ip") or "",
user_agent=metadata.get("user_agent") or "",
) )
await ws.send_json( await ws.send_json(

View File

@@ -8,7 +8,7 @@ This module provides a unified interface for WebAuthn operations including:
""" """
import json import json
from datetime import datetime from datetime import datetime, timezone
from urllib.parse import urlparse from urllib.parse import urlparse
from uuid import UUID from uuid import UUID
@@ -163,7 +163,7 @@ class Passkey:
aaguid=UUID(registration.aaguid), aaguid=UUID(registration.aaguid),
public_key=registration.credential_public_key, public_key=registration.credential_public_key,
sign_count=registration.sign_count, sign_count=registration.sign_count,
created_at=datetime.now(), created_at=datetime.now(timezone.utc),
) )
### Authentication Methods ### ### Authentication Methods ###
@@ -227,7 +227,7 @@ class Passkey:
credential_current_sign_count=stored_cred.sign_count, credential_current_sign_count=stored_cred.sign_count,
) )
stored_cred.sign_count = verification.new_sign_count stored_cred.sign_count = verification.new_sign_count
now = datetime.now() now = datetime.now(timezone.utc)
stored_cred.last_used = now stored_cred.last_used = now
if verification.user_verified: if verification.user_verified:
stored_cred.last_verified = now stored_cred.last_verified = now

View File

@@ -2,7 +2,7 @@
import os import os
from functools import lru_cache from functools import lru_cache
from urllib.parse import urlparse from urllib.parse import urlparse, urlsplit
from ..globals import passkey as global_passkey from ..globals import passkey as global_passkey
@@ -70,3 +70,17 @@ def reset_link_url(
def reload_config() -> None: def reload_config() -> None:
_load_config.cache_clear() _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

View File

@@ -4,6 +4,7 @@ from collections.abc import Sequence
from fnmatch import fnmatchcase from fnmatch import fnmatchcase
from ..globals import db from ..globals import db
from .hostutil import normalize_host
from .tokens import session_key from .tokens import session_key
__all__ = ["has_any", "has_all", "session_context"] __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 return all(_match(ctx.role.permissions, patterns)) if ctx else False
async def session_context(auth: str | None): async def session_context(auth: str | None, host: str | None = None):
return await db.instance.get_session_context(session_key(auth)) if auth else 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)

View File

@@ -15,6 +15,25 @@ def session_key(token: str) -> bytes:
return b"sess" + base64.urlsafe_b64decode(token) 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: def reset_key(passphrase: str) -> bytes:
if not is_well_formed(passphrase): if not is_well_formed(passphrase):
raise ValueError( raise ValueError(

10
passkey/util/useragent.py Normal file
View File

@@ -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()

View File

@@ -18,6 +18,7 @@ dependencies = [
"aiosqlite>=0.19.0", "aiosqlite>=0.19.0",
"uuid7-standard>=1.0.0", "uuid7-standard>=1.0.0",
"pyjwt>=2.8.0", "pyjwt>=2.8.0",
"user-agents>=2.2.0",
] ]
requires-python = ">=3.10" requires-python = ">=3.10"