484 lines
14 KiB
TypeScript
484 lines
14 KiB
TypeScript
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);
|
||
}
|
||
}
|