PageRenderTime 37ms CodeModel.GetById 15ms app.highlight 18ms RepoModel.GetById 0ms app.codeStats 0ms

/org.amdatu.security.account/src/org/amdatu/security/account/admin/AccountAdminImpl.java

https://bitbucket.org/amdatu/amdatu-security
Java | 500 lines | 346 code | 100 blank | 54 comment | 31 complexity | e0460bd62abe91ed9f9967320f9082c4 MD5 | raw file
  1/*
  2 * Licensed under the Apache License, Version 2.0 (the "License");
  3 * you may not use this file except in compliance with the License.
  4 * You may obtain a copy of the License at
  5 *
  6 *      http://www.apache.org/licenses/LICENSE-2.0
  7 *
  8 * Unless required by applicable law or agreed to in writing, software
  9 * distributed under the License is distributed on an "AS IS" BASIS,
 10 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 11 * See the License for the specific language governing permissions and
 12 * limitations under the License.
 13 */
 14package org.amdatu.security.account.admin;
 15
 16import static java.util.stream.Collectors.toMap;
 17import static org.amdatu.security.account.AccountConstants.KEY_ACCOUNT_ACCESS_TOKEN;
 18import static org.amdatu.security.account.AccountConstants.KEY_ACCOUNT_CREDENTIALS;
 19import static org.amdatu.security.account.AccountConstants.KEY_ACCOUNT_ID;
 20import static org.amdatu.security.account.AccountConstants.KEY_ACCOUNT_STATE;
 21import static org.amdatu.security.account.AccountConstants.TOPIC_ACCOUNT_CREATED;
 22import static org.amdatu.security.account.AccountConstants.TOPIC_ACCOUNT_REMOVED;
 23import static org.amdatu.security.account.AccountConstants.TOPIC_ACCOUNT_RESET_CREDENTIALS;
 24import static org.amdatu.security.account.AccountConstants.TOPIC_ACCOUNT_UPDATED;
 25import static org.amdatu.security.tokenprovider.TokenConstants.EXPIRATION_TIME;
 26import static org.amdatu.security.tokenprovider.TokenConstants.NOT_BEFORE;
 27import static org.amdatu.security.tokenprovider.TokenConstants.SUBJECT;
 28import static org.osgi.service.log.LogService.LOG_DEBUG;
 29import static org.osgi.service.log.LogService.LOG_INFO;
 30import static org.osgi.service.log.LogService.LOG_WARNING;
 31
 32import java.time.Instant;
 33import java.time.temporal.ChronoUnit;
 34import java.util.Dictionary;
 35import java.util.HashMap;
 36import java.util.Map;
 37import java.util.Objects;
 38import java.util.Optional;
 39import java.util.Set;
 40
 41import org.amdatu.security.account.Account;
 42import org.amdatu.security.account.Account.State;
 43import org.amdatu.security.account.AccountAdmin;
 44import org.amdatu.security.account.AccountAdminBackend;
 45import org.amdatu.security.account.AccountCredentialResetException;
 46import org.amdatu.security.account.AccountException;
 47import org.amdatu.security.account.AccountExistsException;
 48import org.amdatu.security.account.AccountLockedException;
 49import org.amdatu.security.account.AccountValidator;
 50import org.amdatu.security.account.NoSuchAccountException;
 51import org.amdatu.security.account.UnverifiedAccountException;
 52import org.amdatu.security.password.hash.PasswordHasher;
 53import org.amdatu.security.tokenprovider.TokenProvider;
 54import org.amdatu.security.tokenprovider.TokenProviderException;
 55import org.osgi.service.cm.ConfigurationException;
 56import org.osgi.service.cm.ManagedService;
 57import org.osgi.service.event.Event;
 58import org.osgi.service.event.EventAdmin;
 59import org.osgi.service.log.LogService;
 60
 61/**
 62 * Default implementation of {@link AccountAdmin}.
 63 */
 64public class AccountAdminImpl implements AccountAdmin, ManagedService {
 65    // Injected by Felix DM...
 66    private volatile AccountAdminBackend m_backend;
 67    private volatile AccountValidator m_accountValidator;
 68    private volatile PasswordHasher m_passwordHasher;
 69    private volatile TokenProvider m_tokenProvider;
 70    private volatile EventAdmin m_eventAdmin;
 71    private volatile LogService m_log;
 72    // Locally managed...
 73    private volatile AccountAdminConfig m_config;
 74
 75    /**
 76     * Creates a new {@link AccountAdminImpl} instance.
 77     */
 78    public AccountAdminImpl() {
 79        m_config = new AccountAdminConfig();
 80    }
 81
 82    /**
 83     * Creates a new {@link AccountAdminImpl} instance.
 84     */
 85    protected AccountAdminImpl(AccountAdminBackend backend, PasswordHasher passwordHasher) {
 86        m_backend = Objects.requireNonNull(backend);
 87        m_passwordHasher = Objects.requireNonNull(passwordHasher);
 88        m_config = new AccountAdminConfig();
 89    }
 90
 91    @Override
 92    public boolean accountExists(String accountId) {
 93        if (accountId == null) {
 94            return false;
 95        }
 96        return m_backend.accountExists(accountId);
 97    }
 98
 99    @Override
100    public Account createAccount(Map<String, String> credentials) throws AccountExistsException {
101        AccountAdminConfig cfg = m_config;
102
103        verifyCredentialsValid(cfg, credentials);
104
105        String accountId = credentials.get(cfg.getAccountIdKey());
106        if (m_backend.accountExists(accountId)) {
107            log(LOG_WARNING, "Invalid account creation: account already exist!");
108
109            throw new AccountExistsException();
110        }
111
112        String token;
113
114        try {
115            token = generateAccessToken(cfg, accountId);
116        }
117        catch (IllegalArgumentException | TokenProviderException e) {
118            log(LOG_WARNING, "Failed to generate access token for account: %s", e, accountId);
119
120            throw new AccountException("token_generation_failed");
121        }
122
123        State state = State.NORMAL;
124        if (cfg.isAccountVerificationNeeded()) {
125            state = State.ACCOUNT_VERIFICATION_NEEDED;
126        }
127
128        Account account =
129            new Account(accountId, hashSensitiveCredentials(cfg, credentials), state, token, false /* locked */);
130
131        validateAccount(account, credentials);
132
133        // Will throw an exception in case the creation fails...
134        m_backend.create(account);
135
136        // Notify others about this...
137        postEvent(TOPIC_ACCOUNT_CREATED, KEY_ACCOUNT_ID, accountId, KEY_ACCOUNT_STATE, account.getState(),
138            KEY_ACCOUNT_ACCESS_TOKEN, token, KEY_ACCOUNT_CREDENTIALS, account.getCredentials());
139
140        return account;
141    }
142
143    @Override
144    public Optional<Account> getAccount(Map<String, String> credentials)
145        throws AccountLockedException, AccountCredentialResetException {
146        AccountAdminConfig cfg = m_config;
147
148        // Work on a mutable copy of the given credentials...
149        Map<String, String> creds = new HashMap<>(credentials);
150
151        String accountId = creds.get(cfg.getAccountIdKey());
152        if (accountId == null) {
153            log(LOG_INFO, "Unable to retrieve account: credentials are missing property '%s'!", cfg.getAccountIdKey());
154
155            return Optional.empty();
156        }
157
158        return m_backend.get(accountId)
159            .map(account -> {
160                if (account.isLocked()) {
161                    throw new AccountLockedException();
162                }
163                else if (account.isForcedCredentialReset()) {
164                    throw new AccountCredentialResetException("reset_needed");
165                }
166                else if (account.isAccountVerificationNeeded()) {
167                    throw new UnverifiedAccountException();
168                }
169
170                boolean verificationResult = verifyCredentials(cfg, account.getCredentials(), creds);
171
172                if (verificationResult && account.isVoluntaryCredentialReset()) {
173                    // Account logged in successfully with existing credentials, which implies that a reset token wasn't necessary after all...
174                    account.clearAccessToken();
175
176                    // Store the changes...
177                    m_backend.update(account);
178                }
179                else if (!verificationResult) {
180                    // Nope, caller didn't prove he was the one...
181                    account = null;
182                }
183
184                return account;
185            });
186    }
187
188    @Override
189    public Account getAccountByAccessToken(String accessToken) throws AccountLockedException, NoSuchAccountException {
190        AccountAdminConfig cfg = m_config;
191
192        String accountId;
193        try {
194            accountId = getAccountIdFromAccessToken(accessToken);
195        }
196        catch (IllegalArgumentException | TokenProviderException e) {
197            log(LOG_INFO, "Attempting to get accountID from token failed!", e);
198
199            throw new NoSuchAccountException();
200        }
201
202        Account account = getExistingAccount(accountId);
203
204        verifyAccessToken(cfg, account, accessToken)
205            .ifPresent(reason -> {
206                log(LOG_INFO, "Attempting to get account for %s failed: %s!", account.getId(), reason);
207
208                throw new NoSuchAccountException();
209            });
210
211        return account;
212    }
213
214    @Override
215    public Account getAccountById(String accountId) throws AccountLockedException, NoSuchAccountException {
216        return getExistingAccount(accountId);
217    }
218
219    @Override
220    public Account removeAccount(String accountId) throws NoSuchAccountException, AccountException {
221        AccountAdminConfig cfg = m_config;
222
223        // This is an optional operation, and might not be desired in all use cases...
224        if (!cfg.isAccountRemovalAllowed()) {
225            throw new AccountException("not_allowed");
226        }
227
228        try {
229            Account account = getExistingAccount(accountId);
230
231            // Store the changes...
232            m_backend.remove(account);
233
234            // Notify other of this (for example, send an email or something)...
235            postEvent(TOPIC_ACCOUNT_REMOVED, KEY_ACCOUNT_ID, accountId, KEY_ACCOUNT_STATE, account.getState(),
236                KEY_ACCOUNT_CREDENTIALS, filterSensitiveCredentials(cfg, account.getCredentials()));
237
238            return account;
239        }
240        catch (UnsupportedOperationException e) {
241            log(LOG_WARNING, "Cannot remove account '%s': operation not supported by backend!", accountId);
242
243            throw new AccountException("not_allowed");
244        }
245    }
246
247    @Override
248    public Account resetCredentials(String accountId, boolean forced) {
249        AccountAdminConfig cfg = m_config;
250
251        try {
252            Account account = getExistingAccount(accountId);
253
254            // Generate the reset-credential token...
255            String token = generateAccessToken(cfg, accountId);
256
257            // Update the account...
258            account.resetCredentials(token, forced);
259
260            // Store the changes...
261            m_backend.update(account);
262
263            // Notify other of this (for example, send an email or something)...
264            postEvent(TOPIC_ACCOUNT_RESET_CREDENTIALS, KEY_ACCOUNT_ID, accountId, KEY_ACCOUNT_STATE, account.getState(),
265                KEY_ACCOUNT_ACCESS_TOKEN, token, KEY_ACCOUNT_CREDENTIALS,
266                filterSensitiveCredentials(cfg, account.getCredentials()));
267
268            return account;
269        }
270        catch (IllegalStateException e) {
271            log(LOG_WARNING, "Cannot reset credentials for unverified account: %s", e, accountId);
272
273            throw new AccountCredentialResetException("unverified_account");
274        }
275        catch (IllegalArgumentException | TokenProviderException e) {
276            log(LOG_WARNING, "Failed to generate reset credentials token for account: %s", e, accountId);
277
278            throw new AccountException("token_generation_failed");
279        }
280    }
281
282    @Override
283    public Account updateAccount(Map<String, String> oldCredentials, Map<String, String> newCredentials)
284        throws NoSuchAccountException {
285        AccountAdminConfig cfg = m_config;
286
287        Optional<Account> account = getAccount(oldCredentials);
288        if (!account.isPresent()) {
289            throw new NoSuchAccountException();
290        }
291
292        return updateCredentials(cfg, account.get(), newCredentials);
293    }
294
295    @Override
296    public Account updateAccount(Map<String, String> credentials, String resetToken)
297        throws NoSuchAccountException, AccountLockedException {
298        AccountAdminConfig cfg = m_config;
299
300        Account account = getAccountByAccessToken(resetToken);
301        // No need to verify the reset token twice: we can directly update the credentials for this one...
302        return updateCredentials(cfg, account, credentials);
303    }
304
305    @Override
306    public void updated(Dictionary<String, ?> properties) throws ConfigurationException {
307        AccountAdminConfig newConfig;
308        if (properties != null) {
309            newConfig = new AccountAdminConfig(properties);
310        }
311        else {
312            newConfig = new AccountAdminConfig();
313        }
314        m_config = newConfig;
315    }
316
317    @Override
318    public Account verifyAccount(String accountId, String verificationToken) throws NoSuchAccountException {
319        AccountAdminConfig cfg = m_config;
320
321        Account account = getExistingAccount(accountId);
322
323        verifyAccessToken(cfg, account, verificationToken)
324            .ifPresent(reason -> {
325                log(LOG_INFO, "Attempting to verify account for %s failed: %s!", account.getId(), reason);
326
327                throw new AccountException(reason);
328            });
329
330        return updateCredentials(cfg, account, account.getCredentials());
331    }
332
333    private Event createEvent(String topic, Object... eventProps) {
334        Map<String, Object> props = new HashMap<>();
335        for (int i = 0; i < eventProps.length; i += 2) {
336            props.put(eventProps[i].toString(), eventProps[i + 1]);
337        }
338        return new Event(topic, props);
339    }
340
341    private Map<String, String> filterSensitiveCredentials(AccountAdminConfig cfg, Map<String, String> credentials) {
342        Set<String> sensitiveCredentials = cfg.getSensitiveCredentials();
343        return credentials.entrySet().stream()
344            .filter(e -> !sensitiveCredentials.contains(e.getKey()))
345            .collect(toMap(Map.Entry::getKey, Map.Entry::getValue));
346    }
347
348    private String generateAccessToken(AccountAdminConfig cfg, String accountId) {
349        Instant notBefore = Instant.now();
350        Instant notAfter = notBefore.plus(cfg.getResetTokenLifetime(), ChronoUnit.HOURS);
351
352        Map<String, String> attrs = new HashMap<>();
353        attrs.put(SUBJECT, accountId);
354        attrs.put(NOT_BEFORE, Long.toString(notBefore.getEpochSecond()));
355        attrs.put(EXPIRATION_TIME, Long.toString(notAfter.getEpochSecond()));
356
357        return m_tokenProvider.generateToken(attrs);
358    }
359
360    private String getAccountIdFromAccessToken(String accessToken)
361        throws IllegalArgumentException, TokenProviderException {
362        Map<String, String> attrs = m_tokenProvider.verifyToken(accessToken);
363        return attrs.get(SUBJECT);
364    }
365
366    private Account getExistingAccount(String accountId) {
367        return m_backend.get(accountId)
368            .map(account -> {
369                if (account.isLocked()) {
370                    log(LOG_INFO, "Obtaining account for %s failed: account is locked!", accountId);
371
372                    throw new AccountLockedException();
373                }
374                return account;
375            }).orElseThrow(() -> {
376                log(LOG_INFO, "Obtaining account for %s failed: no such account!", accountId);
377
378                return new NoSuchAccountException();
379            });
380    }
381
382    private Map<String, String> hashSensitiveCredentials(AccountAdminConfig cfg, Map<String, String> credentials) {
383        Set<String> sensitiveCredentials = cfg.getSensitiveCredentials();
384        return credentials.entrySet().stream()
385            .collect(toMap(Map.Entry::getKey, e -> {
386                String value = e.getValue();
387                // By default we're going to hash anything that looks like a password...
388                if (sensitiveCredentials.contains(e.getKey()) && !m_passwordHasher.isHashed(value)) {
389                    value = m_passwordHasher.hash(value);
390                }
391                return value;
392            }));
393    }
394
395    private void log(int level, String msg, Object... args) {
396        log(level, msg, null, args);
397    }
398
399    private void log(int level, String msg, Throwable e, Object... args) {
400        m_log.log(level, String.format(msg, args), e);
401    }
402
403    private void postEvent(String topic, Object... eventProps) {
404        m_eventAdmin.postEvent(createEvent(topic, eventProps));
405    }
406
407    private Account updateCredentials(AccountAdminConfig cfg, Account account, Map<String, String> credentials) {
408        log(LOG_INFO, "Updating credentials for account: %s", account.getId());
409
410        account.clearAccessToken();
411        account.setCredentials(hashSensitiveCredentials(cfg, credentials));
412
413        // Will throw an exception in case validation fails...
414        validateAccount(account, credentials);
415
416        // Will throw an exception in case it fails to update the account...
417        m_backend.update(account);
418
419        log(LOG_DEBUG, "Credentials updated for account: %s", account.getId());
420
421        // Notify others of the changes in this account...
422        postEvent(TOPIC_ACCOUNT_UPDATED, KEY_ACCOUNT_ID, account.getId(), KEY_ACCOUNT_STATE, account.getState(),
423            KEY_ACCOUNT_CREDENTIALS, filterSensitiveCredentials(cfg, account.getCredentials()));
424
425        return account;
426    }
427
428    /**
429     * @param account the account to validate, cannot be <code>null</code>;
430     * @param credentials the <b>unhashed</b> credentials of the account.
431     */
432    private void validateAccount(Account account, Map<String, String> credentials) {
433        // work on a copy to avoid the original being modified (maliciously or accidentally)...
434        Account copy = new Account(account);
435        copy.setCredentials(credentials);
436
437        m_accountValidator.validate(copy);
438    }
439
440    private Optional<String> verifyAccessToken(AccountAdminConfig cfg, Account account, String accessToken) {
441        Optional<String> accountResetToken = account.getAccessToken();
442        if (!accountResetToken.isPresent()) {
443            return Optional.of("no_reset_token");
444        }
445
446        if (!accountResetToken.get().equals(accessToken)) {
447            return Optional.of("invalid_reset_token");
448        }
449
450        Map<String, String> attrs;
451        try {
452            attrs = m_tokenProvider.verifyToken(accessToken);
453        }
454        catch (IllegalArgumentException | TokenProviderException e) {
455            return Optional.of("invalid_reset_token");
456        }
457
458        if (!account.getId().equals(attrs.get(SUBJECT))) {
459            return Optional.of("invalid_reset_token");
460        }
461
462        // All preconditions are verified, we can "safely" update the account...
463        return Optional.empty();
464    }
465
466    private boolean verifyCredentials(AccountAdminConfig cfg, Map<String, String> expected,
467        Map<String, String> actual) {
468        Set<String> checkedCreds = cfg.getCheckedCredentials();
469
470        boolean verificationResult = true;
471        for (String key : checkedCreds) {
472            String expectedValue = expected.getOrDefault(key, "");
473            String actualValue = actual.getOrDefault(key, "");
474
475            // Do *not* break out of the loop in case the verification fails, this
476            // would make this routine susceptible to a timing attack. For instance,
477            // if the email credential is checked *prior* to the password credential,
478            // the fact that this method returns early might reveal this to a
479            // malicious user which can guess which accounts are available and which
480            // are not...
481
482            verificationResult &= m_passwordHasher.verify(expectedValue, actualValue);
483        }
484
485        return verificationResult;
486    }
487
488    private void verifyCredentialsValid(AccountAdminConfig cfg, Map<String, String> credentials) {
489        Set<String> checkedCreds = cfg.getCheckedCredentials();
490
491        boolean credentialValid = checkedCreds.stream()
492            .allMatch(k -> credentials.containsKey(k) && credentials.get(k) != null);
493
494        if (!credentialValid) {
495            log(LOG_WARNING, "Invalid account creation: not all required credentials present: " + checkedCreds + "!");
496
497            throw new AccountException();
498        }
499    }
500}