前端服务共享

This commit is contained in:
2025-12-11 14:21:36 +08:00
parent fa3dbe0496
commit 5ee9770747
46 changed files with 3732 additions and 1782 deletions

View File

@@ -1,6 +1,92 @@
import { api } from '@/api/index'
import type { LoginParam, LoginDomain } from '@/types'
/**
* 认证 API
* 通过 Gateway (8180) 访问 Auth Service (8181)
* 路由规则:/urban-lifeline/auth/** → auth-service/urban-lifeline/auth/**
*/
export const authAPI = {
baseUrl: "/auth",
baseUrl: "/urban-lifeline/auth",
/**
* 用户登录
* @param loginParam 登录参数
* @returns 登录结果(包含 token 和用户信息)
*/
login(loginParam: LoginParam) {
return api.post<LoginDomain>(`${this.baseUrl}/login`, loginParam)
},
/**
* 用户登出
* @returns 登出结果
*/
logout() {
return api.post<LoginDomain>(`${this.baseUrl}/logout`)
},
/**
* 获取验证码(统一接口)
* @param loginParam 登录参数(包含验证码类型)
* @returns 验证码结果
*/
getCaptcha(loginParam: LoginParam) {
return api.post<LoginDomain>(`${this.baseUrl}/captcha`, loginParam)
},
/**
* 刷新 Token
* @returns 新的登录信息
*/
refreshToken() {
return api.post<LoginDomain>(`${this.baseUrl}/refresh`)
},
/**
* 发送邮箱验证码
* @param email 邮箱地址
* @returns 发送结果
*/
sendEmailCode(email: string) {
return api.post<LoginDomain>(`${this.baseUrl}/send-email-code`, { email })
},
/**
* 发送短信验证码
* @param phone 手机号
* @returns 发送结果
*/
sendSmsCode(phone: string) {
return api.post<LoginDomain>(`${this.baseUrl}/send-sms-code`, { phone })
},
/**
* 用户注册
* @param registerData 注册数据
* @returns 注册结果(成功后自动登录,返回 token
*/
register(registerData: {
registerType: 'username' | 'phone' | 'email'
username?: string
phone?: string
email?: string
password: string
confirmPassword: string
smsCode?: string
emailCode?: string
smsSessionId?: string
emailSessionId?: string
studentId?: string
}) {
return api.post<LoginDomain>(`${this.baseUrl}/register`, registerData)
},
/**
* 健康检查
* @returns 健康状态
*/
health() {
return api.get<string>(`${this.baseUrl}/health`)
}
}

View File

@@ -0,0 +1,274 @@
# 加密工具
## AES-256-GCM 加密
### 概述
前端 AES 加密工具,与后端 `AesEncryptUtil` 保持一致,用于敏感信息传输加密。
### 使用场景
1. **密码传输**:登录时加密密码
2. **敏感信息**:加密手机号、身份证号等
### 快速开始
#### 1. 初始化(应用启动时)
```typescript
import { initAesEncrypt } from '@/utils/crypto'
// 在 main.ts 中初始化
const AES_SECRET_KEY = '1234567890qwer' // 与后端配置保持一致
await initAesEncrypt(AES_SECRET_KEY)
```
#### 2. 使用加密
```typescript
import { getAesInstance } from '@/utils/crypto'
// 获取加密实例
const aes = getAesInstance()
// 加密密码
const encryptedPassword = await aes.encryptPassword('myPassword123')
// 加密手机号
const encryptedPhone = await aes.encryptPhone('13812345678')
```
### 完整示例
#### 登录时加密密码
```typescript
import { authAPI } from '@/api/auth'
import { getAesInstance } from '@/utils/crypto'
async function login(username: string, password: string) {
try {
// 加密密码
const aes = getAesInstance()
const encryptedPassword = await aes.encryptPassword(password)
// 发送登录请求
const response = await authAPI.login({
username,
password: encryptedPassword,
loginType: 'password'
})
return response.data
} catch (error) {
console.error('登录失败:', error)
throw error
}
}
```
#### 手机号注册
```typescript
import { authAPI } from '@/api/auth'
import { getAesInstance } from '@/utils/crypto'
async function register(phone: string, password: string, smsCode: string) {
try {
const aes = getAesInstance()
// 加密密码
const encryptedPassword = await aes.encryptPassword(password)
// 加密手机号
const encryptedPhone = await aes.encryptPhone(phone)
// 发送注册请求
const response = await authAPI.register({
registerType: 'phone',
phone: encryptedPhone,
password: encryptedPassword,
confirmPassword: encryptedPassword,
smsCode,
smsSessionId: 'session-id-from-captcha'
})
return response.data
} catch (error) {
console.error('注册失败:', error)
throw error
}
}
```
### 工具函数
#### 数据脱敏
```typescript
import { AesUtils } from '@/utils/crypto'
// 脱敏手机号
const masked = AesUtils.maskPhone('13812345678')
// 输出138****5678
// 脱敏身份证号
const maskedId = AesUtils.maskIdCard('110101199001011234')
// 输出110101********1234
// 脱敏邮箱
const maskedEmail = AesUtils.maskEmail('test@example.com')
// 输出t***@example.com
```
### API 参考
#### AesEncryptUtil 类
| 方法 | 参数 | 返回值 | 说明 |
|------|------|--------|------|
| `init()` | - | `Promise<void>` | 初始化密钥(必须先调用) |
| `encrypt(plaintext)` | `string` | `Promise<string>` | 加密字符串 |
| `decrypt(ciphertext)` | `string` | `Promise<string>` | 解密字符串 |
| `encryptPassword(password)` | `string` | `Promise<string>` | 加密密码 |
| `encryptPhone(phone)` | `string` | `Promise<string>` | 加密手机号 |
| `decryptPhone(encrypted)` | `string` | `Promise<string>` | 解密手机号 |
| `encryptIdCard(idCard)` | `string` | `Promise<string>` | 加密身份证号 |
| `decryptIdCard(encrypted)` | `string` | `Promise<string>` | 解密身份证号 |
#### AesUtils 静态方法
| 方法 | 参数 | 返回值 | 说明 |
|------|------|--------|------|
| `maskPhone(phone)` | `string` | `string` | 脱敏手机号 |
| `maskIdCard(idCard)` | `string` | `string` | 脱敏身份证号 |
| `maskEmail(email)` | `string` | `string` | 脱敏邮箱 |
#### 全局函数
| 函数 | 参数 | 返回值 | 说明 |
|------|------|--------|------|
| `initAesEncrypt(secretKey)` | `string` | `Promise<void>` | 初始化 AES 加密(应用启动时调用) |
| `getAesInstance()` | - | `AesEncryptUtil` | 获取 AES 加密实例 |
| `createAesEncrypt(secretKey)` | `string` | `Promise<AesEncryptUtil>` | 创建新的 AES 实例 |
### 配置说明
#### 密钥配置
前端密钥必须与后端配置保持一致:
**后端配置application.yml**
```yaml
security:
aes:
secret-key: 1234567890qwer
```
**前端配置**
```typescript
const AES_SECRET_KEY = '1234567890qwer' // 与后端保持一致
await initAesEncrypt(AES_SECRET_KEY)
```
#### 算法参数
| 参数 | 值 | 说明 |
|------|-----|------|
| 算法 | AES-256-GCM | 高强度加密算法 |
| 密钥长度 | 256 bits | AES-256 |
| IV 长度 | 12 bytes | GCM 推荐长度 |
| Tag 长度 | 128 bits | GCM 认证标签 |
| 编码 | Base64 | 密文编码格式 |
### 安全注意事项
1. **密钥管理**
- 密钥不要硬编码在代码中
- 生产环境从配置中心或环境变量获取
- 定期轮换密钥
2. **HTTPS 传输**
- 生产环境必须使用 HTTPS
- 加密只是额外保障,不能替代 HTTPS
3. **密码安全**
- 密码传输前加密
- 后端再次使用 BCrypt 等算法加密存储
- 前端加密防止明文传输被截获
4. **错误处理**
- 捕获加密失败异常
- 不要在错误信息中暴露敏感信息
### 浏览器兼容性
使用 Web Crypto API支持以下浏览器
- Chrome 37+
- Firefox 34+
- Safari 11+
- Edge 79+
不支持 IE 浏览器。
### 与后端对接
#### 数据流程
```
前端 ----[加密数据]----> Gateway -----> Auth Service
(AES-256-GCM) (AES-256-GCM)
[解密] → [BCrypt] → 数据库
```
#### 示例对比
**前端加密**
```typescript
const encrypted = await aes.encrypt('13812345678')
// 输出Base64([IV(12字节)][密文])
```
**后端解密**
```java
String decrypted = aesEncryptUtil.decrypt(encrypted)
// 输出:'13812345678'
```
### 故障排查
#### 1. "AES 密钥未初始化"
**原因**:未调用 `initAesEncrypt()`
**解决**:在 `main.ts` 中初始化
```typescript
await initAesEncrypt(AES_SECRET_KEY)
```
#### 2. "AES 解密失败"
**原因**:前后端密钥不一致
**解决**:检查前后端密钥配置是否相同
#### 3. 类型错误
**原因**TypeScript 类型问题
**解决**:确保使用最新版本的工具类
### 性能优化
1. **密钥复用**:使用单例模式,避免重复初始化
2. **异步处理**:加密操作是异步的,注意使用 `await`
3. **批量加密**:如需加密多个字段,可并行处理
```typescript
// 并行加密
const [encryptedPhone, encryptedPassword] = await Promise.all([
aes.encryptPhone(phone),
aes.encryptPassword(password)
])
```

View File

@@ -0,0 +1,322 @@
/**
* AES-256-GCM 加密工具(兼容 H5/小程序/App
* 与后端 AesEncryptUtil 保持一致
*
* 使用场景:
* - 密码传输前加密
* - 敏感信息(手机号、身份证号)加密
*
* @author yslg
* @since 2025-12-10
*/
/**
* AES 加密配置
*/
interface AesConfig {
algorithm: string
keySize: number
ivLength: number
tagLength: number
}
const AES_CONFIG: AesConfig = {
algorithm: 'AES-GCM',
keySize: 256,
ivLength: 12, // GCM 推荐 IV 长度
tagLength: 128 // GCM 认证标签长度
}
const getCrypto = (): Crypto => {
return window.crypto
}
/**
* AES 加密工具类
*/
export class AesEncryptUtil {
private secretKey: CryptoKey | null = null
private secretKeyString: string
/**
* 构造函数
* @param secretKeyString Base64 编码的密钥32字节AES-256
*/
constructor(secretKeyString: string) {
this.secretKeyString = secretKeyString
}
/**
* 初始化密钥(异步)
*/
async init(): Promise<void> {
if (!this.secretKeyString) {
throw new Error('AES 密钥未配置')
}
try {
// Base64 解码密钥
const keyData = this.base64ToArrayBuffer(this.secretKeyString)
// 校验密钥长度AES-256 必须是 32 字节)
if (keyData.byteLength !== 32) {
throw new Error(`AES 密钥长度错误需32字节实际${keyData.byteLength}字节`)
}
// 导入密钥(跨端兼容)
this.secretKey = await getCrypto().subtle.importKey(
'raw',
keyData,
{ name: AES_CONFIG.algorithm },
false,
['encrypt', 'decrypt']
)
} catch (error) {
throw new Error(`AES 密钥初始化失败: ${error}`)
}
}
/**
* 加密字符串
* @param plaintext 明文
* @returns Base64 编码的密文(包含 IV
*/
async encrypt(plaintext: string): Promise<string> {
if (!plaintext) {
return plaintext
}
if (!this.secretKey) {
throw new Error('AES 密钥未初始化,请先调用 init()')
}
try {
// 生成随机 IV跨端兼容
const iv = getCrypto().getRandomValues(new Uint8Array(AES_CONFIG.ivLength))
// 将明文转为 ArrayBuffer
const encoder = new TextEncoder()
const data = encoder.encode(plaintext)
// 加密
const ciphertext = await getCrypto().subtle.encrypt(
{
name: AES_CONFIG.algorithm,
iv: iv,
tagLength: AES_CONFIG.tagLength
},
this.secretKey,
data
)
// 将 IV 和密文组合:[IV(12字节)][密文]
const combined = new Uint8Array(iv.length + ciphertext.byteLength)
combined.set(iv, 0)
combined.set(new Uint8Array(ciphertext), iv.length)
// Base64 编码
return this.arrayBufferToBase64(combined)
} catch (error) {
throw new Error(`AES 加密失败: ${error}`)
}
}
/**
* 解密字符串
* @param ciphertext Base64 编码的密文(包含 IV
* @returns 明文
*/
async decrypt(ciphertext: string): Promise<string> {
if (!ciphertext) {
return ciphertext
}
if (!this.secretKey) {
throw new Error('AES 密钥未初始化,请先调用 init()')
}
try {
// Base64 解码
const combinedBuffer = this.base64ToArrayBuffer(ciphertext)
const combined = new Uint8Array(combinedBuffer)
// 校验 IV 长度
if (combined.length < AES_CONFIG.ivLength) {
throw new Error('密文格式错误IV 长度不足')
}
// 提取 IV 和密文
const iv = combined.slice(0, AES_CONFIG.ivLength)
const data = combined.slice(AES_CONFIG.ivLength)
// 解密
const plaintext = await getCrypto().subtle.decrypt(
{
name: AES_CONFIG.algorithm,
iv: iv,
tagLength: AES_CONFIG.tagLength
},
this.secretKey,
data
)
// 将 ArrayBuffer 转为字符串
const decoder = new TextDecoder()
return decoder.decode(plaintext)
} catch (error) {
throw new Error(`AES 解密失败: ${error}`)
}
}
/**
* 加密密码(用于登录等场景)
*/
async encryptPassword(password: string): Promise<string> {
return this.encrypt(password)
}
/**
* 加密手机号
*/
async encryptPhone(phone: string): Promise<string> {
return this.encrypt(phone)
}
/**
* 解密手机号
*/
async decryptPhone(encryptedPhone: string): Promise<string> {
return this.decrypt(encryptedPhone)
}
/**
* 加密身份证号
*/
async encryptIdCard(idCard: string): Promise<string> {
return this.encrypt(idCard)
}
/**
* 解密身份证号
*/
async decryptIdCard(encryptedIdCard: string): Promise<string> {
return this.decrypt(encryptedIdCard)
}
// ============ 工具方法 ============
/**
* Base64 转 ArrayBuffer
*/
private base64ToArrayBuffer(base64: string): ArrayBuffer {
// 处理 Base64 填充字符
base64 = base64.replace(/-/g, '+').replace(/_/g, '/')
const padLength = (4 - (base64.length % 4)) % 4
base64 += '='.repeat(padLength)
const binaryString = atob(base64)
const bytes = new Uint8Array(binaryString.length)
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i)
}
return bytes.buffer
}
/**
* ArrayBuffer 转 Base64
*/
private arrayBufferToBase64(buffer: Uint8Array): string {
let binary = ''
const len = buffer.byteLength
for (let i = 0; i < len; i++) {
binary += String.fromCharCode(buffer[i])
}
return btoa(binary)
}
}
/**
* 静态工具方法
*/
export class AesUtils {
/**
* 脱敏显示手机号
* 例如13812345678 -> 138****5678
*/
static maskPhone(phone: string): string {
if (!phone || phone.length < 11) {
return phone
}
return phone.substring(0, 3) + '****' + phone.substring(7)
}
/**
* 脱敏显示身份证号
* 例如110101199001011234 -> 110101********1234
*/
static maskIdCard(idCard: string): string {
if (!idCard || idCard.length < 18) {
return idCard
}
return idCard.substring(0, 6) + '********' + idCard.substring(14)
}
/**
* 脱敏显示邮箱
* 例如test@example.com -> t***@example.com
*/
static maskEmail(email: string): string {
if (!email || !email.includes('@')) {
return email
}
const [username, domain] = email.split('@')
if (username.length <= 1) {
return email
}
return username[0] + '***@' + domain
}
/**
* 生成 AES-256 Base64 密钥(工具方法,用于后端/测试)
*/
static generateAes256KeyBase64(): string {
const crypto = getCrypto()
const keyBytes = crypto.getRandomValues(new Uint8Array(32)) // 32字节=256位
let binary = ''
for (let i = 0; i < keyBytes.length; i++) {
binary += String.fromCharCode(keyBytes[i])
}
return btoa(binary)
}
}
/**
* 创建 AES 加密实例的工厂函数
* @param secretKey Base64 编码的密钥32字节
*/
export async function createAesEncrypt(secretKey: string): Promise<AesEncryptUtil> {
const aes = new AesEncryptUtil(secretKey)
await aes.init()
return aes
}
// 导出单例(需要在应用启动时初始化)
let aesInstance: AesEncryptUtil | null = null
/**
* 获取 AES 加密实例
*/
export function getAesInstance(): AesEncryptUtil {
if (!aesInstance) {
throw new Error('AES 加密工具未初始化,请先调用 initAesEncrypt()')
}
return aesInstance
}
/**
* 初始化 AES 加密工具(在应用启动时调用)
* @param secretKey Base64 编码的密钥32字节与后端配置保持一致
*/
export async function initAesEncrypt(secretKey: string): Promise<void> {
aesInstance = await createAesEncrypt(secretKey)
}

View File

@@ -0,0 +1 @@
export * from './aes'

View File

@@ -3,3 +3,4 @@
*/
export * from './file'
export * from './crypto'