Commit f4a20935 authored by ThinhNC's avatar ThinhNC

Merge branch 'feat/unrecognized-device-warning' into 'develop'

feat(auth): implement unrecognized device warning and session management

See merge request !8
parents aea83a66 85e41667
......@@ -36,6 +36,7 @@ model User {
socialAccounts UserSocial[]
verificationTokens VerificationToken[]
passwordResetTokens PasswordResetToken[]
devices UserDevice[]
@@map("users")
}
......@@ -169,3 +170,19 @@ model PasswordResetToken {
@@map("password_reset_tokens")
}
model UserDevice {
id String @id @default(uuid()) @db.Uuid
userId String @map("user_id") @db.Uuid
deviceHash String @map("device_hash")
deviceName String? @map("device_name")
ipAddress String? @map("ip_address")
createdAt DateTime @default(now()) @map("created_at")
lastLoginAt DateTime @default(now()) @map("last_login_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([userId, deviceHash])
@@map("user_devices")
}
import crypto from 'crypto';
/**
* Parses raw User-Agent string to a friendly format (e.g. Chrome trên Windows)
*/
export function parseUserAgent(uaString: string | undefined): string {
if (!uaString) return 'Thiết bị không xác định';
let os = 'Hệ điều hành không xác định';
let browser = 'Trình duyệt không xác định';
const uaLower = uaString.toLowerCase();
// Parse OS
if (uaLower.includes('windows')) {
os = 'Windows';
} else if (uaLower.includes('macintosh') || uaLower.includes('mac os x')) {
os = 'macOS';
} else if (uaLower.includes('iphone') || uaLower.includes('ipad') || uaLower.includes('ipod')) {
os = 'iOS';
} else if (uaLower.includes('android')) {
os = 'Android';
} else if (uaLower.includes('linux')) {
os = 'Linux';
}
// Parse Browser
if (uaLower.includes('zalo')) {
browser = 'Zalo Mini App';
} else if (uaLower.includes('edg')) {
browser = 'Edge';
} else if (uaLower.includes('chrome') && !uaLower.includes('opr') && !uaLower.includes('opios')) {
browser = 'Chrome';
} else if (uaLower.includes('safari') && !uaLower.includes('chrome') && !uaLower.includes('crios')) {
browser = 'Safari';
} else if (uaLower.includes('firefox') || uaLower.includes('fxios')) {
browser = 'Firefox';
} else if (uaLower.includes('opera') || uaLower.includes('opr')) {
browser = 'Opera';
}
return `${browser} trên ${os}`;
}
/**
* Generates MD5 hash from raw User-Agent string
*/
export function generateDeviceHash(uaString: string | undefined): string {
const cleanUa = uaString || 'unknown';
return crypto.createHash('md5').update(cleanUa).digest('hex');
}
......@@ -95,4 +95,53 @@ export class MailService {
throw error;
}
}
async sendNewDeviceAlertEmail(email: string, details: { deviceName: string; ipAddress: string; loginTime: Date }, fullName?: string) {
const formattedDate = details.loginTime.toLocaleString('vi-VN', { timeZone: 'Asia/Ho_Chi_Minh' });
const mailOptions = {
from: mailConfig.from,
to: email,
subject: '[Cảnh báo bảo mật] Đăng nhập từ thiết bị mới trên FinWise',
html: `
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; border: 1px solid #e0e0e0; border-radius: 5px;">
<h2 style="color: #f44336; text-align: center;">Cảnh báo đăng nhập thiết bị mới</h2>
<p>Chào ${fullName || 'bạn'},</p>
<p>FinWise phát hiện tài khoản của bạn vừa được đăng nhập từ một thiết bị mới hoặc trình duyệt lạ.</p>
<div style="background-color: #f9f9f9; padding: 15px; border-radius: 5px; margin: 20px 0; border-left: 4px solid #f44336;">
<p style="margin: 5px 0;"><strong>Thiết bị:</strong> ${details.deviceName}</p>
<p style="margin: 5px 0;"><strong>Địa chỉ IP:</strong> ${details.ipAddress}</p>
<p style="margin: 5px 0;"><strong>Thời gian:</strong> ${formattedDate} (Giờ Việt Nam)</p>
</div>
<p>Nếu <strong>đây là bạn</strong>, bạn không cần làm gì cả. Thiết bị này sẽ tự động được thêm vào danh sách tin cậy.</p>
<p style="color: #f44336; font-weight: bold;">Nếu KHÔNG phải bạn, vui lòng thực hiện các bước sau ngay lập tức để bảo vệ tài khoản:</p>
<ol>
<li>Đăng nhập vào tài khoản trên thiết bị chính chủ của bạn.</li>
<li>Vào mục <strong>Cài đặt bảo mật > Quản lý thiết bị</strong> để đăng xuất từ xa phiên đăng nhập lạ này.</li>
<li>Tiến hành <strong>Đổi mật khẩu</strong> tài khoản ngay lập tức.</li>
</ol>
<hr style="border: 0; border-top: 1px solid #e0e0e0; margin: 20px 0;" />
<p style="font-size: 12px; color: #888888; text-align: center;">Đây là email tự động từ hệ thống bảo mật FinWise. Vui lòng không trả lời email này.</p>
</div>
`,
};
if (!mailConfig.auth.user || !mailConfig.auth.pass) {
console.warn('-------- NEW DEVICE ALERT EMAIL (DEV MODE) --------');
console.warn(`To: ${email}`);
console.warn(`Device: ${details.deviceName}`);
console.warn(`IP: ${details.ipAddress}`);
console.warn(`Time: ${formattedDate}`);
console.warn('----------------------------------------------------');
return;
}
try {
await this.transporter.sendMail(mailOptions);
} catch (error) {
console.error('Failed to send new device alert email:', error);
throw error;
}
}
}
......@@ -110,6 +110,16 @@ export const swaggerSpec = {
refreshToken: { type: 'string' },
},
},
Session: {
type: 'object',
properties: {
id: { type: 'string', format: 'uuid' },
deviceName: { type: 'string', example: 'Chrome trên Windows' },
ipAddress: { type: 'string', example: '127.0.0.1' },
createdAt: { type: 'string', format: 'date-time' },
isCurrent: { type: 'boolean', example: false },
},
},
RefreshBody: {
type: 'object',
properties: {
......@@ -476,6 +486,91 @@ export const swaggerSpec = {
},
},
},
'/auth/sessions': {
get: {
tags: ['Auth'],
summary: 'Lấy danh sách các phiên đăng nhập đang hoạt động',
security: [{ BearerAuth: [] }],
responses: {
200: {
description: 'Danh sách các phiên đăng nhập đang hoạt động',
content: {
'application/json': {
schema: {
allOf: [
{ $ref: '#/components/schemas/SuccessResponse' },
{
type: 'object',
properties: {
data: {
type: 'array',
items: { $ref: '#/components/schemas/Session' },
},
},
},
],
},
},
},
},
401: { $ref: '#/components/responses/Unauthorized' },
},
},
delete: {
tags: ['Auth'],
summary: 'Đăng xuất khỏi tất cả các thiết bị khác',
security: [{ BearerAuth: [] }],
responses: {
200: {
description: 'Đăng xuất tất cả thiết bị khác thành công',
content: {
'application/json': {
schema: {
allOf: [
{ $ref: '#/components/schemas/SuccessResponse' },
{ type: 'object', properties: { message: { type: 'string', example: 'All other sessions revoked successfully' } } },
],
},
},
},
},
401: { $ref: '#/components/responses/Unauthorized' },
},
},
},
'/auth/sessions/{id}': {
delete: {
tags: ['Auth'],
summary: 'Hủy/Đăng xuất một phiên đăng nhập cụ thể',
security: [{ BearerAuth: [] }],
parameters: [
{
in: 'path',
name: 'id',
required: true,
schema: { type: 'string', format: 'uuid' },
description: 'ID của phiên đăng nhập (RefreshToken ID) cần xóa',
},
],
responses: {
200: {
description: 'Đăng xuất thiết bị thành công',
content: {
'application/json': {
schema: {
allOf: [
{ $ref: '#/components/schemas/SuccessResponse' },
{ type: 'object', properties: { message: { type: 'string', example: 'Session revoked successfully' } } },
],
},
},
},
},
401: { $ref: '#/components/responses/Unauthorized' },
404: { $ref: '#/components/responses/NotFound' },
},
},
},
'/users': {
get: {
tags: ['Users'],
......
......@@ -201,4 +201,46 @@ export class AuthController {
next(error);
}
};
getSessions = async (req: Request, res: Response, next: NextFunction) => {
try {
const currentToken = req.cookies?.refreshToken || req.body.refreshToken;
const result = await this.service.getActiveSessions(req.user.id, currentToken);
res.json({
success: true,
data: result,
});
} catch (error) {
next(error);
}
};
revokeSession = async (req: Request, res: Response, next: NextFunction) => {
try {
const { id } = req.params;
await this.service.revokeSession(req.user.id, id);
res.json({
success: true,
message: 'Session revoked successfully',
});
} catch (error) {
next(error);
}
};
revokeOtherSessions = async (req: Request, res: Response, next: NextFunction) => {
try {
const currentToken = req.cookies?.refreshToken || req.body.refreshToken;
if (!currentToken) {
throw new AppError('Current session token is required', 400, ERROR_CODE.TOKEN_INVALID);
}
await this.service.revokeAllOtherSessions(req.user.id, currentToken);
res.json({
success: true,
message: 'All other sessions revoked successfully',
});
} catch (error) {
next(error);
}
};
}
......@@ -192,4 +192,66 @@ export class AuthRepository {
where: { id: tokenId },
});
}
async findUserDevice(userId: string, deviceHash: string) {
return prisma.userDevice.findUnique({
where: {
userId_deviceHash: {
userId,
deviceHash,
},
},
});
}
async createUserDevice(data: { userId: string; deviceHash: string; deviceName: string; ipAddress?: string }) {
return prisma.userDevice.create({
data,
});
}
async updateUserDeviceLastLogin(userId: string, deviceHash: string, ipAddress?: string) {
return prisma.userDevice.update({
where: {
userId_deviceHash: {
userId,
deviceHash,
},
},
data: {
ipAddress,
lastLoginAt: new Date(),
},
});
}
async findSessionsByUserId(userId: string) {
return prisma.refreshToken.findMany({
where: { userId },
orderBy: { createdAt: 'desc' },
});
}
async findSessionById(userId: string, sessionId: string) {
return prisma.refreshToken.findFirst({
where: { id: sessionId, userId },
});
}
async deleteSessionById(userId: string, sessionId: string) {
return prisma.refreshToken.deleteMany({
where: { id: sessionId, userId },
});
}
async deleteOtherSessions(userId: string, currentToken: string) {
return prisma.refreshToken.deleteMany({
where: {
userId,
NOT: {
token: currentToken,
},
},
});
}
}
......@@ -19,4 +19,9 @@ router.post('/forgot-password', validate(forgotPasswordSchema), controller.forgo
router.post('/reset-password', validate(resetPasswordSchema), controller.resetPassword);
router.post('/resend-verification', validate(resendVerificationSchema), controller.resendVerification);
// Session management
router.get('/sessions', authMiddleware, controller.getSessions);
router.delete('/sessions/:id', authMiddleware, controller.revokeSession);
router.delete('/sessions', authMiddleware, controller.revokeOtherSessions);
export default router;
......@@ -8,6 +8,7 @@ import { jwtConfig } from '../../config/jwt.config';
import { LoginDto, LoginResponseDto, AuthTokensDto, MeDto, RegisterDto, UpdateProfileDto, UpdatePasswordDto, ForgotPasswordDto, ResetPasswordDto, ResendVerificationDto } from './auth.dto';
import { MailService } from '../../common/services/mail.service';
import { ROLES } from '../../common/constants/role.constant';
import { generateDeviceHash, parseUserAgent } from '../../common/helpers/user-agent.helper';
export class AuthService {
private readonly repository = new AuthRepository();
......@@ -41,14 +42,51 @@ export class AuthService {
expiresIn: jwtConfig.accessExpiresIn as any,
});
const refreshToken = jwt.sign(payload, jwtConfig.refreshSecret, {
expiresIn: jwtConfig.refreshExpiresIn as any,
});
const refreshToken = jwt.sign(
{ ...payload, jti: crypto.randomUUID() },
jwtConfig.refreshSecret,
{ expiresIn: jwtConfig.refreshExpiresIn as any }
);
const decoded = jwt.decode(refreshToken) as { exp: number };
const expiresAt = new Date(decoded.exp * 1000);
await this.repository.saveRefreshToken(user.id, refreshToken, expiresAt, metadata?.userAgent, metadata?.ipAddress);
// Track user devices and trigger new device login alert
if (metadata?.userAgent) {
const deviceHash = generateDeviceHash(metadata.userAgent);
const parsedDevice = parseUserAgent(metadata.userAgent);
const existingDevice = await this.repository.findUserDevice(user.id, deviceHash);
if (!existingDevice) {
// Create new device and trigger email alert in the background
await this.repository.createUserDevice({
userId: user.id,
deviceHash,
deviceName: parsedDevice,
ipAddress: metadata.ipAddress,
});
if (user.email) {
// Send email alert asynchronously without blocking login response
this.mailService.sendNewDeviceAlertEmail(
user.email,
{
deviceName: parsedDevice,
ipAddress: metadata.ipAddress || 'Không rõ',
loginTime: new Date(),
},
user.fullName || undefined
).catch(err => {
console.error('Failed to send unrecognized device email:', err);
});
}
} else {
// Update last login info
await this.repository.updateUserDeviceLastLogin(user.id, deviceHash, metadata.ipAddress);
}
}
return {
accessToken,
refreshToken,
......@@ -90,9 +128,11 @@ export class AuthService {
expiresIn: jwtConfig.accessExpiresIn as any,
});
const newRefreshToken = jwt.sign(newPayload, jwtConfig.refreshSecret, {
expiresIn: jwtConfig.refreshExpiresIn as any,
});
const newRefreshToken = jwt.sign(
{ ...newPayload, jti: crypto.randomUUID() },
jwtConfig.refreshSecret,
{ expiresIn: jwtConfig.refreshExpiresIn as any }
);
await this.repository.deleteRefreshToken(token);
......@@ -273,4 +313,27 @@ export class AuthService {
await this.mailService.sendVerificationEmail(user.email!, token, user.fullName || undefined);
}
async getActiveSessions(userId: string, currentToken?: string) {
const sessions = await this.repository.findSessionsByUserId(userId);
return sessions.map(session => ({
id: session.id,
deviceName: parseUserAgent(session.userAgent || undefined),
ipAddress: session.ipAddress || 'Không rõ',
createdAt: session.createdAt,
isCurrent: currentToken ? session.token === currentToken : false,
}));
}
async revokeSession(userId: string, sessionId: string) {
const session = await this.repository.findSessionById(userId, sessionId);
if (!session) {
throw new AppError('Session not found', 404, ERROR_CODE.NOT_FOUND);
}
await this.repository.deleteSessionById(userId, sessionId);
}
async revokeAllOtherSessions(userId: string, currentToken: string) {
await this.repository.deleteOtherSessions(userId, currentToken);
}
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment