@@ -0,0 +1,33 @@ | |||
package bubble.filters; | |||
import bubble.model.account.Account; | |||
import lombok.NoArgsConstructor; | |||
import org.cobbzilla.wizard.filters.RateLimitFilter; | |||
import org.springframework.stereotype.Service; | |||
import javax.ws.rs.container.ContainerRequestContext; | |||
import javax.ws.rs.ext.Provider; | |||
import java.security.Principal; | |||
import java.util.List; | |||
import static bubble.ApiConstants.SESSION_HEADER; | |||
@Provider @Service @NoArgsConstructor | |||
public class BubbleRateLimitFilter extends RateLimitFilter { | |||
@Override protected String getToken(ContainerRequestContext request) { return request.getHeaderString(SESSION_HEADER); } | |||
@Override protected List<String> getKeys(ContainerRequestContext request) { | |||
return super.getKeys(request); | |||
} | |||
// super-admins have unlimited API usage. helpful when populating models | |||
@Override protected boolean allowUnlimitedUse(Principal user) { | |||
try { | |||
return ((Account) user).admin(); | |||
} catch (Exception e) { | |||
return false; | |||
} | |||
} | |||
} |
@@ -28,6 +28,7 @@ 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; | |||
@@ -326,21 +327,42 @@ public class AccountsResource { | |||
ChangePasswordRequest request) { | |||
final AccountContext c = new AccountContext(ctx, id); | |||
if (!c.caller.admin()) return forbidden(); | |||
final Account caller = accountDAO.findByUuid(c.caller.getUuid()).setToken(c.caller.getToken()); | |||
final AccountPolicy policy = policyDAO.findSingleByAccount(c.account.getUuid()); | |||
if (c.caller.getUuid().equals(c.account.getUuid()) || c.account.admin()) { | |||
if (policy != null) authenticatorService.ensureAuthenticated(ctx, policy, ActionTarget.account); | |||
// if there was a totp token provided, try it | |||
final String authSession; | |||
if (request.hasTotpToken()) { | |||
c.account.setToken(authenticatorService.authenticate(caller, policy, new AuthenticatorRequest() | |||
.setAccount(c.account.getUuid()) | |||
.setAuthenticate(true) | |||
.setToken(request.getTotpToken()))); | |||
} else { | |||
authSession = null; | |||
} | |||
if (policy != null) { | |||
// older admin, or self (since ctime would be equal) | |||
final boolean olderAdmin = c.account.admin() && caller.getCtime() >= c.account.getCtime(); | |||
// admins created earlier can do anything to admins created later | |||
// if admins created later try to change password of admins created earlier, we go through approvals | |||
if (policy != null && olderAdmin) { | |||
// ensure authenticator has been provided for account | |||
authenticatorService.ensureAuthenticated(c.account, policy, ActionTarget.account); | |||
// if caller is younger admin, they must know the current password of the admin whose password they're trying to change | |||
if (!c.account.getHashedPassword().isCorrectPassword(request.getOldPassword())) { | |||
return invalid("err.currentPassword.invalid", "current password was invalid", ""); | |||
} | |||
final AccountMessage forgotPasswordMessage = forgotPasswordMessage(req, c.account, configuration); | |||
final List<AccountContact> requiredApprovals = policy.getRequiredApprovals(forgotPasswordMessage); | |||
final List<AccountContact> requiredExternalApprovals = policy.getRequiredExternalApprovals(forgotPasswordMessage); | |||
if (!requiredApprovals.isEmpty()) { | |||
if (requiredApprovals.stream().anyMatch(AccountContact::isAuthenticator)) { | |||
if (!request.hasTotpToken()) return invalid("err.totpToken.required"); | |||
authenticatorService.authenticate(c.account, policy, new AuthenticatorRequest() | |||
authenticatorService.authenticate(caller, policy, new AuthenticatorRequest() | |||
.setAccount(c.account.getUuid()) | |||
.setAuthenticate(true) | |||
.setToken(request.getTotpToken())); | |||
@@ -352,16 +374,14 @@ public class AccountsResource { | |||
} | |||
} | |||
if (!c.account.getHashedPassword().isCorrectPassword(request.getOldPassword())) { | |||
return invalid("err.currentPassword.invalid", "current password was invalid", ""); | |||
} | |||
// validate new password | |||
final ConstraintViolationBean passwordViolation = validatePassword(request.getNewPassword()); | |||
if (passwordViolation != null) return invalid(passwordViolation); | |||
// Update account | |||
final Account updated = accountDAO.update(c.account); | |||
if (c.caller.getUuid().equals(c.account.getUuid())) { | |||
sessionDAO.update(c.caller.getApiToken(), updated); | |||
// Set password, update account. Update session if caller is targeting self, otherwise invalidate all sessions | |||
final Account updated = accountDAO.update(c.account.setHashedPassword(new HashedPassword(request.getNewPassword()))); | |||
if (caller.getUuid().equals(c.account.getUuid())) { | |||
sessionDAO.update(caller.getApiToken(), updated); | |||
} else { | |||
sessionDAO.invalidateAllSessions(c.account.getUuid()); | |||
} | |||
@@ -237,25 +237,32 @@ public class AuthResource { | |||
final AccountContact authenticator = authFactors.stream().filter(AccountContact::isAuthenticator).findFirst().orElse(null); | |||
if (authenticator != null && request.hasTotpToken()) { | |||
// try totp token now | |||
authenticatorService.authenticate(account, policy, new AuthenticatorRequest() | |||
account.setToken(authenticatorService.authenticate(account, policy, new AuthenticatorRequest() | |||
.setAccount(account.getUuid()) | |||
.setAuthenticate(true) | |||
.setToken(request.getTotpToken())); | |||
.setToken(request.getTotpToken()))); | |||
authFactors.removeIf(AccountContact::isAuthenticator); | |||
} | |||
if (!empty(authFactors)) { | |||
final AccountMessage loginRequest = accountMessageDAO.create(new AccountMessage() | |||
.setAccount(account.getUuid()) | |||
.setNetwork(configuration.getThisNetwork().getUuid()) | |||
.setName(account.getUuid()) | |||
.setMessageType(AccountMessageType.request) | |||
.setAction(AccountAction.login) | |||
.setTarget(ActionTarget.account) | |||
.setRemoteHost(getRemoteHost(req)) | |||
); | |||
final AccountMessage loginRequest; | |||
if (authFactors.size() == 1 && authFactors.get(0) == authenticator) { | |||
// we have already authenticated, unless we didn't have a token | |||
if (!request.hasTotpToken()) return invalid("err.totpToken.required"); | |||
loginRequest = null; // should never happen | |||
} else { | |||
loginRequest = accountMessageDAO.create(new AccountMessage() | |||
.setAccount(account.getUuid()) | |||
.setNetwork(configuration.getThisNetwork().getUuid()) | |||
.setName(account.getUuid()) | |||
.setMessageType(AccountMessageType.request) | |||
.setAction(AccountAction.login) | |||
.setTarget(ActionTarget.account) | |||
.setRemoteHost(getRemoteHost(req)) | |||
); | |||
} | |||
return ok(new Account() | |||
.setName(account.getName()) | |||
.setLoginRequest(loginRequest.getUuid()) | |||
.setLoginRequest(loginRequest != null ? loginRequest.getUuid() : null) | |||
.setMultifactorAuth(AccountContact.mask(authFactors))); | |||
} | |||
} | |||
@@ -417,11 +424,23 @@ public class AuthResource { | |||
final Account account = optionalUserPrincipal(ctx); | |||
if (account == null) return invalid("err.logout.noSession"); | |||
if (all != null && all) { | |||
sessionDAO.invalidateAllSessions(account.getApiToken()); | |||
sessionDAO.invalidateAllSessions(account.getUuid()); | |||
} else { | |||
sessionDAO.invalidate(account.getApiToken()); | |||
} | |||
return ok_empty(); | |||
} | |||
@POST @Path(EP_LOGOUT+"/{id}") | |||
public Response logoutUserEverywhere(@Context ContainerRequest ctx, | |||
@PathParam("id") String id) { | |||
final Account account = optionalUserPrincipal(ctx); | |||
if (account == null) return invalid("err.logout.noSession"); | |||
if (!account.admin()) return forbidden(); | |||
final Account target = accountDAO.findById(id); | |||
if (target == null) return notFound(id); | |||
sessionDAO.invalidateAllSessions(id); | |||
return ok_empty(); | |||
} | |||
} |
@@ -154,10 +154,8 @@ public class MeResource { | |||
final ConstraintViolationBean passwordViolation = validatePassword(request.getNewPassword()); | |||
if (passwordViolation != null) return invalid(passwordViolation); | |||
caller.setHashedPassword(new HashedPassword(request.getNewPassword())); | |||
// Update account, and write back to session | |||
final Account updated = accountDAO.update(caller); | |||
// Set new password, update account, and write back to session | |||
final Account updated = accountDAO.update(caller.setHashedPassword(new HashedPassword(request.getNewPassword()))); | |||
sessionDAO.update(caller.getApiToken(), updated); | |||
return ok(updated); | |||
@@ -60,15 +60,15 @@ public class AuthenticatorService { | |||
public void ensureAuthenticated(ContainerRequest ctx, ActionTarget target) { | |||
final Account account = userPrincipal(ctx); | |||
final AccountPolicy policy = policyDAO.findSingleByAccount(account.getUuid()); | |||
checkAuth(account, policy, target); | |||
ensureAuthenticated(account, policy, target); | |||
} | |||
public void ensureAuthenticated(ContainerRequest ctx, AccountPolicy policy, ActionTarget target) { | |||
final Account account = userPrincipal(ctx); | |||
checkAuth(account, policy, target); | |||
ensureAuthenticated(account, policy, target); | |||
} | |||
private void checkAuth(Account account, AccountPolicy policy, ActionTarget target) { | |||
public void ensureAuthenticated(Account account, AccountPolicy policy, ActionTarget target) { | |||
if (policy == null || !policy.hasVerifiedAuthenticator()) return; | |||
if (target != null) { | |||
final AccountContact authenticator = policy.getAuthenticator(); | |||
@@ -52,7 +52,9 @@ jersey: | |||
- org.cobbzilla.wizard.filters | |||
providerPackages: | |||
- org.cobbzilla.wizard.exceptionmappers | |||
requestFilters: [ bubble.auth.BubbleAuthFilter ] | |||
requestFilters: | |||
- bubble.auth.BubbleAuthFilter | |||
- bubble.filters.BubbleRateLimitFilter | |||
responseFilters: | |||
- org.cobbzilla.wizard.filters.ScrubbableScrubber | |||
- org.cobbzilla.wizard.filters.EntityTypeHeaderResponseFilter | |||
@@ -72,3 +74,10 @@ letsencryptEmail: {{LETSENCRYPT_EMAIL}} | |||
localStorageDir: {{LOCALSTORAGE_BASE_DIR}} | |||
disallowedCountries: {{DISALLOWED_COUNTRIES}} | |||
rateLimits: | |||
- { limit: 200, interval: 3s, block: 5m } | |||
- { limit: 10000, interval: 1m, block: 5m } | |||
- { limit: 25000, interval: 10m, block: 1h } | |||
- { limit: 100000, interval: 1h, block: 24h } | |||
- { limit: 500000, interval: 6h, block: 96h } |
@@ -26,6 +26,7 @@ public class AuthTest extends ActivatedBubbleModelTestBase { | |||
@Test public void testRegistration () throws Exception { modelTest("auth/account_registration"); } | |||
@Test public void testForgotPassword () throws Exception { modelTest("auth/forgot_password"); } | |||
@Test public void testChangePassword () throws Exception { modelTest("auth/change_password"); } | |||
@Test public void testChangeAdminPassword () throws Exception { modelTest("auth/change_admin_password"); } | |||
@Test public void testMultifactorAuth () throws Exception { modelTest("auth/multifactor_auth"); } | |||
@Test public void testDownloadAccount () throws Exception { modelTest("auth/download_account"); } | |||
@Test public void testNetworkAuth () throws Exception { modelTest("auth/network_auth"); } | |||
@@ -0,0 +1,199 @@ | |||
[ | |||
{ | |||
"comment": "get root policy (creates it)", | |||
"request": { "uri": "users/root/policy" }, | |||
"response": { | |||
"store": "rootPolicy" | |||
} | |||
}, | |||
{ | |||
"comment": "update root policy, add authenticator", | |||
"include": "add_authenticator", | |||
"params": { | |||
"userId": "root", | |||
"authenticatorVar": "rootAuthenticator" | |||
} | |||
}, | |||
{ | |||
"comment": "as root user, create another admin user", | |||
"include": "new_account", | |||
"params": { | |||
"username": "admin-change_password", | |||
"email": "admin-change_password@example.com", | |||
"password": "bazquux1!", | |||
"admin": true, | |||
"verifyEmail": "true" | |||
} | |||
}, | |||
{ | |||
"comment": "login as second admin, verify OK", | |||
"request": { | |||
"session": "new", | |||
"uri": "auth/login", | |||
"entity": { | |||
"name": "admin-change_password", | |||
"password": "bazquux1!" | |||
} | |||
}, | |||
"response": { | |||
"sessionName": "adminSession", | |||
"session": "token" | |||
} | |||
}, | |||
{ | |||
"comment": "as second admin, read self, succeeds. verify we are admin", | |||
"request": { "uri": "me" }, | |||
"response": { | |||
"check": [ | |||
{"condition": "json.getName() === 'admin-change_password'"}, | |||
{"condition": "json.admin() === true"} | |||
] | |||
} | |||
}, | |||
{ | |||
"comment": "as second admin, try to change root password without sending current password, fails without TOTP token", | |||
"request": { | |||
"uri": "users/root/changePassword", | |||
"entity": { | |||
"newPassword": "wh00pDeDoo." | |||
} | |||
}, | |||
"response": { | |||
"status": 422, | |||
"check": [ {"condition": "json.has('err.totpToken.invalid')"} ] | |||
} | |||
}, | |||
{ | |||
"comment": "as second admin, try to change root password sending TOTP token, but without sending current password, fails password check", | |||
"request": { | |||
"uri": "users/root/changePassword", | |||
"entity": { | |||
"newPassword": "wh00pDeDoo.", | |||
"totpToken": "{{authenticator_token rootAuthenticator.totpKey}}" | |||
} | |||
}, | |||
"response": { | |||
"status": 422, | |||
"check": [ {"condition": "json.has('err.currentPassword.invalid')"} ] | |||
} | |||
}, | |||
{ | |||
"comment": "as admin user, try to change root user password sending current password and TOTP token, succeeds", | |||
"request": { | |||
"uri": "users/root/changePassword", | |||
"session": "adminSession", | |||
"entity": { | |||
"oldPassword": "password", | |||
"newPassword": "aNewRootPass1!", | |||
"totpToken": "{{authenticator_token rootAuthenticator.totpKey}}" | |||
} | |||
} | |||
}, | |||
{ | |||
"comment": "as root user, try to read self, fails because our session has been invalidated", | |||
"request": { | |||
"uri": "me", | |||
"session": "rootSession" | |||
}, | |||
"response": { "status": 403 } | |||
}, | |||
{ | |||
"comment": "login as root with old password, verify failure", | |||
"request": { | |||
"session": "new", | |||
"uri": "auth/login", | |||
"entity": { | |||
"name": "root", | |||
"password": "password" | |||
} | |||
}, | |||
"response": { | |||
"status": 404 | |||
} | |||
}, | |||
{ | |||
"comment": "login as root with new password and TOTP token, succeeds", | |||
"request": { | |||
"session": "new", | |||
"uri": "auth/login", | |||
"entity": { | |||
"name": "root", | |||
"password": "aNewRootPass1!", | |||
"totpToken": "{{authenticator_token rootAuthenticator.totpKey}}" | |||
} | |||
}, | |||
"response": { | |||
"sessionName": "rootSession", | |||
"session": "token" | |||
} | |||
}, | |||
{ | |||
"comment": "as root, read self, succeeds", | |||
"request": { "uri": "me" }, | |||
"response": { | |||
"check": [ {"condition": "json.getName() === 'root'"} ] | |||
} | |||
}, | |||
{ | |||
"comment": "as root user, try to change admin user password without sending current password, succeeds because we are senior admin", | |||
"request": { | |||
"uri": "users/admin-change_password/changePassword", | |||
"entity": { | |||
"newPassword": "newadminPASS1!" | |||
} | |||
} | |||
}, | |||
{ | |||
"comment": "login as second admin with old password, verify failure", | |||
"request": { | |||
"session": "new", | |||
"uri": "auth/login", | |||
"entity": { | |||
"name": "admin-change_password", | |||
"password": "bazquux1!" | |||
} | |||
}, | |||
"response": { | |||
"status": 404 | |||
} | |||
}, | |||
{ | |||
"comment": "login as second admin with new password, verify OK", | |||
"request": { | |||
"session": "new", | |||
"uri": "auth/login", | |||
"entity": { | |||
"name": "admin-change_password", | |||
"password": "newadminPASS1!" | |||
} | |||
}, | |||
"response": { | |||
"sessionName": "adminSession", | |||
"session": "token" | |||
} | |||
}, | |||
{ | |||
"comment": "as second admin, read self, succeeds", | |||
"request": { "uri": "me" }, | |||
"response": { | |||
"check": [ {"condition": "json.getName() === 'admin-change_password'"} ] | |||
} | |||
} | |||
] |
@@ -165,7 +165,10 @@ | |||
{ | |||
"comment": "as non-admin, read self-profile, succeeds", | |||
"request": { "uri": "me" } | |||
"request": { "uri": "me" }, | |||
"response": { | |||
"check": [ {"condition": "json.getName() === 'user-change_password'"} ] | |||
} | |||
}, | |||
{ | |||
@@ -397,104 +400,5 @@ | |||
"totpToken": "{{authenticator_token userAuthenticator.totpKey}}" | |||
} | |||
} | |||
}, | |||
{ | |||
"comment": "as root user, create another admin user", | |||
"include": "new_account", | |||
"params": { | |||
"username": "admin-change_password", | |||
"email": "admin-change_password@example.com", | |||
"password": "bazquux1!", | |||
"admin": true, | |||
"verifyEmail": "true" | |||
} | |||
}, | |||
{ | |||
"comment": "login as second admin, verify OK", | |||
"request": { | |||
"session": "new", | |||
"uri": "auth/login", | |||
"entity": { | |||
"name": "admin-change_password", | |||
"password": "bazquux1!" | |||
} | |||
}, | |||
"response": { | |||
"sessionName": "adminSession", | |||
"session": "token" | |||
} | |||
}, | |||
{ | |||
"comment": "as second admin, read self-profile, succeeds. verify we are admin", | |||
"request": { "uri": "me" }, | |||
"response": { | |||
"check": [ {"condition": "json.admin() == true"} ] | |||
} | |||
}, | |||
{ | |||
"comment": "as root user, try to change admin user password without sending current password, fails", | |||
"request": { | |||
"uri": "users/admin-change_password/changePassword", | |||
"session": "rootSession", | |||
"entity": { | |||
"newPassword": "newadminPASS!" | |||
} | |||
}, | |||
"response": { | |||
"status": 422, | |||
"check": [ {"condition": "json.has('err.currentPassword.invalid')"} ] | |||
} | |||
}, | |||
{ | |||
"comment": "as root user, try to change admin user password sending current password, succeeds", | |||
"request": { | |||
"uri": "users/admin-change_password/changePassword", | |||
"session": "rootSession", | |||
"entity": { | |||
"oldPassword": "bazquux1!", | |||
"newPassword": "newadminPASS2!" | |||
} | |||
} | |||
}, | |||
{ | |||
"comment": "login as second admin with old password, verify failure", | |||
"request": { | |||
"session": "new", | |||
"uri": "auth/login", | |||
"entity": { | |||
"name": "admin-change_password", | |||
"password": "bazquux1!" | |||
} | |||
}, | |||
"response": { | |||
"status": 404 | |||
} | |||
}, | |||
{ | |||
"comment": "login as second admin with new password, verify OK", | |||
"request": { | |||
"session": "new", | |||
"uri": "auth/login", | |||
"entity": { | |||
"name": "admin-change_password", | |||
"password": "newadminPASS2!" | |||
} | |||
}, | |||
"response": { | |||
"sessionName": "adminSession", | |||
"session": "token" | |||
} | |||
}, | |||
{ | |||
"comment": "as second admin, read self-profile, succeeds", | |||
"request": { "uri": "me" } | |||
} | |||
] |
@@ -3,6 +3,8 @@ | |||
serverName: bubble-api | |||
bcryptRounds: 2 | |||
#publicUriBase: https://127.0.0.1 | |||
publicUriBase: {{PUBLIC_BASE_URI}} | |||
@@ -1 +1 @@ | |||
Subproject commit e7c3727fc3e1405b3bba6b95de0271db23309e14 | |||
Subproject commit 12d7d0d1104ee1c2da52f2348a96b49232e0ed3b |
@@ -1 +1 @@ | |||
Subproject commit 00e779f2fe37045ca2a82669bcf29c4b81f74525 | |||
Subproject commit 88f19d61844a4fd7bce11f81021e798473b9f135 |