/* * This file is part of ARSnova Backend. * Copyright (C) 2012-2017 The ARSnova Team * * ARSnova Backend is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * ARSnova Backend 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 General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see <http://www.gnu.org/licenses/>. */ package de.thm.arsnova.services; import com.codahale.metrics.annotation.Gauge; import com.github.leleuj.ss.oauth.client.authentication.OAuthAuthenticationToken; import de.thm.arsnova.dao.IDatabaseDao; import de.thm.arsnova.entities.DbUser; import de.thm.arsnova.entities.User; import de.thm.arsnova.exceptions.BadRequestException; import de.thm.arsnova.exceptions.NotFoundException; import de.thm.arsnova.exceptions.UnauthorizedException; import org.apache.commons.lang.RandomStringUtils; import org.apache.commons.lang.StringUtils; import org.scribe.up.profile.facebook.FacebookProfile; import org.scribe.up.profile.google.Google2Profile; import org.scribe.up.profile.twitter.TwitterProfile; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.mail.MailException; import org.springframework.mail.javamail.JavaMailSender; import org.springframework.mail.javamail.MimeMessageHelper; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.security.authentication.AnonymousAuthenticationToken; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.cas.authentication.CasAuthenticationToken; import org.springframework.security.core.Authentication; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.keygen.BytesKeyGenerator; import org.springframework.security.crypto.keygen.KeyGenerators; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Isolation; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.util.UriUtils; import org.stagemonitor.core.metrics.MonitorGauges; import javax.annotation.PreDestroy; import javax.mail.MessagingException; import javax.mail.internet.MimeMessage; import java.io.UnsupportedEncodingException; import java.text.MessageFormat; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Map.Entry; import java.util.Set; import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; import java.util.regex.Pattern; /** * Performs all user related operations. */ @Service @MonitorGauges public class UserService implements IUserService { private static final int LOGIN_TRY_RESET_DELAY_MS = 30 * 1000; private static final int LOGIN_BAN_RESET_DELAY_MS = 2 * 60 * 1000; private static final int REPEATED_PASSWORD_RESET_DELAY_MS = 3 * 60 * 1000; private static final int PASSWORD_RESET_KEY_DURABILITY_MS = 2 * 60 * 60 * 1000; private static final long ACTIVATION_KEY_CHECK_INTERVAL_MS = 30 * 60 * 1000L; private static final long ACTIVATION_KEY_DURABILITY_MS = 6 * 60 * 60 * 1000L; private static final Logger logger = LoggerFactory.getLogger(UserService.class); private static final ConcurrentHashMap<UUID, User> socketid2user = new ConcurrentHashMap<>(); /* used for Socket.IO online check solution (new) */ private static final ConcurrentHashMap<User, String> user2session = new ConcurrentHashMap<>(); @Autowired private IDatabaseDao databaseDao; @Autowired private JavaMailSender mailSender; @Value("${root-url}") private String rootUrl; @Value("${customization.path}") private String customizationPath; @Value("${security.user-db.allowed-email-domains}") private String allowedEmailDomains; @Value("${security.user-db.activation-path}") private String activationPath; @Value("${security.user-db.reset-password-path}") private String resetPasswordPath; @Value("${mail.sender.address}") private String mailSenderAddress; @Value("${mail.sender.name}") private String mailSenderName; @Value("${security.user-db.registration-mail.subject}") private String regMailSubject; @Value("${security.user-db.registration-mail.body}") private String regMailBody; @Value("${security.user-db.reset-password-mail.subject}") private String resetPasswordMailSubject; @Value("${security.user-db.reset-password-mail.body}") private String resetPasswordMailBody; @Value("${security.authentication.login-try-limit}") private int loginTryLimit; @Value("${security.admin-accounts}") private String[] adminAccounts; private Pattern mailPattern; private BytesKeyGenerator keygen; private BCryptPasswordEncoder encoder; private ConcurrentHashMap<String, Byte> loginTries; private Set<String> loginBans; { loginTries = new ConcurrentHashMap<>(); loginBans = Collections.synchronizedSet(new HashSet<String>()); } @Scheduled(fixedDelay = LOGIN_TRY_RESET_DELAY_MS) public void resetLoginTries() { if (!loginTries.isEmpty()) { logger.debug("Reset failed login counters."); loginTries.clear(); } } @Scheduled(fixedDelay = LOGIN_BAN_RESET_DELAY_MS) public void resetLoginBans() { if (!loginBans.isEmpty()) { logger.info("Reset temporary login bans."); loginBans.clear(); } } @Scheduled(fixedDelay = ACTIVATION_KEY_CHECK_INTERVAL_MS) public void deleteInactiveUsers() { logger.info("Delete inactive users."); long unixTime = System.currentTimeMillis(); long lastActivityBefore = unixTime - ACTIVATION_KEY_DURABILITY_MS; databaseDao.deleteInactiveUsers(lastActivityBefore); } @Override public User getCurrentUser() { final Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); if (authentication == null || authentication.getPrincipal() == null) { return null; } User user = null; if (authentication instanceof OAuthAuthenticationToken) { user = getOAuthUser(authentication); } else if (authentication instanceof CasAuthenticationToken) { final CasAuthenticationToken token = (CasAuthenticationToken) authentication; user = new User(token.getAssertion().getPrincipal()); } else if (authentication instanceof AnonymousAuthenticationToken) { final AnonymousAuthenticationToken token = (AnonymousAuthenticationToken) authentication; user = new User(token); } else if (authentication instanceof UsernamePasswordAuthenticationToken) { final UsernamePasswordAuthenticationToken token = (UsernamePasswordAuthenticationToken) authentication; user = new User(token); if (authentication.getAuthorities().contains(new SimpleGrantedAuthority("ROLE_GUEST"))) { user.setType(User.GUEST); } else if (authentication.getAuthorities().contains(new SimpleGrantedAuthority("ROLE_DB_USER"))) { user.setType(User.ARSNOVA); } } if (user == null || "anonymous".equals(user.getUsername())) { throw new UnauthorizedException(); } user.setAdmin(Arrays.asList(adminAccounts).contains(user.getUsername())); return user; } private User getOAuthUser(final Authentication authentication) { User user = null; final OAuthAuthenticationToken token = (OAuthAuthenticationToken) authentication; if (token.getUserProfile() instanceof Google2Profile) { final Google2Profile profile = (Google2Profile) token.getUserProfile(); user = new User(profile); } else if (token.getUserProfile() instanceof TwitterProfile) { final TwitterProfile profile = (TwitterProfile) token.getUserProfile(); user = new User(profile); } else if (token.getUserProfile() instanceof FacebookProfile) { final FacebookProfile profile = (FacebookProfile) token.getUserProfile(); user = new User(profile); } return user; } @Override public boolean isBannedFromLogin(String addr) { return loginBans.contains(addr); } @Override public void increaseFailedLoginCount(String addr) { Byte tries = loginTries.get(addr); if (null == tries) { tries = 0; } if (tries < loginTryLimit) { loginTries.put(addr, ++tries); if (loginTryLimit == tries) { logger.info("Temporarily banned {} from login.", addr); loginBans.add(addr); } } } @Override public User getUser2SocketId(final UUID socketId) { return socketid2user.get(socketId); } @Override public void putUser2SocketId(final UUID socketId, final User user) { socketid2user.put(socketId, user); } @Override public Set<Map.Entry<UUID, User>> socketId2User() { return socketid2user.entrySet(); } @Override public void removeUser2SocketId(final UUID socketId) { socketid2user.remove(socketId); } @Override public boolean isUserInSession(final User user, final String keyword) { if (keyword == null) { return false; } String session = user2session.get(user); return session != null && keyword.equals(session); } @Override public Set<User> getUsersInSession(final String keyword) { final Set<User> result = new HashSet<>(); for (final Entry<User, String> e : user2session.entrySet()) { if (e.getValue().equals(keyword)) { result.add(e.getKey()); } } return result; } @Override @Transactional(isolation = Isolation.READ_COMMITTED) public void addUserToSessionBySocketId(final UUID socketId, final String keyword) { final User user = socketid2user.get(socketId); user2session.put(user, keyword); } @Override @Transactional(isolation = Isolation.READ_COMMITTED) public void removeUserFromSessionBySocketId(final UUID socketId) { final User user = socketid2user.get(socketId); if (null == user) { logger.warn("No user exists for socket {}.", socketId); return; } user2session.remove(user); } @Override public String getSessionForUser(final String username) { for (final Entry<User, String> entry : user2session.entrySet()) { if (entry.getKey().getUsername().equals(username)) { return entry.getValue(); } } return null; } @PreDestroy public void destroy() { logger.error("Destroy UserService"); } @Override public void removeUserFromMaps(final User user) { if (user != null) { user2session.remove(user); } } @Override @Gauge public int loggedInUsers() { return user2session.size(); } @Override public DbUser getDbUser(String username) { return databaseDao.getUser(username.toLowerCase()); } @Override public DbUser createDbUser(String username, String password) { String lcUsername = username.toLowerCase(); if (null == keygen) { keygen = KeyGenerators.secureRandom(32); } if (null == mailPattern) { parseMailAddressPattern(); } if (null == mailPattern || !mailPattern.matcher(lcUsername).matches()) { logger.info("User registration failed. {} does not match pattern.", lcUsername); return null; } if (null != databaseDao.getUser(lcUsername)) { logger.info("User registration failed. {} already exists.", lcUsername); return null; } DbUser dbUser = new DbUser(); dbUser.setUsername(lcUsername); dbUser.setPassword(encodePassword(password)); dbUser.setActivationKey(RandomStringUtils.randomAlphanumeric(32)); dbUser.setCreation(System.currentTimeMillis()); DbUser result = databaseDao.createOrUpdateUser(dbUser); if (null != result) { sendActivationEmail(result); } else { logger.error("User registration failed. {} could not be created.", lcUsername); } return result; } private String encodePassword(String password) { if (null == encoder) { encoder = new BCryptPasswordEncoder(12); } return encoder.encode(password); } private void sendActivationEmail(DbUser dbUser) { String activationUrl; try { activationUrl = MessageFormat.format( "{0}{1}/{2}?action=activate&username={3}&key={4}", rootUrl, customizationPath, activationPath, UriUtils.encodeQueryParam(dbUser.getUsername(), "UTF-8"), dbUser.getActivationKey() ); } catch (UnsupportedEncodingException e) { logger.error("Sending of activation mail failed.", e); return; } sendEmail(dbUser, regMailSubject, MessageFormat.format(regMailBody, activationUrl)); } private void parseMailAddressPattern() { /* TODO: Add Unicode support */ List<String> domainList = Arrays.asList(allowedEmailDomains.split(",")); if (!domainList.isEmpty()) { List<String> patterns = new ArrayList<>(); if (domainList.contains("*")) { patterns.add("([a-z0-9-]+\\.)+[a-z0-9-]+"); } else { Pattern patternPattern = Pattern.compile("[a-z0-9.*-]+", Pattern.CASE_INSENSITIVE); for (String patternStr : domainList) { if (patternPattern.matcher(patternStr).matches()) { patterns.add(patternStr.replaceAll("[.]", "[.]").replaceAll("[*]", "[a-z0-9-]+?")); } } } mailPattern = Pattern.compile("[a-z0-9._-]+?@(" + StringUtils.join(patterns, "|") + ")", Pattern.CASE_INSENSITIVE); logger.info("Allowed e-mail addresses (pattern) for registration: '{}'.", mailPattern.pattern()); } } @Override public DbUser updateDbUser(DbUser dbUser) { if (null != dbUser.getId()) { return databaseDao.createOrUpdateUser(dbUser); } return null; } @Override public DbUser deleteDbUser(String username) { User user = getCurrentUser(); if (!user.getUsername().equals(username.toLowerCase()) && !SecurityContextHolder.getContext().getAuthentication().getAuthorities() .contains(new SimpleGrantedAuthority("ROLE_ADMIN"))) { throw new UnauthorizedException(); } DbUser dbUser = getDbUser(username); if (null == dbUser) { throw new NotFoundException(); } databaseDao.deleteUser(dbUser); return dbUser; } @Override public void initiatePasswordReset(String username) { DbUser dbUser = getDbUser(username); if (null == dbUser) { logger.info("Password reset failed. User {} does not exist.", username); throw new NotFoundException(); } if (System.currentTimeMillis() < dbUser.getPasswordResetTime() + REPEATED_PASSWORD_RESET_DELAY_MS) { logger.info("Password reset failed. The reset delay for User {} is still active.", username); throw new BadRequestException(); } dbUser.setPasswordResetKey(RandomStringUtils.randomAlphanumeric(32)); dbUser.setPasswordResetTime(System.currentTimeMillis()); if (null == databaseDao.createOrUpdateUser(dbUser)) { logger.error("Password reset failed. {} could not be updated.", username); } String resetPasswordUrl; try { resetPasswordUrl = MessageFormat.format( "{0}{1}/{2}?action=resetpassword&username={3}&key={4}", rootUrl, customizationPath, resetPasswordPath, UriUtils.encodeQueryParam(dbUser.getUsername(), "UTF-8"), dbUser.getPasswordResetKey() ); } catch (UnsupportedEncodingException e) { logger.error("Sending of password reset mail failed.", e); return; } sendEmail(dbUser, resetPasswordMailSubject, MessageFormat.format(resetPasswordMailBody, resetPasswordUrl)); } @Override public boolean resetPassword(DbUser dbUser, String key, String password) { if (null == key || "".equals(key) || !key.equals(dbUser.getPasswordResetKey())) { logger.info("Password reset failed. Invalid key provided for User {}.", dbUser.getUsername()); return false; } if (System.currentTimeMillis() > dbUser.getPasswordResetTime() + PASSWORD_RESET_KEY_DURABILITY_MS) { logger.info("Password reset failed. Key provided for User {} is no longer valid.", dbUser.getUsername()); dbUser.setPasswordResetKey(null); dbUser.setPasswordResetTime(0); updateDbUser(dbUser); return false; } dbUser.setPassword(encodePassword(password)); dbUser.setPasswordResetKey(null); if (null == updateDbUser(dbUser)) { logger.error("Password reset failed. {} could not be updated.", dbUser.getUsername()); } return true; } private void sendEmail(DbUser dbUser, String subject, String body) { MimeMessage msg = mailSender.createMimeMessage(); MimeMessageHelper helper = new MimeMessageHelper(msg, "UTF-8"); try { helper.setFrom(mailSenderName + "<" + mailSenderAddress + ">"); helper.setTo(dbUser.getUsername()); helper.setSubject(subject); helper.setText(body); logger.info("Sending mail \"{}\" from \"{}\" to \"{}\"", subject, msg.getFrom(), dbUser.getUsername()); mailSender.send(msg); } catch (MailException | MessagingException e) { logger.warn("Mail \"{}\" could not be sent.", subject, e); } } }