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:
1
API.md
1
API.md
@@ -12,6 +12,7 @@ POST /auth/api/logout - Logout and delete session
|
||||
POST /auth/api/set-session - Set session cookie from Authorization header
|
||||
POST /auth/api/create-link - Create device addition link
|
||||
DELETE /auth/api/credential/{uuid} - Delete specific credential
|
||||
DELETE /auth/api/session/{session_id} - Terminate an active session
|
||||
POST /auth/api/validate - Session validation and renewal endpoint (fetch regularly)
|
||||
GET /auth/api/forward - Authentication validation for Caddy/Nginx
|
||||
- On success returns `204 No Content` with [user info](Headers.md)
|
||||
|
||||
@@ -2,13 +2,10 @@
|
||||
<div class="app-shell">
|
||||
<StatusMessage />
|
||||
<main class="app-main">
|
||||
<!-- Only render views after authentication status is determined -->
|
||||
<template v-if="initialized">
|
||||
<LoginView v-if="store.currentView === 'login'" />
|
||||
<ProfileView v-if="store.currentView === 'profile'" />
|
||||
<DeviceLinkView v-if="store.currentView === 'device-link'" />
|
||||
</template>
|
||||
<!-- Show loading state while determining auth status -->
|
||||
<div v-else class="loading-container">
|
||||
<div class="loading-spinner"></div>
|
||||
<p>Loading...</p>
|
||||
@@ -23,14 +20,11 @@ import { useAuthStore } from '@/stores/auth'
|
||||
import StatusMessage from '@/components/StatusMessage.vue'
|
||||
import LoginView from '@/components/LoginView.vue'
|
||||
import ProfileView from '@/components/ProfileView.vue'
|
||||
import DeviceLinkView from '@/components/DeviceLinkView.vue'
|
||||
const store = useAuthStore()
|
||||
const initialized = ref(false)
|
||||
|
||||
onMounted(async () => {
|
||||
// Load branding / settings first (non-blocking for auth flow)
|
||||
await store.loadSettings()
|
||||
// Was an error message passed in the URL hash?
|
||||
const message = location.hash.substring(1)
|
||||
if (message) {
|
||||
store.showMessage(decodeURIComponent(message), 'error')
|
||||
@@ -48,31 +42,8 @@ onMounted(async () => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.loading-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
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;
|
||||
}
|
||||
.loading-container { display: flex; flex-direction: column; align-items: center; 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>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ref } from 'vue'
|
||||
import UserBasicInfo from '@/components/UserBasicInfo.vue'
|
||||
import CredentialList from '@/components/CredentialList.vue'
|
||||
import RegistrationLinkModal from '@/components/RegistrationLinkModal.vue'
|
||||
import SessionList from '@/components/SessionList.vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const props = defineProps({
|
||||
@@ -57,18 +58,45 @@ function handleDelete(credential) {
|
||||
/>
|
||||
<div v-else-if="userDetail?.error" class="error small">{{ userDetail.error }}</div>
|
||||
<template v-if="userDetail && !userDetail.error">
|
||||
<h3 class="cred-title">Registered Passkeys</h3>
|
||||
<CredentialList :credentials="userDetail.credentials" :aaguid-info="userDetail.aaguid_info" :allow-delete="true" @delete="handleDelete" />
|
||||
<div class="registration-actions">
|
||||
<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>
|
||||
<div class="actions">
|
||||
<button @click="$emit('generateUserRegistrationLink', selectedUser)">Generate Registration Token</button>
|
||||
<div class="actions ancillary-actions">
|
||||
<button v-if="selectedOrg" @click="$emit('openOrg', selectedOrg)" class="icon-btn" title="Back to Org">↩️</button>
|
||||
</div>
|
||||
<p class="matrix-hint muted">Use the token dialog to register a new credential for the member.</p>
|
||||
<RegistrationLinkModal
|
||||
v-if="showRegModal"
|
||||
:endpoint="`/auth/admin/orgs/${selectedUser.org_uuid}/users/${selectedUser.uuid}/create-link`"
|
||||
:auto-copy="false"
|
||||
:user-name="userDetail?.display_name || selectedUser.display_name"
|
||||
@close="$emit('closeRegModal')"
|
||||
@copied="onLinkCopied"
|
||||
/>
|
||||
@@ -77,9 +105,10 @@ function handleDelete(credential) {
|
||||
|
||||
<style scoped>
|
||||
.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 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:hover { color: var(--color-heading); background: var(--color-surface-muted); }
|
||||
.matrix-hint { font-size: 0.8rem; color: var(--color-text-muted); }
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
/* Passkey Authentication – Unified Layout */
|
||||
|
||||
:root {
|
||||
color-scheme: light dark;
|
||||
@@ -440,60 +439,110 @@ th {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.credential-list {
|
||||
:root { --card-width: 22rem; }
|
||||
|
||||
.record-list,
|
||||
.credential-list,
|
||||
.session-list {
|
||||
width: 100%;
|
||||
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;
|
||||
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;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
padding: 0.85rem 1rem;
|
||||
padding: 1rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-surface);
|
||||
height: 100%;
|
||||
transition: border-color 0.2s ease, box-shadow 0.2s ease, transform 0.2s ease;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.credential-item.current-session {
|
||||
border-color: var(--color-accent);
|
||||
background: rgba(37, 99, 235, 0.08);
|
||||
.record-item:hover,
|
||||
.credential-item:hover,
|
||||
.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;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
align-items: flex-start;
|
||||
flex-wrap: wrap;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.credential-icon {
|
||||
.item-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);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.credential-info {
|
||||
flex: 1 1 auto;
|
||||
.auth-icon {
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.credential-info h4 {
|
||||
.item-title {
|
||||
flex: 1;
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
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 {
|
||||
display: grid;
|
||||
grid-auto-flow: row;
|
||||
grid-template-columns: auto 1fr;
|
||||
grid-template-columns: 7rem 1fr;
|
||||
gap: 0.35rem 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-muted);
|
||||
@@ -509,27 +558,59 @@ th {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.credential-actions {
|
||||
margin-left: auto;
|
||||
.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; }
|
||||
.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;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
.btn-delete-credential {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--color-danger);
|
||||
padding: 0.25rem 0.35rem;
|
||||
font-size: 1.05rem;
|
||||
.badge {
|
||||
padding: 0.2rem 0.5rem;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.btn-delete-credential:hover:not(:disabled) {
|
||||
background: rgba(220, 38, 38, 0.08);
|
||||
.badge-current {
|
||||
background: var(--color-accent);
|
||||
color: var(--color-accent-contrast);
|
||||
box-shadow: 0 0 0 1px var(--color-accent) inset;
|
||||
}
|
||||
|
||||
.btn-delete-credential:disabled {
|
||||
opacity: 0.35;
|
||||
cursor: not-allowed;
|
||||
.badge:not(.badge-current) {
|
||||
background: var(--color-surface-subtle);
|
||||
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 {
|
||||
@@ -597,7 +678,6 @@ th {
|
||||
}
|
||||
}
|
||||
|
||||
/* Dialog styles for auth views */
|
||||
.dialog-backdrop {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
|
||||
@@ -6,10 +6,10 @@
|
||||
<div
|
||||
v-for="credential in credentials"
|
||||
: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="credential-icon">
|
||||
<div class="item-top">
|
||||
<div class="item-icon">
|
||||
<img
|
||||
v-if="getCredentialAuthIcon(credential)"
|
||||
:src="getCredentialAuthIcon(credential)"
|
||||
@@ -20,24 +20,28 @@
|
||||
>
|
||||
<span v-else class="auth-emoji">🔑</span>
|
||||
</div>
|
||||
<div class="credential-info">
|
||||
<h4>{{ getCredentialAuthName(credential) }}</h4>
|
||||
</div>
|
||||
<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">
|
||||
<h4 class="item-title">{{ getCredentialAuthName(credential) }}</h4>
|
||||
<div class="item-actions">
|
||||
<span v-if="credential.is_current_session" class="badge badge-current">Current</span>
|
||||
<button
|
||||
v-if="allowDelete"
|
||||
@click="$emit('delete', credential)"
|
||||
class="btn-delete-credential"
|
||||
class="btn-card-delete"
|
||||
:disabled="credential.is_current_session"
|
||||
:title="credential.is_current_session ? 'Cannot delete current session credential' : 'Delete passkey'"
|
||||
>🗑️</button>
|
||||
</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>
|
||||
</template>
|
||||
</div>
|
||||
@@ -67,121 +71,3 @@ const getCredentialAuthIcon = (credential) => {
|
||||
return info[iconKey] || null
|
||||
}
|
||||
</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>
|
||||
|
||||
@@ -5,74 +5,39 @@
|
||||
<h1>📱 Add Another Device</h1>
|
||||
<p class="view-lede">Generate a one-time link to set up passkeys on a new device.</p>
|
||||
</header>
|
||||
<section class="section-block">
|
||||
<div class="section-body">
|
||||
<div class="device-link-section">
|
||||
<div class="qr-container">
|
||||
<a :href="url" class="qr-link" @click="copyLink">
|
||||
<canvas ref="qrCanvas" class="qr-code"></canvas>
|
||||
<p v-if="url">
|
||||
{{ url.replace(/^[^:]+:\/\//, '') }}
|
||||
</p>
|
||||
<p v-else>
|
||||
<em>Generating link...</em>
|
||||
</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 class="button-row">
|
||||
<button @click="authStore.currentView = 'profile'" class="btn-secondary">
|
||||
Back to Profile
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<RegistrationLinkModal
|
||||
inline
|
||||
:endpoint="'/auth/api/create-link'"
|
||||
:user-name="userName"
|
||||
:auto-copy="false"
|
||||
:prefix-copy-with-user-name="!!userName"
|
||||
show-close-in-inline
|
||||
@copied="onCopied"
|
||||
/>
|
||||
<div class="button-row" style="margin-top:1rem;">
|
||||
<button @click="authStore.currentView = 'profile'" class="btn-secondary">Back to Profile</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, nextTick } from 'vue'
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import QRCode from 'qrcode/lib/browser'
|
||||
import RegistrationLinkModal from '@/components/RegistrationLinkModal.vue'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const url = ref(null)
|
||||
const qrCanvas = ref(null)
|
||||
|
||||
const copyLink = async (event) => {
|
||||
event.preventDefault()
|
||||
if (url.value) {
|
||||
await navigator.clipboard.writeText(url.value)
|
||||
authStore.showMessage('Link copied to clipboard!')
|
||||
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)
|
||||
})
|
||||
const userName = ref(null)
|
||||
const onCopied = () => {
|
||||
authStore.showMessage('Link copied to clipboard!', 'success', 2500)
|
||||
authStore.currentView = 'profile'
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const response = await fetch('/auth/api/create-link', { method: 'POST' })
|
||||
const result = await response.json()
|
||||
if (result.detail) throw new Error(result.detail)
|
||||
|
||||
url.value = result.url
|
||||
await drawQr()
|
||||
} catch (error) {
|
||||
authStore.showMessage(`Failed to create device link: ${error.message}`, 'error')
|
||||
authStore.currentView = 'profile'
|
||||
}
|
||||
// Extract optional admin-provided query parameters (?user=Name&emoji=😀)
|
||||
const params = new URLSearchParams(location.search)
|
||||
const qUser = params.get('user')
|
||||
if (qUser) userName.value = qUser.trim()
|
||||
})
|
||||
|
||||
</script>
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
:created-at="authStore.userInfo.user.created_at"
|
||||
:last-seen="authStore.userInfo.user.last_seen"
|
||||
:loading="authStore.isLoading"
|
||||
update-endpoint="/auth/api/user/display-name"
|
||||
update-endpoint="/auth/api/user-display-name"
|
||||
@saved="authStore.loadUserInfo()"
|
||||
@edit-name="openNameDialog"
|
||||
/>
|
||||
@@ -35,25 +35,19 @@
|
||||
@delete="handleDelete"
|
||||
/>
|
||||
<div class="button-row">
|
||||
<button @click="addNewCredential" class="btn-primary">
|
||||
Add New Passkey
|
||||
</button>
|
||||
<button @click="authStore.currentView = 'device-link'" class="btn-secondary">
|
||||
Add Another Device
|
||||
</button>
|
||||
<button @click="addNewCredential" class="btn-primary">Add New Passkey</button>
|
||||
<button @click="showRegLink = true" class="btn-secondary">Add Another Device</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section-block">
|
||||
<div class="button-row">
|
||||
<button @click="logout" class="btn-danger logout-button">
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
<SessionList
|
||||
:sessions="sessions"
|
||||
:terminating-sessions="terminatingSessions"
|
||||
@terminate="terminateSession"
|
||||
section-description="Review where you're signed in and end any sessions you no longer recognize."
|
||||
/>
|
||||
|
||||
<!-- Name Edit Dialog -->
|
||||
<Modal v-if="showNameDialog" @close="showNameDialog = false">
|
||||
<h3>Edit Display Name</h3>
|
||||
<form @submit.prevent="saveName" class="modal-form">
|
||||
@@ -65,6 +59,21 @@
|
||||
/>
|
||||
</form>
|
||||
</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>
|
||||
</section>
|
||||
</template>
|
||||
@@ -76,35 +85,25 @@ import CredentialList from '@/components/CredentialList.vue'
|
||||
import UserBasicInfo from '@/components/UserBasicInfo.vue'
|
||||
import Modal from '@/components/Modal.vue'
|
||||
import NameEditForm from '@/components/NameEditForm.vue'
|
||||
import SessionList from '@/components/SessionList.vue'
|
||||
import RegistrationLinkModal from '@/components/RegistrationLinkModal.vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import passkey from '@/utils/passkey'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const updateInterval = ref(null)
|
||||
const showNameDialog = ref(false)
|
||||
const showRegLink = ref(false)
|
||||
const newName = ref('')
|
||||
const saving = ref(false)
|
||||
|
||||
watch(showNameDialog, (newVal) => {
|
||||
if (newVal) {
|
||||
newName.value = authStore.userInfo?.user?.user_name || ''
|
||||
}
|
||||
})
|
||||
watch(showNameDialog, (newVal) => { if (newVal) newName.value = authStore.userInfo?.user?.user_name || '' })
|
||||
|
||||
onMounted(() => {
|
||||
updateInterval.value = setInterval(() => {
|
||||
// Trigger Vue reactivity to update formatDate fields
|
||||
if (authStore.userInfo) {
|
||||
authStore.userInfo = { ...authStore.userInfo }
|
||||
}
|
||||
}, 60000) // Update every minute
|
||||
updateInterval.value = setInterval(() => { if (authStore.userInfo) authStore.userInfo = { ...authStore.userInfo } }, 60000)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (updateInterval.value) {
|
||||
clearInterval(updateInterval.value)
|
||||
}
|
||||
})
|
||||
onUnmounted(() => { if (updateInterval.value) clearInterval(updateInterval.value) })
|
||||
|
||||
const addNewCredential = async () => {
|
||||
try {
|
||||
@@ -116,9 +115,7 @@ const addNewCredential = async () => {
|
||||
} catch (error) {
|
||||
console.error('Failed to add new passkey:', error)
|
||||
authStore.showMessage(error.message, 'error')
|
||||
} finally {
|
||||
authStore.isLoading = false
|
||||
}
|
||||
} finally { authStore.isLoading = false }
|
||||
}
|
||||
|
||||
const handleDelete = async (credential) => {
|
||||
@@ -128,80 +125,55 @@ const handleDelete = async (credential) => {
|
||||
try {
|
||||
await authStore.deleteCredential(credentialId)
|
||||
authStore.showMessage('Passkey deleted successfully!', 'success', 3000)
|
||||
} catch (error) {
|
||||
authStore.showMessage(`Failed to delete passkey: ${error.message}`, 'error')
|
||||
} catch (error) { authStore.showMessage(`Failed to delete passkey: ${error.message}`, 'error') }
|
||||
}
|
||||
|
||||
const sessions = computed(() => authStore.userInfo?.sessions || [])
|
||||
const terminatingSessions = ref({})
|
||||
|
||||
const terminateSession = async (session) => {
|
||||
const sessionId = session?.id
|
||||
if (!sessionId) return
|
||||
terminatingSessions.value = { ...terminatingSessions.value, [sessionId]: true }
|
||||
try { await authStore.terminateSession(sessionId) }
|
||||
catch (error) { authStore.showMessage(error.message || 'Failed to terminate session', 'error', 5000) }
|
||||
finally {
|
||||
const next = { ...terminatingSessions.value }
|
||||
delete next[sessionId]
|
||||
terminatingSessions.value = next
|
||||
}
|
||||
}
|
||||
|
||||
const logout = async () => {
|
||||
await authStore.logout()
|
||||
}
|
||||
|
||||
const openNameDialog = () => {
|
||||
newName.value = authStore.userInfo?.user?.user_name || ''
|
||||
showNameDialog.value = true
|
||||
}
|
||||
|
||||
const logoutEverywhere = async () => { await authStore.logoutEverywhere() }
|
||||
const openNameDialog = () => { newName.value = authStore.userInfo?.user?.user_name || ''; showNameDialog.value = true }
|
||||
const isAdmin = computed(() => !!(authStore.userInfo?.is_global_admin || authStore.userInfo?.is_org_admin))
|
||||
|
||||
const breadcrumbEntries = computed(() => {
|
||||
const entries = [{ label: 'Auth', href: authStore.uiHref() }]
|
||||
if (isAdmin.value) entries.push({ label: 'Admin', href: authStore.adminHomeHref() })
|
||||
return entries
|
||||
})
|
||||
const breadcrumbEntries = computed(() => { const entries = [{ label: 'Auth', href: authStore.uiHref() }]; if (isAdmin.value) entries.push({ label: 'Admin', href: authStore.adminHomeHref() }); return entries })
|
||||
|
||||
const saveName = async () => {
|
||||
const name = newName.value.trim()
|
||||
if (!name) {
|
||||
authStore.showMessage('Name cannot be empty', 'error')
|
||||
return
|
||||
}
|
||||
if (!name) { authStore.showMessage('Name cannot be empty', 'error'); return }
|
||||
try {
|
||||
saving.value = true
|
||||
const res = await fetch('/auth/api/user/display-name', {
|
||||
method: 'PUT',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ display_name: name })
|
||||
})
|
||||
const res = await fetch('/auth/api/user/display-name', { method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ display_name: name }) })
|
||||
const data = await res.json()
|
||||
if (!res.ok || data.detail) throw new Error(data.detail || 'Update failed')
|
||||
showNameDialog.value = false
|
||||
showNameDialog.value = false
|
||||
await authStore.loadUserInfo()
|
||||
authStore.showMessage('Name updated successfully!', 'success', 3000)
|
||||
} catch (e) {
|
||||
authStore.showMessage(e.message || 'Failed to update name', 'error')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
} catch (e) { authStore.showMessage(e.message || 'Failed to update name', 'error') }
|
||||
finally { saving.value = false }
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.view-lede {
|
||||
margin: 0;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
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%;
|
||||
}
|
||||
}
|
||||
.view-lede { margin: 0; color: var(--color-text-muted); font-size: 1rem; }
|
||||
.section-header { display: flex; flex-direction: column; gap: 0.4rem; }
|
||||
.section-description { margin: 0; color: var(--color-text-muted); }
|
||||
.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; }
|
||||
.logout-row.single { justify-content: flex-start; }
|
||||
.logout-note { margin: 0.75rem 0 0; color: var(--color-text-muted); font-size: 0.875rem; }
|
||||
@media (max-width: 720px) { .logout-button { width: 100%; } }
|
||||
</style>
|
||||
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
<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 style="display:flex; justify-content:space-between; align-items:center; margin-bottom:10px;">
|
||||
<h2 id="regTitle" style="margin:0; font-size:1.25rem;">📱 Device Registration Link</h2>
|
||||
<div class="reg-header-row">
|
||||
<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>
|
||||
</div>
|
||||
<div class="device-link-section">
|
||||
@@ -14,28 +16,62 @@
|
||||
<div v-else>
|
||||
<em>Generating link...</em>
|
||||
</div>
|
||||
<p>
|
||||
<strong>Scan and visit the URL on another device.</strong><br>
|
||||
<small>⚠️ Expires in 24 hours and one-time use.</small>
|
||||
<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 or scan this link on the device you wish to register to your account.</span>
|
||||
<br><small>{{ expirationMessage }}</small>
|
||||
</p>
|
||||
<div v-if="expires" style="font-size:12px; margin-top:6px;">Expires: {{ new Date(expires).toLocaleString() }}</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-primary" :disabled="!url" @click="copy">Copy Link</button>
|
||||
</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>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, watch, computed, nextTick } from 'vue'
|
||||
import QRCode from 'qrcode/lib/browser'
|
||||
import { formatDate } from '@/utils/helpers'
|
||||
|
||||
const props = defineProps({
|
||||
endpoint: { type: String, required: true }, // POST endpoint returning {url, expires}
|
||||
autoCopy: { type: Boolean, default: true }
|
||||
endpoint: { type: String, required: 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'])
|
||||
@@ -46,6 +82,16 @@ const qrCanvas = ref(null)
|
||||
|
||||
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() {
|
||||
try {
|
||||
const res = await fetch(props.endpoint, { method: 'POST' })
|
||||
@@ -73,15 +119,35 @@ async function drawQR() {
|
||||
|
||||
async function copy() {
|
||||
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)
|
||||
watch(url, () => drawQR(), { flush: 'post' })
|
||||
|
||||
</script>
|
||||
<style scoped>
|
||||
.icon-btn { background:none; border:none; cursor:pointer; font-size:1rem; opacity:.6; }
|
||||
.icon-btn:hover { opacity:1; }
|
||||
/* Minimal extra styling; main look comes from global styles */
|
||||
.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>
|
||||
|
||||
64
frontend/src/components/SessionList.vue
Normal file
64
frontend/src/components/SessionList.vue
Normal 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>
|
||||
@@ -4,7 +4,7 @@ import { register, authenticate } from '@/utils/passkey'
|
||||
export const useAuthStore = defineStore('auth', {
|
||||
state: () => ({
|
||||
// Auth State
|
||||
userInfo: null, // Contains the full user info response: {user, credentials, aaguid_info, session_type, authenticated}
|
||||
userInfo: null, // Contains the full user info response: {user, credentials, aaguid_info}
|
||||
settings: null, // Server provided settings (/auth/settings)
|
||||
isLoading: false,
|
||||
|
||||
@@ -91,8 +91,7 @@ export const useAuthStore = defineStore('auth', {
|
||||
},
|
||||
selectView() {
|
||||
if (!this.userInfo) this.currentView = 'login'
|
||||
else if (this.userInfo.authenticated) this.currentView = 'profile'
|
||||
else this.currentView = 'login'
|
||||
else this.currentView = 'profile'
|
||||
},
|
||||
async loadUserInfo() {
|
||||
const response = await fetch('/auth/api/user-info', { method: 'POST' })
|
||||
@@ -134,9 +133,44 @@ export const useAuthStore = defineStore('auth', {
|
||||
|
||||
await this.loadUserInfo()
|
||||
},
|
||||
async terminateSession(sessionId) {
|
||||
try {
|
||||
const res = await fetch(`/auth/api/session/${sessionId}`, { method: 'DELETE' })
|
||||
let payload = null
|
||||
try {
|
||||
payload = await res.json()
|
||||
} catch (_) {
|
||||
// ignore JSON parse errors
|
||||
}
|
||||
if (!res.ok || payload?.detail) {
|
||||
const message = payload?.detail || 'Failed to terminate session'
|
||||
throw new Error(message)
|
||||
}
|
||||
if (payload?.current_session_terminated) {
|
||||
sessionStorage.clear()
|
||||
location.reload()
|
||||
return
|
||||
}
|
||||
await this.loadUserInfo()
|
||||
this.showMessage('Session terminated', 'success', 2500)
|
||||
} catch (error) {
|
||||
console.error('Terminate session error:', error)
|
||||
throw error
|
||||
}
|
||||
},
|
||||
async logout() {
|
||||
try {
|
||||
await fetch('/auth/api/logout', {method: 'POST'})
|
||||
const res = await fetch('/auth/api/logout', {method: 'POST'})
|
||||
if (!res.ok) {
|
||||
let message = 'Logout failed'
|
||||
try {
|
||||
const data = await res.json()
|
||||
if (data?.detail) message = data.detail
|
||||
} catch (_) {
|
||||
// ignore JSON parse errors
|
||||
}
|
||||
throw new Error(message)
|
||||
}
|
||||
sessionStorage.clear()
|
||||
location.reload()
|
||||
} catch (error) {
|
||||
@@ -144,5 +178,25 @@ export const useAuthStore = defineStore('auth', {
|
||||
this.showMessage(error.message, 'error')
|
||||
}
|
||||
},
|
||||
async logoutEverywhere() {
|
||||
try {
|
||||
const res = await fetch('/auth/api/logout-all', {method: 'POST'})
|
||||
if (!res.ok) {
|
||||
let message = 'Logout failed'
|
||||
try {
|
||||
const data = await res.json()
|
||||
if (data?.detail) message = data.detail
|
||||
} catch (_) {
|
||||
// ignore JSON parse errors
|
||||
}
|
||||
throw new Error(message)
|
||||
}
|
||||
sessionStorage.clear()
|
||||
location.reload()
|
||||
} catch (error) {
|
||||
console.error('Logout-all error:', error)
|
||||
this.showMessage(error.message, 'error')
|
||||
}
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
@@ -5,16 +5,18 @@ export function formatDate(dateString) {
|
||||
|
||||
const date = new Date(dateString)
|
||||
const now = new Date()
|
||||
const diffMs = now - date
|
||||
const diffMinutes = Math.floor(diffMs / (1000 * 60))
|
||||
const diffHours = Math.floor(diffMs / (1000 * 60 * 60))
|
||||
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24))
|
||||
const diffMs = date - now // Changed to date - now for future/past
|
||||
const isFuture = diffMs > 0
|
||||
const absDiffMs = Math.abs(diffMs)
|
||||
const diffMinutes = Math.round(absDiffMs / (1000 * 60))
|
||||
const diffHours = Math.round(absDiffMs / (1000 * 60 * 60))
|
||||
const diffDays = Math.round(absDiffMs / (1000 * 60 * 60 * 24))
|
||||
|
||||
if (diffMs < 0 || diffDays > 7) return date.toLocaleDateString()
|
||||
if (diffMinutes === 0) return 'Just now'
|
||||
if (diffMinutes < 60) return diffMinutes === 1 ? 'a minute ago' : `${diffMinutes} minutes ago`
|
||||
if (diffHours < 24) return diffHours === 1 ? 'an hour ago' : `${diffHours} hours ago`
|
||||
return diffDays === 1 ? 'a day ago' : `${diffDays} days ago`
|
||||
if (absDiffMs < 1000 * 60) return 'Now'
|
||||
if (diffMinutes <= 60) return isFuture ? `In ${diffMinutes} minute${diffMinutes === 1 ? '' : 's'}` : diffMinutes === 1 ? 'a minute ago' : `${diffMinutes} minutes ago`
|
||||
if (diffHours <= 24) return isFuture ? `In ${diffHours} hour${diffHours === 1 ? '' : 's'}` : diffHours === 1 ? 'an hour ago' : `${diffHours} hours ago`
|
||||
if (diffDays <= 14) return isFuture ? `In ${diffDays} day${diffDays === 1 ? '' : 's'}` : diffDays === 1 ? 'a day ago' : `${diffDays} days ago`
|
||||
return date.toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric' })
|
||||
}
|
||||
|
||||
export function getCookie(name) {
|
||||
|
||||
@@ -8,61 +8,107 @@ independent of any web framework:
|
||||
- Credential management
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import datetime, timezone
|
||||
from uuid import UUID
|
||||
|
||||
from .db import Session
|
||||
from .config import SESSION_LIFETIME
|
||||
from .db import ResetToken, Session
|
||||
from .globals import db
|
||||
from .util import hostutil
|
||||
from .util.tokens import create_token, reset_key, session_key
|
||||
|
||||
EXPIRES = timedelta(hours=24)
|
||||
EXPIRES = SESSION_LIFETIME
|
||||
|
||||
|
||||
def expires() -> datetime:
|
||||
return datetime.now() + EXPIRES
|
||||
return datetime.now(timezone.utc) + EXPIRES
|
||||
|
||||
|
||||
async def create_session(user_uuid: UUID, credential_uuid: UUID, info: dict) -> str:
|
||||
def reset_expires() -> datetime:
|
||||
from .config import RESET_LIFETIME
|
||||
|
||||
return datetime.now(timezone.utc) + RESET_LIFETIME
|
||||
|
||||
|
||||
def session_expiry(session: Session) -> datetime:
|
||||
"""Calculate the expiration timestamp for a session (UTC aware)."""
|
||||
# After migration all renewed timestamps are timezone-aware UTC
|
||||
return session.renewed + EXPIRES
|
||||
|
||||
|
||||
async def create_session(
|
||||
user_uuid: UUID,
|
||||
credential_uuid: UUID,
|
||||
*,
|
||||
host: str,
|
||||
ip: str,
|
||||
user_agent: str,
|
||||
) -> str:
|
||||
"""Create a new session and return a session token."""
|
||||
normalized_host = hostutil.normalize_host(host)
|
||||
if not normalized_host:
|
||||
raise ValueError("Host required for session creation")
|
||||
token = create_token()
|
||||
now = datetime.now(timezone.utc)
|
||||
await db.instance.create_session(
|
||||
user_uuid=user_uuid,
|
||||
credential_uuid=credential_uuid,
|
||||
key=session_key(token),
|
||||
expires=datetime.now() + EXPIRES,
|
||||
info=info,
|
||||
host=normalized_host,
|
||||
ip=ip,
|
||||
user_agent=user_agent,
|
||||
renewed=now,
|
||||
)
|
||||
return token
|
||||
|
||||
|
||||
async def get_reset(token: str) -> Session:
|
||||
async def get_reset(token: str) -> ResetToken:
|
||||
"""Validate a credential reset token. Returns None if the token is not well formed (i.e. it is another type of token)."""
|
||||
session = await db.instance.get_session(reset_key(token))
|
||||
if not session:
|
||||
record = await db.instance.get_reset_token(reset_key(token))
|
||||
if not record:
|
||||
raise ValueError("Invalid or expired session token")
|
||||
return session
|
||||
if record.expiry < datetime.now(timezone.utc):
|
||||
await db.instance.delete_reset_token(record.key)
|
||||
raise ValueError("Invalid or expired session token")
|
||||
return record
|
||||
|
||||
|
||||
async def get_session(token: str) -> Session:
|
||||
async def get_session(token: str, host: str | None = None) -> Session:
|
||||
"""Validate a session token and return session data if valid."""
|
||||
session = await db.instance.get_session(session_key(token))
|
||||
if not session:
|
||||
raise ValueError("Invalid or expired session token")
|
||||
if session_expiry(session) < datetime.now(timezone.utc):
|
||||
await db.instance.delete_session(session.key)
|
||||
raise ValueError("Invalid or expired session token")
|
||||
if host is not None:
|
||||
normalized_host = hostutil.normalize_host(host)
|
||||
if not normalized_host:
|
||||
raise ValueError("Invalid host")
|
||||
if session.host is None:
|
||||
await db.instance.set_session_host(session.key, normalized_host)
|
||||
session.host = normalized_host
|
||||
elif session.host != normalized_host:
|
||||
raise ValueError("Invalid or expired session token")
|
||||
return session
|
||||
|
||||
|
||||
async def refresh_session_token(token: str):
|
||||
async def refresh_session_token(token: str, *, ip: str, user_agent: str):
|
||||
"""Refresh a session extending its expiry."""
|
||||
# Get the current session
|
||||
s = await db.instance.update_session(
|
||||
session_key(token), datetime.now() + EXPIRES, {}
|
||||
session_record = await db.instance.get_session(session_key(token))
|
||||
if not session_record:
|
||||
raise ValueError("Session not found or expired")
|
||||
updated = await db.instance.update_session(
|
||||
session_key(token),
|
||||
ip=ip,
|
||||
user_agent=user_agent,
|
||||
renewed=datetime.now(timezone.utc),
|
||||
)
|
||||
|
||||
if not s:
|
||||
if not updated:
|
||||
raise ValueError("Session not found or expired")
|
||||
|
||||
|
||||
async def delete_credential(credential_uuid: UUID, auth: str):
|
||||
async def delete_credential(credential_uuid: UUID, auth: str, host: str | None = None):
|
||||
"""Delete a specific credential for the current user."""
|
||||
s = await get_session(auth)
|
||||
s = await get_session(auth, host=host)
|
||||
await db.instance.delete_credential(credential_uuid, s.user_uuid)
|
||||
|
||||
@@ -8,7 +8,7 @@ generating a reset link for initial admin setup.
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import uuid7
|
||||
|
||||
@@ -41,11 +41,12 @@ ADMIN_RESET_MESSAGE = """\
|
||||
async def _create_and_log_admin_reset_link(user_uuid, message, session_type) -> str:
|
||||
"""Create an admin reset link and log it with the provided message."""
|
||||
token = passphrase.generate()
|
||||
await globals.db.instance.create_session(
|
||||
expiry = authsession.reset_expires()
|
||||
await globals.db.instance.create_reset_token(
|
||||
user_uuid=user_uuid,
|
||||
key=tokens.reset_key(token),
|
||||
expires=authsession.expires(),
|
||||
info={"type": session_type},
|
||||
expiry=expiry,
|
||||
token_type=session_type,
|
||||
)
|
||||
reset_link = hostutil.reset_link_url(token)
|
||||
logger.info(ADMIN_RESET_MESSAGE, message, reset_link)
|
||||
@@ -90,7 +91,7 @@ async def bootstrap_system(
|
||||
uuid=uuid7.create(),
|
||||
display_name=user_name or "Admin",
|
||||
role_uuid=role.uuid,
|
||||
created_at=datetime.now(),
|
||||
created_at=datetime.now(timezone.utc),
|
||||
visits=0,
|
||||
)
|
||||
await globals.db.instance.create_user(user)
|
||||
|
||||
7
passkey/config.py
Normal file
7
passkey/config.py
Normal 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)
|
||||
@@ -63,9 +63,27 @@ class Credential:
|
||||
class Session:
|
||||
key: bytes
|
||||
user_uuid: UUID
|
||||
expires: datetime
|
||||
info: dict
|
||||
credential_uuid: UUID | None = None
|
||||
credential_uuid: UUID
|
||||
host: str
|
||||
ip: str
|
||||
user_agent: str
|
||||
renewed: datetime
|
||||
|
||||
def metadata(self) -> dict:
|
||||
"""Return session metadata for backwards compatibility."""
|
||||
return {
|
||||
"ip": self.ip,
|
||||
"user_agent": self.user_agent,
|
||||
"renewed": self.renewed.isoformat(),
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class ResetToken:
|
||||
key: bytes
|
||||
user_uuid: UUID
|
||||
expiry: datetime
|
||||
token_type: str
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -146,9 +164,11 @@ class DatabaseInterface(ABC):
|
||||
self,
|
||||
user_uuid: UUID,
|
||||
key: bytes,
|
||||
expires: datetime,
|
||||
info: dict,
|
||||
credential_uuid: UUID | None = None,
|
||||
credential_uuid: UUID,
|
||||
host: str,
|
||||
ip: str,
|
||||
user_agent: str,
|
||||
renewed: datetime,
|
||||
) -> None:
|
||||
"""Create a new session."""
|
||||
|
||||
@@ -162,14 +182,50 @@ class DatabaseInterface(ABC):
|
||||
|
||||
@abstractmethod
|
||||
async def update_session(
|
||||
self, key: bytes, expires: datetime, info: dict
|
||||
self,
|
||||
key: bytes,
|
||||
*,
|
||||
ip: str,
|
||||
user_agent: str,
|
||||
renewed: datetime,
|
||||
) -> Session | None:
|
||||
"""Update session expiry and info."""
|
||||
"""Update session metadata and touch renewed timestamp."""
|
||||
|
||||
@abstractmethod
|
||||
async def set_session_host(self, key: bytes, host: str) -> None:
|
||||
"""Bind a session to a specific host if not already set."""
|
||||
|
||||
@abstractmethod
|
||||
async def list_sessions_for_user(self, user_uuid: UUID) -> list[Session]:
|
||||
"""Return all sessions for a user (including other hosts)."""
|
||||
|
||||
@abstractmethod
|
||||
async def cleanup(self) -> None:
|
||||
"""Called periodically to clean up expired records."""
|
||||
|
||||
@abstractmethod
|
||||
async def delete_sessions_for_user(self, user_uuid: UUID) -> None:
|
||||
"""Delete all sessions belonging to the provided user."""
|
||||
|
||||
# Reset token operations
|
||||
@abstractmethod
|
||||
async def create_reset_token(
|
||||
self,
|
||||
user_uuid: UUID,
|
||||
key: bytes,
|
||||
expiry: datetime,
|
||||
token_type: str,
|
||||
) -> None:
|
||||
"""Create a reset token for a user."""
|
||||
|
||||
@abstractmethod
|
||||
async def get_reset_token(self, key: bytes) -> ResetToken | None:
|
||||
"""Retrieve a reset token by key."""
|
||||
|
||||
@abstractmethod
|
||||
async def delete_reset_token(self, key: bytes) -> None:
|
||||
"""Delete a reset token by key."""
|
||||
|
||||
# Organization operations
|
||||
@abstractmethod
|
||||
async def create_organization(self, org: Org) -> None:
|
||||
@@ -315,36 +371,41 @@ class DatabaseInterface(ABC):
|
||||
"""Create a new user and their first credential in a transaction."""
|
||||
|
||||
@abstractmethod
|
||||
async def get_session_context(self, session_key: bytes) -> SessionContext | None:
|
||||
async def get_session_context(
|
||||
self, session_key: bytes, host: str | None = None
|
||||
) -> SessionContext | None:
|
||||
"""Get complete session context including user, organization, role, and permissions."""
|
||||
|
||||
# Combined atomic operations
|
||||
@abstractmethod
|
||||
async def create_credential_session(
|
||||
self,
|
||||
user_uuid: UUID,
|
||||
credential: Credential,
|
||||
reset_key: bytes | None,
|
||||
session_key: bytes,
|
||||
session_expires: datetime,
|
||||
session_info: dict,
|
||||
display_name: str | None = None,
|
||||
) -> None:
|
||||
"""Atomically add a credential and create a session.
|
||||
# Combined atomic operations
|
||||
@abstractmethod
|
||||
async def create_credential_session(
|
||||
self,
|
||||
user_uuid: UUID,
|
||||
credential: Credential,
|
||||
reset_key: bytes | None,
|
||||
session_key: bytes,
|
||||
*,
|
||||
display_name: str | None = None,
|
||||
host: str | None = None,
|
||||
ip: str | None = None,
|
||||
user_agent: str | None = None,
|
||||
) -> None:
|
||||
"""Atomically add a credential and create a session.
|
||||
|
||||
Steps (single transaction):
|
||||
1. Insert credential
|
||||
2. Optionally delete old session (e.g. reset token) if provided
|
||||
3. Optionally update user's display name
|
||||
4. Insert new session referencing the credential
|
||||
5. Update user's last_seen and increment visits (treat as a login)
|
||||
"""
|
||||
Steps (single transaction):
|
||||
1. Insert credential
|
||||
2. Optionally delete old reset token if provided
|
||||
3. Optionally update user's display name
|
||||
4. Insert new session referencing the credential
|
||||
5. Update user's last_seen and increment visits (treat as a login)
|
||||
"""
|
||||
|
||||
|
||||
__all__ = [
|
||||
"User",
|
||||
"Credential",
|
||||
"Session",
|
||||
"ResetToken",
|
||||
"SessionContext",
|
||||
"Org",
|
||||
"Role",
|
||||
|
||||
@@ -6,7 +6,7 @@ for managing users and credentials in a WebAuthn authentication system.
|
||||
"""
|
||||
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy import (
|
||||
@@ -19,18 +19,21 @@ from sqlalchemy import (
|
||||
event,
|
||||
insert,
|
||||
select,
|
||||
text,
|
||||
update,
|
||||
)
|
||||
from sqlalchemy.dialects.sqlite import BLOB, JSON
|
||||
from sqlalchemy.dialects.sqlite import BLOB
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
||||
|
||||
from ..config import SESSION_LIFETIME
|
||||
from ..globals import db
|
||||
from . import (
|
||||
Credential,
|
||||
DatabaseInterface,
|
||||
Org,
|
||||
Permission,
|
||||
ResetToken,
|
||||
Role,
|
||||
Session,
|
||||
SessionContext,
|
||||
@@ -40,6 +43,14 @@ from . import (
|
||||
DB_PATH = "sqlite+aiosqlite:///passkey-auth.sqlite"
|
||||
|
||||
|
||||
def _normalize_dt(value: datetime | None) -> datetime | None:
|
||||
if value is None:
|
||||
return None
|
||||
if value.tzinfo is None:
|
||||
return value.replace(tzinfo=timezone.utc)
|
||||
return value.astimezone(timezone.utc)
|
||||
|
||||
|
||||
async def init(*args, **kwargs):
|
||||
db.instance = DB()
|
||||
await db.instance.init_db()
|
||||
@@ -98,8 +109,12 @@ class UserModel(Base):
|
||||
role_uuid: Mapped[bytes] = mapped_column(
|
||||
LargeBinary(16), ForeignKey("roles.uuid", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.now)
|
||||
last_seen: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
|
||||
)
|
||||
last_seen: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
visits: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
|
||||
def as_dataclass(self) -> User:
|
||||
@@ -107,8 +122,8 @@ class UserModel(Base):
|
||||
uuid=UUID(bytes=self.uuid),
|
||||
display_name=self.display_name,
|
||||
role_uuid=UUID(bytes=self.role_uuid),
|
||||
created_at=self.created_at,
|
||||
last_seen=self.last_seen,
|
||||
created_at=_normalize_dt(self.created_at) or self.created_at,
|
||||
last_seen=_normalize_dt(self.last_seen) or self.last_seen,
|
||||
visits=self.visits,
|
||||
)
|
||||
|
||||
@@ -118,7 +133,7 @@ class UserModel(Base):
|
||||
uuid=user.uuid.bytes,
|
||||
display_name=user.display_name,
|
||||
role_uuid=user.role_uuid.bytes,
|
||||
created_at=user.created_at or datetime.now(),
|
||||
created_at=user.created_at or datetime.now(timezone.utc),
|
||||
last_seen=user.last_seen,
|
||||
visits=user.visits,
|
||||
)
|
||||
@@ -137,9 +152,29 @@ class CredentialModel(Base):
|
||||
aaguid: Mapped[bytes] = mapped_column(LargeBinary(16), nullable=False)
|
||||
public_key: Mapped[bytes] = mapped_column(BLOB, nullable=False)
|
||||
sign_count: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.now)
|
||||
last_used: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
last_verified: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
|
||||
)
|
||||
# Columns declared timezone-aware going forward; legacy rows may still be naive in storage
|
||||
last_used: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
last_verified: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
|
||||
def as_dataclass(self): # type: ignore[override]
|
||||
return Credential(
|
||||
uuid=UUID(bytes=self.uuid),
|
||||
credential_id=self.credential_id,
|
||||
user_uuid=UUID(bytes=self.user_uuid),
|
||||
aaguid=UUID(bytes=self.aaguid),
|
||||
public_key=self.public_key,
|
||||
sign_count=self.sign_count,
|
||||
created_at=_normalize_dt(self.created_at) or self.created_at,
|
||||
last_used=_normalize_dt(self.last_used) or self.last_used,
|
||||
last_verified=_normalize_dt(self.last_verified) or self.last_verified,
|
||||
)
|
||||
|
||||
|
||||
class SessionModel(Base):
|
||||
@@ -147,23 +182,31 @@ class SessionModel(Base):
|
||||
|
||||
key: Mapped[bytes] = mapped_column(LargeBinary(16), primary_key=True)
|
||||
user_uuid: Mapped[bytes] = mapped_column(
|
||||
LargeBinary(16), ForeignKey("users.uuid", ondelete="CASCADE")
|
||||
LargeBinary(16), ForeignKey("users.uuid", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
credential_uuid: Mapped[bytes | None] = mapped_column(
|
||||
LargeBinary(16), ForeignKey("credentials.uuid", ondelete="CASCADE")
|
||||
credential_uuid: Mapped[bytes] = mapped_column(
|
||||
LargeBinary(16),
|
||||
ForeignKey("credentials.uuid", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
)
|
||||
host: Mapped[str] = mapped_column(String, nullable=False)
|
||||
ip: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
user_agent: Mapped[str] = mapped_column(String(512), nullable=False)
|
||||
renewed: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
default=lambda: datetime.now(timezone.utc),
|
||||
nullable=False,
|
||||
)
|
||||
expires: Mapped[datetime] = mapped_column(DateTime, nullable=False)
|
||||
info: Mapped[dict] = mapped_column(JSON, default=dict)
|
||||
|
||||
def as_dataclass(self):
|
||||
return Session(
|
||||
key=self.key,
|
||||
user_uuid=UUID(bytes=self.user_uuid),
|
||||
credential_uuid=(
|
||||
UUID(bytes=self.credential_uuid) if self.credential_uuid else None
|
||||
),
|
||||
expires=self.expires,
|
||||
info=self.info,
|
||||
credential_uuid=UUID(bytes=self.credential_uuid),
|
||||
host=self.host,
|
||||
ip=self.ip,
|
||||
user_agent=self.user_agent,
|
||||
renewed=_normalize_dt(self.renewed) or self.renewed,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
@@ -171,9 +214,30 @@ class SessionModel(Base):
|
||||
return SessionModel(
|
||||
key=session.key,
|
||||
user_uuid=session.user_uuid.bytes,
|
||||
credential_uuid=session.credential_uuid and session.credential_uuid.bytes,
|
||||
expires=session.expires,
|
||||
info=session.info,
|
||||
credential_uuid=session.credential_uuid.bytes,
|
||||
host=session.host,
|
||||
ip=session.ip,
|
||||
user_agent=session.user_agent,
|
||||
renewed=session.renewed,
|
||||
)
|
||||
|
||||
|
||||
class ResetTokenModel(Base):
|
||||
__tablename__ = "reset_tokens"
|
||||
|
||||
key: Mapped[bytes] = mapped_column(LargeBinary(16), primary_key=True)
|
||||
user_uuid: Mapped[bytes] = mapped_column(
|
||||
LargeBinary(16), ForeignKey("users.uuid", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
token_type: Mapped[str] = mapped_column(String, nullable=False)
|
||||
expiry: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
|
||||
def as_dataclass(self) -> ResetToken:
|
||||
return ResetToken(
|
||||
key=self.key,
|
||||
user_uuid=UUID(bytes=self.user_uuid),
|
||||
token_type=self.token_type,
|
||||
expiry=_normalize_dt(self.expiry) or self.expiry,
|
||||
)
|
||||
|
||||
|
||||
@@ -257,6 +321,58 @@ class DB(DatabaseInterface):
|
||||
"""Initialize database tables."""
|
||||
async with self.engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
result = await conn.execute(text("PRAGMA table_info('sessions')"))
|
||||
columns = {row[1] for row in result}
|
||||
expected = {
|
||||
"key",
|
||||
"user_uuid",
|
||||
"credential_uuid",
|
||||
"host",
|
||||
"ip",
|
||||
"user_agent",
|
||||
"renewed",
|
||||
}
|
||||
needs_recreate = False
|
||||
if columns and columns != expected:
|
||||
await conn.execute(text("DROP TABLE sessions"))
|
||||
needs_recreate = True
|
||||
result = await conn.execute(text("PRAGMA table_info('reset_tokens')"))
|
||||
if not list(result):
|
||||
needs_recreate = True
|
||||
if needs_recreate:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
# Run one-time migration to add UTC tzinfo to any naive datetimes
|
||||
await self._migrate_naive_datetimes()
|
||||
|
||||
async def _migrate_naive_datetimes(self) -> None:
|
||||
"""Attach UTC tzinfo to any legacy naive datetime rows.
|
||||
|
||||
SQLite stores datetimes as text; older rows may have been inserted naive.
|
||||
We treat naive timestamps as already UTC and rewrite them in ISO8601 with Z.
|
||||
"""
|
||||
# Helper SQL fragment for detecting naive (no timezone offset) for ISO strings
|
||||
# We only update rows whose textual representation lacks a 'Z' or '+' sign.
|
||||
async with self.session() as session:
|
||||
# Users
|
||||
for model, fields in [
|
||||
(UserModel, ["created_at", "last_seen"]),
|
||||
(CredentialModel, ["created_at", "last_used", "last_verified"]),
|
||||
(SessionModel, ["renewed"]),
|
||||
(ResetTokenModel, ["expiry"]),
|
||||
]:
|
||||
stmt = select(model)
|
||||
result = await session.execute(stmt)
|
||||
rows = result.scalars().all()
|
||||
dirty = False
|
||||
for row in rows:
|
||||
for fname in fields:
|
||||
value = getattr(row, fname, None)
|
||||
if isinstance(value, datetime) and value.tzinfo is None:
|
||||
setattr(row, fname, value.replace(tzinfo=timezone.utc))
|
||||
dirty = True
|
||||
if dirty:
|
||||
# SQLAlchemy autoflush/commit in context manager will persist
|
||||
pass
|
||||
|
||||
async def get_user_by_uuid(self, user_uuid: UUID) -> User:
|
||||
async with self.session() as session:
|
||||
@@ -409,9 +525,11 @@ class DB(DatabaseInterface):
|
||||
credential: Credential,
|
||||
reset_key: bytes | None,
|
||||
session_key: bytes,
|
||||
session_expires: datetime,
|
||||
session_info: dict,
|
||||
*,
|
||||
display_name: str | None = None,
|
||||
host: str | None = None,
|
||||
ip: str | None = None,
|
||||
user_agent: str | None = None,
|
||||
) -> None:
|
||||
"""Atomic credential + (optional old session delete) + (optional rename) + new session."""
|
||||
async with self.session() as session:
|
||||
@@ -434,10 +552,10 @@ class DB(DatabaseInterface):
|
||||
last_verified=credential.last_verified,
|
||||
)
|
||||
)
|
||||
# Delete old session if provided
|
||||
# Delete old reset token if provided
|
||||
if reset_key:
|
||||
await session.execute(
|
||||
delete(SessionModel).where(SessionModel.key == reset_key)
|
||||
delete(ResetTokenModel).where(ResetTokenModel.key == reset_key)
|
||||
)
|
||||
# Optional rename
|
||||
if display_name:
|
||||
@@ -452,8 +570,9 @@ class DB(DatabaseInterface):
|
||||
key=session_key,
|
||||
user_uuid=user_uuid.bytes,
|
||||
credential_uuid=credential.uuid.bytes,
|
||||
expires=session_expires,
|
||||
info=session_info,
|
||||
host=host,
|
||||
ip=ip,
|
||||
user_agent=user_agent,
|
||||
)
|
||||
)
|
||||
# Login side-effects: update user analytics (last_seen + visits increment)
|
||||
@@ -476,17 +595,21 @@ class DB(DatabaseInterface):
|
||||
self,
|
||||
user_uuid: UUID,
|
||||
key: bytes,
|
||||
expires: datetime,
|
||||
info: dict,
|
||||
credential_uuid: UUID | None = None,
|
||||
credential_uuid: UUID,
|
||||
host: str,
|
||||
ip: str,
|
||||
user_agent: str,
|
||||
renewed: datetime,
|
||||
) -> None:
|
||||
async with self.session() as session:
|
||||
session_model = SessionModel(
|
||||
key=key,
|
||||
user_uuid=user_uuid.bytes,
|
||||
credential_uuid=credential_uuid.bytes if credential_uuid else None,
|
||||
expires=expires,
|
||||
info=info,
|
||||
credential_uuid=credential_uuid.bytes,
|
||||
host=host,
|
||||
ip=ip,
|
||||
user_agent=user_agent,
|
||||
renewed=renewed,
|
||||
)
|
||||
session.add(session_model)
|
||||
|
||||
@@ -497,29 +620,88 @@ class DB(DatabaseInterface):
|
||||
session_model = result.scalar_one_or_none()
|
||||
|
||||
if session_model:
|
||||
return Session(
|
||||
key=session_model.key,
|
||||
user_uuid=UUID(bytes=session_model.user_uuid),
|
||||
credential_uuid=UUID(bytes=session_model.credential_uuid)
|
||||
if session_model.credential_uuid
|
||||
else None,
|
||||
expires=session_model.expires,
|
||||
info=session_model.info or {},
|
||||
)
|
||||
return session_model.as_dataclass()
|
||||
return None
|
||||
|
||||
async def delete_session(self, key: bytes) -> None:
|
||||
async with self.session() as session:
|
||||
await session.execute(delete(SessionModel).where(SessionModel.key == key))
|
||||
|
||||
async def update_session(self, key: bytes, expires: datetime, info: dict) -> None:
|
||||
async def delete_sessions_for_user(self, user_uuid: UUID) -> None:
|
||||
async with self.session() as session:
|
||||
await session.execute(
|
||||
update(SessionModel)
|
||||
.where(SessionModel.key == key)
|
||||
.values(expires=expires, info=info)
|
||||
delete(SessionModel).where(SessionModel.user_uuid == user_uuid.bytes)
|
||||
)
|
||||
|
||||
async def create_reset_token(
|
||||
self,
|
||||
user_uuid: UUID,
|
||||
key: bytes,
|
||||
expiry: datetime,
|
||||
token_type: str,
|
||||
) -> None:
|
||||
async with self.session() as session:
|
||||
model = ResetTokenModel(
|
||||
key=key,
|
||||
user_uuid=user_uuid.bytes,
|
||||
token_type=token_type,
|
||||
expiry=expiry,
|
||||
)
|
||||
session.add(model)
|
||||
|
||||
async def get_reset_token(self, key: bytes) -> ResetToken | None:
|
||||
async with self.session() as session:
|
||||
stmt = select(ResetTokenModel).where(ResetTokenModel.key == key)
|
||||
result = await session.execute(stmt)
|
||||
model = result.scalar_one_or_none()
|
||||
return model.as_dataclass() if model else None
|
||||
|
||||
async def delete_reset_token(self, key: bytes) -> None:
|
||||
async with self.session() as session:
|
||||
await session.execute(
|
||||
delete(ResetTokenModel).where(ResetTokenModel.key == key)
|
||||
)
|
||||
|
||||
async def update_session(
|
||||
self,
|
||||
key: bytes,
|
||||
*,
|
||||
ip: str,
|
||||
user_agent: str,
|
||||
renewed: datetime,
|
||||
) -> Session | None:
|
||||
async with self.session() as session:
|
||||
model = await session.get(SessionModel, key)
|
||||
if not model:
|
||||
return None
|
||||
model.ip = ip
|
||||
model.user_agent = user_agent
|
||||
model.renewed = renewed
|
||||
await session.flush()
|
||||
return model.as_dataclass()
|
||||
|
||||
async def set_session_host(self, key: bytes, host: str) -> None:
|
||||
async with self.session() as session:
|
||||
model = await session.get(SessionModel, key)
|
||||
if model and model.host is None:
|
||||
model.host = host
|
||||
await session.flush()
|
||||
|
||||
async def list_sessions_for_user(self, user_uuid: UUID) -> list[Session]:
|
||||
async with self.session() as session:
|
||||
stmt = (
|
||||
select(SessionModel)
|
||||
.where(SessionModel.user_uuid == user_uuid.bytes)
|
||||
.order_by(SessionModel.renewed.desc())
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
session_models = [
|
||||
model
|
||||
for model in result.scalars().all()
|
||||
if model.key.startswith(b"sess")
|
||||
]
|
||||
return [model.as_dataclass() for model in session_models]
|
||||
|
||||
# Organization operations
|
||||
async def create_organization(self, org: Org) -> None:
|
||||
async with self.session() as session:
|
||||
@@ -1115,11 +1297,18 @@ class DB(DatabaseInterface):
|
||||
|
||||
async def cleanup(self) -> None:
|
||||
async with self.session() as session:
|
||||
current_time = datetime.now()
|
||||
stmt = delete(SessionModel).where(SessionModel.expires < current_time)
|
||||
await session.execute(stmt)
|
||||
current_time = datetime.now(timezone.utc)
|
||||
session_threshold = current_time - SESSION_LIFETIME
|
||||
await session.execute(
|
||||
delete(SessionModel).where(SessionModel.renewed < session_threshold)
|
||||
)
|
||||
await session.execute(
|
||||
delete(ResetTokenModel).where(ResetTokenModel.expiry < current_time)
|
||||
)
|
||||
|
||||
async def get_session_context(self, session_key: bytes) -> SessionContext | None:
|
||||
async def get_session_context(
|
||||
self, session_key: bytes, host: str | None = None
|
||||
) -> SessionContext | None:
|
||||
"""Get complete session context including user, organization, role, and permissions.
|
||||
|
||||
Uses efficient JOINs to retrieve all related data in a single database query.
|
||||
@@ -1156,15 +1345,18 @@ class DB(DatabaseInterface):
|
||||
session_model, user_model, role_model, org_model, _ = first_row
|
||||
|
||||
# Create the session object
|
||||
session_obj = Session(
|
||||
key=session_model.key,
|
||||
user_uuid=UUID(bytes=session_model.user_uuid),
|
||||
credential_uuid=UUID(bytes=session_model.credential_uuid)
|
||||
if session_model.credential_uuid
|
||||
else None,
|
||||
expires=session_model.expires,
|
||||
info=session_model.info or {},
|
||||
)
|
||||
if host is not None:
|
||||
if session_model.host is None:
|
||||
await session.execute(
|
||||
update(SessionModel)
|
||||
.where(SessionModel.key == session_key)
|
||||
.values(host=host)
|
||||
)
|
||||
session_model.host = host
|
||||
elif session_model.host != host:
|
||||
return None
|
||||
|
||||
session_obj = session_model.as_dataclass()
|
||||
|
||||
# Create the user object
|
||||
user_obj = user_model.as_dataclass()
|
||||
|
||||
@@ -1,12 +1,22 @@
|
||||
import logging
|
||||
from datetime import timezone
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from fastapi import Body, Cookie, FastAPI, HTTPException, Request
|
||||
from fastapi.responses import FileResponse, JSONResponse
|
||||
|
||||
from ..authsession import expires
|
||||
from ..authsession import reset_expires
|
||||
from ..globals import db
|
||||
from ..util import frontend, hostutil, passphrase, permutil, querysafe, tokens
|
||||
from ..util import (
|
||||
frontend,
|
||||
hostutil,
|
||||
passphrase,
|
||||
permutil,
|
||||
querysafe,
|
||||
tokens,
|
||||
useragent,
|
||||
)
|
||||
from ..util.tokens import encode_session_key, session_key
|
||||
from . import authz
|
||||
|
||||
app = FastAPI()
|
||||
@@ -24,9 +34,14 @@ async def general_exception_handler(_request, exc: Exception):
|
||||
|
||||
|
||||
@app.get("/")
|
||||
async def adminapp(auth=Cookie(None)):
|
||||
async def adminapp(request: Request, auth=Cookie(None, alias="__Host-auth")):
|
||||
try:
|
||||
await authz.verify(auth, ["auth:admin", "auth:org:*"], match=permutil.has_any)
|
||||
await authz.verify(
|
||||
auth,
|
||||
["auth:admin", "auth:org:*"],
|
||||
match=permutil.has_any,
|
||||
host=request.headers.get("host"),
|
||||
)
|
||||
return FileResponse(frontend.file("admin/index.html"))
|
||||
except HTTPException as e:
|
||||
return FileResponse(frontend.file("index.html"), status_code=e.status_code)
|
||||
@@ -36,8 +51,13 @@ async def adminapp(auth=Cookie(None)):
|
||||
|
||||
|
||||
@app.get("/orgs")
|
||||
async def admin_list_orgs(auth=Cookie(None)):
|
||||
ctx = await authz.verify(auth, ["auth:admin", "auth:org:*"], match=permutil.has_any)
|
||||
async def admin_list_orgs(request: Request, auth=Cookie(None, alias="__Host-auth")):
|
||||
ctx = await authz.verify(
|
||||
auth,
|
||||
["auth:admin", "auth:org:*"],
|
||||
match=permutil.has_any,
|
||||
host=request.headers.get("host"),
|
||||
)
|
||||
orgs = await db.instance.list_organizations()
|
||||
if "auth:admin" not in ctx.role.permissions:
|
||||
orgs = [o for o in orgs if f"auth:org:{o.uuid}" in ctx.role.permissions]
|
||||
@@ -73,8 +93,12 @@ async def admin_list_orgs(auth=Cookie(None)):
|
||||
|
||||
|
||||
@app.post("/orgs")
|
||||
async def admin_create_org(payload: dict = Body(...), auth=Cookie(None)):
|
||||
await authz.verify(auth, ["auth:admin"])
|
||||
async def admin_create_org(
|
||||
request: Request, payload: dict = Body(...), auth=Cookie(None, alias="__Host-auth")
|
||||
):
|
||||
await authz.verify(
|
||||
auth, ["auth:admin"], host=request.headers.get("host"), match=permutil.has_all
|
||||
)
|
||||
from ..db import Org as OrgDC # local import to avoid cycles
|
||||
from ..db import Role as RoleDC # local import to avoid cycles
|
||||
|
||||
@@ -99,10 +123,16 @@ async def admin_create_org(payload: dict = Body(...), auth=Cookie(None)):
|
||||
|
||||
@app.put("/orgs/{org_uuid}")
|
||||
async def admin_update_org(
|
||||
org_uuid: UUID, payload: dict = Body(...), auth=Cookie(None)
|
||||
org_uuid: UUID,
|
||||
request: Request,
|
||||
payload: dict = Body(...),
|
||||
auth=Cookie(None, alias="__Host-auth"),
|
||||
):
|
||||
ctx = await authz.verify(
|
||||
auth, ["auth:admin", f"auth:org:{org_uuid}"], match=permutil.has_any
|
||||
auth,
|
||||
["auth:admin", f"auth:org:{org_uuid}"],
|
||||
match=permutil.has_any,
|
||||
host=request.headers.get("host"),
|
||||
)
|
||||
from ..db import Org as OrgDC # local import to avoid cycles
|
||||
|
||||
@@ -129,9 +159,14 @@ async def admin_update_org(
|
||||
|
||||
|
||||
@app.delete("/orgs/{org_uuid}")
|
||||
async def admin_delete_org(org_uuid: UUID, auth=Cookie(None)):
|
||||
async def admin_delete_org(
|
||||
org_uuid: UUID, request: Request, auth=Cookie(None, alias="__Host-auth")
|
||||
):
|
||||
ctx = await authz.verify(
|
||||
auth, ["auth:admin", f"auth:org:{org_uuid}"], match=permutil.has_any
|
||||
auth,
|
||||
["auth:admin", f"auth:org:{org_uuid}"],
|
||||
match=permutil.has_any,
|
||||
host=request.headers.get("host"),
|
||||
)
|
||||
if ctx.org.uuid == org_uuid:
|
||||
raise ValueError("Cannot delete the organization you belong to")
|
||||
@@ -156,18 +191,28 @@ async def admin_delete_org(org_uuid: UUID, auth=Cookie(None)):
|
||||
|
||||
@app.post("/orgs/{org_uuid}/permission")
|
||||
async def admin_add_org_permission(
|
||||
org_uuid: UUID, permission_id: str, auth=Cookie(None)
|
||||
org_uuid: UUID,
|
||||
permission_id: str,
|
||||
request: Request,
|
||||
auth=Cookie(None, alias="__Host-auth"),
|
||||
):
|
||||
await authz.verify(auth, ["auth:admin"])
|
||||
await authz.verify(
|
||||
auth, ["auth:admin"], host=request.headers.get("host"), match=permutil.has_all
|
||||
)
|
||||
await db.instance.add_permission_to_organization(str(org_uuid), permission_id)
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.delete("/orgs/{org_uuid}/permission")
|
||||
async def admin_remove_org_permission(
|
||||
org_uuid: UUID, permission_id: str, auth=Cookie(None)
|
||||
org_uuid: UUID,
|
||||
permission_id: str,
|
||||
request: Request,
|
||||
auth=Cookie(None, alias="__Host-auth"),
|
||||
):
|
||||
await authz.verify(auth, ["auth:admin"])
|
||||
await authz.verify(
|
||||
auth, ["auth:admin"], host=request.headers.get("host"), match=permutil.has_all
|
||||
)
|
||||
await db.instance.remove_permission_from_organization(str(org_uuid), permission_id)
|
||||
return {"status": "ok"}
|
||||
|
||||
@@ -177,10 +222,16 @@ async def admin_remove_org_permission(
|
||||
|
||||
@app.post("/orgs/{org_uuid}/roles")
|
||||
async def admin_create_role(
|
||||
org_uuid: UUID, payload: dict = Body(...), auth=Cookie(None)
|
||||
org_uuid: UUID,
|
||||
request: Request,
|
||||
payload: dict = Body(...),
|
||||
auth=Cookie(None, alias="__Host-auth"),
|
||||
):
|
||||
await authz.verify(
|
||||
auth, ["auth:admin", f"auth:org:{org_uuid}"], match=permutil.has_any
|
||||
auth,
|
||||
["auth:admin", f"auth:org:{org_uuid}"],
|
||||
match=permutil.has_any,
|
||||
host=request.headers.get("host"),
|
||||
)
|
||||
from ..db import Role as RoleDC
|
||||
|
||||
@@ -205,11 +256,18 @@ async def admin_create_role(
|
||||
|
||||
@app.put("/orgs/{org_uuid}/roles/{role_uuid}")
|
||||
async def admin_update_role(
|
||||
org_uuid: UUID, role_uuid: UUID, payload: dict = Body(...), auth=Cookie(None)
|
||||
org_uuid: UUID,
|
||||
role_uuid: UUID,
|
||||
request: Request,
|
||||
payload: dict = Body(...),
|
||||
auth=Cookie(None, alias="__Host-auth"),
|
||||
):
|
||||
# Verify caller is global admin or admin of provided org
|
||||
ctx = await authz.verify(
|
||||
auth, ["auth:admin", f"auth:org:{org_uuid}"], match=permutil.has_any
|
||||
auth,
|
||||
["auth:admin", f"auth:org:{org_uuid}"],
|
||||
match=permutil.has_any,
|
||||
host=request.headers.get("host"),
|
||||
)
|
||||
role = await db.instance.get_role(role_uuid)
|
||||
if role.org_uuid != org_uuid:
|
||||
@@ -247,9 +305,17 @@ async def admin_update_role(
|
||||
|
||||
|
||||
@app.delete("/orgs/{org_uuid}/roles/{role_uuid}")
|
||||
async def admin_delete_role(org_uuid: UUID, role_uuid: UUID, auth=Cookie(None)):
|
||||
async def admin_delete_role(
|
||||
org_uuid: UUID,
|
||||
role_uuid: UUID,
|
||||
request: Request,
|
||||
auth=Cookie(None, alias="__Host-auth"),
|
||||
):
|
||||
ctx = await authz.verify(
|
||||
auth, ["auth:admin", f"auth:org:{org_uuid}"], match=permutil.has_any
|
||||
auth,
|
||||
["auth:admin", f"auth:org:{org_uuid}"],
|
||||
match=permutil.has_any,
|
||||
host=request.headers.get("host"),
|
||||
)
|
||||
role = await db.instance.get_role(role_uuid)
|
||||
if role.org_uuid != org_uuid:
|
||||
@@ -268,10 +334,16 @@ async def admin_delete_role(org_uuid: UUID, role_uuid: UUID, auth=Cookie(None)):
|
||||
|
||||
@app.post("/orgs/{org_uuid}/users")
|
||||
async def admin_create_user(
|
||||
org_uuid: UUID, payload: dict = Body(...), auth=Cookie(None)
|
||||
org_uuid: UUID,
|
||||
request: Request,
|
||||
payload: dict = Body(...),
|
||||
auth=Cookie(None, alias="__Host-auth"),
|
||||
):
|
||||
await authz.verify(
|
||||
auth, ["auth:admin", f"auth:org:{org_uuid}"], match=permutil.has_any
|
||||
auth,
|
||||
["auth:admin", f"auth:org:{org_uuid}"],
|
||||
match=permutil.has_any,
|
||||
host=request.headers.get("host"),
|
||||
)
|
||||
display_name = payload.get("display_name")
|
||||
role_name = payload.get("role")
|
||||
@@ -297,10 +369,17 @@ async def admin_create_user(
|
||||
|
||||
@app.put("/orgs/{org_uuid}/users/{user_uuid}/role")
|
||||
async def admin_update_user_role(
|
||||
org_uuid: UUID, user_uuid: UUID, payload: dict = Body(...), auth=Cookie(None)
|
||||
org_uuid: UUID,
|
||||
user_uuid: UUID,
|
||||
request: Request,
|
||||
payload: dict = Body(...),
|
||||
auth=Cookie(None, alias="__Host-auth"),
|
||||
):
|
||||
ctx = await authz.verify(
|
||||
auth, ["auth:admin", f"auth:org:{org_uuid}"], match=permutil.has_any
|
||||
auth,
|
||||
["auth:admin", f"auth:org:{org_uuid}"],
|
||||
match=permutil.has_any,
|
||||
host=request.headers.get("host"),
|
||||
)
|
||||
new_role = payload.get("role")
|
||||
if not new_role:
|
||||
@@ -334,7 +413,10 @@ async def admin_update_user_role(
|
||||
|
||||
@app.post("/orgs/{org_uuid}/users/{user_uuid}/create-link")
|
||||
async def admin_create_user_registration_link(
|
||||
org_uuid: UUID, user_uuid: UUID, request: Request, auth=Cookie(None)
|
||||
org_uuid: UUID,
|
||||
user_uuid: UUID,
|
||||
request: Request,
|
||||
auth=Cookie(None, alias="__Host-auth"),
|
||||
):
|
||||
try:
|
||||
user_org, _role_name = await db.instance.get_user_organization(user_uuid)
|
||||
@@ -343,7 +425,10 @@ async def admin_create_user_registration_link(
|
||||
if user_org.uuid != org_uuid:
|
||||
raise HTTPException(status_code=404, detail="User not found in organization")
|
||||
ctx = await authz.verify(
|
||||
auth, ["auth:admin", f"auth:org:{org_uuid}"], match=permutil.has_any
|
||||
auth,
|
||||
["auth:admin", f"auth:org:{org_uuid}"],
|
||||
match=permutil.has_any,
|
||||
host=request.headers.get("host"),
|
||||
)
|
||||
if (
|
||||
"auth:admin" not in ctx.role.permissions
|
||||
@@ -351,20 +436,33 @@ async def admin_create_user_registration_link(
|
||||
):
|
||||
raise HTTPException(status_code=403, detail="Insufficient permissions")
|
||||
token = passphrase.generate()
|
||||
await db.instance.create_session(
|
||||
expiry = reset_expires()
|
||||
await db.instance.create_reset_token(
|
||||
user_uuid=user_uuid,
|
||||
key=tokens.reset_key(token),
|
||||
expires=expires(),
|
||||
info={"type": "device addition", "created_by_admin": True},
|
||||
expiry=expiry,
|
||||
token_type="device addition",
|
||||
)
|
||||
url = hostutil.reset_link_url(
|
||||
token, request.url.scheme, request.headers.get("host")
|
||||
)
|
||||
return {"url": url, "expires": expires().isoformat()}
|
||||
return {
|
||||
"url": url,
|
||||
"expires": (
|
||||
expiry.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||
if expiry.tzinfo
|
||||
else expiry.replace(tzinfo=timezone.utc).isoformat().replace("+00:00", "Z")
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@app.get("/orgs/{org_uuid}/users/{user_uuid}")
|
||||
async def admin_get_user_detail(org_uuid: UUID, user_uuid: UUID, auth=Cookie(None)):
|
||||
async def admin_get_user_detail(
|
||||
org_uuid: UUID,
|
||||
user_uuid: UUID,
|
||||
request: Request,
|
||||
auth=Cookie(None, alias="__Host-auth"),
|
||||
):
|
||||
try:
|
||||
user_org, role_name = await db.instance.get_user_organization(user_uuid)
|
||||
except ValueError:
|
||||
@@ -372,7 +470,10 @@ async def admin_get_user_detail(org_uuid: UUID, user_uuid: UUID, auth=Cookie(Non
|
||||
if user_org.uuid != org_uuid:
|
||||
raise HTTPException(status_code=404, detail="User not found in organization")
|
||||
ctx = await authz.verify(
|
||||
auth, ["auth:admin", f"auth:org:{org_uuid}"], match=permutil.has_any
|
||||
auth,
|
||||
["auth:admin", f"auth:org:{org_uuid}"],
|
||||
match=permutil.has_any,
|
||||
host=request.headers.get("host"),
|
||||
)
|
||||
if (
|
||||
"auth:admin" not in ctx.role.permissions
|
||||
@@ -394,9 +495,41 @@ async def admin_get_user_detail(org_uuid: UUID, user_uuid: UUID, auth=Cookie(Non
|
||||
{
|
||||
"credential_uuid": str(c.uuid),
|
||||
"aaguid": aaguid_str,
|
||||
"created_at": c.created_at.isoformat(),
|
||||
"last_used": c.last_used.isoformat() if c.last_used else None,
|
||||
"last_verified": c.last_verified.isoformat()
|
||||
"created_at": (
|
||||
c.created_at.astimezone(timezone.utc)
|
||||
.isoformat()
|
||||
.replace("+00:00", "Z")
|
||||
if c.created_at.tzinfo
|
||||
else c.created_at.replace(tzinfo=timezone.utc)
|
||||
.isoformat()
|
||||
.replace("+00:00", "Z")
|
||||
),
|
||||
"last_used": (
|
||||
c.last_used.astimezone(timezone.utc)
|
||||
.isoformat()
|
||||
.replace("+00:00", "Z")
|
||||
if c.last_used and c.last_used.tzinfo
|
||||
else (
|
||||
c.last_used.replace(tzinfo=timezone.utc)
|
||||
.isoformat()
|
||||
.replace("+00:00", "Z")
|
||||
if c.last_used
|
||||
else None
|
||||
)
|
||||
),
|
||||
"last_verified": (
|
||||
c.last_verified.astimezone(timezone.utc)
|
||||
.isoformat()
|
||||
.replace("+00:00", "Z")
|
||||
if c.last_verified and c.last_verified.tzinfo
|
||||
else (
|
||||
c.last_verified.replace(tzinfo=timezone.utc)
|
||||
.isoformat()
|
||||
.replace("+00:00", "Z")
|
||||
if c.last_verified
|
||||
else None
|
||||
)
|
||||
)
|
||||
if c.last_verified
|
||||
else None,
|
||||
"sign_count": c.sign_count,
|
||||
@@ -405,21 +538,77 @@ async def admin_get_user_detail(org_uuid: UUID, user_uuid: UUID, auth=Cookie(Non
|
||||
from .. import aaguid as aaguid_mod
|
||||
|
||||
aaguid_info = aaguid_mod.filter(aaguids)
|
||||
|
||||
# Get sessions for the user
|
||||
normalized_request_host = hostutil.normalize_host(request.headers.get("host"))
|
||||
session_records = await db.instance.list_sessions_for_user(user_uuid)
|
||||
current_session_key = session_key(auth)
|
||||
sessions_payload: list[dict] = []
|
||||
for entry in session_records:
|
||||
sessions_payload.append(
|
||||
{
|
||||
"id": encode_session_key(entry.key),
|
||||
"host": entry.host,
|
||||
"ip": entry.ip,
|
||||
"user_agent": useragent.compact_user_agent(entry.user_agent),
|
||||
"last_renewed": (
|
||||
entry.renewed.astimezone(timezone.utc)
|
||||
.isoformat()
|
||||
.replace("+00:00", "Z")
|
||||
if entry.renewed.tzinfo
|
||||
else entry.renewed.replace(tzinfo=timezone.utc)
|
||||
.isoformat()
|
||||
.replace("+00:00", "Z")
|
||||
),
|
||||
"is_current": entry.key == current_session_key,
|
||||
"is_current_host": bool(
|
||||
normalized_request_host
|
||||
and entry.host
|
||||
and entry.host == normalized_request_host
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"display_name": user.display_name,
|
||||
"org": {"display_name": user_org.display_name},
|
||||
"role": role_name,
|
||||
"visits": user.visits,
|
||||
"created_at": user.created_at.isoformat() if user.created_at else None,
|
||||
"last_seen": user.last_seen.isoformat() if user.last_seen else None,
|
||||
"created_at": (
|
||||
user.created_at.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||
if user.created_at and user.created_at.tzinfo
|
||||
else (
|
||||
user.created_at.replace(tzinfo=timezone.utc)
|
||||
.isoformat()
|
||||
.replace("+00:00", "Z")
|
||||
if user.created_at
|
||||
else None
|
||||
)
|
||||
),
|
||||
"last_seen": (
|
||||
user.last_seen.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||
if user.last_seen and user.last_seen.tzinfo
|
||||
else (
|
||||
user.last_seen.replace(tzinfo=timezone.utc)
|
||||
.isoformat()
|
||||
.replace("+00:00", "Z")
|
||||
if user.last_seen
|
||||
else None
|
||||
)
|
||||
),
|
||||
"credentials": creds,
|
||||
"aaguid_info": aaguid_info,
|
||||
"sessions": sessions_payload,
|
||||
}
|
||||
|
||||
|
||||
@app.put("/orgs/{org_uuid}/users/{user_uuid}/display-name")
|
||||
async def admin_update_user_display_name(
|
||||
org_uuid: UUID, user_uuid: UUID, payload: dict = Body(...), auth=Cookie(None)
|
||||
org_uuid: UUID,
|
||||
user_uuid: UUID,
|
||||
request: Request,
|
||||
payload: dict = Body(...),
|
||||
auth=Cookie(None, alias="__Host-auth"),
|
||||
):
|
||||
try:
|
||||
user_org, _role_name = await db.instance.get_user_organization(user_uuid)
|
||||
@@ -428,7 +617,10 @@ async def admin_update_user_display_name(
|
||||
if user_org.uuid != org_uuid:
|
||||
raise HTTPException(status_code=404, detail="User not found in organization")
|
||||
ctx = await authz.verify(
|
||||
auth, ["auth:admin", f"auth:org:{org_uuid}"], match=permutil.has_any
|
||||
auth,
|
||||
["auth:admin", f"auth:org:{org_uuid}"],
|
||||
match=permutil.has_any,
|
||||
host=request.headers.get("host"),
|
||||
)
|
||||
if (
|
||||
"auth:admin" not in ctx.role.permissions
|
||||
@@ -446,7 +638,11 @@ async def admin_update_user_display_name(
|
||||
|
||||
@app.delete("/orgs/{org_uuid}/users/{user_uuid}/credentials/{credential_uuid}")
|
||||
async def admin_delete_user_credential(
|
||||
org_uuid: UUID, user_uuid: UUID, credential_uuid: UUID, auth=Cookie(None)
|
||||
org_uuid: UUID,
|
||||
user_uuid: UUID,
|
||||
credential_uuid: UUID,
|
||||
request: Request,
|
||||
auth=Cookie(None, alias="__Host-auth"),
|
||||
):
|
||||
try:
|
||||
user_org, _role_name = await db.instance.get_user_organization(user_uuid)
|
||||
@@ -455,7 +651,10 @@ async def admin_delete_user_credential(
|
||||
if user_org.uuid != org_uuid:
|
||||
raise HTTPException(status_code=404, detail="User not found in organization")
|
||||
ctx = await authz.verify(
|
||||
auth, ["auth:admin", f"auth:org:{org_uuid}"], match=permutil.has_any
|
||||
auth,
|
||||
["auth:admin", f"auth:org:{org_uuid}"],
|
||||
match=permutil.has_any,
|
||||
host=request.headers.get("host"),
|
||||
)
|
||||
if (
|
||||
"auth:admin" not in ctx.role.permissions
|
||||
@@ -470,8 +669,15 @@ async def admin_delete_user_credential(
|
||||
|
||||
|
||||
@app.get("/permissions")
|
||||
async def admin_list_permissions(auth=Cookie(None)):
|
||||
ctx = await authz.verify(auth, ["auth:admin", "auth:org:*"], match=permutil.has_any)
|
||||
async def admin_list_permissions(
|
||||
request: Request, auth=Cookie(None, alias="__Host-auth")
|
||||
):
|
||||
ctx = await authz.verify(
|
||||
auth,
|
||||
["auth:admin", "auth:org:*"],
|
||||
match=permutil.has_any,
|
||||
host=request.headers.get("host"),
|
||||
)
|
||||
perms = await db.instance.list_permissions()
|
||||
|
||||
# Global admins see all permissions
|
||||
@@ -485,8 +691,14 @@ async def admin_list_permissions(auth=Cookie(None)):
|
||||
|
||||
|
||||
@app.post("/permissions")
|
||||
async def admin_create_permission(payload: dict = Body(...), auth=Cookie(None)):
|
||||
await authz.verify(auth, ["auth:admin"])
|
||||
async def admin_create_permission(
|
||||
request: Request,
|
||||
payload: dict = Body(...),
|
||||
auth=Cookie(None, alias="__Host-auth"),
|
||||
):
|
||||
await authz.verify(
|
||||
auth, ["auth:admin"], host=request.headers.get("host"), match=permutil.has_all
|
||||
)
|
||||
from ..db import Permission as PermDC
|
||||
|
||||
perm_id = payload.get("id")
|
||||
@@ -500,9 +712,14 @@ async def admin_create_permission(payload: dict = Body(...), auth=Cookie(None)):
|
||||
|
||||
@app.put("/permission")
|
||||
async def admin_update_permission(
|
||||
permission_id: str, display_name: str, auth=Cookie(None)
|
||||
permission_id: str,
|
||||
display_name: str,
|
||||
request: Request,
|
||||
auth=Cookie(None, alias="__Host-auth"),
|
||||
):
|
||||
await authz.verify(auth, ["auth:admin"])
|
||||
await authz.verify(
|
||||
auth, ["auth:admin"], host=request.headers.get("host"), match=permutil.has_all
|
||||
)
|
||||
from ..db import Permission as PermDC
|
||||
|
||||
if not display_name:
|
||||
@@ -515,8 +732,14 @@ async def admin_update_permission(
|
||||
|
||||
|
||||
@app.post("/permission/rename")
|
||||
async def admin_rename_permission(payload: dict = Body(...), auth=Cookie(None)):
|
||||
await authz.verify(auth, ["auth:admin"])
|
||||
async def admin_rename_permission(
|
||||
request: Request,
|
||||
payload: dict = Body(...),
|
||||
auth=Cookie(None, alias="__Host-auth"),
|
||||
):
|
||||
await authz.verify(
|
||||
auth, ["auth:admin"], host=request.headers.get("host"), match=permutil.has_all
|
||||
)
|
||||
old_id = payload.get("old_id")
|
||||
new_id = payload.get("new_id")
|
||||
display_name = payload.get("display_name")
|
||||
@@ -540,8 +763,14 @@ async def admin_rename_permission(payload: dict = Body(...), auth=Cookie(None)):
|
||||
|
||||
|
||||
@app.delete("/permission")
|
||||
async def admin_delete_permission(permission_id: str, auth=Cookie(None)):
|
||||
await authz.verify(auth, ["auth:admin"])
|
||||
async def admin_delete_permission(
|
||||
permission_id: str,
|
||||
request: Request,
|
||||
auth=Cookie(None, alias="__Host-auth"),
|
||||
):
|
||||
await authz.verify(
|
||||
auth, ["auth:admin"], host=request.headers.get("host"), match=permutil.has_all
|
||||
)
|
||||
querysafe.assert_safe(permission_id, field="permission_id")
|
||||
|
||||
# Sanity check: prevent deleting critical permissions
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import logging
|
||||
from contextlib import suppress
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import (
|
||||
@@ -16,7 +16,7 @@ from fastapi import (
|
||||
from fastapi.responses import JSONResponse
|
||||
from fastapi.security import HTTPBearer
|
||||
|
||||
from passkey.util import frontend
|
||||
from passkey.util import frontend, useragent
|
||||
|
||||
from .. import aaguid
|
||||
from ..authsession import (
|
||||
@@ -26,11 +26,12 @@ from ..authsession import (
|
||||
get_reset,
|
||||
get_session,
|
||||
refresh_session_token,
|
||||
session_expiry,
|
||||
)
|
||||
from ..globals import db
|
||||
from ..globals import passkey as global_passkey
|
||||
from ..util import hostutil, passphrase, permutil, tokens
|
||||
from ..util.tokens import session_key
|
||||
from ..util.tokens import decode_session_key, encode_session_key, session_key
|
||||
from . import authz, session
|
||||
|
||||
bearer_auth = HTTPBearer(auto_error=True)
|
||||
@@ -56,7 +57,10 @@ async def general_exception_handler(_request: Request, exc: Exception):
|
||||
|
||||
@app.post("/validate")
|
||||
async def validate_token(
|
||||
response: Response, perm: list[str] = Query([]), auth=Cookie(None)
|
||||
request: Request,
|
||||
response: Response,
|
||||
perm: list[str] = Query([]),
|
||||
auth=Cookie(None, alias="__Host-auth"),
|
||||
):
|
||||
"""Validate the current session and extend its expiry.
|
||||
|
||||
@@ -64,13 +68,18 @@ async def validate_token(
|
||||
renewed max-age. This keeps active users logged in without needing a separate
|
||||
refresh endpoint.
|
||||
"""
|
||||
ctx = await authz.verify(auth, perm)
|
||||
ctx = await authz.verify(auth, perm, host=request.headers.get("host"))
|
||||
renewed = False
|
||||
if auth:
|
||||
consumed = EXPIRES - (ctx.session.expires - datetime.now())
|
||||
current_expiry = session_expiry(ctx.session)
|
||||
consumed = EXPIRES - (current_expiry - datetime.now())
|
||||
if not timedelta(0) < consumed < _REFRESH_INTERVAL:
|
||||
try:
|
||||
await refresh_session_token(auth)
|
||||
await refresh_session_token(
|
||||
auth,
|
||||
ip=request.client.host if request.client else "",
|
||||
user_agent=request.headers.get("user-agent") or "",
|
||||
)
|
||||
session.set_session_cookie(response, auth)
|
||||
renewed = True
|
||||
except ValueError:
|
||||
@@ -84,7 +93,11 @@ async def validate_token(
|
||||
|
||||
|
||||
@app.get("/forward")
|
||||
async def forward_authentication(perm: list[str] = Query([]), auth=Cookie(None)):
|
||||
async def forward_authentication(
|
||||
request: Request,
|
||||
perm: list[str] = Query([]),
|
||||
auth=Cookie(None, alias="__Host-auth"),
|
||||
):
|
||||
"""Forward auth validation for Caddy/Nginx.
|
||||
|
||||
Query Params:
|
||||
@@ -94,7 +107,7 @@ async def forward_authentication(perm: list[str] = Query([]), auth=Cookie(None))
|
||||
Failure (unauthenticated / unauthorized): 4xx JSON body with detail.
|
||||
"""
|
||||
try:
|
||||
ctx = await authz.verify(auth, perm)
|
||||
ctx = await authz.verify(auth, perm, host=request.headers.get("host"))
|
||||
role_permissions = set(ctx.role.permissions or [])
|
||||
if ctx.permissions:
|
||||
role_permissions.update(permission.id for permission in ctx.permissions)
|
||||
@@ -107,7 +120,17 @@ async def forward_authentication(perm: list[str] = Query([]), auth=Cookie(None))
|
||||
"Remote-Org-Name": ctx.org.display_name,
|
||||
"Remote-Role": str(ctx.role.uuid),
|
||||
"Remote-Role-Name": ctx.role.display_name,
|
||||
"Remote-Session-Expires": ctx.session.expires.isoformat(),
|
||||
"Remote-Session-Expires": (
|
||||
session_expiry(ctx.session)
|
||||
.astimezone(timezone.utc)
|
||||
.isoformat()
|
||||
.replace("+00:00", "Z")
|
||||
if session_expiry(ctx.session).tzinfo
|
||||
else session_expiry(ctx.session)
|
||||
.replace(tzinfo=timezone.utc)
|
||||
.isoformat()
|
||||
.replace("+00:00", "Z")
|
||||
),
|
||||
"Remote-Credential": str(ctx.session.credential_uuid),
|
||||
}
|
||||
return Response(status_code=204, headers=remote_headers)
|
||||
@@ -129,34 +152,43 @@ async def get_settings():
|
||||
|
||||
|
||||
@app.post("/user-info")
|
||||
async def api_user_info(reset: str | None = None, auth=Cookie(None)):
|
||||
async def api_user_info(
|
||||
request: Request, reset: str | None = None, auth=Cookie(None, alias="__Host-auth")
|
||||
):
|
||||
authenticated = False
|
||||
session_record = None
|
||||
reset_token = None
|
||||
try:
|
||||
if reset:
|
||||
if not passphrase.is_well_formed(reset):
|
||||
raise ValueError("Invalid reset token")
|
||||
s = await get_reset(reset)
|
||||
reset_token = await get_reset(reset)
|
||||
target_user_uuid = reset_token.user_uuid
|
||||
else:
|
||||
if auth is None:
|
||||
raise ValueError("Authentication Required")
|
||||
s = await get_session(auth)
|
||||
session_record = await get_session(auth, host=request.headers.get("host"))
|
||||
authenticated = True
|
||||
target_user_uuid = session_record.user_uuid
|
||||
except ValueError as e:
|
||||
raise HTTPException(401, str(e))
|
||||
|
||||
u = await db.instance.get_user_by_uuid(s.user_uuid)
|
||||
u = await db.instance.get_user_by_uuid(target_user_uuid)
|
||||
|
||||
if not authenticated: # minimal response for reset tokens
|
||||
if not authenticated and reset_token: # minimal response for reset tokens
|
||||
return {
|
||||
"authenticated": False,
|
||||
"session_type": s.info.get("type"),
|
||||
"session_type": reset_token.token_type,
|
||||
"user": {"user_uuid": str(u.uuid), "user_name": u.display_name},
|
||||
}
|
||||
|
||||
assert authenticated and auth is not None
|
||||
assert auth is not None
|
||||
assert session_record is not None
|
||||
|
||||
ctx = await permutil.session_context(auth)
|
||||
credential_ids = await db.instance.get_credentials_by_user_uuid(s.user_uuid)
|
||||
ctx = await permutil.session_context(auth, request.headers.get("host"))
|
||||
credential_ids = await db.instance.get_credentials_by_user_uuid(
|
||||
session_record.user_uuid
|
||||
)
|
||||
credentials: list[dict] = []
|
||||
user_aaguids: set[str] = set()
|
||||
for cred_id in credential_ids:
|
||||
@@ -170,13 +202,45 @@ async def api_user_info(reset: str | None = None, auth=Cookie(None)):
|
||||
{
|
||||
"credential_uuid": str(c.uuid),
|
||||
"aaguid": aaguid_str,
|
||||
"created_at": c.created_at.isoformat(),
|
||||
"last_used": c.last_used.isoformat() if c.last_used else None,
|
||||
"last_verified": c.last_verified.isoformat()
|
||||
"created_at": (
|
||||
c.created_at.astimezone(timezone.utc)
|
||||
.isoformat()
|
||||
.replace("+00:00", "Z")
|
||||
if c.created_at.tzinfo
|
||||
else c.created_at.replace(tzinfo=timezone.utc)
|
||||
.isoformat()
|
||||
.replace("+00:00", "Z")
|
||||
),
|
||||
"last_used": (
|
||||
c.last_used.astimezone(timezone.utc)
|
||||
.isoformat()
|
||||
.replace("+00:00", "Z")
|
||||
if c.last_used and c.last_used.tzinfo
|
||||
else (
|
||||
c.last_used.replace(tzinfo=timezone.utc)
|
||||
.isoformat()
|
||||
.replace("+00:00", "Z")
|
||||
if c.last_used
|
||||
else None
|
||||
)
|
||||
),
|
||||
"last_verified": (
|
||||
c.last_verified.astimezone(timezone.utc)
|
||||
.isoformat()
|
||||
.replace("+00:00", "Z")
|
||||
if c.last_verified and c.last_verified.tzinfo
|
||||
else (
|
||||
c.last_verified.replace(tzinfo=timezone.utc)
|
||||
.isoformat()
|
||||
.replace("+00:00", "Z")
|
||||
if c.last_verified
|
||||
else None
|
||||
)
|
||||
)
|
||||
if c.last_verified
|
||||
else None,
|
||||
"sign_count": c.sign_count,
|
||||
"is_current_session": s.credential_uuid == c.uuid,
|
||||
"is_current_session": session_record.credential_uuid == c.uuid,
|
||||
}
|
||||
)
|
||||
credentials.sort(key=lambda cred: cred["created_at"])
|
||||
@@ -204,14 +268,62 @@ async def api_user_info(reset: str | None = None, auth=Cookie(None)):
|
||||
p.startswith("auth:org:") for p in (role_info["permissions"] or [])
|
||||
)
|
||||
|
||||
normalized_request_host = hostutil.normalize_host(request.headers.get("host"))
|
||||
session_records = await db.instance.list_sessions_for_user(session_record.user_uuid)
|
||||
current_session_key = session_key(auth)
|
||||
sessions_payload: list[dict] = []
|
||||
for entry in session_records:
|
||||
sessions_payload.append(
|
||||
{
|
||||
"id": encode_session_key(entry.key),
|
||||
"host": entry.host,
|
||||
"ip": entry.ip,
|
||||
"user_agent": useragent.compact_user_agent(entry.user_agent),
|
||||
"last_renewed": (
|
||||
entry.renewed.astimezone(timezone.utc)
|
||||
.isoformat()
|
||||
.replace("+00:00", "Z")
|
||||
if entry.renewed.tzinfo
|
||||
else entry.renewed.replace(tzinfo=timezone.utc)
|
||||
.isoformat()
|
||||
.replace("+00:00", "Z")
|
||||
),
|
||||
"is_current": entry.key == current_session_key,
|
||||
"is_current_host": bool(
|
||||
normalized_request_host
|
||||
and entry.host
|
||||
and entry.host == normalized_request_host
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"authenticated": True,
|
||||
"session_type": s.info.get("type"),
|
||||
"user": {
|
||||
"user_uuid": str(u.uuid),
|
||||
"user_name": u.display_name,
|
||||
"created_at": u.created_at.isoformat() if u.created_at else None,
|
||||
"last_seen": u.last_seen.isoformat() if u.last_seen else None,
|
||||
"created_at": (
|
||||
u.created_at.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||
if u.created_at and u.created_at.tzinfo
|
||||
else (
|
||||
u.created_at.replace(tzinfo=timezone.utc)
|
||||
.isoformat()
|
||||
.replace("+00:00", "Z")
|
||||
if u.created_at
|
||||
else None
|
||||
)
|
||||
),
|
||||
"last_seen": (
|
||||
u.last_seen.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||
if u.last_seen and u.last_seen.tzinfo
|
||||
else (
|
||||
u.last_seen.replace(tzinfo=timezone.utc)
|
||||
.isoformat()
|
||||
.replace("+00:00", "Z")
|
||||
if u.last_seen
|
||||
else None
|
||||
)
|
||||
),
|
||||
"visits": u.visits,
|
||||
},
|
||||
"org": org_info,
|
||||
@@ -221,14 +333,17 @@ async def api_user_info(reset: str | None = None, auth=Cookie(None)):
|
||||
"is_org_admin": is_org_admin,
|
||||
"credentials": credentials,
|
||||
"aaguid_info": aaguid_info,
|
||||
"sessions": sessions_payload,
|
||||
}
|
||||
|
||||
|
||||
@app.put("/user/display-name")
|
||||
async def user_update_display_name(payload: dict = Body(...), auth=Cookie(None)):
|
||||
async def user_update_display_name(
|
||||
request: Request, payload: dict = Body(...), auth=Cookie(None, alias="__Host-auth")
|
||||
):
|
||||
if not auth:
|
||||
raise HTTPException(status_code=401, detail="Authentication Required")
|
||||
s = await get_session(auth)
|
||||
s = await get_session(auth, host=request.headers.get("host"))
|
||||
new_name = (payload.get("display_name") or "").strip()
|
||||
if not new_name:
|
||||
raise HTTPException(status_code=400, detail="display_name required")
|
||||
@@ -239,18 +354,76 @@ async def user_update_display_name(payload: dict = Body(...), auth=Cookie(None))
|
||||
|
||||
|
||||
@app.post("/logout")
|
||||
async def api_logout(response: Response, auth=Cookie(None)):
|
||||
async def api_logout(
|
||||
request: Request, response: Response, auth=Cookie(None, alias="__Host-auth")
|
||||
):
|
||||
if not auth:
|
||||
return {"message": "Already logged out"}
|
||||
try:
|
||||
await get_session(auth, host=request.headers.get("host"))
|
||||
except ValueError:
|
||||
response.delete_cookie(session.AUTH_COOKIE_NAME, path="/")
|
||||
return {"message": "Already logged out"}
|
||||
with suppress(Exception):
|
||||
await db.instance.delete_session(session_key(auth))
|
||||
response.delete_cookie("auth")
|
||||
response.delete_cookie(session.AUTH_COOKIE_NAME, path="/")
|
||||
return {"message": "Logged out successfully"}
|
||||
|
||||
|
||||
@app.post("/logout-all")
|
||||
async def api_logout_all(
|
||||
request: Request, response: Response, auth=Cookie(None, alias="__Host-auth")
|
||||
):
|
||||
if not auth:
|
||||
return {"message": "Already logged out"}
|
||||
try:
|
||||
s = await get_session(auth, host=request.headers.get("host"))
|
||||
except ValueError:
|
||||
response.delete_cookie(session.AUTH_COOKIE_NAME, path="/")
|
||||
raise HTTPException(status_code=401, detail="Session expired")
|
||||
await db.instance.delete_sessions_for_user(s.user_uuid)
|
||||
response.delete_cookie(session.AUTH_COOKIE_NAME, path="/")
|
||||
return {"message": "Logged out from all hosts"}
|
||||
|
||||
|
||||
@app.delete("/session/{session_id}")
|
||||
async def api_delete_session(
|
||||
request: Request,
|
||||
response: Response,
|
||||
session_id: str,
|
||||
auth=Cookie(None, alias="__Host-auth"),
|
||||
):
|
||||
if not auth:
|
||||
raise HTTPException(status_code=401, detail="Authentication Required")
|
||||
try:
|
||||
current_session = await get_session(auth, host=request.headers.get("host"))
|
||||
except ValueError as exc:
|
||||
response.delete_cookie(session.AUTH_COOKIE_NAME, path="/")
|
||||
raise HTTPException(status_code=401, detail="Session expired") from exc
|
||||
|
||||
try:
|
||||
target_key = decode_session_key(session_id)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(
|
||||
status_code=400, detail="Invalid session identifier"
|
||||
) from exc
|
||||
|
||||
target_session = await db.instance.get_session(target_key)
|
||||
if not target_session or target_session.user_uuid != current_session.user_uuid:
|
||||
raise HTTPException(status_code=404, detail="Session not found")
|
||||
|
||||
await db.instance.delete_session(target_key)
|
||||
current_terminated = target_key == session_key(auth)
|
||||
if current_terminated:
|
||||
response.delete_cookie(session.AUTH_COOKIE_NAME, path="/")
|
||||
return {"status": "ok", "current_session_terminated": current_terminated}
|
||||
|
||||
|
||||
@app.post("/set-session")
|
||||
async def api_set_session(response: Response, auth=Depends(bearer_auth)):
|
||||
user = await get_session(auth.credentials)
|
||||
async def api_set_session(
|
||||
request: Request, response: Response, auth=Depends(bearer_auth)
|
||||
):
|
||||
user = await get_session(auth.credentials, host=request.headers.get("host"))
|
||||
session.set_session_cookie(response, auth.credentials)
|
||||
return {
|
||||
"message": "Session cookie set successfully",
|
||||
@@ -259,20 +432,23 @@ async def api_set_session(response: Response, auth=Depends(bearer_auth)):
|
||||
|
||||
|
||||
@app.delete("/credential/{uuid}")
|
||||
async def api_delete_credential(uuid: UUID, auth: str = Cookie(None)):
|
||||
await delete_credential(uuid, auth)
|
||||
async def api_delete_credential(
|
||||
request: Request, uuid: UUID, auth: str = Cookie(None, alias="__Host-auth")
|
||||
):
|
||||
await delete_credential(uuid, auth, host=request.headers.get("host"))
|
||||
return {"message": "Credential deleted successfully"}
|
||||
|
||||
|
||||
@app.post("/create-link")
|
||||
async def api_create_link(request: Request, auth=Cookie(None)):
|
||||
s = await get_session(auth)
|
||||
async def api_create_link(request: Request, auth=Cookie(None, alias="__Host-auth")):
|
||||
s = await get_session(auth, host=request.headers.get("host"))
|
||||
token = passphrase.generate()
|
||||
await db.instance.create_session(
|
||||
expiry = expires()
|
||||
await db.instance.create_reset_token(
|
||||
user_uuid=s.user_uuid,
|
||||
key=tokens.reset_key(token),
|
||||
expires=expires(),
|
||||
info=session.infodict(request, "device addition"),
|
||||
expiry=expiry,
|
||||
token_type="device addition",
|
||||
)
|
||||
url = hostutil.reset_link_url(
|
||||
token, request.url.scheme, request.headers.get("host")
|
||||
@@ -280,5 +456,9 @@ async def api_create_link(request: Request, auth=Cookie(None)):
|
||||
return {
|
||||
"message": "Registration link generated successfully",
|
||||
"url": url,
|
||||
"expires": expires().isoformat(),
|
||||
"expires": (
|
||||
expiry.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||
if expiry.tzinfo
|
||||
else expiry.replace(tzinfo=timezone.utc).isoformat().replace("+00:00", "Z")
|
||||
),
|
||||
}
|
||||
|
||||
@@ -7,7 +7,12 @@ from ..util import permutil
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def verify(auth: str | None, perm: list[str], match=permutil.has_all):
|
||||
async def verify(
|
||||
auth: str | None,
|
||||
perm: list[str],
|
||||
match=permutil.has_all,
|
||||
host: str | None = None,
|
||||
):
|
||||
"""Validate session token and optional list of required permissions.
|
||||
|
||||
Returns the session context.
|
||||
@@ -19,7 +24,7 @@ async def verify(auth: str | None, perm: list[str], match=permutil.has_all):
|
||||
if not auth:
|
||||
raise HTTPException(status_code=401, detail="Authentication required")
|
||||
|
||||
ctx = await permutil.session_context(auth)
|
||||
ctx = await permutil.session_context(auth, host)
|
||||
if not ctx:
|
||||
raise HTTPException(status_code=401, detail="Session not found")
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import logging
|
||||
import os
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import Cookie, FastAPI, HTTPException
|
||||
from fastapi import Cookie, FastAPI, HTTPException, Request
|
||||
from fastapi.responses import FileResponse, RedirectResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
@@ -70,8 +70,8 @@ async def admin_root_redirect():
|
||||
|
||||
|
||||
@app.get("/admin/", include_in_schema=False)
|
||||
async def admin_root(auth=Cookie(None)):
|
||||
return await admin.adminapp(auth) # Delegate to handler of /auth/admin/
|
||||
async def admin_root(request: Request, auth=Cookie(None, alias="__Host-auth")):
|
||||
return await admin.adminapp(request, auth) # Delegate to handler of /auth/admin/
|
||||
|
||||
|
||||
@app.get("/{reset}")
|
||||
|
||||
@@ -63,11 +63,12 @@ async def _resolve_targets(query: str | None):
|
||||
|
||||
async def _create_reset(user, role_name: str):
|
||||
token = passphrase.generate()
|
||||
await _g.db.instance.create_session(
|
||||
expiry = _authsession.reset_expires()
|
||||
await _g.db.instance.create_reset_token(
|
||||
user_uuid=user.uuid,
|
||||
key=_tokens.reset_key(token),
|
||||
expires=_authsession.expires(),
|
||||
info={"type": "manual reset", "role": role_name},
|
||||
expiry=expiry,
|
||||
token_type="manual reset",
|
||||
)
|
||||
return hostutil.reset_link_url(token), token
|
||||
|
||||
|
||||
@@ -12,22 +12,26 @@ from fastapi import Request, Response, WebSocket
|
||||
|
||||
from ..authsession import EXPIRES
|
||||
|
||||
AUTH_COOKIE_NAME = "__Host-auth"
|
||||
|
||||
|
||||
def infodict(request: Request | WebSocket, type: str) -> dict:
|
||||
"""Extract client information from request."""
|
||||
return {
|
||||
"ip": request.client.host if request.client else "",
|
||||
"user_agent": request.headers.get("user-agent", "")[:500],
|
||||
"type": type,
|
||||
"ip": request.client.host if request.client else None,
|
||||
"user_agent": request.headers.get("user-agent", "")[:500] or None,
|
||||
"session_type": type,
|
||||
}
|
||||
|
||||
|
||||
def set_session_cookie(response: Response, token: str) -> None:
|
||||
"""Set the session token as an HTTP-only cookie."""
|
||||
response.set_cookie(
|
||||
key="auth",
|
||||
key=AUTH_COOKIE_NAME,
|
||||
value=token,
|
||||
max_age=int(EXPIRES.total_seconds()),
|
||||
httponly=True,
|
||||
secure=True,
|
||||
path="/",
|
||||
samesite="lax",
|
||||
)
|
||||
|
||||
@@ -5,9 +5,9 @@ from uuid import UUID
|
||||
from fastapi import Cookie, FastAPI, WebSocket, WebSocketDisconnect
|
||||
from webauthn.helpers.exceptions import InvalidAuthenticationResponse
|
||||
|
||||
from ..authsession import create_session, expires, get_reset, get_session
|
||||
from ..authsession import create_session, get_reset, get_session
|
||||
from ..globals import db, passkey
|
||||
from ..util import passphrase
|
||||
from ..util import hostutil, passphrase
|
||||
from ..util.tokens import create_token, session_key
|
||||
from .session import infodict
|
||||
|
||||
@@ -56,7 +56,10 @@ async def register_chat(
|
||||
@app.websocket("/register")
|
||||
@websocket_error_handler
|
||||
async def websocket_register_add(
|
||||
ws: WebSocket, reset: str | None = None, name: str | None = None, auth=Cookie(None)
|
||||
ws: WebSocket,
|
||||
reset: str | None = None,
|
||||
name: str | None = None,
|
||||
auth=Cookie(None, alias="__Host-auth"),
|
||||
):
|
||||
"""Register a new credential for an existing user.
|
||||
|
||||
@@ -65,6 +68,9 @@ async def websocket_register_add(
|
||||
- Reset token supplied as ?reset=... (auth cookie ignored)
|
||||
"""
|
||||
origin = ws.headers["origin"]
|
||||
host = hostutil.normalize_host(ws.headers.get("host"))
|
||||
if host is None:
|
||||
raise ValueError("Missing host header")
|
||||
if reset is not None:
|
||||
if not passphrase.is_well_formed(reset):
|
||||
raise ValueError("Invalid reset token")
|
||||
@@ -72,7 +78,7 @@ async def websocket_register_add(
|
||||
else:
|
||||
if not auth:
|
||||
raise ValueError("Authentication Required")
|
||||
s = await get_session(auth)
|
||||
s = await get_session(auth, host=host)
|
||||
user_uuid = s.user_uuid
|
||||
|
||||
# Get user information and determine effective user_name for this registration
|
||||
@@ -89,14 +95,16 @@ async def websocket_register_add(
|
||||
|
||||
# Create a new session and store everything in database
|
||||
token = create_token()
|
||||
metadata = infodict(ws, "authenticated")
|
||||
await db.instance.create_credential_session( # type: ignore[attr-defined]
|
||||
user_uuid=user_uuid,
|
||||
credential=credential,
|
||||
reset_key=(s.key if reset is not None else None),
|
||||
session_key=session_key(token),
|
||||
session_expires=expires(),
|
||||
session_info=infodict(ws, "authenticated"),
|
||||
display_name=user_name,
|
||||
host=host,
|
||||
ip=metadata.get("ip"),
|
||||
user_agent=metadata.get("user_agent"),
|
||||
)
|
||||
auth = token
|
||||
|
||||
@@ -115,6 +123,9 @@ async def websocket_register_add(
|
||||
@websocket_error_handler
|
||||
async def websocket_authenticate(ws: WebSocket):
|
||||
origin = ws.headers["origin"]
|
||||
host = hostutil.normalize_host(ws.headers.get("host"))
|
||||
if host is None:
|
||||
raise ValueError("Missing host header")
|
||||
options, challenge = passkey.instance.auth_generate_options()
|
||||
await ws.send_json(options)
|
||||
# Wait for the client to use his authenticator to authenticate
|
||||
@@ -128,10 +139,13 @@ async def websocket_authenticate(ws: WebSocket):
|
||||
|
||||
# Create a session token for the authenticated user
|
||||
assert stored_cred.uuid is not None
|
||||
metadata = infodict(ws, "auth")
|
||||
token = await create_session(
|
||||
user_uuid=stored_cred.user_uuid,
|
||||
info=infodict(ws, "auth"),
|
||||
credential_uuid=stored_cred.uuid,
|
||||
host=host,
|
||||
ip=metadata.get("ip") or "",
|
||||
user_agent=metadata.get("user_agent") or "",
|
||||
)
|
||||
|
||||
await ws.send_json(
|
||||
|
||||
@@ -8,7 +8,7 @@ This module provides a unified interface for WebAuthn operations including:
|
||||
"""
|
||||
|
||||
import json
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
from urllib.parse import urlparse
|
||||
from uuid import UUID
|
||||
|
||||
@@ -163,7 +163,7 @@ class Passkey:
|
||||
aaguid=UUID(registration.aaguid),
|
||||
public_key=registration.credential_public_key,
|
||||
sign_count=registration.sign_count,
|
||||
created_at=datetime.now(),
|
||||
created_at=datetime.now(timezone.utc),
|
||||
)
|
||||
|
||||
### Authentication Methods ###
|
||||
@@ -227,7 +227,7 @@ class Passkey:
|
||||
credential_current_sign_count=stored_cred.sign_count,
|
||||
)
|
||||
stored_cred.sign_count = verification.new_sign_count
|
||||
now = datetime.now()
|
||||
now = datetime.now(timezone.utc)
|
||||
stored_cred.last_used = now
|
||||
if verification.user_verified:
|
||||
stored_cred.last_verified = now
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import os
|
||||
from functools import lru_cache
|
||||
from urllib.parse import urlparse
|
||||
from urllib.parse import urlparse, urlsplit
|
||||
|
||||
from ..globals import passkey as global_passkey
|
||||
|
||||
@@ -70,3 +70,17 @@ def reset_link_url(
|
||||
|
||||
def reload_config() -> None:
|
||||
_load_config.cache_clear()
|
||||
|
||||
|
||||
def normalize_host(raw_host: str | None) -> str | None:
|
||||
"""Normalize a Host header or hostname by stripping port and lowercasing."""
|
||||
if not raw_host:
|
||||
return None
|
||||
candidate = raw_host.strip()
|
||||
if not candidate:
|
||||
return None
|
||||
# Ensure urlsplit can parse bare hosts (prepend //)
|
||||
parsed = urlsplit(candidate if "//" in candidate else f"//{candidate}")
|
||||
host = parsed.hostname or parsed.path or ""
|
||||
host = host.strip("[]") # Remove IPv6 brackets if present
|
||||
return host.lower() if host else None
|
||||
|
||||
@@ -4,6 +4,7 @@ from collections.abc import Sequence
|
||||
from fnmatch import fnmatchcase
|
||||
|
||||
from ..globals import db
|
||||
from .hostutil import normalize_host
|
||||
from .tokens import session_key
|
||||
|
||||
__all__ = ["has_any", "has_all", "session_context"]
|
||||
@@ -24,5 +25,8 @@ def has_all(ctx, patterns: Sequence[str]) -> bool:
|
||||
return all(_match(ctx.role.permissions, patterns)) if ctx else False
|
||||
|
||||
|
||||
async def session_context(auth: str | None):
|
||||
return await db.instance.get_session_context(session_key(auth)) if auth else None
|
||||
async def session_context(auth: str | None, host: str | None = None):
|
||||
if not auth:
|
||||
return None
|
||||
normalized_host = normalize_host(host) if host else None
|
||||
return await db.instance.get_session_context(session_key(auth), normalized_host)
|
||||
|
||||
@@ -15,6 +15,25 @@ def session_key(token: str) -> bytes:
|
||||
return b"sess" + base64.urlsafe_b64decode(token)
|
||||
|
||||
|
||||
def encode_session_key(key: bytes) -> str:
|
||||
"""Encode an opaque session key for external representation."""
|
||||
return base64.urlsafe_b64encode(key).decode().rstrip("=")
|
||||
|
||||
|
||||
def decode_session_key(encoded: str) -> bytes:
|
||||
"""Decode an opaque session key from its public representation."""
|
||||
if not encoded:
|
||||
raise ValueError("Invalid session identifier")
|
||||
padding = "=" * (-len(encoded) % 4)
|
||||
try:
|
||||
raw = base64.urlsafe_b64decode(encoded + padding)
|
||||
except Exception as exc: # pragma: no cover - defensive
|
||||
raise ValueError("Invalid session identifier") from exc
|
||||
if not raw.startswith(b"sess"):
|
||||
raise ValueError("Invalid session identifier")
|
||||
return raw
|
||||
|
||||
|
||||
def reset_key(passphrase: str) -> bytes:
|
||||
if not is_well_formed(passphrase):
|
||||
raise ValueError(
|
||||
|
||||
10
passkey/util/useragent.py
Normal file
10
passkey/util/useragent.py
Normal 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()
|
||||
@@ -18,6 +18,7 @@ dependencies = [
|
||||
"aiosqlite>=0.19.0",
|
||||
"uuid7-standard>=1.0.0",
|
||||
"pyjwt>=2.8.0",
|
||||
"user-agents>=2.2.0",
|
||||
]
|
||||
requires-python = ">=3.10"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user