/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
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}