diff --git a/api-gateway/src/api/service/account.ts b/api-gateway/src/api/service/account.ts index 0fc43f2efa..3272813a96 100644 --- a/api-gateway/src/api/service/account.ts +++ b/api-gateway/src/api/service/account.ts @@ -2,8 +2,8 @@ import { IAuthUser, NotificationHelper, PinoLogger } from '@guardian/common'; import { Permissions, PolicyStatus, SchemaEntity, UserRole } from '@guardian/interfaces'; import { ClientProxy } from '@nestjs/microservices'; import { Body, Controller, Get, Headers, HttpCode, HttpException, HttpStatus, Inject, Post, Req } from '@nestjs/common'; -import { ApiBearerAuth, ApiBody, ApiExtraModels, ApiInternalServerErrorResponse, ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger'; -import { AccountsResponseDTO, AccountsSessionResponseDTO, AggregatedDTOItem, BalanceResponseDTO, ChangePasswordDTO, InternalServerErrorDTO, LoginUserDTO, RegisterUserDTO } from '#middlewares'; +import { ApiBearerAuth, ApiBody, ApiExtraModels, ApiInternalServerErrorResponse, ApiOkResponse, ApiOperation, ApiTags, getSchemaPath } from '@nestjs/swagger'; +import { AccountsResponseDTO, AccountsSessionResponseDTO, AggregatedDTOItem, BalanceResponseDTO, ChangePasswordDTO, InternalServerErrorDTO, LoginUserDTO, RegisterUserDTO, GenerateOPTResponseDTO, EmptyResponseDTO, LoginSuccessResponseDTO, LoginOTPRequiredResponseDTO, OTPConfirmDTO, OTPConfirmResponseDTO, ObjectExamples, OTPStatusResponseDTO } from '#middlewares'; import { Auth, AuthUser, checkPermission } from '#auth'; import { EntityOwner, Guardians, InternalException, PolicyEngine, UseCache, Users } from '#helpers'; import { PolicyListResponse } from '../../entities/policy'; @@ -126,8 +126,22 @@ export class AccountApi { summary: 'Logs user into the system.', }) @ApiOkResponse({ - description: 'Successful operation.', - type: AccountsSessionResponseDTO + schema: { + oneOf: [ + { $ref: getSchemaPath(LoginSuccessResponseDTO) }, + { $ref: getSchemaPath(LoginOTPRequiredResponseDTO) } + ] + }, + examples: { + success: { + summary: 'Successful response', + value: ObjectExamples.LOGIN_SUCCESSFUL + }, + otpRequired: { + summary: 'OTP required', + value: ObjectExamples.OTP_REQUIRED_RESPONSE + } + } }) @ApiInternalServerErrorResponse({ description: 'Internal server error.', @@ -139,9 +153,9 @@ export class AccountApi { @Body() body: LoginUserDTO ): Promise { try { - const { username, password } = body; + const { username, password, otp } = body; const users = new Users(); - return await users.generateNewToken(username, password); + return await users.generateNewToken(username, password, otp); } catch (error) { await this.logger.warn(error.message, ['API_GATEWAY'], null); throw new HttpException(error.message, error.code || HttpStatus.UNAUTHORIZED); @@ -385,4 +399,141 @@ export class AccountApi { await InternalException(error, this.logger, user.id); } } + + /** + * Generate an OTP secret for 2FA setup + */ + @Post('otp/generate') + @Auth() + @ApiOperation({ + summary: 'Generate an OTP secret for 2FA setup.', + description: 'Generate an OTP secret for 2FA setup.', + }) + @ApiOkResponse({ + description: 'Successful operation.', + type: GenerateOPTResponseDTO, + }) + @ApiInternalServerErrorResponse({ + description: 'Internal server error.', + type: InternalServerErrorDTO, + }) + @HttpCode(HttpStatus.CREATED) + async generateOtp(@AuthUser() user: IAuthUser,) { + const users = new Users(); + try { + const code = await users.otpGenerateSecret(user.id); + + return code + + } catch (error) { + await this.logger.error(error.message, ['API_GATEWAY']); + throw new HttpException(error.message, error.code || HttpStatus.INTERNAL_SERVER_ERROR); + } + } + + /** + * Confirm OTP setup + */ + @Post('otp/confirm') + @Auth() + @ApiOperation({ + summary: 'Confirm OTP setup.', + description: 'Confirm OTP setup by OTP token.', + }) + @ApiBody({ + description: 'Configuration.', + type: OTPConfirmDTO, + required: true + }) + @ApiOkResponse({ + description: 'Successful operation.', + type: OTPConfirmResponseDTO, + }) + @ApiInternalServerErrorResponse({ + description: 'Internal server error.', + type: InternalServerErrorDTO, + }) + @HttpCode(HttpStatus.CREATED) + async confirmOtp( + @AuthUser() user: IAuthUser, + @Body() body: OTPConfirmDTO + ) { + const users = new Users(); + try { + const token = body.token; + const result = await users.otpConfirmSecret(user.id, token); + + return result; + + } catch (error) { + await this.logger.error(error.message, ['API_GATEWAY']); + throw new HttpException(error.message, error.code || HttpStatus.INTERNAL_SERVER_ERROR); + } + } + + /** + * Get OTP status + */ + @Get('otp/status') + @Auth() + @ApiOperation({ + summary: 'Get OTP status.', + description: 'Get OTP status for the current user.', + }) + @ApiOkResponse({ + description: 'Successful operation.', + type: OTPStatusResponseDTO + }) + @ApiInternalServerErrorResponse({ + description: 'Internal server error.', + type: InternalServerErrorDTO, + }) + @HttpCode(HttpStatus.OK) + async getOtpStatus( + @AuthUser() user: IAuthUser, + ) { + const users = new Users(); + try { + const result = await users.otpGetStatus(user.id); + + return result; + + } catch (error) { + await this.logger.error(error.message, ['API_GATEWAY']); + throw new HttpException(error.message, error.code || HttpStatus.INTERNAL_SERVER_ERROR); + } + } + + /** + * Deactivate 2FA + */ + @Post('otp/deactivate') + @Auth() + @ApiOperation({ + summary: 'Deactivate 2FA.', + description: 'Deactivate 2FA.', + }) + @ApiOkResponse({ + description: 'Successful operation.', + type: EmptyResponseDTO, + }) + @ApiInternalServerErrorResponse({ + description: 'Internal server error.', + type: InternalServerErrorDTO, + }) + @HttpCode(HttpStatus.CREATED) + async deactivateOtp( + @AuthUser() user: IAuthUser, + ) { + const users = new Users(); + try { + const result = await users.otpDeactivate(user.id); + + return result; + + } catch (error) { + await this.logger.error(error.message, ['API_GATEWAY']); + throw new HttpException(error.message, error.code || HttpStatus.INTERNAL_SERVER_ERROR); + } + } } diff --git a/api-gateway/src/helpers/users.ts b/api-gateway/src/helpers/users.ts index b1045d74a4..b973bef156 100644 --- a/api-gateway/src/helpers/users.ts +++ b/api-gateway/src/helpers/users.ts @@ -175,9 +175,10 @@ export class Users extends NatsService { * Register new token * @param username * @param password + * @param otp */ - public async generateNewToken(username: string, password: string): Promise { - return await this.sendMessage(AuthEvents.GENERATE_NEW_TOKEN, { username, password }); + public async generateNewToken(username: string, password: string, otp?: string): Promise { + return await this.sendMessage(AuthEvents.GENERATE_NEW_TOKEN, { username, password, otp }); } public async generateNewAccessToken(refreshToken: string): Promise { @@ -447,6 +448,22 @@ export class Users extends NatsService { ): Promise { return await this.sendMessage(AuthEvents.GENERATE_RELAYER_ACCOUNT, { user }); } + + public async otpGenerateSecret(userId: string) { + return await this.sendMessage(AuthEvents.OTP_GENERATE_SECRET, { userId }); + } + + public async otpConfirmSecret(userId: any, token: string) { + return await this.sendMessage(AuthEvents.OTP_CONFIRM_SECRET, { userId, token }); + } + + public async otpGetStatus(userId: string) { + return await this.sendMessage(AuthEvents.OTP_GET_STATUS, { userId }); + } + + public async otpDeactivate(userId: string) { + return await this.sendMessage(AuthEvents.OTP_DEACTIVATE, { userId }); + } } @Injectable() diff --git a/api-gateway/src/middlewares/validation/examples.ts b/api-gateway/src/middlewares/validation/examples.ts index 189cae2832..1324cc4ac7 100644 --- a/api-gateway/src/middlewares/validation/examples.ts +++ b/api-gateway/src/middlewares/validation/examples.ts @@ -8,4 +8,29 @@ export enum Examples { COLOR = '#000000', DID = '#did:hedera:testnet:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA_0.0.0000001', HASH = 'GcDE9NsPJc7oCZvSVJySCZHxTxvjc3ZAMgtKozP1r1Eh', + OTP_NAME = 'OS Guardian', + USER_NAME_SR_1 = 'StandardRegistry', + OTP_SECRET = 'AAA0AA0A0A00A000', + OTP_AUTH_URL = 'otpauth://totp/OS%20Guardian:StandardRegistry?issuer=OS+Guardian&period=30&secret=XXX0XX0X0X00X000', + OTP_ALGO = 'sha1', + NUMBER = 111, + OTP_CODE = '111111', + TOKEN = 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImIxM2E0NTkyLWU2YjQtNDg0OS1hMTkxLTAxOWVkODNkYzM5ZCIsIm5hbWUiOiJTdGFuZGFyZFJlZ2lzdHJ5IiwiZXhwaXJlQXQiOjE4MDUyODI4NjU3MDEsImlhdCI6MTc3Mzc0Njg2NX0.BsKje1bza0NEKKTAHFMRfwa3H-H-eRu7-KEDHKTftljXE3eQNmYCf_ftaPpw3DdsfsavBcEDfs5UQwlyeMsaTJPehEx_gl697rGQx6b8objGkqfFL2A7nWetMbWtxFFsIrbxs4mqHy1LM_4VVJuiXsH2DYQZkxOmw4HdyUshjE84', + ROLE_SR = 'STANDARD_REGISTRY', + ROLE_USER = 'USER' +} + +export const ObjectExamples = { + LOGIN_SUCCESSFUL: { + did: Examples.DID, + refreshToken: Examples.TOKEN, + role: Examples.ROLE_SR, + username: Examples.USER_NAME_SR_1, + weakPassword: false + }, + + OTP_REQUIRED_RESPONSE: { + success: false, + otprequired: true + } } \ No newline at end of file diff --git a/api-gateway/src/middlewares/validation/schemas/accounts.ts b/api-gateway/src/middlewares/validation/schemas/accounts.ts index cb8ff6f5ad..c79359d83f 100644 --- a/api-gateway/src/middlewares/validation/schemas/accounts.ts +++ b/api-gateway/src/middlewares/validation/schemas/accounts.ts @@ -1,10 +1,11 @@ import * as yup from 'yup'; import fieldsValidation from '../fields-validation.js' -import { IsIn, IsNotEmpty, IsString } from 'class-validator'; +import { IsIn, IsNotEmpty, IsString, IsOptional, IsNumber, IsBoolean, IsArray } from 'class-validator'; import { UserRole } from '@guardian/interfaces'; -import { Expose } from 'class-transformer'; +import { Expose, Type } from 'class-transformer'; import { ApiProperty } from '@nestjs/swagger'; import { Match } from '../../../helpers/decorators/match.validator.js'; +import { Examples } from '../../../middlewares/validation/examples.js'; export class AccountsResponseDTO { @ApiProperty() @@ -40,6 +41,66 @@ export class AccountsSessionResponseDTO { accessToken: string } +export class LoginSuccessResponseDTO { + @ApiProperty({ + type: String, + required: true, + example: Examples.DID + }) + @IsString() + did: string; + + @ApiProperty({ + type: String, + required: true, + example: Examples.TOKEN + }) + @IsString() + refreshToken: string; + + @ApiProperty({ + type: String, + required: true, + example: Examples.ROLE_SR + }) + @IsString() + role: string + + @ApiProperty({ + type: String, + required: true, + example: Examples.USER_NAME_SR_1 + }) + @IsString() + username: string + + @ApiProperty({ + type: String, + required: true, + example: false + }) + @IsString() + weakPassword: string +} + +export class LoginOTPRequiredResponseDTO { + @ApiProperty({ + type: Boolean, + required: true, + example: false + }) + @IsBoolean() + success: boolean; + + @ApiProperty({ + type: Boolean, + required: true, + example: true + }) + @IsBoolean() + otprequired: boolean; +} + export class ChangePasswordDTO { @ApiProperty() @IsString() @@ -67,6 +128,11 @@ export class LoginUserDTO { @IsString() @IsNotEmpty() password: string; + + @ApiProperty() + @IsString() + @IsOptional() + otp: string; } export class RegisterUserDTO { @@ -138,6 +204,124 @@ export class BalanceResponseDTO { user: UserAccountDTO; } +export class OTPConfigDTO { + @ApiProperty({ + type: String, + required: true, + example: Examples.OTP_ALGO + }) + @IsString() + algo: string; + + @ApiProperty({ + type: Number, + required: true, + example: Examples.NUMBER + }) + @IsNumber() + digits: number; + + @ApiProperty({ + type: Number, + required: true, + example: Examples.NUMBER + }) + @IsNumber() + period: number; + + @ApiProperty({ + type: Number, + required: true, + example: Examples.NUMBER + }) + @IsNumber() + secretSize: number; +} + +export class GenerateOPTResponseDTO { + @ApiProperty({ + type: String, + required: true, + example: Examples.OTP_NAME + }) + @IsString() + issuer: string; + + @ApiProperty({ + type: String, + required: true, + example: Examples.USER_NAME_SR_1 + }) + @IsString() + user: string; + + @ApiProperty({ + type: String, + required: true, + example: Examples.OTP_SECRET + }) + @IsString() + secret: string; + + @ApiProperty({ + type: String, + required: true, + example: Examples.OTP_AUTH_URL + }) + @IsString() + url: string; + + @ApiProperty({ + type: OTPConfigDTO, + required: true, + }) + @Type(() => OTPConfigDTO) + config: OTPConfigDTO; +} + +export class OTPConfirmDTO { + @ApiProperty({ + type: String, + required: true, + example: Examples.OTP_CODE + }) + @IsString() + token: string; +} + +export class OTPConfirmResponseDTO { + @ApiProperty({ + type: Boolean, + required: true, + example: true + }) + @IsBoolean() + success: boolean; + + @ApiProperty({ + type: String, + required: true, + isArray: true, + example: ['000000', '111111', '222222', '333333', '444444', '555555', '666666', '777777', '888888', '999999'] + }) + @IsArray() + @IsString({ each: true }) + backupCodes: string[]; +} + +export class OTPStatusResponseDTO { + @ApiProperty({ + type: Boolean, + required: true, + example: true + }) + @IsBoolean() + enabled: boolean; +} + +export class EmptyResponseDTO { +} + export const registerSchema = () => { const { username, password, password_confirmation, role } = fieldsValidation return yup.object({ diff --git a/auth-service/package.json b/auth-service/package.json index 4bb872b564..bad9f7171d 100644 --- a/auth-service/package.json +++ b/auth-service/package.json @@ -18,9 +18,11 @@ "@nestjs/common": "^11.0.11", "@nestjs/core": "^11.0.11", "@nestjs/microservices": "^11.0.11", + "@sendgrid/mail": "^7.7.0", "axios": "^1.8.3", "base-x": "^4.0.0", "base64url": "^3.0.1", + "cron": "^2.4.0", "dotenv": "^16.0.0", "express": "^5.1.0", "gulp": "^5.0.0", @@ -28,12 +30,17 @@ "gulp-sourcemaps": "^3.0.0", "gulp-typescript": "^6.0.0-alpha.1", "jsonwebtoken": "^8.5.1", + "moment": "^2.29.4", + "moment-timezone": "^0.5.45", + "node-quickbooks": "^2.0.43", "node-vault": "^0.10.0", "pako": "^2.1.0", "prom-client": "^14.1.1", "prometheus-api-metrics": "4.0.0", "reflect-metadata": "^0.1.13", - "rxjs": "^7.8.1" + "rxjs": "^7.8.1", + "time2fa": "^1.4.2", + "ts-enum-util": "^4.0.2" }, "description": "", "devDependencies": { diff --git a/auth-service/src/api/account-service.ts b/auth-service/src/api/account-service.ts index 594bf7ddcc..234641c3bf 100644 --- a/auth-service/src/api/account-service.ts +++ b/auth-service/src/api/account-service.ts @@ -1,5 +1,5 @@ import { User } from '../entity/user.js'; -import { DatabaseServer, MessageError, MessageResponse, NatsService, PinoLogger, ProviderAuthUser, Singleton } from '@guardian/common'; +import { DatabaseServer, MessageError, MessageResponse, NatsService, PinoLogger, ProviderAuthUser, Singleton, DataBaseHelper } from '@guardian/common'; import { AuthEvents, GenerateUUIDv4, @@ -23,6 +23,7 @@ import { import { UserUtils, UserPassword, PasswordType, UserAccessTokenService, UserProp } from '#utils'; import { passwordComplexity, PasswordError } from '#constants'; import { HttpStatus } from '@nestjs/common'; +import { OtpHelper } from '../helpers/otp-helper.js'; /** * Account service @@ -351,13 +352,19 @@ export class AccountService extends NatsService { }); this.getMessages(AuthEvents.GENERATE_NEW_TOKEN, - async (msg: { username: string, password: string }) => { + async (msg: { username: string, password: string, otp: string }) => { try { - const { username, password } = msg; + const { username, password, otp } = msg; const user = await UserUtils.getUser({ username, template: { $ne: true } }, UserProp.RAW); if (user) { if (user.passwordVersion === PasswordType.V2) { if (await UserPassword.verifyPasswordV2(user, password)) { + if (await OtpHelper.isConfiguredFor(user) && !otp) { + return new MessageResponse({ success: false, otprequired: true }); + } + if (!await OtpHelper.checkOtp(user, otp)) { + return new MessageError('OTP not valid'); + } const userAccessTokenService = await UserAccessTokenService.New(); const token = userAccessTokenService.generateRefreshToken(user); if (!Array.isArray(user.refreshToken)) { @@ -538,5 +545,80 @@ export class AccountService extends NatsService { return new MessageError(error); } }); + + this.getMessages(AuthEvents.OTP_GENERATE_SECRET, async (msg) => { + try { + const { userId } = msg; + + const user = await new DataBaseHelper(User).findOne({ id: userId }); + if (!user) { + return new MessageError('Invalid user'); + } + + const key = await OtpHelper.generateNewSecretFor(user); + + return new MessageResponse(key); + } catch (error) { + await logger.error(error, ['AUTH_SERVICE', 'OTP_GENERATE_SECRET']); + return new MessageError(error); + } + }); + + // + this.getMessages(AuthEvents.OTP_CONFIRM_SECRET, async (msg) => { + try { + const { userId, token } = msg; + + const user = await new DataBaseHelper(User).findOne({ id: userId }); + if (!user) { + return new MessageError('Invalid user'); + } + const result = await OtpHelper.confirmNewSecret(user, token); + if (result) { + const codes = await OtpHelper.generateBackupCodes(user); + return new MessageResponse({ success: true, backupCodes: codes }); + } + else { + return new MessageResponse({ success: false }); + } + } catch (error) { + await logger.error(error, ['AUTH_SERVICE', 'OTP_GENERATE_SECRET']); + return new MessageError(error); + } + }); + + this.getMessages(AuthEvents.OTP_GET_STATUS, async (msg) => { + try { + const { userId } = msg; + + const user = await new DataBaseHelper(User).findOne({ id: userId }); + if (!user) { + return new MessageError('Invalid user'); + } + const result = await OtpHelper.isConfiguredFor(user); + + return new MessageResponse({ enabled: result }); + } catch (error) { + await logger.error(error, ['AUTH_SERVICE', 'OTP_GET_STATUS']); + return new MessageError(error); + } + }); + + this.getMessages(AuthEvents.OTP_DEACTIVATE, async (msg) => { + try { + const { userId } = msg; + + const user = await new DataBaseHelper(User).findOne({ id: userId }); + if (!user) { + return new MessageError('Invalid user'); + } + const result = await OtpHelper.deactivate(user); + + return new MessageResponse({ enabled: result }); + } catch (error) { + await logger.error(error, ['AUTH_SERVICE', 'OTP_DEACTIVATE']); + return new MessageError(error); + } + }); } } diff --git a/auth-service/src/entity/otp-secret.ts b/auth-service/src/entity/otp-secret.ts new file mode 100644 index 0000000000..4d6120c812 --- /dev/null +++ b/auth-service/src/entity/otp-secret.ts @@ -0,0 +1,39 @@ +import { Entity, Property} from '@mikro-orm/core'; +import { BaseEntity } from '@guardian/common'; + +/** + * Invite collection + */ +@Entity() +export class OtpSecret extends BaseEntity { + /** + * User Id + */ + @Property({ nullable: true }) + userId: string; + + /** + * Otp Secret + */ + @Property({ nullable: true }) + secret: string; + + /** + * Otp Secret + */ + @Property({ nullable: true }) + config: any; + + @Property({ nullable: true }) + backupCodes: string[]; + + @Property() + enabled: boolean; + + /** + * Encripted + */ + @Property() + encrypted: boolean; + +} \ No newline at end of file diff --git a/auth-service/src/helpers/otp-helper.ts b/auth-service/src/helpers/otp-helper.ts new file mode 100644 index 0000000000..72aab41c1b --- /dev/null +++ b/auth-service/src/helpers/otp-helper.ts @@ -0,0 +1,118 @@ +import { Totp, generateBackupCodes } from 'time2fa'; +import { OtpSecret } from '../entity/otp-secret.js'; +import { DataBaseHelper } from '@guardian/common'; +import { User } from '../entity/user.js'; + +export class OtpHelper { + + private static getFilter(user: User) { + return { userId: user.id } + } + + private static async deleteOtp(user: User): Promise { + await new DataBaseHelper(OtpSecret).delete({ ...OtpHelper.getFilter(user), enabled: true }); + } + + private static async getOtp(user: User): Promise { + return await new DataBaseHelper(OtpSecret).findOne({ ...OtpHelper.getFilter(user), enabled: true }); + } + + private static getAccountName(user: User): string { + return `${user.username}`; + } + + public static async generateNewSecretFor(user: User) { + + //Delete prev temp secret if exists + await new DataBaseHelper(OtpSecret).delete({ ...OtpHelper.getFilter(user), enabled: false }); + + //Generate secret + const key = Totp.generateKey({ issuer: 'OS Guardian', user: OtpHelper.getAccountName(user) }); + const entity = await new DataBaseHelper(OtpSecret).create({ + userId: user.id, + secret: key.secret, + config: key.config, + encrypted: false, + enabled: false + }); + new DataBaseHelper(OtpSecret).save(entity); + + return key; + } + + public static async confirmNewSecret(user: User, token: string): Promise { + const temp = await new DataBaseHelper(OtpSecret).findOne({ ...OtpHelper.getFilter(user), enabled: false }); + if (!temp) { + return false; + } + try { + const valid = Totp.validate({ passcode: token, secret: temp.secret }); + if (!valid) { return false; } + //Delete prev secret if exists + await OtpHelper.deleteOtp(user); + temp.enabled = true; + await new DataBaseHelper(OtpSecret).save(temp); + + } catch (e) { + return false; + } + return true; + } + + public static async generateBackupCodes(user: User): Promise { + const otp = await OtpHelper.getOtp(user); + if (!otp) { return undefined; } + if (otp.backupCodes && otp.backupCodes.length > 0) { throw new Error('Backup codes already cenerated'); } + otp.backupCodes = generateBackupCodes(); + await new DataBaseHelper(OtpSecret).save(otp); + return otp.backupCodes; + } + + public static async isConfiguredFor(user: User): Promise { + const key = await OtpHelper.getOtp(user); + if (key) { return true } + else { return false } + } + + public static async isValidToken(user: User, token: string): Promise { + + const key = await OtpHelper.getOtp(user); + if (!key) { + return false;// not configured + } + + if (!token) { + return false;// configured but not provided + } + + const result = Totp.validate({ passcode: token, secret: key.secret }) + return result; + + } + + /** + * Check user otp if required + * @returns + */ + public static async checkOtp(user: User, token: string): Promise { + const configured = await OtpHelper.isConfiguredFor(user); + if (!configured) { + return true; //Not required - ignore + } + if (!token) { + return false; //Required and empty otp - reject + } + //Validate otp + try { + const isValid = OtpHelper.isValidToken(user, token); + return isValid; + } + catch (e) { + return false; + } + } + + public static async deactivate(user: User): Promise { + await OtpHelper.deleteOtp(user); + } +} \ No newline at end of file diff --git a/common/src/helpers/users.ts b/common/src/helpers/users.ts index 2f7584fd94..c72b60f194 100644 --- a/common/src/helpers/users.ts +++ b/common/src/helpers/users.ts @@ -364,4 +364,8 @@ export class Users extends NatsService { public async getRemoteUsers(did: string, userId: string | null): Promise { return await this.sendMessage(AuthEvents.GET_REMOTE_USERS, { did, userId }); } + + public async otpGenerateSecret(userId: string) { + return await this.sendMessage(AuthEvents.OTP_GENERATE_SECRET, { userId }); + } } diff --git a/frontend/src/app/app.module.ts b/frontend/src/app/app.module.ts index d3a6f5ec25..eb33cf5089 100644 --- a/frontend/src/app/app.module.ts +++ b/frontend/src/app/app.module.ts @@ -9,6 +9,7 @@ import { AppRoutingModule, PermissionsGuard } from './app-routing.module'; import { AppComponent } from './app.component'; import { SchemaHelper } from '@guardian/interfaces'; import { CheckboxModule } from 'primeng/checkbox'; +import { ClipboardModule } from '@angular/cdk/clipboard'; //Services import { AuthInterceptor, AuthService } from './services/auth.service'; import { ProfileService } from './services/profile.service'; @@ -61,6 +62,10 @@ import { NotificationsComponent } from './views/notifications/notifications.comp import { RolesViewComponent } from './views/roles/roles-view.component'; import { UsersManagementComponent } from './views/user-management/user-management.component'; import { UsersManagementDetailComponent } from './views/user-management-detail/user-management-detail.component'; +import { OtpDialogComponent } from './views/login/otp-dialog/otp-dialog.component'; +import { OtpConfigDialogComponent } from './views/login/otp-config-dialog/otp-config-dialog.component'; +import { OtpDisableDialogComponent } from './views/login/otp-disable-dialog/otp-disable-dialog.component'; +import { OtpCodesDialogComponent } from './views/login/otp-codes-dialog/otp-codes-dialog.component'; //Components import { InfoComponent } from './components/info/info/info.component'; import { BrandingComponent } from './views/branding/branding.component'; @@ -185,7 +190,11 @@ import { TreeTableModule } from 'primeng/treetable' RelayerAccountsComponent, UsersManagementDetailComponent, WorkerTasksComponent, - UserKeysDialog + UserKeysDialog, + OtpDialogComponent, + OtpConfigDialogComponent, + OtpDisableDialogComponent, + OtpCodesDialogComponent ], exports: [], bootstrap: [AppComponent], @@ -205,7 +214,8 @@ import { TreeTableModule } from 'primeng/treetable' ToastrModule.forRoot(), QRCodeModule, ButtonModule, - InputTextModule, + InputTextModule, + ClipboardModule, SelectButtonModule, DropdownModule, ButtonModule, diff --git a/frontend/src/app/services/auth.service.ts b/frontend/src/app/services/auth.service.ts index 41468debdb..20a4055917 100644 --- a/frontend/src/app/services/auth.service.ts +++ b/frontend/src/app/services/auth.service.ts @@ -21,14 +21,30 @@ export class AuthService { this.refreshTokenSubject = new Subject(); } - public login(username: string, password: string): Observable { - return this.http.post(`${this.url}/login`, { username, password }); + public login(username: string, password: string, otp?: string): Observable { + return this.http.post(`${this.url}/login`, { username, password, otp }); } public changePassword(username: string, oldPassword: string, newPassword: string): Observable { return this.http.post(`${this.url}/change-password`, { username, oldPassword, newPassword }); } + public generateOtpSecret(): Observable { + return this.http.post(`${this.url}/otp/generate`, {}); + } + + public confirmOtpSecret(token: string): Observable { + return this.http.post(`${this.url}/otp/confirm`, { token }); + } + + public getOtpStatus(): Observable { + return this.http.get(`${this.url}/otp/status`); + } + + public deactivateOtp(): Observable { + return this.http.post(`${this.url}/otp/deactivate`, {}); + } + public updateAccessToken(): Observable { return this.http.post(`${this.url}/access-token`, { refreshToken: this.getRefreshToken() }).pipe( map(result => { diff --git a/frontend/src/app/views/login/login.component.ts b/frontend/src/app/views/login/login.component.ts index 93193863ae..fa47ae36d7 100644 --- a/frontend/src/app/views/login/login.component.ts +++ b/frontend/src/app/views/login/login.component.ts @@ -1,26 +1,27 @@ -import {Component, OnDestroy, OnInit, AfterViewChecked, QueryList, ViewChildren, ElementRef} from '@angular/core'; -import {Router} from '@angular/router'; -import {AbstractControl, UntypedFormControl, UntypedFormGroup, ValidationErrors, Validators,} from '@angular/forms'; -import {AuthService} from '../../services/auth.service'; -import {UserCategory, UserRole} from '@guardian/interfaces'; -import {AuthStateService} from 'src/app/services/auth-state.service'; -import {Observable, Subject, Subscription} from 'rxjs'; -import {noWhitespaceValidator} from 'src/app/validators/no-whitespace-validator'; -import {WebSocketService} from 'src/app/services/web-socket.service'; -import {QrCodeDialogComponent} from 'src/app/components/qr-code-dialog/qr-code-dialog.component'; -import {MeecoVCSubmitDialogComponent} from 'src/app/components/meeco-vc-submit-dialog/meeco-vc-submit-dialog.component'; -import {environment} from 'src/environments/environment'; -import {takeUntil} from 'rxjs/operators'; -import {BrandingService} from '../../services/branding.service'; -import {DialogService, DynamicDialogRef} from 'primeng/dynamicdialog'; +import { Component, OnDestroy, OnInit, AfterViewChecked, QueryList, ViewChildren, ElementRef } from '@angular/core'; +import { Router } from '@angular/router'; +import { AbstractControl, UntypedFormControl, UntypedFormGroup, ValidationErrors, Validators, } from '@angular/forms'; +import { AuthService } from '../../services/auth.service'; +import { UserCategory, UserRole } from '@guardian/interfaces'; +import { AuthStateService } from 'src/app/services/auth-state.service'; +import { Observable, Subject, Subscription } from 'rxjs'; +import { noWhitespaceValidator } from 'src/app/validators/no-whitespace-validator'; +import { WebSocketService } from 'src/app/services/web-socket.service'; +import { QrCodeDialogComponent } from 'src/app/components/qr-code-dialog/qr-code-dialog.component'; +import { MeecoVCSubmitDialogComponent } from 'src/app/components/meeco-vc-submit-dialog/meeco-vc-submit-dialog.component'; +import { environment } from 'src/environments/environment'; +import { takeUntil } from 'rxjs/operators'; +import { BrandingService } from '../../services/branding.service'; +import { DialogService, DynamicDialogRef } from 'primeng/dynamicdialog'; import { AccountTypeSelectorDialogComponent } from './register-dialogs/account-type-selector-dialog/account-type-selector-dialog.component'; -import {ForgotPasswordDialogComponent} from './forgot-password-dialog/forgot-password-dialog.component'; -import {RegisterDialogComponent} from './register-dialogs/register-dialog/register-dialog.component'; -import {DemoService} from '../../services/demo.service'; -import {ChangePasswordComponent} from './change-password/change-password.component'; +import { ForgotPasswordDialogComponent } from './forgot-password-dialog/forgot-password-dialog.component'; +import { RegisterDialogComponent } from './register-dialogs/register-dialog/register-dialog.component'; +import { DemoService } from '../../services/demo.service'; +import { ChangePasswordComponent } from './change-password/change-password.component'; import { InformService } from 'src/app/services/inform.service'; +import { OtpDialogComponent } from './otp-dialog/otp-dialog.component'; /** * Login page. @@ -145,18 +146,34 @@ export class LoginComponent implements OnInit, OnDestroy, AfterViewChecked { } } - private login(login: string, password: string) { + private login(login: string, password: string, otp?: string) { this.loading = true; this.wrongNameOrPassword = false; - this.auth.login(login, password) + this.auth.login(login, password, otp) .subscribe((result) => { - this.auth.setRefreshToken(result.refreshToken); - this.auth.setUsername(login); - this.auth.updateAccessToken().subscribe(_result => { - this.authState.updateState(true); - const home = this.auth.home(result.role); - this.router.navigate([home]); - }); + if (result.otprequired) { + this.dialogService.open(OtpDialogComponent, { + header: 'Enter Verification Code', + width: '40vw', + closable: false, + }).onClose.subscribe(token => { + if (token) { + this.login(login, password, token); + } + else { + this.loading = false; + } + }); + } + else { + this.auth.setRefreshToken(result.refreshToken); + this.auth.setUsername(login); + this.auth.updateAccessToken().subscribe(_result => { + this.authState.updateState(true); + const home = this.auth.home(result.role); + this.router.navigate([home]); + }); + } if (result.weakPassword) { this.informService.shortWarnMessage( @@ -164,6 +181,7 @@ export class LoginComponent implements OnInit, OnDestroy, AfterViewChecked { 'Weak Password', ); } + }, (error) => { this.loading = false; this.errorMessage = error.message; @@ -195,7 +213,7 @@ export class LoginComponent implements OnInit, OnDestroy, AfterViewChecked { return; } this.login(userData.username, userData.password); - }, ({error}) => { + }, ({ error }) => { this.error = error.message; this.loading = false; this.brandingLoading = false; @@ -344,7 +362,7 @@ export class LoginComponent implements OnInit, OnDestroy, AfterViewChecked { data: { document: event.vc, presentationRequestId: - event.presentation_request_id, + event.presentation_request_id, submissionId: event.submission_id, userRole: event.role, }, diff --git a/frontend/src/app/views/login/otp-codes-dialog/otp-codes-dialog.component.html b/frontend/src/app/views/login/otp-codes-dialog/otp-codes-dialog.component.html new file mode 100644 index 0000000000..1bfeb29190 --- /dev/null +++ b/frontend/src/app/views/login/otp-codes-dialog/otp-codes-dialog.component.html @@ -0,0 +1,18 @@ +
+
If you lose access to your authentication app, use your recovery codes to restore your Guardian + account. Each code can be used only once!
+
+
{{code}}
+
+
+ + +
+
+ \ No newline at end of file diff --git a/frontend/src/app/views/login/otp-codes-dialog/otp-codes-dialog.component.scss b/frontend/src/app/views/login/otp-codes-dialog/otp-codes-dialog.component.scss new file mode 100644 index 0000000000..3d0c196f85 --- /dev/null +++ b/frontend/src/app/views/login/otp-codes-dialog/otp-codes-dialog.component.scss @@ -0,0 +1,140 @@ +form { + width: 100%; + position: relative; +} + +.codes { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; + margin: 15px; + font-size: 24px; + & > div { + text-align: center; + } +} + +button .pi { + margin-right: 8px; +} + +.download-actions { + display: flex; + flex-direction: row; + gap: 8px; + +} + +.dialog-body { + position: relative; + height: auto; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 10px; + margin-bottom: 24px; + + .form-input-container { + display: flex; + flex-direction: column; + width: 100%; + margin-bottom: 24px; + } + + .p-field-label { + color: var(--color-grey-black-1, #181818); + font-family: Inter, sans-serif; + font-size: 12px; + font-style: normal; + font-weight: 500; + line-height: 14px; + margin-bottom: 6px; + } + + .p-field-input { + width: 100%; + border-radius: 8px; + border: 1px solid var(--color-grey-3, #e1e7ef); + background: var(--color-grey-white, #fff); + outline: none !important; + box-shadow: unset !important; + padding: 12px 0 12px 16px; + align-items: center; + align-self: stretch; + transition: border 0.25s ease-in-out; + box-sizing: border-box; + resize: vertical; + + color: var(--color-grey-black-1, #181818); + font-family: Inter, sans-serif; + font-size: 24px; + font-style: normal; + font-weight: 400; + line-height: 16px; + } + + .primary-text-button { + color: var(--color-primary, #4169e2); + font-family: Inter, sans-serif; + font-size: 14px; + font-style: normal; + font-weight: 500; + line-height: 30px; + cursor: pointer; + } +} + +.primary-text-button { + color: var(--color-primary, #4169e2); + font-family: Inter, sans-serif; + font-size: 14px; + font-style: normal; + font-weight: 500; + line-height: 30px; + cursor: pointer; +} + +:host ::ng-deep .p-button:focus { + box-shadow: unset !important; +} + +.action-buttons { + display: flex; + align-items: center; + button { + width: 100%; + } +} + +.cancel-button { + margin-left: auto; + margin-right: 15px; +} + +.dialog-footer { + border-top: 1px solid var(--guardian-border-color); + padding-top: 20px; +} + +::ng-deep { + .p-dialog-title { + font-family: Poppins, sans-serif; + font-size: 24px !important; + font-style: normal; + font-weight: 600 !important; + } + + .p-dialog { + box-shadow: none; + } + + .p-dialog-header { + border-top-left-radius: 16px !important; + border-top-right-radius: 16px !important; + } + + .p-dialog-content { + border-bottom-left-radius: 16px; + border-bottom-right-radius: 16px; + } +} \ No newline at end of file diff --git a/frontend/src/app/views/login/otp-codes-dialog/otp-codes-dialog.component.ts b/frontend/src/app/views/login/otp-codes-dialog/otp-codes-dialog.component.ts new file mode 100644 index 0000000000..07c9f42ad7 --- /dev/null +++ b/frontend/src/app/views/login/otp-codes-dialog/otp-codes-dialog.component.ts @@ -0,0 +1,57 @@ +import { Component, OnInit } from '@angular/core'; +import { UntypedFormControl, UntypedFormGroup, Validators } from '@angular/forms'; +import { Router } from '@angular/router'; +import { AuthStateService } from 'src/app/services/auth-state.service'; +import { AuthService } from 'src/app/services/auth.service'; +import { noWhitespaceValidator } from 'src/app/validators/no-whitespace-validator'; +import { DynamicDialogConfig, DynamicDialogRef } from 'primeng/dynamicdialog'; +import { ToastrService } from "ngx-toastr"; + +/** + * Registration page. + */ +@Component({ + selector: 'app-otp-codes-dialog', + templateUrl: './otp-codes-dialog.component.html', + styleUrls: ['./otp-codes-dialog.component.scss'] +}) +export class OtpCodesDialogComponent implements OnInit { + public codes: string[] | undefined; + public codesText: string; + constructor( + private dialogRef: DynamicDialogRef, + private dialogConfig: DynamicDialogConfig, + private toastr: ToastrService, + ) { + this.codes = this.dialogConfig.data?.codes; + this.codesText = this.codes?.join('\t') || ''; + } + + ngOnInit() { + } + + onCancelClick() { + this.dialogRef.close(false); + } + + onOkClick() { + this.dialogRef.close(true); + } + + onCopy() { + this.toastr.success('Codes copied'); + } + + saveToFile() { + const downloadLink = document.createElement('a'); + downloadLink.href = window.URL.createObjectURL( + new Blob([Uint8Array.from(new TextEncoder().encode(this.codesText))], { + type: 'application/text' + }) + ); + downloadLink.setAttribute('download', `mgs_recovery_codes.txt`); + document.body.appendChild(downloadLink); + downloadLink.click(); + downloadLink.remove(); + } +} \ No newline at end of file diff --git a/frontend/src/app/views/login/otp-config-dialog/otp-config-dialog.component.html b/frontend/src/app/views/login/otp-config-dialog/otp-config-dialog.component.html new file mode 100644 index 0000000000..4ca609f0f3 --- /dev/null +++ b/frontend/src/app/views/login/otp-config-dialog/otp-config-dialog.component.html @@ -0,0 +1,37 @@ +
+
+
Step 1
+ Use an authentication app (such as Google Authenticator or another) on your mobile device to scan this QR + code. + +
+ +
+
If the scan isn't working, manually enter this key into your authentication app.
+
{{config.secret}} +
+ +
+ +
+
Step 2
+ Enter the code given by the app on your mobile device. +
+
+
+ + +
Wrong or expired code! Please enter the code again to confirm setup. +
+
+ +
+
+ \ No newline at end of file diff --git a/frontend/src/app/views/login/otp-config-dialog/otp-config-dialog.component.scss b/frontend/src/app/views/login/otp-config-dialog/otp-config-dialog.component.scss new file mode 100644 index 0000000000..6c52d6943f --- /dev/null +++ b/frontend/src/app/views/login/otp-config-dialog/otp-config-dialog.component.scss @@ -0,0 +1,133 @@ +form { + width: 100%; + position: relative; +} + +.dialog-body { + position: relative; + display: flex; + flex-direction: column; + gap: 24px; + + .secret { + color: var(--color-primary); + font-size: 14px; + display: flex; + gap: 8px; + i { + cursor: pointer; + } + } + .error { + color: var(--color-accent-red-1); + } + .input-error { + border-color: var(--color-accent-red-1) !important; + } + .header-step { + font-weight: bold; + } + .code-manual { + display: flex; + flex-direction: column; + gap:8px; + align-items: center; + position: relative; + z-index: 1; + + &::after { + content: ""; + position: absolute; + top: -8px; + bottom: -8px; + left: 0px; + right: 0px; + opacity: 0.1; + border-radius: 8px; + background-color: var(--color-primary); + z-index: -1; + } + } + + .form-input-container { + display: flex; + flex-direction: column; + width: 100%; + gap: 6px; + } + + .p-field-label { + color: var(--color-grey-black-1, #181818); + font-family: Inter, sans-serif; + font-size: 12px; + font-style: normal; + font-weight: 500; + line-height: 14px; + margin-bottom: 6px; + } + + .p-field-input { + width: 100%; + border-radius: 8px; + border: 1px solid var(--color-grey-3, #e1e7ef); + background: var(--color-grey-white, #fff); + outline: none !important; + box-shadow: unset !important; + padding: 12px 0 12px 16px; + align-items: center; + align-self: stretch; + transition: border 0.25s ease-in-out; + box-sizing: border-box; + resize: vertical; + + color: var(--color-grey-black-1, #181818); + font-family: Inter, sans-serif; + font-size: 14px; + font-style: normal; + font-weight: 400; + line-height: 16px; + } +} + +:host ::ng-deep .p-button:focus { + box-shadow: unset !important; +} + +.dialog-body { + height: auto; + overflow-y: auto; +} + +.action-buttons { + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + margin-top: 24px; + .button { + width: 100%; + } +} + +::ng-deep { + .p-dialog-title { + font-family: Poppins, sans-serif; + font-size: 24px !important; + font-style: normal; + font-weight: 600 !important; + } + + .p-dialog { + box-shadow: none; + } + + .p-dialog-header { + border-top-left-radius: 16px !important; + border-top-right-radius: 16px !important; + } + + .p-dialog-content { + border-bottom-left-radius: 16px; + border-bottom-right-radius: 16px; + } +} \ No newline at end of file diff --git a/frontend/src/app/views/login/otp-config-dialog/otp-config-dialog.component.ts b/frontend/src/app/views/login/otp-config-dialog/otp-config-dialog.component.ts new file mode 100644 index 0000000000..9219e8fa4c --- /dev/null +++ b/frontend/src/app/views/login/otp-config-dialog/otp-config-dialog.component.ts @@ -0,0 +1,64 @@ +import { Component, OnInit } from '@angular/core'; +import { UntypedFormControl, UntypedFormGroup, Validators } from '@angular/forms'; +import { AuthStateService } from 'src/app/services/auth-state.service'; +import { AuthService } from 'src/app/services/auth.service'; +import { noWhitespaceValidator } from 'src/app/validators/no-whitespace-validator'; +import { DynamicDialogConfig, DynamicDialogRef } from 'primeng/dynamicdialog'; +import { ToastrService } from "ngx-toastr"; + + +/** + * Registration page. + */ +@Component({ + selector: 'app-config-otp-dialog', + templateUrl: './otp-config-dialog.component.html', + styleUrls: ['./otp-config-dialog.component.scss'] +}) +export class OtpConfigDialogComponent implements OnInit { + public form = new UntypedFormGroup({ + token: new UntypedFormControl('', [ + Validators.required, + Validators.minLength(6), + Validators.maxLength(6), + noWhitespaceValidator(), + ]), + }); + + public config: any; + public tokenError: boolean = false; + + constructor( + private dialogRef: DynamicDialogRef, + private dialogConfig: DynamicDialogConfig, + private auth: AuthService, + private toastr: ToastrService, + private authState: AuthStateService + ) { + this.config = this.dialogConfig.data?.config; + } + + ngOnInit() { + } + secretCopied() { + this.toastr.success('Secret copied'); + } + + onNoClick() { + this.dialogRef.close(null); + } + + onEnable() { + const token = this.form.value.token; + + this.auth.confirmOtpSecret(token).subscribe(result => { + if (!result.success) { + this.tokenError = true; + } + else { + this.tokenError = false; + this.dialogRef.close(result.backupCodes); + } + }); + } +} \ No newline at end of file diff --git a/frontend/src/app/views/login/otp-dialog/otp-dialog.component.html b/frontend/src/app/views/login/otp-dialog/otp-dialog.component.html new file mode 100644 index 0000000000..533d784ef3 --- /dev/null +++ b/frontend/src/app/views/login/otp-dialog/otp-dialog.component.html @@ -0,0 +1,19 @@ +
+
+
+
Enter the verification code generated by your mobile application.
+
+ +
+ + +
+
+
+ \ No newline at end of file diff --git a/frontend/src/app/views/login/otp-dialog/otp-dialog.component.scss b/frontend/src/app/views/login/otp-dialog/otp-dialog.component.scss new file mode 100644 index 0000000000..cee4c61242 --- /dev/null +++ b/frontend/src/app/views/login/otp-dialog/otp-dialog.component.scss @@ -0,0 +1,114 @@ +form { + width: 100%; + position: relative; +} + +.dialog-body { + position: relative; + + .form-input-container { + display: flex; + flex-direction: column; + width: 100%; + margin-bottom: 24px; + } + + .p-field-label { + color: var(--color-grey-black-1, #181818); + font-family: Inter, sans-serif; + font-size: 12px; + font-style: normal; + font-weight: 500; + line-height: 14px; + margin-bottom: 6px; + } + + .p-field-input { + width: 100%; + border-radius: 8px; + border: 1px solid var(--color-grey-3, #e1e7ef); + background: var(--color-grey-white, #fff); + outline: none !important; + box-shadow: unset !important; + padding: 12px 0 12px 16px; + align-items: center; + align-self: stretch; + transition: border 0.25s ease-in-out; + box-sizing: border-box; + resize: vertical; + + color: var(--color-grey-black-1, #181818); + font-family: Inter, sans-serif; + font-size: 16px; + font-style: normal; + font-weight: 400; + line-height: 16px; + } + + .primary-text-button { + color: var(--color-primary, #4169e2); + font-family: Inter, sans-serif; + font-size: 14px; + font-style: normal; + font-weight: 500; + line-height: 30px; + cursor: pointer; + } +} + +.primary-text-button { + color: var(--color-primary, #4169e2); + font-family: Inter, sans-serif; + font-size: 14px; + font-style: normal; + font-weight: 500; + line-height: 30px; + cursor: pointer; +} + +:host ::ng-deep .p-button:focus { + box-shadow: unset !important; +} + +.dialog-body { + height: auto; + overflow-y: auto; +} + +.action-buttons { + display: flex; + flex-direction: column; + gap: 8px; + align-items: center; + button { + width: 100%; + } +} + + +.dialog-footer { + padding-top: 20px; +} + +::ng-deep { + .p-dialog-title { + font-family: Poppins, sans-serif; + font-size: 24px !important; + font-style: normal; + font-weight: 600 !important; + } + + .p-dialog { + box-shadow: none; + } + + .p-dialog-header { + border-top-left-radius: 16px !important; + border-top-right-radius: 16px !important; + } + + .p-dialog-content { + border-bottom-left-radius: 16px; + border-bottom-right-radius: 16px; + } +} \ No newline at end of file diff --git a/frontend/src/app/views/login/otp-dialog/otp-dialog.component.ts b/frontend/src/app/views/login/otp-dialog/otp-dialog.component.ts new file mode 100644 index 0000000000..10bac7daf0 --- /dev/null +++ b/frontend/src/app/views/login/otp-dialog/otp-dialog.component.ts @@ -0,0 +1,50 @@ +import { Component, OnInit } from '@angular/core'; +import { UntypedFormControl, UntypedFormGroup, Validators } from '@angular/forms'; +import { Router } from '@angular/router'; +import { AuthStateService } from 'src/app/services/auth-state.service'; +import { AuthService } from 'src/app/services/auth.service'; +import { noWhitespaceValidator } from 'src/app/validators/no-whitespace-validator'; +import { DynamicDialogConfig, DynamicDialogRef } from 'primeng/dynamicdialog'; + +/** + * Registration page. + */ +@Component({ + selector: 'app-otp-dialog', + templateUrl: './otp-dialog.component.html', + styleUrls: ['./otp-dialog.component.scss'] +}) +export class OtpDialogComponent implements OnInit { + public form = new UntypedFormGroup({ + token: new UntypedFormControl('', [ + Validators.required, + Validators.minLength(6), + Validators.maxLength(6), + noWhitespaceValidator(), + ]), + }); + + + constructor( + private dialogRef: DynamicDialogRef, + private dialogConfig: DynamicDialogConfig, + private auth: AuthService, + private router: Router, + private authState: AuthStateService + ) { + + } + + ngOnInit() { + + } + + onNoClick() { + this.dialogRef.close(null); + } + + onChange() { + const token = this.form.value.token; + this.dialogRef.close(token); + } +} \ No newline at end of file diff --git a/frontend/src/app/views/login/otp-disable-dialog/otp-disable-dialog.component.html b/frontend/src/app/views/login/otp-disable-dialog/otp-disable-dialog.component.html new file mode 100644 index 0000000000..a037255403 --- /dev/null +++ b/frontend/src/app/views/login/otp-disable-dialog/otp-disable-dialog.component.html @@ -0,0 +1,16 @@ +
+
Please confirm that you want to deactivate two-factor authentication.
+
+ Turning off 2-Step Verification will remove the extra security on your account, and you'll only use your + password to sign in.
+
+ \ No newline at end of file diff --git a/frontend/src/app/views/login/otp-disable-dialog/otp-disable-dialog.component.scss b/frontend/src/app/views/login/otp-disable-dialog/otp-disable-dialog.component.scss new file mode 100644 index 0000000000..1efaf3ca04 --- /dev/null +++ b/frontend/src/app/views/login/otp-disable-dialog/otp-disable-dialog.component.scss @@ -0,0 +1,122 @@ +form { + width: 100%; + position: relative; +} + +.button.red { + background-color: var(--color-accent-red-1) !important; + color: white !important; +} + +.dialog-body { + position: relative; + height: auto; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 10px; + margin-bottom:32px; + + .form-input-container { + display: flex; + flex-direction: column; + width: 100%; + margin-bottom: 24px; + } + + .p-field-label { + color: var(--color-grey-black-1, #181818); + font-family: Inter, sans-serif; + font-size: 12px; + font-style: normal; + font-weight: 500; + line-height: 14px; + margin-bottom: 6px; + } + + .p-field-input { + width: 100%; + border-radius: 8px; + border: 1px solid var(--color-grey-3, #e1e7ef); + background: var(--color-grey-white, #fff); + outline: none !important; + box-shadow: unset !important; + padding: 12px 0 12px 16px; + align-items: center; + align-self: stretch; + transition: border 0.25s ease-in-out; + box-sizing: border-box; + resize: vertical; + + color: var(--color-grey-black-1, #181818); + font-family: Inter, sans-serif; + font-size: 24px; + font-style: normal; + font-weight: 400; + line-height: 16px; + } + + .primary-text-button { + color: var(--color-primary, #4169e2); + font-family: Inter, sans-serif; + font-size: 14px; + font-style: normal; + font-weight: 500; + line-height: 30px; + /* 214.286% */ + cursor: pointer; + } +} + +.primary-text-button { + color: var(--color-primary, #4169e2); + font-family: Inter, sans-serif; + font-size: 14px; + font-style: normal; + font-weight: 500; + line-height: 30px; + /* 214.286% */ + cursor: pointer; +} + +:host ::ng-deep .p-button:focus { + box-shadow: unset !important; +} + +.action-buttons { + display: flex; + align-items: center; +} + +.cancel-button { + margin-left: auto; + margin-right: 15px; +} + +.dialog-footer { + border-top: 1px solid var(--guardian-border-color); + padding-top: 20px; +} + +::ng-deep { + .p-dialog-title { + font-family: Poppins, sans-serif; + font-size: 24px !important; + font-style: normal; + font-weight: 600 !important; + } + + .p-dialog { + box-shadow: none; + } + + .p-dialog-header { + border-top-left-radius: 16px !important; + border-top-right-radius: 16px !important; + } + + .p-dialog-content { + border-bottom-left-radius: 16px; + border-bottom-right-radius: 16px; + } +} \ No newline at end of file diff --git a/frontend/src/app/views/login/otp-disable-dialog/otp-disable-dialog.component.ts b/frontend/src/app/views/login/otp-disable-dialog/otp-disable-dialog.component.ts new file mode 100644 index 0000000000..cd7419c0e3 --- /dev/null +++ b/frontend/src/app/views/login/otp-disable-dialog/otp-disable-dialog.component.ts @@ -0,0 +1,36 @@ +import { Component, OnInit } from '@angular/core'; +import { UntypedFormControl, UntypedFormGroup, Validators } from '@angular/forms'; +import { Router } from '@angular/router'; +import { AuthStateService } from 'src/app/services/auth-state.service'; +import { AuthService } from 'src/app/services/auth.service'; +import { noWhitespaceValidator } from 'src/app/validators/no-whitespace-validator'; +import { DynamicDialogConfig, DynamicDialogRef } from 'primeng/dynamicdialog'; + +/** + * Registration page. + */ +@Component({ + selector: 'app-otp-disable-dialog', + templateUrl: './otp-disable-dialog.component.html', + styleUrls: ['./otp-disable-dialog.component.scss'] +}) +export class OtpDisableDialogComponent implements OnInit { + + constructor( + private dialogRef: DynamicDialogRef, + private dialogConfig: DynamicDialogConfig, + ) { + + } + + ngOnInit() { + } + + onCancelClick() { + this.dialogRef.close(false); + } + + onDisableClick() { + this.dialogRef.close(true); + } +} \ No newline at end of file diff --git a/frontend/src/app/views/root-profile/root-profile.component.html b/frontend/src/app/views/root-profile/root-profile.component.html index 8e098adae1..0601a22a70 100644 --- a/frontend/src/app/views/root-profile/root-profile.component.html +++ b/frontend/src/app/views/root-profile/root-profile.component.html @@ -54,6 +54,31 @@

Profile

+
Security Settings
+
+ Two-factor authentication +
+ Active +
+
+ Disabled +
+
+
+ When you sign in, you'll be asked to use the security code provided + by your Authenticator + App. +
+
+
+ + Set up +
+
+ Deactivate +
+
+
Hedera
diff --git a/frontend/src/app/views/root-profile/root-profile.component.scss b/frontend/src/app/views/root-profile/root-profile.component.scss index 2b1ae0966c..089fde2653 100644 --- a/frontend/src/app/views/root-profile/root-profile.component.scss +++ b/frontend/src/app/views/root-profile/root-profile.component.scss @@ -762,4 +762,24 @@ mat-radio-button { :host ::ng-deep .p-tabview-nav-link { text-decoration: none !important; +} + +.otp-status { + &.disabled { + border-color: var(--color-grey-3) !important; + background-color: var(--color-grey-3); + + span { + color: var(--color-grey-6) !important; + } + } + + &.active { + border-color: var(--color-accent-green-2) !important; + background-color: var(--color-accent-green-2); + + span { + color: var(--color-accent-green-1) !important; + } + } } \ No newline at end of file diff --git a/frontend/src/app/views/root-profile/root-profile.component.ts b/frontend/src/app/views/root-profile/root-profile.component.ts index cd5bd350d3..1926e7be24 100644 --- a/frontend/src/app/views/root-profile/root-profile.component.ts +++ b/frontend/src/app/views/root-profile/root-profile.component.ts @@ -18,6 +18,9 @@ import { prepareVcData } from 'src/app/modules/common/models/prepare-vc-data'; import { RelayerAccountsService } from 'src/app/services/relayer-accounts.service'; import { NewRelayerAccountDialog } from 'src/app/components/new-relayer-account-dialog/new-relayer-account-dialog.component'; import { RelayerAccountDetailsDialog } from 'src/app/components/relayer-account-details-dialog/relayer-account-details-dialog.component'; +import { OtpCodesDialogComponent } from '../login/otp-codes-dialog/otp-codes-dialog.component'; +import { OtpConfigDialogComponent } from '../login/otp-config-dialog/otp-config-dialog.component'; +import { OtpDisableDialogComponent } from '../login/otp-disable-dialog/otp-disable-dialog.component'; import moment from 'moment'; enum OperationMode { @@ -59,6 +62,7 @@ export class RootProfileComponent implements OnInit, OnDestroy { public progress: number = 0; public userTopics: any[] = []; public schema!: Schema; + public is2faEnabled = false; public hederaForm = this.fb.group({ hederaAccountId: ['', Validators.required], hederaAccountKey: ['', Validators.required], @@ -182,6 +186,7 @@ export class RootProfileComponent implements OnInit, OnDestroy { ); this.loadProfile(); this.step = 'HEDERA'; + this.refreshOtpStatus(); } ngOnDestroy(): void { @@ -835,4 +840,45 @@ export class RootProfileComponent implements OnInit, OnDestroy { this.updateBalance(row); } } + + refreshOtpStatus() { + this.auth.getOtpStatus().subscribe((result) => { + this.is2faEnabled = result.enabled; + }); + } + + generate2fa() { + this.auth.generateOtpSecret().subscribe(config => { + + this.dialogService.open(OtpConfigDialogComponent, { + header: 'Enable two-factor authentication', + width: '50vw', + closable: false, + data: { config: config } + }).onClose.subscribe((codes) => { + this.refreshOtpStatus(); + if (codes && codes.length) { + this.dialogService.open(OtpCodesDialogComponent, { + header: "Save your recovery codes", + data: { codes: codes } + }); + } + }) + }); + } + + deactivate2fa() { + this.dialogService.open(OtpDisableDialogComponent, { + header: 'Deactivate two-factor authentication', + width: '50vw', + closable: false, + + }).onClose.subscribe(result => { + if (result == true) { + this.auth.deactivateOtp().subscribe(() => { + this.refreshOtpStatus(); + }); + } + }) + } } diff --git a/frontend/src/app/views/user-profile/user-profile.component.html b/frontend/src/app/views/user-profile/user-profile.component.html index f45e1835d1..7e6185552c 100644 --- a/frontend/src/app/views/user-profile/user-profile.component.html +++ b/frontend/src/app/views/user-profile/user-profile.component.html @@ -50,6 +50,31 @@

Profile

+
Security Settings
+
+ Two-factor authentication +
+ Active +
+
+ Disabled +
+
+
+ When you sign in, you'll be asked to use the security code provided + by your Authenticator + App. +
+
+
+ + Set up +
+
+ Deactivate +
+
+
Hedera
Hedera Account ID diff --git a/frontend/src/app/views/user-profile/user-profile.component.scss b/frontend/src/app/views/user-profile/user-profile.component.scss index 5248103e98..beb7f32405 100644 --- a/frontend/src/app/views/user-profile/user-profile.component.scss +++ b/frontend/src/app/views/user-profile/user-profile.component.scss @@ -1243,4 +1243,21 @@ mat-radio-button { :host ::ng-deep .p-tabview-nav-link { text-decoration: none !important; +} + +.otp-status { + &.disabled { + border-color: var(--color-grey-3) !important; + background-color: var(--color-grey-3); + span { + color: var(--color-grey-6) !important; + } + } + &.active{ + border-color: var(--color-accent-green-2) !important; + background-color: var(--color-accent-green-2); + span { + color: var(--color-accent-green-1) !important; + } + } } \ No newline at end of file diff --git a/frontend/src/app/views/user-profile/user-profile.component.ts b/frontend/src/app/views/user-profile/user-profile.component.ts index d5c0e68986..82019ea34f 100644 --- a/frontend/src/app/views/user-profile/user-profile.component.ts +++ b/frontend/src/app/views/user-profile/user-profile.component.ts @@ -22,8 +22,12 @@ import { CustomConfirmDialogComponent } from 'src/app/modules/common/custom-conf import { RelayerAccountsService } from 'src/app/services/relayer-accounts.service'; import { NewRelayerAccountDialog } from 'src/app/components/new-relayer-account-dialog/new-relayer-account-dialog.component'; import { RelayerAccountDetailsDialog } from 'src/app/components/relayer-account-details-dialog/relayer-account-details-dialog.component'; +import { OtpConfigDialogComponent } from '../login/otp-config-dialog/otp-config-dialog.component'; +import { OtpDisableDialogComponent } from '../login/otp-disable-dialog/otp-disable-dialog.component'; +import { OtpCodesDialogComponent } from '../login/otp-codes-dialog/otp-codes-dialog.component'; import moment from 'moment'; + enum OperationMode { None, Generate, @@ -77,6 +81,8 @@ export class UserProfileComponent implements OnInit { public noFilterResults: boolean = false; + public is2faEnabled = false; + public get hasRegistries(): boolean { return this.standardRegistriesList.length > 0; } @@ -461,6 +467,7 @@ export class UserProfileComponent implements OnInit { ); this.loadDate(); this.update(); + this.refreshOtpStatus(); } ngOnDestroy(): void { @@ -1256,4 +1263,45 @@ export class UserProfileComponent implements OnInit { this.updateBalance(row); } } + + refreshOtpStatus() { + this.auth.getOtpStatus().subscribe((result) => { + this.is2faEnabled = result.enabled; + }); + } + + generate2fa() { + this.auth.generateOtpSecret().subscribe(config => { + + this.dialogService.open(OtpConfigDialogComponent, { + header: 'Enable two-factor authentication', + width: '50vw', + closable: false, + data: { config: config } + }).onClose.subscribe((codes) => { + this.refreshOtpStatus(); + if (codes && codes.length) { + this.dialogService.open(OtpCodesDialogComponent, { + header: "Save your recovery codes", + data: { codes: codes } + }); + } + }) + }); + } + + deactivate2fa() { + this.dialogService.open(OtpDisableDialogComponent, { + header: 'Deactivate two-factor authentication', + width: '50vw', + closable: false, + + }).onClose.subscribe(result => { + if (result == true) { + this.auth.deactivateOtp().subscribe(() => { + this.refreshOtpStatus(); + }); + } + }) + } } diff --git a/interfaces/src/type/messages/auth-events.ts b/interfaces/src/type/messages/auth-events.ts index 00f74705da..796c13ae0e 100644 --- a/interfaces/src/type/messages/auth-events.ts +++ b/interfaces/src/type/messages/auth-events.ts @@ -50,5 +50,9 @@ export enum AuthEvents { GET_CURRENT_RELAYER_ACCOUNT = 'GET_CURRENT_RELAYER_ACCOUNT', CREATE_RELAYER_ACCOUNT = 'CREATE_RELAYER_ACCOUNT', GENERATE_RELAYER_ACCOUNT = 'GENERATE_RELAYER_ACCOUNT', - RELAYER_ACCOUNT_EXIST = 'RELAYER_ACCOUNT_EXIST' + RELAYER_ACCOUNT_EXIST = 'RELAYER_ACCOUNT_EXIST', + OTP_GENERATE_SECRET = 'OTP_GENERATE_SECRET', + OTP_CONFIRM_SECRET = 'OTP_CONFIRM_SECRET', + OTP_GET_STATUS = 'OTP_GET_STATUS', + OTP_DEACTIVATE = 'OTP_DEACTIVATE', } diff --git a/swagger.yaml b/swagger.yaml index 06c8a1ef11..74d24b476d 100644 --- a/swagger.yaml +++ b/swagger.yaml @@ -62,11 +62,29 @@ paths: $ref: '#/components/schemas/LoginUserDTO' responses: '200': - description: Successful operation. + description: '' content: application/json: schema: - $ref: '#/components/schemas/AccountsSessionResponseDTO' + oneOf: + - $ref: '#/components/schemas/LoginSuccessResponseDTO' + - $ref: '#/components/schemas/LoginOTPRequiredResponseDTO' + examples: + success: + summary: Successful response + value: + did: >- + #did:hedera:testnet:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA_0.0.0000001 + refreshToken: >- + eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImIxM2E0NTkyLWU2YjQtNDg0OS1hMTkxLTAxOWVkODNkYzM5ZCIsIm5hbWUiOiJTdGFuZGFyZFJlZ2lzdHJ5IiwiZXhwaXJlQXQiOjE4MDUyODI4NjU3MDEsImlhdCI6MTc3Mzc0Njg2NX0.BsKje1bza0NEKKTAHFMRfwa3H-H-eRu7-KEDHKTftljXE3eQNmYCf_ftaPpw3DdsfsavBcEDfs5UQwlyeMsaTJPehEx_gl697rGQx6b8objGkqfFL2A7nWetMbWtxFFsIrbxs4mqHy1LM_4VVJuiXsH2DYQZkxOmw4HdyUshjE84 + role: STANDARD_REGISTRY + username: StandardRegistry + weakPassword: false + otpRequired: + summary: OTP required + value: + success: false + otprequired: true '500': description: Internal server error. content: @@ -233,6 +251,121 @@ paths: summary: Returns user's Hedera account balance. tags: - accounts + /accounts/otp/generate: + post: + description: Generate an OTP secret for 2FA setup. + operationId: AccountApi_generateOtp + parameters: [] + responses: + '200': + description: Successful operation. + content: + application/json: + schema: + $ref: '#/components/schemas/GenerateOPTResponseDTO' + '401': + description: Unauthorized. + '403': + description: Forbidden. + '500': + description: Internal server error. + content: + application/json: + schema: + $ref: '#/components/schemas/InternalServerErrorDTO' + security: + - bearer: [] + summary: Generate an OTP secret for 2FA setup. + tags: + - accounts + /accounts/otp/confirm: + post: + description: Confirm OTP setup by OTP token. + operationId: AccountApi_confirmOtp + parameters: [] + requestBody: + required: true + description: Configuration. + content: + application/json: + schema: + $ref: '#/components/schemas/OTPConfirmDTO' + responses: + '200': + description: Successful operation. + content: + application/json: + schema: + $ref: '#/components/schemas/OTPConfirmResponseDTO' + '401': + description: Unauthorized. + '403': + description: Forbidden. + '500': + description: Internal server error. + content: + application/json: + schema: + $ref: '#/components/schemas/InternalServerErrorDTO' + security: + - bearer: [] + summary: Confirm OTP setup. + tags: + - accounts + /accounts/otp/status: + get: + description: Get OTP status for the current user. + operationId: AccountApi_getOtpStatus + parameters: [] + responses: + '200': + description: Successful operation. + content: + application/json: + schema: + $ref: '#/components/schemas/OTPStatusResponseDTO' + '401': + description: Unauthorized. + '403': + description: Forbidden. + '500': + description: Internal server error. + content: + application/json: + schema: + $ref: '#/components/schemas/InternalServerErrorDTO' + security: + - bearer: [] + summary: Get OTP status. + tags: + - accounts + /accounts/otp/deactivate: + post: + description: Deactivate 2FA. + operationId: AccountApi_deactivateOtp + parameters: [] + responses: + '200': + description: Successful operation. + content: + application/json: + schema: + $ref: '#/components/schemas/EmptyResponseDTO' + '401': + description: Unauthorized. + '403': + description: Forbidden. + '500': + description: Internal server error. + content: + application/json: + schema: + $ref: '#/components/schemas/InternalServerErrorDTO' + security: + - bearer: [] + summary: Deactivate 2FA. + tags: + - accounts /analytics/search/policies: post: description: >- @@ -18792,9 +18925,12 @@ components: type: string password: type: string + otp: + type: string required: - username - password + - otp ChangePasswordDTO: type: object properties: @@ -19265,6 +19401,93 @@ components: - balance - unit - user + OTPConfigDTO: + type: object + properties: + algo: + type: string + example: sha1 + digits: + type: number + example: 111 + period: + type: number + example: 111 + secretSize: + type: number + example: 111 + required: + - algo + - digits + - period + - secretSize + GenerateOPTResponseDTO: + type: object + properties: + issuer: + type: string + example: OS Guardian + user: + type: string + example: StandardRegistry + secret: + type: string + example: AAA0AA0A0A00A000 + url: + type: string + example: >- + otpauth://totp/OS%20Guardian:StandardRegistry?issuer=OS+Guardian&period=30&secret=XXX0XX0X0X00X000 + config: + $ref: '#/components/schemas/OTPConfigDTO' + required: + - issuer + - user + - secret + - url + - config + OTPConfirmDTO: + type: object + properties: + token: + type: string + example: '111111' + required: + - token + OTPConfirmResponseDTO: + type: object + properties: + success: + type: boolean + example: true + backupCodes: + example: + - '000000' + - '111111' + - '222222' + - '333333' + - '444444' + - '555555' + - '666666' + - '777777' + - '888888' + - '999999' + type: array + items: + type: string + required: + - success + - backupCodes + OTPStatusResponseDTO: + type: object + properties: + enabled: + type: boolean + example: true + required: + - enabled + EmptyResponseDTO: + type: object + properties: {} FilterSearchPoliciesDTO: type: object properties: @@ -23646,3 +23869,41 @@ components: - '@context' - id - type + LoginOTPRequiredResponseDTO: + type: object + properties: + success: + type: boolean + example: false + otprequired: + type: boolean + example: true + required: + - success + - otprequired + LoginSuccessResponseDTO: + type: object + properties: + did: + type: string + example: >- + #did:hedera:testnet:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA_0.0.0000001 + refreshToken: + type: string + example: >- + eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImIxM2E0NTkyLWU2YjQtNDg0OS1hMTkxLTAxOWVkODNkYzM5ZCIsIm5hbWUiOiJTdGFuZGFyZFJlZ2lzdHJ5IiwiZXhwaXJlQXQiOjE4MDUyODI4NjU3MDEsImlhdCI6MTc3Mzc0Njg2NX0.BsKje1bza0NEKKTAHFMRfwa3H-H-eRu7-KEDHKTftljXE3eQNmYCf_ftaPpw3DdsfsavBcEDfs5UQwlyeMsaTJPehEx_gl697rGQx6b8objGkqfFL2A7nWetMbWtxFFsIrbxs4mqHy1LM_4VVJuiXsH2DYQZkxOmw4HdyUshjE84 + role: + type: string + example: STANDARD_REGISTRY + username: + type: string + example: StandardRegistry + weakPassword: + type: string + example: false + required: + - did + - refreshToken + - role + - username + - weakPassword