auth.ts 7.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161
  1. import CryptoJS from 'crypto-js'
  2. import useStore from '../store'
  3. import type { AuthPayload } from '../store'
  4. export type { AuthPayload } from '../store'
  5. export { AUTH_TOKEN_KEY, AUTH_DISPLAY_NAME_KEY, AUTH_ROLE_KEY } from '../store'
  6. /** 认证工具:生成授权链接、管理 PKCE 临时会话,以及读写当前标签页的登录信息。 */
  7. /** 是否使用本地 Mock 账号登录,由构建环境配置决定。 */
  8. export const mockLogin = import.meta.env.VITE_USE_MOCK === 'true'
  9. /** 保留参考实现的原始值;不作为 PKCE verifier 或随机数种子使用。 */
  10. export const originRandomString = 'ABCDEFGHIJ'
  11. /** 使用密码学安全随机数生成字符串,默认 128 位,可用于 PKCE verifier。 */
  12. export function generateRandomString(length = 128) {
  13. if (!Number.isInteger(length) || length < 1 || length > 1024) throw new Error('随机字符串长度必须为 1 到 1024 的整数')
  14. const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
  15. let result = ''
  16. while (result.length < length) {
  17. const bytes = crypto.getRandomValues(new Uint8Array(length - result.length))
  18. for (const byte of bytes) {
  19. if (byte < 248) result += characters[byte % characters.length]
  20. }
  21. }
  22. return result
  23. }
  24. /** 登录成功后保存令牌、名称和角色,并解除自动认证暂停标记。 */
  25. export function setAuthInfo(payload : AuthPayload) {
  26. useStore().SET_AUTH_INFO(payload)
  27. }
  28. /** 获取访问令牌并去掉已有的 Bearer 前缀,由请求拦截器统一添加前缀。 */
  29. export function getToken() {
  30. const store = useStore()
  31. return store.accessToken || store.token || ''
  32. }
  33. /** 获取用于界面展示的用户名称,未登录时返回空字符串。 */
  34. export const getDisplayName = () => useStore().displayName || ''
  35. /** 读取缓存的角色列表;仅供前端使用,实际操作权限必须由后端校验。 */
  36. export function getRole() : string[] {
  37. return useStore().role
  38. }
  39. /** 清除本应用的登录信息,不会注销认证中心的单点登录会话。 */
  40. export function clearAuthInfo() {
  41. useStore().CLEAR_AUTH_INFO()
  42. }
  43. /** 优先使用配置的回调地址,否则使用 forward.html 中转页,避免 history 路由 404。 */
  44. export function getOAuthRedirectUri() {
  45. return String(import.meta.env.VITE_OAUTH_REDIRECT_URI || '').trim() || `${location.origin}/forward.html`
  46. }
  47. /** 读取有效的 PKCE 缓存:verifier 长度为 43~128 位,不设置本地过期时间。 */
  48. function readTransaction() {
  49. try {
  50. const value = useStore().oauthTransaction
  51. if (!value || typeof value.verifier !== 'string' || !/^[A-Za-z0-9._~-]{43,128}$/.test(value.verifier)) return null
  52. return { ...value }
  53. } catch { return null }
  54. }
  55. /** 获取缓存的 code_verifier,供回调换取令牌使用;无有效缓存时返回空字符串。 */
  56. export const getCachedVerifier = () => readTransaction()?.verifier || ''
  57. /** 获取发起授权时生成的 state,用于校验回调是否属于本次登录。 */
  58. export const getCachedOAuthState = () => readTransaction()?.state || ''
  59. export const getCachedOAuthNonce = () => readTransaction()?.nonce || ''
  60. /** 读取并校验授权地址、令牌地址、客户端 ID 和回调地址,缺少配置时停止登录。 */
  61. export function getOAuthConfig() {
  62. const productionBase = String(import.meta.env.VITE_API_AUTH_URL || '').trim()
  63. const baseURL = productionBase.replace(/\/+$/, '')
  64. const authorizeURL = `${baseURL}/oauth2/authorize`
  65. const tokenURL = `${baseURL}/oauth2/token`
  66. const responseMode = String(import.meta.env.VITE_OAUTH_RESPONSE_MODE || 'query').trim()
  67. if (!['query', 'form_post'].includes(responseMode)) throw new Error('回调模式仅支持 query 或 form_post')
  68. const redirectURI = getOAuthRedirectUri()
  69. if (!authorizeURL || !tokenURL) throw new Error('请配置认证地址、令牌地址')
  70. for (const value of [authorizeURL, tokenURL, redirectURI]) {
  71. const url = new URL(value, location.origin)
  72. if (!['http:', 'https:'].includes(url.protocol)) throw new Error('认证地址必须使用 HTTP 或 HTTPS')
  73. }
  74. if (new URL(redirectURI).hash) throw new Error('OAuth 回调地址不能包含 #')
  75. const clientId = 'client'
  76. return { authorizeURL, tokenURL, clientId, redirectURI, responseMode }
  77. }
  78. /** 同步计算 SHA-256 摘要并编码为 Base64URL;不是可逆加密。 */
  79. export function sha256(message : string) {
  80. return CryptoJS.SHA256(message).toString(CryptoJS.enc.Base64)
  81. .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
  82. }
  83. /** 保留原有方法名称,与 sha256 使用同一套 S256 计算逻辑。 */
  84. export const createChallenge = sha256
  85. /** 生成新的 verifier 和 state,缓存本次授权会话并返回 challenge;再次调用会覆盖旧会话。 */
  86. export function generatePKCE() {
  87. const verifier = generateRandomString()
  88. const challenge = sha256(verifier)
  89. useStore().SET_OAUTH_TRANSACTION({
  90. verifier, state: generateRandomString(10),
  91. nonce: generateRandomString(12),
  92. redirectURI: getOAuthRedirectUri()
  93. })
  94. return challenge
  95. }
  96. /** 生成授权链接;form_post 模式必须有服务端接收并转交回调,静态前端默认使用 query。 */
  97. export function getOAuthLoginUrl() {
  98. const config = getOAuthConfig()
  99. const challenge = generatePKCE()
  100. const state = getCachedOAuthState()
  101. const nonce = getCachedOAuthNonce()
  102. const url = new URL(config.authorizeURL, location.origin)
  103. url.search = new URLSearchParams({
  104. client_id: config.clientId,
  105. redirect_uri: config.redirectURI,
  106. response_type: 'code',
  107. response_mode: config.responseMode,
  108. scope: 'openid',
  109. state,
  110. nonce,
  111. code_challenge: challenge,
  112. code_challenge_method: 'S256'
  113. }).toString()
  114. return url.toString()
  115. }
  116. /** 兼容原有 URL 大写命名。 */
  117. export const getOAuthLoginURL = getOAuthLoginUrl
  118. /** 校验回调 state 和回调地址,返回换取令牌所需的 verifier 与回调地址。 */
  119. export function consumeOAuthTransaction(state : string | null) {
  120. const transaction = readTransaction()
  121. // 无论校验成功与否都删除缓存,禁止重复使用;失败后需重新发起认证。
  122. useStore().CLEAR_OAUTH_TEMP()
  123. if (!state || !transaction || transaction.state !== state || typeof transaction.verifier !== 'string'
  124. || transaction.redirectURI !== getOAuthConfig().redirectURI) {
  125. throw new Error('登录会话已失效或状态校验失败,请重新认证')
  126. }
  127. return { verifier: transaction.verifier as string, redirectURI: transaction.redirectURI as string }
  128. }
  129. /** 清除 PKCE 临时会话,用于认证失败、退出登录或登录失效后的清理。 */
  130. export function clearOAuthTemp() {
  131. useStore().CLEAR_OAUTH_TEMP()
  132. }
  133. /** 生成 OIDC 登出链接,跳转到认证服务器登出后再回到登录页。 */
  134. export function getOAuthLogoutUrl() {
  135. const config = getOAuthConfig()
  136. const baseURL = config.authorizeURL.replace(/\/oauth2\/authorize$/, '')
  137. // 登出后回到应用根路径,由路由守卫自动跳转到登录页
  138. const postLogoutRedirectUri = location.origin + '/'
  139. return `${baseURL}/logout?post_logout_redirect_uri=${encodeURIComponent(postLogoutRedirectUri)}`
  140. }