/org.amdatu.security.account/src/org/amdatu/security/account/admin/AccountAdminImpl.java
Java | 500 lines | 346 code | 100 blank | 54 comment | 31 complexity | e0460bd62abe91ed9f9967320f9082c4 MD5 | raw file
- /*
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
- package org.amdatu.security.account.admin;
- import static java.util.stream.Collectors.toMap;
- import static org.amdatu.security.account.AccountConstants.KEY_ACCOUNT_ACCESS_TOKEN;
- import static org.amdatu.security.account.AccountConstants.KEY_ACCOUNT_CREDENTIALS;
- import static org.amdatu.security.account.AccountConstants.KEY_ACCOUNT_ID;
- import static org.amdatu.security.account.AccountConstants.KEY_ACCOUNT_STATE;
- import static org.amdatu.security.account.AccountConstants.TOPIC_ACCOUNT_CREATED;
- import static org.amdatu.security.account.AccountConstants.TOPIC_ACCOUNT_REMOVED;
- import static org.amdatu.security.account.AccountConstants.TOPIC_ACCOUNT_RESET_CREDENTIALS;
- import static org.amdatu.security.account.AccountConstants.TOPIC_ACCOUNT_UPDATED;
- import static org.amdatu.security.tokenprovider.TokenConstants.EXPIRATION_TIME;
- import static org.amdatu.security.tokenprovider.TokenConstants.NOT_BEFORE;
- import static org.amdatu.security.tokenprovider.TokenConstants.SUBJECT;
- import static org.osgi.service.log.LogService.LOG_DEBUG;
- import static org.osgi.service.log.LogService.LOG_INFO;
- import static org.osgi.service.log.LogService.LOG_WARNING;
- import java.time.Instant;
- import java.time.temporal.ChronoUnit;
- import java.util.Dictionary;
- import java.util.HashMap;
- import java.util.Map;
- import java.util.Objects;
- import java.util.Optional;
- import java.util.Set;
- import org.amdatu.security.account.Account;
- import org.amdatu.security.account.Account.State;
- import org.amdatu.security.account.AccountAdmin;
- import org.amdatu.security.account.AccountAdminBackend;
- import org.amdatu.security.account.AccountCredentialResetException;
- import org.amdatu.security.account.AccountException;
- import org.amdatu.security.account.AccountExistsException;
- import org.amdatu.security.account.AccountLockedException;
- import org.amdatu.security.account.AccountValidator;
- import org.amdatu.security.account.NoSuchAccountException;
- import org.amdatu.security.account.UnverifiedAccountException;
- import org.amdatu.security.password.hash.PasswordHasher;
- import org.amdatu.security.tokenprovider.TokenProvider;
- import org.amdatu.security.tokenprovider.TokenProviderException;
- import org.osgi.service.cm.ConfigurationException;
- import org.osgi.service.cm.ManagedService;
- import org.osgi.service.event.Event;
- import org.osgi.service.event.EventAdmin;
- import org.osgi.service.log.LogService;
- /**
- * Default implementation of {@link AccountAdmin}.
- */
- public class AccountAdminImpl implements AccountAdmin, ManagedService {
- // Injected by Felix DM...
- private volatile AccountAdminBackend m_backend;
- private volatile AccountValidator m_accountValidator;
- private volatile PasswordHasher m_passwordHasher;
- private volatile TokenProvider m_tokenProvider;
- private volatile EventAdmin m_eventAdmin;
- private volatile LogService m_log;
- // Locally managed...
- private volatile AccountAdminConfig m_config;
- /**
- * Creates a new {@link AccountAdminImpl} instance.
- */
- public AccountAdminImpl() {
- m_config = new AccountAdminConfig();
- }
- /**
- * Creates a new {@link AccountAdminImpl} instance.
- */
- protected AccountAdminImpl(AccountAdminBackend backend, PasswordHasher passwordHasher) {
- m_backend = Objects.requireNonNull(backend);
- m_passwordHasher = Objects.requireNonNull(passwordHasher);
- m_config = new AccountAdminConfig();
- }
- @Override
- public boolean accountExists(String accountId) {
- if (accountId == null) {
- return false;
- }
- return m_backend.accountExists(accountId);
- }
- @Override
- public Account createAccount(Map<String, String> credentials) throws AccountExistsException {
- AccountAdminConfig cfg = m_config;
- verifyCredentialsValid(cfg, credentials);
- String accountId = credentials.get(cfg.getAccountIdKey());
- if (m_backend.accountExists(accountId)) {
- log(LOG_WARNING, "Invalid account creation: account already exist!");
- throw new AccountExistsException();
- }
- String token;
- try {
- token = generateAccessToken(cfg, accountId);
- }
- catch (IllegalArgumentException | TokenProviderException e) {
- log(LOG_WARNING, "Failed to generate access token for account: %s", e, accountId);
- throw new AccountException("token_generation_failed");
- }
- State state = State.NORMAL;
- if (cfg.isAccountVerificationNeeded()) {
- state = State.ACCOUNT_VERIFICATION_NEEDED;
- }
- Account account =
- new Account(accountId, hashSensitiveCredentials(cfg, credentials), state, token, false /* locked */);
- validateAccount(account, credentials);
- // Will throw an exception in case the creation fails...
- m_backend.create(account);
- // Notify others about this...
- postEvent(TOPIC_ACCOUNT_CREATED, KEY_ACCOUNT_ID, accountId, KEY_ACCOUNT_STATE, account.getState(),
- KEY_ACCOUNT_ACCESS_TOKEN, token, KEY_ACCOUNT_CREDENTIALS, account.getCredentials());
- return account;
- }
- @Override
- public Optional<Account> getAccount(Map<String, String> credentials)
- throws AccountLockedException, AccountCredentialResetException {
- AccountAdminConfig cfg = m_config;
- // Work on a mutable copy of the given credentials...
- Map<String, String> creds = new HashMap<>(credentials);
- String accountId = creds.get(cfg.getAccountIdKey());
- if (accountId == null) {
- log(LOG_INFO, "Unable to retrieve account: credentials are missing property '%s'!", cfg.getAccountIdKey());
- return Optional.empty();
- }
- return m_backend.get(accountId)
- .map(account -> {
- if (account.isLocked()) {
- throw new AccountLockedException();
- }
- else if (account.isForcedCredentialReset()) {
- throw new AccountCredentialResetException("reset_needed");
- }
- else if (account.isAccountVerificationNeeded()) {
- throw new UnverifiedAccountException();
- }
- boolean verificationResult = verifyCredentials(cfg, account.getCredentials(), creds);
- if (verificationResult && account.isVoluntaryCredentialReset()) {
- // Account logged in successfully with existing credentials, which implies that a reset token wasn't necessary after all...
- account.clearAccessToken();
- // Store the changes...
- m_backend.update(account);
- }
- else if (!verificationResult) {
- // Nope, caller didn't prove he was the one...
- account = null;
- }
- return account;
- });
- }
- @Override
- public Account getAccountByAccessToken(String accessToken) throws AccountLockedException, NoSuchAccountException {
- AccountAdminConfig cfg = m_config;
- String accountId;
- try {
- accountId = getAccountIdFromAccessToken(accessToken);
- }
- catch (IllegalArgumentException | TokenProviderException e) {
- log(LOG_INFO, "Attempting to get accountID from token failed!", e);
- throw new NoSuchAccountException();
- }
- Account account = getExistingAccount(accountId);
- verifyAccessToken(cfg, account, accessToken)
- .ifPresent(reason -> {
- log(LOG_INFO, "Attempting to get account for %s failed: %s!", account.getId(), reason);
- throw new NoSuchAccountException();
- });
- return account;
- }
- @Override
- public Account getAccountById(String accountId) throws AccountLockedException, NoSuchAccountException {
- return getExistingAccount(accountId);
- }
- @Override
- public Account removeAccount(String accountId) throws NoSuchAccountException, AccountException {
- AccountAdminConfig cfg = m_config;
- // This is an optional operation, and might not be desired in all use cases...
- if (!cfg.isAccountRemovalAllowed()) {
- throw new AccountException("not_allowed");
- }
- try {
- Account account = getExistingAccount(accountId);
- // Store the changes...
- m_backend.remove(account);
- // Notify other of this (for example, send an email or something)...
- postEvent(TOPIC_ACCOUNT_REMOVED, KEY_ACCOUNT_ID, accountId, KEY_ACCOUNT_STATE, account.getState(),
- KEY_ACCOUNT_CREDENTIALS, filterSensitiveCredentials(cfg, account.getCredentials()));
- return account;
- }
- catch (UnsupportedOperationException e) {
- log(LOG_WARNING, "Cannot remove account '%s': operation not supported by backend!", accountId);
- throw new AccountException("not_allowed");
- }
- }
- @Override
- public Account resetCredentials(String accountId, boolean forced) {
- AccountAdminConfig cfg = m_config;
- try {
- Account account = getExistingAccount(accountId);
- // Generate the reset-credential token...
- String token = generateAccessToken(cfg, accountId);
- // Update the account...
- account.resetCredentials(token, forced);
- // Store the changes...
- m_backend.update(account);
- // Notify other of this (for example, send an email or something)...
- postEvent(TOPIC_ACCOUNT_RESET_CREDENTIALS, KEY_ACCOUNT_ID, accountId, KEY_ACCOUNT_STATE, account.getState(),
- KEY_ACCOUNT_ACCESS_TOKEN, token, KEY_ACCOUNT_CREDENTIALS,
- filterSensitiveCredentials(cfg, account.getCredentials()));
- return account;
- }
- catch (IllegalStateException e) {
- log(LOG_WARNING, "Cannot reset credentials for unverified account: %s", e, accountId);
- throw new AccountCredentialResetException("unverified_account");
- }
- catch (IllegalArgumentException | TokenProviderException e) {
- log(LOG_WARNING, "Failed to generate reset credentials token for account: %s", e, accountId);
- throw new AccountException("token_generation_failed");
- }
- }
- @Override
- public Account updateAccount(Map<String, String> oldCredentials, Map<String, String> newCredentials)
- throws NoSuchAccountException {
- AccountAdminConfig cfg = m_config;
- Optional<Account> account = getAccount(oldCredentials);
- if (!account.isPresent()) {
- throw new NoSuchAccountException();
- }
- return updateCredentials(cfg, account.get(), newCredentials);
- }
- @Override
- public Account updateAccount(Map<String, String> credentials, String resetToken)
- throws NoSuchAccountException, AccountLockedException {
- AccountAdminConfig cfg = m_config;
- Account account = getAccountByAccessToken(resetToken);
- // No need to verify the reset token twice: we can directly update the credentials for this one...
- return updateCredentials(cfg, account, credentials);
- }
- @Override
- public void updated(Dictionary<String, ?> properties) throws ConfigurationException {
- AccountAdminConfig newConfig;
- if (properties != null) {
- newConfig = new AccountAdminConfig(properties);
- }
- else {
- newConfig = new AccountAdminConfig();
- }
- m_config = newConfig;
- }
- @Override
- public Account verifyAccount(String accountId, String verificationToken) throws NoSuchAccountException {
- AccountAdminConfig cfg = m_config;
- Account account = getExistingAccount(accountId);
- verifyAccessToken(cfg, account, verificationToken)
- .ifPresent(reason -> {
- log(LOG_INFO, "Attempting to verify account for %s failed: %s!", account.getId(), reason);
- throw new AccountException(reason);
- });
- return updateCredentials(cfg, account, account.getCredentials());
- }
- private Event createEvent(String topic, Object... eventProps) {
- Map<String, Object> props = new HashMap<>();
- for (int i = 0; i < eventProps.length; i += 2) {
- props.put(eventProps[i].toString(), eventProps[i + 1]);
- }
- return new Event(topic, props);
- }
- private Map<String, String> filterSensitiveCredentials(AccountAdminConfig cfg, Map<String, String> credentials) {
- Set<String> sensitiveCredentials = cfg.getSensitiveCredentials();
- return credentials.entrySet().stream()
- .filter(e -> !sensitiveCredentials.contains(e.getKey()))
- .collect(toMap(Map.Entry::getKey, Map.Entry::getValue));
- }
- private String generateAccessToken(AccountAdminConfig cfg, String accountId) {
- Instant notBefore = Instant.now();
- Instant notAfter = notBefore.plus(cfg.getResetTokenLifetime(), ChronoUnit.HOURS);
- Map<String, String> attrs = new HashMap<>();
- attrs.put(SUBJECT, accountId);
- attrs.put(NOT_BEFORE, Long.toString(notBefore.getEpochSecond()));
- attrs.put(EXPIRATION_TIME, Long.toString(notAfter.getEpochSecond()));
- return m_tokenProvider.generateToken(attrs);
- }
- private String getAccountIdFromAccessToken(String accessToken)
- throws IllegalArgumentException, TokenProviderException {
- Map<String, String> attrs = m_tokenProvider.verifyToken(accessToken);
- return attrs.get(SUBJECT);
- }
- private Account getExistingAccount(String accountId) {
- return m_backend.get(accountId)
- .map(account -> {
- if (account.isLocked()) {
- log(LOG_INFO, "Obtaining account for %s failed: account is locked!", accountId);
- throw new AccountLockedException();
- }
- return account;
- }).orElseThrow(() -> {
- log(LOG_INFO, "Obtaining account for %s failed: no such account!", accountId);
- return new NoSuchAccountException();
- });
- }
- private Map<String, String> hashSensitiveCredentials(AccountAdminConfig cfg, Map<String, String> credentials) {
- Set<String> sensitiveCredentials = cfg.getSensitiveCredentials();
- return credentials.entrySet().stream()
- .collect(toMap(Map.Entry::getKey, e -> {
- String value = e.getValue();
- // By default we're going to hash anything that looks like a password...
- if (sensitiveCredentials.contains(e.getKey()) && !m_passwordHasher.isHashed(value)) {
- value = m_passwordHasher.hash(value);
- }
- return value;
- }));
- }
- private void log(int level, String msg, Object... args) {
- log(level, msg, null, args);
- }
- private void log(int level, String msg, Throwable e, Object... args) {
- m_log.log(level, String.format(msg, args), e);
- }
- private void postEvent(String topic, Object... eventProps) {
- m_eventAdmin.postEvent(createEvent(topic, eventProps));
- }
- private Account updateCredentials(AccountAdminConfig cfg, Account account, Map<String, String> credentials) {
- log(LOG_INFO, "Updating credentials for account: %s", account.getId());
- account.clearAccessToken();
- account.setCredentials(hashSensitiveCredentials(cfg, credentials));
- // Will throw an exception in case validation fails...
- validateAccount(account, credentials);
- // Will throw an exception in case it fails to update the account...
- m_backend.update(account);
- log(LOG_DEBUG, "Credentials updated for account: %s", account.getId());
- // Notify others of the changes in this account...
- postEvent(TOPIC_ACCOUNT_UPDATED, KEY_ACCOUNT_ID, account.getId(), KEY_ACCOUNT_STATE, account.getState(),
- KEY_ACCOUNT_CREDENTIALS, filterSensitiveCredentials(cfg, account.getCredentials()));
- return account;
- }
- /**
- * @param account the account to validate, cannot be <code>null</code>;
- * @param credentials the <b>unhashed</b> credentials of the account.
- */
- private void validateAccount(Account account, Map<String, String> credentials) {
- // work on a copy to avoid the original being modified (maliciously or accidentally)...
- Account copy = new Account(account);
- copy.setCredentials(credentials);
- m_accountValidator.validate(copy);
- }
- private Optional<String> verifyAccessToken(AccountAdminConfig cfg, Account account, String accessToken) {
- Optional<String> accountResetToken = account.getAccessToken();
- if (!accountResetToken.isPresent()) {
- return Optional.of("no_reset_token");
- }
- if (!accountResetToken.get().equals(accessToken)) {
- return Optional.of("invalid_reset_token");
- }
- Map<String, String> attrs;
- try {
- attrs = m_tokenProvider.verifyToken(accessToken);
- }
- catch (IllegalArgumentException | TokenProviderException e) {
- return Optional.of("invalid_reset_token");
- }
- if (!account.getId().equals(attrs.get(SUBJECT))) {
- return Optional.of("invalid_reset_token");
- }
- // All preconditions are verified, we can "safely" update the account...
- return Optional.empty();
- }
- private boolean verifyCredentials(AccountAdminConfig cfg, Map<String, String> expected,
- Map<String, String> actual) {
- Set<String> checkedCreds = cfg.getCheckedCredentials();
- boolean verificationResult = true;
- for (String key : checkedCreds) {
- String expectedValue = expected.getOrDefault(key, "");
- String actualValue = actual.getOrDefault(key, "");
- // Do *not* break out of the loop in case the verification fails, this
- // would make this routine susceptible to a timing attack. For instance,
- // if the email credential is checked *prior* to the password credential,
- // the fact that this method returns early might reveal this to a
- // malicious user which can guess which accounts are available and which
- // are not...
- verificationResult &= m_passwordHasher.verify(expectedValue, actualValue);
- }
- return verificationResult;
- }
- private void verifyCredentialsValid(AccountAdminConfig cfg, Map<String, String> credentials) {
- Set<String> checkedCreds = cfg.getCheckedCredentials();
- boolean credentialValid = checkedCreds.stream()
- .allMatch(k -> credentials.containsKey(k) && credentials.get(k) != null);
- if (!credentialValid) {
- log(LOG_WARNING, "Invalid account creation: not all required credentials present: " + checkedCreds + "!");
- throw new AccountException();
- }
- }
- }