Refactor API under /auth/api
This commit is contained in:
parent
859cc9ed41
commit
312d23b79a
@ -167,7 +167,7 @@ async function load() {
|
|||||||
loading.value = true
|
loading.value = true
|
||||||
error.value = null
|
error.value = null
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/auth/user-info', { method: 'POST' })
|
const res = await fetch('/auth/api/user-info', { method: 'POST' })
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
if (data.detail) throw new Error(data.detail)
|
if (data.detail) throw new Error(data.detail)
|
||||||
info.value = data
|
info.value = data
|
||||||
|
@ -46,7 +46,7 @@ const copyLink = async (event) => {
|
|||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/auth/create-link', { method: 'POST' })
|
const response = await fetch('/auth/api/create-link', { method: 'POST' })
|
||||||
const result = await response.json()
|
const result = await response.json()
|
||||||
if (result.detail) throw new Error(result.detail)
|
if (result.detail) throw new Error(result.detail)
|
||||||
|
|
||||||
|
@ -9,7 +9,7 @@
|
|||||||
:created-at="authStore.userInfo.user.created_at"
|
:created-at="authStore.userInfo.user.created_at"
|
||||||
:last-seen="authStore.userInfo.user.last_seen"
|
:last-seen="authStore.userInfo.user.last_seen"
|
||||||
:loading="authStore.isLoading"
|
:loading="authStore.isLoading"
|
||||||
update-endpoint="/auth/user/display-name"
|
update-endpoint="/auth/api/user/display-name"
|
||||||
@saved="authStore.loadUserInfo()"
|
@saved="authStore.loadUserInfo()"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
@ -32,7 +32,7 @@ export const useAuthStore = defineStore('auth', {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
async setSessionCookie(sessionToken) {
|
async setSessionCookie(sessionToken) {
|
||||||
const response = await fetch('/auth/set-session', {
|
const response = await fetch('/auth/api/set-session', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {'Authorization': `Bearer ${sessionToken}`},
|
headers: {'Authorization': `Bearer ${sessionToken}`},
|
||||||
})
|
})
|
||||||
@ -87,7 +87,7 @@ export const useAuthStore = defineStore('auth', {
|
|||||||
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
|
||||||
const url = this.resetToken ? `/auth/user-info?reset=${encodeURIComponent(this.resetToken)}` : '/auth/user-info'
|
const url = this.resetToken ? `/auth/api/user-info?reset=${encodeURIComponent(this.resetToken)}` : '/auth/api/user-info'
|
||||||
const response = await fetch(url, { method: 'POST', headers })
|
const response = await fetch(url, { method: 'POST', headers })
|
||||||
let result = null
|
let result = null
|
||||||
try {
|
try {
|
||||||
@ -109,7 +109,7 @@ export const useAuthStore = defineStore('auth', {
|
|||||||
},
|
},
|
||||||
async loadSettings() {
|
async loadSettings() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/auth/settings')
|
const res = await fetch('/auth/api/settings')
|
||||||
if (!res.ok) return
|
if (!res.ok) return
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
this.settings = data
|
this.settings = data
|
||||||
@ -121,7 +121,7 @@ export const useAuthStore = defineStore('auth', {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
async deleteCredential(uuid) {
|
async deleteCredential(uuid) {
|
||||||
const response = await fetch(`/auth/credential/${uuid}`, {method: 'Delete'})
|
const response = await fetch(`/auth/api/credential/${uuid}`, {method: 'Delete'})
|
||||||
const result = await response.json()
|
const result = await response.json()
|
||||||
if (result.detail) throw new Error(`Server: ${result.detail}`)
|
if (result.detail) throw new Error(`Server: ${result.detail}`)
|
||||||
|
|
||||||
@ -129,7 +129,7 @@ export const useAuthStore = defineStore('auth', {
|
|||||||
},
|
},
|
||||||
async logout() {
|
async logout() {
|
||||||
try {
|
try {
|
||||||
await fetch('/auth/logout', {method: 'POST'})
|
await fetch('/auth/api/logout', {method: 'POST'})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Logout error:', error)
|
console.error('Logout error:', error)
|
||||||
}
|
}
|
||||||
|
@ -1,180 +1,193 @@
|
|||||||
"""Core (non-admin) HTTP API routes.
|
from contextlib import suppress
|
||||||
|
|
||||||
Contains endpoints for:
|
|
||||||
* Session validation
|
|
||||||
* Basic runtime settings
|
|
||||||
* Authenticated user info & credential listing
|
|
||||||
* User self-service updates (display name)
|
|
||||||
* Logout / set-session / credential deletion
|
|
||||||
|
|
||||||
Admin endpoints have been moved to `adminapp.py` and mounted at
|
|
||||||
`/auth/admin` by the main application.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from fastapi import Body, Cookie, Depends, FastAPI, HTTPException, Query, Response
|
from fastapi import (
|
||||||
|
Body,
|
||||||
|
Cookie,
|
||||||
|
Depends,
|
||||||
|
FastAPI,
|
||||||
|
HTTPException,
|
||||||
|
Query,
|
||||||
|
Request,
|
||||||
|
Response,
|
||||||
|
)
|
||||||
from fastapi.security import HTTPBearer
|
from fastapi.security import HTTPBearer
|
||||||
|
|
||||||
from .. import aaguid
|
from .. import aaguid
|
||||||
from ..authsession import delete_credential, 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 passphrase, tokens
|
||||||
from ..util.tokens import session_key
|
from ..util.tokens import session_key
|
||||||
from . import authz, session
|
from . import authz, session
|
||||||
|
|
||||||
bearer_auth = HTTPBearer(auto_error=True)
|
bearer_auth = HTTPBearer(auto_error=True)
|
||||||
|
|
||||||
|
app = FastAPI()
|
||||||
|
|
||||||
def register_api_routes(app: FastAPI):
|
|
||||||
"""Register non-admin API routes on the FastAPI app."""
|
|
||||||
|
|
||||||
@app.post("/auth/validate")
|
@app.post("/validate")
|
||||||
async def validate_token(perm=Query(None), auth=Cookie(None)):
|
async def validate_token(perm=Query(None), auth=Cookie(None)):
|
||||||
s = await authz.verify(auth, perm)
|
s = await authz.verify(auth, perm)
|
||||||
return {"valid": True, "user_uuid": str(s.user_uuid)}
|
return {"valid": True, "user_uuid": str(s.user_uuid)}
|
||||||
|
|
||||||
@app.get("/auth/settings")
|
|
||||||
async def get_settings():
|
|
||||||
pk = global_passkey.instance
|
|
||||||
return {"rp_id": pk.rp_id, "rp_name": pk.rp_name}
|
|
||||||
|
|
||||||
@app.post("/auth/user-info")
|
@app.get("/settings")
|
||||||
async def api_user_info(reset: str | None = None, auth=Cookie(None)):
|
async def get_settings():
|
||||||
authenticated = False
|
pk = global_passkey.instance
|
||||||
try:
|
return {"rp_id": pk.rp_id, "rp_name": pk.rp_name}
|
||||||
if reset:
|
|
||||||
if not tokens.passphrase.is_well_formed(reset): # type: ignore[attr-defined]
|
|
||||||
raise ValueError("Invalid reset token")
|
|
||||||
s = await get_reset(reset)
|
|
||||||
else:
|
|
||||||
if auth is None:
|
|
||||||
raise ValueError("Authentication Required")
|
|
||||||
s = await get_session(auth)
|
|
||||||
authenticated = True
|
|
||||||
except ValueError as e:
|
|
||||||
raise HTTPException(401, str(e))
|
|
||||||
|
|
||||||
u = await db.instance.get_user_by_uuid(s.user_uuid)
|
|
||||||
|
|
||||||
if not authenticated: # minimal response for reset tokens
|
@app.post("/user-info")
|
||||||
return {
|
async def api_user_info(reset: str | None = None, auth=Cookie(None)):
|
||||||
"authenticated": False,
|
authenticated = False
|
||||||
"session_type": s.info.get("type"),
|
try:
|
||||||
"user": {"user_uuid": str(u.uuid), "user_name": u.display_name},
|
if reset:
|
||||||
}
|
if not passphrase.is_well_formed(reset):
|
||||||
|
raise ValueError("Invalid reset token")
|
||||||
|
s = await get_reset(reset)
|
||||||
|
else:
|
||||||
|
if auth is None:
|
||||||
|
raise ValueError("Authentication Required")
|
||||||
|
s = await get_session(auth)
|
||||||
|
authenticated = True
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(401, str(e))
|
||||||
|
|
||||||
assert authenticated and auth is not None
|
u = await db.instance.get_user_by_uuid(s.user_uuid)
|
||||||
ctx = await db.instance.get_session_context(session_key(auth))
|
|
||||||
credential_ids = await db.instance.get_credentials_by_user_uuid(s.user_uuid)
|
|
||||||
credentials: list[dict] = []
|
|
||||||
user_aaguids: set[str] = set()
|
|
||||||
for cred_id in credential_ids:
|
|
||||||
try:
|
|
||||||
c = await db.instance.get_credential_by_id(cred_id)
|
|
||||||
except ValueError:
|
|
||||||
continue
|
|
||||||
aaguid_str = str(c.aaguid)
|
|
||||||
user_aaguids.add(aaguid_str)
|
|
||||||
credentials.append(
|
|
||||||
{
|
|
||||||
"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()
|
|
||||||
if c.last_verified
|
|
||||||
else None,
|
|
||||||
"sign_count": c.sign_count,
|
|
||||||
"is_current_session": s.credential_uuid == c.uuid,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
credentials.sort(key=lambda cred: cred["created_at"])
|
|
||||||
aaguid_info = aaguid.filter(user_aaguids)
|
|
||||||
|
|
||||||
role_info = None
|
|
||||||
org_info = None
|
|
||||||
effective_permissions: list[str] = []
|
|
||||||
is_global_admin = False
|
|
||||||
is_org_admin = False
|
|
||||||
if ctx:
|
|
||||||
role_info = {
|
|
||||||
"uuid": str(ctx.role.uuid),
|
|
||||||
"display_name": ctx.role.display_name,
|
|
||||||
"permissions": ctx.role.permissions,
|
|
||||||
}
|
|
||||||
org_info = {
|
|
||||||
"uuid": str(ctx.org.uuid),
|
|
||||||
"display_name": ctx.org.display_name,
|
|
||||||
"permissions": ctx.org.permissions,
|
|
||||||
}
|
|
||||||
effective_permissions = [p.id for p in (ctx.permissions or [])]
|
|
||||||
is_global_admin = "auth:admin" in (role_info["permissions"] or [])
|
|
||||||
if org_info:
|
|
||||||
is_org_admin = f"auth:org:{org_info['uuid']}" in (
|
|
||||||
role_info["permissions"] or []
|
|
||||||
)
|
|
||||||
|
|
||||||
|
if not authenticated: # minimal response for reset tokens
|
||||||
return {
|
return {
|
||||||
"authenticated": True,
|
"authenticated": False,
|
||||||
"session_type": s.info.get("type"),
|
"session_type": s.info.get("type"),
|
||||||
"user": {
|
"user": {"user_uuid": str(u.uuid), "user_name": u.display_name},
|
||||||
"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,
|
|
||||||
"visits": u.visits,
|
|
||||||
},
|
|
||||||
"org": org_info,
|
|
||||||
"role": role_info,
|
|
||||||
"permissions": effective_permissions,
|
|
||||||
"is_global_admin": is_global_admin,
|
|
||||||
"is_org_admin": is_org_admin,
|
|
||||||
"credentials": credentials,
|
|
||||||
"aaguid_info": aaguid_info,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@app.put("/auth/user/display-name")
|
assert authenticated and auth is not None
|
||||||
async def user_update_display_name(payload: dict = Body(...), auth=Cookie(None)):
|
ctx = await db.instance.get_session_context(session_key(auth))
|
||||||
if not auth:
|
credential_ids = await db.instance.get_credentials_by_user_uuid(s.user_uuid)
|
||||||
raise HTTPException(status_code=401, detail="Authentication Required")
|
credentials: list[dict] = []
|
||||||
s = await get_session(auth)
|
user_aaguids: set[str] = set()
|
||||||
new_name = (payload.get("display_name") or "").strip()
|
for cred_id in credential_ids:
|
||||||
if not new_name:
|
|
||||||
raise HTTPException(status_code=400, detail="display_name required")
|
|
||||||
if len(new_name) > 64:
|
|
||||||
raise HTTPException(status_code=400, detail="display_name too long")
|
|
||||||
await db.instance.update_user_display_name(s.user_uuid, new_name)
|
|
||||||
return {"status": "ok"}
|
|
||||||
|
|
||||||
@app.post("/auth/logout")
|
|
||||||
async def api_logout(response: Response, auth=Cookie(None)):
|
|
||||||
"""Log out the current user by clearing the session cookie and deleting from database."""
|
|
||||||
if not auth:
|
|
||||||
return {"message": "Already logged out"}
|
|
||||||
# Remove from database if possible
|
|
||||||
try:
|
try:
|
||||||
await db.instance.delete_session(session_key(auth))
|
c = await db.instance.get_credential_by_id(cred_id)
|
||||||
except Exception:
|
except ValueError:
|
||||||
...
|
continue
|
||||||
response.delete_cookie("auth")
|
aaguid_str = str(c.aaguid)
|
||||||
return {"message": "Logged out successfully"}
|
user_aaguids.add(aaguid_str)
|
||||||
|
credentials.append(
|
||||||
|
{
|
||||||
|
"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()
|
||||||
|
if c.last_verified
|
||||||
|
else None,
|
||||||
|
"sign_count": c.sign_count,
|
||||||
|
"is_current_session": s.credential_uuid == c.uuid,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
credentials.sort(key=lambda cred: cred["created_at"])
|
||||||
|
aaguid_info = aaguid.filter(user_aaguids)
|
||||||
|
|
||||||
@app.post("/auth/set-session")
|
role_info = None
|
||||||
async def api_set_session(response: Response, auth=Depends(bearer_auth)):
|
org_info = None
|
||||||
"""Set session cookie from Authorization Bearer session token (never via query)."""
|
effective_permissions: list[str] = []
|
||||||
user = await get_session(auth.credentials)
|
is_global_admin = False
|
||||||
session.set_session_cookie(response, auth.credentials)
|
is_org_admin = False
|
||||||
|
if ctx:
|
||||||
return {
|
role_info = {
|
||||||
"message": "Session cookie set successfully",
|
"uuid": str(ctx.role.uuid),
|
||||||
"user_uuid": str(user.user_uuid),
|
"display_name": ctx.role.display_name,
|
||||||
|
"permissions": ctx.role.permissions,
|
||||||
}
|
}
|
||||||
|
org_info = {
|
||||||
|
"uuid": str(ctx.org.uuid),
|
||||||
|
"display_name": ctx.org.display_name,
|
||||||
|
"permissions": ctx.org.permissions,
|
||||||
|
}
|
||||||
|
effective_permissions = [p.id for p in (ctx.permissions or [])]
|
||||||
|
is_global_admin = "auth:admin" in (role_info["permissions"] or [])
|
||||||
|
if org_info:
|
||||||
|
is_org_admin = f"auth:org:{org_info['uuid']}" in (
|
||||||
|
role_info["permissions"] or []
|
||||||
|
)
|
||||||
|
|
||||||
@app.delete("/auth/credential/{uuid}")
|
return {
|
||||||
async def api_delete_credential(
|
"authenticated": True,
|
||||||
response: Response, uuid: UUID, auth: str = Cookie(None)
|
"session_type": s.info.get("type"),
|
||||||
):
|
"user": {
|
||||||
await delete_credential(uuid, auth)
|
"user_uuid": str(u.uuid),
|
||||||
return {"message": "Credential deleted successfully"}
|
"user_name": u.display_name,
|
||||||
|
"created_at": u.created_at.isoformat() if u.created_at else None,
|
||||||
|
"last_seen": u.last_seen.isoformat() if u.last_seen else None,
|
||||||
|
"visits": u.visits,
|
||||||
|
},
|
||||||
|
"org": org_info,
|
||||||
|
"role": role_info,
|
||||||
|
"permissions": effective_permissions,
|
||||||
|
"is_global_admin": is_global_admin,
|
||||||
|
"is_org_admin": is_org_admin,
|
||||||
|
"credentials": credentials,
|
||||||
|
"aaguid_info": aaguid_info,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.put("/user/display-name")
|
||||||
|
async def user_update_display_name(payload: dict = Body(...), auth=Cookie(None)):
|
||||||
|
if not auth:
|
||||||
|
raise HTTPException(status_code=401, detail="Authentication Required")
|
||||||
|
s = await get_session(auth)
|
||||||
|
new_name = (payload.get("display_name") or "").strip()
|
||||||
|
if not new_name:
|
||||||
|
raise HTTPException(status_code=400, detail="display_name required")
|
||||||
|
if len(new_name) > 64:
|
||||||
|
raise HTTPException(status_code=400, detail="display_name too long")
|
||||||
|
await db.instance.update_user_display_name(s.user_uuid, new_name)
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/logout")
|
||||||
|
async def api_logout(response: Response, auth=Cookie(None)):
|
||||||
|
if not auth:
|
||||||
|
return {"message": "Already logged out"}
|
||||||
|
with suppress(Exception):
|
||||||
|
await db.instance.delete_session(session_key(auth))
|
||||||
|
response.delete_cookie("auth")
|
||||||
|
return {"message": "Logged out successfully"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/set-session")
|
||||||
|
async def api_set_session(response: Response, auth=Depends(bearer_auth)):
|
||||||
|
user = await get_session(auth.credentials)
|
||||||
|
session.set_session_cookie(response, auth.credentials)
|
||||||
|
return {
|
||||||
|
"message": "Session cookie set successfully",
|
||||||
|
"user_uuid": str(user.user_uuid),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.delete("/credential/{uuid}")
|
||||||
|
async def api_delete_credential(uuid: UUID, auth: str = Cookie(None)):
|
||||||
|
await delete_credential(uuid, auth)
|
||||||
|
return {"message": "Credential deleted successfully"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/create-link")
|
||||||
|
async def api_create_link(request: Request, auth=Cookie(None)):
|
||||||
|
s = await get_session(auth)
|
||||||
|
token = passphrase.generate()
|
||||||
|
await db.instance.create_session(
|
||||||
|
user_uuid=s.user_uuid,
|
||||||
|
key=tokens.reset_key(token),
|
||||||
|
expires=expires(),
|
||||||
|
info=session.infodict(request, "device addition"),
|
||||||
|
)
|
||||||
|
origin = global_passkey.instance.origin.rstrip("/")
|
||||||
|
url = f"{origin}/auth/{token}"
|
||||||
|
return {
|
||||||
|
"message": "Registration link generated successfully",
|
||||||
|
"url": url,
|
||||||
|
"expires": expires().isoformat(),
|
||||||
|
}
|
||||||
|
@ -4,12 +4,13 @@ from contextlib import asynccontextmanager
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from fastapi import Cookie, FastAPI, HTTPException, Query, Request, Response
|
from fastapi import Cookie, FastAPI, HTTPException, Query, Request, Response
|
||||||
from fastapi.responses import FileResponse, JSONResponse
|
from fastapi.responses import FileResponse, JSONResponse, RedirectResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
|
||||||
from . import admin, authz, ws
|
from passkey.util import passphrase
|
||||||
from .api import register_api_routes
|
|
||||||
from .reset import register_reset_routes
|
from ..globals import passkey as global_passkey
|
||||||
|
from . import admin, api, authz, ws
|
||||||
|
|
||||||
STATIC_DIR = Path(__file__).parent.parent / "frontend-build"
|
STATIC_DIR = Path(__file__).parent.parent / "frontend-build"
|
||||||
|
|
||||||
@ -51,6 +52,7 @@ async def lifespan(app: FastAPI): # pragma: no cover - startup path
|
|||||||
app = FastAPI(lifespan=lifespan)
|
app = FastAPI(lifespan=lifespan)
|
||||||
app.mount("/auth/ws", ws.app)
|
app.mount("/auth/ws", ws.app)
|
||||||
app.mount("/auth/admin", admin.app)
|
app.mount("/auth/admin", admin.app)
|
||||||
|
app.mount("/auth/api", api.app)
|
||||||
|
|
||||||
|
|
||||||
# Global exception handlers
|
# Global exception handlers
|
||||||
@ -67,6 +69,35 @@ async def general_exception_handler(request: Request, exc: Exception):
|
|||||||
return JSONResponse(status_code=500, content={"detail": "Internal server error"})
|
return JSONResponse(status_code=500, content={"detail": "Internal server error"})
|
||||||
|
|
||||||
|
|
||||||
|
# Serve static files
|
||||||
|
app.mount(
|
||||||
|
"/auth/assets", StaticFiles(directory=STATIC_DIR / "assets"), name="static assets"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/auth/")
|
||||||
|
async def redirect_to_index():
|
||||||
|
"""Serve the main authentication app."""
|
||||||
|
return FileResponse(STATIC_DIR / "index.html")
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/auth/{reset_token}")
|
||||||
|
async def reset_authentication(request: Request, reset_token: str):
|
||||||
|
"""Validate reset token and redirect with it as query parameter (no cookies).
|
||||||
|
|
||||||
|
After validation we 303 redirect to /auth/?reset=<token>. The frontend will:
|
||||||
|
- Read the token from location.search
|
||||||
|
- Use it via Authorization header or websocket query param
|
||||||
|
- history.replaceState to remove it from the address bar/history
|
||||||
|
"""
|
||||||
|
if not passphrase.is_well_formed(reset_token):
|
||||||
|
raise HTTPException(status_code=404)
|
||||||
|
origin = global_passkey.instance.origin
|
||||||
|
# Do not verify existence/expiry here; frontend + user-info endpoint will handle invalid tokens.
|
||||||
|
url = f"{origin}/auth/?reset={reset_token}"
|
||||||
|
return RedirectResponse(url=url, status_code=303)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/auth/forward-auth")
|
@app.get("/auth/forward-auth")
|
||||||
async def forward_authentication(request: Request, perm=Query(None), 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.
|
||||||
@ -86,20 +117,3 @@ async def forward_authentication(request: Request, perm=Query(None), auth=Cookie
|
|||||||
)
|
)
|
||||||
except HTTPException as e:
|
except HTTPException as e:
|
||||||
return FileResponse(STATIC_DIR / "index.html", e.status_code)
|
return FileResponse(STATIC_DIR / "index.html", e.status_code)
|
||||||
|
|
||||||
|
|
||||||
# Serve static files
|
|
||||||
app.mount(
|
|
||||||
"/auth/assets", StaticFiles(directory=STATIC_DIR / "assets"), name="static assets"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/auth/")
|
|
||||||
async def redirect_to_index():
|
|
||||||
"""Serve the main authentication app."""
|
|
||||||
return FileResponse(STATIC_DIR / "index.html")
|
|
||||||
|
|
||||||
|
|
||||||
# Register API routes
|
|
||||||
register_api_routes(app)
|
|
||||||
register_reset_routes(app)
|
|
||||||
|
@ -1,59 +0,0 @@
|
|||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from fastapi import Cookie, HTTPException, Request, Response
|
|
||||||
from fastapi.responses import RedirectResponse
|
|
||||||
|
|
||||||
from ..authsession import expires, get_session
|
|
||||||
from ..globals import db
|
|
||||||
from ..globals import passkey as global_passkey
|
|
||||||
from ..util import passphrase, tokens
|
|
||||||
from . import session
|
|
||||||
|
|
||||||
# Local copy to avoid circular import with mainapp
|
|
||||||
STATIC_DIR = Path(__file__).parent.parent / "frontend-build"
|
|
||||||
|
|
||||||
|
|
||||||
def register_reset_routes(app):
|
|
||||||
"""Register all device addition/reset routes on the FastAPI app."""
|
|
||||||
|
|
||||||
@app.post("/auth/create-link")
|
|
||||||
async def api_create_link(request: Request, response: Response, auth=Cookie(None)):
|
|
||||||
"""Create a device addition link for the authenticated user."""
|
|
||||||
# Require authentication
|
|
||||||
s = await get_session(auth)
|
|
||||||
|
|
||||||
# Generate a human-readable token
|
|
||||||
token = passphrase.generate() # e.g., "cross.rotate.yin.note.evoke"
|
|
||||||
await db.instance.create_session(
|
|
||||||
user_uuid=s.user_uuid,
|
|
||||||
key=tokens.reset_key(token),
|
|
||||||
expires=expires(),
|
|
||||||
info=session.infodict(request, "device addition"),
|
|
||||||
)
|
|
||||||
|
|
||||||
# Generate the device addition link with pretty URL using configured origin
|
|
||||||
origin = global_passkey.instance.origin.rstrip("/")
|
|
||||||
path = request.url.path.removesuffix("create-link") + token # /auth/<token>
|
|
||||||
url = f"{origin}{path}"
|
|
||||||
|
|
||||||
return {
|
|
||||||
"message": "Registration link generated successfully",
|
|
||||||
"url": url,
|
|
||||||
"expires": expires().isoformat(),
|
|
||||||
}
|
|
||||||
|
|
||||||
@app.get("/auth/{reset_token}")
|
|
||||||
async def reset_authentication(request: Request, reset_token: str):
|
|
||||||
"""Validate reset token and redirect with it as query parameter (no cookies).
|
|
||||||
|
|
||||||
After validation we 303 redirect to /auth/?reset=<token>. The frontend will:
|
|
||||||
- Read the token from location.search
|
|
||||||
- Use it via Authorization header or websocket query param
|
|
||||||
- history.replaceState to remove it from the address bar/history
|
|
||||||
"""
|
|
||||||
if not passphrase.is_well_formed(reset_token):
|
|
||||||
raise HTTPException(status_code=404)
|
|
||||||
origin = global_passkey.instance.origin
|
|
||||||
# Do not verify existence/expiry here; frontend + user-info endpoint will handle invalid tokens.
|
|
||||||
redirect_url = f"{origin}/auth/?reset={reset_token}"
|
|
||||||
return RedirectResponse(url=redirect_url, status_code=303)
|
|
Loading…
x
Reference in New Issue
Block a user