feat(auth): U7-U10 会话管理 UI + admin API + 测试修复
- U7: 前端 ActiveSessionsPanel + ChangePasswordPanel 组件 - U8: 用户会话管理(查看/撤销/改密)集成到 SettingsView - U9: 管理员会话管理 API + UserSessionsPanel + AdminApiClient - U10: 认证中间件支持 sid 会话验证 + legacy client 兼容 - 修复 test_auth.py 测试夹具:注入 SessionService 单例绑定测试 DB - 修复 wrong-password 断言大小写匹配 - ruff: 清理未使用导入
This commit is contained in:
parent
b418c3dc95
commit
9328451050
|
|
@ -926,6 +926,7 @@ def create_app(
|
||||||
app.include_router(terminal_whitelist.router, prefix="/api/v1")
|
app.include_router(terminal_whitelist.router, prefix="/api/v1")
|
||||||
app.include_router(experts.router, prefix="/api/v1")
|
app.include_router(experts.router, prefix="/api/v1")
|
||||||
app.include_router(auth_routes.router, prefix="/api/v1")
|
app.include_router(auth_routes.router, prefix="/api/v1")
|
||||||
|
app.include_router(auth_routes.admin_router, prefix="/api/v1")
|
||||||
|
|
||||||
# Serve GUI when in GUI mode
|
# Serve GUI when in GUI mode
|
||||||
gui_mode = os.environ.get("AGENTKIT_GUI_MODE")
|
gui_mode = os.environ.get("AGENTKIT_GUI_MODE")
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,14 @@ async def get_current_user(request: Request) -> dict[str, Any] | None:
|
||||||
The payload is set by :class:`AuthMiddleware` and contains
|
The payload is set by :class:`AuthMiddleware` and contains
|
||||||
``user_id``, ``username``, and ``role``. When no auth middleware is
|
``user_id``, ``username``, and ``role``. When no auth middleware is
|
||||||
active (e.g. dev mode with no keys configured), returns ``None``.
|
active (e.g. dev mode with no keys configured), returns ``None``.
|
||||||
|
|
||||||
|
U10 back-compat: the payload may or may not carry a ``sid`` field.
|
||||||
|
Legacy V1 tokens (issued before the auth_sessions table existed)
|
||||||
|
do not have ``sid``; the caller must check ``"sid" in payload`` and
|
||||||
|
fall back to the V1 ``user_sessions`` table when the claim is
|
||||||
|
missing. This helper does NOT itself consult the session table —
|
||||||
|
that responsibility belongs to the routes / dependencies that
|
||||||
|
need a real session (e.g. :func:`get_current_session` in U4).
|
||||||
"""
|
"""
|
||||||
return getattr(request.state, "current_user", None)
|
return getattr(request.state, "current_user", None)
|
||||||
|
|
||||||
|
|
@ -47,6 +55,15 @@ async def require_authenticated(request: Request) -> dict[str, Any]:
|
||||||
status_code=401,
|
status_code=401,
|
||||||
detail="Authentication required",
|
detail="Authentication required",
|
||||||
)
|
)
|
||||||
|
# U10 debug signal: log once per request when a legacy (no-sid)
|
||||||
|
# token is in use. This makes the migration window observable in
|
||||||
|
# the server log; when the count drops to zero, we can remove the
|
||||||
|
# legacy code path.
|
||||||
|
if "sid" not in user:
|
||||||
|
logger.debug(
|
||||||
|
"Legacy JWT (no sid) in use for user_id=%s — accepted via U10 back-compat path",
|
||||||
|
user.get("user_id"),
|
||||||
|
)
|
||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -117,6 +117,7 @@ def create_token_pair(
|
||||||
session_id: str | None = None,
|
session_id: str | None = None,
|
||||||
remember_me: bool = False,
|
remember_me: bool = False,
|
||||||
now: datetime | None = None,
|
now: datetime | None = None,
|
||||||
|
legacy_mode: bool = False,
|
||||||
) -> TokenPair:
|
) -> TokenPair:
|
||||||
"""Create a signed access + refresh JWT pair.
|
"""Create a signed access + refresh JWT pair.
|
||||||
|
|
||||||
|
|
@ -134,6 +135,12 @@ def create_token_pair(
|
||||||
remember_me: When ``True``, the refresh token is valid for 30
|
remember_me: When ``True``, the refresh token is valid for 30
|
||||||
days instead of the default 7.
|
days instead of the default 7.
|
||||||
now: Override the issued-at time (for testing). Defaults to UTC now.
|
now: Override the issued-at time (for testing). Defaults to UTC now.
|
||||||
|
legacy_mode: When ``True``, the resulting tokens are
|
||||||
|
intentionally issued without a ``sid`` claim, regardless of
|
||||||
|
whether ``session_id`` was passed. This is the U10
|
||||||
|
back-compat flag used by the login route for clients with
|
||||||
|
``X-Client-Version`` below the rollout cutoff. Default
|
||||||
|
``False`` — production tokens always carry ``sid``.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
A :class:`TokenPair` with both signed tokens and their expiry times.
|
A :class:`TokenPair` with both signed tokens and their expiry times.
|
||||||
|
|
@ -141,6 +148,12 @@ def create_token_pair(
|
||||||
if not secret:
|
if not secret:
|
||||||
raise ValueError("JWT secret must not be empty")
|
raise ValueError("JWT secret must not be empty")
|
||||||
|
|
||||||
|
# U10: legacy_mode forces the no-sid path even if the caller has
|
||||||
|
# already created a session row. This avoids handing a fresh sid
|
||||||
|
# to a client that doesn't know how to use it (the
|
||||||
|
# ``/auth/whoami`` route will fall back to its legacy branch).
|
||||||
|
effective_session_id: str | None = None if legacy_mode else session_id
|
||||||
|
|
||||||
issued_at = now or datetime.now(timezone.utc)
|
issued_at = now or datetime.now(timezone.utc)
|
||||||
refresh_ttl = _refresh_ttl_for(remember_me)
|
refresh_ttl = _refresh_ttl_for(remember_me)
|
||||||
access_exp = issued_at + ACCESS_TOKEN_TTL
|
access_exp = issued_at + ACCESS_TOKEN_TTL
|
||||||
|
|
@ -150,7 +163,7 @@ def create_token_pair(
|
||||||
# sha256 of its plaintext value as the rotation handle in the
|
# sha256 of its plaintext value as the rotation handle in the
|
||||||
# denylist; giving it a jti too would be redundant and would bloat
|
# denylist; giving it a jti too would be redundant and would bloat
|
||||||
# the JWT.
|
# the JWT.
|
||||||
jti = str(uuid.uuid4()) if session_id else None
|
jti = str(uuid.uuid4()) if effective_session_id else None
|
||||||
|
|
||||||
access_payload: dict[str, Any] = {
|
access_payload: dict[str, Any] = {
|
||||||
"sub": user_id,
|
"sub": user_id,
|
||||||
|
|
@ -168,10 +181,10 @@ def create_token_pair(
|
||||||
"iat": int(issued_at.timestamp()),
|
"iat": int(issued_at.timestamp()),
|
||||||
"exp": int(refresh_exp.timestamp()),
|
"exp": int(refresh_exp.timestamp()),
|
||||||
}
|
}
|
||||||
if session_id:
|
if effective_session_id:
|
||||||
access_payload["sid"] = session_id
|
access_payload["sid"] = effective_session_id
|
||||||
access_payload["jti"] = jti
|
access_payload["jti"] = jti
|
||||||
refresh_payload["sid"] = session_id
|
refresh_payload["sid"] = effective_session_id
|
||||||
|
|
||||||
access_token = jwt.encode(access_payload, secret, algorithm=JWT_ALGORITHM)
|
access_token = jwt.encode(access_payload, secret, algorithm=JWT_ALGORITHM)
|
||||||
refresh_token = jwt.encode(refresh_payload, secret, algorithm=JWT_ALGORITHM)
|
refresh_token = jwt.encode(refresh_payload, secret, algorithm=JWT_ALGORITHM)
|
||||||
|
|
|
||||||
|
|
@ -78,7 +78,16 @@ class AuthMiddleware(BaseHTTPMiddleware):
|
||||||
return not self._jwt_secret and self._api_key is None and not self._client_keys
|
return not self._jwt_secret and self._api_key is None and not self._client_keys
|
||||||
|
|
||||||
def _verify_jwt(self, token: str) -> dict[str, Any] | None:
|
def _verify_jwt(self, token: str) -> dict[str, Any] | None:
|
||||||
"""Verify a JWT bearer token. Returns payload or None."""
|
"""Verify a JWT bearer token. Returns payload or None.
|
||||||
|
|
||||||
|
V2 tokens carry a ``sid`` claim. The middleware does NOT
|
||||||
|
consult the session cache here — it only checks the JWT
|
||||||
|
signature + ``type`` claim. The sid-aware validation runs
|
||||||
|
lazily in the :func:`require_authenticated` dependency when
|
||||||
|
the route opts in (see :mod:`.dependencies`). This keeps the
|
||||||
|
middleware hot path cheap and lets the per-route decision
|
||||||
|
about cache TTL be made by the route author.
|
||||||
|
"""
|
||||||
if not self._jwt_secret:
|
if not self._jwt_secret:
|
||||||
return None
|
return None
|
||||||
try:
|
try:
|
||||||
|
|
@ -140,6 +149,7 @@ class AuthMiddleware(BaseHTTPMiddleware):
|
||||||
"user_id": payload.get("sub"),
|
"user_id": payload.get("sub"),
|
||||||
"username": payload.get("username"),
|
"username": payload.get("username"),
|
||||||
"role": payload.get("role"),
|
"role": payload.get("role"),
|
||||||
|
"sid": payload.get("sid"),
|
||||||
}
|
}
|
||||||
return await call_next(request)
|
return await call_next(request)
|
||||||
# Fall through to API key check, then 401
|
# Fall through to API key check, then 401
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,6 @@ from __future__ import annotations
|
||||||
|
|
||||||
import time
|
import time
|
||||||
from collections import OrderedDict
|
from collections import OrderedDict
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from .session_service import SessionService
|
from .session_service import SessionService
|
||||||
|
|
||||||
|
|
@ -104,9 +103,7 @@ def init_validation_cache(
|
||||||
a reference for tests.
|
a reference for tests.
|
||||||
"""
|
"""
|
||||||
global _cache
|
global _cache
|
||||||
_cache = SessionValidationCache(
|
_cache = SessionValidationCache(service, ttl_seconds=ttl_seconds, max_entries=max_entries)
|
||||||
service, ttl_seconds=ttl_seconds, max_entries=max_entries
|
|
||||||
)
|
|
||||||
return _cache
|
return _cache
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -228,12 +228,8 @@ class SessionService:
|
||||||
"""
|
"""
|
||||||
session_id = str(uuid.uuid4())
|
session_id = str(uuid.uuid4())
|
||||||
now = _now_iso()
|
now = _now_iso()
|
||||||
expires = (
|
expires = datetime.now(timezone.utc).timestamp() + payload.ttl_seconds
|
||||||
datetime.now(timezone.utc).timestamp() + payload.ttl_seconds
|
expires_iso = datetime.fromtimestamp(expires, tz=timezone.utc).isoformat()
|
||||||
)
|
|
||||||
expires_iso = (
|
|
||||||
datetime.fromtimestamp(expires, tz=timezone.utc).isoformat()
|
|
||||||
)
|
|
||||||
refresh_hash = hash_token(payload.refresh_token)
|
refresh_hash = hash_token(payload.refresh_token)
|
||||||
|
|
||||||
await self._enforce_session_cap(payload.user_id, keep_id=session_id)
|
await self._enforce_session_cap(payload.user_id, keep_id=session_id)
|
||||||
|
|
@ -302,9 +298,7 @@ class SessionService:
|
||||||
# Concurrent retry — revoke everything for this user.
|
# Concurrent retry — revoke everything for this user.
|
||||||
user_id = self._recent_users.pop(old_hash, None)
|
user_id = self._recent_users.pop(old_hash, None)
|
||||||
if user_id is not None:
|
if user_id is not None:
|
||||||
await self.revoke_all_for_user(
|
await self.revoke_all_for_user(user_id, reason=REVOKE_REASON_REUSE_DETECTED)
|
||||||
user_id, reason=REVOKE_REASON_REUSE_DETECTED
|
|
||||||
)
|
|
||||||
raise SessionReuseDetected("refresh token reuse detected")
|
raise SessionReuseDetected("refresh token reuse detected")
|
||||||
|
|
||||||
info = await self.find_by_refresh_token(old_refresh_token)
|
info = await self.find_by_refresh_token(old_refresh_token)
|
||||||
|
|
@ -315,12 +309,10 @@ class SessionService:
|
||||||
|
|
||||||
new_hash = hash_token(new_refresh_token)
|
new_hash = hash_token(new_refresh_token)
|
||||||
now = _now_iso()
|
now = _now_iso()
|
||||||
new_expires_iso = (
|
new_expires_iso = datetime.fromtimestamp(
|
||||||
datetime.fromtimestamp(
|
datetime.now(timezone.utc).timestamp() + new_ttl_seconds,
|
||||||
datetime.now(timezone.utc).timestamp() + new_ttl_seconds,
|
tz=timezone.utc,
|
||||||
tz=timezone.utc,
|
).isoformat()
|
||||||
).isoformat()
|
|
||||||
)
|
|
||||||
|
|
||||||
async with aiosqlite.connect(str(self._db_path)) as db:
|
async with aiosqlite.connect(str(self._db_path)) as db:
|
||||||
await db.execute(
|
await db.execute(
|
||||||
|
|
@ -357,9 +349,7 @@ class SessionService:
|
||||||
# Revoke
|
# Revoke
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
async def revoke(
|
async def revoke(self, session_id: str, *, reason: str = REVOKE_REASON_USER_TERMINATED) -> bool:
|
||||||
self, session_id: str, *, reason: str = REVOKE_REASON_USER_TERMINATED
|
|
||||||
) -> bool:
|
|
||||||
"""Revoke a single session.
|
"""Revoke a single session.
|
||||||
|
|
||||||
Returns ``True`` if a row was updated, ``False`` if the session
|
Returns ``True`` if a row was updated, ``False`` if the session
|
||||||
|
|
|
||||||
|
|
@ -59,11 +59,11 @@ def _has_shell_operators(command: str) -> bool:
|
||||||
|
|
||||||
# ── Decision constants ────────────────────────────────────────────────
|
# ── Decision constants ────────────────────────────────────────────────
|
||||||
|
|
||||||
DECISION_EXECUTED = "executed" # Ran without confirmation (whitelisted)
|
DECISION_EXECUTED = "executed" # Ran without confirmation (whitelisted)
|
||||||
DECISION_CONFIRMED = "confirmed" # Ran after user confirmation
|
DECISION_CONFIRMED = "confirmed" # Ran after user confirmation
|
||||||
DECISION_REJECTED = "rejected" # User rejected confirmation prompt
|
DECISION_REJECTED = "rejected" # User rejected confirmation prompt
|
||||||
DECISION_BLOCKED = "blocked" # Blocked by blocklist
|
DECISION_BLOCKED = "blocked" # Blocked by blocklist
|
||||||
DECISION_DENIED = "denied" # Blocked by safety check (non-whitelist)
|
DECISION_DENIED = "denied" # Blocked by safety check (non-whitelist)
|
||||||
|
|
||||||
|
|
||||||
# ── Pattern matching ─────────────────────────────────────────────────
|
# ── Pattern matching ─────────────────────────────────────────────────
|
||||||
|
|
@ -183,9 +183,7 @@ async def _fetch_blocklist(db_path: str | Path) -> list[tuple[str, str | None]]:
|
||||||
patterns: list[tuple[str, str | None]] = []
|
patterns: list[tuple[str, str | None]] = []
|
||||||
try:
|
try:
|
||||||
async with aiosqlite.connect(str(db_path)) as db:
|
async with aiosqlite.connect(str(db_path)) as db:
|
||||||
cursor = await db.execute(
|
cursor = await db.execute("SELECT command_pattern, reason FROM terminal_blocklist")
|
||||||
"SELECT command_pattern, reason FROM terminal_blocklist"
|
|
||||||
)
|
|
||||||
rows = await cursor.fetchall()
|
rows = await cursor.fetchall()
|
||||||
patterns = [(row[0], row[1]) for row in rows]
|
patterns = [(row[0], row[1]) for row in rows]
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ import {
|
||||||
setTokenProvider,
|
setTokenProvider,
|
||||||
setRefreshProvider,
|
setRefreshProvider,
|
||||||
setUnauthorizedHandler,
|
setUnauthorizedHandler,
|
||||||
|
setPreEmptiveRefreshProvider,
|
||||||
} from './api/base'
|
} from './api/base'
|
||||||
import SplashScreen from './components/layout/SplashScreen.vue'
|
import SplashScreen from './components/layout/SplashScreen.vue'
|
||||||
|
|
||||||
|
|
@ -38,12 +39,22 @@ onMounted(async () => {
|
||||||
// Wire the auth store into the API client so every request gets a JWT
|
// Wire the auth store into the API client so every request gets a JWT
|
||||||
// attached automatically, and 401s trigger a token refresh.
|
// attached automatically, and 401s trigger a token refresh.
|
||||||
setTokenProvider(() => authStore.accessToken)
|
setTokenProvider(() => authStore.accessToken)
|
||||||
setRefreshProvider(() => authStore.refreshIfPossible())
|
setRefreshProvider(async () => {
|
||||||
|
const pair = await authStore.silentRefresh()
|
||||||
|
return pair?.access_token ?? null
|
||||||
|
})
|
||||||
|
setPreEmptiveRefreshProvider(() => authStore.shouldRefresh)
|
||||||
setUnauthorizedHandler(() => {
|
setUnauthorizedHandler(() => {
|
||||||
authStore.logoutLocal()
|
void authStore.logoutLocal()
|
||||||
router.replace({ name: 'login' })
|
router.replace({ name: 'login' })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Cold-start auth: rehydrate from the persisted refresh token.
|
||||||
|
// This populates user + access token WITHOUT forcing a login.
|
||||||
|
// The router guard then decides whether to keep the user where
|
||||||
|
// they are or redirect to /login.
|
||||||
|
await authStore.startupCheck()
|
||||||
|
|
||||||
if (isTauri()) {
|
if (isTauri()) {
|
||||||
try {
|
try {
|
||||||
await bootstrapBackend()
|
await bootstrapBackend()
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,74 @@
|
||||||
|
/**
|
||||||
|
* Admin API client (U9 — Admin UI: user sessions management).
|
||||||
|
*
|
||||||
|
* Talks to the server-side ``/api/v1/admin/...`` endpoints implemented
|
||||||
|
* in ``src/agentkit/server/routes/auth.py``. The endpoints require
|
||||||
|
* ``USER_MANAGE`` permission (admin role); the API client does not
|
||||||
|
* itself enforce that — the server's 403 response is surfaced to the
|
||||||
|
* caller as an :class:`IApiError` with ``status: 403``.
|
||||||
|
*
|
||||||
|
* Surface:
|
||||||
|
* - ``listAllSessions`` — every recent session across the system
|
||||||
|
* (admin "active sessions" overview).
|
||||||
|
* - ``listUserSessions(userId)`` — every session for a specific user,
|
||||||
|
* including revoked ones when ``includeRevoked`` is true.
|
||||||
|
* - ``revokeUserSession(userId, sid)`` — force-revoke a single session
|
||||||
|
* of a single user.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { BaseApiClient } from './base'
|
||||||
|
import type { ISessionInfo } from './auth'
|
||||||
|
|
||||||
|
class AdminApiClient extends BaseApiClient {
|
||||||
|
constructor(baseUrl: string = '/api/v1') {
|
||||||
|
super(baseUrl)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List recent sessions across all users (admin only).
|
||||||
|
*
|
||||||
|
* @param limit Maximum number of sessions to return (default 200).
|
||||||
|
*/
|
||||||
|
async listAllSessions(limit: number = 200): Promise<ISessionInfo[]> {
|
||||||
|
return this.request<ISessionInfo[]>(
|
||||||
|
`/admin/sessions?limit=${encodeURIComponent(String(limit))}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List every session for a specific user.
|
||||||
|
*
|
||||||
|
* @param userId The user whose sessions to list.
|
||||||
|
* @param includeRevoked When true, revoked sessions are also returned
|
||||||
|
* with their ``revoked`` and ``revoked_reason`` fields populated.
|
||||||
|
*/
|
||||||
|
async listUserSessions(
|
||||||
|
userId: string,
|
||||||
|
includeRevoked: boolean = false,
|
||||||
|
): Promise<ISessionInfo[]> {
|
||||||
|
const qs = includeRevoked ? '?include_revoked=true' : ''
|
||||||
|
return this.request<ISessionInfo[]>(
|
||||||
|
`/admin/users/${encodeURIComponent(userId)}/sessions${qs}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Force-revoke a session of a specific user.
|
||||||
|
*
|
||||||
|
* The endpoint returns ``{ revoked: boolean }`` — ``false`` means the
|
||||||
|
* session was already revoked (no-op). The server returns 404 when
|
||||||
|
* the session does not exist or does not belong to ``userId``.
|
||||||
|
*/
|
||||||
|
async revokeUserSession(
|
||||||
|
userId: string,
|
||||||
|
sid: string,
|
||||||
|
): Promise<{ revoked: boolean }> {
|
||||||
|
return this.request<{ revoked: boolean }>(
|
||||||
|
`/admin/users/${encodeURIComponent(userId)}/sessions/${encodeURIComponent(sid)}`,
|
||||||
|
{ method: 'DELETE' },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const adminApi = new AdminApiClient()
|
||||||
|
export { AdminApiClient }
|
||||||
|
|
@ -1,8 +1,17 @@
|
||||||
/**
|
/**
|
||||||
* Auth API client — login / refresh / logout / me
|
* Auth API client — login / refresh / logout / whoami / sessions / me
|
||||||
*
|
*
|
||||||
* Talks to the server-side ``/api/v1/auth/*`` endpoints implemented in
|
* Talks to the server-side ``/api/v1/auth/*`` endpoints implemented in
|
||||||
* ``src/agentkit/server/routes/auth.py``.
|
* ``src/agentkit/server/routes/auth.py`` (U4 — Centralized Auth).
|
||||||
|
*
|
||||||
|
* U7 refactor:
|
||||||
|
* - ``whoami`` accepts an optional ``refreshToken`` for the cold-start
|
||||||
|
* path (when the access token is gone but a refresh token survives
|
||||||
|
* in the OS Keychain / localStorage fallback).
|
||||||
|
* - ``login`` accepts ``rememberMe`` to ask the server for a 30-day
|
||||||
|
* refresh token TTL.
|
||||||
|
* - New ``listSessions`` / ``revokeSession`` / ``changePassword``
|
||||||
|
* endpoints back the Settings / Security UI (U8).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { BaseApiClient } from './base'
|
import { BaseApiClient } from './base'
|
||||||
|
|
@ -25,6 +34,35 @@ export interface ITokenPair {
|
||||||
token_type: 'bearer'
|
token_type: 'bearer'
|
||||||
expires_in: number
|
expires_in: number
|
||||||
user: IAuthUser
|
user: IAuthUser
|
||||||
|
/** V2 sessions carry an id; legacy tokens may omit it. */
|
||||||
|
session_id?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Public projection of an ``auth_sessions`` row. */
|
||||||
|
export interface ISessionInfo {
|
||||||
|
id: string
|
||||||
|
device_fingerprint: string
|
||||||
|
device_label: string
|
||||||
|
ip: string
|
||||||
|
user_agent: string
|
||||||
|
auth_provider: string
|
||||||
|
created_at: string
|
||||||
|
last_active_at: string
|
||||||
|
expires_at: string
|
||||||
|
is_current: boolean
|
||||||
|
/** User id — populated by admin endpoints; user-endpoint omits this. */
|
||||||
|
user_id?: string
|
||||||
|
/** Revocation flag — admin endpoints surface this for the audit view. */
|
||||||
|
revoked?: boolean
|
||||||
|
/** Machine-readable revoke reason. ``null`` for active sessions. */
|
||||||
|
revoked_reason?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Response of /auth/whoami (used for cold-start). */
|
||||||
|
export interface IWhoamiResponse {
|
||||||
|
user: IAuthUser
|
||||||
|
access_token: string
|
||||||
|
session: ISessionInfo | null
|
||||||
}
|
}
|
||||||
|
|
||||||
class AuthApiClient extends BaseApiClient {
|
class AuthApiClient extends BaseApiClient {
|
||||||
|
|
@ -33,10 +71,18 @@ class AuthApiClient extends BaseApiClient {
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Authenticate with username + password. */
|
/** Authenticate with username + password. */
|
||||||
async login(username: string, password: string): Promise<ITokenPair> {
|
async login(
|
||||||
|
username: string,
|
||||||
|
password: string,
|
||||||
|
rememberMe: boolean = false,
|
||||||
|
): Promise<ITokenPair> {
|
||||||
return this.request<ITokenPair>('/auth/login', {
|
return this.request<ITokenPair>('/auth/login', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ username, password }),
|
body: JSON.stringify({
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
remember_me: rememberMe,
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -48,6 +94,26 @@ class AuthApiClient extends BaseApiClient {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cold-start probe. Returns the current user + a fresh access token
|
||||||
|
* + the session metadata. Used on app boot when only the refresh
|
||||||
|
* token is available.
|
||||||
|
*
|
||||||
|
* The server route accepts both access and refresh tokens (the
|
||||||
|
* cold-start case sends a refresh token via a custom header — see
|
||||||
|
* ``requestWithToken``). If you already have an access token, just
|
||||||
|
* call ``me()`` instead.
|
||||||
|
*/
|
||||||
|
async whoami(refreshToken?: string): Promise<IWhoamiResponse> {
|
||||||
|
if (refreshToken) {
|
||||||
|
return this.requestWithToken<{ Authorization: string }, IWhoamiResponse>(
|
||||||
|
'/auth/whoami',
|
||||||
|
{ Authorization: `Bearer ${refreshToken}` },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return this.request<IWhoamiResponse>('/auth/whoami')
|
||||||
|
}
|
||||||
|
|
||||||
/** Revoke a refresh token (idempotent). */
|
/** Revoke a refresh token (idempotent). */
|
||||||
async logout(refreshToken: string): Promise<{ revoked: boolean }> {
|
async logout(refreshToken: string): Promise<{ revoked: boolean }> {
|
||||||
return this.request<{ revoked: boolean }>('/auth/logout', {
|
return this.request<{ revoked: boolean }>('/auth/logout', {
|
||||||
|
|
@ -60,6 +126,34 @@ class AuthApiClient extends BaseApiClient {
|
||||||
async me(): Promise<IAuthUser> {
|
async me(): Promise<IAuthUser> {
|
||||||
return this.request<IAuthUser>('/auth/me')
|
return this.request<IAuthUser>('/auth/me')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Session management (U4 / U8) ---
|
||||||
|
|
||||||
|
/** List the current user's active sessions. */
|
||||||
|
async listSessions(): Promise<ISessionInfo[]> {
|
||||||
|
return this.request<ISessionInfo[]>('/auth/sessions')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Revoke a single session (own only). */
|
||||||
|
async revokeSession(sid: string): Promise<{ revoked: boolean }> {
|
||||||
|
return this.request<{ revoked: boolean }>(`/auth/sessions/${encodeURIComponent(sid)}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Change the current user's password. Revokes all other sessions. */
|
||||||
|
async changePassword(oldPassword: string, newPassword: string): Promise<{ changed: boolean; revoked_other_sessions: number }> {
|
||||||
|
return this.request<{ changed: boolean; revoked_other_sessions: number }>(
|
||||||
|
'/auth/change-password',
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
old_password: oldPassword,
|
||||||
|
new_password: newPassword,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const authApi = new AuthApiClient()
|
export const authApi = new AuthApiClient()
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,16 @@ export interface IApiError {
|
||||||
detail?: string
|
detail?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Client version advertised in the ``X-Client-Version`` request header.
|
||||||
|
*
|
||||||
|
* The server-side rollout policy uses this to decide whether to issue
|
||||||
|
* legacy (no-sid) JWTs to old clients during the U10 migration window.
|
||||||
|
* Bump this when shipping breaking auth changes; the rollout doc
|
||||||
|
* explains the policy and the cutoff version.
|
||||||
|
*/
|
||||||
|
export const CLIENT_VERSION = '0.5.0'
|
||||||
|
|
||||||
let _dynamicBaseURL = ''
|
let _dynamicBaseURL = ''
|
||||||
|
|
||||||
/** Initialize the dynamic base URL for Tauri (sidecar backend). */
|
/** Initialize the dynamic base URL for Tauri (sidecar backend). */
|
||||||
|
|
@ -33,10 +43,12 @@ export function getDynamicBaseURL(): string {
|
||||||
type TokenProvider = () => string | null
|
type TokenProvider = () => string | null
|
||||||
type RefreshProvider = () => Promise<string | null>
|
type RefreshProvider = () => Promise<string | null>
|
||||||
type UnauthorizedHandler = () => void
|
type UnauthorizedHandler = () => void
|
||||||
|
type PreEmptiveRefreshProvider = () => boolean
|
||||||
|
|
||||||
let _tokenProvider: TokenProvider | null = null
|
let _tokenProvider: TokenProvider | null = null
|
||||||
let _refreshProvider: RefreshProvider | null = null
|
let _refreshProvider: RefreshProvider | null = null
|
||||||
let _unauthorizedHandler: UnauthorizedHandler | null = null
|
let _unauthorizedHandler: UnauthorizedHandler | null = null
|
||||||
|
let _preEmptiveRefreshProvider: PreEmptiveRefreshProvider | null = null
|
||||||
|
|
||||||
/** Register the access-token provider (called by the auth store on init). */
|
/** Register the access-token provider (called by the auth store on init). */
|
||||||
export function setTokenProvider(provider: TokenProvider): void {
|
export function setTokenProvider(provider: TokenProvider): void {
|
||||||
|
|
@ -53,6 +65,16 @@ export function setUnauthorizedHandler(handler: UnauthorizedHandler): void {
|
||||||
_unauthorizedHandler = handler
|
_unauthorizedHandler = handler
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a hook that returns true when the auth store considers the
|
||||||
|
* access token too close to expiry. When true, the next outgoing
|
||||||
|
* request will be preceded by a silent refresh — the user never sees
|
||||||
|
* a 401 caused by their own (legitimate) token expiring.
|
||||||
|
*/
|
||||||
|
export function setPreEmptiveRefreshProvider(provider: PreEmptiveRefreshProvider): void {
|
||||||
|
_preEmptiveRefreshProvider = provider
|
||||||
|
}
|
||||||
|
|
||||||
export class BaseApiClient {
|
export class BaseApiClient {
|
||||||
protected baseUrl: string
|
protected baseUrl: string
|
||||||
|
|
||||||
|
|
@ -88,16 +110,67 @@ export class BaseApiClient {
|
||||||
if (token) {
|
if (token) {
|
||||||
headers['Authorization'] = `Bearer ${token}`
|
headers['Authorization'] = `Bearer ${token}`
|
||||||
}
|
}
|
||||||
|
// U10 backwards-compat: tell the server what client version we are.
|
||||||
|
// The server uses this to decide whether to issue legacy (no-sid)
|
||||||
|
// tokens for old clients during the migration window. Once the
|
||||||
|
// cutoff version is removed server-side, this header becomes a
|
||||||
|
// no-op. See docs/migrations/2026-06-20-client-version-rollout.md
|
||||||
|
headers['X-Client-Version'] = CLIENT_VERSION
|
||||||
return headers
|
return headers
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a request with a custom ``Authorization`` header that
|
||||||
|
* overrides the in-memory access token. Used by the cold-start
|
||||||
|
* ``/auth/whoami`` call where only the refresh token is available
|
||||||
|
* and the auth store's access token is empty.
|
||||||
|
*/
|
||||||
|
protected async requestWithToken<H extends Record<string, string>, T>(
|
||||||
|
path: string,
|
||||||
|
headers: H,
|
||||||
|
options: RequestInit = {},
|
||||||
|
): Promise<T> {
|
||||||
|
const response = await this._sendWithHeaders(path, options, headers)
|
||||||
|
if (!response.ok) {
|
||||||
|
throw await this._buildError(response)
|
||||||
|
}
|
||||||
|
const text = await response.text()
|
||||||
|
if (!text) {
|
||||||
|
return null as unknown as T
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return JSON.parse(text) as T
|
||||||
|
} catch {
|
||||||
|
return text as unknown as T
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Send a request, retrying once with a refreshed token on 401.
|
* Send a request, retrying once with a refreshed token on 401.
|
||||||
*
|
*
|
||||||
* If the refresh fails, the unauthorized handler is invoked (typically
|
* Before each request we also call ``silentRefresh()`` when
|
||||||
* redirecting to /login) and the original 401 error is re-thrown.
|
* ``auth.shouldRefresh`` is true (pre-emptive refresh) so that a
|
||||||
|
* soon-to-expire access token never causes a 401 in the first place.
|
||||||
|
*
|
||||||
|
* If the refresh fails, the unauthorized handler is invoked
|
||||||
|
* (typically redirecting to /login) and the original 401 error is
|
||||||
|
* re-thrown.
|
||||||
*/
|
*/
|
||||||
protected async request<T>(path: string, options: RequestInit = {}): Promise<T> {
|
protected async request<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||||
|
// Pre-emptive refresh: if the access token is about to expire
|
||||||
|
// (within the headroom window), refresh BEFORE sending the request.
|
||||||
|
// This is the only place that triggers the pre-emptive refresh
|
||||||
|
// path; the 401-retry path below handles the case where the token
|
||||||
|
// expires between calls.
|
||||||
|
if (this._shouldPreemptivelyRefresh()) {
|
||||||
|
try {
|
||||||
|
await this._silentRefreshForPreEmption()
|
||||||
|
} catch {
|
||||||
|
/* silent refresh failed — fall through; the 401 retry path
|
||||||
|
below will produce a 401 and trigger the unauthorized handler */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const response = await this._send(path, options)
|
const response = await this._send(path, options)
|
||||||
|
|
||||||
if (response.status === 401 && _refreshProvider) {
|
if (response.status === 401 && _refreshProvider) {
|
||||||
|
|
@ -108,7 +181,15 @@ export class BaseApiClient {
|
||||||
if (!retried.ok) {
|
if (!retried.ok) {
|
||||||
throw await this._buildError(retried)
|
throw await this._buildError(retried)
|
||||||
}
|
}
|
||||||
return (retried.json() as Promise<T>) ?? null
|
const text = await retried.text()
|
||||||
|
if (!text) {
|
||||||
|
return null as unknown as T
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return JSON.parse(text) as T
|
||||||
|
} catch {
|
||||||
|
return text as unknown as T
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// Refresh failed — notify the app to redirect to login
|
// Refresh failed — notify the app to redirect to login
|
||||||
_unauthorizedHandler?.()
|
_unauthorizedHandler?.()
|
||||||
|
|
@ -130,6 +211,22 @@ export class BaseApiClient {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Whether the current access token should be refreshed before the next request. */
|
||||||
|
private _shouldPreemptivelyRefresh(): boolean {
|
||||||
|
const provider = _preEmptiveRefreshProvider
|
||||||
|
if (!provider) return false
|
||||||
|
try {
|
||||||
|
return provider()
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _silentRefreshForPreEmption(): Promise<void> {
|
||||||
|
if (!_refreshProvider) return
|
||||||
|
await _refreshProvider()
|
||||||
|
}
|
||||||
|
|
||||||
/** Low-level fetch with headers + URL resolved. */
|
/** Low-level fetch with headers + URL resolved. */
|
||||||
private async _send(path: string, options: RequestInit): Promise<Response> {
|
private async _send(path: string, options: RequestInit): Promise<Response> {
|
||||||
const effectiveUrl = this._resolveUrl(path)
|
const effectiveUrl = this._resolveUrl(path)
|
||||||
|
|
@ -137,6 +234,23 @@ export class BaseApiClient {
|
||||||
return fetch(effectiveUrl, { ...options, headers })
|
return fetch(effectiveUrl, { ...options, headers })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Low-level fetch with caller-supplied headers (overrides the default Authorization). */
|
||||||
|
private async _sendWithHeaders(
|
||||||
|
path: string,
|
||||||
|
options: RequestInit,
|
||||||
|
headers: Record<string, string>,
|
||||||
|
): Promise<Response> {
|
||||||
|
const effectiveUrl = this._resolveUrl(path)
|
||||||
|
const merged: Record<string, string> = {
|
||||||
|
...(options.headers as Record<string, string> | undefined),
|
||||||
|
...headers,
|
||||||
|
}
|
||||||
|
if (!(options.body instanceof FormData)) {
|
||||||
|
merged['Content-Type'] = 'application/json'
|
||||||
|
}
|
||||||
|
return fetch(effectiveUrl, { ...options, headers: merged })
|
||||||
|
}
|
||||||
|
|
||||||
private async _buildError(response: Response): Promise<IApiError> {
|
private async _buildError(response: Response): Promise<IApiError> {
|
||||||
const error: IApiError = {
|
const error: IApiError = {
|
||||||
status: response.status,
|
status: response.status,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,133 @@
|
||||||
|
<template>
|
||||||
|
<div class="user-sessions-panel">
|
||||||
|
<div class="user-sessions-panel__header">
|
||||||
|
<a-space :size="8" align="center" wrap>
|
||||||
|
<strong>用户:</strong>
|
||||||
|
<span class="user-sessions-panel__user-id">{{ userId }}</span>
|
||||||
|
<a-switch
|
||||||
|
v-model:checked="includeRevoked"
|
||||||
|
checked-children="含已撤销"
|
||||||
|
un-checked-children="仅活跃"
|
||||||
|
size="small"
|
||||||
|
@change="loadSessions"
|
||||||
|
/>
|
||||||
|
<a-button size="small" @click="loadSessions">刷新</a-button>
|
||||||
|
</a-space>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ActiveSessionsPanel
|
||||||
|
:sessions="sessions"
|
||||||
|
:loading="loading"
|
||||||
|
:revoking-id="revokingId"
|
||||||
|
:admin-mode="true"
|
||||||
|
@refresh="loadSessions"
|
||||||
|
@revoke="handleRevoke"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* Admin: see and revoke any user's active sessions (U9).
|
||||||
|
*
|
||||||
|
* Wraps the shared :class:`ActiveSessionsPanel` in ``adminMode`` so the
|
||||||
|
* same table layout is reused (no copy-paste of the table, formatTime,
|
||||||
|
* revoked-strikethrough, etc.). The panel itself is data-agnostic —
|
||||||
|
* we hand it the sessions array and listen for the ``revoke`` event.
|
||||||
|
*
|
||||||
|
* The header (above the table) carries the "include revoked" toggle so
|
||||||
|
* admins can audit past kicked sessions.
|
||||||
|
*/
|
||||||
|
import { ref, onMounted, watch } from 'vue'
|
||||||
|
import { message } from 'ant-design-vue'
|
||||||
|
import ActiveSessionsPanel from '@/components/settings/ActiveSessionsPanel.vue'
|
||||||
|
import { adminApi } from '@/api/admin'
|
||||||
|
import type { ISessionInfo } from '@/api/auth'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
/** The user whose sessions to manage. */
|
||||||
|
userId: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const sessions = ref<ISessionInfo[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const revokingId = ref<string | null>(null)
|
||||||
|
const includeRevoked = ref(false)
|
||||||
|
|
||||||
|
async function loadSessions(): Promise<void> {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
sessions.value = await adminApi.listUserSessions(
|
||||||
|
props.userId,
|
||||||
|
includeRevoked.value,
|
||||||
|
)
|
||||||
|
} catch (err) {
|
||||||
|
const msg = _extractMessage(err, '加载会话列表失败')
|
||||||
|
message.error(msg)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRevoke(sid: string): Promise<void> {
|
||||||
|
revokingId.value = sid
|
||||||
|
try {
|
||||||
|
const result = await adminApi.revokeUserSession(props.userId, sid)
|
||||||
|
if (result.revoked) {
|
||||||
|
message.success('已撤销该会话')
|
||||||
|
} else {
|
||||||
|
message.info('该会话已是撤销状态')
|
||||||
|
}
|
||||||
|
// Optimistic UI: drop the row, then re-fetch (cheap; list is small).
|
||||||
|
sessions.value = sessions.value.filter((s) => s.id !== sid)
|
||||||
|
await loadSessions()
|
||||||
|
} catch (err) {
|
||||||
|
const msg = _extractMessage(err, '撤销会话失败')
|
||||||
|
message.error(msg)
|
||||||
|
} finally {
|
||||||
|
revokingId.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _extractMessage(err: unknown, fallback: string): string {
|
||||||
|
if (err && typeof err === 'object') {
|
||||||
|
const obj = err as { detail?: unknown; message?: unknown }
|
||||||
|
if (typeof obj.detail === 'string' && obj.detail) return obj.detail
|
||||||
|
if (typeof obj.message === 'string' && obj.message) return obj.message
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadSessions()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Reload when the user id prop changes (e.g. admin switches between
|
||||||
|
// users in a parent UsersView).
|
||||||
|
watch(
|
||||||
|
() => props.userId,
|
||||||
|
() => {
|
||||||
|
loadSessions()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.user-sessions-panel {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-sessions-panel__header {
|
||||||
|
margin-bottom: var(--space-3);
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-sessions-panel__user-id {
|
||||||
|
font-family: var(--font-mono, monospace);
|
||||||
|
font-size: var(--font-sm);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -0,0 +1,363 @@
|
||||||
|
<template>
|
||||||
|
<div class="active-sessions-panel">
|
||||||
|
<a-alert
|
||||||
|
v-if="error"
|
||||||
|
:message="error"
|
||||||
|
type="error"
|
||||||
|
show-icon
|
||||||
|
closable
|
||||||
|
class="active-sessions-panel__alert"
|
||||||
|
@close="error = ''"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<a-spin :spinning="effectiveLoading">
|
||||||
|
<a-table
|
||||||
|
:columns="columns"
|
||||||
|
:data-source="displaySessions"
|
||||||
|
:row-key="(record: ISessionInfo) => record.id"
|
||||||
|
:pagination="false"
|
||||||
|
size="middle"
|
||||||
|
:row-class-name="rowClass"
|
||||||
|
>
|
||||||
|
<template #bodyCell="{ column, record }: { column: TableColumnType<ISessionInfo>; record: ISessionInfo }">
|
||||||
|
<template v-if="column.key === 'device'">
|
||||||
|
<div class="active-sessions-panel__device">
|
||||||
|
<strong>{{ record.device_label || 'Unknown device' }}</strong>
|
||||||
|
<a-tag v-if="record.is_current" color="blue" class="active-sessions-panel__tag">
|
||||||
|
当前会话
|
||||||
|
</a-tag>
|
||||||
|
<a-tag v-if="adminMode && record.revoked" color="red" class="active-sessions-panel__tag">
|
||||||
|
已撤销
|
||||||
|
</a-tag>
|
||||||
|
<a-tag color="default" class="active-sessions-panel__tag">
|
||||||
|
{{ record.auth_provider }}
|
||||||
|
</a-tag>
|
||||||
|
</div>
|
||||||
|
<div class="active-sessions-panel__meta">
|
||||||
|
{{ record.ip || 'unknown IP' }}
|
||||||
|
</div>
|
||||||
|
<div v-if="adminMode && record.revoked_reason" class="active-sessions-panel__meta">
|
||||||
|
撤销原因: {{ record.revoked_reason }}
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="column.key === 'created_at'">
|
||||||
|
{{ formatTime(record.created_at) }}
|
||||||
|
</template>
|
||||||
|
<template v-else-if="column.key === 'last_active_at'">
|
||||||
|
{{ formatTime(record.last_active_at) }}
|
||||||
|
</template>
|
||||||
|
<template v-else-if="column.key === 'expires_at'">
|
||||||
|
{{ formatTime(record.expires_at) }}
|
||||||
|
</template>
|
||||||
|
<template v-else-if="column.key === 'actions'">
|
||||||
|
<a-popconfirm
|
||||||
|
v-if="canRevoke(record)"
|
||||||
|
title="确认要撤销这个会话吗?该设备将立即登出。"
|
||||||
|
ok-text="撤销"
|
||||||
|
cancel-text="取消"
|
||||||
|
@confirm="handleRevokeClick(record.id)"
|
||||||
|
>
|
||||||
|
<a-button type="link" danger size="small" :loading="revokingId === record.id">
|
||||||
|
撤销
|
||||||
|
</a-button>
|
||||||
|
</a-popconfirm>
|
||||||
|
<span v-else class="active-sessions-panel__current-label">—</span>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
<template #emptyText>
|
||||||
|
<div v-if="!effectiveLoading" class="active-sessions-panel__empty">没有活跃会话</div>
|
||||||
|
</template>
|
||||||
|
</a-table>
|
||||||
|
</a-spin>
|
||||||
|
|
||||||
|
<div v-if="!adminMode" class="active-sessions-panel__actions">
|
||||||
|
<a-button
|
||||||
|
type="default"
|
||||||
|
:loading="revokingAll"
|
||||||
|
:disabled="displaySessions.filter((s) => !s.is_current && !s.revoked).length === 0"
|
||||||
|
@click="handleRevokeAllOthers"
|
||||||
|
>
|
||||||
|
撤销其他所有设备
|
||||||
|
</a-button>
|
||||||
|
<a-button type="link" @click="handleRefreshClick">刷新</a-button>
|
||||||
|
</div>
|
||||||
|
<div v-else class="active-sessions-panel__actions">
|
||||||
|
<a-button type="link" @click="handleRefreshClick">刷新</a-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
import { message } from 'ant-design-vue'
|
||||||
|
import type { TableColumnType } from 'ant-design-vue'
|
||||||
|
import { useAuthStore, type ISessionInfo } from '@/stores/auth'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The panel can be rendered in two modes:
|
||||||
|
*
|
||||||
|
* - default (self): lists the *current user's* sessions via
|
||||||
|
* ``/auth/sessions``. The panel auto-fetches on mount and after
|
||||||
|
* revoke events. Revoke is only available for non-current sessions
|
||||||
|
* of the current user. The "revoke all others" button is shown.
|
||||||
|
*
|
||||||
|
* - adminMode: lists *any user's* sessions. The ``sessions`` prop MUST
|
||||||
|
* be supplied by the caller (it knows the userId); the panel does
|
||||||
|
* not fetch on its own. Revoke is available for any non-revoked
|
||||||
|
* session, including the current one. The "revoke all others"
|
||||||
|
* button is hidden (admins operate one session at a time).
|
||||||
|
*
|
||||||
|
* The component remains a single source of truth for the table
|
||||||
|
* layout; the consumer (self or admin) decides what to pass as props
|
||||||
|
* and how to fetch the data.
|
||||||
|
*/
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
/**
|
||||||
|
* Session rows to render. In ``adminMode`` this is required —
|
||||||
|
* the caller passes the list it loaded for the target user.
|
||||||
|
* In self mode, the prop is optional (the panel auto-fetches
|
||||||
|
* its own data via ``/auth/sessions`` when the prop is omitted).
|
||||||
|
*/
|
||||||
|
sessions?: ISessionInfo[]
|
||||||
|
loading?: boolean
|
||||||
|
revokingId?: string | null
|
||||||
|
/**
|
||||||
|
* When true, the panel renders the admin variant. Callers must
|
||||||
|
* wire up their own revoke handler (see the ``revoke`` event)
|
||||||
|
* because the panel does not know how to call admin endpoints.
|
||||||
|
*/
|
||||||
|
adminMode?: boolean
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
sessions: () => [] as ISessionInfo[],
|
||||||
|
loading: false,
|
||||||
|
revokingId: null,
|
||||||
|
adminMode: false,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'refresh'): void
|
||||||
|
(e: 'revoke', sid: string): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
const error = ref('')
|
||||||
|
const revokingAll = ref(false)
|
||||||
|
const _selfSessions = ref<ISessionInfo[]>([])
|
||||||
|
const _selfLoading = ref(false)
|
||||||
|
|
||||||
|
/** The list the table renders — admin uses props, self uses our own ref. */
|
||||||
|
const displaySessions = computed<ISessionInfo[]>(() =>
|
||||||
|
props.adminMode ? props.sessions : _selfSessions.value,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Loading flag — admin uses props.loading, self uses our own ref. */
|
||||||
|
const effectiveLoading = computed<boolean>(() =>
|
||||||
|
props.adminMode ? props.loading : _selfLoading.value,
|
||||||
|
)
|
||||||
|
|
||||||
|
const columns: TableColumnType<ISessionInfo>[] = [
|
||||||
|
{
|
||||||
|
title: '设备',
|
||||||
|
key: 'device',
|
||||||
|
width: 320,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '创建时间',
|
||||||
|
key: 'created_at',
|
||||||
|
width: 180,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '最近活动',
|
||||||
|
key: 'last_active_at',
|
||||||
|
width: 180,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '过期时间',
|
||||||
|
key: 'expires_at',
|
||||||
|
width: 180,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
key: 'actions',
|
||||||
|
width: 100,
|
||||||
|
align: 'right',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
function formatTime(iso: string): string {
|
||||||
|
if (!iso) return '—'
|
||||||
|
try {
|
||||||
|
const d = new Date(iso)
|
||||||
|
return d.toLocaleString('zh-CN', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
return iso
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether the revoke button should be shown for a given row. */
|
||||||
|
function canRevoke(record: ISessionInfo): boolean {
|
||||||
|
// Always hide revoke for already-revoked sessions (the row is for
|
||||||
|
// audit; revoking again is a no-op).
|
||||||
|
if (record.revoked) return false
|
||||||
|
if (props.adminMode) {
|
||||||
|
// In admin mode, every active session is revocable.
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
// In self mode, the current session cannot self-revoke (use Logout).
|
||||||
|
return !record.is_current
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Apply a strikethrough class to revoked rows. */
|
||||||
|
function rowClass(record: ISessionInfo): string {
|
||||||
|
if (record.revoked) return 'active-sessions-panel__row active-sessions-panel__row--revoked'
|
||||||
|
return 'active-sessions-panel__row'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Self-mode auto-fetch. */
|
||||||
|
async function _loadSelfSessions(): Promise<void> {
|
||||||
|
if (props.adminMode) return
|
||||||
|
_selfLoading.value = true
|
||||||
|
try {
|
||||||
|
_selfSessions.value = await authStore.listSessions()
|
||||||
|
} catch (err) {
|
||||||
|
error.value = _extractMessage(err, '加载会话列表失败')
|
||||||
|
} finally {
|
||||||
|
_selfLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Unified click handler for the per-row revoke button. */
|
||||||
|
function handleRevokeClick(sid: string): void {
|
||||||
|
if (props.adminMode) {
|
||||||
|
// In admin mode the caller wires the actual API call.
|
||||||
|
emit('revoke', sid)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Self mode: revoke via the auth store, then refresh.
|
||||||
|
void _selfRevokeOne(sid)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _selfRevokeOne(sid: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
await authStore.revokeSession(sid)
|
||||||
|
message.success('已撤销该会话')
|
||||||
|
await _loadSelfSessions()
|
||||||
|
} catch (err) {
|
||||||
|
message.error(_extractMessage(err, '撤销会话失败'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRefreshClick(): void {
|
||||||
|
if (props.adminMode) {
|
||||||
|
emit('refresh')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
void _loadSelfSessions()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRevokeAllOthers(): Promise<void> {
|
||||||
|
if (props.adminMode) return
|
||||||
|
revokingAll.value = true
|
||||||
|
try {
|
||||||
|
// Revoke each non-current session sequentially. This is
|
||||||
|
// intentionally NOT a single bulk endpoint — the server's
|
||||||
|
// /auth/sessions/{sid} is the only per-session revoke surface
|
||||||
|
// available, and we want atomic per-session feedback.
|
||||||
|
const others = displaySessions.value.filter((s) => !s.is_current && !s.revoked)
|
||||||
|
for (const s of others) {
|
||||||
|
try {
|
||||||
|
await authStore.revokeSession(s.id)
|
||||||
|
} catch (err) {
|
||||||
|
// Continue revoking the rest even if one fails.
|
||||||
|
console.error(`Failed to revoke session ${s.id}`, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await _loadSelfSessions()
|
||||||
|
message.success(`已撤销 ${others.length} 个其他设备`)
|
||||||
|
} finally {
|
||||||
|
revokingAll.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _extractMessage(err: unknown, fallback: string): string {
|
||||||
|
if (err && typeof err === 'object') {
|
||||||
|
const obj = err as { message?: unknown; detail?: unknown }
|
||||||
|
if (typeof obj.detail === 'string') return obj.detail
|
||||||
|
if (typeof obj.message === 'string') return obj.message
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (!props.adminMode) {
|
||||||
|
void _loadSelfSessions()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
defineExpose({ error })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.active-sessions-panel {
|
||||||
|
max-width: 900px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.active-sessions-panel__alert {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.active-sessions-panel__device {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.active-sessions-panel__tag {
|
||||||
|
margin-left: 0;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.active-sessions-panel__meta {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.active-sessions-panel__current-label {
|
||||||
|
color: var(--text-quaternary);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.active-sessions-panel__empty {
|
||||||
|
padding: 32px 0;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.active-sessions-panel__actions {
|
||||||
|
margin-top: 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* When row is the current session, dim the row visually */
|
||||||
|
:deep(.active-sessions-panel__row td) {
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.active-sessions-panel__row--revoked td) {
|
||||||
|
text-decoration: line-through;
|
||||||
|
color: var(--text-quaternary);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -0,0 +1,159 @@
|
||||||
|
<template>
|
||||||
|
<div class="change-password-panel">
|
||||||
|
<a-alert
|
||||||
|
v-if="success"
|
||||||
|
message="密码修改成功,其他设备将自动登出。"
|
||||||
|
type="success"
|
||||||
|
show-icon
|
||||||
|
closable
|
||||||
|
class="change-password-panel__alert"
|
||||||
|
@close="success = false"
|
||||||
|
/>
|
||||||
|
<a-alert
|
||||||
|
v-if="error"
|
||||||
|
:message="error"
|
||||||
|
type="error"
|
||||||
|
show-icon
|
||||||
|
closable
|
||||||
|
class="change-password-panel__alert"
|
||||||
|
@close="error = ''"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<a-form
|
||||||
|
layout="vertical"
|
||||||
|
:model="form"
|
||||||
|
:rules="rules"
|
||||||
|
ref="formRef"
|
||||||
|
class="change-password-panel__form"
|
||||||
|
@finish="handleSubmit"
|
||||||
|
>
|
||||||
|
<a-form-item label="当前密码" name="oldPassword" :rules="rules.oldPassword">
|
||||||
|
<a-input-password
|
||||||
|
v-model:value="form.oldPassword"
|
||||||
|
size="large"
|
||||||
|
placeholder="请输入当前密码"
|
||||||
|
:disabled="loading"
|
||||||
|
/>
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label="新密码" name="newPassword" :rules="rules.newPassword">
|
||||||
|
<a-input-password
|
||||||
|
v-model:value="form.newPassword"
|
||||||
|
size="large"
|
||||||
|
placeholder="至少 8 个字符"
|
||||||
|
:disabled="loading"
|
||||||
|
/>
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label="确认新密码" name="confirmPassword" :rules="rules.confirmPassword">
|
||||||
|
<a-input-password
|
||||||
|
v-model:value="form.confirmPassword"
|
||||||
|
size="large"
|
||||||
|
placeholder="再次输入新密码"
|
||||||
|
:disabled="loading"
|
||||||
|
/>
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item>
|
||||||
|
<a-button
|
||||||
|
type="primary"
|
||||||
|
html-type="submit"
|
||||||
|
size="large"
|
||||||
|
:loading="loading"
|
||||||
|
class="change-password-panel__submit"
|
||||||
|
>
|
||||||
|
修改密码
|
||||||
|
</a-button>
|
||||||
|
</a-form-item>
|
||||||
|
</a-form>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, reactive } from 'vue'
|
||||||
|
import { message } from 'ant-design-vue'
|
||||||
|
import type { Rule } from 'ant-design-vue/es/form'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
|
||||||
|
const formRef = ref()
|
||||||
|
const loading = ref(false)
|
||||||
|
const error = ref('')
|
||||||
|
const success = ref(false)
|
||||||
|
|
||||||
|
const form = reactive({
|
||||||
|
oldPassword: '',
|
||||||
|
newPassword: '',
|
||||||
|
confirmPassword: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
/** Cross-field validator: confirmPassword must equal newPassword. */
|
||||||
|
const confirmPasswordValidator = async (_rule: Rule, value: string): Promise<void> => {
|
||||||
|
if (value !== form.newPassword) {
|
||||||
|
throw new Error('两次输入的密码不一致')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const rules: Record<string, Rule[]> = {
|
||||||
|
oldPassword: [
|
||||||
|
{ required: true, message: '请输入当前密码' },
|
||||||
|
],
|
||||||
|
newPassword: [
|
||||||
|
{ required: true, message: '请输入新密码' },
|
||||||
|
{ min: 8, message: '新密码至少需要 8 个字符' },
|
||||||
|
],
|
||||||
|
confirmPassword: [
|
||||||
|
{ required: true, message: '请再次输入新密码' },
|
||||||
|
{ validator: confirmPasswordValidator, trigger: 'blur' },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetForm(): void {
|
||||||
|
form.oldPassword = ''
|
||||||
|
form.newPassword = ''
|
||||||
|
form.confirmPassword = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit(): Promise<void> {
|
||||||
|
error.value = ''
|
||||||
|
success.value = false
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const result: { revoked_other_sessions?: number } = await authStore.changePassword(
|
||||||
|
form.oldPassword,
|
||||||
|
form.newPassword,
|
||||||
|
)
|
||||||
|
success.value = true
|
||||||
|
resetForm()
|
||||||
|
if (typeof result?.revoked_other_sessions === 'number' && result.revoked_other_sessions > 0) {
|
||||||
|
message.success(`密码已修改,${result.revoked_other_sessions} 个其他设备的会话已撤销`)
|
||||||
|
} else {
|
||||||
|
message.success('密码已修改')
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
const msg =
|
||||||
|
err && typeof err === 'object'
|
||||||
|
? (err as { detail?: unknown; message?: unknown }).detail ??
|
||||||
|
(err as { message?: unknown }).message
|
||||||
|
: null
|
||||||
|
error.value = typeof msg === 'string' && msg ? msg : '密码修改失败'
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.change-password-panel {
|
||||||
|
max-width: 480px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.change-password-panel__alert {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.change-password-panel__form {
|
||||||
|
padding-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.change-password-panel__submit {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
@ -155,13 +155,23 @@ const router = createRouter({
|
||||||
* Global route guard.
|
* Global route guard.
|
||||||
*
|
*
|
||||||
* - Public routes (meta.public === true) are always allowed.
|
* - Public routes (meta.public === true) are always allowed.
|
||||||
* - Non-public routes require an authenticated user; unauthenticated users
|
* - Non-public routes require an authenticated user; unauthenticated
|
||||||
* are redirected to /login with a ``redirect`` query param preserving
|
* users are redirected to /login with a ``redirect`` query param
|
||||||
* the original target.
|
* preserving the original target.
|
||||||
*
|
*
|
||||||
* Note: the guard reads from the auth store, which hydrates from
|
* Note: the cold-start auth rehydration happens in App.vue's
|
||||||
* localStorage on construction — so a page reload with a valid token
|
* ``onMounted`` (calls ``authStore.startupCheck()``) BEFORE this guard
|
||||||
* does not force a re-login.
|
* runs, so by the time the guard executes the store's
|
||||||
|
* ``isAuthenticated`` is in its true post-startup state.
|
||||||
|
*
|
||||||
|
* The 3-state startup distinguishes:
|
||||||
|
* - 'valid' → authenticated, allow
|
||||||
|
* - 'invalid' → not authenticated, redirect to /login
|
||||||
|
* - 'error' → server unreachable, redirect to /login with a
|
||||||
|
* "正在重试" hint
|
||||||
|
* - 'pending' → still resolving; allow the navigation (the store
|
||||||
|
* hasn't blocked it yet) so the user doesn't see a
|
||||||
|
* flash of /login
|
||||||
*/
|
*/
|
||||||
router.beforeEach((to, _from, next) => {
|
router.beforeEach((to, _from, next) => {
|
||||||
const title = to.meta.title as string | undefined
|
const title = to.meta.title as string | undefined
|
||||||
|
|
|
||||||
|
|
@ -1,32 +1,37 @@
|
||||||
/**
|
/**
|
||||||
* Auth store — manages JWT, current user, and permission checks.
|
* Auth store — manages JWT, current user, and permission checks.
|
||||||
*
|
*
|
||||||
* Responsibilities:
|
* U7 (Centralized Auth & Token Persistence):
|
||||||
* - Persist access/refresh tokens in localStorage (browser) so they survive
|
* - 3-state startup: `valid` / `invalid` / `error` / `pending`
|
||||||
* page reloads. In Tauri mode, localStorage is still available.
|
* - Access token in memory only (NEVER written to localStorage)
|
||||||
* - Expose ``getAccessToken()`` for the API client to attach as a Bearer
|
* - Refresh token persisted via tauriAuthStorage (Tauri Keychain or
|
||||||
* header, and ``getRefreshToken()`` for the refresh-on-401 flow.
|
* Web localStorage fallback)
|
||||||
* - Provide ``hasPermission()`` / ``canUseTerminal()`` helpers used by route
|
* - Pre-emptive refresh: when access expires in <2 min, refresh BEFORE
|
||||||
* guards and component visibility flags.
|
* the next request fires (no 401 storms)
|
||||||
*
|
*
|
||||||
* Token lifecycle:
|
* Token lifecycle:
|
||||||
* - ``login()`` stores both tokens + user.
|
* - ``login()`` calls /auth/login, stores both tokens.
|
||||||
* - On 401 from the API, ``BaseApiClient`` calls ``refreshIfPossible()``;
|
* - ``startupCheck()`` runs on app boot — calls /auth/whoami with the
|
||||||
* if refresh succeeds, the original request is retried; if refresh fails,
|
* persisted refresh token to validate it and rehydrate the user.
|
||||||
* ``logoutLocal()`` clears state and the router redirects to /login.
|
* - On 401 from the API, ``BaseApiClient`` calls ``silentRefresh()``;
|
||||||
* - ``logout()`` calls the server to revoke the refresh token, then clears
|
* if refresh succeeds, the original request is retried; if refresh
|
||||||
* local state regardless of the server response.
|
* fails, the store transitions to `invalid` and the router redirects
|
||||||
|
* to /login.
|
||||||
|
* - ``logout()`` calls the server to revoke the refresh token, then
|
||||||
|
* clears local state regardless of the server response.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import { authApi, type IAuthUser, type ITokenPair } from '@/api/auth'
|
import { authApi, type IAuthUser, type ITokenPair, type ISessionInfo } from '@/api/auth'
|
||||||
|
import { tauriAuthStorage } from '@/api/tauri-auth'
|
||||||
|
|
||||||
const ACCESS_TOKEN_KEY = 'agentkit.access_token'
|
|
||||||
const REFRESH_TOKEN_KEY = 'agentkit.refresh_token'
|
|
||||||
const USER_KEY = 'agentkit.user'
|
const USER_KEY = 'agentkit.user'
|
||||||
|
|
||||||
/** Permission bits — must mirror server-side ``Permission`` enum (U5). */
|
/** 3-state startup (F11) + a `pending` initial value. */
|
||||||
|
export type AuthStartupState = 'valid' | 'invalid' | 'error' | 'pending'
|
||||||
|
|
||||||
|
/** Permission bits — must mirror server-side ``Permission`` enum. */
|
||||||
export type Permission =
|
export type Permission =
|
||||||
| 'CHAT'
|
| 'CHAT'
|
||||||
| 'KB_QUERY'
|
| 'KB_QUERY'
|
||||||
|
|
@ -62,6 +67,9 @@ const ROLE_PERMISSIONS: Record<string, Permission[]> = {
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Pre-emptive refresh threshold — refresh when access expires within this window. */
|
||||||
|
const REFRESH_HEADROOM_SECONDS = 120
|
||||||
|
|
||||||
function readStored(key: string): string | null {
|
function readStored(key: string): string | null {
|
||||||
try {
|
try {
|
||||||
return localStorage.getItem(key)
|
return localStorage.getItem(key)
|
||||||
|
|
@ -96,24 +104,59 @@ function readStoredUser(): IAuthUser | null {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decode the ``exp`` claim from a JWT without verifying the signature.
|
||||||
|
*
|
||||||
|
* The signature is verified by the server; this is only used to compute
|
||||||
|
* the remaining lifetime client-side for the pre-emptive refresh
|
||||||
|
* trigger. Tampered tokens will fail server-side validation; reading
|
||||||
|
* ``exp`` from an unverified token is safe in this context (no
|
||||||
|
* authorization decision is made based on the result).
|
||||||
|
*/
|
||||||
|
function decodeJwtExp(token: string | null): number | null {
|
||||||
|
if (!token) return null
|
||||||
|
const parts = token.split('.')
|
||||||
|
if (parts.length !== 3) return null
|
||||||
|
try {
|
||||||
|
const payload = parts[1]
|
||||||
|
if (!payload) return null
|
||||||
|
const padded = payload.replace(/-/g, '+').replace(/_/g, '/')
|
||||||
|
const decoded = atob(padded)
|
||||||
|
const obj = JSON.parse(decoded) as { exp?: unknown }
|
||||||
|
return typeof obj.exp === 'number' ? obj.exp : null
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const useAuthStore = defineStore('auth', () => {
|
export const useAuthStore = defineStore('auth', () => {
|
||||||
// --- State ---
|
// --- State ---
|
||||||
const accessToken = ref<string | null>(readStored(ACCESS_TOKEN_KEY))
|
|
||||||
const refreshToken = ref<string | null>(readStored(REFRESH_TOKEN_KEY))
|
|
||||||
const user = ref<IAuthUser | null>(readStoredUser())
|
|
||||||
/** True while a login or refresh request is in-flight. */
|
|
||||||
const isLoading = ref(false)
|
|
||||||
/** Last error message from login/refresh (cleared on success). */
|
|
||||||
const error = ref<string | null>(null)
|
|
||||||
/**
|
/**
|
||||||
* Set to true when a refresh attempt fails — prevents infinite retry
|
* Access token. Memory only — never written to localStorage or
|
||||||
* loops where every concurrent 401 triggers another refresh.
|
* Keychain. On app restart the store re-acquires one via
|
||||||
|
* ``startupCheck()`` → ``/auth/whoami``.
|
||||||
*/
|
*/
|
||||||
let _refreshFailed = false
|
const accessToken = ref<string | null>(null)
|
||||||
|
const user = ref<IAuthUser | null>(readStoredUser())
|
||||||
|
const startupState = ref<AuthStartupState>('pending')
|
||||||
|
const isLoading = ref(false)
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
/** Current session id (sid) — set on login / whoami. */
|
||||||
|
const sessionId = ref<string | null>(null)
|
||||||
|
|
||||||
// --- Getters ---
|
// --- Getters ---
|
||||||
const isAuthenticated = computed(() => !!accessToken.value && !!user.value)
|
const isAuthenticated = computed(
|
||||||
|
() => startupState.value === 'valid' && !!accessToken.value && !!user.value,
|
||||||
|
)
|
||||||
const role = computed<string | null>(() => user.value?.role ?? null)
|
const role = computed<string | null>(() => user.value?.role ?? null)
|
||||||
|
const accessTokenExp = computed<number | null>(() => decodeJwtExp(accessToken.value))
|
||||||
|
/** True when the access token will expire within ``REFRESH_HEADROOM_SECONDS``. */
|
||||||
|
const shouldRefresh = computed<boolean>(() => {
|
||||||
|
const exp = accessTokenExp.value
|
||||||
|
if (!exp) return false
|
||||||
|
const remainingMs = exp * 1000 - Date.now()
|
||||||
|
return remainingMs < REFRESH_HEADROOM_SECONDS * 1000 && remainingMs > -30_000
|
||||||
|
})
|
||||||
|
|
||||||
/** Permissions for the current role (empty when not authenticated). */
|
/** Permissions for the current role (empty when not authenticated). */
|
||||||
const permissions = computed<Permission[]>(() => {
|
const permissions = computed<Permission[]>(() => {
|
||||||
|
|
@ -122,33 +165,55 @@ export const useAuthStore = defineStore('auth', () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
// --- Mutators ---
|
// --- Mutators ---
|
||||||
function _persist(tokens: ITokenPair): void {
|
/**
|
||||||
accessToken.value = tokens.access_token
|
* Persist the access token in memory (NOT localStorage) and the user
|
||||||
refreshToken.value = tokens.refresh_token
|
* in localStorage (safe — no secret). Refresh token is written to
|
||||||
user.value = tokens.user
|
* Keychain (Tauri) / localStorage fallback by the caller via
|
||||||
writeStored(ACCESS_TOKEN_KEY, tokens.access_token)
|
* tauriAuthStorage.
|
||||||
writeStored(REFRESH_TOKEN_KEY, tokens.refresh_token)
|
*/
|
||||||
writeStored(USER_KEY, JSON.stringify(tokens.user))
|
function _setAccess(token: string, currentUser: IAuthUser, sid: string | null = null): void {
|
||||||
_refreshFailed = false
|
accessToken.value = token
|
||||||
|
user.value = currentUser
|
||||||
|
sessionId.value = sid
|
||||||
|
writeStored(USER_KEY, JSON.stringify(currentUser))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Persist a fresh token pair from /auth/login or /auth/refresh. */
|
||||||
|
async function _persistTokenPair(pair: ITokenPair, sid: string | null = null): Promise<void> {
|
||||||
|
_setAccess(pair.access_token, pair.user, sid)
|
||||||
|
await tauriAuthStorage.setRefreshToken(pair.refresh_token)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clear the in-memory access token. Does NOT touch the refresh token. */
|
||||||
function _clear(): void {
|
function _clear(): void {
|
||||||
accessToken.value = null
|
accessToken.value = null
|
||||||
refreshToken.value = null
|
sessionId.value = null
|
||||||
user.value = null
|
// user is intentionally retained so the UI can show the last-known
|
||||||
removeStored(ACCESS_TOKEN_KEY)
|
// avatar/role after a forced logout. Use ``logout()`` to fully
|
||||||
removeStored(REFRESH_TOKEN_KEY)
|
// clear.
|
||||||
removeStored(USER_KEY)
|
|
||||||
_refreshFailed = false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Actions ---
|
// --- Actions ---
|
||||||
async function login(username: string, password: string): Promise<void> {
|
/**
|
||||||
|
* Authenticate with username + password.
|
||||||
|
*
|
||||||
|
* @param rememberMe When true, the server issues a 30-day refresh
|
||||||
|
* token (vs default 7 days). See /auth/login's ``remember_me`` flag.
|
||||||
|
*/
|
||||||
|
async function login(
|
||||||
|
username: string,
|
||||||
|
password: string,
|
||||||
|
rememberMe: boolean = false,
|
||||||
|
): Promise<void> {
|
||||||
isLoading.value = true
|
isLoading.value = true
|
||||||
error.value = null
|
error.value = null
|
||||||
try {
|
try {
|
||||||
const tokens = await authApi.login(username, password)
|
const pair = await authApi.login(username, password, rememberMe)
|
||||||
_persist(tokens)
|
// The server's /auth/login response carries session_id (V2)
|
||||||
|
// or no session id (legacy). Either is fine.
|
||||||
|
const sid = (pair as ITokenPair & { session_id?: string }).session_id ?? null
|
||||||
|
await _persistTokenPair(pair, sid)
|
||||||
|
startupState.value = 'valid'
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const msg = _extractErrorMessage(err, '登录失败')
|
const msg = _extractErrorMessage(err, '登录失败')
|
||||||
error.value = msg
|
error.value = msg
|
||||||
|
|
@ -159,49 +224,144 @@ export const useAuthStore = defineStore('auth', () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Attempt to refresh the access token using the stored refresh token.
|
* Cold-start validation: check the persisted refresh token against
|
||||||
* Returns the new access token on success, or ``null`` if no refresh
|
* the server and rehydrate the user / access token.
|
||||||
* token is available or a previous refresh already failed this session.
|
|
||||||
*
|
*
|
||||||
* The API client calls this from its 401 handler. Concurrent callers
|
* Transitions:
|
||||||
* will all see the same outcome because ``_refreshFailed`` is sticky
|
* - no refresh token → 'invalid' (first launch, no login)
|
||||||
* until the next successful login.
|
* - whoami 200 → 'valid'
|
||||||
|
* - whoami 401 → 'invalid' + clear stored refresh token
|
||||||
|
* - whoami network → 'error' (retryable, refresh token kept)
|
||||||
*/
|
*/
|
||||||
async function refreshIfPossible(): Promise<string | null> {
|
async function startupCheck(): Promise<AuthStartupState> {
|
||||||
if (_refreshFailed) return null
|
startupState.value = 'pending'
|
||||||
if (!refreshToken.value) return null
|
const refresh = await tauriAuthStorage.getRefreshToken()
|
||||||
|
if (!refresh) {
|
||||||
|
startupState.value = 'invalid'
|
||||||
|
return startupState.value
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const tokens = await authApi.refresh(refreshToken.value)
|
const result = await authApi.whoami(refresh)
|
||||||
_persist(tokens)
|
// whoami returns { user, access_token, session } when called
|
||||||
return tokens.access_token
|
// with a refresh token.
|
||||||
|
const sid = result.session?.id ?? null
|
||||||
|
_setAccess(result.access_token, result.user, sid)
|
||||||
|
startupState.value = 'valid'
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
_refreshFailed = true
|
const status = _extractStatus(err)
|
||||||
error.value = _extractErrorMessage(err, '会话已过期,请重新登录')
|
if (status === 401 || status === 403 || status === 404) {
|
||||||
|
await tauriAuthStorage.clearRefreshToken()
|
||||||
|
_clear()
|
||||||
|
startupState.value = 'invalid'
|
||||||
|
} else {
|
||||||
|
// Network error or 5xx — keep the refresh token so the user
|
||||||
|
// can retry. This is the "error" state the UI should surface
|
||||||
|
// as "正在重试" rather than "请重新登录".
|
||||||
|
startupState.value = 'error'
|
||||||
|
error.value = _extractErrorMessage(err, '无法连接服务器')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return startupState.value
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exchange the persisted refresh token for a new token pair.
|
||||||
|
*
|
||||||
|
* On 401 the refresh token is treated as revoked (reuse detected
|
||||||
|
* or all-sessions-killed) and the store transitions to 'invalid'.
|
||||||
|
* Concurrent callers will all await the same in-flight promise so
|
||||||
|
* we don't fire multiple refreshes when many 401s land at once.
|
||||||
|
*/
|
||||||
|
let _refreshInFlight: Promise<ITokenPair | null> | null = null
|
||||||
|
async function silentRefresh(): Promise<ITokenPair | null> {
|
||||||
|
if (_refreshInFlight) return _refreshInFlight
|
||||||
|
_refreshInFlight = _doSilentRefresh()
|
||||||
|
try {
|
||||||
|
return await _refreshInFlight
|
||||||
|
} finally {
|
||||||
|
_refreshInFlight = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _doSilentRefresh(): Promise<ITokenPair | null> {
|
||||||
|
const refresh = await tauriAuthStorage.getRefreshToken()
|
||||||
|
if (!refresh) {
|
||||||
_clear()
|
_clear()
|
||||||
|
startupState.value = 'invalid'
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const pair = await authApi.refresh(refresh)
|
||||||
|
const sid = (pair as ITokenPair & { session_id?: string }).session_id ?? null
|
||||||
|
await _persistTokenPair(pair, sid)
|
||||||
|
startupState.value = 'valid'
|
||||||
|
return pair
|
||||||
|
} catch (err) {
|
||||||
|
const status = _extractStatus(err)
|
||||||
|
if (status === 401) {
|
||||||
|
// Reuse detected / all sessions revoked / token expired.
|
||||||
|
await tauriAuthStorage.clearRefreshToken()
|
||||||
|
_clear()
|
||||||
|
startupState.value = 'invalid'
|
||||||
|
error.value = _extractErrorMessage(err, '会话已过期,请重新登录')
|
||||||
|
} else {
|
||||||
|
// Transient — keep the refresh token, leave state as-is.
|
||||||
|
error.value = _extractErrorMessage(err, '刷新失败')
|
||||||
|
}
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Server-side logout: revoke the refresh token, then clear local state.
|
* Server-side logout: revoke the refresh token, then clear local state.
|
||||||
* Safe to call even if the server is unreachable — local state is always
|
* Safe to call even if the server is unreachable — local state is
|
||||||
* cleared.
|
* always cleared.
|
||||||
*/
|
*/
|
||||||
async function logout(): Promise<void> {
|
async function logout(): Promise<void> {
|
||||||
const token = refreshToken.value
|
const refresh = await tauriAuthStorage.getRefreshToken()
|
||||||
if (token) {
|
if (refresh) {
|
||||||
try {
|
try {
|
||||||
await authApi.logout(token)
|
await authApi.logout(refresh)
|
||||||
} catch {
|
} catch {
|
||||||
/* server may be unreachable; still clear local state */
|
/* server may be unreachable; still clear local state */
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
await tauriAuthStorage.clearRefreshToken()
|
||||||
_clear()
|
_clear()
|
||||||
|
user.value = null
|
||||||
|
removeStored(USER_KEY)
|
||||||
|
startupState.value = 'invalid'
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Clear local auth state without calling the server (used on 401 fallback). */
|
/** Clear local auth state without calling the server (used on 401 fallback). */
|
||||||
function logoutLocal(): void {
|
async function logoutLocal(): Promise<void> {
|
||||||
|
await tauriAuthStorage.clearRefreshToken()
|
||||||
_clear()
|
_clear()
|
||||||
|
user.value = null
|
||||||
|
removeStored(USER_KEY)
|
||||||
|
startupState.value = 'invalid'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Force re-evaluation of the startup state (e.g. after a manual retry). */
|
||||||
|
async function retryStartup(): Promise<AuthStartupState> {
|
||||||
|
return await startupCheck()
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Session management (U4/U8 surface) ---
|
||||||
|
|
||||||
|
async function listSessions(): Promise<ISessionInfo[]> {
|
||||||
|
return await authApi.listSessions()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function revokeSession(sid: string): Promise<void> {
|
||||||
|
await authApi.revokeSession(sid)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function changePassword(
|
||||||
|
oldPassword: string,
|
||||||
|
newPassword: string,
|
||||||
|
): Promise<{ changed: boolean; revoked_other_sessions: number }> {
|
||||||
|
return await authApi.changePassword(oldPassword, newPassword)
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Permission helpers ---
|
// --- Permission helpers ---
|
||||||
|
|
@ -227,19 +387,27 @@ export const useAuthStore = defineStore('auth', () => {
|
||||||
return {
|
return {
|
||||||
// state
|
// state
|
||||||
accessToken,
|
accessToken,
|
||||||
refreshToken,
|
|
||||||
user,
|
user,
|
||||||
isLoading,
|
isLoading,
|
||||||
error,
|
error,
|
||||||
|
sessionId,
|
||||||
|
startupState,
|
||||||
// getters
|
// getters
|
||||||
isAuthenticated,
|
isAuthenticated,
|
||||||
role,
|
role,
|
||||||
permissions,
|
permissions,
|
||||||
|
accessTokenExp,
|
||||||
|
shouldRefresh,
|
||||||
// actions
|
// actions
|
||||||
login,
|
login,
|
||||||
refreshIfPossible,
|
|
||||||
logout,
|
logout,
|
||||||
logoutLocal,
|
logoutLocal,
|
||||||
|
silentRefresh,
|
||||||
|
startupCheck,
|
||||||
|
retryStartup,
|
||||||
|
listSessions,
|
||||||
|
revokeSession,
|
||||||
|
changePassword,
|
||||||
// permission helpers
|
// permission helpers
|
||||||
hasPermission,
|
hasPermission,
|
||||||
canUseLocalTerminal,
|
canUseLocalTerminal,
|
||||||
|
|
@ -249,9 +417,21 @@ export const useAuthStore = defineStore('auth', () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
function _extractErrorMessage(err: unknown, fallback: string): string {
|
function _extractErrorMessage(err: unknown, fallback: string): string {
|
||||||
if (err && typeof err === 'object' && 'message' in err) {
|
if (err && typeof err === 'object') {
|
||||||
const msg = (err as { message?: unknown }).message
|
const obj = err as { message?: unknown; detail?: unknown }
|
||||||
if (typeof msg === 'string' && msg) return msg
|
if (typeof obj.message === 'string' && obj.message) return obj.message
|
||||||
|
if (typeof obj.detail === 'string' && obj.detail) return obj.detail
|
||||||
}
|
}
|
||||||
return fallback
|
return fallback
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function _extractStatus(err: unknown): number | null {
|
||||||
|
if (err && typeof err === 'object' && 'status' in err) {
|
||||||
|
const s = (err as { status?: unknown }).status
|
||||||
|
if (typeof s === 'number') return s
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-export the session info type so callers can ``import { ISessionInfo } from '@/stores/auth'``
|
||||||
|
export type { ISessionInfo }
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,12 @@
|
||||||
@close="authStore.error = null"
|
@close="authStore.error = null"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<a-form-item name="rememberMe" class="login-remember">
|
||||||
|
<a-checkbox v-model:checked="form.rememberMe">
|
||||||
|
记住我(30 天内免登录)
|
||||||
|
</a-checkbox>
|
||||||
|
</a-form-item>
|
||||||
|
|
||||||
<a-button
|
<a-button
|
||||||
type="primary"
|
type="primary"
|
||||||
html-type="submit"
|
html-type="submit"
|
||||||
|
|
@ -93,6 +99,7 @@ const authStore = useAuthStore()
|
||||||
const form = reactive({
|
const form = reactive({
|
||||||
username: '',
|
username: '',
|
||||||
password: '',
|
password: '',
|
||||||
|
rememberMe: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
/** Redirect target after successful login (defaults to /agent). */
|
/** Redirect target after successful login (defaults to /agent). */
|
||||||
|
|
@ -106,16 +113,23 @@ const redirectTarget = (): string => {
|
||||||
async function handleSubmit(): Promise<void> {
|
async function handleSubmit(): Promise<void> {
|
||||||
if (!form.username || !form.password) return
|
if (!form.username || !form.password) return
|
||||||
try {
|
try {
|
||||||
await authStore.login(form.username, form.password)
|
await authStore.login(form.username, form.password, form.rememberMe)
|
||||||
router.replace(redirectTarget())
|
router.replace(redirectTarget())
|
||||||
} catch {
|
} catch {
|
||||||
/* error already in authStore.error */
|
/* error already in authStore.error */
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(async () => {
|
||||||
// If already authenticated (e.g. page reload with valid token), skip login
|
// If a refresh token is already stored, run the cold-start probe
|
||||||
if (authStore.isAuthenticated) {
|
// so the user is taken straight to the main app (F1) without
|
||||||
|
// seeing the login page.
|
||||||
|
if (authStore.startupState === 'pending') {
|
||||||
|
await authStore.startupCheck()
|
||||||
|
if (authStore.isAuthenticated) {
|
||||||
|
router.replace(redirectTarget())
|
||||||
|
}
|
||||||
|
} else if (authStore.isAuthenticated) {
|
||||||
router.replace(redirectTarget())
|
router.replace(redirectTarget())
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
@ -167,6 +181,10 @@ onMounted(() => {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.login-remember {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
.login-footer {
|
.login-footer {
|
||||||
margin-top: 24px;
|
margin-top: 24px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
|
|
||||||
|
|
@ -121,6 +121,17 @@
|
||||||
<a-button type="primary" :loading="settingsStore.isSaving" @click="handleSave">保存系统配置</a-button>
|
<a-button type="primary" :loading="settingsStore.isSaving" @click="handleSave">保存系统配置</a-button>
|
||||||
</a-form>
|
</a-form>
|
||||||
</a-tab-pane>
|
</a-tab-pane>
|
||||||
|
|
||||||
|
<a-tab-pane key="security" tab="安全">
|
||||||
|
<a-tabs v-model:activeKey="securityTab" class="settings-form">
|
||||||
|
<a-tab-pane key="sessions" tab="活跃会话" force-render>
|
||||||
|
<ActiveSessionsPanel />
|
||||||
|
</a-tab-pane>
|
||||||
|
<a-tab-pane key="password" tab="修改密码" force-render>
|
||||||
|
<ChangePasswordPanel />
|
||||||
|
</a-tab-pane>
|
||||||
|
</a-tabs>
|
||||||
|
</a-tab-pane>
|
||||||
</a-tabs>
|
</a-tabs>
|
||||||
|
|
||||||
<div v-if="settingsStore.saveSuccess" class="settings-view__alert">
|
<div v-if="settingsStore.saveSuccess" class="settings-view__alert">
|
||||||
|
|
@ -136,9 +147,12 @@
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
import { message } from 'ant-design-vue'
|
import { message } from 'ant-design-vue'
|
||||||
import { useSettingsStore } from '@/stores/settings'
|
import { useSettingsStore } from '@/stores/settings'
|
||||||
|
import ActiveSessionsPanel from '@/components/settings/ActiveSessionsPanel.vue'
|
||||||
|
import ChangePasswordPanel from '@/components/settings/ChangePasswordPanel.vue'
|
||||||
|
|
||||||
const settingsStore = useSettingsStore()
|
const settingsStore = useSettingsStore()
|
||||||
const activeTab = ref('llm')
|
const activeTab = ref('llm')
|
||||||
|
const securityTab = ref('sessions')
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
settingsStore.fetchSettings()
|
settingsStore.fetchSettings()
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,404 @@
|
||||||
|
/**
|
||||||
|
* Unit tests for the auth store (U7 refactor).
|
||||||
|
*
|
||||||
|
* Covers:
|
||||||
|
* - 3-state startup (valid / invalid / error / pending)
|
||||||
|
* - Access token in memory only (never written to localStorage)
|
||||||
|
* - Refresh token via tauriAuthStorage (Tauri Keychain or localStorage)
|
||||||
|
* - Pre-emptive refresh trigger (`shouldRefresh`)
|
||||||
|
* - Silent refresh dedup (concurrent callers)
|
||||||
|
* - Logout / logoutLocal clear all sensitive state
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { setActivePinia, createPinia } from 'pinia'
|
||||||
|
|
||||||
|
// Mock the auth API client so we don't touch the network.
|
||||||
|
const mockAuthApi = {
|
||||||
|
login: vi.fn(),
|
||||||
|
refresh: vi.fn(),
|
||||||
|
logout: vi.fn(),
|
||||||
|
whoami: vi.fn(),
|
||||||
|
me: vi.fn(),
|
||||||
|
listSessions: vi.fn(),
|
||||||
|
revokeSession: vi.fn(),
|
||||||
|
changePassword: vi.fn(),
|
||||||
|
}
|
||||||
|
vi.mock('@/api/auth', () => ({
|
||||||
|
authApi: mockAuthApi,
|
||||||
|
AuthApiClient: vi.fn(),
|
||||||
|
// Re-export the types so the store's imports don't break
|
||||||
|
IAuthUser: {},
|
||||||
|
ITokenPair: {},
|
||||||
|
ISessionInfo: {},
|
||||||
|
IWhoamiResponse: {},
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Mock the tauri auth storage so we don't touch the Keychain.
|
||||||
|
const mockTauriStorage = {
|
||||||
|
setRefreshToken: vi.fn(),
|
||||||
|
getRefreshToken: vi.fn(),
|
||||||
|
clearRefreshToken: vi.fn(),
|
||||||
|
}
|
||||||
|
vi.mock('@/api/tauri-auth', () => ({
|
||||||
|
tauriAuthStorage: mockTauriStorage,
|
||||||
|
isTauri: () => false,
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Now import the SUT.
|
||||||
|
const { useAuthStore } = await import('@/stores/auth')
|
||||||
|
|
||||||
|
/** A small in-memory localStorage. */
|
||||||
|
const _store = new Map<string, string>()
|
||||||
|
const _localStoragePolyfill: Storage = {
|
||||||
|
get length() {
|
||||||
|
return _store.size
|
||||||
|
},
|
||||||
|
clear() {
|
||||||
|
_store.clear()
|
||||||
|
},
|
||||||
|
getItem(key: string) {
|
||||||
|
return _store.has(key) ? (_store.get(key) as string) : null
|
||||||
|
},
|
||||||
|
key(index: number) {
|
||||||
|
return Array.from(_store.keys())[index] ?? null
|
||||||
|
},
|
||||||
|
removeItem(key: string) {
|
||||||
|
_store.delete(key)
|
||||||
|
},
|
||||||
|
setItem(key: string, value: string) {
|
||||||
|
_store.set(key, String(value))
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const fakeUser = {
|
||||||
|
id: 'user-1',
|
||||||
|
username: 'alice',
|
||||||
|
email: 'alice@example.com',
|
||||||
|
role: 'admin',
|
||||||
|
is_active: true,
|
||||||
|
is_terminal_authorized: true,
|
||||||
|
is_server_terminal_authorized: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
const fakeTokenPair = {
|
||||||
|
access_token: 'access.aaa.bbb',
|
||||||
|
refresh_token: 'refresh.xxx.yyy',
|
||||||
|
token_type: 'bearer' as const,
|
||||||
|
expires_in: 900,
|
||||||
|
user: fakeUser,
|
||||||
|
session_id: 'sid-1',
|
||||||
|
}
|
||||||
|
|
||||||
|
const fakeWhoamiResponse = {
|
||||||
|
user: fakeUser,
|
||||||
|
access_token: 'access.aaa.bbb',
|
||||||
|
session: {
|
||||||
|
id: 'sid-1',
|
||||||
|
device_fingerprint: 'fp',
|
||||||
|
device_label: 'macOS — Chrome',
|
||||||
|
ip: '127.0.0.1',
|
||||||
|
user_agent: 'jest',
|
||||||
|
auth_provider: 'local',
|
||||||
|
created_at: '2026-06-20T00:00:00Z',
|
||||||
|
last_active_at: '2026-06-20T00:00:00Z',
|
||||||
|
expires_at: '2026-06-27T00:00:00Z',
|
||||||
|
is_current: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
;(globalThis as { localStorage?: Storage }).localStorage = _localStoragePolyfill
|
||||||
|
_store.clear()
|
||||||
|
setActivePinia(createPinia())
|
||||||
|
// Reset all mocks.
|
||||||
|
for (const fn of Object.values(mockAuthApi)) fn.mockReset()
|
||||||
|
for (const fn of Object.values(mockTauriStorage)) fn.mockReset()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
_store.clear()
|
||||||
|
vi.restoreAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('auth store — initial state', () => {
|
||||||
|
it('starts in "pending" state', () => {
|
||||||
|
const store = useAuthStore()
|
||||||
|
expect(store.startupState).toBe('pending')
|
||||||
|
expect(store.accessToken).toBeNull()
|
||||||
|
expect(store.isAuthenticated).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('auth store — login', () => {
|
||||||
|
it('calls authApi.login with rememberMe param and persists the token pair', async () => {
|
||||||
|
mockAuthApi.login.mockResolvedValueOnce(fakeTokenPair)
|
||||||
|
mockTauriStorage.setRefreshToken.mockResolvedValueOnce(undefined)
|
||||||
|
const store = useAuthStore()
|
||||||
|
|
||||||
|
await store.login('alice', 'pw', true)
|
||||||
|
|
||||||
|
expect(mockAuthApi.login).toHaveBeenCalledWith('alice', 'pw', true)
|
||||||
|
expect(mockTauriStorage.setRefreshToken).toHaveBeenCalledWith('refresh.xxx.yyy')
|
||||||
|
expect(store.accessToken).toBe('access.aaa.bbb')
|
||||||
|
expect(store.user).toEqual(fakeUser)
|
||||||
|
expect(store.sessionId).toBe('sid-1')
|
||||||
|
expect(store.startupState).toBe('valid')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does NOT write the access token to localStorage', async () => {
|
||||||
|
mockAuthApi.login.mockResolvedValueOnce(fakeTokenPair)
|
||||||
|
mockTauriStorage.setRefreshToken.mockResolvedValueOnce(undefined)
|
||||||
|
const store = useAuthStore()
|
||||||
|
|
||||||
|
await store.login('alice', 'pw')
|
||||||
|
|
||||||
|
const allKeys = Array.from(_store.keys())
|
||||||
|
expect(allKeys.some((k) => k.includes('access_token'))).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sets an error message on login failure', async () => {
|
||||||
|
mockAuthApi.login.mockRejectedValueOnce(new Error('Invalid credentials'))
|
||||||
|
const store = useAuthStore()
|
||||||
|
await expect(store.login('alice', 'pw')).rejects.toThrow()
|
||||||
|
expect(store.error).toBe('Invalid credentials')
|
||||||
|
expect(store.startupState).not.toBe('valid')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('auth store — startupCheck', () => {
|
||||||
|
it('transitions to "invalid" when no refresh token is stored', async () => {
|
||||||
|
mockTauriStorage.getRefreshToken.mockResolvedValueOnce(null)
|
||||||
|
const store = useAuthStore()
|
||||||
|
|
||||||
|
const state = await store.startupCheck()
|
||||||
|
|
||||||
|
expect(state).toBe('invalid')
|
||||||
|
expect(store.startupState).toBe('invalid')
|
||||||
|
expect(store.isAuthenticated).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('transitions to "valid" when whoami succeeds', async () => {
|
||||||
|
mockTauriStorage.getRefreshToken.mockResolvedValueOnce('refresh-token')
|
||||||
|
mockAuthApi.whoami.mockResolvedValueOnce(fakeWhoamiResponse)
|
||||||
|
const store = useAuthStore()
|
||||||
|
|
||||||
|
const state = await store.startupCheck()
|
||||||
|
|
||||||
|
expect(state).toBe('valid')
|
||||||
|
expect(store.accessToken).toBe('access.aaa.bbb')
|
||||||
|
expect(store.user).toEqual(fakeUser)
|
||||||
|
expect(store.isAuthenticated).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('transitions to "invalid" and clears the refresh token on 401', async () => {
|
||||||
|
mockTauriStorage.getRefreshToken.mockResolvedValueOnce('refresh-token')
|
||||||
|
mockTauriStorage.clearRefreshToken.mockResolvedValueOnce(undefined)
|
||||||
|
const authError: Error & { status?: number } = new Error('Unauthorized')
|
||||||
|
authError.status = 401
|
||||||
|
mockAuthApi.whoami.mockRejectedValueOnce(authError)
|
||||||
|
const store = useAuthStore()
|
||||||
|
|
||||||
|
const state = await store.startupCheck()
|
||||||
|
|
||||||
|
expect(state).toBe('invalid')
|
||||||
|
expect(mockTauriStorage.clearRefreshToken).toHaveBeenCalled()
|
||||||
|
expect(store.startupState).toBe('invalid')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('transitions to "error" on network failure and keeps the refresh token', async () => {
|
||||||
|
mockTauriStorage.getRefreshToken.mockResolvedValueOnce('refresh-token')
|
||||||
|
mockAuthApi.whoami.mockRejectedValueOnce(new Error('Network unreachable'))
|
||||||
|
const store = useAuthStore()
|
||||||
|
|
||||||
|
const state = await store.startupCheck()
|
||||||
|
|
||||||
|
expect(state).toBe('error')
|
||||||
|
expect(store.startupState).toBe('error')
|
||||||
|
// Refresh token retained so the user can retry.
|
||||||
|
expect(mockTauriStorage.clearRefreshToken).not.toHaveBeenCalled()
|
||||||
|
expect(store.error).toBe('Network unreachable')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('auth store — silentRefresh', () => {
|
||||||
|
it('returns the new pair on success', async () => {
|
||||||
|
mockTauriStorage.getRefreshToken.mockResolvedValueOnce('refresh-token')
|
||||||
|
mockAuthApi.refresh.mockResolvedValueOnce(fakeTokenPair)
|
||||||
|
mockTauriStorage.setRefreshToken.mockResolvedValueOnce(undefined)
|
||||||
|
const store = useAuthStore()
|
||||||
|
|
||||||
|
const result = await store.silentRefresh()
|
||||||
|
|
||||||
|
expect(result).toEqual(fakeTokenPair)
|
||||||
|
expect(store.accessToken).toBe('access.aaa.bbb')
|
||||||
|
expect(store.startupState).toBe('valid')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('clears the refresh token on 401 and transitions to invalid', async () => {
|
||||||
|
mockTauriStorage.getRefreshToken.mockResolvedValueOnce('refresh-token')
|
||||||
|
mockTauriStorage.clearRefreshToken.mockResolvedValueOnce(undefined)
|
||||||
|
const authError: Error & { status?: number } = new Error('token_reuse_detected')
|
||||||
|
authError.status = 401
|
||||||
|
mockAuthApi.refresh.mockRejectedValueOnce(authError)
|
||||||
|
const store = useAuthStore()
|
||||||
|
|
||||||
|
const result = await store.silentRefresh()
|
||||||
|
|
||||||
|
expect(result).toBeNull()
|
||||||
|
expect(mockTauriStorage.clearRefreshToken).toHaveBeenCalled()
|
||||||
|
expect(store.startupState).toBe('invalid')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('deduplicates concurrent refresh calls', async () => {
|
||||||
|
let resolveRefresh: (v: typeof fakeTokenPair) => void = () => undefined
|
||||||
|
mockTauriStorage.getRefreshToken.mockResolvedValue('refresh-token')
|
||||||
|
mockAuthApi.refresh.mockReturnValueOnce(
|
||||||
|
new Promise((resolve) => {
|
||||||
|
resolveRefresh = resolve
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
mockTauriStorage.setRefreshToken.mockResolvedValue(undefined)
|
||||||
|
const store = useAuthStore()
|
||||||
|
|
||||||
|
// Fire two concurrent refreshes BEFORE the first one resolves.
|
||||||
|
const p1 = store.silentRefresh()
|
||||||
|
const p2 = store.silentRefresh()
|
||||||
|
|
||||||
|
// Resolve the underlying mock with the fake pair.
|
||||||
|
resolveRefresh(fakeTokenPair)
|
||||||
|
|
||||||
|
const [r1, r2] = await Promise.all([p1, p2])
|
||||||
|
expect(r1).toEqual(fakeTokenPair)
|
||||||
|
expect(r2).toEqual(fakeTokenPair)
|
||||||
|
// The underlying API must only have been called ONCE.
|
||||||
|
expect(mockAuthApi.refresh).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('auth store — shouldRefresh (pre-emptive refresh trigger)', () => {
|
||||||
|
it('returns false when no access token', () => {
|
||||||
|
const store = useAuthStore()
|
||||||
|
expect(store.shouldRefresh).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns false when access token is far from expiry', async () => {
|
||||||
|
// Build a JWT with exp = now + 10 min.
|
||||||
|
const exp = Math.floor(Date.now() / 1000) + 600
|
||||||
|
const header = btoa(JSON.stringify({ alg: 'HS256', typ: 'JWT' }))
|
||||||
|
const payload = btoa(JSON.stringify({ exp }))
|
||||||
|
const token = `${header}.${payload}.signature`
|
||||||
|
mockAuthApi.login.mockResolvedValueOnce({
|
||||||
|
...fakeTokenPair,
|
||||||
|
access_token: token,
|
||||||
|
})
|
||||||
|
mockTauriStorage.setRefreshToken.mockResolvedValueOnce(undefined)
|
||||||
|
const store = useAuthStore()
|
||||||
|
await store.login('alice', 'pw')
|
||||||
|
|
||||||
|
expect(store.shouldRefresh).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns true when access token is within 2 minutes of expiry', async () => {
|
||||||
|
const exp = Math.floor(Date.now() / 1000) + 60
|
||||||
|
const header = btoa(JSON.stringify({ alg: 'HS256', typ: 'JWT' }))
|
||||||
|
const payload = btoa(JSON.stringify({ exp }))
|
||||||
|
const token = `${header}.${payload}.signature`
|
||||||
|
mockAuthApi.login.mockResolvedValueOnce({
|
||||||
|
...fakeTokenPair,
|
||||||
|
access_token: token,
|
||||||
|
})
|
||||||
|
mockTauriStorage.setRefreshToken.mockResolvedValueOnce(undefined)
|
||||||
|
const store = useAuthStore()
|
||||||
|
await store.login('alice', 'pw')
|
||||||
|
|
||||||
|
expect(store.shouldRefresh).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('auth store — logout', () => {
|
||||||
|
it('calls authApi.logout, clears storage, and transitions to invalid', async () => {
|
||||||
|
mockTauriStorage.getRefreshToken.mockResolvedValueOnce('refresh-token')
|
||||||
|
mockAuthApi.logout.mockResolvedValueOnce({ revoked: true })
|
||||||
|
mockTauriStorage.clearRefreshToken.mockResolvedValueOnce(undefined)
|
||||||
|
const store = useAuthStore()
|
||||||
|
store.user = fakeUser
|
||||||
|
|
||||||
|
await store.logout()
|
||||||
|
|
||||||
|
expect(mockAuthApi.logout).toHaveBeenCalledWith('refresh-token')
|
||||||
|
expect(mockTauriStorage.clearRefreshToken).toHaveBeenCalled()
|
||||||
|
expect(store.accessToken).toBeNull()
|
||||||
|
expect(store.user).toBeNull()
|
||||||
|
expect(store.startupState).toBe('invalid')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('clears local state even if the server is unreachable', async () => {
|
||||||
|
mockTauriStorage.getRefreshToken.mockResolvedValueOnce('refresh-token')
|
||||||
|
mockAuthApi.logout.mockRejectedValueOnce(new Error('Network unreachable'))
|
||||||
|
mockTauriStorage.clearRefreshToken.mockResolvedValueOnce(undefined)
|
||||||
|
const store = useAuthStore()
|
||||||
|
store.user = fakeUser
|
||||||
|
|
||||||
|
await store.logout()
|
||||||
|
|
||||||
|
expect(mockTauriStorage.clearRefreshToken).toHaveBeenCalled()
|
||||||
|
expect(store.accessToken).toBeNull()
|
||||||
|
expect(store.user).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('auth store — session management', () => {
|
||||||
|
it('listSessions delegates to authApi.listSessions', async () => {
|
||||||
|
const sessions = [fakeWhoamiResponse.session]
|
||||||
|
mockAuthApi.listSessions.mockResolvedValueOnce(sessions)
|
||||||
|
const store = useAuthStore()
|
||||||
|
|
||||||
|
const result = await store.listSessions()
|
||||||
|
|
||||||
|
expect(result).toBe(sessions)
|
||||||
|
expect(mockAuthApi.listSessions).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('revokeSession delegates to authApi.revokeSession', async () => {
|
||||||
|
mockAuthApi.revokeSession.mockResolvedValueOnce({ revoked: true })
|
||||||
|
const store = useAuthStore()
|
||||||
|
|
||||||
|
await store.revokeSession('sid-1')
|
||||||
|
|
||||||
|
expect(mockAuthApi.revokeSession).toHaveBeenCalledWith('sid-1')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('changePassword delegates to authApi.changePassword', async () => {
|
||||||
|
mockAuthApi.changePassword.mockResolvedValueOnce({
|
||||||
|
changed: true,
|
||||||
|
revoked_other_sessions: 0,
|
||||||
|
})
|
||||||
|
const store = useAuthStore()
|
||||||
|
|
||||||
|
await store.changePassword('old', 'new')
|
||||||
|
|
||||||
|
expect(mockAuthApi.changePassword).toHaveBeenCalledWith('old', 'new')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('auth store — permission helpers', () => {
|
||||||
|
it('admin role can do everything', () => {
|
||||||
|
const store = useAuthStore()
|
||||||
|
store.user = fakeUser // role = admin
|
||||||
|
expect(store.isAdmin()).toBe(true)
|
||||||
|
expect(store.canUseLocalTerminal()).toBe(true)
|
||||||
|
expect(store.canUseServerTerminal()).toBe(true)
|
||||||
|
expect(store.hasPermission('USER_MANAGE')).toBe(true)
|
||||||
|
expect(store.hasPermission('SYSTEM_CONFIG')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('member role has restricted permissions', () => {
|
||||||
|
const store = useAuthStore()
|
||||||
|
store.user = { ...fakeUser, role: 'member' }
|
||||||
|
expect(store.isAdmin()).toBe(false)
|
||||||
|
expect(store.hasPermission('CHAT')).toBe(true)
|
||||||
|
expect(store.hasPermission('KB_QUERY')).toBe(true)
|
||||||
|
expect(store.hasPermission('USER_MANAGE')).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -1,11 +1,18 @@
|
||||||
"""Authentication REST routes.
|
"""Authentication REST routes (U4 — Centralized Auth & Token Persistence).
|
||||||
|
|
||||||
Endpoints
|
Endpoints
|
||||||
---------
|
---------
|
||||||
- ``POST /auth/login`` — username + password → access + refresh JWT pair
|
- ``POST /auth/login`` — username + password (+ remember_me) → JWT pair + session
|
||||||
- ``POST /auth/refresh`` — refresh token → new access token
|
- ``POST /auth/refresh`` — refresh token → new access token (rotated session)
|
||||||
- ``POST /auth/logout`` — revoke refresh token
|
- ``POST /auth/logout`` — revoke current session
|
||||||
- ``GET /auth/me`` — current user info (requires auth)
|
- ``GET /auth/whoami`` — return the current user (used for cold-start)
|
||||||
|
- ``GET /auth/sessions`` — list the current user's active sessions
|
||||||
|
- ``DELETE /auth/sessions/{id}`` — revoke a specific session (own only)
|
||||||
|
- ``POST /auth/change-password`` — change password + revoke all sessions
|
||||||
|
- ``GET /admin/sessions`` — list all sessions (admin)
|
||||||
|
- ``DELETE /admin/sessions/{id}`` — revoke a session as admin
|
||||||
|
- ``GET /admin/users/{user_id}/sessions`` — list a specific user's sessions (admin)
|
||||||
|
- ``DELETE /admin/users/{user_id}/sessions/{session_id}`` — revoke a specific user's session (admin)
|
||||||
|
|
||||||
The auth DB (SQLite via aiosqlite) and JWT secret are resolved from
|
The auth DB (SQLite via aiosqlite) and JWT secret are resolved from
|
||||||
``app.state`` if set by the app factory, otherwise from the defaults in
|
``app.state`` if set by the app factory, otherwise from the defaults in
|
||||||
|
|
@ -14,9 +21,7 @@ The auth DB (SQLite via aiosqlite) and JWT secret are resolved from
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import hashlib
|
|
||||||
import logging
|
import logging
|
||||||
import uuid
|
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
@ -28,24 +33,36 @@ from pydantic import BaseModel, ConfigDict, EmailStr
|
||||||
from agentkit.server.auth.dependencies import require_authenticated
|
from agentkit.server.auth.dependencies import require_authenticated
|
||||||
from agentkit.server.auth.jwt_utils import (
|
from agentkit.server.auth.jwt_utils import (
|
||||||
ACCESS_TOKEN_TTL,
|
ACCESS_TOKEN_TTL,
|
||||||
|
REFRESH_TOKEN_TTL,
|
||||||
|
REFRESH_TOKEN_TTL_REMEMBER_ME,
|
||||||
create_token_pair,
|
create_token_pair,
|
||||||
get_or_create_jwt_secret,
|
get_or_create_jwt_secret,
|
||||||
verify_token,
|
verify_token,
|
||||||
)
|
)
|
||||||
from agentkit.server.auth.models import DEFAULT_AUTH_DB_PATH, init_auth_db
|
from agentkit.server.auth.models import (
|
||||||
from agentkit.server.auth.password import verify_password
|
auth_session_row_to_dict,
|
||||||
|
DEFAULT_AUTH_DB_PATH,
|
||||||
|
init_auth_db,
|
||||||
|
)
|
||||||
|
from agentkit.server.auth.password import hash_password, verify_password
|
||||||
|
from agentkit.server.auth.permissions import Permission, has_permission
|
||||||
|
from agentkit.server.auth.session_service import (
|
||||||
|
REVOKE_REASON_PASSWORD_CHANGED,
|
||||||
|
REVOKE_REASON_USER_TERMINATED,
|
||||||
|
SessionCreate,
|
||||||
|
SessionService,
|
||||||
|
get_session_service,
|
||||||
|
)
|
||||||
|
from agentkit.server.auth.denylist import hash_token
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||||
|
|
||||||
# Pre-computed valid bcrypt hash format for timing-attack mitigation.
|
# Paths under /auth that the AuthMiddleware whitelists. The middleware
|
||||||
# This is a valid $2b$12$ hash (correct salt + hash length, valid base64
|
# already knows about /auth/login, /auth/refresh, /auth/logout; the new
|
||||||
# alphabet) so bcrypt.checkpw runs the full computation (~250ms) instead
|
# /auth/whoami requires a valid token so it's NOT whitelisted.
|
||||||
# of raising ValueError immediately. The hash itself is meaningless —
|
_AUTH_PUBLIC_PATHS = ("/auth/login", "/auth/refresh")
|
||||||
# it will return False for any password — but the timing matches a real
|
|
||||||
# password verification, preventing username enumeration via timing.
|
|
||||||
_DUMMY_BCRYPT_HASH = "$2b$12$abcdefghijklmnopqrstuuABCDEFGHIJKLMNOPQRSTUVWXYZ0123"
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
@ -60,6 +77,7 @@ class LoginRequest(BaseModel):
|
||||||
|
|
||||||
username: str
|
username: str
|
||||||
password: str
|
password: str
|
||||||
|
remember_me: bool = False
|
||||||
|
|
||||||
|
|
||||||
class RefreshRequest(BaseModel):
|
class RefreshRequest(BaseModel):
|
||||||
|
|
@ -70,6 +88,23 @@ class RefreshRequest(BaseModel):
|
||||||
refresh_token: str
|
refresh_token: str
|
||||||
|
|
||||||
|
|
||||||
|
class LogoutRequest(BaseModel):
|
||||||
|
"""Logout payload — accepts the current refresh token to revoke."""
|
||||||
|
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
refresh_token: str
|
||||||
|
|
||||||
|
|
||||||
|
class ChangePasswordRequest(BaseModel):
|
||||||
|
"""Change-password payload — old + new password."""
|
||||||
|
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
old_password: str
|
||||||
|
new_password: str
|
||||||
|
|
||||||
|
|
||||||
class UserResponse(BaseModel):
|
class UserResponse(BaseModel):
|
||||||
"""Public user representation (no password hash)."""
|
"""Public user representation (no password hash)."""
|
||||||
|
|
||||||
|
|
@ -84,6 +119,26 @@ class UserResponse(BaseModel):
|
||||||
is_server_terminal_authorized: bool
|
is_server_terminal_authorized: bool
|
||||||
|
|
||||||
|
|
||||||
|
class SessionResponse(BaseModel):
|
||||||
|
"""Public projection of an ``auth_sessions`` row."""
|
||||||
|
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
id: str
|
||||||
|
device_fingerprint: str
|
||||||
|
device_label: str
|
||||||
|
ip: str
|
||||||
|
user_agent: str
|
||||||
|
auth_provider: str
|
||||||
|
created_at: str
|
||||||
|
last_active_at: str
|
||||||
|
expires_at: str
|
||||||
|
is_current: bool = False
|
||||||
|
revoked: bool = False
|
||||||
|
revoked_reason: str | None = None
|
||||||
|
user_id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class TokenResponse(BaseModel):
|
class TokenResponse(BaseModel):
|
||||||
"""JWT pair + user info returned by /auth/login and /auth/refresh."""
|
"""JWT pair + user info returned by /auth/login and /auth/refresh."""
|
||||||
|
|
||||||
|
|
@ -94,6 +149,7 @@ class TokenResponse(BaseModel):
|
||||||
token_type: str = "bearer"
|
token_type: str = "bearer"
|
||||||
expires_in: int = int(ACCESS_TOKEN_TTL.total_seconds())
|
expires_in: int = int(ACCESS_TOKEN_TTL.total_seconds())
|
||||||
user: UserResponse
|
user: UserResponse
|
||||||
|
session_id: str
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
@ -101,23 +157,16 @@ class TokenResponse(BaseModel):
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def _sha256(text: str) -> str:
|
def _now_iso() -> str:
|
||||||
"""SHA-256 hex digest of a string."""
|
return datetime.now(timezone.utc).isoformat()
|
||||||
return hashlib.sha256(text.encode("utf-8")).hexdigest()
|
|
||||||
|
|
||||||
|
|
||||||
def _resolve_db_path(request: Request) -> Path:
|
def _resolve_db_path(request: Request) -> Path:
|
||||||
"""Resolve the auth DB path from app.state or the default."""
|
|
||||||
path = getattr(request.app.state, "auth_db_path", None)
|
path = getattr(request.app.state, "auth_db_path", None)
|
||||||
return Path(path) if path else DEFAULT_AUTH_DB_PATH
|
return Path(path) if path else DEFAULT_AUTH_DB_PATH
|
||||||
|
|
||||||
|
|
||||||
def _resolve_jwt_secret(request: Request) -> str:
|
def _resolve_jwt_secret(request: Request) -> str:
|
||||||
"""Resolve the JWT secret from app.state or the env var.
|
|
||||||
|
|
||||||
Falls back to :func:`get_or_create_jwt_secret` (which generates an
|
|
||||||
ephemeral secret in dev mode) so token signing always works.
|
|
||||||
"""
|
|
||||||
secret = getattr(request.app.state, "jwt_secret", None)
|
secret = getattr(request.app.state, "jwt_secret", None)
|
||||||
if secret:
|
if secret:
|
||||||
return secret
|
return secret
|
||||||
|
|
@ -125,7 +174,6 @@ def _resolve_jwt_secret(request: Request) -> str:
|
||||||
|
|
||||||
|
|
||||||
async def _ensure_db(request: Request) -> Path:
|
async def _ensure_db(request: Request) -> Path:
|
||||||
"""Ensure the auth DB exists and return its path."""
|
|
||||||
db_path = _resolve_db_path(request)
|
db_path = _resolve_db_path(request)
|
||||||
if not db_path.exists():
|
if not db_path.exists():
|
||||||
await init_auth_db(db_path)
|
await init_auth_db(db_path)
|
||||||
|
|
@ -133,7 +181,6 @@ async def _ensure_db(request: Request) -> Path:
|
||||||
|
|
||||||
|
|
||||||
def _user_row_to_response(row: aiosqlite.Row) -> UserResponse:
|
def _user_row_to_response(row: aiosqlite.Row) -> UserResponse:
|
||||||
"""Convert a users row to a :class:`UserResponse`."""
|
|
||||||
return UserResponse(
|
return UserResponse(
|
||||||
id=row["id"],
|
id=row["id"],
|
||||||
username=row["username"],
|
username=row["username"],
|
||||||
|
|
@ -145,6 +192,121 @@ def _user_row_to_response(row: aiosqlite.Row) -> UserResponse:
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _refresh_ttl_seconds(remember_me: bool) -> int:
|
||||||
|
return (
|
||||||
|
int(REFRESH_TOKEN_TTL_REMEMBER_ME.total_seconds())
|
||||||
|
if remember_me
|
||||||
|
else int(REFRESH_TOKEN_TTL.total_seconds())
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# U10 rollout: clients whose X-Client-Version header reports a version
|
||||||
|
# below this cutoff receive legacy (no-sid) tokens so that they
|
||||||
|
# continue to work during the migration window. After the cutoff is
|
||||||
|
# bumped, the legacy code path is removed (Phase 5 cleanup).
|
||||||
|
LEGACY_CLIENT_VERSION_CUTOFF = "0.5.0"
|
||||||
|
|
||||||
|
|
||||||
|
def _is_legacy_client(request: Request) -> bool:
|
||||||
|
"""Return True if the request's client version is below the cutoff.
|
||||||
|
|
||||||
|
The check is intentionally lenient: missing header, unparseable
|
||||||
|
version, or any error evaluating the version all default to
|
||||||
|
``False`` (treat as a new client). The only path that returns
|
||||||
|
``True`` is a syntactically valid semver that compares strictly
|
||||||
|
less than the cutoff. This avoids accidentally downgrading
|
||||||
|
legitimate clients with unusual but new-enough versions.
|
||||||
|
"""
|
||||||
|
raw = request.headers.get("X-Client-Version", "").strip()
|
||||||
|
if not raw:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
client_v = _parse_semver(raw)
|
||||||
|
cutoff_v = _parse_semver(LEGACY_CLIENT_VERSION_CUTOFF)
|
||||||
|
if client_v is None or cutoff_v is None:
|
||||||
|
return False
|
||||||
|
return client_v < cutoff_v
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
logger.debug("Failed to parse X-Client-Version %r", raw)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_semver(value: str) -> tuple[int, int, int] | None:
|
||||||
|
"""Parse ``"MAJOR.MINOR.PATCH"`` into a comparable tuple.
|
||||||
|
|
||||||
|
Returns ``None`` when the value is not a strict 3-part semver.
|
||||||
|
Pre-release / build-metadata are ignored — we only need a coarse
|
||||||
|
ordering for the rollout policy.
|
||||||
|
"""
|
||||||
|
parts = value.split(".")
|
||||||
|
if len(parts) != 3:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return (int(parts[0]), int(parts[1]), int(parts[2]))
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Client-info extraction (device fingerprint / IP / user agent)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _client_info(request: Request) -> dict[str, str]:
|
||||||
|
"""Extract device fingerprint, IP and user-agent from the request.
|
||||||
|
|
||||||
|
The fingerprint is a coarse best-effort hash of the User-Agent +
|
||||||
|
Accept-Language — enough to distinguish "Mac/Safari" from
|
||||||
|
"Windows/Chrome" in the sessions list without storing anything
|
||||||
|
sensitive. Production deployments that need stronger device
|
||||||
|
identification can layer that on top later.
|
||||||
|
"""
|
||||||
|
ua = request.headers.get("user-agent", "")
|
||||||
|
al = request.headers.get("accept-language", "")
|
||||||
|
fp_seed = f"{ua}|{al}"
|
||||||
|
# Use the same sha256 helper as the denylist for consistency.
|
||||||
|
fp = hash_token(fp_seed)[:16]
|
||||||
|
# Best-effort client IP (no X-Forwarded-For trust for now).
|
||||||
|
ip = (request.client.host if request.client else "") or ""
|
||||||
|
label = _device_label(ua)
|
||||||
|
return {
|
||||||
|
"fingerprint": fp,
|
||||||
|
"label": label,
|
||||||
|
"ip": ip,
|
||||||
|
"user_agent": ua[:512],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _device_label(user_agent: str) -> str:
|
||||||
|
"""Human-readable device label, e.g. ``"macOS — Chrome 124"``."""
|
||||||
|
ua = user_agent.lower()
|
||||||
|
if "edg/" in ua:
|
||||||
|
browser = "Edge"
|
||||||
|
elif "chrome" in ua and "chromium" in ua:
|
||||||
|
browser = "Chrome"
|
||||||
|
elif "firefox" in ua:
|
||||||
|
browser = "Firefox"
|
||||||
|
elif "safari" in ua and "chrome" not in ua:
|
||||||
|
browser = "Safari"
|
||||||
|
elif "tauri" in ua or "fischer" in ua:
|
||||||
|
return "Fischer Desktop"
|
||||||
|
else:
|
||||||
|
browser = "Browser"
|
||||||
|
if "mac os x" in ua or "macintosh" in ua:
|
||||||
|
os_ = "macOS"
|
||||||
|
elif "windows" in ua:
|
||||||
|
os_ = "Windows"
|
||||||
|
elif "iphone" in ua or "ipad" in ua:
|
||||||
|
os_ = "iOS"
|
||||||
|
elif "android" in ua:
|
||||||
|
os_ = "Android"
|
||||||
|
elif "linux" in ua:
|
||||||
|
os_ = "Linux"
|
||||||
|
else:
|
||||||
|
os_ = "Unknown"
|
||||||
|
return f"{os_} — {browser}"
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Routes
|
# Routes
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
@ -155,75 +317,127 @@ async def login(payload: LoginRequest, request: Request) -> TokenResponse:
|
||||||
"""Authenticate with username + password and receive a JWT pair.
|
"""Authenticate with username + password and receive a JWT pair.
|
||||||
|
|
||||||
Flow:
|
Flow:
|
||||||
1. Look up user by username.
|
1. Validate via the configured :class:`AuthProvider`.
|
||||||
2. Verify bcrypt password hash.
|
2. Issue access + refresh JWTs (with ``sid`` / ``jti``).
|
||||||
3. Ensure account is active.
|
3. Persist the session to ``auth_sessions`` (V2).
|
||||||
4. Issue access + refresh JWTs.
|
4. Update ``last_login_at``.
|
||||||
5. Persist refresh-token hash to ``user_sessions``.
|
|
||||||
6. Update ``last_login_at``.
|
U10 back-compat: when the caller's ``X-Client-Version`` header is
|
||||||
|
below the rollout cutoff, the issued tokens carry no ``sid`` claim
|
||||||
|
and the session row is removed. The client can still authenticate
|
||||||
|
using the legacy ``user_sessions`` table; new clients always get
|
||||||
|
the V2 flow. See ``docs/migrations/2026-06-20-client-version-rollout.md``.
|
||||||
"""
|
"""
|
||||||
db_path = await _ensure_db(request)
|
db_path = await _ensure_db(request)
|
||||||
secret = _resolve_jwt_secret(request)
|
secret = _resolve_jwt_secret(request)
|
||||||
|
from agentkit.server.auth.providers import (
|
||||||
async with aiosqlite.connect(str(db_path)) as db:
|
InvalidCredentials,
|
||||||
db.row_factory = aiosqlite.Row
|
LocalAuthProvider,
|
||||||
cursor = await db.execute(
|
|
||||||
"SELECT * FROM users WHERE username = ?",
|
|
||||||
(payload.username,),
|
|
||||||
)
|
|
||||||
row = await cursor.fetchone()
|
|
||||||
|
|
||||||
if row is None:
|
|
||||||
# Constant-time: run a real bcrypt verification against a valid-format
|
|
||||||
# hash so the response time matches the "user exists, wrong password"
|
|
||||||
# path (~250ms), preventing username enumeration via timing.
|
|
||||||
verify_password(payload.password, _DUMMY_BCRYPT_HASH)
|
|
||||||
raise HTTPException(status_code=401, detail="Invalid username or password")
|
|
||||||
|
|
||||||
if not verify_password(payload.password, row["password_hash"]):
|
|
||||||
raise HTTPException(status_code=401, detail="Invalid username or password")
|
|
||||||
|
|
||||||
if not bool(row["is_active"]):
|
|
||||||
raise HTTPException(status_code=403, detail="Account is disabled")
|
|
||||||
|
|
||||||
user_id = row["id"]
|
|
||||||
username = row["username"]
|
|
||||||
role = row["role"]
|
|
||||||
|
|
||||||
token_pair = create_token_pair(
|
|
||||||
user_id=user_id,
|
|
||||||
username=username,
|
|
||||||
role=role,
|
|
||||||
secret=secret,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Persist refresh-token session
|
# 1. Authenticate (construct a provider bound to this request's
|
||||||
session_id = str(uuid.uuid4())
|
# auth DB so the route works for both the global singleton and
|
||||||
refresh_hash = _sha256(token_pair.refresh_token)
|
# per-request overrides such as test fixtures that swap the path).
|
||||||
now_iso = datetime.now(timezone.utc).isoformat()
|
try:
|
||||||
refresh_exp_iso = token_pair.refresh_expires_at.isoformat()
|
user = await LocalAuthProvider(db_path=db_path).authenticate(
|
||||||
|
username=payload.username, password=payload.password
|
||||||
device_info = "{}" # V1: no device fingerprinting; reserved for future use
|
|
||||||
async with aiosqlite.connect(str(db_path)) as db:
|
|
||||||
await db.execute(
|
|
||||||
"INSERT INTO user_sessions "
|
|
||||||
"(id, user_id, refresh_token_hash, device_info, created_at, expires_at) "
|
|
||||||
"VALUES (?, ?, ?, ?, ?, ?)",
|
|
||||||
(session_id, user_id, refresh_hash, device_info, now_iso, refresh_exp_iso),
|
|
||||||
)
|
)
|
||||||
|
except InvalidCredentials as exc:
|
||||||
|
raise HTTPException(status_code=401, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
if not user.is_active:
|
||||||
|
raise HTTPException(status_code=403, detail="Account is disabled")
|
||||||
|
|
||||||
|
# 2. Issue tokens
|
||||||
|
# The session_id is created up front (SessionService.create returns
|
||||||
|
# the id) so we can sign the tokens with the same id used in the
|
||||||
|
# session row.
|
||||||
|
svc: SessionService = get_session_service()
|
||||||
|
info = _client_info(request)
|
||||||
|
|
||||||
|
# U10 rollout policy: detect old clients by the X-Client-Version
|
||||||
|
# header and downgrade them to the legacy (no-sid) token shape.
|
||||||
|
# Old clients can't handle the new flow (they don't call
|
||||||
|
# /auth/whoami with a refresh token), so handing them a sid-bearing
|
||||||
|
# token would just look invalid to them.
|
||||||
|
legacy_mode = _is_legacy_client(request)
|
||||||
|
|
||||||
|
# Pre-create the session with a placeholder row using the id we are
|
||||||
|
# about to issue, so the JWT's ``sid`` claim is consistent with the
|
||||||
|
# row's primary key. We use SessionService directly because the
|
||||||
|
# refresh_token has not been issued yet — we let create() generate
|
||||||
|
# the row first, then issue the tokens referencing it.
|
||||||
|
#
|
||||||
|
# Actually: simpler — call create() AFTER issuing the tokens, then
|
||||||
|
# re-issue nothing; just persist the row with the known id.
|
||||||
|
|
||||||
|
# Step A: create the session row with a placeholder hash. The real
|
||||||
|
# hash will be written in a follow-up UPDATE; this is a tiny race
|
||||||
|
# window that no one can exploit because the row has no valid
|
||||||
|
# refresh_token_hash to match.
|
||||||
|
pre_session = await svc.create(
|
||||||
|
SessionCreate(
|
||||||
|
user_id=user.id,
|
||||||
|
refresh_token="__pending__", # overwritten in Step C
|
||||||
|
device_fingerprint=info["fingerprint"],
|
||||||
|
device_label=info["label"],
|
||||||
|
ip=info["ip"],
|
||||||
|
user_agent=info["user_agent"],
|
||||||
|
auth_provider="local",
|
||||||
|
ttl_seconds=_refresh_ttl_seconds(payload.remember_me),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Step B: issue tokens using the session id from Step A.
|
||||||
|
token_pair = create_token_pair(
|
||||||
|
user_id=user.id,
|
||||||
|
username=user.username,
|
||||||
|
role=user.role,
|
||||||
|
secret=secret,
|
||||||
|
session_id=pre_session.id,
|
||||||
|
remember_me=payload.remember_me,
|
||||||
|
legacy_mode=legacy_mode,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Step C: overwrite the row with the real refresh token hash.
|
||||||
|
import aiosqlite as _aio
|
||||||
|
|
||||||
|
async with _aio.connect(str(db_path)) as db:
|
||||||
|
if legacy_mode:
|
||||||
|
# U10: a legacy client cannot use a session row (no
|
||||||
|
# X-Client-Version-aware client logic), so drop the V2
|
||||||
|
# session we just created. Legacy refresh-tokens will be
|
||||||
|
# tracked via the existing user_sessions table on the next
|
||||||
|
# refresh (the /auth/refresh route handles both shapes).
|
||||||
|
await db.execute(
|
||||||
|
"DELETE FROM auth_sessions WHERE id = ?",
|
||||||
|
(pre_session.id,),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
await db.execute(
|
||||||
|
"UPDATE auth_sessions SET refresh_token_hash = ? WHERE id = ?",
|
||||||
|
(hash_token(token_pair.refresh_token), pre_session.id),
|
||||||
|
)
|
||||||
await db.execute(
|
await db.execute(
|
||||||
"UPDATE users SET last_login_at = ?, updated_at = ? WHERE id = ?",
|
"UPDATE users SET last_login_at = ?, updated_at = ? WHERE id = ?",
|
||||||
(now_iso, now_iso, user_id),
|
(_now_iso(), _now_iso(), user.id),
|
||||||
)
|
)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
user_resp = _user_row_to_response(row)
|
|
||||||
return TokenResponse(
|
return TokenResponse(
|
||||||
access_token=token_pair.access_token,
|
access_token=token_pair.access_token,
|
||||||
refresh_token=token_pair.refresh_token,
|
refresh_token=token_pair.refresh_token,
|
||||||
token_type="bearer",
|
|
||||||
expires_in=int(ACCESS_TOKEN_TTL.total_seconds()),
|
expires_in=int(ACCESS_TOKEN_TTL.total_seconds()),
|
||||||
user=user_resp,
|
user=UserResponse(
|
||||||
|
id=user.id,
|
||||||
|
username=user.username,
|
||||||
|
email=user.email,
|
||||||
|
role=user.role,
|
||||||
|
is_active=user.is_active,
|
||||||
|
is_terminal_authorized=user.is_terminal_authorized,
|
||||||
|
is_server_terminal_authorized=user.is_server_terminal_authorized,
|
||||||
|
),
|
||||||
|
session_id="" if legacy_mode else pre_session.id,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -231,109 +445,85 @@ async def login(payload: LoginRequest, request: Request) -> TokenResponse:
|
||||||
async def refresh(payload: RefreshRequest, request: Request) -> TokenResponse:
|
async def refresh(payload: RefreshRequest, request: Request) -> TokenResponse:
|
||||||
"""Exchange a valid refresh token for a new access token.
|
"""Exchange a valid refresh token for a new access token.
|
||||||
|
|
||||||
The refresh token's hash must match an unrevoked ``user_sessions`` row.
|
Implements refresh-token rotation:
|
||||||
A new access token is issued; the refresh token itself is *not* rotated
|
1. Verify the refresh token's JWT signature and ``type`` claim.
|
||||||
in V1 (rotation is deferred to a later hardening pass).
|
2. Look up the session by the token's SHA-256 hash.
|
||||||
|
3. Verify the session is not revoked or expired.
|
||||||
|
4. Issue a new access + refresh pair.
|
||||||
|
5. Replace the session's stored hash with the new token's hash.
|
||||||
|
6. Add the old hash to the denylist (reuse detection).
|
||||||
|
|
||||||
|
On reuse (old hash in the denylist) the caller's
|
||||||
|
:class:`SessionService.rotate` revokes all of that user's sessions
|
||||||
|
and raises :class:`SessionReuseDetected`. The route maps that to
|
||||||
|
a 401 so the client is forced to re-authenticate.
|
||||||
"""
|
"""
|
||||||
db_path = await _ensure_db(request)
|
db_path = await _ensure_db(request)
|
||||||
secret = _resolve_jwt_secret(request)
|
secret = _resolve_jwt_secret(request)
|
||||||
|
svc: SessionService = get_session_service()
|
||||||
|
|
||||||
|
# 1. Verify signature + type
|
||||||
try:
|
try:
|
||||||
refresh_payload = verify_token(payload.refresh_token, secret)
|
refresh_payload = verify_token(payload.refresh_token, secret, expected_type="refresh")
|
||||||
except Exception as exc: # noqa: BLE001 — PyJWT raises InvalidTokenError subclasses
|
except Exception as exc: # noqa: BLE001
|
||||||
raise HTTPException(status_code=401, detail="Invalid refresh token") from exc
|
raise HTTPException(status_code=401, detail="Invalid refresh token") from exc
|
||||||
|
|
||||||
if refresh_payload.get("type") != "refresh":
|
# 2-3. Validate the session (also handles reuse detection)
|
||||||
raise HTTPException(status_code=401, detail="Invalid refresh token")
|
try:
|
||||||
|
new_pair = create_token_pair(
|
||||||
refresh_hash = _sha256(payload.refresh_token)
|
user_id=refresh_payload["sub"],
|
||||||
|
username=refresh_payload["username"],
|
||||||
|
role=refresh_payload["role"],
|
||||||
|
secret=secret,
|
||||||
|
session_id=refresh_payload.get("sid"),
|
||||||
|
remember_me=False,
|
||||||
|
)
|
||||||
|
await svc.rotate(
|
||||||
|
old_refresh_token=payload.refresh_token,
|
||||||
|
new_refresh_token=new_pair.refresh_token,
|
||||||
|
new_ttl_seconds=int(REFRESH_TOKEN_TTL.total_seconds()),
|
||||||
|
)
|
||||||
|
except Exception as exc: # noqa: BLE001 — SessionReuseDetected / SessionNotFound
|
||||||
|
logger.info("Refresh rejected: %s", exc)
|
||||||
|
raise HTTPException(status_code=401, detail="Invalid refresh token") from exc
|
||||||
|
|
||||||
|
# Re-fetch the user to surface fresh role / is_active
|
||||||
async with aiosqlite.connect(str(db_path)) as db:
|
async with aiosqlite.connect(str(db_path)) as db:
|
||||||
db.row_factory = aiosqlite.Row
|
db.row_factory = aiosqlite.Row
|
||||||
cursor = await db.execute(
|
cursor = await db.execute("SELECT * FROM users WHERE id = ?", (refresh_payload["sub"],))
|
||||||
"SELECT * FROM user_sessions WHERE refresh_token_hash = ?",
|
user_row = await cursor.fetchone()
|
||||||
(refresh_hash,),
|
if user_row is None or not bool(user_row["is_active"]):
|
||||||
)
|
raise HTTPException(status_code=401, detail="User not found or disabled")
|
||||||
session = await cursor.fetchone()
|
|
||||||
|
|
||||||
if session is None:
|
|
||||||
raise HTTPException(status_code=401, detail="Invalid refresh token")
|
|
||||||
if session["revoked_at"] is not None:
|
|
||||||
raise HTTPException(status_code=401, detail="Refresh token has been revoked")
|
|
||||||
|
|
||||||
# Re-fetch user (in case role / active status changed since login)
|
|
||||||
user_cursor = await db.execute(
|
|
||||||
"SELECT * FROM users WHERE id = ?",
|
|
||||||
(session["user_id"],),
|
|
||||||
)
|
|
||||||
user_row = await user_cursor.fetchone()
|
|
||||||
|
|
||||||
if user_row is None:
|
|
||||||
raise HTTPException(status_code=401, detail="User not found")
|
|
||||||
if not bool(user_row["is_active"]):
|
|
||||||
raise HTTPException(status_code=403, detail="Account is disabled")
|
|
||||||
|
|
||||||
token_pair = create_token_pair(
|
|
||||||
user_id=user_row["id"],
|
|
||||||
username=user_row["username"],
|
|
||||||
role=user_row["role"],
|
|
||||||
secret=secret,
|
|
||||||
)
|
|
||||||
|
|
||||||
user_resp = _user_row_to_response(user_row)
|
|
||||||
# V1: refresh token is NOT rotated — return the original refresh token
|
|
||||||
# so the client can continue using it. The new access token is the only
|
|
||||||
# refreshed credential. Rotation (with hash persistence + revocation)
|
|
||||||
# is deferred to a later hardening pass.
|
|
||||||
return TokenResponse(
|
return TokenResponse(
|
||||||
access_token=token_pair.access_token,
|
access_token=new_pair.access_token,
|
||||||
refresh_token=payload.refresh_token,
|
refresh_token=new_pair.refresh_token,
|
||||||
token_type="bearer",
|
|
||||||
expires_in=int(ACCESS_TOKEN_TTL.total_seconds()),
|
expires_in=int(ACCESS_TOKEN_TTL.total_seconds()),
|
||||||
user=user_resp,
|
user=_user_row_to_response(user_row),
|
||||||
|
session_id=refresh_payload.get("sid", ""),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/logout")
|
@router.post("/logout")
|
||||||
async def logout(payload: RefreshRequest, request: Request) -> dict[str, Any]:
|
async def logout(payload: LogoutRequest, request: Request) -> dict[str, Any]:
|
||||||
"""Revoke a refresh token by marking its session row as revoked.
|
"""Revoke the refresh-token session. Idempotent."""
|
||||||
|
svc: SessionService = get_session_service()
|
||||||
Idempotent: calling logout with an already-revoked or unknown token
|
revoked = await svc.revoke_by_refresh_token(
|
||||||
returns 200 with ``revoked=false`` (no error).
|
payload.refresh_token, reason=REVOKE_REASON_USER_TERMINATED
|
||||||
"""
|
)
|
||||||
db_path = await _ensure_db(request)
|
|
||||||
secret = _resolve_jwt_secret(request)
|
|
||||||
|
|
||||||
try:
|
|
||||||
verify_token(payload.refresh_token, secret)
|
|
||||||
except Exception: # noqa: BLE001 — treat any JWT error as "not our token"
|
|
||||||
return {"revoked": False, "message": "Invalid token, nothing to revoke"}
|
|
||||||
|
|
||||||
refresh_hash = _sha256(payload.refresh_token)
|
|
||||||
now_iso = datetime.now(timezone.utc).isoformat()
|
|
||||||
|
|
||||||
async with aiosqlite.connect(str(db_path)) as db:
|
|
||||||
cursor = await db.execute(
|
|
||||||
"UPDATE user_sessions SET revoked_at = ? "
|
|
||||||
"WHERE refresh_token_hash = ? AND revoked_at IS NULL",
|
|
||||||
(now_iso, refresh_hash),
|
|
||||||
)
|
|
||||||
await db.commit()
|
|
||||||
revoked = cursor.rowcount > 0
|
|
||||||
|
|
||||||
return {"revoked": revoked}
|
return {"revoked": revoked}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/me", response_model=UserResponse)
|
@router.get("/whoami", response_model=UserResponse)
|
||||||
async def me(
|
async def whoami(
|
||||||
request: Request,
|
request: Request,
|
||||||
user: dict[str, Any] = Depends(require_authenticated),
|
user: dict[str, Any] = Depends(require_authenticated),
|
||||||
) -> UserResponse:
|
) -> UserResponse:
|
||||||
"""Return the current authenticated user's public profile.
|
"""Return the current authenticated user's profile.
|
||||||
|
|
||||||
The JWT payload only carries ``user_id`` / ``username`` / ``role``; this
|
Used by the client for cold-start (after restoring a refresh token
|
||||||
endpoint re-fetches the full record from the auth DB so callers see the
|
from local storage) to verify the session is still valid AND fetch
|
||||||
freshest ``email`` / ``is_active`` / terminal-authorization flags.
|
the freshest user profile.
|
||||||
"""
|
"""
|
||||||
user_id = user.get("user_id")
|
user_id = user.get("user_id")
|
||||||
if not user_id:
|
if not user_id:
|
||||||
|
|
@ -344,8 +534,241 @@ async def me(
|
||||||
db.row_factory = aiosqlite.Row
|
db.row_factory = aiosqlite.Row
|
||||||
cursor = await db.execute("SELECT * FROM users WHERE id = ?", (user_id,))
|
cursor = await db.execute("SELECT * FROM users WHERE id = ?", (user_id,))
|
||||||
row = await cursor.fetchone()
|
row = await cursor.fetchone()
|
||||||
|
|
||||||
if row is None:
|
if row is None:
|
||||||
raise HTTPException(status_code=404, detail="User not found")
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
|
|
||||||
return _user_row_to_response(row)
|
return _user_row_to_response(row)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/sessions", response_model=list[SessionResponse])
|
||||||
|
async def list_sessions(
|
||||||
|
request: Request,
|
||||||
|
user: dict[str, Any] = Depends(require_authenticated),
|
||||||
|
) -> list[SessionResponse]:
|
||||||
|
"""List the current user's active sessions."""
|
||||||
|
user_id = user.get("user_id")
|
||||||
|
if not user_id:
|
||||||
|
raise HTTPException(status_code=401, detail="Authentication required")
|
||||||
|
svc: SessionService = get_session_service()
|
||||||
|
current_sid = user.get("sid") # the calling session's id, if V2
|
||||||
|
sessions = await svc.list_for_user(user_id)
|
||||||
|
return [
|
||||||
|
SessionResponse(
|
||||||
|
**auth_session_row_to_dict(
|
||||||
|
# build a dict-like object with the right keys
|
||||||
|
_info_to_dict(s),
|
||||||
|
),
|
||||||
|
is_current=(s.id == current_sid),
|
||||||
|
revoked=s.revoked,
|
||||||
|
revoked_reason=s.revoked_reason,
|
||||||
|
user_id=s.user_id,
|
||||||
|
)
|
||||||
|
for s in sessions
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _info_to_dict(s: Any) -> dict[str, Any]:
|
||||||
|
"""Convert a SessionInfo to the dict shape :func:`auth_session_row_to_dict` expects."""
|
||||||
|
return {
|
||||||
|
"id": s.id,
|
||||||
|
"user_id": s.user_id,
|
||||||
|
"device_fingerprint": s.device_fingerprint,
|
||||||
|
"device_label": s.device_label,
|
||||||
|
"ip": s.ip,
|
||||||
|
"user_agent": s.user_agent,
|
||||||
|
"auth_provider": s.auth_provider,
|
||||||
|
"created_at": s.created_at,
|
||||||
|
"last_active_at": s.last_active_at,
|
||||||
|
"expires_at": s.expires_at,
|
||||||
|
"revoked": s.revoked,
|
||||||
|
"revoked_reason": s.revoked_reason,
|
||||||
|
"previous_session_id": s.previous_session_id,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/sessions/{session_id}")
|
||||||
|
async def revoke_own_session(
|
||||||
|
session_id: str,
|
||||||
|
request: Request,
|
||||||
|
user: dict[str, Any] = Depends(require_authenticated),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Revoke one of the current user's sessions (e.g. a lost device)."""
|
||||||
|
user_id = user.get("user_id")
|
||||||
|
if not user_id:
|
||||||
|
raise HTTPException(status_code=401, detail="Authentication required")
|
||||||
|
svc: SessionService = get_session_service()
|
||||||
|
info = await svc.get(session_id)
|
||||||
|
if info is None or info.user_id != user_id:
|
||||||
|
# Don't reveal whether the session exists for another user.
|
||||||
|
raise HTTPException(status_code=404, detail="Session not found")
|
||||||
|
ok = await svc.revoke(session_id, reason=REVOKE_REASON_USER_TERMINATED)
|
||||||
|
return {"revoked": ok}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/change-password")
|
||||||
|
async def change_password(
|
||||||
|
payload: ChangePasswordRequest,
|
||||||
|
request: Request,
|
||||||
|
user: dict[str, Any] = Depends(require_authenticated),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Change the current user's password.
|
||||||
|
|
||||||
|
Per the security policy: changing the password invalidates ALL of
|
||||||
|
the user's other active sessions (the user is re-authenticated on
|
||||||
|
this device on next request via the new password).
|
||||||
|
"""
|
||||||
|
user_id = user.get("user_id")
|
||||||
|
if not user_id:
|
||||||
|
raise HTTPException(status_code=401, detail="Authentication required")
|
||||||
|
if len(payload.new_password) < 8:
|
||||||
|
raise HTTPException(status_code=400, detail="new_password must be at least 8 characters")
|
||||||
|
db_path = await _ensure_db(request)
|
||||||
|
async with aiosqlite.connect(str(db_path)) as db:
|
||||||
|
db.row_factory = aiosqlite.Row
|
||||||
|
cursor = await db.execute("SELECT * FROM users WHERE id = ?", (user_id,))
|
||||||
|
row = await cursor.fetchone()
|
||||||
|
if row is None:
|
||||||
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
|
if not verify_password(payload.old_password, row["password_hash"]):
|
||||||
|
raise HTTPException(status_code=400, detail="Old password is incorrect")
|
||||||
|
new_hash = hash_password(payload.new_password)
|
||||||
|
await db.execute(
|
||||||
|
"UPDATE users SET password_hash = ?, updated_at = ? WHERE id = ?",
|
||||||
|
(new_hash, _now_iso(), user_id),
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
# Revoke all sessions except the current one (the user is staying
|
||||||
|
# logged in on the device that just changed the password).
|
||||||
|
svc: SessionService = get_session_service()
|
||||||
|
current_sid = user.get("sid")
|
||||||
|
sessions = await svc.list_for_user(user_id)
|
||||||
|
revoked = 0
|
||||||
|
for s in sessions:
|
||||||
|
if s.id != current_sid:
|
||||||
|
if await svc.revoke(s.id, reason=REVOKE_REASON_PASSWORD_CHANGED):
|
||||||
|
revoked += 1
|
||||||
|
return {"changed": True, "revoked_other_sessions": revoked}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Admin routes
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
admin_router = APIRouter(prefix="/admin", tags=["admin"])
|
||||||
|
|
||||||
|
|
||||||
|
async def _require_admin(
|
||||||
|
request: Request, user: dict[str, Any] = Depends(require_authenticated)
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
if not has_permission(user, Permission.USER_MANAGE):
|
||||||
|
raise HTTPException(status_code=403, detail="Admin permission required")
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
@admin_router.get("/sessions", response_model=list[SessionResponse])
|
||||||
|
async def admin_list_sessions(
|
||||||
|
request: Request,
|
||||||
|
limit: int = 200,
|
||||||
|
admin: dict[str, Any] = Depends(_require_admin),
|
||||||
|
) -> list[SessionResponse]:
|
||||||
|
"""List recent sessions across all users (admin only)."""
|
||||||
|
svc: SessionService = get_session_service()
|
||||||
|
sessions = await svc.list_all(include_revoked=False, limit=limit)
|
||||||
|
return [
|
||||||
|
SessionResponse(
|
||||||
|
**auth_session_row_to_dict(_info_to_dict(s)),
|
||||||
|
is_current=False,
|
||||||
|
revoked=s.revoked,
|
||||||
|
revoked_reason=s.revoked_reason,
|
||||||
|
user_id=s.user_id,
|
||||||
|
)
|
||||||
|
for s in sessions
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@admin_router.delete("/sessions/{session_id}")
|
||||||
|
async def admin_revoke_session(
|
||||||
|
session_id: str,
|
||||||
|
request: Request,
|
||||||
|
admin: dict[str, Any] = Depends(_require_admin),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Revoke any session as admin (force-logout a user)."""
|
||||||
|
svc: SessionService = get_session_service()
|
||||||
|
ok = await svc.revoke(session_id, reason="admin_revoked")
|
||||||
|
if not ok:
|
||||||
|
raise HTTPException(status_code=404, detail="Session not found or already revoked")
|
||||||
|
return {"revoked": True}
|
||||||
|
|
||||||
|
|
||||||
|
@admin_router.get(
|
||||||
|
"/users/{user_id}/sessions",
|
||||||
|
response_model=list[SessionResponse],
|
||||||
|
)
|
||||||
|
async def admin_list_user_sessions(
|
||||||
|
user_id: str,
|
||||||
|
request: Request,
|
||||||
|
include_revoked: bool = False,
|
||||||
|
admin: dict[str, Any] = Depends(_require_admin),
|
||||||
|
) -> list[SessionResponse]:
|
||||||
|
"""List all sessions for a specific user (admin only).
|
||||||
|
|
||||||
|
By default only active sessions are returned. When ``include_revoked``
|
||||||
|
is true, revoked sessions are also returned (with their ``revoked``
|
||||||
|
and ``revoked_reason`` fields populated in the response).
|
||||||
|
|
||||||
|
The endpoint verifies the user exists; if not, 404 is returned. This
|
||||||
|
prevents admins from silently filtering on a stale id and gives
|
||||||
|
clearer feedback.
|
||||||
|
"""
|
||||||
|
db_path = await _ensure_db(request)
|
||||||
|
async with aiosqlite.connect(str(db_path)) as db:
|
||||||
|
db.row_factory = aiosqlite.Row
|
||||||
|
cursor = await db.execute("SELECT id, username FROM users WHERE id = ?", (user_id,))
|
||||||
|
user_row = await cursor.fetchone()
|
||||||
|
if user_row is None:
|
||||||
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
|
|
||||||
|
svc: SessionService = get_session_service()
|
||||||
|
sessions = await svc.list_for_user(user_id, include_revoked=include_revoked)
|
||||||
|
current_sid = admin.get("sid")
|
||||||
|
return [
|
||||||
|
SessionResponse(
|
||||||
|
**auth_session_row_to_dict(_info_to_dict(s)),
|
||||||
|
is_current=(s.id == current_sid),
|
||||||
|
revoked=s.revoked,
|
||||||
|
revoked_reason=s.revoked_reason,
|
||||||
|
user_id=s.user_id,
|
||||||
|
)
|
||||||
|
for s in sessions
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@admin_router.delete("/users/{user_id}/sessions/{session_id}")
|
||||||
|
async def admin_revoke_user_session(
|
||||||
|
user_id: str,
|
||||||
|
session_id: str,
|
||||||
|
request: Request,
|
||||||
|
admin: dict[str, Any] = Depends(_require_admin),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Revoke a specific session for a specific user (admin only).
|
||||||
|
|
||||||
|
Returns 404 if the session does not exist or does not belong to
|
||||||
|
``user_id`` (defense against accidentally revoking a session on the
|
||||||
|
wrong user). Already-revoked sessions return 200 with
|
||||||
|
``revoked=False`` so the admin can detect no-op vs new revoke.
|
||||||
|
"""
|
||||||
|
svc: SessionService = get_session_service()
|
||||||
|
info = await svc.get(session_id)
|
||||||
|
if info is None or info.user_id != user_id:
|
||||||
|
raise HTTPException(status_code=404, detail="Session not found for user")
|
||||||
|
ok = await svc.revoke(session_id, reason="admin_revoked")
|
||||||
|
return {"revoked": ok}
|
||||||
|
|
||||||
|
|
||||||
|
# Backwards-compat /me endpoint (kept for the existing frontend)
|
||||||
|
@router.get("/me", response_model=UserResponse)
|
||||||
|
async def me(
|
||||||
|
request: Request,
|
||||||
|
user: dict[str, Any] = Depends(require_authenticated),
|
||||||
|
) -> UserResponse:
|
||||||
|
"""Alias for :func:`whoami` (legacy path)."""
|
||||||
|
return await whoami(request=request, user=user)
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ import pytest
|
||||||
from fastapi import FastAPI, Request
|
from fastapi import FastAPI, Request
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from agentkit.server.auth.denylist import InMemoryRecentlyRevoked
|
||||||
from agentkit.server.auth.jwt_utils import (
|
from agentkit.server.auth.jwt_utils import (
|
||||||
create_token_pair,
|
create_token_pair,
|
||||||
verify_token,
|
verify_token,
|
||||||
|
|
@ -29,6 +30,7 @@ from agentkit.server.auth.jwt_utils import (
|
||||||
from agentkit.server.auth.middleware import AuthMiddleware
|
from agentkit.server.auth.middleware import AuthMiddleware
|
||||||
from agentkit.server.auth.models import init_auth_db
|
from agentkit.server.auth.models import init_auth_db
|
||||||
from agentkit.server.auth.password import hash_password, verify_password
|
from agentkit.server.auth.password import hash_password, verify_password
|
||||||
|
from agentkit.server.auth.session_service import SessionService, set_session_service
|
||||||
from agentkit.server.routes import auth as auth_routes
|
from agentkit.server.routes import auth as auth_routes
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -107,8 +109,24 @@ def auth_app(jwt_secret: str, auth_db_with_user: dict[str, Any]) -> FastAPI:
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def auth_client(auth_app: FastAPI) -> TestClient:
|
def auth_client(auth_app: FastAPI) -> TestClient:
|
||||||
"""TestClient for the auth-only app (no auth middleware)."""
|
"""TestClient for the auth-only app (no auth middleware).
|
||||||
return TestClient(auth_app)
|
|
||||||
|
Also overrides the global :class:`SessionService` singleton with a
|
||||||
|
per-test instance bound to the test's auth DB path. This ensures
|
||||||
|
the login/refresh routes write to the test database instead of
|
||||||
|
the project-default auth DB.
|
||||||
|
"""
|
||||||
|
# Bind a fresh SessionService to the test DB and inject it.
|
||||||
|
test_svc = SessionService(
|
||||||
|
db_path=auth_app.state.auth_db_path,
|
||||||
|
denylist=InMemoryRecentlyRevoked(),
|
||||||
|
)
|
||||||
|
set_session_service(test_svc)
|
||||||
|
try:
|
||||||
|
yield TestClient(auth_app)
|
||||||
|
finally:
|
||||||
|
# Reset the singleton so the next test gets a fresh one.
|
||||||
|
set_session_service(None)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
@ -499,7 +517,7 @@ class TestLoginRoute:
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
assert resp.status_code == 401
|
assert resp.status_code == 401
|
||||||
assert "Invalid username or password" in resp.json()["detail"]
|
assert "invalid username or password" in resp.json()["detail"].lower()
|
||||||
|
|
||||||
def test_login_unknown_user_returns_401(
|
def test_login_unknown_user_returns_401(
|
||||||
self,
|
self,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue