From 1212a384a7c57e6c29cb27c08e651ec216e9903b Mon Sep 17 00:00:00 2001 From: Kirill Ignatov Date: Fri, 14 May 2021 16:12:23 +0300 Subject: [PATCH] feat(auth): add dom-based CSRF token (#43) --- docker-compose.local.yml | 25 +++++++++++++++ nginx-config/local.conf | 19 +++++++++++ public/Dockerfile | 1 + public/package-lock.json | 5 +++ public/package.json | 1 + public/src/api/httpClient.ts | 16 ++++++++-- public/src/interfaces/User.ts | 4 ++- public/src/pages/auth/Login/Login.tsx | 29 ++++++++++++++--- public/tsconfig.json | 1 + .../get-browser-fingerprint/index.d.ts | 1 + src/auth/api/login.request.ts | 3 ++ src/auth/auth.controller.ts | 32 ++++++++++++++++--- src/auth/csrf.guard.ts | 14 +++++++- 13 files changed, 139 insertions(+), 12 deletions(-) create mode 100644 docker-compose.local.yml create mode 100644 nginx-config/local.conf create mode 100644 public/typings/get-browser-fingerprint/index.d.ts diff --git a/docker-compose.local.yml b/docker-compose.local.yml new file mode 100644 index 00000000..989ae850 --- /dev/null +++ b/docker-compose.local.yml @@ -0,0 +1,25 @@ +version: '3' + +services: + db: + image: postgres + environment: + POSTGRES_DB: bc + POSTGRES_USER: bc + POSTGRES_PASSWORD: bc + ports: + - 5432:5432 + volumes: + - ./pg.sql:/docker-entrypoint-initdb.d/pg.sql + + proxy: + image: nginx:1.17.3-alpine + + ports: + - 8090:80 + + volumes: + - ./nginx-config/local.conf:/etc/nginx/conf.d/default.conf + - ./public/build:/htdocs + + diff --git a/nginx-config/local.conf b/nginx-config/local.conf new file mode 100644 index 00000000..0f1bd0dd --- /dev/null +++ b/nginx-config/local.conf @@ -0,0 +1,19 @@ + +server { + gzip on; + listen 80; + + sendfile off; + charset utf8; + + location / { + root /htdocs/; + index index.html; + try_files $uri /index.html; + + location /api { + proxy_pass http://172.17.0.1:3000; + } + } + +} diff --git a/public/Dockerfile b/public/Dockerfile index d482f4ae..ea9ce4a3 100644 --- a/public/Dockerfile +++ b/public/Dockerfile @@ -6,6 +6,7 @@ COPY package*.json ./ COPY tsconfig.json ./ COPY .env ./ COPY src ./src +COPY typings ./typings COPY public ./public COPY vcs ./vcs diff --git a/public/package-lock.json b/public/package-lock.json index bf7504b0..b3c751e6 100644 --- a/public/package-lock.json +++ b/public/package-lock.json @@ -7355,6 +7355,11 @@ "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==" }, + "get-browser-fingerprint": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/get-browser-fingerprint/-/get-browser-fingerprint-2.0.1.tgz", + "integrity": "sha512-1pHyIBuzv5dgERs6U9abgRcfp8RnpUKLyMw9bgDi0v0l6GGJnqdktp8AhUDA7gEXl+PNQ0kL6BPYNk494zNEQQ==" + }, "get-caller-file": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", diff --git a/public/package.json b/public/package.json index f6bc19c8..c14a5151 100644 --- a/public/package.json +++ b/public/package.json @@ -9,6 +9,7 @@ "@testing-library/user-event": "^12.1.10", "axios": "^0.21.0", "dangerously-set-html-content": "^1.0.8", + "get-browser-fingerprint": "^2.0.1", "history": "^4.10.1", "jest": "^26.6.3", "react": "^17.0.1", diff --git a/public/src/api/httpClient.ts b/public/src/api/httpClient.ts index 3d639a97..7cf82214 100644 --- a/public/src/api/httpClient.ts +++ b/public/src/api/httpClient.ts @@ -63,11 +63,23 @@ export function getLdap(ldapProfileLink: string): Promise { }); } +export function loadDomXsrfToken(fingerprint: string): Promise { + const config: AxiosRequestConfig = { + url: `${ApiUrl.Auth}/dom-csrf-flow`, + method: 'get', + headers: { fingerprint } + }; + + return makeApiRequest(config); +} + export function loadXsrfToken(): Promise { - return makeApiRequest({ + const config: AxiosRequestConfig = { url: `${ApiUrl.Auth}/simple-csrf-flow`, method: 'get' - }); + }; + + return makeApiRequest(config); } export function postMetadata(): Promise { diff --git a/public/src/interfaces/User.ts b/public/src/interfaces/User.ts index 9b1a4514..549667e1 100644 --- a/public/src/interfaces/User.ts +++ b/public/src/interfaces/User.ts @@ -3,12 +3,14 @@ export interface LoginUser { password: string; csrf?: string; op?: LoginFormMode; + fingerprint?: string; } export enum LoginFormMode { BASIC = 'basic', HTML = 'html', - CSRF = 'csrf' + CSRF = 'csrf', + DOM_BASED_CSRF = 'csrf_dom' } export interface LoginResponse { diff --git a/public/src/pages/auth/Login/Login.tsx b/public/src/pages/auth/Login/Login.tsx index 83e1792c..34fd9952 100644 --- a/public/src/pages/auth/Login/Login.tsx +++ b/public/src/pages/auth/Login/Login.tsx @@ -1,7 +1,13 @@ import { AxiosRequestConfig } from 'axios'; +import getBrowserFingerprint from 'get-browser-fingerprint'; import React, { FC, FormEvent, useEffect, useState } from 'react'; import { Link } from 'react-router-dom'; -import { getLdap, getUser, loadXsrfToken } from '../../../api/httpClient'; +import { + getLdap, + getUser, + loadDomXsrfToken, + loadXsrfToken +} from '../../../api/httpClient'; import { LoginFormMode, LoginResponse, @@ -78,6 +84,9 @@ export const Login: FC = () => { switch (mode) { case LoginFormMode.CSRF: return { ...data, csrf }; + case LoginFormMode.DOM_BASED_CSRF: + const fingerprint = getBrowserFingerprint(); + return { ...data, csrf, fingerprint }; default: return data; } @@ -87,12 +96,20 @@ export const Login: FC = () => { loadXsrfToken().then((token) => setCsrf(token)); }; + const loadDomCsrf = (fingerprint: string) => { + loadDomXsrfToken(fingerprint).then((token) => setCsrf(token)); + }; + useEffect(() => sendLdap(), [loginResponse]); useEffect(() => { switch (mode) { case LoginFormMode.CSRF: { return loadCsrf(); } + case LoginFormMode.DOM_BASED_CSRF: { + const fingerprint = getBrowserFingerprint(); + return loadDomCsrf(fingerprint); + } } }, [mode]); @@ -118,6 +135,9 @@ export const Login: FC = () => { + @@ -144,9 +164,10 @@ export const Login: FC = () => { onInput={onInput} /> - {mode === LoginFormMode.CSRF && csrf && ( - - )} + + {(mode === LoginFormMode.CSRF || + mode === LoginFormMode.DOM_BASED_CSRF) && + csrf && } {loginResponse && showLoginResponse(loginResponse)}
diff --git a/public/tsconfig.json b/public/tsconfig.json index 0df6cfee..0af19a3b 100644 --- a/public/tsconfig.json +++ b/public/tsconfig.json @@ -1,5 +1,6 @@ { "compilerOptions": { + "typeRoots": [ "./typings", "./node_modules/@types"], "jsx": "react", "target": "es5", "module": "esnext", diff --git a/public/typings/get-browser-fingerprint/index.d.ts b/public/typings/get-browser-fingerprint/index.d.ts new file mode 100644 index 00000000..b901df12 --- /dev/null +++ b/public/typings/get-browser-fingerprint/index.d.ts @@ -0,0 +1 @@ +declare module 'get-browser-fingerprint'; \ No newline at end of file diff --git a/src/auth/api/login.request.ts b/src/auth/api/login.request.ts index 3e969b1d..4822284a 100644 --- a/src/auth/api/login.request.ts +++ b/src/auth/api/login.request.ts @@ -4,6 +4,7 @@ export enum FormMode { BASIC = 'basic', HTML = 'html', CSRF = 'csrf', + DOM_BASED_CSRF = 'csrf_dom', } export class LoginRequest { @@ -19,4 +20,6 @@ export class LoginRequest { op?: string; csrf?: string; + + fingerprint?: string; } diff --git a/src/auth/auth.controller.ts b/src/auth/auth.controller.ts index 51fb1afa..cafe410d 100644 --- a/src/auth/auth.controller.ts +++ b/src/auth/auth.controller.ts @@ -1,15 +1,17 @@ import { + BadRequestException, Body, Controller, Get, HttpStatus, InternalServerErrorException, Logger, - Post, + Post, Req, Res, UnauthorizedException, UseGuards, } from '@nestjs/common'; +import { createHash } from 'crypto'; import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; import { User } from '../model/user.entity'; import { LdapQueryHandler } from '../users/ldap.query.handler'; @@ -36,7 +38,7 @@ import { AuthGuard } from './auth.guard'; import { AuthService, JwtProcessorType } from './auth.service'; import { passwordMatches } from './credentials.utils'; import { JwtType } from './jwt/jwt.type.decorator'; -import { FastifyReply } from 'fastify'; +import { FastifyReply, FastifyRequest } from 'fastify'; import { randomBytes } from 'crypto'; import { CsrfGuard } from './csrf.guard'; @@ -118,9 +120,9 @@ export class AuthController { res.header( 'authorization', await this.authService.createToken( - { + { user: profile.email, - exp: 90 + Math.floor(Date.now() / 1000) + exp: 90 + Math.floor(Date.now() / 1000), }, JwtProcessorType.RSA, ), @@ -129,6 +131,28 @@ export class AuthController { return profile; } + @Get('dom-csrf-flow') + async getDomCsrfToken( + @Req() request: FastifyRequest, + @Res({ passthrough: true }) res: FastifyReply, + ): Promise { + const fp = request.headers['fingerprint'] as string; + + if (!fp) { + throw new BadRequestException('Fingerprint header is required') + } + const token = createHash('md5') + .update(fp) + .digest('hex'); + + res.setCookie(this.CSRF_COOKIE_HEADER, token, { + httpOnly: true, + sameSite: 'strict', + }); + + return token; + } + @Get('simple-csrf-flow') async getCsrfToken( @Res({ passthrough: true }) res: FastifyReply, diff --git a/src/auth/csrf.guard.ts b/src/auth/csrf.guard.ts index abe6d72f..aedf0306 100644 --- a/src/auth/csrf.guard.ts +++ b/src/auth/csrf.guard.ts @@ -5,6 +5,7 @@ import { ExecutionContext, Logger, } from '@nestjs/common'; +import { createHash } from 'crypto'; import { FastifyRequest } from 'fastify'; import { FormMode, LoginRequest } from './api/login.request'; @@ -20,11 +21,22 @@ export class CsrfGuard implements CanActivate { try { const body: LoginRequest = request.body as LoginRequest; - if (body?.op === FormMode.CSRF) { + const mode = body?.op + if ( mode === FormMode.CSRF || mode === FormMode.DOM_BASED_CSRF) { const csrfCookie = request.cookies[CsrfGuard.CSRF_COOKIE_HEADER]; + if (decodeURIComponent(csrfCookie) !== body.csrf) { this.throwError(); } + + if (mode === FormMode.DOM_BASED_CSRF && !body.fingerprint ) { + const fpHash = createHash('md5') + .update(body.fingerprint) + .digest('hex'); + if (body.csrf !== fpHash) { + this.throwError() + } + } } return true;