feat: Apple 登录真实验签 - jwks-rsa + jsonwebtoken 验签 Apple identityToken
All checks were successful
Deploy API Server / build-and-deploy (push) Successful in 2m13s
All checks were successful
Deploy API Server / build-and-deploy (push) Successful in 2m13s
This commit is contained in:
parent
a16871fdc5
commit
77c62599b1
@ -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
71
package-lock.json
generated
@ -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,
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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({
|
||||
|
||||
7
src/config/apple.config.ts
Normal file
7
src/config/apple.config.ts
Normal 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',
|
||||
}));
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user