Migrar backend a PostgreSQL + Node.js/Express con nuevas funcionalidades
Backend (water-api/): - Crear API REST completa con Express + TypeScript - Implementar autenticación JWT con refresh tokens - CRUD completo para: projects, concentrators, meters, gateways, devices, users, roles - Agregar validación con Zod para todas las entidades - Implementar webhooks para The Things Stack (LoRaWAN) - Agregar endpoint de lecturas con filtros y resumen de consumo - Implementar carga masiva de medidores via Excel (.xlsx) Frontend: - Crear cliente HTTP con manejo automático de JWT y refresh - Actualizar todas las APIs para usar nuevo backend - Agregar sistema de autenticación real (login, logout, me) - Agregar selector de tipo (LORA, LoRaWAN, Grandes) en concentradores y medidores - Agregar campo Meter ID en medidores - Crear modal de carga masiva para medidores - Agregar página de consumo con gráficas y filtros - Corregir carga de proyectos independiente de datos existentes Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
252
water-api/src/services/auth.service.ts
Normal file
252
water-api/src/services/auth.service.ts
Normal file
@@ -0,0 +1,252 @@
|
||||
import { query } from '../config/database';
|
||||
import {
|
||||
generateAccessToken,
|
||||
generateRefreshToken,
|
||||
verifyRefreshToken,
|
||||
} from '../utils/jwt';
|
||||
import { comparePassword } from '../utils/password';
|
||||
import crypto from 'crypto';
|
||||
|
||||
/**
|
||||
* Hash a token for storage
|
||||
*/
|
||||
function hashToken(token: string): string {
|
||||
return crypto.createHash('sha256').update(token).digest('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* Authentication service response types
|
||||
*/
|
||||
export interface AuthTokens {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
}
|
||||
|
||||
export interface UserProfile {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
role: string;
|
||||
avatarUrl?: string | null;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export interface LoginResult extends AuthTokens {
|
||||
user: UserProfile;
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticate user with email and password
|
||||
* Generates access and refresh tokens on successful login
|
||||
* Stores hashed refresh token in database
|
||||
* @param email - User email
|
||||
* @param password - User password
|
||||
* @returns Access and refresh tokens with user info
|
||||
*/
|
||||
export async function login(
|
||||
email: string,
|
||||
password: string
|
||||
): Promise<LoginResult> {
|
||||
// Find user by email with role name
|
||||
const userResult = await query<{
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
password_hash: string;
|
||||
avatar_url: string | null;
|
||||
role_name: string;
|
||||
created_at: Date;
|
||||
}>(
|
||||
`SELECT u.id, u.email, u.name, u.password_hash, u.avatar_url, r.name as role_name, u.created_at
|
||||
FROM users u
|
||||
JOIN roles r ON u.role_id = r.id
|
||||
WHERE LOWER(u.email) = LOWER($1) AND u.is_active = true
|
||||
LIMIT 1`,
|
||||
[email]
|
||||
);
|
||||
|
||||
const user = userResult.rows[0];
|
||||
|
||||
if (!user) {
|
||||
throw new Error('Invalid email or password');
|
||||
}
|
||||
|
||||
// Verify password
|
||||
const isValidPassword = await comparePassword(password, user.password_hash);
|
||||
|
||||
if (!isValidPassword) {
|
||||
throw new Error('Invalid email or password');
|
||||
}
|
||||
|
||||
// Generate tokens
|
||||
const accessToken = generateAccessToken({
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
role: user.role_name,
|
||||
});
|
||||
|
||||
const refreshToken = generateRefreshToken({
|
||||
id: user.id,
|
||||
});
|
||||
|
||||
// Hash and store refresh token
|
||||
const hashedRefreshToken = hashToken(refreshToken);
|
||||
const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); // 7 days
|
||||
|
||||
await query(
|
||||
`INSERT INTO refresh_tokens (user_id, token_hash, expires_at)
|
||||
VALUES ($1, $2, $3)`,
|
||||
[user.id, hashedRefreshToken, expiresAt]
|
||||
);
|
||||
|
||||
// Update last login
|
||||
await query(
|
||||
`UPDATE users SET last_login = NOW() WHERE id = $1`,
|
||||
[user.id]
|
||||
);
|
||||
|
||||
return {
|
||||
accessToken,
|
||||
refreshToken,
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
role: user.role_name,
|
||||
avatarUrl: user.avatar_url,
|
||||
createdAt: user.created_at,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh access token using a valid refresh token
|
||||
* Verifies the refresh token exists in database and is not expired
|
||||
* @param refreshToken - The refresh token
|
||||
* @returns New access token
|
||||
*/
|
||||
export async function refresh(refreshToken: string): Promise<{ accessToken: string }> {
|
||||
// Verify JWT signature
|
||||
const decoded = verifyRefreshToken(refreshToken);
|
||||
|
||||
if (!decoded) {
|
||||
throw new Error('Invalid refresh token');
|
||||
}
|
||||
|
||||
// Hash token to check against database
|
||||
const hashedToken = hashToken(refreshToken);
|
||||
|
||||
// Find token in database
|
||||
const tokenResult = await query<{
|
||||
id: string;
|
||||
expires_at: Date;
|
||||
}>(
|
||||
`SELECT id, expires_at FROM refresh_tokens
|
||||
WHERE token_hash = $1 AND user_id = $2 AND revoked_at IS NULL
|
||||
LIMIT 1`,
|
||||
[hashedToken, decoded.id]
|
||||
);
|
||||
|
||||
const storedToken = tokenResult.rows[0];
|
||||
|
||||
if (!storedToken) {
|
||||
throw new Error('Refresh token not found or revoked');
|
||||
}
|
||||
|
||||
// Check if token is expired
|
||||
if (new Date() > storedToken.expires_at) {
|
||||
// Clean up expired token
|
||||
await query(
|
||||
`DELETE FROM refresh_tokens WHERE id = $1`,
|
||||
[storedToken.id]
|
||||
);
|
||||
|
||||
throw new Error('Refresh token expired');
|
||||
}
|
||||
|
||||
// Get user data for new access token
|
||||
const userResult = await query<{
|
||||
id: string;
|
||||
email: string;
|
||||
role_name: string;
|
||||
}>(
|
||||
`SELECT u.id, u.email, r.name as role_name
|
||||
FROM users u
|
||||
JOIN roles r ON u.role_id = r.id
|
||||
WHERE u.id = $1 AND u.is_active = true
|
||||
LIMIT 1`,
|
||||
[decoded.id]
|
||||
);
|
||||
|
||||
const user = userResult.rows[0];
|
||||
|
||||
if (!user) {
|
||||
throw new Error('User not found');
|
||||
}
|
||||
|
||||
// Generate new access token
|
||||
const accessToken = generateAccessToken({
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
role: user.role_name,
|
||||
});
|
||||
|
||||
return { accessToken };
|
||||
}
|
||||
|
||||
/**
|
||||
* Logout user by revoking the specified refresh token
|
||||
* @param userId - The user ID
|
||||
* @param refreshToken - The refresh token to revoke
|
||||
*/
|
||||
export async function logout(
|
||||
userId: string,
|
||||
refreshToken: string
|
||||
): Promise<void> {
|
||||
const hashedToken = hashToken(refreshToken);
|
||||
|
||||
// Revoke the specific refresh token
|
||||
await query(
|
||||
`UPDATE refresh_tokens SET revoked_at = NOW()
|
||||
WHERE token_hash = $1 AND user_id = $2`,
|
||||
[hashedToken, userId]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get authenticated user's profile
|
||||
* @param userId - The user ID
|
||||
* @returns User profile data
|
||||
*/
|
||||
export async function getMe(userId: string): Promise<UserProfile> {
|
||||
const userResult = await query<{
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
avatar_url: string | null;
|
||||
role_name: string;
|
||||
created_at: Date;
|
||||
}>(
|
||||
`SELECT u.id, u.email, u.name, u.avatar_url, r.name as role_name, u.created_at
|
||||
FROM users u
|
||||
JOIN roles r ON u.role_id = r.id
|
||||
WHERE u.id = $1 AND u.is_active = true
|
||||
LIMIT 1`,
|
||||
[userId]
|
||||
);
|
||||
|
||||
const user = userResult.rows[0];
|
||||
|
||||
if (!user) {
|
||||
throw new Error('User not found');
|
||||
}
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
role: user.role_name,
|
||||
avatarUrl: user.avatar_url,
|
||||
createdAt: user.created_at,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user