/** * Copyright (C) 2011 JTalks.org Team * This library is free software; you can redistribute it and/or * modify it under the terms of the GNU Lesser General Public * License as published by the Free Software Foundation; either * version 2.1 of the License, or (at your option) any later version. * This library is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU * Lesser General Public License for more details. * You should have received a copy of the GNU Lesser General Public * License along with this library; if not, write to the Free Software * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA */ package org.jtalks.jcommune.service.transactional; import org.hibernate.Session; import org.joda.time.DateTime; import org.jtalks.common.model.dao.GroupDao; import org.jtalks.common.model.dao.hibernate.GenericDao; import org.jtalks.common.model.entity.Group; import org.jtalks.common.model.entity.User; import org.jtalks.common.service.security.SecurityContextFacade; import org.jtalks.jcommune.model.dao.UserDao; import org.jtalks.jcommune.model.dto.LoginUserDto; import org.jtalks.jcommune.model.dto.RegisterUserDto; import org.jtalks.jcommune.model.dto.UserDto; import org.jtalks.jcommune.model.entity.JCUser; import org.jtalks.jcommune.model.entity.UserInfo; import org.jtalks.jcommune.plugin.api.PluginLoader; import org.jtalks.jcommune.plugin.api.core.AuthenticationPlugin; import org.jtalks.jcommune.plugin.api.core.Plugin; import org.jtalks.jcommune.plugin.api.core.RegistrationPlugin; import org.jtalks.jcommune.plugin.api.exceptions.NoConnectionException; import org.jtalks.jcommune.plugin.api.exceptions.NotFoundException; import org.jtalks.jcommune.plugin.api.exceptions.UnexpectedErrorException; import org.jtalks.jcommune.service.Authenticator; import org.jtalks.jcommune.service.PluginService; import org.jtalks.jcommune.service.exceptions.UserTriesActivatingAccountAgainException; import org.jtalks.jcommune.service.nontransactional.EncryptionService; import org.jtalks.jcommune.service.nontransactional.ImageService; import org.jtalks.jcommune.service.nontransactional.MailService; import org.jtalks.jcommune.service.security.AdministrationGroup; import org.jtalks.jcommune.service.util.AuthenticationStatus; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.aop.framework.Advised; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.authentication.DisabledException; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; import org.springframework.security.core.AuthenticationException; import org.springframework.security.web.authentication.RememberMeServices; import org.springframework.security.web.authentication.session.SessionAuthenticationStrategy; import org.springframework.validation.BeanPropertyBindingResult; import org.springframework.validation.BindingResult; import org.springframework.validation.FieldError; import org.springframework.validation.Validator; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.io.UnsupportedEncodingException; import java.net.URLEncoder; import java.util.HashMap; import java.util.Map; /** * Serves for authentication and registration user. * Authentication: * Firstly tries to authenticate by default behavior (with JCommune authentication). * If default authentication was failed tries to authenticate by any available plugin. * <p/> * Registration: * Register user by any available plugin and save in JCommune internal database. * * @author Andrey Pogorelov */ public class TransactionalAuthenticator extends AbstractTransactionalEntityService<JCUser, UserDao> implements Authenticator { /** * While registering a new user, she gets {@link JCUser#setAutosubscribe(boolean)} set to {@code true} by default. * Afterwards user can edit her profile and change this setting. */ public static final boolean DEFAULT_AUTOSUBSCRIBE = true; public static final boolean DEFAULT_SEND_PM_NOTIFICATION = true; private PluginLoader pluginLoader; private EncryptionService encryptionService; private AuthenticationManager authenticationManager; private SecurityContextFacade securityFacade; private RememberMeServices rememberMeServices; private SessionAuthenticationStrategy sessionStrategy; private Validator validator; private MailService mailService; private ImageService avatarService; private GroupDao groupDao; private PluginService pluginService; private static final Logger LOGGER = LoggerFactory.getLogger(TransactionalAuthenticator.class); /** * @param pluginLoader used for obtain auth plugin * @param dao for operations with data storage * @param encryptionService encodes user password * @param securityFacade used in login logic * @param rememberMeServices used in login logic to specify remember user or * not * @param sessionStrategy used in login logic to call onAuthentication hook * which stored this user to online uses list. * @param authenticationManager to authenticate users */ public TransactionalAuthenticator(PluginLoader pluginLoader, UserDao dao, GroupDao groupDao, EncryptionService encryptionService, MailService mailService, ImageService avatarService, PluginService pluginService, SecurityContextFacade securityFacade, RememberMeServices rememberMeServices, SessionAuthenticationStrategy sessionStrategy, Validator validator, AuthenticationManager authenticationManager) { super(dao); this.groupDao = groupDao; this.pluginLoader = pluginLoader; this.encryptionService = encryptionService; this.mailService = mailService; this.avatarService = avatarService; this.pluginService = pluginService; this.securityFacade = securityFacade; this.rememberMeServices = rememberMeServices; this.sessionStrategy = sessionStrategy; this.validator = validator; this.authenticationManager = authenticationManager; } /** * {@inheritDoc} */ @Override public AuthenticationStatus authenticate(LoginUserDto loginUserDto, HttpServletRequest request, HttpServletResponse response) throws UnexpectedErrorException, NoConnectionException { AuthenticationStatus result; JCUser user; try { user = getByUsername(loginUserDto.getUserName()); result = authenticateDefault(user, loginUserDto.getPassword(), loginUserDto.isRememberMe(), request, response); } catch (NotFoundException e) { LOGGER.info("User was not found during login process, username = {}, IP={}", loginUserDto.getUserName(), loginUserDto.getClientIp()); result = authenticateByPluginAndUpdateUserInfo(loginUserDto, true, request, response); } catch(DisabledException e) { LOGGER.info("DisabledException: username = {}, IP={}, message={}", new String[]{loginUserDto.getUserName(), loginUserDto.getClientIp(), e.getMessage()}); result = AuthenticationStatus.NOT_ENABLED; } catch (AuthenticationException e) { LOGGER.info("AuthenticationException: username = {}, IP={}, message={}", new String[]{loginUserDto.getUserName(), loginUserDto.getClientIp(), e.getMessage()}); result = authenticateByPluginAndUpdateUserInfo(loginUserDto, false, request, response); } return result; } /** * Authenticate user by auth plugin and save updated user details to inner database. * * @param loginUserDto DTO object which represent authentication information * @param newUser is new user or not * @return AUTHENTICATED if authentication was successful, otherwise AUTHENTICATION_FAIL * @throws UnexpectedErrorException if some unexpected error occurred * @throws NoConnectionException if some connection error occurred */ private AuthenticationStatus authenticateByPluginAndUpdateUserInfo(LoginUserDto loginUserDto, boolean newUser, HttpServletRequest request, HttpServletResponse response) throws UnexpectedErrorException, NoConnectionException { String passwordHash = encryptionService.encryptPassword(loginUserDto.getPassword()); String encodedUsername; try { encodedUsername = loginUserDto.getUserName() == null ? null : URLEncoder.encode(loginUserDto.getUserName(), "UTF-8").replace("+", "%20"); } catch (UnsupportedEncodingException e) { LOGGER.error("Could not encode username '{}'", loginUserDto.getUserName()); throw new UnexpectedErrorException(e); } Map<String, String> authInfo = authenticateByAvailablePlugin(encodedUsername, passwordHash); if (authInfo.isEmpty() || !authInfo.containsKey("email") || !authInfo.containsKey("username")) { LOGGER.info("Could not authenticate user '{}' by plugin.", loginUserDto.getUserName()); return AuthenticationStatus.AUTHENTICATION_FAIL; } JCUser user = saveUser(authInfo, passwordHash, newUser); try { return authenticateDefault(user, loginUserDto.getUserName(), loginUserDto.isRememberMe(), request, response); } catch (AuthenticationException e) { return AuthenticationStatus.AUTHENTICATION_FAIL; } } /** * Authenticate user by JCommune. * * @param user user entity * @param password user password * @param rememberMe remember this user or not * @param request HTTP request * @param response HTTP response * @return AUTHENTICATED if authentication was successful, otherwise AUTHENTICATION_FAIL * @throws AuthenticationException */ private AuthenticationStatus authenticateDefault(JCUser user, String password, boolean rememberMe, HttpServletRequest request, HttpServletResponse response) throws AuthenticationException { UsernamePasswordAuthenticationToken token = new UsernamePasswordAuthenticationToken(user.getUsername(), password); token.setDetails(new UserInfo(user)); Authentication auth = authenticationManager.authenticate(token); securityFacade.getContext().setAuthentication(auth); if (auth.isAuthenticated()) { sessionStrategy.onAuthentication(auth, request, response); if (rememberMe) { rememberMeServices.loginSuccess(request, response, auth); } user.updateLastLoginTime(); return AuthenticationStatus.AUTHENTICATED; } return AuthenticationStatus.AUTHENTICATION_FAIL; } /** * Process authentication with available plugin. * * @param username username * @param passwordHash user password hash * @return user auth details returned by authentication plugin * @throws UnexpectedErrorException if some unexpected error occurred * @throws NoConnectionException if some connection error occurred */ private Map<String, String> authenticateByAvailablePlugin(String username, String passwordHash) throws UnexpectedErrorException, NoConnectionException { AuthenticationPlugin authPlugin = (AuthenticationPlugin) pluginLoader.getPluginByClassName(AuthenticationPlugin.class); Map<String, String> authInfo = new HashMap<>(); if (authPlugin != null && authPlugin.getState() == Plugin.State.ENABLED) { authInfo.putAll(authPlugin.authenticate(username, passwordHash)); } return authInfo; } private JCUser getByUsername(String username) throws NotFoundException { JCUser user = this.getDao().getByUsername(username); if (user == null) { throw new NotFoundException(); } return user; } private void copyFieldsFromUserToJCUser(User commonUser, JCUser user) { user.setRole(commonUser.getRole()); user.setAvatar(commonUser.getAvatar()); user.setBanReason(commonUser.getBanReason()); for (Group group : commonUser.getGroups()) { user.addGroup(group); } } /** * Save (or update) user with specified details in internal database. * * @param authInfo user details * @param passwordHash user password hash * @param newUser user is new for JCommune * @return saved (or updated) user */ private JCUser saveUser(Map<String, String> authInfo, String passwordHash, boolean newUser) { JCUser user; if (newUser) { user = new JCUser(authInfo.get("username"), authInfo.get("email"), passwordHash); user.setRegistrationDate(new DateTime()); user.setAutosubscribe(DEFAULT_AUTOSUBSCRIBE); user.setSendPmNotification(DEFAULT_SEND_PM_NOTIFICATION); User commonUser = this.getDao().getCommonUserByUsername(authInfo.get("username")); if (commonUser != null) { copyFieldsFromUserToJCUser(commonUser, user); // user already exist in database (poulpe uses the same database), // we need to delete common User and create JCUser try { Session session = ((GenericDao) ((Advised) this.getDao()).getTargetSource().getTarget()).session(); session.delete(commonUser); this.getDao().flush(); } catch (Exception e) { LOGGER.warn("Could not delete common user."); } } else { user.setAvatar(avatarService.getDefaultImage()); } } else { user = getDao().getByUsername(authInfo.get("username")); user.setPassword(passwordHash); user.setEmail(authInfo.get("email")); } if (authInfo.containsKey("firstName")) { user.setFirstName(authInfo.get("firstName")); } if (authInfo.containsKey("lastName")) { user.setLastName(authInfo.get("lastName")); } if (authInfo.containsKey("enabled")) { user.setEnabled(Boolean.parseBoolean(authInfo.get("enabled"))); } if (user.isEnabled() && user.getGroups().isEmpty()) { Group group = groupDao.getGroupByName(AdministrationGroup.USER.getName()); user.addGroup(group); } getDao().saveOrUpdate(user); return user; } /** * {@inheritDoc} */ @Override public BindingResult register(RegisterUserDto registerUserDto) throws UnexpectedErrorException, NoConnectionException { BindingResult result = new BeanPropertyBindingResult(registerUserDto, "newUser"); BindingResult jcErrors = new BeanPropertyBindingResult(registerUserDto, "newUser"); validator.validate(registerUserDto, jcErrors); UserDto userDto = registerUserDto.getUserDto(); String notEncodedPassword = userDto.getPassword(); String encodedPassword = (notEncodedPassword == null || notEncodedPassword.isEmpty()) ? "" : encryptionService.encryptPassword(notEncodedPassword); userDto.setPassword(encodedPassword); registerByPlugin(userDto, true, result); mergeValidationErrors(jcErrors, result); if (!result.hasErrors()) { registerByPlugin(userDto, false, result); // because next http call can fail (in the interim another user was registered) // we need to double check it if (!result.hasErrors()) { storeRegisteredUser(userDto); } } if(result.hasErrors()) { userDto.setPassword(notEncodedPassword); } return result; } /** * {@inheritDoc} */ @Override public void activateAccount(String uuid) throws NotFoundException, UserTriesActivatingAccountAgainException { JCUser user = this.getDao().getByUuid(uuid); if (user == null) { LOGGER.warn("Could not activate user with UUID[{}] because it doesn't exist. Either it was removed from DB " + "because too much time passed between registration and activation, or there is an error in link" + ", might be possible the user searches for vulnerabilities in the forum.", uuid); throw new NotFoundException(); } else if (!user.isEnabled()) { Group group = groupDao.getGroupByName(AdministrationGroup.USER.getName()); user.addGroup(group); user.setEnabled(true); this.getDao().saveOrUpdate(user); activateByPlugin(user.getUsername()); LOGGER.info("User [{}] successfully activated", user.getUsername()); } else { LOGGER.warn("User [{}] tried to activate his account again, but that's impossible. Either he clicked the " + "link again, or someone looks for vulnerabilities in the forum.", user.getUsername()); throw new UserTriesActivatingAccountAgainException(); } } private void activateByPlugin(String username) { AuthenticationPlugin authPlugin = (AuthenticationPlugin) pluginLoader.getPluginByClassName(AuthenticationPlugin.class); if (authPlugin != null && authPlugin.isEnabled()) authPlugin.activate(username); } /** * Performs registration or validation by available registration plugins * * @param userDto {@link UserDto} object * @param dryRun flag which determines registration or validation will be performed. If set to <code>true</code> * validation will be performed. Otherwise - registration * @param bindingResult validation result * * @throws UnexpectedErrorException if plugin returns unexpected response * @throws NoConnectionException if plugin can't connect to registration service */ public void registerByPlugin(UserDto userDto, boolean dryRun, BindingResult bindingResult) throws UnexpectedErrorException, NoConnectionException { Map<Long, RegistrationPlugin> registrationPlugins = pluginService.getRegistrationPlugins(); for (Map.Entry<Long, RegistrationPlugin> entry : registrationPlugins.entrySet()) { RegistrationPlugin registrationPlugin = entry.getValue(); if (registrationPlugin != null && registrationPlugin.getState() == Plugin.State.ENABLED) { Map<String, String> errors = dryRun ? registrationPlugin.validateUser(userDto, entry.getKey()) : registrationPlugin.registerUser(userDto, entry.getKey()); for (Map.Entry<String, String> error : errors.entrySet()) { bindingResult.rejectValue(error.getKey(), null, error.getValue()); } } } } protected void mergeValidationErrors(BindingResult srcErrors, BindingResult dstErrors) { for (FieldError error : srcErrors.getFieldErrors()) { if (!dstErrors.hasFieldErrors(error.getField())) { dstErrors.addError(error); } } } /** * Just saves a new {@link JCUser} or upgrade {@link org.jtalks.common.model.entity.User} * to {@link JCUser} without any additional checks * * @param userDto coming from enclosing methods, this object is built by Spring MVC * @return stored user */ public JCUser storeRegisteredUser(UserDto userDto) { // check if user already saved by plugin as common user User commonUser = this.getDao().getCommonUserByUsername(userDto.getUsername()); if (commonUser != null) { // in this case we must delete old common user and save user as JCUser, // because hibernate doesn't allow upgrade common User to JCUser try { Session session = ((GenericDao) ((Advised) this.getDao()).getTargetSource().getTarget()).session(); session.delete(commonUser); this.getDao().flush(); } catch (Exception e) { LOGGER.warn("Could not delete common user. This is needed if Poulpe still works with JCommune on the " + "same DB and therefore saves User first."); } } JCUser user = new JCUser(userDto.getUsername(), userDto.getEmail(), userDto.getPassword()); user.setLanguage(userDto.getLanguage()); user.setAutosubscribe(DEFAULT_AUTOSUBSCRIBE); user.setSendPmNotification(DEFAULT_SEND_PM_NOTIFICATION); user.setAvatar(avatarService.getDefaultImage()); user.setRegistrationDate(new DateTime()); this.getDao().saveOrUpdate(user); mailService.sendAccountActivationMail(user); LOGGER.info("JCUser registered: {}", user.getUsername()); return user; } }