feat: Apple 登录真实验签 - jwks-rsa + jsonwebtoken 验签 Apple identityToken
All checks were successful
Deploy API Server / build-and-deploy (push) Successful in 2m13s

This commit is contained in:
WangDL 2026-05-13 15:35:41 +08:00
parent a16871fdc5
commit 77c62599b1
6 changed files with 160 additions and 35 deletions

View File

@ -16,6 +16,10 @@ JWT_SECRET=change_me_in_production
JWT_EXPIRES_IN=1h JWT_EXPIRES_IN=1h
JWT_REFRESH_EXPIRES_IN=7d 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 ENABLE_SWAGGER=true
SWAGGER_USER=admin SWAGGER_USER=admin
SWAGGER_PASSWORD=change_me SWAGGER_PASSWORD=change_me

71
package-lock.json generated
View File

@ -19,11 +19,13 @@
"@nestjs/platform-express": "^11.0.1", "@nestjs/platform-express": "^11.0.1",
"@nestjs/swagger": "^11.4.2", "@nestjs/swagger": "^11.4.2",
"@nestjs/throttler": "^6.5.0", "@nestjs/throttler": "^6.5.0",
"@prisma/client": "^5.22.0",
"bcryptjs": "^3.0.3", "bcryptjs": "^3.0.3",
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"class-validator": "^0.15.1", "class-validator": "^0.15.1",
"helmet": "^8.1.0", "helmet": "^8.1.0",
"ioredis": "^5.10.1", "ioredis": "^5.10.1",
"jwks-rsa": "^4.0.1",
"passport": "^0.7.0", "passport": "^0.7.0",
"passport-jwt": "^4.0.1", "passport-jwt": "^4.0.1",
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
@ -36,7 +38,6 @@
"@nestjs/cli": "^11.0.0", "@nestjs/cli": "^11.0.0",
"@nestjs/schematics": "^11.0.0", "@nestjs/schematics": "^11.0.0",
"@nestjs/testing": "^11.0.1", "@nestjs/testing": "^11.0.1",
"@prisma/client": "^5.22.0",
"@types/bcryptjs": "^2.4.6", "@types/bcryptjs": "^2.4.6",
"@types/express": "^5.0.0", "@types/express": "^5.0.0",
"@types/jest": "^30.0.0", "@types/jest": "^30.0.0",
@ -2779,7 +2780,6 @@
"version": "5.22.0", "version": "5.22.0",
"resolved": "https://registry.npmmirror.com/@prisma/client/-/client-5.22.0.tgz", "resolved": "https://registry.npmmirror.com/@prisma/client/-/client-5.22.0.tgz",
"integrity": "sha512-M0SVXfyHnQREBKxCgyo7sffrKttwE6R8PMq330MIUF0pTwjUhLbW84pFDlf06B27XyCR++VtjugEnIHdr07SVA==", "integrity": "sha512-M0SVXfyHnQREBKxCgyo7sffrKttwE6R8PMq330MIUF0pTwjUhLbW84pFDlf06B27XyCR++VtjugEnIHdr07SVA==",
"dev": true,
"hasInstallScript": true, "hasInstallScript": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"engines": { "engines": {
@ -2798,14 +2798,14 @@
"version": "5.22.0", "version": "5.22.0",
"resolved": "https://registry.npmmirror.com/@prisma/debug/-/debug-5.22.0.tgz", "resolved": "https://registry.npmmirror.com/@prisma/debug/-/debug-5.22.0.tgz",
"integrity": "sha512-AUt44v3YJeggO2ZU5BkXI7M4hu9BF2zzH2iF2V5pyXT/lRTyWiElZ7It+bRH1EshoMRxHgpYg4VB6rCM+mG5jQ==", "integrity": "sha512-AUt44v3YJeggO2ZU5BkXI7M4hu9BF2zzH2iF2V5pyXT/lRTyWiElZ7It+bRH1EshoMRxHgpYg4VB6rCM+mG5jQ==",
"dev": true, "devOptional": true,
"license": "Apache-2.0" "license": "Apache-2.0"
}, },
"node_modules/@prisma/engines": { "node_modules/@prisma/engines": {
"version": "5.22.0", "version": "5.22.0",
"resolved": "https://registry.npmmirror.com/@prisma/engines/-/engines-5.22.0.tgz", "resolved": "https://registry.npmmirror.com/@prisma/engines/-/engines-5.22.0.tgz",
"integrity": "sha512-UNjfslWhAt06kVL3CjkuYpHAWSO6L4kDCVPegV6itt7nD1kSJavd3vhgAEhjglLJJKEdJ7oIqDJ+yHk6qO8gPA==", "integrity": "sha512-UNjfslWhAt06kVL3CjkuYpHAWSO6L4kDCVPegV6itt7nD1kSJavd3vhgAEhjglLJJKEdJ7oIqDJ+yHk6qO8gPA==",
"dev": true, "devOptional": true,
"hasInstallScript": true, "hasInstallScript": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
@ -2819,14 +2819,14 @@
"version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2", "version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2",
"resolved": "https://registry.npmmirror.com/@prisma/engines-version/-/engines-version-5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2.tgz", "resolved": "https://registry.npmmirror.com/@prisma/engines-version/-/engines-version-5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2.tgz",
"integrity": "sha512-2PTmxFR2yHW/eB3uqWtcgRcgAbG1rwG9ZriSvQw+nnb7c4uCr3RAcGMb6/zfE88SKlC1Nj2ziUvc96Z379mHgQ==", "integrity": "sha512-2PTmxFR2yHW/eB3uqWtcgRcgAbG1rwG9ZriSvQw+nnb7c4uCr3RAcGMb6/zfE88SKlC1Nj2ziUvc96Z379mHgQ==",
"dev": true, "devOptional": true,
"license": "Apache-2.0" "license": "Apache-2.0"
}, },
"node_modules/@prisma/fetch-engine": { "node_modules/@prisma/fetch-engine": {
"version": "5.22.0", "version": "5.22.0",
"resolved": "https://registry.npmmirror.com/@prisma/fetch-engine/-/fetch-engine-5.22.0.tgz", "resolved": "https://registry.npmmirror.com/@prisma/fetch-engine/-/fetch-engine-5.22.0.tgz",
"integrity": "sha512-bkrD/Mc2fSvkQBV5EpoFcZ87AvOgDxbG99488a5cexp5Ccny+UM6MAe/UFkUC0wLYD9+9befNOqGiIJhhq+HbA==", "integrity": "sha512-bkrD/Mc2fSvkQBV5EpoFcZ87AvOgDxbG99488a5cexp5Ccny+UM6MAe/UFkUC0wLYD9+9befNOqGiIJhhq+HbA==",
"dev": true, "devOptional": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@prisma/debug": "5.22.0", "@prisma/debug": "5.22.0",
@ -2838,7 +2838,7 @@
"version": "5.22.0", "version": "5.22.0",
"resolved": "https://registry.npmmirror.com/@prisma/get-platform/-/get-platform-5.22.0.tgz", "resolved": "https://registry.npmmirror.com/@prisma/get-platform/-/get-platform-5.22.0.tgz",
"integrity": "sha512-pHhpQdr1UPFpt+zFfnPazhulaZYCUqeIcPpJViYoq9R+D/yw4fjE+CtnsnKzPYm0ddUbeXUzjGVGIRVgPDCk4Q==", "integrity": "sha512-pHhpQdr1UPFpt+zFfnPazhulaZYCUqeIcPpJViYoq9R+D/yw4fjE+CtnsnKzPYm0ddUbeXUzjGVGIRVgPDCk4Q==",
"dev": true, "devOptional": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@prisma/debug": "5.22.0" "@prisma/debug": "5.22.0"
@ -7598,6 +7598,15 @@
"url": "https://github.com/chalk/supports-color?sponsor=1" "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": { "node_modules/js-tokens": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmmirror.com/js-tokens/-/js-tokens-4.0.0.tgz", "resolved": "https://registry.npmmirror.com/js-tokens/-/js-tokens-4.0.0.tgz",
@ -7724,6 +7733,22 @@
"safe-buffer": "^5.0.1" "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": { "node_modules/jws": {
"version": "4.0.1", "version": "4.0.1",
"resolved": "https://registry.npmmirror.com/jws/-/jws-4.0.1.tgz", "resolved": "https://registry.npmmirror.com/jws/-/jws-4.0.1.tgz",
@ -7774,6 +7799,11 @@
"integrity": "sha512-N12qmdu0BM1wVNkMKYOoJR4fTOZDblrKNsOqGbKoUZrYsYLX2zx1O5X+vhK0WJPBU/+/kh9tCr8x0a7t1puGWg==", "integrity": "sha512-N12qmdu0BM1wVNkMKYOoJR4fTOZDblrKNsOqGbKoUZrYsYLX2zx1O5X+vhK0WJPBU/+/kh9tCr8x0a7t1puGWg==",
"license": "MIT" "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": { "node_modules/lines-and-columns": {
"version": "1.2.4", "version": "1.2.4",
"resolved": "https://registry.npmmirror.com/lines-and-columns/-/lines-and-columns-1.2.4.tgz", "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==", "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
"license": "MIT" "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": { "node_modules/lodash.defaults": {
"version": "4.2.0", "version": "4.2.0",
"resolved": "https://registry.npmmirror.com/lodash.defaults/-/lodash.defaults-4.2.0.tgz", "resolved": "https://registry.npmmirror.com/lodash.defaults/-/lodash.defaults-4.2.0.tgz",
@ -7931,6 +7967,25 @@
"yallist": "^3.0.2" "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": { "node_modules/luxon": {
"version": "3.7.2", "version": "3.7.2",
"resolved": "https://registry.npmmirror.com/luxon/-/luxon-3.7.2.tgz", "resolved": "https://registry.npmmirror.com/luxon/-/luxon-3.7.2.tgz",
@ -8858,7 +8913,7 @@
"version": "5.22.0", "version": "5.22.0",
"resolved": "https://registry.npmmirror.com/prisma/-/prisma-5.22.0.tgz", "resolved": "https://registry.npmmirror.com/prisma/-/prisma-5.22.0.tgz",
"integrity": "sha512-vtpjW3XuYCSnMsNVBjLMNkTj6OZbudcPPTPYHqX0CJfpcdWciI1dM8uHETwmDxxiqEwCIE6WvXucWUetJgfu/A==", "integrity": "sha512-vtpjW3XuYCSnMsNVBjLMNkTj6OZbudcPPTPYHqX0CJfpcdWciI1dM8uHETwmDxxiqEwCIE6WvXucWUetJgfu/A==",
"dev": true, "devOptional": true,
"hasInstallScript": true, "hasInstallScript": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true, "peer": true,

View File

@ -30,17 +30,18 @@
"@nestjs/platform-express": "^11.0.1", "@nestjs/platform-express": "^11.0.1",
"@nestjs/swagger": "^11.4.2", "@nestjs/swagger": "^11.4.2",
"@nestjs/throttler": "^6.5.0", "@nestjs/throttler": "^6.5.0",
"@prisma/client": "^5.22.0",
"bcryptjs": "^3.0.3", "bcryptjs": "^3.0.3",
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"class-validator": "^0.15.1", "class-validator": "^0.15.1",
"helmet": "^8.1.0", "helmet": "^8.1.0",
"ioredis": "^5.10.1", "ioredis": "^5.10.1",
"jwks-rsa": "^4.0.1",
"passport": "^0.7.0", "passport": "^0.7.0",
"passport-jwt": "^4.0.1", "passport-jwt": "^4.0.1",
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1", "rxjs": "^7.8.1",
"swagger-ui-express": "^5.0.1", "swagger-ui-express": "^5.0.1"
"@prisma/client": "^5.22.0"
}, },
"devDependencies": { "devDependencies": {
"@eslint/eslintrc": "^3.2.0", "@eslint/eslintrc": "^3.2.0",

View File

@ -35,6 +35,7 @@ import redisConfig from './config/redis.config';
import jwtConfig from './config/jwt.config'; import jwtConfig from './config/jwt.config';
import aiConfig from './config/ai.config'; import aiConfig from './config/ai.config';
import storageConfig from './config/storage.config'; import storageConfig from './config/storage.config';
import appleConfig from './config/apple.config';
@Module({ @Module({
imports: [ imports: [
@ -47,6 +48,7 @@ import storageConfig from './config/storage.config';
jwtConfig, jwtConfig,
aiConfig, aiConfig,
storageConfig, storageConfig,
appleConfig,
], ],
}), }),
JwtModule.registerAsync({ JwtModule.registerAsync({

View File

@ -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',
}));

View File

@ -3,11 +3,26 @@ import { Injectable, UnauthorizedException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt'; import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { PrismaService } from '../../infrastructure/database/prisma.service'; 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() @Injectable()
export class AuthService { export class AuthService {
private jwks: jwksClient.JwksClient;
constructor( constructor(
private readonly jwtService: JwtService, private readonly nativeJwtService: JwtService,
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly configService: ConfigService, private readonly configService: ConfigService,
) {} ) {}
@ -20,7 +35,6 @@ export class AuthService {
const appleUserId = await this.verifyAppleIdentity( const appleUserId = await this.verifyAppleIdentity(
params.identityToken, params.identityToken,
params.authorizationCode, params.authorizationCode,
params.user?.email,
); );
let account = await this.prisma.authAccount.findUnique({ let account = await this.prisma.authAccount.findUnique({
@ -50,10 +64,8 @@ export class AuthService {
}); });
} }
const userIdStr = String(account.user.id); const accessToken = await this.nativeJwtService.signAsync({
sub: String(account.user.id),
const accessToken = await this.jwtService.signAsync({
sub: userIdStr,
email: account.user.email, 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), sub: String(stored.user.id),
email: stored.user.email, email: stored.user.email,
}); });
return { return { accessToken, refreshToken: newRefreshToken, expiresIn: 3600 };
accessToken,
refreshToken: newRefreshToken,
expiresIn: 3600,
};
} }
async logout(userId: number) { async logout(userId: number) {
@ -126,35 +134,83 @@ export class AuthService {
private async verifyAppleIdentity( private async verifyAppleIdentity(
identityToken: string, identityToken: string,
authorizationCode: string, authorizationCode: string,
email?: string | null,
): Promise<string> { ): Promise<string> {
if (this.isMockMode()) { if (this.isMockMode()) {
return this.verifyMockApple(identityToken, email); return this.verifyMockApple(identityToken);
} }
return this.verifyRealApple(identityToken, authorizationCode); return this.verifyRealApple(identityToken);
} }
private isMockMode(): boolean { private isMockMode(): boolean {
const env = this.configService.get<string>('app.nodeEnv'); const bundleId = this.configService.get<string>('apple.bundleId');
const aiProvider = this.configService.get<string>('ai.provider'); return !bundleId;
return env !== 'production' || aiProvider === 'mock';
} }
private verifyMockApple(identityToken: string, email?: string | null): string { private verifyMockApple(identityToken: string): string {
if (!identityToken || identityToken.trim().length < 4) { if (!identityToken || identityToken.trim().length < 4) {
throw new UnauthorizedException('identityToken 无效'); throw new UnauthorizedException('identityToken 无效');
} }
return crypto return crypto
.createHash('sha256') .createHash('sha256')
.update(`apple-mock:${identityToken}:${email || 'no-email'}`) .update(`apple-mock:${identityToken}`)
.digest('hex') .digest('hex')
.slice(0, 64); .slice(0, 64);
} }
private async verifyRealApple( private getJwksClient(): jwksClient.JwksClient {
identityToken: string, if (!this.jwks) {
authorizationCode: string, const jwksUrl = this.configService.get<string>(
): Promise<string> { 'apple.jwksUrl',
throw new UnauthorizedException('Apple 登录尚未接入,请先配置 Apple Developer 凭证'); 'https://appleid.apple.com/auth/keys',
);
this.jwks = jwksClient({ jwksUri: jwksUrl, cache: true, rateLimit: true });
}
return this.jwks!;
}
private async verifyRealApple(identityToken: string): Promise<string> {
const bundleId = this.configService.get<string>('apple.bundleId');
const issuer = this.configService.get<string>('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;
} }
} }