/**
* Copyright (C) 2009-2013 FoundationDB, LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program 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 Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package com.foundationdb.server.service.security;
import com.foundationdb.ais.model.AkibanInformationSchema;
import com.foundationdb.ais.model.Routine;
import com.foundationdb.ais.model.TableName;
import com.foundationdb.ais.model.aisb2.AISBBasedBuilder;
import com.foundationdb.ais.model.aisb2.NewAISBuilder;
import com.foundationdb.server.error.AkibanInternalException;
import com.foundationdb.server.error.AuthenticationFailedException;
import com.foundationdb.server.error.SecurityException;
import com.foundationdb.server.service.Service;
import com.foundationdb.server.service.config.ConfigurationService;
import com.foundationdb.server.service.monitor.MonitorService;
import com.foundationdb.server.service.session.Session;
import com.foundationdb.server.store.SchemaManager;
import com.foundationdb.sql.server.ServerCallContextStack;
import com.foundationdb.sql.server.ServerQueryContext;
import com.foundationdb.util.Strings;
import com.google.inject.Inject;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.io.UnsupportedEncodingException;
import java.lang.reflect.Method;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.security.Principal;
import javax.security.auth.Subject;
import javax.security.auth.callback.Callback;
import javax.security.auth.callback.CallbackHandler;
import javax.security.auth.callback.NameCallback;
import javax.security.auth.callback.PasswordCallback;
import javax.security.auth.callback.UnsupportedCallbackException;
import javax.security.auth.login.LoginContext;
import javax.security.auth.login.LoginException;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Properties;
import java.util.Set;
public class SecurityServiceImpl implements SecurityService, Service {
public static final String SCHEMA = TableName.SECURITY_SCHEMA;
public static final String ROLES_TABLE_NAME = "roles";
public static final String USERS_TABLE_NAME = "users";
public static final String USER_ROLES_TABLE_NAME = "user_roles";
public static final String ADD_ROLE_PROC_NAME = "add_role";
public static final String ADD_USER_PROC_NAME = "add_user";
public static final int TABLE_VERSION = 1;
public static final String ADMIN_USER_NAME = "foundationdb";
public static final String CONNECTION_URL = "jdbc:default:connection";
public static final String ADD_ROLE_SQL = "INSERT INTO roles(name) VALUES(?)";
public static final String DELETE_ROLE_SQL = "DELETE FROM roles WHERE name = ?";
public static final String GET_USER_SQL = "SELECT id, name, password_basic, password_digest, password_md5, (SELECT r.id, r.name FROM roles r INNER JOIN user_roles ur ON r.id = ur.role_id WHERE ur.user_id = users.id) FROM users WHERE name = ?";
public static final String ADD_USER_SQL = "INSERT INTO users(name, password_basic, password_digest, password_md5) VALUES(?,?,?,?) RETURNING id";
public static final String ADD_USER_ROLE_SQL = "INSERT INTO user_roles(user_id, role_id) VALUES(?,(SELECT id FROM roles WHERE name = ?))";
public static final String CHANGE_USER_PASSWORD_SQL = "UPDATE users SET password_basic = ?, password_digest = ?, password_md5 = ? WHERE name = ?";
public static final String DELETE_USER_SQL = "DELETE FROM users WHERE name = ?";
public static final String DELETE_ROLE_USER_ROLES_SQL = "DELETE FROM user_roles WHERE role_id IN (SELECT id FROM roles WHERE name = ?)";
public static final String DELETE_USER_USER_ROLES_SQL = "DELETE FROM user_roles WHERE user_id IN (SELECT id FROM users WHERE name = ?)";
public static final String RESTRICT_USER_SCHEMA_PROPERTY = "fdbsql.restrict_user_schema";
public static final String SECURITY_REALM_PROPERTY = "fdbsql.security.realm"; // See also HttpConductorImpl
private final ConfigurationService configService;
private final SchemaManager schemaManager;
private final MonitorService monitor;
private boolean restrictUserSchema;
private String securityRealm;
private static final Logger logger = LoggerFactory.getLogger(SecurityServiceImpl.class);
@Inject
public SecurityServiceImpl(ConfigurationService configService,
SchemaManager schemaManager,
MonitorService monitor) {
this.configService = configService;
this.schemaManager = schemaManager;
this.monitor = monitor;
}
// Connections are not thread safe, and prepared statements remember a Session,
// so rather than trying to pool them, just make a new one each
// request, which is reasonably cheap.
protected Connection openConnection() throws SQLException {
Properties info = new Properties();
info.put("user", ADMIN_USER_NAME);
info.put("password", "");
info.put("database", SCHEMA);
Connection conn = DriverManager.getConnection(CONNECTION_URL, info);
conn.setAutoCommit(false);
return conn;
}
protected void cleanup(Connection conn, Statement stmt) {
if (stmt != null) {
try {
stmt.close();
}
catch (SQLException ex) {
logger.warn("Error closing statement", ex);
}
}
if (conn != null) {
try {
conn.close();
}
catch (SQLException ex) {
logger.warn("Error closing connection", ex);
}
}
}
/* SecurityService */
@Override
public void addRole(String name) {
Connection conn = null;
PreparedStatement stmt = null;
try {
conn = openConnection();
stmt = conn.prepareStatement(ADD_ROLE_SQL);
stmt.setString(1, name);
int nrows = stmt.executeUpdate();
if (nrows != 1) {
throw new SecurityException("Failed to add role " + name);
}
conn.commit();
}
catch (SQLException ex) {
throw new SecurityException("Error adding role", ex);
}
finally {
cleanup(conn, stmt);
}
}
@Override
public void deleteRole(String name) {
Connection conn = null;
PreparedStatement stmt = null;
try {
conn = openConnection();
stmt = conn.prepareStatement(DELETE_ROLE_SQL);
stmt.setString(1, name);
int nrows = stmt.executeUpdate();
if (nrows != 1) {
throw new SecurityException("Failed to delete role");
}
conn.commit();
}
catch (SQLException ex) {
throw new SecurityException("Error deleting role", ex);
}
finally {
cleanup(conn, stmt);
}
}
@Override
public User getUser(String name) {
User user = null;
Connection conn = null;
PreparedStatement stmt = null;
try {
conn = openConnection();
stmt = conn.prepareStatement(GET_USER_SQL);
stmt.setString(1, name);
ResultSet rs = stmt.executeQuery();
if (rs.next()) {
user = getUser(rs);
}
rs.close();
conn.commit();
}
catch (SQLException ex) {
throw new SecurityException("Error adding role", ex);
}
finally {
cleanup(conn, stmt);
}
return user;
}
protected User getUser(ResultSet rs) throws SQLException {
List<String> roles = new ArrayList<>();
ResultSet rs1 = (ResultSet)rs.getObject(6);
while (rs1.next()) {
roles.add(rs1.getString(2));
}
rs1.close();
return new User(rs.getInt(1), rs.getString(2), rs.getString(3), rs.getString(4), roles);
}
@Override
public User addUser(String name, String password, Collection<String> roles) {
int id;
Connection conn = null;
PreparedStatement stmt = null;
String basicPassword = basicPassword(password);
String digestPassword = digestPassword(name, password);
try {
conn = openConnection();
stmt = conn.prepareStatement(ADD_USER_SQL);
stmt.setString(1, name);
stmt.setString(2, basicPassword);
stmt.setString(3, digestPassword);
stmt.setString(4, md5Password(name, password));
int nrows = stmt.executeUpdate();
if (nrows != 1) {
throw new SecurityException("Failed to add user " + name);
}
ResultSet rs = stmt.getGeneratedKeys();
if (rs.next()) {
id = rs.getInt(1);
}
else {
throw new SecurityException("Failed to get user id for " + name);
}
rs.close();
stmt.close();
stmt = null;
stmt = conn.prepareStatement(ADD_USER_ROLE_SQL);
stmt.setInt(1, id);
for (String role : roles) {
stmt.setString(2, role);
nrows = stmt.executeUpdate();
if (nrows != 1) {
throw new SecurityException("Failed to add role " + role);
}
}
conn.commit();
}
catch (SQLException ex) {
throw new SecurityException("Error adding user", ex);
}
finally {
cleanup(conn, stmt);
}
return new User(id, name, basicPassword, digestPassword, new ArrayList<>(roles));
}
@Override
public void deleteUser(String name) {
Connection conn = null;
PreparedStatement stmt = null;
try {
conn = openConnection();
stmt = conn.prepareStatement(DELETE_USER_USER_ROLES_SQL);
stmt.setString(1, name);
stmt.executeUpdate();
stmt.close();
stmt = null;
stmt = conn.prepareStatement(DELETE_USER_SQL);
stmt.setString(1, name);
int nrows = stmt.executeUpdate();
if (nrows != 1) {
throw new SecurityException("Failed to delete user");
}
conn.commit();
monitor.deregisterUserMonitor(name);
}
catch (SQLException ex) {
throw new SecurityException("Error deleting user", ex);
}
finally {
cleanup(conn, stmt);
}
}
@Override
public void changeUserPassword(String name, String password) {
Connection conn = null;
PreparedStatement stmt = null;
try {
conn = openConnection();
stmt = conn.prepareStatement(CHANGE_USER_PASSWORD_SQL);
stmt.setString(1, basicPassword(password));
stmt.setString(2, digestPassword(name, password));
stmt.setString(3, md5Password(name, password));
stmt.setString(4, name);
int nrows = stmt.executeUpdate();
if (nrows != 1) {
throw new SecurityException("Failed to change user");
}
conn.commit();
}
catch (SQLException ex) {
throw new SecurityException("Error changing user", ex);
}
finally {
cleanup(conn, stmt);
}
}
@Override
public Principal authenticateLocal(Session session, String name, String password) {
return authenticateLocal(session, name, password, null);
}
@Override
public Principal authenticateLocal(Session session, String name, String password,
byte[] salt) {
User user = null;
Connection conn = null;
PreparedStatement stmt = null;
try {
conn = openConnection();
stmt = conn.prepareStatement(GET_USER_SQL);
stmt.setString(1, name);
ResultSet rs = stmt.executeQuery();
if (rs.next()) {
String md5 = rs.getString(5);
if ((salt == null) ?
md5Password(name, password).equals(md5) :
password.equals(salted(md5, salt))) {
user = getUser(rs);
}
}
rs.close();
conn.commit();
}
catch (SQLException ex) {
throw new SecurityException("Error adding role", ex);
}
finally {
cleanup(conn, stmt);
}
if (user == null) {
throw new AuthenticationFailedException("invalid username or password");
}
if (session != null) {
session.put(SESSION_PRINCIPAL_KEY, user);
session.put(SESSION_ROLES_KEY, user.getRoles());
}
if (monitor.getUserMonitor(user.getName()) == null) {
monitor.registerUserMonitor(new UserMonitorImpl(user.getName()));
}
return user;
}
protected String basicPassword(String password) {
try {
MessageDigest md = MessageDigest.getInstance("MD5");
md.update(password.getBytes("UTF-8"));
return formatMD5(md.digest(), true);
}
catch (NoSuchAlgorithmException ex) {
throw new AkibanInternalException("Cannot create digest", ex);
}
catch (UnsupportedEncodingException ex) {
throw new AkibanInternalException("Cannot create digest", ex);
}
}
protected String digestPassword(String user, String password) {
try {
MessageDigest md = MessageDigest.getInstance("MD5");
md.update(user.getBytes("UTF-8"));
md.update((":" + securityRealm + ":").getBytes("UTF-8"));
md.update(password.getBytes("UTF-8"));
return formatMD5(md.digest(), true);
}
catch (NoSuchAlgorithmException ex) {
throw new AkibanInternalException("Cannot create digest", ex);
}
catch (UnsupportedEncodingException ex) {
throw new AkibanInternalException("Cannot create digest", ex);
}
}
protected String md5Password(String user, String password) {
try {
MessageDigest md = MessageDigest.getInstance("MD5");
md.update(password.getBytes("UTF-8"));
md.update(user.getBytes("UTF-8"));
return formatMD5(md.digest(), false);
}
catch (NoSuchAlgorithmException ex) {
throw new AkibanInternalException("Cannot create digest", ex);
}
catch (UnsupportedEncodingException ex) {
throw new AkibanInternalException("Cannot create digest", ex);
}
}
protected String salted(String base, byte[] salt) {
try {
MessageDigest md = MessageDigest.getInstance("MD5");
md.update(base.getBytes("UTF-8"), 3, 32); // Skipping "md5".
md.update(salt);
return formatMD5(md.digest(), false);
}
catch (NoSuchAlgorithmException ex) {
throw new AkibanInternalException("Cannot create digest", ex);
}
catch (UnsupportedEncodingException ex) {
throw new AkibanInternalException("Cannot create digest", ex);
}
}
protected String formatMD5(byte[] md5, boolean forDigest) {
StringBuilder str = new StringBuilder();
str.append(forDigest ? "MD5:" : "md5");
// Strings#formatMD5 wants toLowerCase for second parameter, inverse of the forDigest flag
str.append(Strings.formatMD5(md5, !forDigest));
return str.toString();
}
@Override
public void clearAll(Session session) {
Connection conn = null;
Statement stmt = null;
try {
conn = openConnection();
stmt = conn.createStatement();
stmt.execute("DELETE FROM user_roles");
stmt.execute("DELETE FROM users");
stmt.execute("DELETE FROM roles");
conn.commit();
}
catch (SQLException ex) {
throw new SecurityException("Error adding role", ex);
}
finally {
cleanup(conn, stmt);
}
session.remove(SESSION_PRINCIPAL_KEY);
session.remove(SESSION_ROLES_KEY);
}
@Override
public boolean isAccessible(Session session, String schema) {
Principal user = session.get(SESSION_PRINCIPAL_KEY);
if (user == null) return true; // Authentication disabled.
if (isAccessible(user.getName(), schema)) return true;
Collection<String> roles = session.get(SESSION_ROLES_KEY);
return ((roles != null) && roles.contains(ADMIN_ROLE));
}
@Override
public boolean isAccessible(Principal user, boolean inAdminRole, String schema) {
if (user == null) return true; // Authentication disabled.
if (inAdminRole) return true;
return isAccessible(user.getName(), schema);
}
protected boolean isAccessible(String user, String schema) {
return !restrictUserSchema ||
user.equals(schema) ||
TableName.INFORMATION_SCHEMA.equals(schema) ||
TableName.SQLJ_SCHEMA.equals(schema) ||
TableName.SYS_SCHEMA.equals(schema);
}
@Override
public boolean hasRestrictedAccess(Session session) {
Principal user = session.get(SESSION_PRINCIPAL_KEY);
if (user == null) return true; // Authentication disabled.
Collection<String> roles = session.get(SESSION_ROLES_KEY);
return ((roles != null) && roles.contains(ADMIN_ROLE));
}
@Override
public void setAuthenticated(Session session, Principal user, boolean inAdminRole) {
Collection<String> roles;
if (inAdminRole)
roles = Collections.singleton(ADMIN_ROLE);
else
roles = Collections.emptyList();
session.put(SESSION_PRINCIPAL_KEY, user);
session.put(SESSION_ROLES_KEY, roles);
}
@Override
public Principal authenticateJaas(Session session, String name, String password,
String configName, Class<? extends Principal> userClass, Collection<Class<? extends Principal>> roleClasses) {
Subject subject;
try {
LoginContext login = new LoginContext(configName, new NamePasswordCallbackHandler(name, password));
login.login();
subject = login.getSubject();
}
catch (LoginException ex) {
throw new AuthenticationFailedException(ex);
}
Set<? extends Principal> allPrincs = (userClass == null) ?
new HashSet<>(subject.getPrincipals()) :
subject.getPrincipals(userClass);
Collection<String> roles = null;
if (roleClasses != null) {
roles = new HashSet<>();
for (Class<? extends Principal> clazz : roleClasses) {
Set<? extends Principal> rolePrincs = subject.getPrincipals(clazz);
allPrincs.removeAll(rolePrincs);
for (Principal role : rolePrincs) {
roles.add(role.getName());
}
}
}
Principal user;
if (allPrincs.isEmpty())
throw new AuthenticationFailedException("Authentication successful but no Principals returned");
user = allPrincs.iterator().next();
if (roleClasses == null) {
User localUser = getUser(user.getName());
if (localUser != null) {
roles = localUser.getRoles();
}
}
logger.debug("For user {}:\n{}\n Chose principal {}, roles {}", name, subject, user, roles);
session.put(SESSION_PRINCIPAL_KEY, user);
session.put(SESSION_ROLES_KEY, roles);
return user;
}
protected static class NamePasswordCallbackHandler implements CallbackHandler {
private final String name, password;
public NamePasswordCallbackHandler(String name, String password) {
this.name = name;
this.password = password;
}
@Override
public void handle(Callback[] callbacks) throws IOException, UnsupportedCallbackException {
for (Callback callback : callbacks) {
if (callback instanceof NameCallback) {
((NameCallback)callback).setName(name);
}
else if (callback instanceof PasswordCallback) {
((PasswordCallback)callback).setPassword(password.toCharArray());
}
else if (jettyCallback(callback)) {
try {
jettySetObjectMethod.invoke(callback, password);
}
catch (ReflectiveOperationException ex) {
throw new IOException(ex);
}
}
else {
throw new UnsupportedCallbackException(callback);
}
}
}
// Avoid a dependency on all of jetty-plus just to get a one
// line method that might be used.
static Class<?> jettyObjectCallbackClass;
static Method jettySetObjectMethod;
static boolean jettyCallback(Callback callback) {
if (jettyObjectCallbackClass != null) {
return jettyObjectCallbackClass.isInstance(callback);
}
else if ("org.eclipse.jetty.plus.jaas.callback.ObjectCallback".equals(callback.getClass().getName())) {
try {
jettySetObjectMethod = callback.getClass().getMethod("setObject", Object.class);
}
catch (ReflectiveOperationException ex) {
return false;
}
jettyObjectCallbackClass = callback.getClass();
return true;
}
else {
return false;
}
}
}
/* Service */
@Override
public void start() {
restrictUserSchema = Boolean.parseBoolean(configService.getProperty(RESTRICT_USER_SCHEMA_PROPERTY));
securityRealm = configService.getProperty(SECURITY_REALM_PROPERTY);
registerSystemObjects();
if (restrictUserSchema) {
schemaManager.setSecurityService(this); // Injection would be circular.
}
}
@Override
public void stop() {
deregisterSystemObjects();
}
@Override
public void crash() {
stop();
}
protected void registerSystemObjects() {
AkibanInformationSchema ais = buildSystemObjects();
schemaManager.registerStoredInformationSchemaTable(ais.getTable(SCHEMA, ROLES_TABLE_NAME), TABLE_VERSION);
schemaManager.registerStoredInformationSchemaTable(ais.getTable(SCHEMA, USERS_TABLE_NAME), TABLE_VERSION);
schemaManager.registerStoredInformationSchemaTable(ais.getTable(SCHEMA, USER_ROLES_TABLE_NAME), TABLE_VERSION);
schemaManager.registerSystemRoutine(ais.getRoutine(SCHEMA, ADD_ROLE_PROC_NAME));
schemaManager.registerSystemRoutine(ais.getRoutine(SCHEMA, ADD_USER_PROC_NAME));
}
protected void deregisterSystemObjects() {
schemaManager.unRegisterSystemRoutine(new TableName(SCHEMA, ADD_ROLE_PROC_NAME));
schemaManager.unRegisterSystemRoutine(new TableName(SCHEMA, ADD_USER_PROC_NAME));
}
protected AkibanInformationSchema buildSystemObjects() {
NewAISBuilder builder = AISBBasedBuilder.create(SCHEMA, schemaManager.getTypesTranslator());
builder.table(ROLES_TABLE_NAME)
.autoIncInt("id", 1)
.colString("name", 128, false)
.pk("id")
.uniqueKey("role_name", "name");
builder.table(USERS_TABLE_NAME)
.autoIncInt("id", 1)
.colString("name", 128, false)
.colString("password_basic", 36, true)
.colString("password_digest", 36, true)
.colString("password_md5", 35, true)
.pk("id")
.uniqueKey("user_name", "name");
builder.table(USER_ROLES_TABLE_NAME)
.autoIncInt("id", 1)
.colInt("role_id", false)
.colInt("user_id", false)
.pk("id")
.uniqueKey("user_roles", "user_id", "role_id")
.joinTo(USERS_TABLE_NAME)
.on("user_id", "id");
builder.procedure(ADD_ROLE_PROC_NAME)
.language("java", Routine.CallingConvention.JAVA)
.paramStringIn("role_name", 128)
.externalName(Routines.class.getName(), "addRole");
builder.procedure(ADD_USER_PROC_NAME)
.language("java", Routine.CallingConvention.JAVA)
.paramStringIn("user_name", 128)
.paramStringIn("password", 128)
.paramStringIn("roles", 128)
.externalName(Routines.class.getName(), "addUser");
return builder.ais(true);
}
// TODO: Temporary way of accessing these via stored procedures.
public static class Routines {
public static void addRole(String roleName) {
ServerQueryContext context = ServerCallContextStack.getCallingContext();
SecurityService service = context.getServer().getSecurityService();
service.addRole(roleName);
}
public static void addUser(String userName, String password, String roles) {
ServerQueryContext context = ServerCallContextStack.getCallingContext();
SecurityService service = context.getServer().getSecurityService();
service.addUser(userName, password, Arrays.asList(roles.split(",")));
}
}
}