Implement metadata for RestrictedForward, set by /auth/api/forward endpoint when returning the app. Use this to implement support for time-based reauth requirement.
This commit is contained in:
@@ -2,7 +2,6 @@
|
|||||||
<RestrictedAuth
|
<RestrictedAuth
|
||||||
:mode="authMode"
|
:mode="authMode"
|
||||||
@authenticated="handleAuthenticated"
|
@authenticated="handleAuthenticated"
|
||||||
@logout="handleLogout"
|
|
||||||
@back="goBack"
|
@back="goBack"
|
||||||
@home="returnHome"
|
@home="returnHome"
|
||||||
/>
|
/>
|
||||||
@@ -16,20 +15,21 @@ import { goBack } from '@/utils/helpers'
|
|||||||
|
|
||||||
const basePath = computed(() => uiBasePath())
|
const basePath = computed(() => uiBasePath())
|
||||||
|
|
||||||
// Detect mode from URL parameters
|
// Detect mode from data attribute on html tag only
|
||||||
|
// (RestrictedApi uses URL query, RestrictedForward uses data injected by server)
|
||||||
const authMode = computed(() => {
|
const authMode = computed(() => {
|
||||||
const params = new URLSearchParams(window.location.search)
|
const htmlElement = document.documentElement
|
||||||
return params.get('mode') === 'reauth' ? 'reauth' : 'login'
|
const dataMode = htmlElement.getAttribute('data-mode')
|
||||||
|
if (dataMode === 'reauth') return 'reauth'
|
||||||
|
if (dataMode === 'forbidden') return 'forbidden'
|
||||||
|
return 'login'
|
||||||
})
|
})
|
||||||
|
|
||||||
function handleAuthenticated() {
|
function handleAuthenticated() {
|
||||||
|
// Reload page to re-trigger forward auth validation
|
||||||
location.reload()
|
location.reload()
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleLogout() {
|
|
||||||
window.location.reload()
|
|
||||||
}
|
|
||||||
|
|
||||||
function returnHome() {
|
function returnHome() {
|
||||||
const target = basePath.value || '/auth/'
|
const target = basePath.value || '/auth/'
|
||||||
if (window.location.pathname !== target) history.replaceState(null, '', target)
|
if (window.location.pathname !== target) history.replaceState(null, '', target)
|
||||||
|
|||||||
@@ -29,8 +29,8 @@
|
|||||||
<button v-if="canAuthenticate" class="btn-primary" :disabled="loading" @click="authenticateUser">
|
<button v-if="canAuthenticate" class="btn-primary" :disabled="loading" @click="authenticateUser">
|
||||||
{{ loading ? (mode === 'reauth' ? 'Verifying…' : 'Signing in…') : (mode === 'reauth' ? 'Verify' : 'Login') }}
|
{{ loading ? (mode === 'reauth' ? 'Verifying…' : 'Signing in…') : (mode === 'reauth' ? 'Verify' : 'Login') }}
|
||||||
</button>
|
</button>
|
||||||
<button v-if="isAuthenticated && mode !== 'reauth'" class="btn-danger" :disabled="loading" @click="logoutUser">Logout</button>
|
<button v-if="isAuthenticated && mode !== 'reauth' && mode !== 'forbidden'" class="btn-danger" :disabled="loading" @click="logoutUser">Logout</button>
|
||||||
<button v-if="isAuthenticated && mode !== 'reauth'" class="btn-primary" :disabled="loading" @click="openProfile">Profile</button>
|
<button v-if="isAuthenticated && mode !== 'reauth' && mode !== 'forbidden'" class="btn-primary" :disabled="loading" @click="openProfile">Profile</button>
|
||||||
</slot>
|
</slot>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -49,7 +49,7 @@ const props = defineProps({
|
|||||||
mode: {
|
mode: {
|
||||||
type: String,
|
type: String,
|
||||||
default: 'login',
|
default: 'login',
|
||||||
validator: (value) => ['login', 'reauth'].includes(value)
|
validator: (value) => ['login', 'reauth', 'forbidden'].includes(value)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -69,15 +69,17 @@ const canAuthenticate = computed(() => {
|
|||||||
if (initializing.value) return false
|
if (initializing.value) return false
|
||||||
// In reauth mode, allow authentication even if already authenticated
|
// In reauth mode, allow authentication even if already authenticated
|
||||||
if (props.mode === 'reauth') return true
|
if (props.mode === 'reauth') return true
|
||||||
// In login view or initial state, allow if not authenticated
|
// In forbidden mode or forbidden view, don't allow authentication
|
||||||
return currentView.value !== 'forbidden'
|
if (props.mode === 'forbidden' || currentView.value === 'forbidden') return false
|
||||||
|
// In login view or initial state, allow authentication
|
||||||
|
return true
|
||||||
})
|
})
|
||||||
|
|
||||||
const headingTitle = computed(() => {
|
const headingTitle = computed(() => {
|
||||||
if (props.mode === 'reauth') {
|
if (props.mode === 'reauth') {
|
||||||
return `🔐 Additional Verification Required`
|
return `🔐 Additional Verification Required`
|
||||||
}
|
}
|
||||||
if (currentView.value === 'forbidden') return '🚫 Forbidden'
|
if (props.mode === 'forbidden' || currentView.value === 'forbidden') return '🚫 Forbidden'
|
||||||
return `🔐 ${settings.value?.rp_name || location.origin}`
|
return `🔐 ${settings.value?.rp_name || location.origin}`
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -85,7 +87,10 @@ const headerMessage = computed(() => {
|
|||||||
if (props.mode === 'reauth') {
|
if (props.mode === 'reauth') {
|
||||||
return 'Please verify your identity to continue with this action.'
|
return 'Please verify your identity to continue with this action.'
|
||||||
}
|
}
|
||||||
return currentView.value === 'forbidden' ? 'You lack the required permissions.' : 'Please sign in with your passkey.'
|
if (props.mode === 'forbidden' || currentView.value === 'forbidden') {
|
||||||
|
return 'You lack the required permissions.'
|
||||||
|
}
|
||||||
|
return 'Please sign in with your passkey.'
|
||||||
})
|
})
|
||||||
|
|
||||||
const userDisplayName = computed(() => userInfo.value?.user?.user_name || 'User')
|
const userDisplayName = computed(() => userInfo.value?.user?.user_name || 'User')
|
||||||
|
|||||||
+20
-10
@@ -24,7 +24,7 @@ from ..authsession import (
|
|||||||
)
|
)
|
||||||
from ..globals import db
|
from ..globals import db
|
||||||
from ..globals import passkey as global_passkey
|
from ..globals import passkey as global_passkey
|
||||||
from ..util import hostutil, passphrase, userinfo
|
from ..util import hostutil, htmlutil, passphrase, userinfo
|
||||||
from ..util.tokens import session_key
|
from ..util.tokens import session_key
|
||||||
from . import authz, session, user
|
from . import authz, session, user
|
||||||
from .session import AUTH_COOKIE
|
from .session import AUTH_COOKIE
|
||||||
@@ -109,18 +109,24 @@ async def forward_authentication(
|
|||||||
request: Request,
|
request: Request,
|
||||||
response: Response,
|
response: Response,
|
||||||
perm: list[str] = Query([]),
|
perm: list[str] = Query([]),
|
||||||
|
max_age: str | None = Query(None),
|
||||||
auth=AUTH_COOKIE,
|
auth=AUTH_COOKIE,
|
||||||
):
|
):
|
||||||
"""Forward auth validation for Caddy/Nginx.
|
"""Forward auth validation for Caddy/Nginx.
|
||||||
|
|
||||||
Query Params:
|
Query Params:
|
||||||
- perm: repeated permission IDs the authenticated user must possess (ALL required).
|
- perm: repeated permission IDs the authenticated user must possess (ALL required).
|
||||||
|
- max_age: maximum age of authentication (e.g., "5m", "1h", "30s"). If the session
|
||||||
|
is older than this, user must re-authenticate.
|
||||||
|
|
||||||
Success: 204 No Content with Remote-* headers describing the authenticated user.
|
Success: 204 No Content with Remote-* headers describing the authenticated user.
|
||||||
Failure (unauthenticated / unauthorized): 4xx JSON body with detail.
|
Failure (unauthenticated / unauthorized): 4xx with HTML page for authentication.
|
||||||
|
The HTML includes data attributes for mode and other metadata.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
ctx = await authz.verify(auth, perm, host=request.headers.get("host"))
|
ctx = await authz.verify(
|
||||||
|
auth, perm, host=request.headers.get("host"), max_age=max_age
|
||||||
|
)
|
||||||
role_permissions = set(ctx.role.permissions or [])
|
role_permissions = set(ctx.role.permissions or [])
|
||||||
if ctx.permissions:
|
if ctx.permissions:
|
||||||
role_permissions.update(permission.id for permission in ctx.permissions)
|
role_permissions.update(permission.id for permission in ctx.permissions)
|
||||||
@@ -147,14 +153,18 @@ async def forward_authentication(
|
|||||||
"Remote-Credential": str(ctx.session.credential_uuid),
|
"Remote-Credential": str(ctx.session.credential_uuid),
|
||||||
}
|
}
|
||||||
return Response(status_code=204, headers=remote_headers)
|
return Response(status_code=204, headers=remote_headers)
|
||||||
except HTTPException as e:
|
except authz.AuthException as e:
|
||||||
# Let global handler clear cookie; still return HTML surface instead of JSON
|
# Authentication/authorization failed - return HTML with metadata
|
||||||
html = frontend.file("int", "restricted", "index.html").read_bytes()
|
html = frontend.file("int", "forward", "index.html").read_bytes()
|
||||||
status = e.status_code
|
# Inject mode and any additional metadata
|
||||||
# If 401 we still want cookie cleared; rely on handler by raising again not feasible (we need HTML)
|
data_attrs = {"mode": e.mode, **e.metadata}
|
||||||
if status == 401:
|
html = htmlutil.patch_html_data_attrs(html, **data_attrs)
|
||||||
|
# Clear cookie only if session is invalid (not for reauth)
|
||||||
|
if e.clear_session:
|
||||||
session.clear_session_cookie(response)
|
session.clear_session_cookie(response)
|
||||||
return Response(html, status_code=status, media_type="text/html")
|
return Response(
|
||||||
|
html, status_code=e.status_code, media_type="text/html; charset=UTF-8"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/settings")
|
@app.get("/settings")
|
||||||
|
|||||||
@@ -2,31 +2,76 @@ import logging
|
|||||||
|
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
|
|
||||||
from ..util import permutil
|
from ..util import permutil, sessionutil
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class AuthException(HTTPException):
|
||||||
|
"""Exception raised during authentication/authorization with metadata for the UI.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
status_code: HTTP status code (401 for auth, 403 for authz)
|
||||||
|
detail: Error message
|
||||||
|
mode: UI mode ('login' or 'reauth')
|
||||||
|
clear_session: Whether to clear the session cookie (True for invalid sessions)
|
||||||
|
metadata: Additional data to pass to the frontend
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
status_code: int,
|
||||||
|
detail: str,
|
||||||
|
mode: str,
|
||||||
|
clear_session: bool = False,
|
||||||
|
**metadata,
|
||||||
|
):
|
||||||
|
super().__init__(status_code=status_code, detail=detail)
|
||||||
|
self.mode = mode
|
||||||
|
self.clear_session = clear_session
|
||||||
|
self.metadata = metadata
|
||||||
|
|
||||||
|
|
||||||
async def verify(
|
async def verify(
|
||||||
auth: str | None,
|
auth: str | None,
|
||||||
perm: list[str],
|
perm: list[str],
|
||||||
match=permutil.has_all,
|
match=permutil.has_all,
|
||||||
host: str | None = None,
|
host: str | None = None,
|
||||||
|
max_age: str | None = None,
|
||||||
):
|
):
|
||||||
"""Validate session token and optional list of required permissions.
|
"""Validate session token and optional list of required permissions.
|
||||||
|
|
||||||
Returns the session context.
|
Returns the session context.
|
||||||
|
|
||||||
Raises HTTPException on failure:
|
Raises AuthException on failure with metadata for UI rendering.
|
||||||
401: unauthenticated / invalid session
|
|
||||||
403: required permissions missing
|
|
||||||
"""
|
"""
|
||||||
if not auth:
|
if not auth:
|
||||||
raise HTTPException(status_code=401, detail="Authentication required")
|
raise AuthException(
|
||||||
|
status_code=401,
|
||||||
|
detail="Authentication required",
|
||||||
|
mode="login",
|
||||||
|
)
|
||||||
|
|
||||||
ctx = await permutil.session_context(auth, host)
|
ctx = await permutil.session_context(auth, host)
|
||||||
if not ctx:
|
if not ctx:
|
||||||
raise HTTPException(status_code=401, detail="Session not found")
|
raise AuthException(
|
||||||
|
status_code=401,
|
||||||
|
detail="Your session has expired. Please sign in again.",
|
||||||
|
mode="login",
|
||||||
|
clear_session=True,
|
||||||
|
)
|
||||||
|
# Check max_age requirement if specified
|
||||||
|
if max_age:
|
||||||
|
try:
|
||||||
|
if not sessionutil.check_session_age(ctx.session, max_age):
|
||||||
|
raise AuthException(
|
||||||
|
status_code=401,
|
||||||
|
detail="Additional authentication required",
|
||||||
|
mode="reauth",
|
||||||
|
)
|
||||||
|
except ValueError as e:
|
||||||
|
# Invalid max_age format - log but don't fail the request
|
||||||
|
logger.warning(f"Invalid max_age format '{max_age}': {e}")
|
||||||
|
|
||||||
if not match(ctx, perm):
|
if not match(ctx, perm):
|
||||||
# Determine which permissions are missing for clearer diagnostics
|
# Determine which permissions are missing for clearer diagnostics
|
||||||
@@ -39,6 +84,8 @@ async def verify(
|
|||||||
perm,
|
perm,
|
||||||
ctx.role.permissions,
|
ctx.role.permissions,
|
||||||
)
|
)
|
||||||
raise HTTPException(status_code=403, detail="Permission required")
|
raise AuthException(
|
||||||
|
status_code=403, mode="forbidden", detail="Permission required"
|
||||||
|
)
|
||||||
|
|
||||||
return ctx
|
return ctx
|
||||||
|
|||||||
@@ -59,6 +59,11 @@ app.mount(
|
|||||||
StaticFiles(directory=frontend.file("auth", "assets")),
|
StaticFiles(directory=frontend.file("auth", "assets")),
|
||||||
name="assets",
|
name="assets",
|
||||||
)
|
)
|
||||||
|
app.mount(
|
||||||
|
"/int/",
|
||||||
|
StaticFiles(directory=frontend.file("int"), html=True),
|
||||||
|
name="int",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/auth/restricted/")
|
@app.get("/auth/restricted/")
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
"""Utility functions for HTML manipulation."""
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
|
||||||
|
def patch_html_data_attrs(html: bytes, **data_attrs: str) -> bytes:
|
||||||
|
"""Patch HTML by adding data attributes to the <html> tag.
|
||||||
|
|
||||||
|
If an <html> tag exists, adds data attributes to it.
|
||||||
|
If no <html> tag exists, prepends one with the data attributes.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
html: The HTML content as bytes
|
||||||
|
**data_attrs: Key-value pairs for data attributes (e.g., mode='reauth')
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Modified HTML as bytes
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
>>> patch_html_data_attrs(b'<html><body>test</body></html>', mode='reauth')
|
||||||
|
b'<html data-mode="reauth"><body>test</body></html>'
|
||||||
|
|
||||||
|
>>> patch_html_data_attrs(b'<body>test</body>', mode='reauth')
|
||||||
|
b'<html data-mode="reauth"><body>test</body>'
|
||||||
|
"""
|
||||||
|
if not data_attrs:
|
||||||
|
return html
|
||||||
|
|
||||||
|
html_str = html.decode("utf-8")
|
||||||
|
|
||||||
|
# Build the data attributes string
|
||||||
|
attrs_str = " ".join(f'data-{key}="{value}"' for key, value in data_attrs.items())
|
||||||
|
|
||||||
|
# Check if there's an <html> tag (case-insensitive, may have existing attributes)
|
||||||
|
html_tag_pattern = re.compile(r"<html([^>]*)>", re.IGNORECASE)
|
||||||
|
match = html_tag_pattern.search(html_str)
|
||||||
|
|
||||||
|
if match:
|
||||||
|
# Insert data attributes into existing <html> tag
|
||||||
|
existing_attrs = match.group(1)
|
||||||
|
new_tag = f"<html{existing_attrs} {attrs_str}>"
|
||||||
|
html_str = html_tag_pattern.sub(new_tag, html_str, count=1)
|
||||||
|
else:
|
||||||
|
# Prepend <html> tag with data attributes
|
||||||
|
html_str = f"<html {attrs_str}>" + html_str
|
||||||
|
|
||||||
|
return html_str.encode("utf-8")
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
"""Utility functions for session validation and checking."""
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from ..db import Session
|
||||||
|
from .timeutil import parse_duration
|
||||||
|
|
||||||
|
|
||||||
|
def check_session_age(session: Session, max_age: str | None) -> bool:
|
||||||
|
"""Check if a session satisfies the max_age requirement.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session: The session record to check
|
||||||
|
max_age: Maximum age string (e.g., "5m", "1h", "30s") or None
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if session is recent enough or max_age is None, False if too old
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If max_age format is invalid
|
||||||
|
"""
|
||||||
|
if not max_age:
|
||||||
|
return True
|
||||||
|
|
||||||
|
max_age_delta = parse_duration(max_age)
|
||||||
|
time_since_auth = datetime.now(timezone.utc) - session.renewed
|
||||||
|
return time_since_auth <= max_age_delta
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
"""Utility functions for parsing time durations."""
|
||||||
|
|
||||||
|
import re
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
|
||||||
|
def parse_duration(duration_str: str) -> timedelta:
|
||||||
|
"""Parse a duration string into a timedelta.
|
||||||
|
|
||||||
|
Supports units: s, m, min, h, d
|
||||||
|
Examples: "30s", "5m", "5min", "2h", "1d"
|
||||||
|
|
||||||
|
Args:
|
||||||
|
duration_str: A string like "30s", "5m", "2h"
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A timedelta object
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If the format is invalid
|
||||||
|
"""
|
||||||
|
duration_str = duration_str.strip().lower()
|
||||||
|
|
||||||
|
# Pattern matches: number + unit
|
||||||
|
# Units: s (seconds), m/min (minutes), h (hours), d (days)
|
||||||
|
pattern = r"^(\d+(?:\.\d+)?)(s|m|min|h|d)$"
|
||||||
|
match = re.match(pattern, duration_str)
|
||||||
|
|
||||||
|
if not match:
|
||||||
|
raise ValueError(
|
||||||
|
f"Invalid duration format: '{duration_str}'. "
|
||||||
|
"Expected format like '30s', '5m', '5min', '2h', or '1d'"
|
||||||
|
)
|
||||||
|
|
||||||
|
value = float(match.group(1))
|
||||||
|
unit = match.group(2)
|
||||||
|
|
||||||
|
if unit == "s":
|
||||||
|
return timedelta(seconds=value)
|
||||||
|
elif unit in ("m", "min"):
|
||||||
|
return timedelta(minutes=value)
|
||||||
|
elif unit == "h":
|
||||||
|
return timedelta(hours=value)
|
||||||
|
elif unit == "d":
|
||||||
|
return timedelta(days=value)
|
||||||
|
else:
|
||||||
|
raise ValueError(f"Unsupported time unit: {unit}")
|
||||||
Reference in New Issue
Block a user