3 * Copyright (C) 2009-2024 SonarSource SA
4 * mailto:info AT sonarsource DOT com
6 * This program is free software; you can redistribute it and/or
7 * modify it under the terms of the GNU Lesser General Public
8 * License as published by the Free Software Foundation; either
9 * version 3 of the License, or (at your option) any later version.
11 * This program is distributed in the hope that it will be useful,
12 * but WITHOUT ANY WARRANTY; without even the implied warranty of
13 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
14 * Lesser General Public License for more details.
16 * You should have received a copy of the GNU Lesser General Public License
17 * along with this program; if not, write to the Free Software Foundation,
18 * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
20 package org.sonar.server.authentication;
22 import java.security.NoSuchAlgorithmException;
23 import java.security.SecureRandom;
24 import java.security.spec.InvalidKeySpecException;
25 import java.util.Base64;
26 import java.util.EnumMap;
27 import javax.annotation.Nullable;
28 import javax.crypto.SecretKeyFactory;
29 import javax.crypto.spec.PBEKeySpec;
30 import org.apache.commons.lang3.RandomStringUtils;
31 import org.mindrot.jbcrypt.BCrypt;
32 import org.sonar.api.config.Configuration;
33 import org.sonar.db.DbClient;
34 import org.sonar.db.DbSession;
35 import org.sonar.db.user.UserDto;
36 import org.sonar.server.authentication.event.AuthenticationEvent.Method;
37 import org.sonar.server.authentication.event.AuthenticationEvent.Source;
38 import org.sonar.server.authentication.event.AuthenticationException;
40 import static com.google.common.base.Preconditions.checkArgument;
41 import static java.lang.String.format;
44 * Validates the password of a "local" user (password is stored in
47 public class CredentialsLocalAuthentication {
48 public static final String ERROR_NULL_HASH_METHOD = "null hash method";
49 public static final String ERROR_NULL_PASSWORD_IN_DB = "null password in DB";
50 public static final String ERROR_NULL_SALT = "null salt";
51 public static final String ERROR_WRONG_PASSWORD = "wrong password";
52 public static final String ERROR_UNKNOWN_HASH_METHOD = "Unknown hash method [%s]";
53 private static final SecureRandom SECURE_RANDOM = new SecureRandom();
54 private static final String PBKDF2_ITERATIONS_PROP = "sonar.internal.pbkdf2.iterations";
55 private static final HashMethod DEFAULT = HashMethod.PBKDF2;
56 private static final int DUMMY_PASSWORD_AND_SALT_SIZE = 100;
58 private final DbClient dbClient;
59 private final EnumMap<HashMethod, HashFunction> hashFunctions = new EnumMap<>(HashMethod.class);
61 public enum HashMethod {
65 public CredentialsLocalAuthentication(DbClient dbClient, Configuration configuration) {
66 this.dbClient = dbClient;
67 hashFunctions.put(HashMethod.BCRYPT, new BcryptFunction());
68 hashFunctions.put(HashMethod.PBKDF2, new PBKDF2Function(configuration.getInt(PBKDF2_ITERATIONS_PROP).orElse(null)));
71 void generateHashToAvoidEnumerationAttack(){
72 String randomSalt = RandomStringUtils.randomAlphabetic(DUMMY_PASSWORD_AND_SALT_SIZE);
73 String randomPassword = RandomStringUtils.randomAlphabetic(DUMMY_PASSWORD_AND_SALT_SIZE);
74 hashFunctions.get(HashMethod.PBKDF2).encryptPassword(randomSalt, randomPassword);
78 * This method authenticate a user with his password against the value stored in user.
79 * If authentication failed an AuthenticationException will be thrown containing the failure message.
80 * If the password must be updated because an old algorithm is used, the UserDto is updated but the session
83 public void authenticate(DbSession session, UserDto user, String password, Method method) {
84 HashMethod hashMethod = getHashMethod(user, method);
85 HashFunction hashFunction = hashFunctions.get(hashMethod);
86 AuthenticationResult result = authenticate(user, password, method, hashFunction);
88 // Upgrade the password if it's an old hashMethod
89 if (hashMethod != DEFAULT || result.needsUpdate) {
90 hashFunctions.get(DEFAULT).storeHashPassword(user, password);
91 dbClient.userDao().update(session, user);
95 private HashMethod getHashMethod(UserDto user, Method method) {
96 if (user.getHashMethod() == null) {
97 throw AuthenticationException.newBuilder()
98 .setSource(Source.local(method))
99 .setLogin(user.getLogin())
100 .setMessage(ERROR_NULL_HASH_METHOD)
104 return HashMethod.valueOf(user.getHashMethod());
105 } catch (IllegalArgumentException ex) {
106 generateHashToAvoidEnumerationAttack();
107 throw AuthenticationException.newBuilder()
108 .setSource(Source.local(method))
109 .setLogin(user.getLogin())
110 .setMessage(format(ERROR_UNKNOWN_HASH_METHOD, user.getHashMethod()))
115 private static AuthenticationResult authenticate(UserDto user, String password, Method method, HashFunction hashFunction) {
116 AuthenticationResult result = hashFunction.checkCredentials(user, password);
117 if (!result.isSuccessful()) {
118 throw AuthenticationException.newBuilder()
119 .setSource(Source.local(method))
120 .setLogin(user.getLogin())
121 .setMessage(result.getFailureMessage())
128 * Method used to store the password as a hash in database.
129 * The crypted_password, salt and hash_method are set
131 public void storeHashPassword(UserDto user, String password) {
132 hashFunctions.get(DEFAULT).storeHashPassword(user, password);
135 private static class AuthenticationResult {
136 private final boolean successful;
137 private final String failureMessage;
138 private final boolean needsUpdate;
140 private AuthenticationResult(boolean successful, String failureMessage) {
141 this(successful, failureMessage, false);
144 private AuthenticationResult(boolean successful, String failureMessage, boolean needsUpdate) {
145 checkArgument((successful && failureMessage.isEmpty()) || (!successful && !failureMessage.isEmpty()), "Incorrect parameters");
146 this.successful = successful;
147 this.failureMessage = failureMessage;
148 this.needsUpdate = needsUpdate;
151 public boolean isSuccessful() {
155 public String getFailureMessage() {
156 return failureMessage;
159 public boolean isNeedsUpdate() {
164 public interface HashFunction {
165 AuthenticationResult checkCredentials(UserDto user, String password);
167 void storeHashPassword(UserDto user, String password);
169 default String encryptPassword(String salt, String password) {
170 throw new IllegalStateException("This method is not supported for this hash function");
174 static final class PBKDF2Function implements HashFunction {
175 private static final char ITERATIONS_HASH_SEPARATOR = '$';
176 private static final int DEFAULT_ITERATIONS = 100_000;
177 private static final String ALGORITHM = "PBKDF2WithHmacSHA512";
178 private static final int KEY_LEN = 512;
179 private static final String ERROR_INVALID_HASH_STORED = "invalid hash stored";
180 private final int generationIterations;
182 public PBKDF2Function(@Nullable Integer generationIterations) {
183 this.generationIterations = generationIterations != null ? generationIterations : DEFAULT_ITERATIONS;
187 public AuthenticationResult checkCredentials(UserDto user, String password) {
188 if (user.getCryptedPassword() == null) {
189 return new AuthenticationResult(false, ERROR_NULL_PASSWORD_IN_DB);
191 if (user.getSalt() == null) {
192 return new AuthenticationResult(false, ERROR_NULL_SALT);
195 int pos = user.getCryptedPassword().indexOf(ITERATIONS_HASH_SEPARATOR);
197 return new AuthenticationResult(false, ERROR_INVALID_HASH_STORED);
201 iterations = Integer.parseInt(user.getCryptedPassword().substring(0, pos));
202 } catch (NumberFormatException e) {
203 return new AuthenticationResult(false, ERROR_INVALID_HASH_STORED);
205 String hash = user.getCryptedPassword().substring(pos + 1);
206 byte[] salt = Base64.getDecoder().decode(user.getSalt());
208 if (!hash.equals(hash(salt, password, iterations))) {
209 return new AuthenticationResult(false, ERROR_WRONG_PASSWORD);
211 boolean needsUpdate = iterations != generationIterations;
212 return new AuthenticationResult(true, "", needsUpdate);
216 public void storeHashPassword(UserDto user, String password) {
217 byte[] salt = new byte[20];
218 SECURE_RANDOM.nextBytes(salt);
219 String hashStr = hash(salt, password, generationIterations);
220 String saltStr = Base64.getEncoder().encodeToString(salt);
221 user.setHashMethod(HashMethod.PBKDF2.name())
222 .setCryptedPassword(composeEncryptedPassword(hashStr))
227 public String encryptPassword(String saltStr, String password) {
228 byte[] salt = Base64.getDecoder().decode(saltStr);
229 return composeEncryptedPassword(hash(salt, password, generationIterations));
232 private String composeEncryptedPassword(String hashStr) {
233 return format("%d%c%s", generationIterations, ITERATIONS_HASH_SEPARATOR, hashStr);
236 private static String hash(byte[] salt, String password, int iterations) {
238 SecretKeyFactory skf = SecretKeyFactory.getInstance(ALGORITHM);
239 PBEKeySpec spec = new PBEKeySpec(password.toCharArray(), salt, iterations, KEY_LEN);
240 byte[] hash = skf.generateSecret(spec).getEncoded();
241 return Base64.getEncoder().encodeToString(hash);
242 } catch (NoSuchAlgorithmException | InvalidKeySpecException e) {
243 throw new RuntimeException(e);
249 * Implementation of deprecated bcrypt hash function
251 private static final class BcryptFunction implements HashFunction {
253 public AuthenticationResult checkCredentials(UserDto user, String password) {
254 if (user.getCryptedPassword() == null) {
255 return new AuthenticationResult(false, ERROR_NULL_PASSWORD_IN_DB);
257 // This behavior is overridden in most of integration tests for performance reasons, any changes to BCrypt calls should be propagated to
259 if (!BCrypt.checkpw(password, user.getCryptedPassword())) {
260 return new AuthenticationResult(false, ERROR_WRONG_PASSWORD);
262 return new AuthenticationResult(true, "");
266 public void storeHashPassword(UserDto user, String password) {
267 user.setHashMethod(HashMethod.BCRYPT.name())
268 .setCryptedPassword(BCrypt.hashpw(password, BCrypt.gensalt(12)))