diff --git a/.env.example b/.env.example index 5328fad..d15bd3c 100644 --- a/.env.example +++ b/.env.example @@ -16,6 +16,10 @@ JWT_SECRET=change_me_in_production JWT_EXPIRES_IN=1h JWT_REFRESH_EXPIRES_IN=7d +APPLE_BUNDLE_ID=cloud.longde.AIStudyApp +APPLE_ISSUER=https://appleid.apple.com +APPLE_JWKS_URL=https://appleid.apple.com/auth/keys + ENABLE_SWAGGER=true SWAGGER_USER=admin SWAGGER_PASSWORD=change_me diff --git a/package-lock.json b/package-lock.json index fedc3b9..568d327 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,11 +19,13 @@ "@nestjs/platform-express": "^11.0.1", "@nestjs/swagger": "^11.4.2", "@nestjs/throttler": "^6.5.0", + "@prisma/client": "^5.22.0", "bcryptjs": "^3.0.3", "class-transformer": "^0.5.1", "class-validator": "^0.15.1", "helmet": "^8.1.0", "ioredis": "^5.10.1", + "jwks-rsa": "^4.0.1", "passport": "^0.7.0", "passport-jwt": "^4.0.1", "reflect-metadata": "^0.2.2", @@ -36,7 +38,6 @@ "@nestjs/cli": "^11.0.0", "@nestjs/schematics": "^11.0.0", "@nestjs/testing": "^11.0.1", - "@prisma/client": "^5.22.0", "@types/bcryptjs": "^2.4.6", "@types/express": "^5.0.0", "@types/jest": "^30.0.0", @@ -2779,7 +2780,6 @@ "version": "5.22.0", "resolved": "https://registry.npmmirror.com/@prisma/client/-/client-5.22.0.tgz", "integrity": "sha512-M0SVXfyHnQREBKxCgyo7sffrKttwE6R8PMq330MIUF0pTwjUhLbW84pFDlf06B27XyCR++VtjugEnIHdr07SVA==", - "dev": true, "hasInstallScript": true, "license": "Apache-2.0", "engines": { @@ -2798,14 +2798,14 @@ "version": "5.22.0", "resolved": "https://registry.npmmirror.com/@prisma/debug/-/debug-5.22.0.tgz", "integrity": "sha512-AUt44v3YJeggO2ZU5BkXI7M4hu9BF2zzH2iF2V5pyXT/lRTyWiElZ7It+bRH1EshoMRxHgpYg4VB6rCM+mG5jQ==", - "dev": true, + "devOptional": true, "license": "Apache-2.0" }, "node_modules/@prisma/engines": { "version": "5.22.0", "resolved": "https://registry.npmmirror.com/@prisma/engines/-/engines-5.22.0.tgz", "integrity": "sha512-UNjfslWhAt06kVL3CjkuYpHAWSO6L4kDCVPegV6itt7nD1kSJavd3vhgAEhjglLJJKEdJ7oIqDJ+yHk6qO8gPA==", - "dev": true, + "devOptional": true, "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { @@ -2819,14 +2819,14 @@ "version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2", "resolved": "https://registry.npmmirror.com/@prisma/engines-version/-/engines-version-5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2.tgz", "integrity": "sha512-2PTmxFR2yHW/eB3uqWtcgRcgAbG1rwG9ZriSvQw+nnb7c4uCr3RAcGMb6/zfE88SKlC1Nj2ziUvc96Z379mHgQ==", - "dev": true, + "devOptional": true, "license": "Apache-2.0" }, "node_modules/@prisma/fetch-engine": { "version": "5.22.0", "resolved": "https://registry.npmmirror.com/@prisma/fetch-engine/-/fetch-engine-5.22.0.tgz", "integrity": "sha512-bkrD/Mc2fSvkQBV5EpoFcZ87AvOgDxbG99488a5cexp5Ccny+UM6MAe/UFkUC0wLYD9+9befNOqGiIJhhq+HbA==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "dependencies": { "@prisma/debug": "5.22.0", @@ -2838,7 +2838,7 @@ "version": "5.22.0", "resolved": "https://registry.npmmirror.com/@prisma/get-platform/-/get-platform-5.22.0.tgz", "integrity": "sha512-pHhpQdr1UPFpt+zFfnPazhulaZYCUqeIcPpJViYoq9R+D/yw4fjE+CtnsnKzPYm0ddUbeXUzjGVGIRVgPDCk4Q==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "dependencies": { "@prisma/debug": "5.22.0" @@ -7598,6 +7598,15 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, + "node_modules/jose": { + "version": "6.2.3", + "resolved": "https://registry.npmmirror.com/jose/-/jose-6.2.3.tgz", + "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmmirror.com/js-tokens/-/js-tokens-4.0.0.tgz", @@ -7724,6 +7733,22 @@ "safe-buffer": "^5.0.1" } }, + "node_modules/jwks-rsa": { + "version": "4.0.1", + "resolved": "https://registry.npmmirror.com/jwks-rsa/-/jwks-rsa-4.0.1.tgz", + "integrity": "sha512-poXwUA8S4cP9P5N8tZS3xnUDJH8WmwSGfKK9gIaRPdjLHyJtd9iX/cngX9CUIe0Caof5JhK2EbN7N5lnnaf9NA==", + "license": "MIT", + "dependencies": { + "@types/jsonwebtoken": "^9.0.4", + "debug": "^4.3.4", + "jose": "^6.1.3", + "limiter": "^1.1.5", + "lru-memoizer": "^3.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >= 23.0.0" + } + }, "node_modules/jws": { "version": "4.0.1", "resolved": "https://registry.npmmirror.com/jws/-/jws-4.0.1.tgz", @@ -7774,6 +7799,11 @@ "integrity": "sha512-N12qmdu0BM1wVNkMKYOoJR4fTOZDblrKNsOqGbKoUZrYsYLX2zx1O5X+vhK0WJPBU/+/kh9tCr8x0a7t1puGWg==", "license": "MIT" }, + "node_modules/limiter": { + "version": "1.1.5", + "resolved": "https://registry.npmmirror.com/limiter/-/limiter-1.1.5.tgz", + "integrity": "sha512-FWWMIEOxz3GwUI4Ts/IvgVy6LPvoMPgjMdQ185nN6psJyBJ4yOpzqm695/h5umdLJg2vW3GR5iG11MAkR2AzJA==" + }, "node_modules/lines-and-columns": { "version": "1.2.4", "resolved": "https://registry.npmmirror.com/lines-and-columns/-/lines-and-columns-1.2.4.tgz", @@ -7836,6 +7866,12 @@ "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", "license": "MIT" }, + "node_modules/lodash.clonedeep": { + "version": "4.5.0", + "resolved": "https://registry.npmmirror.com/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz", + "integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==", + "license": "MIT" + }, "node_modules/lodash.defaults": { "version": "4.2.0", "resolved": "https://registry.npmmirror.com/lodash.defaults/-/lodash.defaults-4.2.0.tgz", @@ -7931,6 +7967,25 @@ "yallist": "^3.0.2" } }, + "node_modules/lru-memoizer": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/lru-memoizer/-/lru-memoizer-3.0.0.tgz", + "integrity": "sha512-m83w/cYXLdUIboKSPxzPAGfYnk+vqeDYXuoSrQRw1q+yVEd8IXhvMufN8Q5TIPe7e2jyX4SRNrDJI2Skw1yznQ==", + "license": "MIT", + "dependencies": { + "lodash.clonedeep": "^4.5.0", + "lru-cache": "^11.0.1" + } + }, + "node_modules/lru-memoizer/node_modules/lru-cache": { + "version": "11.3.6", + "resolved": "https://registry.npmmirror.com/lru-cache/-/lru-cache-11.3.6.tgz", + "integrity": "sha512-Gf/KoL3C/MlI7Bt0PGI9I+TeTC/I6r/csU58N4BSNc4lppLBeKsOdFYkK+dX0ABDUMJNfCHTyPpzwwO21Awd3A==", + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/luxon": { "version": "3.7.2", "resolved": "https://registry.npmmirror.com/luxon/-/luxon-3.7.2.tgz", @@ -8858,7 +8913,7 @@ "version": "5.22.0", "resolved": "https://registry.npmmirror.com/prisma/-/prisma-5.22.0.tgz", "integrity": "sha512-vtpjW3XuYCSnMsNVBjLMNkTj6OZbudcPPTPYHqX0CJfpcdWciI1dM8uHETwmDxxiqEwCIE6WvXucWUetJgfu/A==", - "dev": true, + "devOptional": true, "hasInstallScript": true, "license": "Apache-2.0", "peer": true, diff --git a/package.json b/package.json index d07c240..09dece2 100644 --- a/package.json +++ b/package.json @@ -30,17 +30,18 @@ "@nestjs/platform-express": "^11.0.1", "@nestjs/swagger": "^11.4.2", "@nestjs/throttler": "^6.5.0", + "@prisma/client": "^5.22.0", "bcryptjs": "^3.0.3", "class-transformer": "^0.5.1", "class-validator": "^0.15.1", "helmet": "^8.1.0", "ioredis": "^5.10.1", + "jwks-rsa": "^4.0.1", "passport": "^0.7.0", "passport-jwt": "^4.0.1", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", - "swagger-ui-express": "^5.0.1", - "@prisma/client": "^5.22.0" + "swagger-ui-express": "^5.0.1" }, "devDependencies": { "@eslint/eslintrc": "^3.2.0", diff --git a/src/app.module.ts b/src/app.module.ts index a936498..39af05e 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -35,6 +35,7 @@ import redisConfig from './config/redis.config'; import jwtConfig from './config/jwt.config'; import aiConfig from './config/ai.config'; import storageConfig from './config/storage.config'; +import appleConfig from './config/apple.config'; @Module({ imports: [ @@ -47,6 +48,7 @@ import storageConfig from './config/storage.config'; jwtConfig, aiConfig, storageConfig, + appleConfig, ], }), JwtModule.registerAsync({ diff --git a/src/config/apple.config.ts b/src/config/apple.config.ts new file mode 100644 index 0000000..e9ea6f8 --- /dev/null +++ b/src/config/apple.config.ts @@ -0,0 +1,7 @@ +import { registerAs } from '@nestjs/config'; + +export default registerAs('apple', () => ({ + bundleId: process.env.APPLE_BUNDLE_ID || '', + issuer: process.env.APPLE_ISSUER || 'https://appleid.apple.com', + jwksUrl: process.env.APPLE_JWKS_URL || 'https://appleid.apple.com/auth/keys', +})); diff --git a/src/modules/auth/auth.service.ts b/src/modules/auth/auth.service.ts index c504a6e..d508938 100644 --- a/src/modules/auth/auth.service.ts +++ b/src/modules/auth/auth.service.ts @@ -3,11 +3,26 @@ import { Injectable, UnauthorizedException } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import { ConfigService } from '@nestjs/config'; import { PrismaService } from '../../infrastructure/database/prisma.service'; +import jwksClient from 'jwks-rsa'; +import jwt from 'jsonwebtoken'; + +interface AppleIdTokenPayload { + sub: string; + email?: string; + email_verified?: string | boolean; + is_private_email?: string | boolean; + aud: string; + iss: string; + exp: number; + iat: number; +} @Injectable() export class AuthService { + private jwks: jwksClient.JwksClient; + constructor( - private readonly jwtService: JwtService, + private readonly nativeJwtService: JwtService, private readonly prisma: PrismaService, private readonly configService: ConfigService, ) {} @@ -20,7 +35,6 @@ export class AuthService { const appleUserId = await this.verifyAppleIdentity( params.identityToken, params.authorizationCode, - params.user?.email, ); let account = await this.prisma.authAccount.findUnique({ @@ -50,10 +64,8 @@ export class AuthService { }); } - const userIdStr = String(account.user.id); - - const accessToken = await this.jwtService.signAsync({ - sub: userIdStr, + const accessToken = await this.nativeJwtService.signAsync({ + sub: String(account.user.id), email: account.user.email, }); @@ -104,16 +116,12 @@ export class AuthService { }, }); - const accessToken = await this.jwtService.signAsync({ + const accessToken = await this.nativeJwtService.signAsync({ sub: String(stored.user.id), email: stored.user.email, }); - return { - accessToken, - refreshToken: newRefreshToken, - expiresIn: 3600, - }; + return { accessToken, refreshToken: newRefreshToken, expiresIn: 3600 }; } async logout(userId: number) { @@ -126,35 +134,83 @@ export class AuthService { private async verifyAppleIdentity( identityToken: string, authorizationCode: string, - email?: string | null, ): Promise { if (this.isMockMode()) { - return this.verifyMockApple(identityToken, email); + return this.verifyMockApple(identityToken); } - return this.verifyRealApple(identityToken, authorizationCode); + return this.verifyRealApple(identityToken); } private isMockMode(): boolean { - const env = this.configService.get('app.nodeEnv'); - const aiProvider = this.configService.get('ai.provider'); - return env !== 'production' || aiProvider === 'mock'; + const bundleId = this.configService.get('apple.bundleId'); + return !bundleId; } - private verifyMockApple(identityToken: string, email?: string | null): string { + private verifyMockApple(identityToken: string): string { if (!identityToken || identityToken.trim().length < 4) { throw new UnauthorizedException('identityToken 无效'); } return crypto .createHash('sha256') - .update(`apple-mock:${identityToken}:${email || 'no-email'}`) + .update(`apple-mock:${identityToken}`) .digest('hex') .slice(0, 64); } - private async verifyRealApple( - identityToken: string, - authorizationCode: string, - ): Promise { - throw new UnauthorizedException('Apple 登录尚未接入,请先配置 Apple Developer 凭证'); + private getJwksClient(): jwksClient.JwksClient { + if (!this.jwks) { + const jwksUrl = this.configService.get( + 'apple.jwksUrl', + 'https://appleid.apple.com/auth/keys', + ); + this.jwks = jwksClient({ jwksUri: jwksUrl, cache: true, rateLimit: true }); + } + return this.jwks!; + } + + private async verifyRealApple(identityToken: string): Promise { + const bundleId = this.configService.get('apple.bundleId'); + const issuer = this.configService.get('apple.issuer', 'https://appleid.apple.com'); + + const decodedHeader = jwt.decode(identityToken, { complete: true }); + if (!decodedHeader || typeof decodedHeader === 'string') { + throw new UnauthorizedException('无法解析 identityToken'); + } + + const kid = decodedHeader.header.kid; + if (!kid) { + throw new UnauthorizedException('identityToken 缺少 kid'); + } + + let publicKey: string; + try { + const client = this.getJwksClient(); + const key = await client.getSigningKey(kid); + publicKey = key.getPublicKey(); + } catch { + throw new UnauthorizedException('无法获取 Apple 公钥,请稍后重试'); + } + + let payload: AppleIdTokenPayload; + try { + payload = jwt.verify(identityToken, publicKey, { + algorithms: ['RS256'], + issuer, + audience: bundleId, + }) as AppleIdTokenPayload; + } catch (err: any) { + const msg = err.message || ''; + if (msg.includes('audience')) { + throw new UnauthorizedException( + `identityToken audience 不匹配,期望 ${bundleId}`, + ); + } + if (msg.includes('issuer')) { + throw new UnauthorizedException('identityToken issuer 无效'); + } + throw new UnauthorizedException('identityToken 验证失败'); + } + + return payload.sub; } }