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

feat/authentication

parent 4f023fe9
...@@ -102,18 +102,17 @@ export default (_express: Application) => { ...@@ -102,18 +102,17 @@ export default (_express: Application) => {
return res.sendOk({ return res.sendOk({
data: { data: {
user: { user: {
id: loginResult.user.id, id: loginResult.user._id,
email: loginResult.user.email, email: loginResult.user.email,
username: loginResult.user.username, username: loginResult.user.username,
first_name: loginResult.user.first_name, first_name: loginResult.user.first_name,
last_name: loginResult.user.last_name, last_name: loginResult.user.last_name,
roles: (loginResult.user as any).roles, roles: (loginResult.user.roles as any[]).map(r => r.name),
permissions: (loginResult.user as any).permissions,
status: loginResult.user.status, status: loginResult.user.status,
last_login_at: loginResult.user_auth.last_login_at, last_login_at: loginResult.user.last_login,
}, },
session: { session: {
id: loginResult.session.id, id: loginResult.session._id,
expires_at: loginResult.session.expires_at, expires_at: loginResult.session.expires_at,
refresh_expires_at: loginResult.session.refresh_expires_at, refresh_expires_at: loginResult.session.refresh_expires_at,
}, },
......
...@@ -14,7 +14,7 @@ export default (_express: Application) => { ...@@ -14,7 +14,7 @@ export default (_express: Application) => {
* summary: Logout user * summary: Logout user
* description: Logout user and invalidate current session * description: Logout user and invalidate current session
* security: * security:
* - Bearer: [] * - BearerAuth: []
* responses: * responses:
* 200: * 200:
* description: Logged out successfully * description: Logged out successfully
......
...@@ -2,7 +2,7 @@ import { Application } from "express"; ...@@ -2,7 +2,7 @@ import { Application } from "express";
import { Resource } from "express-automatic-routes"; import { Resource } from "express-automatic-routes";
import { Req, Res } from "#interfaces/IApi"; import { Req, Res } from "#interfaces/IApi";
import { authenticate } from "#middlewares/auth"; import { authenticate } from "#middlewares/auth";
import { User } from "#models/User"; import { User } from "#models/mongodb/User";
export default (_express: Application) => { export default (_express: Application) => {
return <Resource>{ return <Resource>{
...@@ -14,7 +14,7 @@ export default (_express: Application) => { ...@@ -14,7 +14,7 @@ export default (_express: Application) => {
* summary: Get user profile * summary: Get user profile
* description: Get current authenticated user's profile information * description: Get current authenticated user's profile information
* security: * security:
* - Bearer: [] * - BearerAuth: []
* responses: * responses:
* 200: * 200:
* description: Profile retrieved successfully * description: Profile retrieved successfully
...@@ -25,48 +25,47 @@ export default (_express: Application) => { ...@@ -25,48 +25,47 @@ export default (_express: Application) => {
* properties: * properties:
* success: * success:
* type: boolean * type: boolean
* example: true
* data: * data:
* type: object * type: object
* properties: * properties:
* id: * id:
* type: string * type: string
* example: "uuid-string"
* email: * email:
* type: string * type: string
* example: "user@example.com"
* username: * username:
* type: string * type: string
* example: "john_doe" * first_name:
* role:
* type: string * type: string
* enum: [user, admin, system_admin] * last_name:
* example: "user" * type: string
* permissions: * status:
* type: string
* roles:
* type: array * type: array
* items: * items:
* type: string * type: string
* example: ["read:profile"] * example: ["admin"]
* 401:
* $ref: '#/components/responses/Unauthorized'
*/ */
get: { get: {
middleware: [authenticate], middleware: [authenticate],
handler: async (req: Req, res: Res) => { handler: async (req: Req, res: Res) => {
try { try {
// Get user with roles and permissions const user = await User.findById(req.user?.id)
const user = await User.findByPk(req.user?.id, { .select("-password_hash")
attributes: ["id", "email", "username", "first_name", "last_name", "status"], .populate("roles", "name");
});
if (!user) { if (!user) {
return res.error({ message: "User not found", status: 404 }); return res.error({ message: "User not found", status: 404 });
} }
const userData = { const userData = {
...user.toJSON(), id: user._id,
roles: req.user?.roles, email: user.email,
permissions: req.user?.permissions, 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 }); return res.sendOk({ data: userData });
......
...@@ -18,14 +18,7 @@ export default (_express: Application) => { ...@@ -18,14 +18,7 @@ export default (_express: Application) => {
* content: * content:
* application/json: * application/json:
* schema: * schema:
* type: object * $ref: '#/components/schemas/RefreshRequest'
* properties:
* refresh_token:
* type: string
* description: Refresh token (optional if sent via cookie)
* device_info:
* type: object
* description: Device information
* responses: * responses:
* 200: * 200:
* description: Token refreshed successfully * 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"; ...@@ -3,7 +3,7 @@ import { NextFunction } from "express";
import { AuthService, JWTPayload } from "../services/authService"; import { AuthService, JWTPayload } from "../services/authService";
import { GenericError } from "#interfaces/error/generic"; import { GenericError } from "#interfaces/error/generic";
import { console } from "inspector"; import { console } from "inspector";
type UserRole = "user" | "admin" | "system_admin"; type UserRole = "admin" | "mentor" | "student";
// Extend Request interface // Extend Request interface
declare module "express" { declare module "express" {
...@@ -113,14 +113,14 @@ export function authorize(allowedRoles: UserRole[] = []) { ...@@ -113,14 +113,14 @@ export function authorize(allowedRoles: UserRole[] = []) {
* Admin only middleware * Admin only middleware
*/ */
export function requireAdmin(req: Req, res: Res, next: NextFunction): void { 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 { export function requireMentor(req: Req, res: Res, next: NextFunction): void {
return authorize(["system_admin"])(req, res, next); return authorize(["mentor", "admin"])(req, res, next);
} }
/** /**
......
...@@ -35,6 +35,9 @@ export const authSchemas = { ...@@ -35,6 +35,9 @@ export const authSchemas = {
first_name: Joi.string().optional(), first_name: Joi.string().optional(),
last_name: Joi.string().optional(), last_name: Joi.string().optional(),
phone: 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(), params: Joi.object(),
query: 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();
This diff is collapsed.
...@@ -6,23 +6,67 @@ module.exports = { ...@@ -6,23 +6,67 @@ module.exports = {
email: { email: {
type: "string", type: "string",
format: "email", format: "email",
example: "user@example.com", example: "admin@admin.com",
}, },
password: { password: {
type: "string", type: "string",
example: "password123", example: "admin123",
}, },
device_info: { device_info: {
type: "object", type: "object",
description: "Device information for session tracking", 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", type: "string",
description: "IP address for security logging", example: "John",
}, },
user_agent: { last_name: {
type: "string", 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 = { ...@@ -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 = { module.exports = {
Bearer: { BearerAuth: {
name: "Authorization", type: "http",
in: "header", scheme: "bearer",
type: "apiKey", bearerFormat: "JWT",
}, },
}; };
import { User } from "../models/User"; import { IUser } from "../models/mongodb/User";
import { UserAuth } from "../models/UserAuth"; import { IUserSession } from "../models/mongodb/UserSession";
import { UserSession } from "../models/UserSession";
import dayjs from "dayjs"; import dayjs from "dayjs";
/** /**
* Authentication utility functions * Authentication utility functions for Mongoose models
* These functions provide the logic that would normally be instance methods
* but are kept separate to avoid conflicts with auto-generated models
*/ */
/**
* Maximum number of login attempts before account lockout
*/
const MAX_LOGIN_ATTEMPTS = 5; const MAX_LOGIN_ATTEMPTS = 5;
/** export const isUserLocked = (user: IUser): boolean => {
* Check if user account is locked due to too many failed login attempts if (user.lock_until) {
*/ return dayjs().isBefore(dayjs(user.lock_until));
export const isUserLocked = (userAuth: UserAuth): boolean => {
if (userAuth.locked_until) {
const now = dayjs();
const lockedUntil = dayjs(userAuth.locked_until);
return now.isBefore(lockedUntil);
} }
return false; return false;
}; };
/** export const incrementUserLoginAttempts = async (user: IUser): Promise<void> => {
* Increment login attempts for a user const currentAttempts = (user.login_attempts || 0) + 1;
*/
export const incrementUserLoginAttempts = async (userAuthInstance: UserAuth): Promise<void> => {
const currentAttempts = (userAuthInstance.login_attempts || 0) + 1;
// Lock account if too many attempts
const updates: any = { login_attempts: currentAttempts }; const updates: any = { login_attempts: currentAttempts };
if (currentAttempts >= MAX_LOGIN_ATTEMPTS) { 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);
}; };
/** export const resetUserLoginAttempts = async (user: IUser): Promise<void> => {
* Reset login attempts for a user (on successful login) await user.updateOne({
*/
export const resetUserLoginAttempts = async (userAuthInstance: UserAuth): Promise<void> => {
await userAuthInstance.update({
login_attempts: 0, login_attempts: 0,
locked_until: new Date(0), lock_until: new Date(0),
}); });
}; };
/** export const unlockUserAccount = async (user: IUser): Promise<void> => {
* Unlock user account (reset lockout) await user.updateOne({
*/ lock_until: new Date(0),
export const unlockUserAccount = async (userAuthInstance: UserAuth): Promise<void> => {
await userAuthInstance.update({
locked_until: new Date(0),
login_attempts: 0, login_attempts: 0,
}); });
}; };
/** export const checkAndUnlockExpiredLockout = async (user: IUser): Promise<boolean> => {
* Check if user account lockout has expired and unlock if needed if (user.lock_until && dayjs().isAfter(dayjs(user.lock_until))) {
*/ await unlockUserAccount(user);
export const checkAndUnlockExpiredLockout = async (userAuthInstance: UserAuth): Promise<boolean> => { return true;
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
} }
} return false;
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;
}; };
/** export const updateUserLastLogin = async (user: IUser): Promise<void> => {
* Update user's password changed timestamp await user.updateOne({
*/ last_login: new Date(),
export const updateUserPasswordChanged = async (userAuthInstance: UserAuth): Promise<void> => {
await userAuthInstance.update({
password_changed_at: new Date(),
}); });
}; };
/** export const isSessionExpired = (session: IUserSession): boolean => {
* Check if password was used recently (prevent reuse) return dayjs().isAfter(dayjs(session.expires_at));
*/
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 isSessionRefreshExpired = (session: IUserSession): boolean => {
* Check if user has specific role return dayjs().isAfter(dayjs(session.refresh_expires_at));
*/
export const hasUserRole = (user: User, role: string): boolean => {
return (user as any).role?.name === role;
}; };
/** export const deactivateUserSession = async (session: IUserSession): Promise<void> => {
* Check if user has admin privileges await session.updateOne({ is_active: false });
*/
export const isUserAdmin = (user: User): boolean => {
return (user as any).role?.name === "admin" || (user as any).role?.name === "system_admin";
}; };
/** export const updateSessionActivity = async (session: IUserSession): Promise<void> => {
* Check if user is system admin await session.updateOne({ last_activity_at: new Date() });
*/
export const isUserSystemAdmin = (user: User): boolean => {
return (user as any).role?.name === "system_admin";
}; };
/** export const canUserChangePassword = (user: IUser, minDaysBetweenChanges: number = 1): boolean => {
* Get user status display text if (!user.password_changed_at) return true;
*/ return dayjs().diff(dayjs(user.password_changed_at), "day") >= minDaysBetweenChanges;
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" };
}; };
/**
* Get role display text
*/
export const getRoleText = (role: string): { vi: string; en: string } => { export const getRoleText = (role: string): { vi: string; en: string } => {
const roleMap: Record<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" }, 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" }; 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