diff --git a/.github/workflows/test.yml-template b/.github/workflows/test.yml-template new file mode 100644 index 00000000..bb13dfc4 --- /dev/null +++ b/.github/workflows/test.yml-template @@ -0,0 +1,23 @@ +name: Test + +on: + pull_request: + branches: [ master ] + +jobs: + build: + + runs-on: ubuntu-latest + + strategy: + matrix: + node-version: [20.x] + + steps: + - uses: actions/checkout@v2 + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v1 + with: + node-version: ${{ matrix.node-version }} + - run: npm install + - run: npm test diff --git a/prisma.config.ts b/prisma.config.ts new file mode 100644 index 00000000..831a20fa --- /dev/null +++ b/prisma.config.ts @@ -0,0 +1,14 @@ +// This file was generated by Prisma, and assumes you have installed the following: +// npm install --save-dev prisma dotenv +import "dotenv/config"; +import { defineConfig } from "prisma/config"; + +export default defineConfig({ + schema: "prisma/schema.prisma", + migrations: { + path: "prisma/migrations", + }, + datasource: { + url: process.env["DATABASE_URL"], + }, +}); diff --git a/prisma/migrations/20260206215804_init/migration.sql b/prisma/migrations/20260206215804_init/migration.sql new file mode 100644 index 00000000..f552f2a2 --- /dev/null +++ b/prisma/migrations/20260206215804_init/migration.sql @@ -0,0 +1,38 @@ +-- CreateTable +CREATE TABLE "users_auth" ( + "id" SERIAL NOT NULL, + "name" VARCHAR(255) NOT NULL, + "email" VARCHAR(255) NOT NULL, + "password" VARCHAR(255) NOT NULL, + "activationToken" VARCHAR(255), + "createdAt" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMPTZ(6) NOT NULL, + + CONSTRAINT "users_auth_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "tokens_auth" ( + "id" SERIAL NOT NULL, + "refreshToken" VARCHAR(255) NOT NULL, + "createdAt" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMPTZ(6) NOT NULL, + "userId" INTEGER NOT NULL, + + CONSTRAINT "tokens_auth_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "users_auth_email_key" ON "users_auth"("email"); + +-- CreateIndex +CREATE UNIQUE INDEX "users_auth_activationToken_key" ON "users_auth"("activationToken"); + +-- CreateIndex +CREATE UNIQUE INDEX "tokens_auth_refreshToken_key" ON "tokens_auth"("refreshToken"); + +-- CreateIndex +CREATE UNIQUE INDEX "tokens_auth_userId_key" ON "tokens_auth"("userId"); + +-- AddForeignKey +ALTER TABLE "tokens_auth" ADD CONSTRAINT "tokens_auth_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users_auth"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/prisma/migrations/20260209120310_add_user_reset_token/migration.sql b/prisma/migrations/20260209120310_add_user_reset_token/migration.sql new file mode 100644 index 00000000..72735481 --- /dev/null +++ b/prisma/migrations/20260209120310_add_user_reset_token/migration.sql @@ -0,0 +1,11 @@ +/* + Warnings: + + - A unique constraint covering the columns `[resetToken]` on the table `users_auth` will be added. If there are existing duplicate values, this will fail. + +*/ +-- AlterTable +ALTER TABLE "users_auth" ADD COLUMN "resetToken" VARCHAR(255); + +-- CreateIndex +CREATE UNIQUE INDEX "users_auth_resetToken_key" ON "users_auth"("resetToken"); diff --git a/prisma/migrations/20260210160621_add_pending_email/migration.sql b/prisma/migrations/20260210160621_add_pending_email/migration.sql new file mode 100644 index 00000000..7420a1ff --- /dev/null +++ b/prisma/migrations/20260210160621_add_pending_email/migration.sql @@ -0,0 +1,11 @@ +/* + Warnings: + + - A unique constraint covering the columns `[pendingEmail]` on the table `users_auth` will be added. If there are existing duplicate values, this will fail. + +*/ +-- AlterTable +ALTER TABLE "users_auth" ADD COLUMN "pendingEmail" VARCHAR(255); + +-- CreateIndex +CREATE UNIQUE INDEX "users_auth_pendingEmail_key" ON "users_auth"("pendingEmail"); diff --git a/prisma/migrations/20260211144328_add_pending_email_token/migration.sql b/prisma/migrations/20260211144328_add_pending_email_token/migration.sql new file mode 100644 index 00000000..a52b0c64 --- /dev/null +++ b/prisma/migrations/20260211144328_add_pending_email_token/migration.sql @@ -0,0 +1,21 @@ +/* + Warnings: + + - You are about to drop the column `resetToken` on the `users_auth` table. All the data in the column will be lost. + - A unique constraint covering the columns `[passwordResetToken]` on the table `users_auth` will be added. If there are existing duplicate values, this will fail. + - A unique constraint covering the columns `[pendingEmailToken]` on the table `users_auth` will be added. If there are existing duplicate values, this will fail. + +*/ +-- DropIndex +DROP INDEX "users_auth_resetToken_key"; + +-- AlterTable +ALTER TABLE "users_auth" DROP COLUMN "resetToken", +ADD COLUMN "passwordResetToken" VARCHAR(255), +ADD COLUMN "pendingEmailToken" VARCHAR(255); + +-- CreateIndex +CREATE UNIQUE INDEX "users_auth_passwordResetToken_key" ON "users_auth"("passwordResetToken"); + +-- CreateIndex +CREATE UNIQUE INDEX "users_auth_pendingEmailToken_key" ON "users_auth"("pendingEmailToken"); diff --git a/prisma/migrations/20260326152454_add_social_accounts/migration.sql b/prisma/migrations/20260326152454_add_social_accounts/migration.sql new file mode 100644 index 00000000..b6dc9ff9 --- /dev/null +++ b/prisma/migrations/20260326152454_add_social_accounts/migration.sql @@ -0,0 +1,19 @@ +-- AlterTable +ALTER TABLE "users_auth" ALTER COLUMN "password" DROP NOT NULL; + +-- CreateTable +CREATE TABLE "social_accounts_auth" ( + "id" SERIAL NOT NULL, + "provider" VARCHAR(50) NOT NULL, + "providerId" VARCHAR(255) NOT NULL, + "userId" INTEGER NOT NULL, + "createdAt" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "social_accounts_auth_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "social_accounts_auth_provider_providerId_key" ON "social_accounts_auth"("provider", "providerId"); + +-- AddForeignKey +ALTER TABLE "social_accounts_auth" ADD CONSTRAINT "social_accounts_auth_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users_auth"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/20260327095858_add_token_expiry_fields/migration.sql b/prisma/migrations/20260327095858_add_token_expiry_fields/migration.sql new file mode 100644 index 00000000..385e3477 --- /dev/null +++ b/prisma/migrations/20260327095858_add_token_expiry_fields/migration.sql @@ -0,0 +1,4 @@ +-- AlterTable +ALTER TABLE "users_auth" ADD COLUMN "activationExpiresAt" TIMESTAMPTZ(6), +ADD COLUMN "passwordResetExpiresAt" TIMESTAMPTZ(6), +ADD COLUMN "pendingEmailExpiresAt" TIMESTAMPTZ(6); diff --git a/prisma/migrations/migration_lock.toml b/prisma/migrations/migration_lock.toml new file mode 100644 index 00000000..044d57cd --- /dev/null +++ b/prisma/migrations/migration_lock.toml @@ -0,0 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (e.g., Git) +provider = "postgresql" diff --git a/prisma/schema.prisma b/prisma/schema.prisma new file mode 100644 index 00000000..b514322d --- /dev/null +++ b/prisma/schema.prisma @@ -0,0 +1,50 @@ +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "postgresql" +} + +model User { + id Int @id @default(autoincrement()) + name String @db.VarChar(255) + email String @unique @db.VarChar(255) + password String? @db.VarChar(255) + activationToken String? @unique @db.VarChar(255) + activationExpiresAt DateTime? @db.Timestamptz(6) + createdAt DateTime @default(now()) @db.Timestamptz(6) + updatedAt DateTime @updatedAt @db.Timestamptz(6) + passwordResetToken String? @unique @db.VarChar(255) + passwordResetExpiresAt DateTime? @db.Timestamptz(6) + pendingEmail String? @unique @db.VarChar(255) + pendingEmailToken String? @unique @db.VarChar(255) + pendingEmailExpiresAt DateTime? @db.Timestamptz(6) + tokens Token[] + socialAccounts SocialAccount[] + + @@map("users_auth") +} + +model SocialAccount { + id Int @id @default(autoincrement()) + provider String @db.VarChar(50) + providerId String @db.VarChar(255) + userId Int + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + createdAt DateTime @default(now()) @db.Timestamptz(6) + + @@unique([provider, providerId]) + @@map("social_accounts_auth") +} + +model Token { + id Int @id @default(autoincrement()) + refreshToken String @unique @db.VarChar(255) + createdAt DateTime @default(now()) @db.Timestamptz(6) + updatedAt DateTime @updatedAt @db.Timestamptz(6) + userId Int @unique + user User @relation(fields: [userId], references: [id]) + + @@map("tokens_auth") +} diff --git a/src/controllers/auth.controller.js b/src/controllers/auth.controller.js new file mode 100644 index 00000000..0c669338 --- /dev/null +++ b/src/controllers/auth.controller.js @@ -0,0 +1,179 @@ +import { v4 as uuidv4 } from 'uuid'; +import { userService } from '../services/user.service.js'; +import { authService } from '../services/auth.service.js'; +import { ApiError } from '../utils/api.error.js'; +import { logger } from '../utils/logger.js'; +import passport from '../utils/passport.js'; + +const register = async (req, res) => { + const { name, email, password } = req.body; + + await authService.register(name, email, password); + + res.send({ message: 'Registration successful' }); +}; + +const activate = async (req, res) => { + const { activationToken } = req.params; + + const activatedUser = await authService.activate(activationToken); + + res.send(userService.normalize(activatedUser)); +}; + +const sendAuthentication = async (res, user) => { + const { + user: normalizedUser, + accessToken, + refreshToken, + } = await authService.authenticate(user); + + res.cookie('refreshToken', refreshToken, { + maxAge: 30 * 24 * 60 * 60 * 1000, + httpOnly: true, + secure: true, + sameSite: 'lax', + }); + res.send({ user: normalizedUser, accessToken }); +}; + +const login = async (req, res) => { + const { email, password } = req.body; + + const user = await authService.login(email, password); + + await sendAuthentication(res, user); +}; + +const refresh = async (req, res) => { + const { refreshToken } = req.cookies; + + const user = await authService.refresh(refreshToken); + + await sendAuthentication(res, user); +}; + +const logout = async (req, res) => { + const { refreshToken } = req.cookies; + + await authService.logout(refreshToken); + + res.clearCookie('refreshToken'); + + res.send({ message: 'Logged out successfully' }); +}; + +const requestPasswordReset = async (req, res) => { + const { email } = req.body; + + await authService.requestPasswordReset(email); + + res.clearCookie('refreshToken'); + res.send({ message: 'Password reset email sent' }); +}; + +const confirmPasswordReset = async (req, res) => { + const { passwordResetToken } = req.params; + const { password } = req.body; + + await authService.confirmPasswordReset(passwordResetToken, password); + + res.clearCookie('refreshToken'); + res.send({ message: 'Password reset successful' }); +}; + +const confirmEmailChange = async (req, res) => { + const { pendingEmailToken } = req.params; + + await authService.confirmEmailChange(pendingEmailToken); + + res.clearCookie('refreshToken'); + res.send({ message: 'Email updated successfully' }); +}; + +const socialCallback = (provider) => (req, res, next) => { + passport.authenticate( + provider, + { session: false }, + async (err, user, info) => { + if (err) { + return next(err); + } + + const isLink = req.session?.socialAuthIntent === 'link'; + + if (req.session?.socialAuthIntent) { + delete req.session.socialAuthIntent; + } + + if (!user) { + const errorMsg = encodeURIComponent( + info?.message || 'Authentication failed', + ); + const redirectPath = isLink ? '/profile' : '/login'; + + return res.redirect( + `${process.env.CLIENT_HOST}${redirectPath}?error=${errorMsg}`, + ); + } + + if (isLink) { + return res.redirect( + `${process.env.CLIENT_HOST}/profile?linked=${provider}`, + ); + } + + const code = uuidv4(); + + req.session.authCode = code; + req.session.authUserId = user.id; + + req.session.save((saveErr) => { + if (saveErr) { + return next(saveErr); + } + + res.redirect(`${process.env.CLIENT_HOST}/auth/callback?code=${code}`); + }); + }, + )(req, res, next); +}; + +const exchangeCode = async (req, res) => { + const { code } = req.body; + + if (!code || !req.session?.authCode || req.session.authCode !== code) { + throw ApiError.badRequest('Invalid or expired authorization code'); + } + + const userId = req.session.authUserId; + + req.session.destroy((err) => { + if (err) { + logger.error('Failed to destroy session after code exchange', { + message: err.message, + }); + } + }); + + const user = await userService.getById(userId); + + if (!user) { + throw ApiError.badRequest('User not found'); + } + + await sendAuthentication(res, user); +}; + +export const authController = { + register, + activate, + login, + refresh, + logout, + requestPasswordReset, + confirmPasswordReset, + confirmEmailChange, + socialCallback, + exchangeCode, +}; diff --git a/src/controllers/user.controller.js b/src/controllers/user.controller.js new file mode 100644 index 00000000..9c322760 --- /dev/null +++ b/src/controllers/user.controller.js @@ -0,0 +1,49 @@ +import { authService } from '../services/auth.service.js'; +import { userService } from '../services/user.service.js'; + +const getProfile = async (req, res) => { + const profile = await userService.getProfile(req.userId); + + res.send(profile); +}; + +const updateName = async (req, res) => { + const { name } = req.body; + + await authService.updateName(req.userId, name); + + res.send({ message: 'Name changed successfully' }); +}; + +const updatePassword = async (req, res) => { + const { password, newPassword } = req.body; + + await authService.updatePassword(req.userId, password, newPassword); + + res.clearCookie('refreshToken'); + res.send({ message: 'Password changed successfully' }); +}; + +const requestEmailChange = async (req, res) => { + const { newEmail, password } = req.body; + + await authService.requestEmailChange(req.userId, newEmail, password); + + res.send({ message: 'Change email notification sent' }); +}; + +const unlinkSocialAccount = async (req, res) => { + const { provider } = req.params; + + await authService.unlinkSocialAccount(req.userId, provider); + + res.send({ message: `${provider} account unlinked successfully` }); +}; + +export const userController = { + getProfile, + updateName, + updatePassword, + requestEmailChange, + unlinkSocialAccount, +}; diff --git a/src/index.js b/src/index.js index ad9a93a7..f093ccc5 100644 --- a/src/index.js +++ b/src/index.js @@ -1 +1,57 @@ 'use strict'; +import 'dotenv/config'; +import cors from 'cors'; +import express from 'express'; +import session from 'express-session'; +import pgSession from 'connect-pg-simple'; +import cookieParser from 'cookie-parser'; +import { authRouter } from './routes/auth.route.js'; +import { userRouter } from './routes/user.route.js'; +import { isAuth } from './middlewares/auth.middleware.js'; +import { errorMiddleware } from './middlewares/error.middleware.js'; +import { notFound } from './middlewares/not-found.middleware.js'; +import passport from './utils/passport.js'; +import { logger } from './utils/logger.js'; + +const app = express(); +const PORT = process.env.PORT || 3005; + +const PgStore = pgSession(session); + +const sessionStore = new PgStore({ + conString: process.env.DATABASE_URL, + createTableIfMissing: true, +}); + +sessionStore.on('error', (err) => { + logger.error('PgStore error', { message: err.message }); +}); + +app.use(cors({ origin: process.env.CLIENT_HOST, credentials: true })); + +app.use( + session({ + store: sessionStore, + secret: process.env.SESSION_SECRET, + resave: false, + saveUninitialized: false, + cookie: { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + maxAge: 1000 * 60 * 5, + sameSite: 'lax', + }, + }), +); +app.use(passport.initialize()); +app.use(passport.session()); + +app.use(express.json()); +app.use(cookieParser()); + +app.use(authRouter); +app.use('/profile', isAuth, userRouter); +app.use(notFound); +app.use(errorMiddleware); + +app.listen(PORT); diff --git a/src/middlewares/auth.middleware.js b/src/middlewares/auth.middleware.js new file mode 100644 index 00000000..f4e46b4b --- /dev/null +++ b/src/middlewares/auth.middleware.js @@ -0,0 +1,52 @@ +import { jwtService } from '../services/jwt.service.js'; +import { ApiError } from '../utils/api.error.js'; +import { logger } from '../utils/logger.js'; + +export const isAuth = async (req, res, next) => { + const authHeader = req.headers.authorization ?? ''; + + if (!authHeader.startsWith('Bearer ')) { + logger.warn('Authorization header missing or malformed'); + + return next(ApiError.unauthorized()); + } + + const token = authHeader.split(' ')[1]; + const userData = jwtService.verify(token); + + if (!userData) { + logger.error('Invalid or expired token'); + + return next(ApiError.unauthorized()); + } + + req.userId = userData.id; + next(); +}; + +export const storeLinkIntent = (req, res, next) => { + const { refreshToken } = req.cookies; + + if (!refreshToken) { + logger.warn('No refresh token found'); + + return next(ApiError.unauthorized()); + } + + const userData = jwtService.verifyRefresh(refreshToken); + + if (!userData) { + return next(ApiError.unauthorized()); + } + + req.session.linkUserId = userData.id; + req.session.socialAuthIntent = 'link'; + + req.session.save((err) => { + if (err) { + return next(err); + } + + next(); + }); +}; diff --git a/src/middlewares/catch-error.middleware.js b/src/middlewares/catch-error.middleware.js new file mode 100644 index 00000000..37ba73be --- /dev/null +++ b/src/middlewares/catch-error.middleware.js @@ -0,0 +1,9 @@ +export const catchError = (action) => { + return async (req, res, next) => { + try { + await action(req, res, next); + } catch (error) { + next(error); + } + }; +}; diff --git a/src/middlewares/error.middleware.js b/src/middlewares/error.middleware.js new file mode 100644 index 00000000..706fcd92 --- /dev/null +++ b/src/middlewares/error.middleware.js @@ -0,0 +1,19 @@ +import { ApiError } from '../utils/api.error.js'; +import { logger } from '../utils/logger.js'; + +export const errorMiddleware = (error, req, res, next) => { + if (error instanceof ApiError) { + return res + .status(error.status) + .send({ message: error.message, errors: error.errors }); + } + + logger.error('Unexpected error', { + message: error.message, + stack: error.stack, + path: req.path, + method: req.method, + }); + + res.status(500).send({ message: 'Internal Server Error' }); +}; diff --git a/src/middlewares/guest.middleware.js b/src/middlewares/guest.middleware.js new file mode 100644 index 00000000..4af63cce --- /dev/null +++ b/src/middlewares/guest.middleware.js @@ -0,0 +1,23 @@ +import { jwtService } from '../services/jwt.service.js'; +import { logger } from '../utils/logger.js'; +import { ApiError } from '../utils/api.error.js'; + +export const isNotAuth = (req, res, next) => { + const { refreshToken } = req.cookies; + + if (!refreshToken) { + logger.info('No refresh token found, proceeding to guest route'); + + return next(); + } + + const userData = jwtService.verifyRefresh(refreshToken); + + if (!userData) { + logger.info('Invalid refresh token, proceeding to guest route'); + + return next(); + } + + return next(ApiError.badRequest('Already authenticated')); +}; diff --git a/src/middlewares/not-found.middleware.js b/src/middlewares/not-found.middleware.js new file mode 100644 index 00000000..82992ee6 --- /dev/null +++ b/src/middlewares/not-found.middleware.js @@ -0,0 +1,5 @@ +import { ApiError } from '../utils/api.error.js'; + +export const notFound = (req, res, next) => { + return next(ApiError.notFound('Route not found')); +}; diff --git a/src/middlewares/rate-limit.middleware.js b/src/middlewares/rate-limit.middleware.js new file mode 100644 index 00000000..0f8310d0 --- /dev/null +++ b/src/middlewares/rate-limit.middleware.js @@ -0,0 +1,21 @@ +import rateLimit from 'express-rate-limit'; + +export const authLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, + max: 10, + standardHeaders: true, + legacyHeaders: false, + message: { + message: 'Too many attempts, please try again later', + }, +}); + +export const exchangeLimiter = rateLimit({ + windowMs: 5 * 60 * 1000, + max: 20, + standardHeaders: true, + legacyHeaders: false, + message: { + message: 'Too many attempts, please try again later', + }, +}); diff --git a/src/middlewares/validate.middleware.js b/src/middlewares/validate.middleware.js new file mode 100644 index 00000000..c1b6e17c --- /dev/null +++ b/src/middlewares/validate.middleware.js @@ -0,0 +1,25 @@ +import { ApiError } from '../utils/api.error.js'; + +export const validate = (schemas) => { + return async (req, res, next) => { + const errors = {}; + + for (const [source, schema] of Object.entries(schemas)) { + try { + req[source] = await schema.parseAsync(req[source]); + } catch (error) { + if (error.name !== 'ZodError') { + return next(error); + } + + Object.assign(errors, error.flatten().fieldErrors); + } + } + + if (Object.keys(errors).length > 0) { + return next(ApiError.badRequest('Validation error', errors)); + } + + return next(); + }; +}; diff --git a/src/routes/auth.route.js b/src/routes/auth.route.js new file mode 100644 index 00000000..84d2ac10 --- /dev/null +++ b/src/routes/auth.route.js @@ -0,0 +1,114 @@ +import express from 'express'; +import { authController } from '../controllers/auth.controller.js'; +import { catchError } from '../middlewares/catch-error.middleware.js'; +import { isAuth, storeLinkIntent } from '../middlewares/auth.middleware.js'; +import { isNotAuth } from '../middlewares/guest.middleware.js'; +import { validate } from '../middlewares/validate.middleware.js'; +import { authValidation } from '../validations/auth.validation.js'; +import { + authLimiter, + exchangeLimiter, +} from '../middlewares/rate-limit.middleware.js'; +import passport from '../utils/passport.js'; + +export const authRouter = express.Router(); + +authRouter.post( + '/registration', + isNotAuth, + authLimiter, + validate(authValidation.register), + catchError(authController.register), +); + +authRouter.get( + '/activate/:activationToken', + isNotAuth, + catchError(authController.activate), +); + +authRouter.post( + '/login', + isNotAuth, + authLimiter, + validate(authValidation.login), + catchError(authController.login), +); +authRouter.post('/refresh', catchError(authController.refresh)); + +authRouter.post( + '/auth/exchange', + exchangeLimiter, + catchError(authController.exchangeCode), +); +authRouter.post('/logout', isAuth, catchError(authController.logout)); + +authRouter.post( + '/reset-password', + isNotAuth, + authLimiter, + validate(authValidation.requestPasswordReset), + catchError(authController.requestPasswordReset), +); + +authRouter.post( + '/reset-password/:passwordResetToken', + isNotAuth, + validate(authValidation.confirmPasswordReset), + catchError(authController.confirmPasswordReset), +); + +authRouter.get( + '/activate-new-email/:pendingEmailToken', + catchError(authController.confirmEmailChange), +); + +// Social auth — login / register +authRouter.get( + '/auth/google', + passport.authenticate('google', { scope: ['email'] }), +); + +authRouter.get( + '/auth/google/callback', + authController.socialCallback('google'), +); + +authRouter.get( + '/auth/facebook', + passport.authenticate('facebook', { scope: ['email'] }), +); + +authRouter.get( + '/auth/facebook/callback', + authController.socialCallback('facebook'), +); + +authRouter.get( + '/auth/github', + passport.authenticate('github', { scope: ['user:email'] }), +); + +authRouter.get( + '/auth/github/callback', + authController.socialCallback('github'), +); + +// Social auth — link to existing account +authRouter.get( + '/link/google', + storeLinkIntent, + passport.authenticate('google', { scope: ['profile', 'email'] }), +); + +authRouter.get( + '/link/facebook', + storeLinkIntent, + passport.authenticate('facebook', { scope: ['email'] }), +); + +authRouter.get( + '/link/github', + storeLinkIntent, + passport.authenticate('github', { scope: ['user:email'] }), +); diff --git a/src/routes/user.route.js b/src/routes/user.route.js new file mode 100644 index 00000000..e67a82bd --- /dev/null +++ b/src/routes/user.route.js @@ -0,0 +1,32 @@ +import express from 'express'; +import { userController } from '../controllers/user.controller.js'; +import { catchError } from '../middlewares/catch-error.middleware.js'; +import { validate } from '../middlewares/validate.middleware.js'; +import { authValidation } from '../validations/auth.validation.js'; + +export const userRouter = express.Router(); + +userRouter.get('/', catchError(userController.getProfile)); + +userRouter.patch( + '/', + validate(authValidation.updateName), + catchError(userController.updateName), +); + +userRouter.patch( + '/password', + validate(authValidation.updatePassword), + catchError(userController.updatePassword), +); + +userRouter.post( + '/change-email', + validate(authValidation.requestEmailChange), + catchError(userController.requestEmailChange), +); + +userRouter.delete( + '/social/:provider', + catchError(userController.unlinkSocialAccount), +); diff --git a/src/services/auth.service.js b/src/services/auth.service.js new file mode 100644 index 00000000..0f47f7bb --- /dev/null +++ b/src/services/auth.service.js @@ -0,0 +1,374 @@ +import { v4 as uuidv4 } from 'uuid'; +import bcrypt from 'bcrypt'; +import { jwtService } from './jwt.service.js'; +import { userService } from './user.service.js'; +import { emailService } from './email.service.js'; +import { tokenService } from './token.service.js'; +import { socialAccountService } from './social-account.service.js'; +import { ApiError } from '../utils/api.error.js'; +import { logger } from '../utils/logger.js'; + +const SUPPORTED_PROVIDERS = ['google', 'facebook', 'github']; + +const TOKEN_TTL = { + activation: 24 * 60 * 60 * 1000, + passwordReset: 60 * 60 * 1000, + pendingEmail: 24 * 60 * 60 * 1000, +}; + +const expiresIn = (ttlMs) => new Date(Date.now() + ttlMs); + +const register = async (name, email, password) => { + const existingUser = await userService.getByEmail(email); + + if (existingUser) { + throw ApiError.badRequest('User with this email already exists', { + email: 'User with this email already exists', + }); + } + + const hashedPassword = await bcrypt.hash(password, 10); + const activationToken = uuidv4(); + const activationExpiresAt = expiresIn(TOKEN_TTL.activation); + + await userService.register( + name, + email, + hashedPassword, + activationToken, + activationExpiresAt, + ); + await emailService.sendActivationEmail(name, email, activationToken); +}; + +const authenticate = async (user) => { + const normalizedUser = userService.normalize(user); + + const accessToken = jwtService.sign(normalizedUser); + const refreshToken = jwtService.signRefresh(normalizedUser); + + await tokenService.create(normalizedUser.id, refreshToken); + + return { + user: normalizedUser, + accessToken, + refreshToken, + }; +}; + +const activate = async (activationToken) => { + const user = await userService.getByActivationToken(activationToken); + + if (!user) { + logger.warn('Invalid activation token attempt'); + throw ApiError.badRequest(); + } + + if (user.activationExpiresAt && user.activationExpiresAt < new Date()) { + logger.warn('Expired activation token attempt'); + throw ApiError.badRequest('Activation link has expired'); + } + + return userService.activate(activationToken); +}; + +const login = async (email, password) => { + const user = await userService.getByEmail(email); + + if (!user || !user.password) { + logger.warn('Login attempt for non-existent or social-only user', { + email, + }); + throw ApiError.badRequest('Invalid email or password'); + } + + const isPasswordValid = await bcrypt.compare(password, user.password); + + if (!isPasswordValid) { + logger.warn('Login attempt with incorrect password', { + email, + }); + throw ApiError.badRequest('Invalid email or password'); + } + + if (user.activationToken) { + throw ApiError.forbidden('Account is not activated', { + activation: 'Account is not activated', + }); + } + + return user; +}; + +const logout = async (refreshToken) => { + if (!refreshToken) { + logger.warn('Logout attempt without refresh token'); + throw ApiError.unauthorized(); + } + + const userData = jwtService.verifyRefresh(refreshToken); + + if (!userData) { + throw ApiError.unauthorized(); + } + + await tokenService.removeByToken(refreshToken); +}; + +const refresh = async (refreshToken) => { + if (!refreshToken) { + logger.warn('Refresh attempt without refresh token'); + throw ApiError.unauthorized(); + } + + const userData = jwtService.verifyRefresh(refreshToken); + + if (!userData) { + logger.warn('Invalid refresh token attempt'); + throw ApiError.unauthorized(); + } + + const user = await userService.getByEmail(userData.email); + const token = await tokenService.getByToken(refreshToken); + + if (!user || !token || token.userId !== user.id) { + logger.warn('Invalid refresh token attempt'); + throw ApiError.unauthorized(); + } + + return user; +}; + +const requestPasswordReset = async (email) => { + const user = await userService.getByEmail(email); + + if (!user) { + logger.warn('Password reset email attempt for non-existent user', { + email, + }); + throw ApiError.badRequest(); + } + + if (user.activationToken) { + throw ApiError.badRequest('Account is not activated'); + } + + if (!user.password) { + throw ApiError.badRequest('This account uses social login'); + } + + if (user.passwordResetToken) { + logger.warn( + 'Password reset email attempt when there is already a pending reset', + { + email, + }, + ); + throw ApiError.badRequest( + 'There is already a pending password reset for this email', + ); + } + + const passwordResetToken = uuidv4(); + const passwordResetExpiresAt = expiresIn(TOKEN_TTL.passwordReset); + + await userService.setPasswordResetToken( + user.id, + passwordResetToken, + passwordResetExpiresAt, + ); + + await emailService.sendResetPasswordEmail( + user.name, + email, + passwordResetToken, + ); +}; + +const confirmPasswordReset = async (passwordResetToken, password) => { + if (!passwordResetToken) { + logger.warn('Password reset attempt without token'); + throw ApiError.badRequest(); + } + + const user = await userService.getByPasswordResetToken(passwordResetToken); + + if (!user) { + logger.warn('Invalid password reset token attempt'); + throw ApiError.badRequest(); + } + + if (user.passwordResetExpiresAt && user.passwordResetExpiresAt < new Date()) { + await userService.clearPasswordResetToken(user.id); + throw ApiError.badRequest('Password reset link has expired'); + } + + if (!user.password) { + throw ApiError.badRequest('Set a password first to reset your password'); + } + + const isNewEqualsOld = await bcrypt.compare(password, user.password); + + if (isNewEqualsOld) { + throw ApiError.badRequest('New password is the same as the old password'); + } + + const hashedPassword = await bcrypt.hash(password, 10); + + await userService.updatePassword(user.id, hashedPassword); + await userService.clearPasswordResetToken(user.id); + await tokenService.invalidateSessions(user.id); +}; + +const updateName = async (id, name) => { + const user = await userService.getById(id); + + if (!user) { + throw ApiError.notFound('User not found'); + } + + if (user.name === name) { + throw ApiError.badRequest( + 'New name must be different from the current name', + ); + } + + await userService.updateName(id, name); +}; + +const requestEmailChange = async (userId, newEmail, password) => { + const existingUser = await userService.getByEmail(newEmail); + + if (existingUser) { + throw ApiError.badRequest('Email already in use'); + } + + const user = await userService.getById(userId); + + if (!user) { + throw ApiError.notFound('User not found'); + } + + if (!user.password) { + throw ApiError.badRequest('Set a password first to change your email'); + } + + const isPasswordValid = await bcrypt.compare(password, user.password); + + if (!isPasswordValid) { + throw ApiError.badRequest('Incorrect password'); + } + + if (user.pendingEmail) { + throw ApiError.badRequest('There is already a pending email change'); + } + + const hasPendingEmail = await userService.getByPendingEmail(newEmail); + + if (hasPendingEmail) { + throw ApiError.badRequest('This email is already pending for another user'); + } + + const pendingEmailToken = uuidv4(); + const pendingEmailExpiresAt = expiresIn(TOKEN_TTL.pendingEmail); + + await userService.setPendingEmail( + userId, + newEmail, + pendingEmailToken, + pendingEmailExpiresAt, + ); + + await emailService.sendActivationNewEmail( + user.name, + newEmail, + pendingEmailToken, + ); +}; + +const confirmEmailChange = async (pendingEmailToken) => { + const user = await userService.getByPendingEmailToken(pendingEmailToken); + + if (!user || user.pendingEmailToken !== pendingEmailToken) { + logger.warn('Invalid email change activation attempt'); + throw ApiError.badRequest(); + } + + if (user.pendingEmailExpiresAt && user.pendingEmailExpiresAt < new Date()) { + await userService.clearPendingEmail(user.id); + throw ApiError.badRequest('Email change link has expired'); + } + + await userService.updateEmail(user.id, user.pendingEmail); + await emailService.sendChangeEmailNotification(user.name, user.email); + await tokenService.invalidateSessions(user.id); +}; + +const updatePassword = async (userId, password, newPassword) => { + const user = await userService.getById(userId); + + if (user.password) { + const isOldPasswordValid = await bcrypt.compare(password, user.password); + + if (!isOldPasswordValid) { + throw ApiError.badRequest('Incorrect old password'); + } + + if (password === newPassword) { + throw ApiError.badRequest( + 'New password must be different from the old password', + ); + } + } + + const hashedPassword = await bcrypt.hash(newPassword, 10); + + await userService.updatePassword(userId, hashedPassword); + await tokenService.invalidateSessions(userId); +}; + +const unlinkSocialAccount = async (userId, provider) => { + if (!SUPPORTED_PROVIDERS.includes(provider)) { + throw ApiError.badRequest('Unsupported provider'); + } + + const user = await userService.getById(userId); + + if (!user) { + throw ApiError.notFound('User not found'); + } + + const socialAccounts = await socialAccountService.getByUserId(userId); + const accountToRemove = socialAccounts.find((a) => a.provider === provider); + + if (!accountToRemove) { + throw ApiError.notFound('Social account not found'); + } + + const hasPassword = !!user.password; + const remainingSocials = socialAccounts.length - 1; + + if (!hasPassword && remainingSocials === 0) { + throw ApiError.badRequest( + 'Cannot remove last authentication method. Set a password first.', + ); + } + + await socialAccountService.remove(accountToRemove.id); +}; + +export const authService = { + register, + authenticate, + activate, + login, + logout, + refresh, + requestPasswordReset, + confirmPasswordReset, + updateName, + requestEmailChange, + confirmEmailChange, + updatePassword, + unlinkSocialAccount, +}; diff --git a/src/services/email.service.js b/src/services/email.service.js new file mode 100644 index 00000000..540e28c8 --- /dev/null +++ b/src/services/email.service.js @@ -0,0 +1,73 @@ +import nodemailer from 'nodemailer'; +import 'dotenv/config'; + +const transporter = nodemailer.createTransport({ + host: process.env.SMTP_HOST, + port: process.env.SMTP_PORT, + auth: { + user: process.env.SMTP_USER, + pass: process.env.SMTP_PASSWORD, + }, +}); + +const send = ({ email, subject, html }) => { + return transporter.sendMail({ + to: email, + subject, + html, + }); +}; + +const sendActivationEmail = (name, email, token) => { + const activationLink = `${process.env.CLIENT_HOST}/activate/${token}`; + const subject = 'Account Activation'; + const html = ` +
Please click the link below to activate your account:
+ ${activationLink} + `; + + return send({ email, subject, html }); +}; + +const sendResetPasswordEmail = (name, email, token) => { + const resetLink = `${process.env.CLIENT_HOST}/reset-password/${token}`; + const subject = 'Password Reset'; + const html = ` +Please click the link below to reset your password:
+ ${resetLink} + `; + + return send({ email, subject, html }); +}; + +const sendChangeEmailNotification = (name, email) => { + const subject = 'Email Change Notification'; + const html = ` +Your email has been successfully changed.
+ `; + + return send({ email, subject, html }); +}; + +const sendActivationNewEmail = (name, email, token) => { + const activationLink = `${process.env.CLIENT_HOST}/activate-new-email/${token}`; + const subject = 'New Email Activation'; + const html = ` +Please click the link below to activate your new email:
+ ${activationLink} + `; + + return send({ email, subject, html }); +}; + +export const emailService = { + send, + sendActivationEmail, + sendResetPasswordEmail, + sendChangeEmailNotification, + sendActivationNewEmail, +}; diff --git a/src/services/jwt.service.js b/src/services/jwt.service.js new file mode 100644 index 00000000..5b6c3a2a --- /dev/null +++ b/src/services/jwt.service.js @@ -0,0 +1,38 @@ +import jwt from 'jsonwebtoken'; + +const sign = (user) => { + return jwt.sign({ id: user.id, email: user.email }, process.env.JWT_SECRET, { + expiresIn: process.env.JWT_SECRET_EXPIRES_IN, + }); +}; + +const verify = (token) => { + try { + return jwt.verify(token, process.env.JWT_SECRET); + } catch (err) { + return null; + } +}; + +const signRefresh = (user) => { + return jwt.sign( + { id: user.id, email: user.email }, + process.env.JWT_REFRESH_SECRET, + { expiresIn: process.env.JWT_REFRESH_SECRET_EXPIRES_IN }, + ); +}; + +const verifyRefresh = (token) => { + try { + return jwt.verify(token, process.env.JWT_REFRESH_SECRET); + } catch (err) { + return null; + } +}; + +export const jwtService = { + sign, + verify, + signRefresh, + verifyRefresh, +}; diff --git a/src/services/social-account.service.js b/src/services/social-account.service.js new file mode 100644 index 00000000..50855205 --- /dev/null +++ b/src/services/social-account.service.js @@ -0,0 +1,28 @@ +import { db } from '../utils/db.js'; + +const getByProviderAndId = async (provider, providerId) => { + return db.socialAccount.findUnique({ + where: { provider_providerId: { provider, providerId } }, + }); +}; + +const getByUserId = async (userId) => { + return db.socialAccount.findMany({ where: { userId } }); +}; + +const create = async (userId, provider, providerId) => { + return db.socialAccount.create({ + data: { userId, provider, providerId }, + }); +}; + +const remove = async (id) => { + return db.socialAccount.delete({ where: { id } }); +}; + +export const socialAccountService = { + getByProviderAndId, + getByUserId, + create, + remove, +}; diff --git a/src/services/token.service.js b/src/services/token.service.js new file mode 100644 index 00000000..ef8168cb --- /dev/null +++ b/src/services/token.service.js @@ -0,0 +1,32 @@ +import { db } from '../utils/db.js'; + +const create = async (userId, newToken) => { + await db.token.upsert({ + where: { userId }, + update: { refreshToken: newToken }, + create: { userId, refreshToken: newToken }, + }); +}; + +const getByToken = async (refreshToken) => { + return db.token.findUnique({ + where: { refreshToken }, + }); +}; + +const removeByToken = async (refreshToken) => { + await db.token.deleteMany({ + where: { refreshToken }, + }); +}; + +const invalidateSessions = async (userId) => { + return db.token.deleteMany({ where: { userId } }); +}; + +export const tokenService = { + create, + getByToken, + removeByToken, + invalidateSessions, +}; diff --git a/src/services/user.service.js b/src/services/user.service.js new file mode 100644 index 00000000..074a83f4 --- /dev/null +++ b/src/services/user.service.js @@ -0,0 +1,177 @@ +import { ApiError } from '../utils/api.error.js'; +import { db } from '../utils/db.js'; +import { logger } from '../utils/logger.js'; + +const normalize = ({ id, name, email }) => { + return { + id, + name, + email, + }; +}; + +const register = async (name, email, password, activationToken, expiresAt) => { + await db.user.create({ + data: { + name, + email, + password, + activationToken, + activationExpiresAt: expiresAt, + }, + }); +}; + +const activate = async (activationToken) => { + return db.user.update({ + data: { activationToken: null, activationExpiresAt: null }, + where: { activationToken }, + }); +}; + +const getById = async (id) => { + return db.user.findUnique({ where: { id } }); +}; + +const getByEmail = async (email) => { + return db.user.findUnique({ where: { email } }); +}; + +const getProfile = async (userId) => { + const user = await db.user.findUnique({ + where: { id: userId }, + include: { socialAccounts: { select: { provider: true } } }, + }); + + if (!user) { + logger.warn('Get profile attempt for non-existent user', { userId }); + throw ApiError.notFound(); + } + + return { + ...normalize(user), + hasPassword: !!user.password, + socialAccounts: user.socialAccounts.map((a) => a.provider), + }; +}; + +const getByActivationToken = async (activationToken) => { + return db.user.findUnique({ + where: { activationToken }, + }); +}; + +const getByPasswordResetToken = async (passwordResetToken) => { + return db.user.findUnique({ + where: { passwordResetToken }, + }); +}; + +const updatePassword = async (id, newPassword) => { + await db.user.update({ + data: { password: newPassword }, + where: { id }, + }); +}; + +const setPasswordResetToken = async (id, passwordResetToken, expiresAt) => { + await db.user.update({ + data: { passwordResetToken, passwordResetExpiresAt: expiresAt }, + where: { id }, + }); +}; + +const clearPasswordResetToken = async (id) => { + await db.user.update({ + data: { passwordResetToken: null, passwordResetExpiresAt: null }, + where: { id }, + }); +}; + +const updateName = async (id, name) => { + await db.user.update({ + data: { name }, + where: { id }, + }); +}; + +const setPendingEmail = async (id, newEmail, pendingEmailToken, expiresAt) => { + await db.user.update({ + data: { + pendingEmail: newEmail, + pendingEmailToken, + pendingEmailExpiresAt: expiresAt, + }, + where: { id }, + }); +}; + +const updateEmail = async (id, pendingEmail) => { + await db.user.update({ + data: { + email: pendingEmail, + pendingEmail: null, + pendingEmailToken: null, + pendingEmailExpiresAt: null, + }, + where: { id }, + }); +}; + +const clearPendingEmail = async (id) => { + await db.user.update({ + data: { + pendingEmail: null, + pendingEmailToken: null, + pendingEmailExpiresAt: null, + }, + where: { id }, + }); +}; + +const getByPendingEmailToken = async (pendingEmailToken) => { + return db.user.findUnique({ + where: { pendingEmailToken }, + }); +}; + +const getByPendingEmail = async (pendingEmail) => { + return db.user.findUnique({ + where: { pendingEmail }, + }); +}; + +const createSocialUser = async (name, email) => { + return db.user.create({ + data: { name, email }, + }); +}; + +const clearActivation = async (id) => { + return db.user.update({ + where: { id }, + data: { activationToken: null, activationExpiresAt: null }, + }); +}; + +export const userService = { + normalize, + register, + activate, + getById, + getByEmail, + getProfile, + getByActivationToken, + getByPasswordResetToken, + updatePassword, + setPasswordResetToken, + clearPasswordResetToken, + setPendingEmail, + clearPendingEmail, + updateName, + updateEmail, + getByPendingEmailToken, + getByPendingEmail, + createSocialUser, + clearActivation, +}; diff --git a/src/utils/api.error.js b/src/utils/api.error.js new file mode 100644 index 00000000..c0197344 --- /dev/null +++ b/src/utils/api.error.js @@ -0,0 +1,27 @@ +export class ApiError extends Error { + constructor({ message, status, errors = {} }) { + super(message); + this.status = status; + this.errors = errors; + } + + static badRequest(message = 'Bad Request', errors = {}) { + return new ApiError({ message, status: 400, errors }); + } + + static unauthorized(message = 'Unauthorized', errors = {}) { + return new ApiError({ message, status: 401, errors }); + } + + static forbidden(message = 'Forbidden', errors = {}) { + return new ApiError({ message, status: 403, errors }); + } + + static notFound(message = 'Not Found', errors = {}) { + return new ApiError({ message, status: 404, errors }); + } + + static internal(message = 'Internal Server Error', errors = {}) { + return new ApiError({ message, status: 500, errors }); + } +} diff --git a/src/utils/db.js b/src/utils/db.js new file mode 100644 index 00000000..5fcad9a0 --- /dev/null +++ b/src/utils/db.js @@ -0,0 +1,9 @@ +import 'dotenv/config'; +import { PrismaPg } from '@prisma/adapter-pg'; +import { PrismaClient } from '@prisma/client'; + +const connectionString = `${process.env.DATABASE_URL}`; + +const adapter = new PrismaPg({ connectionString }); + +export const db = new PrismaClient({ adapter }); diff --git a/src/utils/logger.js b/src/utils/logger.js new file mode 100644 index 00000000..4a2fa20e --- /dev/null +++ b/src/utils/logger.js @@ -0,0 +1,30 @@ +/* eslint-disable no-console */ +export const logger = { + info: (msg, meta = {}) => + console.log( + JSON.stringify({ + level: 'info', + msg, + timestamp: new Date().toISOString(), + ...meta, + }), + ), + warn: (msg, meta = {}) => + console.warn( + JSON.stringify({ + level: 'warn', + msg, + timestamp: new Date().toISOString(), + ...meta, + }), + ), + error: (msg, meta = {}) => + console.error( + JSON.stringify({ + level: 'error', + msg, + timestamp: new Date().toISOString(), + ...meta, + }), + ), +}; diff --git a/src/utils/passport.js b/src/utils/passport.js new file mode 100644 index 00000000..eb82b83f --- /dev/null +++ b/src/utils/passport.js @@ -0,0 +1,132 @@ +import passport from 'passport'; +import { Strategy as GoogleStrategy } from 'passport-google-oauth20'; +import { Strategy as FacebookStrategy } from 'passport-facebook'; +import { Strategy as GitHubStrategy } from 'passport-github2'; +import { userService } from '../services/user.service.js'; +import { socialAccountService } from '../services/social-account.service.js'; + +const SERVER_HOST = + process.env.SERVER_HOST || `http://localhost:${process.env.PORT || 3005}`; + +const handleSocialAuth = (provider) => { + return async (req, accessToken, refreshToken, profile, done) => { + try { + const providerId = profile.id; + const email = profile.emails?.[0]?.value; + const displayName = profile.displayName || email?.split('@')[0] || 'User'; + const linkUserId = req.session?.linkUserId; + + if (linkUserId) { + delete req.session.linkUserId; + + const linkedAccount = await socialAccountService.getByProviderAndId( + provider, + providerId, + ); + + if (linkedAccount) { + if (linkedAccount.userId === linkUserId) { + return done(null, await userService.getById(linkUserId)); + } + + return done(null, false, { + message: 'This account is already linked to another user', + }); + } + + await socialAccountService.create(linkUserId, provider, providerId); + + return done(null, await userService.getById(linkUserId)); + } + + const existing = await socialAccountService.getByProviderAndId( + provider, + providerId, + ); + + if (existing) { + return done(null, await userService.getById(existing.userId)); + } + + let user = email ? await userService.getByEmail(email) : null; + + if (!user) { + if (!email) { + return done(null, false, { + message: 'Email is required for registration', + }); + } + + user = await userService.createSocialUser(displayName, email); + } else if (user.activationToken) { + user = await userService.clearActivation(user.id); + } + + await socialAccountService.create(user.id, provider, providerId); + + return done(null, user); + } catch (err) { + return done(err); + } + }; +}; + +if (process.env.GOOGLE_CLIENT_ID) { + passport.use( + new GoogleStrategy( + { + clientID: process.env.GOOGLE_CLIENT_ID, + clientSecret: process.env.GOOGLE_CLIENT_SECRET, + callbackURL: `${SERVER_HOST}/auth/google/callback`, + passReqToCallback: true, + }, + handleSocialAuth('google'), + ), + ); +} + +if (process.env.FACEBOOK_APP_ID) { + passport.use( + new FacebookStrategy( + { + clientID: process.env.FACEBOOK_APP_ID, + clientSecret: process.env.FACEBOOK_APP_SECRET, + callbackURL: `${SERVER_HOST}/auth/facebook/callback`, + profileFields: ['id', 'displayName', 'email'], + passReqToCallback: true, + }, + handleSocialAuth('facebook'), + ), + ); +} + +if (process.env.GITHUB_CLIENT_ID) { + passport.use( + new GitHubStrategy( + { + clientID: process.env.GITHUB_CLIENT_ID, + clientSecret: process.env.GITHUB_CLIENT_SECRET, + callbackURL: `${SERVER_HOST}/auth/github/callback`, + passReqToCallback: true, + scope: ['user:email'], + }, + handleSocialAuth('github'), + ), + ); +} + +passport.serializeUser((user, done) => { + done(null, user.id); +}); + +passport.deserializeUser(async (id, done) => { + try { + const user = await userService.getById(id); + + done(null, user); + } catch (err) { + done(err); + } +}); + +export default passport; diff --git a/src/validations/auth.validation.js b/src/validations/auth.validation.js new file mode 100644 index 00000000..5ca8eb5f --- /dev/null +++ b/src/validations/auth.validation.js @@ -0,0 +1,62 @@ +import { z } from 'zod'; + +const nameField = z.string().min(1, 'Name is required'); +const emailField = z.email('Email is not valid'); +const passwordField = z.string().min(6, 'At least 6 characters'); + +const registerSchema = z.object({ + name: nameField, + email: emailField, + password: passwordField, +}); + +const loginSchema = z.object({ + email: emailField, + password: passwordField, +}); + +const requestPasswordResetSchema = z.object({ + email: emailField, +}); + +const confirmPasswordResetSchema = z + .object({ + password: passwordField, + confirmation: passwordField, + }) + .refine((data) => data.password === data.confirmation, { + message: 'Confirmation and password do not match', + path: ['confirmation'], + }); + +const updateNameSchema = z.object({ + name: nameField, +}); + +const requestEmailChangeSchema = z.object({ + newEmail: emailField, + password: passwordField, +}); + +const updatePasswordSchema = z + .object({ + password: passwordField.optional(), + newPassword: passwordField, + confirmation: passwordField, + }) + .refine((data) => data.newPassword === data.confirmation, { + message: 'Confirmation and new password do not match', + path: ['confirmation'], + }); + +export const authValidation = { + register: { body: registerSchema }, + login: { body: loginSchema }, + requestPasswordReset: { body: requestPasswordResetSchema }, + confirmPasswordReset: { + body: confirmPasswordResetSchema, + }, + updateName: { body: updateNameSchema }, + requestEmailChange: { body: requestEmailChangeSchema }, + updatePassword: { body: updatePasswordSchema }, +};