mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage-server
synced 2026-04-11 10:07:22 +00:00
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:
@@ -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$';
|
||||
|
||||
@@ -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> {
|
||||
|
||||
Reference in New Issue
Block a user