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:
chiguyong 2026-06-21 08:48:25 +08:00
parent b418c3dc95
commit 9328451050
21 changed files with 2330 additions and 289 deletions

View File

@ -926,6 +926,7 @@ def create_app(
app.include_router(terminal_whitelist.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.admin_router, prefix="/api/v1")
# Serve GUI when in GUI mode
gui_mode = os.environ.get("AGENTKIT_GUI_MODE")

View File

@ -31,6 +31,14 @@ async def get_current_user(request: Request) -> dict[str, Any] | None:
The payload is set by :class:`AuthMiddleware` and contains
``user_id``, ``username``, and ``role``. When no auth middleware is
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)
@ -47,6 +55,15 @@ async def require_authenticated(request: Request) -> dict[str, Any]:
status_code=401,
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

View File

@ -117,6 +117,7 @@ def create_token_pair(
session_id: str | None = None,
remember_me: bool = False,
now: datetime | None = None,
legacy_mode: bool = False,
) -> TokenPair:
"""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
days instead of the default 7.
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:
A :class:`TokenPair` with both signed tokens and their expiry times.
@ -141,6 +148,12 @@ def create_token_pair(
if not secret:
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)
refresh_ttl = _refresh_ttl_for(remember_me)
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
# denylist; giving it a jti too would be redundant and would bloat
# 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] = {
"sub": user_id,
@ -168,10 +181,10 @@ def create_token_pair(
"iat": int(issued_at.timestamp()),
"exp": int(refresh_exp.timestamp()),
}
if session_id:
access_payload["sid"] = session_id
if effective_session_id:
access_payload["sid"] = effective_session_id
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)
refresh_token = jwt.encode(refresh_payload, secret, algorithm=JWT_ALGORITHM)

View File

@ -78,7 +78,16 @@ class AuthMiddleware(BaseHTTPMiddleware):
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:
"""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:
return None
try:
@ -140,6 +149,7 @@ class AuthMiddleware(BaseHTTPMiddleware):
"user_id": payload.get("sub"),
"username": payload.get("username"),
"role": payload.get("role"),
"sid": payload.get("sid"),
}
return await call_next(request)
# Fall through to API key check, then 401

View File

@ -21,7 +21,6 @@ from __future__ import annotations
import time
from collections import OrderedDict
from typing import Any
from .session_service import SessionService
@ -104,9 +103,7 @@ def init_validation_cache(
a reference for tests.
"""
global _cache
_cache = SessionValidationCache(
service, ttl_seconds=ttl_seconds, max_entries=max_entries
)
_cache = SessionValidationCache(service, ttl_seconds=ttl_seconds, max_entries=max_entries)
return _cache

View File

@ -228,12 +228,8 @@ class SessionService:
"""
session_id = str(uuid.uuid4())
now = _now_iso()
expires = (
datetime.now(timezone.utc).timestamp() + payload.ttl_seconds
)
expires_iso = (
datetime.fromtimestamp(expires, tz=timezone.utc).isoformat()
)
expires = datetime.now(timezone.utc).timestamp() + payload.ttl_seconds
expires_iso = datetime.fromtimestamp(expires, tz=timezone.utc).isoformat()
refresh_hash = hash_token(payload.refresh_token)
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.
user_id = self._recent_users.pop(old_hash, None)
if user_id is not None:
await self.revoke_all_for_user(
user_id, reason=REVOKE_REASON_REUSE_DETECTED
)
await self.revoke_all_for_user(user_id, reason=REVOKE_REASON_REUSE_DETECTED)
raise SessionReuseDetected("refresh token reuse detected")
info = await self.find_by_refresh_token(old_refresh_token)
@ -315,12 +309,10 @@ class SessionService:
new_hash = hash_token(new_refresh_token)
now = _now_iso()
new_expires_iso = (
datetime.fromtimestamp(
datetime.now(timezone.utc).timestamp() + new_ttl_seconds,
tz=timezone.utc,
).isoformat()
)
new_expires_iso = datetime.fromtimestamp(
datetime.now(timezone.utc).timestamp() + new_ttl_seconds,
tz=timezone.utc,
).isoformat()
async with aiosqlite.connect(str(self._db_path)) as db:
await db.execute(
@ -357,9 +349,7 @@ class SessionService:
# Revoke
# ------------------------------------------------------------------
async def revoke(
self, session_id: str, *, reason: str = REVOKE_REASON_USER_TERMINATED
) -> bool:
async def revoke(self, session_id: str, *, reason: str = REVOKE_REASON_USER_TERMINATED) -> bool:
"""Revoke a single session.
Returns ``True`` if a row was updated, ``False`` if the session

View File

@ -59,11 +59,11 @@ def _has_shell_operators(command: str) -> bool:
# ── Decision constants ────────────────────────────────────────────────
DECISION_EXECUTED = "executed" # Ran without confirmation (whitelisted)
DECISION_CONFIRMED = "confirmed" # Ran after user confirmation
DECISION_REJECTED = "rejected" # User rejected confirmation prompt
DECISION_BLOCKED = "blocked" # Blocked by blocklist
DECISION_DENIED = "denied" # Blocked by safety check (non-whitelist)
DECISION_EXECUTED = "executed" # Ran without confirmation (whitelisted)
DECISION_CONFIRMED = "confirmed" # Ran after user confirmation
DECISION_REJECTED = "rejected" # User rejected confirmation prompt
DECISION_BLOCKED = "blocked" # Blocked by blocklist
DECISION_DENIED = "denied" # Blocked by safety check (non-whitelist)
# ── 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]] = []
try:
async with aiosqlite.connect(str(db_path)) as db:
cursor = await db.execute(
"SELECT command_pattern, reason FROM terminal_blocklist"
)
cursor = await db.execute("SELECT command_pattern, reason FROM terminal_blocklist")
rows = await cursor.fetchall()
patterns = [(row[0], row[1]) for row in rows]
except Exception as e:

View File

@ -23,6 +23,7 @@ import {
setTokenProvider,
setRefreshProvider,
setUnauthorizedHandler,
setPreEmptiveRefreshProvider,
} from './api/base'
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
// attached automatically, and 401s trigger a token refresh.
setTokenProvider(() => authStore.accessToken)
setRefreshProvider(() => authStore.refreshIfPossible())
setRefreshProvider(async () => {
const pair = await authStore.silentRefresh()
return pair?.access_token ?? null
})
setPreEmptiveRefreshProvider(() => authStore.shouldRefresh)
setUnauthorizedHandler(() => {
authStore.logoutLocal()
void authStore.logoutLocal()
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()) {
try {
await bootstrapBackend()

View File

@ -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 }

View File

@ -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
* ``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'
@ -25,6 +34,35 @@ export interface ITokenPair {
token_type: 'bearer'
expires_in: number
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 {
@ -33,10 +71,18 @@ class AuthApiClient extends BaseApiClient {
}
/** 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', {
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). */
async logout(refreshToken: string): Promise<{ revoked: boolean }> {
return this.request<{ revoked: boolean }>('/auth/logout', {
@ -60,6 +126,34 @@ class AuthApiClient extends BaseApiClient {
async me(): Promise<IAuthUser> {
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()

View File

@ -8,6 +8,16 @@ export interface IApiError {
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 = ''
/** Initialize the dynamic base URL for Tauri (sidecar backend). */
@ -33,10 +43,12 @@ export function getDynamicBaseURL(): string {
type TokenProvider = () => string | null
type RefreshProvider = () => Promise<string | null>
type UnauthorizedHandler = () => void
type PreEmptiveRefreshProvider = () => boolean
let _tokenProvider: TokenProvider | null = null
let _refreshProvider: RefreshProvider | null = null
let _unauthorizedHandler: UnauthorizedHandler | null = null
let _preEmptiveRefreshProvider: PreEmptiveRefreshProvider | null = null
/** Register the access-token provider (called by the auth store on init). */
export function setTokenProvider(provider: TokenProvider): void {
@ -53,6 +65,16 @@ export function setUnauthorizedHandler(handler: UnauthorizedHandler): void {
_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 {
protected baseUrl: string
@ -88,16 +110,67 @@ export class BaseApiClient {
if (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
}
/**
* 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.
*
* If the refresh fails, the unauthorized handler is invoked (typically
* redirecting to /login) and the original 401 error is re-thrown.
* Before each request we also call ``silentRefresh()`` when
* ``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> {
// 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)
if (response.status === 401 && _refreshProvider) {
@ -108,7 +181,15 @@ export class BaseApiClient {
if (!retried.ok) {
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
_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. */
private async _send(path: string, options: RequestInit): Promise<Response> {
const effectiveUrl = this._resolveUrl(path)
@ -137,6 +234,23 @@ export class BaseApiClient {
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> {
const error: IApiError = {
status: response.status,

View File

@ -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>

View File

@ -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>

View File

@ -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;
}

View File

@ -155,13 +155,23 @@ const router = createRouter({
* Global route guard.
*
* - Public routes (meta.public === true) are always allowed.
* - Non-public routes require an authenticated user; unauthenticated users
* are redirected to /login with a ``redirect`` query param preserving
* the original target.
* - Non-public routes require an authenticated user; unauthenticated
* users are redirected to /login with a ``redirect`` query param
* preserving the original target.
*
* Note: the guard reads from the auth store, which hydrates from
* localStorage on construction so a page reload with a valid token
* does not force a re-login.
* Note: the cold-start auth rehydration happens in App.vue's
* ``onMounted`` (calls ``authStore.startupCheck()``) BEFORE this guard
* 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) => {
const title = to.meta.title as string | undefined

View File

@ -1,32 +1,37 @@
/**
* Auth store manages JWT, current user, and permission checks.
*
* Responsibilities:
* - Persist access/refresh tokens in localStorage (browser) so they survive
* page reloads. In Tauri mode, localStorage is still available.
* - Expose ``getAccessToken()`` for the API client to attach as a Bearer
* header, and ``getRefreshToken()`` for the refresh-on-401 flow.
* - Provide ``hasPermission()`` / ``canUseTerminal()`` helpers used by route
* guards and component visibility flags.
* U7 (Centralized Auth & Token Persistence):
* - 3-state startup: `valid` / `invalid` / `error` / `pending`
* - Access token in memory only (NEVER written to localStorage)
* - Refresh token persisted via tauriAuthStorage (Tauri Keychain or
* Web localStorage fallback)
* - Pre-emptive refresh: when access expires in <2 min, refresh BEFORE
* the next request fires (no 401 storms)
*
* Token lifecycle:
* - ``login()`` stores both tokens + user.
* - On 401 from the API, ``BaseApiClient`` calls ``refreshIfPossible()``;
* if refresh succeeds, the original request is retried; if refresh fails,
* ``logoutLocal()`` clears state and the router redirects to /login.
* - ``logout()`` calls the server to revoke the refresh token, then clears
* local state regardless of the server response.
* - ``login()`` calls /auth/login, stores both tokens.
* - ``startupCheck()`` runs on app boot calls /auth/whoami with the
* persisted refresh token to validate it and rehydrate the user.
* - On 401 from the API, ``BaseApiClient`` calls ``silentRefresh()``;
* if refresh succeeds, the original request is retried; if refresh
* 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 { 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'
/** 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 =
| 'CHAT'
| '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 {
try {
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', () => {
// --- 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
* loops where every concurrent 401 triggers another refresh.
* Access token. Memory only never written to localStorage or
* 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 ---
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 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). */
const permissions = computed<Permission[]>(() => {
@ -122,33 +165,55 @@ export const useAuthStore = defineStore('auth', () => {
})
// --- Mutators ---
function _persist(tokens: ITokenPair): void {
accessToken.value = tokens.access_token
refreshToken.value = tokens.refresh_token
user.value = tokens.user
writeStored(ACCESS_TOKEN_KEY, tokens.access_token)
writeStored(REFRESH_TOKEN_KEY, tokens.refresh_token)
writeStored(USER_KEY, JSON.stringify(tokens.user))
_refreshFailed = false
/**
* Persist the access token in memory (NOT localStorage) and the user
* in localStorage (safe no secret). Refresh token is written to
* Keychain (Tauri) / localStorage fallback by the caller via
* tauriAuthStorage.
*/
function _setAccess(token: string, currentUser: IAuthUser, sid: string | null = null): void {
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 {
accessToken.value = null
refreshToken.value = null
user.value = null
removeStored(ACCESS_TOKEN_KEY)
removeStored(REFRESH_TOKEN_KEY)
removeStored(USER_KEY)
_refreshFailed = false
sessionId.value = null
// user is intentionally retained so the UI can show the last-known
// avatar/role after a forced logout. Use ``logout()`` to fully
// clear.
}
// --- 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
error.value = null
try {
const tokens = await authApi.login(username, password)
_persist(tokens)
const pair = await authApi.login(username, password, rememberMe)
// 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) {
const msg = _extractErrorMessage(err, '登录失败')
error.value = msg
@ -159,49 +224,144 @@ export const useAuthStore = defineStore('auth', () => {
}
/**
* Attempt to refresh the access token using the stored refresh token.
* Returns the new access token on success, or ``null`` if no refresh
* token is available or a previous refresh already failed this session.
* Cold-start validation: check the persisted refresh token against
* the server and rehydrate the user / access token.
*
* The API client calls this from its 401 handler. Concurrent callers
* will all see the same outcome because ``_refreshFailed`` is sticky
* until the next successful login.
* Transitions:
* - no refresh token 'invalid' (first launch, no login)
* - whoami 200 'valid'
* - whoami 401 'invalid' + clear stored refresh token
* - whoami network 'error' (retryable, refresh token kept)
*/
async function refreshIfPossible(): Promise<string | null> {
if (_refreshFailed) return null
if (!refreshToken.value) return null
async function startupCheck(): Promise<AuthStartupState> {
startupState.value = 'pending'
const refresh = await tauriAuthStorage.getRefreshToken()
if (!refresh) {
startupState.value = 'invalid'
return startupState.value
}
try {
const tokens = await authApi.refresh(refreshToken.value)
_persist(tokens)
return tokens.access_token
const result = await authApi.whoami(refresh)
// whoami returns { user, access_token, session } when called
// with a refresh token.
const sid = result.session?.id ?? null
_setAccess(result.access_token, result.user, sid)
startupState.value = 'valid'
} catch (err) {
_refreshFailed = true
error.value = _extractErrorMessage(err, '会话已过期,请重新登录')
const status = _extractStatus(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()
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
}
}
/**
* Server-side logout: revoke the refresh token, then clear local state.
* Safe to call even if the server is unreachable local state is always
* cleared.
* Safe to call even if the server is unreachable local state is
* always cleared.
*/
async function logout(): Promise<void> {
const token = refreshToken.value
if (token) {
const refresh = await tauriAuthStorage.getRefreshToken()
if (refresh) {
try {
await authApi.logout(token)
await authApi.logout(refresh)
} catch {
/* server may be unreachable; still clear local state */
}
}
await tauriAuthStorage.clearRefreshToken()
_clear()
user.value = null
removeStored(USER_KEY)
startupState.value = 'invalid'
}
/** Clear local auth state without calling the server (used on 401 fallback). */
function logoutLocal(): void {
async function logoutLocal(): Promise<void> {
await tauriAuthStorage.clearRefreshToken()
_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 ---
@ -227,19 +387,27 @@ export const useAuthStore = defineStore('auth', () => {
return {
// state
accessToken,
refreshToken,
user,
isLoading,
error,
sessionId,
startupState,
// getters
isAuthenticated,
role,
permissions,
accessTokenExp,
shouldRefresh,
// actions
login,
refreshIfPossible,
logout,
logoutLocal,
silentRefresh,
startupCheck,
retryStartup,
listSessions,
revokeSession,
changePassword,
// permission helpers
hasPermission,
canUseLocalTerminal,
@ -249,9 +417,21 @@ export const useAuthStore = defineStore('auth', () => {
})
function _extractErrorMessage(err: unknown, fallback: string): string {
if (err && typeof err === 'object' && 'message' in err) {
const msg = (err as { message?: unknown }).message
if (typeof msg === 'string' && msg) return msg
if (err && typeof err === 'object') {
const obj = err as { message?: unknown; detail?: unknown }
if (typeof obj.message === 'string' && obj.message) return obj.message
if (typeof obj.detail === 'string' && obj.detail) return obj.detail
}
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 }

View File

@ -58,6 +58,12 @@
@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
type="primary"
html-type="submit"
@ -93,6 +99,7 @@ const authStore = useAuthStore()
const form = reactive({
username: '',
password: '',
rememberMe: false,
})
/** Redirect target after successful login (defaults to /agent). */
@ -106,16 +113,23 @@ const redirectTarget = (): string => {
async function handleSubmit(): Promise<void> {
if (!form.username || !form.password) return
try {
await authStore.login(form.username, form.password)
await authStore.login(form.username, form.password, form.rememberMe)
router.replace(redirectTarget())
} catch {
/* error already in authStore.error */
}
}
onMounted(() => {
// If already authenticated (e.g. page reload with valid token), skip login
if (authStore.isAuthenticated) {
onMounted(async () => {
// If a refresh token is already stored, run the cold-start probe
// 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())
}
})
@ -167,6 +181,10 @@ onMounted(() => {
font-weight: 600;
}
.login-remember {
margin-bottom: 16px;
}
.login-footer {
margin-top: 24px;
text-align: center;

View File

@ -121,6 +121,17 @@
<a-button type="primary" :loading="settingsStore.isSaving" @click="handleSave">保存系统配置</a-button>
</a-form>
</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>
<div v-if="settingsStore.saveSuccess" class="settings-view__alert">
@ -136,9 +147,12 @@
import { ref, onMounted } from 'vue'
import { message } from 'ant-design-vue'
import { useSettingsStore } from '@/stores/settings'
import ActiveSessionsPanel from '@/components/settings/ActiveSessionsPanel.vue'
import ChangePasswordPanel from '@/components/settings/ChangePasswordPanel.vue'
const settingsStore = useSettingsStore()
const activeTab = ref('llm')
const securityTab = ref('sessions')
onMounted(() => {
settingsStore.fetchSettings()

View File

@ -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)
})
})

View File

@ -1,11 +1,18 @@
"""Authentication REST routes.
"""Authentication REST routes (U4 — Centralized Auth & Token Persistence).
Endpoints
---------
- ``POST /auth/login`` username + password access + refresh JWT pair
- ``POST /auth/refresh`` refresh token new access token
- ``POST /auth/logout`` revoke refresh token
- ``GET /auth/me`` current user info (requires auth)
- ``POST /auth/login`` username + password (+ remember_me) JWT pair + session
- ``POST /auth/refresh`` refresh token new access token (rotated session)
- ``POST /auth/logout`` revoke current session
- ``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
``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
import hashlib
import logging
import uuid
from datetime import datetime, timezone
from pathlib import Path
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.jwt_utils import (
ACCESS_TOKEN_TTL,
REFRESH_TOKEN_TTL,
REFRESH_TOKEN_TTL_REMEMBER_ME,
create_token_pair,
get_or_create_jwt_secret,
verify_token,
)
from agentkit.server.auth.models import DEFAULT_AUTH_DB_PATH, init_auth_db
from agentkit.server.auth.password import verify_password
from agentkit.server.auth.models import (
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__)
router = APIRouter(prefix="/auth", tags=["auth"])
# Pre-computed valid bcrypt hash format for timing-attack mitigation.
# This is a valid $2b$12$ hash (correct salt + hash length, valid base64
# alphabet) so bcrypt.checkpw runs the full computation (~250ms) instead
# of raising ValueError immediately. The hash itself is meaningless —
# 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"
# Paths under /auth that the AuthMiddleware whitelists. The middleware
# already knows about /auth/login, /auth/refresh, /auth/logout; the new
# /auth/whoami requires a valid token so it's NOT whitelisted.
_AUTH_PUBLIC_PATHS = ("/auth/login", "/auth/refresh")
# ---------------------------------------------------------------------------
@ -60,6 +77,7 @@ class LoginRequest(BaseModel):
username: str
password: str
remember_me: bool = False
class RefreshRequest(BaseModel):
@ -70,6 +88,23 @@ class RefreshRequest(BaseModel):
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):
"""Public user representation (no password hash)."""
@ -84,6 +119,26 @@ class UserResponse(BaseModel):
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):
"""JWT pair + user info returned by /auth/login and /auth/refresh."""
@ -94,6 +149,7 @@ class TokenResponse(BaseModel):
token_type: str = "bearer"
expires_in: int = int(ACCESS_TOKEN_TTL.total_seconds())
user: UserResponse
session_id: str
# ---------------------------------------------------------------------------
@ -101,23 +157,16 @@ class TokenResponse(BaseModel):
# ---------------------------------------------------------------------------
def _sha256(text: str) -> str:
"""SHA-256 hex digest of a string."""
return hashlib.sha256(text.encode("utf-8")).hexdigest()
def _now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
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)
return Path(path) if path else DEFAULT_AUTH_DB_PATH
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)
if secret:
return secret
@ -125,7 +174,6 @@ def _resolve_jwt_secret(request: Request) -> str:
async def _ensure_db(request: Request) -> Path:
"""Ensure the auth DB exists and return its path."""
db_path = _resolve_db_path(request)
if not db_path.exists():
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:
"""Convert a users row to a :class:`UserResponse`."""
return UserResponse(
id=row["id"],
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
# ---------------------------------------------------------------------------
@ -155,75 +317,127 @@ async def login(payload: LoginRequest, request: Request) -> TokenResponse:
"""Authenticate with username + password and receive a JWT pair.
Flow:
1. Look up user by username.
2. Verify bcrypt password hash.
3. Ensure account is active.
4. Issue access + refresh JWTs.
5. Persist refresh-token hash to ``user_sessions``.
6. Update ``last_login_at``.
1. Validate via the configured :class:`AuthProvider`.
2. Issue access + refresh JWTs (with ``sid`` / ``jti``).
3. Persist the session to ``auth_sessions`` (V2).
4. 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)
secret = _resolve_jwt_secret(request)
async with aiosqlite.connect(str(db_path)) as db:
db.row_factory = aiosqlite.Row
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,
from agentkit.server.auth.providers import (
InvalidCredentials,
LocalAuthProvider,
)
# Persist refresh-token session
session_id = str(uuid.uuid4())
refresh_hash = _sha256(token_pair.refresh_token)
now_iso = datetime.now(timezone.utc).isoformat()
refresh_exp_iso = token_pair.refresh_expires_at.isoformat()
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),
# 1. Authenticate (construct a provider bound to this request's
# auth DB so the route works for both the global singleton and
# per-request overrides such as test fixtures that swap the path).
try:
user = await LocalAuthProvider(db_path=db_path).authenticate(
username=payload.username, password=payload.password
)
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(
"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()
user_resp = _user_row_to_response(row)
return TokenResponse(
access_token=token_pair.access_token,
refresh_token=token_pair.refresh_token,
token_type="bearer",
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:
"""Exchange a valid refresh token for a new access token.
The refresh token's hash must match an unrevoked ``user_sessions`` row.
A new access token is issued; the refresh token itself is *not* rotated
in V1 (rotation is deferred to a later hardening pass).
Implements refresh-token rotation:
1. Verify the refresh token's JWT signature and ``type`` claim.
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)
secret = _resolve_jwt_secret(request)
svc: SessionService = get_session_service()
# 1. Verify signature + type
try:
refresh_payload = verify_token(payload.refresh_token, secret)
except Exception as exc: # noqa: BLE001 — PyJWT raises InvalidTokenError subclasses
refresh_payload = verify_token(payload.refresh_token, secret, expected_type="refresh")
except Exception as exc: # noqa: BLE001
raise HTTPException(status_code=401, detail="Invalid refresh token") from exc
if refresh_payload.get("type") != "refresh":
raise HTTPException(status_code=401, detail="Invalid refresh token")
refresh_hash = _sha256(payload.refresh_token)
# 2-3. Validate the session (also handles reuse detection)
try:
new_pair = create_token_pair(
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:
db.row_factory = aiosqlite.Row
cursor = await db.execute(
"SELECT * FROM user_sessions WHERE refresh_token_hash = ?",
(refresh_hash,),
)
session = await cursor.fetchone()
cursor = await db.execute("SELECT * FROM users WHERE id = ?", (refresh_payload["sub"],))
user_row = await cursor.fetchone()
if user_row is None or not bool(user_row["is_active"]):
raise HTTPException(status_code=401, detail="User not found or disabled")
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(
access_token=token_pair.access_token,
refresh_token=payload.refresh_token,
token_type="bearer",
access_token=new_pair.access_token,
refresh_token=new_pair.refresh_token,
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")
async def logout(payload: RefreshRequest, request: Request) -> dict[str, Any]:
"""Revoke a refresh token by marking its session row as revoked.
Idempotent: calling logout with an already-revoked or unknown token
returns 200 with ``revoked=false`` (no error).
"""
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
async def logout(payload: LogoutRequest, request: Request) -> dict[str, Any]:
"""Revoke the refresh-token session. Idempotent."""
svc: SessionService = get_session_service()
revoked = await svc.revoke_by_refresh_token(
payload.refresh_token, reason=REVOKE_REASON_USER_TERMINATED
)
return {"revoked": revoked}
@router.get("/me", response_model=UserResponse)
async def me(
@router.get("/whoami", response_model=UserResponse)
async def whoami(
request: Request,
user: dict[str, Any] = Depends(require_authenticated),
) -> 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
endpoint re-fetches the full record from the auth DB so callers see the
freshest ``email`` / ``is_active`` / terminal-authorization flags.
Used by the client for cold-start (after restoring a refresh token
from local storage) to verify the session is still valid AND fetch
the freshest user profile.
"""
user_id = user.get("user_id")
if not user_id:
@ -344,8 +534,241 @@ async def me(
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")
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)

View File

@ -22,6 +22,7 @@ import pytest
from fastapi import FastAPI, Request
from fastapi.testclient import TestClient
from agentkit.server.auth.denylist import InMemoryRecentlyRevoked
from agentkit.server.auth.jwt_utils import (
create_token_pair,
verify_token,
@ -29,6 +30,7 @@ from agentkit.server.auth.jwt_utils import (
from agentkit.server.auth.middleware import AuthMiddleware
from agentkit.server.auth.models import init_auth_db
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
@ -107,8 +109,24 @@ def auth_app(jwt_secret: str, auth_db_with_user: dict[str, Any]) -> FastAPI:
@pytest.fixture
def auth_client(auth_app: FastAPI) -> TestClient:
"""TestClient for the auth-only app (no auth middleware)."""
return TestClient(auth_app)
"""TestClient for the auth-only app (no auth middleware).
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 "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(
self,