diff --git a/.eslintrc.cjs b/.eslintrc.cjs new file mode 100644 index 00000000..d2e8676f --- /dev/null +++ b/.eslintrc.cjs @@ -0,0 +1,19 @@ +module.exports = { + extends: [ + '@mate-academy/eslint-config', + 'plugin:@typescript-eslint/recommended', + ], + parser: '@typescript-eslint/parser', + plugins: ['jest', '@typescript-eslint'], + env: { + jest: true, + node: true, + }, + ignorePatterns: ['dist/', 'node_modules/', 'src/generated/'], + rules: { + 'no-console': 'off', + 'no-shadow': 'off', + indent: 'off', + 'no-proto': 0, + }, +}; diff --git a/.eslintrc.js b/.eslintrc.js deleted file mode 100644 index f44c7a1d..00000000 --- a/.eslintrc.js +++ /dev/null @@ -1,10 +0,0 @@ -module.exports = { - extends: '@mate-academy/eslint-config', - env: { - jest: true - }, - rules: { - 'no-proto': 0 - }, - plugins: ['jest'] -}; diff --git a/.gitignore b/.gitignore index ed48a299..9cef865c 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,5 @@ node_modules # MacOS .DS_Store -# env files -*.env -.env* +.env +src/generated diff --git a/package.json b/package.json index 5e195a15..3f0db7c7 100644 --- a/package.json +++ b/package.json @@ -3,9 +3,12 @@ "version": "1.0.0", "description": "Auth app", "main": "src/index.js", + "type": "module", "scripts": { + "dev": "tsx watch src/index.ts", + "build": "tsc", + "start": "node dist/index.js", "init": "mate-scripts init", - "start": "node src/index.js", "lint": "npm run format && mate-scripts lint", "format": "prettier --ignore-path .prettierignore --write './src/**/*.{js,ts}'", "test:only": "mate-scripts test", @@ -17,14 +20,37 @@ "license": "GPL-3.0", "devDependencies": { "@mate-academy/eslint-config": "latest", - "@mate-academy/scripts": "^1.8.6", + "@mate-academy/scripts": "^2.1.3", + "@types/bcrypt": "^6.0.0", + "@types/cors": "^2.8.19", + "@types/express": "^5.0.6", + "@types/jsonwebtoken": "^9.0.10", + "@types/node": "^25.6.2", + "@types/nodemailer": "^8.0.0", + "@types/pg": "^8.20.0", + "@typescript-eslint/eslint-plugin": "^7.18.0", + "@typescript-eslint/parser": "^7.18.0", "eslint": "^8.57.0", "eslint-plugin-jest": "^28.6.0", "eslint-plugin-node": "^11.1.0", "jest": "^29.7.0", - "prettier": "^3.3.2" + "prettier": "3.5.3", + "prisma": "^7.8.0", + "tsx": "^4.21.0", + "typescript": "^6.0.3" }, "mateAcademy": { "projectType": "javascript" + }, + "dependencies": { + "@prisma/adapter-pg": "^7.8.0", + "@prisma/client": "^7.8.0", + "bcrypt": "^6.0.0", + "cors": "^2.8.6", + "dotenv": "^17.4.2", + "express": "^5.2.1", + "jsonwebtoken": "^9.0.3", + "nodemailer": "^8.0.7", + "pg": "^8.20.0" } } diff --git a/src/errors/AppError.ts b/src/errors/AppError.ts new file mode 100644 index 00000000..b0693dda --- /dev/null +++ b/src/errors/AppError.ts @@ -0,0 +1,12 @@ +export class AppError extends Error { + statusCode: number; + details?: unknown; + + constructor(statusCode: number, message: string, details?: unknown) { + super(message); + + this.name = 'AppError'; + this.statusCode = statusCode; + this.details = details; + } +} diff --git a/src/index.js b/src/index.js deleted file mode 100644 index ad9a93a7..00000000 --- a/src/index.js +++ /dev/null @@ -1 +0,0 @@ -'use strict'; diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 00000000..8038896e --- /dev/null +++ b/src/index.ts @@ -0,0 +1,38 @@ +import express from 'express'; +import cors from 'cors'; +import dotenv from 'dotenv'; +import { errorMiddleware } from './middlewares/error.middleware.js'; +import { AppError } from './errors/AppError.js'; +import { authRoutes } from './modules/auth/auth.routes.js'; +import { profileRoutes } from './modules/profile/profile.routes.js'; +import cookieParser from 'cookie-parser'; +import { pagesRoutes } from './modules/pages/pages.routes.js'; + +dotenv.config(); + +const app = express(); + +const PORT = process.env.PORT ?? 3000; + +app.use(cors()); +app.use(express.json()); + +app.use(express.urlencoded({ extended: true })); +app.use(cookieParser()); + +app.use(pagesRoutes); + +// +app.use('/api/auth', authRoutes); +app.use('/api/profile', profileRoutes); +// + +app.use((_req, _res, next) => { + next(new AppError(404, 'Route not found')); +}); + +app.use(errorMiddleware); + +app.listen(PORT, () => { + console.log(`Server is running on http://localhost:${PORT}`); +}); diff --git a/src/lib/prisma.ts b/src/lib/prisma.ts new file mode 100644 index 00000000..93f5aea0 --- /dev/null +++ b/src/lib/prisma.ts @@ -0,0 +1,12 @@ +import 'dotenv/config'; + +import { PrismaClient } from '../generated/prisma/client.js'; +import { PrismaPg } from '@prisma/adapter-pg'; + +const adapter = new PrismaPg({ + connectionString: process.env.DATABASE_URL!, +}); + +export const prisma = new PrismaClient({ + adapter, +}); diff --git a/src/middlewares/asyncHandler.ts b/src/middlewares/asyncHandler.ts new file mode 100644 index 00000000..f1b5a8d4 --- /dev/null +++ b/src/middlewares/asyncHandler.ts @@ -0,0 +1,15 @@ +import type { RequestHandler } from 'express'; +import type { ParamsDictionary, Query } from 'express-serve-static-core'; + +export const asyncHandler = < + Params extends ParamsDictionary = ParamsDictionary, + ResBody = unknown, + ReqBody = unknown, + ReqQuery extends Query = Query, +>( + handler: RequestHandler, +): RequestHandler => { + return (req, res, next) => { + Promise.resolve(handler(req, res, next)).catch(next); + }; +}; diff --git a/src/middlewares/auth.middleware.ts b/src/middlewares/auth.middleware.ts new file mode 100644 index 00000000..1113bf2c --- /dev/null +++ b/src/middlewares/auth.middleware.ts @@ -0,0 +1,25 @@ +import type { RequestHandler } from 'express'; +import { AppError } from '../errors/AppError.js'; +import { validateAccessToken } from '../utils/jwt.js'; + +export const authMiddleware: RequestHandler = (req, _res, next) => { + const authHeader = req.headers.authorization; + const [type, headerToken] = authHeader?.split(' ') || []; + + const token = + type === 'Bearer' && headerToken ? headerToken : req.cookies?.accessToken; + + if (!token) { + throw new AppError(401, 'Unauthorized'); + } + + const userData = validateAccessToken(token); + + if (!userData) { + throw new AppError(401, 'Invalid or expired token'); + } + + req.user = userData; + + next(); +}; diff --git a/src/middlewares/error.middleware.ts b/src/middlewares/error.middleware.ts new file mode 100644 index 00000000..fec22ae7 --- /dev/null +++ b/src/middlewares/error.middleware.ts @@ -0,0 +1,21 @@ +import type { ErrorRequestHandler } from 'express'; +import { AppError } from '../errors/AppError.js'; + +export const errorMiddleware: ErrorRequestHandler = (error, _req, res) => { + if (error instanceof AppError) { + return res.status(error.statusCode).json({ + error: { + message: error.message, + details: error.details, + }, + }); + } + + console.error(error); + + return res.status(500).json({ + error: { + message: 'Internal Server Error', + }, + }); +}; diff --git a/src/modules/auth/auth.controller.ts b/src/modules/auth/auth.controller.ts new file mode 100644 index 00000000..08daddb9 --- /dev/null +++ b/src/modules/auth/auth.controller.ts @@ -0,0 +1,64 @@ +import type { RequestHandler } from 'express'; +import { authService } from './auth.service.js'; + +type ActivateParams = { + token: string; +}; + +type ResetPasswordParams = { + token: string; +}; + +const register: RequestHandler = async (req, res) => { + const user = await authService.register(req.body); + + res.status(201).json({ user }); +}; + +const activate: RequestHandler = async (req, res) => { + const { token } = req.params; + + const user = await authService.activate(token); + + res.json({ + user, + message: 'Account activated successfully', + }); +}; + +const login: RequestHandler = async (req, res) => { + const authData = await authService.login(req.body); + + res.json(authData); +}; + +const logout: RequestHandler = async (req, res) => { + res.status(204).send(); +}; + +const forgotPassword: RequestHandler = async (req, res) => { + await authService.forgotPassword(req.body); + + res.json({ + message: 'If an account exists, a password reset link has been sent', + }); +}; + +const resetPassword: RequestHandler = async (req, res) => { + const { token } = req.params; + + await authService.resetPassword(token, req.body); + + res.json({ + message: 'Password has been reset successfully', + }); +}; + +export const authController = { + register, + activate, + login, + logout, + forgotPassword, + resetPassword, +}; diff --git a/src/modules/auth/auth.routes.ts b/src/modules/auth/auth.routes.ts new file mode 100644 index 00000000..438fd7d5 --- /dev/null +++ b/src/modules/auth/auth.routes.ts @@ -0,0 +1,21 @@ +import { Router } from 'express'; +import { authController } from './auth.controller.js'; +import { asyncHandler } from '../../middlewares/asyncHandler.js'; +import { authMiddleware } from '../../middlewares/auth.middleware.js'; + +export const authRoutes = Router(); + +authRoutes.post('/register', asyncHandler(authController.register)); +authRoutes.get('/activate/:token', asyncHandler(authController.activate)); +authRoutes.post('/login', asyncHandler(authController.login)); +authRoutes.post('/logout', authMiddleware, authController.logout); + +authRoutes.post( + '/forgot-password', + asyncHandler(authController.forgotPassword), +); + +authRoutes.post( + '/reset-password/:token', + asyncHandler(authController.resetPassword), +); diff --git a/src/modules/auth/auth.service.ts b/src/modules/auth/auth.service.ts new file mode 100644 index 00000000..97b840a8 --- /dev/null +++ b/src/modules/auth/auth.service.ts @@ -0,0 +1,178 @@ +import { authValidator } from './auth.validator.js'; +import { usersRepository } from '../users/users.repository.js'; +import { usersService, type NormalizedUser } from '../users/users.service.js'; +import { comparePasswords, hashPassword } from '../../utils/password.js'; +import { generateToken } from '../../utils/token.js'; +import { AppError } from '../../errors/AppError.js'; +import { generateAccessToken } from '../../utils/jwt.js'; +import { mailer } from '../../utils/mailer.js'; + +type ForgotPasswordInput = { + email: string; +}; + +type RegisterInput = { + name: string; + email: string; + password: string; +}; + +type LoginInput = { + email: string; + password: string; +}; + +type AuthResponse = { + user: NormalizedUser; + accessToken: string; +}; + +const register = async (input: unknown): Promise => { + const validationResult = authValidator.validateRegisterInput(input); + + if (!validationResult.isValid) { + throw new AppError(400, 'Validation failed', validationResult.errors); + } + + const { name, email, password } = input as RegisterInput; + + const normalizedName = name.trim(); + const normalizedEmail = email.trim().toLowerCase(); + + const existingUser = await usersRepository.findByEmail(normalizedEmail); + + if (existingUser) { + throw new AppError(409, 'Email already in use'); + } + + const passwordHash = await hashPassword(password); + const activationToken = generateToken(); + + const newUser = await usersRepository.create({ + name: normalizedName, + email: normalizedEmail, + passwordHash, + activationToken, + }); + + await mailer.sendActivationLink(normalizedEmail, activationToken); + + return usersService.normalize(newUser); +}; + +const activate = async (token: string): Promise => { + if (!token.trim()) { + throw new AppError(400, 'Activation token is required'); + } + + const user = await usersRepository.findByActivationToken(token); + + if (!user) { + throw new AppError(404, 'Invalid activation token'); + } + + const activatedUser = await usersRepository.activate(user.id); + + return usersService.normalize(activatedUser); +}; + +const login = async (input: unknown): Promise => { + const validationResult = authValidator.validateLoginInput(input); + + if (!validationResult.isValid) { + throw new AppError(400, 'Validation failed', validationResult.errors); + } + + const { email, password } = input as LoginInput; + + const normalizedEmail = email.trim().toLowerCase(); + + const user = await usersRepository.findByEmail(normalizedEmail); + + if (!user) { + throw new AppError(401, 'Invalid email or password'); + } + + const isPasswordValid = await comparePasswords(password, user.passwordHash); + + if (!isPasswordValid) { + throw new AppError(401, 'Invalid email or password'); + } + + if (!user.isActivated) { + throw new AppError(403, 'Account is not activated'); + } + + const normalizedUser = usersService.normalize(user); + + const accessToken = generateAccessToken(normalizedUser); + + return { + user: normalizedUser, + accessToken, + }; +}; + +const forgotPassword = async (input: unknown): Promise => { + const validationResult = authValidator.validateForgotPasswordInput(input); + + if (!validationResult.isValid) { + throw new AppError(400, 'Validation failed', validationResult.errors); + } + + const { email } = input as ForgotPasswordInput; + + const normalizedEmail = email.trim().toLowerCase(); + + const user = await usersRepository.findByEmail(normalizedEmail); + + if (!user) { + return; + } + + const resetToken = generateToken(); + + const resetTokenExpires = new Date(Date.now() + 30 * 60 * 1000); + + await usersRepository.setPasswordResetToken( + user.id, + resetToken, + resetTokenExpires, + ); + + await mailer.sendPasswordResetLink(normalizedEmail, resetToken); +}; + +const resetPassword = async (token: string, input: unknown): Promise => { + if (!token.trim()) { + throw new AppError(400, 'Reset token is required'); + } + + const validationResult = authValidator.validateResetPasswordInput(input); + + if (!validationResult.isValid) { + throw new AppError(400, 'Validation failed', validationResult.errors); + } + + const { password } = input as { password: string }; + + const user = await usersRepository.findByPasswordResetToken(token); + + if (!user || !user.resetTokenExpires || user.resetTokenExpires < new Date()) { + throw new AppError(400, 'Invalid or expired reset token'); + } + + const passwordHash = await hashPassword(password); + + await usersRepository.updatePassword(user.id, passwordHash); + + await usersRepository.clearPasswordResetToken(user.id); +}; + +export const authService = { + register, + activate, + login, + forgotPassword, + resetPassword, +}; diff --git a/src/modules/auth/auth.validator.ts b/src/modules/auth/auth.validator.ts new file mode 100644 index 00000000..4fd14821 --- /dev/null +++ b/src/modules/auth/auth.validator.ts @@ -0,0 +1,236 @@ +import { validatePassword } from '../../utils/password.js'; + +export type RegisterValidationErrors = { + name?: string; + email?: string; + password?: string[]; +}; + +export type RegisterValidationResult = { + isValid: boolean; + errors: RegisterValidationErrors; +}; + +export type LoginValidationErrors = { + email?: string; + password?: string; +}; + +export type LoginValidationResult = { + isValid: boolean; + errors: LoginValidationErrors; +}; + +export type ForgotPasswordValidationErrors = { + email?: string; +}; + +export type ForgotPasswordValidationResult = { + isValid: boolean; + errors: ForgotPasswordValidationErrors; +}; + +export type ResetPasswordValidationErrors = { + password?: string[]; + confirmation?: string; +}; + +export type ResetPasswordValidationResult = { + isValid: boolean; + errors: ResetPasswordValidationErrors; +}; + +const validateEmail = (email: unknown): string | null => { + if (typeof email !== 'string' || email.trim() === '') { + return 'Email is required'; + } + + const emailPattern = /^[\w.+-]+@([\w-]+\.)+[\w-]{2,}$/; + + const normalizedEmail = email.trim(); + + if (!emailPattern.test(normalizedEmail)) { + return 'Invalid email format'; + } + + return null; +}; + +const validateStrongPassword = (password: unknown): string[] | null => { + const validation = validatePassword(password); + + if (!validation.isValid) { + return validation.errors; + } + + return null; +}; + +const validateRequiredPassword = (password: unknown): string | null => { + if (typeof password !== 'string' || password.trim() === '') { + return 'Password is required'; + } + + return null; +}; + +const validateRegisterInput = (input: unknown): RegisterValidationResult => { + const errors: RegisterValidationErrors = {}; + + if (typeof input !== 'object' || input === null) { + return { + isValid: false, + errors: { + name: 'Invalid input', + email: 'Invalid input', + password: ['Invalid input'], + }, + }; + } + + const { name, email, password } = input as { + name?: unknown; + email?: unknown; + password?: unknown; + }; + + if (typeof name !== 'string' || name.trim() === '') { + errors.name = 'Name is required'; + } + + const emailError = validateEmail(email); + + if (emailError) { + errors.email = emailError; + } + + const passwordErrors = validateStrongPassword(password); + + if (passwordErrors) { + errors.password = passwordErrors; + } + + return { + isValid: Object.keys(errors).length === 0, + errors, + }; +}; + +const validateLoginInput = (input: unknown): LoginValidationResult => { + const errors: LoginValidationErrors = {}; + + if (typeof input !== 'object' || input === null) { + return { + isValid: false, + errors: { + email: 'Invalid input', + password: 'Invalid input', + }, + }; + } + + const { email, password } = input as { + email?: unknown; + password?: unknown; + }; + + const emailError = validateEmail(email); + + if (emailError) { + errors.email = emailError; + } + + const passwordError = validateRequiredPassword(password); + + if (passwordError) { + errors.password = passwordError; + } + + return { + isValid: Object.keys(errors).length === 0, + errors, + }; +}; + +const validateForgotPasswordInput = ( + input: unknown, +): ForgotPasswordValidationResult => { + const errors: ForgotPasswordValidationErrors = {}; + + if (typeof input !== 'object' || input === null) { + return { + isValid: false, + errors: { + email: 'Invalid input', + }, + }; + } + + const { email } = input as { + email?: unknown; + }; + + const emailError = validateEmail(email); + + if (emailError) { + errors.email = emailError; + } + + return { + isValid: Object.keys(errors).length === 0, + errors, + }; +}; + +const validateResetPasswordInput = ( + input: unknown, +): ResetPasswordValidationResult => { + const errors: ResetPasswordValidationErrors = {}; + + if (typeof input !== 'object' || input === null) { + return { + isValid: false, + errors: { + password: ['Invalid input'], + confirmation: 'Invalid input', + }, + }; + } + + const { password, confirmation } = input as { + password?: unknown; + confirmation?: unknown; + }; + + const passwordErrors = validateStrongPassword(password); + + if (passwordErrors) { + errors.password = passwordErrors; + } + + if (typeof confirmation !== 'string' || confirmation.trim() === '') { + errors.confirmation = 'Password confirmation is required'; + } + + if ( + typeof password === 'string' && + typeof confirmation === 'string' && + confirmation.trim() !== '' && + password !== confirmation + ) { + errors.confirmation = 'Password confirmation does not match'; + } + + return { + isValid: Object.keys(errors).length === 0, + errors, + }; +}; + +export const authValidator = { + validateEmail, + validateRegisterInput, + validateLoginInput, + validateForgotPasswordInput, + validateResetPasswordInput, +}; diff --git a/src/modules/pages/pages.controller.ts b/src/modules/pages/pages.controller.ts new file mode 100644 index 00000000..a4646f0a --- /dev/null +++ b/src/modules/pages/pages.controller.ts @@ -0,0 +1,264 @@ +import type { RequestHandler } from 'express'; +import { authService } from '../auth/auth.service.js'; +import { profileService } from '../profile/profile.service.js'; +import { AppError } from '../../errors/AppError.js'; +import { generateAccessToken } from '../../utils/jwt.js'; + +type TokenParams = { + token: string; +}; + +const cookieOptions = { + httpOnly: true, + sameSite: 'lax' as const, +}; + +const layout = (title: string, body: string) => ` + + + + + ${title} + + +

${title}

+ ${body} + + +`; + +const showRegister: RequestHandler = (_req, res) => { + res.send( + layout( + 'Register', + ` +
+ + + + +
+

Password must be at least 8 characters and contain uppercase, lowercase, digit and special character.

+ Login + `, + ), + ); +}; + +const register: RequestHandler = async (req, res) => { + await authService.register(req.body); + + res.redirect('/email-sent'); +}; + +const showLogin: RequestHandler = (_req, res) => { + res.send( + layout( + 'Login', + ` +
+ + + +
+ Forgot password? + `, + ), + ); +}; + +const login: RequestHandler = async (req, res) => { + const { accessToken } = await authService.login(req.body); + + res.cookie('accessToken', accessToken, cookieOptions); + res.redirect('/profile'); +}; + +const activate: RequestHandler = async (req, res) => { + const user = await authService.activate(req.params.token); + const accessToken = generateAccessToken(user); + + res.cookie('accessToken', accessToken, cookieOptions); + res.redirect('/profile'); +}; + +const logout: RequestHandler = (_req, res) => { + res.clearCookie('accessToken'); + res.redirect('/login'); +}; + +const showForgotPassword: RequestHandler = (_req, res) => { + res.send( + layout( + 'Forgot password', + ` +
+ + +
+ `, + ), + ); +}; + +const forgotPassword: RequestHandler = async (req, res) => { + await authService.forgotPassword(req.body); + + res.redirect('/email-sent'); +}; + +const showEmailSent: RequestHandler = (_req, res) => { + res.send( + layout( + 'Email sent', + ` +

If this email exists, instructions were sent.

+ Back to login + `, + ), + ); +}; + +const showResetPassword: RequestHandler = (req, res) => { + const { token } = req.params; + + res.send( + layout( + 'Reset password', + ` +
+ + + +
+ `, + ), + ); +}; + +const resetPassword: RequestHandler = async (req, res) => { + await authService.resetPassword(req.params.token, req.body); + + res.redirect('/reset-password-success'); +}; + +const showResetPasswordSuccess: RequestHandler = (_req, res) => { + res.send( + layout( + 'Password reset successful', + ` +

Your password was reset successfully.

+ Go to login + `, + ), + ); +}; + +const showProfile: RequestHandler = async (req, res) => { + const currentUser = req.user; + + if (!currentUser) { + throw new AppError(401, 'Unauthorized'); + } + + const user = await profileService.getProfile(currentUser.id); + + res.send( + layout( + 'Profile', + ` +

Name: ${user.name}

+

Email: ${user.email}

+ +
+ +
+ +

Change name

+
+ + +
+ +

Change password

+
+ + + + +
+ +

Change email

+
+ + + +
+ `, + ), + ); +}; + +const updateName: RequestHandler = async (req, res) => { + const currentUser = req.user; + + if (!currentUser) { + throw new AppError(401, 'Unauthorized'); + } + + await profileService.updateName(currentUser.id, req.body.name); + + res.redirect('/profile'); +}; + +const changePassword: RequestHandler = async (req, res) => { + const currentUser = req.user; + + if (!currentUser) { + throw new AppError(401, 'Unauthorized'); + } + + await profileService.changePassword(currentUser.id, req.body); + + res.redirect('/profile'); +}; + +const changeEmail: RequestHandler = async (req, res) => { + const currentUser = req.user; + + if (!currentUser) { + throw new AppError(401, 'Unauthorized'); + } + + await profileService.changeEmail(currentUser.id, req.body); + + res.redirect('/email-sent'); +}; + +const confirmEmailChange: RequestHandler = async (req, res) => { + const user = await profileService.confirmEmailChange(req.params.token); + const accessToken = generateAccessToken(user); + + res.cookie('accessToken', accessToken, cookieOptions); + res.redirect('/profile'); +}; + +export const pagesController = { + showRegister, + register, + showLogin, + login, + activate, + logout, + showForgotPassword, + forgotPassword, + showEmailSent, + showResetPassword, + resetPassword, + showResetPasswordSuccess, + showProfile, + updateName, + changePassword, + changeEmail, + confirmEmailChange, +}; diff --git a/src/modules/pages/pages.routes.ts b/src/modules/pages/pages.routes.ts new file mode 100644 index 00000000..adc759e2 --- /dev/null +++ b/src/modules/pages/pages.routes.ts @@ -0,0 +1,64 @@ +import { Router } from 'express'; +import { pagesController } from './pages.controller.js'; +import { asyncHandler } from '../../middlewares/asyncHandler.js'; +import { authMiddleware } from '../../middlewares/auth.middleware.js'; + +export const pagesRoutes = Router(); + +pagesRoutes.get('/register', pagesController.showRegister); +pagesRoutes.post('/register', asyncHandler(pagesController.register)); + +pagesRoutes.get('/login', pagesController.showLogin); +pagesRoutes.post('/login', asyncHandler(pagesController.login)); + +pagesRoutes.get('/activate/:token', asyncHandler(pagesController.activate)); + +pagesRoutes.post('/logout', authMiddleware, pagesController.logout); + +pagesRoutes.get('/forgot-password', pagesController.showForgotPassword); +pagesRoutes.post( + '/forgot-password', + asyncHandler(pagesController.forgotPassword), +); + +pagesRoutes.get('/email-sent', pagesController.showEmailSent); + +pagesRoutes.get('/reset-password/:token', pagesController.showResetPassword); +pagesRoutes.post( + '/reset-password/:token', + asyncHandler(pagesController.resetPassword), +); + +pagesRoutes.get( + '/reset-password-success', + pagesController.showResetPasswordSuccess, +); + +pagesRoutes.get( + '/profile', + authMiddleware, + asyncHandler(pagesController.showProfile), +); + +pagesRoutes.post( + '/profile/name', + authMiddleware, + asyncHandler(pagesController.updateName), +); + +pagesRoutes.post( + '/profile/password', + authMiddleware, + asyncHandler(pagesController.changePassword), +); + +pagesRoutes.post( + '/profile/email', + authMiddleware, + asyncHandler(pagesController.changeEmail), +); + +pagesRoutes.get( + '/profile/email/confirm/:token', + asyncHandler(pagesController.confirmEmailChange), +); diff --git a/src/modules/profile/profile.controller.ts b/src/modules/profile/profile.controller.ts new file mode 100644 index 00000000..56d938c2 --- /dev/null +++ b/src/modules/profile/profile.controller.ts @@ -0,0 +1,82 @@ +import type { RequestHandler } from 'express'; +import { AppError } from '../../errors/AppError.js'; +import { profileService } from './profile.service.js'; + +type ConfirmEmailParams = { + token: string; +}; + +const getProfile: RequestHandler = async (req, res) => { + const user = req.user; + + if (!user) { + throw new AppError(401, 'Unauthorized'); + } + + const profile = await profileService.getProfile(user.id); + + res.json({ user: profile }); +}; + +const updateName: RequestHandler = async (req, res) => { + const user = req.user; + + if (!user) { + throw new AppError(401, 'Unauthorized'); + } + + const name = req.body?.name; + + const updatedUser = await profileService.updateName(user.id, name); + + res.json({ user: updatedUser }); +}; + +const changePassword: RequestHandler = async (req, res) => { + const user = req.user; + + if (!user) { + throw new AppError(401, 'Unauthorized'); + } + + await profileService.changePassword(user.id, req.body); + + res.status(204).send(); +}; + +const changeEmail: RequestHandler = async (req, res) => { + const user = req.user; + + if (!user) { + throw new AppError(401, 'Unauthorized'); + } + + await profileService.changeEmail(user.id, req.body); + + res.json({ + message: + 'Email change confirmation has been sent to your new email address', + }); +}; + +const confirmEmailChange: RequestHandler = async ( + req, + res, +) => { + const { token } = req.params; + + const user = await profileService.confirmEmailChange(token); + + res.json({ + user, + message: 'Email has been changed successfully', + }); +}; + +export const profileController = { + getProfile, + updateName, + changePassword, + changeEmail, + confirmEmailChange, +}; diff --git a/src/modules/profile/profile.routes.ts b/src/modules/profile/profile.routes.ts new file mode 100644 index 00000000..1c488dd8 --- /dev/null +++ b/src/modules/profile/profile.routes.ts @@ -0,0 +1,35 @@ +import { Router } from 'express'; +import { profileController } from './profile.controller.js'; +import { asyncHandler } from '../../middlewares/asyncHandler.js'; +import { authMiddleware } from '../../middlewares/auth.middleware.js'; + +export const profileRoutes = Router(); + +profileRoutes.get( + '/', + authMiddleware, + asyncHandler(profileController.getProfile), +); + +profileRoutes.patch( + '/name', + authMiddleware, + asyncHandler(profileController.updateName), +); + +profileRoutes.patch( + '/password', + authMiddleware, + asyncHandler(profileController.changePassword), +); + +profileRoutes.patch( + '/email', + authMiddleware, + asyncHandler(profileController.changeEmail), +); + +profileRoutes.get( + '/email/confirm/:token', + asyncHandler(profileController.confirmEmailChange), +); diff --git a/src/modules/profile/profile.service.ts b/src/modules/profile/profile.service.ts new file mode 100644 index 00000000..5b9e6bd7 --- /dev/null +++ b/src/modules/profile/profile.service.ts @@ -0,0 +1,183 @@ +import { AppError } from '../../errors/AppError.js'; +import { mailer } from '../../utils/mailer.js'; +import { + comparePasswords, + hashPassword, + validatePassword, +} from '../../utils/password.js'; +import { generateToken } from '../../utils/token.js'; +import { usersRepository } from '../users/users.repository.js'; +import { usersService, type NormalizedUser } from '../users/users.service.js'; + +const getProfile = async (userId: number): Promise => { + const user = await usersRepository.findById(userId); + + if (!user) { + throw new AppError(404, 'User not found'); + } + + return usersService.normalize(user); +}; + +const updateName = async ( + userId: number, + name: unknown, +): Promise => { + if (typeof name !== 'string' || name.trim() === '') { + throw new AppError(400, 'Name is required'); + } + + const updatedUser = await usersRepository.updateName(userId, name.trim()); + + return usersService.normalize(updatedUser); +}; + +const changePassword = async ( + userId: number, + input: unknown, +): Promise => { + if (typeof input !== 'object' || input === null) { + throw new AppError(400, 'Invalid input'); + } + + const { oldPassword, newPassword, confirmation } = input as { + oldPassword?: unknown; + newPassword?: unknown; + confirmation?: unknown; + }; + + if (typeof oldPassword !== 'string' || oldPassword.trim() === '') { + throw new AppError(400, 'Old password is required'); + } + + if (typeof newPassword !== 'string' || newPassword.trim() === '') { + throw new AppError(400, 'New password is required'); + } + + if (typeof confirmation !== 'string' || confirmation.trim() === '') { + throw new AppError(400, 'Password confirmation is required'); + } + + if (newPassword !== confirmation) { + throw new AppError(400, 'Password confirmation does not match'); + } + + const passwordValidation = validatePassword(newPassword); + + if (!passwordValidation.isValid) { + throw new AppError(400, 'Validation failed', { + newPassword: passwordValidation.errors, + }); + } + + const user = await usersRepository.findById(userId); + + if (!user) { + throw new AppError(404, 'User not found'); + } + + const isOldPasswordValid = await comparePasswords( + oldPassword, + user.passwordHash, + ); + + if (!isOldPasswordValid) { + throw new AppError(400, 'Old password is incorrect'); + } + + const newPasswordHash = await hashPassword(newPassword); + + await usersRepository.updatePassword(userId, newPasswordHash); +}; + +const changeEmail = async (userId: number, input: unknown): Promise => { + if (typeof input !== 'object' || input === null) { + throw new AppError(400, 'Invalid input'); + } + + const { newEmail, password } = input as { + newEmail?: unknown; + password?: unknown; + }; + + if (typeof newEmail !== 'string' || newEmail.trim() === '') { + throw new AppError(400, 'New email is required'); + } + + if (typeof password !== 'string' || password.trim() === '') { + throw new AppError(400, 'Password is required'); + } + + const emailPattern = /^[\w.+-]+@([\w-]+\.)+[\w-]{2,}$/; + const normalizedEmail = newEmail.trim().toLowerCase(); + + if (!emailPattern.test(normalizedEmail)) { + throw new AppError(400, 'Invalid email format'); + } + + const user = await usersRepository.findById(userId); + + if (!user) { + throw new AppError(404, 'User not found'); + } + + if (user.email === normalizedEmail) { + throw new AppError(400, 'New email must be different from current email'); + } + + const isPasswordValid = await comparePasswords(password, user.passwordHash); + + if (!isPasswordValid) { + throw new AppError(400, 'Password is incorrect'); + } + + const existingUser = await usersRepository.findByEmail(normalizedEmail); + + if (existingUser) { + throw new AppError(409, 'Email is already in use'); + } + + const emailChangeToken = generateToken(); + + await usersRepository.setEmailChangeData( + userId, + normalizedEmail, + emailChangeToken, + ); + + await mailer.sendEmailChangeConfirmation(normalizedEmail, emailChangeToken); + await mailer.sendEmailChangeNotification(user.email, normalizedEmail); +}; + +const confirmEmailChange = async (token: string): Promise => { + if (!token.trim()) { + throw new AppError(400, 'Email change token is required'); + } + + const user = await usersRepository.findByEmailChangeToken(token); + + if (!user || !user.pendingEmail) { + throw new AppError(404, 'Invalid email change token'); + } + + const existingUser = await usersRepository.findByEmail(user.pendingEmail); + + if (existingUser) { + throw new AppError(409, 'Email is already in use'); + } + + const updatedUser = await usersRepository.confirmEmailChange( + user.id, + user.pendingEmail, + ); + + return usersService.normalize(updatedUser); +}; + +export const profileService = { + updateName, + getProfile, + changePassword, + changeEmail, + confirmEmailChange, +}; diff --git a/src/modules/users/users.repository.ts b/src/modules/users/users.repository.ts new file mode 100644 index 00000000..d938fcdd --- /dev/null +++ b/src/modules/users/users.repository.ts @@ -0,0 +1,137 @@ +import { prisma } from '../../lib/prisma.js'; + +type CreateUserData = { + name: string; + email: string; + passwordHash: string; + activationToken: string; +}; + +const findByEmail = (email: string) => { + return prisma.user.findUnique({ + where: { email }, + }); +}; + +const create = (data: CreateUserData) => { + return prisma.user.create({ + data, + }); +}; + +const findByActivationToken = (token: string) => { + return prisma.user.findUnique({ + where: { activationToken: token }, + }); +}; + +const activate = (userId: number) => { + return prisma.user.update({ + where: { id: userId }, + data: { + isActivated: true, + activationToken: null, + }, + }); +}; + +const updateName = (userId: number, name: string) => { + return prisma.user.update({ + where: { id: userId }, + data: { name }, + }); +}; + +const findById = (userId: number) => { + return prisma.user.findUnique({ + where: { id: userId }, + }); +}; + +const updatePassword = (userId: number, passwordHash: string) => { + return prisma.user.update({ + where: { id: userId }, + data: { passwordHash }, + }); +}; + +const setPasswordResetToken = ( + userId: number, + resetPasswordToken: string, + resetTokenExpires: Date, +) => { + return prisma.user.update({ + where: { id: userId }, + data: { + resetPasswordToken, + resetTokenExpires, + }, + }); +}; + +const findByPasswordResetToken = (token: string) => { + return prisma.user.findUnique({ + where: { + resetPasswordToken: token, + }, + }); +}; + +const clearPasswordResetToken = (userId: number) => { + return prisma.user.update({ + where: { id: userId }, + data: { + resetPasswordToken: null, + resetTokenExpires: null, + }, + }); +}; + +const setEmailChangeData = ( + userId: number, + pendingEmail: string, + emailChangeToken: string, +) => { + return prisma.user.update({ + where: { id: userId }, + data: { + pendingEmail, + emailChangeToken, + }, + }); +}; + +const findByEmailChangeToken = (token: string) => { + return prisma.user.findUnique({ + where: { + emailChangeToken: token, + }, + }); +}; + +const confirmEmailChange = (userId: number, newEmail: string) => { + return prisma.user.update({ + where: { id: userId }, + data: { + email: newEmail, + pendingEmail: null, + emailChangeToken: null, + }, + }); +}; + +export const usersRepository = { + findByEmail, + create, + findByActivationToken, + activate, + updateName, + findById, + updatePassword, + setPasswordResetToken, + findByPasswordResetToken, + clearPasswordResetToken, + setEmailChangeData, + findByEmailChangeToken, + confirmEmailChange, +}; diff --git a/src/modules/users/users.service.ts b/src/modules/users/users.service.ts new file mode 100644 index 00000000..687c74ab --- /dev/null +++ b/src/modules/users/users.service.ts @@ -0,0 +1,23 @@ +import type { User } from '../../generated/prisma/client.js'; + +export type NormalizedUser = { + id: number; + name: string; + email: string; + isActivated: boolean; +}; + +const normalize = (user: User): NormalizedUser => { + const { id, name, email, isActivated } = user; + + return { + id, + name, + email, + isActivated, + }; +}; + +export const usersService = { + normalize, +}; diff --git a/src/types/express.d.ts b/src/types/express.d.ts new file mode 100644 index 00000000..ae8387ce --- /dev/null +++ b/src/types/express.d.ts @@ -0,0 +1,11 @@ +import type { NormalizedUser } from '../modules/users/users.service.ts'; + +declare global { + namespace Express { + interface Request { + user?: NormalizedUser; + } + } +} + +export {}; diff --git a/src/utils/jwt.ts b/src/utils/jwt.ts new file mode 100644 index 00000000..cb0f8b8f --- /dev/null +++ b/src/utils/jwt.ts @@ -0,0 +1,43 @@ +import 'dotenv/config'; +import jwt from 'jsonwebtoken'; +import type { NormalizedUser } from '../modules/users/users.service.js'; + +const ACCESS_TOKEN_SECRET = process.env.JWT_ACCESS_SECRET; + +if (!ACCESS_TOKEN_SECRET) { + throw new Error('JWT_ACCESS_SECRET is not defined in environment variables'); +} + +export const generateAccessToken = (user: NormalizedUser): string => { + return jwt.sign(user, ACCESS_TOKEN_SECRET, { expiresIn: '15m' }); +}; + +export const validateAccessToken = (token: string): NormalizedUser | null => { + try { + const payload = jwt.verify(token, ACCESS_TOKEN_SECRET); + + if (typeof payload !== 'object' || payload === null) { + return null; + } + + const { id, name, email, isActivated } = payload; + + if ( + typeof id !== 'number' || + typeof name !== 'string' || + typeof email !== 'string' || + typeof isActivated !== 'boolean' + ) { + return null; + } + + return { + id, + name, + email, + isActivated, + }; + } catch { + return null; + } +}; diff --git a/src/utils/mailer.ts b/src/utils/mailer.ts new file mode 100644 index 00000000..7214edbb --- /dev/null +++ b/src/utils/mailer.ts @@ -0,0 +1,101 @@ +import 'dotenv/config'; +import nodemailer from 'nodemailer'; + +const SMTP_USER = process.env.SMTP_USER; +const SMTP_PASSWORD = process.env.SMTP_PASSWORD; +const API_URL = process.env.API_URL ?? 'http://localhost:3000'; + +if (!SMTP_USER || !SMTP_PASSWORD) { + throw new Error('SMTP_USER or SMTP_PASSWORD is not defined'); +} + +const transporter = nodemailer.createTransport({ + host: 'smtp.gmail.com', + port: 465, + secure: true, + auth: { + user: SMTP_USER, + pass: SMTP_PASSWORD, + }, + tls: + process.env.NODE_ENV === 'development' + ? { + rejectUnauthorized: false, + } + : undefined, +}); + +transporter.verify((error) => { + if (error) { + console.error('SMTP connection error:', error); + } else { + console.log('SMTP server is ready to send emails'); + } +}); + +const send = (email: string, subject: string, html: string) => { + return transporter.sendMail({ + from: `"Auth API" <${SMTP_USER}>`, + to: email, + subject, + html, + }); +}; + +const sendActivationLink = (email: string, activationToken: string) => { + const link = `${API_URL}/activate/${activationToken}`; + + const html = ` +

Account activation

+

Please click the link below to activate your account:

+ ${link} + `; + + return send(email, 'Account activation', html); +}; + +const sendPasswordResetLink = (email: string, resetToken: string) => { + const link = `${API_URL}/reset-password/${resetToken}`; + + const html = ` +

Password reset

+

Please click the link below to reset your password:

+ ${link} + `; + + return send(email, 'Password reset', html); +}; + +const sendEmailChangeConfirmation = ( + newEmail: string, + emailChangeToken: string, +) => { + const link = `${API_URL}/profile/email/confirm/${emailChangeToken}`; + + const html = ` +

Email change confirmation

+

Please click the link below to confirm your new email:

+ ${link} + `; + + return send(newEmail, 'Confirm email change', html); +}; + +const sendEmailChangeNotification = (oldEmail: string, newEmail: string) => { + const html = ` +

Email change requested

+

Your account email change was requested.

+

New email: ${newEmail}

+

If this was not you, please secure your account.

+ `; + + return send(oldEmail, 'Email change requested', html); +}; + +export const mailer = { + send, + sendActivationLink, + sendPasswordResetLink, + sendEmailChangeConfirmation, + sendEmailChangeNotification, +}; diff --git a/src/utils/password.ts b/src/utils/password.ts new file mode 100644 index 00000000..735dcd72 --- /dev/null +++ b/src/utils/password.ts @@ -0,0 +1,59 @@ +import bcrypt from 'bcrypt'; + +export type PasswordValidationResult = { + isValid: boolean; + errors: string[]; +}; + +const SALT_ROUNDS = 10; + +export function validatePassword(password: unknown): PasswordValidationResult { + const errors: string[] = []; + + if (typeof password !== 'string') { + return { + isValid: false, + errors: ['Password must be a string'], + }; + } + + if (password.trim().length === 0) { + errors.push('Password cannot be empty'); + } + + if (password.length < 8) { + errors.push('Password must be at least 8 characters long'); + } + + if (!/[A-Z]/.test(password)) { + errors.push('Password must contain at least one uppercase letter'); + } + + if (!/[a-z]/.test(password)) { + errors.push('Password must contain at least one lowercase letter'); + } + + if (!/[0-9]/.test(password)) { + errors.push('Password must contain at least one digit'); + } + + if (!/[^A-Za-z0-9]/.test(password)) { + errors.push('Password must contain at least one special character'); + } + + return { + isValid: errors.length === 0, + errors, + }; +} + +export function hashPassword(password: string): Promise { + return bcrypt.hash(password, SALT_ROUNDS); +} + +export function comparePasswords( + password: string, + hashedPassword: string, +): Promise { + return bcrypt.compare(password, hashedPassword); +} diff --git a/src/utils/token.ts b/src/utils/token.ts new file mode 100644 index 00000000..0fe11294 --- /dev/null +++ b/src/utils/token.ts @@ -0,0 +1,7 @@ +import crypto from 'node:crypto'; + +const TOKEN_LENGTH = 32; + +export const generateToken = (): string => { + return crypto.randomBytes(TOKEN_LENGTH).toString('hex'); +};