Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "users" ADD COLUMN "isFirstLogin" BOOLEAN NOT NULL DEFAULT true;
29 changes: 15 additions & 14 deletions apps/api/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -32,20 +32,21 @@ enum ActivityType {
}

model User {
id String @id @default(cuid())
username String @unique
email String @unique
password String
fullName String
role UserRole @default(viewer)
status UserStatus @default(active)
avatar String?
phone String?
timezone String @default("Asia/Ho_Chi_Minh")
language String @default("en")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
lastLogin DateTime?
id String @id @default(cuid())
username String @unique
email String @unique
password String
fullName String
role UserRole @default(viewer)
status UserStatus @default(active)
avatar String?
phone String?
timezone String @default("Asia/Ho_Chi_Minh")
language String @default("en")
isFirstLogin Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
lastLogin DateTime?

// Relations
profile UserProfile?
Expand Down
4 changes: 2 additions & 2 deletions apps/api/prisma/seed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ async function main() {
password: operatorPassword,
fullName: 'System Operator',
role: 'moderator',
status: 'active',
status: 'inactive',
avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=operator',
phone: '+84 987 654 321',
timezone: 'Asia/Ho_Chi_Minh',
Expand All @@ -73,7 +73,7 @@ async function main() {
password: viewerPassword,
fullName: 'Read Only User',
role: 'viewer',
status: 'active',
status: 'inactive',
avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=viewer',
timezone: 'Asia/Singapore',
language: 'en',
Expand Down
4 changes: 2 additions & 2 deletions apps/api/src/domains/account/account.validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,8 @@ export const changePasswordValidation: ValidationChain[] = [
.withMessage('New password is required')
.isLength({ min: 8 })
.withMessage('New password must be at least 8 characters')
.matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/)
.withMessage('Password must contain uppercase, lowercase, and number'),
.matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])/)
.withMessage('Password must contain uppercase, lowercase, number, and special character (@$!%*?&)'),
body('confirmPassword')
.notEmpty()
.withMessage('Confirm password is required')
Expand Down
94 changes: 78 additions & 16 deletions apps/api/src/domains/auth/auth.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ import { Request, Response } from 'express';
import { validationResult } from 'express-validator';
import { AuthService } from './auth.service';
import { AuthRepository } from './auth.repository';
import { LoginDto, LogoutDto, RefreshTokenDto, Verify2FADto } from './dto';
import { RequestMetadata } from './auth.types';
import { LoginDto, LogoutDto, RefreshTokenDto, Verify2FADto, FirstLoginPasswordDto } from './dto';
import { RequestMetadata, LoginFirstTimeResult, Login2FARequiredResult } from './auth.types';
import logger from '../../utils/logger';
import { AppError } from '../../shared/errors/app-error';

Expand Down Expand Up @@ -45,30 +45,49 @@ export class AuthController {
// Call service
const result = await this.authService.login(dto, metadata);

// Check if password change is required (first login)
if ('requirePasswordChange' in result && result.requirePasswordChange) {
const firstLoginResult = result as LoginFirstTimeResult;
res.json({
success: true,
message: 'Password change required',
data: {
requirePasswordChange: true,
userId: firstLoginResult.userId,
tempToken: firstLoginResult.tempToken,
user: firstLoginResult.user,
},
});
return;
}

// Check if 2FA is required
if ('requires2FA' in result) {
if ('requires2FA' in result && result.requires2FA) {
const twoFAResult = result as Login2FARequiredResult;
res.json({
success: true,
message: '2FA verification required',
data: {
requires2FA: result.requires2FA,
userId: result.userId,
user: result.user,
requires2FA: true,
userId: twoFAResult.userId,
user: twoFAResult.user,
},
});
return;
}

// Return successful login response
res.json({
success: true,
message: 'Login successful',
data: {
user: result.user,
accessToken: result.accessToken,
refreshToken: result.refreshToken,
},
});
// Return successful login response (LoginResult type)
if ('accessToken' in result && 'refreshToken' in result) {
Comment on lines +79 to +80
Copy link

Copilot AI Oct 8, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using type guards with string property checks is fragile and doesn't provide type safety. Consider using discriminated unions with explicit type fields or proper type narrowing.

Copilot uses AI. Check for mistakes.
res.json({
success: true,
message: 'Login successful',
data: {
user: result.user,
accessToken: result.accessToken,
refreshToken: result.refreshToken,
},
});
}
} catch (error) {
this.handleError(error, res);
}
Expand Down Expand Up @@ -180,6 +199,49 @@ export class AuthController {
}
};

/**
* Change password on first login
* POST /api/auth/first-login/change-password
*/
changePasswordFirstLogin = async (req: Request, res: Response): Promise<void> => {
try {
// Validate request
const errors = validationResult(req);
if (!errors.isEmpty()) {
res.status(400).json({
success: false,
errors: errors.array(),
});
return;
}

const dto: FirstLoginPasswordDto = req.body;

// Extract request metadata
const metadata: RequestMetadata = {
ip: req.ip || 'unknown',
userAgent: req.headers['user-agent'] || 'unknown',
};

// Call service
const result = await this.authService.changePasswordFirstLogin(dto, metadata);

// Return success response with tokens
res.json({
success: true,
message: 'Password changed successfully',
data: {
user: result.user,
accessToken: result.accessToken,
refreshToken: result.refreshToken,
require2FASetup: result.require2FASetup,
},
});
} catch (error) {
this.handleError(error, res);
}
};

/**
* Handle errors and send appropriate HTTP responses
*/
Expand Down
20 changes: 20 additions & 0 deletions apps/api/src/domains/auth/auth.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,4 +134,24 @@ export class AuthRepository {
}
return true;
}

/**
* Update user password
*/
async updateUserPassword(userId: string, hashedPassword: string): Promise<void> {
await prisma.user.update({
where: { id: userId },
data: { password: hashedPassword },
});
}

/**
* Update user first login status
*/
async updateUserFirstLoginStatus(userId: string, isFirstLogin: boolean): Promise<void> {
await prisma.user.update({
where: { id: userId },
data: { isFirstLogin },
});
}
}
8 changes: 8 additions & 0 deletions apps/api/src/domains/auth/auth.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
loginValidation,
verify2FAValidation,
refreshTokenValidation,
firstLoginPasswordValidation,
} from './dto';

const router = Router();
Expand Down Expand Up @@ -37,4 +38,11 @@ router.post('/logout', authController.logout);
*/
router.post('/refresh', refreshTokenValidation, authController.refreshAccessToken);

/**
* @route POST /api/auth/first-login/change-password
* @desc Change password on first login
* @access Public
*/
router.post('/first-login/change-password', firstLoginPasswordValidation, authController.changePasswordFirstLogin);

export default router;
81 changes: 79 additions & 2 deletions apps/api/src/domains/auth/auth.service.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { comparePassword } from '../../utils/password';
import { generateAccessToken, generateRefreshToken } from '../../utils/jwt';
import { comparePassword, hashPassword } from '../../utils/password';
import { generateAccessToken, generateRefreshToken, generateTempToken, verifyTempToken } from '../../utils/jwt';
import { verify2FAToken } from '../../utils/twoFactor';
import logger from '../../utils/logger';
import { AuthRepository } from './auth.repository';
Expand All @@ -8,11 +8,13 @@ import {
RefreshTokenDto,
Verify2FADto,
LogoutDto,
FirstLoginPasswordDto,
} from './dto';
import {
LoginResponse,
LoginResult,
Login2FARequiredResult,
LoginFirstTimeResult,
RefreshTokenResult,
RequestMetadata,
TokenPayload,
Expand Down Expand Up @@ -81,6 +83,23 @@ export class AuthService {
throw new AuthenticationError('Invalid credentials');
}

// Check if this is first login
if (user.isFirstLogin) {
logger.info(`User ${username} is logging in for the first time`);

const userData = this.mapUserData(user);
const tempToken = generateTempToken(user.id);

const result: LoginFirstTimeResult = {
requirePasswordChange: true,
userId: user.id,
tempToken,
user: userData,
};

return result;
}

// Check if 2FA is enabled
if (user.twoFactor?.enabled) {
logger.info(`User ${username} requires 2FA verification`);
Expand Down Expand Up @@ -197,6 +216,64 @@ export class AuthService {
return { accessToken };
}

/**
* Change password on first login
*/
async changePasswordFirstLogin(
dto: FirstLoginPasswordDto,
metadata: RequestMetadata
): Promise<LoginResult> {
const { userId, tempToken, newPassword } = dto;

// Verify temp token
try {
const payload = verifyTempToken(tempToken);
if (payload.userId !== userId) {
throw new AuthenticationError('Invalid token');
}
} catch (error) {
throw new AuthenticationError('Invalid or expired token');
}

// Find user
const user = await this.authRepository.findUserById(userId);
if (!user) {
throw new NotFoundError('User not found');
}

// Check if user is still in first login state
if (!user.isFirstLogin) {
throw new ValidationError('Password has already been changed');
}

// Hash new password
const hashedPassword = await hashPassword(newPassword);

// Update password and set isFirstLogin to false
await this.authRepository.updateUserPassword(userId, hashedPassword);
await this.authRepository.updateUserFirstLoginStatus(userId, false);

// Log activity
await this.authRepository.createActivityLog(
userId,
'Changed password on first login',
'security',
metadata,
true
);

logger.info(`User ${user.username} changed password on first login`);

// Generate tokens and complete login (no need to login again)
const result = await this.completeLogin(user, metadata, false);

// Add flag to indicate if 2FA setup is needed
return {
...result,
require2FASetup: !user.twoFactor?.enabled,
};
Comment on lines +268 to +274
Copy link

Copilot AI Oct 8, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The require2FASetup flag is calculated based on the user state before password change, but after password change the user relationship might have changed. Consider fetching fresh user data or ensuring this flag is accurate.

Copilot uses AI. Check for mistakes.
}

/**
* Complete login process (generate tokens, update user, create session, log activity)
*/
Expand Down
11 changes: 10 additions & 1 deletion apps/api/src/domains/auth/auth.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ export interface LoginResult {
user: UserData;
accessToken: string;
refreshToken: string;
requirePasswordChange?: boolean;
require2FASetup?: boolean;
}

export interface Login2FARequiredResult {
Expand All @@ -41,7 +43,14 @@ export interface Login2FARequiredResult {
user: UserData;
}

export type LoginResponse = LoginResult | Login2FARequiredResult;
export interface LoginFirstTimeResult {
requirePasswordChange: true;
userId: string;
tempToken: string;
user: UserData;
}

export type LoginResponse = LoginResult | Login2FARequiredResult | LoginFirstTimeResult;

export interface RefreshTokenResult {
accessToken: string;
Expand Down
Loading