feat: add JWT authentication

This commit is contained in:
Cesar
2026-05-22 17:13:24 -06:00
parent 397c2ef3df
commit 45d6347d4c
15 changed files with 524 additions and 12 deletions

View File

@@ -11,13 +11,17 @@
"dependencies": {
"@prisma/adapter-pg": "^7.8.0",
"@prisma/client": "^7.8.0",
"bcryptjs": "^3.0.3",
"dotenv": "^17.4.2",
"env-var": "^7.5.0",
"express": "^5.2.1",
"jsonwebtoken": "^9.0.3",
"pg": "^8.21.0"
},
"devDependencies": {
"@types/bcryptjs": "^2.4.6",
"@types/express": "^5.0.6",
"@types/jsonwebtoken": "^9.0.10",
"@types/node": "^25.9.1",
"@types/pg": "^8.20.0",
"prisma": "^7.8.0",
@@ -882,6 +886,13 @@
"devOptional": true,
"license": "MIT"
},
"node_modules/@types/bcryptjs": {
"version": "2.4.6",
"resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz",
"integrity": "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/body-parser": {
"version": "1.19.6",
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
@@ -935,6 +946,24 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/jsonwebtoken": {
"version": "9.0.10",
"resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz",
"integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/ms": "*",
"@types/node": "*"
}
},
"node_modules/@types/ms": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/node": {
"version": "25.9.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.1.tgz",
@@ -1051,6 +1080,15 @@
"node": "18 || 20 || >=22"
}
},
"node_modules/bcryptjs": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz",
"integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==",
"license": "BSD-3-Clause",
"bin": {
"bcrypt": "bin/bcrypt"
}
},
"node_modules/better-result": {
"version": "2.9.2",
"resolved": "https://registry.npmjs.org/better-result/-/better-result-2.9.2.tgz",
@@ -1095,6 +1133,12 @@
"node": "18 || 20 || >=22"
}
},
"node_modules/buffer-equal-constant-time": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
"license": "BSD-3-Clause"
},
"node_modules/bytes": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
@@ -1347,6 +1391,15 @@
"node": ">= 0.4"
}
},
"node_modules/ecdsa-sig-formatter": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
"license": "Apache-2.0",
"dependencies": {
"safe-buffer": "^5.0.1"
}
},
"node_modules/ee-first": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
@@ -1913,6 +1966,91 @@
"devOptional": true,
"license": "MIT"
},
"node_modules/jsonwebtoken": {
"version": "9.0.3",
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
"integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==",
"license": "MIT",
"dependencies": {
"jws": "^4.0.1",
"lodash.includes": "^4.3.0",
"lodash.isboolean": "^3.0.3",
"lodash.isinteger": "^4.0.4",
"lodash.isnumber": "^3.0.3",
"lodash.isplainobject": "^4.0.6",
"lodash.isstring": "^4.0.1",
"lodash.once": "^4.0.0",
"ms": "^2.1.1",
"semver": "^7.5.4"
},
"engines": {
"node": ">=12",
"npm": ">=6"
}
},
"node_modules/jwa": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
"integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
"license": "MIT",
"dependencies": {
"buffer-equal-constant-time": "^1.0.1",
"ecdsa-sig-formatter": "1.0.11",
"safe-buffer": "^5.0.1"
}
},
"node_modules/jws": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
"integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
"license": "MIT",
"dependencies": {
"jwa": "^2.0.1",
"safe-buffer": "^5.0.1"
}
},
"node_modules/lodash.includes": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
"integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
"license": "MIT"
},
"node_modules/lodash.isboolean": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
"integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
"license": "MIT"
},
"node_modules/lodash.isinteger": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
"integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==",
"license": "MIT"
},
"node_modules/lodash.isnumber": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",
"integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==",
"license": "MIT"
},
"node_modules/lodash.isplainobject": {
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
"integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
"license": "MIT"
},
"node_modules/lodash.isstring": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
"integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",
"license": "MIT"
},
"node_modules/lodash.once": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
"integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
"license": "MIT"
},
"node_modules/long": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz",
@@ -2584,6 +2722,26 @@
"node": ">= 18"
}
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
@@ -2598,6 +2756,18 @@
"license": "MIT",
"peer": true
},
"node_modules/semver": {
"version": "7.8.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz",
"integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/send": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz",

View File

@@ -13,7 +13,9 @@
"license": "ISC",
"type": "module",
"devDependencies": {
"@types/bcryptjs": "^2.4.6",
"@types/express": "^5.0.6",
"@types/jsonwebtoken": "^9.0.10",
"@types/node": "^25.9.1",
"@types/pg": "^8.20.0",
"prisma": "^7.8.0",
@@ -24,9 +26,11 @@
"dependencies": {
"@prisma/adapter-pg": "^7.8.0",
"@prisma/client": "^7.8.0",
"bcryptjs": "^3.0.3",
"dotenv": "^17.4.2",
"env-var": "^7.5.0",
"express": "^5.2.1",
"jsonwebtoken": "^9.0.3",
"pg": "^8.21.0"
}
}

View File

@@ -1,16 +1,15 @@
import express from 'express';
import { env } from "./config/env.js";
import { AppRoutes } from "./presentation/routes.js";
import { Server } from "./presentation/server.js";
const app = express();
const port = process.env.PORT ?? 3000;
app.use(express.json());
app.get('/', (_req, res) => {
res.json({
message: 'API funcionando con Express + TypeScript + TSX',
});
async function main() {
const server = new Server( {
port : env.PORT,
routes : AppRoutes.routes,
});
await server.start();
app.listen(port, () => {
console.log(`Servidor corriendo en http://localhost:${port}`);
});
}
main();

View File

@@ -0,0 +1,9 @@
import 'dotenv/config';
import envVar from 'env-var';
export const env = {
PORT: envVar.get('PORT').required().asPortNumber(),
PUBLIC_PATH: envVar.get('PUBLIC_PATH').default('public').asString(),
POSTGRES_URL: envVar.get('POSTGRES_URL').required().asString()
}

20
backend/src/config/jwt.ts Normal file
View File

@@ -0,0 +1,20 @@
import jwt, { SignOptions } from 'jsonwebtoken';
const JWT_SECRET = process.env.JWT_SECRET ?? 'dev_secret';
const JWT_EXPIRES_IN = process.env.JWT_EXPIRES_IN ?? '2h';
export class JwtAdapter {
static generateToken(payload: object): string {
return jwt.sign(payload, JWT_SECRET, {
expiresIn: JWT_EXPIRES_IN,
} as SignOptions);
}
static validateToken<T>(token: string): T | null {
try {
return jwt.verify(token, JWT_SECRET) as T;
} catch {
return null;
}
}
}

View File

@@ -0,0 +1,9 @@
import { env } from "../../config/env.js";
import { PrismaPg } from "@prisma/adapter-pg";
import { PrismaClient } from "../../generated/prisma/client.js";
const connectionString = `${env.POSTGRES_URL}`;
const adapter = new PrismaPg({ connectionString });
export const prisma = new PrismaClient({ adapter });

View File

@@ -0,0 +1,15 @@
export class LoginUserDto {
private constructor(
public readonly email: string,
public readonly password: string,
) {}
static create(props: { [key: string]: any }): [string | undefined, LoginUserDto?] {
const { email, password } = props;
if (!email) return ['email is required'];
if (!password) return ['password is required'];
return [undefined, new LoginUserDto(email, password)];
}
}

View File

@@ -0,0 +1,21 @@
export class RegisterUserDto {
private constructor(
public readonly name: string,
public readonly email: string,
public readonly password: string,
) {}
static create(props: { [key: string]: any }): [string | undefined, RegisterUserDto?] {
const { name, email, password } = props;
if (!name) return ['name is required'];
if (!email) return ['email is required'];
if (!password) return ['password is required'];
if (password.length < 6) {
return ['password must be at least 6 characters'];
}
return [undefined, new RegisterUserDto(name, email, password)];
}
}

View File

@@ -0,0 +1,2 @@
export * from './auth/register-user.dto.js';
export * from './auth/login-user.dto.js';

View File

@@ -0,0 +1,137 @@
import { Request, Response } from "express";
import bcrypt from "bcryptjs";
import { prisma } from "../../data/postgres/index.js";
import { JwtAdapter } from "../../config/jwt.js";
import { LoginUserDto, RegisterUserDto } from "../../domain/dtos/index.js";
import { AuthRequest } from "../middlewares/auth.middleware.js";
export class AuthController {
// SIN metodos estaticos para hacer DI
constructor() {}
public registerUser = async (req: Request, res: Response) => {
const [error, registerUserDto] = RegisterUserDto.create(req.body);
if (error) {
return res.status(400).json({ error });
}
try {
const userExists = await prisma.user.findUnique({
where: {
email: registerUserDto!.email,
}
});
if (userExists) {
return res.status(400).json({
error: 'Email already exists'
});
}
const hashedPassword = bcrypt.hashSync(registerUserDto!.password, 10);
const user = await prisma.user.create({
data: {
name: registerUserDto!.name,
email: registerUserDto!.email,
password: hashedPassword,
}
});
const token = JwtAdapter.generateToken({
id: user.id,
email: user.email,
role: user.role,
});
return res.status(201).json({
user: {
id: user.id,
name: user.name,
email: user.email,
role: user.role,
},
token
});
} catch (error) {
console.log(error);
return res.status(500).json({
error: 'Internal server error'
});
}
}
public loginUser = async (req: Request, res: Response) => {
const [error, loginUserDto] = LoginUserDto.create(req.body);
if (error) {
return res.status(400).json({ error });
}
try {
const user = await prisma.user.findUnique({
where: {
email: loginUserDto!.email,
}
});
if (!user) {
return res.status(400).json({
error: 'Invalid email or password'
});
}
const isPasswordValid = bcrypt.compareSync(
loginUserDto!.password,
user.password
);
if (!isPasswordValid) {
return res.status(400).json({
error: 'Invalid email or password'
});
}
const token = JwtAdapter.generateToken({
id: user.id,
email: user.email,
role: user.role,
});
return res.json({
user: {
id: user.id,
name: user.name,
email: user.email,
role: user.role,
},
token
});
} catch (error) {
console.log(error);
return res.status(500).json({
error: 'Internal server error'
});
}
}
public getMe = async (req: AuthRequest, res: Response) => {
if (!req.user) {
return res.status(401).json({
error: 'User not authenticated'
});
}
return res.json({
user: req.user
});
}
}

View File

@@ -0,0 +1,20 @@
import { Router } from "express";
import { AuthController } from "./controller.js";
import { AuthMiddleware } from "../middlewares/auth.middleware.js";
export class AuthRoutes {
static get routes(): Router {
const router = Router();
const controller = new AuthController();
router.post('/register', controller.registerUser);
router.post('/login', controller.loginUser);
// Ruta protegida para probar JWT
router.get('/me', AuthMiddleware.validateJwt, controller.getMe);
return router;
}
}

View File

@@ -0,0 +1,46 @@
import { Request, Response, NextFunction } from "express";
import { JwtAdapter } from "../../config/jwt.js";
interface TokenPayload {
id: number;
email: string;
role: string;
}
export interface AuthRequest extends Request {
user?: TokenPayload;
}
export class AuthMiddleware {
static validateJwt = (req: AuthRequest, res: Response, next: NextFunction) => {
const authorization = req.header('Authorization');
if (!authorization) {
return res.status(401).json({
error: 'No token provided'
});
}
if (!authorization.startsWith('Bearer ')) {
return res.status(401).json({
error: 'Invalid Bearer token'
});
}
const token = authorization.split(' ').at(1) ?? '';
const payload = JwtAdapter.validateToken<TokenPayload>(token);
if (!payload) {
return res.status(401).json({
error: 'Invalid token'
});
}
req.user = payload;
next();
}
}

View File

@@ -0,0 +1,14 @@
import { Router } from "express";
import { AuthRoutes } from "./auth/routes.js";
export class AppRoutes {
static get routes(): Router {
const router = Router();
router.use('/api/auth', AuthRoutes.routes);
return router;
}
}

View File

@@ -0,0 +1,46 @@
import express, { Router } from 'express';
interface Options {
port: number;
routes: Router;
}
export class Server {
private app = express();
private readonly port: number;
private readonly routes: Router;
constructor (options : Options) {
const {port, routes} = options;
this.port = port;
this.routes = routes;
}
async start() {
/*middleware*/
// para recebir el body como json
this.app.use(express.json());
// para recebir el body como urlencoded
this.app.use(express.urlencoded({ extended: true }));
//* Public folder
// Routes
this.app.use(this.routes);
// ponner servido a escuchar en el puerto 8080
this.app.listen(this.port, () => {
console.log(`Server is running on port ${this.port}`);
});
}
}