feat(auth): add http endpoints for authenticators

This commit is contained in:
Karol Sójko
2022-12-29 11:29:18 +01:00
parent 14669df890
commit b6fda901ef
28 changed files with 328 additions and 46 deletions

View File

@@ -21,6 +21,7 @@ import '../src/Controller/v1/FilesController'
import '../src/Controller/v1/SubscriptionInvitesController'
import '../src/Controller/v1/WorkspacesController'
import '../src/Controller/v1/InvitesController'
import '../src/Controller/v1/AuthenticatorsController'
import '../src/Controller/v2/PaymentsControllerV2'
import '../src/Controller/v2/ActionsControllerV2'

View File

@@ -0,0 +1,43 @@
import { inject } from 'inversify'
import { Request, Response } from 'express'
import { controller, BaseHttpController, httpPost, httpGet } from 'inversify-express-utils'
import TYPES from '../../Bootstrap/Types'
import { HttpServiceInterface } from '../../Service/Http/HttpServiceInterface'
@controller('/v1/authenticators', TYPES.AuthMiddleware)
export class AuthenticatorsController extends BaseHttpController {
constructor(@inject(TYPES.HTTPService) private httpService: HttpServiceInterface) {
super()
}
@httpGet('/generate-registration-options')
async generateRegistrationOptions(request: Request, response: Response): Promise<void> {
await this.httpService.callAuthServer(
request,
response,
'authenticators/generate-registration-options',
request.body,
)
}
@httpGet('/generate-authentication-options')
async generateAuthenticationOptions(request: Request, response: Response): Promise<void> {
await this.httpService.callAuthServer(
request,
response,
'authenticators/generate-authentication-options',
request.body,
)
}
@httpPost('/verify-registration')
async verifyRegistration(request: Request, response: Response): Promise<void> {
await this.httpService.callAuthServer(request, response, 'authenticators/verify-registration', request.body)
}
@httpPost('/verify-authentication')
async verifyAuthentication(request: Request, response: Response): Promise<void> {
await this.httpService.callAuthServer(request, response, 'authenticators/verify-authentication', request.body)
}
}

View File

@@ -21,6 +21,7 @@ import '../src/Controller/ListedController'
import '../src/Controller/SubscriptionSettingsController'
import '../src/Infra/InversifyExpressUtils/InversifyExpressAuthController'
import '../src/Infra/InversifyExpressUtils/InversifyExpressAuthenticatorsController'
import '../src/Infra/InversifyExpressUtils/InversifyExpressSubscriptionInvitesController'
import '../src/Infra/InversifyExpressUtils/InversifyExpressUserRequestsController'
import '../src/Infra/InversifyExpressUtils/InversifyExpressWebSocketsController'

View File

@@ -0,0 +1,15 @@
import { MigrationInterface, QueryRunner } from 'typeorm'
export class removeCompoundIndex1672307975117 implements MigrationInterface {
name = 'removeCompoundIndex1672307975117'
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query('DROP INDEX `user_uuid_and_challenge` ON `authenticator_challenges`')
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
'CREATE INDEX `user_uuid_and_challenge` ON `authenticator_challenges` (`user_uuid`, `challenge`)',
)
}
}

View File

@@ -217,6 +217,7 @@ import { GenerateAuthenticatorRegistrationOptions } from '../Domain/UseCase/Gene
import { VerifyAuthenticatorRegistrationResponse } from '../Domain/UseCase/VerifyAuthenticatorRegistrationResponse/VerifyAuthenticatorRegistrationResponse'
import { GenerateAuthenticatorAuthenticationOptions } from '../Domain/UseCase/GenerateAuthenticatorAuthenticationOptions/GenerateAuthenticatorAuthenticationOptions'
import { VerifyAuthenticatorAuthenticationResponse } from '../Domain/UseCase/VerifyAuthenticatorAuthenticationResponse/VerifyAuthenticatorAuthenticationResponse'
import { AuthenticatorsController } from '../Controller/AuthenticatorsController'
// eslint-disable-next-line @typescript-eslint/no-var-requires
const newrelicFormatter = require('@newrelic/winston-enricher')
@@ -303,11 +304,6 @@ export class ContainerConfigLoader {
)
.toConstantValue(new AuthenticatorChallengePersistenceMapper())
// Controller
container.bind<AuthController>(TYPES.AuthController).to(AuthController)
container.bind<SubscriptionInvitesController>(TYPES.SubscriptionInvitesController).to(SubscriptionInvitesController)
container.bind<UserRequestsController>(TYPES.UserRequestsController).to(UserRequestsController)
// ORM
container
.bind<Repository<OfflineSetting>>(TYPES.ORMOfflineSettingRepository)
@@ -641,6 +637,21 @@ export class ContainerConfigLoader {
container.bind<CreateCrossServiceToken>(TYPES.CreateCrossServiceToken).to(CreateCrossServiceToken)
container.bind<ProcessUserRequest>(TYPES.ProcessUserRequest).to(ProcessUserRequest)
// Controller
container.bind<AuthController>(TYPES.AuthController).to(AuthController)
container
.bind<AuthenticatorsController>(TYPES.AuthenticatorsController)
.toConstantValue(
new AuthenticatorsController(
container.get(TYPES.GenerateAuthenticatorRegistrationOptions),
container.get(TYPES.VerifyAuthenticatorRegistrationResponse),
container.get(TYPES.GenerateAuthenticatorAuthenticationOptions),
container.get(TYPES.VerifyAuthenticatorAuthenticationResponse),
),
)
container.bind<SubscriptionInvitesController>(TYPES.SubscriptionInvitesController).to(SubscriptionInvitesController)
container.bind<UserRequestsController>(TYPES.UserRequestsController).to(UserRequestsController)
// Handlers
container.bind<UserRegisteredEventHandler>(TYPES.UserRegisteredEventHandler).to(UserRegisteredEventHandler)
container

View File

@@ -9,6 +9,7 @@ const TYPES = {
AuthenticatorPersistenceMapper: Symbol.for('AuthenticatorPersistenceMapper'),
// Controller
AuthController: Symbol.for('AuthController'),
AuthenticatorsController: Symbol.for('AuthenticatorsController'),
SubscriptionInvitesController: Symbol.for('SubscriptionInvitesController'),
UserRequestsController: Symbol.for('UserRequestsController'),
// Repositories

View File

@@ -0,0 +1,122 @@
import { HttpStatusCode } from '@standardnotes/api'
import { GenerateAuthenticatorAuthenticationOptions } from '../Domain/UseCase/GenerateAuthenticatorAuthenticationOptions/GenerateAuthenticatorAuthenticationOptions'
import { GenerateAuthenticatorRegistrationOptions } from '../Domain/UseCase/GenerateAuthenticatorRegistrationOptions/GenerateAuthenticatorRegistrationOptions'
import { VerifyAuthenticatorAuthenticationResponse } from '../Domain/UseCase/VerifyAuthenticatorAuthenticationResponse/VerifyAuthenticatorAuthenticationResponse'
import { VerifyAuthenticatorRegistrationResponse } from '../Domain/UseCase/VerifyAuthenticatorRegistrationResponse/VerifyAuthenticatorRegistrationResponse'
import { GenerateAuthenticatorAuthenticationOptionsRequestParams } from '../Infra/Http/Request/GenerateAuthenticatorAuthenticationOptionsRequestParams'
import { GenerateAuthenticatorRegistrationOptionsRequestParams } from '../Infra/Http/Request/GenerateAuthenticatorRegistrationOptionsRequestParams'
import { VerifyAuthenticatorAuthenticationResponseRequestParams } from '../Infra/Http/Request/VerifyAuthenticatorAuthenticationResponseRequestParams'
import { VerifyAuthenticatorRegistrationResponseRequestParams } from '../Infra/Http/Request/VerifyAuthenticatorRegistrationResponseRequestParams'
import { GenerateAuthenticatorAuthenticationOptionsResponse } from '../Infra/Http/Response/GenerateAuthenticatorAuthenticationOptionsResponse'
import { GenerateAuthenticatorRegistrationOptionsResponse } from '../Infra/Http/Response/GenerateAuthenticatorRegistrationOptionsResponse'
import { VerifyAuthenticatorAuthenticationResponseResponse } from '../Infra/Http/Response/VerifyAuthenticatorAuthenticationResponseResponse'
import { VerifyAuthenticatorRegistrationResponseResponse } from '../Infra/Http/Response/VerifyAuthenticatorRegistrationResponseResponse'
export class AuthenticatorsController {
constructor(
private generateAuthenticatorRegistrationOptions: GenerateAuthenticatorRegistrationOptions,
private verifyAuthenticatorRegistrationResponse: VerifyAuthenticatorRegistrationResponse,
private generateAuthenticatorAuthenticationOptions: GenerateAuthenticatorAuthenticationOptions,
private verifyAuthenticatorAuthenticationResponse: VerifyAuthenticatorAuthenticationResponse,
) {}
async generateRegistrationOptions(
params: GenerateAuthenticatorRegistrationOptionsRequestParams,
): Promise<GenerateAuthenticatorRegistrationOptionsResponse> {
const result = await this.generateAuthenticatorRegistrationOptions.execute({
userUuid: params.userUuid,
username: params.username,
})
if (result.isFailed()) {
return {
status: HttpStatusCode.BadRequest,
data: {
error: {
message: result.getError(),
},
},
}
}
return {
status: HttpStatusCode.Success,
data: { options: result.getValue() },
}
}
async verifyRegistrationResponse(
params: VerifyAuthenticatorRegistrationResponseRequestParams,
): Promise<VerifyAuthenticatorRegistrationResponseResponse> {
const result = await this.verifyAuthenticatorRegistrationResponse.execute({
userUuid: params.userUuid,
registrationCredential: params.registrationCredential,
})
if (result.isFailed()) {
return {
status: HttpStatusCode.Unauthorized,
data: {
error: {
message: result.getError(),
},
},
}
}
return {
status: HttpStatusCode.Success,
data: { success: result.getValue() },
}
}
async generateAuthenticationOptions(
params: GenerateAuthenticatorAuthenticationOptionsRequestParams,
): Promise<GenerateAuthenticatorAuthenticationOptionsResponse> {
const result = await this.generateAuthenticatorAuthenticationOptions.execute({
userUuid: params.userUuid,
})
if (result.isFailed()) {
return {
status: HttpStatusCode.BadRequest,
data: {
error: {
message: result.getError(),
},
},
}
}
return {
status: HttpStatusCode.Success,
data: { options: result.getValue() },
}
}
async verifyAuthenticationResponse(
params: VerifyAuthenticatorAuthenticationResponseRequestParams,
): Promise<VerifyAuthenticatorAuthenticationResponseResponse> {
const result = await this.verifyAuthenticatorAuthenticationResponse.execute({
userUuid: params.userUuid,
authenticationCredential: params.authenticationCredential,
})
if (result.isFailed()) {
return {
status: HttpStatusCode.Unauthorized,
data: {
error: {
message: result.getError(),
},
},
}
}
return {
status: HttpStatusCode.Success,
data: { success: result.getValue() },
}
}
}

View File

@@ -3,7 +3,6 @@ import { Uuid } from '@standardnotes/domain-core'
import { AuthenticatorChallenge } from './AuthenticatorChallenge'
export interface AuthenticatorChallengeRepositoryInterface {
findByUserUuidAndChallenge(userUuid: Uuid, challenge: Buffer): Promise<AuthenticatorChallenge | null>
findByUserUuid(userUuid: Uuid): Promise<AuthenticatorChallenge | null>
save(authenticatorChallenge: AuthenticatorChallenge): Promise<void>
}

View File

@@ -27,18 +27,18 @@ export class VerifyAuthenticatorAuthenticationResponse implements UseCaseInterfa
const authenticator = await this.authenticatorRepository.findByUserUuidAndCredentialId(
userUuid,
Buffer.from(dto.registrationCredential.id as string),
Buffer.from(dto.authenticationCredential.id as string),
)
if (!authenticator) {
return Result.fail(
`Could not verify authenticator authentication response: authenticator ${dto.registrationCredential.id} not found`,
`Could not verify authenticator authentication response: authenticator ${dto.authenticationCredential.id} not found`,
)
}
let verification: VerifiedAuthenticationResponse
try {
verification = await verifyAuthenticationResponse({
credential: dto.registrationCredential,
credential: dto.authenticationCredential,
expectedChallenge: authenticatorChallenge.props.challenge.toString(),
expectedOrigin: `https://${RelyingParty.RP_ID}`,
expectedRPID: RelyingParty.RP_ID,

View File

@@ -1,4 +1,4 @@
export interface VerifyAuthenticatorAuthenticationResponseDTO {
userUuid: string
registrationCredential: Record<string, unknown>
authenticationCredential: Record<string, unknown>
}

View File

@@ -20,7 +20,7 @@ describe('VerifyAuthenticatorRegistrationResponse', () => {
authenticatorRepository.save = jest.fn()
authenticatorChallengeRepository = {} as jest.Mocked<AuthenticatorChallengeRepositoryInterface>
authenticatorChallengeRepository.findByUserUuidAndChallenge = jest.fn().mockReturnValue({
authenticatorChallengeRepository.findByUserUuid = jest.fn().mockReturnValue({
props: {
challenge: Buffer.from('challenge'),
},
@@ -32,7 +32,6 @@ describe('VerifyAuthenticatorRegistrationResponse', () => {
const result = await useCase.execute({
userUuid: 'invalid',
challenge: Buffer.from('challenge'),
registrationCredential: {
id: Buffer.from('id'),
rawId: Buffer.from('rawId'),
@@ -51,13 +50,12 @@ describe('VerifyAuthenticatorRegistrationResponse', () => {
})
it('should return error if challenge is not found', async () => {
authenticatorChallengeRepository.findByUserUuidAndChallenge = jest.fn().mockReturnValue(null)
authenticatorChallengeRepository.findByUserUuid = jest.fn().mockReturnValue(null)
const useCase = createUseCase()
const result = await useCase.execute({
userUuid: '00000000-0000-0000-0000-000000000000',
challenge: Buffer.from('challenge'),
registrationCredential: {
id: Buffer.from('id'),
rawId: Buffer.from('rawId'),
@@ -74,7 +72,7 @@ describe('VerifyAuthenticatorRegistrationResponse', () => {
})
it('should return error if verification could not verify', async () => {
authenticatorChallengeRepository.findByUserUuidAndChallenge = jest.fn().mockReturnValue({
authenticatorChallengeRepository.findByUserUuid = jest.fn().mockReturnValue({
props: {
challenge: Buffer.from('challenge'),
},
@@ -98,7 +96,6 @@ describe('VerifyAuthenticatorRegistrationResponse', () => {
const result = await useCase.execute({
userUuid: '00000000-0000-0000-0000-000000000000',
challenge: Buffer.from('invalid'),
registrationCredential: {
id: Buffer.from('id'),
rawId: Buffer.from('rawId'),
@@ -117,7 +114,7 @@ describe('VerifyAuthenticatorRegistrationResponse', () => {
})
it('should return error if verification throws error', async () => {
authenticatorChallengeRepository.findByUserUuidAndChallenge = jest.fn().mockReturnValue({
authenticatorChallengeRepository.findByUserUuid = jest.fn().mockReturnValue({
props: {
challenge: Buffer.from('challenge'),
},
@@ -132,7 +129,6 @@ describe('VerifyAuthenticatorRegistrationResponse', () => {
const result = await useCase.execute({
userUuid: '00000000-0000-0000-0000-000000000000',
challenge: Buffer.from('invalid'),
registrationCredential: {
id: Buffer.from('id'),
rawId: Buffer.from('rawId'),
@@ -151,7 +147,7 @@ describe('VerifyAuthenticatorRegistrationResponse', () => {
})
it('should return error if verification is missing registration info', async () => {
authenticatorChallengeRepository.findByUserUuidAndChallenge = jest.fn().mockReturnValue({
authenticatorChallengeRepository.findByUserUuid = jest.fn().mockReturnValue({
props: {
challenge: Buffer.from('challenge'),
},
@@ -168,7 +164,6 @@ describe('VerifyAuthenticatorRegistrationResponse', () => {
const result = await useCase.execute({
userUuid: '00000000-0000-0000-0000-000000000000',
challenge: Buffer.from('invalid'),
registrationCredential: {
id: Buffer.from('id'),
rawId: Buffer.from('rawId'),
@@ -189,7 +184,7 @@ describe('VerifyAuthenticatorRegistrationResponse', () => {
})
it('should return error if authenticator could not be created', async () => {
authenticatorChallengeRepository.findByUserUuidAndChallenge = jest.fn().mockReturnValue({
authenticatorChallengeRepository.findByUserUuid = jest.fn().mockReturnValue({
props: {
challenge: Buffer.from('challenge'),
},
@@ -218,7 +213,6 @@ describe('VerifyAuthenticatorRegistrationResponse', () => {
const result = await useCase.execute({
userUuid: '00000000-0000-0000-0000-000000000000',
challenge: Buffer.from('invalid'),
registrationCredential: {
id: Buffer.from('id'),
rawId: Buffer.from('rawId'),
@@ -238,7 +232,7 @@ describe('VerifyAuthenticatorRegistrationResponse', () => {
})
it('should verify authenticator registration response', async () => {
authenticatorChallengeRepository.findByUserUuidAndChallenge = jest.fn().mockReturnValue({
authenticatorChallengeRepository.findByUserUuid = jest.fn().mockReturnValue({
props: {
challenge: Buffer.from('challenge'),
},
@@ -262,7 +256,6 @@ describe('VerifyAuthenticatorRegistrationResponse', () => {
const result = await useCase.execute({
userUuid: '00000000-0000-0000-0000-000000000000',
challenge: Buffer.from('invalid'),
registrationCredential: {
id: Buffer.from('id'),
rawId: Buffer.from('rawId'),

View File

@@ -20,10 +20,7 @@ export class VerifyAuthenticatorRegistrationResponse implements UseCaseInterface
}
const userUuid = userUuidOrError.getValue()
const authenticatorChallenge = await this.authenticatorChallengeRepository.findByUserUuidAndChallenge(
userUuid,
dto.challenge,
)
const authenticatorChallenge = await this.authenticatorChallengeRepository.findByUserUuid(userUuid)
if (!authenticatorChallenge) {
return Result.fail('Could not verify authenticator registration response: challenge not found')
}

View File

@@ -1,5 +1,4 @@
export interface VerifyAuthenticatorRegistrationResponseDTO {
userUuid: string
challenge: Buffer
registrationCredential: Record<string, unknown>
}

View File

@@ -0,0 +1,3 @@
export interface GenerateAuthenticatorAuthenticationOptionsRequestParams {
userUuid: string
}

View File

@@ -0,0 +1,4 @@
export interface GenerateAuthenticatorRegistrationOptionsRequestParams {
userUuid: string
username: string
}

View File

@@ -0,0 +1,4 @@
export interface VerifyAuthenticatorAuthenticationResponseRequestParams {
userUuid: string
authenticationCredential: Record<string, unknown>
}

View File

@@ -0,0 +1,4 @@
export interface VerifyAuthenticatorRegistrationResponseRequestParams {
userUuid: string
registrationCredential: Record<string, unknown>
}

View File

@@ -0,0 +1,8 @@
import { HttpErrorResponseBody, HttpResponse } from '@standardnotes/api'
import { Either } from '@standardnotes/common'
import { GenerateAuthenticatorAuthenticationOptionsResponseBody } from './GenerateAuthenticatorAuthenticationOptionsResponseBody'
export interface GenerateAuthenticatorAuthenticationOptionsResponse extends HttpResponse {
data: Either<GenerateAuthenticatorAuthenticationOptionsResponseBody, HttpErrorResponseBody>
}

View File

@@ -0,0 +1,3 @@
export interface GenerateAuthenticatorAuthenticationOptionsResponseBody {
options: Record<string, unknown>
}

View File

@@ -0,0 +1,8 @@
import { HttpErrorResponseBody, HttpResponse } from '@standardnotes/api'
import { Either } from '@standardnotes/common'
import { GenerateAuthenticatorRegistrationOptionsResponseBody } from './GenerateAuthenticatorRegistrationOptionsResponseBody'
export interface GenerateAuthenticatorRegistrationOptionsResponse extends HttpResponse {
data: Either<GenerateAuthenticatorRegistrationOptionsResponseBody, HttpErrorResponseBody>
}

View File

@@ -0,0 +1,3 @@
export interface GenerateAuthenticatorRegistrationOptionsResponseBody {
options: Record<string, unknown>
}

View File

@@ -0,0 +1,8 @@
import { HttpErrorResponseBody, HttpResponse } from '@standardnotes/api'
import { Either } from '@standardnotes/common'
import { VerifyAuthenticatorAuthenticationResponseResponseBody } from './VerifyAuthenticatorAuthenticationResponseResponseBody'
export interface VerifyAuthenticatorAuthenticationResponseResponse extends HttpResponse {
data: Either<VerifyAuthenticatorAuthenticationResponseResponseBody, HttpErrorResponseBody>
}

View File

@@ -0,0 +1,3 @@
export interface VerifyAuthenticatorAuthenticationResponseResponseBody {
success: boolean
}

View File

@@ -0,0 +1,8 @@
import { HttpErrorResponseBody, HttpResponse } from '@standardnotes/api'
import { Either } from '@standardnotes/common'
import { VerifyAuthenticatorRegistrationResponseResponseBody } from './VerifyAuthenticatorRegistrationResponseResponseBody'
export interface VerifyAuthenticatorRegistrationResponseResponse extends HttpResponse {
data: Either<VerifyAuthenticatorRegistrationResponseResponseBody, HttpErrorResponseBody>
}

View File

@@ -0,0 +1,3 @@
export interface VerifyAuthenticatorRegistrationResponseResponseBody {
success: boolean
}

View File

@@ -0,0 +1,58 @@
import { Request, Response } from 'express'
import { inject } from 'inversify'
import {
BaseHttpController,
controller,
httpGet,
httpPost,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
results,
} from 'inversify-express-utils'
import TYPES from '../../Bootstrap/Types'
import { AuthenticatorsController } from '../../Controller/AuthenticatorsController'
@controller('/authenticators', TYPES.ApiGatewayAuthMiddleware)
export class InversifyExpressAuthenticatorsController extends BaseHttpController {
constructor(@inject(TYPES.AuthenticatorsController) private authenticatorsController: AuthenticatorsController) {
super()
}
@httpGet('/generate-registration-options')
async generateRegistrationOptions(_request: Request, response: Response): Promise<results.JsonResult> {
const result = await this.authenticatorsController.generateRegistrationOptions({
username: response.locals.user.email,
userUuid: response.locals.user.uuid,
})
return this.json(result.data, result.status)
}
@httpPost('/verify-registration')
async verifyRegistration(request: Request, response: Response): Promise<results.JsonResult> {
const result = await this.authenticatorsController.verifyRegistrationResponse({
userUuid: response.locals.user.uuid,
registrationCredential: request.body,
})
return this.json(result.data, result.status)
}
@httpGet('/generate-authentication-options')
async generateAuthenticationOptions(_request: Request, response: Response): Promise<results.JsonResult> {
const result = await this.authenticatorsController.generateAuthenticationOptions({
userUuid: response.locals.user.uuid,
})
return this.json(result.data, result.status)
}
@httpPost('/verify-authentication')
async verifyAuthentication(request: Request, response: Response): Promise<results.JsonResult> {
const result = await this.authenticatorsController.verifyAuthenticationResponse({
userUuid: response.locals.user.uuid,
authenticationCredential: request.body,
})
return this.json(result.data, result.status)
}
}

View File

@@ -12,22 +12,6 @@ export class MySQLAuthenticatorChallengeRepository implements AuthenticatorChall
private mapper: MapperInterface<AuthenticatorChallenge, TypeORMAuthenticatorChallenge>,
) {}
async findByUserUuidAndChallenge(userUuid: Uuid, challenge: Buffer): Promise<AuthenticatorChallenge | null> {
const typeOrm = await this.ormRepository
.createQueryBuilder('challenge')
.where('challenge.user_uuid = :userUuid and challenge.challenge = :challenge', {
userUuid: userUuid.value,
challenge,
})
.getOne()
if (typeOrm === null) {
return null
}
return this.mapper.toDomain(typeOrm)
}
async save(authenticatorChallenge: AuthenticatorChallenge): Promise<void> {
let persistence = this.mapper.toProjection(authenticatorChallenge)

View File

@@ -1,7 +1,6 @@
import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm'
@Entity({ name: 'authenticator_challenges' })
@Index('user_uuid_and_challenge', ['userUuid', 'challenge'])
export class TypeORMAuthenticatorChallenge {
@PrimaryGeneratedColumn('uuid')
declare uuid: string