add credentials login with secure cookies
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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>> {
|
||||
|
||||
11
apps/backend/src/database/db.d.ts
vendored
11
apps/backend/src/database/db.d.ts
vendored
@@ -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;
|
||||
}
|
||||
|
||||
11
apps/backend/src/lib/nestjs/cookies/cookies-decorator.ts
Normal file
11
apps/backend/src/lib/nestjs/cookies/cookies-decorator.ts
Normal 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;
|
||||
},
|
||||
);
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}));
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
3
packages/common/src/models/auth/auth-token-payload.ts
Normal file
3
packages/common/src/models/auth/auth-token-payload.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export interface AuthTokenPayload {
|
||||
userId: string;
|
||||
}
|
||||
@@ -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<typeof credentialAuthParser>;
|
||||
|
||||
export type CredentialAuthResponse = z.infer<typeof credentialAuthResponse>;
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from "./auth-token-payload";
|
||||
export * from "./credential-auth";
|
||||
export * from "./reset-auth";
|
||||
|
||||
29
pnpm-lock.yaml
generated
29
pnpm-lock.yaml
generated
@@ -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: {}
|
||||
|
||||
Reference in New Issue
Block a user