Compare commits
No commits in common. "2b03fa74cd113ff8754052e1e025135081d0b287" and "3e5c0065d5f6b357ce550e877094b67d09c56453" have entirely different histories.
2b03fa74cd
...
3e5c0065d5
41
Caddyfile
41
Caddyfile
@ -1,35 +1,22 @@
|
||||
(auth) {
|
||||
# Permission check (named arg: perm=...)
|
||||
# Forward /auth/ to the authentication service
|
||||
@auth path /auth/*
|
||||
handle @auth {
|
||||
reverse_proxy localhost:4401
|
||||
}
|
||||
handle {
|
||||
# Check for authentication
|
||||
forward_auth localhost:4401 {
|
||||
uri /auth/forward-auth?{args.0}
|
||||
copy_headers x-auth-*
|
||||
uri /auth/forward-auth
|
||||
copy_headers x-auth*
|
||||
}
|
||||
{block}
|
||||
}
|
||||
}
|
||||
|
||||
localhost {
|
||||
# Single definition for auth service endpoints (avoid duplicate matcher names)
|
||||
@auth_api path /auth/*
|
||||
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
|
||||
import auth {
|
||||
# Proxy authenticated requests to the main application
|
||||
reverse_proxy localhost:3000
|
||||
}
|
||||
}
|
||||
|
@ -5,7 +5,6 @@
|
||||
<ProfileView v-if="store.currentView === 'profile'" />
|
||||
<DeviceLinkView v-if="store.currentView === 'device-link'" />
|
||||
<ResetView v-if="store.currentView === 'reset'" />
|
||||
<PermissionDeniedView v-if="store.currentView === 'permission-denied'" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@ -17,15 +16,10 @@ import LoginView from '@/components/LoginView.vue'
|
||||
import ProfileView from '@/components/ProfileView.vue'
|
||||
import DeviceLinkView from '@/components/DeviceLinkView.vue'
|
||||
import ResetView from '@/components/ResetView.vue'
|
||||
import PermissionDeniedView from '@/components/PermissionDeniedView.vue'
|
||||
|
||||
const store = useAuthStore()
|
||||
|
||||
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?
|
||||
const message = location.hash.substring(1)
|
||||
if (message) {
|
||||
|
@ -17,16 +17,6 @@ const userLink = ref(null) // latest generated registration link
|
||||
const userLinkExpires = ref(null)
|
||||
const authStore = useAuthStore()
|
||||
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) {
|
||||
if (!addingOrgForPermission.value) return
|
||||
@ -98,22 +88,22 @@ async function renamePermissionDisplay(p) {
|
||||
}
|
||||
}
|
||||
|
||||
function startRenamePermissionId(p) {
|
||||
editingPermId.value = p.id
|
||||
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 }
|
||||
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 = {} }
|
||||
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(); cancelRenameId()
|
||||
await refreshPermissionsContext()
|
||||
} catch (e) {
|
||||
alert(e?.message || 'Failed to rename permission id')
|
||||
alert((e && e.message) ? e.message : 'Failed to rename permission id')
|
||||
}
|
||||
}
|
||||
|
||||
@ -346,15 +336,20 @@ async function deleteRole(role) {
|
||||
}
|
||||
|
||||
// Permission actions
|
||||
async function submitCreatePermission() {
|
||||
const id = newPermId.value.trim()
|
||||
const name = newPermName.value.trim()
|
||||
if (!id || !name) return
|
||||
const res = await fetch('/auth/admin/permissions', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ id, display_name: name }) })
|
||||
const data = await res.json(); if (data.detail) { alert(data.detail); return }
|
||||
await loadPermissions(); newPermId.value=''; newPermName.value=''; showCreatePermission.value=false
|
||||
async function createPermission() {
|
||||
const id = prompt('Permission ID (e.g., auth/example):')
|
||||
if (!id) return
|
||||
const name = prompt('Permission display name:')
|
||||
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 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) {
|
||||
const name = prompt('Permission display name:', p.display_name)
|
||||
@ -628,13 +623,7 @@ async function toggleRolePermission(role, permId, checked) {
|
||||
<div v-if="!selectedUser && !selectedOrg && (info.is_global_admin || info.is_org_admin)" class="card">
|
||||
<h2>All Permissions</h2>
|
||||
<div class="actions">
|
||||
<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>
|
||||
<button @click="createPermission">+ Create Permission</button>
|
||||
</div>
|
||||
<div class="permission-grid">
|
||||
<div class="perm-grid-head">Permission</div>
|
||||
@ -683,16 +672,9 @@ async function toggleRolePermission(role, permId, checked) {
|
||||
</div>
|
||||
<div class="perm-cell perm-users center">{{ permissionSummary[p.id]?.userCount || 0 }}</div>
|
||||
<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="startRenamePermissionId(p)" class="icon-btn" aria-label="Change id" title="Change id">🆔</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>
|
||||
</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>
|
||||
</template>
|
||||
</div>
|
||||
|
@ -26,10 +26,7 @@ const handleLogin = async () => {
|
||||
authStore.showMessage('Starting authentication...', 'info')
|
||||
await authStore.authenticate()
|
||||
authStore.showMessage('Authentication successful!', 'success', 2000)
|
||||
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/')) {
|
||||
if (location.pathname.startsWith('/auth/')) {
|
||||
authStore.currentView = 'profile'
|
||||
} else {
|
||||
location.reload()
|
||||
|
@ -1,44 +0,0 @@
|
||||
<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,11 +6,10 @@ export const useAuthStore = defineStore('auth', {
|
||||
// Auth State
|
||||
userInfo: null, // Contains the full user info response: {user, credentials, aaguid_info, session_type, authenticated}
|
||||
isLoading: false,
|
||||
resetToken: null, // transient reset token
|
||||
restrictedMode: false, // If true, app loaded outside /auth/ and should restrict to login or permission denied
|
||||
resetToken: null, // transient reset token (never stored in cookie)
|
||||
|
||||
// UI State
|
||||
currentView: 'login',
|
||||
currentView: 'login', // 'login', 'profile', 'device-link', 'reset'
|
||||
status: {
|
||||
message: '',
|
||||
type: 'info',
|
||||
@ -69,20 +68,10 @@ export const useAuthStore = defineStore('auth', {
|
||||
}
|
||||
},
|
||||
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'
|
||||
else if (this.userInfo.authenticated) this.currentView = 'profile'
|
||||
else this.currentView = 'reset'
|
||||
},
|
||||
setRestrictedMode(flag) {
|
||||
this.restrictedMode = !!flag
|
||||
},
|
||||
async loadUserInfo() {
|
||||
const headers = {}
|
||||
// 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
|
||||
"""
|
||||
# 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)
|
||||
|
||||
org = Org(uuid7.create(), org_name or "Organization")
|
||||
@ -122,7 +122,7 @@ async def check_admin_credentials() -> bool:
|
||||
try:
|
||||
# Get permission organizations to find admin users
|
||||
permission_orgs = await globals.db.instance.get_permission_organizations(
|
||||
"auth:admin"
|
||||
"auth/admin"
|
||||
)
|
||||
|
||||
if not permission_orgs:
|
||||
@ -173,7 +173,7 @@ async def bootstrap_if_needed(
|
||||
"""
|
||||
try:
|
||||
# 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
|
||||
# Check if admin needs credentials (only for already-bootstrapped systems)
|
||||
await check_admin_credentials()
|
||||
|
@ -462,7 +462,8 @@ class DB(DatabaseInterface):
|
||||
)
|
||||
|
||||
# Automatically create an organization admin permission if not present.
|
||||
auto_perm_id = f"auth:org:{org.uuid}"
|
||||
# Pattern: 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)
|
||||
existing_perm = await session.execute(
|
||||
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 fastapi import Body, Cookie, Depends, FastAPI, HTTPException, Query, Response
|
||||
from fastapi import Body, Cookie, Depends, FastAPI, HTTPException, Response
|
||||
from fastapi.security import HTTPBearer
|
||||
|
||||
from passkey.util import passphrase
|
||||
@ -19,9 +19,9 @@ from .. import aaguid
|
||||
from ..authsession import delete_credential, expires, get_reset, get_session
|
||||
from ..globals import db
|
||||
from ..globals import passkey as global_passkey
|
||||
from ..util import querysafe, tokens
|
||||
from ..util import tokens
|
||||
from ..util.tokens import session_key
|
||||
from . import authz, session
|
||||
from . import session
|
||||
|
||||
bearer_auth = HTTPBearer(auto_error=True)
|
||||
|
||||
@ -38,20 +38,18 @@ def register_api_routes(app: FastAPI):
|
||||
raise ValueError("Not authenticated")
|
||||
role_perm_ids = set(ctx.role.permissions or [])
|
||||
org_uuid_str = str(ctx.org.uuid)
|
||||
is_global_admin = "auth:admin" in role_perm_ids
|
||||
is_org_admin = f"auth:org:{org_uuid_str}" 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
|
||||
return ctx, is_global_admin, is_org_admin
|
||||
|
||||
@app.post("/auth/validate")
|
||||
async def validate_token(perm=Query(None), auth=Cookie(None)):
|
||||
"""Lightweight token validation endpoint.
|
||||
|
||||
Query Params:
|
||||
- perm: repeated permission IDs the caller must possess (ALL required)
|
||||
"""
|
||||
|
||||
s = await authz.verify(auth, perm)
|
||||
return {"valid": True, "user_uuid": str(s.user_uuid)}
|
||||
async def validate_token(response: Response, auth=Cookie(None)):
|
||||
"""Lightweight token validation endpoint."""
|
||||
s = await get_session(auth)
|
||||
return {
|
||||
"valid": True,
|
||||
"user_uuid": str(s.user_uuid),
|
||||
}
|
||||
|
||||
@app.post("/auth/user-info")
|
||||
async def api_user_info(reset: str | None = None, auth=Cookie(None)):
|
||||
@ -135,9 +133,9 @@ def register_api_routes(app: FastAPI):
|
||||
"permissions": ctx.org.permissions,
|
||||
}
|
||||
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 = (
|
||||
f"auth:org:{org_info['uuid']}" in role_info["permissions"]
|
||||
f"auth/org:{org_info['uuid']}" in role_info["permissions"]
|
||||
if org_info
|
||||
else False
|
||||
)
|
||||
@ -260,7 +258,6 @@ def register_api_routes(app: FastAPI):
|
||||
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)):
|
||||
raise ValueError("Insufficient permissions")
|
||||
querysafe.assert_safe(permission_id, field="permission_id")
|
||||
await db.instance.add_permission_to_organization(str(org_uuid), permission_id)
|
||||
return {"status": "ok"}
|
||||
|
||||
@ -271,7 +268,6 @@ def register_api_routes(app: FastAPI):
|
||||
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)):
|
||||
raise ValueError("Insufficient permissions")
|
||||
querysafe.assert_safe(permission_id, field="permission_id")
|
||||
await db.instance.remove_permission_from_organization(
|
||||
str(org_uuid), permission_id
|
||||
)
|
||||
@ -508,7 +504,6 @@ def register_api_routes(app: FastAPI):
|
||||
display_name = payload.get("display_name")
|
||||
if not perm_id or not display_name:
|
||||
raise ValueError("id and display_name are required")
|
||||
querysafe.assert_safe(perm_id, field="id")
|
||||
await db.instance.create_permission(
|
||||
PermDC(id=perm_id, display_name=display_name)
|
||||
)
|
||||
@ -525,7 +520,6 @@ def register_api_routes(app: FastAPI):
|
||||
|
||||
if not display_name:
|
||||
raise ValueError("display_name is required")
|
||||
querysafe.assert_safe(permission_id, field="permission_id")
|
||||
await db.instance.update_permission(
|
||||
PermDC(id=permission_id, display_name=display_name)
|
||||
)
|
||||
@ -545,8 +539,6 @@ def register_api_routes(app: FastAPI):
|
||||
display_name = payload.get("display_name")
|
||||
if not old_id or not new_id:
|
||||
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:
|
||||
# Fetch old to retain display name
|
||||
perm = await db.instance.get_permission(old_id)
|
||||
@ -563,7 +555,6 @@ def register_api_routes(app: FastAPI):
|
||||
_, is_global_admin, _ = await _get_ctx_and_admin_flags(auth)
|
||||
if not is_global_admin:
|
||||
raise ValueError("Global admin required")
|
||||
querysafe.assert_safe(permission_id, field="permission_id")
|
||||
await db.instance.delete_permission(permission_id)
|
||||
return {"status": "ok"}
|
||||
|
||||
|
@ -1,39 +0,0 @@
|
||||
"""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 pathlib import Path
|
||||
|
||||
from fastapi import Cookie, FastAPI, HTTPException, Query, Request, Response
|
||||
from fastapi import Cookie, FastAPI, Request, Response
|
||||
from fastapi.responses import FileResponse, JSONResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from ..authsession import get_session
|
||||
from . import authz, ws
|
||||
from . import ws
|
||||
from .api import register_api_routes
|
||||
from .reset import register_reset_routes
|
||||
|
||||
@ -72,24 +72,26 @@ app.mount("/auth/ws", ws.app)
|
||||
|
||||
|
||||
@app.get("/auth/forward-auth")
|
||||
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.
|
||||
|
||||
Query Params:
|
||||
- perm: repeated permission IDs the authenticated user must possess (ALL required).
|
||||
|
||||
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)
|
||||
async def forward_authentication(request: Request, auth=Cookie(None)):
|
||||
"""A validation endpoint to use with Caddy forward_auth or Nginx auth_request."""
|
||||
if auth:
|
||||
with contextlib.suppress(ValueError):
|
||||
s = await get_session(auth)
|
||||
# If authenticated, return a success response
|
||||
if s.info and s.info["type"] == "authenticated":
|
||||
return Response(
|
||||
status_code=204,
|
||||
headers={"x-auth-user-uuid": str(s.user_uuid)},
|
||||
headers={
|
||||
"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
|
||||
|
@ -30,4 +30,5 @@ def set_session_cookie(response: Response, token: str) -> None:
|
||||
max_age=int(EXPIRES.total_seconds()),
|
||||
httponly=True,
|
||||
secure=True,
|
||||
path="/auth/",
|
||||
)
|
||||
|
@ -1,10 +0,0 @@
|
||||
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