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
|
||||
|
||||
Reference in New Issue
Block a user