安全页重构:免密登录与活跃会话;按钮规范统一
CI / test (push) Successful in 53s

This commit is contained in:
2026-07-30 12:23:16 +08:00
parent d0fbecbd43
commit d675b950ee
43 changed files with 1944 additions and 475 deletions
+160 -7
View File
@@ -1,3 +1,10 @@
import type {
AuthenticationResponseJSON,
PublicKeyCredentialCreationOptionsJSON,
PublicKeyCredentialRequestOptionsJSON,
RegistrationResponseJSON,
} from '@simplewebauthn/browser'
import { mockOn, mocked, request } from './request'
import type {
CredentialsInfo,
@@ -5,6 +12,9 @@ import type {
LoginRequest,
LoginResponse,
OAuthSettings,
PasskeyCeremonyOptions,
PasskeyInfo,
SessionItem,
SessionRefresh,
TotpSetup,
TotpStatus,
@@ -32,7 +42,36 @@ export function logout(): Promise<void> {
/** 撤销全部会话:旧 token 全部失效,响应带操作者的新会话 */
export function revokeSessions(): Promise<SessionRefresh> {
if (mockOn) return mocked({ token: 'mock-token-2', expiresAt: '2099-01-01T00:00:00Z' }, 300)
return request('/auth/revoke-sessions', { method: 'POST' })
return request('/auth/revoke-sessions', { method: 'POST', refreshesSession: true })
}
/** mock 会话样例:相对当前时间偏移,保证「最近活跃」展示合理 */
function mockSessions(): { items: SessionItem[] } {
const ago = (min: number) => new Date(Date.now() - min * 60000).toISOString()
const later = new Date(Date.now() + 23 * 3600000).toISOString()
const mk = (id: number, method: string, clientIp: string, userAgent: string, seen: number, created: number, current = false): SessionItem => ({
id, method, clientIp, userAgent,
createdAt: ago(created), lastSeenAt: ago(seen), expiresAt: later, current,
})
return {
items: [
mk(1, 'password', '198.51.100.7', 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 Chrome/126.0 Safari/537.36', 0, 180, true),
mk(2, 'passkey', '203.0.113.24', 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 Version/17.5 Mobile/15E148 Safari/604.1', 185, 205),
mk(3, 'wallet', '192.0.2.88', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/126.0 Safari/537.36 Edg/126.0', 780, 800),
],
}
}
/** 活跃会话列表:未撤销、未过期、版本为当前,最近活跃在前 */
export function listSessions(): Promise<{ items: SessionItem[] }> {
if (mockOn) return mocked(mockSessions(), 300)
return request('/auth/sessions')
}
/** 定点撤销一个其他会话;当前会话不可撤销(请走退出登录) */
export function revokeSession(id: number): Promise<void> {
if (mockOn) return mocked(undefined, 250)
return request(`/auth/sessions/${id}`, { method: 'DELETE' })
}
// ---- 登录凭据 ----
@@ -45,13 +84,17 @@ export function getCredentials(): Promise<CredentialsInfo> {
/** 修改用户名 / 密码;成功后旧 token 全部失效,响应带操作者的新会话 */
export function updateCredentials(body: UpdateCredentialsRequest): Promise<SessionRefresh> {
if (mockOn) return mocked({ token: 'mock-token-2', expiresAt: '2099-01-01T00:00:00Z' }, 400)
return request('/auth/credentials', { method: 'PUT', body })
return request('/auth/credentials', { method: 'PUT', body, refreshesSession: true })
}
/** 保存密码登录禁用开关;成功后旧 token 全部失效,响应带新会话 */
export function updatePasswordLogin(disabled: boolean): Promise<SessionRefresh> {
if (mockOn) return mocked({ token: 'mock-token-2', expiresAt: '2099-01-01T00:00:00Z' }, 300)
return request('/auth/password-login', { method: 'PUT', body: { disabled } })
return request('/auth/password-login', {
method: 'PUT',
body: { disabled },
refreshesSession: true,
})
}
// ---- 两步验证(TOTP) ----
@@ -74,22 +117,29 @@ export function setupTotp(): Promise<TotpSetup> {
/** 激活两步验证;成功后旧 token 全部失效,响应带新会话 */
export function activateTotp(code: string): Promise<SessionRefresh> {
if (mockOn) return mocked({ token: 'mock-token-2', expiresAt: '2099-01-01T00:00:00Z' }, 300)
return request('/auth/totp/activate', { method: 'POST', body: { code } })
return request('/auth/totp/activate', {
method: 'POST',
body: { code },
refreshesSession: true,
})
}
/** 停用两步验证;密码或当前验证码任一确认,响应带新会话 */
export function disableTotp(body: { password?: string; code?: string }): Promise<SessionRefresh> {
if (mockOn) return mocked({ token: 'mock-token-2', expiresAt: '2099-01-01T00:00:00Z' }, 300)
return request('/auth/totp/disable', { method: 'POST', body })
return request('/auth/totp/disable', { method: 'POST', body, refreshesSession: true })
}
// ---- 外部身份(OAuth) ----
/** 可登录的 provider 列表(登录页公开接口;已禁用的不返回);
* passwordLoginDisabled 为 true 且有可用 provider 时,登录页隐藏密码表单 */
* passwordLoginDisabled 为 true 且有可用 provider 时,登录页隐藏密码表单;
* passkeyLogin / walletLogin 为 true 表示存在对应凭据,登录页显示入口 */
export function getOauthProviders(): Promise<{
providers: OauthProviderInfo[]
passwordLoginDisabled: boolean
passkeyLogin: boolean
walletLogin: boolean
}> {
if (mockOn)
return mocked({
@@ -98,6 +148,8 @@ export function getOauthProviders(): Promise<{
{ provider: 'oidc', displayName: 'OIDC SSO' },
],
passwordLoginDisabled: false,
passkeyLogin: true,
walletLogin: true,
})
return request('/auth/oauth/providers')
}
@@ -124,7 +176,108 @@ export function listIdentities(): Promise<{ items: UserIdentityInfo[] }> {
/** 解绑外部身份;成功后旧 token 全部失效,响应带新会话 */
export function unbindIdentity(id: number): Promise<SessionRefresh> {
if (mockOn) return mocked({ token: 'mock-token-2', expiresAt: '2099-01-01T00:00:00Z' }, 300)
return request(`/auth/identities/${id}`, { method: 'DELETE' })
return request(`/auth/identities/${id}`, { method: 'DELETE', refreshesSession: true })
}
// ---- 通行密钥(Passkey) ----
const mockPasskeyRegisterOptions: PasskeyCeremonyOptions<PublicKeyCredentialCreationOptionsJSON> = {
sessionId: 'mock-session',
options: {
publicKey: {
challenge: 'bW9jaw',
rp: { id: 'localhost', name: 'OCI Portal' },
user: { id: 'AQ', name: 'admin', displayName: 'admin' },
pubKeyCredParams: [{ type: 'public-key', alg: -7 }],
},
},
}
/** 发起通行密钥注册;sessionId 需原样带回 finish */
export function beginPasskeyRegister(): Promise<
PasskeyCeremonyOptions<PublicKeyCredentialCreationOptionsJSON>
> {
if (mockOn) return mocked(mockPasskeyRegisterOptions, 300)
return request('/auth/passkey/register/begin', { method: 'POST' })
}
/** 完成通行密钥注册;成功后旧 token 全部失效,响应带新会话 */
export function finishPasskeyRegister(
sessionId: string,
name: string,
credential: RegistrationResponseJSON,
): Promise<SessionRefresh> {
if (mockOn) return mocked({ token: 'mock-token-2', expiresAt: '2099-01-01T00:00:00Z' }, 300)
return request('/auth/passkey/register/finish', {
method: 'POST',
body: { sessionId, name, credential },
refreshesSession: true,
})
}
export function listPasskeys(): Promise<{ items: PasskeyInfo[] }> {
if (mockOn)
return mocked({
items: [
{ id: 1, name: 'MacBook Touch ID', createdAt: '2026-07-20T10:00:00+08:00', lastUsedAt: null },
],
})
return request('/auth/passkeys')
}
/** 删除通行密钥;成功后旧 token 全部失效,响应带新会话 */
export function removePasskey(id: number): Promise<SessionRefresh> {
if (mockOn) return mocked({ token: 'mock-token-2', expiresAt: '2099-01-01T00:00:00Z' }, 300)
return request(`/auth/passkeys/${id}`, { method: 'DELETE', refreshesSession: true })
}
/** 发起通行密钥登录(公开接口) */
export function beginPasskeyLogin(): Promise<
PasskeyCeremonyOptions<PublicKeyCredentialRequestOptionsJSON>
> {
if (mockOn)
return mocked({
sessionId: 'mock-session',
options: { publicKey: { challenge: 'bW9jaw', rpId: 'localhost' } },
})
return request('/auth/passkey/login/begin', { method: 'POST' })
}
/** 完成通行密钥登录;UV 通过后豁免 TOTP */
export function finishPasskeyLogin(
sessionId: string,
credential: AuthenticationResponseJSON,
): Promise<LoginResponse> {
if (mockOn) return mocked({ token: 'mock-token', expiresAt: '2099-01-01T00:00:00Z' }, 300)
return request('/auth/passkey/login/finish', {
method: 'POST',
body: { sessionId, credential },
})
}
// ---- Web3 钱包(SIWE) ----
/** 发起钱包签名挑战;mode=bind 要求已登录,message 需原样 personal_sign */
export function getWalletChallenge(
address: string,
mode: 'login' | 'bind',
): Promise<{ nonce: string; message: string }> {
if (mockOn)
return mocked({
nonce: 'mock-nonce',
message: `localhost wants you to sign in with your Ethereum account:\n${address}\n\n登录 OCI Portal 面板\n\nNonce: mock-nonce`,
})
return request('/auth/wallet/challenge', { method: 'POST', body: { address, mode } })
}
/** 校验钱包签名:login 返回新会话;bind 返回换发的新 token(旧会话已失效) */
export function verifyWallet(nonce: string, signature: string): Promise<LoginResponse> {
if (mockOn) return mocked({ token: 'mock-token', expiresAt: '2099-01-01T00:00:00Z' }, 300)
return request('/auth/wallet/verify', {
method: 'POST',
body: { nonce, signature },
refreshesSession: true,
})
}
// ---- OAuth provider 配置(设置页) ----