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