Remodel reset token handling due to browsers sometimes refusing to set the cookie when opening the link (from another site).
This commit is contained in:
parent
4f094a7016
commit
3e5c0065d5
@ -20,12 +20,22 @@ import ResetView from '@/components/ResetView.vue'
|
||||
const store = useAuthStore()
|
||||
|
||||
onMounted(async () => {
|
||||
// Was an error message passed in the URL?
|
||||
// Was an error message passed in the URL hash?
|
||||
const message = location.hash.substring(1)
|
||||
if (message) {
|
||||
store.showMessage(decodeURIComponent(message), 'error')
|
||||
history.replaceState(null, '', location.pathname)
|
||||
}
|
||||
// Capture reset token from query parameter and then remove it
|
||||
const params = new URLSearchParams(location.search)
|
||||
const reset = params.get('reset')
|
||||
if (reset) {
|
||||
store.resetToken = reset
|
||||
// Remove query param to avoid lingering in history / clipboard
|
||||
const targetPath = '/auth/'
|
||||
const currentPath = location.pathname.endsWith('/') ? location.pathname : location.pathname + '/'
|
||||
history.replaceState(null, '', currentPath.startsWith('/auth') ? '/auth/' : targetPath)
|
||||
}
|
||||
try {
|
||||
await store.loadUserInfo()
|
||||
} catch (error) {
|
||||
|
@ -27,12 +27,14 @@ async function register() {
|
||||
authStore.showMessage('Starting registration...', 'info')
|
||||
|
||||
try {
|
||||
const result = await passkey.register()
|
||||
const result = await passkey.register(authStore.resetToken)
|
||||
console.log("Result", result)
|
||||
await authStore.setSessionCookie(result.session_token)
|
||||
|
||||
// resetToken cleared by setSessionCookie; ensure again
|
||||
authStore.resetToken = null
|
||||
authStore.showMessage('Passkey registered successfully!', 'success', 2000)
|
||||
authStore.loadUserInfo().then(authStore.selectView)
|
||||
await authStore.loadUserInfo()
|
||||
authStore.selectView()
|
||||
} catch (error) {
|
||||
authStore.showMessage(`Registration failed: ${error.message}`, 'error')
|
||||
} finally {
|
||||
|
@ -6,6 +6,7 @@ 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 (never stored in cookie)
|
||||
|
||||
// UI State
|
||||
currentView: 'login', // 'login', 'profile', 'device-link', 'reset'
|
||||
@ -37,6 +38,9 @@ export const useAuthStore = defineStore('auth', {
|
||||
if (result.detail) {
|
||||
throw new Error(result.detail)
|
||||
}
|
||||
// On successful session establishment, discard any reset token to avoid
|
||||
// sending stale Authorization headers on subsequent API calls.
|
||||
this.resetToken = null
|
||||
return result
|
||||
},
|
||||
async register() {
|
||||
@ -69,9 +73,25 @@ export const useAuthStore = defineStore('auth', {
|
||||
else this.currentView = 'reset'
|
||||
},
|
||||
async loadUserInfo() {
|
||||
const response = await fetch('/auth/user-info', {method: 'POST'})
|
||||
const result = await response.json()
|
||||
if (result.detail) throw new Error(`Server: ${result.detail}`)
|
||||
const headers = {}
|
||||
// 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 response = await fetch(url, { method: 'POST', headers })
|
||||
let result = null
|
||||
try {
|
||||
result = await response.json()
|
||||
} catch (_) {
|
||||
// ignore JSON parse errors (unlikely)
|
||||
}
|
||||
if (response.status === 401 && result?.detail) {
|
||||
this.showMessage(result.detail, 'error', 5000)
|
||||
throw new Error(result.detail)
|
||||
}
|
||||
if (result?.detail) {
|
||||
// Other error style
|
||||
this.showMessage(result.detail, 'error', 5000)
|
||||
throw new Error(result.detail)
|
||||
}
|
||||
this.userInfo = result
|
||||
console.log('User info loaded:', result)
|
||||
},
|
||||
|
@ -1,8 +1,9 @@
|
||||
import { startRegistration, startAuthentication } from '@simplewebauthn/browser'
|
||||
import aWebSocket from '@/utils/awaitable-websocket'
|
||||
|
||||
export async function register() {
|
||||
const ws = await aWebSocket("/auth/ws/register")
|
||||
export async function register(resetToken = null) {
|
||||
const url = resetToken ? `/auth/ws/register?reset=${encodeURIComponent(resetToken)}` : "/auth/ws/register"
|
||||
const ws = await aWebSocket(url)
|
||||
try {
|
||||
const optionsJSON = await ws.receive_json()
|
||||
const registrationResponse = await startRegistration({ optionsJSON })
|
||||
|
@ -27,7 +27,9 @@ export default defineConfig(({ command, mode }) => ({
|
||||
// and static assets so that HMR works. Bypass tells http-proxy to skip
|
||||
// proxying when we return a (possibly rewritten) local path.
|
||||
bypass(req) {
|
||||
const url = req.url || ''
|
||||
const rawUrl = req.url || ''
|
||||
// Strip query/hash to match path-only for SPA entrypoints with query params (e.g. ?reset=token)
|
||||
const url = rawUrl.split('?')[0].split('#')[0]
|
||||
// Bypass only root SPA entrypoints + static assets so Vite serves them for HMR.
|
||||
// Admin API endpoints (e.g., /auth/admin/orgs) must still hit backend.
|
||||
if (url === '/auth/' || url === '/auth') return '/'
|
||||
|
@ -52,47 +52,42 @@ def register_api_routes(app: FastAPI):
|
||||
}
|
||||
|
||||
@app.post("/auth/user-info")
|
||||
async def api_user_info(response: Response, auth=Cookie(None)):
|
||||
async def api_user_info(reset: str | None = None, auth=Cookie(None)):
|
||||
"""Get user information.
|
||||
|
||||
- For authenticated sessions: return full context (org/role/permissions/credentials)
|
||||
- For reset tokens: return only basic user information to drive reset flow
|
||||
"""
|
||||
reset = False
|
||||
authenticated = False
|
||||
try:
|
||||
if auth is None:
|
||||
raise ValueError("Auth cookie missing")
|
||||
reset = passphrase.is_well_formed(auth)
|
||||
s = await (get_reset if reset else get_session)(auth)
|
||||
except ValueError as e:
|
||||
if reset:
|
||||
print(e, reset, auth, tokens.reset_key(auth).hex())
|
||||
if not passphrase.is_well_formed(reset):
|
||||
raise ValueError("Invalid reset token")
|
||||
s = await get_reset(reset)
|
||||
else:
|
||||
print(e, reset, auth)
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail="Authentication Required",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
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)
|
||||
|
||||
# Minimal response for reset tokens
|
||||
if reset:
|
||||
u = await db.instance.get_user_by_uuid(s.user_uuid)
|
||||
if not authenticated:
|
||||
return {
|
||||
"authenticated": False,
|
||||
"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,
|
||||
"visits": u.visits,
|
||||
},
|
||||
}
|
||||
|
||||
# Full context for authenticated sessions
|
||||
assert authenticated and auth is not None
|
||||
ctx = await db.instance.get_session_context(session_key(auth))
|
||||
u = await db.instance.get_user_by_uuid(s.user_uuid)
|
||||
credential_ids = await db.instance.get_credentials_by_user_uuid(s.user_uuid)
|
||||
|
||||
credentials: list[dict] = []
|
||||
@ -224,25 +219,22 @@ def register_api_routes(app: FastAPI):
|
||||
async def admin_update_org(
|
||||
org_uuid: UUID, payload: dict = Body(...), auth=Cookie(None)
|
||||
):
|
||||
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")
|
||||
from ..db import Org as OrgDC
|
||||
# Only global admins can modify org definitions (simpler rule)
|
||||
_, is_global_admin, _ = await _get_ctx_and_admin_flags(auth)
|
||||
if not is_global_admin:
|
||||
raise ValueError("Global admin required")
|
||||
from ..db import Org as OrgDC # local import to avoid cycles
|
||||
|
||||
current = await db.instance.get_organization(str(org_uuid))
|
||||
display_name = payload.get("display_name") or current.display_name
|
||||
permissions = (
|
||||
payload.get("permissions")
|
||||
if "permissions" in payload
|
||||
else current.permissions
|
||||
) or []
|
||||
permissions = payload.get("permissions") or current.permissions or []
|
||||
org = OrgDC(uuid=org_uuid, display_name=display_name, permissions=permissions)
|
||||
await db.instance.update_organization(org)
|
||||
return {"status": "ok"}
|
||||
|
||||
@app.delete("/auth/admin/orgs/{org_uuid}")
|
||||
async def admin_delete_org(org_uuid: UUID, auth=Cookie(None)):
|
||||
ctx, is_global_admin, is_org_admin = await _get_ctx_and_admin_flags(auth)
|
||||
ctx, is_global_admin, _ = await _get_ctx_and_admin_flags(auth)
|
||||
if not is_global_admin:
|
||||
# Org admins cannot delete at all (avoid self-lockout)
|
||||
raise ValueError("Global admin required")
|
||||
@ -372,7 +364,6 @@ def register_api_routes(app: FastAPI):
|
||||
role_uuid=role_obj.uuid,
|
||||
visits=0,
|
||||
created_at=None,
|
||||
last_seen=None,
|
||||
)
|
||||
await db.instance.create_user(user)
|
||||
return {"uuid": str(user_uuid)}
|
||||
@ -582,10 +573,8 @@ def register_api_routes(app: FastAPI):
|
||||
|
||||
@app.post("/auth/set-session")
|
||||
async def api_set_session(response: Response, auth=Depends(bearer_auth)):
|
||||
"""Set session cookie from Authorization header. Fetched after login by WebSocket."""
|
||||
"""Set session cookie from Authorization Bearer session token (never via query)."""
|
||||
user = await get_session(auth.credentials)
|
||||
if not user:
|
||||
raise ValueError("Invalid Authorization header.")
|
||||
session.set_session_cookie(response, auth.credentials)
|
||||
|
||||
return {
|
||||
|
@ -1,4 +1,4 @@
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import Cookie, HTTPException, Request, Response
|
||||
from fastapi.responses import RedirectResponse
|
||||
@ -9,6 +9,9 @@ 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."""
|
||||
@ -28,9 +31,10 @@ def register_reset_routes(app):
|
||||
info=session.infodict(request, "device addition"),
|
||||
)
|
||||
|
||||
# Generate the device addition link with pretty URL
|
||||
path = request.url.path.removesuffix("create-link") + token
|
||||
url = f"{request.headers['origin']}{path}"
|
||||
# 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",
|
||||
@ -39,31 +43,17 @@ def register_reset_routes(app):
|
||||
}
|
||||
|
||||
@app.get("/auth/{reset_token}")
|
||||
async def reset_authentication(
|
||||
request: Request,
|
||||
reset_token: str,
|
||||
):
|
||||
"""Verifies the token and redirects to auth app for credential registration."""
|
||||
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
|
||||
try:
|
||||
# Get session token to validate it exists and get user_id
|
||||
key = tokens.reset_key(reset_token)
|
||||
sess = await db.instance.get_session(key)
|
||||
if not sess:
|
||||
raise ValueError("Invalid or expired registration token")
|
||||
|
||||
response = RedirectResponse(url=f"{origin}/auth/", status_code=303)
|
||||
session.set_session_cookie(response, reset_token)
|
||||
print(response.headers)
|
||||
return response
|
||||
|
||||
except Exception as e:
|
||||
# On any error, redirect to auth app
|
||||
if isinstance(e, ValueError):
|
||||
msg = str(e)
|
||||
else:
|
||||
logging.exception("Internal Server Error in reset_authentication")
|
||||
msg = "Internal Server Error"
|
||||
return RedirectResponse(url=f"{origin}/auth/#{msg}", status_code=303)
|
||||
# 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)
|
||||
|
@ -54,12 +54,26 @@ async def register_chat(
|
||||
|
||||
@app.websocket("/register")
|
||||
@websocket_error_handler
|
||||
async def websocket_register_add(ws: WebSocket, auth=Cookie(None)):
|
||||
"""Register a new credential for an existing user."""
|
||||
async def websocket_register_add(
|
||||
ws: WebSocket, reset: str | None = None, auth=Cookie(None)
|
||||
):
|
||||
"""Register a new credential for an existing user.
|
||||
|
||||
Supports either:
|
||||
- Normal session via auth cookie
|
||||
- Reset token supplied as ?reset=... (auth cookie ignored)
|
||||
"""
|
||||
origin = ws.headers["origin"]
|
||||
# Try to get either a regular session or a reset session
|
||||
reset = passphrase.is_well_formed(auth)
|
||||
s = await (get_reset if reset else get_session)(auth)
|
||||
is_reset = False
|
||||
if reset is not None:
|
||||
if not passphrase.is_well_formed(reset):
|
||||
raise ValueError("Invalid reset token")
|
||||
is_reset = True
|
||||
s = await get_reset(reset)
|
||||
else:
|
||||
if not auth:
|
||||
raise ValueError("Authentication Required")
|
||||
s = await get_session(auth)
|
||||
user_uuid = s.user_uuid
|
||||
|
||||
# Get user information to get the user_name
|
||||
@ -73,23 +87,19 @@ async def websocket_register_add(ws: WebSocket, auth=Cookie(None)):
|
||||
# to satisfy the sessions.credential_uuid foreign key (now enforced).
|
||||
await db.instance.create_credential(credential)
|
||||
|
||||
if reset:
|
||||
if is_reset:
|
||||
# Invalidate the one-time reset session only after credential persisted
|
||||
await db.instance.delete_session(s.key)
|
||||
token = await create_session(
|
||||
auth = await create_session(
|
||||
user_uuid, credential.uuid, infodict(ws, "authenticated")
|
||||
)
|
||||
else:
|
||||
# Existing session continues; we don't need to create a new one here.
|
||||
token = auth
|
||||
|
||||
assert isinstance(token, str) and len(token) == 16
|
||||
|
||||
assert isinstance(auth, str) and len(auth) == 16
|
||||
await ws.send_json(
|
||||
{
|
||||
"user_uuid": str(user.uuid),
|
||||
"credential_uuid": str(credential.uuid),
|
||||
"session_token": token,
|
||||
"session_token": auth,
|
||||
"message": "New credential added successfully",
|
||||
}
|
||||
)
|
||||
|
Loading…
x
Reference in New Issue
Block a user