|
|
@@ -1,158 +1,215 @@
|
|
|
// apps/box-app-api/src/feature/auth/auth.service.ts
|
|
|
import { Injectable, Logger } from '@nestjs/common';
|
|
|
-import { JwtService } from '@nestjs/jwt';
|
|
|
import { UserLoginEventPayload } from '@box/common/events/user-login-event.dto';
|
|
|
import { RabbitmqPublisherService } from '../../rabbitmq/rabbitmq-publisher.service';
|
|
|
-import { AppJwtPayload } from './interfaces/app-jwt-payload';
|
|
|
import { PrismaMongoStatsService } from '../../prisma/prisma-mongo-stats.service';
|
|
|
import { PrismaMongoService } from '../../prisma/prisma-mongo.service';
|
|
|
|
|
|
+type LoginParams = {
|
|
|
+ uid: string;
|
|
|
+ ip: string;
|
|
|
+ userAgent?: string;
|
|
|
+ appVersion?: string;
|
|
|
+ os?: string;
|
|
|
+ channelId?: string;
|
|
|
+ machine?: string;
|
|
|
+};
|
|
|
+
|
|
|
+type LoginResult = {
|
|
|
+ uid: string;
|
|
|
+ channelId: string;
|
|
|
+ startupAds: any | null; // keep as any until you wire your Ad payload DTO
|
|
|
+};
|
|
|
+
|
|
|
@Injectable()
|
|
|
export class AuthService {
|
|
|
private readonly logger = new Logger(AuthService.name);
|
|
|
+
|
|
|
constructor(
|
|
|
- private readonly jwtService: JwtService,
|
|
|
private readonly rabbitmqPublisher: RabbitmqPublisherService,
|
|
|
private readonly prismaMongoStatsService: PrismaMongoStatsService,
|
|
|
- private readonly prismaMongoService: PrismaMongoService,
|
|
|
+ private readonly prismaMongoService: PrismaMongoService, // box-admin
|
|
|
) {}
|
|
|
|
|
|
- async login(params: {
|
|
|
- uid: string;
|
|
|
- ip: string;
|
|
|
- userAgent?: string;
|
|
|
- appVersion?: string;
|
|
|
- os?: string;
|
|
|
- channelId?: string;
|
|
|
- machine?: string;
|
|
|
- // plus whatever you need like account, password, etc.
|
|
|
- }): Promise<any> {
|
|
|
- const { uid, ip, userAgent, appVersion, os, channelId, machine } = params;
|
|
|
-
|
|
|
- // 1) Your existing auth logic (validate user, etc.)
|
|
|
- // const user = await this.validateUser(...);
|
|
|
-
|
|
|
- // 2) Generate tokenId (jti) and JWT
|
|
|
- const tokenId = crypto.randomUUID(); // Node 18+; or use uuid library
|
|
|
-
|
|
|
- const now = Date.now(); // number (ms since epoch)
|
|
|
-
|
|
|
- const user = await this.getUserByUid(uid);
|
|
|
- const firstChannel = await this.getChannelById(channelId || '');
|
|
|
- // if user does not exist, channelId = firstChannel.channelId
|
|
|
- let finalchannelId = channelId;
|
|
|
- if (!user && firstChannel) {
|
|
|
- finalchannelId = firstChannel.channelId;
|
|
|
- }
|
|
|
- // if user exists, take firstChannel.channelId
|
|
|
- else if (user && firstChannel) {
|
|
|
- finalchannelId = firstChannel.channelId;
|
|
|
+ async login(params: LoginParams): Promise<LoginResult> {
|
|
|
+ const { uid, ip, userAgent, appVersion, os } = params;
|
|
|
+ const channelIdInput = this.normalizeOptional(params.channelId);
|
|
|
+ const machine = this.normalizeOptional(params.machine);
|
|
|
+
|
|
|
+ const nowSec = BigInt(Math.floor(Date.now() / 1000)); // BigInt epoch seconds
|
|
|
+
|
|
|
+ // 1) Find user (in box-admin)
|
|
|
+ let user = await this.getUserByUid(uid);
|
|
|
+
|
|
|
+ // 2) First login: auto-register
|
|
|
+ if (!user) {
|
|
|
+ const resolvedChannel =
|
|
|
+ await this.resolveFirstLoginChannel(channelIdInput);
|
|
|
+ user = await this.createUser({
|
|
|
+ uid,
|
|
|
+ ip,
|
|
|
+ os: os ?? 'unknown',
|
|
|
+ channelId: resolvedChannel.channelId,
|
|
|
+ machine,
|
|
|
+ nowSec,
|
|
|
+ });
|
|
|
+ } else {
|
|
|
+ // 3) Subsequent login: keep stored user.channelId (ignore incoming channelId)
|
|
|
+ // Optionally update machine if provided (safe)
|
|
|
+ if (
|
|
|
+ machine &&
|
|
|
+ machine !== this.normalizeOptional((user as any).machine)
|
|
|
+ ) {
|
|
|
+ try {
|
|
|
+ user = await this.prismaMongoService.user.update({
|
|
|
+ where: { uid },
|
|
|
+ data: {
|
|
|
+ machine,
|
|
|
+ updateAt: nowSec,
|
|
|
+ },
|
|
|
+ });
|
|
|
+ } catch (e) {
|
|
|
+ // non-blocking
|
|
|
+ this.logger.warn(
|
|
|
+ `Failed to update machine for uid=${uid}: ${String(e)}`,
|
|
|
+ );
|
|
|
+ }
|
|
|
+ }
|
|
|
}
|
|
|
|
|
|
- // Build JWT payload with required and optional tracking fields.
|
|
|
- // Tracking fields (channelId, machine, ip, userAgent, appVersion, os) are optional
|
|
|
- // to preserve backward compatibility with older tokens and minimize JWT size for stability.
|
|
|
- const payload: AppJwtPayload = {
|
|
|
- sub: uid,
|
|
|
+ const finalChannelId = String((user as any).channelId);
|
|
|
+
|
|
|
+ // 4) Publish login history event (fire-and-forget)
|
|
|
+ const loginEvent: UserLoginEventPayload = {
|
|
|
uid,
|
|
|
- jti: tokenId,
|
|
|
- channelId: finalchannelId,
|
|
|
- machine,
|
|
|
ip,
|
|
|
userAgent,
|
|
|
appVersion,
|
|
|
os,
|
|
|
- iat: Math.floor(now / 1000), // issued at (in seconds, per JWT spec)
|
|
|
+ channelId: finalChannelId,
|
|
|
+ machine: machine ?? undefined,
|
|
|
+ // IMPORTANT: use seconds (BigInt not always serializable across MQ)
|
|
|
+ loginAt: Number(nowSec), // if consumer expects ms, change consumer; per your rule we use seconds
|
|
|
};
|
|
|
|
|
|
- const accessToken = await this.jwtService.signAsync(payload);
|
|
|
+ this.publishLoginEvent(loginEvent).catch(() => {
|
|
|
+ // already logged inside
|
|
|
+ });
|
|
|
+
|
|
|
+ // 5) startupAds (placeholder: you’ll wire channel-specific ads later)
|
|
|
+ // For now return null to keep behaviour deterministic.
|
|
|
+ const startupAds: any | null = null;
|
|
|
|
|
|
- // 3) Build login event payload
|
|
|
- const loginEvent: UserLoginEventPayload = {
|
|
|
+ return {
|
|
|
uid,
|
|
|
- ip,
|
|
|
- userAgent,
|
|
|
- appVersion,
|
|
|
- os,
|
|
|
- channelId: finalchannelId,
|
|
|
- machine,
|
|
|
- tokenId,
|
|
|
- loginAt: now,
|
|
|
+ channelId: finalChannelId,
|
|
|
+ startupAds,
|
|
|
};
|
|
|
+ }
|
|
|
|
|
|
- // 4) Fire-and-forget publish (but wait for broker confirm)
|
|
|
+ private async publishLoginEvent(
|
|
|
+ payload: UserLoginEventPayload,
|
|
|
+ ): Promise<void> {
|
|
|
try {
|
|
|
- this.logger.log(`Publishing user login event for uid=${uid}`);
|
|
|
- await this.rabbitmqPublisher.publishUserLogin(loginEvent);
|
|
|
+ this.logger.log(`Publishing user login event for uid=${payload.uid}`);
|
|
|
+ await this.rabbitmqPublisher.publishUserLogin(payload);
|
|
|
} catch (error) {
|
|
|
- // Decide your policy:
|
|
|
- // - Either just log and continue (login succeeds even if stats fail),
|
|
|
- // - Or throw to fail login if stats are critical.
|
|
|
- // For now, let's log and continue.
|
|
|
- // If you want stricter behaviour, re-throw.
|
|
|
const errorMessage =
|
|
|
error instanceof Error ? error.message : String(error);
|
|
|
const errorStack = error instanceof Error ? error.stack : undefined;
|
|
|
this.logger.error(
|
|
|
- `Failed to publish user login event for uid=${uid}: ${errorMessage}`,
|
|
|
+ `Failed to publish user login event for uid=${payload.uid}: ${errorMessage}`,
|
|
|
errorStack,
|
|
|
);
|
|
|
}
|
|
|
+ }
|
|
|
|
|
|
- // let startupAd: AdPayload | null = null;
|
|
|
- // try {
|
|
|
- // startupAd = await this.adPoolService.getRandomAdByType(AdType.STARTUP);
|
|
|
- // } catch (err) {
|
|
|
- // this.logger.error('Failed to get startup ad for login', err);
|
|
|
- // }
|
|
|
- // let startupAdWithoutUrl: Omit<AdPayload, 'adsUrl'> | null = null;
|
|
|
-
|
|
|
- // if (startupAd) {
|
|
|
- // const { adsUrl, ...rest } = startupAd;
|
|
|
- // startupAdWithoutUrl = rest;
|
|
|
- // }
|
|
|
-
|
|
|
- return { accessToken };
|
|
|
+ private normalizeOptional(v?: string): string | undefined {
|
|
|
+ const s = (v ?? '').trim();
|
|
|
+ return s.length > 0 ? s : undefined;
|
|
|
}
|
|
|
|
|
|
- // add a function to retrieve user record from mongo by uid
|
|
|
- async getUserByUid(uid: string): Promise<any> {
|
|
|
- // Implement your MongoDB query here to find the user by uid
|
|
|
- // For example, if you have a UserModel injected, you might do:
|
|
|
- // return this.userModel.findOne({ uid }).exec();
|
|
|
- // Placeholder implementation:
|
|
|
+ // ---------------------------
|
|
|
+ // User (box-admin)
|
|
|
+ // ---------------------------
|
|
|
|
|
|
- const user = await this.prismaMongoStatsService.user.findUnique({
|
|
|
+ private async getUserByUid(uid: string): Promise<any | null> {
|
|
|
+ return this.prismaMongoService.user.findUnique({
|
|
|
where: { uid },
|
|
|
});
|
|
|
-
|
|
|
- if (user) {
|
|
|
- return user;
|
|
|
- }
|
|
|
- return null;
|
|
|
}
|
|
|
|
|
|
- // retrive the first channel from mongo collection, order by createAt ascending
|
|
|
- async getFirstChannel(): Promise<any> {
|
|
|
- const channel = await this.prismaMongoService.channel.findFirst({
|
|
|
- orderBy: { createAt: 'asc' },
|
|
|
+ private async createUser(input: {
|
|
|
+ uid: string;
|
|
|
+ ip: string;
|
|
|
+ os: string;
|
|
|
+ channelId: string;
|
|
|
+ machine?: string;
|
|
|
+ nowSec: bigint;
|
|
|
+ }): Promise<any> {
|
|
|
+ const { uid, channelId, machine, nowSec } = input;
|
|
|
+
|
|
|
+ // Validate uniqueness explicitly (clear intention; Prisma will also enforce unique if set)
|
|
|
+ const existed = await this.getUserByUid(uid);
|
|
|
+ if (existed) return existed;
|
|
|
+
|
|
|
+ return this.prismaMongoService.user.create({
|
|
|
+ data: {
|
|
|
+ uid,
|
|
|
+ ip: input.ip,
|
|
|
+ os: input.os,
|
|
|
+ channelId,
|
|
|
+ machine: machine ?? null,
|
|
|
+ createAt: nowSec,
|
|
|
+ updateAt: nowSec,
|
|
|
+ lastLoginAt: nowSec,
|
|
|
+ },
|
|
|
});
|
|
|
+ }
|
|
|
|
|
|
- if (channel) {
|
|
|
- return channel;
|
|
|
+ // ---------------------------
|
|
|
+ // Channel resolution (box-admin)
|
|
|
+ // ---------------------------
|
|
|
+
|
|
|
+ private async resolveFirstLoginChannel(
|
|
|
+ channelIdInput?: string,
|
|
|
+ ): Promise<any> {
|
|
|
+ // 1) If client provided channelId, validate it exists
|
|
|
+ if (channelIdInput) {
|
|
|
+ const byId = await this.getChannelByChannelId(channelIdInput);
|
|
|
+ if (byId) return byId;
|
|
|
}
|
|
|
- return null;
|
|
|
+
|
|
|
+ // 2) Use default channel where isDefault=true
|
|
|
+ const def = await this.getDefaultChannel();
|
|
|
+ if (def) return def;
|
|
|
+
|
|
|
+ // 3) Last resort: first created channel (so system can still run)
|
|
|
+ const first = await this.getFirstChannel();
|
|
|
+ if (first) return first;
|
|
|
+
|
|
|
+ // If no channel exists at all, this is a deployment/config error
|
|
|
+ throw new Error(
|
|
|
+ 'No channel available (missing default channel and no channels exist)',
|
|
|
+ );
|
|
|
}
|
|
|
|
|
|
- // find by channelId
|
|
|
- async getChannelById(channelId: string): Promise<any> {
|
|
|
- const channel = await this.prismaMongoService.channel.findUnique({
|
|
|
+ private async getChannelByChannelId(channelId: string): Promise<any | null> {
|
|
|
+ return this.prismaMongoService.channel.findUnique({
|
|
|
where: { channelId },
|
|
|
});
|
|
|
+ }
|
|
|
|
|
|
- if (channel) {
|
|
|
- return this.getFirstChannel();
|
|
|
- }
|
|
|
- return null;
|
|
|
+ private async getDefaultChannel(): Promise<any | null> {
|
|
|
+ return this.prismaMongoService.channel.findFirst({
|
|
|
+ where: { isDefault: true },
|
|
|
+ orderBy: { createAt: 'asc' },
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ private async getFirstChannel(): Promise<any | null> {
|
|
|
+ return this.prismaMongoService.channel.findFirst({
|
|
|
+ orderBy: { createAt: 'asc' },
|
|
|
+ });
|
|
|
}
|
|
|
}
|