org.jivesoftware.openfire.auth.JDBCAuthProvider Maven / Gradle / Ivy
The newest version!
/*
* Copyright (C) 2005-2008 Jive Software. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.jivesoftware.openfire.auth;
import java.security.SecureRandom;
import java.security.MessageDigest;
import java.security.Security;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import org.bouncycastle.crypto.generators.OpenBSDBCrypt;
import org.bouncycastle.jce.provider.BouncyCastleProvider;
import org.bouncycastle.util.encoders.Hex;
import org.jivesoftware.database.DbConnectionManager;
import org.jivesoftware.openfire.XMPPServer;
import org.jivesoftware.openfire.user.UserAlreadyExistsException;
import org.jivesoftware.openfire.user.UserManager;
import org.jivesoftware.openfire.user.UserNotFoundException;
import org.jivesoftware.util.JiveGlobals;
import org.jivesoftware.util.PropertyEventDispatcher;
import org.jivesoftware.util.PropertyEventListener;
import org.jivesoftware.util.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* The JDBC auth provider allows you to authenticate users against any database
* that you can connect to with JDBC. It can be used along with the
* {@link HybridAuthProvider hybrid} auth provider, so that you can also have
* XMPP-only users that won't pollute your external data.
*
* To enable this provider, set the following in the system properties:
*
* - provider.auth.className = org.jivesoftware.openfire.auth.JDBCAuthProvider
*
*
* You'll also need to set your JDBC driver, connection string, and SQL statements:
*
*
* - jdbcProvider.driver = com.mysql.jdbc.Driver
* - jdbcProvider.connectionString = jdbc:mysql://localhost/dbname?user=username&password=secret
* - jdbcAuthProvider.passwordSQL = SELECT password FROM user_account WHERE username=?
* - jdbcAuthProvider.passwordType = plain
* - jdbcAuthProvider.allowUpdate = true
* - jdbcAuthProvider.setPasswordSQL = UPDATE user_account SET password=? WHERE username=?
* - jdbcAuthProvider.bcrypt.cost = 12
*
*
* jdbcAuthProvider.passwordType can accept a comma separated string of password types. This can be useful in
* situations where legacy (ex/md5) password hashes were stored and then "upgraded" to a stronger hash algorithm.
* Hashes are executed left to right.
* Example Setting: "md5,sha1"
* Usage: password ->
* (md5) 286755fad04869ca523320acce0dc6a4 ->
* (sha1) 0524b1fc84d315b08db890413e65260040b08caa ->
*
* Bcrypt is supported as a passwordType; however, when chaining password types it MUST be the last type given. (bcrypt hashes are different
* every time they are generated)
* Optional bcrypt configuration:
*
* - jdbcAuthProvider.bcrypt.cost: The BCrypt cost. Default: BCrypt.GENSALT_DEFAULT_LOG2_ROUNDS (currently: 10)
*
*
* In order to use the configured JDBC connection provider do not use a JDBC
* connection string, set the following property
*
*
* - jdbcAuthProvider.useConnectionProvider = true
*
*
* The passwordType setting tells Openfire how the password is stored. Setting the value
* is optional (when not set, it defaults to "plain"). The valid values are:
* - {@link PasswordType#plain plain}
*
- {@link PasswordType#md5 md5}
*
- {@link PasswordType#sha1 sha1}
*
- {@link PasswordType#sha256 sha256}
*
- {@link PasswordType#sha512 sha512}
*
- {@link PasswordType#bcrypt bcrypt}
*
- {@link PasswordType#nt nt}
*
*
* @author David Snopek
*/
public class JDBCAuthProvider implements AuthProvider, PropertyEventListener {
private static final Logger Log = LoggerFactory.getLogger(JDBCAuthProvider.class);
private static final int DEFAULT_BCRYPT_COST = 10; // Current (2015) value provided by Mindrot's BCrypt.GENSALT_DEFAULT_LOG2_ROUNDS value
private String connectionString;
private String passwordSQL;
private String setPasswordSQL;
private List passwordTypes;
private boolean allowUpdate;
private boolean useConnectionProvider;
private int bcryptCost;
/**
* Constructs a new JDBC authentication provider.
*/
public JDBCAuthProvider() {
// Convert XML based provider setup to Database based
JiveGlobals.migrateProperty("jdbcProvider.driver");
JiveGlobals.migrateProperty("jdbcProvider.connectionString");
JiveGlobals.migrateProperty("jdbcAuthProvider.passwordSQL");
JiveGlobals.migrateProperty("jdbcAuthProvider.passwordType");
JiveGlobals.migrateProperty("jdbcAuthProvider.setPasswordSQL");
JiveGlobals.migrateProperty("jdbcAuthProvider.allowUpdate");
JiveGlobals.migrateProperty("jdbcAuthProvider.bcrypt.cost");
JiveGlobals.migrateProperty("jdbcAuthProvider.useConnectionProvider");
JiveGlobals.migrateProperty("jdbcAuthProvider.acceptPreHashedPassword");
useConnectionProvider = JiveGlobals.getBooleanProperty("jdbcAuthProvider.useConnectionProvider");
if (!useConnectionProvider) {
// Load the JDBC driver and connection string.
String jdbcDriver = JiveGlobals.getProperty("jdbcProvider.driver");
try {
Class.forName(jdbcDriver).newInstance();
}
catch (Exception e) {
Log.error("Unable to load JDBC driver: " + jdbcDriver, e);
return;
}
connectionString = JiveGlobals.getProperty("jdbcProvider.connectionString");
}
// Load SQL statements.
passwordSQL = JiveGlobals.getProperty("jdbcAuthProvider.passwordSQL");
setPasswordSQL = JiveGlobals.getProperty("jdbcAuthProvider.setPasswordSQL");
allowUpdate = JiveGlobals.getBooleanProperty("jdbcAuthProvider.allowUpdate",false);
setPasswordTypes(JiveGlobals.getProperty("jdbcAuthProvider.passwordType", "plain"));
bcryptCost = JiveGlobals.getIntProperty("jdbcAuthProvider.bcrypt.cost", -1);
PropertyEventDispatcher.addListener(this);
if (Security.getProvider(BouncyCastleProvider.PROVIDER_NAME) == null) {
java.security.Security.addProvider(new BouncyCastleProvider());
}
}
private void setPasswordTypes(String passwordTypeProperty){
Collection passwordTypeStringList = StringUtils.stringToCollection(passwordTypeProperty);
List passwordTypeList = new ArrayList<>(passwordTypeStringList.size());
Iterator it = passwordTypeStringList.iterator();
while(it.hasNext()){
try {
PasswordType type = PasswordType.valueOf(it.next().toLowerCase());
passwordTypeList.add(type);
if(type == PasswordType.bcrypt){
// Do not support chained hashes beyond bcrypt
if(it.hasNext()){
Log.warn("The jdbcAuthProvider.passwordType setting in invalid. Bcrypt must be the final hashType if a series is given. Ignoring all hash types beyond bcrypt: {}", passwordTypeProperty);
}
break;
}
}
catch (IllegalArgumentException iae) { }
}
if(passwordTypeList.isEmpty()){
Log.warn("The jdbcAuthProvider.passwordType setting is not set or contains invalid values. Setting the type to 'plain'");
passwordTypeList.add(PasswordType.plain);
}
passwordTypes = passwordTypeList;
}
@Override
public void authenticate(String username, String password) throws UnauthorizedException {
if (username == null || password == null) {
throw new UnauthorizedException();
}
username = username.trim().toLowerCase();
if (username.contains("@")) {
// Check that the specified domain matches the server's domain
int index = username.indexOf("@");
String domain = username.substring(index + 1);
if (domain.equals(XMPPServer.getInstance().getServerInfo().getXMPPDomain())) {
username = username.substring(0, index);
} else {
// Unknown domain. Return authentication failed.
throw new UnauthorizedException();
}
}
String userPassword;
try {
userPassword = getPasswordValue(username);
}
catch (UserNotFoundException unfe) {
throw new UnauthorizedException();
}
if (comparePasswords(password, userPassword)) {
// Got this far, so the user must be authorized.
createUser(username);
} else {
throw new UnauthorizedException();
}
}
// @VisibleForTesting
protected boolean comparePasswords(String plainText, String hashed) {
int lastIndex = passwordTypes.size() - 1;
if (passwordTypes.get(lastIndex) == PasswordType.bcrypt) {
for (int i = 0; i < lastIndex; i++) {
plainText = hashPassword(plainText, passwordTypes.get(i));
}
return OpenBSDBCrypt.checkPassword(hashed, plainText.toCharArray());
}
return hashPassword(plainText).equals(hashed);
}
private String hashPassword(String password) {
for (PasswordType type : passwordTypes) {
password = hashPassword(password, type);
}
return password;
}
// @VisibleForTesting
protected String hashPassword(String password, PasswordType type) {
switch (type) {
case md5:
return StringUtils.hash(password, "MD5");
case sha1:
return StringUtils.hash(password, "SHA-1");
case sha256:
return StringUtils.hash(password, "SHA-256");
case sha512:
return StringUtils.hash(password, "SHA-512");
case bcrypt:
byte[] salt = new byte[16];
new SecureRandom().nextBytes(salt);
int cost = (bcryptCost < 4 || bcryptCost > 31) ? DEFAULT_BCRYPT_COST : bcryptCost;
return OpenBSDBCrypt.generate(password.toCharArray(), salt, cost);
case nt:
byte[] digestBytes;
byte[] utf16leBytes = null;
try {
MessageDigest md = MessageDigest.getInstance("MD4");
utf16leBytes = password.getBytes("UTF-16LE");
digestBytes = md.digest(utf16leBytes);
return new String(new String(Hex.encode(digestBytes)));
}
catch (Exception e) {
return null;
}
case plain:
default:
return password;
}
}
@Override
public String getPassword(String username) throws UserNotFoundException,
UnsupportedOperationException
{
if (!supportsPasswordRetrieval()) {
throw new UnsupportedOperationException();
}
if (username.contains("@")) {
// Check that the specified domain matches the server's domain
int index = username.indexOf("@");
String domain = username.substring(index + 1);
if (domain.equals(XMPPServer.getInstance().getServerInfo().getXMPPDomain())) {
username = username.substring(0, index);
} else {
// Unknown domain.
throw new UserNotFoundException();
}
}
return getPasswordValue(username);
}
@Override
public void setPassword(String username, String password)
throws UserNotFoundException, UnsupportedOperationException
{
if (allowUpdate && setPasswordSQL != null) {
setPasswordValue(username, password);
} else {
throw new UnsupportedOperationException();
}
}
@Override
public boolean supportsPasswordRetrieval() {
return (passwordSQL != null && passwordTypes.size() == 1 && passwordTypes.get(0) == PasswordType.plain);
}
private Connection getConnection() throws SQLException {
if (useConnectionProvider)
return DbConnectionManager.getConnection();
return DriverManager.getConnection(connectionString);
}
/**
* Returns the value of the password field. It will be in plain text or hashed
* format, depending on the password type.
*
* @param username user to retrieve the password field for
* @return the password value.
* @throws UserNotFoundException if the given user could not be loaded.
*/
private String getPasswordValue(String username) throws UserNotFoundException {
String password = null;
Connection con = null;
PreparedStatement pstmt = null;
ResultSet rs = null;
if (username.contains("@")) {
// Check that the specified domain matches the server's domain
int index = username.indexOf("@");
String domain = username.substring(index + 1);
if (domain.equals(XMPPServer.getInstance().getServerInfo().getXMPPDomain())) {
username = username.substring(0, index);
} else {
// Unknown domain.
throw new UserNotFoundException();
}
}
try {
con = getConnection();
pstmt = con.prepareStatement(passwordSQL);
pstmt.setString(1, username);
rs = pstmt.executeQuery();
// If the query had no results, the username and password
// did not match a user record. Therefore, throw an exception.
if (!rs.next()) {
throw new UserNotFoundException();
}
password = rs.getString(1);
}
catch (SQLException e) {
Log.error("Exception in JDBCAuthProvider", e);
throw new UserNotFoundException();
}
finally {
DbConnectionManager.closeConnection(rs, pstmt, con);
}
return password;
}
private void setPasswordValue(String username, String password) throws UserNotFoundException {
Connection con = null;
PreparedStatement pstmt = null;
if (username.contains("@")) {
// Check that the specified domain matches the server's domain
int index = username.indexOf("@");
String domain = username.substring(index + 1);
if (domain.equals(XMPPServer.getInstance().getServerInfo().getXMPPDomain())) {
username = username.substring(0, index);
} else {
// Unknown domain.
throw new UserNotFoundException();
}
}
try {
con = getConnection();
pstmt = con.prepareStatement(setPasswordSQL);
pstmt.setString(2, username);
password = hashPassword(password);
pstmt.setString(1, password);
pstmt.executeQuery();
}
catch (SQLException e) {
Log.error("Exception in JDBCAuthProvider", e);
throw new UserNotFoundException();
}
finally {
DbConnectionManager.closeConnection(pstmt, con);
}
}
/**
* Indicates how the password is stored.
*/
@SuppressWarnings({"UnnecessarySemicolon"}) // Support for QDox Parser
public enum PasswordType {
/**
* The password is stored as plain text.
*/
plain,
/**
* The password is stored as a hex-encoded MD5 hash.
*/
md5,
/**
* The password is stored as a hex-encoded SHA-1 hash.
*/
sha1,
/**
* The password is stored as a hex-encoded SHA-256 hash.
*/
sha256,
/**
* The password is stored as a hex-encoded SHA-512 hash.
*/
sha512,
/**
* The password is stored as a bcrypt hash.
*/
bcrypt,
/**
* The password is stored as an nt hash.
*/
nt;
}
/**
* Checks to see if the user exists; if not, a new user is created.
*
* @param username the username.
*/
// @VisibleForTesting
protected void createUser(String username) {
// See if the user exists in the database. If not, automatically create them.
UserManager userManager = UserManager.getInstance();
try {
userManager.getUser(username);
}
catch (UserNotFoundException unfe) {
try {
Log.debug("JDBCAuthProvider: Automatically creating new user account for " + username);
UserManager.getUserProvider().createUser(username, StringUtils.randomString(8),
null, null);
}
catch (UserAlreadyExistsException uaee) {
// Ignore.
}
}
}
@Override
public boolean isScramSupported() {
// TODO Auto-generated method stub
return false;
}
@Override
public String getSalt(String username) throws UnsupportedOperationException, UserNotFoundException {
throw new UnsupportedOperationException();
}
@Override
public int getIterations(String username) throws UnsupportedOperationException, UserNotFoundException {
throw new UnsupportedOperationException();
}
@Override
public String getServerKey(String username) throws UnsupportedOperationException, UserNotFoundException {
throw new UnsupportedOperationException();
}
@Override
public String getStoredKey(String username) throws UnsupportedOperationException, UserNotFoundException {
throw new UnsupportedOperationException();
}
/**
* Support a subset of JDBCAuthProvider properties when updated via REST,
* web GUI, or other sources. Provider strings (and related settings) must
* be set via XML.
*
* @param property the name of the property.
* @param params event parameters.
*/
@Override
public void propertySet(String property, Map params) {
String value = (String) params.get("value");
switch (property) {
case "jdbcAuthProvider.passwordSQL":
passwordSQL = value;
Log.debug("jdbcAuthProvider.passwordSQL configured to: {}", passwordSQL);
break;
case "jdbcAuthProvider.setPasswordSQL":
setPasswordSQL = value;
Log.debug("jdbcAuthProvider.setPasswordSQL configured to: {}", setPasswordSQL);
break;
case "jdbcAuthProvider.allowUpdate":
allowUpdate = Boolean.parseBoolean(value);
Log.debug("jdbcAuthProvider.allowUpdate configured to: {}", allowUpdate);
break;
case "jdbcAuthProvider.passwordType":
setPasswordTypes(value);
Log.debug("jdbcAuthProvider.passwordType configured to: {}", Arrays.toString(passwordTypes.toArray()));
break;
case "jdbcAuthProvider.bcrypt.cost":
try {
bcryptCost = Integer.parseInt(value);
} catch (NumberFormatException e) {
bcryptCost = -1;
}
Log.debug("jdbcAuthProvider.bcrypt.cost configured to: {}", bcryptCost);
break;
}
}
@Override
public void propertyDeleted(String property, Map params) {
propertySet(property, Collections.emptyMap());
}
@Override
public void xmlPropertySet(String property, Map params) {
}
@Override
public void xmlPropertyDeleted(String property, Map params) {
}
}