diff --git a/backend/.env.example b/backend/.env.example index be99c9c..9710d63 100755 --- a/backend/.env.example +++ b/backend/.env.example @@ -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 # ============================================================================= diff --git a/backend/internal/infrastructure/auth/jwt_service.go b/backend/internal/infrastructure/auth/jwt_service.go index ac156a8..f2e67fe 100644 --- a/backend/internal/infrastructure/auth/jwt_service.go +++ b/backend/internal/infrastructure/auth/jwt_service.go @@ -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(), } diff --git a/backend/migrations/010_seed_super_admin.sql b/backend/migrations/010_seed_super_admin.sql index 3b26bf9..8bbc14e 100644 --- a/backend/migrations/010_seed_super_admin.sql +++ b/backend/migrations/010_seed_super_admin.sql @@ -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 diff --git a/backoffice/.env.example b/backoffice/.env.example index 32649c7..86f0984 100644 --- a/backoffice/.env.example +++ b/backoffice/.env.example @@ -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) # ============================================================================= diff --git a/backoffice/src/app.module.ts b/backoffice/src/app.module.ts index 68b0c78..e6f05c7 100644 --- a/backoffice/src/app.module.ts +++ b/backoffice/src/app.module.ts @@ -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 { } diff --git a/backoffice/src/auth/auth.module.ts b/backoffice/src/auth/auth.module.ts new file mode 100644 index 0000000..f3c714c --- /dev/null +++ b/backoffice/src/auth/auth.module.ts @@ -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 { } diff --git a/backoffice/src/auth/index.ts b/backoffice/src/auth/index.ts new file mode 100644 index 0000000..f094f34 --- /dev/null +++ b/backoffice/src/auth/index.ts @@ -0,0 +1,2 @@ +export * from './auth.module'; +export * from './jwt-auth.guard'; diff --git a/backoffice/src/auth/jwt-auth.guard.ts b/backoffice/src/auth/jwt-auth.guard.ts new file mode 100644 index 0000000..c2558c2 --- /dev/null +++ b/backoffice/src/auth/jwt-auth.guard.ts @@ -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('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; + } +} diff --git a/backoffice/src/main.ts b/backoffice/src/main.ts index de3198e..dcf2965 100644 --- a/backoffice/src/main.ts +++ b/backoffice/src/main.ts @@ -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({ diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index 6bed529..98484e8 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -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) : [] diff --git a/frontend/src/lib/auth.ts b/frontend/src/lib/auth.ts index 5e64982..8645e31 100644 --- a/frontend/src/lib/auth.ts +++ b/frontend/src/lib/auth.ts @@ -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; diff --git a/seeder-api/.env.example b/seeder-api/.env.example index 77aea58..b3e815e 100644 --- a/seeder-api/.env.example +++ b/seeder-api/.env.example @@ -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 diff --git a/seeder-api/README.md b/seeder-api/README.md index 17e6eaf..cd1d0e5 100644 --- a/seeder-api/README.md +++ b/seeder-api/README.md @@ -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 | diff --git a/seeder-api/src/seeders/acme.js b/seeder-api/src/seeders/acme.js index edc876d..7e89554 100644 --- a/seeder-api/src/seeders/acme.js +++ b/seeder-api/src/seeders/acme.js @@ -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) diff --git a/seeder-api/src/seeders/users.js b/seeder-api/src/seeders/users.js index 77445ca..7f63f87 100644 --- a/seeder-api/src/seeders/users.js +++ b/seeder-api/src/seeders/users.js @@ -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,