Compare commits
No commits in common. "3e5c0065d5f6b357ce550e877094b67d09c56453" and "f3e3679b6d5cbd74bfce4800195416b1689117cd" have entirely different histories.
3e5c0065d5
...
f3e3679b6d
@ -20,22 +20,12 @@ import ResetView from '@/components/ResetView.vue'
|
|||||||
const store = useAuthStore()
|
const store = useAuthStore()
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
// Was an error message passed in the URL hash?
|
// Was an error message passed in the URL?
|
||||||
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')
|
||||||
history.replaceState(null, '', location.pathname)
|
history.replaceState(null, '', location.pathname)
|
||||||
}
|
}
|
||||||
// Capture reset token from query parameter and then remove it
|
|
||||||
const params = new URLSearchParams(location.search)
|
|
||||||
const reset = params.get('reset')
|
|
||||||
if (reset) {
|
|
||||||
store.resetToken = reset
|
|
||||||
// Remove query param to avoid lingering in history / clipboard
|
|
||||||
const targetPath = '/auth/'
|
|
||||||
const currentPath = location.pathname.endsWith('/') ? location.pathname : location.pathname + '/'
|
|
||||||
history.replaceState(null, '', currentPath.startsWith('/auth') ? '/auth/' : targetPath)
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
await store.loadUserInfo()
|
await store.loadUserInfo()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
@ -70,48 +70,6 @@ function availableOrgsForPermission(pid) {
|
|||||||
return orgs.value.filter(o => !o.permissions.includes(pid))
|
return orgs.value.filter(o => !o.permissions.includes(pid))
|
||||||
}
|
}
|
||||||
|
|
||||||
async function renamePermissionDisplay(p) {
|
|
||||||
const newName = prompt('New display name', p.display_name)
|
|
||||||
if (!newName || newName === p.display_name) return
|
|
||||||
try {
|
|
||||||
const body = { id: p.id, display_name: newName }
|
|
||||||
const res = await fetch(`/auth/admin/permission?permission_id=${encodeURIComponent(p.id)}`, {
|
|
||||||
method: 'PUT',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(body)
|
|
||||||
})
|
|
||||||
const data = await res.json()
|
|
||||||
if (data.detail) throw new Error(data.detail)
|
|
||||||
await refreshPermissionsContext()
|
|
||||||
} catch (e) {
|
|
||||||
alert(e.message || 'Failed to rename display name')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function renamePermissionId(p) {
|
|
||||||
const newId = prompt('New permission id', p.id)
|
|
||||||
if (!newId || newId === p.id) return
|
|
||||||
try {
|
|
||||||
const body = { old_id: p.id, new_id: newId, display_name: p.display_name }
|
|
||||||
const res = await fetch('/auth/admin/permission/rename', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(body)
|
|
||||||
})
|
|
||||||
let data
|
|
||||||
try { data = await res.json() } catch(_) { data = {} }
|
|
||||||
if (!res.ok || data.detail) throw new Error(data.detail || data.error || `Failed (${res.status})`)
|
|
||||||
await refreshPermissionsContext()
|
|
||||||
} catch (e) {
|
|
||||||
alert((e && e.message) ? e.message : 'Failed to rename permission id')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function refreshPermissionsContext() {
|
|
||||||
// Reload both lists so All Permissions table shows new associations promptly.
|
|
||||||
await Promise.all([loadPermissions(), loadOrgs()])
|
|
||||||
}
|
|
||||||
|
|
||||||
async function attachPermissionToOrg(pid, orgUuid) {
|
async function attachPermissionToOrg(pid, orgUuid) {
|
||||||
if (!orgUuid) return
|
if (!orgUuid) return
|
||||||
try {
|
try {
|
||||||
@ -226,10 +184,6 @@ async function updateOrg(org) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function deleteOrg(org) {
|
async function deleteOrg(org) {
|
||||||
if (!info.value?.is_global_admin) {
|
|
||||||
alert('Only global admins may delete organizations.')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (!confirm(`Delete organization ${org.display_name}?`)) return
|
if (!confirm(`Delete organization ${org.display_name}?`)) return
|
||||||
const res = await fetch(`/auth/admin/orgs/${org.uuid}`, { method: 'DELETE' })
|
const res = await fetch(`/auth/admin/orgs/${org.uuid}`, { method: 'DELETE' })
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
@ -632,8 +586,8 @@ async function toggleRolePermission(role, permId, checked) {
|
|||||||
<div class="perm-grid-head center">Actions</div>
|
<div class="perm-grid-head center">Actions</div>
|
||||||
<template v-for="p in [...permissions].sort((a,b)=> a.id.localeCompare(b.id))" :key="p.id">
|
<template v-for="p in [...permissions].sort((a,b)=> a.id.localeCompare(b.id))" :key="p.id">
|
||||||
<div class="perm-cell perm-name" :title="p.id">
|
<div class="perm-cell perm-name" :title="p.id">
|
||||||
<div class="perm-title-line">{{ p.display_name }}</div>
|
<span class="perm-title">{{ p.display_name }}</span>
|
||||||
<div class="perm-id-line muted">{{ p.id }}</div>
|
<span class="perm-id muted">({{ p.id }})</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="perm-cell perm-orgs" :title="permissionSummary[p.id]?.orgs?.map(o=>o.display_name).join(', ') || ''">
|
<div class="perm-cell perm-orgs" :title="permissionSummary[p.id]?.orgs?.map(o=>o.display_name).join(', ') || ''">
|
||||||
<template v-if="permissionSummary[p.id]">
|
<template v-if="permissionSummary[p.id]">
|
||||||
@ -672,8 +626,7 @@ async function toggleRolePermission(role, permId, checked) {
|
|||||||
</div>
|
</div>
|
||||||
<div class="perm-cell perm-users center">{{ permissionSummary[p.id]?.userCount || 0 }}</div>
|
<div class="perm-cell perm-users center">{{ permissionSummary[p.id]?.userCount || 0 }}</div>
|
||||||
<div class="perm-cell perm-actions center">
|
<div class="perm-cell perm-actions center">
|
||||||
<button @click="renamePermissionDisplay(p)" class="icon-btn" aria-label="Change display name" title="Change display name">✏️</button>
|
<button @click="updatePermission(p)" class="icon-btn" aria-label="Rename permission" title="Rename permission">✏️</button>
|
||||||
<button @click="renamePermissionId(p)" class="icon-btn" aria-label="Change id" title="Change id">🆔</button>
|
|
||||||
<button @click="deletePermission(p)" class="icon-btn delete-icon" aria-label="Delete permission" title="Delete permission">❌</button>
|
<button @click="deletePermission(p)" class="icon-btn delete-icon" aria-label="Delete permission" title="Delete permission">❌</button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@ -774,9 +727,8 @@ button, .perm-actions button, .org-actions button, .role-actions button { width:
|
|||||||
.permission-grid .perm-grid-head { font-size: .6rem; text-transform: uppercase; letter-spacing: .05em; font-weight: 600; padding: .35rem .4rem; background: #f3f3f3; border: 1px solid #e1e1e1; }
|
.permission-grid .perm-grid-head { font-size: .6rem; text-transform: uppercase; letter-spacing: .05em; font-weight: 600; padding: .35rem .4rem; background: #f3f3f3; border: 1px solid #e1e1e1; }
|
||||||
.permission-grid .perm-cell { background: #fff; border: 1px solid #eee; padding: .35rem .4rem; font-size: .7rem; display: flex; align-items: center; gap: .4rem; }
|
.permission-grid .perm-cell { background: #fff; border: 1px solid #eee; padding: .35rem .4rem; font-size: .7rem; display: flex; align-items: center; gap: .4rem; }
|
||||||
.permission-grid .perm-name { flex-direction: row; flex-wrap: wrap; }
|
.permission-grid .perm-name { flex-direction: row; flex-wrap: wrap; }
|
||||||
.permission-grid .perm-name { flex-direction: column; align-items: flex-start; gap:2px; }
|
.permission-grid .perm-title { font-weight: 600; }
|
||||||
.permission-grid .perm-title-line { font-weight:600; line-height:1.1; }
|
.permission-grid .perm-id { font-size: .55rem; }
|
||||||
.permission-grid .perm-id-line { font-size:.55rem; line-height:1.1; word-break:break-all; }
|
|
||||||
.permission-grid .center { justify-content: center; }
|
.permission-grid .center { justify-content: center; }
|
||||||
.permission-grid .perm-actions { gap: .25rem; }
|
.permission-grid .perm-actions { gap: .25rem; }
|
||||||
.permission-grid .perm-actions .icon-btn { font-size: .9rem; }
|
.permission-grid .perm-actions .icon-btn { font-size: .9rem; }
|
||||||
|
@ -27,14 +27,12 @@ async function register() {
|
|||||||
authStore.showMessage('Starting registration...', 'info')
|
authStore.showMessage('Starting registration...', 'info')
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await passkey.register(authStore.resetToken)
|
const result = await passkey.register()
|
||||||
console.log("Result", result)
|
console.log("Result", result)
|
||||||
await authStore.setSessionCookie(result.session_token)
|
await authStore.setSessionCookie(result.session_token)
|
||||||
// resetToken cleared by setSessionCookie; ensure again
|
|
||||||
authStore.resetToken = null
|
|
||||||
authStore.showMessage('Passkey registered successfully!', 'success', 2000)
|
authStore.showMessage('Passkey registered successfully!', 'success', 2000)
|
||||||
await authStore.loadUserInfo()
|
authStore.loadUserInfo().then(authStore.selectView)
|
||||||
authStore.selectView()
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
authStore.showMessage(`Registration failed: ${error.message}`, 'error')
|
authStore.showMessage(`Registration failed: ${error.message}`, 'error')
|
||||||
} finally {
|
} finally {
|
||||||
|
@ -6,7 +6,6 @@ export const useAuthStore = defineStore('auth', {
|
|||||||
// 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, session_type, authenticated}
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
resetToken: null, // transient reset token (never stored in cookie)
|
|
||||||
|
|
||||||
// UI State
|
// UI State
|
||||||
currentView: 'login', // 'login', 'profile', 'device-link', 'reset'
|
currentView: 'login', // 'login', 'profile', 'device-link', 'reset'
|
||||||
@ -38,9 +37,6 @@ export const useAuthStore = defineStore('auth', {
|
|||||||
if (result.detail) {
|
if (result.detail) {
|
||||||
throw new Error(result.detail)
|
throw new Error(result.detail)
|
||||||
}
|
}
|
||||||
// On successful session establishment, discard any reset token to avoid
|
|
||||||
// sending stale Authorization headers on subsequent API calls.
|
|
||||||
this.resetToken = null
|
|
||||||
return result
|
return result
|
||||||
},
|
},
|
||||||
async register() {
|
async register() {
|
||||||
@ -73,25 +69,9 @@ export const useAuthStore = defineStore('auth', {
|
|||||||
else this.currentView = 'reset'
|
else this.currentView = 'reset'
|
||||||
},
|
},
|
||||||
async loadUserInfo() {
|
async loadUserInfo() {
|
||||||
const headers = {}
|
const response = await fetch('/auth/user-info', {method: 'POST'})
|
||||||
// Reset tokens are only passed via query param now, not Authorization header
|
const result = await response.json()
|
||||||
const url = this.resetToken ? `/auth/user-info?reset=${encodeURIComponent(this.resetToken)}` : '/auth/user-info'
|
if (result.detail) throw new Error(`Server: ${result.detail}`)
|
||||||
const response = await fetch(url, { method: 'POST', headers })
|
|
||||||
let result = null
|
|
||||||
try {
|
|
||||||
result = await response.json()
|
|
||||||
} catch (_) {
|
|
||||||
// ignore JSON parse errors (unlikely)
|
|
||||||
}
|
|
||||||
if (response.status === 401 && result?.detail) {
|
|
||||||
this.showMessage(result.detail, 'error', 5000)
|
|
||||||
throw new Error(result.detail)
|
|
||||||
}
|
|
||||||
if (result?.detail) {
|
|
||||||
// Other error style
|
|
||||||
this.showMessage(result.detail, 'error', 5000)
|
|
||||||
throw new Error(result.detail)
|
|
||||||
}
|
|
||||||
this.userInfo = result
|
this.userInfo = result
|
||||||
console.log('User info loaded:', result)
|
console.log('User info loaded:', result)
|
||||||
},
|
},
|
||||||
|
@ -1,9 +1,8 @@
|
|||||||
import { startRegistration, startAuthentication } from '@simplewebauthn/browser'
|
import { startRegistration, startAuthentication } from '@simplewebauthn/browser'
|
||||||
import aWebSocket from '@/utils/awaitable-websocket'
|
import aWebSocket from '@/utils/awaitable-websocket'
|
||||||
|
|
||||||
export async function register(resetToken = null) {
|
export async function register() {
|
||||||
const url = resetToken ? `/auth/ws/register?reset=${encodeURIComponent(resetToken)}` : "/auth/ws/register"
|
const ws = await aWebSocket("/auth/ws/register")
|
||||||
const ws = await aWebSocket(url)
|
|
||||||
try {
|
try {
|
||||||
const optionsJSON = await ws.receive_json()
|
const optionsJSON = await ws.receive_json()
|
||||||
const registrationResponse = await startRegistration({ optionsJSON })
|
const registrationResponse = await startRegistration({ optionsJSON })
|
||||||
|
@ -27,9 +27,7 @@ export default defineConfig(({ command, mode }) => ({
|
|||||||
// and static assets so that HMR works. Bypass tells http-proxy to skip
|
// and static assets so that HMR works. Bypass tells http-proxy to skip
|
||||||
// proxying when we return a (possibly rewritten) local path.
|
// proxying when we return a (possibly rewritten) local path.
|
||||||
bypass(req) {
|
bypass(req) {
|
||||||
const rawUrl = req.url || ''
|
const url = req.url || ''
|
||||||
// Strip query/hash to match path-only for SPA entrypoints with query params (e.g. ?reset=token)
|
|
||||||
const url = rawUrl.split('?')[0].split('#')[0]
|
|
||||||
// Bypass only root SPA entrypoints + static assets so Vite serves them for HMR.
|
// Bypass only root SPA entrypoints + static assets so Vite serves them for HMR.
|
||||||
// Admin API endpoints (e.g., /auth/admin/orgs) must still hit backend.
|
// Admin API endpoints (e.g., /auth/admin/orgs) must still hit backend.
|
||||||
if (url === '/auth/' || url === '/auth') return '/'
|
if (url === '/auth/' || url === '/auth') return '/'
|
||||||
|
@ -242,18 +242,6 @@ class DatabaseInterface(ABC):
|
|||||||
async def delete_permission(self, permission_id: str) -> None:
|
async def delete_permission(self, permission_id: str) -> None:
|
||||||
"""Delete permission by ID."""
|
"""Delete permission by ID."""
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
async def rename_permission(
|
|
||||||
self, old_id: str, new_id: str, display_name: str
|
|
||||||
) -> None:
|
|
||||||
"""Rename a permission's ID (and display name) updating all references.
|
|
||||||
|
|
||||||
This must update:
|
|
||||||
- permissions.id (primary key)
|
|
||||||
- org_permissions.permission_id
|
|
||||||
- role_permissions.permission_id
|
|
||||||
"""
|
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
async def add_permission_to_organization(
|
async def add_permission_to_organization(
|
||||||
self, org_id: str, permission_id: str
|
self, org_id: str, permission_id: str
|
||||||
|
@ -16,7 +16,6 @@ from sqlalchemy import (
|
|||||||
LargeBinary,
|
LargeBinary,
|
||||||
String,
|
String,
|
||||||
delete,
|
delete,
|
||||||
event,
|
|
||||||
select,
|
select,
|
||||||
update,
|
update,
|
||||||
)
|
)
|
||||||
@ -227,18 +226,6 @@ class DB(DatabaseInterface):
|
|||||||
def __init__(self, db_path: str = DB_PATH):
|
def __init__(self, db_path: str = DB_PATH):
|
||||||
"""Initialize with database path."""
|
"""Initialize with database path."""
|
||||||
self.engine = create_async_engine(db_path, echo=False)
|
self.engine = create_async_engine(db_path, echo=False)
|
||||||
# Ensure SQLite foreign key enforcement is ON for every new connection
|
|
||||||
if db_path.startswith("sqlite"):
|
|
||||||
|
|
||||||
@event.listens_for(self.engine.sync_engine, "connect")
|
|
||||||
def _fk_on(dbapi_connection, connection_record): # type: ignore
|
|
||||||
try:
|
|
||||||
cursor = dbapi_connection.cursor()
|
|
||||||
cursor.execute("PRAGMA foreign_keys=ON;")
|
|
||||||
cursor.close()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
self.async_session_factory = async_sessionmaker(
|
self.async_session_factory = async_sessionmaker(
|
||||||
self.engine, expire_on_commit=False
|
self.engine, expire_on_commit=False
|
||||||
)
|
)
|
||||||
@ -763,63 +750,6 @@ class DB(DatabaseInterface):
|
|||||||
)
|
)
|
||||||
await session.execute(stmt)
|
await session.execute(stmt)
|
||||||
|
|
||||||
async def rename_permission(
|
|
||||||
self, old_id: str, new_id: str, display_name: str
|
|
||||||
) -> None:
|
|
||||||
"""Rename a permission's primary key and update referencing tables.
|
|
||||||
|
|
||||||
Approach: insert new row (if id changes), update FKs, delete old row.
|
|
||||||
Wrapped in a transaction; will raise on conflict.
|
|
||||||
"""
|
|
||||||
if old_id == new_id:
|
|
||||||
# Just update display name
|
|
||||||
async with self.session() as session:
|
|
||||||
stmt = (
|
|
||||||
update(PermissionModel)
|
|
||||||
.where(PermissionModel.id == old_id)
|
|
||||||
.values(display_name=display_name)
|
|
||||||
)
|
|
||||||
await session.execute(stmt)
|
|
||||||
return
|
|
||||||
async with self.session() as session:
|
|
||||||
# Ensure old exists
|
|
||||||
existing_old = await session.execute(
|
|
||||||
select(PermissionModel).where(PermissionModel.id == old_id)
|
|
||||||
)
|
|
||||||
if not existing_old.scalar_one_or_none():
|
|
||||||
raise ValueError("Original permission not found")
|
|
||||||
|
|
||||||
# Check new not taken
|
|
||||||
existing_new = await session.execute(
|
|
||||||
select(PermissionModel).where(PermissionModel.id == new_id)
|
|
||||||
)
|
|
||||||
if existing_new.scalar_one_or_none():
|
|
||||||
raise ValueError("New permission id already exists")
|
|
||||||
|
|
||||||
# Create new permission row first
|
|
||||||
session.add(PermissionModel(id=new_id, display_name=display_name))
|
|
||||||
await session.flush()
|
|
||||||
|
|
||||||
# Update org_permissions
|
|
||||||
await session.execute(
|
|
||||||
update(OrgPermission)
|
|
||||||
.where(OrgPermission.permission_id == old_id)
|
|
||||||
.values(permission_id=new_id)
|
|
||||||
)
|
|
||||||
await session.flush()
|
|
||||||
# Update role_permissions
|
|
||||||
await session.execute(
|
|
||||||
update(RolePermission)
|
|
||||||
.where(RolePermission.permission_id == old_id)
|
|
||||||
.values(permission_id=new_id)
|
|
||||||
)
|
|
||||||
await session.flush()
|
|
||||||
# Delete old permission row
|
|
||||||
await session.execute(
|
|
||||||
delete(PermissionModel).where(PermissionModel.id == old_id)
|
|
||||||
)
|
|
||||||
await session.flush()
|
|
||||||
|
|
||||||
async def delete_permission(self, permission_id: str) -> None:
|
async def delete_permission(self, permission_id: str) -> None:
|
||||||
async with self.session() as session:
|
async with self.session() as session:
|
||||||
stmt = delete(PermissionModel).where(PermissionModel.id == permission_id)
|
stmt = delete(PermissionModel).where(PermissionModel.id == permission_id)
|
||||||
|
@ -52,42 +52,40 @@ def register_api_routes(app: FastAPI):
|
|||||||
}
|
}
|
||||||
|
|
||||||
@app.post("/auth/user-info")
|
@app.post("/auth/user-info")
|
||||||
async def api_user_info(reset: str | None = None, auth=Cookie(None)):
|
async def api_user_info(response: Response, auth=Cookie(None)):
|
||||||
"""Get user information.
|
"""Get user information.
|
||||||
|
|
||||||
- For authenticated sessions: return full context (org/role/permissions/credentials)
|
- For authenticated sessions: return full context (org/role/permissions/credentials)
|
||||||
- For reset tokens: return only basic user information to drive reset flow
|
- For reset tokens: return only basic user information to drive reset flow
|
||||||
"""
|
"""
|
||||||
authenticated = False
|
|
||||||
try:
|
try:
|
||||||
if reset:
|
reset = auth and passphrase.is_well_formed(auth)
|
||||||
if not passphrase.is_well_formed(reset):
|
s = await (get_reset if reset else get_session)(auth)
|
||||||
raise ValueError("Invalid reset token")
|
except ValueError:
|
||||||
s = await get_reset(reset)
|
raise HTTPException(
|
||||||
else:
|
status_code=401,
|
||||||
if auth is None:
|
detail="Authentication Required",
|
||||||
raise ValueError("Authentication Required")
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
s = await get_session(auth)
|
)
|
||||||
authenticated = True
|
|
||||||
except ValueError as e:
|
|
||||||
raise HTTPException(401, str(e))
|
|
||||||
|
|
||||||
u = await db.instance.get_user_by_uuid(s.user_uuid)
|
|
||||||
|
|
||||||
# Minimal response for reset tokens
|
# Minimal response for reset tokens
|
||||||
if not authenticated:
|
if reset:
|
||||||
|
u = await db.instance.get_user_by_uuid(s.user_uuid)
|
||||||
return {
|
return {
|
||||||
"authenticated": False,
|
"authenticated": False,
|
||||||
"session_type": s.info.get("type"),
|
"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,
|
||||||
|
"last_seen": u.last_seen.isoformat() if u.last_seen else None,
|
||||||
|
"visits": u.visits,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
# Full context for authenticated sessions
|
# Full context for authenticated sessions
|
||||||
assert authenticated and auth is not None
|
|
||||||
ctx = await db.instance.get_session_context(session_key(auth))
|
ctx = await db.instance.get_session_context(session_key(auth))
|
||||||
|
u = await db.instance.get_user_by_uuid(s.user_uuid)
|
||||||
credential_ids = await db.instance.get_credentials_by_user_uuid(s.user_uuid)
|
credential_ids = await db.instance.get_credentials_by_user_uuid(s.user_uuid)
|
||||||
|
|
||||||
credentials: list[dict] = []
|
credentials: list[dict] = []
|
||||||
@ -219,42 +217,33 @@ def register_api_routes(app: FastAPI):
|
|||||||
async def admin_update_org(
|
async def admin_update_org(
|
||||||
org_uuid: UUID, payload: dict = Body(...), auth=Cookie(None)
|
org_uuid: UUID, payload: dict = Body(...), auth=Cookie(None)
|
||||||
):
|
):
|
||||||
# Only global admins can modify org definitions (simpler rule)
|
ctx, is_global_admin, is_org_admin = await _get_ctx_and_admin_flags(auth)
|
||||||
_, is_global_admin, _ = await _get_ctx_and_admin_flags(auth)
|
if not (is_global_admin or (is_org_admin and ctx.org.uuid == org_uuid)):
|
||||||
if not is_global_admin:
|
raise ValueError("Insufficient permissions")
|
||||||
raise ValueError("Global admin required")
|
from ..db import Org as OrgDC
|
||||||
from ..db import Org as OrgDC # local import to avoid cycles
|
|
||||||
|
|
||||||
current = await db.instance.get_organization(str(org_uuid))
|
current = await db.instance.get_organization(str(org_uuid))
|
||||||
display_name = payload.get("display_name") or current.display_name
|
display_name = payload.get("display_name") or current.display_name
|
||||||
permissions = payload.get("permissions") or current.permissions or []
|
permissions = (
|
||||||
|
payload.get("permissions")
|
||||||
|
if "permissions" in payload
|
||||||
|
else current.permissions
|
||||||
|
) or []
|
||||||
org = OrgDC(uuid=org_uuid, display_name=display_name, permissions=permissions)
|
org = OrgDC(uuid=org_uuid, display_name=display_name, permissions=permissions)
|
||||||
await db.instance.update_organization(org)
|
await db.instance.update_organization(org)
|
||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
|
|
||||||
@app.delete("/auth/admin/orgs/{org_uuid}")
|
@app.delete("/auth/admin/orgs/{org_uuid}")
|
||||||
async def admin_delete_org(org_uuid: UUID, auth=Cookie(None)):
|
async def admin_delete_org(org_uuid: UUID, auth=Cookie(None)):
|
||||||
ctx, is_global_admin, _ = await _get_ctx_and_admin_flags(auth)
|
_, is_global_admin, _ = await _get_ctx_and_admin_flags(auth)
|
||||||
if not is_global_admin:
|
if not is_global_admin:
|
||||||
# Org admins cannot delete at all (avoid self-lockout)
|
|
||||||
raise ValueError("Global admin required")
|
raise ValueError("Global admin required")
|
||||||
# Prevent deleting the organization that the acting global admin currently belongs to
|
|
||||||
# if that deletion would remove their effective access (e.g., last org granting auth/admin)
|
|
||||||
try:
|
|
||||||
acting_org_uuid = ctx.org.uuid if ctx.org else None
|
|
||||||
except Exception:
|
|
||||||
acting_org_uuid = None
|
|
||||||
if acting_org_uuid and acting_org_uuid == org_uuid:
|
|
||||||
# Never allow deletion of the caller's own organization to avoid immediate account deletion.
|
|
||||||
raise ValueError("Cannot delete the organization you belong to")
|
|
||||||
await db.instance.delete_organization(org_uuid)
|
await db.instance.delete_organization(org_uuid)
|
||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
|
|
||||||
# Manage an org's grantable permissions (query param for permission_id)
|
# Manage an org's grantable permissions (query param for permission_id)
|
||||||
@app.post("/auth/admin/orgs/{org_uuid}/permission")
|
@app.post("/auth/admin/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, auth=Cookie(None)
|
|
||||||
):
|
|
||||||
ctx, is_global_admin, is_org_admin = await _get_ctx_and_admin_flags(auth)
|
ctx, is_global_admin, is_org_admin = await _get_ctx_and_admin_flags(auth)
|
||||||
if not (is_global_admin or (is_org_admin and ctx.org.uuid == org_uuid)):
|
if not (is_global_admin or (is_org_admin and ctx.org.uuid == org_uuid)):
|
||||||
raise ValueError("Insufficient permissions")
|
raise ValueError("Insufficient permissions")
|
||||||
@ -262,15 +251,11 @@ def register_api_routes(app: FastAPI):
|
|||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
|
|
||||||
@app.delete("/auth/admin/orgs/{org_uuid}/permission")
|
@app.delete("/auth/admin/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, auth=Cookie(None)
|
|
||||||
):
|
|
||||||
ctx, is_global_admin, is_org_admin = await _get_ctx_and_admin_flags(auth)
|
ctx, is_global_admin, is_org_admin = await _get_ctx_and_admin_flags(auth)
|
||||||
if not (is_global_admin or (is_org_admin and ctx.org.uuid == org_uuid)):
|
if not (is_global_admin or (is_org_admin and ctx.org.uuid == org_uuid)):
|
||||||
raise ValueError("Insufficient permissions")
|
raise ValueError("Insufficient permissions")
|
||||||
await db.instance.remove_permission_from_organization(
|
await db.instance.remove_permission_from_organization(str(org_uuid), permission_id)
|
||||||
str(org_uuid), permission_id
|
|
||||||
)
|
|
||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
|
|
||||||
# -------------------- Admin API: Roles --------------------
|
# -------------------- Admin API: Roles --------------------
|
||||||
@ -351,7 +336,6 @@ def register_api_routes(app: FastAPI):
|
|||||||
raise ValueError("display_name and role are required")
|
raise ValueError("display_name and role are required")
|
||||||
# Validate role exists in org
|
# Validate role exists in org
|
||||||
from ..db import User as UserDC # local import to avoid cycles
|
from ..db import User as UserDC # local import to avoid cycles
|
||||||
|
|
||||||
roles = await db.instance.get_roles_by_organization(str(org_uuid))
|
roles = await db.instance.get_roles_by_organization(str(org_uuid))
|
||||||
role_obj = next((r for r in roles if r.display_name == role_name), None)
|
role_obj = next((r for r in roles if r.display_name == role_name), None)
|
||||||
if not role_obj:
|
if not role_obj:
|
||||||
@ -364,6 +348,7 @@ def register_api_routes(app: FastAPI):
|
|||||||
role_uuid=role_obj.uuid,
|
role_uuid=role_obj.uuid,
|
||||||
visits=0,
|
visits=0,
|
||||||
created_at=None,
|
created_at=None,
|
||||||
|
last_seen=None,
|
||||||
)
|
)
|
||||||
await db.instance.create_user(user)
|
await db.instance.create_user(user)
|
||||||
return {"uuid": str(user_uuid)}
|
return {"uuid": str(user_uuid)}
|
||||||
@ -463,14 +448,11 @@ def register_api_routes(app: FastAPI):
|
|||||||
"aaguid": aaguid_str,
|
"aaguid": aaguid_str,
|
||||||
"created_at": c.created_at.isoformat(),
|
"created_at": c.created_at.isoformat(),
|
||||||
"last_used": c.last_used.isoformat() if c.last_used else None,
|
"last_used": c.last_used.isoformat() if c.last_used else None,
|
||||||
"last_verified": c.last_verified.isoformat()
|
"last_verified": c.last_verified.isoformat() if c.last_verified else None,
|
||||||
if c.last_verified
|
|
||||||
else None,
|
|
||||||
"sign_count": c.sign_count,
|
"sign_count": c.sign_count,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
from .. import aaguid as aaguid_mod
|
from .. import aaguid as aaguid_mod
|
||||||
|
|
||||||
aaguid_info = aaguid_mod.filter(aaguids)
|
aaguid_info = aaguid_mod.filter(aaguids)
|
||||||
return {
|
return {
|
||||||
"display_name": user.display_name,
|
"display_name": user.display_name,
|
||||||
@ -510,44 +492,14 @@ def register_api_routes(app: FastAPI):
|
|||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
|
|
||||||
@app.put("/auth/admin/permission")
|
@app.put("/auth/admin/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, auth=Cookie(None)
|
|
||||||
):
|
|
||||||
_, is_global_admin, _ = await _get_ctx_and_admin_flags(auth)
|
_, is_global_admin, _ = await _get_ctx_and_admin_flags(auth)
|
||||||
if not is_global_admin:
|
if not is_global_admin:
|
||||||
raise ValueError("Global admin required")
|
raise ValueError("Global admin required")
|
||||||
from ..db import Permission as PermDC
|
from ..db import Permission as PermDC
|
||||||
|
|
||||||
if not display_name:
|
if not display_name:
|
||||||
raise ValueError("display_name is required")
|
raise ValueError("display_name is required")
|
||||||
await db.instance.update_permission(
|
await db.instance.update_permission(PermDC(id=permission_id, display_name=display_name))
|
||||||
PermDC(id=permission_id, display_name=display_name)
|
|
||||||
)
|
|
||||||
return {"status": "ok"}
|
|
||||||
|
|
||||||
@app.post("/auth/admin/permission/rename")
|
|
||||||
async def admin_rename_permission(payload: dict = Body(...), auth=Cookie(None)):
|
|
||||||
"""Rename a permission's id (and optionally display name) updating all references.
|
|
||||||
|
|
||||||
Body: { "old_id": str, "new_id": str, "display_name": str|null }
|
|
||||||
"""
|
|
||||||
_, is_global_admin, _ = await _get_ctx_and_admin_flags(auth)
|
|
||||||
if not is_global_admin:
|
|
||||||
raise ValueError("Global admin required")
|
|
||||||
old_id = payload.get("old_id")
|
|
||||||
new_id = payload.get("new_id")
|
|
||||||
display_name = payload.get("display_name")
|
|
||||||
if not old_id or not new_id:
|
|
||||||
raise ValueError("old_id and new_id required")
|
|
||||||
if display_name is None:
|
|
||||||
# Fetch old to retain display name
|
|
||||||
perm = await db.instance.get_permission(old_id)
|
|
||||||
display_name = perm.display_name
|
|
||||||
# rename_permission added to interface; use getattr for forward compatibility
|
|
||||||
rename_fn = getattr(db.instance, "rename_permission", None)
|
|
||||||
if not rename_fn:
|
|
||||||
raise ValueError("Permission renaming not supported by this backend")
|
|
||||||
await rename_fn(old_id, new_id, display_name)
|
|
||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
|
|
||||||
@app.delete("/auth/admin/permission")
|
@app.delete("/auth/admin/permission")
|
||||||
@ -573,8 +525,10 @@ def register_api_routes(app: FastAPI):
|
|||||||
|
|
||||||
@app.post("/auth/set-session")
|
@app.post("/auth/set-session")
|
||||||
async def api_set_session(response: Response, auth=Depends(bearer_auth)):
|
async def api_set_session(response: Response, auth=Depends(bearer_auth)):
|
||||||
"""Set session cookie from Authorization Bearer session token (never via query)."""
|
"""Set session cookie from Authorization header. Fetched after login by WebSocket."""
|
||||||
user = await get_session(auth.credentials)
|
user = await get_session(auth.credentials)
|
||||||
|
if not user:
|
||||||
|
raise ValueError("Invalid Authorization header.")
|
||||||
session.set_session_cookie(response, auth.credentials)
|
session.set_session_cookie(response, auth.credentials)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
from pathlib import Path
|
import logging
|
||||||
|
|
||||||
from fastapi import Cookie, HTTPException, Request, Response
|
from fastapi import Cookie, HTTPException, Request, Response
|
||||||
from fastapi.responses import RedirectResponse
|
from fastapi.responses import RedirectResponse
|
||||||
@ -9,9 +9,6 @@ from ..globals import passkey as global_passkey
|
|||||||
from ..util import passphrase, tokens
|
from ..util import passphrase, tokens
|
||||||
from . import session
|
from . import session
|
||||||
|
|
||||||
# Local copy to avoid circular import with mainapp
|
|
||||||
STATIC_DIR = Path(__file__).parent.parent / "frontend-build"
|
|
||||||
|
|
||||||
|
|
||||||
def register_reset_routes(app):
|
def register_reset_routes(app):
|
||||||
"""Register all device addition/reset routes on the FastAPI app."""
|
"""Register all device addition/reset routes on the FastAPI app."""
|
||||||
@ -31,10 +28,9 @@ def register_reset_routes(app):
|
|||||||
info=session.infodict(request, "device addition"),
|
info=session.infodict(request, "device addition"),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Generate the device addition link with pretty URL using configured origin
|
# Generate the device addition link with pretty URL
|
||||||
origin = global_passkey.instance.origin.rstrip("/")
|
path = request.url.path.removesuffix("create-link") + token
|
||||||
path = request.url.path.removesuffix("create-link") + token # /auth/<token>
|
url = f"{request.headers['origin']}{path}"
|
||||||
url = f"{origin}{path}"
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"message": "Registration link generated successfully",
|
"message": "Registration link generated successfully",
|
||||||
@ -43,17 +39,30 @@ def register_reset_routes(app):
|
|||||||
}
|
}
|
||||||
|
|
||||||
@app.get("/auth/{reset_token}")
|
@app.get("/auth/{reset_token}")
|
||||||
async def reset_authentication(request: Request, reset_token: str):
|
async def reset_authentication(
|
||||||
"""Validate reset token and redirect with it as query parameter (no cookies).
|
request: Request,
|
||||||
|
reset_token: str,
|
||||||
After validation we 303 redirect to /auth/?reset=<token>. The frontend will:
|
):
|
||||||
- Read the token from location.search
|
"""Verifies the token and redirects to auth app for credential registration."""
|
||||||
- Use it via Authorization header or websocket query param
|
|
||||||
- history.replaceState to remove it from the address bar/history
|
|
||||||
"""
|
|
||||||
if not passphrase.is_well_formed(reset_token):
|
if not passphrase.is_well_formed(reset_token):
|
||||||
raise HTTPException(status_code=404)
|
raise HTTPException(status_code=404)
|
||||||
origin = global_passkey.instance.origin
|
origin = global_passkey.instance.origin
|
||||||
# Do not verify existence/expiry here; frontend + user-info endpoint will handle invalid tokens.
|
try:
|
||||||
redirect_url = f"{origin}/auth/?reset={reset_token}"
|
# Get session token to validate it exists and get user_id
|
||||||
return RedirectResponse(url=redirect_url, status_code=303)
|
key = tokens.reset_key(reset_token)
|
||||||
|
sess = await db.instance.get_session(key)
|
||||||
|
if not sess:
|
||||||
|
raise ValueError("Invalid or expired registration token")
|
||||||
|
|
||||||
|
response = RedirectResponse(url=f"{origin}/auth/", status_code=303)
|
||||||
|
session.set_session_cookie(response, reset_token)
|
||||||
|
return response
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
# On any error, redirect to auth app
|
||||||
|
if isinstance(e, ValueError):
|
||||||
|
msg = str(e)
|
||||||
|
else:
|
||||||
|
logging.exception("Internal Server Error in reset_authentication")
|
||||||
|
msg = "Internal Server Error"
|
||||||
|
return RedirectResponse(url=f"{origin}/auth/#{msg}", status_code=303)
|
||||||
|
@ -54,26 +54,12 @@ 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, auth=Cookie(None)):
|
||||||
ws: WebSocket, reset: str | None = None, auth=Cookie(None)
|
"""Register a new credential for an existing user."""
|
||||||
):
|
|
||||||
"""Register a new credential for an existing user.
|
|
||||||
|
|
||||||
Supports either:
|
|
||||||
- Normal session via auth cookie
|
|
||||||
- Reset token supplied as ?reset=... (auth cookie ignored)
|
|
||||||
"""
|
|
||||||
origin = ws.headers["origin"]
|
origin = ws.headers["origin"]
|
||||||
is_reset = False
|
# Try to get either a regular session or a reset session
|
||||||
if reset is not None:
|
reset = passphrase.is_well_formed(auth)
|
||||||
if not passphrase.is_well_formed(reset):
|
s = await (get_reset if reset else get_session)(auth)
|
||||||
raise ValueError("Invalid reset token")
|
|
||||||
is_reset = True
|
|
||||||
s = await get_reset(reset)
|
|
||||||
else:
|
|
||||||
if not auth:
|
|
||||||
raise ValueError("Authentication Required")
|
|
||||||
s = await get_session(auth)
|
|
||||||
user_uuid = s.user_uuid
|
user_uuid = s.user_uuid
|
||||||
|
|
||||||
# Get user information to get the user_name
|
# Get user information to get the user_name
|
||||||
@ -83,23 +69,23 @@ async def websocket_register_add(
|
|||||||
|
|
||||||
# WebAuthn registration
|
# WebAuthn registration
|
||||||
credential = await register_chat(ws, user_uuid, user_name, challenge_ids, origin)
|
credential = await register_chat(ws, user_uuid, user_name, challenge_ids, origin)
|
||||||
# IMPORTANT: Insert the credential before creating a session that references it
|
if reset:
|
||||||
# to satisfy the sessions.credential_uuid foreign key (now enforced).
|
# Replace reset session with a new session
|
||||||
await db.instance.create_credential(credential)
|
|
||||||
|
|
||||||
if is_reset:
|
|
||||||
# Invalidate the one-time reset session only after credential persisted
|
|
||||||
await db.instance.delete_session(s.key)
|
await db.instance.delete_session(s.key)
|
||||||
auth = await create_session(
|
token = await create_session(
|
||||||
user_uuid, credential.uuid, infodict(ws, "authenticated")
|
user_uuid, credential.uuid, infodict(ws, "authenticated")
|
||||||
)
|
)
|
||||||
|
else:
|
||||||
|
token = auth
|
||||||
|
assert isinstance(token, str) and len(token) == 16
|
||||||
|
# Store the new credential in the database
|
||||||
|
await db.instance.create_credential(credential)
|
||||||
|
|
||||||
assert isinstance(auth, str) and len(auth) == 16
|
|
||||||
await ws.send_json(
|
await ws.send_json(
|
||||||
{
|
{
|
||||||
"user_uuid": str(user.uuid),
|
"user_uuid": str(user.uuid),
|
||||||
"credential_uuid": str(credential.uuid),
|
"credential_uuid": str(credential.uuid),
|
||||||
"session_token": auth,
|
"session_token": token,
|
||||||
"message": "New credential added successfully",
|
"message": "New credential added successfully",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
Loading…
x
Reference in New Issue
Block a user