feat(auth): add cookie parsing and JWT auth guard to backoffice

- Add JWT auth guard with Bearer token and cookie support
- Update .env.example files with PASSWORD_PEPPER documentation
- Update seeder to use PASSWORD_PEPPER for password hashing
- Update seeder README with hash verification examples
- Fix frontend auth and page components
- Update backend JWT service and seed migration
This commit is contained in:
Tiago Yamamoto 2025-12-24 10:27:04 -03:00
parent 02f35b46b6
commit 340911b4d1
15 changed files with 133 additions and 13 deletions

View file

@ -18,7 +18,10 @@ S3_BUCKET=your-bucket-name
# JWT Authentication
# =============================================================================
JWT_SECRET=change-this-to-a-strong-secret-at-least-32-characters
JWT_EXPIRATION=7d
PASSWORD_PEPPER=some-random-string-for-password-hashing
COOKIE_SECRET=change-this-to-something-secure
COOKIE_DOMAIN=localhost
# =============================================================================
# Server Configuration
# =============================================================================

View file

@ -3,6 +3,8 @@ package auth
import (
"errors"
"os"
"strconv"
"strings"
"time"
"github.com/golang-jwt/jwt/v5"
@ -41,12 +43,36 @@ func (s *JWTService) VerifyPassword(hash, password string) bool {
}
func (s *JWTService) GenerateToken(userID, tenantID string, roles []string) (string, error) {
// Parse expiration from env
expirationStr := os.Getenv("JWT_EXPIRATION")
var expirationDuration time.Duration
var err error
if expirationStr == "" {
expirationDuration = time.Hour * 24 // Default 24h
} else {
if strings.HasSuffix(expirationStr, "d") {
daysStr := strings.TrimSuffix(expirationStr, "d")
days, err := strconv.Atoi(daysStr)
if err == nil {
expirationDuration = time.Hour * 24 * time.Duration(days)
} else {
expirationDuration = time.Hour * 24 // Fallback
}
} else {
expirationDuration, err = time.ParseDuration(expirationStr)
if err != nil {
expirationDuration = time.Hour * 24 // Fallback
}
}
}
claims := jwt.MapClaims{
"sub": userID,
"tenant": tenantID,
"roles": roles,
"iss": s.issuer,
"exp": time.Now().Add(time.Hour * 24).Unix(), // 24 hours
"exp": time.Now().Add(expirationDuration).Unix(),
"iat": time.Now().Unix(),
}

View file

@ -15,7 +15,9 @@ VALUES (
) ON CONFLICT (id) DO NOTHING;
-- 2. Insert Super Admin User
-- Password: "password123" (BCrypt hash)
-- WARNING: This hash is generated WITHOUT PASSWORD_PEPPER.
-- For development only. Use seeder-api for proper user creation.
-- Password: "password123" (BCrypt hash without pepper)
INSERT INTO core_users (id, tenant_id, name, email, password_hash, status, created_at, updated_at)
VALUES (
'00000000-0000-0000-0000-000000000002', -- Fixed SuperAdmin User ID

View file

@ -23,11 +23,20 @@ STRIPE_PUBLISHABLE_KEY=pk_test_your_publishable_key
DATABASE_URL=postgresql://user:password@localhost:5432/gohorse_backoffice
# =============================================================================
# JWT
# JWT Authentication (Shared with Backend)
# =============================================================================
# The backoffice validates JWT tokens issued by the backend.
# These values MUST match the backend configuration for auth to work.
# MUST match backend/.env JWT_SECRET exactly
JWT_SECRET=your-super-secret-jwt-key
# Token expiration (should match backend for consistency)
JWT_EXPIRATION=7d
# NOTE: PASSWORD_PEPPER is NOT needed here.
# The backoffice does not handle login - it only validates tokens via Bearer header or cookie.
# =============================================================================
# Cloudflare API (for cache management)
# =============================================================================

View file

@ -5,10 +5,12 @@ import { AppService } from './app.service';
import { StripeModule } from './stripe';
import { PlansModule } from './plans';
import { AdminModule } from './admin';
import { AuthModule } from './auth';
@Module({
imports: [
ConfigModule.forRoot({ isGlobal: true, envFilePath: '.env' }),
AuthModule,
StripeModule,
PlansModule,
AdminModule,
@ -16,4 +18,4 @@ import { AdminModule } from './admin';
controllers: [AppController],
providers: [AppService],
})
export class AppModule {}
export class AppModule { }

View file

@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { JwtAuthGuard } from './jwt-auth.guard';
@Module({
imports: [ConfigModule],
providers: [JwtAuthGuard],
exports: [JwtAuthGuard],
})
export class AuthModule { }

View file

@ -0,0 +1,2 @@
export * from './auth.module';
export * from './jwt-auth.guard';

View file

@ -0,0 +1,51 @@
import {
Injectable,
CanActivate,
ExecutionContext,
UnauthorizedException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import * as jwt from 'jsonwebtoken';
@Injectable()
export class JwtAuthGuard implements CanActivate {
constructor(private readonly configService: ConfigService) { }
canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest();
const token = this.extractToken(request);
if (!token) {
throw new UnauthorizedException('Missing authentication token');
}
try {
const secret = this.configService.get<string>('JWT_SECRET');
if (!secret) {
throw new UnauthorizedException('JWT secret not configured');
}
const payload = jwt.verify(token, secret);
request.user = payload;
return true;
} catch {
throw new UnauthorizedException('Invalid or expired token');
}
}
private extractToken(request: any): string | null {
// 1. Try Authorization header first
const authHeader = request.headers?.authorization;
if (authHeader && authHeader.startsWith('Bearer ')) {
return authHeader.slice(7);
}
// 2. Fallback to cookie
const cookies = request.cookies;
if (cookies?.jwt) {
return cookies.jwt;
}
return null;
}
}

View file

@ -8,6 +8,7 @@ import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
import { AppModule } from './app.module';
import compression from '@fastify/compress';
import helmet from '@fastify/helmet';
import cookie from '@fastify/cookie';
async function bootstrap() {
// Create Fastify adapter with Pino logging
@ -34,6 +35,7 @@ async function bootstrap() {
await app.register(helmet, {
contentSecurityPolicy: process.env.NODE_ENV === 'production',
});
await app.register(cookie); // Enable cookie parsing for JWT auth
// CORS configuration (Fastify-native)
app.enableCors({

View file

@ -39,7 +39,7 @@ export default function HomePage() {
isFeatured: j.isFeatured
}))
const featuredRes = await fetch(`${apiBase}/jobs?featured=true&limit=6`)
const featuredRes = await fetch(`${apiBase}/api/v1/jobs?featured=true&limit=6`)
if (!featuredRes.ok) throw new Error("Failed to fetch featured jobs")
const featuredData = await featuredRes.json()
const featuredList = featuredData.data ? mapJobs(featuredData.data) : []
@ -49,7 +49,7 @@ export default function HomePage() {
return
}
const fallbackRes = await fetch(`${apiBase}/jobs?limit=6`)
const fallbackRes = await fetch(`${apiBase}/api/v1/jobs?limit=6`)
if (!fallbackRes.ok) throw new Error("Failed to fetch fallback jobs")
const fallbackData = await fallbackRes.json()
const fallbackList = fallbackData.data ? mapJobs(fallbackData.data) : []

View file

@ -2,7 +2,8 @@ import { User } from "./types";
export type { User };
const AUTH_KEY = "job-portal-auth";
const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8521/api/v1";
const BASE_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8521";
const API_URL = `${BASE_URL}/api/v1`;
interface LoginResponse {
token: string;

View file

@ -17,3 +17,6 @@ NODE_ENV=development
# Backend API URL (to sync data)
BACKEND_API_URL=http://localhost:8521/api/v1
# MUST match backend PASSWORD_PEPPER for login to work
PASSWORD_PEPPER=some-random-string-for-password-hashing

View file

@ -105,8 +105,13 @@ DB_PORT=5432
DB_USER=postgres
DB_PASSWORD=yourpassword
DB_NAME=gohorsejobs
# MUST match backend PASSWORD_PEPPER
PASSWORD_PEPPER=your-pepper-value
```
> ⚠️ **IMPORTANTE**: O `PASSWORD_PEPPER` deve ser idêntico ao configurado no backend. Caso contrário, os usuários criados pelo seeder não conseguirão fazer login.
### Comandos
| Comando | Descrição |

View file

@ -244,7 +244,8 @@ export async function seedWileECoyote() {
try {
const bcrypt = await import('bcrypt');
const hash = await bcrypt.default.hash('MeepMeep@123', 10);
const pepper = process.env.PASSWORD_PEPPER || '';
const hash = await bcrypt.default.hash('MeepMeep@123' + pepper, 10);
await pool.query(`
INSERT INTO users (identifier, password_hash, role, full_name, email, city, state, bio, title)

View file

@ -3,6 +3,9 @@ import bcrypt from 'bcrypt';
import crypto from 'crypto';
import { pool } from '../db.js';
// Get pepper from environment - MUST match backend PASSWORD_PEPPER
const PASSWORD_PEPPER = process.env.PASSWORD_PEPPER || '';
export async function seedUsers() {
console.log('👤 Seeding users (Core Architecture)...');
@ -14,7 +17,7 @@ export async function seedUsers() {
const systemTenantId = '00000000-0000-0000-0000-000000000000';
// 1. Create SuperAdmin (Requested: superadmin / Admin@2025!)
const superAdminPassword = await bcrypt.hash('Admin@2025!', 10);
const superAdminPassword = await bcrypt.hash('Admin@2025!' + PASSWORD_PEPPER, 10);
const superAdminId = crypto.randomUUID();
// User requested identifier 'superadmin'
const superAdminIdentifier = 'superadmin';
@ -47,7 +50,7 @@ export async function seedUsers() {
];
for (const admin of companyAdmins) {
const hash = await bcrypt.hash(admin.pass, 10);
const hash = await bcrypt.hash(admin.pass + PASSWORD_PEPPER, 10);
const userId = crypto.randomUUID();
const tenantId = companyMap[admin.company];
@ -84,7 +87,7 @@ export async function seedUsers() {
];
for (const cand of candidates) {
const hash = await bcrypt.hash(cand.pass, 10);
const hash = await bcrypt.hash(cand.pass + PASSWORD_PEPPER, 10);
const userId = crypto.randomUUID();
const result = await pool.query(`
@ -175,7 +178,7 @@ export async function seedUsers() {
];
for (const cand of legacyCandidates) {
const hash = await bcrypt.hash('User@2025', 10);
const hash = await bcrypt.hash('User@2025' + PASSWORD_PEPPER, 10);
await pool.query(`
INSERT INTO users (
identifier,