fix(auth): H0-01 Apple登录—nonce验证+启动检查+fullName补写修复
Some checks failed
Deploy API Server / build-and-deploy (push) Failing after 34s
Some checks failed
Deploy API Server / build-and-deploy (push) Failing after 34s
This commit is contained in:
parent
6a13edc7fb
commit
5fcfc87f84
@ -1,13 +1,16 @@
|
|||||||
import * as crypto from 'crypto';
|
import * as crypto from 'crypto';
|
||||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
import { Injectable, UnauthorizedException, Logger, OnModuleInit } from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { createRemoteJWKSet, jwtVerify } from 'jose';
|
import { createRemoteJWKSet, jwtVerify } from 'jose';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AppleAuthService {
|
export class AppleAuthService implements OnModuleInit {
|
||||||
|
private readonly logger = new Logger(AppleAuthService.name);
|
||||||
private readonly appleIssuer: string;
|
private readonly appleIssuer: string;
|
||||||
private readonly appleBundleId: string;
|
private readonly appleBundleId: string;
|
||||||
private readonly jwks: ReturnType<typeof createRemoteJWKSet>;
|
private readonly jwks: ReturnType<typeof createRemoteJWKSet>;
|
||||||
|
/** 上次已验证通过的 nonce set,用于防重放(生产环境) */
|
||||||
|
private readonly usedNonces = new Set<string>();
|
||||||
|
|
||||||
constructor(private readonly configService: ConfigService) {
|
constructor(private readonly configService: ConfigService) {
|
||||||
this.appleIssuer = this.configService.get<string>(
|
this.appleIssuer = this.configService.get<string>(
|
||||||
@ -20,36 +23,69 @@ export class AppleAuthService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async verifyIdentityToken(identityToken: string): Promise<{
|
onModuleInit() {
|
||||||
|
const nodeEnv = process.env.NODE_ENV;
|
||||||
|
if (nodeEnv === 'production') {
|
||||||
|
if (!this.appleBundleId) {
|
||||||
|
throw new Error(
|
||||||
|
'生产环境必须设置 APPLE_BUNDLE_ID 环境变量。\n' +
|
||||||
|
'请在 .env 中添加: APPLE_BUNDLE_ID=com.your.bundle.id',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
this.logger.log('Apple 登录已配置,使用真实验签');
|
||||||
|
} else {
|
||||||
|
if (this.appleBundleId) {
|
||||||
|
this.logger.log('Apple 登录使用真实验签模式(已配置 bundleId)');
|
||||||
|
} else {
|
||||||
|
this.logger.warn(
|
||||||
|
'Apple 登录使用 mock 模式(未配置 APPLE_BUNDLE_ID),仅限开发环境使用',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async verifyIdentityToken(
|
||||||
|
identityToken: string,
|
||||||
|
rawNonce?: string,
|
||||||
|
): Promise<{
|
||||||
appleUserId: string;
|
appleUserId: string;
|
||||||
email?: string;
|
email?: string;
|
||||||
emailVerified?: boolean;
|
emailVerified?: boolean;
|
||||||
}> {
|
}> {
|
||||||
if (!this.appleBundleId) {
|
if (!this.appleBundleId) {
|
||||||
if (process.env.NODE_ENV === 'production') {
|
|
||||||
throw new UnauthorizedException('Apple 登录未配置,请联系管理员');
|
|
||||||
}
|
|
||||||
return this.verifyMock(identityToken);
|
return this.verifyMock(identityToken);
|
||||||
}
|
}
|
||||||
return this.verifyReal(identityToken);
|
return this.verifyReal(identityToken, rawNonce);
|
||||||
}
|
}
|
||||||
|
|
||||||
private verifyMock(identityToken: string): {
|
private verifyMock(identityToken: string): {
|
||||||
appleUserId: string;
|
appleUserId: string;
|
||||||
|
email?: string;
|
||||||
|
emailVerified?: boolean;
|
||||||
} {
|
} {
|
||||||
if (!identityToken || identityToken.trim().length < 4) {
|
if (!identityToken || identityToken.trim().length < 4) {
|
||||||
throw new UnauthorizedException('identityToken 无效');
|
throw new UnauthorizedException('identityToken 无效');
|
||||||
}
|
}
|
||||||
return {
|
|
||||||
appleUserId: crypto
|
const mockUserId = crypto
|
||||||
.createHash('sha256')
|
.createHash('sha256')
|
||||||
.update(`apple-mock:${identityToken}`)
|
.update(`apple-mock:${identityToken}`)
|
||||||
.digest('hex')
|
.digest('hex')
|
||||||
.slice(0, 64),
|
.slice(0, 64);
|
||||||
|
|
||||||
|
const mockEmail = `${mockUserId.slice(0, 12)}@mock.apple.user`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
appleUserId: mockUserId,
|
||||||
|
email: mockEmail,
|
||||||
|
emailVerified: true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private async verifyReal(identityToken: string): Promise<{
|
private async verifyReal(
|
||||||
|
identityToken: string,
|
||||||
|
rawNonce?: string,
|
||||||
|
): Promise<{
|
||||||
appleUserId: string;
|
appleUserId: string;
|
||||||
email?: string;
|
email?: string;
|
||||||
emailVerified?: boolean;
|
emailVerified?: boolean;
|
||||||
@ -58,13 +94,17 @@ export class AppleAuthService {
|
|||||||
const { payload } = await jwtVerify(identityToken, this.jwks, {
|
const { payload } = await jwtVerify(identityToken, this.jwks, {
|
||||||
issuer: this.appleIssuer,
|
issuer: this.appleIssuer,
|
||||||
audience: this.appleBundleId,
|
audience: this.appleBundleId,
|
||||||
|
// nonce 校验:如果提供了 nonce,jose 会自动校验 JWT 中的 nonce claim
|
||||||
|
...(rawNonce ? { nonce: this.sha256Hex(rawNonce) } : {}),
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
appleUserId: payload.sub!,
|
appleUserId: payload.sub!,
|
||||||
email:
|
email:
|
||||||
typeof payload.email === 'string' ? payload.email : undefined,
|
typeof payload.email === 'string' ? payload.email : undefined,
|
||||||
emailVerified: payload.email_verified === true || payload.email_verified === 'true',
|
emailVerified:
|
||||||
|
payload.email_verified === true ||
|
||||||
|
payload.email_verified === 'true',
|
||||||
};
|
};
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
const msg: string = err?.message ?? '';
|
const msg: string = err?.message ?? '';
|
||||||
@ -76,7 +116,17 @@ export class AppleAuthService {
|
|||||||
if (msg.includes('issuer')) {
|
if (msg.includes('issuer')) {
|
||||||
throw new UnauthorizedException('identityToken issuer 无效');
|
throw new UnauthorizedException('identityToken issuer 无效');
|
||||||
}
|
}
|
||||||
|
if (msg.includes('nonce')) {
|
||||||
|
throw new UnauthorizedException('identityToken nonce 验证失败');
|
||||||
|
}
|
||||||
throw new UnauthorizedException('identityToken 验证失败');
|
throw new UnauthorizedException('identityToken 验证失败');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算 nonce 的 SHA256 哈希(与 iOS 端 SHA256(nonce) 一致)
|
||||||
|
*/
|
||||||
|
private sha256Hex(input: string): string {
|
||||||
|
return crypto.createHash('sha256').update(input).digest('hex');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -57,7 +57,10 @@ export class AuthService {
|
|||||||
|
|
||||||
async appleLogin(dto: AppleLoginDto) {
|
async appleLogin(dto: AppleLoginDto) {
|
||||||
const { appleUserId, email: appleEmail } =
|
const { appleUserId, email: appleEmail } =
|
||||||
await this.appleAuthService.verifyIdentityToken(dto.identityToken);
|
await this.appleAuthService.verifyIdentityToken(
|
||||||
|
dto.identityToken,
|
||||||
|
dto.nonce,
|
||||||
|
);
|
||||||
|
|
||||||
let account = await this.prisma.authAccount.findUnique({
|
let account = await this.prisma.authAccount.findUnique({
|
||||||
where: {
|
where: {
|
||||||
@ -90,6 +93,29 @@ export class AuthService {
|
|||||||
},
|
},
|
||||||
include: { user: true },
|
include: { user: true },
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
|
// 已有账户:如果首次登录时 nickname 未成功写入(网络中断等),
|
||||||
|
// 后续 Apple 返回 fullName 时补写
|
||||||
|
if (!account.user.nickname && dto.fullName?.givenName) {
|
||||||
|
const displayName = `${dto.fullName.familyName || ''}${dto.fullName.givenName}`;
|
||||||
|
await this.prisma.user.update({
|
||||||
|
where: { id: account.userId },
|
||||||
|
data: { nickname: displayName },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// 补写首次可能缺失的 email
|
||||||
|
if (!account.user.email && appleEmail) {
|
||||||
|
await this.prisma.user.update({
|
||||||
|
where: { id: account.userId },
|
||||||
|
data: { email: appleEmail },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!account.email && appleEmail) {
|
||||||
|
await this.prisma.authAccount.update({
|
||||||
|
where: { id: account.id },
|
||||||
|
data: { email: appleEmail },
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.buildLoginResponse(account.user);
|
return this.buildLoginResponse(account.user);
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user