diff --git a/java/org/apache/catalina/realm/DataSourceRealm.java b/java/org/apache/catalina/realm/DataSourceRealm.java index 5adf14f615fd..58d91f774dfb 100644 --- a/java/org/apache/catalina/realm/DataSourceRealm.java +++ b/java/org/apache/catalina/realm/DataSourceRealm.java @@ -18,16 +18,25 @@ import java.security.Principal; +import java.sql.Array; +import java.sql.Blob; +import java.sql.Clob; import java.sql.Connection; import java.sql.PreparedStatement; import java.sql.ResultSet; +import java.sql.ResultSetMetaData; import java.sql.SQLException; +import java.sql.Types; import java.util.ArrayList; - +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; import javax.naming.Context; import javax.sql.DataSource; import org.apache.catalina.LifecycleException; +import org.apache.catalina.TomcatPrincipal; import org.apache.naming.ContextBindings; /** @@ -43,6 +52,16 @@ */ public class DataSourceRealm extends RealmBase { + /** + * String object (empty string) signaling an empty list of user attributes, that + * is, no valid or existing user attributes to additionally query from the user + * table have been specified. + *

+ * Will be assigned to userAttributesStatement in order to prevent + * entering the DCL lock in method getUserAttributesStatement every + * time in case there are no user attributes to query. + */ + private static final String USER_ATTRIBUTES_NONE_REQUESTED = new String(); // ----------------------------------------------------- Instance Variables @@ -59,6 +78,18 @@ public class DataSourceRealm extends RealmBase { private String preparedCredentials = null; + /** + * The generated string for the user attributes PreparedStatement + */ + private String preparedAttributesTail = null; + + + /** + * The generated string for the user attributes available PreparedStatement + */ + private String preparedAttributesAvailable = null; + + /** * The name of the JNDI JDBC DataSource */ @@ -107,6 +138,22 @@ public class DataSourceRealm extends RealmBase { private volatile boolean connectionSuccess = true; + /** + * The comma separated names of user attributes to additionally query from the + * user table. These will be provided to the user through the created + * Principal's attributes map. + */ + protected String userAttributes; + + + /** + * Generated SQL statement to query additional user attributes from the user + * table. + */ + private volatile String userAttributesStatement; + private final Object userAttributesStatementLock = new Object(); + + // ------------------------------------------------------------- Properties @@ -223,6 +270,34 @@ public void setUserTable( String userTable ) { this.userTable = userTable; } + /** + * @return the comma separated names of user attributes to additionally query + * from the user table + */ + public String getUserAttributes() { + return userAttributes; + } + + /** + * Set the comma separated names of user attributes to additionally query from + * the user table. These will be provided to the user through the created + * Principal's attributes map. In this map, each field value is bound to + * the field's name, that is, the name of the field serves as the key of the + * mapping. + *

+ * If set to the wildcard character, or, if the wildcard character is part of + * the comma separated list, all available attributes - except the + * password attribute (as specified by userCredCol) - are + * queried. The wildcard character is defined by constant + * {@link RealmBase#USER_ATTRIBUTES_WILDCARD}. It defaults to the asterisk (*) + * character. + * + * @param userAttributes the comma separated names of user attributes + */ + public void setUserAttributes(String userAttributes) { + this.userAttributes = userAttributes; + } + // --------------------------------------------------------- Public Methods @@ -336,9 +411,10 @@ protected Principal authenticate(Connection dbConnection, } ArrayList list = getRoles(dbConnection, username); + Map attrs = getUserAttributesMap(dbConnection, username); // Create and return a suitable Principal for this user - return new GenericPrincipal(username, list); + return new GenericPrincipal(username, list, null, null, null, attrs); } @@ -463,8 +539,8 @@ protected Principal getPrincipal(String username) { return new GenericPrincipal(username, null); } try { - return new GenericPrincipal(username, - getRoles(dbConnection, username)); + return new GenericPrincipal(username, getRoles(dbConnection, username), null, null, + null, getUserAttributesMap(dbConnection, username)); } finally { close(dbConnection); } @@ -539,6 +615,265 @@ private boolean isRoleStoreDefined() { } + /** + * Return the specified user's requested user attributes as a map. + *

+ * This method does not support values of every possible SQL data type. Uses + * {@link ResultSet#getObject(int)} to get the attribute's value, except for + * columns of these SQL types: + *

+ * Other multivalued or complex types obtained from getObject(int) + * may not be serializable and so, cannot be defensively copied when being + * returned from {@link TomcatPrincipal#getAttribute(String)}. In that case, + * only a String with the object's string representation will be + * returned (in contrast to the object itself). + *

+ * In other words, user attributes queried by DataSourceRealm works + * well with values of a simple scalar type as well as for SQL arrays, + * BLOBs, CLOBs and NCLOBs. Any other types are not fully supported. + * + * @param dbConnection The database connection to be used + * @param username User name for which to return user attributes + * + * @return a map containing the specified user's requested user attributes + */ + protected Map getUserAttributesMap(Connection dbConnection, String username) { + + String preparedAttributes = getUserAttributesStatement(dbConnection); + if (preparedAttributes == null || preparedAttributes.isEmpty()) { + // Return null if the SQL statement was not yet built successfully ( = null), or + // if no user attributes have been requested (isEmpty). + return null; + } + + try (PreparedStatement stmt = dbConnection.prepareStatement(preparedAttributes)) { + stmt.setString(1, username); + + try (ResultSet rs = stmt.executeQuery()) { + + if (rs.next()) { + Map attrs = new LinkedHashMap<>(); + ResultSetMetaData md = rs.getMetaData(); + int ncols = md.getColumnCount(); + for (int columnIndex = 1; columnIndex <= ncols; columnIndex++) { + + String columnName = md.getColumnName(columnIndex); + // Ignore case, database may have case-insensitive field names + if (columnName.equalsIgnoreCase(userCredCol)) { + // Always skip userCredCol (must be there if all columns + // have been requested) + continue; + } + + switch (md.getColumnType(columnIndex)) { + case Types.BLOB: + Blob blob = rs.getBlob(columnIndex); + if (blob != null) { + attrs.put(columnName, blob.getBytes(1, (int) blob.length())); + blob.free(); + } else { + attrs.put(columnName, null); + } + break; + + case Types.CLOB: + case Types.NCLOB: + Clob clob = rs.getClob(columnIndex); + if (clob != null) { + attrs.put(columnName, clob.getSubString(1, (int) clob.length())); + clob.free(); + } else { + attrs.put(columnName, null); + } + break; + + case Types.ARRAY: + Array array = rs.getArray(columnIndex); + if (array != null) { + attrs.put(columnName, array.getArray()); + array.free(); + } else { + attrs.put(columnName, null); + } + break; + + default: + attrs.put(columnName, rs.getObject(columnIndex)); + break; + } + } + + return attrs.size() > 0 ? attrs : null; + } + } + } catch (SQLException e) { + containerLog.error( + sm.getString("dataSourceRealm.getUserAttributes.exception", username), e); + } + + return null; + } + + + /** + * Return the SQL statement for querying additional user attributes. The + * statement is lazily initialized (lazily initialized singleton with + * double-checked locking, DCL) since building it may require an extra + * database query under some conditions. + * + * @param dbConnection connection for accessing the database + */ + private String getUserAttributesStatement(Connection dbConnection) { + // DCL so userAttributesStatement MUST be volatile + if (userAttributesStatement == null) { + synchronized (userAttributesStatementLock) { + if (userAttributesStatement == null) { + List requestedAttributes = parseUserAttributes(userAttributes); + if (requestedAttributes == null) { + // No user attributes have been specified. Do not query any extra attributes. + // Set field to (non-null) empty string USER_ATTRIBUTES_NONE_REQUESTED, so + // we don't try to enter the critical section next time this method is + // called. + userAttributesStatement = USER_ATTRIBUTES_NONE_REQUESTED; + return userAttributesStatement; + } + if (requestedAttributes.size() == 1 + && requestedAttributes.get(0).equals(USER_ATTRIBUTES_WILDCARD)) { + // wildcard case + userAttributesStatement = "SELECT *" + preparedAttributesTail; + return userAttributesStatement; + } + List availableUserAttributes = getAvailableUserAttributes(dbConnection); + if (availableUserAttributes == null) { + // Failed getting all available user attributes (this has already been + // logged) so, just return, leaving userAttributesStatement null (aka + // uninitialized, will try again next time). + return null; + } + requestedAttributes = + validateUserAttributes(requestedAttributes, availableUserAttributes); + if (requestedAttributes == null) { + // None of the requested user attributes are actually available or valid. Do + // not query any attributes. + // Set field to (non-null) empty string USER_ATTRIBUTES_NONE_REQUESTED, so + // we don't try to enter the critical section next time this method is + // called. + userAttributesStatement = USER_ATTRIBUTES_NONE_REQUESTED; + return userAttributesStatement; + } + StringBuilder sb = new StringBuilder("SELECT "); + boolean first = true; + for (String attr : requestedAttributes) { + if (first) { + first = false; + } else { + sb.append(", "); + } + sb.append(attr); + } + userAttributesStatement = sb.append(preparedAttributesTail).toString(); + } + } + } + return userAttributesStatement; + } + + + /** + * Return a list of all available user attributes. The list contains all field + * names of the user table, except for fields for which access is denied (e. g. + * field userCredCol). + * + * @param dbConnection connection for accessing the database + */ + private List getAvailableUserAttributes(Connection dbConnection) { + + try (PreparedStatement stmt = + dbConnection.prepareStatement(preparedAttributesAvailable)) { + + try (ResultSet rs = stmt.executeQuery()) { + + // Query is not expected to return any rows (...WHERE 1 = 2) so, must not call + // next(). ResultSetMetadata is available before calling next() anyway. + List result = new ArrayList<>(); + ResultSetMetaData md = rs.getMetaData(); + int ncols = md.getColumnCount(); + for (int columnIndex = 1; columnIndex <= ncols; columnIndex++) { + String columnName = md.getColumnName(columnIndex); + // Ignore case, database may have case-insensitive field names + if (columnName.equalsIgnoreCase(userCredCol)) { + // always skip userCredCol + continue; + } + result.add(columnName); + } + return result; + } + } catch (SQLException e) { + containerLog.error(sm.getString( + "dataSourceRealm.getAvailableUserAttributes.exception"), e); + } + + return null; + } + + + /** + * Validate the specified list of attribute names and return a list containing + * valid items only or null, if there are no valid attributes. + *

+ * If availableAttributes is not null, log an + * userAttributeNotFound warning message for each specified attribute + * not contained in that list. + *

+ * If userCredCol is not null, and not the empty + * string, log an userAttributeAccessDenied warning message for each + * specified attribute equal to that value. + * + * @param userAttributes list of attribute names to validate + * @param availableAttributes list of available (aka valid) attribute names + * @return the validated attribute names as a list or null, if + * there are no valid attributes + */ + private List validateUserAttributes(List userAttributes, + List availableAttributes) { + if (userAttributes == null || userAttributes.size() == 0) { + return null; + } + if (userAttributes.size() == 1 && userAttributes.get(0).equals(USER_ATTRIBUTES_WILDCARD)) { + return userAttributes; + } + String deniedAttribute = userCredCol; + if (deniedAttribute != null && deniedAttribute.isEmpty()) { + deniedAttribute = null; + } + List attrs = new ArrayList<>(); + for (String name : userAttributes) { + if (deniedAttribute != null && deniedAttribute.equals(name)) { + if (containerLog.isWarnEnabled()) { + containerLog + .warn(sm.getString("dataSourceRealm.userAttributeAccessDenied", name)); + } + continue; + } else if (availableAttributes != null && !availableAttributes.contains(name)) { + if (containerLog.isWarnEnabled()) { + containerLog.warn(sm.getString("dataSourceRealm.userAttributeNotFound", name)); + } + continue; + } else if (name.equals(USER_ATTRIBUTES_WILDCARD)) { + return Collections.singletonList(USER_ATTRIBUTES_WILDCARD); + } + attrs.add(name); + } + return attrs.size() > 0 ? attrs : null; + } + + // ------------------------------------------------------ Lifecycle Methods /** @@ -572,6 +907,26 @@ protected void startInternal() throws LifecycleException { temp.append(" = ?"); preparedCredentials = temp.toString(); + // Create the user attributes PreparedStatement string (only its tail w/o SELECT + // clause) + temp = new StringBuilder(" FROM "); + temp.append(userTable); + temp.append(" WHERE "); + temp.append(userNameCol); + temp.append(" = ?"); + preparedAttributesTail = temp.toString(); + + // Create the available user attributes PreparedStatement string + // With this statement, we only want to query the definitions of all fields of + // the user table. In other words, we want an empty ResultSet, which, however, + // still has its ResultSetMetadata describing all the column types. In order to + // prevent the database from sending any row, it uses a WHERE clause that always + // evaluates to false (WHERE 1 = 2). + temp = new StringBuilder("SELECT * FROM "); + temp.append(userTable); + temp.append(" WHERE 1 = 2"); + preparedAttributesAvailable = temp.toString(); + super.startInternal(); } } diff --git a/java/org/apache/catalina/realm/LocalStrings.properties b/java/org/apache/catalina/realm/LocalStrings.properties index cd09ed4b50f2..60297175dc72 100644 --- a/java/org/apache/catalina/realm/LocalStrings.properties +++ b/java/org/apache/catalina/realm/LocalStrings.properties @@ -32,6 +32,10 @@ dataSourceRealm.commit=Exception committing connection before closing dataSourceRealm.exception=Exception performing authentication dataSourceRealm.getPassword.exception=Exception retrieving password for [{0}] dataSourceRealm.getRoles.exception=Exception retrieving roles for [{0}] +dataSourceRealm.getUserAttributes.exception=Exception retrieving user attributes for [{0}] +dataSourceRealm.getAvailableUserAttributes.exception=Exception retrieving names of all available user attributes +dataSourceRealm.userAttributeNotFound=Specified user attribute [{0}] does not exist +dataSourceRealm.userAttributeAccessDenied=Access to specified user attribute [{0}] has been denied jaasCallback.username=Returned username [{0}] diff --git a/java/org/apache/catalina/realm/RealmBase.java b/java/org/apache/catalina/realm/RealmBase.java index f3598e5cfc0a..270911d4b9bd 100644 --- a/java/org/apache/catalina/realm/RealmBase.java +++ b/java/org/apache/catalina/realm/RealmBase.java @@ -26,6 +26,7 @@ import java.security.Principal; import java.security.cert.X509Certificate; import java.util.ArrayList; +import java.util.Collections; import java.util.List; import java.util.Locale; @@ -76,6 +77,21 @@ public abstract class RealmBase extends LifecycleMBeanBase implements Realm { private static final List> credentialHandlerClasses = new ArrayList<>(); + /** + * The character used for delimiting user attribute names. + *

+ * Applies to some of the Realm implementations only. + */ + protected static final String USER_ATTRIBUTES_DELIMITER = ","; + + /** + * The character used as wildcard in user attribute lists. Using it means + * query all available user attributes. + *

+ * Applies to some of the Realm implementations only. + */ + protected static final String USER_ATTRIBUTES_WILDCARD = "*"; + static { // Order is important since it determines the search order for a // matching handler if only an algorithm is specified when calling @@ -1293,6 +1309,40 @@ protected Server getServer() { } + /** + * Parse the specified delimiter separated attribute names and return a list of + * that names or null, if no attributes have been specified. + *

+ * If a wildcard character is found, return a list consisting of a single + * wildcard character only. + * + * @param userAttributes comma separated names of attributes to parse + * @return a list containing the parsed attribute names or null, if + * no attributes have been specified + */ + protected List parseUserAttributes(String userAttributes) { + if (userAttributes == null) { + return null; + } + List attrs = new ArrayList<>(); + for (String name : userAttributes.split(USER_ATTRIBUTES_DELIMITER)) { + name = name.trim(); + if (name.length() == 0) { + continue; + } + if (name.equals(USER_ATTRIBUTES_WILDCARD)) { + return Collections.singletonList(USER_ATTRIBUTES_WILDCARD); + } + if (attrs.contains(name)) { + // skip duplicates + continue; + } + attrs.add(name); + } + return attrs.size() > 0 ? attrs : null; + } + + // --------------------------------------------------------- Static Methods /** diff --git a/java/org/apache/catalina/realm/mbeans-descriptors.xml b/java/org/apache/catalina/realm/mbeans-descriptors.xml index 9885e740ad70..f5d1520e863f 100644 --- a/java/org/apache/catalina/realm/mbeans-descriptors.xml +++ b/java/org/apache/catalina/realm/mbeans-descriptors.xml @@ -57,6 +57,10 @@ type="java.lang.String" writeable="false"/> + + diff --git a/webapps/docs/changelog.xml b/webapps/docs/changelog.xml index bd2a1246f8b4..c37278b7a9ac 100644 --- a/webapps/docs/changelog.xml +++ b/webapps/docs/changelog.xml @@ -128,6 +128,10 @@ TomcatPrincipal and GenericPrincipal. Patch provided by Carsten Klein. (michaelo) + + xxx: Add support for additional user attributes to + DataSourceRealm. Patch provided by Carsten Klein. (michaelo) + 469: Include the Jakarata Annotations API in the classes that Tomcat will not load from web applications. Pull request provided by diff --git a/webapps/docs/config/realm.xml b/webapps/docs/config/realm.xml index 78b4c20fd58b..a657267f3cb7 100644 --- a/webapps/docs/config/realm.xml +++ b/webapps/docs/config/realm.xml @@ -166,6 +166,34 @@ name. If not specified, the default is true.

+ +

Comma separated list of names of columns to additionally query from + the "users" table named by the userTable attribute. These + are provided to the application as Additional User Attributes + through the Principal's attributes map (with configured column names + used as the keys). This attribute also supports the wildcard character + *, which means to query all columns (except, for + security reasons, the column containing the user's credentials).

+

Any of the "users" table's columns can be specified, except the + column containing the user's credentials (i.e. password) named by the + userCredCol attribute. If the credentials column is + explicitly specified, it will be ignored and a warning will be logged. +

+

Specified columns that are not present in the "users" table are + dropped from the list and a warning is logged, one for each missing + column. However, these checks are performed only once while the Realm + processes its first login attempt. If the structure of the "users" + table significantly changes after that point in time (i.e. a specified + column gets removed), a logged exception is likely the result for each + subsequent login attempt until the Realm gets restarted. Even so, + exceptions caused by user attribute queries do not prevent the user + from loggin in, but only cause the associated Principal's attributes + map being empty.

+

See the + Additional User Attributes How-To for more information on how + additional user attributes can be access from your application.

+
+

Name of the column, in the "users" table, which contains the user's credentials (i.e. password). If a @@ -173,6 +201,8 @@ will assume that the passwords have been encoded with the specified algorithm. Otherwise, they will be assumed to be in clear text.

+

For security reasons, the column named by this attribute cannot + be used as an Additional User Attribute.

@@ -192,7 +222,10 @@

Name of the "users" table, which must contain columns named by the userNameCol and userCredCol - attributes.

+ attributes. More columns containing additional user information could + freely be added. These can be provided to the application as + Additional User Attributes configured by the + userAttributes attribute.

diff --git a/webapps/docs/realm-howto.xml b/webapps/docs/realm-howto.xml index d03291c4f26f..a45ab6c4ed9f 100644 --- a/webapps/docs/realm-howto.xml +++ b/webapps/docs/realm-howto.xml @@ -253,6 +253,95 @@ simplified to {digest}.

+ + +

Some of the Realm implementations, currently only DataSourceRealm, +support a feature called Additional User Attributes. With that, the +Realm additionally queries a configurable list of attributes from the user's +entry in the associated user database. The purpose of this feature is to provide +extra user-related information, like user dispay name, e-mail address, department, +room number, phone number etc., to the application with only a configured list of +attribute names (like the SELECT clause of an SQL statement).

+ +

Additional user attributes are provided to the application through the +Principal instance, which is associated with each authenticated +request. Principals created by the DataSourceRealm maintain a set of +read-only named attributes, accessible by these methods: + +java.lang.Object getAttribute(java.lang.String name); + +java.util.Enumeration<java.lang.String> getAttributeNames(); + + +This set of attributes is populated by the Realm with the queried additional +user attributes. The configured names of the attributes to query additionally +also serve as the names of the attributes. These are the real names of the user +table's columns in the JDBC database.

+ +

The Principal returned for a request by method +HttpServletRequest.getUserPrincipal() is of type +java.security.Principal. The methods to access the Principal's +attributes, however, are actually declared in the +org.apache.catalina.TomcatPrincipal interface, which extends +java.security.Principal and is the base type for all Principal +instances used in Tomcat. So, in order to actually access the Principal's +attributes, an instanceof check and casting is required. Have a +look at this simple example, which dumps all available additional user +attributes: + +Principal p = request.getUserPrincipal(); +if (p instanceof TomcatPrincipal) { + + TomcatPrincipal principal = (TomcatPrincipal) p; + + out.writeln("Principal contains these attributes:"); + + Enumeration<String> names = principal.getAttributeNames(); + while (names.hasMoreElements()) { + String name = names.nextElement(); + Object value = principal.getAttribute(name); + + out.writeln(name + ": " + value.toString()); + } +} else { + out.writeln("Principal does not support attributes.") +} + + +The JSP example application referred to in the next section also lists the +Principal's attributes. However, in order to get any user attributes, ensure +to use DataSourceRealm +with the example application and configure its userAttributes +attribute accordingly:

+ +

An example SQL script to create an attributes-extended user table might +look something like this (adapt the syntax as required for your particular +database):

+create table users ( + user_name varchar(15) not null primary key, + user_pass varchar(15) not null, + user_disp_name varchar(30), + user_dept varchar(15), + user_room integer, + user_phone varchar(15) +); + +

With that user table, the Realm's userAttributes attribute could +look like:

+]]> + +

Security alert: Please note, that this feature, when +configured inappropriately and, depending on the user related data available in +the Realm's user database, could leak sensitive user data. +

+ +
+ @@ -363,7 +452,9 @@ configuration documentation.

Example

An example SQL script to create the needed tables might look something -like this (adapt the syntax as required for your particular database):

+like this (adapt the syntax as required for your particular database). You may +add more columns to the "users" table to be provided as +Additional User Attributes:

create table users ( user_name varchar(15) not null primary key, user_pass varchar(15) not null