diff --git a/server/scripts/approve_client.py b/server/scripts/approve_client.py new file mode 100644 index 0000000..bea1f98 --- /dev/null +++ b/server/scripts/approve_client.py @@ -0,0 +1,51 @@ +from configparser import ConfigParser +from datetime import datetime +import sys +import base64 +import psycopg2 + +def approve_client(): + if(len(sys.argv)!=2): + print("Invalid number of cmd line arguments provided.") + client_id = base64.b64decode(sys.argv[1]) + with conn.cursor() as cursor: + cursor.execute("UPDATE auth_secret SET is_verified = 1 WHERE auth_secret.client_id = {client_id}".format( + client_id=client_id + )) + conn.commit() + print("Auth Secret updated") + + +def connect(): + conn = None + print("Connecting to PostgreSQL server...") + + parser = ConfigParser() + with open("db.ini") as f: + parser.read_file(f) + + keys = parser["postgresql"] + try: + conn = psycopg2.connect( + host=keys.get("host"), + database=keys.get("database"), + user=keys.get("user"), + password=keys.get("password")) + print("Connection Successful") + cursor = conn.cursor() + cursor.execute("SELECT version()") + print(cursor.fetchone()) + return conn + except (Exception, psycopg2.DatabaseError) as error: + print(error) + if conn is not None: + conn.close() + print("Connection Closed") + + +conn = connect() + +if conn is None: + exit() + +approve_client() diff --git a/server/scripts/requirements.txt b/server/scripts/requirements.txt new file mode 100644 index 0000000..010a125 --- /dev/null +++ b/server/scripts/requirements.txt @@ -0,0 +1 @@ +psycopg2==2.8.6 diff --git a/server/src/migrations/Migration20210829055229.ts b/server/src/migrations/Migration20210829055229.ts new file mode 100644 index 0000000..35ab193 --- /dev/null +++ b/server/src/migrations/Migration20210829055229.ts @@ -0,0 +1,12 @@ +import { Migration } from '@mikro-orm/migrations'; + +export class Migration20210829055229 extends Migration { + + async up(): Promise { + this.addSql('alter table "user" drop column "access_token";'); + this.addSql('alter table "user" drop column "refresh_token";'); + + this.addSql('alter table "auth_secret" add column "decoded_redirect_uri" varchar(255) not null, add column "client_name" varchar(255) not null;'); + } + +} diff --git a/server/src/modules/users/application/use-cases/authenticate-user/authenticate-user-errors.ts b/server/src/modules/users/application/use-cases/authenticate-user/authenticate-user-errors.ts index aa9641e..2da9230 100644 --- a/server/src/modules/users/application/use-cases/authenticate-user/authenticate-user-errors.ts +++ b/server/src/modules/users/application/use-cases/authenticate-user/authenticate-user-errors.ts @@ -1,9 +1,8 @@ export namespace AuthenticateUserErrors { - export class AuthenticationFailedError { - public message: string - public constructor(email: string, message: string) { - this.message = `Authentication for user with ${email} failed: ${message}` - } + export class AuthenticationFailedError extends Error { + public constructor(email: string, message: string) { + super() + this.message = `Authentication for user with ${email} failed: ${message}` } } - \ No newline at end of file +} diff --git a/server/src/modules/users/application/use-cases/authenticate-user/authenticate-user-use-case.ts b/server/src/modules/users/application/use-cases/authenticate-user/authenticate-user-use-case.ts index 5ce1352..dc1cbe2 100644 --- a/server/src/modules/users/application/use-cases/authenticate-user/authenticate-user-use-case.ts +++ b/server/src/modules/users/application/use-cases/authenticate-user/authenticate-user-use-case.ts @@ -9,13 +9,14 @@ import { AuthenticateUserDTO } from './authenticate-user-dto' import { AuthenticateUserErrors } from './authenticate-user-errors' type AuthenticateUserUseCaseError = - AuthenticateUserErrors.AuthenticationFailedError + | AuthenticateUserErrors.AuthenticationFailedError | AppError.UnexpectedError export type AuthenticateUserUseCaseResponse = Result export class AuthenticateUserUseCase - implements UseCaseWithDTO { + implements UseCaseWithDTO +{ private userRepo: UserRepo constructor(userRepo: UserRepo) { @@ -37,14 +38,18 @@ export class AuthenticateUserUseCase const email = results[0].value const password = results[1].value - try { - const userByEmailAndPassword = await this.userRepo.getUserByUserEmailandUserPassword(email, password) - if (userByEmailAndPassword.isErr()) { - return Result.err(new AuthenticateUserErrors.AuthenticationFailedError(email.value, userByEmailAndPassword.error.message)) - } - return Result.ok(userByEmailAndPassword.value) - } catch (err) { - return Result.err(new AppError.UnexpectedError(err)) + const userByEmailAndPassword = await this.userRepo.getUserByUserEmailandUserPassword( + email, + password + ) + if (userByEmailAndPassword.isErr()) { + return Result.err( + new AuthenticateUserErrors.AuthenticationFailedError( + email.value, + userByEmailAndPassword.error.message + ) + ) } + return Result.ok(userByEmailAndPassword.value) } } diff --git a/server/src/modules/users/application/use-cases/authorize-user/__tests__/authorize-user-controller.test.unit.ts b/server/src/modules/users/application/use-cases/authorize-user/__tests__/authorize-user-controller.test.unit.ts new file mode 100644 index 0000000..f0b11ac --- /dev/null +++ b/server/src/modules/users/application/use-cases/authorize-user/__tests__/authorize-user-controller.test.unit.ts @@ -0,0 +1,75 @@ +import express from 'express' +import httpMocks from 'node-mocks-http' +import { AppError } from '../../../../../../shared/core/app-error' +import { Result } from '../../../../../../shared/core/result' +import { AuthorizeUserDTO } from '../authorize-user-dto' +import { AuthorizeUserErrors } from '../authorize-user-errors' +import { AuthorizeUserUseCase } from '../authorize-user-use-case' +import { AuthorizeUserController } from '../authorize-user-controller' +import { mocks } from '../../../../../../test-utils' +import { ParamList, ParamPair } from '../../../../../../shared/app/param-list' + +describe('AuthorizeUserController', () => { + let authorizeUserDTO: AuthorizeUserDTO + let authorizeUserUseCase: AuthorizeUserUseCase + let authorizeUserController: AuthorizeUserController + let mockResponse: express.Response + + beforeAll(async () => { + const authorizeUser = await mocks.mockAuthorizeUser() + authorizeUserController = authorizeUser.authorizeUserController + authorizeUserUseCase = authorizeUser.authorizeUserUseCase + mockResponse = httpMocks.createResponse() + authorizeUserDTO = { + req: httpMocks.createRequest(), + params: { + client_id: '6a88757bceaddaf03540dbd891dfb828', + response_type: 'code', + redirect_uri: 'www.loolabs.org', + scope: 'openid', + }, + } + }) + + test('When the AuthorizeUserUseCase returns Ok, the AuthorizeUserController returns 302 Redirect', async () => { + const useCaseResolvedValue = { + redirectParams: new ParamList([new ParamPair('type', 'test')]), + redirectUrl: 'www.loolabs.org', + } + jest.spyOn(authorizeUserUseCase, 'execute').mockResolvedValue(Result.ok(useCaseResolvedValue)) + + const result = await authorizeUserController.executeImpl(authorizeUserDTO, mockResponse) + expect(result.statusCode).toBe(302) + }) + + test('When the AuthorizeUserUseCase returns AuthorizeUserErrors.InvalidRequestParameters, AuthorizeUserController returns 400 Bad Request', async () => { + jest + .spyOn(authorizeUserUseCase, 'execute') + .mockResolvedValue(Result.err(new AuthorizeUserErrors.InvalidRequestParameters())) + + const result = await authorizeUserController.executeImpl(authorizeUserDTO, mockResponse) + + expect(result.statusCode).toBe(400) + }) + + test('When the AuthorizeUserUseCase returns AuthorizeUserErrors.UserNotAuthenticated, AuthorizeUserController returns 302 Redirect', async () => { + const useCaseErrorValue = { + redirectParams: new ParamList([new ParamPair('type', 'test')]), + redirectUrl: 'www.loolabs.org', + } + jest.spyOn(authorizeUserUseCase, 'execute').mockResolvedValue(Result.err(useCaseErrorValue)) + + const result = await authorizeUserController.executeImpl(authorizeUserDTO, mockResponse) + expect(result.statusCode).toBe(302) + }) + + test('When the AuthorizeUserUseCase returns AppError.UnexpectedError, AuthorizeUserController returns 500 Internal Server Error', async () => { + jest + .spyOn(authorizeUserUseCase, 'execute') + .mockResolvedValue(Result.err(new AppError.UnexpectedError('Unexpected error'))) + + const result = await authorizeUserController.executeImpl(authorizeUserDTO, mockResponse) + + expect(result.statusCode).toBe(500) + }) +}) diff --git a/server/src/modules/users/application/use-cases/authorize-user/authorize-user-controller.ts b/server/src/modules/users/application/use-cases/authorize-user/authorize-user-controller.ts new file mode 100644 index 0000000..b0dabf2 --- /dev/null +++ b/server/src/modules/users/application/use-cases/authorize-user/authorize-user-controller.ts @@ -0,0 +1,53 @@ +import express from 'express' +import { ControllerWithDTO } from '../../../../../shared/app/controller-with-dto' +import { AuthorizeUserUseCase } from './authorize-user-use-case' +import { AuthorizeUserDTO, AuthorizeUserDTOSchema } from './authorize-user-dto' +import { AuthorizeUserErrors } from './authorize-user-errors' +import { Result } from '../../../../../shared/core/result' +import { ValidationError } from 'joi' + +export class AuthorizeUserController extends ControllerWithDTO { + constructor(useCase: AuthorizeUserUseCase) { + super(useCase) + } + + buildDTO(req: express.Request): Result> { + let params: any = req.params + const errs: Array = [] + const compiledRequest = { + req, + params, + } + const bodyResult = this.validate(compiledRequest, AuthorizeUserDTOSchema) + if (bodyResult.isOk()) { + const body = bodyResult.value + return Result.ok(body) + } else { + errs.push(bodyResult.error) + return Result.err(errs) + } + } + + async executeImpl(dto: AuthorizeUserDTO, res: Res): Promise { + try { + const result = await this.useCase.execute(dto) + + if (result.isOk()) { + return this.redirect(res, result.value.redirectUrl, result.value.redirectParams) + } else { + const error = result.error + if ('redirectParams' in error) { + return this.redirect(res, error.redirectUrl, error.redirectParams) + } + switch (error.constructor) { + case AuthorizeUserErrors.InvalidRequestParameters: + return this.clientError(res, error.message) + default: + return this.fail(res, error.message) + } + } + } catch (err) { + return this.fail(res, err) + } + } +} diff --git a/server/src/modules/users/application/use-cases/authorize-user/authorize-user-dto.ts b/server/src/modules/users/application/use-cases/authorize-user/authorize-user-dto.ts new file mode 100644 index 0000000..91bd263 --- /dev/null +++ b/server/src/modules/users/application/use-cases/authorize-user/authorize-user-dto.ts @@ -0,0 +1,33 @@ +import Joi from 'joi' +import express from 'express' + +export const SUPPORTED_OPEN_ID_RESPONSE_TYPES = ['code'] +export const SUPPORTED_OPEN_ID_SCOPE = ['openid'] + +export interface AuthorizeUserDTOParams { + client_id: string + scope: string + response_type: string + redirect_uri: string +} + +export interface AuthorizeUserDTO { + req: express.Request + params: AuthorizeUserDTOParams +} + +export const AuthorizeUserDTOParamsSchema = Joi.object({ + client_id: Joi.string().required(), + scope: Joi.string() + .valid(...SUPPORTED_OPEN_ID_SCOPE) + .required(), + response_type: Joi.string() + .valid(...SUPPORTED_OPEN_ID_RESPONSE_TYPES) + .required(), + redirect_uri: Joi.string().uri().required(), +}).options({ abortEarly: false }) + +export const AuthorizeUserDTOSchema = Joi.object({ + req: Joi.object().required(), + params: AuthorizeUserDTOParamsSchema.optional(), // this ensures that all of the necessary request params for client authentication are present, not just an insufficient subset +}).options({ abortEarly: false }) diff --git a/server/src/modules/users/application/use-cases/authorize-user/authorize-user-errors.ts b/server/src/modules/users/application/use-cases/authorize-user/authorize-user-errors.ts new file mode 100644 index 0000000..db94363 --- /dev/null +++ b/server/src/modules/users/application/use-cases/authorize-user/authorize-user-errors.ts @@ -0,0 +1,12 @@ +export namespace AuthorizeUserErrors { + export class InvalidRequestParameters extends Error { + public constructor() { + super(`Invalid openid request parameters supplied.`) + } + } + export class UserNotAuthenticated extends Error { + public constructor(email: string) { + super(`The user with email ${email} is not authenticated.`) + } + } +} diff --git a/server/src/modules/users/application/use-cases/authorize-user/authorize-user-use-case.ts b/server/src/modules/users/application/use-cases/authorize-user/authorize-user-use-case.ts new file mode 100644 index 0000000..8ad7bc6 --- /dev/null +++ b/server/src/modules/users/application/use-cases/authorize-user/authorize-user-use-case.ts @@ -0,0 +1,76 @@ +import { UseCaseWithDTO } from '../../../../../shared/app/use-case-with-dto' +import { AppError } from '../../../../../shared/core/app-error' +import { Result } from '../../../../../shared/core/result' +import { AuthorizeUserDTO } from './authorize-user-dto' +import { AuthorizeUserErrors } from './authorize-user-errors' +import { ParamList, ParamPair } from '../../../../../shared/app/param-list' +import { AuthCodeRepo } from '../../../infra/repos/auth-code-repo/auth-code-repo' +import { AuthSecretRepo } from '../../../infra/repos/auth-secret-repo/auth-secret-repo' +import { AuthCode } from '../../../domain/entities/auth-code' +import { AuthCodeString } from '../../../domain/value-objects/auth-code-string' +import { User } from '../../../domain/entities/user' + +export type AuthorizeUserUseCaseClientError = + | AuthorizeUserErrors.InvalidRequestParameters + | AppError.UnexpectedError + +export type AuthorizeUserUseCaseRedirectError = { + redirectParams: ParamList + redirectUrl: string +} + +export type AuthorizeUserUseCaseError = + | AuthorizeUserUseCaseClientError + | AuthorizeUserUseCaseRedirectError + +export interface AuthorizeUserSuccess { + redirectParams: ParamList + redirectUrl: string +} + +export type AuthorizeUserUseCaseResponse = Result + +export class AuthorizeUserUseCase + implements UseCaseWithDTO +{ + constructor(private authCodeRepo: AuthCodeRepo, private authSecretRepo: AuthSecretRepo) {} + + async execute(dto: AuthorizeUserDTO): Promise { + const params = dto.params + const decodedUri = decodeURI(params.redirect_uri) + const authSecretExists = await this.authSecretRepo.exists(params.client_id, decodedUri) + if (authSecretExists.isErr() || authSecretExists.value === false) { + return Result.err(new AuthorizeUserErrors.InvalidRequestParameters()) + } + const user = dto.req.user as User + if (user === undefined) { + const redirectParams = new ParamList( + Object.entries(params).map((paramPair) => new ParamPair(paramPair[0], paramPair[1])) + ) + return Result.err({ + redirectParams, + redirectUrl: `${process.env.PUBLIC_HOST}/login`, + }) + } + const authCode = AuthCode.create({ + clientId: params.client_id, + userId: user.userId.id.toString(), + userEmail: user.email.value, + userEmailVerified: user.isEmailVerified || false, + authCodeString: new AuthCodeString(), + }) + if (authCode.isErr()) { + return Result.err(new AppError.UnexpectedError('Authcode creation failed')) + } + await this.authCodeRepo.save(authCode.value) + const redirectParams = new ParamList([ + new ParamPair('code', authCode.value.authCodeString.getValue()), + ]) + const AuthorizeUserSuccessResponse: AuthorizeUserSuccess = { + redirectParams: redirectParams, + redirectUrl: params.redirect_uri, + } + + return Result.ok(AuthorizeUserSuccessResponse) + } +} diff --git a/server/src/modules/users/application/use-cases/create-user/__tests__/create-user-controller.test.unit.ts b/server/src/modules/users/application/use-cases/create-user/__tests__/create-user-controller.test.unit.ts index fb0b537..31fbf4c 100644 --- a/server/src/modules/users/application/use-cases/create-user/__tests__/create-user-controller.test.unit.ts +++ b/server/src/modules/users/application/use-cases/create-user/__tests__/create-user-controller.test.unit.ts @@ -8,18 +8,17 @@ import { CreateUserErrors } from '../create-user-errors' import { CreateUserSuccess, CreateUserUseCase } from '../create-user-use-case' import { UserMap } from '../../../../mappers/user-map' -// TODO: how to show developer these mocks are necessary when building a controller? aka must be synced with buildController() -jest.mock('../create-user-use-case') - describe('CreateUserController', () => { const createUserDTO: CreateUserDTOBody = { email: 'john.doe@uwaterloo.ca', password: 'secret23', } let createUserController: CreateUserController + let createUserUseCase: CreateUserUseCase beforeAll(async () => { const createUser = await mocks.mockCreateUser() createUserController = createUser.createUserController + createUserUseCase = createUser.createUserUseCase }) test('When the CreateUserUseCase returns Ok, the CreateUserController returns 200 OK', async () => { @@ -28,8 +27,8 @@ describe('CreateUserController', () => { const useCaseResolvedValue: CreateUserSuccess = { user: UserMap.toDTO(user), } - - jest.spyOn(CreateUserUseCase.prototype, 'execute').mockResolvedValue(Result.ok(useCaseResolvedValue)) + + jest.spyOn(createUserUseCase, 'execute').mockResolvedValue(Result.ok(useCaseResolvedValue)) const { req, res } = mocks.mockHandlerParams(createUserDTO) await createUserController.execute(req, res) @@ -38,7 +37,7 @@ describe('CreateUserController', () => { test('When the CreateUserUseCase returns UserValueObjectErrors.InvalidEmail, CreateUserController returns 400 Bad Request', async () => { jest - .spyOn(CreateUserUseCase.prototype, 'execute') + .spyOn(createUserUseCase, 'execute') .mockResolvedValue(Result.err(new UserValueObjectErrors.InvalidEmail(createUserDTO.email))) const { req, res } = mocks.mockHandlerParams(createUserDTO) @@ -57,7 +56,7 @@ describe('CreateUserController', () => { test('When the CreateUserUseCase returns UserValueObjectErrors.InvalidSecretValue, CreateUserController returns 400 Bad Request', async () => { jest - .spyOn(CreateUserUseCase.prototype, 'execute') + .spyOn(createUserUseCase, 'execute') .mockResolvedValue( Result.err(new UserValueObjectErrors.InvalidSecretValue(createUserDTO.password)) ) @@ -69,7 +68,7 @@ describe('CreateUserController', () => { test('When the CreateUserUseCase returns CreateUserErrors.EmailAlreadyExistsError, CreateUserController returns 409 Conflict', async () => { jest - .spyOn(CreateUserUseCase.prototype, 'execute') + .spyOn(createUserUseCase, 'execute') .mockResolvedValue( Result.err(new CreateUserErrors.EmailAlreadyExistsError(createUserDTO.email)) ) @@ -81,7 +80,7 @@ describe('CreateUserController', () => { test('When the CreateUserUseCase returns AppError.UnexpectedError, CreateUserController returns 500 Internal Server Error', async () => { jest - .spyOn(CreateUserUseCase.prototype, 'execute') + .spyOn(createUserUseCase, 'execute') .mockResolvedValue(Result.err(new AppError.UnexpectedError('Unexpected error'))) const { req, res } = mocks.mockHandlerParams(createUserDTO) diff --git a/server/src/modules/users/application/use-cases/create-user/__tests__/create-user-use-case.test.unit.ts b/server/src/modules/users/application/use-cases/create-user/__tests__/create-user-use-case.test.unit.ts index e21fe91..130149b 100644 --- a/server/src/modules/users/application/use-cases/create-user/__tests__/create-user-use-case.test.unit.ts +++ b/server/src/modules/users/application/use-cases/create-user/__tests__/create-user-use-case.test.unit.ts @@ -1,4 +1,5 @@ import { mocks } from '../../../../../../test-utils' +import httpMocks from 'node-mocks-http' import { Err, Result } from '../../../../../../shared/core/result' import { UserRepo } from '../../../../infra/repos/user-repo/user-repo' import { UserValueObjectErrors } from '../../../../domain/value-objects/errors' @@ -22,6 +23,8 @@ describe('CreateUserUseCase', () => { beforeEach(() => { createUserDTO = { + req: httpMocks.createRequest(), + res: httpMocks.createResponse(), body: { email: 'john.doe@uwaterloo.ca', password: 'secret23', @@ -31,9 +34,11 @@ describe('CreateUserUseCase', () => { test('When executed with valid DTO, should save the user and return an Ok', async () => { const mockUser = mocks.mockUser(createUserDTO.body) - jest.spyOn(userRepo, 'exists').mockResolvedValue(Result.err(new DBError.UserNotFoundError(createUserDTO.body.email))) + jest + .spyOn(userRepo, 'exists') + .mockResolvedValue(Result.err(new DBError.UserNotFoundError(createUserDTO.body.email))) jest.spyOn(userRepo, 'getUserByUserEmail').mockResolvedValue(Result.ok(mockUser)) - + const createUserResult = await createUserUseCase.execute(createUserDTO) expect(userRepo.save).toBeCalled() @@ -45,7 +50,10 @@ describe('CreateUserUseCase', () => { const createUserResult = await createUserUseCase.execute(createUserDTO) expect(createUserResult.isErr()).toBe(true) - const createUserErr = createUserResult as Err + const createUserErr = createUserResult as Err< + CreateUserSuccess, + UserValueObjectErrors.InvalidEmail + > expect(createUserErr.error instanceof UserValueObjectErrors.InvalidEmail).toBe(true) }) @@ -54,7 +62,10 @@ describe('CreateUserUseCase', () => { const createUserResult = await createUserUseCase.execute(createUserDTO) expect(createUserResult.isErr()).toBe(true) - const createUserErr = createUserResult as Err + const createUserErr = createUserResult as Err< + CreateUserSuccess, + UserValueObjectErrors.InvalidSecretValue + > expect(createUserErr.error instanceof UserValueObjectErrors.InvalidSecretValue).toBe(true) }) @@ -63,7 +74,10 @@ describe('CreateUserUseCase', () => { const createUserResult = await createUserUseCase.execute(createUserDTO) expect(createUserResult.isErr()).toBe(true) - const createUserErr = createUserResult as Err + const createUserErr = createUserResult as Err< + CreateUserSuccess, + CreateUserErrors.EmailAlreadyExistsError + > expect(createUserErr.error instanceof CreateUserErrors.EmailAlreadyExistsError).toBe(true) }) }) diff --git a/server/src/modules/users/application/use-cases/create-user/create-user-controller.ts b/server/src/modules/users/application/use-cases/create-user/create-user-controller.ts index a69b664..2735d50 100644 --- a/server/src/modules/users/application/use-cases/create-user/create-user-controller.ts +++ b/server/src/modules/users/application/use-cases/create-user/create-user-controller.ts @@ -12,15 +12,20 @@ export class CreateUserController extends ControllerWithDTO { super(useCase) } - buildDTO(req: express.Request): Result> { + buildDTO( + req: express.Request, + res: express.Response + ): Result> { let params: any = req.params - if(Object.keys(req.params).length === 0){ + if (Object.keys(req.params).length === 0) { params = undefined } const errs: Array = [] const compiledRequest = { + req, + res, body: req.body, - params + params, } const bodyResult = this.validate(compiledRequest, createUserDTOSchema) if (bodyResult.isOk()) { @@ -35,9 +40,9 @@ export class CreateUserController extends ControllerWithDTO { async executeImpl(dto: CreateUserDTO, res: Res): Promise { try { const result = await this.useCase.execute(dto) - + if (result.isOk()) { - if('user' in result.value){ + if ('user' in result.value) { return this.ok(res, result.value) } else { return this.redirect(res, result.value.redirectUrl, result.value.redirectParams) diff --git a/server/src/modules/users/application/use-cases/create-user/create-user-dto.ts b/server/src/modules/users/application/use-cases/create-user/create-user-dto.ts index 202a987..ced33fd 100644 --- a/server/src/modules/users/application/use-cases/create-user/create-user-dto.ts +++ b/server/src/modules/users/application/use-cases/create-user/create-user-dto.ts @@ -1,5 +1,9 @@ +import express from 'express' import Joi from 'joi' +export const SUPPORTED_OPEN_ID_RESPONSE_TYPES = ['code'] +export const SUPPORTED_OPEN_ID_SCOPE = ['openid'] + export interface CreateUserDTOBody { email: string password: string @@ -8,12 +12,14 @@ export interface CreateUserDTOBody { export interface CreateUserDTOParams { client_id: string scope: string - response_type: string, + response_type: string redirect_uri: string } -export interface CreateUserDTO { - body: CreateUserDTOBody, +export interface CreateUserDTO { + req: express.Request + res: express.Response + body: CreateUserDTOBody params?: CreateUserDTOParams } @@ -22,14 +28,9 @@ export const createUserDTOBodySchema = Joi.object({ password: Joi.string().required(), }).options({ abortEarly: false }) -export const createUserDTOParamsSchema = Joi.object({ - client_id: Joi.string().required(), - scope: Joi.string().required(), - response_type: Joi.string().required(), - redirect_uri: Joi.string().required() -}).options({ abortEarly: false }) - export const createUserDTOSchema = Joi.object({ + req: Joi.object().required(), + res: Joi.object().required(), body: createUserDTOBodySchema.required(), - params: createUserDTOParamsSchema.optional() // this ensures that all of the necessary request params for client authentication are present, not just an insufficient subset + params: Joi.object().optional(), }).options({ abortEarly: false }) diff --git a/server/src/modules/users/application/use-cases/create-user/create-user-errors.ts b/server/src/modules/users/application/use-cases/create-user/create-user-errors.ts index b5e7e9c..24b9e4d 100644 --- a/server/src/modules/users/application/use-cases/create-user/create-user-errors.ts +++ b/server/src/modules/users/application/use-cases/create-user/create-user-errors.ts @@ -4,10 +4,4 @@ export namespace CreateUserErrors { super(`An account with the email ${email} already exists`) } } - - export class InvalidOpenIDParamsError extends Error { - public constructor() { - super(`Invalid OpenID parameters were provided.`) - } - } } diff --git a/server/src/modules/users/application/use-cases/create-user/create-user-use-case.ts b/server/src/modules/users/application/use-cases/create-user/create-user-use-case.ts index 5f98b9d..039a106 100644 --- a/server/src/modules/users/application/use-cases/create-user/create-user-use-case.ts +++ b/server/src/modules/users/application/use-cases/create-user/create-user-use-case.ts @@ -8,7 +8,10 @@ import { UserPassword } from '../../../domain/value-objects/user-password' import { UserRepo } from '../../../infra/repos/user-repo/user-repo' import { CreateUserDTO } from './create-user-dto' import { CreateUserErrors } from './create-user-errors' -import { UserAuthHandler } from '../../../../../shared/auth/user-auth-handler' +import { + UserAuthHandler, + UserAuthHandlerLoginResponse, +} from '../../../../../shared/auth/user-auth-handler' import { ParamList, ParamPair } from '../../../../../shared/app/param-list' import { UserDTO } from '../../../mappers/user-dto' import { UserMap } from '../../../mappers/user-map' @@ -17,17 +20,16 @@ export type CreateUserUseCaseError = | UserValueObjectErrors.InvalidEmail | UserValueObjectErrors.InvalidSecretValue | CreateUserErrors.EmailAlreadyExistsError - | CreateUserErrors.InvalidOpenIDParamsError | AppError.UnexpectedError -export interface CreateUserClientRequestSuccess { - redirectParams: ParamList, +export interface CreateUserClientRequestSuccess { + redirectParams: ParamList redirectUrl: string -} +} export interface CreateUserNonClientRequestSuccess { user: UserDTO -} +} // TODO: perhaps better to decouple these into separate use-cases or further subclasses export type CreateUserSuccess = CreateUserClientRequestSuccess | CreateUserNonClientRequestSuccess @@ -35,14 +37,9 @@ export type CreateUserSuccess = CreateUserClientRequestSuccess | CreateUserNonCl export type CreateUserUseCaseResponse = Result export class CreateUserUseCase implements UseCaseWithDTO { - constructor(private authHandler: UserAuthHandler, private userRepo: UserRepo) {} + constructor(private userAuthHandler: UserAuthHandler, private userRepo: UserRepo) {} async execute(dto: CreateUserDTO): Promise { - if(dto.params && dto.params.scope){ - if(dto.params.scope !== 'openid' || dto.params.response_type !== 'code'){ - return Result.err(new CreateUserErrors.InvalidOpenIDParamsError()) - } - } const emailResult = UserEmail.create(dto.body.email) const passwordResult = UserPassword.create({ value: dto.body.password, @@ -57,52 +54,47 @@ export class CreateUserUseCase implements UseCaseWithDTO new ParamPair(paramPair[0], paramPair[1])) + ) + const loginUserSuccessResponse: CreateUserSuccess = { redirectParams: redirectParams, - redirectUrl: dto.params.redirect_uri + redirectUrl: `${process.env.PUBLIC_HOST}/authorize`, } - - return Result.ok(createUserSuccessResponse) + return Result.ok(loginUserSuccessResponse) } - - } catch (err) { - return Result.err(new AppError.UnexpectedError(err)) + } else { + return Result.ok({ + user: UserMap.toDTO(updatedUser.value), + }) } } } diff --git a/server/src/modules/users/application/use-cases/discover-sp/__tests__/discover-sp.test.unit.ts b/server/src/modules/users/application/use-cases/discover-sp/__tests__/discover-sp.test.unit.ts new file mode 100644 index 0000000..c99f9d0 --- /dev/null +++ b/server/src/modules/users/application/use-cases/discover-sp/__tests__/discover-sp.test.unit.ts @@ -0,0 +1,61 @@ +import express from 'express' +import httpMocks from 'node-mocks-http' +import { AppError } from '../../../../../../shared/core/app-error' +import { Result } from '../../../../../../shared/core/result' +import { DiscoverSPDTO } from '../discover-sp-dto' +import { DiscoverSPErrors } from '../discover-sp-errors' +import { DiscoverSPUseCase } from '../discover-sp-use-case' +import { DiscoverSPController } from '../discover-sp-controller' +import { mocks } from '../../../../../../test-utils' + +describe('DiscoverSPController', () => { + let discoverSPDTO: DiscoverSPDTO + let discoverSPController: DiscoverSPController + let discoverSPUseCase: DiscoverSPUseCase + let mockResponse: express.Response + + beforeAll(async () => { + const discoverSP = await mocks.mockDiscoverSP() + discoverSPController = discoverSP.discoverSPController + discoverSPUseCase = discoverSP.discoverSPUseCase + mockResponse = httpMocks.createResponse() + discoverSPDTO = { + client_name: 'testclient', + redirect_uri: 'www.loolabs.org/cb', + } + }) + + test('When the DiscoverSPUseCase returns Ok, the DiscoverSPController returns 200 OK', async () => { + const useCaseResolvedValue = { + clientId: 'fcc89db61d93607afbb7008df9197570', + clientSecret: '81281dd17eafeda8f34b2192aff22f2a', + } + jest.spyOn(discoverSPUseCase, 'execute').mockResolvedValue(Result.ok(useCaseResolvedValue)) + + const result = await discoverSPController.executeImpl(discoverSPDTO, mockResponse) + + expect(result.statusCode).toBe(200) + }) + + test('When the DiscoverSPUseCase returns DiscoverSPErrors, DiscoverSPController returns 400 Bad Request', async () => { + jest + .spyOn(discoverSPUseCase, 'execute') + .mockResolvedValue( + Result.err(new DiscoverSPErrors.ClientNameAlreadyInUse(discoverSPDTO.client_name)) + ) + + const result = await discoverSPController.executeImpl(discoverSPDTO, mockResponse) + + expect(result.statusCode).toBe(400) + }) + + test('When the DiscoverSPUseCase returns AppError.UnexpectedError, DiscoverSPController returns 500 Internal Server Error', async () => { + jest + .spyOn(discoverSPUseCase, 'execute') + .mockResolvedValue(Result.err(new AppError.UnexpectedError('Unexpected error'))) + + const result = await discoverSPController.executeImpl(discoverSPDTO, mockResponse) + + expect(result.statusCode).toBe(500) + }) +}) diff --git a/server/src/modules/users/application/use-cases/protected-user/protected-user-controller.ts b/server/src/modules/users/application/use-cases/discover-sp/discover-sp-controller.ts similarity index 51% rename from server/src/modules/users/application/use-cases/protected-user/protected-user-controller.ts rename to server/src/modules/users/application/use-cases/discover-sp/discover-sp-controller.ts index b6ba55f..e48ad60 100644 --- a/server/src/modules/users/application/use-cases/protected-user/protected-user-controller.ts +++ b/server/src/modules/users/application/use-cases/discover-sp/discover-sp-controller.ts @@ -1,21 +1,21 @@ import express from 'express' +import { DiscoverSPUseCase } from './discover-sp-use-case' +import { DiscoverSPDTO, discoverSPDTOSchema } from './discover-sp-dto' +import { DiscoverSPErrors } from './discover-sp-errors' import { ControllerWithDTO } from '../../../../../shared/app/controller-with-dto' -import { ProtectedUserUseCase } from './protected-user-use-case' -import { ProtectedUserDTO, protectedUserDTOSchema } from './protected-user-dto' import { Result } from '../../../../../shared/core/result' import { ValidationError } from 'joi' -export class ProtectedUserController extends ControllerWithDTO { - constructor(useCase: ProtectedUserUseCase) { super(useCase) } +export class DiscoverSPController extends ControllerWithDTO { + constructor(useCase: DiscoverSPUseCase) { + super(useCase) + } - buildDTO(req: express.Request): Result> { + buildDTO(req: express.Request): Result> { const errs: Array = [] - const compiledBody = { - user: req.user - } - const bodyResult = this.validate(compiledBody, protectedUserDTOSchema) + const bodyResult = this.validate(req.body, discoverSPDTOSchema) if (bodyResult.isOk()) { - const body = bodyResult.value + const body: DiscoverSPDTO = bodyResult.value return Result.ok(body) } else { errs.push(bodyResult.error) @@ -23,16 +23,18 @@ export class ProtectedUserController extends ControllerWithDTO { + async executeImpl(dto: DiscoverSPDTO, res: express.Response): Promise { try { const result = await this.useCase.execute(dto) - + if (result.isOk()) { return this.ok(res, result.value) } else { const error = result.error switch (error.constructor) { + case DiscoverSPErrors.ClientNameAlreadyInUse: + return this.clientError(res, error.message) default: return this.fail(res, error.message) } diff --git a/server/src/modules/users/application/use-cases/discover-sp/discover-sp-dto.ts b/server/src/modules/users/application/use-cases/discover-sp/discover-sp-dto.ts new file mode 100644 index 0000000..0558dc0 --- /dev/null +++ b/server/src/modules/users/application/use-cases/discover-sp/discover-sp-dto.ts @@ -0,0 +1,11 @@ +import Joi from 'joi' + +export interface DiscoverSPDTO { + client_name: string + redirect_uri: string +} + +export const discoverSPDTOSchema = Joi.object({ + client_name: Joi.string().required(), + redirect_uri: Joi.string().uri().required(), +}).options({ abortEarly: false }) diff --git a/server/src/modules/users/application/use-cases/discover-sp/discover-sp-errors.ts b/server/src/modules/users/application/use-cases/discover-sp/discover-sp-errors.ts new file mode 100644 index 0000000..49e40a5 --- /dev/null +++ b/server/src/modules/users/application/use-cases/discover-sp/discover-sp-errors.ts @@ -0,0 +1,8 @@ +export namespace DiscoverSPErrors { + export class ClientNameAlreadyInUse extends Error { + public constructor(clientName: string) { + super() + this.message = `The provided client name ${clientName} is already in use.` + } + } +} diff --git a/server/src/modules/users/application/use-cases/discover-sp/discover-sp-use-case.ts b/server/src/modules/users/application/use-cases/discover-sp/discover-sp-use-case.ts new file mode 100644 index 0000000..353ce3c --- /dev/null +++ b/server/src/modules/users/application/use-cases/discover-sp/discover-sp-use-case.ts @@ -0,0 +1,61 @@ +import { UseCaseWithDTO } from '../../../../../shared/app/use-case-with-dto' +import '../../../../../shared/auth/user-auth-handler' +import { DiscoverSPDTO } from './discover-sp-dto' +import { AppError } from '../../../../../shared/core/app-error' +import { DiscoverSPErrors } from './discover-sp-errors' +import { Result } from '../../../../../shared/core/result' +import { AuthSecretRepo } from '../../../infra/repos/auth-secret-repo/auth-secret-repo' +import { EncryptedClientSecret } from '../../../domain/value-objects/encrypted-client-secret' +import { AuthSecret } from '../../../domain/entities/auth-secret' +import crypto from 'crypto' + +export type DiscoverSPUseCaseError = + | DiscoverSPErrors.ClientNameAlreadyInUse + | AppError.UnexpectedError + +export interface DiscoverSPSuccess { + clientId: string + clientSecret: string +} + +export type DiscoverSPUseCaseResponse = Result + +export class DiscoverSPUseCase implements UseCaseWithDTO { + constructor(private authSecretRepo: AuthSecretRepo) {} + + async execute(dto: DiscoverSPDTO): Promise { + const authSecretExists = await this.authSecretRepo.clientNameExists(dto.client_name) + if (authSecretExists.isErr()) { + return Result.err( + new AppError.UnexpectedError('Unexpected error when validating client name.') + ) + } + if (authSecretExists.value) { + return Result.err(new DiscoverSPErrors.ClientNameAlreadyInUse(dto.client_name)) + } + const encryptedClientSecret = EncryptedClientSecret.create({ + value: crypto.randomBytes(32).toString('hex'), + hashed: false, + }) + if (encryptedClientSecret.isErr()) { + return Result.err( + new AppError.UnexpectedError('Unexpected error when creating client secret.') + ) + } + const authSecret = AuthSecret.create({ + clientName: dto.client_name, + encryptedClientSecret: encryptedClientSecret.value, + decodedRedirectUri: decodeURI(dto.redirect_uri), + isVerified: false, + clientId: crypto.randomBytes(32).toString('hex'), + }) + if (authSecret.isErr()) { + return Result.err(new AppError.UnexpectedError('Unexpected error when saving client secret.')) + } + await this.authSecretRepo.save(authSecret.value) + return Result.ok({ + clientId: Buffer.from(authSecret.value.clientId).toString('base64'), + clientSecret: Buffer.from(encryptedClientSecret.value.value).toString('base64'), + }) + } +} diff --git a/server/src/modules/users/application/use-cases/get-token/__tests__/get-token-controller.test.unit.ts b/server/src/modules/users/application/use-cases/get-token/__tests__/get-token-controller.test.unit.ts new file mode 100644 index 0000000..6edc7ff --- /dev/null +++ b/server/src/modules/users/application/use-cases/get-token/__tests__/get-token-controller.test.unit.ts @@ -0,0 +1,60 @@ +import express from 'express' +import httpMocks from 'node-mocks-http' +import { AppError } from '../../../../../../shared/core/app-error' +import { Result } from '../../../../../../shared/core/result' +import { GetTokenDTO } from '../get-token-dto' +import { GetTokenErrors } from '../get-token-errors' +import { GetTokenUseCase } from '../get-token-use-case' +import { GetTokenController } from '../get-token-controller' +import { mocks } from '../../../../../../test-utils' + +describe('GetTokenController', () => { + let getTokenDTO: GetTokenDTO + let getTokenController: GetTokenController + let getTokenUseCase: GetTokenUseCase + let mockResponse: express.Response + + beforeAll(async () => { + const getToken = await mocks.mockGetToken() + getTokenController = getToken.getTokenController + getTokenUseCase = getToken.getTokenUseCase + mockResponse = httpMocks.createResponse() + getTokenDTO = { + authHeader: 'asdklasdoladoassald', + params: { + code: 'sd', + grant_type: 'code', + response_type: 'id', + }, + } + }) + + test('When the GetTokenUseCase returns Ok, the GetTokenController returns 200 OK', async () => { + const useCaseResolvedValue = 'asdklasdhnjkjkewhf' + jest.spyOn(getTokenUseCase, 'execute').mockResolvedValue(Result.ok(useCaseResolvedValue)) + + const result = await getTokenController.executeImpl(getTokenDTO, mockResponse) + + expect(result.statusCode).toBe(200) + }) + + test('When the GetTokenUseCase returns GetTokenErrors.InvalidCredentials, GetTokenController returns 400 Bad Request', async () => { + jest + .spyOn(getTokenUseCase, 'execute') + .mockResolvedValue(Result.err(new GetTokenErrors.InvalidCredentials())) + + const result = await getTokenController.executeImpl(getTokenDTO, mockResponse) + + expect(result.statusCode).toBe(400) + }) + + test('When the GetTokenUseCase returns AppError.UnexpectedError, GetTokenController returns 500 Internal Server Error', async () => { + jest + .spyOn(getTokenUseCase, 'execute') + .mockResolvedValue(Result.err(new AppError.UnexpectedError('Unexpected error'))) + + const result = await getTokenController.executeImpl(getTokenDTO, mockResponse) + + expect(result.statusCode).toBe(500) + }) +}) diff --git a/server/src/modules/users/application/use-cases/get-token/get-token-controller.ts b/server/src/modules/users/application/use-cases/get-token/get-token-controller.ts new file mode 100644 index 0000000..aff87a3 --- /dev/null +++ b/server/src/modules/users/application/use-cases/get-token/get-token-controller.ts @@ -0,0 +1,50 @@ +import express from 'express' +import { GetTokenUseCase } from './get-token-use-case' +import { GetTokenDTO, getTokenDTOSchema } from './get-token-dto' +import { GetTokenErrors } from './get-token-errors' +import { ControllerWithDTO } from '../../../../../shared/app/controller-with-dto' +import { Result } from '../../../../../shared/core/result' +import { ValidationError } from 'joi' + +export class GetTokenController extends ControllerWithDTO { + constructor(useCase: GetTokenUseCase) { + super(useCase) + } + + buildDTO(req: express.Request): Result> { + const errs: Array = [] + const compiledValidationBody = { + authHeader: req.headers.authorization, + params: req.params, + } + const bodyResult = this.validate(compiledValidationBody, getTokenDTOSchema) + if (bodyResult.isOk()) { + const body: GetTokenDTO = bodyResult.value + return Result.ok(body) + } else { + errs.push(bodyResult.error) + return Result.err(errs) + } + } + + async executeImpl(dto: GetTokenDTO, res: express.Response): Promise { + try { + const result = await this.useCase.execute(dto) + + if (result.isOk()) { + return this.ok(res, result.value) + } else { + const error = result.error + + switch (error.constructor) { + case GetTokenErrors.InvalidCredentials: + return this.clientError(res, error.message) + default: + return this.fail(res, error.message) + } + } + } catch (err) { + return this.fail(res, err) + } + } +} diff --git a/server/src/modules/users/application/use-cases/get-token/get-token-dto.ts b/server/src/modules/users/application/use-cases/get-token/get-token-dto.ts new file mode 100644 index 0000000..0234b93 --- /dev/null +++ b/server/src/modules/users/application/use-cases/get-token/get-token-dto.ts @@ -0,0 +1,31 @@ +import Joi from 'joi' + +export const SUPPORTED_OPEN_ID_GRANT_TYPES = ['authorization_code'] +export const SUPPORTED_OPEN_ID_RESPONSE_TYPES = ['id'] + +export interface GetTokenDTOParams { + code: string + grant_type: string + response_type: string +} + +export interface GetTokenDTO { + authHeader: string + params: GetTokenDTOParams +} + +export const getTokenDTOParamsSchema = Joi.object({ + code: Joi.string().required(), + grant_type: Joi.string() + .valid(...SUPPORTED_OPEN_ID_GRANT_TYPES) + .required(), + response_type: Joi.string() + .valid(...SUPPORTED_OPEN_ID_RESPONSE_TYPES) + .required(), +}).options({ abortEarly: false }) + +export const getTokenDTOSchema = Joi.object({ + //Example: Authorization: Basic 3904238orfiefiekfhjri3u24r789 + authHeader: Joi.string().pattern(new RegExp('^Basic .+$')).required(), + params: getTokenDTOParamsSchema.required(), +}).options({ abortEarly: false }) diff --git a/server/src/modules/users/application/use-cases/get-token/get-token-errors.ts b/server/src/modules/users/application/use-cases/get-token/get-token-errors.ts new file mode 100644 index 0000000..d74abe4 --- /dev/null +++ b/server/src/modules/users/application/use-cases/get-token/get-token-errors.ts @@ -0,0 +1,8 @@ +export namespace GetTokenErrors { + export class InvalidCredentials extends Error { + public constructor() { + super() + this.message = `Incorrect authentication credentials provided.` + } + } +} diff --git a/server/src/modules/users/application/use-cases/get-token/get-token-use-case.ts b/server/src/modules/users/application/use-cases/get-token/get-token-use-case.ts new file mode 100644 index 0000000..6760fba --- /dev/null +++ b/server/src/modules/users/application/use-cases/get-token/get-token-use-case.ts @@ -0,0 +1,87 @@ +import { UseCaseWithDTO } from '../../../../../shared/app/use-case-with-dto' +import '../../../../../shared/auth/user-auth-handler' +import { GetTokenDTO } from './get-token-dto' +import { AppError } from '../../../../../shared/core/app-error' +import { GetTokenErrors } from './get-token-errors' +import { Result } from '../../../../../shared/core/result' +import { AuthCodeRepo } from '../../../infra/repos/auth-code-repo/auth-code-repo' +import { AuthCodeString } from '../../../domain/value-objects/auth-code-string' +import { DBError } from '../../../../../shared/infra/db/errors/errors' +import { AuthSecretRepo } from '../../../infra/repos/auth-secret-repo/auth-secret-repo' +import { EncryptedClientSecret } from '../../../domain/value-objects/encrypted-client-secret' +import { AuthCode } from '../../../domain/entities/auth-code' +import jwt from 'jsonwebtoken' + +export type GetTokenUseCaseError = GetTokenErrors.InvalidCredentials | AppError.UnexpectedError + +export type GetTokenIdToken = string + +export type GetTokenSupportedResponseType = GetTokenIdToken + +export type GetTokenUseCaseResponse = Result + +export const ID_TOKEN_EXPIRY_TIME_SECONDS = 300 //5 minutes + +export class GetTokenUseCase implements UseCaseWithDTO { + constructor(private authCodeRepo: AuthCodeRepo, private authSecretRepo: AuthSecretRepo) {} + + async execute(dto: GetTokenDTO): Promise { + const authHeader = dto.authHeader + const params = dto.params + + const authCodeString = new AuthCodeString(params.code) + + const authCodeResult = await this.authCodeRepo.getAuthCodeFromAuthCodeString(authCodeString) + + if (authCodeResult.isErr()) { + if (authCodeResult.error.constructor === DBError.AuthCodeNotFoundError) { + return Result.err(new GetTokenErrors.InvalidCredentials()) + } else { + return Result.err(authCodeResult.error) + } + } else { + const authHeaderValue = authHeader.split(' ')[1] + const authHeaderValueDecoded = Buffer.from(authHeaderValue, 'base64').toString('utf-8') + const serviceProviderCredentials = authHeaderValueDecoded.split(':') + const clientId = serviceProviderCredentials[0] + const clientSecret = serviceProviderCredentials[1] + const encryptedClientSecret = EncryptedClientSecret.create({ + value: clientSecret, + hashed: false, + }) + if (encryptedClientSecret.isErr() || clientId != authCodeResult.value.clientId) { + return Result.err(new GetTokenErrors.InvalidCredentials()) + } + const authSecretResult = await this.authSecretRepo.getAuthSecretByClientIdandSecret( + clientId, + encryptedClientSecret.value + ) + if (authSecretResult.isErr()) { + return Result.err(new GetTokenErrors.InvalidCredentials()) + } + this.authCodeRepo.delete(authCodeResult.value) + const successResponse: GetTokenSupportedResponseType = this.generateIdToken( + authCodeResult.value, + encryptedClientSecret.value + ) + + return Result.ok(successResponse) + } + } + + private generateIdToken(authCode: AuthCode, authSecret: EncryptedClientSecret): GetTokenIdToken { + //currently using HMAC symmetric signing via the client secret + return jwt.sign( + { + iss: `${process.env.PUBLIC_HOST}`, + sub: authCode.userId, + aud: authCode.clientId, + iat: new Date().getTime() / 1000, + exp: (new Date().getTime() + ID_TOKEN_EXPIRY_TIME_SECONDS * 1000) / 1000, + email: authCode.userEmail, + email_verified: authCode.userEmailVerified, + }, + authSecret.value + ) + } +} diff --git a/server/src/modules/users/application/use-cases/get-user/get-user-errors.ts b/server/src/modules/users/application/use-cases/get-user/get-user-errors.ts index 8cf2098..514bf9b 100644 --- a/server/src/modules/users/application/use-cases/get-user/get-user-errors.ts +++ b/server/src/modules/users/application/use-cases/get-user/get-user-errors.ts @@ -1,9 +1,8 @@ export namespace GetUserErrors { - export class GetUserByIdFailedError { - public message: string - public constructor(id: string) { - this.message = `No account with the id ${id} exists` - } + export class GetUserByIdFailedError extends Error { + public constructor(id: string) { + super() + this.message = `No account with the id ${id} exists` } } - \ No newline at end of file +} diff --git a/server/src/modules/users/application/use-cases/get-user/get-user-use-case.ts b/server/src/modules/users/application/use-cases/get-user/get-user-use-case.ts index deef69f..754905a 100644 --- a/server/src/modules/users/application/use-cases/get-user/get-user-use-case.ts +++ b/server/src/modules/users/application/use-cases/get-user/get-user-use-case.ts @@ -6,14 +6,11 @@ import { UserRepo } from '../../../infra/repos/user-repo/user-repo' import { GetUserDTO } from './get-user-dto' import { GetUserErrors } from './get-user-errors' -export type GetUserUseCaseError = - GetUserErrors.GetUserByIdFailedError - | AppError.UnexpectedError +export type GetUserUseCaseError = GetUserErrors.GetUserByIdFailedError | AppError.UnexpectedError export type GetUserUseCaseResponse = Result -export class GetUserUseCase - implements UseCaseWithDTO { +export class GetUserUseCase implements UseCaseWithDTO { private userRepo: UserRepo constructor(userRepo: UserRepo) { @@ -22,14 +19,9 @@ export class GetUserUseCase async execute(dto: GetUserDTO): Promise { const userId = dto.userId - try { - const userById = await this.userRepo.getUserByUserId(userId) - if (userById.isErr()) - return Result.err(new GetUserErrors.GetUserByIdFailedError(userId)) + const userById = await this.userRepo.getUserByUserId(userId) + if (userById.isErr()) return Result.err(new GetUserErrors.GetUserByIdFailedError(userId)) - return Result.ok(userById.value) - } catch (err) { - return Result.err(new AppError.UnexpectedError(err)) - } + return Result.ok(userById.value) } } diff --git a/server/src/modules/users/application/use-cases/login-user/__tests__/login-user-controller.test.unit.ts b/server/src/modules/users/application/use-cases/login-user/__tests__/login-user-controller.test.unit.ts index 49d82ff..ed8f303 100644 --- a/server/src/modules/users/application/use-cases/login-user/__tests__/login-user-controller.test.unit.ts +++ b/server/src/modules/users/application/use-cases/login-user/__tests__/login-user-controller.test.unit.ts @@ -12,28 +12,29 @@ import { CreateUserDTOBody } from '../../create-user/create-user-dto' // TODO: how to show developer these mocks are necessary when building a controller? aka must be synced with buildController() jest.mock('../../../../infra/repos/user-repo/implementations/mikro-user-repo') -jest.mock('../login-user-use-case') describe('LoginUserController', () => { let loginUserDTO: LoginUserDTO let userDTO: CreateUserDTOBody let loginUserController: LoginUserController + let loginUserUseCase: LoginUserUseCase beforeAll(async () => { const loginUser = await mocks.mockLoginUser() loginUserController = loginUser.loginUserController + loginUserUseCase = loginUser.loginUserUseCase }) beforeEach(() => { - userDTO = { + ;(userDTO = { email: 'loolabs@uwaterloo.ca', password: 'password', - }, - loginUserDTO = { - req: httpMocks.createRequest(), - res: httpMocks.createResponse(), - body: userDTO, - } + }), + (loginUserDTO = { + req: httpMocks.createRequest(), + res: httpMocks.createResponse(), + body: userDTO, + }) }) test('When the LoginUserUseCase returns Ok, the LoginUserController returns 200 OK', async () => { @@ -41,7 +42,7 @@ describe('LoginUserController', () => { const useCaseResolvedValue = { user: UserMap.toDTO(user), } - jest.spyOn(LoginUserUseCase.prototype, 'execute').mockResolvedValue(Result.ok(useCaseResolvedValue)) + jest.spyOn(loginUserUseCase, 'execute').mockResolvedValue(Result.ok(useCaseResolvedValue)) const result = await loginUserController.executeImpl(loginUserDTO, loginUserDTO.res) @@ -50,7 +51,7 @@ describe('LoginUserController', () => { test('When the LoginUserUseCase returns UserValueObjectErrors.InvalidEmail, LoginUserController returns 400 Bad Request', async () => { jest - .spyOn(LoginUserUseCase.prototype, 'execute') + .spyOn(loginUserUseCase, 'execute') .mockResolvedValue(Result.err(new UserValueObjectErrors.InvalidEmail(userDTO.email))) const result = await loginUserController.executeImpl(loginUserDTO, loginUserDTO.res) @@ -61,10 +62,8 @@ describe('LoginUserController', () => { test('When the LoginUserUseCase returns UserValueObjectErrors.InvalidSecretValue, LoginUserController returns 400 Bad Request', async () => { const mockResponse = httpMocks.createResponse() jest - .spyOn(LoginUserUseCase.prototype, 'execute') - .mockResolvedValue( - Result.err(new UserValueObjectErrors.InvalidSecretValue(userDTO.password)) - ) + .spyOn(loginUserUseCase, 'execute') + .mockResolvedValue(Result.err(new UserValueObjectErrors.InvalidSecretValue(userDTO.password))) const result = await loginUserController.executeImpl(loginUserDTO, mockResponse) @@ -73,10 +72,8 @@ describe('LoginUserController', () => { test('When the LoginUserUseCase returns LoginUserErrors.IncorrectPasswordError, LoginUserController returns 400 Unauthorized', async () => { jest - .spyOn(LoginUserUseCase.prototype, 'execute') - .mockResolvedValue( - Result.err(new LoginUserErrors.IncorrectPasswordError()) - ) + .spyOn(loginUserUseCase, 'execute') + .mockResolvedValue(Result.err(new LoginUserErrors.IncorrectPasswordError())) const result = await loginUserController.executeImpl(loginUserDTO, loginUserDTO.res) @@ -85,7 +82,7 @@ describe('LoginUserController', () => { test('When the LoginUserUseCase returns AppError.UnexpectedError, LoginUserController returns 500 Internal Server Error', async () => { jest - .spyOn(LoginUserUseCase.prototype, 'execute') + .spyOn(loginUserUseCase, 'execute') .mockResolvedValue(Result.err(new AppError.UnexpectedError('Unexpected error'))) const result = await loginUserController.executeImpl(loginUserDTO, loginUserDTO.res) diff --git a/server/src/modules/users/application/use-cases/login-user/login-user-controller.ts b/server/src/modules/users/application/use-cases/login-user/login-user-controller.ts index 7118cd9..98a286e 100644 --- a/server/src/modules/users/application/use-cases/login-user/login-user-controller.ts +++ b/server/src/modules/users/application/use-cases/login-user/login-user-controller.ts @@ -8,23 +8,28 @@ import { Result } from '../../../../../shared/core/result' import { ValidationError } from 'joi' export class LoginUserController extends ControllerWithDTO { - constructor(useCase: LoginUserUseCase) { super(useCase) } - buildDTO(req: express.Request, res: express.Response): Result> { + buildDTO( + req: express.Request, + res: express.Response + ): Result> { const errs: Array = [] let params: any = req.params - if(Object.keys(req.params).length === 0){ + if (Object.keys(req.params).length === 0) { params = undefined } const compiledValidationBody = { - req, res, body: req.body, params + req, + res, + body: req.body, + params, } const bodyResult = this.validate(compiledValidationBody, loginUserDTOSchema) if (bodyResult.isOk()) { - const body: LoginUserDTO = compiledValidationBody + const body: LoginUserDTO = bodyResult.value return Result.ok(body) } else { errs.push(bodyResult.error) @@ -37,7 +42,7 @@ export class LoginUserController extends ControllerWithDTO { const result = await this.useCase.execute(dto) if (result.isOk()) { - if('user' in result.value){ + if ('user' in result.value) { return this.ok(res, result.value) } else { return this.redirect(res, result.value.redirectUrl, result.value.redirectParams) diff --git a/server/src/modules/users/application/use-cases/login-user/login-user-dto.ts b/server/src/modules/users/application/use-cases/login-user/login-user-dto.ts index 005c56c..39e088c 100644 --- a/server/src/modules/users/application/use-cases/login-user/login-user-dto.ts +++ b/server/src/modules/users/application/use-cases/login-user/login-user-dto.ts @@ -9,14 +9,14 @@ export interface LoginUserDTOBody { export interface LoginUserDTOParams { client_id: string scope: string - response_type: string, + response_type: string redirect_uri: string } export interface LoginUserDTO { - req: express.Request, - res: express.Response, - body: LoginUserDTOBody, + req: express.Request + res: express.Response + body: LoginUserDTOBody params?: LoginUserDTOParams } @@ -25,16 +25,9 @@ export const loginUserDTOBodySchema = Joi.object({ password: Joi.string().required(), }).options({ abortEarly: false }) -export const loginUserDTOParamsSchema = Joi.object({ - client_id: Joi.string().required(), - scope: Joi.string().required(), - response_type: Joi.string().required(), - redirect_uri: Joi.string().required() -}).options({ abortEarly: false }) - export const loginUserDTOSchema = Joi.object({ req: Joi.object().required(), res: Joi.object().required(), body: loginUserDTOBodySchema.required(), - params: loginUserDTOParamsSchema.optional() + params: Joi.object().optional(), }).options({ abortEarly: false }) diff --git a/server/src/modules/users/application/use-cases/login-user/login-user-errors.ts b/server/src/modules/users/application/use-cases/login-user/login-user-errors.ts index e43f495..a3569c4 100644 --- a/server/src/modules/users/application/use-cases/login-user/login-user-errors.ts +++ b/server/src/modules/users/application/use-cases/login-user/login-user-errors.ts @@ -1,14 +1,8 @@ export namespace LoginUserErrors { - export class IncorrectPasswordError { - public message: string + export class IncorrectPasswordError extends Error { public constructor() { + super() this.message = `Incorrect email/password combination provided.` } } - - export class InvalidOpenIDParamsError extends Error { - public constructor() { - super(`Invalid OpenID parameters were provided.`) - } - } } diff --git a/server/src/modules/users/application/use-cases/login-user/login-user-use-case.ts b/server/src/modules/users/application/use-cases/login-user/login-user-use-case.ts index f9d85e1..5b95969 100644 --- a/server/src/modules/users/application/use-cases/login-user/login-user-use-case.ts +++ b/server/src/modules/users/application/use-cases/login-user/login-user-use-case.ts @@ -1,37 +1,32 @@ import { UseCaseWithDTO } from '../../../../../shared/app/use-case-with-dto' -import { UserAuthHandler, UserAuthHandlerLoginError, UserAuthHandlerLoginResponse } from '../../../../../shared/auth/user-auth-handler' -import '../../../../../shared/auth/user-auth-handler' +import { + UserAuthHandler, + UserAuthHandlerLoginError, + UserAuthHandlerLoginResponse, +} from '../../../../../shared/auth/user-auth-handler' +import '../../../../../shared/auth/user-auth-handler' import { LoginUserDTO } from './login-user-dto' import { ParamList, ParamPair } from '../../../../../shared/app/param-list' import { AppError } from '../../../../../shared/core/app-error' -import { LoginUserErrors } from './login-user-errors' import { Result } from '../../../../../shared/core/result' import { UserDTO } from '../../../mappers/user-dto' -import { CreateUserErrors } from '../create-user/create-user-errors' -export type LoginUserUseCaseError = - | LoginUserErrors.InvalidOpenIDParamsError - | UserAuthHandlerLoginError - | AppError.UnexpectedError +export type LoginUserUseCaseError = UserAuthHandlerLoginError | AppError.UnexpectedError -export interface LoginUserSuccessRedirect { +export interface LoginUserSuccessRedirect { redirectParams: ParamList redirectUrl: string } -export interface LoginUserSuccessUser { +export interface LoginUserSuccessUser { user: UserDTO } -export type LoginUserSuccess = LoginUserSuccessRedirect | LoginUserSuccessUser +export type LoginUserSuccess = LoginUserSuccessRedirect | LoginUserSuccessUser export type LoginUserUseCaseResponse = Result -export const OPEN_ID_SCOPE = 'open_id' -export const OPEN_ID_RESPONSE_TYPE = 'code' - -export class LoginUserUseCase - implements UseCaseWithDTO { +export class LoginUserUseCase implements UseCaseWithDTO { private userAuthHandler: UserAuthHandler constructor(userAuthHandler: UserAuthHandler) { @@ -42,30 +37,25 @@ export class LoginUserUseCase const userAuthHandlerLoginOptions = { req: dto.req, res: dto.res, - params: dto.params } - const userAuthHandlerLoginResponse: UserAuthHandlerLoginResponse = await this.userAuthHandler.login(userAuthHandlerLoginOptions) - if(userAuthHandlerLoginResponse.isErr()){ + const userAuthHandlerLoginResponse: UserAuthHandlerLoginResponse = + await this.userAuthHandler.login(userAuthHandlerLoginOptions) + if (userAuthHandlerLoginResponse.isErr()) { return Result.err(userAuthHandlerLoginResponse.error) } else { - const params = dto.params; - if(params && params.scope){ - if(params.scope !== OPEN_ID_SCOPE || params.response_type !== OPEN_ID_RESPONSE_TYPE){ - return Result.err(new CreateUserErrors.InvalidOpenIDParamsError()) - } - } - if(params && params.scope && userAuthHandlerLoginResponse.value.cert){ - const redirectParams = new ParamList([ - new ParamPair('code', userAuthHandlerLoginResponse.value.cert.getValue()) - ]) + const params = dto.params + if (params) { + const redirectParams = new ParamList( + Object.entries(params).map((paramPair) => new ParamPair(paramPair[0], paramPair[1])) + ) const loginUserSuccessResponse: LoginUserSuccess = { redirectParams: redirectParams, - redirectUrl: params.redirect_uri + redirectUrl: `${process.env.PUBLIC_HOST}/authorize`, } return Result.ok(loginUserSuccessResponse) } else { const loginUserSuccessResponse: LoginUserSuccess = { - user: userAuthHandlerLoginResponse.value.user + user: userAuthHandlerLoginResponse.value.user, } return Result.ok(loginUserSuccessResponse) } diff --git a/server/src/modules/users/application/use-cases/protected-user/__tests__/protected-user-controller.test.unit.ts b/server/src/modules/users/application/use-cases/protected-user/__tests__/protected-user-controller.test.unit.ts deleted file mode 100644 index 799e6cd..0000000 --- a/server/src/modules/users/application/use-cases/protected-user/__tests__/protected-user-controller.test.unit.ts +++ /dev/null @@ -1,56 +0,0 @@ -import httpMocks from 'node-mocks-http' -import { Result } from '../../../../../../shared/core/result' -import { ProtectedUserDTO } from '../protected-user-dto' -import { ProtectedUserSuccess, ProtectedUserUseCase } from '../protected-user-use-case' -import { AppError } from '../../../../../../shared/core/app-error' -import { ProtectedUserController } from '../protected-user-controller' -import { mocks } from '../../../../../../test-utils' -import { CreateUserDTOBody } from '../../create-user/create-user-dto' - -// TODO: how to show developer these mocks are necessary when building a controller? aka must be synced with buildController() -jest.mock('../../../../infra/repos/user-repo/implementations/mikro-user-repo') -jest.mock('../protected-user-use-case') - -describe('ProtectedUserController', () => { - - let protectedUserDTO: ProtectedUserDTO - let userDTO: CreateUserDTOBody - let protectedUserController: ProtectedUserController - beforeAll(async () => { - const protectedUser = await mocks.mockProtectedUser() - protectedUserController = protectedUser.protectedUserController - }) - - beforeEach(() => { - userDTO = { - email: 'loolabs@uwaterloo.ca', - password: 'password', - }, - protectedUserDTO = { - user: mocks.mockUser(userDTO) - } - }) - - test('When the ProtectedUserUserCase returns Ok, the ProtectedUserController returns 200 OK', async () => { - const mockResponse = httpMocks.createResponse() - const useCaseResolvedValue: ProtectedUserSuccess = { - email: userDTO.email - } - jest.spyOn(ProtectedUserUseCase.prototype, 'execute').mockResolvedValue(Result.ok(useCaseResolvedValue)) - - const result = await protectedUserController.executeImpl(protectedUserDTO, mockResponse) - - expect(result.statusCode).toBe(200) - }), - - test('When the ProtectedUserUseCase returns AppError.UnexpectedError, ProtectedUserController returns 500 Internal Server Error', async () => { - const mockResponse = httpMocks.createResponse() - jest - .spyOn(ProtectedUserUseCase.prototype, 'execute') - .mockResolvedValue(Result.err(new AppError.UnexpectedError('Unexpected error'))) - - const result = await protectedUserController.executeImpl(protectedUserDTO, mockResponse) - - expect(result.statusCode).toBe(500) - }) -}) diff --git a/server/src/modules/users/application/use-cases/protected-user/protected-user-dto.ts b/server/src/modules/users/application/use-cases/protected-user/protected-user-dto.ts deleted file mode 100644 index d443cd5..0000000 --- a/server/src/modules/users/application/use-cases/protected-user/protected-user-dto.ts +++ /dev/null @@ -1,10 +0,0 @@ -import Joi from 'joi' -import { User } from '../../../domain/entities/user' - -export interface ProtectedUserDTO { - user: User -} - -export const protectedUserDTOSchema = Joi.object({ - user: Joi.object().required() -}).options({ abortEarly: false }) diff --git a/server/src/modules/users/application/use-cases/protected-user/protected-user-use-case.ts b/server/src/modules/users/application/use-cases/protected-user/protected-user-use-case.ts deleted file mode 100644 index cafdffa..0000000 --- a/server/src/modules/users/application/use-cases/protected-user/protected-user-use-case.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { UseCaseWithDTO } from '../../../../../shared/app/use-case-with-dto' -import { AppError } from '../../../../../shared/core/app-error' -import { Result } from '../../../../../shared/core/result' -import { ProtectedUserDTO } from './protected-user-dto' - -export type ProtectedUserUseCaseError = - | AppError.UnexpectedError - -export type ProtectedUserSuccess = { - email: string -} - -export type ProtectedUserUseCaseResponse = Result - -export class ProtectedUserUseCase - implements UseCaseWithDTO { - - async execute(dto: ProtectedUserDTO): Promise { - const res: ProtectedUserSuccess = { - email: dto.user.email.value - } - try { - return Result.ok(res) - } catch (err) { - return Result.err(new AppError.UnexpectedError(err)) - } - } -} diff --git a/server/src/modules/users/domain/entities/auth-code/__tests__/auth-code.test.unit.ts b/server/src/modules/users/domain/entities/auth-code/__tests__/auth-code.test.unit.ts index d6c2ac0..f21c6f5 100644 --- a/server/src/modules/users/domain/entities/auth-code/__tests__/auth-code.test.unit.ts +++ b/server/src/modules/users/domain/entities/auth-code/__tests__/auth-code.test.unit.ts @@ -1,6 +1,6 @@ import { DomainEvents } from '../../../../../../shared/domain/events/domain-events' import { AuthCodeCreated } from '../../../events/auth-code-created' -import { AuthCodeString } from '../../../value-objects/auth-code' +import { AuthCodeString } from '../../../value-objects/auth-code-string' import { AuthCode } from '../auth-code' jest.mock('../../../events/auth-code-created') @@ -8,11 +8,12 @@ jest.mock('../../../../../../shared/domain/events/domain-events') describe('Authcode AggregateRoot', () => { test('it adds a AuthCodeCreated domain event on new AuthCode creation', () => { - AuthCode.create({ clientId: 'test_client_id', userId: 'test_user_id', - authCodeString: new AuthCodeString('test_auth_code') + userEmail: 'testemail@uwaterloo.ca', + userEmailVerified: false, + authCodeString: new AuthCodeString('test_auth_code'), }) expect(AuthCodeCreated).toBeCalled() diff --git a/server/src/modules/users/domain/entities/auth-code/auth-code.ts b/server/src/modules/users/domain/entities/auth-code/auth-code.ts index d2726a2..df48d1a 100644 --- a/server/src/modules/users/domain/entities/auth-code/auth-code.ts +++ b/server/src/modules/users/domain/entities/auth-code/auth-code.ts @@ -3,11 +3,13 @@ import { Result } from '../../../../../shared/core/result' import { AggregateRoot } from '../../../../../shared/domain/aggregate-root' import { UniqueEntityID } from '../../../../../shared/domain/unique-entity-id' import { AuthCodeCreated } from '../../events/auth-code-created' -import { AuthCodeString } from '../../value-objects/auth-code' +import { AuthCodeString } from '../../value-objects/auth-code-string' interface AuthCodeProps { clientId: string userId: string + userEmail: string + userEmailVerified: boolean authCodeString: AuthCodeString } @@ -36,6 +38,14 @@ export class AuthCode extends AggregateRoot { return this.props.clientId } + get userEmail(): string { + return this.props.userEmail + } + + get userEmailVerified(): boolean { + return this.props.userEmailVerified + } + get userId(): string { return this.props.userId } diff --git a/server/src/modules/users/domain/entities/auth-secret/auth-secret.ts b/server/src/modules/users/domain/entities/auth-secret/auth-secret.ts index 5fe5111..78836be 100644 --- a/server/src/modules/users/domain/entities/auth-secret/auth-secret.ts +++ b/server/src/modules/users/domain/entities/auth-secret/auth-secret.ts @@ -4,8 +4,10 @@ import { UniqueEntityID } from '../../../../../shared/domain/unique-entity-id' import { EncryptedClientSecret } from '../../value-objects/encrypted-client-secret' interface AuthSecretProps { - clientId: string, - encryptedClientSecret: EncryptedClientSecret, + clientId: string + decodedRedirectUri: string + clientName: string + encryptedClientSecret: EncryptedClientSecret isVerified: boolean } @@ -25,15 +27,23 @@ export class AuthSecret extends AggregateRoot { super(props, id) } + get clientName(): string { + return this.props.clientName + } + get clientId(): string { return this.props.clientId } + get decodedRedirectUri(): string { + return this.props.decodedRedirectUri + } + get encryptedClientSecret(): EncryptedClientSecret { return this.props.encryptedClientSecret } - get isVerified() :boolean { + get isVerified(): boolean { return this.props.isVerified } } diff --git a/server/src/modules/users/domain/entities/user/__tests__/user.test.unit.ts b/server/src/modules/users/domain/entities/user/__tests__/user.test.unit.ts index f45e8c8..a161d4c 100644 --- a/server/src/modules/users/domain/entities/user/__tests__/user.test.unit.ts +++ b/server/src/modules/users/domain/entities/user/__tests__/user.test.unit.ts @@ -1,7 +1,6 @@ import { DomainEvents } from '../../../../../../shared/domain/events/domain-events' import { UserCreated } from '../../../events/user-created' import { UserDeleted } from '../../../events/user-deleted' -import { UserLoggedIn } from '../../../events/user-logged-in' import { UserEmail } from '../../../value-objects/user-email' import { UserPassword } from '../../../value-objects/user-password' import { User } from '../user' @@ -30,29 +29,6 @@ describe('User AggregateRoot', () => { expect(DomainEvents.markAggregateForDispatch).toBeCalled() }) - test('it adds a UserLoggedIn domain event on user login', () => { - const emailResult = UserEmail.create('john.doe@uwaterloo.ca') - const passwordResult = UserPassword.create({ value: 'secretpassword', hashed: false }) - - if (emailResult.isErr() || passwordResult.isErr()) - throw new Error('Result should be isOk, not isErr') - - const userResult = User.create({ - email: emailResult.value, - password: passwordResult.value, - }) - - if (userResult.isErr()) throw new Error('User result should be isOk, not isErr') - - const user = userResult.value - user.setAccessToken('token', 'refresh') - - // note: not sure if this is the best way to test AggregateRoot.addDomainEvent() was called with UserLoggedIn event - // TODO: if changes made here, see clubs/domain/entities/__tests__/club.test.unit.ts as well. - expect(UserLoggedIn).toBeCalled() - expect(DomainEvents.markAggregateForDispatch).toBeCalled() - }) - test('it adds a UserDeleted domain event on user deletion', () => { const emailResult = UserEmail.create('john.doe@uwaterloo.ca') const passwordResult = UserPassword.create({ value: 'secretpassword', hashed: false }) diff --git a/server/src/modules/users/domain/entities/user/user.ts b/server/src/modules/users/domain/entities/user/user.ts index ea53f1e..8f6b81d 100644 --- a/server/src/modules/users/domain/entities/user/user.ts +++ b/server/src/modules/users/domain/entities/user/user.ts @@ -3,8 +3,6 @@ import { AggregateRoot } from '../../../../../shared/domain/aggregate-root' import { UniqueEntityID } from '../../../../../shared/domain/unique-entity-id' import { UserCreated } from '../../events/user-created' import { UserDeleted } from '../../events/user-deleted' -import { UserLoggedIn } from '../../events/user-logged-in' -import { JWTToken, RefreshToken } from '../../value-objects/jwt' import { UserEmail } from '../../value-objects/user-email' import { UserPassword } from '../../value-objects/user-password' import { UserId } from '../../value-objects/userId' @@ -13,8 +11,6 @@ interface UserProps { email: UserEmail password: UserPassword emailVerified?: boolean - accessToken?: JWTToken - refreshToken?: RefreshToken isDeleted?: boolean lastLogin?: Date } @@ -22,6 +18,8 @@ interface UserProps { export class User extends AggregateRoot { public static create(props: UserProps, id?: UniqueEntityID): Result { const isNewUser = !!id === false + props.emailVerified = false + props.isDeleted = false const user = new User( { ...props, @@ -38,17 +36,6 @@ export class User extends AggregateRoot { super(props, id) } - public setAccessToken(token: JWTToken, refreshToken: RefreshToken): void { - this.addDomainEvent(new UserLoggedIn(this)) - this.props.accessToken = token - this.props.refreshToken = refreshToken - this.props.lastLogin = new Date() - } - - public isLoggedIn(): boolean { - return !!this.props.accessToken && !!this.props.refreshToken - } - public delete(): void { if (!this.props.isDeleted) { this.addDomainEvent(new UserDeleted(this)) @@ -70,10 +57,6 @@ export class User extends AggregateRoot { return this.props.password } - get accessToken(): string | undefined { - return this.props.accessToken - } - get isDeleted(): boolean | undefined { return this.props.isDeleted } @@ -85,8 +68,4 @@ export class User extends AggregateRoot { get lastLogin(): Date | undefined { return this.props.lastLogin } - - get refreshToken(): RefreshToken | undefined { - return this.props.refreshToken - } } diff --git a/server/src/modules/users/domain/value-objects/__tests__/authCode.test.unit.ts b/server/src/modules/users/domain/value-objects/__tests__/authCode.test.unit.ts index 7c80259..529e2be 100644 --- a/server/src/modules/users/domain/value-objects/__tests__/authCode.test.unit.ts +++ b/server/src/modules/users/domain/value-objects/__tests__/authCode.test.unit.ts @@ -1,12 +1,11 @@ -import { AuthCodeString } from "../auth-code" +import { AuthCodeString } from '../auth-code-string' describe('AuthCodeString ValueObject', () => { test("When an AuthCodeString is created, it's value is hex", async () => { + const authCodeResult = new AuthCodeString() - const authCodeResult = new AuthCodeString(); + const hexRegex = /[0-9A-Fa-f]{6}/g - const hexRegex = /[0-9A-Fa-f]{6}/g; - expect(hexRegex.test(authCodeResult.getValue())).toBe(true) }) }) diff --git a/server/src/modules/users/domain/value-objects/auth-code-string.ts b/server/src/modules/users/domain/value-objects/auth-code-string.ts new file mode 100644 index 0000000..56d79e0 --- /dev/null +++ b/server/src/modules/users/domain/value-objects/auth-code-string.ts @@ -0,0 +1,25 @@ +import crypto from 'crypto' + +export const getRandom256BitHexCode = () => { + /*motivation for a 256 bit (= 32 byte) cryptographic key can be found here + https://www.geeksforgeeks.org/node-js-crypto-randombytes-method/ + */ + const authCodeBuffer = crypto.randomBytes(32) + return authCodeBuffer.toString('hex') +} + +export class AuthCodeString { + private value: string + + public constructor(hashedValue?: string) { + if (hashedValue !== undefined) { + this.value = hashedValue + } else { + this.value = getRandom256BitHexCode() + } + } + + public getValue() { + return this.value + } +} diff --git a/server/src/modules/users/domain/value-objects/auth-code.ts b/server/src/modules/users/domain/value-objects/auth-code.ts deleted file mode 100644 index ff05f11..0000000 --- a/server/src/modules/users/domain/value-objects/auth-code.ts +++ /dev/null @@ -1,25 +0,0 @@ -import crypto from 'crypto' - -export class AuthCodeString { - private value: string; - - public constructor(hashedValue?: string) { - if(hashedValue){ - this.value = hashedValue - } else { - this.value = this.getRandomCode() - } - } - - private getRandomCode() { - /*motivation for a 256 bit (= 32 byte) crypographic key can be found here - https://www.geeksforgeeks.org/node-js-crypto-randombytes-method/ - */ - const authCodeBuffer = crypto.randomBytes(32) - return authCodeBuffer.toString('hex') - } - - public getValue(){ - return this.value - } -} diff --git a/server/src/modules/users/infra/http/routes/user-router.ts b/server/src/modules/users/infra/http/routes/user-router.ts index f862b88..98b6439 100644 --- a/server/src/modules/users/infra/http/routes/user-router.ts +++ b/server/src/modules/users/infra/http/routes/user-router.ts @@ -11,17 +11,15 @@ class UserRouter { static using(controllers: Controllers): Router { const userRouter = Router() - userRouter.post('/', (req, res): void => { - controllers.createUser.execute(req, res) - }) - userRouter.post('/login', (req, res): void => { controllers.loginUser.execute(req, res) }) + userRouter.post('/create', (req, res) => controllers.createUser.execute(req, res)) + userRouter.get('/authorize', (req, res) => controllers.authorizeUser.execute(req, res)) + userRouter.get('/token', (req, res) => controllers.getToken.execute(req, res)) + userRouter.post('/discovery', (req, res) => controllers.discoverSP.execute(req, res)) - userRouter.get('/protected', (req, res) => controllers.protectedUser.execute(req, res)) userRouter.use(limiter) - return userRouter } } diff --git a/server/src/modules/users/infra/repos/auth-code-repo/auth-code-repo.ts b/server/src/modules/users/infra/repos/auth-code-repo/auth-code-repo.ts index 6d08027..60a4bd2 100644 --- a/server/src/modules/users/infra/repos/auth-code-repo/auth-code-repo.ts +++ b/server/src/modules/users/infra/repos/auth-code-repo/auth-code-repo.ts @@ -1,7 +1,7 @@ import { Result } from '../../../../../shared/core/result' import { DBErrors } from '../../../../../shared/infra/db/errors/errors' import { AuthCode } from '../../../domain/entities/auth-code' -import { AuthCodeString } from '../../../domain/value-objects/auth-code' +import { AuthCodeString } from '../../../domain/value-objects/auth-code-string' export abstract class AuthCodeRepo { abstract getAuthCodeFromAuthCodeString( diff --git a/server/src/modules/users/infra/repos/auth-code-repo/implementations/mock-auth-code-repo.ts b/server/src/modules/users/infra/repos/auth-code-repo/implementations/mock-auth-code-repo.ts index 80e1812..33d3797 100644 --- a/server/src/modules/users/infra/repos/auth-code-repo/implementations/mock-auth-code-repo.ts +++ b/server/src/modules/users/infra/repos/auth-code-repo/implementations/mock-auth-code-repo.ts @@ -2,7 +2,7 @@ import { Result } from '../../../../../../shared/core/result' import { AuthCodeEntity } from '../../../../../../shared/infra/cache/entities/auth-code-entity' import { DBError, DBErrors } from '../../../../../../shared/infra/db/errors/errors' import { AuthCode } from '../../../../domain/entities/auth-code' -import { AuthCodeString } from '../../../../domain/value-objects/auth-code' +import { AuthCodeString } from '../../../../domain/value-objects/auth-code-string' import { AuthCodeMap } from '../../../../mappers/auth-code-map' import { AuthCodeRepo } from '../auth-code-repo' diff --git a/server/src/modules/users/infra/repos/auth-code-repo/implementations/redis-auth-code-repo.ts b/server/src/modules/users/infra/repos/auth-code-repo/implementations/redis-auth-code-repo.ts index 6b37102..378c3c5 100644 --- a/server/src/modules/users/infra/repos/auth-code-repo/implementations/redis-auth-code-repo.ts +++ b/server/src/modules/users/infra/repos/auth-code-repo/implementations/redis-auth-code-repo.ts @@ -3,7 +3,7 @@ import { AuthCodeEntity } from '../../../../../../shared/infra/cache/entities/au import { RedisRepository } from '../../../../../../shared/infra/cache/redis-repository' import { DBError, DBErrors } from '../../../../../../shared/infra/db/errors/errors' import { AuthCode } from '../../../../domain/entities/auth-code' -import { AuthCodeString } from '../../../../domain/value-objects/auth-code' +import { AuthCodeString } from '../../../../domain/value-objects/auth-code-string' import { AuthCodeMap } from '../../../../mappers/auth-code-map' import { AuthCodeRepo } from '../auth-code-repo' @@ -16,9 +16,13 @@ export class RedisAuthCodeRepo implements AuthCodeRepo { authCodeString: AuthCodeString ): Promise> { const authCode = await this.authCodeEntityRepo.getEntity(authCodeString.getValue()) - if (authCode.isErr()) - return Result.err(new DBError.AuthSecretNotFoundError(authCodeString.getValue())) - return Result.ok(AuthCodeMap.toDomain(authCode.value)) + if (authCode.isErr()) { + return Result.err(authCode.error) + } else if (authCode.value === null) { + return Result.err(new DBError.AuthCodeNotFoundError(authCodeString.getValue())) + } else { + return Result.ok(AuthCodeMap.toDomain(authCode.value)) + } } async save(authCode: AuthCode): Promise { diff --git a/server/src/modules/users/infra/repos/auth-secret-repo/auth-secret-repo.ts b/server/src/modules/users/infra/repos/auth-secret-repo/auth-secret-repo.ts index bc59720..406484e 100644 --- a/server/src/modules/users/infra/repos/auth-secret-repo/auth-secret-repo.ts +++ b/server/src/modules/users/infra/repos/auth-secret-repo/auth-secret-repo.ts @@ -1,9 +1,14 @@ import { Result } from '../../../../../shared/core/result' import { DBErrors } from '../../../../../shared/infra/db/errors/errors' import { AuthSecret } from '../../../domain/entities/auth-secret' +import { EncryptedClientSecret } from '../../../domain/value-objects/encrypted-client-secret' export abstract class AuthSecretRepo { - abstract exists(clientId: string): Promise> - abstract getAuthSecretByClientId(clientId: string): Promise> + abstract exists(clientId: string, decodedRedirectUri?: string): Promise> + abstract clientNameExists(clientName: string): Promise> + abstract getAuthSecretByClientIdandSecret( + clientId: string, + clientSecret: EncryptedClientSecret + ): Promise> abstract save(authSecret: AuthSecret): Promise } diff --git a/server/src/modules/users/infra/repos/auth-secret-repo/implementations/mikro-auth-secret-repo.ts b/server/src/modules/users/infra/repos/auth-secret-repo/implementations/mikro-auth-secret-repo.ts index 3c99a02..989fdd5 100644 --- a/server/src/modules/users/infra/repos/auth-secret-repo/implementations/mikro-auth-secret-repo.ts +++ b/server/src/modules/users/infra/repos/auth-secret-repo/implementations/mikro-auth-secret-repo.ts @@ -3,22 +3,39 @@ import { Result } from '../../../../../../shared/core/result' import { AuthSecretEntity } from '../../../../../../shared/infra/db/entities/auth-secret.entity' import { DBError, DBErrors } from '../../../../../../shared/infra/db/errors/errors' import { AuthSecret } from '../../../../domain/entities/auth-secret' +import { EncryptedClientSecret } from '../../../../domain/value-objects/encrypted-client-secret' import { AuthSecretMap } from '../../../../mappers/auth-secret-map' import { AuthSecretRepo } from '../../auth-secret-repo/auth-secret-repo' export class MikroAuthSecretRepo implements AuthSecretRepo { constructor(protected authSecretEntityRepo: EntityRepository) {} - async exists(clientId: string): Promise> { + async exists(clientId: string, decodedRedirectUri?: string): Promise> { const authSecretEntity = await this.authSecretEntityRepo.findOne({ clientId: clientId, + ...{ decodedRedirectUri }, }) return Result.ok(authSecretEntity !== null) } - async getAuthSecretByClientId(clientId: string): Promise> { + async clientNameExists(clientName: string): Promise> { + const authSecretEntity = await this.authSecretEntityRepo.findOne({ + clientName, + }) + return Result.ok(authSecretEntity !== null) + } + + async getAuthSecretByClientIdandSecret( + clientId: string, + clientSecret: EncryptedClientSecret + ): Promise> { const authSecret = await this.authSecretEntityRepo.findOne({ clientId: clientId }) if (authSecret === null) return Result.err(new DBError.AuthSecretNotFoundError(clientId)) + + const authSecretsEqual = await clientSecret.compareSecret(authSecret.encryptedClientSecret) + if (authSecretsEqual.isOk() && !authSecretsEqual.value) { + return Result.err(new DBError.AuthSecretsNotEqualError(clientSecret.value)) + } return Result.ok(AuthSecretMap.toDomain(authSecret)) } diff --git a/server/src/modules/users/infra/repos/auth-secret-repo/implementations/mock-auth-secret-repo.ts b/server/src/modules/users/infra/repos/auth-secret-repo/implementations/mock-auth-secret-repo.ts index edc83bc..f2b320f 100644 --- a/server/src/modules/users/infra/repos/auth-secret-repo/implementations/mock-auth-secret-repo.ts +++ b/server/src/modules/users/infra/repos/auth-secret-repo/implementations/mock-auth-secret-repo.ts @@ -2,6 +2,7 @@ import { Result } from '../../../../../../shared/core/result' import { AuthSecretEntity } from '../../../../../../shared/infra/db/entities/auth-secret.entity' import { DBError, DBErrors } from '../../../../../../shared/infra/db/errors/errors' import { AuthSecret } from '../../../../domain/entities/auth-secret' +import { EncryptedClientSecret } from '../../../../domain/value-objects/encrypted-client-secret' import { AuthSecretMap } from '../../../../mappers/auth-secret-map' import { AuthSecretRepo } from '../auth-secret-repo' @@ -18,10 +19,19 @@ export class MockAuthSecretRepo implements AuthSecretRepo { return Result.ok(this.authSecretEntities.has(clientId)) } - async getAuthSecretByClientId(clientId: string): Promise> { - const authSecretEntity = this.authSecretEntities.get(clientId) + async clientNameExists(clientName: string): Promise> { + return Result.ok(this.authSecretEntities.has(clientName)) + } - if (authSecretEntity === undefined) { + async getAuthSecretByClientIdandSecret( + clientId: string, + clientSecret: EncryptedClientSecret + ): Promise> { + const authSecretEntity = this.authSecretEntities.get(clientId) + if ( + authSecretEntity === undefined || + authSecretEntity.encryptedClientSecret != clientSecret.value + ) { return Result.err(new DBError.AuthSecretNotFoundError(clientId)) } return Result.ok(AuthSecretMap.toDomain(authSecretEntity)) @@ -33,5 +43,6 @@ export class MockAuthSecretRepo implements AuthSecretRepo { const authSecretEntity = await AuthSecretMap.toPersistence(authSecret) this.authSecretEntities.set(authSecretEntity.clientId, authSecretEntity) + this.authSecretEntities.set(authSecretEntity.clientName, authSecretEntity) } } diff --git a/server/src/modules/users/mappers/auth-code-map.ts b/server/src/modules/users/mappers/auth-code-map.ts index c005203..4a30e2f 100644 --- a/server/src/modules/users/mappers/auth-code-map.ts +++ b/server/src/modules/users/mappers/auth-code-map.ts @@ -1,12 +1,14 @@ import { AuthCodeEntity } from '../../../shared/infra/cache/entities/auth-code-entity' import { AuthCode } from '../domain/entities/auth-code' -import { AuthCodeString } from '../domain/value-objects/auth-code' +import { AuthCodeString } from '../domain/value-objects/auth-code-string' export class AuthCodeMap { public static toDomain(authCodeEntity: AuthCodeEntity): AuthCode { const authCode = AuthCode.create({ clientId: authCodeEntity.clientId, userId: authCodeEntity.clientId, + userEmail: authCodeEntity.userEmail, + userEmailVerified: authCodeEntity.userEmailVerified, authCodeString: new AuthCodeString(authCodeEntity.authCodeString), }) if (authCode.isErr()) throw new Error() // TODO: check if we should handle error differently @@ -19,7 +21,8 @@ export class AuthCodeMap { authCodeEntity.clientId = authCode.clientId authCodeEntity.userId = authCode.userId authCodeEntity.authCodeString = authCode.authCodeString.getValue() - + authCodeEntity.userEmail = authCode.userEmail + authCodeEntity.userEmailVerified = authCode.userEmailVerified return authCodeEntity } } diff --git a/server/src/modules/users/mappers/auth-secret-map.ts b/server/src/modules/users/mappers/auth-secret-map.ts index 7fd9484..2d6f9b2 100644 --- a/server/src/modules/users/mappers/auth-secret-map.ts +++ b/server/src/modules/users/mappers/auth-secret-map.ts @@ -15,6 +15,8 @@ export class AuthSecretMap { const authCodeResult = AuthSecret.create( { clientId: authSecretEntity.clientId, + decodedRedirectUri: authSecretEntity.decodedRedirectUri, + clientName: authSecretEntity.clientName, encryptedClientSecret: encryptedClientSecret.value, isVerified: authSecretEntity.isVerified, }, @@ -28,8 +30,11 @@ export class AuthSecretMap { public static async toPersistence(authSecret: AuthSecret): Promise { const authSecretEntity = new AuthSecretEntity() + authSecretEntity.decodedRedirectUri = authSecret.decodedRedirectUri + authSecretEntity.clientName = authSecret.clientName authSecretEntity.clientId = authSecret.clientId - authSecretEntity.encryptedClientSecret = authSecret.encryptedClientSecret.value + const hashedEncryptedClientSecret = await authSecret.encryptedClientSecret.getHashedValue() + authSecretEntity.encryptedClientSecret = hashedEncryptedClientSecret authSecretEntity.isVerified = authSecret.isVerified return authSecretEntity diff --git a/server/src/modules/users/mappers/user-map.ts b/server/src/modules/users/mappers/user-map.ts index 4ae1228..2940739 100644 --- a/server/src/modules/users/mappers/user-map.ts +++ b/server/src/modules/users/mappers/user-map.ts @@ -31,8 +31,6 @@ export class UserMap { email, password, emailVerified: userEntity.emailVerified, - accessToken: userEntity.accessToken, - refreshToken: userEntity.refreshToken, isDeleted: userEntity.isDeleted, lastLogin: userEntity.lastLogin, }, @@ -55,8 +53,6 @@ export class UserMap { if (user.isEmailVerified !== undefined) userEntity.emailVerified = user.isEmailVerified if (user.isDeleted !== undefined) userEntity.isDeleted = user.isDeleted - userEntity.accessToken = user.accessToken - userEntity.refreshToken = user.refreshToken userEntity.lastLogin = user.lastLogin return userEntity diff --git a/server/src/setup/application/controllers.ts b/server/src/setup/application/controllers.ts index 61b7410..c10f95a 100644 --- a/server/src/setup/application/controllers.ts +++ b/server/src/setup/application/controllers.ts @@ -1,12 +1,16 @@ import { UseCases, Controllers } from './types' import { CreateUserController } from '../../modules/users/application/use-cases/create-user/create-user-controller' import { LoginUserController } from '../../modules/users/application/use-cases/login-user/login-user-controller' -import { ProtectedUserController } from '../../modules/users/application/use-cases/protected-user/protected-user-controller' +import { AuthorizeUserController } from '../../modules/users/application/use-cases/authorize-user/authorize-user-controller' +import { DiscoverSPController } from '../../modules/users/application/use-cases/discover-sp/discover-sp-controller' +import { GetTokenController } from '../../modules/users/application/use-cases/get-token/get-token-controller' export const setupControllers = (useCases: UseCases): Controllers => { return { createUser: new CreateUserController(useCases.createUser), loginUser: new LoginUserController(useCases.loginUser), - protectedUser: new ProtectedUserController(useCases.protectedUser) + authorizeUser: new AuthorizeUserController(useCases.authorizeUser), + discoverSP: new DiscoverSPController(useCases.discoverSP), + getToken: new GetTokenController(useCases.getToken), } } diff --git a/server/src/setup/application/types.ts b/server/src/setup/application/types.ts index d87df88..41dc0c7 100644 --- a/server/src/setup/application/types.ts +++ b/server/src/setup/application/types.ts @@ -4,21 +4,29 @@ import { LoginUserUseCase } from '../../modules/users/application/use-cases/logi import { GetUserUseCase } from '../../modules/users/application/use-cases/get-user/get-user-use-case' import { AuthenticateUserUseCase } from '../../modules/users/application/use-cases/authenticate-user/authenticate-user-use-case' import { LoginUserController } from '../../modules/users/application/use-cases/login-user/login-user-controller' -import { ProtectedUserUseCase } from '../../modules/users/application/use-cases/protected-user/protected-user-use-case' -import { ProtectedUserController } from '../../modules/users/application/use-cases/protected-user/protected-user-controller' +import { AuthorizeUserUseCase } from '../../modules/users/application/use-cases/authorize-user/authorize-user-use-case' +import { DiscoverSPUseCase } from '../../modules/users/application/use-cases/discover-sp/discover-sp-use-case' +import { GetTokenUseCase } from '../../modules/users/application/use-cases/get-token/get-token-use-case' +import { AuthorizeUserController } from '../../modules/users/application/use-cases/authorize-user/authorize-user-controller' +import { DiscoverSPController } from '../../modules/users/application/use-cases/discover-sp/discover-sp-controller' +import { GetTokenController } from '../../modules/users/application/use-cases/get-token/get-token-controller' export interface UseCases { createUser: CreateUserUseCase loginUser: LoginUserUseCase getUser: GetUserUseCase - authUser: AuthenticateUserUseCase - protectedUser: ProtectedUserUseCase + authenticateUser: AuthenticateUserUseCase + authorizeUser: AuthorizeUserUseCase + discoverSP: DiscoverSPUseCase + getToken: GetTokenUseCase } export interface Controllers { createUser: CreateUserController loginUser: LoginUserController - protectedUser: ProtectedUserController + authorizeUser: AuthorizeUserController + discoverSP: DiscoverSPController + getToken: GetTokenController } export interface Application { diff --git a/server/src/setup/application/use-cases.ts b/server/src/setup/application/use-cases.ts index 9235025..e24b0c7 100644 --- a/server/src/setup/application/use-cases.ts +++ b/server/src/setup/application/use-cases.ts @@ -4,15 +4,19 @@ import { LoginUserUseCase } from '../../modules/users/application/use-cases/logi import { PassportUserAuthHandler } from '../../shared/auth/implementations/passport-user-auth-handler' import { GetUserUseCase } from '../../modules/users/application/use-cases/get-user/get-user-use-case' import { AuthenticateUserUseCase } from '../../modules/users/application/use-cases/authenticate-user/authenticate-user-use-case' -import { ProtectedUserUseCase } from '../../modules/users/application/use-cases/protected-user/protected-user-use-case' import { Persistence } from '../persistence/persistence' +import { AuthorizeUserUseCase } from '../../modules/users/application/use-cases/authorize-user/authorize-user-use-case' +import { GetTokenUseCase } from '../../modules/users/application/use-cases/get-token/get-token-use-case' +import { DiscoverSPUseCase } from '../../modules/users/application/use-cases/discover-sp/discover-sp-use-case' -export const setupUseCases = ({ db }: Persistence): UseCases => { +export const setupUseCases = ({ db, cache }: Persistence): UseCases => { return { createUser: new CreateUserUseCase(new PassportUserAuthHandler(), db.repos.user), loginUser: new LoginUserUseCase(new PassportUserAuthHandler()), getUser: new GetUserUseCase(db.repos.user), - authUser: new AuthenticateUserUseCase(db.repos.user), - protectedUser: new ProtectedUserUseCase(), + authenticateUser: new AuthenticateUserUseCase(db.repos.user), + authorizeUser: new AuthorizeUserUseCase(cache.repos.authCode, db.repos.authSecret), + discoverSP: new DiscoverSPUseCase(db.repos.authSecret), + getToken: new GetTokenUseCase(cache.repos.authCode, db.repos.authSecret), } } diff --git a/server/src/setup/http/express/basic-web-server.ts b/server/src/setup/http/express/basic-web-server.ts index 935b19f..df7ea92 100644 --- a/server/src/setup/http/express/basic-web-server.ts +++ b/server/src/setup/http/express/basic-web-server.ts @@ -9,50 +9,56 @@ import { MikroORM } from '../../database' import { APIRouter, WebServer } from './types' import { Controllers, UseCases } from '../../application/types' - interface BasicWebServerOptions { mikroORM?: MikroORM } -const setupBasicWebServer = (apiRouter: APIRouter, _controllers: Controllers, useCases: UseCases, options: BasicWebServerOptions): WebServer => { +const setupBasicWebServer = ( + apiRouter: APIRouter, + _controllers: Controllers, + useCases: UseCases, + options: BasicWebServerOptions +): WebServer => { const server = express() server.use(cors()) server.use(CookieParser()) server.use(express.json()) - server.use(session({ secret: `${process.env.EXPRESS_SESSION_SECRET}` })); + server.use(session({ secret: `${process.env.EXPRESS_SESSION_SECRET}` })) const entityManager = options?.mikroORM?.em if (entityManager !== undefined) { server.use((_req, _res, next) => RequestContext.create(entityManager, next)) } - server.use(passport.initialize()); - server.use(passport.session()); - passport.use(new LocalStrategy.Strategy({ - usernameField: 'email', - passwordField: 'password', - }, function (email, password, cb) { - useCases.authUser.execute({email, password}) - .then(result => { - return cb(null, result); - }) + server.use(passport.initialize()) + server.use(passport.session()) + passport.use( + new LocalStrategy.Strategy( + { + usernameField: 'email', + passwordField: 'password', + }, + function (email, password, cb) { + useCases.authenticateUser.execute({ email, password }).then((result) => { + return cb(null, result) + }) } - )); + ) + ) + + passport.serializeUser(function (user: any, done) { + done(null, user._id.value) + }) - passport.serializeUser(function(user: any, done) { - done(null, user._id.value); - }); - - passport.deserializeUser(function(userId: string, cb) { - useCases.getUser.execute({userId}) - .then(result => { - if(result.isOk()){ - cb(null, result.value); + passport.deserializeUser(function (userId: string, cb) { + useCases.getUser.execute({ userId }).then((result) => { + if (result.isOk()) { + cb(null, result.value) } else { cb(result.error, null) } }) - }); + }) server.use('/api', apiRouter) diff --git a/server/src/shared/app/base-controller.ts b/server/src/shared/app/base-controller.ts index 738000a..157fc20 100644 --- a/server/src/shared/app/base-controller.ts +++ b/server/src/shared/app/base-controller.ts @@ -16,11 +16,11 @@ export abstract class BaseController { } public redirect(res: Res, url: string, params: ParamList) { - res.status(200).redirect(params.getFormattedUrlWithParams(url)) - return this.ok(res) + res.redirect(params.getFormattedUrlWithParams(url)) + return res } - public fail(res: Res, error: Error | string): Res { + public fail(res: Res, error: any): Res { console.log(error) return res.status(500).json({ message: error.toString(), diff --git a/server/src/shared/auth/implementations/mock-user-auth-handler.ts b/server/src/shared/auth/implementations/mock-user-auth-handler.ts index 4e80d89..de56ed6 100644 --- a/server/src/shared/auth/implementations/mock-user-auth-handler.ts +++ b/server/src/shared/auth/implementations/mock-user-auth-handler.ts @@ -1,20 +1,17 @@ import { Result } from '../../core/result' import { UserAuthHandler, - UserAuthHandlerCreateOptions, - UserAuthHandlerCreateResponse, UserAuthHandlerLoginOptions, UserAuthHandlerLoginResponse, UserAuthHandlerLoginSuccess, } from '../user-auth-handler' import { AppError } from '../../core/app-error' -import { AuthCodeString } from '../../../modules/users/domain/value-objects/auth-code' +import { AuthCodeString } from '../../../modules/users/domain/value-objects/auth-code-string' import { mocks } from '../../../test-utils' import { CreateUserDTOBody } from '../../../modules/users/application/use-cases/create-user/create-user-dto' import { UserMap } from '../../../modules/users/mappers/user-map' export const LOGIN_ERROR = 'login_error' -export const CREATE_ERROR = 'create_error' //add implementation-specific auth functions here export class MockUserAuthHandler implements UserAuthHandler { @@ -37,11 +34,4 @@ export class MockUserAuthHandler implements UserAuthHandler { } }) } - - async create(options: UserAuthHandlerCreateOptions): Promise { - if (options.userId == CREATE_ERROR) - return Result.err(new AppError.UnexpectedError('Account creation failed')) - const authCodeString = new AuthCodeString('test_authcode_string') - return Result.ok(authCodeString) - } } diff --git a/server/src/shared/auth/implementations/passport-user-auth-handler.ts b/server/src/shared/auth/implementations/passport-user-auth-handler.ts index 54402e8..0d2c76c 100644 --- a/server/src/shared/auth/implementations/passport-user-auth-handler.ts +++ b/server/src/shared/auth/implementations/passport-user-auth-handler.ts @@ -3,69 +3,32 @@ import { Result } from '../../core/result' import { AuthenticateUserUseCaseResponse } from '../../../modules/users/application/use-cases/authenticate-user/authenticate-user-use-case' import { UserAuthHandler, - UserAuthHandlerCreateOptions, - UserAuthHandlerCreateResponse, UserAuthHandlerLoginOptions, UserAuthHandlerLoginResponse, UserAuthHandlerLoginSuccess, } from '../user-auth-handler' -import { AuthCode } from '../../../modules/users/domain/entities/auth-code' -import { AuthCodeString } from '../../../modules/users/domain/value-objects/auth-code' import { UserMap } from '../../../modules/users/mappers/user-map' //add implementation-specific auth functions here export class PassportUserAuthHandler implements UserAuthHandler { async login(options: UserAuthHandlerLoginOptions): Promise { return new Promise((resolve) => { - passport.authenticate('local', function (err, user: AuthenticateUserUseCaseResponse) { - if (err) resolve(Result.err(err)) - if (user.isErr()) { + passport.authenticate('local', async function (err, user: AuthenticateUserUseCaseResponse) { + if (err) { + resolve(Result.err(err)) + } else if (user.isErr()) { resolve(Result.err(user.error)) } else { - if (!options.params) { - const successResponse: UserAuthHandlerLoginSuccess = { user: UserMap.toDTO(user.value) } - options.req.login(user.value, function (err) { - if (err) { - resolve(Result.err(err)) - } else { - resolve(Result.ok(successResponse)) - } - }) - } - const authCode = AuthCode.create({ - clientId: options.clientId, - userId: user.value.userId.id.toString(), - authCodeString: new AuthCodeString(), - }) - if (authCode.isErr()) { - resolve(Result.err(authCode.error)) - } else { - const successResponse: UserAuthHandlerLoginSuccess = { - cert: authCode.value.authCodeString, - user: UserMap.toDTO(user.value), + const successResponse: UserAuthHandlerLoginSuccess = { user: UserMap.toDTO(user.value) } + options.req.login(user.value, function (err) { + if (err) { + resolve(Result.err(err)) + } else { + resolve(Result.ok(successResponse)) } - options.req.login(user.value, function (err) { - if (err) { - resolve(Result.err(err)) - } else { - resolve(Result.ok(successResponse)) - } - }) - } + }) } })(options.req, options.res) }) } - - async create(options: UserAuthHandlerCreateOptions): Promise { - const authCode = AuthCode.create({ - clientId: options.clientId, - userId: options.userId, - authCodeString: new AuthCodeString(), - }) - if (authCode.isErr()) { - return Result.err(authCode.error) - } - return Result.ok(authCode.value.authCodeString) - } } diff --git a/server/src/shared/auth/user-auth-handler.ts b/server/src/shared/auth/user-auth-handler.ts index e1d64e7..750b9e6 100644 --- a/server/src/shared/auth/user-auth-handler.ts +++ b/server/src/shared/auth/user-auth-handler.ts @@ -2,7 +2,7 @@ import express from 'express' import { Result } from '../core/result' import { LoginUserErrors } from '../../modules/users/application/use-cases/login-user/login-user-errors' import { AppError } from '../core/app-error' -import { AuthCodeString } from '../../modules/users/domain/value-objects/auth-code' +import { AuthCodeString } from '../../modules/users/domain/value-objects/auth-code-string' import { UserDTO } from '../../modules/users/mappers/user-dto' export type AuthToken = string @@ -30,20 +30,6 @@ export interface UserAuthHandlerLoginOptions { [key: string]: any //additional, implementation-specific options for auth handlers } -//creation -export type UserAuthHandlerCreateSuccess = AuthCertificate -export type UserAuthHandlerCreateError = AppError.UnexpectedError -export type UserAuthHandlerCreateResponse = Result< - UserAuthHandlerCreateSuccess, - UserAuthHandlerCreateError -> - -export interface UserAuthHandlerCreateOptions { - userId: string // we make the assumption that all auth handlers will require this at the least - [key: string]: any //additional, implementation-specific options for auth handlers -} - export abstract class UserAuthHandler { abstract login(options: UserAuthHandlerLoginOptions): Promise - abstract create(options: UserAuthHandlerCreateOptions): Promise } diff --git a/server/src/shared/infra/cache/entities/auth-code-entity.ts b/server/src/shared/infra/cache/entities/auth-code-entity.ts index 176121e..1320854 100644 --- a/server/src/shared/infra/cache/entities/auth-code-entity.ts +++ b/server/src/shared/infra/cache/entities/auth-code-entity.ts @@ -5,6 +5,10 @@ export class AuthCodeEntity extends RedisEntity { userId!: string + userEmail!: string + + userEmailVerified!: boolean + authCodeString!: string getEntityKey() { @@ -12,10 +16,12 @@ export class AuthCodeEntity extends RedisEntity { } toJSON(): object { - const { clientId, userId, authCodeString, id } = this + const { clientId, userId, userEmail, userEmailVerified, authCodeString, id } = this return { clientId, userId, + userEmail, + userEmailVerified, authCodeString, id, } diff --git a/server/src/shared/infra/cache/redis-repository.ts b/server/src/shared/infra/cache/redis-repository.ts index 6016884..1f33d93 100644 --- a/server/src/shared/infra/cache/redis-repository.ts +++ b/server/src/shared/infra/cache/redis-repository.ts @@ -7,7 +7,10 @@ export type RedisSaveEntityResponse = Result = Result +export type RedisGetEntityResponse = Result< + RedisEntityType | null, + RedisGetEntityError +> export type RedisGetEntityError = AppError.UnexpectedError export type RedisDeleteEntityResponse = Result @@ -22,8 +25,10 @@ export class RedisRepository { async getEntity(entityKey: string): Promise> { return new Promise((resolve) => { RedisClient().get(entityKey, (err, value) => { - if (err || value === null) { + if (err) { resolve(Result.err(new AppError.UnexpectedError(`Redis get operation failed. ${err}`))) + } else if (value === null) { + resolve(Result.ok(null)) } else { resolve(Result.ok(JSON.parse(value) as RedisEntityType)) } diff --git a/server/src/shared/infra/db/entities/auth-secret.entity.ts b/server/src/shared/infra/db/entities/auth-secret.entity.ts index 04be312..2a95100 100644 --- a/server/src/shared/infra/db/entities/auth-secret.entity.ts +++ b/server/src/shared/infra/db/entities/auth-secret.entity.ts @@ -7,6 +7,12 @@ export class AuthSecretEntity extends BaseEntity { @Index() clientId!: string + @Property() + decodedRedirectUri!: string + + @Property() + clientName!: string + @Property() encryptedClientSecret!: string diff --git a/server/src/shared/infra/db/entities/user.entity.ts b/server/src/shared/infra/db/entities/user.entity.ts index bb66839..2583e65 100644 --- a/server/src/shared/infra/db/entities/user.entity.ts +++ b/server/src/shared/infra/db/entities/user.entity.ts @@ -17,12 +17,6 @@ export class UserEntity extends BaseEntity { @Property({ default: false }) isDeleted!: boolean - @Property() - accessToken?: string - - @Property() - refreshToken?: string - @Property() lastLogin?: Date diff --git a/server/src/shared/infra/db/errors/errors.ts b/server/src/shared/infra/db/errors/errors.ts index 46b4d88..9a1d469 100644 --- a/server/src/shared/infra/db/errors/errors.ts +++ b/server/src/shared/infra/db/errors/errors.ts @@ -1,32 +1,41 @@ +import { AppError } from '../../../core/app-error' + export namespace DBError { - export class UserNotFoundError { - public message: string + export class UserNotFoundError extends Error { public constructor(identifier: string) { + super() this.message = `The user with attribute (id/email) ${identifier} could not be found.` } } - export class AuthSecretNotFoundError { - public message: string + export class AuthSecretNotFoundError extends Error { public constructor(identifier: string) { + super() this.message = `The auth secret with clientId ${identifier} could not be found.` } } - export class AuthCodeNotFoundError { - public message: string + export class AuthCodeNotFoundError extends Error { public constructor(identifier: string) { + super() this.message = `The auth code ${identifier} could not be found.` } } - export class PasswordsNotEqualError { - public message: string + export class PasswordsNotEqualError extends Error { public constructor(identifier: string) { + super() this.message = `An invalid password for the user with attribute (id/email) ${identifier} was provided.` } } + export class AuthSecretsNotEqualError extends Error { + public constructor(identifier: string) { + super() + this.message = `An invalid client secret ${identifier} was provided.` + } + } } -export type DBErrors = -DBError.UserNotFoundError -| DBError.AuthSecretNotFoundError -| DBError.AuthCodeNotFoundError -| DBError.PasswordsNotEqualError +export type DBErrors = + | DBError.UserNotFoundError + | DBError.AuthSecretNotFoundError + | DBError.AuthCodeNotFoundError + | DBError.PasswordsNotEqualError + | AppError.UnexpectedError diff --git a/server/src/test-utils/mocks/application/index.ts b/server/src/test-utils/mocks/application/index.ts index cfd0fb8..9406005 100644 --- a/server/src/test-utils/mocks/application/index.ts +++ b/server/src/test-utils/mocks/application/index.ts @@ -1,4 +1,6 @@ export { mockCreateUser } from './mock-create-user' export { mockGetUser } from './mock-get-user' export { mockLoginUser } from './mock-login-user' -export { mockProtectedUser } from './mock-protected-user' +export { mockGetToken } from './mock-get-token' +export { mockAuthorizeUser } from './mock-authorize-user' +export { mockDiscoverSP } from './mock-discover-sp' diff --git a/server/src/test-utils/mocks/application/mock-authorize-user.ts b/server/src/test-utils/mocks/application/mock-authorize-user.ts new file mode 100644 index 0000000..c08ecf9 --- /dev/null +++ b/server/src/test-utils/mocks/application/mock-authorize-user.ts @@ -0,0 +1,20 @@ +import { AuthorizeUserUseCase } from '../../../modules/users/application/use-cases/authorize-user/authorize-user-use-case' +import { AuthorizeUserController } from '../../../modules/users/application/use-cases/authorize-user/authorize-user-controller' +import { AuthSecretEntity } from '../../../shared/infra/db/entities/auth-secret.entity' +import { AuthCodeEntity } from '../../../shared/infra/cache/entities/auth-code-entity' +import { MockAuthCodeRepo } from '../../../modules/users/infra/repos/auth-code-repo/implementations/mock-auth-code-repo' +import { MockAuthSecretRepo } from '../../../modules/users/infra/repos/auth-secret-repo/implementations/mock-auth-secret-repo' + +const mockAuthorizeUser = async ( + authCodeEntities: Array = [], + authSecretEntities: Array = [] +) => { + const authCodeRepo = new MockAuthCodeRepo(authCodeEntities) + const authSecretRepo = new MockAuthSecretRepo(authSecretEntities) + const authorizeUserUseCase = new AuthorizeUserUseCase(authCodeRepo, authSecretRepo) + const authorizeUserController = new AuthorizeUserController(authorizeUserUseCase) + + return { authorizeUserUseCase, authorizeUserController } +} + +export { mockAuthorizeUser } diff --git a/server/src/test-utils/mocks/application/mock-discover-sp.ts b/server/src/test-utils/mocks/application/mock-discover-sp.ts new file mode 100644 index 0000000..4369a82 --- /dev/null +++ b/server/src/test-utils/mocks/application/mock-discover-sp.ts @@ -0,0 +1,14 @@ +import { DiscoverSPUseCase } from '../../../modules/users/application/use-cases/discover-sp/discover-sp-use-case' +import { DiscoverSPController } from '../../../modules/users/application/use-cases/discover-sp/discover-sp-controller' +import { MockAuthSecretRepo } from '../../../modules/users/infra/repos/auth-secret-repo/implementations/mock-auth-secret-repo' +import { AuthSecretEntity } from '../../../shared/infra/db/entities/auth-secret.entity' + +const mockDiscoverSP = async (authSecretEntities: Array = []) => { + const authSecretRepo = new MockAuthSecretRepo(authSecretEntities) + const discoverSPUseCase = new DiscoverSPUseCase(authSecretRepo) + const discoverSPController = new DiscoverSPController(discoverSPUseCase) + + return { discoverSPUseCase, discoverSPController } +} + +export { mockDiscoverSP } diff --git a/server/src/test-utils/mocks/application/mock-get-token.ts b/server/src/test-utils/mocks/application/mock-get-token.ts new file mode 100644 index 0000000..8b5acd8 --- /dev/null +++ b/server/src/test-utils/mocks/application/mock-get-token.ts @@ -0,0 +1,20 @@ +import { GetTokenUseCase } from '../../../modules/users/application/use-cases/get-token/get-token-use-case' +import { GetTokenController } from '../../../modules/users/application/use-cases/get-token/get-token-controller' +import { MockAuthCodeRepo } from '../../../modules/users/infra/repos/auth-code-repo/implementations/mock-auth-code-repo' +import { AuthCodeEntity } from '../../../shared/infra/cache/entities/auth-code-entity' +import { MockAuthSecretRepo } from '../../../modules/users/infra/repos/auth-secret-repo/implementations/mock-auth-secret-repo' +import { AuthSecretEntity } from '../../../shared/infra/db/entities/auth-secret.entity' + +const mockGetToken = async ( + authCodeEntities: Array = [], + authSecretEntities: Array = [] +) => { + const authCodeRepo = new MockAuthCodeRepo(authCodeEntities) + const authSecretRepo = new MockAuthSecretRepo(authSecretEntities) + const getTokenUseCase = new GetTokenUseCase(authCodeRepo, authSecretRepo) + const getTokenController = new GetTokenController(getTokenUseCase) + + return { getTokenUseCase, getTokenController } +} + +export { mockGetToken } diff --git a/server/src/test-utils/mocks/application/mock-protected-user.ts b/server/src/test-utils/mocks/application/mock-protected-user.ts deleted file mode 100644 index 2cf165e..0000000 --- a/server/src/test-utils/mocks/application/mock-protected-user.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { ProtectedUserUseCase } from '../../../modules/users/application/use-cases/protected-user/protected-user-use-case' -import { ProtectedUserController } from '../../../modules/users/application/use-cases/protected-user/protected-user-controller' - -const mockProtectedUser = async () => { - const protectedUserUseCase = new ProtectedUserUseCase() - const protectedUserController = new ProtectedUserController(protectedUserUseCase) - - return { protectedUserUseCase, protectedUserController } -} - -export { mockProtectedUser }