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:
parent
02f35b46b6
commit
340911b4d1
15 changed files with 133 additions and 13 deletions
|
|
@ -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
|
||||
# =============================================================================
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
# =============================================================================
|
||||
|
|
|
|||
|
|
@ -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 { }
|
||||
|
|
|
|||
10
backoffice/src/auth/auth.module.ts
Normal file
10
backoffice/src/auth/auth.module.ts
Normal 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 { }
|
||||
2
backoffice/src/auth/index.ts
Normal file
2
backoffice/src/auth/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export * from './auth.module';
|
||||
export * from './jwt-auth.guard';
|
||||
51
backoffice/src/auth/jwt-auth.guard.ts
Normal file
51
backoffice/src/auth/jwt-auth.guard.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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) : []
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue