diff --git a/bubble-server/src/main/java/bubble/model/account/Account.java b/bubble-server/src/main/java/bubble/model/account/Account.java index 413a4bed..b0eba1b9 100644 --- a/bubble-server/src/main/java/bubble/model/account/Account.java +++ b/bubble-server/src/main/java/bubble/model/account/Account.java @@ -237,7 +237,20 @@ public class Account extends IdentifiableBaseParentEntity implements TokenPrinci public boolean sendWelcomeEmail() { return sendWelcomeEmail != null && sendWelcomeEmail; } @Transient @Getter @Setter private transient String loginRequest; - @Transient @Getter @Setter private transient AccountContact[] multifactorAuth; + @Transient @Getter private transient AccountContact[] multifactorAuth; + public Account setMultifactorAuth(AccountContact[] mfa) { + if (!empty(mfa)) { + final AccountContact[] masked = new AccountContact[mfa.length]; + for (int i=0; i mfa) { + return setMultifactorAuth(empty(mfa) ? null : mfa.stream().map(AccountContact::mask).toArray(AccountContact[]::new)); + } @Transient @Getter @Setter private transient String remoteHost; @Transient @JsonIgnore @Getter @Setter private transient Boolean verifyContact; diff --git a/bubble-server/src/main/java/bubble/model/account/AccountPolicy.java b/bubble-server/src/main/java/bubble/model/account/AccountPolicy.java index 8fdc89a5..919597ef 100644 --- a/bubble-server/src/main/java/bubble/model/account/AccountPolicy.java +++ b/bubble-server/src/main/java/bubble/model/account/AccountPolicy.java @@ -141,6 +141,13 @@ public class AccountPolicy extends IdentifiableBase implements HasAccount { } } + public List getRequiredExternalApprovals(AccountMessage message) { + final List required = getRequiredApprovals(message); + return required.isEmpty() ? required : required.stream() + .filter(AccountContact::isNotAuthenticator) + .collect(Collectors.toList()); + } + public List requiredAuthFactors() { return Arrays.stream(getAccountContacts()) .filter(AccountContact::requiredAuthFactor) diff --git a/bubble-server/src/main/java/bubble/resources/account/AccountsResource.java b/bubble-server/src/main/java/bubble/resources/account/AccountsResource.java index e6c4824c..dc4885bf 100644 --- a/bubble-server/src/main/java/bubble/resources/account/AccountsResource.java +++ b/bubble-server/src/main/java/bubble/resources/account/AccountsResource.java @@ -1,6 +1,7 @@ package bubble.resources.account; import bubble.cloud.CloudServiceType; +import bubble.dao.SessionDAO; import bubble.dao.account.AccountDAO; import bubble.dao.account.AccountPolicyDAO; import bubble.dao.account.message.AccountMessageDAO; @@ -26,6 +27,8 @@ import bubble.service.account.download.AccountDownloadService; import bubble.service.boot.SelfNodeService; import bubble.service.cloud.StandardNetworkService; import lombok.extern.slf4j.Slf4j; +import org.cobbzilla.wizard.auth.ChangePasswordRequest; +import org.cobbzilla.wizard.model.HashedPassword; import org.cobbzilla.wizard.validation.ConstraintViolationBean; import org.cobbzilla.wizard.validation.ValidationResult; import org.glassfish.grizzly.http.server.Request; @@ -43,6 +46,7 @@ import java.util.Map; import static bubble.ApiConstants.*; import static bubble.model.account.Account.ADMIN_UPDATE_FIELDS; import static bubble.model.account.Account.validatePassword; +import static bubble.resources.account.AuthResource.forgotPasswordMessage; import static org.cobbzilla.util.http.HttpContentTypes.APPLICATION_JSON; import static org.cobbzilla.wizard.resources.ResourceUtil.*; @@ -60,6 +64,7 @@ public class AccountsResource { @Autowired private AccountDownloadService downloadService; @Autowired private AuthenticatorService authenticatorService; @Autowired private SelfNodeService selfNodeService; + @Autowired private SessionDAO sessionDAO; @GET public Response list(@Context ContainerRequest ctx) { @@ -310,6 +315,54 @@ public class AccountsResource { .setRemoteHost(getRemoteHost(req)))); } + + @POST @Path("/{id}"+EP_CHANGE_PASSWORD) + public Response rootChangePassword(@Context Request req, + @Context ContainerRequest ctx, + @PathParam("id") String id, + ChangePasswordRequest request) { + final AccountContext c = new AccountContext(ctx, id); + if (!c.caller.admin()) return forbidden(); + + final AccountPolicy policy = policyDAO.findSingleByAccount(c.account.getUuid()); + if (policy != null && request.hasTotpToken()) { + authenticatorService.authenticate(c.account, policy, new AuthenticatorRequest() + .setAccount(c.account.getUuid()) + .setAuthenticate(true) + .setToken(request.getTotpToken())); + } + + if (c.caller.getUuid().equals(c.account.getUuid()) || c.account.admin()) { + if (policy != null) authenticatorService.ensureAuthenticated(ctx, policy, ActionTarget.account); + if (!c.account.getHashedPassword().isCorrectPassword(request.getOldPassword())) { + return invalid("err.currentPassword.invalid", "current password was invalid", ""); + } + } + + final ConstraintViolationBean passwordViolation = validatePassword(request.getNewPassword()); + if (passwordViolation != null) return invalid(passwordViolation); + + if (policy != null) { + final AccountMessage forgotPasswordMessage = forgotPasswordMessage(req, c.account, configuration); + final List requiredApprovals = policy.getRequiredExternalApprovals(forgotPasswordMessage); + if (!requiredApprovals.isEmpty()) { + messageDAO.create(forgotPasswordMessage); + return ok(c.account.setMultifactorAuthList(requiredApprovals)); + } + } + + c.account.setHashedPassword(new HashedPassword(request.getNewPassword())); + + // Update account + final Account updated = accountDAO.update(c.account); + if (c.caller.getUuid().equals(c.account.getUuid())) { + sessionDAO.update(c.caller.getApiToken(), updated); + } else { + sessionDAO.invalidateAllSessions(c.account.getUuid()); + } + return ok(updated); + } + @DELETE @Path("/{id}") public Response rootDeleteUser(@Context ContainerRequest ctx, @PathParam("id") String id) { diff --git a/bubble-server/src/main/java/bubble/resources/account/AuthResource.java b/bubble-server/src/main/java/bubble/resources/account/AuthResource.java index c796b170..b93c399a 100644 --- a/bubble-server/src/main/java/bubble/resources/account/AuthResource.java +++ b/bubble-server/src/main/java/bubble/resources/account/AuthResource.java @@ -262,15 +262,19 @@ public class AuthResource { final Account account = accountDAO.findById(request.getName()); if (account == null) return ok(); - accountMessageDAO.create(new AccountMessage() + accountMessageDAO.create(forgotPasswordMessage(req, account, configuration)); + return ok(); + } + + public static AccountMessage forgotPasswordMessage(Request req, Account account, BubbleConfiguration configuration) { + return new AccountMessage() .setAccount(account.getUuid()) .setNetwork(configuration.getThisNetwork().getUuid()) .setName(account.getUuid()) .setMessageType(AccountMessageType.request) .setAction(AccountAction.password) .setTarget(ActionTarget.account) - .setRemoteHost(getRemoteHost(req))); - return ok(); + .setRemoteHost(getRemoteHost(req)); } @POST @Path(EP_APPROVE+"/{token}") diff --git a/bubble-server/src/main/java/bubble/resources/account/MeResource.java b/bubble-server/src/main/java/bubble/resources/account/MeResource.java index 654fde19..03d0b751 100644 --- a/bubble-server/src/main/java/bubble/resources/account/MeResource.java +++ b/bubble-server/src/main/java/bubble/resources/account/MeResource.java @@ -3,8 +3,11 @@ package bubble.resources.account; import bubble.dao.SessionDAO; import bubble.dao.account.AccountDAO; import bubble.dao.account.AccountPolicyDAO; +import bubble.dao.account.message.AccountMessageDAO; import bubble.model.account.Account; +import bubble.model.account.AccountContact; import bubble.model.account.AccountPolicy; +import bubble.model.account.AuthenticatorRequest; import bubble.model.account.message.AccountMessage; import bubble.model.account.message.AccountMessageType; import bubble.model.account.message.ActionTarget; @@ -36,6 +39,7 @@ import org.cobbzilla.wizard.client.script.ApiRunnerListener; import org.cobbzilla.wizard.client.script.ApiRunnerListenerStreamLogger; import org.cobbzilla.wizard.client.script.ApiScript; import org.cobbzilla.wizard.model.HashedPassword; +import org.cobbzilla.wizard.validation.ConstraintViolationBean; import org.glassfish.grizzly.http.server.Request; import org.glassfish.jersey.media.multipart.FormDataParam; import org.glassfish.jersey.server.ContainerRequest; @@ -49,9 +53,12 @@ import java.io.File; import java.io.IOException; import java.io.InputStream; import java.io.StringWriter; +import java.util.List; import java.util.Locale; import static bubble.ApiConstants.*; +import static bubble.model.account.Account.validatePassword; +import static bubble.resources.account.AuthResource.forgotPasswordMessage; import static org.cobbzilla.util.daemon.ZillaRuntime.empty; import static org.cobbzilla.util.daemon.ZillaRuntime.errorString; import static org.cobbzilla.util.http.HttpContentTypes.*; @@ -70,6 +77,7 @@ public class MeResource { @Autowired private AccountDownloadService downloadService; @Autowired private BubbleConfiguration configuration; @Autowired private AuthenticatorService authenticatorService; + @Autowired private AccountMessageDAO messageDAO; @GET public Response me(@Context ContainerRequest ctx) { @@ -104,13 +112,34 @@ public class MeResource { } @POST @Path(EP_CHANGE_PASSWORD) - public Response changePassword(@Context ContainerRequest ctx, + public Response changePassword(@Context Request req, + @Context ContainerRequest ctx, ChangePasswordRequest request) { final Account caller = userPrincipal(ctx); - authenticatorService.ensureAuthenticated(ctx, ActionTarget.account); + + final AccountPolicy policy = policyDAO.findSingleByAccount(caller.getUuid()); + if (policy != null && request.hasTotpToken()) { + authenticatorService.authenticate(caller, policy, new AuthenticatorRequest() + .setAccount(caller.getUuid()) + .setAuthenticate(true) + .setToken(request.getTotpToken())); + } + if (policy != null) authenticatorService.ensureAuthenticated(ctx, ActionTarget.account); if (!caller.getHashedPassword().isCorrectPassword(request.getOldPassword())) { - return invalid("err.oldPassword.invalid", "old password was invalid"); + return invalid("err.currentPassword.invalid", "current password was invalid", ""); } + final ConstraintViolationBean passwordViolation = validatePassword(request.getNewPassword()); + if (passwordViolation != null) return invalid(passwordViolation); + + if (policy != null) { + final AccountMessage forgotPasswordMessage = forgotPasswordMessage(req, caller, configuration); + final List requiredApprovals = policy.getRequiredExternalApprovals(forgotPasswordMessage); + if (!requiredApprovals.isEmpty()) { + messageDAO.create(forgotPasswordMessage); + return ok(caller.setMultifactorAuthList(requiredApprovals)); + } + } + caller.setHashedPassword(new HashedPassword(request.getNewPassword())); // Update account, and write back to session diff --git a/bubble-server/src/main/java/bubble/service/account/StandardAccountMessageService.java b/bubble-server/src/main/java/bubble/service/account/StandardAccountMessageService.java index be0b2b7a..a11e2008 100644 --- a/bubble-server/src/main/java/bubble/service/account/StandardAccountMessageService.java +++ b/bubble-server/src/main/java/bubble/service/account/StandardAccountMessageService.java @@ -259,8 +259,6 @@ public class StandardAccountMessageService implements AccountMessageService { .collect(Collectors.toList()); // return masked list of contacts remaining to approve - return new Account().setMultifactorAuth(remainingApprovals.stream() - .map(AccountContact::mask) - .toArray(AccountContact[]::new)); + return new Account().setMultifactorAuthList(remainingApprovals); } } diff --git a/bubble-server/src/main/resources/message_templates/en_US/server/post_auth/ResourceMessages.properties b/bubble-server/src/main/resources/message_templates/en_US/server/post_auth/ResourceMessages.properties index 159fd851..fc7eb79e 100644 --- a/bubble-server/src/main/resources/message_templates/en_US/server/post_auth/ResourceMessages.properties +++ b/bubble-server/src/main/resources/message_templates/en_US/server/post_auth/ResourceMessages.properties @@ -156,6 +156,16 @@ button_label_create_account=Create Account button_label_delete_account=Delete button_label_force_delete_account=Force Delete +# Change Password page +form_title_change_password=Change Password +field_label_current_password=Current Password +field_label_new_password=New Password +field_label_new_password_confirm=Confirm New Password +button_label_change_password=Set New Password +button_label_request_password_reset=Request Password Reset +message_change_password_external_auth=Changing account password requires approval from these contacts on file: +message_change_password_authenticator_auth=Changing account password requires Authenticator password + # Networks table loading_networks=Loading bubbles... table_title_networks=Bubbles @@ -548,7 +558,7 @@ err.node.notInitialized=Node is not initialized err.node.running=Node must be stopped before deleting err.node.shutdownFailed=Node shutdown failed err.node.stop.error=Error stopping node -err.oldPassword.invalid=Old password was invalid +err.currentPassword.invalid=Current password was invalid err.paymentInfo.invalid=Payment information is invalid err.paymentInfo.required=Payment information is required err.paymentInfo.processingError=Processing payment information failed diff --git a/bubble-web b/bubble-web index 7b0bc74b..689734e6 160000 --- a/bubble-web +++ b/bubble-web @@ -1 +1 @@ -Subproject commit 7b0bc74bd5923465c6d99ea27ed7904c3078a9e5 +Subproject commit 689734e6c9fa7a51cbe19b47105520d0801c47f0 diff --git a/utils/cobbzilla-wizard b/utils/cobbzilla-wizard index 661578b9..1c4d29ae 160000 --- a/utils/cobbzilla-wizard +++ b/utils/cobbzilla-wizard @@ -1 +1 @@ -Subproject commit 661578b96b67dd02c0e7540843ac8313bc6e8777 +Subproject commit 1c4d29aef889254e046e781378e35012853d32a6