2 * This document is a part of the source code and related artifacts
3 * for CollectionSpace, an open source collections management system
4 * for museums and related institutions:
6 * http://www.collectionspace.org
7 * http://wiki.collectionspace.org
9 * Copyright 2009 University of California at Berkeley
11 * Licensed under the Educational Community License (ECL), Version 2.0.
12 * You may not use this file except in compliance with this License.
14 * You may obtain a copy of the ECL 2.0 License at
16 * https://source.collectionspace.org/collection-space/LICENSE.txt
18 * Unless required by applicable law or agreed to in writing, software
19 * distributed under the License is distributed on an "AS IS" BASIS,
20 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
21 * See the License for the specific language governing permissions and
22 * limitations under the License.
24 * This document is a part of the source code and related artifacts
25 * for CollectionSpace, an open source collections management system
26 * for museums and related institutions:
28 * http://www.collectionspace.org
29 * http://wiki.collectionspace.org
31 * Copyright 2009 University of California at Berkeley
33 * Licensed under the Educational Community License (ECL), Version 2.0.
34 * You may not use this file except in compliance with this License.
36 * You may obtain a copy of the ECL 2.0 License at
38 * https://source.collectionspace.org/collection-space/LICENSE.txt
40 * Unless required by applicable law or agreed to in writing, software
41 * distributed under the License is distributed on an "AS IS" BASIS,
42 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
43 * See the License for the specific language governing permissions and
44 * limitations under the License.
47 * To change this template, choose Tools | Templates
48 * and open the template in the editor.
50 package org.collectionspace.authentication.realm.db;
52 import java.net.ConnectException;
53 import java.sql.Connection;
54 import java.sql.PreparedStatement;
55 import java.sql.ResultSet;
56 import java.sql.SQLException;
57 import java.util.LinkedHashSet;
61 import javax.naming.Context;
62 import javax.naming.InitialContext;
63 import javax.naming.NamingException;
64 import javax.security.auth.login.AccountException;
65 import javax.security.auth.login.AccountNotFoundException;
66 import javax.sql.DataSource;
68 import org.collectionspace.authentication.AuthN;
69 import org.collectionspace.authentication.CSpaceTenant;
70 import org.collectionspace.authentication.realm.CSpaceRealm;
71 import org.postgresql.util.PSQLState;
72 import org.slf4j.Logger;
73 import org.slf4j.LoggerFactory;
76 * CSpaceDbRealm provides access to user, password, role, tenant database
79 public class CSpaceDbRealm implements CSpaceRealm {
80 public static String DEFAULT_DATASOURCE_NAME = "CspaceDS";
82 private Logger logger = LoggerFactory.getLogger(CSpaceDbRealm.class);
84 private String datasourceName;
85 private String principalsQuery;
86 private String saltQuery;
87 private String rolesQuery;
88 private String tenantsQueryNoDisabled;
89 private String tenantsQueryWithDisabled;
90 private boolean suspendResume;
92 private long maxRetrySeconds = MAX_RETRY_SECONDS;
93 private static final int MAX_RETRY_SECONDS = 5;
94 private static final String MAX_RETRY_SECONDS_STR = "maxRetrySeconds";
96 private long delayBetweenAttemptsMillis = DELAY_BETWEEN_ATTEMPTS_MILLISECONDS;
97 private static final String DELAY_BETWEEN_ATTEMPTS_MILLISECONDS_STR = "delayBetweenAttemptsMillis";
98 private static final long DELAY_BETWEEN_ATTEMPTS_MILLISECONDS = 200;
100 protected void setMaxRetrySeconds(Map<String, ?> options) {
101 Object optionsObj = options.get(MAX_RETRY_SECONDS_STR);
102 if (optionsObj != null) {
103 String paramValue = optionsObj.toString();
105 maxRetrySeconds = Long.parseLong(paramValue);
106 } catch (NumberFormatException e) {
107 logger.warn(String.format("The Spring Security login authentication parameter '%s' with value '%s' could not be parsed to a long value. The default value of '%d' will be used instead.",
108 MAX_RETRY_SECONDS_STR, paramValue, maxRetrySeconds));
113 protected long getMaxRetrySeconds() {
114 return this.maxRetrySeconds;
117 protected void setDelayBetweenAttemptsMillis(Map<String, ?> options) {
118 Object optionsObj = options.get(DELAY_BETWEEN_ATTEMPTS_MILLISECONDS_STR);
119 if (optionsObj != null) {
120 String paramValue = optionsObj.toString();
122 delayBetweenAttemptsMillis = Long.parseLong(paramValue);
123 } catch (NumberFormatException e) {
124 logger.warn(String.format("The Spring Security login authentication parameter '%s' with value '%s' could not be parsed to a long value. The default value of '%d' will be used instead.",
125 MAX_RETRY_SECONDS_STR, paramValue, delayBetweenAttemptsMillis));
130 protected long getDelayBetweenAttemptsMillis() {
131 return this.delayBetweenAttemptsMillis;
134 public CSpaceDbRealm() {
135 datasourceName = DEFAULT_DATASOURCE_NAME;
139 * CSpace Database Realm
140 * @param datasourceName datasource name
142 public CSpaceDbRealm(Map<String, ?> options) {
143 datasourceName = (String) options.get("dsJndiName");
144 if (datasourceName == null) {
145 datasourceName = DEFAULT_DATASOURCE_NAME;
147 Object tmp = options.get("principalsQuery");
149 principalsQuery = tmp.toString();
151 tmp = options.get("saltQuery");
153 saltQuery = tmp.toString();
155 tmp = options.get("rolesQuery");
157 rolesQuery = tmp.toString();
159 tmp = options.get("tenantsQueryNoDisabled");
161 tenantsQueryNoDisabled = tmp.toString();
163 tmp = options.get("tenantsQueryWithDisabled");
165 tenantsQueryWithDisabled = tmp.toString();
167 tmp = options.get("suspendResume");
169 suspendResume = Boolean.valueOf(tmp.toString()).booleanValue();
172 this.setMaxRetrySeconds(options);
173 this.setDelayBetweenAttemptsMillis(options);
175 if (logger.isTraceEnabled()) {
176 logger.trace("DatabaseServerLoginModule, dsJndiName=" + datasourceName);
177 logger.trace("principalsQuery=" + principalsQuery);
178 logger.trace("rolesQuery=" + rolesQuery);
179 logger.trace("suspendResume=" + suspendResume);
184 public String getPassword(String username) throws AccountException {
186 String password = null;
187 Connection conn = null;
188 PreparedStatement ps = null;
191 conn = getConnection();
193 if (logger.isDebugEnabled()) {
194 logger.debug("Executing query: " + principalsQuery + ", with username: " + username);
196 ps = conn.prepareStatement(principalsQuery);
197 ps.setString(1, username);
198 rs = ps.executeQuery();
199 if (rs.next() == false) {
200 if (logger.isDebugEnabled()) {
201 logger.debug(principalsQuery + " returned no matches from db");
203 throw new AccountNotFoundException("No matching username found");
206 password = rs.getString(1);
207 } catch (SQLException ex) {
208 if (logger.isTraceEnabled() == true) {
209 logger.error("Could not open database to read AuthN tables.", ex);
211 AccountException ae = new AccountException("Authentication query failed: " + ex.getLocalizedMessage());
214 } catch (AccountNotFoundException ex) {
216 } catch (Exception ex) {
217 AccountException ae = new AccountException("Unknown Exception");
224 } catch (SQLException e) {
230 } catch (SQLException e) {
236 } catch (SQLException ex) {
244 public Set<String> getRoles(String username) throws AccountException {
245 if (logger.isDebugEnabled()) {
246 logger.debug("getRoleSets using rolesQuery: " + rolesQuery + ", username: " + username);
249 Set<String> roles = new LinkedHashSet<String>();
251 Connection conn = null;
252 PreparedStatement ps = null;
256 conn = getConnection();
257 // Get the user role names
258 if (logger.isDebugEnabled()) {
259 logger.debug("Executing query: " + rolesQuery + ", with username: " + username);
262 ps = conn.prepareStatement(rolesQuery);
264 ps.setString(1, username);
265 } catch (ArrayIndexOutOfBoundsException ignore) {
266 // The query may not have any parameters so just try it
268 rs = ps.executeQuery();
269 if (rs.next() == false) {
270 if (logger.isDebugEnabled()) {
271 logger.debug("No roles found");
278 String roleName = rs.getString(1);
282 } catch (SQLException ex) {
283 AccountException ae = new AccountException("Query failed");
286 } catch (Exception e) {
287 AccountException ae = new AccountException("unknown exception");
294 } catch (SQLException e) {
300 } catch (SQLException e) {
306 } catch (Exception ex) {
316 public Set<CSpaceTenant> getTenants(String username) throws AccountException {
317 return getTenants(username, false);
320 private boolean userIsTenantManager(Connection conn, String username) {
321 String acctQuery = "SELECT csid FROM accounts_common WHERE userid=?";
322 PreparedStatement ps = null;
324 boolean accountIsTenantManager = false;
326 ps = conn.prepareStatement(acctQuery);
327 ps.setString(1, username);
328 rs = ps.executeQuery();
330 String acctCSID = rs.getString(1);
331 if(AuthN.TENANT_MANAGER_ACCT_ID.equals(acctCSID)) {
332 accountIsTenantManager = true;
335 } catch (SQLException ex) {
336 if(logger.isDebugEnabled()) {
337 logger.debug("userIsTenantManager query failed on SQL error: " + ex.getLocalizedMessage());
339 } catch (Exception e) {
340 if(logger.isDebugEnabled()) {
341 logger.debug("userIsTenantManager unknown error: " + e.getLocalizedMessage());
347 } catch (SQLException e) {
353 } catch (SQLException e) {
357 return accountIsTenantManager;
361 * Execute the tenantsQuery against the datasourceName to obtain the tenants for
362 * the authenticated user.
363 * @return set containing the roles
366 public Set<CSpaceTenant> getTenants(String username, boolean includeDisabledTenants) throws AccountException {
368 String tenantsQuery = getTenantQuery(includeDisabledTenants);
370 if (logger.isDebugEnabled()) {
371 logger.debug("getTenants using tenantsQuery: " + tenantsQuery + ", username: " + username);
374 Set<CSpaceTenant> tenants = new LinkedHashSet<CSpaceTenant>();
376 Connection conn = null;
377 PreparedStatement ps = null;
381 conn = getConnection();
383 ps = conn.prepareStatement(tenantsQuery);
385 ps.setString(1, username);
386 } catch (ArrayIndexOutOfBoundsException ignore) {
387 // The query may not have any parameters so just try it
389 rs = ps.executeQuery();
390 if (rs.next() == false) {
391 // Check for the tenantManager
392 if(userIsTenantManager(conn, username)) {
393 if (logger.isDebugEnabled()) {
394 logger.debug("GetTenants called with tenantManager - synthesizing the pseudo-tenant");
397 tenants.add(new CSpaceTenant(AuthN.TENANT_MANAGER_ACCT_ID, "PseudoTenant"));
399 if (logger.isDebugEnabled()) {
400 logger.debug("No tenants found");
402 // We are running with an unauthenticatedIdentity so return an
403 // empty Tenants set.
404 // FIXME should this be allowed?
411 String tenantId = rs.getString(1);
412 String tenantName = rs.getString(2);
414 tenants.add(new CSpaceTenant(tenantId, tenantName));
416 } catch (SQLException ex) {
417 AccountException ae = new AccountException("Query failed");
420 } catch (Exception e) {
421 AccountException ae = new AccountException("unknown exception");
428 } catch (SQLException e) {
434 } catch (SQLException e) {
440 } catch (Exception ex) {
449 * This method will attempt to get a connection. If a network error prevents it from getting a connection on the first try
450 * it will retry for the next 'getMaxRetrySeconds()' seconds. If it is unable to get the connection then it will timeout and
451 * throw an exception.
453 public Connection getConnection() throws Exception {
454 Connection result = null;
455 boolean failed = true;
456 Exception lastException = null;
457 int requestAttempts = 0;
459 long quittingTime = System.currentTimeMillis() + getMaxRetrySeconds() * 1000; // This is how long we attempt retries
461 if (requestAttempts > 0) {
462 Thread.sleep(getDelayBetweenAttemptsMillis()); // Wait a little time between reattempts.
466 // proceed to the original request by calling doFilter()
467 result = this.getConnection(getDataSourceName());
468 if (result != null) {
470 break; // the request was successfully executed, so we can break out of this retry loop
473 throw new ConnectException(); // The 'response' argument indicated a network related failure, so let's throw a generic connection exception
475 } catch (Exception e) {
477 if (exceptionChainContainsNetworkError(lastException) == false) {
478 // Break if the exception chain does not contain a
479 // network related exception because we don't want to retry if it's not a network related failure
482 requestAttempts++; // keep track of how many times we've tried the request
484 } while (System.currentTimeMillis() < quittingTime); // keep trying until we run out of time
487 // Add a warning to the logs if we encountered *any* failures on our re-attempts. Only add the warning
488 // if we were eventually successful.
490 if (requestAttempts > 0 && failed == false) {
491 logger.warn(String.format("Request to get a connection from data source '%s' failed with exception '%s' at attempt number '%d' before finally succeeding on the next attempt.",
493 lastException.getClass().getName(),
497 if (failed == true) {
498 // If we get here, it means all of our attempts to get a successful call to chain.doFilter() have failed.
506 * Don't call this method directly. Instead, use the getConnection() method that take no arguments.
508 private Connection getConnection(String dataSourceName) throws AccountException, SQLException {
509 InitialContext ctx = null;
510 Connection conn = null;
511 DataSource ds = null;
514 ctx = new InitialContext();
516 ds = (DataSource) ctx.lookup(dataSourceName);
517 } catch (Exception e) {}
520 Context envCtx = (Context) ctx.lookup("java:comp/env");
521 ds = (DataSource) envCtx.lookup(dataSourceName);
522 } catch (Exception e) {}
525 Context envCtx = (Context) ctx.lookup("java:comp");
526 ds = (DataSource) envCtx.lookup(dataSourceName);
527 } catch (Exception e) {}
530 Context envCtx = (Context) ctx.lookup("java:");
531 ds = (DataSource) envCtx.lookup(dataSourceName);
532 } catch (Exception e) {}
535 Context envCtx = (Context) ctx.lookup("java");
536 ds = (DataSource) envCtx.lookup(dataSourceName);
537 } catch (Exception e) {}
540 ds = (DataSource) ctx.lookup("java:/" + dataSourceName);
541 } catch (Exception e) {}
544 ds = AuthN.getDataSource();
548 throw new IllegalArgumentException("datasource not found: " + dataSourceName);
551 conn = ds.getConnection();
553 conn = AuthN.getDataSource().getConnection(); //FIXME:REM - This is the result of some type of JNDI mess. Should try to solve this problem and clean up this code.
558 } catch (NamingException ex) {
559 AccountException ae = new AccountException("Error looking up DataSource from: " + dataSourceName);
566 } catch (Exception e) {
567 e.printStackTrace(); // We should be using a logger here instead.
575 * @return the datasourceName
577 public String getDataSourceName() {
578 return datasourceName;
582 * @return the principalQuery
584 public String getPrincipalQuery() {
585 return principalsQuery;
589 * @param principalQuery the principalQuery to set
591 public void setPrincipalQuery(String principalQuery) {
592 this.principalsQuery = principalQuery;
596 * @return the roleQuery
598 public String getRoleQuery() {
603 * @param roleQuery the roleQuery to set
605 public void setRoleQuery(String roleQuery) {
606 this.rolesQuery = roleQuery;
610 * @return the tenantQuery
612 public String getTenantQuery(boolean includeDisabledTenants) {
613 return includeDisabledTenants?tenantsQueryWithDisabled:tenantsQueryNoDisabled;
617 * @param tenantQuery the tenantQuery to set
618 public void setTenantQuery(String tenantQuery) {
619 this.tenantsQueryNoDisabled = tenantQuery;
624 * This method crawls the exception chain looking for network related exceptions and
625 * returns 'true' if it finds one.
627 public static boolean exceptionChainContainsNetworkError(Throwable exceptionChain) {
628 boolean result = false;
629 Throwable cause = exceptionChain;
631 while (cause != null) {
632 if (isExceptionNetworkRelated(cause) == true) {
637 cause = cause.getCause();
644 * Return 'true' if the exception is in the "java.net" package.
646 private static boolean isExceptionNetworkRelated(Throwable cause) {
647 boolean result = false;
649 String className = cause.getClass().getCanonicalName();
650 if (className.contains("java.net") == true) {
658 public String getSalt(String username) throws AccountException {
660 Connection conn = null;
661 PreparedStatement ps = null;
664 conn = getConnection();
666 if (logger.isDebugEnabled()) {
667 logger.debug("Executing query: " + saltQuery + ", with username: " + username);
669 ps = conn.prepareStatement(saltQuery);
670 ps.setString(1, username);
671 rs = ps.executeQuery();
672 if (rs.next() == false) {
673 if (logger.isDebugEnabled()) {
674 logger.debug(saltQuery + " returned no matches from db");
676 throw new AccountNotFoundException("No matching username found");
679 salt = rs.getString(1);
680 } catch (SQLException ex) {
681 // Assuming PostgreSQL
682 if (PSQLState.UNDEFINED_COLUMN.getState().equals(ex.getSQLState())) {
683 String msg = "'USERS' table is missing 'salt' column for password encyrption. Assuming existing passwords are unsalted.";
686 AccountException ae = new AccountException("Authentication query failed: " + ex.getLocalizedMessage());
690 } catch (AccountNotFoundException ex) {
692 } catch (Exception ex) {
693 AccountException ae = new AccountException("Unknown Exception");
700 } catch (SQLException e) {
706 } catch (SQLException e) {
712 } catch (SQLException ex) {