View Javadoc
1   /*
2    * Licensed to the Apache Software Foundation (ASF) under one
3    * or more contributor license agreements.  See the NOTICE file
4    * distributed with this work for additional information
5    * regarding copyright ownership.  The ASF licenses this file
6    * to you under the Apache License, Version 2.0 (the
7    * "License"); you may not use this file except in compliance
8    * with the License.  You may obtain a copy of the License at
9    *
10   *     http://www.apache.org/licenses/LICENSE-2.0
11   *
12   * Unless required by applicable law or agreed to in writing,
13   * software distributed under the License is distributed on an
14   * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15   * KIND, either express or implied.  See the License for the
16   * specific language governing permissions and limitations
17   * under the License.
18   */
19  package org.apache.shiro.realm.jdbc;
20  
21  import org.apache.shiro.authc.AccountException;
22  import org.apache.shiro.authc.AuthenticationException;
23  import org.apache.shiro.authc.AuthenticationInfo;
24  import org.apache.shiro.authc.AuthenticationToken;
25  import org.apache.shiro.authc.SimpleAuthenticationInfo;
26  import org.apache.shiro.authc.UnknownAccountException;
27  import org.apache.shiro.authc.UsernamePasswordToken;
28  import org.apache.shiro.authz.AuthorizationException;
29  import org.apache.shiro.authz.AuthorizationInfo;
30  import org.apache.shiro.authz.SimpleAuthorizationInfo;
31  import org.apache.shiro.lang.codec.Base64;
32  import org.apache.shiro.config.ConfigurationException;
33  import org.apache.shiro.realm.AuthorizingRealm;
34  import org.apache.shiro.subject.PrincipalCollection;
35  import org.apache.shiro.lang.util.ByteSource;
36  import org.apache.shiro.util.JdbcUtils;
37  import org.slf4j.Logger;
38  import org.slf4j.LoggerFactory;
39  
40  import javax.sql.DataSource;
41  import java.sql.Connection;
42  import java.sql.PreparedStatement;
43  import java.sql.ResultSet;
44  import java.sql.SQLException;
45  import java.util.Collection;
46  import java.util.LinkedHashSet;
47  import java.util.Set;
48  
49  /**
50   * Realm that allows authentication and authorization via JDBC calls.  The default queries suggest a potential schema
51   * for retrieving the user's password for authentication, and querying for a user's roles and permissions.  The
52   * default queries can be overridden by setting the query properties of the realm.
53   * <p/>
54   * If the default implementation
55   * of authentication and authorization cannot handle your schema, this class can be subclassed and the
56   * appropriate methods overridden. (usually {@link #doGetAuthenticationInfo(org.apache.shiro.authc.AuthenticationToken)},
57   * {@link #getRoleNamesForUser(java.sql.Connection, String)},
58   * and/or {@link #getPermissions(java.sql.Connection, String, java.util.Collection)}
59   * <p/>
60   * This realm supports caching by extending from {@link org.apache.shiro.realm.AuthorizingRealm}.
61   *
62   * @since 0.2
63   */
64  public class JdbcRealm extends AuthorizingRealm {
65  
66      /*--------------------------------------------
67      |             C O N S T A N T S             |
68      ============================================*/
69      /**
70       * The default query used to retrieve account data for the user.
71       */
72      protected static final String DEFAULT_AUTHENTICATION_QUERY = "select password from users where username = ?";
73  
74      /**
75       * The default query used to retrieve account data for the user when {@link #saltStyle} is COLUMN.
76       */
77      protected static final String DEFAULT_SALTED_AUTHENTICATION_QUERY
78              = "select password, password_salt from users where username = ?";
79  
80      /**
81       * The default query used to retrieve the roles that apply to a user.
82       */
83      protected static final String DEFAULT_USER_ROLES_QUERY = "select role_name from user_roles where username = ?";
84  
85      /**
86       * The default query used to retrieve permissions that apply to a particular role.
87       */
88      protected static final String DEFAULT_PERMISSIONS_QUERY = "select permission from roles_permissions where role_name = ?";
89  
90      private static final Logger LOGGER = LoggerFactory.getLogger(JdbcRealm.class);
91  
92      /**
93       * Password hash salt configuration. <ul>
94       * <li>NO_SALT - password hashes are not salted.</li>
95       * <li>CRYPT - password hashes are stored in unix crypt format.</li>
96       * <li>COLUMN - salt is in a separate column in the database.</li>
97       * <li>EXTERNAL - salt is not stored in the database. {@link #getSaltForUser(String)} will be called
98       * to get the salt</li></ul>
99       */
100     public enum SaltStyle { NO_SALT, CRYPT, COLUMN, EXTERNAL }
101 
102     /*--------------------------------------------
103     |    I N S T A N C E   V A R I A B L E S    |
104     ============================================*/
105     protected DataSource dataSource;
106 
107     protected String authenticationQuery = DEFAULT_AUTHENTICATION_QUERY;
108 
109     protected String userRolesQuery = DEFAULT_USER_ROLES_QUERY;
110 
111     protected String permissionsQuery = DEFAULT_PERMISSIONS_QUERY;
112 
113     protected boolean permissionsLookupEnabled;
114 
115     protected SaltStyle saltStyle = SaltStyle.NO_SALT;
116 
117     protected boolean saltIsBase64Encoded = true;
118 
119     /*--------------------------------------------
120     |         C O N S T R U C T O R S           |
121     ============================================*/
122 
123     /*--------------------------------------------
124     |  A C C E S S O R S / M O D I F I E R S    |
125     ============================================*/
126 
127     /**
128      * Sets the datasource that should be used to retrieve connections used by this realm.
129      *
130      * @param dataSource the SQL data source.
131      */
132     public void setDataSource(DataSource dataSource) {
133         this.dataSource = dataSource;
134     }
135 
136     /**
137      * Overrides the default query used to retrieve a user's password during authentication.  When using the default
138      * implementation, this query must take the user's username as a single parameter and return a single result
139      * with the user's password as the first column.  If you require a solution that does not match this query
140      * structure, you can override {@link #doGetAuthenticationInfo(org.apache.shiro.authc.AuthenticationToken)} or
141      * just {@link #getPasswordForUser(java.sql.Connection, String)}
142      *
143      * @param authenticationQuery the query to use for authentication.
144      * @see #DEFAULT_AUTHENTICATION_QUERY
145      */
146     public void setAuthenticationQuery(String authenticationQuery) {
147         this.authenticationQuery = authenticationQuery;
148     }
149 
150     /**
151      * Overrides the default query used to retrieve a user's roles during authorization.  When using the default
152      * implementation, this query must take the user's username as a single parameter and return a row
153      * per role with a single column containing the role name.  If you require a solution that does not match this query
154      * structure, you can override {@link #doGetAuthorizationInfo(PrincipalCollection)} or just
155      * {@link #getRoleNamesForUser(java.sql.Connection, String)}
156      *
157      * @param userRolesQuery the query to use for retrieving a user's roles.
158      * @see #DEFAULT_USER_ROLES_QUERY
159      */
160     public void setUserRolesQuery(String userRolesQuery) {
161         this.userRolesQuery = userRolesQuery;
162     }
163 
164     /**
165      * Overrides the default query used to retrieve a user's permissions during authorization.  When using the default
166      * implementation, this query must take a role name as the single parameter and return a row
167      * per permission with a single column, containing the permission.
168      * If you require a solution that does not match this query
169      * structure, you can override {@link #doGetAuthorizationInfo(org.apache.shiro.subject.PrincipalCollection)} or just
170      * {@link #getPermissions(java.sql.Connection, String, java.util.Collection)}</p>
171      * <p/>
172      * <b>Permissions are only retrieved if you set {@link #permissionsLookupEnabled} to true.  Otherwise,
173      * this query is ignored.</b>
174      *
175      * @param permissionsQuery the query to use for retrieving permissions for a role.
176      * @see #DEFAULT_PERMISSIONS_QUERY
177      * @see #setPermissionsLookupEnabled(boolean)
178      */
179     public void setPermissionsQuery(String permissionsQuery) {
180         this.permissionsQuery = permissionsQuery;
181     }
182 
183     /**
184      * Enables lookup of permissions during authorization.  The default is "false" - meaning that only roles
185      * are associated with a user.  Set this to true in order to lookup roles <b>and</b> permissions.
186      *
187      * @param permissionsLookupEnabled true if permissions should be looked up during authorization, or false if only
188      *                                 roles should be looked up.
189      */
190     public void setPermissionsLookupEnabled(boolean permissionsLookupEnabled) {
191         this.permissionsLookupEnabled = permissionsLookupEnabled;
192     }
193 
194     /**
195      * Sets the salt style.  See {@link #saltStyle}.
196      *
197      * @param saltStyle new SaltStyle to set.
198      */
199     public void setSaltStyle(SaltStyle saltStyle) {
200         this.saltStyle = saltStyle;
201         if (saltStyle == SaltStyle.COLUMN && authenticationQuery.equals(DEFAULT_AUTHENTICATION_QUERY)) {
202             authenticationQuery = DEFAULT_SALTED_AUTHENTICATION_QUERY;
203         }
204     }
205 
206     /**
207      * Makes it possible to switch off base64 encoding of password salt.
208      * The default value is true, i.e. expect the salt from a string
209      * value in a database to be base64 encoded.
210      *
211      * @param saltIsBase64Encoded the saltIsBase64Encoded to set
212      */
213     public void setSaltIsBase64Encoded(boolean saltIsBase64Encoded) {
214         this.saltIsBase64Encoded = saltIsBase64Encoded;
215     }
216 
217     /*--------------------------------------------
218     |               M E T H O D S               |
219     ============================================*/
220 
221     protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException {
222 
223         UsernamePasswordToken upToken = (UsernamePasswordToken) token;
224         String username = upToken.getUsername();
225 
226         // Null username is invalid
227         if (username == null) {
228             throw new AccountException("Null usernames are not allowed by this realm.");
229         }
230 
231         Connection conn = null;
232         SimpleAuthenticationInfo info = null;
233         try {
234             conn = dataSource.getConnection();
235 
236             String password = null;
237             String salt = null;
238             switch (saltStyle) {
239                 case NO_SALT:
240                     password = getPasswordForUser(conn, username)[0];
241                     break;
242                 case CRYPT:
243                     // TODO: separate password and hash from getPasswordForUser[0]
244                     throw new ConfigurationException("Not implemented yet");
245                     //break;
246                 case COLUMN:
247                     String[] queryResults = getPasswordForUser(conn, username);
248                     password = queryResults[0];
249                     salt = queryResults[1];
250                     break;
251                 case EXTERNAL:
252                     password = getPasswordForUser(conn, username)[0];
253                     salt = getSaltForUser(username);
254                     break;
255                 default:
256             }
257 
258             if (password == null) {
259                 throw new UnknownAccountException("No account found for user [" + username + "]");
260             }
261 
262             info = new SimpleAuthenticationInfo(username, password.toCharArray(), getName());
263 
264             if (salt != null) {
265                 if (saltStyle == SaltStyle.COLUMN && saltIsBase64Encoded) {
266                     info.setCredentialsSalt(ByteSource.Util.bytes(Base64.decode(salt)));
267                 } else {
268                     info.setCredentialsSalt(ByteSource.Util.bytes(salt));
269                 }
270             }
271 
272         } catch (SQLException e) {
273             final String message = "There was a SQL error while authenticating user [" + username + "]";
274             if (LOGGER.isErrorEnabled()) {
275                 LOGGER.error(message, e);
276             }
277 
278             // Rethrow any SQL errors as an authentication exception
279             throw new AuthenticationException(message, e);
280         } finally {
281             JdbcUtils.closeConnection(conn);
282         }
283 
284         return info;
285     }
286 
287     private String[] getPasswordForUser(Connection conn, String username) throws SQLException {
288 
289         String[] result;
290         boolean returningSeparatedSalt = false;
291         switch (saltStyle) {
292             case NO_SALT:
293             case CRYPT:
294             case EXTERNAL:
295                 result = new String[1];
296                 break;
297             default:
298                 result = new String[2];
299                 returningSeparatedSalt = true;
300         }
301 
302         PreparedStatement ps = null;
303         ResultSet rs = null;
304         try {
305             ps = conn.prepareStatement(authenticationQuery);
306             ps.setString(1, username);
307 
308             // Execute query
309             rs = ps.executeQuery();
310 
311             // Loop over results - although we are only expecting one result, since usernames should be unique
312             boolean foundResult = false;
313             while (rs.next()) {
314 
315                 // Check to ensure only one row is processed
316                 if (foundResult) {
317                     throw new AuthenticationException("More than one user row found for user ["
318                             + username + "]. Usernames must be unique.");
319                 }
320 
321                 result[0] = rs.getString(1);
322                 if (returningSeparatedSalt) {
323                     result[1] = rs.getString(2);
324                 }
325 
326                 foundResult = true;
327             }
328         } finally {
329             JdbcUtils.closeResultSet(rs);
330             JdbcUtils.closeStatement(ps);
331         }
332 
333         return result;
334     }
335 
336     /**
337      * This implementation of the interface expects the principals collection to return a String username keyed off of
338      * this realm's {@link #getName() name}
339      *
340      * @see #getAuthorizationInfo(org.apache.shiro.subject.PrincipalCollection)
341      */
342     @Override
343     protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {
344 
345         //null usernames are invalid
346         if (principals == null) {
347             throw new AuthorizationException("PrincipalCollection method argument cannot be null.");
348         }
349 
350         String username = (String) getAvailablePrincipal(principals);
351 
352         Connection conn = null;
353         Set<String> roleNames = null;
354         Set<String> permissions = null;
355         try {
356             conn = dataSource.getConnection();
357 
358             // Retrieve roles and permissions from database
359             roleNames = getRoleNamesForUser(conn, username);
360             if (permissionsLookupEnabled) {
361                 permissions = getPermissions(conn, username, roleNames);
362             }
363 
364         } catch (SQLException e) {
365             final String message = "There was a SQL error while authorizing user [" + username + "]";
366             if (LOGGER.isErrorEnabled()) {
367                 LOGGER.error(message, e);
368             }
369 
370             // Rethrow any SQL errors as an authorization exception
371             throw new AuthorizationException(message, e);
372         } finally {
373             JdbcUtils.closeConnection(conn);
374         }
375 
376         SimpleAuthorizationInfo info = new SimpleAuthorizationInfo(roleNames);
377         info.setStringPermissions(permissions);
378         return info;
379 
380     }
381 
382     protected Set<String> getRoleNamesForUser(Connection conn, String username) throws SQLException {
383         PreparedStatement ps = null;
384         ResultSet rs = null;
385         Set<String> roleNames = new LinkedHashSet<String>();
386         try {
387             ps = conn.prepareStatement(userRolesQuery);
388             ps.setString(1, username);
389 
390             // Execute query
391             rs = ps.executeQuery();
392 
393             // Loop over results and add each returned role to a set
394             while (rs.next()) {
395 
396                 String roleName = rs.getString(1);
397 
398                 // Add the role to the list of names if it isn't null
399                 if (roleName != null) {
400                     roleNames.add(roleName);
401                 } else {
402                     if (LOGGER.isWarnEnabled()) {
403                         LOGGER.warn("Null role name found while retrieving role names for user [" + username + "]");
404                     }
405                 }
406             }
407         } finally {
408             JdbcUtils.closeResultSet(rs);
409             JdbcUtils.closeStatement(ps);
410         }
411         return roleNames;
412     }
413 
414     protected Set<String> getPermissions(Connection conn, String username, Collection<String> roleNames) throws SQLException {
415         PreparedStatement ps = null;
416         Set<String> permissions = new LinkedHashSet<String>();
417         try {
418             ps = conn.prepareStatement(permissionsQuery);
419             for (String roleName : roleNames) {
420 
421                 ps.setString(1, roleName);
422 
423                 ResultSet rs = null;
424 
425                 try {
426                     // Execute query
427                     rs = ps.executeQuery();
428 
429                     // Loop over results and add each returned role to a set
430                     while (rs.next()) {
431 
432                         String permissionString = rs.getString(1);
433 
434                         // Add the permission to the set of permissions
435                         permissions.add(permissionString);
436                     }
437                 } finally {
438                     JdbcUtils.closeResultSet(rs);
439                 }
440 
441             }
442         } finally {
443             JdbcUtils.closeStatement(ps);
444         }
445 
446         return permissions;
447     }
448 
449     protected String getSaltForUser(String username) {
450         return username;
451     }
452 
453 }