Files
thebet365/apps/api/src/domains/identity/auth.service.ts

484 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Injectable, Logger } from '@nestjs/common';
import { Decimal } from '@prisma/client/runtime/library';
import { appForbidden, appUnauthorized, appBadRequest, appNotFound } from '../../shared/common/app-error';
import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';
import * as bcrypt from 'bcryptjs';
import { assertPlayerUsername } from '@thebet365/shared';
import { PrismaService } from '../../shared/prisma/prisma.service';
import { SystemConfigService } from '../../shared/config/system-config.service';
import { InvitesService } from './invites.service';
import { SmsService } from './sms/sms.service';
import { AuditService } from '../operations/audit/audit.service';
import { normalizePhone, resolvePlayerLoginCandidates, stripLocalPhoneDigits } from './sms/phone.util';
import { maskPhoneForLog } from './sms/sms-log.util';
const MAX_LOGIN_FAILS = 5;
const LOCK_DURATION_MS = 15 * 60 * 1000;
export interface JwtPayload {
sub: string;
username: string;
userType: string;
role?: string;
}
@Injectable()
export class AuthService {
private readonly logger = new Logger(AuthService.name);
constructor(
private prisma: PrismaService,
private jwt: JwtService,
private config: ConfigService,
private systemConfig: SystemConfigService,
private invites: InvitesService,
private sms: SmsService,
private audit: AuditService,
) {}
/** 平台管理员 / 代理统一登录(按 userType 签发对应 JWT */
async staffLogin(username: string, password: string) {
const user = await this.prisma.user.findUnique({
where: { username },
select: { userType: true },
});
if (!user || (user.userType !== 'ADMIN' && user.userType !== 'AGENT')) {
throw appUnauthorized('INVALID_CREDENTIALS');
}
const portal = user.userType === 'ADMIN' ? 'admin' : 'agent';
return this.login(username, password, portal);
}
async login(
username: string,
password: string,
portal: 'player' | 'admin' | 'agent',
countryCode?: string,
) {
let user;
if (portal === 'player') {
const trimmed = username.trim();
if (/[a-zA-Z]/.test(trimmed)) {
user = await this.prisma.user.findUnique({
where: { username: trimmed },
include: { auth: true, adminRole: { include: { role: true } } },
});
} else {
const candidates = resolvePlayerLoginCandidates(username, countryCode);
let matches = await this.prisma.user.findMany({
where: {
username: { in: candidates },
userType: 'PLAYER',
deletedAt: null,
},
include: { auth: true, adminRole: { include: { role: true } } },
});
if (matches.length === 0 && countryCode?.trim()) {
const dial = countryCode.replace(/\D/g, '');
const local = stripLocalPhoneDigits(username);
matches = await this.prisma.user.findMany({
where: {
userType: 'PLAYER',
deletedAt: null,
preferences: {
phoneCountryDial: dial,
phoneLocal: local,
},
},
include: { auth: true, adminRole: { include: { role: true } } },
});
}
if (matches.length === 1) {
user = matches[0];
}
}
} else {
user = await this.prisma.user.findUnique({
where: { username: username.trim() },
include: {
auth: true,
adminRole: {
include: {
role: {
include: {
permissions: { include: { permission: true } },
},
},
},
},
},
});
}
if (!user || !user.auth) {
throw appUnauthorized('INVALID_CREDENTIALS');
}
const expectedType = portal === 'admin' ? 'ADMIN' : portal === 'agent' ? 'AGENT' : 'PLAYER';
if (user.userType !== expectedType) {
throw appUnauthorized('INVALID_CREDENTIALS');
}
if (user.status === 'DISABLED') {
throw appForbidden('ACCOUNT_DISABLED');
}
if (portal === 'agent' && user.status === 'SUSPENDED') {
throw appForbidden('AGENT_ACCOUNT_SUSPENDED');
}
if (portal === 'player' && user.status === 'SUSPENDED') {
throw appForbidden('ACCOUNT_SUSPENDED');
}
if (portal === 'player' && user.parentId) {
const parentAgent = await this.prisma.user.findUnique({
where: { id: user.parentId },
select: {
userType: true,
status: true,
agentProfile: { select: { blockDirectPlayerLogin: true } },
},
});
if (
parentAgent?.userType === 'AGENT' &&
parentAgent.status !== 'ACTIVE' &&
parentAgent.agentProfile?.blockDirectPlayerLogin
) {
throw appForbidden('PARENT_AGENT_SUSPENDED');
}
}
if (user.auth.lockedUntil && user.auth.lockedUntil > new Date()) {
throw appForbidden('ACCOUNT_LOCKED');
}
const valid = await bcrypt.compare(password, user.auth.passwordHash);
if (!valid) {
const failCount = user.auth.loginFailCount + 1;
const lockedUntil =
failCount >= MAX_LOGIN_FAILS ? new Date(Date.now() + LOCK_DURATION_MS) : null;
await this.prisma.userAuth.update({
where: { userId: user.id },
data: { loginFailCount: failCount, lockedUntil },
});
throw appUnauthorized('INVALID_CREDENTIALS');
}
await this.prisma.userAuth.update({
where: { userId: user.id },
data: { loginFailCount: 0, lockedUntil: null, lastLoginAt: new Date() },
});
const expiresIn =
portal === 'admin'
? this.config.get('JWT_ADMIN_EXPIRES', '2h')
: portal === 'agent'
? this.config.get('JWT_AGENT_EXPIRES', '8h')
: this.config.get('JWT_PLAYER_EXPIRES', '24h');
const payload: JwtPayload = {
sub: user.id.toString(),
username: user.username,
userType: user.userType,
role: user.adminRole?.role?.code,
};
const token = this.jwt.sign(payload, { expiresIn });
const rolePerms = user.adminRole?.role as
| { permissions?: Array<{ permission: { code: string } }> }
| undefined
| null;
const adminPermissions =
user.userType === 'ADMIN'
? (rolePerms?.permissions?.map((rp) => rp.permission.code) ?? [])
: undefined;
return {
token,
user: {
id: user.id.toString(),
username: user.username,
userType: user.userType,
locale: user.locale,
role: user.adminRole?.role?.code,
agentLevel: user.userType === 'AGENT' ? user.agentLevel : null,
visibleMenus: user.visibleMenus,
...(adminPermissions ? { permissions: adminPermissions } : {}),
},
};
}
async resolveInviteSponsor(inviteCodeRaw?: string | null) {
const resolved = await this.invites.resolveActiveInvite(inviteCodeRaw);
return {
sponsorId: resolved.sponsorId,
parentId: resolved.parentId,
inviteId: resolved.inviteId,
};
}
async registerPlayer(data: {
username: string;
phone: string;
countryCode: string;
password: string;
smsCode: string;
sessionId: string;
inviteCode?: string;
locale?: string;
}) {
const username = data.username.trim();
if (!username) {
throw appBadRequest('USERNAME_REQUIRED');
}
try {
assertPlayerUsername(username);
} catch {
throw appBadRequest('USERNAME_FORMAT_INVALID');
}
const dial = data.countryCode.replace(/\D/g, '');
const phoneLocal = stripLocalPhoneDigits(data.phone);
const phone = normalizePhone(dial, data.phone);
if (!data.password || data.password.length < 8) {
throw appBadRequest('PASSWORD_MIN_LENGTH');
}
if (!data.smsCode?.trim() || !data.sessionId?.trim()) {
throw appBadRequest('SMS_CODE_REQUIRED');
}
await this.sms.verifyCode({
phone: data.phone,
countryCode: data.countryCode,
code: data.smsCode.trim(),
sessionId: data.sessionId.trim(),
expectedPurpose: 'register',
});
const { parentId, sponsorId, inviteId } = await this.resolveInviteSponsor(data.inviteCode);
const inviteSponsorId = parentId == null && sponsorId != null ? sponsorId : null;
const existingUser = await this.prisma.user.findUnique({
where: { username },
select: { id: true },
});
if (existingUser) {
throw appBadRequest('USERNAME_TAKEN');
}
const existingPhone = await this.prisma.userPreference.findFirst({
where: {
phoneCountryDial: dial,
phoneLocal,
user: { deletedAt: null, userType: 'PLAYER' },
},
select: { userId: true },
});
if (existingPhone) {
throw appBadRequest('PHONE_TAKEN');
}
const hash = await this.hashPassword(data.password);
const locale = data.locale?.trim() || 'zh-CN';
const user = await this.prisma.$transaction(async (tx) => {
const created = await tx.user.create({
data: {
username,
userType: 'PLAYER',
parentId,
inviteSponsorId,
locale,
},
});
await tx.userAuth.create({
data: { userId: created.id, passwordHash: hash },
});
await tx.wallet.create({
data: { userId: created.id },
});
await tx.userPreference.create({
data: {
userId: created.id,
locale,
phone,
phoneCountryDial: dial,
phoneLocal,
},
});
if (inviteId) {
await this.invites.recordRegistration(inviteId, created.id, tx);
const invite = await tx.userInvite.findUnique({
where: { id: inviteId },
select: { cashbackRate: true },
});
if (invite?.cashbackRate != null && new Decimal(invite.cashbackRate).gt(0)) {
await tx.cashbackRule.updateMany({
where: { targetType: 'USER', targetId: created.id },
data: { isActive: false },
});
await tx.cashbackRule.create({
data: {
name: `Player ${created.id.toString()}`,
targetType: 'USER',
targetId: created.id,
rate: invite.cashbackRate,
isActive: true,
},
});
}
}
return created;
});
this.logger.log(`Player registered username=${username} phone=${maskPhoneForLog(phone)}`);
return this.login(username, data.password, 'player');
}
async getInviteInfo(userId: bigint) {
const user = await this.prisma.user.findUnique({
where: { id: userId },
select: { inviteCode: true, userType: true, deletedAt: true },
});
if (!user || user.deletedAt) {
throw appNotFound('USER_NOT_FOUND');
}
if (user.userType !== 'ADMIN' && user.userType !== 'AGENT') {
throw appForbidden('ACCESS_DENIED_PORTAL');
}
return { inviteCode: user.inviteCode ?? null };
}
async generateInviteCode(
userId: bigint,
userType: string,
cashbackRate?: number,
) {
return this.invites.generateInviteCode(userId, {
userType,
cashbackRate: cashbackRate ?? null,
});
}
async changePassword(userId: bigint, oldPassword: string, newPassword: string) {
const auth = await this.prisma.userAuth.findUnique({ where: { userId } });
if (!auth) throw appUnauthorized('USER_NOT_FOUND');
const settings = await this.systemConfig.getPlayerAccountSettings();
if (!settings.allowPasswordChange) {
throw appForbidden('PASSWORD_CHANGE_DISABLED');
}
const valid = await bcrypt.compare(oldPassword, auth.passwordHash);
if (!valid) throw appUnauthorized('INVALID_OLD_PASSWORD');
const hash = await bcrypt.hash(newPassword, 10);
await this.prisma.userAuth.update({
where: { userId },
data: { passwordHash: hash },
});
await this.prisma.userPreference.updateMany({
where: { userId },
data: { managedPassword: null },
});
return { success: true };
}
async resetPasswordByPhone(data: {
phone: string;
countryCode: string;
smsCode: string;
sessionId: string;
newPassword: string;
ipAddress?: string;
}) {
const settings = await this.systemConfig.getPlayerAccountSettings();
if (!settings.allowPasswordChange) {
throw appForbidden('PASSWORD_CHANGE_DISABLED');
}
if (!data.newPassword || data.newPassword.length < 8) {
throw appBadRequest('PASSWORD_MIN_LENGTH');
}
if (!data.smsCode?.trim() || !data.sessionId?.trim()) {
throw appBadRequest('SMS_CODE_REQUIRED');
}
await this.sms.verifyCode({
phone: data.phone,
countryCode: data.countryCode,
code: data.smsCode.trim(),
sessionId: data.sessionId.trim(),
expectedPurpose: 'reset_password',
});
const player = await this.findPlayerByPhone(data.countryCode, data.phone);
if (!player) {
throw appBadRequest('PHONE_NOT_REGISTERED');
}
if (player.status === 'DISABLED') {
throw appForbidden('ACCOUNT_DISABLED');
}
const hash = await this.hashPassword(data.newPassword);
await this.prisma.userAuth.update({
where: { userId: player.id },
data: {
passwordHash: hash,
loginFailCount: 0,
lockedUntil: null,
},
});
await this.prisma.userPreference.updateMany({
where: { userId: player.id },
data: { managedPassword: null },
});
await this.audit.log({
operatorId: player.id,
operatorType: 'PLAYER',
action: 'FORGOT_PASSWORD_RESET',
module: 'identity',
targetType: 'user',
targetId: player.id.toString(),
ipAddress: data.ipAddress,
});
this.logger.log(
`Password reset by phone userId=${player.id.toString()} phone=${maskPhoneForLog(normalizePhone(data.countryCode, data.phone))} ip=${data.ipAddress ?? 'n/a'}`,
);
return { success: true };
}
private async findPlayerByPhone(countryCode: string, phone: string) {
const dial = countryCode.replace(/\D/g, '');
const phoneLocal = stripLocalPhoneDigits(phone);
const pref = await this.prisma.userPreference.findFirst({
where: {
phoneCountryDial: dial,
phoneLocal,
user: { deletedAt: null, userType: 'PLAYER' },
},
select: { user: { select: { id: true, status: true } } },
});
return pref?.user ?? null;
}
async hashPassword(password: string): Promise<string> {
return bcrypt.hash(password, 10);
}
}