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_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

71
package-lock.json generated
View File

@ -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,

View File

@ -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",

View File

@ -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({

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 { 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<string> {
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<string>('app.nodeEnv');
const aiProvider = this.configService.get<string>('ai.provider');
return env !== 'production' || aiProvider === 'mock';
const bundleId = this.configService.get<string>('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<string> {
throw new UnauthorizedException('Apple 登录尚未接入,请先配置 Apple Developer 凭证');
private getJwksClient(): jwksClient.JwksClient {
if (!this.jwks) {
const jwksUrl = this.configService.get<string>(
'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<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;
}
}