Add host-based authentication, UTC timestamps, session management, and secure cookies; fix styling issues; refactor to remove module; update database schema for sessions and reset tokens.

This commit is contained in:
Leo Vasanko
2025-10-03 18:31:54 -06:00
parent 963ab06664
commit 591ea626bf
29 changed files with 1489 additions and 611 deletions

View File

@@ -1,12 +1,22 @@
import logging
from datetime import timezone
from uuid import UUID, uuid4
from fastapi import Body, Cookie, FastAPI, HTTPException, Request
from fastapi.responses import FileResponse, JSONResponse
from ..authsession import expires
from ..authsession import reset_expires
from ..globals import db
from ..util import frontend, hostutil, passphrase, permutil, querysafe, tokens
from ..util import (
frontend,
hostutil,
passphrase,
permutil,
querysafe,
tokens,
useragent,
)
from ..util.tokens import encode_session_key, session_key
from . import authz
app = FastAPI()
@@ -24,9 +34,14 @@ async def general_exception_handler(_request, exc: Exception):
@app.get("/")
async def adminapp(auth=Cookie(None)):
async def adminapp(request: Request, auth=Cookie(None, alias="__Host-auth")):
try:
await authz.verify(auth, ["auth:admin", "auth:org:*"], match=permutil.has_any)
await authz.verify(
auth,
["auth:admin", "auth:org:*"],
match=permutil.has_any,
host=request.headers.get("host"),
)
return FileResponse(frontend.file("admin/index.html"))
except HTTPException as e:
return FileResponse(frontend.file("index.html"), status_code=e.status_code)
@@ -36,8 +51,13 @@ async def adminapp(auth=Cookie(None)):
@app.get("/orgs")
async def admin_list_orgs(auth=Cookie(None)):
ctx = await authz.verify(auth, ["auth:admin", "auth:org:*"], match=permutil.has_any)
async def admin_list_orgs(request: Request, auth=Cookie(None, alias="__Host-auth")):
ctx = await authz.verify(
auth,
["auth:admin", "auth:org:*"],
match=permutil.has_any,
host=request.headers.get("host"),
)
orgs = await db.instance.list_organizations()
if "auth:admin" not in ctx.role.permissions:
orgs = [o for o in orgs if f"auth:org:{o.uuid}" in ctx.role.permissions]
@@ -73,8 +93,12 @@ async def admin_list_orgs(auth=Cookie(None)):
@app.post("/orgs")
async def admin_create_org(payload: dict = Body(...), auth=Cookie(None)):
await authz.verify(auth, ["auth:admin"])
async def admin_create_org(
request: Request, payload: dict = Body(...), auth=Cookie(None, alias="__Host-auth")
):
await authz.verify(
auth, ["auth:admin"], host=request.headers.get("host"), match=permutil.has_all
)
from ..db import Org as OrgDC # local import to avoid cycles
from ..db import Role as RoleDC # local import to avoid cycles
@@ -99,10 +123,16 @@ async def admin_create_org(payload: dict = Body(...), auth=Cookie(None)):
@app.put("/orgs/{org_uuid}")
async def admin_update_org(
org_uuid: UUID, payload: dict = Body(...), auth=Cookie(None)
org_uuid: UUID,
request: Request,
payload: dict = Body(...),
auth=Cookie(None, alias="__Host-auth"),
):
ctx = await authz.verify(
auth, ["auth:admin", f"auth:org:{org_uuid}"], match=permutil.has_any
auth,
["auth:admin", f"auth:org:{org_uuid}"],
match=permutil.has_any,
host=request.headers.get("host"),
)
from ..db import Org as OrgDC # local import to avoid cycles
@@ -129,9 +159,14 @@ async def admin_update_org(
@app.delete("/orgs/{org_uuid}")
async def admin_delete_org(org_uuid: UUID, auth=Cookie(None)):
async def admin_delete_org(
org_uuid: UUID, request: Request, auth=Cookie(None, alias="__Host-auth")
):
ctx = await authz.verify(
auth, ["auth:admin", f"auth:org:{org_uuid}"], match=permutil.has_any
auth,
["auth:admin", f"auth:org:{org_uuid}"],
match=permutil.has_any,
host=request.headers.get("host"),
)
if ctx.org.uuid == org_uuid:
raise ValueError("Cannot delete the organization you belong to")
@@ -156,18 +191,28 @@ async def admin_delete_org(org_uuid: UUID, auth=Cookie(None)):
@app.post("/orgs/{org_uuid}/permission")
async def admin_add_org_permission(
org_uuid: UUID, permission_id: str, auth=Cookie(None)
org_uuid: UUID,
permission_id: str,
request: Request,
auth=Cookie(None, alias="__Host-auth"),
):
await authz.verify(auth, ["auth:admin"])
await authz.verify(
auth, ["auth:admin"], host=request.headers.get("host"), match=permutil.has_all
)
await db.instance.add_permission_to_organization(str(org_uuid), permission_id)
return {"status": "ok"}
@app.delete("/orgs/{org_uuid}/permission")
async def admin_remove_org_permission(
org_uuid: UUID, permission_id: str, auth=Cookie(None)
org_uuid: UUID,
permission_id: str,
request: Request,
auth=Cookie(None, alias="__Host-auth"),
):
await authz.verify(auth, ["auth:admin"])
await authz.verify(
auth, ["auth:admin"], host=request.headers.get("host"), match=permutil.has_all
)
await db.instance.remove_permission_from_organization(str(org_uuid), permission_id)
return {"status": "ok"}
@@ -177,10 +222,16 @@ async def admin_remove_org_permission(
@app.post("/orgs/{org_uuid}/roles")
async def admin_create_role(
org_uuid: UUID, payload: dict = Body(...), auth=Cookie(None)
org_uuid: UUID,
request: Request,
payload: dict = Body(...),
auth=Cookie(None, alias="__Host-auth"),
):
await authz.verify(
auth, ["auth:admin", f"auth:org:{org_uuid}"], match=permutil.has_any
auth,
["auth:admin", f"auth:org:{org_uuid}"],
match=permutil.has_any,
host=request.headers.get("host"),
)
from ..db import Role as RoleDC
@@ -205,11 +256,18 @@ async def admin_create_role(
@app.put("/orgs/{org_uuid}/roles/{role_uuid}")
async def admin_update_role(
org_uuid: UUID, role_uuid: UUID, payload: dict = Body(...), auth=Cookie(None)
org_uuid: UUID,
role_uuid: UUID,
request: Request,
payload: dict = Body(...),
auth=Cookie(None, alias="__Host-auth"),
):
# Verify caller is global admin or admin of provided org
ctx = await authz.verify(
auth, ["auth:admin", f"auth:org:{org_uuid}"], match=permutil.has_any
auth,
["auth:admin", f"auth:org:{org_uuid}"],
match=permutil.has_any,
host=request.headers.get("host"),
)
role = await db.instance.get_role(role_uuid)
if role.org_uuid != org_uuid:
@@ -247,9 +305,17 @@ async def admin_update_role(
@app.delete("/orgs/{org_uuid}/roles/{role_uuid}")
async def admin_delete_role(org_uuid: UUID, role_uuid: UUID, auth=Cookie(None)):
async def admin_delete_role(
org_uuid: UUID,
role_uuid: UUID,
request: Request,
auth=Cookie(None, alias="__Host-auth"),
):
ctx = await authz.verify(
auth, ["auth:admin", f"auth:org:{org_uuid}"], match=permutil.has_any
auth,
["auth:admin", f"auth:org:{org_uuid}"],
match=permutil.has_any,
host=request.headers.get("host"),
)
role = await db.instance.get_role(role_uuid)
if role.org_uuid != org_uuid:
@@ -268,10 +334,16 @@ async def admin_delete_role(org_uuid: UUID, role_uuid: UUID, auth=Cookie(None)):
@app.post("/orgs/{org_uuid}/users")
async def admin_create_user(
org_uuid: UUID, payload: dict = Body(...), auth=Cookie(None)
org_uuid: UUID,
request: Request,
payload: dict = Body(...),
auth=Cookie(None, alias="__Host-auth"),
):
await authz.verify(
auth, ["auth:admin", f"auth:org:{org_uuid}"], match=permutil.has_any
auth,
["auth:admin", f"auth:org:{org_uuid}"],
match=permutil.has_any,
host=request.headers.get("host"),
)
display_name = payload.get("display_name")
role_name = payload.get("role")
@@ -297,10 +369,17 @@ async def admin_create_user(
@app.put("/orgs/{org_uuid}/users/{user_uuid}/role")
async def admin_update_user_role(
org_uuid: UUID, user_uuid: UUID, payload: dict = Body(...), auth=Cookie(None)
org_uuid: UUID,
user_uuid: UUID,
request: Request,
payload: dict = Body(...),
auth=Cookie(None, alias="__Host-auth"),
):
ctx = await authz.verify(
auth, ["auth:admin", f"auth:org:{org_uuid}"], match=permutil.has_any
auth,
["auth:admin", f"auth:org:{org_uuid}"],
match=permutil.has_any,
host=request.headers.get("host"),
)
new_role = payload.get("role")
if not new_role:
@@ -334,7 +413,10 @@ async def admin_update_user_role(
@app.post("/orgs/{org_uuid}/users/{user_uuid}/create-link")
async def admin_create_user_registration_link(
org_uuid: UUID, user_uuid: UUID, request: Request, auth=Cookie(None)
org_uuid: UUID,
user_uuid: UUID,
request: Request,
auth=Cookie(None, alias="__Host-auth"),
):
try:
user_org, _role_name = await db.instance.get_user_organization(user_uuid)
@@ -343,7 +425,10 @@ async def admin_create_user_registration_link(
if user_org.uuid != org_uuid:
raise HTTPException(status_code=404, detail="User not found in organization")
ctx = await authz.verify(
auth, ["auth:admin", f"auth:org:{org_uuid}"], match=permutil.has_any
auth,
["auth:admin", f"auth:org:{org_uuid}"],
match=permutil.has_any,
host=request.headers.get("host"),
)
if (
"auth:admin" not in ctx.role.permissions
@@ -351,20 +436,33 @@ async def admin_create_user_registration_link(
):
raise HTTPException(status_code=403, detail="Insufficient permissions")
token = passphrase.generate()
await db.instance.create_session(
expiry = reset_expires()
await db.instance.create_reset_token(
user_uuid=user_uuid,
key=tokens.reset_key(token),
expires=expires(),
info={"type": "device addition", "created_by_admin": True},
expiry=expiry,
token_type="device addition",
)
url = hostutil.reset_link_url(
token, request.url.scheme, request.headers.get("host")
)
return {"url": url, "expires": expires().isoformat()}
return {
"url": url,
"expires": (
expiry.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
if expiry.tzinfo
else expiry.replace(tzinfo=timezone.utc).isoformat().replace("+00:00", "Z")
),
}
@app.get("/orgs/{org_uuid}/users/{user_uuid}")
async def admin_get_user_detail(org_uuid: UUID, user_uuid: UUID, auth=Cookie(None)):
async def admin_get_user_detail(
org_uuid: UUID,
user_uuid: UUID,
request: Request,
auth=Cookie(None, alias="__Host-auth"),
):
try:
user_org, role_name = await db.instance.get_user_organization(user_uuid)
except ValueError:
@@ -372,7 +470,10 @@ async def admin_get_user_detail(org_uuid: UUID, user_uuid: UUID, auth=Cookie(Non
if user_org.uuid != org_uuid:
raise HTTPException(status_code=404, detail="User not found in organization")
ctx = await authz.verify(
auth, ["auth:admin", f"auth:org:{org_uuid}"], match=permutil.has_any
auth,
["auth:admin", f"auth:org:{org_uuid}"],
match=permutil.has_any,
host=request.headers.get("host"),
)
if (
"auth:admin" not in ctx.role.permissions
@@ -394,9 +495,41 @@ async def admin_get_user_detail(org_uuid: UUID, user_uuid: UUID, auth=Cookie(Non
{
"credential_uuid": str(c.uuid),
"aaguid": aaguid_str,
"created_at": c.created_at.isoformat(),
"last_used": c.last_used.isoformat() if c.last_used else None,
"last_verified": c.last_verified.isoformat()
"created_at": (
c.created_at.astimezone(timezone.utc)
.isoformat()
.replace("+00:00", "Z")
if c.created_at.tzinfo
else c.created_at.replace(tzinfo=timezone.utc)
.isoformat()
.replace("+00:00", "Z")
),
"last_used": (
c.last_used.astimezone(timezone.utc)
.isoformat()
.replace("+00:00", "Z")
if c.last_used and c.last_used.tzinfo
else (
c.last_used.replace(tzinfo=timezone.utc)
.isoformat()
.replace("+00:00", "Z")
if c.last_used
else None
)
),
"last_verified": (
c.last_verified.astimezone(timezone.utc)
.isoformat()
.replace("+00:00", "Z")
if c.last_verified and c.last_verified.tzinfo
else (
c.last_verified.replace(tzinfo=timezone.utc)
.isoformat()
.replace("+00:00", "Z")
if c.last_verified
else None
)
)
if c.last_verified
else None,
"sign_count": c.sign_count,
@@ -405,21 +538,77 @@ async def admin_get_user_detail(org_uuid: UUID, user_uuid: UUID, auth=Cookie(Non
from .. import aaguid as aaguid_mod
aaguid_info = aaguid_mod.filter(aaguids)
# Get sessions for the user
normalized_request_host = hostutil.normalize_host(request.headers.get("host"))
session_records = await db.instance.list_sessions_for_user(user_uuid)
current_session_key = session_key(auth)
sessions_payload: list[dict] = []
for entry in session_records:
sessions_payload.append(
{
"id": encode_session_key(entry.key),
"host": entry.host,
"ip": entry.ip,
"user_agent": useragent.compact_user_agent(entry.user_agent),
"last_renewed": (
entry.renewed.astimezone(timezone.utc)
.isoformat()
.replace("+00:00", "Z")
if entry.renewed.tzinfo
else entry.renewed.replace(tzinfo=timezone.utc)
.isoformat()
.replace("+00:00", "Z")
),
"is_current": entry.key == current_session_key,
"is_current_host": bool(
normalized_request_host
and entry.host
and entry.host == normalized_request_host
),
}
)
return {
"display_name": user.display_name,
"org": {"display_name": user_org.display_name},
"role": role_name,
"visits": user.visits,
"created_at": user.created_at.isoformat() if user.created_at else None,
"last_seen": user.last_seen.isoformat() if user.last_seen else None,
"created_at": (
user.created_at.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
if user.created_at and user.created_at.tzinfo
else (
user.created_at.replace(tzinfo=timezone.utc)
.isoformat()
.replace("+00:00", "Z")
if user.created_at
else None
)
),
"last_seen": (
user.last_seen.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
if user.last_seen and user.last_seen.tzinfo
else (
user.last_seen.replace(tzinfo=timezone.utc)
.isoformat()
.replace("+00:00", "Z")
if user.last_seen
else None
)
),
"credentials": creds,
"aaguid_info": aaguid_info,
"sessions": sessions_payload,
}
@app.put("/orgs/{org_uuid}/users/{user_uuid}/display-name")
async def admin_update_user_display_name(
org_uuid: UUID, user_uuid: UUID, payload: dict = Body(...), auth=Cookie(None)
org_uuid: UUID,
user_uuid: UUID,
request: Request,
payload: dict = Body(...),
auth=Cookie(None, alias="__Host-auth"),
):
try:
user_org, _role_name = await db.instance.get_user_organization(user_uuid)
@@ -428,7 +617,10 @@ async def admin_update_user_display_name(
if user_org.uuid != org_uuid:
raise HTTPException(status_code=404, detail="User not found in organization")
ctx = await authz.verify(
auth, ["auth:admin", f"auth:org:{org_uuid}"], match=permutil.has_any
auth,
["auth:admin", f"auth:org:{org_uuid}"],
match=permutil.has_any,
host=request.headers.get("host"),
)
if (
"auth:admin" not in ctx.role.permissions
@@ -446,7 +638,11 @@ async def admin_update_user_display_name(
@app.delete("/orgs/{org_uuid}/users/{user_uuid}/credentials/{credential_uuid}")
async def admin_delete_user_credential(
org_uuid: UUID, user_uuid: UUID, credential_uuid: UUID, auth=Cookie(None)
org_uuid: UUID,
user_uuid: UUID,
credential_uuid: UUID,
request: Request,
auth=Cookie(None, alias="__Host-auth"),
):
try:
user_org, _role_name = await db.instance.get_user_organization(user_uuid)
@@ -455,7 +651,10 @@ async def admin_delete_user_credential(
if user_org.uuid != org_uuid:
raise HTTPException(status_code=404, detail="User not found in organization")
ctx = await authz.verify(
auth, ["auth:admin", f"auth:org:{org_uuid}"], match=permutil.has_any
auth,
["auth:admin", f"auth:org:{org_uuid}"],
match=permutil.has_any,
host=request.headers.get("host"),
)
if (
"auth:admin" not in ctx.role.permissions
@@ -470,8 +669,15 @@ async def admin_delete_user_credential(
@app.get("/permissions")
async def admin_list_permissions(auth=Cookie(None)):
ctx = await authz.verify(auth, ["auth:admin", "auth:org:*"], match=permutil.has_any)
async def admin_list_permissions(
request: Request, auth=Cookie(None, alias="__Host-auth")
):
ctx = await authz.verify(
auth,
["auth:admin", "auth:org:*"],
match=permutil.has_any,
host=request.headers.get("host"),
)
perms = await db.instance.list_permissions()
# Global admins see all permissions
@@ -485,8 +691,14 @@ async def admin_list_permissions(auth=Cookie(None)):
@app.post("/permissions")
async def admin_create_permission(payload: dict = Body(...), auth=Cookie(None)):
await authz.verify(auth, ["auth:admin"])
async def admin_create_permission(
request: Request,
payload: dict = Body(...),
auth=Cookie(None, alias="__Host-auth"),
):
await authz.verify(
auth, ["auth:admin"], host=request.headers.get("host"), match=permutil.has_all
)
from ..db import Permission as PermDC
perm_id = payload.get("id")
@@ -500,9 +712,14 @@ async def admin_create_permission(payload: dict = Body(...), auth=Cookie(None)):
@app.put("/permission")
async def admin_update_permission(
permission_id: str, display_name: str, auth=Cookie(None)
permission_id: str,
display_name: str,
request: Request,
auth=Cookie(None, alias="__Host-auth"),
):
await authz.verify(auth, ["auth:admin"])
await authz.verify(
auth, ["auth:admin"], host=request.headers.get("host"), match=permutil.has_all
)
from ..db import Permission as PermDC
if not display_name:
@@ -515,8 +732,14 @@ async def admin_update_permission(
@app.post("/permission/rename")
async def admin_rename_permission(payload: dict = Body(...), auth=Cookie(None)):
await authz.verify(auth, ["auth:admin"])
async def admin_rename_permission(
request: Request,
payload: dict = Body(...),
auth=Cookie(None, alias="__Host-auth"),
):
await authz.verify(
auth, ["auth:admin"], host=request.headers.get("host"), match=permutil.has_all
)
old_id = payload.get("old_id")
new_id = payload.get("new_id")
display_name = payload.get("display_name")
@@ -540,8 +763,14 @@ async def admin_rename_permission(payload: dict = Body(...), auth=Cookie(None)):
@app.delete("/permission")
async def admin_delete_permission(permission_id: str, auth=Cookie(None)):
await authz.verify(auth, ["auth:admin"])
async def admin_delete_permission(
permission_id: str,
request: Request,
auth=Cookie(None, alias="__Host-auth"),
):
await authz.verify(
auth, ["auth:admin"], host=request.headers.get("host"), match=permutil.has_all
)
querysafe.assert_safe(permission_id, field="permission_id")
# Sanity check: prevent deleting critical permissions

View File

@@ -1,6 +1,6 @@
import logging
from contextlib import suppress
from datetime import datetime, timedelta
from datetime import datetime, timedelta, timezone
from uuid import UUID
from fastapi import (
@@ -16,7 +16,7 @@ from fastapi import (
from fastapi.responses import JSONResponse
from fastapi.security import HTTPBearer
from passkey.util import frontend
from passkey.util import frontend, useragent
from .. import aaguid
from ..authsession import (
@@ -26,11 +26,12 @@ from ..authsession import (
get_reset,
get_session,
refresh_session_token,
session_expiry,
)
from ..globals import db
from ..globals import passkey as global_passkey
from ..util import hostutil, passphrase, permutil, tokens
from ..util.tokens import session_key
from ..util.tokens import decode_session_key, encode_session_key, session_key
from . import authz, session
bearer_auth = HTTPBearer(auto_error=True)
@@ -56,7 +57,10 @@ async def general_exception_handler(_request: Request, exc: Exception):
@app.post("/validate")
async def validate_token(
response: Response, perm: list[str] = Query([]), auth=Cookie(None)
request: Request,
response: Response,
perm: list[str] = Query([]),
auth=Cookie(None, alias="__Host-auth"),
):
"""Validate the current session and extend its expiry.
@@ -64,13 +68,18 @@ async def validate_token(
renewed max-age. This keeps active users logged in without needing a separate
refresh endpoint.
"""
ctx = await authz.verify(auth, perm)
ctx = await authz.verify(auth, perm, host=request.headers.get("host"))
renewed = False
if auth:
consumed = EXPIRES - (ctx.session.expires - datetime.now())
current_expiry = session_expiry(ctx.session)
consumed = EXPIRES - (current_expiry - datetime.now())
if not timedelta(0) < consumed < _REFRESH_INTERVAL:
try:
await refresh_session_token(auth)
await refresh_session_token(
auth,
ip=request.client.host if request.client else "",
user_agent=request.headers.get("user-agent") or "",
)
session.set_session_cookie(response, auth)
renewed = True
except ValueError:
@@ -84,7 +93,11 @@ async def validate_token(
@app.get("/forward")
async def forward_authentication(perm: list[str] = Query([]), auth=Cookie(None)):
async def forward_authentication(
request: Request,
perm: list[str] = Query([]),
auth=Cookie(None, alias="__Host-auth"),
):
"""Forward auth validation for Caddy/Nginx.
Query Params:
@@ -94,7 +107,7 @@ async def forward_authentication(perm: list[str] = Query([]), auth=Cookie(None))
Failure (unauthenticated / unauthorized): 4xx JSON body with detail.
"""
try:
ctx = await authz.verify(auth, perm)
ctx = await authz.verify(auth, perm, host=request.headers.get("host"))
role_permissions = set(ctx.role.permissions or [])
if ctx.permissions:
role_permissions.update(permission.id for permission in ctx.permissions)
@@ -107,7 +120,17 @@ async def forward_authentication(perm: list[str] = Query([]), auth=Cookie(None))
"Remote-Org-Name": ctx.org.display_name,
"Remote-Role": str(ctx.role.uuid),
"Remote-Role-Name": ctx.role.display_name,
"Remote-Session-Expires": ctx.session.expires.isoformat(),
"Remote-Session-Expires": (
session_expiry(ctx.session)
.astimezone(timezone.utc)
.isoformat()
.replace("+00:00", "Z")
if session_expiry(ctx.session).tzinfo
else session_expiry(ctx.session)
.replace(tzinfo=timezone.utc)
.isoformat()
.replace("+00:00", "Z")
),
"Remote-Credential": str(ctx.session.credential_uuid),
}
return Response(status_code=204, headers=remote_headers)
@@ -129,34 +152,43 @@ async def get_settings():
@app.post("/user-info")
async def api_user_info(reset: str | None = None, auth=Cookie(None)):
async def api_user_info(
request: Request, reset: str | None = None, auth=Cookie(None, alias="__Host-auth")
):
authenticated = False
session_record = None
reset_token = None
try:
if reset:
if not passphrase.is_well_formed(reset):
raise ValueError("Invalid reset token")
s = await get_reset(reset)
reset_token = await get_reset(reset)
target_user_uuid = reset_token.user_uuid
else:
if auth is None:
raise ValueError("Authentication Required")
s = await get_session(auth)
session_record = await get_session(auth, host=request.headers.get("host"))
authenticated = True
target_user_uuid = session_record.user_uuid
except ValueError as e:
raise HTTPException(401, str(e))
u = await db.instance.get_user_by_uuid(s.user_uuid)
u = await db.instance.get_user_by_uuid(target_user_uuid)
if not authenticated: # minimal response for reset tokens
if not authenticated and reset_token: # minimal response for reset tokens
return {
"authenticated": False,
"session_type": s.info.get("type"),
"session_type": reset_token.token_type,
"user": {"user_uuid": str(u.uuid), "user_name": u.display_name},
}
assert authenticated and auth is not None
assert auth is not None
assert session_record is not None
ctx = await permutil.session_context(auth)
credential_ids = await db.instance.get_credentials_by_user_uuid(s.user_uuid)
ctx = await permutil.session_context(auth, request.headers.get("host"))
credential_ids = await db.instance.get_credentials_by_user_uuid(
session_record.user_uuid
)
credentials: list[dict] = []
user_aaguids: set[str] = set()
for cred_id in credential_ids:
@@ -170,13 +202,45 @@ async def api_user_info(reset: str | None = None, auth=Cookie(None)):
{
"credential_uuid": str(c.uuid),
"aaguid": aaguid_str,
"created_at": c.created_at.isoformat(),
"last_used": c.last_used.isoformat() if c.last_used else None,
"last_verified": c.last_verified.isoformat()
"created_at": (
c.created_at.astimezone(timezone.utc)
.isoformat()
.replace("+00:00", "Z")
if c.created_at.tzinfo
else c.created_at.replace(tzinfo=timezone.utc)
.isoformat()
.replace("+00:00", "Z")
),
"last_used": (
c.last_used.astimezone(timezone.utc)
.isoformat()
.replace("+00:00", "Z")
if c.last_used and c.last_used.tzinfo
else (
c.last_used.replace(tzinfo=timezone.utc)
.isoformat()
.replace("+00:00", "Z")
if c.last_used
else None
)
),
"last_verified": (
c.last_verified.astimezone(timezone.utc)
.isoformat()
.replace("+00:00", "Z")
if c.last_verified and c.last_verified.tzinfo
else (
c.last_verified.replace(tzinfo=timezone.utc)
.isoformat()
.replace("+00:00", "Z")
if c.last_verified
else None
)
)
if c.last_verified
else None,
"sign_count": c.sign_count,
"is_current_session": s.credential_uuid == c.uuid,
"is_current_session": session_record.credential_uuid == c.uuid,
}
)
credentials.sort(key=lambda cred: cred["created_at"])
@@ -204,14 +268,62 @@ async def api_user_info(reset: str | None = None, auth=Cookie(None)):
p.startswith("auth:org:") for p in (role_info["permissions"] or [])
)
normalized_request_host = hostutil.normalize_host(request.headers.get("host"))
session_records = await db.instance.list_sessions_for_user(session_record.user_uuid)
current_session_key = session_key(auth)
sessions_payload: list[dict] = []
for entry in session_records:
sessions_payload.append(
{
"id": encode_session_key(entry.key),
"host": entry.host,
"ip": entry.ip,
"user_agent": useragent.compact_user_agent(entry.user_agent),
"last_renewed": (
entry.renewed.astimezone(timezone.utc)
.isoformat()
.replace("+00:00", "Z")
if entry.renewed.tzinfo
else entry.renewed.replace(tzinfo=timezone.utc)
.isoformat()
.replace("+00:00", "Z")
),
"is_current": entry.key == current_session_key,
"is_current_host": bool(
normalized_request_host
and entry.host
and entry.host == normalized_request_host
),
}
)
return {
"authenticated": True,
"session_type": s.info.get("type"),
"user": {
"user_uuid": str(u.uuid),
"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,
"created_at": (
u.created_at.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
if u.created_at and u.created_at.tzinfo
else (
u.created_at.replace(tzinfo=timezone.utc)
.isoformat()
.replace("+00:00", "Z")
if u.created_at
else None
)
),
"last_seen": (
u.last_seen.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
if u.last_seen and u.last_seen.tzinfo
else (
u.last_seen.replace(tzinfo=timezone.utc)
.isoformat()
.replace("+00:00", "Z")
if u.last_seen
else None
)
),
"visits": u.visits,
},
"org": org_info,
@@ -221,14 +333,17 @@ async def api_user_info(reset: str | None = None, auth=Cookie(None)):
"is_org_admin": is_org_admin,
"credentials": credentials,
"aaguid_info": aaguid_info,
"sessions": sessions_payload,
}
@app.put("/user/display-name")
async def user_update_display_name(payload: dict = Body(...), auth=Cookie(None)):
async def user_update_display_name(
request: Request, payload: dict = Body(...), auth=Cookie(None, alias="__Host-auth")
):
if not auth:
raise HTTPException(status_code=401, detail="Authentication Required")
s = await get_session(auth)
s = await get_session(auth, host=request.headers.get("host"))
new_name = (payload.get("display_name") or "").strip()
if not new_name:
raise HTTPException(status_code=400, detail="display_name required")
@@ -239,18 +354,76 @@ async def user_update_display_name(payload: dict = Body(...), auth=Cookie(None))
@app.post("/logout")
async def api_logout(response: Response, auth=Cookie(None)):
async def api_logout(
request: Request, response: Response, auth=Cookie(None, alias="__Host-auth")
):
if not auth:
return {"message": "Already logged out"}
try:
await get_session(auth, host=request.headers.get("host"))
except ValueError:
response.delete_cookie(session.AUTH_COOKIE_NAME, path="/")
return {"message": "Already logged out"}
with suppress(Exception):
await db.instance.delete_session(session_key(auth))
response.delete_cookie("auth")
response.delete_cookie(session.AUTH_COOKIE_NAME, path="/")
return {"message": "Logged out successfully"}
@app.post("/logout-all")
async def api_logout_all(
request: Request, response: Response, auth=Cookie(None, alias="__Host-auth")
):
if not auth:
return {"message": "Already logged out"}
try:
s = await get_session(auth, host=request.headers.get("host"))
except ValueError:
response.delete_cookie(session.AUTH_COOKIE_NAME, path="/")
raise HTTPException(status_code=401, detail="Session expired")
await db.instance.delete_sessions_for_user(s.user_uuid)
response.delete_cookie(session.AUTH_COOKIE_NAME, path="/")
return {"message": "Logged out from all hosts"}
@app.delete("/session/{session_id}")
async def api_delete_session(
request: Request,
response: Response,
session_id: str,
auth=Cookie(None, alias="__Host-auth"),
):
if not auth:
raise HTTPException(status_code=401, detail="Authentication Required")
try:
current_session = await get_session(auth, host=request.headers.get("host"))
except ValueError as exc:
response.delete_cookie(session.AUTH_COOKIE_NAME, path="/")
raise HTTPException(status_code=401, detail="Session expired") from exc
try:
target_key = decode_session_key(session_id)
except ValueError as exc:
raise HTTPException(
status_code=400, detail="Invalid session identifier"
) from exc
target_session = await db.instance.get_session(target_key)
if not target_session or target_session.user_uuid != current_session.user_uuid:
raise HTTPException(status_code=404, detail="Session not found")
await db.instance.delete_session(target_key)
current_terminated = target_key == session_key(auth)
if current_terminated:
response.delete_cookie(session.AUTH_COOKIE_NAME, path="/")
return {"status": "ok", "current_session_terminated": current_terminated}
@app.post("/set-session")
async def api_set_session(response: Response, auth=Depends(bearer_auth)):
user = await get_session(auth.credentials)
async def api_set_session(
request: Request, response: Response, auth=Depends(bearer_auth)
):
user = await get_session(auth.credentials, host=request.headers.get("host"))
session.set_session_cookie(response, auth.credentials)
return {
"message": "Session cookie set successfully",
@@ -259,20 +432,23 @@ async def api_set_session(response: Response, auth=Depends(bearer_auth)):
@app.delete("/credential/{uuid}")
async def api_delete_credential(uuid: UUID, auth: str = Cookie(None)):
await delete_credential(uuid, auth)
async def api_delete_credential(
request: Request, uuid: UUID, auth: str = Cookie(None, alias="__Host-auth")
):
await delete_credential(uuid, auth, host=request.headers.get("host"))
return {"message": "Credential deleted successfully"}
@app.post("/create-link")
async def api_create_link(request: Request, auth=Cookie(None)):
s = await get_session(auth)
async def api_create_link(request: Request, auth=Cookie(None, alias="__Host-auth")):
s = await get_session(auth, host=request.headers.get("host"))
token = passphrase.generate()
await db.instance.create_session(
expiry = expires()
await db.instance.create_reset_token(
user_uuid=s.user_uuid,
key=tokens.reset_key(token),
expires=expires(),
info=session.infodict(request, "device addition"),
expiry=expiry,
token_type="device addition",
)
url = hostutil.reset_link_url(
token, request.url.scheme, request.headers.get("host")
@@ -280,5 +456,9 @@ async def api_create_link(request: Request, auth=Cookie(None)):
return {
"message": "Registration link generated successfully",
"url": url,
"expires": expires().isoformat(),
"expires": (
expiry.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
if expiry.tzinfo
else expiry.replace(tzinfo=timezone.utc).isoformat().replace("+00:00", "Z")
),
}

View File

@@ -7,7 +7,12 @@ from ..util import permutil
logger = logging.getLogger(__name__)
async def verify(auth: str | None, perm: list[str], match=permutil.has_all):
async def verify(
auth: str | None,
perm: list[str],
match=permutil.has_all,
host: str | None = None,
):
"""Validate session token and optional list of required permissions.
Returns the session context.
@@ -19,7 +24,7 @@ async def verify(auth: str | None, perm: list[str], match=permutil.has_all):
if not auth:
raise HTTPException(status_code=401, detail="Authentication required")
ctx = await permutil.session_context(auth)
ctx = await permutil.session_context(auth, host)
if not ctx:
raise HTTPException(status_code=401, detail="Session not found")

View File

@@ -2,7 +2,7 @@ import logging
import os
from contextlib import asynccontextmanager
from fastapi import Cookie, FastAPI, HTTPException
from fastapi import Cookie, FastAPI, HTTPException, Request
from fastapi.responses import FileResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
@@ -70,8 +70,8 @@ async def admin_root_redirect():
@app.get("/admin/", include_in_schema=False)
async def admin_root(auth=Cookie(None)):
return await admin.adminapp(auth) # Delegate to handler of /auth/admin/
async def admin_root(request: Request, auth=Cookie(None, alias="__Host-auth")):
return await admin.adminapp(request, auth) # Delegate to handler of /auth/admin/
@app.get("/{reset}")

View File

@@ -63,11 +63,12 @@ async def _resolve_targets(query: str | None):
async def _create_reset(user, role_name: str):
token = passphrase.generate()
await _g.db.instance.create_session(
expiry = _authsession.reset_expires()
await _g.db.instance.create_reset_token(
user_uuid=user.uuid,
key=_tokens.reset_key(token),
expires=_authsession.expires(),
info={"type": "manual reset", "role": role_name},
expiry=expiry,
token_type="manual reset",
)
return hostutil.reset_link_url(token), token

View File

@@ -12,22 +12,26 @@ from fastapi import Request, Response, WebSocket
from ..authsession import EXPIRES
AUTH_COOKIE_NAME = "__Host-auth"
def infodict(request: Request | WebSocket, type: str) -> dict:
"""Extract client information from request."""
return {
"ip": request.client.host if request.client else "",
"user_agent": request.headers.get("user-agent", "")[:500],
"type": type,
"ip": request.client.host if request.client else None,
"user_agent": request.headers.get("user-agent", "")[:500] or None,
"session_type": type,
}
def set_session_cookie(response: Response, token: str) -> None:
"""Set the session token as an HTTP-only cookie."""
response.set_cookie(
key="auth",
key=AUTH_COOKIE_NAME,
value=token,
max_age=int(EXPIRES.total_seconds()),
httponly=True,
secure=True,
path="/",
samesite="lax",
)

View File

@@ -5,9 +5,9 @@ from uuid import UUID
from fastapi import Cookie, FastAPI, WebSocket, WebSocketDisconnect
from webauthn.helpers.exceptions import InvalidAuthenticationResponse
from ..authsession import create_session, expires, get_reset, get_session
from ..authsession import create_session, get_reset, get_session
from ..globals import db, passkey
from ..util import passphrase
from ..util import hostutil, passphrase
from ..util.tokens import create_token, session_key
from .session import infodict
@@ -56,7 +56,10 @@ async def register_chat(
@app.websocket("/register")
@websocket_error_handler
async def websocket_register_add(
ws: WebSocket, reset: str | None = None, name: str | None = None, auth=Cookie(None)
ws: WebSocket,
reset: str | None = None,
name: str | None = None,
auth=Cookie(None, alias="__Host-auth"),
):
"""Register a new credential for an existing user.
@@ -65,6 +68,9 @@ async def websocket_register_add(
- Reset token supplied as ?reset=... (auth cookie ignored)
"""
origin = ws.headers["origin"]
host = hostutil.normalize_host(ws.headers.get("host"))
if host is None:
raise ValueError("Missing host header")
if reset is not None:
if not passphrase.is_well_formed(reset):
raise ValueError("Invalid reset token")
@@ -72,7 +78,7 @@ async def websocket_register_add(
else:
if not auth:
raise ValueError("Authentication Required")
s = await get_session(auth)
s = await get_session(auth, host=host)
user_uuid = s.user_uuid
# Get user information and determine effective user_name for this registration
@@ -89,14 +95,16 @@ async def websocket_register_add(
# Create a new session and store everything in database
token = create_token()
metadata = infodict(ws, "authenticated")
await db.instance.create_credential_session( # type: ignore[attr-defined]
user_uuid=user_uuid,
credential=credential,
reset_key=(s.key if reset is not None else None),
session_key=session_key(token),
session_expires=expires(),
session_info=infodict(ws, "authenticated"),
display_name=user_name,
host=host,
ip=metadata.get("ip"),
user_agent=metadata.get("user_agent"),
)
auth = token
@@ -115,6 +123,9 @@ async def websocket_register_add(
@websocket_error_handler
async def websocket_authenticate(ws: WebSocket):
origin = ws.headers["origin"]
host = hostutil.normalize_host(ws.headers.get("host"))
if host is None:
raise ValueError("Missing host header")
options, challenge = passkey.instance.auth_generate_options()
await ws.send_json(options)
# Wait for the client to use his authenticator to authenticate
@@ -128,10 +139,13 @@ async def websocket_authenticate(ws: WebSocket):
# Create a session token for the authenticated user
assert stored_cred.uuid is not None
metadata = infodict(ws, "auth")
token = await create_session(
user_uuid=stored_cred.user_uuid,
info=infodict(ws, "auth"),
credential_uuid=stored_cred.uuid,
host=host,
ip=metadata.get("ip") or "",
user_agent=metadata.get("user_agent") or "",
)
await ws.send_json(