Compare commits
7 Commits
3e5c0065d5
...
2b03fa74cd
Author | SHA1 | Date | |
---|---|---|---|
![]() |
2b03fa74cd | ||
![]() |
d045e1c520 | ||
![]() |
326a7664d3 | ||
![]() |
c422f59b2e | ||
![]() |
4a0fbd8199 | ||
![]() |
16de7b5f1f | ||
![]() |
cb17a332a3 |
41
Caddyfile
41
Caddyfile
@ -1,22 +1,35 @@
|
|||||||
(auth) {
|
(auth) {
|
||||||
# Forward /auth/ to the authentication service
|
# Permission check (named arg: perm=...)
|
||||||
@auth path /auth/*
|
|
||||||
handle @auth {
|
|
||||||
reverse_proxy localhost:4401
|
|
||||||
}
|
|
||||||
handle {
|
|
||||||
# Check for authentication
|
|
||||||
forward_auth localhost:4401 {
|
forward_auth localhost:4401 {
|
||||||
uri /auth/forward-auth
|
uri /auth/forward-auth?{args.0}
|
||||||
copy_headers x-auth*
|
copy_headers x-auth-*
|
||||||
}
|
|
||||||
{block}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
localhost {
|
localhost {
|
||||||
import auth {
|
# Single definition for auth service endpoints (avoid duplicate matcher names)
|
||||||
# Proxy authenticated requests to the main application
|
@auth_api path /auth/*
|
||||||
reverse_proxy localhost:3000
|
handle @auth_api {
|
||||||
|
reverse_proxy localhost:4401
|
||||||
|
}
|
||||||
|
|
||||||
|
# Admin-protected paths
|
||||||
|
handle_path /admin/* {
|
||||||
|
import auth perm=auth:admin
|
||||||
|
# Respond with a message for the admin area
|
||||||
|
respond "Admin area (protected)" 200
|
||||||
|
}
|
||||||
|
|
||||||
|
# Reports-protected paths
|
||||||
|
handle_path /reports/* {
|
||||||
|
import auth perm=reports:view
|
||||||
|
# Respond with a message for the reports area
|
||||||
|
respond "Reports area (protected)" 200
|
||||||
|
}
|
||||||
|
|
||||||
|
# Unprotected (fallback)
|
||||||
|
handle {
|
||||||
|
# Respond with a public content message
|
||||||
|
respond "Public content" 200
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -5,6 +5,7 @@
|
|||||||
<ProfileView v-if="store.currentView === 'profile'" />
|
<ProfileView v-if="store.currentView === 'profile'" />
|
||||||
<DeviceLinkView v-if="store.currentView === 'device-link'" />
|
<DeviceLinkView v-if="store.currentView === 'device-link'" />
|
||||||
<ResetView v-if="store.currentView === 'reset'" />
|
<ResetView v-if="store.currentView === 'reset'" />
|
||||||
|
<PermissionDeniedView v-if="store.currentView === 'permission-denied'" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@ -16,10 +17,15 @@ import LoginView from '@/components/LoginView.vue'
|
|||||||
import ProfileView from '@/components/ProfileView.vue'
|
import ProfileView from '@/components/ProfileView.vue'
|
||||||
import DeviceLinkView from '@/components/DeviceLinkView.vue'
|
import DeviceLinkView from '@/components/DeviceLinkView.vue'
|
||||||
import ResetView from '@/components/ResetView.vue'
|
import ResetView from '@/components/ResetView.vue'
|
||||||
|
import PermissionDeniedView from '@/components/PermissionDeniedView.vue'
|
||||||
|
|
||||||
const store = useAuthStore()
|
const store = useAuthStore()
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
|
// Detect restricted mode: any path not starting with /auth/
|
||||||
|
if (!location.pathname.startsWith('/auth/')) {
|
||||||
|
store.setRestrictedMode(true)
|
||||||
|
}
|
||||||
// Was an error message passed in the URL hash?
|
// 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) {
|
||||||
|
@ -17,6 +17,16 @@ const userLink = ref(null) // latest generated registration link
|
|||||||
const userLinkExpires = ref(null)
|
const userLinkExpires = ref(null)
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
const addingOrgForPermission = ref(null)
|
const addingOrgForPermission = ref(null)
|
||||||
|
const PERMISSION_ID_PATTERN = '^[A-Za-z0-9:._~-]+$'
|
||||||
|
const showCreatePermission = ref(false)
|
||||||
|
const newPermId = ref('')
|
||||||
|
const newPermName = ref('')
|
||||||
|
const editingPermId = ref(null)
|
||||||
|
const renameIdValue = ref('')
|
||||||
|
const safeIdRegex = /[^A-Za-z0-9:._~-]/g
|
||||||
|
|
||||||
|
function sanitizeNewId() { if (newPermId.value) newPermId.value = newPermId.value.replace(safeIdRegex, '') }
|
||||||
|
function sanitizeRenameId() { if (renameIdValue.value) renameIdValue.value = renameIdValue.value.replace(safeIdRegex, '') }
|
||||||
|
|
||||||
function handleGlobalClick(e) {
|
function handleGlobalClick(e) {
|
||||||
if (!addingOrgForPermission.value) return
|
if (!addingOrgForPermission.value) return
|
||||||
@ -88,22 +98,22 @@ async function renamePermissionDisplay(p) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function renamePermissionId(p) {
|
function startRenamePermissionId(p) {
|
||||||
const newId = prompt('New permission id', p.id)
|
editingPermId.value = p.id
|
||||||
if (!newId || newId === p.id) return
|
renameIdValue.value = p.id
|
||||||
|
}
|
||||||
|
function cancelRenameId() { editingPermId.value = null; renameIdValue.value = '' }
|
||||||
|
async function submitRenamePermissionId(p) {
|
||||||
|
const newId = renameIdValue.value.trim()
|
||||||
|
if (!newId || newId === p.id) { cancelRenameId(); return }
|
||||||
try {
|
try {
|
||||||
const body = { old_id: p.id, new_id: newId, display_name: p.display_name }
|
const body = { old_id: p.id, new_id: newId, display_name: p.display_name }
|
||||||
const res = await fetch('/auth/admin/permission/rename', {
|
const res = await fetch('/auth/admin/permission/rename', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) })
|
||||||
method: 'POST',
|
let data; try { data = await res.json() } catch(_) { data = {} }
|
||||||
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})`)
|
if (!res.ok || data.detail) throw new Error(data.detail || data.error || `Failed (${res.status})`)
|
||||||
await refreshPermissionsContext()
|
await refreshPermissionsContext(); cancelRenameId()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
alert((e && e.message) ? e.message : 'Failed to rename permission id')
|
alert(e?.message || 'Failed to rename permission id')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -336,20 +346,15 @@ async function deleteRole(role) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Permission actions
|
// Permission actions
|
||||||
async function createPermission() {
|
async function submitCreatePermission() {
|
||||||
const id = prompt('Permission ID (e.g., auth/example):')
|
const id = newPermId.value.trim()
|
||||||
if (!id) return
|
const name = newPermName.value.trim()
|
||||||
const name = prompt('Permission display name:')
|
if (!id || !name) return
|
||||||
if (!name) return
|
const res = await fetch('/auth/admin/permissions', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ id, display_name: name }) })
|
||||||
const res = await fetch('/auth/admin/permissions', {
|
const data = await res.json(); if (data.detail) { alert(data.detail); return }
|
||||||
method: 'POST',
|
await loadPermissions(); newPermId.value=''; newPermName.value=''; showCreatePermission.value=false
|
||||||
headers: { 'content-type': 'application/json' },
|
|
||||||
body: JSON.stringify({ id, display_name: name })
|
|
||||||
})
|
|
||||||
const data = await res.json()
|
|
||||||
if (data.detail) return alert(data.detail)
|
|
||||||
await loadPermissions()
|
|
||||||
}
|
}
|
||||||
|
function cancelCreatePermission() { newPermId.value=''; newPermName.value=''; showCreatePermission.value=false }
|
||||||
|
|
||||||
async function updatePermission(p) {
|
async function updatePermission(p) {
|
||||||
const name = prompt('Permission display name:', p.display_name)
|
const name = prompt('Permission display name:', p.display_name)
|
||||||
@ -623,7 +628,13 @@ async function toggleRolePermission(role, permId, checked) {
|
|||||||
<div v-if="!selectedUser && !selectedOrg && (info.is_global_admin || info.is_org_admin)" class="card">
|
<div v-if="!selectedUser && !selectedOrg && (info.is_global_admin || info.is_org_admin)" class="card">
|
||||||
<h2>All Permissions</h2>
|
<h2>All Permissions</h2>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button @click="createPermission">+ Create Permission</button>
|
<button v-if="!showCreatePermission" @click="showCreatePermission = true">+ Create Permission</button>
|
||||||
|
<form v-else class="inline-form" @submit.prevent="submitCreatePermission">
|
||||||
|
<input v-model="newPermId" @input="sanitizeNewId" required :pattern="PERMISSION_ID_PATTERN" placeholder="permission id" title="Allowed: A-Za-z0-9:._~-" />
|
||||||
|
<input v-model="newPermName" required placeholder="display name" />
|
||||||
|
<button type="submit">Save</button>
|
||||||
|
<button type="button" @click="cancelCreatePermission">Cancel</button>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
<div class="permission-grid">
|
<div class="permission-grid">
|
||||||
<div class="perm-grid-head">Permission</div>
|
<div class="perm-grid-head">Permission</div>
|
||||||
@ -672,9 +683,16 @@ 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">
|
||||||
|
<template v-if="editingPermId !== p.id">
|
||||||
<button @click="renamePermissionDisplay(p)" class="icon-btn" aria-label="Change display name" title="Change display name">✏️</button>
|
<button @click="renamePermissionDisplay(p)" class="icon-btn" aria-label="Change display name" title="Change display name">✏️</button>
|
||||||
<button @click="renamePermissionId(p)" class="icon-btn" aria-label="Change id" title="Change id">🆔</button>
|
<button @click="startRenamePermissionId(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>
|
||||||
|
</template>
|
||||||
|
<form v-else class="inline-id-form" @submit.prevent="submitRenamePermissionId(p)">
|
||||||
|
<input v-model="renameIdValue" @input="sanitizeRenameId" required :pattern="PERMISSION_ID_PATTERN" class="id-input" title="Allowed: A-Za-z0-9:._~-" />
|
||||||
|
<button type="submit" class="icon-btn" aria-label="Save">✔</button>
|
||||||
|
<button type="button" class="icon-btn" @click="cancelRenameId" aria-label="Cancel">✖</button>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
@ -26,7 +26,10 @@ const handleLogin = async () => {
|
|||||||
authStore.showMessage('Starting authentication...', 'info')
|
authStore.showMessage('Starting authentication...', 'info')
|
||||||
await authStore.authenticate()
|
await authStore.authenticate()
|
||||||
authStore.showMessage('Authentication successful!', 'success', 2000)
|
authStore.showMessage('Authentication successful!', 'success', 2000)
|
||||||
if (location.pathname.startsWith('/auth/')) {
|
if (authStore.restrictedMode) {
|
||||||
|
// In restricted mode after successful auth show permission denied (no profile outside /auth/)
|
||||||
|
authStore.currentView = 'permission-denied'
|
||||||
|
} else if (location.pathname.startsWith('/auth/')) {
|
||||||
authStore.currentView = 'profile'
|
authStore.currentView = 'profile'
|
||||||
} else {
|
} else {
|
||||||
location.reload()
|
location.reload()
|
||||||
|
44
frontend/src/components/PermissionDeniedView.vue
Normal file
44
frontend/src/components/PermissionDeniedView.vue
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
<template>
|
||||||
|
<div class="container">
|
||||||
|
<div class="view active">
|
||||||
|
<h1>🚫 Forbidden</h1>
|
||||||
|
<div v-if="authStore.userInfo?.authenticated" class="user-header">
|
||||||
|
<span class="user-emoji" aria-hidden="true">{{ userEmoji }}</span>
|
||||||
|
<span class="user-name">{{ displayName }}</span>
|
||||||
|
</div>
|
||||||
|
<p>You lack the permissions required for this page.</p>
|
||||||
|
<div class="actions">
|
||||||
|
<button class="btn-secondary" @click="back">Back</button>
|
||||||
|
<button class="btn-primary" @click="goAuth">Account</button>
|
||||||
|
<button class="btn-danger" @click="logout">Logout</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script setup>
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
|
||||||
|
const userEmoji = '👤' // Placeholder / could be extended later if backend provides one
|
||||||
|
const displayName = authStore.userInfo?.user?.user_name || 'User'
|
||||||
|
|
||||||
|
function goAuth() {
|
||||||
|
location.href = '/auth/'
|
||||||
|
}
|
||||||
|
function back() {
|
||||||
|
if (history.length > 1) history.back()
|
||||||
|
else authStore.currentView = 'login'
|
||||||
|
}
|
||||||
|
async function logout() {
|
||||||
|
await authStore.logout()
|
||||||
|
authStore.currentView = 'login'
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<style scoped>
|
||||||
|
.user-header { display:flex; align-items:center; gap:.5rem; font-size:1.1rem; margin-bottom:.75rem; }
|
||||||
|
.user-emoji { font-size:1.5rem; line-height:1; }
|
||||||
|
.user-name { font-weight:600; }
|
||||||
|
.actions { margin-top:1.5rem; display:flex; gap:.5rem; flex-wrap:nowrap; }
|
||||||
|
.hint { font-size:.9rem; opacity:.85; }
|
||||||
|
</style>
|
@ -6,10 +6,11 @@ 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)
|
resetToken: null, // transient reset token
|
||||||
|
restrictedMode: false, // If true, app loaded outside /auth/ and should restrict to login or permission denied
|
||||||
|
|
||||||
// UI State
|
// UI State
|
||||||
currentView: 'login', // 'login', 'profile', 'device-link', 'reset'
|
currentView: 'login',
|
||||||
status: {
|
status: {
|
||||||
message: '',
|
message: '',
|
||||||
type: 'info',
|
type: 'info',
|
||||||
@ -68,10 +69,20 @@ export const useAuthStore = defineStore('auth', {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
selectView() {
|
selectView() {
|
||||||
|
if (this.restrictedMode) {
|
||||||
|
// In restricted mode only allow login or show permission denied if already authenticated
|
||||||
|
if (!this.userInfo) this.currentView = 'login'
|
||||||
|
else if (this.userInfo.authenticated) this.currentView = 'permission-denied'
|
||||||
|
else this.currentView = 'login' // do not expose reset/registration flows outside /auth/
|
||||||
|
return
|
||||||
|
}
|
||||||
if (!this.userInfo) this.currentView = 'login'
|
if (!this.userInfo) this.currentView = 'login'
|
||||||
else if (this.userInfo.authenticated) this.currentView = 'profile'
|
else if (this.userInfo.authenticated) this.currentView = 'profile'
|
||||||
else this.currentView = 'reset'
|
else this.currentView = 'reset'
|
||||||
},
|
},
|
||||||
|
setRestrictedMode(flag) {
|
||||||
|
this.restrictedMode = !!flag
|
||||||
|
},
|
||||||
async loadUserInfo() {
|
async loadUserInfo() {
|
||||||
const headers = {}
|
const headers = {}
|
||||||
// Reset tokens are only passed via query param now, not Authorization header
|
// Reset tokens are only passed via query param now, not Authorization header
|
||||||
|
@ -66,7 +66,7 @@ async def bootstrap_system(
|
|||||||
dict: Contains information about created entities and reset link
|
dict: Contains information about created entities and reset link
|
||||||
"""
|
"""
|
||||||
# Create permission first - will fail if already exists
|
# Create permission first - will fail if already exists
|
||||||
perm0 = Permission(id="auth/admin", display_name="Master Admin")
|
perm0 = Permission(id="auth:admin", display_name="Master Admin")
|
||||||
await globals.db.instance.create_permission(perm0)
|
await globals.db.instance.create_permission(perm0)
|
||||||
|
|
||||||
org = Org(uuid7.create(), org_name or "Organization")
|
org = Org(uuid7.create(), org_name or "Organization")
|
||||||
@ -122,7 +122,7 @@ async def check_admin_credentials() -> bool:
|
|||||||
try:
|
try:
|
||||||
# Get permission organizations to find admin users
|
# Get permission organizations to find admin users
|
||||||
permission_orgs = await globals.db.instance.get_permission_organizations(
|
permission_orgs = await globals.db.instance.get_permission_organizations(
|
||||||
"auth/admin"
|
"auth:admin"
|
||||||
)
|
)
|
||||||
|
|
||||||
if not permission_orgs:
|
if not permission_orgs:
|
||||||
@ -173,7 +173,7 @@ async def bootstrap_if_needed(
|
|||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# Check if the admin permission exists - if it does, system is already bootstrapped
|
# Check if the admin permission exists - if it does, system is already bootstrapped
|
||||||
await globals.db.instance.get_permission("auth/admin")
|
await globals.db.instance.get_permission("auth:admin")
|
||||||
# Permission exists, system is already bootstrapped
|
# Permission exists, system is already bootstrapped
|
||||||
# Check if admin needs credentials (only for already-bootstrapped systems)
|
# Check if admin needs credentials (only for already-bootstrapped systems)
|
||||||
await check_admin_credentials()
|
await check_admin_credentials()
|
||||||
|
@ -462,8 +462,7 @@ class DB(DatabaseInterface):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Automatically create an organization admin permission if not present.
|
# Automatically create an organization admin permission if not present.
|
||||||
# Pattern: auth/org:<org-uuid>
|
auto_perm_id = f"auth:org:{org.uuid}"
|
||||||
auto_perm_id = f"auth/org:{org.uuid}"
|
|
||||||
# Only create if it does not already exist (in case caller passed it)
|
# Only create if it does not already exist (in case caller passed it)
|
||||||
existing_perm = await session.execute(
|
existing_perm = await session.execute(
|
||||||
select(PermissionModel).where(PermissionModel.id == auto_perm_id)
|
select(PermissionModel).where(PermissionModel.id == auto_perm_id)
|
||||||
|
@ -10,7 +10,7 @@ This module contains all the HTTP API endpoints for:
|
|||||||
|
|
||||||
from uuid import UUID, uuid4
|
from uuid import UUID, uuid4
|
||||||
|
|
||||||
from fastapi import Body, Cookie, Depends, FastAPI, HTTPException, Response
|
from fastapi import Body, Cookie, Depends, FastAPI, HTTPException, Query, Response
|
||||||
from fastapi.security import HTTPBearer
|
from fastapi.security import HTTPBearer
|
||||||
|
|
||||||
from passkey.util import passphrase
|
from passkey.util import passphrase
|
||||||
@ -19,9 +19,9 @@ from .. import aaguid
|
|||||||
from ..authsession import delete_credential, expires, get_reset, get_session
|
from ..authsession import delete_credential, expires, get_reset, get_session
|
||||||
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 tokens
|
from ..util import querysafe, tokens
|
||||||
from ..util.tokens import session_key
|
from ..util.tokens import session_key
|
||||||
from . import session
|
from . import authz, session
|
||||||
|
|
||||||
bearer_auth = HTTPBearer(auto_error=True)
|
bearer_auth = HTTPBearer(auto_error=True)
|
||||||
|
|
||||||
@ -38,18 +38,20 @@ def register_api_routes(app: FastAPI):
|
|||||||
raise ValueError("Not authenticated")
|
raise ValueError("Not authenticated")
|
||||||
role_perm_ids = set(ctx.role.permissions or [])
|
role_perm_ids = set(ctx.role.permissions or [])
|
||||||
org_uuid_str = str(ctx.org.uuid)
|
org_uuid_str = str(ctx.org.uuid)
|
||||||
is_global_admin = "auth/admin" in role_perm_ids
|
is_global_admin = "auth:admin" in role_perm_ids
|
||||||
is_org_admin = f"auth/org:{org_uuid_str}" in role_perm_ids
|
is_org_admin = f"auth:org:{org_uuid_str}" in role_perm_ids
|
||||||
return ctx, is_global_admin, is_org_admin
|
return ctx, is_global_admin, is_org_admin
|
||||||
|
|
||||||
@app.post("/auth/validate")
|
@app.post("/auth/validate")
|
||||||
async def validate_token(response: Response, auth=Cookie(None)):
|
async def validate_token(perm=Query(None), auth=Cookie(None)):
|
||||||
"""Lightweight token validation endpoint."""
|
"""Lightweight token validation endpoint.
|
||||||
s = await get_session(auth)
|
|
||||||
return {
|
Query Params:
|
||||||
"valid": True,
|
- perm: repeated permission IDs the caller must possess (ALL required)
|
||||||
"user_uuid": str(s.user_uuid),
|
"""
|
||||||
}
|
|
||||||
|
s = await authz.verify(auth, perm)
|
||||||
|
return {"valid": True, "user_uuid": str(s.user_uuid)}
|
||||||
|
|
||||||
@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(reset: str | None = None, auth=Cookie(None)):
|
||||||
@ -133,9 +135,9 @@ def register_api_routes(app: FastAPI):
|
|||||||
"permissions": ctx.org.permissions,
|
"permissions": ctx.org.permissions,
|
||||||
}
|
}
|
||||||
effective_permissions = [p.id for p in (ctx.permissions or [])]
|
effective_permissions = [p.id for p in (ctx.permissions or [])]
|
||||||
is_global_admin = "auth/admin" in role_info["permissions"]
|
is_global_admin = "auth:admin" in role_info["permissions"]
|
||||||
is_org_admin = (
|
is_org_admin = (
|
||||||
f"auth/org:{org_info['uuid']}" in role_info["permissions"]
|
f"auth:org:{org_info['uuid']}" in role_info["permissions"]
|
||||||
if org_info
|
if org_info
|
||||||
else False
|
else False
|
||||||
)
|
)
|
||||||
@ -258,6 +260,7 @@ def register_api_routes(app: FastAPI):
|
|||||||
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")
|
||||||
|
querysafe.assert_safe(permission_id, field="permission_id")
|
||||||
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"}
|
||||||
|
|
||||||
@ -268,6 +271,7 @@ def register_api_routes(app: FastAPI):
|
|||||||
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")
|
||||||
|
querysafe.assert_safe(permission_id, field="permission_id")
|
||||||
await db.instance.remove_permission_from_organization(
|
await db.instance.remove_permission_from_organization(
|
||||||
str(org_uuid), permission_id
|
str(org_uuid), permission_id
|
||||||
)
|
)
|
||||||
@ -504,6 +508,7 @@ def register_api_routes(app: FastAPI):
|
|||||||
display_name = payload.get("display_name")
|
display_name = payload.get("display_name")
|
||||||
if not perm_id or not display_name:
|
if not perm_id or not display_name:
|
||||||
raise ValueError("id and display_name are required")
|
raise ValueError("id and display_name are required")
|
||||||
|
querysafe.assert_safe(perm_id, field="id")
|
||||||
await db.instance.create_permission(
|
await db.instance.create_permission(
|
||||||
PermDC(id=perm_id, display_name=display_name)
|
PermDC(id=perm_id, display_name=display_name)
|
||||||
)
|
)
|
||||||
@ -520,6 +525,7 @@ def register_api_routes(app: FastAPI):
|
|||||||
|
|
||||||
if not display_name:
|
if not display_name:
|
||||||
raise ValueError("display_name is required")
|
raise ValueError("display_name is required")
|
||||||
|
querysafe.assert_safe(permission_id, field="permission_id")
|
||||||
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)
|
||||||
)
|
)
|
||||||
@ -539,6 +545,8 @@ def register_api_routes(app: FastAPI):
|
|||||||
display_name = payload.get("display_name")
|
display_name = payload.get("display_name")
|
||||||
if not old_id or not new_id:
|
if not old_id or not new_id:
|
||||||
raise ValueError("old_id and new_id required")
|
raise ValueError("old_id and new_id required")
|
||||||
|
querysafe.assert_safe(old_id, field="old_id")
|
||||||
|
querysafe.assert_safe(new_id, field="new_id")
|
||||||
if display_name is None:
|
if display_name is None:
|
||||||
# Fetch old to retain display name
|
# Fetch old to retain display name
|
||||||
perm = await db.instance.get_permission(old_id)
|
perm = await db.instance.get_permission(old_id)
|
||||||
@ -555,6 +563,7 @@ def register_api_routes(app: FastAPI):
|
|||||||
_, 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")
|
||||||
|
querysafe.assert_safe(permission_id, field="permission_id")
|
||||||
await db.instance.delete_permission(permission_id)
|
await db.instance.delete_permission(permission_id)
|
||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
39
passkey/fastapi/authz.py
Normal file
39
passkey/fastapi/authz.py
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
"""Authorization utilities shared across FastAPI endpoints.
|
||||||
|
|
||||||
|
Provides helper(s) to validate a session token (from cookie) and optionally
|
||||||
|
enforce that the user possesses a given permission (either via their role or
|
||||||
|
their organization level permissions).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
from ..authsession import get_session
|
||||||
|
from ..globals import db
|
||||||
|
from ..util.tokens import session_key
|
||||||
|
|
||||||
|
|
||||||
|
async def verify(auth: str | None, perm: list[str] | str | None):
|
||||||
|
"""Validate session token and optional list of required permissions.
|
||||||
|
|
||||||
|
Returns the Session object on success. Raises HTTPException on failure.
|
||||||
|
401: unauthenticated / invalid session
|
||||||
|
403: one or more required permissions missing
|
||||||
|
"""
|
||||||
|
if not auth:
|
||||||
|
raise HTTPException(status_code=401, detail="Authentication required")
|
||||||
|
session = await get_session(auth)
|
||||||
|
if perm is not None:
|
||||||
|
if isinstance(perm, str):
|
||||||
|
perm = [perm]
|
||||||
|
ctx = await db.instance.get_session_context(session_key(auth))
|
||||||
|
if not ctx:
|
||||||
|
raise HTTPException(status_code=401, detail="Session not found")
|
||||||
|
available = set(ctx.role.permissions or []) | (
|
||||||
|
set(ctx.org.permissions or []) if ctx.org else set()
|
||||||
|
)
|
||||||
|
if any(p not in available for p in perm):
|
||||||
|
raise HTTPException(status_code=403, detail="Permission required")
|
||||||
|
return session
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["verify"]
|
@ -4,12 +4,12 @@ import os
|
|||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from fastapi import Cookie, FastAPI, Request, Response
|
from fastapi import Cookie, FastAPI, HTTPException, Query, Request, Response
|
||||||
from fastapi.responses import FileResponse, JSONResponse
|
from fastapi.responses import FileResponse, JSONResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
|
||||||
from ..authsession import get_session
|
from ..authsession import get_session
|
||||||
from . import ws
|
from . import authz, ws
|
||||||
from .api import register_api_routes
|
from .api import register_api_routes
|
||||||
from .reset import register_reset_routes
|
from .reset import register_reset_routes
|
||||||
|
|
||||||
@ -72,26 +72,24 @@ app.mount("/auth/ws", ws.app)
|
|||||||
|
|
||||||
|
|
||||||
@app.get("/auth/forward-auth")
|
@app.get("/auth/forward-auth")
|
||||||
async def forward_authentication(request: Request, auth=Cookie(None)):
|
async def forward_authentication(request: Request, perm=Query(None), auth=Cookie(None)):
|
||||||
"""A validation endpoint to use with Caddy forward_auth or Nginx auth_request."""
|
"""A validation endpoint to use with Caddy forward_auth or Nginx auth_request.
|
||||||
if auth:
|
|
||||||
with contextlib.suppress(ValueError):
|
Query Params:
|
||||||
s = await get_session(auth)
|
- perm: repeated permission IDs the authenticated user must possess (ALL required).
|
||||||
# If authenticated, return a success response
|
|
||||||
if s.info and s.info["type"] == "authenticated":
|
Success: 204 No Content with x-auth-user-uuid header.
|
||||||
|
Failure (unauthenticated / unauthorized): 4xx with index.html body so the
|
||||||
|
client (reverse proxy or browser) can initiate auth flow.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
s = await authz.verify(auth, perm)
|
||||||
return Response(
|
return Response(
|
||||||
status_code=204,
|
status_code=204,
|
||||||
headers={
|
headers={"x-auth-user-uuid": str(s.user_uuid)},
|
||||||
"x-auth-user-uuid": str(s.user_uuid),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
# Serve the index.html of the authentication app if not authenticated
|
|
||||||
return FileResponse(
|
|
||||||
STATIC_DIR / "index.html",
|
|
||||||
status_code=401,
|
|
||||||
headers={"www-authenticate": "PrivateToken"},
|
|
||||||
)
|
)
|
||||||
|
except HTTPException as e:
|
||||||
|
return FileResponse(STATIC_DIR / "index.html", e.status_code)
|
||||||
|
|
||||||
|
|
||||||
# Serve static files
|
# Serve static files
|
||||||
|
@ -30,5 +30,4 @@ def set_session_cookie(response: Response, token: str) -> None:
|
|||||||
max_age=int(EXPIRES.total_seconds()),
|
max_age=int(EXPIRES.total_seconds()),
|
||||||
httponly=True,
|
httponly=True,
|
||||||
secure=True,
|
secure=True,
|
||||||
path="/auth/",
|
|
||||||
)
|
)
|
||||||
|
10
passkey/util/querysafe.py
Normal file
10
passkey/util/querysafe.py
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import re
|
||||||
|
|
||||||
|
_SAFE_RE = re.compile(r"^[A-Za-z0-9:._~-]+$")
|
||||||
|
|
||||||
|
|
||||||
|
def assert_safe(value: str, *, field: str = "value") -> None:
|
||||||
|
if not isinstance(value, str) or not value or not _SAFE_RE.match(value):
|
||||||
|
raise ValueError(f"{field} must match ^[A-Za-z0-9:._~-]+$")
|
||||||
|
|
||||||
|
__all__ = ["assert_safe"]
|
Loading…
x
Reference in New Issue
Block a user