corregido refreshtoken y mejorado ver informacion ui por roles
This commit is contained in:
2
apps/api/src/database/migrations/0017_mute_mole_man.sql
Normal file
2
apps/api/src/database/migrations/0017_mute_mole_man.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE "auth"."sessions" ADD COLUMN "previous_session_token" varchar;--> statement-breakpoint
|
||||
ALTER TABLE "auth"."sessions" ADD COLUMN "last_rotated_at" timestamp;
|
||||
2006
apps/api/src/database/migrations/meta/0017_snapshot.json
Normal file
2006
apps/api/src/database/migrations/meta/0017_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -120,6 +120,13 @@
|
||||
"when": 1769653021994,
|
||||
"tag": "0016_silent_tag",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 17,
|
||||
"version": "7",
|
||||
"when": 1770774052351,
|
||||
"tag": "0017_mute_mole_man",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,9 +1,8 @@
|
||||
import * as t from 'drizzle-orm/pg-core';
|
||||
import { sql } from 'drizzle-orm';
|
||||
import { authSchema } from './schemas';
|
||||
import * as t from 'drizzle-orm/pg-core';
|
||||
import { timestamps } from '../timestamps';
|
||||
import { states, municipalities, parishes } from './general';
|
||||
|
||||
import { municipalities, parishes, states } from './general';
|
||||
import { authSchema } from './schemas';
|
||||
|
||||
// Tabla de Usuarios sistema
|
||||
export const users = authSchema.table(
|
||||
@@ -15,9 +14,15 @@ export const users = authSchema.table(
|
||||
fullname: t.text('fullname').notNull(),
|
||||
phone: t.text('phone'),
|
||||
password: t.text('password').notNull(),
|
||||
state: t.integer('state').references(() => states.id, { onDelete: 'set null' }),
|
||||
municipality: t.integer('municipality').references(() => municipalities.id, { onDelete: 'set null' }),
|
||||
parish: t.integer('parish').references(() => parishes.id, { onDelete: 'set null' }),
|
||||
state: t
|
||||
.integer('state')
|
||||
.references(() => states.id, { onDelete: 'set null' }),
|
||||
municipality: t
|
||||
.integer('municipality')
|
||||
.references(() => municipalities.id, { onDelete: 'set null' }),
|
||||
parish: t
|
||||
.integer('parish')
|
||||
.references(() => parishes.id, { onDelete: 'set null' }),
|
||||
isTwoFactorEnabled: t
|
||||
.boolean('is_two_factor_enabled')
|
||||
.notNull()
|
||||
@@ -32,7 +37,6 @@ export const users = authSchema.table(
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
// Tabla de Roles
|
||||
export const roles = authSchema.table(
|
||||
'roles',
|
||||
@@ -46,8 +50,6 @@ export const roles = authSchema.table(
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
|
||||
//tabla User_roles
|
||||
export const usersRole = authSchema.table(
|
||||
'user_role',
|
||||
@@ -88,7 +90,6 @@ LEFT JOIN
|
||||
LEFT JOIN
|
||||
auth.roles r ON ur.role_id = r.id`);
|
||||
|
||||
|
||||
// Tabla de Sesiones
|
||||
export const sessions = authSchema.table(
|
||||
'sessions',
|
||||
@@ -103,6 +104,9 @@ export const sessions = authSchema.table(
|
||||
.notNull(),
|
||||
sessionToken: t.text('session_token').notNull(),
|
||||
expiresAt: t.integer('expires_at').notNull(),
|
||||
previousSessionToken: t.varchar('previous_session_token'),
|
||||
lastRotatedAt: t.timestamp('last_rotated_at'),
|
||||
|
||||
...timestamps,
|
||||
},
|
||||
(sessions) => ({
|
||||
@@ -110,8 +114,6 @@ export const sessions = authSchema.table(
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
|
||||
//tabla de tokens de verificación
|
||||
export const verificationTokens = authSchema.table(
|
||||
'verificationToken',
|
||||
|
||||
@@ -1,20 +1,9 @@
|
||||
// api/src/feacture/auth/auth.controller.ts
|
||||
import { Public } from '@/common/decorators';
|
||||
import { JwtRefreshGuard } from '@/common/guards/jwt-refresh.guard';
|
||||
import { RefreshTokenDto } from '@/features/auth/dto/refresh-token.dto';
|
||||
import { SignInUserDto } from '@/features/auth/dto/signIn-user.dto';
|
||||
import { SignOutUserDto } from '@/features/auth/dto/signOut-user.dto';
|
||||
import { SingUpUserDto } from '@/features/auth/dto/signUp-user.dto';
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
HttpCode,
|
||||
Patch,
|
||||
Post,
|
||||
Req,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { Body, Controller, HttpCode, Patch, Post } from '@nestjs/common';
|
||||
import { AuthService } from './auth.service';
|
||||
|
||||
@Controller('auth')
|
||||
@@ -58,17 +47,7 @@ export class AuthController {
|
||||
@Patch('refresh')
|
||||
//@RequirePermissions('auth:refresh-token')
|
||||
async refreshToken(@Body() refreshTokenDto: any) {
|
||||
|
||||
console.log('refreshTokenDto', refreshTokenDto);
|
||||
|
||||
const data = await this.authService.refreshToken(refreshTokenDto);
|
||||
|
||||
// console.log('data', data);
|
||||
|
||||
|
||||
if (!data) return null;
|
||||
|
||||
return {tokens: data}
|
||||
return await this.authService.refreshToken(refreshTokenDto);
|
||||
}
|
||||
|
||||
// @Public()
|
||||
|
||||
@@ -27,7 +27,7 @@ import { ConfigService } from '@nestjs/config';
|
||||
import { JwtService, JwtSignOptions } from '@nestjs/jwt';
|
||||
import * as bcrypt from 'bcryptjs';
|
||||
import crypto from 'crypto';
|
||||
import { and, eq, or } from 'drizzle-orm';
|
||||
import { eq, or } from 'drizzle-orm';
|
||||
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
|
||||
import * as schema from 'src/database/index';
|
||||
import { roles, sessions, users, usersRole } from 'src/database/index';
|
||||
@@ -273,48 +273,116 @@ export class AuthService {
|
||||
|
||||
//Refresh User Access Token
|
||||
async refreshToken(dto: RefreshTokenDto): Promise<RefreshTokenInterface> {
|
||||
const secret = envs.refresh_token_secret;
|
||||
const { user_id, token } = dto;
|
||||
const { refreshToken } = dto;
|
||||
|
||||
console.log('secret', secret);
|
||||
console.log('refresh_token', token);
|
||||
// 1. Validar firma del token (Crypto check)
|
||||
let payload: any;
|
||||
try {
|
||||
payload = await this.jwtService.verifyAsync(refreshToken, {
|
||||
secret: envs.refresh_token_secret,
|
||||
});
|
||||
} catch (e) {
|
||||
throw new UnauthorizedException('Invalid Refresh Token Signature');
|
||||
}
|
||||
|
||||
const validation = await this.jwtService.verifyAsync(token, {
|
||||
secret,
|
||||
});
|
||||
const userId = Number(payload.sub); // Es más seguro usar el ID del token que el del DTO
|
||||
|
||||
if (!validation) throw new UnauthorizedException('Invalid refresh token');
|
||||
|
||||
const session = await this.drizzle
|
||||
// 2. Buscar la sesión por UserID (SIN filtrar por token todavía)
|
||||
// Esto es clave: traemos la sesión para ver qué está pasando
|
||||
const [currentSession] = await this.drizzle
|
||||
.select()
|
||||
.from(sessions)
|
||||
.where(
|
||||
and(eq(sessions.userId, user_id), eq(sessions.sessionToken, token)),
|
||||
.where(eq(sessions.userId, userId));
|
||||
|
||||
if (!currentSession) throw new NotFoundException('Session not found');
|
||||
|
||||
// CONFIGURACIÓN: Tiempo de gracia en milisegundos (ej: 15 segundos)
|
||||
const GRACE_PERIOD_MS = 15000;
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// ESCENARIO A: Rotación Normal (El token coincide con el actual)
|
||||
// -------------------------------------------------------------------
|
||||
if (currentSession.sessionToken === refreshToken) {
|
||||
const user = await this.findUserById(userId);
|
||||
if (!user) throw new NotFoundException('User not found');
|
||||
|
||||
// Generar nuevos tokens (A -> B)
|
||||
const tokensNew = await this.generateTokens(user);
|
||||
const decodeAccess = this.decodeToken(tokensNew.access_token);
|
||||
const decodeRefresh = this.decodeToken(tokensNew.refresh_token);
|
||||
|
||||
// Actualizamos DB guardando el token "viejo" como "previous"
|
||||
await this.drizzle
|
||||
.update(sessions)
|
||||
.set({
|
||||
sessionToken: tokensNew.refresh_token, // Nuevo (B)
|
||||
previousSessionToken: refreshToken, // Viejo (A)
|
||||
lastRotatedAt: new Date(), // Marca de tiempo
|
||||
expiresAt: decodeRefresh.exp,
|
||||
})
|
||||
.where(eq(sessions.userId, userId));
|
||||
|
||||
return {
|
||||
access_token: tokensNew.access_token,
|
||||
access_expire_in: decodeAccess.exp,
|
||||
refresh_token: tokensNew.refresh_token,
|
||||
refresh_expire_in: decodeRefresh.exp,
|
||||
};
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// ESCENARIO B: Periodo de Gracia (Condición de Carrera)
|
||||
// -------------------------------------------------------------------
|
||||
// El token no coincide con el actual, ¿pero coincide con el anterior?
|
||||
const isPreviousToken =
|
||||
currentSession.previousSessionToken === refreshToken;
|
||||
|
||||
// Calculamos cuánto tiempo ha pasado desde la rotación
|
||||
const timeSinceRotation = currentSession.lastRotatedAt
|
||||
? Date.now() - new Date(currentSession.lastRotatedAt).getTime()
|
||||
: Infinity;
|
||||
|
||||
if (isPreviousToken && timeSinceRotation < GRACE_PERIOD_MS) {
|
||||
// ¡Es una condición de carrera! El usuario envió 'A' pero ya rotamos a 'B'.
|
||||
// Le devolvemos 'B' (el actual en DB) para que se sincronice.
|
||||
|
||||
const user = await this.findUserById(userId);
|
||||
|
||||
if (!user) throw new NotFoundException('User not found');
|
||||
|
||||
// Generamos un access token nuevo fresco (barato)
|
||||
const accessTokenPayload = { sub: user.id, username: user.username };
|
||||
const newAccessToken = await this.jwtService.signAsync(
|
||||
accessTokenPayload,
|
||||
{
|
||||
secret: envs.access_token_secret,
|
||||
expiresIn: envs.access_token_expiration,
|
||||
} as JwtSignOptions,
|
||||
);
|
||||
const decodeAccess = this.decodeToken(newAccessToken);
|
||||
|
||||
// console.log(session.length);
|
||||
// IMPORTANTE: Devolvemos el refresh token QUE YA ESTÁ EN LA BASE DE DATOS
|
||||
// No generamos uno nuevo para no romper la cadena de la otra petición que ganó.
|
||||
return {
|
||||
access_token: newAccessToken,
|
||||
access_expire_in: decodeAccess.exp,
|
||||
refresh_token: currentSession.sessionToken!, // Devolvemos el token 'B'
|
||||
refresh_expire_in: currentSession.expiresAt as number,
|
||||
};
|
||||
}
|
||||
|
||||
if (session.length === 0) throw new NotFoundException('session not found');
|
||||
const user = await this.findUserById(user_id);
|
||||
if (!user) throw new NotFoundException('User not found');
|
||||
// -------------------------------------------------------------------
|
||||
// ESCENARIO C: Robo de Token (Reuse Detection)
|
||||
// -------------------------------------------------------------------
|
||||
// Si el token no es el actual, ni el anterior válido... ALGUIEN LO ROBÓ.
|
||||
// O el usuario está intentando reusar un token muy viejo.
|
||||
|
||||
// Genera token
|
||||
const tokens = await this.generateTokens(user);
|
||||
const decodeAccess = this.decodeToken(tokens.access_token);
|
||||
const decodeRefresh = this.decodeToken(tokens.refresh_token);
|
||||
// Medida de seguridad: Borrar todas las sesiones del usuario
|
||||
await this.drizzle.delete(sessions).where(eq(sessions.userId, userId));
|
||||
|
||||
// Actualiza session
|
||||
await this.drizzle
|
||||
.update(sessions)
|
||||
.set({ sessionToken: tokens.refresh_token, expiresAt: decodeRefresh.exp })
|
||||
.where(eq(sessions.userId, user_id));
|
||||
|
||||
return {
|
||||
access_token: tokens.access_token,
|
||||
access_expire_in: decodeAccess.exp,
|
||||
refresh_token: tokens.refresh_token,
|
||||
refresh_expire_in: decodeRefresh.exp,
|
||||
};
|
||||
throw new UnauthorizedException(
|
||||
'Refresh token reuse detected. Access revoked.',
|
||||
);
|
||||
}
|
||||
|
||||
async singUp(createUserDto: SingUpUserDto): Promise<User> {
|
||||
|
||||
@@ -7,9 +7,9 @@ export class RefreshTokenDto {
|
||||
@IsString({
|
||||
message: 'Refresh token must be a string',
|
||||
})
|
||||
token: string;
|
||||
refreshToken: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsNumber()
|
||||
user_id: number;
|
||||
userId: number;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user