mirror of
https://github.com/standardnotes/server
synced 2026-07-14 00:01:54 -04:00
feat(auth): add verifying authenticator registration response
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
+3
-5
@@ -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',
|
||||
|
||||
+209
@@ -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()
|
||||
})
|
||||
})
|
||||
+61
@@ -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)
|
||||
}
|
||||
}
|
||||
+5
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user