add credentials login with secure cookies

This commit is contained in:
Paul Coral
2025-08-22 23:12:01 +02:00
parent 2d3d1f57a7
commit 879b32b946
18 changed files with 251 additions and 31 deletions

View File

@@ -30,6 +30,7 @@
"@types/jsonwebtoken": "^9.0.10",
"@types/passport-local": "^1.0.38",
"argon2": "^0.44.0",
"cookie-parser": "^1.4.7",
"dayjs": "^1.11.13",
"dotenv": "^17.2.1",
"ejs": "^3.1.10",
@@ -53,6 +54,7 @@
"@nestjs/testing": "^11.0.1",
"@swc/cli": "^0.6.0",
"@swc/core": "^1.10.7",
"@types/cookie-parser": "^1.4.9",
"@types/ejs": "^3.1.5",
"@types/express": "^5.0.0",
"@types/jest": "^29.5.14",

View File

@@ -1,16 +1,25 @@
import { Injectable } from '@nestjs/common';
import { AuthRegistry } from './auth-registry';
import * as dayjs from 'dayjs';
import { isDefined, ResetAuth } from '@my-monorepo/common';
import { AuthTokenPayload, isDefined, ResetAuth } from '@my-monorepo/common';
import { CryptoService } from '../../../services/core/crypto/crypto.service';
import { EnvService } from '../../../services/core/env/env.service';
const RESET_TOKEN_VALIDITY_MINUTES = 15;
const REFRESH_TOKEN_VALIDITY_DAYS = 7;
export interface TokenWithHmac {
token: string;
hmac: string;
}
@Injectable()
export class AuthCoreService {
constructor(
private authRegistry: AuthRegistry,
private cryptoService: CryptoService,
private envService: EnvService,
) {}
async addNewResetToken(
@@ -31,4 +40,41 @@ export class AuthCoreService {
const res = await this.authRegistry.checkAndInvalidateResetToken(resetAuth);
return res > 0;
}
async addNewRefreshToken(userId: string): Promise<TokenWithHmac> {
const { token } = await this.authRegistry.addRefreshToken({
userId,
token: this.createUserAuthJwt({ userId }),
validUntil: dayjs().add(REFRESH_TOKEN_VALIDITY_DAYS, 'days'),
});
// TODO : add hash and return validity (req and in JWT)
return { token, hmac: this.generateAuthHmac(token) };
}
async verifyPassword(opts: {
hashedPassword: string;
plainTextPassword: string;
}): Promise<boolean> {
return this.cryptoService.verifyPassword(
opts.hashedPassword,
opts.plainTextPassword,
);
}
createAccessToken(userId: string): TokenWithHmac {
const token = this.createUserAuthJwt({ userId });
return { token, hmac: this.generateAuthHmac(token) };
}
private createUserAuthJwt(payload: AuthTokenPayload): string {
return this.cryptoService.createJwt(payload);
}
private generateAuthHmac(payload: string): string {
return this.cryptoService.generateHmac(
payload,
this.envService.config.secrets.authHmac,
);
}
}

View File

@@ -30,6 +30,22 @@ export class AuthRegistry {
.executeTakeFirst();
}
async addRefreshToken(opts: {
userId: string;
token: string;
validUntil: dayjs.Dayjs;
}): Promise<{ token: string; user_id: string }> {
return this.database
.insertInto('refresh_tokens')
.values({
user_id: opts.userId,
token: opts.token,
valid_until: opts.validUntil.toDate(),
})
.returning(['user_id', 'token'])
.executeTakeFirstOrThrow();
}
async checkAndInvalidateResetToken(resetAuth: ResetAuth): Promise<bigint> {
const res = await this.database
.deleteFrom('reset_tokens')

View File

@@ -1,12 +1,12 @@
import { CredentialAuth, ResetAuth } from '@my-monorepo/common';
import {
Injectable,
NotFoundException,
UnauthorizedException,
} from '@nestjs/common';
import { UsersCoreService } from '../../users/users-core/users-core.service';
import { AuthCoreService } from '../auth-core/auth-core.service';
import { ResetAuth } from '@my-monorepo/common';
import { CryptoService } from '../../../services/core/crypto/crypto.service';
import { UsersCoreService } from '../../users/users-core/users-core.service';
import { AuthCoreService, TokenWithHmac } from '../auth-core/auth-core.service';
@Injectable()
export class AuthControllerService {
@@ -16,11 +16,30 @@ export class AuthControllerService {
private cryptoService: CryptoService,
) {}
async verifyCredentials() {
const isValidCredential = await this.userCoreService.existsEmailPassword();
if (!isValidCredential) {
async handleCredentialsAuth(credentials: CredentialAuth): Promise<{
refreshToken: TokenWithHmac;
accessToken: TokenWithHmac;
}> {
const userWithHash =
await this.userCoreService.getUserIdAndHashPass(credentials);
if (!userWithHash || !userWithHash.hash) {
throw new UnauthorizedException('invalid credentials');
}
const { userId, hash } = userWithHash;
const isPasswordValid = await this.authCoreService.verifyPassword({
hashedPassword: hash,
plainTextPassword: credentials.password,
});
if (!isPasswordValid) {
throw new UnauthorizedException('invalid credentials');
}
const refreshToken = await this.authCoreService.addNewRefreshToken(userId);
const accessToken = this.authCoreService.createAccessToken(userId);
return {
refreshToken,
accessToken,
};
}
async resetPassword(resetData: ResetAuth) {

View File

@@ -1,15 +1,66 @@
import { Body, Controller, Post } from '@nestjs/common';
import { Body, Controller, Post, Res } from '@nestjs/common';
import { AuthControllerService } from './auth-controller.service';
import { ZodValidationPipe } from '../../../lib/nestjs/zod-validation/zod-validation.pipe';
import { ResetAuth, resetAuthParser } from '@my-monorepo/common';
import {
CredentialAuth,
credentialAuthParser,
ResetAuth,
resetAuthParser,
} from '@my-monorepo/common';
import { Response } from 'express';
import { EnvService, EnvType } from '../../../services/core/env/env.service';
const REFRESH_TOKEN_COOKIE_NAME = 'refresh';
const REFRESH_HASH_COOKIE_NAME = `${REFRESH_TOKEN_COOKIE_NAME}_hash`;
const ACCESS_TOKEN_COOKIE_NAME = 'access';
const ACCESS_HASH_COOKIE_NAME = `${ACCESS_TOKEN_COOKIE_NAME}_hash`;
@Controller('auth')
export class AuthController {
constructor(private authService: AuthControllerService) {}
constructor(
private authService: AuthControllerService,
private envService: EnvService,
) {}
@Post('credentials')
async postCredentials() {
return this.authService.verifyCredentials();
async postCredentials(
@Body(new ZodValidationPipe(credentialAuthParser))
credentials: CredentialAuth,
@Res({ passthrough: true }) response: Response,
) {
const { accessToken, refreshToken } =
await this.authService.handleCredentialsAuth(credentials);
console.log('>>> paul-debug', {
httpOnly: true,
sameSite: 'strict',
domain: this.envService.config.app.domain,
secure: this.envService.config.app.envType === EnvType.Production,
});
response.cookie(REFRESH_TOKEN_COOKIE_NAME, refreshToken.token, {
httpOnly: true,
sameSite: 'strict',
domain: this.envService.config.app.domain,
secure: this.envService.config.app.envType === EnvType.Production,
});
response.cookie(REFRESH_HASH_COOKIE_NAME, refreshToken.hmac, {
httpOnly: false,
sameSite: 'strict',
domain: this.envService.config.app.domain,
secure: this.envService.config.app.envType === EnvType.Production,
});
response.cookie(ACCESS_TOKEN_COOKIE_NAME, accessToken.token, {
httpOnly: true,
sameSite: 'strict',
domain: this.envService.config.app.domain,
secure: this.envService.config.app.envType === EnvType.Production,
});
response.cookie(ACCESS_HASH_COOKIE_NAME, accessToken.hmac, {
httpOnly: false,
sameSite: 'strict',
domain: this.envService.config.app.domain,
secure: this.envService.config.app.envType === EnvType.Production,
});
}
@Post('reset')

View File

@@ -1,14 +1,18 @@
import { Injectable } from '@nestjs/common';
import { UsersRegistry } from './users-registry';
import { CreateUser, Result, User } from '@my-monorepo/common';
import { CreateUser, CredentialAuth, Result, User } from '@my-monorepo/common';
import { UserEmailAlreadyExistError } from './users-error';
@Injectable()
export class UsersCoreService {
constructor(private userRegistry: UsersRegistry) {}
async existsEmailPassword() {
return await this.userRegistry.existsEmailPassword('', '');
async getUserIdAndHashPass(
credentials: CredentialAuth,
): Promise<{ userId: string; hash?: string } | undefined> {
return await this.userRegistry.getUserIdPasswordFromEmail(
credentials.email,
);
}
async addUser(

View File

@@ -11,16 +11,22 @@ export class UsersRegistry {
constructor(private databaseService: DatabaseService) {}
async existsEmailPassword(email: string, hashedPassword: string) {
const id = await this.database
async getUserIdPasswordFromEmail(
email: string,
): Promise<{ userId: string; hash?: string } | undefined> {
const user = await this.database
.selectFrom('users')
.select('id')
.where((eb) => eb('email', '=', email).and('hash', '=', hashedPassword))
.select(['id', 'hash'])
.where((eb) => eb('email', '=', email))
.executeTakeFirst();
return isDefined(id);
return isDefined(user)
? {
userId: user.id,
hash: user.hash ?? undefined,
}
: undefined;
}
async addUser(
newUser: CreateUser,
): Promise<Result<User, UserEmailAlreadyExistError>> {

View File

@@ -13,6 +13,16 @@ export type Int8 = ColumnType<string, bigint | number | string, bigint | number
export type Timestamp = ColumnType<Date, Date | string, Date | string>;
export interface RefreshTokens {
created_at: Generated<Timestamp>;
deleted_at: Timestamp | null;
id: Generated<Int8>;
token: string;
updated_at: Generated<Timestamp>;
user_id: Generated<Int8>;
valid_until: Timestamp;
}
export interface ResetTokens {
created_at: Generated<Timestamp>;
deleted_at: Timestamp | null;
@@ -35,6 +45,7 @@ export interface Users {
}
export interface DB {
refresh_tokens: RefreshTokens;
reset_tokens: ResetTokens;
users: Users;
}

View File

@@ -0,0 +1,11 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
import { Request } from 'express';
import { ZodType } from 'zod';
export const Cookies = createParamDecorator(
<T>(data: { key: string; parser: ZodType<T> }, ctx: ExecutionContext) => {
const request = ctx.switchToHttp().getRequest<Request>();
const res = data.parser.safeParse(request.cookies?.[data.key]);
return res.success ? res.data : undefined;
},
);

View File

@@ -1,10 +1,13 @@
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { AppErrorFilter } from './app-error.filter';
import * as cookieParser from 'cookie-parser';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.useGlobalFilters(new AppErrorFilter());
app.use(cookieParser());
console.log('Starting server on port', process.env.PORT ?? 3000);
await app.listen(process.env.PORT ?? 3000);
}
bootstrap();

View File

@@ -38,4 +38,12 @@ export class CryptoService {
);
});
}
generateHmac(payload: string, key: string) {
return crypto.createHmac('sha256', key).update(payload).digest('hex');
}
validateHmac(payload: string, hmac: string, key: string): boolean {
return this.generateHmac(payload, key) === hmac;
}
}

View File

@@ -2,6 +2,11 @@ import { Injectable } from '@nestjs/common';
import z from 'zod';
import * as dotenv from 'dotenv';
export enum EnvType {
Dev = 'dev',
Production = 'production',
}
const envParser = z
.object({
DATABASE: z.string(),
@@ -13,8 +18,10 @@ const envParser = z
MAIL_PORT: z.coerce.number(),
MAIL_USER: z.string().email(),
MAIL_PASSWORD: z.string(),
APP_FRONTEND_URL: z.string(),
APP_DOMAIN: z.string(),
APP_ENV_TYPE: z.nativeEnum(EnvType),
SECRET_AUTH_SIGN: z.string(),
SECRET_AUTH_HMAC: z.string(),
})
.transform((parsed) => ({
database: {
@@ -31,10 +38,12 @@ const envParser = z
password: parsed.MAIL_PASSWORD,
},
app: {
frontendUrl: parsed.APP_FRONTEND_URL,
domain: parsed.APP_DOMAIN,
envType: parsed.APP_ENV_TYPE,
},
secrets: {
authSign: parsed.SECRET_AUTH_SIGN,
authHmac: parsed.SECRET_AUTH_HMAC,
},
}));

View File

@@ -9,7 +9,7 @@ export class LinkBuilderService {
path: string[],
queryParams: Record<string, string> = {},
) {
const base = this.envService.config.app.frontendUrl;
const base = this.envService.config.app.domain;
const segments = path.join('/');
const queryParamsList = Object.entries(queryParams);
const joinedQueryParam = queryParamsList