feat(auth): add verifying authenticator registration response

This commit is contained in:
Karol Sójko
2022-12-28 15:50:48 +01:00
parent 0a420ce30e
commit f5683cfd94
11 changed files with 319 additions and 6 deletions
+9
View File
@@ -214,6 +214,7 @@ import { MySQLAuthenticatorRepository } from '../Infra/MySQL/MySQLAuthenticatorR
import { AuthenticatorChallengeRepositoryInterface } from '../Domain/Authenticator/AuthenticatorChallengeRepositoryInterface'
import { MySQLAuthenticatorChallengeRepository } from '../Infra/MySQL/MySQLAuthenticatorChallengeRepository'
import { GenerateAuthenticatorRegistrationOptions } from '../Domain/UseCase/GenerateAuthenticatorRegistrationOptions/GenerateAuthenticatorRegistrationOptions'
import { VerifyAuthenticatorRegistrationResponse } from '../Domain/UseCase/VerifyAuthenticatorRegistrationResponse/VerifyAuthenticatorRegistrationResponse'
// eslint-disable-next-line @typescript-eslint/no-var-requires
const newrelicFormatter = require('@newrelic/winston-enricher')
@@ -560,6 +561,14 @@ export class ContainerConfigLoader {
container.get(TYPES.AuthenticatorChallengeRepository),
),
)
container
.bind<VerifyAuthenticatorRegistrationResponse>(TYPES.VerifyAuthenticatorRegistrationResponse)
.toConstantValue(
new VerifyAuthenticatorRegistrationResponse(
container.get(TYPES.AuthenticatorRepository),
container.get(TYPES.AuthenticatorChallengeRepository),
),
)
container
.bind<CleanupSessionTraces>(TYPES.CleanupSessionTraces)
+1
View File
@@ -134,6 +134,7 @@ const TYPES = {
CleanupSessionTraces: Symbol.for('CleanupSessionTraces'),
PersistStatistics: Symbol.for('PersistStatistics'),
GenerateAuthenticatorRegistrationOptions: Symbol.for('GenerateAuthenticatorRegistrationOptions'),
VerifyAuthenticatorRegistrationResponse: Symbol.for('VerifyAuthenticatorRegistrationResponse'),
// Handlers
UserRegisteredEventHandler: Symbol.for('UserRegisteredEventHandler'),
AccountDeletionRequestedEventHandler: Symbol.for('AccountDeletionRequestedEventHandler'),
@@ -1,5 +1,8 @@
import { Uuid } from '@standardnotes/domain-core'
import { AuthenticatorChallenge } from './AuthenticatorChallenge'
export interface AuthenticatorChallengeRepositoryInterface {
findByUserUuidAndChallenge(userUuid: Uuid, challenge: Buffer): Promise<AuthenticatorChallenge | null>
save(authenticatorChallenge: AuthenticatorChallenge): Promise<void>
}
@@ -3,4 +3,5 @@ import { Authenticator } from './Authenticator'
export interface AuthenticatorRepositoryInterface {
findByUserUuid(userUuid: Uuid): Promise<Authenticator[]>
save(authenticator: Authenticator): Promise<void>
}
@@ -0,0 +1,4 @@
export enum RelyingParty {
RP_NAME = 'Standard Notes',
RP_ID = 'standardnotes.com',
}
@@ -5,11 +5,9 @@ import { GenerateAuthenticatorRegistrationOptionsDTO } from './GenerateAuthentic
import { AuthenticatorRepositoryInterface } from '../../Authenticator/AuthenticatorRepositoryInterface'
import { AuthenticatorChallengeRepositoryInterface } from '../../Authenticator/AuthenticatorChallengeRepositoryInterface'
import { AuthenticatorChallenge } from '../../Authenticator/AuthenticatorChallenge'
import { RelyingParty } from '../../Authenticator/RelyingParty'
export class GenerateAuthenticatorRegistrationOptions implements UseCaseInterface<Record<string, unknown>> {
private readonly RP_NAME = 'Standard Notes'
private readonly RP_ID = 'standardnotes.com'
constructor(
private authenticatorRepository: AuthenticatorRepositoryInterface,
private authenticatorChallengeRepository: AuthenticatorChallengeRepositoryInterface,
@@ -30,8 +28,8 @@ export class GenerateAuthenticatorRegistrationOptions implements UseCaseInterfac
const authenticators = await this.authenticatorRepository.findByUserUuid(userUuid)
const options = generateRegistrationOptions({
rpID: this.RP_ID,
rpName: this.RP_NAME,
rpID: RelyingParty.RP_ID,
rpName: RelyingParty.RP_NAME,
userID: userUuid.value,
userName: username.value,
attestationType: 'none',
@@ -0,0 +1,209 @@
import * as simeplWebAuthnServer from '@simplewebauthn/server'
import { VerifiedRegistrationResponse } from '@simplewebauthn/server'
import { Result } from '@standardnotes/domain-core'
import { Authenticator } from '../../Authenticator/Authenticator'
import { AuthenticatorChallenge } from '../../Authenticator/AuthenticatorChallenge'
import { AuthenticatorChallengeRepositoryInterface } from '../../Authenticator/AuthenticatorChallengeRepositoryInterface'
import { AuthenticatorRepositoryInterface } from '../../Authenticator/AuthenticatorRepositoryInterface'
import { VerifyAuthenticatorRegistrationResponse } from './VerifyAuthenticatorRegistrationResponse'
describe('VerifyAuthenticatorRegistrationResponse', () => {
let authenticatorRepository: AuthenticatorRepositoryInterface
let authenticatorChallengeRepository: AuthenticatorChallengeRepositoryInterface
const createUseCase = () =>
new VerifyAuthenticatorRegistrationResponse(authenticatorRepository, authenticatorChallengeRepository)
beforeEach(() => {
authenticatorRepository = {} as jest.Mocked<AuthenticatorRepositoryInterface>
authenticatorRepository.save = jest.fn()
authenticatorChallengeRepository = {} as jest.Mocked<AuthenticatorChallengeRepositoryInterface>
authenticatorChallengeRepository.findByUserUuidAndChallenge = jest.fn().mockReturnValue({
props: {
challenge: Buffer.from('challenge'),
},
} as jest.Mocked<AuthenticatorChallenge>)
})
it('should return error if user uuid is invalid', async () => {
const useCase = createUseCase()
const result = await useCase.execute({
userUuid: 'invalid',
challenge: Buffer.from('challenge'),
registrationCredential: {
id: Buffer.from('id'),
rawId: Buffer.from('rawId'),
response: {
attestationObject: Buffer.from('attestationObject'),
clientDataJSON: Buffer.from('clientDataJSON'),
},
type: 'type',
},
})
expect(result.isFailed()).toBeTruthy()
expect(result.getError()).toEqual(
'Could not verify authenticator registration response: Given value is not a valid uuid: invalid',
)
})
it('should return error if challenge is not found', async () => {
authenticatorChallengeRepository.findByUserUuidAndChallenge = 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'),
response: {
attestationObject: Buffer.from('attestationObject'),
clientDataJSON: Buffer.from('clientDataJSON'),
},
type: 'type',
},
})
expect(result.isFailed()).toBeTruthy()
expect(result.getError()).toEqual('Could not verify authenticator registration response: challenge not found')
})
it('should return error if verification could not verify', async () => {
authenticatorChallengeRepository.findByUserUuidAndChallenge = jest.fn().mockReturnValue({
props: {
challenge: Buffer.from('challenge'),
},
} as jest.Mocked<AuthenticatorChallenge>)
const useCase = createUseCase()
const mock = jest.spyOn(simeplWebAuthnServer, 'verifyRegistrationResponse')
mock.mockImplementation(() => {
return Promise.resolve({
verified: false,
registrationInfo: {
counter: 1,
credentialBackedUp: true,
credentialDeviceType: 'singleDevice',
credentialID: Buffer.from('test'),
credentialPublicKey: Buffer.from('test'),
},
} as jest.Mocked<VerifiedRegistrationResponse>)
})
const result = await useCase.execute({
userUuid: '00000000-0000-0000-0000-000000000000',
challenge: Buffer.from('invalid'),
registrationCredential: {
id: Buffer.from('id'),
rawId: Buffer.from('rawId'),
response: {
attestationObject: Buffer.from('attestationObject'),
clientDataJSON: Buffer.from('clientDataJSON'),
},
type: 'type',
},
})
expect(result.isFailed()).toBeTruthy()
expect(result.getError()).toEqual('Could not verify authenticator registration response: verification failed')
mock.mockRestore()
})
it('should return error if authenticator could not be created', async () => {
authenticatorChallengeRepository.findByUserUuidAndChallenge = jest.fn().mockReturnValue({
props: {
challenge: Buffer.from('challenge'),
},
} as jest.Mocked<AuthenticatorChallenge>)
const useCase = createUseCase()
const mock = jest.spyOn(simeplWebAuthnServer, 'verifyRegistrationResponse')
mock.mockImplementation(() => {
return Promise.resolve({
verified: true,
registrationInfo: {
counter: 1,
credentialBackedUp: true,
credentialDeviceType: 'singleDevice',
credentialID: Buffer.from('test'),
credentialPublicKey: Buffer.from('test'),
},
} as jest.Mocked<VerifiedRegistrationResponse>)
})
const mockAuthenticator = jest.spyOn(Authenticator, 'create')
mockAuthenticator.mockImplementation(() => {
return Result.fail('Oops')
})
const result = await useCase.execute({
userUuid: '00000000-0000-0000-0000-000000000000',
challenge: Buffer.from('invalid'),
registrationCredential: {
id: Buffer.from('id'),
rawId: Buffer.from('rawId'),
response: {
attestationObject: Buffer.from('attestationObject'),
clientDataJSON: Buffer.from('clientDataJSON'),
},
type: 'type',
},
})
expect(result.isFailed()).toBeTruthy()
expect(result.getError()).toEqual('Could not verify authenticator registration response: Oops')
mock.mockRestore()
mockAuthenticator.mockRestore()
})
it('should verify authenticator registration response', async () => {
authenticatorChallengeRepository.findByUserUuidAndChallenge = jest.fn().mockReturnValue({
props: {
challenge: Buffer.from('challenge'),
},
} as jest.Mocked<AuthenticatorChallenge>)
const useCase = createUseCase()
const mock = jest.spyOn(simeplWebAuthnServer, 'verifyRegistrationResponse')
mock.mockImplementation(() => {
return Promise.resolve({
verified: true,
registrationInfo: {
counter: 1,
credentialBackedUp: true,
credentialDeviceType: 'singleDevice',
credentialID: Buffer.from('test'),
credentialPublicKey: Buffer.from('test'),
},
} as jest.Mocked<VerifiedRegistrationResponse>)
})
const result = await useCase.execute({
userUuid: '00000000-0000-0000-0000-000000000000',
challenge: Buffer.from('invalid'),
registrationCredential: {
id: Buffer.from('id'),
rawId: Buffer.from('rawId'),
response: {
attestationObject: Buffer.from('attestationObject'),
clientDataJSON: Buffer.from('clientDataJSON'),
},
type: 'type',
},
})
expect(result.isFailed()).toBeFalsy()
mock.mockRestore()
})
})
@@ -0,0 +1,61 @@
import { Dates, Result, UseCaseInterface, Uuid } from '@standardnotes/domain-core'
import { verifyRegistrationResponse } from '@simplewebauthn/server'
import { AuthenticatorChallengeRepositoryInterface } from '../../Authenticator/AuthenticatorChallengeRepositoryInterface'
import { RelyingParty } from '../../Authenticator/RelyingParty'
import { AuthenticatorRepositoryInterface } from '../../Authenticator/AuthenticatorRepositoryInterface'
import { Authenticator } from '../../Authenticator/Authenticator'
import { VerifyAuthenticatorRegistrationResponseDTO } from './VerifyAuthenticatorRegistrationResponseDTO'
export class VerifyAuthenticatorRegistrationResponse implements UseCaseInterface<boolean> {
constructor(
private authenticatorRepository: AuthenticatorRepositoryInterface,
private authenticatorChallengeRepository: AuthenticatorChallengeRepositoryInterface,
) {}
async execute(dto: VerifyAuthenticatorRegistrationResponseDTO): Promise<Result<boolean>> {
const userUuidOrError = Uuid.create(dto.userUuid)
if (userUuidOrError.isFailed()) {
return Result.fail(`Could not verify authenticator registration response: ${userUuidOrError.getError()}`)
}
const userUuid = userUuidOrError.getValue()
const authenticatorChallenge = await this.authenticatorChallengeRepository.findByUserUuidAndChallenge(
userUuid,
dto.challenge,
)
if (!authenticatorChallenge) {
return Result.fail('Could not verify authenticator registration response: challenge not found')
}
const verification = await verifyRegistrationResponse({
credential: dto.registrationCredential,
expectedChallenge: authenticatorChallenge.props.challenge.toString(),
expectedOrigin: `https://${RelyingParty.RP_ID}`,
expectedRPID: RelyingParty.RP_ID,
})
if (!verification.verified) {
return Result.fail('Could not verify authenticator registration response: verification failed')
}
const authenticatorOrError = Authenticator.create({
userUuid,
counter: verification.registrationInfo?.counter as number,
credentialBackedUp: verification.registrationInfo?.credentialBackedUp as boolean,
credentialDeviceType: verification.registrationInfo?.credentialDeviceType,
credentialId: verification.registrationInfo?.credentialID as Buffer,
credentialPublicKey: verification.registrationInfo?.credentialPublicKey as Buffer,
dates: Dates.create(new Date(), new Date()).getValue(),
})
if (authenticatorOrError.isFailed()) {
return Result.fail(`Could not verify authenticator registration response: ${authenticatorOrError.getError()}`)
}
const authenticator = authenticatorOrError.getValue()
await this.authenticatorRepository.save(authenticator)
return Result.ok(true)
}
}
@@ -0,0 +1,5 @@
export interface VerifyAuthenticatorRegistrationResponseDTO {
userUuid: string
challenge: Buffer
registrationCredential: Record<string, unknown>
}
@@ -1,4 +1,4 @@
import { MapperInterface } from '@standardnotes/domain-core'
import { MapperInterface, Uuid } from '@standardnotes/domain-core'
import { Repository } from 'typeorm'
import { AuthenticatorChallenge } from '../../Domain/Authenticator/AuthenticatorChallenge'
@@ -12,6 +12,22 @@ 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> {
const persistence = this.mapper.toProjection(authenticatorChallenge)
@@ -11,6 +11,12 @@ export class MySQLAuthenticatorRepository implements AuthenticatorRepositoryInte
private mapper: MapperInterface<Authenticator, TypeORMAuthenticator>,
) {}
async save(authenticator: Authenticator): Promise<void> {
const persistence = this.mapper.toProjection(authenticator)
await this.ormRepository.save(persistence)
}
async findByUserUuid(userUuid: Uuid): Promise<Authenticator[]> {
const typeOrm = await this.ormRepository
.createQueryBuilder('authenticator')