api-server/src/modules/auth/auth.service.ts
WangDL 6a13edc7fb
All checks were successful
Deploy API Server / build-and-deploy (push) Successful in 44s
feat: H0 milestone — iOS integration blocking fixes
H0-01: Reject Apple login mock fallback in production
H0-02: Protect /internal/* with InternalAuthGuard (X-Internal-API-Key)
H0-03: JwtAuthGuard check user status (deletedAt, status)
H0-04: Refresh token check user status + revoke all on deleted
H0-05: User/admin JWT isolation (type=user/admin, enforce ADMIN_JWT_ACCESS_SECRET)
H0-06: Add DTOs for import/source/learning-session controllers
H0-07: 22 E2E tests (h0.e2e-spec.ts), 5 iOS integration docs

Tests: 47/47 (H0 22 + M0 25), no regression.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 16:55:04 +08:00

220 lines
5.7 KiB
TypeScript

import { Injectable, ForbiddenException, UnauthorizedException } from '@nestjs/common';
import { PrismaService } from '../../infrastructure/database/prisma.service';
import { AppleAuthService } from './apple-auth.service';
import { TokenService } from './token.service';
import type { AppleLoginDto, DevLoginDto } from './dto';
@Injectable()
export class AuthService {
constructor(
private readonly prisma: PrismaService,
private readonly appleAuthService: AppleAuthService,
private readonly tokenService: TokenService,
) {}
async devLogin(dto: DevLoginDto) {
if (process.env.NODE_ENV === 'production') {
throw new ForbiddenException('dev-login is disabled in production');
}
const devSecret = process.env.DEV_SECRET;
if (!devSecret || dto.devSecret !== devSecret) {
throw new UnauthorizedException('devSecret 无效');
}
const providerUserId = dto.email;
let account = await this.prisma.authAccount.findUnique({
where: {
provider_providerUserId: {
provider: 'DEV',
providerUserId,
},
},
include: { user: true },
});
if (!account) {
account = await this.prisma.authAccount.create({
data: {
provider: 'DEV',
providerUserId,
email: dto.email,
user: {
create: {
email: dto.email,
nickname: dto.nickname || '测试用户',
status: 'active',
},
},
},
include: { user: true },
});
}
return this.buildLoginResponse(account.user);
}
async appleLogin(dto: AppleLoginDto) {
const { appleUserId, email: appleEmail } =
await this.appleAuthService.verifyIdentityToken(dto.identityToken);
let account = await this.prisma.authAccount.findUnique({
where: {
provider_providerUserId: {
provider: 'APPLE',
providerUserId: appleUserId,
},
},
include: { user: true },
});
if (!account) {
const displayName =
dto.fullName?.givenName
? `${dto.fullName.familyName || ''}${dto.fullName.givenName}`
: undefined;
account = await this.prisma.authAccount.create({
data: {
provider: 'APPLE',
providerUserId: appleUserId,
email: appleEmail || dto.email || null,
user: {
create: {
email: appleEmail || dto.email || null,
nickname: displayName || undefined,
status: 'active',
},
},
},
include: { user: true },
});
}
return this.buildLoginResponse(account.user);
}
async refresh(refreshToken: string) {
const hash = this.tokenService.hashToken(refreshToken);
const stored = await this.prisma.refreshToken.findFirst({
where: { tokenHash: hash, revokedAt: null },
include: { user: true },
});
if (!stored || stored.expiresAt < new Date()) {
throw new UnauthorizedException('刷新令牌无效或已过期');
}
// Check user status before issuing new tokens
if (stored.user.deletedAt) {
await this.revokeAllUserTokens(stored.userId);
throw new UnauthorizedException('账号已注销');
}
if (stored.user.status !== 'active') {
throw new UnauthorizedException('账号已被禁用');
}
await this.prisma.refreshToken.update({
where: { id: stored.id },
data: { revokedAt: new Date() },
});
const { token: newRefreshToken, hash: newHash } =
this.tokenService.generateRefreshToken();
await this.prisma.refreshToken.create({
data: {
userId: stored.userId,
tokenHash: newHash,
expiresAt: new Date(Date.now() + 7 * 86400000),
},
});
const accessToken = await this.tokenService.generateAccessToken(
stored.user,
);
return {
accessToken,
refreshToken: newRefreshToken,
user: this.serializeUser(stored.user),
};
}
private async revokeAllUserTokens(userId: string) {
await this.prisma.refreshToken.updateMany({
where: { userId, revokedAt: null },
data: { revokedAt: new Date() },
});
}
async logout(userId: string, refreshToken: string) {
const hash = this.tokenService.hashToken(refreshToken);
const stored = await this.prisma.refreshToken.findFirst({
where: {
tokenHash: hash,
userId,
revokedAt: null,
},
});
if (stored) {
await this.prisma.refreshToken.update({
where: { id: stored.id },
data: { revokedAt: new Date() },
});
}
}
private async buildLoginResponse(user: {
id: string;
email: string | null;
nickname: string | null;
avatarUrl: string | null;
role: string;
status: string;
onboardingCompleted: boolean;
}) {
const accessToken = await this.tokenService.generateAccessToken(user);
const { token: refreshToken, hash } =
this.tokenService.generateRefreshToken();
await this.prisma.refreshToken.create({
data: {
userId: user.id,
tokenHash: hash,
expiresAt: new Date(Date.now() + 7 * 86400000),
},
});
return {
accessToken,
refreshToken,
user: this.serializeUser(user),
};
}
private serializeUser(user: {
id: string;
email: string | null;
nickname: string | null;
avatarUrl: string | null;
role: string;
status: string;
onboardingCompleted: boolean;
}) {
return {
id: user.id,
email: user.email,
nickname: user.nickname,
avatarUrl: user.avatarUrl,
role: user.role,
status: user.status,
onboardingCompleted: user.onboardingCompleted,
};
}
}