diff --git a/src/app/accounts/accept-organization.component.ts b/src/app/accounts/accept-organization.component.ts index d8c951976e..f96cc63eb1 100644 --- a/src/app/accounts/accept-organization.component.ts +++ b/src/app/accounts/accept-organization.component.ts @@ -2,6 +2,7 @@ import { Component, OnInit, } from '@angular/core'; + import { ActivatedRoute, Router, @@ -13,11 +14,18 @@ import { } from 'angular2-toaster'; import { ApiService } from 'jslib/abstractions/api.service'; +import { CryptoService } from 'jslib/abstractions/crypto.service'; import { I18nService } from 'jslib/abstractions/i18n.service'; +import { PolicyService } from 'jslib/abstractions/policy.service'; import { StateService } from 'jslib/abstractions/state.service'; import { UserService } from 'jslib/abstractions/user.service'; +import { Utils } from 'jslib/misc/utils'; + +import { Policy } from 'jslib/models/domain/policy'; + import { OrganizationUserAcceptRequest } from 'jslib/models/request/organizationUserAcceptRequest'; +import { OrganizationUserResetPasswordEnrollmentRequest } from 'jslib/models/request/organizationUserResetPasswordEnrollmentRequest'; @Component({ selector: 'app-accept-organization', @@ -33,7 +41,8 @@ export class AcceptOrganizationComponent implements OnInit { constructor(private router: Router, private toasterService: ToasterService, private i18nService: I18nService, private route: ActivatedRoute, private apiService: ApiService, private userService: UserService, - private stateService: StateService) { } + private stateService: StateService, private cryptoService: CryptoService, + private policyService: PolicyService) { } ngOnInit() { let fired = false; @@ -51,8 +60,36 @@ export class AcceptOrganizationComponent implements OnInit { const request = new OrganizationUserAcceptRequest(); request.token = qParams.token; try { - this.actionPromise = this.apiService.postOrganizationUserAccept(qParams.organizationId, - qParams.organizationUserId, request); + if (await this.performResetPasswordAutoEnroll(qParams)) { + this.actionPromise = this.apiService.postOrganizationUserAccept(qParams.organizationId, + qParams.organizationUserId, request).then(() => { + // Retrieve Public Key + return this.apiService.getOrganizationKeys(qParams.organizationId); + }).then(async response => { + if (response == null) { + throw new Error(this.i18nService.t('resetPasswordOrgKeysError')); + } + + const publicKey = Utils.fromB64ToArray(response.publicKey); + + // RSA Encrypt user's encKey.key with organization public key + const encKey = await this.cryptoService.getEncKey(); + const encryptedKey = await this.cryptoService.rsaEncrypt(encKey.key, publicKey.buffer); + + // Create request and execute enrollment + const resetRequest = new OrganizationUserResetPasswordEnrollmentRequest(); + resetRequest.resetPasswordKey = encryptedKey.encryptedString; + + // Get User Id + const userId = await this.userService.getUserId(); + + return this.apiService.putOrganizationUserResetPasswordEnrollment(qParams.organizationId, userId, resetRequest); + }); + } else { + this.actionPromise = this.apiService.postOrganizationUserAccept(qParams.organizationId, + qParams.organizationUserId, request); + } + await this.actionPromise; const toast: Toast = { type: 'success', @@ -92,4 +129,21 @@ export class AcceptOrganizationComponent implements OnInit { this.loading = false; }); } + + private async performResetPasswordAutoEnroll(qParams: any): Promise { + let policyList: Policy[] = null; + try { + const policies = await this.apiService.getPoliciesByToken(qParams.organizationId, qParams.token, + qParams.email, qParams.organizationUserId); + policyList = this.policyService.mapPoliciesFromToken(policies); + } catch { } + + if (policyList != null) { + const result = this.policyService.getResetPasswordPolicyOptions(policyList, qParams.organizationId); + // Return true if policy enabled and auto-enroll enabled + return result[1] && result[0].autoEnrollEnabled; + } + + return false; + } } diff --git a/src/app/accounts/login.component.html b/src/app/accounts/login.component.html index c22d76f58d..97d5c4783a 100644 --- a/src/app/accounts/login.component.html +++ b/src/app/accounts/login.component.html @@ -5,6 +5,10 @@

{{'loginOrCreateNewAccount' | i18n}}

+ + {{'resetPasswordAutoEnrollInviteWarning' | i18n}} +
('orgInvitation'); + if (invite != null) { + let policyList: Policy[] = null; + try { + const policies = await this.apiService.getPoliciesByToken(invite.organizationId, invite.token, + invite.email, invite.organizationUserId); + policyList = this.policyService.mapPoliciesFromToken(policies); + } catch { } + + if (policyList != null) { + const result = this.policyService.getResetPasswordPolicyOptions(policyList, invite.organizationId); + // Set to true if policy enabled and auto-enroll enabled + this.showResetPasswordAutoEnrollWarning = result[1] && result[0].autoEnrollEnabled; + } + } } async goAfterLogIn() { diff --git a/src/app/app-routing.module.ts b/src/app/app-routing.module.ts index a0ef4e9178..a7f4f7390b 100644 --- a/src/app/app-routing.module.ts +++ b/src/app/app-routing.module.ts @@ -200,12 +200,12 @@ const routes: Routes = [ { path: '', component: EmergencyAccessComponent, - data: { titleId: 'emergencyAccess'}, + data: { titleId: 'emergencyAccess' }, }, { path: ':id', component: EmergencyAccessViewComponent, - data: { titleId: 'emergencyAccess'}, + data: { titleId: 'emergencyAccess' }, }, ], }, @@ -390,7 +390,7 @@ const routes: Routes = [ canActivate: [OrganizationTypeGuardService], data: { titleId: 'people', - permissions: [Permissions.ManageUsers], + permissions: [Permissions.ManageUsers, Permissions.ManageUsersPassword], }, }, { diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 5ee2a56495..9d98af15e5 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -52,6 +52,7 @@ import { ManageComponent as OrgManageComponent } from './organizations/manage/ma import { PeopleComponent as OrgPeopleComponent } from './organizations/manage/people.component'; import { PoliciesComponent as OrgPoliciesComponent } from './organizations/manage/policies.component'; import { PolicyEditComponent as OrgPolicyEditComponent } from './organizations/manage/policy-edit.component'; +import { ResetPasswordComponent as OrgResetPasswordComponent } from './organizations/manage/reset-password.component'; import { UserAddEditComponent as OrgUserAddEditComponent } from './organizations/manage/user-add-edit.component'; import { UserConfirmComponent as OrgUserConfirmComponent } from './organizations/manage/user-confirm.component'; import { UserGroupsComponent as OrgUserGroupsComponent } from './organizations/manage/user-groups.component'; @@ -368,6 +369,7 @@ registerLocaleData(localeZhTw, 'zh-TW'); OrgPeopleComponent, OrgPolicyEditComponent, OrgPoliciesComponent, + OrgResetPasswordComponent, OrgReusedPasswordsReportComponent, OrgSettingComponent, OrgToolsComponent, @@ -456,6 +458,7 @@ registerLocaleData(localeZhTw, 'zh-TW'); OrgEntityUsersComponent, OrgGroupAddEditComponent, OrgPolicyEditComponent, + OrgResetPasswordComponent, OrgUserAddEditComponent, OrgUserConfirmComponent, OrgUserGroupsComponent, diff --git a/src/app/organizations/manage/people.component.html b/src/app/organizations/manage/people.component.html index ccd90b8a1d..7bcdad67c1 100644 --- a/src/app/organizations/manage/people.component.html +++ b/src/app/organizations/manage/people.component.html @@ -35,7 +35,8 @@ {{'reinviteSelected' | i18n}} - @@ -53,7 +54,7 @@ {{'unselectAll' | i18n}}
-
+
- + diff --git a/src/app/organizations/manage/policy-edit.component.ts b/src/app/organizations/manage/policy-edit.component.ts index 62cdc9a1a6..ab06564bfe 100644 --- a/src/app/organizations/manage/policy-edit.component.ts +++ b/src/app/organizations/manage/policy-edit.component.ts @@ -60,6 +60,9 @@ export class PolicyEditComponent implements OnInit { // Send options sendDisableHideEmail?: boolean; + // Reset Password + resetPasswordAutoEnroll?: boolean; + private policy: PolicyResponse; constructor(private apiService: ApiService, private i18nService: I18nService, @@ -116,6 +119,9 @@ export class PolicyEditComponent implements OnInit { case PolicyType.SendOptions: this.sendDisableHideEmail = this.policy.data.disableHideEmail; break; + case PolicyType.ResetPassword: + this.resetPasswordAutoEnroll = this.policy.data.autoEnrollEnabled; + break; default: break; } @@ -167,6 +173,11 @@ export class PolicyEditComponent implements OnInit { disableHideEmail: this.sendDisableHideEmail, }; break; + case PolicyType.ResetPassword: + request.data = { + autoEnrollEnabled: this.resetPasswordAutoEnroll, + }; + break; default: break; } diff --git a/src/app/organizations/manage/reset-password.component.html b/src/app/organizations/manage/reset-password.component.html new file mode 100644 index 0000000000..7c3e708026 --- /dev/null +++ b/src/app/organizations/manage/reset-password.component.html @@ -0,0 +1,78 @@ + diff --git a/src/app/organizations/manage/reset-password.component.ts b/src/app/organizations/manage/reset-password.component.ts new file mode 100644 index 0000000000..7d26d9d761 --- /dev/null +++ b/src/app/organizations/manage/reset-password.component.ts @@ -0,0 +1,191 @@ +import { + AfterViewInit, + Component, + EventEmitter, + Input, + OnInit, + Output, +} from '@angular/core'; + +import { ApiService } from 'jslib/abstractions/api.service'; +import { CryptoService } from 'jslib/abstractions/crypto.service'; +import { I18nService } from 'jslib/abstractions/i18n.service'; +import { PasswordGenerationService } from 'jslib/abstractions/passwordGeneration.service'; +import { PlatformUtilsService } from 'jslib/abstractions/platformUtils.service'; +import { PolicyService } from 'jslib/abstractions/policy.service'; + +import { EncString } from 'jslib/models/domain/encString'; +import { MasterPasswordPolicyOptions } from 'jslib/models/domain/masterPasswordPolicyOptions'; +import { SymmetricCryptoKey } from 'jslib/models/domain/symmetricCryptoKey'; +import { OrganizationUserResetPasswordRequest } from 'jslib/models/request/organizationUserResetPasswordRequest'; + +@Component({ + selector: 'app-reset-password', + templateUrl: 'reset-password.component.html', +}) +export class ResetPasswordComponent implements OnInit { + @Input() name: string; + @Input() email: string; + @Input() id: string; + @Input() organizationId: string; + @Output() onPasswordReset = new EventEmitter(); + + enforcedPolicyOptions: MasterPasswordPolicyOptions; + newPassword: string = null; + showPassword: boolean = false; + masterPasswordScore: number; + formPromise: Promise; + private newPasswordStrengthTimeout: any; + + constructor(private apiService: ApiService, private i18nService: I18nService, + private platformUtilsService: PlatformUtilsService, private passwordGenerationService: PasswordGenerationService, + private policyService: PolicyService, private cryptoService: CryptoService) { } + + async ngOnInit() { + // Get Enforced Policy Options + this.enforcedPolicyOptions = await this.policyService.getMasterPasswordPolicyOptions(); + } + + get loggedOutWarningName() { + return this.name != null ? this.name : this.i18nService.t('thisUser'); + } + + getPasswordScoreAlertDisplay() { + if (this.enforcedPolicyOptions == null) { + return ''; + } + + let str: string; + switch (this.enforcedPolicyOptions.minComplexity) { + case 4: + str = this.i18nService.t('strong'); + break; + case 3: + str = this.i18nService.t('good'); + break; + default: + str = this.i18nService.t('weak'); + break; + } + return str + ' (' + this.enforcedPolicyOptions.minComplexity + ')'; + } + + async generatePassword() { + const options = (await this.passwordGenerationService.getOptions())[0]; + this.newPassword = await this.passwordGenerationService.generatePassword(options); + this.updatePasswordStrength(); + } + + togglePassword() { + this.showPassword = !this.showPassword; + document.getElementById('newPassword').focus(); + } + + copy(value: string) { + if (value == null) { + return; + } + + this.platformUtilsService.copyToClipboard(value, { window: window }); + this.platformUtilsService.showToast('info', null, + this.i18nService.t('valueCopied', this.i18nService.t('password'))); + } + + async submit() { + // Validation + if (this.newPassword == null || this.newPassword === '') { + this.platformUtilsService.showToast('error', this.i18nService.t('errorOccurred'), + this.i18nService.t('masterPassRequired')); + return false; + } + + if (this.newPassword.length < 8) { + this.platformUtilsService.showToast('error', this.i18nService.t('errorOccurred'), + this.i18nService.t('masterPassLength')); + return false; + } + + if (this.enforcedPolicyOptions != null && + !this.policyService.evaluateMasterPassword(this.masterPasswordScore, this.newPassword, + this.enforcedPolicyOptions)) { + this.platformUtilsService.showToast('error', this.i18nService.t('errorOccurred'), + this.i18nService.t('masterPasswordPolicyRequirementsNotMet')); + return; + } + + if (this.masterPasswordScore < 3) { + const result = await this.platformUtilsService.showDialog(this.i18nService.t('weakMasterPasswordDesc'), + this.i18nService.t('weakMasterPassword'), this.i18nService.t('yes'), this.i18nService.t('no'), + 'warning'); + if (!result) { + return false; + } + } + + // Get user Information (kdf type, kdf iterations, resetPasswordKey, private key) and change password + try { + this.formPromise = this.apiService.getOrganizationUserResetPasswordDetails(this.organizationId, this.id) + .then(async response => { + if (response == null) { + throw new Error(this.i18nService.t('resetPasswordDetailsError')); + } + + const kdfType = response.kdf; + const kdfIterations = response.kdfIterations; + const resetPasswordKey = response.resetPasswordKey; + const encryptedPrivateKey = response.encryptedPrivateKey; + + // Decrypt Organization's encrypted Private Key with org key + const orgSymKey = await this.cryptoService.getOrgKey(this.organizationId); + const decPrivateKey = await this.cryptoService.decryptToBytes(new EncString(encryptedPrivateKey), orgSymKey); + + // Decrypt User's Reset Password Key to get EncKey + const decValue = await this.cryptoService.rsaDecrypt(resetPasswordKey, decPrivateKey); + const userEncKey = new SymmetricCryptoKey(decValue); + + // Create new key and hash new password + const newKey = await this.cryptoService.makeKey(this.newPassword, this.email.trim().toLowerCase(), + kdfType, kdfIterations); + const newPasswordHash = await this.cryptoService.hashPassword(this.newPassword, newKey); + + // Create new encKey for the User + const newEncKey = await this.cryptoService.remakeEncKey(newKey, userEncKey); + + // Create request + const request = new OrganizationUserResetPasswordRequest(); + request.key = newEncKey[1].encryptedString; + request.newMasterPasswordHash = newPasswordHash; + + // Change user's password + return this.apiService.putOrganizationUserResetPassword(this.organizationId, this.id, request); + }); + + await this.formPromise; + this.platformUtilsService.showToast('success', null, this.i18nService.t('resetPasswordSuccess')); + this.onPasswordReset.emit(); + } catch { } + } + + updatePasswordStrength() { + if (this.newPasswordStrengthTimeout != null) { + clearTimeout(this.newPasswordStrengthTimeout); + } + this.newPasswordStrengthTimeout = setTimeout(() => { + const strengthResult = this.passwordGenerationService.passwordStrength(this.newPassword, + this.getPasswordStrengthUserInput()); + this.masterPasswordScore = strengthResult == null ? null : strengthResult.score; + }, 300); + } + + private getPasswordStrengthUserInput() { + let userInput: string[] = []; + const atPosition = this.email.indexOf('@'); + if (atPosition > -1) { + userInput = userInput.concat(this.email.substr(0, atPosition).trim().toLowerCase().split(/[^A-Za-z0-9]/)); + } + if (this.name != null && this.name !== '') { + userInput = userInput.concat(this.name.trim().toLowerCase().split(' ')); + } + return userInput; + } +} diff --git a/src/app/organizations/settings/account.component.ts b/src/app/organizations/settings/account.component.ts index 2011de0816..5e262f58ce 100644 --- a/src/app/organizations/settings/account.component.ts +++ b/src/app/organizations/settings/account.component.ts @@ -4,22 +4,28 @@ import { ViewChild, ViewContainerRef, } from '@angular/core'; + import { ActivatedRoute } from '@angular/router'; import { ToasterService } from 'angular2-toaster'; import { ApiService } from 'jslib/abstractions/api.service'; +import { CryptoService } from 'jslib/abstractions/crypto.service'; import { I18nService } from 'jslib/abstractions/i18n.service'; import { PlatformUtilsService } from 'jslib/abstractions/platformUtils.service'; import { SyncService } from 'jslib/abstractions/sync.service'; +import { OrganizationKeysRequest } from 'jslib/models/request/organizationKeysRequest'; import { OrganizationUpdateRequest } from 'jslib/models/request/organizationUpdateRequest'; + import { OrganizationResponse } from 'jslib/models/response/organizationResponse'; import { ModalComponent } from '../../modal.component'; + import { ApiKeyComponent } from '../../settings/api-key.component'; import { PurgeVaultComponent } from '../../settings/purge-vault.component'; import { TaxInfoComponent } from '../../settings/tax-info.component'; + import { DeleteOrganizationComponent } from './delete-organization.component'; @Component({ @@ -46,7 +52,8 @@ export class AccountComponent { constructor(private componentFactoryResolver: ComponentFactoryResolver, private apiService: ApiService, private i18nService: I18nService, private toasterService: ToasterService, private route: ActivatedRoute, - private syncService: SyncService, private platformUtilsService: PlatformUtilsService) { } + private syncService: SyncService, private platformUtilsService: PlatformUtilsService, + private cryptoService: CryptoService) { } async ngOnInit() { this.selfHosted = this.platformUtilsService.isSelfHost(); @@ -67,6 +74,14 @@ export class AccountComponent { request.businessName = this.org.businessName; request.billingEmail = this.org.billingEmail; request.identifier = this.org.identifier; + + // Backfill pub/priv key if necessary + if (!this.org.hasPublicAndPrivateKeys) { + const orgShareKey = await this.cryptoService.getOrgKey(this.organizationId); + const orgKeys = await this.cryptoService.makeKeyPair(orgShareKey); + request.keys = new OrganizationKeysRequest(orgKeys[0], orgKeys[1].encryptedString); + } + this.formPromise = this.apiService.putOrganization(this.organizationId, request).then(() => { return this.syncService.fullSync(true); }); diff --git a/src/app/services/organization-type-guard.service.ts b/src/app/services/organization-type-guard.service.ts index 30254b41dd..28b77e4d31 100644 --- a/src/app/services/organization-type-guard.service.ts +++ b/src/app/services/organization-type-guard.service.ts @@ -27,7 +27,8 @@ export class OrganizationTypeGuardService implements CanActivate { (permissions.indexOf(Permissions.ManageGroups) !== -1 && org.canManageGroups) || (permissions.indexOf(Permissions.ManageOrganization) !== -1 && org.isOwner) || (permissions.indexOf(Permissions.ManagePolicies) !== -1 && org.canManagePolicies) || - (permissions.indexOf(Permissions.ManageUsers) !== -1 && org.canManageUsers) + (permissions.indexOf(Permissions.ManageUsers) !== -1 && org.canManageUsers) || + (permissions.indexOf(Permissions.ManageUsersPassword) !== -1 && org.canManageUsersPassword) ) { return true; } diff --git a/src/app/settings/change-password.component.ts b/src/app/settings/change-password.component.ts index 444961b261..f527d58a90 100644 --- a/src/app/settings/change-password.component.ts +++ b/src/app/settings/change-password.component.ts @@ -205,9 +205,12 @@ export class ChangePasswordComponent extends BaseChangePasswordComponent { continue; } - // Re-enroll - encrpyt user's encKey.key with organization key - const orgSymKey = await this.cryptoService.getOrgKey(org.id); - const encryptedKey = await this.cryptoService.encrypt(encKey.key, orgSymKey); + // Retrieve public key + const response = await this.apiService.getOrganizationKeys(org.id); + const publicKey = Utils.fromB64ToArray(response?.publicKey); + + // Re-enroll - encrpyt user's encKey.key with organization public key + const encryptedKey = await this.cryptoService.rsaEncrypt(encKey.key, publicKey.buffer); // Create/Execute request const request = new OrganizationUserResetPasswordEnrollmentRequest(); diff --git a/src/app/settings/organization-plans.component.ts b/src/app/settings/organization-plans.component.ts index c643c69347..19d022eee3 100644 --- a/src/app/settings/organization-plans.component.ts +++ b/src/app/settings/organization-plans.component.ts @@ -30,7 +30,9 @@ import { PolicyType } from 'jslib/enums/policyType'; import { ProductType } from 'jslib/enums/productType'; import { OrganizationCreateRequest } from 'jslib/models/request/organizationCreateRequest'; +import { OrganizationKeysRequest } from 'jslib/models/request/organizationKeysRequest'; import { OrganizationUpgradeRequest } from 'jslib/models/request/organizationUpgradeRequest'; + import { PlanResponse } from 'jslib/models/response/planResponse'; @Component({ @@ -259,6 +261,7 @@ export class OrganizationPlansComponent implements OnInit { const collection = await this.cryptoService.encrypt( this.i18nService.t('defaultCollection'), shareKey[1]); const collectionCt = collection.encryptedString; + const orgKeys = await this.cryptoService.makeKeyPair(shareKey[1]); if (this.selfHosted) { const fd = new FormData(); @@ -267,12 +270,17 @@ export class OrganizationPlansComponent implements OnInit { fd.append('collectionName', collectionCt); const response = await this.apiService.postOrganizationLicense(fd); orgId = response.id; + + // Org Keys live outside of the OrganizationLicense - add the keys to the org here + const request = new OrganizationKeysRequest(orgKeys[0], orgKeys[1].encryptedString); + await this.apiService.postOrganizationKeys(orgId, request); } else { const request = new OrganizationCreateRequest(); request.key = key; request.collectionName = collectionCt; request.name = this.name; request.billingEmail = this.billingEmail; + request.keys = new OrganizationKeysRequest(orgKeys[0], orgKeys[1].encryptedString); if (this.selectedPlan.type === PlanType.Free) { request.planType = PlanType.Free; @@ -309,6 +317,14 @@ export class OrganizationPlansComponent implements OnInit { request.billingAddressCountry = this.taxComponent.taxInfo.country; request.billingAddressPostalCode = this.taxComponent.taxInfo.postalCode; + // Retrieve org info to backfill pub/priv key if necessary + const org = await this.userService.getOrganization(this.organizationId); + if (!org.hasPublicAndPrivateKeys) { + const orgShareKey = await this.cryptoService.getOrgKey(this.organizationId); + const orgKeys = await this.cryptoService.makeKeyPair(orgShareKey); + request.keys = new OrganizationKeysRequest(orgKeys[0], orgKeys[1].encryptedString); + } + const result = await this.apiService.postOrganizationUpgrade(this.organizationId, request); if (!result.success && result.paymentIntentClientSecret != null) { await this.paymentComponent.handleStripeCardPayment(result.paymentIntentClientSecret, null); diff --git a/src/app/settings/organizations.component.html b/src/app/settings/organizations.component.html index a3388d9eab..68c799d815 100644 --- a/src/app/settings/organizations.component.html +++ b/src/app/settings/organizations.component.html @@ -65,7 +65,7 @@ title="{{'organizationIsDisabled' | i18n}}" aria-hidden="true"> {{'organizationIsDisabled' | i18n}} - + {{'enrolledPasswordReset' | i18n}} @@ -79,13 +79,13 @@