chore: import DH V2 D5.6.4 production baseline
This commit is contained in:
@@ -0,0 +1,90 @@
|
||||
import { randomBytes, timingSafeEqual } from 'node:crypto';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import type { Request, Response } from 'express';
|
||||
import { AuthConfigService } from '../../common/config/auth-config.service';
|
||||
|
||||
@Injectable()
|
||||
export class CookieService {
|
||||
constructor(private readonly config: AuthConfigService) {}
|
||||
|
||||
setAuthCookies(
|
||||
response: Response,
|
||||
accessToken: string,
|
||||
refreshToken: string,
|
||||
): string {
|
||||
response.cookie(this.config.accessCookieName, accessToken, {
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
sameSite: 'strict',
|
||||
path: '/',
|
||||
maxAge: this.config.accessTokenTtlSeconds * 1000,
|
||||
});
|
||||
response.cookie(this.config.refreshCookieName, refreshToken, {
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
sameSite: 'strict',
|
||||
path: '/api/v3/auth',
|
||||
maxAge: this.config.refreshTokenTtlSeconds * 1000,
|
||||
});
|
||||
return this.rotateCsrfCookie(response);
|
||||
}
|
||||
|
||||
rotateCsrfCookie(response: Response): string {
|
||||
const csrfToken = randomBytes(32).toString('base64url');
|
||||
response.cookie(this.config.csrfCookieName, csrfToken, {
|
||||
httpOnly: false,
|
||||
secure: true,
|
||||
sameSite: 'strict',
|
||||
path: '/',
|
||||
maxAge: this.config.refreshTokenTtlSeconds * 1000,
|
||||
});
|
||||
return csrfToken;
|
||||
}
|
||||
|
||||
clearAuthCookies(response: Response): void {
|
||||
response.clearCookie(this.config.accessCookieName, {
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
sameSite: 'strict',
|
||||
path: '/',
|
||||
});
|
||||
response.clearCookie(this.config.refreshCookieName, {
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
sameSite: 'strict',
|
||||
path: '/api/v3/auth',
|
||||
});
|
||||
response.clearCookie(this.config.csrfCookieName, {
|
||||
httpOnly: false,
|
||||
secure: true,
|
||||
sameSite: 'strict',
|
||||
path: '/',
|
||||
});
|
||||
}
|
||||
|
||||
getAccessToken(request: Request): string | undefined {
|
||||
return this.readCookie(request, this.config.accessCookieName);
|
||||
}
|
||||
|
||||
getRefreshToken(request: Request): string | undefined {
|
||||
return this.readCookie(request, this.config.refreshCookieName);
|
||||
}
|
||||
|
||||
csrfMatches(request: Request): boolean {
|
||||
const cookieValue = this.readCookie(request, this.config.csrfCookieName);
|
||||
const headerValue = request.header('x-csrf-token');
|
||||
if (!cookieValue || !headerValue) return false;
|
||||
const cookieBuffer = Buffer.from(cookieValue, 'utf8');
|
||||
const headerBuffer = Buffer.from(headerValue, 'utf8');
|
||||
return (
|
||||
cookieBuffer.length === headerBuffer.length &&
|
||||
timingSafeEqual(cookieBuffer, headerBuffer)
|
||||
);
|
||||
}
|
||||
|
||||
private readCookie(request: Request, name: string): string | undefined {
|
||||
const cookies = request.cookies as Record<string, unknown> | undefined;
|
||||
const value = cookies?.[name];
|
||||
return typeof value === 'string' ? value : undefined;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import * as argon2 from 'argon2';
|
||||
|
||||
const HASH_OPTIONS: argon2.HashOptions = {
|
||||
type: argon2.argon2id,
|
||||
memoryCost: 65536,
|
||||
timeCost: 3,
|
||||
parallelism: 1,
|
||||
hashLength: 32,
|
||||
};
|
||||
|
||||
const DUMMY_PASSWORD_HASH =
|
||||
'$argon2id$v=19$m=65536,p=1,t=3$aTSorDuRpmrZ/ILFbcoRYQ$sjvsmuiBlN3oTS1qeUiYxSShzsEVWiNnMY4Jo5MQw2c';
|
||||
|
||||
@Injectable()
|
||||
export class PasswordService {
|
||||
hash(password: string): Promise<string> {
|
||||
return argon2.hash(password, HASH_OPTIONS);
|
||||
}
|
||||
|
||||
async verify(hash: string, password: string): Promise<boolean> {
|
||||
try {
|
||||
return await argon2.verify(hash, password);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async verifyUnknown(password: string): Promise<void> {
|
||||
await this.verify(DUMMY_PASSWORD_HASH, password);
|
||||
}
|
||||
|
||||
needsRehash(hash: string): boolean {
|
||||
return argon2.needsRehash(hash, {
|
||||
memoryCost: HASH_OPTIONS.memoryCost,
|
||||
timeCost: HASH_OPTIONS.timeCost,
|
||||
parallelism: HASH_OPTIONS.parallelism,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import {
|
||||
createHmac,
|
||||
randomBytes,
|
||||
randomUUID,
|
||||
timingSafeEqual,
|
||||
} from 'node:crypto';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { AuthConfigService } from '../../common/config/auth-config.service';
|
||||
import type { AccessTokenPayload } from '../interfaces/access-token-payload';
|
||||
|
||||
const REFRESH_TOKEN_PATTERN =
|
||||
/^(?<sessionId>[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})\.(?<secret>[A-Za-z0-9_-]{64})$/i;
|
||||
|
||||
export interface IssuedRefreshToken {
|
||||
sessionId: string;
|
||||
token: string;
|
||||
tokenHash: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class TokenService {
|
||||
constructor(
|
||||
private readonly jwtService: JwtService,
|
||||
private readonly config: AuthConfigService,
|
||||
) {}
|
||||
|
||||
get accessTokenTtlSeconds(): number {
|
||||
return this.config.accessTokenTtlSeconds;
|
||||
}
|
||||
|
||||
get maxLoginAttempts(): number {
|
||||
return this.config.maxLoginAttempts;
|
||||
}
|
||||
|
||||
get lockoutSeconds(): number {
|
||||
return this.config.lockoutSeconds;
|
||||
}
|
||||
|
||||
refreshExpiresAt(from = new Date()): Date {
|
||||
return new Date(from.getTime() + this.config.refreshTokenTtlSeconds * 1000);
|
||||
}
|
||||
|
||||
issueAccessToken(input: {
|
||||
userId: string;
|
||||
sessionId: string;
|
||||
username: string;
|
||||
}): Promise<string> {
|
||||
const payload: AccessTokenPayload = {
|
||||
sub: input.userId,
|
||||
sid: input.sessionId,
|
||||
username: input.username,
|
||||
typ: 'access',
|
||||
};
|
||||
|
||||
return this.jwtService.signAsync(payload, {
|
||||
secret: this.config.accessTokenSecret,
|
||||
algorithm: 'HS256',
|
||||
expiresIn: this.config.accessTokenTtlSeconds,
|
||||
issuer: 'dhv2-api',
|
||||
audience: 'dhv2',
|
||||
});
|
||||
}
|
||||
|
||||
verifyAccessToken(token: string): Promise<AccessTokenPayload> {
|
||||
return this.jwtService.verifyAsync<AccessTokenPayload>(token, {
|
||||
secret: this.config.accessTokenSecret,
|
||||
algorithms: ['HS256'],
|
||||
issuer: 'dhv2-api',
|
||||
audience: 'dhv2',
|
||||
});
|
||||
}
|
||||
|
||||
issueRefreshToken(): IssuedRefreshToken {
|
||||
const sessionId = randomUUID();
|
||||
const secret = randomBytes(48).toString('base64url');
|
||||
const token = `${sessionId}.${secret}`;
|
||||
return { sessionId, token, tokenHash: this.hashRefreshToken(token) };
|
||||
}
|
||||
|
||||
parseRefreshToken(token: string): { sessionId: string } | null {
|
||||
const match = REFRESH_TOKEN_PATTERN.exec(token);
|
||||
const sessionId = match?.groups?.sessionId;
|
||||
return sessionId ? { sessionId: sessionId.toLowerCase() } : null;
|
||||
}
|
||||
|
||||
verifyRefreshToken(token: string, expectedHash: string): boolean {
|
||||
const actual = Buffer.from(this.hashRefreshToken(token), 'hex');
|
||||
const expected = Buffer.from(expectedHash, 'hex');
|
||||
return actual.length === expected.length && timingSafeEqual(actual, expected);
|
||||
}
|
||||
|
||||
private hashRefreshToken(token: string): string {
|
||||
return createHmac('sha256', this.config.refreshTokenPepper)
|
||||
.update(token, 'utf8')
|
||||
.digest('hex');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user