Files
BYW-VoiceHub/server/utils/oauth.ts
T

308 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import CryptoJS from 'crypto-js'
import { createHash, createHmac, randomBytes, timingSafeEqual } from 'node:crypto'
import { createError } from 'h3'
export interface OAuthState {
target: string
csrf: string
timestamp: number
provider?: string
returnTo?: string
loginType?: string
}
export const getSafeOAuthReturnPath = (value: unknown): string | undefined => {
const path = Array.isArray(value) ? value[0] : value
return typeof path === 'string' &&
path.startsWith('/') &&
!path.startsWith('//') &&
!path.startsWith('/\\')
? path
: undefined
}
const normalizePort = (url: URL): string => {
if (url.port) return url.port
if (url.protocol === 'https:') return '443'
if (url.protocol === 'http:') return '80'
return ''
}
const OAUTH_STATE_COOKIE_PREFIX = 'b64url:'
export interface OAuthStateCookieNames {
csrf: string
fullState: string
compactState: string
}
export const LEGACY_OAUTH_STATE_COOKIE_NAMES: OAuthStateCookieNames = {
csrf: 'oauth_csrf',
fullState: 'oauth_full_state',
compactState: 'oauth_compact_state'
}
export const getOAuthStateCookieNames = (compactState?: string): OAuthStateCookieNames => {
if (!compactState) return LEGACY_OAUTH_STATE_COOKIE_NAMES
const suffix = createHash('sha256').update(compactState).digest('hex').slice(0, 24)
return {
csrf: `oauth_csrf_${suffix}`,
fullState: `oauth_full_state_${suffix}`,
compactState: `oauth_compact_state_${suffix}`
}
}
// AES state 是标准 Base64,Cookie 中只需转换为 URL 安全格式以避免 +、/、= 被改写。
export const encodeOAuthStateCookie = (state: string): string =>
`${OAUTH_STATE_COOKIE_PREFIX}${Buffer.from(state, 'base64').toString('base64url')}`
export const decodeOAuthStateCookie = (state: string): string => {
if (!state.startsWith(OAUTH_STATE_COOKIE_PREFIX)) {
return state
}
const encodedState = state.slice(OAUTH_STATE_COOKIE_PREFIX.length)
if (!encodedState || !/^[A-Za-z0-9_-]+$/.test(encodedState)) {
return ''
}
try {
const decodedState = Buffer.from(encodedState, 'base64url')
if (decodedState.toString('base64url') !== encodedState) {
return ''
}
return decodedState.toString('base64')
} catch {
return ''
}
}
// 生成 OAuth 状态参数
export const generateState = (
targetOrigin: string,
provider?: string,
secretKey?: string,
returnTo?: string,
loginType?: string
): { state: string; csrf: string } => {
if (!secretKey) {
throw createError({
statusCode: 500,
message: 'OAuth State 密钥未配置,请在管理员后台配置 OAuth State 密钥'
})
}
const csrf = randomBytes(32).toString('hex')
const payload: OAuthState = {
target: targetOrigin,
csrf,
timestamp: Date.now(),
provider,
returnTo: getSafeOAuthReturnPath(returnTo),
loginType
}
const json = JSON.stringify(payload)
const state = CryptoJS.AES.encrypt(json, secretKey).toString()
return { state, csrf }
}
// 聚合登录服务会把 state 截断为 100 字符,因此 Broker 只接收携带源站的可验签短路由信息。
// 完整状态保存在 HttpOnly Cookie 中,回调时仍会校验 Origin、CSRF 和过期时间。
export const generateCompactOAuthState = (targetOrigin: string, secretKey?: string): string => {
if (!secretKey) {
throw createError({ statusCode: 500, message: 'OAuth State 密钥未配置' })
}
const target = new URL(targetOrigin).origin
const encodedTarget = Buffer.from(target, 'utf8').toString('base64url')
const nonce = randomBytes(8).toString('base64url')
const payload = `v1.${encodedTarget}.${nonce}`
const signature = createHmac('sha256', secretKey)
.update(payload)
.digest()
.subarray(0, 12)
.toString('base64url')
const state = `${payload}.${signature}`
if (state.length > 100) {
throw createError({
statusCode: 400,
message: '当前站点地址过长,无法兼容聚合登录的 state 长度限制'
})
}
return state
}
// 校验紧凑 state 的 HMAC 签名,阻止伪造的 state 进入后续流程。
export const verifyCompactOAuthState = (state: string, secretKey?: string): boolean => {
if (!secretKey) {
throw createError({ statusCode: 500, message: 'OAuth State 密钥未配置' })
}
const lastDot = state.lastIndexOf('.')
if (lastDot <= 0 || !state.startsWith('v1.')) return false
try {
const payload = state.slice(0, lastDot)
const expected = createHmac('sha256', secretKey).update(payload).digest().subarray(0, 12)
const actual = Buffer.from(state.slice(lastDot + 1), 'base64url')
return actual.length === expected.length && timingSafeEqual(actual, expected)
} catch {
return false
}
}
// 解析 OAuth 状态参数
export const parseState = (
stateStr: string,
expectedOrigin?: string,
expectedCsrf?: string,
secretKey?: string
): OAuthState | null => {
if (!secretKey) {
throw createError({
statusCode: 500,
message: 'OAuth State 密钥未配置,请在管理员后台配置 OAuth State 密钥'
})
}
// 修复 base64 中的 '+' 在 URL 传输中被错误解析为空格的问题
// 某些 OAuth 提供商或浏览器环境可能会导致 URL 查询参数中的 '+' 号变成空格
const sanitizedStateStr = stateStr.replace(/ /g, '+')
try {
const bytes = CryptoJS.AES.decrypt(sanitizedStateStr, secretKey)
const json = bytes.toString(CryptoJS.enc.Utf8)
if (!json) return null
const payload = JSON.parse(json)
// 检查时间戳是否过期(例如:10分钟)
if (Date.now() - payload.timestamp > 10 * 60 * 1000) {
console.error('OAuth state expired')
return null
}
// 验证 target origin - 在Codespaces等环境中,两次请求可能使用不同的host
// 所以我们只验证protocol相同,但允许不同的subdomain
if (expectedOrigin && payload.target) {
try {
const expectedUrl = new URL(expectedOrigin)
const payloadUrl = new URL(payload.target)
// 如果protocol不匹配,拒绝
if (expectedUrl.protocol !== payloadUrl.protocol) {
console.error(
`OAuth state protocol mismatch: ${expectedUrl.protocol} vs ${payloadUrl.protocol}`
)
return null
}
// 判断是否为相同的源站
// 使用 hostname 避免端口导致误判,端口单独比较
const expectedHost = expectedUrl.hostname
const payloadHost = payloadUrl.hostname
const expectedPort = normalizePort(expectedUrl)
const payloadPort = normalizePort(payloadUrl)
// hostname 相同时也要校验端口一致,避免同 host 不同端口被接受
if (expectedHost === payloadHost) {
if (expectedPort !== payloadPort) {
console.error(`OAuth state port mismatch: ${expectedPort} vs ${payloadPort}`)
return null
}
} else {
// host 不同 - 检查是否为已知的需要兼容的环境
// 仅在 *.github.dev(Codespaces 环境)中允许不同的子域名
const isExpectedGitHubDev =
expectedHost.endsWith('.github.dev') ||
expectedHost === 'localhost' ||
expectedHost === '127.0.0.1'
const isPayloadGitHubDev =
payloadHost.endsWith('.github.dev') ||
payloadHost === 'localhost' ||
payloadHost === '127.0.0.1'
if (isExpectedGitHubDev && isPayloadGitHubDev) {
// 在 Codespaces 等环境中,允许子域名变化(例如端口号变化导致的 subdomain 变化)
// 但仍需要验证基础域名相同(.github.dev)
const expectedParts = expectedHost.split('.')
const payloadParts = payloadHost.split('.')
// 对于 localhost, 要求完全相同
if (expectedHost === 'localhost' || payloadHost === 'localhost') {
if (expectedHost !== payloadHost || expectedPort !== payloadPort) {
console.error(
`OAuth state host/port mismatch (localhost): ${expectedHost}:${expectedPort} vs ${payloadHost}:${payloadPort}`
)
return null
}
} else if (expectedHost === '127.0.0.1' || payloadHost === '127.0.0.1') {
// 对于 127.0.0.1,也要求完全相同
if (expectedHost !== payloadHost || expectedPort !== payloadPort) {
console.error(
`OAuth state host/port mismatch (127.0.0.1): ${expectedHost}:${expectedPort} vs ${payloadHost}:${payloadPort}`
)
return null
}
} else {
// 对于 *.github.dev,允许不同的子域名,但要求基础部分相同
// 例如:crispy-funicular-q77p479p56r9hx7vr-3000.app.github.dev
// 和 crispy-funicular-q77p479p56r9hx7vr.app.github.dev 都可以通过
const expectedBase = expectedParts.slice(-3).join('.')
const payloadBase = payloadParts.slice(-3).join('.')
if (expectedBase !== payloadBase) {
console.error(`OAuth state domain mismatch: ${expectedBase} vs ${payloadBase}`)
return null
}
}
} else {
// 对于非白名单环境,要求 host 完全相同
console.error(`OAuth state host mismatch: ${expectedHost} vs ${payloadHost}`)
return null
}
}
} catch (e) {
console.error('Failed to parse OAuth state URLs', e)
return null
}
}
// 验证 CSRF
if (!expectedCsrf || payload.csrf !== expectedCsrf) {
console.error('OAuth state CSRF mismatch or missing cookie')
return null
}
payload.returnTo = getSafeOAuthReturnPath(payload.returnTo)
return payload
} catch {
// 解密失败通常来自密钥不一致或客户端状态损坏,不应作为服务端异常上报。
console.warn('OAuth state 解密失败:state 已损坏、被截断或 OAuth State 密钥不一致')
return null
}
}
export const getRedirectUri = (provider: string, redirectUriTemplate?: string): string => {
let redirectUri = redirectUriTemplate
if (!redirectUri) {
throw createError({
statusCode: 500,
message: 'OAuth 重定向 URI 未配置,请在管理员后台配置 OAuth 重定向 URI'
})
}
// 支持 [provider] 占位符
redirectUri = redirectUri.replace('[provider]', provider)
// 兼容用户可能错误地将 "provider" 作为字面量填写的情况
if (redirectUri.includes('/provider/callback')) {
redirectUri = redirectUri.replace('/provider/callback', `/${provider}/callback`)
}
return redirectUri
}