2021-06-14 16:09:40 +02:00
|
|
|
import { AuthDeviceDto } from '@ghostfolio/api/app/auth-device/auth-device.dto';
|
|
|
|
import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service';
|
2021-08-14 16:55:40 +02:00
|
|
|
import { UserService } from '@ghostfolio/api/app/user/user.service';
|
2021-06-14 16:09:40 +02:00
|
|
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
2021-09-11 09:27:22 +02:00
|
|
|
import type { RequestWithUser } from '@ghostfolio/common/types';
|
2021-06-14 16:09:40 +02:00
|
|
|
import {
|
|
|
|
Inject,
|
|
|
|
Injectable,
|
|
|
|
InternalServerErrorException
|
|
|
|
} from '@nestjs/common';
|
|
|
|
import { REQUEST } from '@nestjs/core';
|
|
|
|
import { JwtService } from '@nestjs/jwt';
|
|
|
|
import {
|
2021-09-11 21:23:06 +02:00
|
|
|
GenerateAuthenticationOptionsOpts,
|
|
|
|
GenerateRegistrationOptionsOpts,
|
|
|
|
VerifiedAuthenticationResponse,
|
|
|
|
VerifiedRegistrationResponse,
|
|
|
|
VerifyAuthenticationResponseOpts,
|
|
|
|
VerifyRegistrationResponseOpts,
|
|
|
|
generateAuthenticationOptions,
|
|
|
|
generateRegistrationOptions,
|
|
|
|
verifyAuthenticationResponse,
|
|
|
|
verifyRegistrationResponse
|
2021-06-14 16:09:40 +02:00
|
|
|
} from '@simplewebauthn/server';
|
|
|
|
|
|
|
|
import {
|
|
|
|
AssertionCredentialJSON,
|
|
|
|
AttestationCredentialJSON
|
|
|
|
} from './interfaces/simplewebauthn';
|
|
|
|
|
|
|
|
@Injectable()
|
|
|
|
export class WebAuthService {
|
|
|
|
public constructor(
|
|
|
|
private readonly configurationService: ConfigurationService,
|
|
|
|
private readonly deviceService: AuthDeviceService,
|
|
|
|
private readonly jwtService: JwtService,
|
|
|
|
private readonly userService: UserService,
|
|
|
|
@Inject(REQUEST) private readonly request: RequestWithUser
|
|
|
|
) {}
|
|
|
|
|
|
|
|
get rpID() {
|
|
|
|
return this.configurationService.get('WEB_AUTH_RP_ID');
|
|
|
|
}
|
|
|
|
|
|
|
|
get expectedOrigin() {
|
|
|
|
return this.configurationService.get('ROOT_URL');
|
|
|
|
}
|
|
|
|
|
2021-09-11 21:23:06 +02:00
|
|
|
public async generateRegistrationOptions() {
|
2021-06-14 16:09:40 +02:00
|
|
|
const user = this.request.user;
|
|
|
|
|
2021-09-11 21:23:06 +02:00
|
|
|
const opts: GenerateRegistrationOptionsOpts = {
|
2021-06-14 16:09:40 +02:00
|
|
|
rpName: 'Ghostfolio',
|
|
|
|
rpID: this.rpID,
|
|
|
|
userID: user.id,
|
|
|
|
userName: user.alias,
|
|
|
|
timeout: 60000,
|
|
|
|
attestationType: 'indirect',
|
|
|
|
authenticatorSelection: {
|
2021-06-14 21:57:09 +02:00
|
|
|
authenticatorAttachment: 'platform',
|
|
|
|
requireResidentKey: false,
|
|
|
|
userVerification: 'required'
|
2021-06-14 16:09:40 +02:00
|
|
|
}
|
|
|
|
};
|
|
|
|
|
2021-09-11 21:23:06 +02:00
|
|
|
const options = generateRegistrationOptions(opts);
|
2021-06-14 16:09:40 +02:00
|
|
|
|
|
|
|
await this.userService.updateUser({
|
|
|
|
data: {
|
|
|
|
authChallenge: options.challenge
|
|
|
|
},
|
|
|
|
where: {
|
|
|
|
id: user.id
|
|
|
|
}
|
|
|
|
});
|
|
|
|
|
|
|
|
return options;
|
|
|
|
}
|
|
|
|
|
|
|
|
public async verifyAttestation(
|
|
|
|
deviceName: string,
|
|
|
|
credential: AttestationCredentialJSON
|
|
|
|
): Promise<AuthDeviceDto> {
|
|
|
|
const user = this.request.user;
|
|
|
|
const expectedChallenge = user.authChallenge;
|
|
|
|
|
2021-09-11 21:23:06 +02:00
|
|
|
let verification: VerifiedRegistrationResponse;
|
2021-06-14 16:09:40 +02:00
|
|
|
try {
|
2021-09-11 21:23:06 +02:00
|
|
|
const opts: VerifyRegistrationResponseOpts = {
|
2021-06-14 16:09:40 +02:00
|
|
|
credential,
|
|
|
|
expectedChallenge,
|
|
|
|
expectedOrigin: this.expectedOrigin,
|
|
|
|
expectedRPID: this.rpID
|
|
|
|
};
|
2021-09-11 21:23:06 +02:00
|
|
|
verification = await verifyRegistrationResponse(opts);
|
2021-06-14 16:09:40 +02:00
|
|
|
} catch (error) {
|
|
|
|
console.error(error);
|
|
|
|
throw new InternalServerErrorException(error.message);
|
|
|
|
}
|
|
|
|
|
2021-09-11 21:23:06 +02:00
|
|
|
const { registrationInfo, verified } = verification;
|
2021-06-14 16:09:40 +02:00
|
|
|
|
|
|
|
const devices = await this.deviceService.authDevices({
|
|
|
|
where: { userId: user.id }
|
|
|
|
});
|
2021-09-11 21:23:06 +02:00
|
|
|
if (registrationInfo && verified) {
|
|
|
|
const { counter, credentialID, credentialPublicKey } = registrationInfo;
|
2021-06-14 16:09:40 +02:00
|
|
|
|
|
|
|
let existingDevice = devices.find(
|
|
|
|
(device) => device.credentialId === credentialID
|
|
|
|
);
|
|
|
|
|
|
|
|
if (!existingDevice) {
|
|
|
|
/**
|
|
|
|
* Add the returned device to the user's list of devices
|
|
|
|
*/
|
|
|
|
existingDevice = await this.deviceService.createAuthDevice({
|
2021-09-11 21:23:06 +02:00
|
|
|
counter,
|
2021-06-14 16:09:40 +02:00
|
|
|
credentialPublicKey,
|
|
|
|
credentialId: credentialID,
|
|
|
|
User: { connect: { id: user.id } }
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
|
|
return {
|
|
|
|
createdAt: existingDevice.createdAt.toISOString(),
|
|
|
|
id: existingDevice.id
|
|
|
|
};
|
|
|
|
}
|
|
|
|
|
|
|
|
throw new InternalServerErrorException('An unknown error occurred');
|
|
|
|
}
|
|
|
|
|
|
|
|
public async generateAssertionOptions(deviceId: string) {
|
|
|
|
const device = await this.deviceService.authDevice({ id: deviceId });
|
|
|
|
|
|
|
|
if (!device) {
|
|
|
|
throw new Error('Device not found');
|
|
|
|
}
|
|
|
|
|
2021-09-11 21:23:06 +02:00
|
|
|
const opts: GenerateAuthenticationOptionsOpts = {
|
2021-06-14 16:09:40 +02:00
|
|
|
allowCredentials: [
|
|
|
|
{
|
|
|
|
id: device.credentialId,
|
2021-09-11 21:23:06 +02:00
|
|
|
transports: ['internal'],
|
|
|
|
type: 'public-key'
|
2021-06-14 16:09:40 +02:00
|
|
|
}
|
|
|
|
],
|
2021-09-11 21:23:06 +02:00
|
|
|
rpID: this.rpID,
|
|
|
|
timeout: 60000,
|
|
|
|
userVerification: 'preferred'
|
2021-06-14 16:09:40 +02:00
|
|
|
};
|
|
|
|
|
2021-09-11 21:23:06 +02:00
|
|
|
const options = generateAuthenticationOptions(opts);
|
2021-06-14 16:09:40 +02:00
|
|
|
|
|
|
|
await this.userService.updateUser({
|
|
|
|
data: {
|
|
|
|
authChallenge: options.challenge
|
|
|
|
},
|
|
|
|
where: {
|
|
|
|
id: device.userId
|
|
|
|
}
|
|
|
|
});
|
|
|
|
|
|
|
|
return options;
|
|
|
|
}
|
|
|
|
|
|
|
|
public async verifyAssertion(
|
|
|
|
deviceId: string,
|
|
|
|
credential: AssertionCredentialJSON
|
|
|
|
) {
|
|
|
|
const device = await this.deviceService.authDevice({ id: deviceId });
|
|
|
|
|
|
|
|
if (!device) {
|
|
|
|
throw new Error('Device not found');
|
|
|
|
}
|
|
|
|
|
|
|
|
const user = await this.userService.user({ id: device.userId });
|
|
|
|
|
2021-09-11 21:23:06 +02:00
|
|
|
let verification: VerifiedAuthenticationResponse;
|
2021-06-14 16:09:40 +02:00
|
|
|
try {
|
2021-09-11 21:23:06 +02:00
|
|
|
const opts: VerifyAuthenticationResponseOpts = {
|
2021-06-14 16:09:40 +02:00
|
|
|
credential,
|
|
|
|
authenticator: {
|
|
|
|
credentialID: device.credentialId,
|
|
|
|
credentialPublicKey: device.credentialPublicKey,
|
|
|
|
counter: device.counter
|
2021-09-11 21:23:06 +02:00
|
|
|
},
|
|
|
|
expectedChallenge: `${user.authChallenge}`,
|
|
|
|
expectedOrigin: this.expectedOrigin,
|
|
|
|
expectedRPID: this.rpID
|
2021-06-14 16:09:40 +02:00
|
|
|
};
|
2021-09-11 21:23:06 +02:00
|
|
|
verification = verifyAuthenticationResponse(opts);
|
2021-06-14 16:09:40 +02:00
|
|
|
} catch (error) {
|
|
|
|
console.error(error);
|
|
|
|
throw new InternalServerErrorException({ error: error.message });
|
|
|
|
}
|
|
|
|
|
2021-09-11 21:23:06 +02:00
|
|
|
const { verified, authenticationInfo } = verification;
|
2021-06-14 16:09:40 +02:00
|
|
|
|
|
|
|
if (verified) {
|
2021-09-11 21:23:06 +02:00
|
|
|
device.counter = authenticationInfo.newCounter;
|
2021-06-14 16:09:40 +02:00
|
|
|
|
|
|
|
await this.deviceService.updateAuthDevice({
|
|
|
|
data: device,
|
|
|
|
where: { id: device.id }
|
|
|
|
});
|
|
|
|
|
|
|
|
return this.jwtService.sign({
|
|
|
|
id: user.id
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
|
|
throw new Error();
|
|
|
|
}
|
|
|
|
}
|