// Copyright © 2015 HSL <https://www.hsl.fi>
// This program is dual-licensed under the EUPL v1.2 and AGPLv3 licenses.
package fi.hsl.parkandride.core.service;
import static com.google.common.base.Charsets.UTF_8;
import static fi.hsl.parkandride.core.domain.Permission.ALL_OPERATORS;
import static org.joda.time.Days.daysBetween;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import org.apache.commons.codec.binary.Base64;
import org.jasypt.util.password.PasswordEncryptor;
import org.joda.time.DateTime;
import org.joda.time.Period;
import fi.hsl.parkandride.core.back.UserRepository;
import fi.hsl.parkandride.core.domain.Login;
import fi.hsl.parkandride.core.domain.NotFoundException;
import fi.hsl.parkandride.core.domain.OperatorEntity;
import fi.hsl.parkandride.core.domain.Permission;
import fi.hsl.parkandride.core.domain.User;
import fi.hsl.parkandride.core.domain.UserSecret;
import fi.hsl.parkandride.core.domain.Violation;
public class AuthenticationService {
public static final int SECRET_MIN_LENGTH = 32;
public static final char DELIM = '|';
private static final String DELIM_REGEX = "\\|";
public static final Pattern TOKEN_PATTERN = Pattern.compile("" +
"^" + // start
"(?<message>" + // message for hmac
"(?<type>[PT])" + DELIM_REGEX + // token type
"(?<userId>\\d+)" + DELIM_REGEX + // userId
"(?<timestamp>\\d+)" + DELIM_REGEX + // timestamp
")" + // (message for hmac)
"(?<hmac>[A-Za-z0-9\\-_]+)" + // base64url(hmac)
"$" // end
);
private final PasswordEncryptor passwordEncryptor;
private static final String HMAC = "HmacSHA256";
private final Base64 base64;
private final Period expiresDuration;
private final Period passwordExpiresDuration;
private final Period passwordReminderDuration;
private final byte[] secret;
private final ThreadLocal<Mac> hmacHolder = new ThreadLocal<Mac>() {
@Override
protected Mac initialValue() {
try {
SecretKeySpec key = new SecretKeySpec(secret, HMAC);
Mac hmac = Mac.getInstance(HMAC);
hmac.init(key);
return hmac;
} catch (NoSuchAlgorithmException | InvalidKeyException e) {
throw new RuntimeException(e);
}
}
};
private UserRepository userRepository;
public AuthenticationService(UserRepository userRepository, PasswordEncryptor passwordEncryptor, String secret, Period expiresDuration, Period passwordExpiresDuration, Period passwordReminder) {
if (secret.length() < SECRET_MIN_LENGTH) {
throw new IllegalArgumentException("secret must be at least " + SECRET_MIN_LENGTH + " characters long, " +
"but it was only " + secret.length());
}
this.userRepository = userRepository;
this.passwordEncryptor = passwordEncryptor;
this.expiresDuration = expiresDuration;
this.passwordExpiresDuration = passwordExpiresDuration;
this.passwordReminderDuration = passwordReminder;
this.base64 = new Base64(-1, null, true);
this.secret = secret.getBytes(UTF_8);
}
public static void authorize(User currentUser, Permission permission) {
if (permission.requiresContext) {
throw new IllegalArgumentException("permission requires context");
}
checkPermission(currentUser, permission);
}
public static void authorize(User currentUser, OperatorEntity entity, Permission permission) {
if (!permission.requiresContext) {
throw new IllegalArgumentException("permission does not require context");
}
checkPermission(currentUser, permission);
Long operatorId = getLimitedOperatorId(currentUser);
if (operatorId != null && !operatorId.equals(entity.operatorId())) {
throw new AccessDeniedException();
}
}
public static Long getLimitedOperatorId(User currentUser) {
if (!currentUser.hasPermission(ALL_OPERATORS)) {
if (currentUser.operatorId == null) {
throw new AccessDeniedException();
}
}
return currentUser.operatorId;
}
private static void checkPermission(User currentUser, Permission permission) {
if (currentUser == null) {
throw new AccessDeniedException();
}
if (!currentUser.hasPermission(permission)) {
throw new AccessDeniedException();
}
}
@TransactionalRead
public Login login(String username, String password) {
try {
UserSecret userSecret = userRepository.getUser(username);
if (userSecret.user.role.perpetualToken) {
throw new ValidationException(new Violation("LoginNotAllowed"));
}
if (!passwordEncryptor.checkPassword(password, userSecret.password)) {
throw new ValidationException(new Violation("BadCredentials"));
}
Login login = new Login();
login.token = token(userSecret.user);
login.username = userSecret.user.username;
login.role = userSecret.user.role;
login.permissions = login.role.permissions;
login.operatorId = userSecret.user.operatorId;
int days = daysBetween(now(), userSecret.passwordUpdatedTimestamp.plus(passwordExpiresDuration)).getDays() + 1;
login.passwordExpireInDays = 0;
if (days > 0 && days < passwordReminderDuration.getDays()) {
login.passwordExpireInDays = days;
} else if (days <= 0) {
login.passwordExpireInDays = -1;
}
login.userId = userSecret.user.id;
return login;
} catch (NotFoundException e) {
throw new ValidationException(new Violation("BadCredentials"));
}
}
@TransactionalWrite
public String resetToken(long userId) {
UserSecret userSecret = userRepository.getUser(userId);
if (!userSecret.user.role.perpetualToken) {
throw new ValidationException(new Violation("PerpetualTokenNotAllowed"));
}
DateTime now = now();
userRepository.revokeTokens(userId, now);
return token(userSecret.user, now);
}
private UserSecret loadUser(long id) {
try {
return userRepository.getUser(id);
} catch (NotFoundException e) {
throw new AuthenticationRequiredException();
}
}
public String token(User user) {
return token(user, now());
}
public String token(User user, DateTime now) {
StringBuilder token = new StringBuilder()
.append(user.role.perpetualToken ? "P" : "T").append(DELIM)
.append(user.id).append(DELIM)
.append(now.getMillis()).append(DELIM);
token.append(hmac(token.toString()));
return token.toString();
}
public String encryptPassword(String plain) {
return passwordEncryptor.encryptPassword(plain);
}
private String hmac(String message) {
byte[] mac = hmacHolder.get().doFinal(message.getBytes(UTF_8));
return base64.encodeToString(mac);
}
@TransactionalRead
public User authenticate(String token) {
if (token == null) {
throw new AuthenticationRequiredException();
}
Matcher m = TOKEN_PATTERN.matcher(token);
if (!m.matches()) {
throw new AuthenticationRequiredException();
}
String message = m.group("message");
String type = m.group("type");
long userId = Long.valueOf(m.group("userId"));
long tokenTimestamp = Long.valueOf(m.group("timestamp"));
String givenMac = m.group("hmac");
String expectedMac = hmac(message);
if (!expectedMac.equals(givenMac)) {
throw new AuthenticationRequiredException();
}
boolean perpetualToken = false;
switch (type) {
case "T":
// Temporal token expired?
DateTime expires = new DateTime(tokenTimestamp).plus(expiresDuration);
if (expires.isBeforeNow()) {
throw new AuthenticationRequiredException();
}
break;
case "P":
perpetualToken = true;
break;
default:
throw new AuthenticationRequiredException();
}
UserSecret userSecret = loadUser(userId);
// Token revoked?
if (tokenTimestamp < userSecret.minTokenTimestamp.getMillis()) {
throw new AuthenticationRequiredException();
}
// Token type mismatch
if (userSecret.user.role.perpetualToken != perpetualToken) {
throw new AuthenticationRequiredException();
}
return userSecret.user;
}
private DateTime now() {
return userRepository.getCurrentTime();
}
}