Files
oci-portal-dash/src/api/auth.ts
T
2026-07-30 12:23:16 +08:00

313 lines
11 KiB
TypeScript

import type {
AuthenticationResponseJSON,
PublicKeyCredentialCreationOptionsJSON,
PublicKeyCredentialRequestOptionsJSON,
RegistrationResponseJSON,
} from '@simplewebauthn/browser'
import { mockOn, mocked, request } from './request'
import type {
CredentialsInfo,
OauthProviderInfo,
LoginRequest,
LoginResponse,
OAuthSettings,
PasskeyCeremonyOptions,
PasskeyInfo,
SessionItem,
SessionRefresh,
TotpSetup,
TotpStatus,
UpdateCredentialsRequest,
UpdateOAuthRequest,
UserIdentityInfo,
} from '@/types/api'
export function login(body: LoginRequest): Promise<LoginResponse> {
if (mockOn) {
if (body.password === 'wrong') return Promise.reject(new Error('用户名或密码错误'))
return mocked({ token: 'mock-token', expiresAt: '2099-01-01T00:00:00Z' }, 400)
}
return request('/auth/login', { method: 'POST', body })
}
/** 服务端登出:当前令牌拉黑至自然过期;调用方无论成败都应清本地会话 */
export function logout(): Promise<void> {
if (mockOn) return mocked(undefined, 100)
return request('/auth/logout', { method: 'POST' })
}
// ---- 会话 ----
/** 撤销全部会话:旧 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', 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' })
}
// ---- 登录凭据 ----
export function getCredentials(): Promise<CredentialsInfo> {
if (mockOn) return mocked({ username: 'admin', passwordLoginDisabled: false })
return request('/auth/credentials')
}
/** 修改用户名 / 密码;成功后旧 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, 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 },
refreshesSession: true,
})
}
// ---- 两步验证(TOTP) ----
export function getTotpStatus(): Promise<TotpStatus> {
if (mockOn) return mocked({ enabled: false })
return request('/auth/totp')
}
/** 生成待激活密钥;10 分钟内输入验证码激活,重复调用覆盖旧暂存 */
export function setupTotp(): Promise<TotpSetup> {
if (mockOn)
return mocked({
secret: 'JBSWY3DPEHPK3PXP',
otpauthUri: 'otpauth://totp/oci-portal:admin?secret=JBSWY3DPEHPK3PXP&issuer=oci-portal',
})
return request('/auth/totp/setup', { method: 'POST' })
}
/** 激活两步验证;成功后旧 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 },
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, refreshesSession: true })
}
// ---- 外部身份(OAuth) ----
/** 可登录的 provider 列表(登录页公开接口;已禁用的不返回);
* passwordLoginDisabled 为 true 且有可用 provider 时,登录页隐藏密码表单;
* passkeyLogin / walletLogin 为 true 表示存在对应凭据,登录页显示入口 */
export function getOauthProviders(): Promise<{
providers: OauthProviderInfo[]
passwordLoginDisabled: boolean
passkeyLogin: boolean
walletLogin: boolean
}> {
if (mockOn)
return mocked({
providers: [
{ provider: 'github', displayName: 'GitHub' },
{ provider: 'oidc', displayName: 'OIDC SSO' },
],
passwordLoginDisabled: false,
passkeyLogin: true,
walletLogin: true,
})
return request('/auth/oauth/providers')
}
/** 获取授权跳转 URL;bind 模式要求已登录 */
export function getOauthAuthorizeUrl(
provider: string,
mode: 'login' | 'bind',
): Promise<{ url: string }> {
if (mockOn) return mocked({ url: 'https://example.com/oauth/authorize?mock=1' })
return request(`/auth/oauth/${provider}/authorize`, { query: { mode } })
}
export function listIdentities(): Promise<{ items: UserIdentityInfo[] }> {
if (mockOn)
return mocked({
items: [
{ id: 1, provider: 'github', display: 'octocat', createdAt: '2026-07-07T10:00:00+08:00' },
],
})
return request('/auth/identities')
}
/** 解绑外部身份;成功后旧 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', 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 配置(设置页) ----
const mockOauth: OAuthSettings = {
oidcIssuer: '',
oidcClientId: '',
oidcSecretSet: false,
oidcDisplayName: '',
oidcDisabled: false,
githubClientId: '',
githubSecretSet: false,
githubDisplayName: '',
githubDisabled: false,
}
export function getOAuthSettings(): Promise<OAuthSettings> {
if (mockOn) return mocked({ ...mockOauth })
return request('/settings/oauth')
}
/** 字段补丁部分更新:缺省字段沿用现值,并发编辑不同 provider 互不回滚 */
export function updateOAuthSettings(body: UpdateOAuthRequest): Promise<OAuthSettings> {
if (mockOn) {
const { oidcClientSecret, githubClientSecret, ...rest } = body
Object.assign(mockOauth, Object.fromEntries(Object.entries(rest).filter(([, v]) => v !== undefined)))
if (oidcClientSecret !== undefined) mockOauth.oidcSecretSet = oidcClientSecret !== ''
if (githubClientSecret !== undefined) mockOauth.githubSecretSet = githubClientSecret !== ''
return mocked({ ...mockOauth })
}
return request('/settings/oauth', { method: 'PATCH', body })
}