fix security
This commit is contained in:
parent
3a2a14b5b7
commit
9e8f157d70
@ -18,11 +18,19 @@ REDIS_PORT=6379
|
|||||||
REDIS_PASSWORD=xpeditis_redis_password
|
REDIS_PASSWORD=xpeditis_redis_password
|
||||||
REDIS_DB=0
|
REDIS_DB=0
|
||||||
|
|
||||||
# JWT
|
# JWT (JWT_SECRET must be at least 32 characters)
|
||||||
JWT_SECRET=your-super-secret-jwt-key-change-this-in-production
|
JWT_SECRET=your-super-secret-jwt-key-change-this-in-production
|
||||||
JWT_ACCESS_EXPIRATION=15m
|
JWT_ACCESS_EXPIRATION=15m
|
||||||
JWT_REFRESH_EXPIRATION=7d
|
JWT_REFRESH_EXPIRATION=7d
|
||||||
|
|
||||||
|
# Auth cookies — domain shared between frontend and API in production
|
||||||
|
# (e.g. .xpeditis.com). Leave unset for localhost development.
|
||||||
|
# COOKIE_DOMAIN=.xpeditis.com
|
||||||
|
|
||||||
|
# Secret used to derive carrier document passwords (min 16 chars).
|
||||||
|
# Falls back to JWT_SECRET when unset.
|
||||||
|
# DOCUMENT_PASSWORD_SECRET=your-document-password-secret
|
||||||
|
|
||||||
# OAuth2 - Google
|
# OAuth2 - Google
|
||||||
GOOGLE_CLIENT_ID=your-google-client-id
|
GOOGLE_CLIENT_ID=your-google-client-id
|
||||||
GOOGLE_CLIENT_SECRET=your-google-client-secret
|
GOOGLE_CLIENT_SECRET=your-google-client-secret
|
||||||
|
|||||||
@ -74,7 +74,7 @@ EXPOSE 4000
|
|||||||
|
|
||||||
# Health check
|
# Health check
|
||||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
|
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
|
||||||
CMD node -e "require('http').get('http://localhost:4000/health', (r) => process.exit(r.statusCode === 200 ? 0 : 1))"
|
CMD node -e "require('http').get('http://localhost:4000/api/v1/health', (r) => process.exit(r.statusCode === 200 ? 0 : 1))"
|
||||||
|
|
||||||
# Set environment variables
|
# Set environment variables
|
||||||
ENV NODE_ENV=production \
|
ENV NODE_ENV=production \
|
||||||
|
|||||||
3378
apps/backend/package-lock.json
generated
3378
apps/backend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -51,6 +51,7 @@
|
|||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
"class-validator": "^0.14.2",
|
"class-validator": "^0.14.2",
|
||||||
"compression": "^1.8.1",
|
"compression": "^1.8.1",
|
||||||
|
"cookie-parser": "^1.4.7",
|
||||||
"csv-parse": "^6.1.0",
|
"csv-parse": "^6.1.0",
|
||||||
"exceljs": "^4.4.0",
|
"exceljs": "^4.4.0",
|
||||||
"handlebars": "^4.7.8",
|
"handlebars": "^4.7.8",
|
||||||
@ -87,6 +88,7 @@
|
|||||||
"@nestjs/testing": "^10.2.10",
|
"@nestjs/testing": "^10.2.10",
|
||||||
"@types/bcrypt": "^5.0.2",
|
"@types/bcrypt": "^5.0.2",
|
||||||
"@types/compression": "^1.8.1",
|
"@types/compression": "^1.8.1",
|
||||||
|
"@types/cookie-parser": "^1.4.10",
|
||||||
"@types/express": "^4.17.21",
|
"@types/express": "^4.17.21",
|
||||||
"@types/jest": "^29.5.11",
|
"@types/jest": "^29.5.11",
|
||||||
"@types/multer": "^2.0.0",
|
"@types/multer": "^2.0.0",
|
||||||
|
|||||||
@ -39,6 +39,7 @@ import { CsvRateModule } from './infrastructure/carriers/csv-loader/csv-rate.mod
|
|||||||
|
|
||||||
// Import global guards
|
// Import global guards
|
||||||
import { ApiKeyOrJwtGuard } from './application/guards/api-key-or-jwt.guard';
|
import { ApiKeyOrJwtGuard } from './application/guards/api-key-or-jwt.guard';
|
||||||
|
import { HealthController } from './application/controllers/health.controller';
|
||||||
import { CustomThrottlerGuard } from './application/guards/throttle.guard';
|
import { CustomThrottlerGuard } from './application/guards/throttle.guard';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
@ -59,9 +60,14 @@ import { CustomThrottlerGuard } from './application/guards/throttle.guard';
|
|||||||
REDIS_HOST: Joi.string().required(),
|
REDIS_HOST: Joi.string().required(),
|
||||||
REDIS_PORT: Joi.number().default(6379),
|
REDIS_PORT: Joi.number().default(6379),
|
||||||
REDIS_PASSWORD: Joi.string().required(),
|
REDIS_PASSWORD: Joi.string().required(),
|
||||||
JWT_SECRET: Joi.string().required(),
|
JWT_SECRET: Joi.string().min(32).required(),
|
||||||
JWT_ACCESS_EXPIRATION: Joi.string().default('15m'),
|
JWT_ACCESS_EXPIRATION: Joi.string().default('15m'),
|
||||||
JWT_REFRESH_EXPIRATION: Joi.string().default('7d'),
|
JWT_REFRESH_EXPIRATION: Joi.string().default('7d'),
|
||||||
|
// Cookie domain for auth cookies (e.g. ".xpeditis.com" so the frontend
|
||||||
|
// and API subdomains share them). Unset = host-only (fine for localhost).
|
||||||
|
COOKIE_DOMAIN: Joi.string().optional(),
|
||||||
|
// Secret used to derive carrier document passwords (falls back to JWT_SECRET)
|
||||||
|
DOCUMENT_PASSWORD_SECRET: Joi.string().min(16).optional(),
|
||||||
// SMTP Configuration
|
// SMTP Configuration
|
||||||
SMTP_HOST: Joi.string().required(),
|
SMTP_HOST: Joi.string().required(),
|
||||||
SMTP_PORT: Joi.number().default(2525),
|
SMTP_PORT: Joi.number().default(2525),
|
||||||
@ -185,7 +191,7 @@ import { CustomThrottlerGuard } from './application/guards/throttle.guard';
|
|||||||
ApiKeysModule,
|
ApiKeysModule,
|
||||||
LogsModule,
|
LogsModule,
|
||||||
],
|
],
|
||||||
controllers: [],
|
controllers: [HealthController],
|
||||||
providers: [
|
providers: [
|
||||||
// Global authentication guard — supports both JWT (frontend) and API key (Gold/Platinium)
|
// Global authentication guard — supports both JWT (frontend) and API key (Gold/Platinium)
|
||||||
// All routes are protected by default, use @Public() to bypass
|
// All routes are protected by default, use @Public() to bypass
|
||||||
|
|||||||
@ -21,6 +21,7 @@ import {
|
|||||||
} from '@domain/ports/out/organization.repository';
|
} from '@domain/ports/out/organization.repository';
|
||||||
import { Organization } from '@domain/entities/organization.entity';
|
import { Organization } from '@domain/entities/organization.entity';
|
||||||
import { EmailPort, EMAIL_PORT } from '@domain/ports/out/email.port';
|
import { EmailPort, EMAIL_PORT } from '@domain/ports/out/email.port';
|
||||||
|
import { CachePort, CACHE_PORT } from '@domain/ports/out/cache.port';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
import { RegisterOrganizationDto } from '../dto/auth-login.dto';
|
import { RegisterOrganizationDto } from '../dto/auth-login.dto';
|
||||||
import { SubscriptionService } from '../services/subscription.service';
|
import { SubscriptionService } from '../services/subscription.service';
|
||||||
@ -34,6 +35,7 @@ export interface JwtPayload {
|
|||||||
plan?: string; // subscription plan (BRONZE, SILVER, GOLD, PLATINIUM)
|
plan?: string; // subscription plan (BRONZE, SILVER, GOLD, PLATINIUM)
|
||||||
planFeatures?: string[]; // plan feature flags
|
planFeatures?: string[]; // plan feature flags
|
||||||
type: 'access' | 'refresh';
|
type: 'access' | 'refresh';
|
||||||
|
rememberMe?: boolean; // drives auth cookie persistence across refreshes
|
||||||
}
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@ -47,6 +49,8 @@ export class AuthService {
|
|||||||
private readonly organizationRepository: OrganizationRepository,
|
private readonly organizationRepository: OrganizationRepository,
|
||||||
@Inject(EMAIL_PORT)
|
@Inject(EMAIL_PORT)
|
||||||
private readonly emailService: EmailPort,
|
private readonly emailService: EmailPort,
|
||||||
|
@Inject(CACHE_PORT)
|
||||||
|
private readonly cache: CachePort,
|
||||||
@InjectRepository(PasswordResetTokenOrmEntity)
|
@InjectRepository(PasswordResetTokenOrmEntity)
|
||||||
private readonly passwordResetTokenRepository: Repository<PasswordResetTokenOrmEntity>,
|
private readonly passwordResetTokenRepository: Repository<PasswordResetTokenOrmEntity>,
|
||||||
private readonly jwtService: JwtService,
|
private readonly jwtService: JwtService,
|
||||||
@ -93,6 +97,11 @@ export class AuthService {
|
|||||||
// - Otherwise, default to USER
|
// - Otherwise, default to USER
|
||||||
let userRole: UserRole;
|
let userRole: UserRole;
|
||||||
if (invitationRole) {
|
if (invitationRole) {
|
||||||
|
// Invitations can only grant non-admin roles — reject anything else
|
||||||
|
const allowedInvitationRoles: UserRole[] = [UserRole.MANAGER, UserRole.USER, UserRole.VIEWER];
|
||||||
|
if (!allowedInvitationRoles.includes(invitationRole as UserRole)) {
|
||||||
|
throw new BadRequestException('Invalid invitation role');
|
||||||
|
}
|
||||||
userRole = invitationRole as UserRole;
|
userRole = invitationRole as UserRole;
|
||||||
} else if (organizationData) {
|
} else if (organizationData) {
|
||||||
// User creating a new organization becomes MANAGER
|
// User creating a new organization becomes MANAGER
|
||||||
@ -146,7 +155,8 @@ export class AuthService {
|
|||||||
*/
|
*/
|
||||||
async login(
|
async login(
|
||||||
email: string,
|
email: string,
|
||||||
password: string
|
password: string,
|
||||||
|
rememberMe = false
|
||||||
): Promise<{ accessToken: string; refreshToken: string; user: any }> {
|
): Promise<{ accessToken: string; refreshToken: string; user: any }> {
|
||||||
this.logger.log(`Login attempt for: ${email}`);
|
this.logger.log(`Login attempt for: ${email}`);
|
||||||
|
|
||||||
@ -166,7 +176,7 @@ export class AuthService {
|
|||||||
throw new UnauthorizedException('Invalid credentials');
|
throw new UnauthorizedException('Invalid credentials');
|
||||||
}
|
}
|
||||||
|
|
||||||
const tokens = await this.generateTokens(user);
|
const tokens = await this.generateTokens(user, rememberMe);
|
||||||
|
|
||||||
this.logger.log(`User logged in successfully: ${email}`);
|
this.logger.log(`User logged in successfully: ${email}`);
|
||||||
|
|
||||||
@ -188,7 +198,7 @@ export class AuthService {
|
|||||||
*/
|
*/
|
||||||
async refreshAccessToken(
|
async refreshAccessToken(
|
||||||
refreshToken: string
|
refreshToken: string
|
||||||
): Promise<{ accessToken: string; refreshToken: string }> {
|
): Promise<{ accessToken: string; refreshToken: string; rememberMe: boolean }> {
|
||||||
try {
|
try {
|
||||||
const payload = await this.jwtService.verifyAsync<JwtPayload>(refreshToken, {
|
const payload = await this.jwtService.verifyAsync<JwtPayload>(refreshToken, {
|
||||||
secret: this.configService.get('JWT_SECRET'),
|
secret: this.configService.get('JWT_SECRET'),
|
||||||
@ -198,23 +208,66 @@ export class AuthService {
|
|||||||
throw new UnauthorizedException('Invalid token type');
|
throw new UnauthorizedException('Invalid token type');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (await this.isRefreshTokenRevoked(refreshToken)) {
|
||||||
|
throw new UnauthorizedException('Refresh token has been revoked');
|
||||||
|
}
|
||||||
|
|
||||||
const user = await this.userRepository.findById(payload.sub);
|
const user = await this.userRepository.findById(payload.sub);
|
||||||
|
|
||||||
if (!user || !user.isActive) {
|
if (!user || !user.isActive) {
|
||||||
throw new UnauthorizedException('User not found or inactive');
|
throw new UnauthorizedException('User not found or inactive');
|
||||||
}
|
}
|
||||||
|
|
||||||
const tokens = await this.generateTokens(user);
|
const rememberMe = payload.rememberMe === true;
|
||||||
|
const tokens = await this.generateTokens(user, rememberMe);
|
||||||
|
|
||||||
this.logger.log(`Access token refreshed for user: ${user.email}`);
|
this.logger.log(`Access token refreshed for user: ${user.email}`);
|
||||||
|
|
||||||
return tokens;
|
return { ...tokens, rememberMe };
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
this.logger.error(`Token refresh failed: ${error?.message || 'Unknown error'}`);
|
this.logger.error(`Token refresh failed: ${error?.message || 'Unknown error'}`);
|
||||||
throw new UnauthorizedException('Invalid or expired refresh token');
|
throw new UnauthorizedException('Invalid or expired refresh token');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Logout — revoke the refresh token so it can no longer be used.
|
||||||
|
* The revocation list lives in Redis with a TTL matching the token's
|
||||||
|
* remaining lifetime, so entries clean themselves up.
|
||||||
|
*/
|
||||||
|
async logout(refreshToken?: string): Promise<void> {
|
||||||
|
if (!refreshToken) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload = this.jwtService.decode(refreshToken) as JwtPayload & { exp?: number };
|
||||||
|
const remainingSeconds = payload?.exp
|
||||||
|
? Math.max(payload.exp - Math.floor(Date.now() / 1000), 1)
|
||||||
|
: 7 * 24 * 60 * 60;
|
||||||
|
|
||||||
|
await this.cache.set(this.revokedTokenKey(refreshToken), true, remainingSeconds);
|
||||||
|
this.logger.log(`Refresh token revoked for user: ${payload?.email ?? 'unknown'}`);
|
||||||
|
} catch (error) {
|
||||||
|
// Never block logout on revocation failures — log and continue
|
||||||
|
this.logger.error(`Failed to revoke refresh token: ${error}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async isRefreshTokenRevoked(refreshToken: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
return (await this.cache.get<boolean>(this.revokedTokenKey(refreshToken))) === true;
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`Failed to check refresh token revocation: ${error}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private revokedTokenKey(refreshToken: string): string {
|
||||||
|
const hash = crypto.createHash('sha256').update(refreshToken).digest('hex');
|
||||||
|
return `auth:revoked-refresh:${hash}`;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initiate password reset — generates token and sends email
|
* Initiate password reset — generates token and sends email
|
||||||
*/
|
*/
|
||||||
@ -234,13 +287,15 @@ export class AuthService {
|
|||||||
{ usedAt: new Date() }
|
{ usedAt: new Date() }
|
||||||
);
|
);
|
||||||
|
|
||||||
// Generate a secure random token
|
// Generate a secure random token; only its hash is stored so a database
|
||||||
|
// leak cannot be used to take over accounts via pending reset tokens
|
||||||
const token = crypto.randomBytes(32).toString('hex');
|
const token = crypto.randomBytes(32).toString('hex');
|
||||||
|
const tokenHash = this.hashResetToken(token);
|
||||||
const expiresAt = new Date(Date.now() + 60 * 60 * 1000); // 1 hour
|
const expiresAt = new Date(Date.now() + 60 * 60 * 1000); // 1 hour
|
||||||
|
|
||||||
await this.passwordResetTokenRepository.save({
|
await this.passwordResetTokenRepository.save({
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
token,
|
token: tokenHash,
|
||||||
expiresAt,
|
expiresAt,
|
||||||
usedAt: null,
|
usedAt: null,
|
||||||
});
|
});
|
||||||
@ -254,7 +309,9 @@ export class AuthService {
|
|||||||
* Reset password using token from email
|
* Reset password using token from email
|
||||||
*/
|
*/
|
||||||
async resetPassword(token: string, newPassword: string): Promise<void> {
|
async resetPassword(token: string, newPassword: string): Promise<void> {
|
||||||
const resetToken = await this.passwordResetTokenRepository.findOne({ where: { token } });
|
const resetToken = await this.passwordResetTokenRepository.findOne({
|
||||||
|
where: { token: this.hashResetToken(token) },
|
||||||
|
});
|
||||||
|
|
||||||
if (!resetToken) {
|
if (!resetToken) {
|
||||||
throw new BadRequestException('Token de réinitialisation invalide ou expiré');
|
throw new BadRequestException('Token de réinitialisation invalide ou expiré');
|
||||||
@ -293,6 +350,10 @@ export class AuthService {
|
|||||||
this.logger.log(`Password reset successfully for user: ${user.email}`);
|
this.logger.log(`Password reset successfully for user: ${user.email}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private hashResetToken(token: string): string {
|
||||||
|
return crypto.createHash('sha256').update(token).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validate user from JWT payload
|
* Validate user from JWT payload
|
||||||
*/
|
*/
|
||||||
@ -309,7 +370,10 @@ export class AuthService {
|
|||||||
/**
|
/**
|
||||||
* Generate access and refresh tokens
|
* Generate access and refresh tokens
|
||||||
*/
|
*/
|
||||||
private async generateTokens(user: User): Promise<{ accessToken: string; refreshToken: string }> {
|
private async generateTokens(
|
||||||
|
user: User,
|
||||||
|
rememberMe = false
|
||||||
|
): Promise<{ accessToken: string; refreshToken: string }> {
|
||||||
// ADMIN users always get PLATINIUM plan with no expiration
|
// ADMIN users always get PLATINIUM plan with no expiration
|
||||||
let plan = 'BRONZE';
|
let plan = 'BRONZE';
|
||||||
let planFeatures: string[] = [];
|
let planFeatures: string[] = [];
|
||||||
@ -355,6 +419,7 @@ export class AuthService {
|
|||||||
plan,
|
plan,
|
||||||
planFeatures,
|
planFeatures,
|
||||||
type: 'refresh',
|
type: 'refresh',
|
||||||
|
rememberMe,
|
||||||
};
|
};
|
||||||
|
|
||||||
const [accessToken, refreshToken] = await Promise.all([
|
const [accessToken, refreshToken] = await Promise.all([
|
||||||
|
|||||||
@ -35,7 +35,11 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
|
|||||||
private readonly authService: AuthService
|
private readonly authService: AuthService
|
||||||
) {
|
) {
|
||||||
super({
|
super({
|
||||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
jwtFromRequest: ExtractJwt.fromExtractors([
|
||||||
|
ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||||
|
// httpOnly cookie set by the auth endpoints (XSS-safe storage)
|
||||||
|
(req: { cookies?: Record<string, string> }) => req?.cookies?.accessToken ?? null,
|
||||||
|
]),
|
||||||
ignoreExpiration: false,
|
ignoreExpiration: false,
|
||||||
secretOrKey: configService.get<string>('JWT_SECRET'),
|
secretOrKey: configService.get<string>('JWT_SECRET'),
|
||||||
});
|
});
|
||||||
|
|||||||
@ -8,10 +8,15 @@ import {
|
|||||||
Get,
|
Get,
|
||||||
Inject,
|
Inject,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
|
UnauthorizedException,
|
||||||
InternalServerErrorException,
|
InternalServerErrorException,
|
||||||
Logger,
|
Logger,
|
||||||
|
Req,
|
||||||
|
Res,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
|
import type { Request, Response } from 'express';
|
||||||
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger';
|
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger';
|
||||||
|
import { Throttle } from '@nestjs/throttler';
|
||||||
import { AuthService } from '../auth/auth.service';
|
import { AuthService } from '../auth/auth.service';
|
||||||
import {
|
import {
|
||||||
LoginDto,
|
LoginDto,
|
||||||
@ -29,6 +34,24 @@ import { JwtAuthGuard } from '../guards/jwt-auth.guard';
|
|||||||
import { UserRepository, USER_REPOSITORY } from '@domain/ports/out/user.repository';
|
import { UserRepository, USER_REPOSITORY } from '@domain/ports/out/user.repository';
|
||||||
import { UserMapper } from '../mappers/user.mapper';
|
import { UserMapper } from '../mappers/user.mapper';
|
||||||
import { InvitationService } from '../services/invitation.service';
|
import { InvitationService } from '../services/invitation.service';
|
||||||
|
import {
|
||||||
|
AUTH_COOKIE_NAMES,
|
||||||
|
authCookieOptions,
|
||||||
|
} from '../../infrastructure/security/security.config';
|
||||||
|
|
||||||
|
const REFRESH_COOKIE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Escape user-provided text before interpolating it into HTML emails
|
||||||
|
*/
|
||||||
|
function escapeHtml(value: string): string {
|
||||||
|
return value
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Authentication Controller
|
* Authentication Controller
|
||||||
@ -52,6 +75,33 @@ export class AuthController {
|
|||||||
@Inject(EMAIL_PORT) private readonly emailService: EmailPort
|
@Inject(EMAIL_PORT) private readonly emailService: EmailPort
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deliver tokens as httpOnly cookies so they are out of reach of XSS.
|
||||||
|
* When rememberMe is false the cookies are session-scoped (cleared when
|
||||||
|
* the browser closes); otherwise they persist for the refresh window.
|
||||||
|
*/
|
||||||
|
private setAuthCookies(
|
||||||
|
res: Response,
|
||||||
|
tokens: { accessToken: string; refreshToken: string },
|
||||||
|
rememberMe: boolean
|
||||||
|
): void {
|
||||||
|
const maxAgeMs = rememberMe ? REFRESH_COOKIE_MAX_AGE_MS : undefined;
|
||||||
|
res.cookie(AUTH_COOKIE_NAMES.accessToken, tokens.accessToken, authCookieOptions({ maxAgeMs }));
|
||||||
|
res.cookie(
|
||||||
|
AUTH_COOKIE_NAMES.refreshToken,
|
||||||
|
tokens.refreshToken,
|
||||||
|
authCookieOptions({ maxAgeMs })
|
||||||
|
);
|
||||||
|
// Readable flag (no token inside) so the frontend knows a session exists
|
||||||
|
res.cookie(AUTH_COOKIE_NAMES.session, '1', authCookieOptions({ maxAgeMs, httpOnly: false }));
|
||||||
|
}
|
||||||
|
|
||||||
|
private clearAuthCookies(res: Response): void {
|
||||||
|
res.clearCookie(AUTH_COOKIE_NAMES.accessToken, authCookieOptions());
|
||||||
|
res.clearCookie(AUTH_COOKIE_NAMES.refreshToken, authCookieOptions());
|
||||||
|
res.clearCookie(AUTH_COOKIE_NAMES.session, authCookieOptions({ httpOnly: false }));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Register a new user
|
* Register a new user
|
||||||
*
|
*
|
||||||
@ -61,6 +111,7 @@ export class AuthController {
|
|||||||
* @returns Access token, refresh token, and user info
|
* @returns Access token, refresh token, and user info
|
||||||
*/
|
*/
|
||||||
@Public()
|
@Public()
|
||||||
|
@Throttle({ default: { limit: 5, ttl: 60000 } })
|
||||||
@Post('register')
|
@Post('register')
|
||||||
@HttpCode(HttpStatus.CREATED)
|
@HttpCode(HttpStatus.CREATED)
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
@ -80,7 +131,10 @@ export class AuthController {
|
|||||||
status: 400,
|
status: 400,
|
||||||
description: 'Validation error (invalid email, weak password, etc.)',
|
description: 'Validation error (invalid email, weak password, etc.)',
|
||||||
})
|
})
|
||||||
async register(@Body() dto: RegisterDto): Promise<AuthResponseDto> {
|
async register(
|
||||||
|
@Body() dto: RegisterDto,
|
||||||
|
@Res({ passthrough: true }) res: Response
|
||||||
|
): Promise<AuthResponseDto> {
|
||||||
// If invitation token is provided, verify and use it
|
// If invitation token is provided, verify and use it
|
||||||
let invitationOrganizationId: string | undefined;
|
let invitationOrganizationId: string | undefined;
|
||||||
let invitationRole: string | undefined;
|
let invitationRole: string | undefined;
|
||||||
@ -101,12 +155,14 @@ export class AuthController {
|
|||||||
dto.lastName = dto.lastName || invitation.lastName;
|
dto.lastName = dto.lastName || invitation.lastName;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Joining an existing organization is only allowed through a verified
|
||||||
|
// invitation token — never from a caller-supplied organization ID.
|
||||||
const result = await this.authService.register(
|
const result = await this.authService.register(
|
||||||
dto.email,
|
dto.email,
|
||||||
dto.password,
|
dto.password,
|
||||||
dto.firstName,
|
dto.firstName,
|
||||||
dto.lastName,
|
dto.lastName,
|
||||||
invitationOrganizationId || dto.organizationId,
|
invitationOrganizationId,
|
||||||
dto.organization,
|
dto.organization,
|
||||||
invitationRole
|
invitationRole
|
||||||
);
|
);
|
||||||
@ -116,6 +172,8 @@ export class AuthController {
|
|||||||
await this.invitationService.markInvitationAsUsed(dto.invitationToken);
|
await this.invitationService.markInvitationAsUsed(dto.invitationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.setAuthCookies(res, result, false);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
accessToken: result.accessToken,
|
accessToken: result.accessToken,
|
||||||
refreshToken: result.refreshToken,
|
refreshToken: result.refreshToken,
|
||||||
@ -132,6 +190,7 @@ export class AuthController {
|
|||||||
* @returns Access token, refresh token, and user info
|
* @returns Access token, refresh token, and user info
|
||||||
*/
|
*/
|
||||||
@Public()
|
@Public()
|
||||||
|
@Throttle({ default: { limit: 5, ttl: 60000 } })
|
||||||
@Post('login')
|
@Post('login')
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
@ -147,8 +206,14 @@ export class AuthController {
|
|||||||
status: 401,
|
status: 401,
|
||||||
description: 'Invalid credentials or inactive account',
|
description: 'Invalid credentials or inactive account',
|
||||||
})
|
})
|
||||||
async login(@Body() dto: LoginDto): Promise<AuthResponseDto> {
|
async login(
|
||||||
const result = await this.authService.login(dto.email, dto.password);
|
@Body() dto: LoginDto,
|
||||||
|
@Res({ passthrough: true }) res: Response
|
||||||
|
): Promise<AuthResponseDto> {
|
||||||
|
const rememberMe = dto.rememberMe === true;
|
||||||
|
const result = await this.authService.login(dto.email, dto.password, rememberMe);
|
||||||
|
|
||||||
|
this.setAuthCookies(res, result, rememberMe);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
accessToken: result.accessToken,
|
accessToken: result.accessToken,
|
||||||
@ -166,6 +231,7 @@ export class AuthController {
|
|||||||
* @returns New access token
|
* @returns New access token
|
||||||
*/
|
*/
|
||||||
@Public()
|
@Public()
|
||||||
|
@Throttle({ default: { limit: 20, ttl: 60000 } })
|
||||||
@Post('refresh')
|
@Post('refresh')
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
@ -175,10 +241,10 @@ export class AuthController {
|
|||||||
})
|
})
|
||||||
@ApiResponse({
|
@ApiResponse({
|
||||||
status: 200,
|
status: 200,
|
||||||
description: 'Token refreshed successfully',
|
description: 'Token refreshed successfully — new tokens are set as httpOnly cookies',
|
||||||
schema: {
|
schema: {
|
||||||
properties: {
|
properties: {
|
||||||
accessToken: { type: 'string', example: 'eyJhbGciOiJIUzI1NiIs...' },
|
success: { type: 'boolean', example: true },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@ -186,27 +252,40 @@ export class AuthController {
|
|||||||
status: 401,
|
status: 401,
|
||||||
description: 'Invalid or expired refresh token',
|
description: 'Invalid or expired refresh token',
|
||||||
})
|
})
|
||||||
async refresh(@Body() dto: RefreshTokenDto): Promise<{ accessToken: string }> {
|
async refresh(
|
||||||
const result = await this.authService.refreshAccessToken(dto.refreshToken);
|
@Body() dto: RefreshTokenDto,
|
||||||
|
@Req() req: Request,
|
||||||
|
@Res({ passthrough: true }) res: Response
|
||||||
|
): Promise<{ success: boolean }> {
|
||||||
|
// Prefer the httpOnly cookie; fall back to the body for legacy clients
|
||||||
|
const refreshToken = req.cookies?.[AUTH_COOKIE_NAMES.refreshToken] || dto.refreshToken;
|
||||||
|
|
||||||
return { accessToken: result.accessToken };
|
if (!refreshToken) {
|
||||||
|
this.clearAuthCookies(res);
|
||||||
|
throw new UnauthorizedException('No refresh token provided');
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await this.authService.refreshAccessToken(refreshToken);
|
||||||
|
|
||||||
|
this.setAuthCookies(res, result, result.rememberMe);
|
||||||
|
|
||||||
|
// Tokens are intentionally NOT returned in the body: an XSS payload could
|
||||||
|
// otherwise call this endpoint and exfiltrate a fresh access token.
|
||||||
|
return { success: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Logout (placeholder)
|
* Logout
|
||||||
*
|
*
|
||||||
* Currently a no-op endpoint. With JWT, logout is typically handled client-side
|
* Revokes the refresh token (Redis blacklist) and clears the auth cookies.
|
||||||
* by removing tokens. For more security, implement token blacklisting with Redis.
|
* The access token naturally expires within 15 minutes.
|
||||||
*
|
|
||||||
* @returns Success message
|
|
||||||
*/
|
*/
|
||||||
@UseGuards(JwtAuthGuard)
|
@Public()
|
||||||
@Post('logout')
|
@Post('logout')
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@ApiBearerAuth()
|
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: 'Logout',
|
summary: 'Logout',
|
||||||
description: 'Logout the current user. Currently handled client-side by removing tokens.',
|
description: 'Revoke the refresh token and clear authentication cookies.',
|
||||||
})
|
})
|
||||||
@ApiResponse({
|
@ApiResponse({
|
||||||
status: 200,
|
status: 200,
|
||||||
@ -217,9 +296,15 @@ export class AuthController {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
async logout(): Promise<{ message: string }> {
|
async logout(
|
||||||
// TODO: Implement token blacklisting with Redis for more security
|
@Req() req: Request,
|
||||||
// For now, logout is handled client-side by removing tokens
|
@Res({ passthrough: true }) res: Response
|
||||||
|
): Promise<{ message: string }> {
|
||||||
|
const refreshToken = req.cookies?.[AUTH_COOKIE_NAMES.refreshToken];
|
||||||
|
|
||||||
|
await this.authService.logout(refreshToken);
|
||||||
|
this.clearAuthCookies(res);
|
||||||
|
|
||||||
return { message: 'Logout successful' };
|
return { message: 'Logout successful' };
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -227,6 +312,7 @@ export class AuthController {
|
|||||||
* Contact form — forwards message to contact@xpeditis.com
|
* Contact form — forwards message to contact@xpeditis.com
|
||||||
*/
|
*/
|
||||||
@Public()
|
@Public()
|
||||||
|
@Throttle({ default: { limit: 3, ttl: 60000 } })
|
||||||
@Post('contact')
|
@Post('contact')
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
@ -245,7 +331,13 @@ export class AuthController {
|
|||||||
other: 'Autre',
|
other: 'Autre',
|
||||||
};
|
};
|
||||||
|
|
||||||
const subjectLabel = subjectLabels[dto.subject] || dto.subject;
|
const subjectLabel = escapeHtml(subjectLabels[dto.subject] || dto.subject);
|
||||||
|
const firstName = escapeHtml(dto.firstName);
|
||||||
|
const lastName = escapeHtml(dto.lastName);
|
||||||
|
const email = escapeHtml(dto.email);
|
||||||
|
const company = dto.company ? escapeHtml(dto.company) : undefined;
|
||||||
|
const phone = dto.phone ? escapeHtml(dto.phone) : undefined;
|
||||||
|
const message = escapeHtml(dto.message);
|
||||||
|
|
||||||
const html = `
|
const html = `
|
||||||
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
|
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
|
||||||
@ -256,14 +348,14 @@ export class AuthController {
|
|||||||
<table style="width: 100%; border-collapse: collapse;">
|
<table style="width: 100%; border-collapse: collapse;">
|
||||||
<tr>
|
<tr>
|
||||||
<td style="padding: 8px 0; color: #666; width: 130px; font-size: 14px;">Nom</td>
|
<td style="padding: 8px 0; color: #666; width: 130px; font-size: 14px;">Nom</td>
|
||||||
<td style="padding: 8px 0; color: #222; font-weight: bold; font-size: 14px;">${dto.firstName} ${dto.lastName}</td>
|
<td style="padding: 8px 0; color: #222; font-weight: bold; font-size: 14px;">${firstName} ${lastName}</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td style="padding: 8px 0; color: #666; font-size: 14px;">Email</td>
|
<td style="padding: 8px 0; color: #666; font-size: 14px;">Email</td>
|
||||||
<td style="padding: 8px 0; font-size: 14px;"><a href="mailto:${dto.email}" style="color: #34CCCD;">${dto.email}</a></td>
|
<td style="padding: 8px 0; font-size: 14px;"><a href="mailto:${email}" style="color: #34CCCD;">${email}</a></td>
|
||||||
</tr>
|
</tr>
|
||||||
${dto.company ? `<tr><td style="padding: 8px 0; color: #666; font-size: 14px;">Entreprise</td><td style="padding: 8px 0; color: #222; font-size: 14px;">${dto.company}</td></tr>` : ''}
|
${company ? `<tr><td style="padding: 8px 0; color: #666; font-size: 14px;">Entreprise</td><td style="padding: 8px 0; color: #222; font-size: 14px;">${company}</td></tr>` : ''}
|
||||||
${dto.phone ? `<tr><td style="padding: 8px 0; color: #666; font-size: 14px;">Téléphone</td><td style="padding: 8px 0; color: #222; font-size: 14px;">${dto.phone}</td></tr>` : ''}
|
${phone ? `<tr><td style="padding: 8px 0; color: #666; font-size: 14px;">Téléphone</td><td style="padding: 8px 0; color: #222; font-size: 14px;">${phone}</td></tr>` : ''}
|
||||||
<tr>
|
<tr>
|
||||||
<td style="padding: 8px 0; color: #666; font-size: 14px;">Sujet</td>
|
<td style="padding: 8px 0; color: #666; font-size: 14px;">Sujet</td>
|
||||||
<td style="padding: 8px 0; color: #222; font-size: 14px;">${subjectLabel}</td>
|
<td style="padding: 8px 0; color: #222; font-size: 14px;">${subjectLabel}</td>
|
||||||
@ -271,7 +363,7 @@ export class AuthController {
|
|||||||
</table>
|
</table>
|
||||||
<div style="margin-top: 16px; padding-top: 16px; border-top: 1px solid #ddd;">
|
<div style="margin-top: 16px; padding-top: 16px; border-top: 1px solid #ddd;">
|
||||||
<p style="color: #666; font-size: 14px; margin: 0 0 8px 0;">Message :</p>
|
<p style="color: #666; font-size: 14px; margin: 0 0 8px 0;">Message :</p>
|
||||||
<p style="color: #222; font-size: 14px; white-space: pre-wrap; margin: 0;">${dto.message}</p>
|
<p style="color: #222; font-size: 14px; white-space: pre-wrap; margin: 0;">${message}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style="background: #f0f0f0; padding: 12px 24px; border-radius: 0 0 8px 8px; text-align: center;">
|
<div style="background: #f0f0f0; padding: 12px 24px; border-radius: 0 0 8px 8px; text-align: center;">
|
||||||
@ -284,7 +376,7 @@ export class AuthController {
|
|||||||
await this.emailService.send({
|
await this.emailService.send({
|
||||||
to: 'contact@xpeditis.com',
|
to: 'contact@xpeditis.com',
|
||||||
replyTo: dto.email,
|
replyTo: dto.email,
|
||||||
subject: `[Contact] ${subjectLabel} — ${dto.firstName} ${dto.lastName}`,
|
subject: `[Contact] ${subjectLabels[dto.subject] || dto.subject} — ${dto.firstName} ${dto.lastName}`,
|
||||||
html,
|
html,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -301,6 +393,7 @@ export class AuthController {
|
|||||||
* Forgot password — sends reset email
|
* Forgot password — sends reset email
|
||||||
*/
|
*/
|
||||||
@Public()
|
@Public()
|
||||||
|
@Throttle({ default: { limit: 3, ttl: 60000 } })
|
||||||
@Post('forgot-password')
|
@Post('forgot-password')
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
@ -319,6 +412,7 @@ export class AuthController {
|
|||||||
* Reset password using token from email
|
* Reset password using token from email
|
||||||
*/
|
*/
|
||||||
@Public()
|
@Public()
|
||||||
|
@Throttle({ default: { limit: 5, ttl: 60000 } })
|
||||||
@Post('reset-password')
|
@Post('reset-password')
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
|
|||||||
@ -248,18 +248,7 @@ export class RegisterDto {
|
|||||||
invitationToken?: string;
|
invitationToken?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
example: '550e8400-e29b-41d4-a716-446655440000',
|
description: 'Organization data (required if invitationToken is not provided)',
|
||||||
description:
|
|
||||||
'Organization ID (optional - for invited users). If not provided, organization data must be provided.',
|
|
||||||
required: false,
|
|
||||||
})
|
|
||||||
@IsString()
|
|
||||||
@IsOptional()
|
|
||||||
organizationId?: string;
|
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
|
||||||
description:
|
|
||||||
'Organization data (required if organizationId and invitationToken are not provided)',
|
|
||||||
type: RegisterOrganizationDto,
|
type: RegisterOrganizationDto,
|
||||||
required: false,
|
required: false,
|
||||||
})
|
})
|
||||||
@ -304,10 +293,11 @@ export class AuthResponseDto {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class RefreshTokenDto {
|
export class RefreshTokenDto {
|
||||||
@ApiProperty({
|
@ApiPropertyOptional({
|
||||||
example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...',
|
example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...',
|
||||||
description: 'Refresh token',
|
description: 'Refresh token (optional — the httpOnly cookie is preferred)',
|
||||||
})
|
})
|
||||||
@IsString()
|
@IsString()
|
||||||
refreshToken: string;
|
@IsOptional()
|
||||||
|
refreshToken?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import {
|
|||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
import * as argon2 from 'argon2';
|
import * as argon2 from 'argon2';
|
||||||
|
import * as crypto from 'crypto';
|
||||||
import { CsvBooking, CsvBookingStatus, DocumentType } from '@domain/entities/csv-booking.entity';
|
import { CsvBooking, CsvBookingStatus, DocumentType } from '@domain/entities/csv-booking.entity';
|
||||||
import { PortCode } from '@domain/value-objects/port-code.vo';
|
import { PortCode } from '@domain/value-objects/port-code.vo';
|
||||||
import { TypeOrmCsvBookingRepository } from '../../infrastructure/persistence/typeorm/repositories/csv-booking.repository';
|
import { TypeOrmCsvBookingRepository } from '../../infrastructure/persistence/typeorm/repositories/csv-booking.repository';
|
||||||
@ -81,17 +82,54 @@ export class CsvBookingService {
|
|||||||
const year = new Date().getFullYear();
|
const year = new Date().getFullYear();
|
||||||
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; // No 0, O, 1, I for clarity
|
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; // No 0, O, 1, I for clarity
|
||||||
let code = '';
|
let code = '';
|
||||||
|
const randomBytes = crypto.randomBytes(6);
|
||||||
for (let i = 0; i < 6; i++) {
|
for (let i = 0; i < 6; i++) {
|
||||||
code += chars.charAt(Math.floor(Math.random() * chars.length));
|
code += chars.charAt(randomBytes[i] % chars.length);
|
||||||
}
|
}
|
||||||
return `XPD-${year}-${code}`;
|
return `XPD-${year}-${code}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extract the password from booking number (last 6 characters)
|
* Derive the document-access password for a booking.
|
||||||
|
*
|
||||||
|
* The password is an HMAC of the booking ID keyed with a server-side secret,
|
||||||
|
* so it can be re-computed when (re)sending carrier emails but cannot be
|
||||||
|
* guessed from any data visible to third parties (unlike the previous
|
||||||
|
* scheme, which reused the last 6 characters of the booking number).
|
||||||
*/
|
*/
|
||||||
private extractPasswordFromBookingNumber(bookingNumber: string): string {
|
private deriveDocumentPassword(bookingId: string): string {
|
||||||
return bookingNumber.split('-').pop() || bookingNumber.slice(-6);
|
const secret = process.env.DOCUMENT_PASSWORD_SECRET || process.env.JWT_SECRET || '';
|
||||||
|
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; // No 0, O, 1, I for clarity
|
||||||
|
const digest = crypto.createHmac('sha256', secret).update(`doc-password:${bookingId}`).digest();
|
||||||
|
let password = '';
|
||||||
|
for (let i = 0; i < 8; i++) {
|
||||||
|
password += chars.charAt(digest[i] % chars.length);
|
||||||
|
}
|
||||||
|
return password;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-sync the stored password hash with the derived password and return the
|
||||||
|
* plaintext for inclusion in the carrier email. Self-heals bookings created
|
||||||
|
* before the HMAC-based scheme.
|
||||||
|
*/
|
||||||
|
private async syncDocumentPassword(bookingId: string): Promise<string> {
|
||||||
|
const password = this.deriveDocumentPassword(bookingId);
|
||||||
|
|
||||||
|
const ormBooking = await this.csvBookingRepository['repository'].findOne({
|
||||||
|
where: { id: bookingId },
|
||||||
|
});
|
||||||
|
if (ormBooking) {
|
||||||
|
const matches = ormBooking.passwordHash
|
||||||
|
? await argon2.verify(ormBooking.passwordHash, password).catch(() => false)
|
||||||
|
: false;
|
||||||
|
if (!matches) {
|
||||||
|
ormBooking.passwordHash = await argon2.hash(password);
|
||||||
|
await this.csvBookingRepository['repository'].save(ormBooking);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return password;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -114,7 +152,7 @@ export class CsvBookingService {
|
|||||||
const confirmationToken = uuidv4();
|
const confirmationToken = uuidv4();
|
||||||
const bookingId = uuidv4();
|
const bookingId = uuidv4();
|
||||||
const bookingNumber = this.generateBookingNumber();
|
const bookingNumber = this.generateBookingNumber();
|
||||||
const documentPassword = this.extractPasswordFromBookingNumber(bookingNumber);
|
const documentPassword = this.deriveDocumentPassword(bookingId);
|
||||||
|
|
||||||
// Hash the password for storage
|
// Hash the password for storage
|
||||||
const passwordHash = await argon2.hash(documentPassword);
|
const passwordHash = await argon2.hash(documentPassword);
|
||||||
@ -292,9 +330,7 @@ export class CsvBookingService {
|
|||||||
where: { id: bookingId },
|
where: { id: bookingId },
|
||||||
});
|
});
|
||||||
const bookingNumber = ormBooking?.bookingNumber;
|
const bookingNumber = ormBooking?.bookingNumber;
|
||||||
const documentPassword = bookingNumber
|
const documentPassword = await this.syncDocumentPassword(booking.id);
|
||||||
? this.extractPasswordFromBookingNumber(bookingNumber)
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
// NOW send email to carrier
|
// NOW send email to carrier
|
||||||
try {
|
try {
|
||||||
@ -464,9 +500,7 @@ export class CsvBookingService {
|
|||||||
where: { id: bookingId },
|
where: { id: bookingId },
|
||||||
});
|
});
|
||||||
const bookingNumber = ormBooking?.bookingNumber;
|
const bookingNumber = ormBooking?.bookingNumber;
|
||||||
const documentPassword = bookingNumber
|
const documentPassword = await this.syncDocumentPassword(booking.id);
|
||||||
? this.extractPasswordFromBookingNumber(bookingNumber)
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
await this.emailAdapter.sendCsvBookingRequest(booking.carrierEmail, {
|
await this.emailAdapter.sendCsvBookingRequest(booking.carrierEmail, {
|
||||||
bookingId: booking.id,
|
bookingId: booking.id,
|
||||||
@ -521,9 +555,7 @@ export class CsvBookingService {
|
|||||||
where: { id: bookingId },
|
where: { id: bookingId },
|
||||||
});
|
});
|
||||||
const bookingNumber = ormBooking?.bookingNumber;
|
const bookingNumber = ormBooking?.bookingNumber;
|
||||||
const documentPassword = bookingNumber
|
const documentPassword = await this.syncDocumentPassword(booking.id);
|
||||||
? this.extractPasswordFromBookingNumber(bookingNumber)
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
// Send email to carrier
|
// Send email to carrier
|
||||||
try {
|
try {
|
||||||
@ -784,9 +816,7 @@ export class CsvBookingService {
|
|||||||
|
|
||||||
// Extract password from booking number for the email
|
// Extract password from booking number for the email
|
||||||
const bookingNumber = ormBooking?.bookingNumber;
|
const bookingNumber = ormBooking?.bookingNumber;
|
||||||
const documentPassword = bookingNumber
|
const documentPassword = await this.syncDocumentPassword(booking.id);
|
||||||
? this.extractPasswordFromBookingNumber(bookingNumber)
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
// Send document access email to carrier
|
// Send document access email to carrier
|
||||||
try {
|
try {
|
||||||
|
|||||||
@ -107,21 +107,6 @@ export const corsConfig = {
|
|||||||
maxAge: 86400, // 24 hours
|
maxAge: 86400, // 24 hours
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Session Configuration
|
|
||||||
*/
|
|
||||||
export const sessionConfig = {
|
|
||||||
secret: process.env.SESSION_SECRET || 'change-this-secret',
|
|
||||||
resave: false,
|
|
||||||
saveUninitialized: false,
|
|
||||||
cookie: {
|
|
||||||
httpOnly: true,
|
|
||||||
secure: process.env.NODE_ENV === 'production', // HTTPS only in production
|
|
||||||
sameSite: 'strict' as const,
|
|
||||||
maxAge: 7200000, // 2 hours
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Password Policy
|
* Password Policy
|
||||||
*/
|
*/
|
||||||
@ -155,19 +140,36 @@ export const fileUploadConfig = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* JWT Configuration
|
* Auth Cookie Configuration
|
||||||
|
*
|
||||||
|
* Tokens are delivered as httpOnly cookies so they are not readable from
|
||||||
|
* JavaScript (XSS mitigation). COOKIE_DOMAIN must be set in production when
|
||||||
|
* the API and the frontend live on different subdomains (e.g. ".xpeditis.com").
|
||||||
*/
|
*/
|
||||||
export const jwtConfig = {
|
export const AUTH_COOKIE_NAMES = {
|
||||||
accessToken: {
|
accessToken: 'accessToken',
|
||||||
secret: process.env.JWT_SECRET || 'change-this-secret',
|
refreshToken: 'refreshToken',
|
||||||
expiresIn: '15m', // 15 minutes
|
/** Non-httpOnly flag the frontend reads to know a session exists (contains no token) */
|
||||||
},
|
session: 'xpeditis_session',
|
||||||
refreshToken: {
|
} as const;
|
||||||
secret: process.env.JWT_REFRESH_SECRET || 'change-this-refresh-secret',
|
|
||||||
expiresIn: '7d', // 7 days
|
export function authCookieOptions(options?: { maxAgeMs?: number; httpOnly?: boolean }): {
|
||||||
},
|
httpOnly: boolean;
|
||||||
algorithm: 'HS256' as const,
|
secure: boolean;
|
||||||
};
|
sameSite: 'lax';
|
||||||
|
path: string;
|
||||||
|
domain?: string;
|
||||||
|
maxAge?: number;
|
||||||
|
} {
|
||||||
|
return {
|
||||||
|
httpOnly: options?.httpOnly ?? true,
|
||||||
|
secure: process.env.NODE_ENV === 'production',
|
||||||
|
sameSite: 'lax',
|
||||||
|
path: '/',
|
||||||
|
...(process.env.COOKIE_DOMAIN ? { domain: process.env.COOKIE_DOMAIN } : {}),
|
||||||
|
...(options?.maxAgeMs ? { maxAge: options.maxAgeMs } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Brute Force Protection
|
* Brute Force Protection
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import { ConfigService } from '@nestjs/config';
|
|||||||
import { I18nService, I18nValidationExceptionFilter, I18nValidationPipe } from 'nestjs-i18n';
|
import { I18nService, I18nValidationExceptionFilter, I18nValidationPipe } from 'nestjs-i18n';
|
||||||
import helmet from 'helmet';
|
import helmet from 'helmet';
|
||||||
import compression from 'compression';
|
import compression from 'compression';
|
||||||
|
import cookieParser from 'cookie-parser';
|
||||||
import { AppModule } from './app.module';
|
import { AppModule } from './app.module';
|
||||||
import { Logger } from 'nestjs-pino';
|
import { Logger } from 'nestjs-pino';
|
||||||
import { helmetConfig, corsConfig } from './infrastructure/security/security.config';
|
import { helmetConfig, corsConfig } from './infrastructure/security/security.config';
|
||||||
@ -33,6 +34,9 @@ async function bootstrap() {
|
|||||||
// Compression for API responses
|
// Compression for API responses
|
||||||
app.use(compression());
|
app.use(compression());
|
||||||
|
|
||||||
|
// Parse cookies (httpOnly auth cookies)
|
||||||
|
app.use(cookieParser());
|
||||||
|
|
||||||
// CORS with strict configuration
|
// CORS with strict configuration
|
||||||
app.enableCors(corsConfig);
|
app.enableCors(corsConfig);
|
||||||
|
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import { ArrowLeft, Calendar, User, Tag, Clock, Share2, BookOpen, Anchor } from
|
|||||||
import { Link } from '@/i18n/navigation';
|
import { Link } from '@/i18n/navigation';
|
||||||
import { LandingHeader, LandingFooter } from '@/components/layout';
|
import { LandingHeader, LandingFooter } from '@/components/layout';
|
||||||
import { getBlogPost, type BlogPost } from '@/lib/api/blog';
|
import { getBlogPost, type BlogPost } from '@/lib/api/blog';
|
||||||
|
import DOMPurify from 'isomorphic-dompurify';
|
||||||
|
|
||||||
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000';
|
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000';
|
||||||
|
|
||||||
@ -107,9 +108,9 @@ export default function BlogPostContent({ slug }: { slug: string }) {
|
|||||||
if (notFound || !post) return <NotFoundView />;
|
if (notFound || !post) return <NotFoundView />;
|
||||||
|
|
||||||
const readingTime = estimateReadingTime(post.content);
|
const readingTime = estimateReadingTime(post.content);
|
||||||
const processedContent = post.content.replace(
|
// Sanitize the admin-authored HTML before rendering (stored-XSS defense in depth)
|
||||||
/src="(\/api\/v1\/blog\/images\/[^"]+)"/g,
|
const processedContent = DOMPurify.sanitize(
|
||||||
`src="${API_BASE_URL}$1"`
|
post.content.replace(/src="(\/api\/v1\/blog\/images\/[^"]+)"/g, `src="${API_BASE_URL}$1"`)
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleShare = () => {
|
const handleShare = () => {
|
||||||
|
|||||||
@ -3,7 +3,6 @@
|
|||||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
import { useTranslations, useLocale } from 'next-intl';
|
import { useTranslations, useLocale } from 'next-intl';
|
||||||
import { listCsvBookings, CsvBookingResponse } from '@/lib/api/bookings';
|
import { listCsvBookings, CsvBookingResponse } from '@/lib/api/bookings';
|
||||||
import { getAuthToken } from '@/lib/api/client';
|
|
||||||
import { FileText, Image as ImageIcon, FileEdit, FileSpreadsheet, Paperclip } from 'lucide-react';
|
import { FileText, Image as ImageIcon, FileEdit, FileSpreadsheet, Paperclip } from 'lucide-react';
|
||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
import ExportButton from '@/components/ExportButton';
|
import ExportButton from '@/components/ExportButton';
|
||||||
@ -278,10 +277,9 @@ export default function UserDocumentsPage() {
|
|||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
addFiles.forEach(file => formData.append('documents', file));
|
addFiles.forEach(file => formData.append('documents', file));
|
||||||
|
|
||||||
const token = getAuthToken();
|
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/csv-bookings/${selectedBookingId}/documents`,
|
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/csv-bookings/${selectedBookingId}/documents`,
|
||||||
{ method: 'POST', headers: { Authorization: `Bearer ${token}` }, body: formData }
|
{ method: 'POST', credentials: 'include', body: formData }
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@ -336,10 +334,9 @@ export default function UserDocumentsPage() {
|
|||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('document', replaceFile);
|
formData.append('document', replaceFile);
|
||||||
|
|
||||||
const token = getAuthToken();
|
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/csv-bookings/${documentToReplace.bookingId}/documents/${documentToReplace.id}`,
|
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/csv-bookings/${documentToReplace.bookingId}/documents/${documentToReplace.id}`,
|
||||||
{ method: 'PATCH', headers: { Authorization: `Bearer ${token}` }, body: formData }
|
{ method: 'PATCH', credentials: 'include', body: formData }
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
|||||||
@ -68,7 +68,14 @@ export default function ForgotPasswordPage() {
|
|||||||
<p
|
<p
|
||||||
className="text-body text-neutral-600"
|
className="text-body text-neutral-600"
|
||||||
dangerouslySetInnerHTML={{
|
dangerouslySetInnerHTML={{
|
||||||
__html: t('successMessage', { email }),
|
__html: t('successMessage', {
|
||||||
|
// Escape the user-typed email before HTML interpolation
|
||||||
|
email: email
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"'),
|
||||||
|
}),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<p className="text-body-sm text-neutral-500 mt-3">{t('successHint')}</p>
|
<p className="text-body-sm text-neutral-500 mt-3">{t('successHint')}</p>
|
||||||
|
|||||||
@ -1,11 +1,11 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect, Suspense } from 'react';
|
||||||
import { useSearchParams } from 'next/navigation';
|
import { useSearchParams } from 'next/navigation';
|
||||||
import { Link, useRouter } from '@/i18n/navigation';
|
import { Link, useRouter } from '@/i18n/navigation';
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
|
|
||||||
export default function VerifyEmailPage() {
|
function VerifyEmailContent() {
|
||||||
const t = useTranslations('auth.verifyEmail');
|
const t = useTranslations('auth.verifyEmail');
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@ -140,3 +140,12 @@ export default function VerifyEmailPage() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default function VerifyEmailPage() {
|
||||||
|
// useSearchParams() requires a Suspense boundary for prerendering (Next 14.2+)
|
||||||
|
return (
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<VerifyEmailContent />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
6
apps/frontend/app/api/health/route.ts
Normal file
6
apps/frontend/app/api/health/route.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
/**
|
||||||
|
* Health check endpoint used by the Docker HEALTHCHECK.
|
||||||
|
*/
|
||||||
|
export function GET() {
|
||||||
|
return Response.json({ status: 'ok', timestamp: new Date().toISOString() });
|
||||||
|
}
|
||||||
829
apps/frontend/package-lock.json
generated
829
apps/frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -42,9 +42,10 @@
|
|||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"file-saver": "^2.0.5",
|
"file-saver": "^2.0.5",
|
||||||
"framer-motion": "^12.23.24",
|
"framer-motion": "^12.23.24",
|
||||||
|
"isomorphic-dompurify": "^3.16.0",
|
||||||
"leaflet": "^1.9.4",
|
"leaflet": "^1.9.4",
|
||||||
"lucide-react": "^0.294.0",
|
"lucide-react": "^0.294.0",
|
||||||
"next": "14.0.4",
|
"next": "^14.2.35",
|
||||||
"next-intl": "^4.9.1",
|
"next-intl": "^4.9.1",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
|
|||||||
@ -41,9 +41,7 @@ export function useBookings(initialFilters?: BookingFilters) {
|
|||||||
queryParams.append('pageSize', String(filters.pageSize || 20));
|
queryParams.append('pageSize', String(filters.pageSize || 20));
|
||||||
|
|
||||||
const response = await fetch(`/api/v1/bookings/advanced/search?${queryParams.toString()}`, {
|
const response = await fetch(`/api/v1/bookings/advanced/search?${queryParams.toString()}`, {
|
||||||
headers: {
|
credentials: 'include',
|
||||||
Authorization: `Bearer ${localStorage.getItem('accessToken')}`,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@ -102,9 +100,9 @@ export function useBookings(initialFilters?: BookingFilters) {
|
|||||||
try {
|
try {
|
||||||
const response = await fetch('/api/v1/bookings/export', {
|
const response = await fetch('/api/v1/bookings/export', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
Authorization: `Bearer ${localStorage.getItem('accessToken')}`,
|
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
format: options.format,
|
format: options.format,
|
||||||
|
|||||||
@ -108,11 +108,7 @@ export async function exportAuditLogs(params?: {
|
|||||||
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/audit/export?${queryParams.toString()}`,
|
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/audit/export?${queryParams.toString()}`,
|
||||||
{
|
{
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
headers: {
|
credentials: 'include',
|
||||||
Authorization: `Bearer ${
|
|
||||||
typeof window !== 'undefined' ? localStorage.getItem('access_token') : ''
|
|
||||||
}`,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@ -4,7 +4,7 @@
|
|||||||
* Endpoints for user authentication and session management
|
* Endpoints for user authentication and session management
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { get, post, setAuthTokens, clearAuthTokens } from './client';
|
import { get, post, clearAuthTokens } from './client';
|
||||||
import type {
|
import type {
|
||||||
RegisterRequest,
|
RegisterRequest,
|
||||||
LoginRequest,
|
LoginRequest,
|
||||||
@ -19,12 +19,8 @@ import type {
|
|||||||
* POST /api/v1/auth/register
|
* POST /api/v1/auth/register
|
||||||
*/
|
*/
|
||||||
export async function register(data: RegisterRequest): Promise<AuthResponse> {
|
export async function register(data: RegisterRequest): Promise<AuthResponse> {
|
||||||
const response = await post<AuthResponse>('/api/v1/auth/register', data, false);
|
// Tokens are delivered as httpOnly cookies by the backend
|
||||||
|
return post<AuthResponse>('/api/v1/auth/register', data, false);
|
||||||
// Store tokens
|
|
||||||
setAuthTokens(response.accessToken, response.refreshToken);
|
|
||||||
|
|
||||||
return response;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -32,21 +28,18 @@ export async function register(data: RegisterRequest): Promise<AuthResponse> {
|
|||||||
* POST /api/v1/auth/login
|
* POST /api/v1/auth/login
|
||||||
*/
|
*/
|
||||||
export async function login(data: LoginRequest & { rememberMe?: boolean }): Promise<AuthResponse> {
|
export async function login(data: LoginRequest & { rememberMe?: boolean }): Promise<AuthResponse> {
|
||||||
const { rememberMe, ...loginData } = data;
|
// Tokens are delivered as httpOnly cookies by the backend;
|
||||||
const response = await post<AuthResponse>('/api/v1/auth/login', loginData, false);
|
// rememberMe drives cookie persistence server-side
|
||||||
|
return post<AuthResponse>('/api/v1/auth/login', data, false);
|
||||||
// Store tokens — localStorage if rememberMe, sessionStorage otherwise
|
|
||||||
setAuthTokens(response.accessToken, response.refreshToken, rememberMe ?? false);
|
|
||||||
|
|
||||||
return response;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Refresh access token
|
* Refresh access token
|
||||||
* POST /api/v1/auth/refresh
|
* POST /api/v1/auth/refresh
|
||||||
*/
|
*/
|
||||||
export async function refreshToken(data: RefreshTokenRequest): Promise<{ accessToken: string }> {
|
export async function refreshToken(data?: RefreshTokenRequest): Promise<{ success: boolean }> {
|
||||||
return post<{ accessToken: string }>('/api/v1/auth/refresh', data, false);
|
// The backend reads the httpOnly refresh cookie and rotates the cookies
|
||||||
|
return post<{ success: boolean }>('/api/v1/auth/refresh', data ?? {}, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -181,11 +181,7 @@ export async function exportBookings(params: {
|
|||||||
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/bookings/export?${queryParams.toString()}`,
|
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/bookings/export?${queryParams.toString()}`,
|
||||||
{
|
{
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
headers: {
|
credentials: 'include',
|
||||||
Authorization: `Bearer ${
|
|
||||||
typeof window !== 'undefined' ? localStorage.getItem('access_token') : ''
|
|
||||||
}`,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@ -1,47 +1,37 @@
|
|||||||
/**
|
/**
|
||||||
* API Client Base
|
* API Client Base
|
||||||
*
|
*
|
||||||
* Core HTTP client with authentication and error handling
|
* Core HTTP client with authentication and error handling.
|
||||||
|
*
|
||||||
|
* Authentication relies on httpOnly cookies set by the backend
|
||||||
|
* (`accessToken` / `refreshToken`), so no token is ever stored in
|
||||||
|
* localStorage or readable from JavaScript (XSS mitigation).
|
||||||
|
* The non-httpOnly `xpeditis_session` flag cookie (which contains no
|
||||||
|
* token) tells the frontend whether a session exists.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000';
|
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000';
|
||||||
|
|
||||||
|
const SESSION_FLAG_COOKIE = 'xpeditis_session';
|
||||||
|
|
||||||
// Track if we're currently refreshing to avoid multiple simultaneous refresh requests
|
// Track if we're currently refreshing to avoid multiple simultaneous refresh requests
|
||||||
let isRefreshing = false;
|
let isRefreshing = false;
|
||||||
let refreshSubscribers: Array<(token: string) => void> = [];
|
let refreshSubscribers: Array<() => void> = [];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get authentication token — checks localStorage first (remember me), then sessionStorage
|
* Whether an authenticated session exists (based on the readable flag cookie
|
||||||
|
* set by the backend alongside the httpOnly token cookies).
|
||||||
*/
|
*/
|
||||||
export function getAuthToken(): string | null {
|
export function hasSession(): boolean {
|
||||||
if (typeof window === 'undefined') return null;
|
if (typeof document === 'undefined') return false;
|
||||||
return localStorage.getItem('access_token') || sessionStorage.getItem('access_token');
|
return document.cookie.split('; ').some(cookie => cookie.startsWith(`${SESSION_FLAG_COOKIE}=`));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get refresh token — checks localStorage first (remember me), then sessionStorage
|
* Clear client-side auth state.
|
||||||
*/
|
* Token cookies are httpOnly and are cleared by the backend on logout;
|
||||||
export function getRefreshToken(): string | null {
|
* this removes the user cache and any tokens left over from the legacy
|
||||||
if (typeof window === 'undefined') return null;
|
* localStorage-based auth.
|
||||||
return localStorage.getItem('refresh_token') || sessionStorage.getItem('refresh_token');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set authentication tokens.
|
|
||||||
* rememberMe=true → localStorage (persists across browser sessions)
|
|
||||||
* rememberMe=false → sessionStorage (cleared when browser closes)
|
|
||||||
*/
|
|
||||||
export function setAuthTokens(accessToken: string, refreshToken: string, rememberMe = false): void {
|
|
||||||
if (typeof window === 'undefined') return;
|
|
||||||
const storage = rememberMe ? localStorage : sessionStorage;
|
|
||||||
storage.setItem('access_token', accessToken);
|
|
||||||
storage.setItem('refresh_token', refreshToken);
|
|
||||||
// Sync to cookie so Next.js middleware can read it for route protection
|
|
||||||
document.cookie = `accessToken=${accessToken}; path=/; SameSite=Lax`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Clear authentication tokens from both storages
|
|
||||||
*/
|
*/
|
||||||
export function clearAuthTokens(): void {
|
export function clearAuthTokens(): void {
|
||||||
if (typeof window === 'undefined') return;
|
if (typeof window === 'undefined') return;
|
||||||
@ -51,41 +41,39 @@ export function clearAuthTokens(): void {
|
|||||||
sessionStorage.removeItem('access_token');
|
sessionStorage.removeItem('access_token');
|
||||||
sessionStorage.removeItem('refresh_token');
|
sessionStorage.removeItem('refresh_token');
|
||||||
sessionStorage.removeItem('user');
|
sessionStorage.removeItem('user');
|
||||||
// Expire the middleware cookie
|
// Expire the legacy middleware cookie and the session flag (best effort —
|
||||||
|
// the backend clears the authoritative httpOnly cookies)
|
||||||
document.cookie = 'accessToken=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT; SameSite=Lax';
|
document.cookie = 'accessToken=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT; SameSite=Lax';
|
||||||
|
document.cookie = `${SESSION_FLAG_COOKIE}=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT; SameSite=Lax`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Add subscriber to be notified when token is refreshed
|
* Add subscriber to be notified when the session is refreshed
|
||||||
*/
|
*/
|
||||||
function subscribeTokenRefresh(callback: (token: string) => void): void {
|
function subscribeTokenRefresh(callback: () => void): void {
|
||||||
refreshSubscribers.push(callback);
|
refreshSubscribers.push(callback);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Notify all subscribers that token has been refreshed
|
* Notify all subscribers that the session has been refreshed
|
||||||
*/
|
*/
|
||||||
function onTokenRefreshed(token: string): void {
|
function onTokenRefreshed(): void {
|
||||||
refreshSubscribers.forEach(callback => callback(token));
|
refreshSubscribers.forEach(callback => callback());
|
||||||
refreshSubscribers = [];
|
refreshSubscribers = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Refresh access token using refresh token
|
* Refresh the session — the backend reads the httpOnly refresh cookie and
|
||||||
|
* sets new token cookies on success.
|
||||||
*/
|
*/
|
||||||
async function refreshAccessToken(): Promise<string> {
|
async function refreshSession(): Promise<void> {
|
||||||
const refreshToken = getRefreshToken();
|
|
||||||
|
|
||||||
if (!refreshToken) {
|
|
||||||
throw new Error('No refresh token available');
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await fetch(`${API_BASE_URL}/api/v1/auth/refresh`, {
|
const response = await fetch(`${API_BASE_URL}/api/v1/auth/refresh`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ refreshToken }),
|
body: JSON.stringify({}),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@ -95,54 +83,24 @@ async function refreshAccessToken(): Promise<string> {
|
|||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
window.location.href = '/login';
|
window.location.href = '/login';
|
||||||
}
|
}
|
||||||
throw new Error('Failed to refresh token');
|
throw new Error('Failed to refresh session');
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
const newAccessToken = data.accessToken;
|
|
||||||
|
|
||||||
// Update access token in the same storage that holds the refresh token
|
|
||||||
if (typeof window !== 'undefined') {
|
|
||||||
const storage = localStorage.getItem('refresh_token') ? localStorage : sessionStorage;
|
|
||||||
storage.setItem('access_token', newAccessToken);
|
|
||||||
document.cookie = `accessToken=${newAccessToken}; path=/; SameSite=Lax`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return newAccessToken;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create headers with authentication
|
* Create headers (auth is carried by httpOnly cookies, not headers)
|
||||||
*/
|
*/
|
||||||
export function createHeaders(includeAuth = true): HeadersInit {
|
export function createHeaders(_includeAuth = true): HeadersInit {
|
||||||
const headers: HeadersInit = {
|
return {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
};
|
};
|
||||||
|
|
||||||
if (includeAuth) {
|
|
||||||
const token = getAuthToken();
|
|
||||||
if (token) {
|
|
||||||
headers['Authorization'] = `Bearer ${token}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return headers;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create headers for multipart form data
|
* Create headers for multipart form data
|
||||||
*/
|
*/
|
||||||
export function createMultipartHeaders(includeAuth = true): HeadersInit {
|
export function createMultipartHeaders(_includeAuth = true): HeadersInit {
|
||||||
const headers: HeadersInit = {};
|
return {};
|
||||||
|
|
||||||
if (includeAuth) {
|
|
||||||
const token = getAuthToken();
|
|
||||||
if (token) {
|
|
||||||
headers['Authorization'] = `Bearer ${token}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return headers;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -160,7 +118,7 @@ export class ApiError extends Error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Make API request with automatic token refresh on 401
|
* Make API request with automatic session refresh on 401
|
||||||
*/
|
*/
|
||||||
export async function apiRequest<T>(
|
export async function apiRequest<T>(
|
||||||
endpoint: string,
|
endpoint: string,
|
||||||
@ -171,6 +129,7 @@ export async function apiRequest<T>(
|
|||||||
|
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
...options,
|
...options,
|
||||||
|
credentials: 'include',
|
||||||
headers: {
|
headers: {
|
||||||
...options.headers,
|
...options.headers,
|
||||||
},
|
},
|
||||||
@ -184,10 +143,8 @@ export async function apiRequest<T>(
|
|||||||
endpoint.includes('/auth/refresh');
|
endpoint.includes('/auth/refresh');
|
||||||
|
|
||||||
if (response.status === 401 && !isRetry && !isAuthEndpoint) {
|
if (response.status === 401 && !isRetry && !isAuthEndpoint) {
|
||||||
// Check if we have a refresh token
|
if (!hasSession()) {
|
||||||
const refreshToken = getRefreshToken();
|
// No session, redirect to login
|
||||||
if (!refreshToken) {
|
|
||||||
// No refresh token, redirect to login
|
|
||||||
clearAuthTokens();
|
clearAuthTokens();
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
window.location.href = '/login';
|
window.location.href = '/login';
|
||||||
@ -195,46 +152,21 @@ export async function apiRequest<T>(
|
|||||||
throw new ApiError('Session expired', 401);
|
throw new ApiError('Session expired', 401);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try to refresh the token
|
// Try to refresh the session (cookies are updated server-side)
|
||||||
try {
|
try {
|
||||||
if (!isRefreshing) {
|
if (!isRefreshing) {
|
||||||
isRefreshing = true;
|
isRefreshing = true;
|
||||||
const newAccessToken = await refreshAccessToken();
|
await refreshSession();
|
||||||
isRefreshing = false;
|
isRefreshing = false;
|
||||||
onTokenRefreshed(newAccessToken);
|
onTokenRefreshed();
|
||||||
|
|
||||||
// Retry the original request with new token
|
return apiRequest<T>(endpoint, options, true);
|
||||||
const newHeaders = { ...options.headers };
|
|
||||||
if (newHeaders && typeof newHeaders === 'object' && 'Authorization' in newHeaders) {
|
|
||||||
(newHeaders as any)['Authorization'] = `Bearer ${newAccessToken}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return apiRequest<T>(
|
|
||||||
endpoint,
|
|
||||||
{
|
|
||||||
...options,
|
|
||||||
headers: newHeaders,
|
|
||||||
},
|
|
||||||
true
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
// Already refreshing, wait for the new token
|
// Already refreshing, wait for it to complete
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
subscribeTokenRefresh(async (newAccessToken: string) => {
|
subscribeTokenRefresh(async () => {
|
||||||
const newHeaders = { ...options.headers };
|
|
||||||
if (newHeaders && typeof newHeaders === 'object' && 'Authorization' in newHeaders) {
|
|
||||||
(newHeaders as any)['Authorization'] = `Bearer ${newAccessToken}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await apiRequest<T>(
|
const result = await apiRequest<T>(endpoint, options, true);
|
||||||
endpoint,
|
|
||||||
{
|
|
||||||
...options,
|
|
||||||
headers: newHeaders,
|
|
||||||
},
|
|
||||||
true
|
|
||||||
);
|
|
||||||
resolve(result);
|
resolve(result);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
reject(error);
|
reject(error);
|
||||||
@ -320,23 +252,20 @@ export async function upload<T>(
|
|||||||
|
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
headers: createMultipartHeaders(includeAuth),
|
headers: createMultipartHeaders(includeAuth),
|
||||||
body: formData,
|
body: formData,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Handle 401 Unauthorized for file uploads
|
// Handle 401 Unauthorized for file uploads
|
||||||
if (response.status === 401) {
|
if (response.status === 401 && hasSession()) {
|
||||||
const refreshToken = getRefreshToken();
|
|
||||||
if (refreshToken) {
|
|
||||||
try {
|
try {
|
||||||
const newAccessToken = await refreshAccessToken();
|
await refreshSession();
|
||||||
// Retry upload with new token
|
// Retry upload with refreshed cookies
|
||||||
const retryResponse = await fetch(url, {
|
const retryResponse = await fetch(url, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
credentials: 'include',
|
||||||
...createMultipartHeaders(includeAuth),
|
headers: createMultipartHeaders(includeAuth),
|
||||||
Authorization: `Bearer ${newAccessToken}`,
|
|
||||||
},
|
|
||||||
body: formData,
|
body: formData,
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -358,7 +287,6 @@ export async function upload<T>(
|
|||||||
throw refreshError;
|
throw refreshError;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const error = await response.json().catch(() => ({}));
|
const error = await response.json().catch(() => ({}));
|
||||||
@ -384,22 +312,19 @@ export async function download(
|
|||||||
|
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
|
credentials: 'include',
|
||||||
headers: createHeaders(includeAuth),
|
headers: createHeaders(includeAuth),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Handle 401 Unauthorized for downloads
|
// Handle 401 Unauthorized for downloads
|
||||||
if (response.status === 401) {
|
if (response.status === 401 && hasSession()) {
|
||||||
const refreshToken = getRefreshToken();
|
|
||||||
if (refreshToken) {
|
|
||||||
try {
|
try {
|
||||||
const newAccessToken = await refreshAccessToken();
|
await refreshSession();
|
||||||
// Retry download with new token
|
// Retry download with refreshed cookies
|
||||||
const retryResponse = await fetch(url, {
|
const retryResponse = await fetch(url, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
headers: {
|
credentials: 'include',
|
||||||
...createHeaders(includeAuth),
|
headers: createHeaders(includeAuth),
|
||||||
Authorization: `Bearer ${newAccessToken}`,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!retryResponse.ok) {
|
if (!retryResponse.ok) {
|
||||||
@ -424,7 +349,6 @@ export async function download(
|
|||||||
throw refreshError;
|
throw refreshError;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new ApiError(`Download failed: ${response.statusText}`, response.status);
|
throw new ApiError(`Download failed: ${response.statusText}`, response.status);
|
||||||
|
|||||||
@ -14,27 +14,12 @@ import {
|
|||||||
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000';
|
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get authentication token from localStorage
|
* Create headers — auth is carried by httpOnly cookies (credentials: 'include')
|
||||||
*/
|
|
||||||
function getAuthToken(): string | null {
|
|
||||||
if (typeof window === 'undefined') return null;
|
|
||||||
return localStorage.getItem('access_token');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create headers with authentication
|
|
||||||
*/
|
*/
|
||||||
function createHeaders(): HeadersInit {
|
function createHeaders(): HeadersInit {
|
||||||
const headers: HeadersInit = {
|
return {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
};
|
};
|
||||||
|
|
||||||
const token = getAuthToken();
|
|
||||||
if (token) {
|
|
||||||
headers['Authorization'] = `Bearer ${token}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return headers;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -45,6 +30,7 @@ export async function searchCsvRates(
|
|||||||
): Promise<CsvRateSearchResponse> {
|
): Promise<CsvRateSearchResponse> {
|
||||||
const response = await fetch(`${API_BASE_URL}/api/v1/rates/search-csv`, {
|
const response = await fetch(`${API_BASE_URL}/api/v1/rates/search-csv`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
headers: createHeaders(),
|
headers: createHeaders(),
|
||||||
body: JSON.stringify(request),
|
body: JSON.stringify(request),
|
||||||
});
|
});
|
||||||
@ -63,6 +49,7 @@ export async function searchCsvRates(
|
|||||||
export async function getAvailableCompanies(): Promise<AvailableCompanies> {
|
export async function getAvailableCompanies(): Promise<AvailableCompanies> {
|
||||||
const response = await fetch(`${API_BASE_URL}/api/v1/rates/companies`, {
|
const response = await fetch(`${API_BASE_URL}/api/v1/rates/companies`, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
|
credentials: 'include',
|
||||||
headers: createHeaders(),
|
headers: createHeaders(),
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -79,6 +66,7 @@ export async function getAvailableCompanies(): Promise<AvailableCompanies> {
|
|||||||
export async function getFilterOptions(): Promise<FilterOptions> {
|
export async function getFilterOptions(): Promise<FilterOptions> {
|
||||||
const response = await fetch(`${API_BASE_URL}/api/v1/rates/filters/options`, {
|
const response = await fetch(`${API_BASE_URL}/api/v1/rates/filters/options`, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
|
credentials: 'include',
|
||||||
headers: createHeaders(),
|
headers: createHeaders(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -53,11 +53,7 @@ export interface GdprDataExportResponse {
|
|||||||
export async function requestDataExport(): Promise<Blob> {
|
export async function requestDataExport(): Promise<Blob> {
|
||||||
const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/api/v1/gdpr/export`, {
|
const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/api/v1/gdpr/export`, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
headers: {
|
credentials: 'include',
|
||||||
Authorization: `Bearer ${
|
|
||||||
typeof window !== 'undefined' ? localStorage.getItem('accessToken') : ''
|
|
||||||
}`,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@ -74,11 +70,7 @@ export async function requestDataExport(): Promise<Blob> {
|
|||||||
export async function requestDataExportCSV(): Promise<Blob> {
|
export async function requestDataExportCSV(): Promise<Blob> {
|
||||||
const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/api/v1/gdpr/export/csv`, {
|
const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/api/v1/gdpr/export/csv`, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
headers: {
|
credentials: 'include',
|
||||||
Authorization: `Bearer ${
|
|
||||||
typeof window !== 'undefined' ? localStorage.getItem('accessToken') : ''
|
|
||||||
}`,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@ -96,11 +88,9 @@ export async function requestDataExportCSV(): Promise<Blob> {
|
|||||||
export async function requestAccountDeletion(confirmEmail: string, reason?: string): Promise<void> {
|
export async function requestAccountDeletion(confirmEmail: string, reason?: string): Promise<void> {
|
||||||
const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/api/v1/gdpr/delete-account`, {
|
const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/api/v1/gdpr/delete-account`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
|
credentials: 'include',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
Authorization: `Bearer ${
|
|
||||||
typeof window !== 'undefined' ? localStorage.getItem('accessToken') : ''
|
|
||||||
}`,
|
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ confirmEmail, reason }),
|
body: JSON.stringify({ confirmEmail, reason }),
|
||||||
});
|
});
|
||||||
|
|||||||
@ -10,8 +10,7 @@
|
|||||||
|
|
||||||
// Base client utilities
|
// Base client utilities
|
||||||
export {
|
export {
|
||||||
getAuthToken,
|
hasSession,
|
||||||
setAuthTokens,
|
|
||||||
clearAuthTokens,
|
clearAuthTokens,
|
||||||
createHeaders,
|
createHeaders,
|
||||||
apiRequest,
|
apiRequest,
|
||||||
|
|||||||
@ -34,12 +34,6 @@ export interface PortSearchResponse {
|
|||||||
* Search ports by query (autocomplete)
|
* Search ports by query (autocomplete)
|
||||||
*/
|
*/
|
||||||
export async function searchPorts(params: PortSearchParams): Promise<PortSearchResponse> {
|
export async function searchPorts(params: PortSearchParams): Promise<PortSearchResponse> {
|
||||||
// Use the same key as the rest of the app: 'access_token' with underscore
|
|
||||||
const token = typeof window !== 'undefined' ? localStorage.getItem('access_token') : null;
|
|
||||||
if (!token) {
|
|
||||||
throw new Error('No access token found');
|
|
||||||
}
|
|
||||||
|
|
||||||
const queryParams = new URLSearchParams({
|
const queryParams = new URLSearchParams({
|
||||||
query: params.query,
|
query: params.query,
|
||||||
});
|
});
|
||||||
@ -54,9 +48,9 @@ export async function searchPorts(params: PortSearchParams): Promise<PortSearchR
|
|||||||
|
|
||||||
const response = await fetch(`${API_BASE_URL}/api/v1/ports/search?${queryParams.toString()}`, {
|
const response = await fetch(`${API_BASE_URL}/api/v1/ports/search?${queryParams.toString()}`, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
|
credentials: 'include',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -14,7 +14,7 @@ import {
|
|||||||
logout as apiLogout,
|
logout as apiLogout,
|
||||||
getCurrentUser,
|
getCurrentUser,
|
||||||
} from '../api/auth';
|
} from '../api/auth';
|
||||||
import { getAuthToken } from '../api/client';
|
import { hasSession, clearAuthTokens } from '../api/client';
|
||||||
import type { UserPayload } from '@/types/api';
|
import type { UserPayload } from '@/types/api';
|
||||||
|
|
||||||
interface AuthContextType {
|
interface AuthContextType {
|
||||||
@ -45,16 +45,9 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
// Helper function to check if user is authenticated
|
// Helper function to check if user is authenticated (session flag cookie)
|
||||||
const isAuthenticated = () => {
|
const isAuthenticated = () => {
|
||||||
return !!getAuthToken();
|
return hasSession();
|
||||||
};
|
|
||||||
|
|
||||||
// Helper function to get stored user
|
|
||||||
const getStoredUser = (): UserPayload | null => {
|
|
||||||
if (typeof window === 'undefined') return null;
|
|
||||||
const storedUser = localStorage.getItem('user');
|
|
||||||
return storedUser ? JSON.parse(storedUser) : null;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -62,31 +55,20 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
const checkAuth = async () => {
|
const checkAuth = async () => {
|
||||||
try {
|
try {
|
||||||
if (isAuthenticated()) {
|
if (isAuthenticated()) {
|
||||||
// Try to fetch current user from API (will auto-refresh token if expired)
|
// Try to fetch current user from API (will auto-refresh the session if expired)
|
||||||
try {
|
try {
|
||||||
const currentUser = await getCurrentUser();
|
const currentUser = await getCurrentUser();
|
||||||
setUser(currentUser);
|
setUser(currentUser);
|
||||||
// Update stored user
|
|
||||||
localStorage.setItem('user', JSON.stringify(currentUser));
|
|
||||||
} catch (apiError) {
|
} catch (apiError) {
|
||||||
console.error('Failed to fetch user from API:', apiError);
|
console.error('Failed to fetch user from API:', apiError);
|
||||||
// If API fails after token refresh attempt, clear everything
|
// If API fails after session refresh attempt, clear everything
|
||||||
if (typeof window !== 'undefined') {
|
clearAuthTokens();
|
||||||
localStorage.removeItem('access_token');
|
|
||||||
localStorage.removeItem('refresh_token');
|
|
||||||
localStorage.removeItem('user');
|
|
||||||
}
|
|
||||||
setUser(null);
|
setUser(null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Auth check failed, clearing tokens:', error);
|
console.error('Auth check failed, clearing session:', error);
|
||||||
// Token invalid or no user data, clear storage
|
clearAuthTokens();
|
||||||
if (typeof window !== 'undefined') {
|
|
||||||
localStorage.removeItem('access_token');
|
|
||||||
localStorage.removeItem('refresh_token');
|
|
||||||
localStorage.removeItem('user');
|
|
||||||
}
|
|
||||||
setUser(null);
|
setUser(null);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@ -122,14 +104,9 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
) => {
|
) => {
|
||||||
try {
|
try {
|
||||||
await apiLogin({ email, password, rememberMe });
|
await apiLogin({ email, password, rememberMe });
|
||||||
// Fetch complete user profile after login
|
// Fetch complete user profile after login (session lives in httpOnly cookies)
|
||||||
const currentUser = await getCurrentUser();
|
const currentUser = await getCurrentUser();
|
||||||
setUser(currentUser);
|
setUser(currentUser);
|
||||||
// Store user in the same storage as the tokens
|
|
||||||
if (typeof window !== 'undefined') {
|
|
||||||
const storage = rememberMe ? localStorage : sessionStorage;
|
|
||||||
storage.setItem('user', JSON.stringify(currentUser));
|
|
||||||
}
|
|
||||||
router.push(redirectTo);
|
router.push(redirectTo);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw error;
|
throw error;
|
||||||
@ -144,14 +121,10 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
organizationId: string;
|
organizationId: string;
|
||||||
}) => {
|
}) => {
|
||||||
try {
|
try {
|
||||||
const response = await apiRegister(data);
|
await apiRegister(data);
|
||||||
// Fetch complete user profile after registration
|
// Fetch complete user profile after registration
|
||||||
const currentUser = await getCurrentUser();
|
const currentUser = await getCurrentUser();
|
||||||
setUser(currentUser);
|
setUser(currentUser);
|
||||||
// Store user in localStorage
|
|
||||||
if (typeof window !== 'undefined') {
|
|
||||||
localStorage.setItem('user', JSON.stringify(currentUser));
|
|
||||||
}
|
|
||||||
router.push('/dashboard');
|
router.push('/dashboard');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw error;
|
throw error;
|
||||||
@ -163,10 +136,6 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
await apiLogout();
|
await apiLogout();
|
||||||
} finally {
|
} finally {
|
||||||
setUser(null);
|
setUser(null);
|
||||||
// Clear user from localStorage
|
|
||||||
if (typeof window !== 'undefined') {
|
|
||||||
localStorage.removeItem('user');
|
|
||||||
}
|
|
||||||
router.push('/login');
|
router.push('/login');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -175,9 +144,6 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
try {
|
try {
|
||||||
const currentUser = await getCurrentUser();
|
const currentUser = await getCurrentUser();
|
||||||
setUser(currentUser);
|
setUser(currentUser);
|
||||||
if (typeof window !== 'undefined') {
|
|
||||||
localStorage.setItem('user', JSON.stringify(currentUser));
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to refresh user:', error);
|
console.error('Failed to refresh user:', error);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -13,7 +13,7 @@ import {
|
|||||||
updateConsentPreferences,
|
updateConsentPreferences,
|
||||||
type CookiePreferences,
|
type CookiePreferences,
|
||||||
} from '../api/gdpr';
|
} from '../api/gdpr';
|
||||||
import { getAuthToken } from '../api/client';
|
import { hasSession } from '../api/client';
|
||||||
|
|
||||||
const STORAGE_KEY = 'cookieConsent';
|
const STORAGE_KEY = 'cookieConsent';
|
||||||
const STORAGE_DATE_KEY = 'cookieConsentDate';
|
const STORAGE_DATE_KEY = 'cookieConsentDate';
|
||||||
@ -49,7 +49,7 @@ export function CookieProvider({ children }: { children: React.ReactNode }) {
|
|||||||
|
|
||||||
// Check if user is authenticated
|
// Check if user is authenticated
|
||||||
const isAuthenticated = useCallback(() => {
|
const isAuthenticated = useCallback(() => {
|
||||||
return !!getAuthToken();
|
return hasSession();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Load preferences from localStorage
|
// Load preferences from localStorage
|
||||||
|
|||||||
@ -27,7 +27,6 @@ export interface RegisterRequest {
|
|||||||
firstName: string;
|
firstName: string;
|
||||||
lastName: string;
|
lastName: string;
|
||||||
invitationToken?: string; // For invited users (token-based)
|
invitationToken?: string; // For invited users (token-based)
|
||||||
organizationId?: string; // For invited users (ID-based)
|
|
||||||
organization?: RegisterOrganizationData; // For new users
|
organization?: RegisterOrganizationData; // For new users
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -103,8 +103,8 @@ services:
|
|||||||
REDIS_PASSWORD: xpeditis_redis_password
|
REDIS_PASSWORD: xpeditis_redis_password
|
||||||
REDIS_DB: 0
|
REDIS_DB: 0
|
||||||
|
|
||||||
# JWT
|
# JWT (must be at least 32 characters — enforced at startup)
|
||||||
JWT_SECRET: dev-secret-jwt-key-for-docker
|
JWT_SECRET: dev-secret-jwt-key-for-docker-local-only-0123456789
|
||||||
JWT_ACCESS_EXPIRATION: 15m
|
JWT_ACCESS_EXPIRATION: 15m
|
||||||
JWT_REFRESH_EXPIRATION: 7d
|
JWT_REFRESH_EXPIRATION: 7d
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user