feat: session lock stores IP + timestamp for debugging

- SessionService stores JSON { memberId, ip, lockedAt } instead of plain memberId
- Auth controller extracts client IP from x-forwarded-for header
- Lockout error message includes IP of blocking device

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-24 13:21:13 +05:30
parent 77c5335955
commit a35a7d70bf
2 changed files with 25 additions and 9 deletions

View File

@@ -1,4 +1,5 @@
import { Controller, Post, Body, Headers, Logger, HttpException } from '@nestjs/common';
import { Controller, Post, Body, Headers, Req, Logger, HttpException } from '@nestjs/common';
import type { Request } from 'express';
import { ConfigService } from '@nestjs/config';
import axios from 'axios';
import { OzonetelAgentService } from '../ozonetel/ozonetel-agent.service';
@@ -24,7 +25,7 @@ export class AuthController {
}
@Post('login')
async login(@Body() body: { email: string; password: string }) {
async login(@Body() body: { email: string; password: string }, @Req() req: Request) {
this.logger.log(`Login attempt for ${body.email}`);
try {
@@ -128,13 +129,15 @@ export class AuthController {
}
// Check for duplicate login — strict: one device only
const existingSession = await this.sessionService.isSessionLocked(agentConfig.ozonetelAgentId);
const clientIp = (req.headers['x-forwarded-for'] as string)?.split(',')[0]?.trim() ?? req.ip ?? 'unknown';
const existingSession = await this.sessionService.getSession(agentConfig.ozonetelAgentId);
if (existingSession) {
throw new HttpException('You are already logged in on another device. Please log out there first.', 409);
this.logger.warn(`Duplicate login blocked for ${body.email} — session held by IP ${existingSession.ip} since ${existingSession.lockedAt}`);
throw new HttpException(`You are already logged in from another device (${existingSession.ip}). Please log out there first.`, 409);
}
// Lock session in Redis
await this.sessionService.lockSession(agentConfig.ozonetelAgentId, memberId);
// Lock session in Redis with IP
await this.sessionService.lockSession(agentConfig.ozonetelAgentId, memberId, clientIp);
// Login to Ozonetel with agent-specific credentials
const ozAgentPassword = process.env.OZONETEL_AGENT_PASSWORD ?? 'Test123$';

View File

@@ -22,12 +22,25 @@ export class SessionService implements OnModuleInit {
return `agent:session:${agentId}`;
}
async lockSession(agentId: string, memberId: string): Promise<void> {
await this.redis.set(this.key(agentId), memberId, 'EX', SESSION_TTL);
async lockSession(agentId: string, memberId: string, ip?: string): Promise<void> {
const value = JSON.stringify({ memberId, ip: ip ?? 'unknown', lockedAt: new Date().toISOString() });
await this.redis.set(this.key(agentId), value, 'EX', SESSION_TTL);
}
async getSession(agentId: string): Promise<{ memberId: string; ip: string; lockedAt: string } | null> {
const raw = await this.redis.get(this.key(agentId));
if (!raw) return null;
try {
return JSON.parse(raw);
} catch {
// Legacy format — just memberId string
return { memberId: raw, ip: 'unknown', lockedAt: 'unknown' };
}
}
async isSessionLocked(agentId: string): Promise<string | null> {
return this.redis.get(this.key(agentId));
const session = await this.getSession(agentId);
return session ? session.memberId : null;
}
async refreshSession(agentId: string): Promise<void> {