Commit d505ed0d authored by Blockchain-vn's avatar Blockchain-vn

feat/authentication

parent 4f023fe9
......@@ -102,18 +102,17 @@ export default (_express: Application) => {
return res.sendOk({
data: {
user: {
id: loginResult.user.id,
id: loginResult.user._id,
email: loginResult.user.email,
username: loginResult.user.username,
first_name: loginResult.user.first_name,
last_name: loginResult.user.last_name,
roles: (loginResult.user as any).roles,
permissions: (loginResult.user as any).permissions,
roles: (loginResult.user.roles as any[]).map(r => r.name),
status: loginResult.user.status,
last_login_at: loginResult.user_auth.last_login_at,
last_login_at: loginResult.user.last_login,
},
session: {
id: loginResult.session.id,
id: loginResult.session._id,
expires_at: loginResult.session.expires_at,
refresh_expires_at: loginResult.session.refresh_expires_at,
},
......
......@@ -14,7 +14,7 @@ export default (_express: Application) => {
* summary: Logout user
* description: Logout user and invalidate current session
* security:
* - Bearer: []
* - BearerAuth: []
* responses:
* 200:
* description: Logged out successfully
......
......@@ -2,7 +2,7 @@ import { Application } from "express";
import { Resource } from "express-automatic-routes";
import { Req, Res } from "#interfaces/IApi";
import { authenticate } from "#middlewares/auth";
import { User } from "#models/User";
import { User } from "#models/mongodb/User";
export default (_express: Application) => {
return <Resource>{
......@@ -14,7 +14,7 @@ export default (_express: Application) => {
* summary: Get user profile
* description: Get current authenticated user's profile information
* security:
* - Bearer: []
* - BearerAuth: []
* responses:
* 200:
* description: Profile retrieved successfully
......@@ -25,48 +25,47 @@ export default (_express: Application) => {
* properties:
* success:
* type: boolean
* example: true
* data:
* type: object
* properties:
* id:
* type: string
* example: "uuid-string"
* email:
* type: string
* example: "user@example.com"
* username:
* type: string
* example: "john_doe"
* role:
* first_name:
* type: string
* enum: [user, admin, system_admin]
* example: "user"
* permissions:
* last_name:
* type: string
* status:
* type: string
* roles:
* type: array
* items:
* type: string
* example: ["read:profile"]
* 401:
* $ref: '#/components/responses/Unauthorized'
* example: ["admin"]
*/
get: {
middleware: [authenticate],
handler: async (req: Req, res: Res) => {
try {
// Get user with roles and permissions
const user = await User.findByPk(req.user?.id, {
attributes: ["id", "email", "username", "first_name", "last_name", "status"],
});
const user = await User.findById(req.user?.id)
.select("-password_hash")
.populate("roles", "name");
if (!user) {
return res.error({ message: "User not found", status: 404 });
}
const userData = {
...user.toJSON(),
roles: req.user?.roles,
permissions: req.user?.permissions,
id: user._id,
email: user.email,
username: user.username,
first_name: user.first_name,
last_name: user.last_name,
status: user.status,
roles: (user.roles as any[]).map(r => r.name),
};
return res.sendOk({ data: userData });
......
......@@ -18,14 +18,7 @@ export default (_express: Application) => {
* content:
* application/json:
* schema:
* type: object
* properties:
* refresh_token:
* type: string
* description: Refresh token (optional if sent via cookie)
* device_info:
* type: object
* description: Device information
* $ref: '#/components/schemas/RefreshRequest'
* responses:
* 200:
* description: Token refreshed successfully
......
import { Application } from "express";
import { Resource } from "express-automatic-routes";
import { Req, Res } from "#interfaces/IApi";
import { AuthService } from "#services/authService";
import { authenticate, requireAdmin } from "#middlewares/auth";
import { validateRegister } from "#middlewares/validators/auth";
export default (_express: Application) => {
return <Resource>{
/**
* @openapi
* /auth/register:
* post:
* tags: [Authentication]
* summary: Register a new user (Admin only)
* description: Create a new account. Only accessible by administrators.
* security:
* - BearerAuth: []
* requestBody:
* required: true
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/RegisterRequest'
* responses:
* 201:
* description: User created successfully
* 401:
* $ref: '#/components/responses/Unauthorized'
* 403:
* $ref: '#/components/responses/Forbidden'
* 409:
* description: User already exists
*/
post: {
middleware: [authenticate, requireAdmin, validateRegister],
handler: async (req: Req, res: Res) => {
try {
const userData = req.body;
const user = await AuthService.register(userData);
await user.populate("roles", "name");
return res.sendOk({
data: {
id: user._id,
email: user.email,
username: user.username,
status: user.status,
roles: (user.roles as any[]).map(r => r.name)
},
message: "Người dùng đã được tạo thành công",
message_en: "User created successfully",
statusCode: 201
});
} catch (error) {
return res.error(error);
}
},
},
};
};
......@@ -3,7 +3,7 @@ import { NextFunction } from "express";
import { AuthService, JWTPayload } from "../services/authService";
import { GenericError } from "#interfaces/error/generic";
import { console } from "inspector";
type UserRole = "user" | "admin" | "system_admin";
type UserRole = "admin" | "mentor" | "student";
// Extend Request interface
declare module "express" {
......@@ -113,14 +113,14 @@ export function authorize(allowedRoles: UserRole[] = []) {
* Admin only middleware
*/
export function requireAdmin(req: Req, res: Res, next: NextFunction): void {
return authorize(["admin", "system_admin"])(req, res, next);
return authorize(["admin"])(req, res, next);
}
/**
* Super admin only middleware
* Mentor only middleware
*/
export function requireSuperAdmin(req: Req, res: Res, next: NextFunction): void {
return authorize(["system_admin"])(req, res, next);
export function requireMentor(req: Req, res: Res, next: NextFunction): void {
return authorize(["mentor", "admin"])(req, res, next);
}
/**
......
......@@ -35,6 +35,9 @@ export const authSchemas = {
first_name: Joi.string().optional(),
last_name: Joi.string().optional(),
phone: Joi.string().optional(),
roles: Joi.array().items(Joi.string().valid('admin', 'mentor', 'student')).optional().messages({
"any.only": "Role không hợp lệ",
}),
}),
params: Joi.object(),
query: Joi.object(),
......
import mongoose, { Schema, Document } from 'mongoose';
export interface IRole extends Document {
name: string;
description?: string;
permissions: string[];
createdAt: Date;
updatedAt: Date;
}
const RoleSchemaEntity: Schema = new Schema(
{
name: {
type: String,
required: true,
unique: true,
enum: ['admin', 'mentor', 'student']
},
description: { type: String },
permissions: [{ type: String }],
},
{
timestamps: true,
versionKey: false
}
);
export const Role = mongoose.models.Role || mongoose.model<IRole>('Role', RoleSchemaEntity);
import mongoose, { Schema, Document } from 'mongoose';
export interface IUser extends Document {
email: string;
password_hash: string;
username?: string;
first_name?: string;
last_name?: string;
phone?: string;
avatar_url?: string;
status: 'active' | 'inactive' | 'suspended' | 'pending';
roles: mongoose.Types.ObjectId[] | string[];
last_login?: Date;
password_changed_at?: Date;
login_attempts: number;
lock_until?: Date;
createdAt: Date;
updatedAt: Date;
}
const UserSchemaEntity: Schema = new Schema(
{
email: {
type: String,
required: true,
unique: true,
lowercase: true,
trim: true
},
password_hash: {
type: String,
required: true
},
username: {
type: String,
unique: true,
sparse: true
},
first_name: { type: String },
last_name: { type: String },
phone: { type: String },
avatar_url: { type: String },
status: {
type: String,
enum: ['active', 'inactive', 'suspended', 'pending'],
default: 'active'
},
roles: [{
type: Schema.Types.ObjectId,
ref: 'Role'
}],
last_login: { type: Date },
password_changed_at: { type: Date },
login_attempts: {
type: Number,
required: true,
default: 0
},
lock_until: { type: Date }
},
{
timestamps: true,
versionKey: false
}
);
export const User = mongoose.models.User || mongoose.model<IUser>('User', UserSchemaEntity);
import mongoose, { Schema, Document } from 'mongoose';
export interface IUserSession extends Document {
user: mongoose.Types.ObjectId;
session_token: string;
refresh_token: string;
ip_address?: string;
user_agent?: string;
device_info?: any;
expires_at: Date;
refresh_expires_at: Date;
is_active: boolean;
last_activity_at: Date;
createdAt: Date;
updatedAt: Date;
}
const UserSessionSchemaEntity: Schema = new Schema(
{
user: {
type: Schema.Types.ObjectId,
ref: 'User',
required: true
},
session_token: {
type: String,
required: true,
unique: true
},
refresh_token: {
type: String,
required: true
},
ip_address: { type: String },
user_agent: { type: String },
device_info: { type: Schema.Types.Mixed },
expires_at: { type: Date, required: true },
refresh_expires_at: { type: Date, required: true },
is_active: {
type: Boolean,
default: true
},
last_activity_at: {
type: Date,
default: Date.now
}
},
{
timestamps: true,
versionKey: false
}
);
// TTL index to automatically remove expired sessions
UserSessionSchemaEntity.index({ refresh_expires_at: 1 }, { expireAfterSeconds: 0 });
UserSessionSchemaEntity.index({ session_token: 1 });
UserSessionSchemaEntity.index({ user: 1 });
export const UserSession = mongoose.models.UserSession || mongoose.model<IUserSession>('UserSession', UserSessionSchemaEntity);
import mongoose from 'mongoose';
import dotenv from 'dotenv';
import { Role } from '../models/mongodb/Role';
import { User } from '../models/mongodb/User';
import * as bcrypt from 'bcryptjs';
dotenv.config();
const MONGODB_URI = process.env.MONGODB_URI || 'mongodb://localhost:27017/trainee-schedule';
const seed = async () => {
try {
await mongoose.connect(MONGODB_URI);
console.log('Connected to MongoDB');
// Create Roles
const roles = [
{ name: 'admin', description: 'System Administrator' },
{ name: 'mentor', description: 'Instructor/Mentor' },
{ name: 'student', description: 'Trainee/Student' }
];
for (const roleData of roles) {
await Role.findOneAndUpdate(
{ name: roleData.name },
roleData,
{ upsert: true, new: true }
);
console.log(`Role ${roleData.name} initialized`);
}
const adminRole = await Role.findOne({ name: 'admin' });
if (!adminRole) throw new Error('Admin role not found');
// Create Default Admin User
const adminEmail = 'admin@admin.com';
const existingAdmin = await User.findOne({ email: adminEmail });
if (!existingAdmin) {
const hashedPassword = await bcrypt.hash('admin123', 12);
await User.create({
email: adminEmail,
password_hash: hashedPassword,
username: 'admin',
first_name: 'System',
last_name: 'Admin',
status: 'active',
roles: [adminRole._id] as any
});
console.log('Default admin user created: admin@admin.com / admin123');
} else {
console.log('Admin user already exists');
}
await mongoose.disconnect();
console.log('Seeding completed');
process.exit(0);
} catch (error) {
console.error('Seeding failed:', error);
process.exit(1);
}
};
seed();
import dayjs from "dayjs";
import * as bcrypt from "bcryptjs";
import { sign, verify, SignOptions } from "jsonwebtoken";
import { QueryTypes } from "sequelize";
import sequelize from "#services/database/sequelize/service";
import { User, UserAttributes } from "../models/User";
import { UserAuth } from "../models/UserAuth";
import { Role } from "../models/Role";
import { UserRole as UserRoleModel } from "../models/UserRole";
import { RolePermission } from "../models/RolePermission";
import { Permission } from "../models/Permission";
import { UserSession } from "../models/UserSession";
import { User, IUser } from "../models/mongodb/User";
import { Role, IRole } from "../models/mongodb/Role";
import { UserSession, IUserSession } from "../models/mongodb/UserSession";
import { GenericError } from "#interfaces/error/generic";
import {
isUserLocked,
......@@ -16,27 +11,8 @@ import {
resetUserLoginAttempts,
updateUserLastLogin,
checkAndUnlockExpiredLockout,
isSessionExpired,
isSessionRefreshExpired,
deactivateUserSession,
updateSessionActivity,
canUserChangePassword,
updateUserPasswordChanged,
isPasswordRecentlyUsed,
} from "../utils/authUtils";
enum UserStatus {
ACTIVE = "active",
INACTIVE = "inactive",
SUSPENDED = "suspended",
PENDING_VERIFICATION = "pending_verification",
}
enum UserRole {
USER = "user",
ADMIN = "admin",
SYSTEM_ADMIN = "system_admin",
}
export interface LoginCredentials {
email: string;
password: string;
......@@ -52,6 +28,7 @@ export interface RegisterData {
first_name?: string;
last_name?: string;
phone?: string;
roles?: string[]; // Array of role names
}
export interface TokenPair {
......@@ -63,18 +40,8 @@ export interface TokenPair {
}
export interface LoginResult extends TokenPair {
user: User;
user_auth: UserAuth;
session: UserSession;
}
export interface CookieOptions {
httpOnly: boolean;
secure: boolean;
sameSite: "strict" | "lax" | "none";
maxAge: number;
path: string;
domain?: string;
user: IUser;
session: IUserSession;
}
export interface JWTPayload {
......@@ -82,7 +49,6 @@ export interface JWTPayload {
email: string;
username?: string;
roles?: string[];
permissions?: string[];
type?: string;
iat?: number;
exp?: number;
......@@ -91,408 +57,153 @@ export interface JWTPayload {
export class AuthService {
private static readonly JWT_SECRET = process.env.JWT_SECRET!;
private static readonly JWT_REFRESH_SECRET = process.env.JWT_REFRESH_SECRET!;
private static readonly JWT_EXPIRES_IN = process.env.JWT_EXPIRES_IN || "15m";
private static readonly JWT_EXPIRES_IN = process.env.JWT_EXPIRES_IN || "1h";
private static readonly JWT_REFRESH_EXPIRES_IN = process.env.JWT_REFRESH_EXPIRES_IN || "7d";
private static readonly BCRYPT_ROUNDS = parseInt(process.env.BCRYPT_ROUNDS || "12");
private static readonly TOKEN_ENCRYPTION_KEY = process.env.TOKEN_ENCRYPTION_KEY!;
// Validate required secrets
private static validateSecrets() {
if (!this.JWT_SECRET) {
throw new Error("JWT_SECRET environment variable is required");
}
if (!this.JWT_REFRESH_SECRET) {
throw new Error("JWT_REFRESH_SECRET environment variable is required");
}
if (!this.TOKEN_ENCRYPTION_KEY) {
throw new Error("TOKEN_ENCRYPTION_KEY environment variable is required for token encryption");
if (!this.JWT_SECRET || !this.JWT_REFRESH_SECRET) {
throw new Error("JWT secrets are missing in environment variables");
}
}
/**
* Get secure cookie options for tokens
*/
static getAccessTokenCookieOptions(): CookieOptions {
const isProduction = process.env.NODE_ENV === "production";
const maxAge = this.parseTimeToSeconds(this.JWT_EXPIRES_IN) * 1000;
return {
httpOnly: true, // Prevent XSS attacks
secure: isProduction, // HTTPS only in production
sameSite: "lax", // Allow cross-subdomain requests
maxAge,
path: "/api",
...(isProduction && process.env.COOKIE_DOMAIN ? { domain: process.env.COOKIE_DOMAIN } : {}),
};
static parseTimeToSeconds(timeStr: string): number {
const unit = timeStr.slice(-1);
const value = parseInt(timeStr.slice(0, -1));
switch (unit) {
case 'h': return value * 3600;
case 'd': return value * 86400;
case 'm': return value * 60;
case 's': return value;
default: return value;
}
/**
* Get secure cookie options for refresh tokens
*/
static getRefreshTokenCookieOptions(): CookieOptions {
const isProduction = process.env.NODE_ENV === "production";
const maxAge = this.parseTimeToSeconds(this.JWT_REFRESH_EXPIRES_IN) * 1000;
return {
httpOnly: true,
secure: isProduction,
sameSite: "lax",
maxAge,
path: "/api/v1.0/auth/refresh",
...(isProduction && process.env.COOKIE_DOMAIN ? { domain: process.env.COOKIE_DOMAIN } : {}),
};
}
/**
* Hash password using bcrypt
*/
static async hashPassword(password: string): Promise<string> {
return bcrypt.hash(password, this.BCRYPT_ROUNDS);
}
/**
* Verify password against hash
*/
static async verifyPassword(password: string, hash: string): Promise<boolean> {
return bcrypt.compare(password, hash);
}
/**
* Encrypt token using PostgreSQL pgcrypto
*/
private static async encryptToken(plainToken: string): Promise<Buffer> {
const [result] = await sequelize.query("SELECT encrypt_token(:token, :key) as encrypted", {
replacements: {
token: plainToken,
key: this.TOKEN_ENCRYPTION_KEY,
},
type: QueryTypes.SELECT,
});
return (result as any).encrypted;
}
/**
* Decrypt token using PostgreSQL pgcrypto
*/
private static async decryptToken(encryptedToken: Buffer): Promise<string> {
const [result] = await sequelize.query("SELECT decrypt_token(:encrypted_token, :key) as decrypted", {
replacements: {
encrypted_token: encryptedToken,
key: this.TOKEN_ENCRYPTION_KEY,
},
type: QueryTypes.SELECT,
});
return (result as any).decrypted;
}
/**
* Generate JWT access token
*/
static generateAccessToken(user: User): string {
static generateAccessToken(user: any, roles: string[]): string {
this.validateSecrets();
const primaryUserRole = (user as any).user_user_roles?.find((ur: any) => ur.is_primary);
const payload: JWTPayload = {
id: user.id,
id: user._id.toString(),
email: user.email,
roles: (user as any).roles || [], // All role names
permissions: (user as any).permissions || [], // Permissions collected from all roles
username: user.username,
roles: roles
};
if (user.username) {
payload.username = user.username;
}
return sign(payload, this.JWT_SECRET as any, {
expiresIn: this.JWT_EXPIRES_IN as any,
issuer: "backend-template",
audience: "api-users",
});
return sign(payload, this.JWT_SECRET, { expiresIn: this.JWT_EXPIRES_IN as any });
}
/**
* Generate JWT refresh token
*/
static generateRefreshToken(user: User): string {
static generateRefreshToken(user: any): string {
this.validateSecrets();
const payload = {
id: user.id,
email: user.email,
roles: (user as any).roles || [],
permissions: (user as any).permissions || [],
type: "refresh",
};
const options: SignOptions = {
expiresIn: this.JWT_REFRESH_EXPIRES_IN as any,
issuer: "backend-template",
audience: "api-users",
id: user._id.toString(),
type: "refresh"
};
return sign(payload, this.JWT_REFRESH_SECRET as any, options);
return sign(payload, this.JWT_REFRESH_SECRET, { expiresIn: this.JWT_REFRESH_EXPIRES_IN as any });
}
/**
* Verify JWT access token
*/
static verifyAccessToken(token: string): JWTPayload {
this.validateSecrets();
try {
const decoded = verify(token, this.JWT_SECRET, {
issuer: "backend-template",
audience: "api-users",
}) as unknown as JWTPayload;
return decoded;
return verify(token, this.JWT_SECRET) as JWTPayload;
} catch (error) {
throw new GenericError({ vi: "Token không hợp lệ", en: "Invalid token" }, "UNAUTHORIZED", 401);
}
}
/**
* Verify JWT refresh token
*/
static verifyRefreshToken(token: string): JWTPayload {
static verifyRefreshToken(token: string): any {
this.validateSecrets();
try {
const decoded = verify(token, this.JWT_REFRESH_SECRET, {
issuer: "backend-template",
audience: "api-users",
}) as unknown as JWTPayload;
if (decoded.type !== "refresh") {
throw new Error("Invalid token type");
}
const decoded = verify(token, this.JWT_REFRESH_SECRET) as any;
if (decoded.type !== "refresh") throw new Error("Invalid token type");
return decoded;
} catch (error) {
throw new GenericError({ vi: "Refresh token không hợp lệ", en: "Invalid refresh token" }, "UNAUTHORIZED", 401);
}
}
/**
* Register new user
*/
static async register(data: RegisterData): Promise<User> {
return sequelize.transaction(async (transaction) => {
const { email, password, username, first_name, last_name, phone } = data;
// Check if user already exists
const existingUser = await User.findOne({
where: { email },
paranoid: false, // Include soft deleted users
transaction,
});
static async register(data: RegisterData): Promise<IUser> {
const { email, password, username, first_name, last_name, phone, roles } = data;
const existingUser = await User.findOne({ email });
if (existingUser) {
if (existingUser.deleted_at) {
// Reactivate soft deleted user
await existingUser.restore({ transaction });
// Update UserAuth for reactivated user
const existingUserAuth = await UserAuth.findOne({
where: { user_id: existingUser.id },
transaction,
});
if (existingUserAuth) {
existingUserAuth.password_hash = await this.hashPassword(password);
await existingUserAuth.save({ transaction });
}
existingUser.status = "pending_verification";
return existingUser.save({ transaction });
} else {
throw new GenericError({ vi: "Người dùng đã tồn tại", en: "User already exists" }, "CONFLICT", 409);
}
}
// Hash password
const password_hash = await this.hashPassword(password);
// Get default user role
const defaultRole = await Role.findOne({ where: { name: "user" } });
if (!defaultRole) {
throw new Error("Default user role not found");
}
// Find roles
const roleNames = roles && roles.length > 0 ? roles : ['student'];
const dbRoles = await Role.find({ name: { $in: roleNames } });
const userRoleIds = dbRoles.map(r => r._id);
// Create user
const userData: Partial<UserAttributes> = {
const userDataObj: any = {
email,
status: UserStatus.PENDING_VERIFICATION,
password_hash,
status: 'active',
roles: userRoleIds
};
if (username) userData.username = username;
if (first_name) userData.first_name = first_name;
if (last_name) userData.last_name = last_name;
if (phone) userData.phone = phone;
if (username) userDataObj.username = username;
if (first_name) userDataObj.first_name = first_name;
if (last_name) userDataObj.last_name = last_name;
if (phone) userDataObj.phone = phone;
const user = await User.create(userData as any, { transaction });
// Create UserAuth
await UserAuth.create(
{
user_id: user.id,
password_hash,
},
{ transaction },
);
// Assign default role
await UserRoleModel.create(
{
user_id: user.id,
role_id: defaultRole.id,
is_primary: true,
},
{ transaction },
);
const user = await User.create(userDataObj);
return user;
});
}
/**
* Authenticate user login
*/
static async login(credentials: LoginCredentials): Promise<LoginResult> {
const { email, password, device_info, ip_address, user_agent } = credentials;
// Find user with auth data
const userInstance = await User.findOne({
where: { email },
include: [{ model: UserAuth, as: "user_auth" }],
});
if (!userInstance) {
throw new GenericError(
{ vi: "Email hoặc mật khẩu không đúng", en: "Invalid email or password" },
"UNAUTHORIZED",
401,
);
}
// Get plain data values
const user = userInstance.dataValues as User;
let userAuth = userInstance.user_auth;
// If UserAuth doesn't exist, this is an error (every user should have UserAuth)
if (!userAuth) {
throw new GenericError(
{ vi: "Tài khoản chưa được thiết lập đầy đủ", en: "Account not fully set up" },
"UNAUTHORIZED",
401,
);
}
// Fetch user roles with permissions
const userRoles = await UserRoleModel.findAll({
where: { user_id: user.id },
include: [
{
model: Role,
as: "role",
include: [
{
model: RolePermission,
as: "role_permissions",
include: [
{
model: Permission,
as: "permission",
attributes: ["name"],
},
],
},
],
},
],
});
// Collect permissions and roles from all roles (unique)
const permissionsSet = new Set<string>();
const rolesSet = new Set<string>();
if (userRoles) {
for (const userRole of userRoles) {
if (userRole.role) {
rolesSet.add(userRole.role.name);
if (userRole.role.role_permissions) {
for (const rp of userRole.role.role_permissions) {
if (rp.permission && rp.permission.name) {
permissionsSet.add(rp.permission.name);
}
}
}
}
}
const user = await User.findOne({ email }).populate('roles');
if (!user) {
throw new GenericError({ vi: "Email hoặc mật khẩu không đúng", en: "Invalid email or password" }, "UNAUTHORIZED", 401);
}
(user as any).permissions = Array.from(permissionsSet);
(user as any).roles = Array.from(rolesSet);
// Check if account lockout has expired and unlock if needed
if (userAuth) await checkAndUnlockExpiredLockout(userAuth);
await checkAndUnlockExpiredLockout(user);
// Check if account is locked
if (userAuth && isUserLocked(userAuth)) {
if (isUserLocked(user)) {
throw new GenericError({ vi: "Tài khoản bị khóa tạm thời", en: "Account is temporarily locked" }, "LOCKED", 423);
}
// Check if account is active
if (user.status !== UserStatus.ACTIVE) {
throw new GenericError({ vi: "Tài khoản chưa được kích hoạt", en: "Account is not active" }, "FORBIDDEN", 403);
}
// Check if password_hash exists
if (!userAuth?.password_hash) {
throw new GenericError(
{ vi: "Tài khoản chưa được thiết lập mật khẩu", en: "Account password not set" },
"UNAUTHORIZED",
401,
);
}
// Verify password
const isValidPassword = await this.verifyPassword(password, userAuth.password_hash);
const isValidPassword = await this.verifyPassword(password, user.password_hash);
if (!isValidPassword) {
if (userAuth) await incrementUserLoginAttempts(userAuth);
throw new GenericError(
{ vi: "Email hoặc mật khẩu không đúng", en: "Invalid email or password" },
"UNAUTHORIZED",
401,
);
await incrementUserLoginAttempts(user);
throw new GenericError({ vi: "Email hoặc mật khẩu không đúng", en: "Invalid email or password" }, "UNAUTHORIZED", 401);
}
// Reset login attempts on successful login
if (userAuth) await resetUserLoginAttempts(userAuth);
if (userAuth) await updateUserLastLogin(userAuth);
await resetUserLoginAttempts(user);
await updateUserLastLogin(user);
// Generate tokens
const access_token = this.generateAccessToken(user);
const roleNames = (user.roles as any[]).map(r => r.name);
const access_token = this.generateAccessToken(user, roleNames);
const refresh_token = this.generateRefreshToken(user);
// Encrypt tokens before storing
const encrypted_access_token = await this.encryptToken(access_token);
const encrypted_refresh_token = await this.encryptToken(refresh_token);
// Calculate expiration times
const expires_in = this.parseTimeToSeconds(this.JWT_EXPIRES_IN);
const refresh_expires_in = this.parseTimeToSeconds(this.JWT_REFRESH_EXPIRES_IN);
// Create session with encrypted tokens
const sessionData: any = {
user_id: user.id,
session_token: encrypted_access_token,
refresh_token: encrypted_refresh_token,
const sessionDataObj: any = {
user: user._id,
session_token: access_token,
refresh_token: refresh_token,
device_info: device_info || {},
expires_at: new Date(Date.now() + expires_in * 1000),
refresh_expires_at: new Date(Date.now() + refresh_expires_in * 1000),
is_active: true,
permissions: (user as any).permissions || [],
roles: (user as any).roles || [],
is_active: true
};
if (ip_address) sessionData.ip_address = ip_address;
if (user_agent) sessionData.user_agent = user_agent;
await UserSession.create(sessionData as any);
if (ip_address) sessionDataObj.ip_address = ip_address;
if (user_agent) sessionDataObj.user_agent = user_agent;
// Get the created session
const createdSession = await UserSession.findOne({
where: { session_token: encrypted_access_token },
});
const session = await UserSession.create(sessionDataObj);
return {
access_token,
......@@ -501,338 +212,77 @@ export class AuthService {
refresh_expires_in,
token_type: "Bearer",
user,
user_auth: userAuth.dataValues as UserAuth,
session: createdSession?.dataValues as UserSession,
session
};
}
/**
* Refresh access token
*/
static async refreshToken(refreshToken: string, device_info?: Record<string, unknown>): Promise<TokenPair> {
// Find active session by decrypting stored refresh tokens
const sessions = await sequelize.query(
`
SELECT us.*
FROM user_sessions us
WHERE us.is_active = true
AND us.refresh_expires_at > NOW()
`,
{
type: QueryTypes.SELECT,
},
);
// Find matching session by decrypting refresh tokens
let matchingSession: any = null;
for (const session of sessions as any[]) {
try {
const decryptedRefreshToken = await this.decryptToken(session.refresh_token);
if (decryptedRefreshToken === refreshToken) {
matchingSession = session;
break;
static async logout(token: string): Promise<void> {
await UserSession.updateOne({ session_token: token }, { is_active: false });
}
} catch (error) {
// Skip invalid encrypted tokens
continue;
static async validateSession(token: string): Promise<any> {
const session = await UserSession.findOne({ session_token: token, is_active: true }).populate('user');
if (!session || dayjs().isAfter(dayjs(session.expires_at))) {
return null;
}
await session.updateOne({ last_activity_at: new Date() });
return session.user;
}
if (!matchingSession) {
static async refreshToken(refreshToken: string, device_info?: Record<string, unknown>): Promise<TokenPair> {
const session = await UserSession.findOne({ refresh_token: refreshToken, is_active: true }).populate('user');
if (!session || dayjs().isAfter(dayjs(session.refresh_expires_at))) {
throw new GenericError({ vi: "Refresh token không hợp lệ", en: "Invalid refresh token" }, "UNAUTHORIZED", 401);
}
// Verify refresh token JWT
const decoded = this.verifyRefreshToken(refreshToken);
const user = session.user as any;
// Create user object from token data
const user = {
id: decoded.id,
email: decoded.email,
roles: decoded.roles || [],
permissions: decoded.permissions || [],
} as any;
const roleNames = user.roles ? await Role.find({ _id: { $in: user.roles } }).then(roles => roles.map(r => r.name)) : [];
// Generate new tokens
const access_token = this.generateAccessToken(user);
const new_access_token = this.generateAccessToken(user, roleNames);
const new_refresh_token = this.generateRefreshToken(user);
// Encrypt new tokens
const encrypted_access_token = await this.encryptToken(access_token);
const encrypted_refresh_token = await this.encryptToken(new_refresh_token);
// Calculate expiration times
const expires_in = this.parseTimeToSeconds(this.JWT_EXPIRES_IN);
const refresh_expires_in = this.parseTimeToSeconds(this.JWT_REFRESH_EXPIRES_IN);
// Update session with encrypted tokens
await sequelize.query(
`
UPDATE user_sessions
SET session_token = :access_token,
refresh_token = :refresh_token,
expires_at = :expires_at,
refresh_expires_at = :refresh_expires_at,
device_info = :device_info,
last_activity_at = NOW()
WHERE id = :session_id
`,
{
replacements: {
access_token: encrypted_access_token,
refresh_token: encrypted_refresh_token,
await session.updateOne({
session_token: new_access_token,
refresh_token: new_refresh_token,
expires_at: new Date(Date.now() + expires_in * 1000),
refresh_expires_at: new Date(Date.now() + refresh_expires_in * 1000),
device_info: device_info && Object.keys(device_info).length > 0 ? device_info : null,
session_id: matchingSession.id,
},
type: QueryTypes.UPDATE,
},
);
device_info: device_info || session.device_info,
last_activity_at: new Date()
});
return {
access_token,
access_token: new_access_token,
refresh_token: new_refresh_token,
expires_in,
refresh_expires_in,
token_type: "Bearer",
token_type: "Bearer"
};
}
/**
* Logout user (deactivate session)
*/
static async logout(accessToken: string): Promise<void> {
// Find session by decrypting access tokens
const sessions = await sequelize.query(
`
SELECT id, session_token
FROM user_sessions
WHERE is_active = true
`,
{
type: QueryTypes.SELECT,
},
);
// Find matching session
let sessionId: string | null = null;
for (const session of sessions as any[]) {
try {
const decryptedToken = await this.decryptToken(session.session_token);
if (decryptedToken === accessToken) {
sessionId = session.id;
break;
}
} catch (error) {
continue;
}
}
if (sessionId) {
await sequelize.query("UPDATE user_sessions SET is_active = false WHERE id = :session_id", {
replacements: { session_id: sessionId },
type: QueryTypes.UPDATE,
});
}
}
/**
* Logout from all devices
*/
static async logoutAll(userId: string): Promise<void> {
await UserSession.update(
{ is_active: false },
{
where: {
user_id: userId,
is_active: true,
},
},
);
}
/**
* Get user by ID with sessions
*/
static async getUserWithSessions(userId: string): Promise<User | null> {
return User.findByPk(userId, {
include: [
{
model: UserSession,
as: "sessions",
where: { is_active: true },
required: false,
},
],
});
}
/**
* Validate session token
*/
static async validateSession(sessionToken: string): Promise<User | null> {
let sessions: any[];
try {
// Find sessions and decrypt tokens to match
sessions = await sequelize.query(
`
SELECT us.*, u.email, u.username, u.first_name, u.last_name, u.status
FROM user_sessions us
JOIN users u ON us.user_id = u.id
WHERE us.is_active = true
AND us.expires_at > NOW()
`,
{
type: QueryTypes.SELECT,
},
);
} catch (queryError) {
throw queryError;
}
// Find matching session by decrypting access tokens
let matchingSession: any = null;
let decryptedToken: string | null = null;
for (const session of sessions as any[]) {
try {
const decryptedAccessToken = await this.decryptToken(session.session_token);
if (decryptedAccessToken === sessionToken) {
matchingSession = session;
decryptedToken = decryptedAccessToken;
break;
}
} catch (error) {
// Skip invalid encrypted tokens
continue;
}
}
if (!matchingSession || !decryptedToken) {
return null;
}
// Verify JWT token
try {
this.verifyAccessToken(decryptedToken);
} catch (error) {
return null;
}
// Create user object
const user = {
id: matchingSession.user_id,
email: matchingSession.email,
username: matchingSession.username,
first_name: matchingSession.first_name,
last_name: matchingSession.last_name,
status: matchingSession.status,
} as any;
// Update last activity
try {
await sequelize.query("UPDATE user_sessions SET last_activity_at = NOW() WHERE id = :session_id", {
replacements: { session_id: matchingSession.id },
type: QueryTypes.UPDATE,
});
} catch (updateError) {
// Don't fail validation just because we can't update activity
}
return user;
}
/**
* Change user password
*/
static async changePassword(userId: string, oldPassword: string, newPassword: string): Promise<void> {
const user = await User.findByPk(userId, {
include: [{ model: UserAuth, as: "user_auth" }],
});
if (!user) {
throw new GenericError({ vi: "Người dùng không tồn tại", en: "User not found" }, "NOT_FOUND", 404);
}
const userAuth = user.user_auth;
if (!userAuth) {
throw new GenericError(
{ vi: "Dữ liệu xác thực không tồn tại", en: "Authentication data not found" },
"NOT_FOUND",
404,
);
}
// Check if password can be changed (not too recently)
if (!canUserChangePassword(userAuth)) {
throw new GenericError(
{ vi: "Mật khẩu chỉ có thể thay đổi sau 24 giờ", en: "Password can only be changed after 24 hours" },
"FORBIDDEN",
403,
);
}
// Verify old password
const isValidOldPassword = await this.verifyPassword(oldPassword, userAuth.password_hash);
if (!isValidOldPassword) {
throw new GenericError({ vi: "Mật khẩu cũ không đúng", en: "Invalid old password" }, "UNAUTHORIZED", 401);
}
// Hash new password
const newPasswordHash = await this.hashPassword(newPassword);
// Check if password was recently used
const passwordHistory = (userAuth.password_history as string[]) || [];
if (isPasswordRecentlyUsed(userAuth, passwordHistory, newPasswordHash)) {
throw new GenericError(
{ vi: "Mật khẩu đã được sử dụng gần đây", en: "Password was recently used" },
"FORBIDDEN",
403,
);
}
// Update password history
passwordHistory.unshift(userAuth.password_hash); // Add old hash
if (passwordHistory.length > 5) passwordHistory.pop(); // Keep last 5
userAuth.password_history = passwordHistory;
// Update password
userAuth.password_hash = newPasswordHash;
await updateUserPasswordChanged(userAuth);
// Logout from all other sessions
await this.logoutAll(userId);
}
/**
* Parse time string to seconds (e.g., '15m' -> 900)
*/
private static parseTimeToSeconds(timeStr: string | undefined): number {
if (!timeStr) return 900; // 15 minutes default
const regex = /^(\d+)([smhd])$/;
const match = timeStr.match(regex);
if (!match) {
throw new Error(`Invalid time format: ${timeStr || "undefined"}`);
static getAccessTokenCookieOptions() {
return {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax' as const,
maxAge: this.parseTimeToSeconds(this.JWT_EXPIRES_IN) * 1000,
path: '/api'
};
}
const value = parseInt(match[1] || "0");
const unit = match[2] || "m";
switch (unit) {
case "s":
return value;
case "m":
return value * 60;
case "h":
return value * 60 * 60;
case "d":
return value * 60 * 60 * 24;
default:
throw new Error(`Invalid time unit: ${unit}`);
}
static getRefreshTokenCookieOptions() {
return {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax' as const,
maxAge: this.parseTimeToSeconds(this.JWT_REFRESH_EXPIRES_IN) * 1000,
path: '/api/v1.0/auth/refresh'
};
}
}
......@@ -6,23 +6,67 @@ module.exports = {
email: {
type: "string",
format: "email",
example: "user@example.com",
example: "admin@admin.com",
},
password: {
type: "string",
example: "password123",
example: "admin123",
},
device_info: {
type: "object",
description: "Device information for session tracking",
},
ip_address: {
},
},
RegisterRequest: {
type: "object",
required: ["email", "password"],
properties: {
email: {
type: "string",
format: "email",
example: "mentor1@example.com",
},
password: {
type: "string",
example: "password123",
},
username: {
type: "string",
example: "mentor_john",
},
first_name: {
type: "string",
description: "IP address for security logging",
example: "John",
},
user_agent: {
last_name: {
type: "string",
description: "User agent string",
example: "Doe",
},
phone: {
type: "string",
example: "0912345678",
},
roles: {
type: "array",
items: {
type: "string",
enum: ["admin", "mentor", "student"],
},
example: ["mentor"],
},
},
},
RefreshRequest: {
type: "object",
properties: {
refresh_token: {
type: "string",
description: "Refresh token (optional if sent via cookie)",
},
device_info: {
type: "object",
description: "Device information",
},
},
},
......
......@@ -129,4 +129,69 @@ module.exports = {
},
},
},
Forbidden: {
description: "Forbidden",
content: {
"application/json": {
schema: {
type: "object",
properties: {
success: {
type: "boolean",
example: false,
},
error: {
type: "object",
properties: {
code: {
type: "string",
example: "FORBIDDEN",
},
message: {
type: "object",
properties: {
vi: {
type: "string",
example: "Bạn không có quyền thực hiện hành động này",
},
en: {
type: "string",
example: "You do not have permission to perform this action",
},
},
},
},
},
message: {
type: "string",
nullable: true,
},
message_en: {
type: "string",
nullable: true,
},
responseData: {
type: "object",
nullable: true,
},
status: {
type: "string",
example: "fail",
},
timeStamp: {
type: "string",
example: "2025-12-07 10:00:00",
},
violation: {
type: "array",
items: {
type: "object",
},
nullable: true,
},
},
},
},
},
},
};
module.exports = {
Bearer: {
name: "Authorization",
in: "header",
type: "apiKey",
BearerAuth: {
type: "http",
scheme: "bearer",
bearerFormat: "JWT",
},
};
import { User } from "../models/User";
import { UserAuth } from "../models/UserAuth";
import { UserSession } from "../models/UserSession";
import { IUser } from "../models/mongodb/User";
import { IUserSession } from "../models/mongodb/UserSession";
import dayjs from "dayjs";
/**
* Authentication utility functions
* These functions provide the logic that would normally be instance methods
* but are kept separate to avoid conflicts with auto-generated models
* Authentication utility functions for Mongoose models
*/
/**
* Maximum number of login attempts before account lockout
*/
const MAX_LOGIN_ATTEMPTS = 5;
/**
* Check if user account is locked due to too many failed login attempts
*/
export const isUserLocked = (userAuth: UserAuth): boolean => {
if (userAuth.locked_until) {
const now = dayjs();
const lockedUntil = dayjs(userAuth.locked_until);
return now.isBefore(lockedUntil);
export const isUserLocked = (user: IUser): boolean => {
if (user.lock_until) {
return dayjs().isBefore(dayjs(user.lock_until));
}
return false;
};
/**
* Increment login attempts for a user
*/
export const incrementUserLoginAttempts = async (userAuthInstance: UserAuth): Promise<void> => {
const currentAttempts = (userAuthInstance.login_attempts || 0) + 1;
// Lock account if too many attempts
export const incrementUserLoginAttempts = async (user: IUser): Promise<void> => {
const currentAttempts = (user.login_attempts || 0) + 1;
const updates: any = { login_attempts: currentAttempts };
if (currentAttempts >= MAX_LOGIN_ATTEMPTS) {
updates.locked_until = dayjs().add(15, "minutes").toDate();
updates.lock_until = dayjs().add(15, "minutes").toDate();
}
await userAuthInstance.update(updates);
await user.updateOne(updates);
};
/**
* Reset login attempts for a user (on successful login)
*/
export const resetUserLoginAttempts = async (userAuthInstance: UserAuth): Promise<void> => {
await userAuthInstance.update({
export const resetUserLoginAttempts = async (user: IUser): Promise<void> => {
await user.updateOne({
login_attempts: 0,
locked_until: new Date(0),
lock_until: new Date(0),
});
};
/**
* Unlock user account (reset lockout)
*/
export const unlockUserAccount = async (userAuthInstance: UserAuth): Promise<void> => {
await userAuthInstance.update({
locked_until: new Date(0),
export const unlockUserAccount = async (user: IUser): Promise<void> => {
await user.updateOne({
lock_until: new Date(0),
login_attempts: 0,
});
};
/**
* Check if user account lockout has expired and unlock if needed
*/
export const checkAndUnlockExpiredLockout = async (userAuthInstance: UserAuth): Promise<boolean> => {
if (userAuthInstance.locked_until) {
const now = dayjs();
const lockedUntil = dayjs(userAuthInstance.locked_until);
if (now.isAfter(lockedUntil)) {
await unlockUserAccount(userAuthInstance);
return true; // Account was unlocked
export const checkAndUnlockExpiredLockout = async (user: IUser): Promise<boolean> => {
if (user.lock_until && dayjs().isAfter(dayjs(user.lock_until))) {
await unlockUserAccount(user);
return true;
}
}
return false; // Account was not locked or still locked
};
/**
* Update user's last login timestamp
*/
export const updateUserLastLogin = async (userAuthInstance: UserAuth): Promise<void> => {
await userAuthInstance.update({
last_login_at: new Date(),
});
};
/**
* Check if user session is expired
*/
export const isSessionExpired = (session: UserSession): boolean => {
const now = dayjs();
const expiresAt = dayjs(session.expires_at);
return now.isAfter(expiresAt);
};
/**
* Check if user session refresh token is expired
*/
export const isSessionRefreshExpired = (session: UserSession): boolean => {
const now = dayjs();
const refreshExpiresAt = dayjs(session.refresh_expires_at);
return now.isAfter(refreshExpiresAt);
};
/**
* Deactivate a user session
*/
export const deactivateUserSession = async (sessionInstance: UserSession): Promise<void> => {
await sessionInstance.update({ is_active: false });
};
/**
* Update session activity timestamp
*/
export const updateSessionActivity = async (sessionInstance: UserSession): Promise<void> => {
await sessionInstance.update({ last_activity_at: new Date() });
};
/**
* Check if user can change password (not recently changed)
*/
export const canUserChangePassword = (userAuth: UserAuth, minDaysBetweenChanges: number = 1): boolean => {
if (!userAuth.password_changed_at) return true;
const lastChange = dayjs(userAuth.password_changed_at);
const now = dayjs();
const daysSinceChange = now.diff(lastChange, "day");
return daysSinceChange >= minDaysBetweenChanges;
return false;
};
/**
* Update user's password changed timestamp
*/
export const updateUserPasswordChanged = async (userAuthInstance: UserAuth): Promise<void> => {
await userAuthInstance.update({
password_changed_at: new Date(),
export const updateUserLastLogin = async (user: IUser): Promise<void> => {
await user.updateOne({
last_login: new Date(),
});
};
/**
* Check if password was used recently (prevent reuse)
*/
export const isPasswordRecentlyUsed = (
userAuth: UserAuth,
passwordHistory: string[],
newPasswordHash: string,
): boolean => {
// Check if new password hash matches any in recent history
return passwordHistory.includes(newPasswordHash);
};
/**
* Get user full name
*/
export const getUserFullName = (user: User): string => {
const firstName = user.first_name || "";
const lastName = user.last_name || "";
return `${firstName} ${lastName}`.trim();
export const isSessionExpired = (session: IUserSession): boolean => {
return dayjs().isAfter(dayjs(session.expires_at));
};
/**
* Check if user has specific role
*/
export const hasUserRole = (user: User, role: string): boolean => {
return (user as any).role?.name === role;
export const isSessionRefreshExpired = (session: IUserSession): boolean => {
return dayjs().isAfter(dayjs(session.refresh_expires_at));
};
/**
* Check if user has admin privileges
*/
export const isUserAdmin = (user: User): boolean => {
return (user as any).role?.name === "admin" || (user as any).role?.name === "system_admin";
export const deactivateUserSession = async (session: IUserSession): Promise<void> => {
await session.updateOne({ is_active: false });
};
/**
* Check if user is system admin
*/
export const isUserSystemAdmin = (user: User): boolean => {
return (user as any).role?.name === "system_admin";
export const updateSessionActivity = async (session: IUserSession): Promise<void> => {
await session.updateOne({ last_activity_at: new Date() });
};
/**
* Get user status display text
*/
export const getUserStatusText = (status: string): { vi: string; en: string } => {
const statusMap: Record<string, { vi: string; en: string }> = {
active: { vi: "Hoạt động", en: "Active" },
inactive: { vi: "Không hoạt động", en: "Inactive" },
suspended: { vi: "Đã tạm ngừng", en: "Suspended" },
pending_verification: { vi: "Chờ xác minh", en: "Pending Verification" },
};
return statusMap[status] || { vi: "Không xác định", en: "Unknown" };
export const canUserChangePassword = (user: IUser, minDaysBetweenChanges: number = 1): boolean => {
if (!user.password_changed_at) return true;
return dayjs().diff(dayjs(user.password_changed_at), "day") >= minDaysBetweenChanges;
};
/**
* Get role display text
*/
export const getRoleText = (role: string): { vi: string; en: string } => {
const roleMap: Record<string, { vi: string; en: string }> = {
user: { vi: "Người dùng", en: "User" },
student: { vi: "Sinh viên", en: "Student" },
mentor: { vi: "Người hướng dẫn", en: "Mentor" },
admin: { vi: "Quản trị viên", en: "Administrator" },
system_admin: { vi: "Quản trị hệ thống", en: "System Administrator" },
};
return roleMap[role] || { vi: "Không xác định", en: "Unknown" };
};
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