From 9b0ca7c6a7d9a01d0d493f16b5abbdc216031a9b Mon Sep 17 00:00:00 2001 From: Daniele Manni Date: Fri, 17 Jan 2020 11:24:23 +0100 Subject: [PATCH 1/2] [#168453950] Adult check at login if dateOfBirth is provided --- .../authenticationController.test.ts | 23 +++++++++++++ src/controllers/authenticationController.ts | 18 +++++++++- src/utils/__tests__/date.test.ts | 34 +++++++++++++++++++ src/utils/date.ts | 34 +++++++++++++++++++ src/utils/responses.ts | 22 ++++++++++++ 5 files changed, 130 insertions(+), 1 deletion(-) create mode 100644 src/utils/__tests__/date.test.ts create mode 100644 src/utils/date.ts diff --git a/src/controllers/__tests__/authenticationController.test.ts b/src/controllers/__tests__/authenticationController.test.ts index 650bceb4e..f5f19cb95 100644 --- a/src/controllers/__tests__/authenticationController.test.ts +++ b/src/controllers/__tests__/authenticationController.test.ts @@ -443,6 +443,29 @@ describe("AuthenticationController#acs", () => { detail: "Redis error" }); }); + + it("should return a forbidden error response if user isn't adult", async () => { + const res = mockRes(); + + const todayDate = new Date(); + const notAdultUser = { + ...validUserPayload, + dateOfBirth: `${todayDate.getFullYear() - 17}-01-01` + }; + const response = await controller.acs(notAdultUser); + response.apply(res); + + expect(controller).toBeTruthy(); + expect(res.status).toHaveBeenCalledWith(403); + + const expectedForbiddenResponse = { + detail: expect.any(String), + status: 403, + title: "Forbidden", + type: undefined + }; + expect(res.json).toHaveBeenCalledWith(expectedForbiddenResponse); + }); }); describe("AuthenticationController#getUserIdentity", () => { diff --git a/src/controllers/authenticationController.ts b/src/controllers/authenticationController.ts index 0e609ef47..caa188e63 100644 --- a/src/controllers/authenticationController.ts +++ b/src/controllers/authenticationController.ts @@ -38,7 +38,13 @@ import { withUserFromRequest } from "../types/user"; import { log } from "../utils/logger"; -import { withCatchAsInternalError } from "../utils/responses"; +import { + IResponseErrorForbidden, + ResponseErrorForbidden, + withCatchAsInternalError +} from "../utils/responses"; + +import { isAdult } from "../utils/date"; export default class AuthenticationController { constructor( @@ -62,6 +68,7 @@ export default class AuthenticationController { // tslint:disable-next-line: max-union-size | IResponseErrorInternal | IResponseErrorValidation + | IResponseErrorForbidden | IResponseErrorTooManyRequests | IResponseErrorNotFound | IResponsePermanentRedirect @@ -78,6 +85,15 @@ export default class AuthenticationController { } const spidUser = errorOrUser.value; + + // If the user isn't an adult a forbidden response will be provided + if ( + !fromNullable(spidUser.dateOfBirth) + .map(isAdult) + .getOrElse(true) + ) { + return ResponseErrorForbidden("Forbidden", "The user must be an adult"); + } const sessionToken = this.tokenService.getNewToken() as SessionToken; const walletToken = this.tokenService.getNewToken() as WalletToken; const user = toAppUser(spidUser, sessionToken, walletToken); diff --git a/src/utils/__tests__/date.test.ts b/src/utils/__tests__/date.test.ts new file mode 100644 index 000000000..afd3bc5a7 --- /dev/null +++ b/src/utils/__tests__/date.test.ts @@ -0,0 +1,34 @@ +import { isAdult } from "../date"; + +const today = new Date(); + +describe("Check if a birthdate is for an adult user", () => { + it("should return true if the user has more than 18 years old", () => { + const validAdultBirthdate = `${today.getFullYear() - 19}-01-01`; + expect(isAdult(validAdultBirthdate)).toBeTruthy(); + }); + + it("should return true if the user has exactly 18 years old", () => { + const currentMount = + today.getMonth() + 1 < 10 + ? `0${today.getMonth() + 1}` + : today.getMonth() + 1; + const validAdultBirthdate = `${today.getFullYear() - + 18}-${currentMount}-${today.getDate()}`; + expect(isAdult(validAdultBirthdate)).toBeTruthy(); + }); + + it("should return false if the the user has less than 18 years old", () => { + const currentMount = + today.getMonth() + 1 < 10 + ? `0${today.getMonth() + 1}` + : today.getMonth() + 1; + const validNotAdultBirthdate = `${today.getFullYear() - + 18}-${currentMount}-${today.getDate() - 1}`; + expect(isAdult(validNotAdultBirthdate)).toBeFalsy(); + }); + + it("should return false if the birthdate is invalid", () => { + expect(isAdult("2000-13-32")).toBeFalsy(); + }); +}); diff --git a/src/utils/date.ts b/src/utils/date.ts new file mode 100644 index 000000000..bb132418f --- /dev/null +++ b/src/utils/date.ts @@ -0,0 +1,34 @@ +import { isRight } from "fp-ts/lib/Either"; +import * as t from "io-ts"; + +export const dateRegex = /^(?[12]\d{3})-(?0[1-9]|1[0-2])-(?0[1-9]|[12]\d|3[01])$/; +const dateRegexGroups = t.interface({ + day: t.string, + month: t.string, + year: t.string +}); + +export const isAdult = (dateOfBirth: string): boolean => { + const result = dateRegex.exec(dateOfBirth); + const date = dateRegexGroups.decode(result?.groups); + if (isRight(date)) { + const year = Number(date.value.year); + const currentDate = new Date(); + const currentYear = currentDate.getFullYear(); + const currentMonth = currentDate.getMonth() + 1; + const month = Number(date.value.month); + const dayNumber = Number(date.value.day); + const currentDayNumber = currentDate.getDate(); + if ( + year + 18 > currentYear || + (year + 18 === currentYear && + (currentMonth > month || + (currentMonth === month && currentDayNumber > dayNumber))) + ) { + return false; + } else { + return true; + } + } + return false; +}; diff --git a/src/utils/responses.ts b/src/utils/responses.ts index d347ae7db..1f4e22e15 100644 --- a/src/utils/responses.ts +++ b/src/utils/responses.ts @@ -2,7 +2,9 @@ import * as express from "express"; import * as t from "io-ts"; import { errorsToReadableMessages } from "italia-ts-commons/lib/reporters"; import { + HttpStatusCodeEnum, IResponse, + ResponseErrorGeneric, ResponseErrorInternal, ResponseErrorValidation } from "italia-ts-commons/lib/responses"; @@ -24,6 +26,26 @@ export function ResponseNoContent(): IResponseNoContent { }; } +/** + * Interface for a forbidden error response. + */ +export interface IResponseErrorForbidden + extends IResponse<"IResponseErrorForbidden"> { + readonly detail: string; +} +/** + * Returns a forbidden error response with status code 403. + */ +export function ResponseErrorForbidden( + title: string, + detail: string +): IResponseErrorForbidden { + return { + ...ResponseErrorGeneric(HttpStatusCodeEnum.HTTP_STATUS_403, title, detail), + ...{ detail: `${title}: ${detail}`, kind: "IResponseErrorForbidden" } + }; +} + /** * Transforms async failures into internal errors */ From 34282a47a755b30464a8d5582b95f52d0021a725 Mon Sep 17 00:00:00 2001 From: Daniele Manni Date: Mon, 20 Jan 2020 10:37:45 +0100 Subject: [PATCH 2/2] [#168453950] use date-fns and functional style --- .../authenticationController.test.ts | 17 +++++++- src/controllers/authenticationController.ts | 8 ++-- src/utils/__tests__/date.test.ts | 32 +++++--------- src/utils/date.ts | 42 +++++-------------- 4 files changed, 39 insertions(+), 60 deletions(-) diff --git a/src/controllers/__tests__/authenticationController.test.ts b/src/controllers/__tests__/authenticationController.test.ts index f5f19cb95..c9f374ba0 100644 --- a/src/controllers/__tests__/authenticationController.test.ts +++ b/src/controllers/__tests__/authenticationController.test.ts @@ -276,6 +276,10 @@ afterEach(() => { clock = clock.uninstall(); }); +// tslint:disable-next-line: no-var-requires +const dateUtils = require("../../utils/date"); +const mockIsOlderThan = jest.spyOn(dateUtils, "isOlderThan"); + describe("AuthenticationController#acs", () => { it("redirects to the correct url if userPayload is a valid User and a profile not exists", async () => { const res = mockRes(); @@ -447,15 +451,24 @@ describe("AuthenticationController#acs", () => { it("should return a forbidden error response if user isn't adult", async () => { const res = mockRes(); - const todayDate = new Date(); + // Mock isOlderThan to return false + const mockInnerIsOlderThan = jest.fn(); + mockInnerIsOlderThan.mockImplementationOnce(() => false); + mockIsOlderThan.mockImplementationOnce(() => mockInnerIsOlderThan); + + const expectedDateOfBirth = "2000-01-01"; const notAdultUser = { ...validUserPayload, - dateOfBirth: `${todayDate.getFullYear() - 17}-01-01` + dateOfBirth: expectedDateOfBirth }; const response = await controller.acs(notAdultUser); response.apply(res); expect(controller).toBeTruthy(); + expect(mockIsOlderThan).toBeCalledWith(18); + expect(mockInnerIsOlderThan.mock.calls[0][0]).toEqual( + new Date(expectedDateOfBirth) + ); expect(res.status).toHaveBeenCalledWith(403); const expectedForbiddenResponse = { diff --git a/src/controllers/authenticationController.ts b/src/controllers/authenticationController.ts index caa188e63..07b492d68 100644 --- a/src/controllers/authenticationController.ts +++ b/src/controllers/authenticationController.ts @@ -44,7 +44,7 @@ import { withCatchAsInternalError } from "../utils/responses"; -import { isAdult } from "../utils/date"; +import { isOlderThan } from "../utils/date"; export default class AuthenticationController { constructor( @@ -88,9 +88,9 @@ export default class AuthenticationController { // If the user isn't an adult a forbidden response will be provided if ( - !fromNullable(spidUser.dateOfBirth) - .map(isAdult) - .getOrElse(true) + fromNullable(spidUser.dateOfBirth).exists( + _ => !isOlderThan(18)(new Date(_), new Date()) + ) ) { return ResponseErrorForbidden("Forbidden", "The user must be an adult"); } diff --git a/src/utils/__tests__/date.test.ts b/src/utils/__tests__/date.test.ts index afd3bc5a7..07765e187 100644 --- a/src/utils/__tests__/date.test.ts +++ b/src/utils/__tests__/date.test.ts @@ -1,34 +1,22 @@ -import { isAdult } from "../date"; +import { isOlderThan } from "../date"; -const today = new Date(); +const toDate = new Date("2020-01-01"); +const olderThanValue = 18; describe("Check if a birthdate is for an adult user", () => { it("should return true if the user has more than 18 years old", () => { - const validAdultBirthdate = `${today.getFullYear() - 19}-01-01`; - expect(isAdult(validAdultBirthdate)).toBeTruthy(); + const validOlderDate = new Date("2000-01-01"); + expect(isOlderThan(olderThanValue)(validOlderDate, toDate)).toBeTruthy(); }); it("should return true if the user has exactly 18 years old", () => { - const currentMount = - today.getMonth() + 1 < 10 - ? `0${today.getMonth() + 1}` - : today.getMonth() + 1; - const validAdultBirthdate = `${today.getFullYear() - - 18}-${currentMount}-${today.getDate()}`; - expect(isAdult(validAdultBirthdate)).toBeTruthy(); + const validOlderDate = new Date("2002-01-01"); + expect(isOlderThan(olderThanValue)(validOlderDate, toDate)).toBeTruthy(); }); it("should return false if the the user has less than 18 years old", () => { - const currentMount = - today.getMonth() + 1 < 10 - ? `0${today.getMonth() + 1}` - : today.getMonth() + 1; - const validNotAdultBirthdate = `${today.getFullYear() - - 18}-${currentMount}-${today.getDate() - 1}`; - expect(isAdult(validNotAdultBirthdate)).toBeFalsy(); - }); - - it("should return false if the birthdate is invalid", () => { - expect(isAdult("2000-13-32")).toBeFalsy(); + expect( + isOlderThan(olderThanValue)(new Date("2002-01-02"), toDate) + ).toBeFalsy(); }); }); diff --git a/src/utils/date.ts b/src/utils/date.ts index bb132418f..863b7037c 100644 --- a/src/utils/date.ts +++ b/src/utils/date.ts @@ -1,34 +1,12 @@ -import { isRight } from "fp-ts/lib/Either"; -import * as t from "io-ts"; +import { addYears, isAfter } from "date-fns"; -export const dateRegex = /^(?[12]\d{3})-(?0[1-9]|1[0-2])-(?0[1-9]|[12]\d|3[01])$/; -const dateRegexGroups = t.interface({ - day: t.string, - month: t.string, - year: t.string -}); - -export const isAdult = (dateOfBirth: string): boolean => { - const result = dateRegex.exec(dateOfBirth); - const date = dateRegexGroups.decode(result?.groups); - if (isRight(date)) { - const year = Number(date.value.year); - const currentDate = new Date(); - const currentYear = currentDate.getFullYear(); - const currentMonth = currentDate.getMonth() + 1; - const month = Number(date.value.month); - const dayNumber = Number(date.value.day); - const currentDayNumber = currentDate.getDate(); - if ( - year + 18 > currentYear || - (year + 18 === currentYear && - (currentMonth > month || - (currentMonth === month && currentDayNumber > dayNumber))) - ) { - return false; - } else { - return true; - } - } - return false; +/** + * Returns a comparator of two dates that returns true if + * the difference in years is at least the provided value. + */ +export const isOlderThan = (years: number) => ( + dateOfBirth: Date, + when: Date +) => { + return !isAfter(addYears(dateOfBirth, years), when); };