View Javadoc
1   /*
2    * The coLAB project
3    * Copyright (C) 2021-2023 AlbaSim, MEI, HEIG-VD, HES-SO
4    *
5    * Licensed under the MIT License
6    */
7   package ch.colabproject.colab.api.controller.user;
8   
9   import ch.colabproject.colab.api.Helper;
10  import ch.colabproject.colab.api.controller.RequestManager;
11  import ch.colabproject.colab.api.controller.ValidationManager;
12  import ch.colabproject.colab.api.controller.team.InstanceMakerManager;
13  import ch.colabproject.colab.api.controller.team.TeamManager;
14  import ch.colabproject.colab.api.controller.token.TokenManager;
15  import ch.colabproject.colab.api.exceptions.ColabMergeException;
16  import ch.colabproject.colab.api.model.user.Account;
17  import ch.colabproject.colab.api.model.user.AuthInfo;
18  import ch.colabproject.colab.api.model.user.AuthMethod;
19  import ch.colabproject.colab.api.model.user.HashMethod;
20  import ch.colabproject.colab.api.model.user.HttpSession;
21  import ch.colabproject.colab.api.model.user.LocalAccount;
22  import ch.colabproject.colab.api.model.user.SignUpInfo;
23  import ch.colabproject.colab.api.model.user.User;
24  import ch.colabproject.colab.api.persistence.jpa.user.AccountDao;
25  import ch.colabproject.colab.api.persistence.jpa.user.HttpSessionDao;
26  import ch.colabproject.colab.api.persistence.jpa.user.UserDao;
27  import ch.colabproject.colab.api.security.AuthenticationFailure;
28  import ch.colabproject.colab.api.security.SessionManager;
29  import ch.colabproject.colab.generator.model.exceptions.HttpErrorMessage;
30  import ch.colabproject.colab.generator.model.exceptions.MessageI18nKey;
31  import java.time.OffsetDateTime;
32  import java.util.List;
33  import java.util.Optional;
34  import java.util.stream.Collectors;
35  import javax.ejb.LocalBean;
36  import javax.ejb.Stateless;
37  import javax.ejb.TransactionAttribute;
38  import javax.ejb.TransactionAttributeType;
39  import javax.inject.Inject;
40  
41  import com.google.common.collect.Lists;
42  import org.slf4j.Logger;
43  import org.slf4j.LoggerFactory;
44  
45  /**
46   * Everything related to user management
47   *
48   * @author maxence
49   */
50  @LocalBean
51  @Stateless
52  public class UserManager {
53  
54      /**
55       * Default size of salt in bytes
56       */
57      private static final int SALT_LENGTH = 32;
58  
59      /**
60       * Max number of failed authentication allowed
61       */
62      private static final Long AUTHENTICATION_ATTEMPT_MAX = 25L;
63  
64      /**
65       * Number of second to wait to accept new authentication attempt if max number has been reached
66       */
67      private static final Long AUTHENTICATION_ATTEMPT_RESET_DELAY_SEC = 60 * 15L; // 15min
68  
69      /** logger */
70      private static final Logger logger = LoggerFactory.getLogger(UserManager.class);
71  
72      /**
73       * Request related logic
74       */
75      @Inject
76      private RequestManager requestManager;
77  
78      /** Session manager to keep trace of authentication attempts */
79      @Inject
80      private SessionManager sessionManager;
81  
82      /**
83       * some operation on users will create and send tokens
84       */
85      @Inject
86      private TokenManager tokenManager;
87  
88      /**
89       * Entity validation management
90       */
91      @Inject
92      private ValidationManager validationManager;
93  
94      /** Team specific logic management */
95      @Inject
96      private TeamManager teamManager;
97  
98      /** InstanceMaker specific logic management */
99      @Inject
100     private InstanceMakerManager instanceMakerManager;
101 
102     /** Account persistence handling */
103     @Inject
104     private AccountDao accountDao;
105 
106     /** User persistence handling */
107     @Inject
108     private UserDao userDao;
109 
110     /** Http session persistence handling */
111     @Inject
112     private HttpSessionDao httpSessionDao;
113 
114     // *********************************************************************************************
115     // find user
116     // *********************************************************************************************
117 
118     /**
119      * Retrieve the user. If not found, throw a {@link HttpErrorMessage}.
120      *
121      * @param userId the id of the user
122      *
123      * @return the user if found
124      *
125      * @throws HttpErrorMessage if the user was not found
126      */
127     public User assertAndGetUser(Long userId) {
128         User user = userDao.findUser(userId);
129 
130         if (user == null) {
131             logger.error("user #{} not found", userId);
132             throw HttpErrorMessage.dataError(MessageI18nKey.DATA_NOT_FOUND);
133         }
134 
135         return user;
136     }
137 
138     /**
139      * Find a user by id
140      *
141      * @param id id of the user
142      *
143      * @return the user or null if user not exist or current user has not right to read the user
144      */
145     public User getUserById(Long id) {
146         return userDao.findUser(id);
147     }
148 
149     /**
150      * Get the users related to the given project
151      *
152      * @param projectId the id of the project
153      *
154      * @return users list
155      */
156     public List<User> getUsersForProject(Long projectId) {
157         logger.debug("Get users of project #{}", projectId);
158 
159         List<User> teamMembers = teamManager.getUsersForProject(projectId);
160         List<User> instanceMakers = instanceMakerManager.getUsersForProject(projectId);
161 
162         List<User> allUsers = Lists.newArrayList();
163         allUsers.addAll(teamMembers);
164         allUsers.addAll(instanceMakers);
165 
166         return allUsers;
167     }
168 
169     /**
170      * Which authentication method and parameters should a user use to authenticate. If the
171      * identifier is an email address, it will return authMethod which match the localAccount linked
172      * with the email address. Otherwise, identifier is used as a username and the first
173      * LocalAccount of the user is used.
174      * <p>
175      * In case no LocalAccount has been found, authentication method with random parameters is
176      * returned. Such parameters may be used by clients to create brand-new account. This behavior
177      * prevents to easy account existence leaks.
178      *
179      * @param identifier {@link LocalAccount } email address or {@link User} username
180      *
181      * @return authentication method to use to authentication as email owner or new random one which
182      *         can be used to create a brand new localAccount
183      *
184      * @throws HttpErrorMessage badRequest if there is no identifier
185      */
186     public AuthMethod getAuthenticationMethod(String identifier) {
187         try {
188             return requestManager.sudo(() -> {
189                 if (identifier == null || identifier.isBlank()) {
190                     throw HttpErrorMessage.badRequest();
191                 } else {
192                     LocalAccount account = findLocalAccountByIdentifier(identifier);
193 
194                     if (account != null) {
195                         return new AuthMethod(account.getCurrentClientHashMethod(),
196                                 account.getClientSalt(),
197                                 account.getNextClientHashMethod(), account.getNewClientSalt());
198                     } else {
199                         // no account found, return random method
200                         // TODO: store it in a tmp cache
201                         return this.getDefaultRandomAuthenticationMethod();
202                     }
203                 }
204             });
205         } catch (Exception e) {
206             return this.getDefaultRandomAuthenticationMethod();
207         }
208     }
209 
210     /**
211      * Get default hash method with random salt
212      *
213      * @return a hash method and its parameters
214      */
215     public AuthMethod getDefaultRandomAuthenticationMethod() {
216         return new AuthMethod(Helper.getDefaultHashMethod(), Helper
217                 .generateHexSalt(SALT_LENGTH), null, null);
218     }
219 
220     /**
221      * {@link #createAdminUser(String, String, String)} within a brand-new transaction.
222      *
223      * @param username      username
224      * @param email         email address
225      * @param plainPassword plain text password
226      *
227      * @return a brand-new user to rule them all
228      */
229     @TransactionAttribute(TransactionAttributeType.REQUIRES_NEW)
230     public User createAdminUserTx(String username, String email, String plainPassword) {
231         return this.createAdminUser(username, email, plainPassword);
232     }
233 
234     /**
235      * Create a brand-new admin user, which can authenticate with a {@link LocalAccount}.
236      * <p>
237      * First create the user and the local account, then authenticate and grant admin rights.
238      *
239      * @param username      username
240      * @param email         email address
241      * @param plainPassword plain text password
242      *
243      * @return a brand-new user to rule them all
244      *
245      * @throws HttpErrorMessage if username is already taken
246      */
247     private User createAdminUser(String username, String email, String plainPassword) {
248         User admin = this.createUserWithLocalAccount(username, username /* username is also used as firstname */, email, plainPassword);
249 
250         LocalAccount account = (LocalAccount) admin.getAccounts().get(0);
251 
252         AuthInfo authInfo = new AuthInfo();
253         authInfo.setIdentifier(username);
254         authInfo.setMandatoryHash(
255                 Helper.bytesToHex(
256                         account.getCurrentClientHashMethod().hash(
257                                 plainPassword,
258                                 account.getClientSalt())));
259         this.authenticate(authInfo);
260 
261         this.grantAdminRight(admin.getId());
262         return admin;
263     }
264 
265     /**
266      * Create a brand-new user, which can authenticate with a {@link LocalAccount}.First,
267      * plainPassword will be hashed as any client should do. Then the
268      * {@link #signup(SignUpInfo) signup} method is called.
269      *
270      * @param username      username
271      * @param firstname     first name
272      * @param email         email address
273      * @param plainPassword plain text password
274      *
275      * @return a brand-new user
276      *
277      * @throws HttpErrorMessage if username is already taken
278      */
279     private User createUserWithLocalAccount(String username, String firstname, String email, String plainPassword) {
280         AuthMethod method = getDefaultRandomAuthenticationMethod();
281         byte[] hash = method.getMandatoryMethod().hash(plainPassword, method.getSalt());
282 
283         SignUpInfo signUpInfo = new SignUpInfo();
284 
285         signUpInfo.setUsername(username);
286         signUpInfo.setFirstname(firstname);
287         signUpInfo.setEmail(email);
288         signUpInfo.setHashMethod(method.getMandatoryMethod());
289         signUpInfo.setSalt(method.getSalt());
290         signUpInfo.setHash(Helper.bytesToHex(hash));
291 
292         return this.signup(signUpInfo);
293     }
294 
295     /**
296      * Create a new user with local account. An e-mail will be sent to user to verify its account
297      *
298      * @param signup all info to create a new account
299      *
300      * @return brand-new user embedding an LocalAccount
301      *
302      * @throws HttpErrorMessage if username is already taken
303      */
304     public User signup(SignUpInfo signup) {
305         // username already taken ?
306         User user = userDao.findUserByUsername(signup.getUsername());
307         if (user == null) {
308             // username not yet taken
309             LocalAccount account = accountDao.findLocalAccountByEmail(signup.getEmail());
310             // no local account with the given email address
311 
312             if (account == null) {
313                 if (!Helper.isEmailAddress(signup.getEmail())) {
314                     throw HttpErrorMessage.signUpFailed(MessageI18nKey.EMAIL_NOT_VALID);
315                 }
316 
317                 account = new LocalAccount();
318                 account.setClientSalt(signup.getSalt());
319                 account.setCurrentClientHashMethod(signup.getHashMethod());
320                 account.setEmail(signup.getEmail());
321                 account.setVerified(false);
322 
323                 account.setCurrentDbHashMethod(Helper.getDefaultHashMethod());
324                 this.shadowHash(account, signup.getHash());
325 
326                 user = new User();
327                 user.getAccounts().add((account));
328                 account.setUser(user);
329 
330                 user.setUsername(signup.getUsername());
331                 user.setFirstname(signup.getFirstname());
332                 user.setLastname(signup.getLastname());
333                 user.setAffiliation(signup.getAffiliation());
334                 user.setAgreedTime(OffsetDateTime.now());
335 
336                 validationManager.assertValid(user);
337                 validationManager.assertValid(account);
338 
339                 User persistedUser = userDao.persistUser(user);
340                 // flush changes to DB to check DB constraint
341                 // TODO: build some AfterTXCommit executor
342                 requestManager.flush();
343                 // new user with a local account should verify their e-mail address
344                 tokenManager.requestEmailAddressVerification(account, false);
345                 return persistedUser;
346             } else {
347                 // wait.... throwing something else here leaks account existence...
348                 // for security reason, give as little useful information as possible
349                 // the user is not allowed to know if the error concerns the username or the email
350                 // address
351                 throw HttpErrorMessage.signUpFailed(MessageI18nKey.IDENTIFIER_ALREADY_TAKEN);
352             }
353 
354         } else {
355             // for security reason, give as little useful information as possible
356             // the user is not allowed to know if the error concerns the username or the email
357             // address
358             throw HttpErrorMessage.signUpFailed(MessageI18nKey.IDENTIFIER_ALREADY_TAKEN);
359         }
360     }
361 
362     /**
363      * Try to authenticate user with given token
364      *
365      * @param authInfo authentication information
366      *
367      * @return just authenticated user of null is authentication did not succeed
368      *
369      * @throws HttpErrorMessage if authentication failed
370      */
371     public User authenticate(AuthInfo authInfo) {
372         LocalAccount account = findLocalAccountByIdentifier(authInfo.getIdentifier());
373 
374         if (account != null) {
375             HashMethod m = account.getCurrentDbHashMethod();
376             String mandatoryHash = authInfo.getMandatoryHash();
377 
378             if (mandatoryHash != null) {
379                 byte[] hash = m.hash(mandatoryHash, account.getDbSalt());
380 
381                 AuthenticationFailure aa = sessionManager.getAuthenticationAttempt(account);
382                 if (aa != null) {
383                     logger.warn("Attempt: {}", aa.getCounter());
384                     if (aa.getCounter() >= AUTHENTICATION_ATTEMPT_MAX) {
385                         // max number of failed attempts reached
386                         OffsetDateTime lastAttempt = aa.getTimestamp();
387                         OffsetDateTime delay = lastAttempt
388                                 .plusSeconds(AUTHENTICATION_ATTEMPT_RESET_DELAY_SEC);
389                         if (OffsetDateTime.now().isAfter(delay)) {
390                             // delay has been reached, user may try again
391                             sessionManager.resetAuthenticationAttemptHistory(account);
392                         } else {
393                             // user have to wait some time before any new attempt
394                             logger.warn(
395                                     "Account {} reached the max number of failed authentication",
396                                     account);
397                             throw HttpErrorMessage.tooManyAttempts();
398                         }
399                     }
400                 }
401 
402                 // Spotbugs reports a timing attack vulnerability using:
403                 // if (Arrays.equals(hash, account.getHashedPassword())) {
404                 // doing a full comparison of arrays makes it happy:
405                 if (Helper.constantTimeArrayEquals(hash, account.getHashedPassword())) {
406                     // authentication succeed
407                     /////////////////////////////////
408                     sessionManager.resetAuthenticationAttemptHistory(account);
409 
410                     boolean forceShadow = false;
411 
412                     // should rotate client method ?
413                     if (account.getNextClientHashMethod() != null
414                             && authInfo.getOptionalHash() != null) {
415                         // rotate method
416                         account.setClientSalt(account.getNewClientSalt());
417                         account.setNewClientSalt(null);
418                         account.setCurrentClientHashMethod(account.getNextClientHashMethod());
419                         account.setNextClientHashMethod(null);
420 
421                         // rotate provided hash and force to compute and save db hash
422                         mandatoryHash = authInfo.getOptionalHash();
423                         forceShadow = true;
424                     }
425 
426                     // should rotate server method ?
427                     if (account.getNextDbHashMethod() != null) {
428                         // rotate method
429                         account.setCurrentDbHashMethod(account.getNextDbHashMethod());
430                         account.setNextDbHashMethod(null);
431                         // force to compute and save db hash
432                         forceShadow = true;
433                     }
434 
435                     if (forceShadow) {
436                         this.shadowHash(account, mandatoryHash);
437                     }
438 
439                     requestManager.login(account);
440 
441                     return account.getUser();
442                 } else {
443                     // update cache of failed authentication attempt
444                     sessionManager.authenticationFailure(account);
445                 }
446             }
447         }
448 
449         // authentication fails
450         // OR client did not provide required hash
451         // OR account not found
452         throw HttpErrorMessage.authenticationFailed();
453     }
454 
455     /**
456      * Update password of the given account. The given password should be hashed by the client
457      *
458      * @param authInfo contains account identifier and new password
459      *
460      * @throws HttpErrorMessage if currentUser is not allowed to update the given account
461      */
462     public void updatePassword(AuthInfo authInfo) {
463         LocalAccount account = findLocalAccountByIdentifier(authInfo.getIdentifier());
464 
465         if (account != null) {
466             String mandatoryHash = authInfo.getMandatoryHash();
467 
468             if (mandatoryHash != null) {
469 
470                 // should rotate server method ?
471                 if (account.getNextDbHashMethod() != null) {
472                     // rotate method
473                     account.setCurrentDbHashMethod(account.getNextDbHashMethod());
474                     account.setNextDbHashMethod(null);
475                     // force to compute and save db hash
476                 }
477 
478                 this.shadowHash(account, mandatoryHash);
479                 return;
480             }
481         }
482 
483         throw HttpErrorMessage.forbidden();
484     }
485 
486     /**
487      * hash given client-side hash to dbHash and store it
488      *
489      * @param account  account to update the hash in
490      * @param hash hash (ie account.clientMethod.hash(clientSalt + plain_password))
491      */
492     private void shadowHash(LocalAccount account, String hash) {
493         // use a new salt
494         account.setDbSalt(Helper.generateSalt(SALT_LENGTH));
495         // compute new hash and save it
496         byte[] newHash = account.getCurrentDbHashMethod().hash(hash, account.getDbSalt());
497         account.setHashedPassword(newHash);
498     }
499 
500     /**
501      * Log current user out
502      */
503     public void logout() {
504         // clear account from http session
505         this.requestManager.logout();
506     }
507 
508     /**
509      * Force session logout
510      *
511      * @param sessionId id of session to logout
512      */
513     public void forceLogout(Long sessionId) {
514         HttpSession httpSession = httpSessionDao.findHttpSession(sessionId);
515         HttpSession currentSession = requestManager.getHttpSession();
516         if (httpSession != null) {
517             if (!httpSession.equals(currentSession)) {
518                 requestManager.sudo(() -> sessionManager.deleteHttpSession(httpSession));
519             } else {
520                 throw HttpErrorMessage.badRequest();
521             }
522         }
523     }
524 
525     /**
526      * Grant admin right to a user.
527      *
528      * @param user user who will become an admin
529      */
530     public void grantAdminRight(User user) {
531         user.setAdmin(true);
532     }
533 
534     /**
535      * Grant admin right to a user.
536      *
537      * @param id id of user who will become an admin
538      */
539     public void grantAdminRight(Long id) {
540         this.grantAdminRight(userDao.findUser(id));
541     }
542 
543     /**
544      * Revoke admin right to a user.
545      *
546      * @param id id of user who will not be an admin any longer
547      */
548     public void revokeAdminRight(Long id) {
549         this.revokeAdminRight(userDao.findUser(id));
550     }
551 
552     /**
553      * revoke admin right to a user.
554      *
555      * @param user user who will not be an admin any longer
556      */
557     public void revokeAdminRight(User user) {
558         if (user != null) {
559             User currentUser = requestManager.getCurrentUser();
560             if (user.equals(currentUser)) {
561                 // user shall not remove admin right to itself
562                 throw HttpErrorMessage.badRequest();
563             } else {
564                 user.setAdmin(false);
565             }
566         }
567     }
568 
569     /**
570      * Setup new client hash method to use for the given local account
571      *
572      * @param id id of the LocalAccount
573      */
574     public void switchClientHashMethod(Long id) {
575         Account account = accountDao.findAccount(id);
576         if (account instanceof LocalAccount) {
577             LocalAccount localAccount = (LocalAccount) account;
578             AuthMethod authMethod = getDefaultRandomAuthenticationMethod();
579             localAccount.setNextClientHashMethod(authMethod.getMandatoryMethod());
580             localAccount.setNewClientSalt(authMethod.getSalt());
581         }
582     }
583 
584     /**
585      * Setup new server hash method to use for the given local account
586      *
587      * @param id id of the LocalAccount
588      */
589     public void switchServerHashMethod(Long id) {
590         Account account = accountDao.findAccount(id);
591         if (account instanceof LocalAccount) {
592             LocalAccount localAccount = (LocalAccount) account;
593             AuthMethod authMethod = getDefaultRandomAuthenticationMethod();
594             localAccount.setNextDbHashMethod(authMethod.getMandatoryMethod());
595         }
596     }
597 
598     /**
599      * Find local account by identifier.
600      *
601      * @param identifier email address or username
602      *
603      * @return LocalAccount
604      */
605     public LocalAccount findLocalAccountByIdentifier(String identifier) {
606         LocalAccount account = accountDao.findLocalAccountByEmail(identifier);
607 
608         if (account == null) {
609             // no localAccount with such an email address
610             // try to find a user by username
611             User user = userDao.findUserByUsername(identifier);
612             if (user != null) {
613                 // User found, as authenticationMethod is only available for LocalAccount,
614                 // try to find one
615                 Optional<Account> optAccount = user.getAccounts().stream()
616                         .filter(a -> a instanceof LocalAccount)
617                         .findFirst();
618                 if (optAccount.isPresent()) {
619                     account = (LocalAccount) optAccount.get();
620                 }
621             }
622         }
623         return account;
624     }
625 
626     /**
627      * If the given email address is linked to a local account, sent a link to this mailbox to reset
628      * the account password.
629      *
630      * @param email email address used as account identifier
631      */
632     public void requestPasswordReset(String email) {
633         logger.debug("Request reset password: {}", email);
634         LocalAccount account = accountDao.findLocalAccountByEmail(email);
635         if (account != null) {
636             // account exists, send the message
637             tokenManager.sendResetPasswordToken(account, true);
638         }
639     }
640 
641     /**
642      * Update the account with values provided in given account.Only field which are editable by
643      * users will be impacted.
644      *
645      * @param account account new values
646      *
647      * @return updated account
648      *
649      * @throws HttpErrorMessage    if the provided email is not a valid email
650      * @throws ColabMergeException if something went wrong
651      */
652     public LocalAccount updateLocalAccountEmailAddress(LocalAccount account)
653             throws ColabMergeException {
654         logger.debug("Update LocalAccount email address: {}", account);
655         LocalAccount managedAccount = (LocalAccount) accountDao.findAccount(account.getId());
656 
657         String currentEmail = managedAccount.getEmail();
658         String newEmail = account.getEmail();
659 
660         if (newEmail != null && !newEmail.equals(currentEmail)) {
661             if (!Helper.isEmailAddress(newEmail)) {
662                 throw HttpErrorMessage.dataError(MessageI18nKey.DATA_INTEGRITY_FAILURE);
663             }
664 
665             try {
666                 managedAccount.setVerified(false);
667                 managedAccount.setEmail(newEmail);
668                 // make sure to flush changes to database. It will check index uniqueness
669                 requestManager.flush();
670                 tokenManager.requestEmailAddressVerification(account, false);
671             } catch (Exception e) {
672                 // address already used, do not send any email to this address
673                 logger.error("Exception", e);
674             }
675         }
676 
677         return managedAccount;
678     }
679 
680     /**
681      * Set the account as verified
682      *
683      * @param account the account to change
684      */
685     public void setLocalAccountAsVerified(LocalAccount account) {
686         account.setVerified(Boolean.TRUE);
687     }
688 
689     /**
690      * Update the user agreedTime to now
691      *
692      * @param userId id of the user to update
693      */
694     public void updateUserAgreedTime(Long userId) {
695         User user = assertAndGetUser(userId);
696         OffsetDateTime now = OffsetDateTime.now();
697         user.setAgreedTime(now);
698     }
699 
700     /**
701      * Get all session linked to the current user
702      *
703      * @return list of all active sessions
704      */
705     public List<HttpSession> getCurrentUserActiveHttpSessions() {
706         return requestManager.getCurrentUser().getAccounts().stream()
707                 .flatMap(account -> account.getHttpSessions().stream()).collect(Collectors.toList());
708     }
709 }