diff --git a/apps/backend/package.json b/apps/backend/package.json index 11e7307..694691d 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -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", diff --git a/apps/backend/src/app/auth/auth-core/auth-core.service.ts b/apps/backend/src/app/auth/auth-core/auth-core.service.ts index 1c4ed33..bd10355 100644 --- a/apps/backend/src/app/auth/auth-core/auth-core.service.ts +++ b/apps/backend/src/app/auth/auth-core/auth-core.service.ts @@ -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 { + 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 { + 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, + ); + } } diff --git a/apps/backend/src/app/auth/auth-core/auth-registry.ts b/apps/backend/src/app/auth/auth-core/auth-registry.ts index 06afc35..4742280 100644 --- a/apps/backend/src/app/auth/auth-core/auth-registry.ts +++ b/apps/backend/src/app/auth/auth-core/auth-registry.ts @@ -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 { const res = await this.database .deleteFrom('reset_tokens') diff --git a/apps/backend/src/app/auth/auth-feature/auth-controller.service.ts b/apps/backend/src/app/auth/auth-feature/auth-controller.service.ts index f079b89..bcaca3c 100644 --- a/apps/backend/src/app/auth/auth-feature/auth-controller.service.ts +++ b/apps/backend/src/app/auth/auth-feature/auth-controller.service.ts @@ -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) { diff --git a/apps/backend/src/app/auth/auth-feature/auth.controller.ts b/apps/backend/src/app/auth/auth-feature/auth.controller.ts index f509117..a09007c 100644 --- a/apps/backend/src/app/auth/auth-feature/auth.controller.ts +++ b/apps/backend/src/app/auth/auth-feature/auth.controller.ts @@ -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') diff --git a/apps/backend/src/app/users/users-core/users-core.service.ts b/apps/backend/src/app/users/users-core/users-core.service.ts index b967c88..091b4f2 100644 --- a/apps/backend/src/app/users/users-core/users-core.service.ts +++ b/apps/backend/src/app/users/users-core/users-core.service.ts @@ -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( diff --git a/apps/backend/src/app/users/users-core/users-registry.ts b/apps/backend/src/app/users/users-core/users-registry.ts index 88b6c57..da7e30c 100644 --- a/apps/backend/src/app/users/users-core/users-registry.ts +++ b/apps/backend/src/app/users/users-core/users-registry.ts @@ -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> { diff --git a/apps/backend/src/database/db.d.ts b/apps/backend/src/database/db.d.ts index 8cbaa37..a64a56b 100644 --- a/apps/backend/src/database/db.d.ts +++ b/apps/backend/src/database/db.d.ts @@ -13,6 +13,16 @@ export type Int8 = ColumnType; +export interface RefreshTokens { + created_at: Generated; + deleted_at: Timestamp | null; + id: Generated; + token: string; + updated_at: Generated; + user_id: Generated; + valid_until: Timestamp; +} + export interface ResetTokens { created_at: Generated; deleted_at: Timestamp | null; @@ -35,6 +45,7 @@ export interface Users { } export interface DB { + refresh_tokens: RefreshTokens; reset_tokens: ResetTokens; users: Users; } diff --git a/apps/backend/src/lib/nestjs/cookies/cookies-decorator.ts b/apps/backend/src/lib/nestjs/cookies/cookies-decorator.ts new file mode 100644 index 0000000..258adfd --- /dev/null +++ b/apps/backend/src/lib/nestjs/cookies/cookies-decorator.ts @@ -0,0 +1,11 @@ +import { createParamDecorator, ExecutionContext } from '@nestjs/common'; +import { Request } from 'express'; +import { ZodType } from 'zod'; + +export const Cookies = createParamDecorator( + (data: { key: string; parser: ZodType }, ctx: ExecutionContext) => { + const request = ctx.switchToHttp().getRequest(); + const res = data.parser.safeParse(request.cookies?.[data.key]); + return res.success ? res.data : undefined; + }, +); diff --git a/apps/backend/src/main.ts b/apps/backend/src/main.ts index d5c787b..05228ba 100644 --- a/apps/backend/src/main.ts +++ b/apps/backend/src/main.ts @@ -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(); diff --git a/apps/backend/src/services/core/crypto/crypto.service.ts b/apps/backend/src/services/core/crypto/crypto.service.ts index 4f85818..876b1ff 100644 --- a/apps/backend/src/services/core/crypto/crypto.service.ts +++ b/apps/backend/src/services/core/crypto/crypto.service.ts @@ -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; + } } diff --git a/apps/backend/src/services/core/env/env.service.ts b/apps/backend/src/services/core/env/env.service.ts index 2c0fd70..10d48c9 100644 --- a/apps/backend/src/services/core/env/env.service.ts +++ b/apps/backend/src/services/core/env/env.service.ts @@ -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, }, })); diff --git a/apps/backend/src/services/core/link-builder/link-builder.service.ts b/apps/backend/src/services/core/link-builder/link-builder.service.ts index bc5c376..b5da217 100644 --- a/apps/backend/src/services/core/link-builder/link-builder.service.ts +++ b/apps/backend/src/services/core/link-builder/link-builder.service.ts @@ -9,7 +9,7 @@ export class LinkBuilderService { path: string[], queryParams: Record = {}, ) { - 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 diff --git a/documentation/bruno-rest-api/LocalAPI/auth/credentials.bru b/documentation/bruno-rest-api/LocalAPI/auth/credentials.bru index 633b37d..2fcde46 100644 --- a/documentation/bruno-rest-api/LocalAPI/auth/credentials.bru +++ b/documentation/bruno-rest-api/LocalAPI/auth/credentials.bru @@ -6,10 +6,17 @@ meta { post { url: {{BASE_URL}}/auth/credentials - body: none + body: json auth: inherit } +body:json { + { + "email": "paul@cowsi.ch", + "password": "Password1234" + } +} + settings { encodeUrl: true } diff --git a/packages/common/src/models/auth/auth-token-payload.ts b/packages/common/src/models/auth/auth-token-payload.ts new file mode 100644 index 0000000..d3c6a9e --- /dev/null +++ b/packages/common/src/models/auth/auth-token-payload.ts @@ -0,0 +1,3 @@ +export interface AuthTokenPayload { + userId: string; +} diff --git a/packages/common/src/models/auth/credential-auth.ts b/packages/common/src/models/auth/credential-auth.ts index 5d956ec..9d261a8 100644 --- a/packages/common/src/models/auth/credential-auth.ts +++ b/packages/common/src/models/auth/credential-auth.ts @@ -5,10 +5,4 @@ export const credentialAuthParser = z.object({ password: z.string({ message: "invalid password" }), }); -export const credentialAuthResponse = z.object({ - accessToken: z.string({ message: "invalid access tokens" }), -}); - export type CredentialAuth = z.infer; - -export type CredentialAuthResponse = z.infer; diff --git a/packages/common/src/models/auth/index.ts b/packages/common/src/models/auth/index.ts index 763ca48..97cd94b 100644 --- a/packages/common/src/models/auth/index.ts +++ b/packages/common/src/models/auth/index.ts @@ -1,2 +1,3 @@ +export * from "./auth-token-payload"; export * from "./credential-auth"; export * from "./reset-auth"; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8513acd..4001a5b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -34,6 +34,9 @@ importers: argon2: specifier: ^0.44.0 version: 0.44.0 + cookie-parser: + specifier: ^1.4.7 + version: 1.4.7 dayjs: specifier: ^1.11.13 version: 1.11.13 @@ -98,6 +101,9 @@ importers: '@swc/core': specifier: ^1.10.7 version: 1.12.11 + '@types/cookie-parser': + specifier: ^1.4.9 + version: 1.4.9(@types/express@5.0.3) '@types/ejs': specifier: ^3.1.5 version: 3.1.5 @@ -1042,6 +1048,11 @@ packages: '@types/connect@3.4.38': resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} + '@types/cookie-parser@1.4.9': + resolution: {integrity: sha512-tGZiZ2Gtc4m3wIdLkZ8mkj1T6CEHb35+VApbL2T14Dew8HA7c+04dmKqsKRNC+8RJPm16JEK0tFSwdZqubfc4g==} + peerDependencies: + '@types/express': '*' + '@types/cookiejar@2.1.5': resolution: {integrity: sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==} @@ -1666,6 +1677,13 @@ packages: convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + cookie-parser@1.4.7: + resolution: {integrity: sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==} + engines: {node: '>= 0.8.0'} + + cookie-signature@1.0.6: + resolution: {integrity: sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==} + cookie-signature@1.2.2: resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} engines: {node: '>=6.6.0'} @@ -4860,6 +4878,10 @@ snapshots: dependencies: '@types/node': 22.16.3 + '@types/cookie-parser@1.4.9(@types/express@5.0.3)': + dependencies: + '@types/express': 5.0.3 + '@types/cookiejar@2.1.5': {} '@types/ejs@3.1.5': {} @@ -5650,6 +5672,13 @@ snapshots: convert-source-map@2.0.0: {} + cookie-parser@1.4.7: + dependencies: + cookie: 0.7.2 + cookie-signature: 1.0.6 + + cookie-signature@1.0.6: {} + cookie-signature@1.2.2: {} cookie@0.7.2: {}