diff --git a/angular/src/components/add-edit.component.ts b/angular/src/components/add-edit.component.ts index 193c5720be..3728cdc4a5 100644 --- a/angular/src/components/add-edit.component.ts +++ b/angular/src/components/add-edit.component.ts @@ -47,6 +47,7 @@ export class AddEditComponent implements OnInit { @Output() onShareCipher = new EventEmitter(); @Output() onEditCollections = new EventEmitter(); @Output() onGeneratePassword = new EventEmitter(); + @Output() onGenerateUsername = new EventEmitter(); editMode = false; cipher: CipherView; @@ -425,12 +426,25 @@ export class AddEditComponent implements OnInit { return true; } + async generateUsername(): Promise { + if (this.cipher.login?.username?.length) { + const confirmed = await this.platformUtilsService.showDialog( + this.i18nService.t("overwriteUsernameConfirmation"), + this.i18nService.t("overwriteUsername"), + this.i18nService.t("yes"), + this.i18nService.t("no") + ); + if (!confirmed) { + return false; + } + } + + this.onGenerateUsername.emit(); + return true; + } + async generatePassword(): Promise { - if ( - this.cipher.login != null && - this.cipher.login.password != null && - this.cipher.login.password.length - ) { + if (this.cipher.login?.password?.length) { const confirmed = await this.platformUtilsService.showDialog( this.i18nService.t("overwritePasswordConfirmation"), this.i18nService.t("overwritePassword"), diff --git a/angular/src/components/password-generator.component.ts b/angular/src/components/password-generator.component.ts index 77d1341b2a..06e2442250 100644 --- a/angular/src/components/password-generator.component.ts +++ b/angular/src/components/password-generator.component.ts @@ -1,97 +1,201 @@ import { Directive, EventEmitter, Input, OnInit, Output } from "@angular/core"; +import { ActivatedRoute } from "@angular/router"; +import { first } from "rxjs/operators"; import { I18nService } from "jslib-common/abstractions/i18n.service"; import { PasswordGenerationService } from "jslib-common/abstractions/passwordGeneration.service"; import { PlatformUtilsService } from "jslib-common/abstractions/platformUtils.service"; +import { StateService } from "jslib-common/abstractions/state.service"; +import { UsernameGenerationService } from "jslib-common/abstractions/usernameGeneration.service"; import { PasswordGeneratorPolicyOptions } from "jslib-common/models/domain/passwordGeneratorPolicyOptions"; @Directive() export class PasswordGeneratorComponent implements OnInit { @Input() showSelect = false; + @Input() type = "password"; @Output() onSelected = new EventEmitter(); + typeOptions: any[]; passTypeOptions: any[]; - options: any = {}; + usernameTypeOptions: any[]; + subaddressOptions: any[]; + catchallOptions: any[]; + forwardOptions: any[]; + usernameOptions: any = {}; + passwordOptions: any = {}; + username = "-"; password = "-"; showOptions = false; avoidAmbiguous = false; - enforcedPolicyOptions: PasswordGeneratorPolicyOptions; + showWebsiteOption = false; + enforcedPasswordPolicyOptions: PasswordGeneratorPolicyOptions; constructor( protected passwordGenerationService: PasswordGenerationService, + protected usernameGenerationService: UsernameGenerationService, protected platformUtilsService: PlatformUtilsService, + protected stateService: StateService, protected i18nService: I18nService, + protected route: ActivatedRoute, private win: Window ) { + this.typeOptions = [ + { name: i18nService.t("password"), value: "password" }, + { name: i18nService.t("username"), value: "username" }, + ]; this.passTypeOptions = [ { name: i18nService.t("password"), value: "password" }, { name: i18nService.t("passphrase"), value: "passphrase" }, ]; + this.usernameTypeOptions = [ + { + name: i18nService.t("plusAddressedEmail"), + value: "subaddress", + desc: i18nService.t("plusAddressedEmailDesc"), + }, + { + name: i18nService.t("catchallEmail"), + value: "catchall", + desc: i18nService.t("catchallEmailDesc"), + }, + { name: i18nService.t("randomWord"), value: "word" }, + ]; + this.subaddressOptions = [{ name: i18nService.t("random"), value: "random" }]; + this.catchallOptions = [{ name: i18nService.t("random"), value: "random" }]; + this.forwardOptions = [ + { name: "SimpleLogin", value: "simplelogin" }, + { name: "FastMail", value: "fastmail" }, + ]; } async ngOnInit() { - const optionsResponse = await this.passwordGenerationService.getOptions(); - this.options = optionsResponse[0]; - this.enforcedPolicyOptions = optionsResponse[1]; - this.avoidAmbiguous = !this.options.ambiguous; - this.options.type = this.options.type === "passphrase" ? "passphrase" : "password"; - this.password = await this.passwordGenerationService.generatePassword(this.options); - await this.passwordGenerationService.addHistory(this.password); + this.route.queryParams.pipe(first()).subscribe(async (qParams) => { + const passwordOptionsResponse = await this.passwordGenerationService.getOptions(); + this.passwordOptions = passwordOptionsResponse[0]; + this.enforcedPasswordPolicyOptions = passwordOptionsResponse[1]; + this.avoidAmbiguous = !this.passwordOptions.ambiguous; + this.passwordOptions.type = + this.passwordOptions.type === "passphrase" ? "passphrase" : "password"; + + if (this.showWebsiteOption) { + const websiteOption = { name: this.i18nService.t("websiteName"), value: "website-name" }; + this.subaddressOptions.push(websiteOption); + this.catchallOptions.push(websiteOption); + } + this.usernameOptions = await this.usernameGenerationService.getOptions(); + if (this.usernameOptions.type == null) { + this.usernameOptions.type = "word"; + } + if ( + this.usernameOptions.subaddressEmail == null || + this.usernameOptions.subaddressEmail === "" + ) { + this.usernameOptions.subaddressEmail = await this.stateService.getEmail(); + } + if (!this.showWebsiteOption) { + this.usernameOptions.subaddressType = this.usernameOptions.catchallType = "random"; + } + + if (qParams.type === "username" || qParams.type === "password") { + this.type = qParams.type; + } else { + const generatorOptions = await this.stateService.getGeneratorOptions(); + if (generatorOptions != null && generatorOptions.type != null) { + this.type = generatorOptions.type; + } + } + await this.regenerate(); + }); + } + + async typeChanged() { + await this.stateService.setGeneratorOptions({ type: this.type }); + await this.regenerate(); + } + + async regenerate() { + if (this.type === "password") { + await this.regeneratePassword(); + } else if (this.type === "username") { + await this.regenerateUsername(); + } } async sliderChanged() { - this.saveOptions(false); + this.savePasswordOptions(false); await this.passwordGenerationService.addHistory(this.password); } async sliderInput() { - this.normalizeOptions(); - this.password = await this.passwordGenerationService.generatePassword(this.options); + this.normalizePasswordOptions(); + this.password = await this.passwordGenerationService.generatePassword(this.passwordOptions); } - async saveOptions(regenerate = true) { - this.normalizeOptions(); - await this.passwordGenerationService.saveOptions(this.options); + async savePasswordOptions(regenerate = true) { + this.normalizePasswordOptions(); + await this.passwordGenerationService.saveOptions(this.passwordOptions); if (regenerate) { - await this.regenerate(); + await this.regeneratePassword(); } } - async regenerate() { - this.password = await this.passwordGenerationService.generatePassword(this.options); + async saveUsernameOptions(regenerate = true) { + await this.usernameGenerationService.saveOptions(this.usernameOptions); + if (regenerate) { + await this.regenerateUsername(); + } + } + + async regeneratePassword() { + this.password = await this.passwordGenerationService.generatePassword(this.passwordOptions); await this.passwordGenerationService.addHistory(this.password); } + regenerateUsername() { + return this.generateUsername(); + } + + async generateUsername() { + this.username = await this.usernameGenerationService.generateUsername(this.usernameOptions); + if (this.username === "" || this.username === null) { + this.username = "-"; + } + } + copy() { + const password = this.type === "password"; const copyOptions = this.win != null ? { window: this.win } : null; - this.platformUtilsService.copyToClipboard(this.password, copyOptions); + this.platformUtilsService.copyToClipboard( + password ? this.password : this.username, + copyOptions + ); this.platformUtilsService.showToast( "info", null, - this.i18nService.t("valueCopied", this.i18nService.t("password")) + this.i18nService.t("valueCopied", this.i18nService.t(password ? "password" : "username")) ); } select() { - this.onSelected.emit(this.password); + this.onSelected.emit(this.type === "password" ? this.password : this.username); } toggleOptions() { this.showOptions = !this.showOptions; } - private normalizeOptions() { + private normalizePasswordOptions() { // Application level normalize options depedent on class variables - this.options.ambiguous = !this.avoidAmbiguous; + this.passwordOptions.ambiguous = !this.avoidAmbiguous; if ( - !this.options.uppercase && - !this.options.lowercase && - !this.options.number && - !this.options.special + !this.passwordOptions.uppercase && + !this.passwordOptions.lowercase && + !this.passwordOptions.number && + !this.passwordOptions.special ) { - this.options.lowercase = true; + this.passwordOptions.lowercase = true; if (this.win != null) { const lowercase = this.win.document.querySelector("#lowercase") as HTMLInputElement; if (lowercase) { @@ -100,6 +204,9 @@ export class PasswordGeneratorComponent implements OnInit { } } - this.passwordGenerationService.normalizeOptions(this.options, this.enforcedPolicyOptions); + this.passwordGenerationService.normalizeOptions( + this.passwordOptions, + this.enforcedPasswordPolicyOptions + ); } } diff --git a/common/src/abstractions/state.service.ts b/common/src/abstractions/state.service.ts index 66f4bada6f..ebf94df3d5 100644 --- a/common/src/abstractions/state.service.ts +++ b/common/src/abstractions/state.service.ts @@ -265,6 +265,10 @@ export abstract class StateService { ) => Promise; getPasswordGenerationOptions: (options?: StorageOptions) => Promise; setPasswordGenerationOptions: (value: any, options?: StorageOptions) => Promise; + getUsernameGenerationOptions: (options?: StorageOptions) => Promise; + setUsernameGenerationOptions: (value: any, options?: StorageOptions) => Promise; + getGeneratorOptions: (options?: StorageOptions) => Promise; + setGeneratorOptions: (value: any, options?: StorageOptions) => Promise; getProtectedPin: (options?: StorageOptions) => Promise; setProtectedPin: (value: string, options?: StorageOptions) => Promise; getProviders: (options?: StorageOptions) => Promise<{ [id: string]: ProviderData }>; diff --git a/common/src/abstractions/usernameGeneration.service.ts b/common/src/abstractions/usernameGeneration.service.ts new file mode 100644 index 0000000000..baba74d8e4 --- /dev/null +++ b/common/src/abstractions/usernameGeneration.service.ts @@ -0,0 +1,8 @@ +export abstract class UsernameGenerationService { + generateUsername: (options: any) => Promise; + generateWord: (options: any) => Promise; + generateSubaddress: (options: any) => Promise; + generateCatchall: (options: any) => Promise; + getOptions: () => Promise; + saveOptions: (options: any) => Promise; +} diff --git a/common/src/models/domain/account.ts b/common/src/models/domain/account.ts index a6c0cfd6f8..e03f9be347 100644 --- a/common/src/models/domain/account.ts +++ b/common/src/models/domain/account.ts @@ -125,6 +125,8 @@ export class AccountSettings { minimizeOnCopyToClipboard?: boolean; neverDomains?: { [id: string]: any }; passwordGenerationOptions?: any; + usernameGenerationOptions?: any; + generatorOptions?: any; pinProtected?: EncryptionPair = new EncryptionPair(); protectedPin?: string; settings?: any; // TODO: Merge whatever is going on here into the AccountSettings model properly diff --git a/common/src/services/state.service.ts b/common/src/services/state.service.ts index 4d3aa3b339..9d035fc785 100644 --- a/common/src/services/state.service.ts +++ b/common/src/services/state.service.ts @@ -1797,6 +1797,40 @@ export class StateService< ); } + async getUsernameGenerationOptions(options?: StorageOptions): Promise { + return ( + await this.getAccount(this.reconcileOptions(options, await this.defaultOnDiskOptions())) + )?.settings?.usernameGenerationOptions; + } + + async setUsernameGenerationOptions(value: any, options?: StorageOptions): Promise { + const account = await this.getAccount( + this.reconcileOptions(options, await this.defaultOnDiskOptions()) + ); + account.settings.usernameGenerationOptions = value; + await this.saveAccount( + account, + this.reconcileOptions(options, await this.defaultOnDiskOptions()) + ); + } + + async getGeneratorOptions(options?: StorageOptions): Promise { + return ( + await this.getAccount(this.reconcileOptions(options, await this.defaultOnDiskOptions())) + )?.settings?.generatorOptions; + } + + async setGeneratorOptions(value: any, options?: StorageOptions): Promise { + const account = await this.getAccount( + this.reconcileOptions(options, await this.defaultOnDiskOptions()) + ); + account.settings.generatorOptions = value; + await this.saveAccount( + account, + this.reconcileOptions(options, await this.defaultOnDiskOptions()) + ); + } + async getProtectedPin(options?: StorageOptions): Promise { return ( await this.getAccount(this.reconcileOptions(options, await this.defaultOnDiskOptions())) diff --git a/common/src/services/usernameGeneration.service.ts b/common/src/services/usernameGeneration.service.ts new file mode 100644 index 0000000000..d09721b58b --- /dev/null +++ b/common/src/services/usernameGeneration.service.ts @@ -0,0 +1,128 @@ +import { CryptoService } from "../abstractions/crypto.service"; +import { StateService } from "../abstractions/state.service"; +import { UsernameGenerationService as BaseUsernameGenerationService } from "../abstractions/usernameGeneration.service"; +import { EEFLongWordList } from "../misc/wordlist"; + +const DefaultOptions = { + type: "word", + wordCapitalize: true, + wordIncludeNumber: true, + subaddressType: "random", + catchallType: "random", +}; + +export class UsernameGenerationService implements BaseUsernameGenerationService { + constructor(private cryptoService: CryptoService, private stateService: StateService) {} + + generateUsername(options: any): Promise { + if (options.type === "catchall") { + return this.generateCatchall(options); + } else if (options.type === "subaddress") { + return this.generateSubaddress(options); + } else if (options.type === "forwarded") { + return this.generateSubaddress(options); + } else { + return this.generateWord(options); + } + } + + async generateWord(options: any): Promise { + const o = Object.assign({}, DefaultOptions, options); + + if (o.wordCapitalize == null) { + o.wordCapitalize = true; + } + if (o.wordIncludeNumber == null) { + o.wordIncludeNumber = true; + } + + const wordIndex = await this.cryptoService.randomNumber(0, EEFLongWordList.length - 1); + let word = EEFLongWordList[wordIndex]; + if (o.wordCapitalize) { + word = word.charAt(0).toUpperCase() + word.slice(1); + } + if (o.wordIncludeNumber) { + const num = await this.cryptoService.randomNumber(1, 9999); + word = word + this.zeroPad(num.toString(), 4); + } + return word; + } + + async generateSubaddress(options: any): Promise { + const o = Object.assign({}, DefaultOptions, options); + + const subaddressEmail = o.subaddressEmail; + if (subaddressEmail == null || subaddressEmail.length < 3) { + return o.subaddressEmail; + } + const atIndex = subaddressEmail.indexOf("@"); + if (atIndex < 1 || atIndex >= subaddressEmail.length - 1) { + return subaddressEmail; + } + if (o.subaddressType == null) { + o.subaddressType = "random"; + } + + const emailBeginning = subaddressEmail.substr(0, atIndex); + const emailEnding = subaddressEmail.substr(atIndex + 1, subaddressEmail.length); + + let subaddressString = ""; + if (o.subaddressType === "random") { + subaddressString = await this.randomString(8); + } else if (o.subaddressType === "website-name") { + subaddressString = o.website; + } + return emailBeginning + "+" + subaddressString + "@" + emailEnding; + } + + async generateCatchall(options: any): Promise { + const o = Object.assign({}, DefaultOptions, options); + + if (o.catchallDomain == null || o.catchallDomain === "") { + return null; + } + if (o.catchallType == null) { + o.catchallType = "random"; + } + + let startString = ""; + if (o.catchallType === "random") { + startString = await this.randomString(8); + } else if (o.catchallType === "website-name") { + startString = o.website; + } + return startString + "@" + o.catchallDomain; + } + + async getOptions(): Promise { + let options = await this.stateService.getUsernameGenerationOptions(); + if (options == null) { + options = DefaultOptions; + } else { + options = Object.assign({}, DefaultOptions, options); + } + await this.stateService.setUsernameGenerationOptions(options); + return options; + } + + async saveOptions(options: any) { + await this.stateService.setUsernameGenerationOptions(options); + } + + private async randomString(length: number) { + let str = ""; + const charSet = "abcdefghijklmnopqrstuvwxyz1234567890"; + for (let i = 0; i < length; i++) { + const randomCharIndex = await this.cryptoService.randomNumber(0, charSet.length - 1); + str += charSet.charAt(randomCharIndex); + } + return str; + } + + // ref: https://stackoverflow.com/a/10073788 + private zeroPad(number: string, width: number) { + return number.length >= width + ? number + : new Array(width - number.length + 1).join("0") + number; + } +}