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 requireSSOQuery;
88 private String rolesQuery;
89 private String tenantsQueryNoDisabled;
90 private String tenantsQueryWithDisabled;
91 private boolean suspendResume;
93 private long maxRetrySeconds = MAX_RETRY_SECONDS;
94 private static final int MAX_RETRY_SECONDS = 5;
95 private static final String MAX_RETRY_SECONDS_STR = "maxRetrySeconds";
97 private long delayBetweenAttemptsMillis = DELAY_BETWEEN_ATTEMPTS_MILLISECONDS;
98 private static final String DELAY_BETWEEN_ATTEMPTS_MILLISECONDS_STR = "delayBetweenAttemptsMillis";
99 private static final long DELAY_BETWEEN_ATTEMPTS_MILLISECONDS = 200;
101 protected void setMaxRetrySeconds(Map<String, ?> options) {
102 Object optionsObj = options.get(MAX_RETRY_SECONDS_STR);
103 if (optionsObj != null) {
104 String paramValue = optionsObj.toString();
106 maxRetrySeconds = Long.parseLong(paramValue);
107 } catch (NumberFormatException e) {
108 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.",
109 MAX_RETRY_SECONDS_STR, paramValue, maxRetrySeconds));
114 protected long getMaxRetrySeconds() {
115 return this.maxRetrySeconds;
118 protected void setDelayBetweenAttemptsMillis(Map<String, ?> options) {
119 Object optionsObj = options.get(DELAY_BETWEEN_ATTEMPTS_MILLISECONDS_STR);
120 if (optionsObj != null) {
121 String paramValue = optionsObj.toString();
123 delayBetweenAttemptsMillis = Long.parseLong(paramValue);
124 } catch (NumberFormatException e) {
125 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.",
126 MAX_RETRY_SECONDS_STR, paramValue, delayBetweenAttemptsMillis));
131 protected long getDelayBetweenAttemptsMillis() {
132 return this.delayBetweenAttemptsMillis;
135 public CSpaceDbRealm() {
136 datasourceName = DEFAULT_DATASOURCE_NAME;
140 * CSpace Database Realm
141 * @param datasourceName datasource name
143 public CSpaceDbRealm(Map<String, ?> options) {
144 datasourceName = (String) options.get("dsJndiName");
145 if (datasourceName == null) {
146 datasourceName = DEFAULT_DATASOURCE_NAME;
148 Object tmp = options.get("principalsQuery");
150 principalsQuery = tmp.toString();
152 tmp = options.get("saltQuery");
154 saltQuery = tmp.toString();
156 tmp = options.get("requireSSOQuery");
158 requireSSOQuery = tmp.toString();
160 tmp = options.get("rolesQuery");
162 rolesQuery = tmp.toString();
164 tmp = options.get("tenantsQueryNoDisabled");
166 tenantsQueryNoDisabled = tmp.toString();
168 tmp = options.get("tenantsQueryWithDisabled");
170 tenantsQueryWithDisabled = tmp.toString();
172 tmp = options.get("suspendResume");
174 suspendResume = Boolean.valueOf(tmp.toString()).booleanValue();
177 this.setMaxRetrySeconds(options);
178 this.setDelayBetweenAttemptsMillis(options);
180 if (logger.isTraceEnabled()) {
181 logger.trace("DatabaseServerLoginModule, dsJndiName=" + datasourceName);
182 logger.trace("principalsQuery=" + principalsQuery);
183 logger.trace("rolesQuery=" + rolesQuery);
184 logger.trace("suspendResume=" + suspendResume);
189 public String getPassword(String username) throws AccountException {
191 String password = null;
192 Connection conn = null;
193 PreparedStatement ps = null;
196 conn = getConnection();
198 if (logger.isDebugEnabled()) {
199 logger.debug("Executing query: " + principalsQuery + ", with username: " + username);
201 ps = conn.prepareStatement(principalsQuery);
202 ps.setString(1, username);
203 rs = ps.executeQuery();
204 if (rs.next() == false) {
205 if (logger.isDebugEnabled()) {
206 logger.debug(principalsQuery + " returned no matches from db");
208 throw new AccountNotFoundException("No matching username found");
211 password = rs.getString(1);
212 } catch (SQLException ex) {
213 if (logger.isTraceEnabled() == true) {
214 logger.error("Could not open database to read AuthN tables.", ex);
216 AccountException ae = new AccountException("Authentication query failed: " + ex.getLocalizedMessage());
219 } catch (AccountNotFoundException ex) {
221 } catch (Exception ex) {
222 AccountException ae = new AccountException("Unknown Exception");
229 } catch (SQLException e) {
235 } catch (SQLException e) {
241 } catch (SQLException ex) {
249 public Set<String> getRoles(String username) throws AccountException {
250 if (logger.isDebugEnabled()) {
251 logger.debug("getRoleSets using rolesQuery: " + rolesQuery + ", username: " + username);
254 Set<String> roles = new LinkedHashSet<String>();
256 Connection conn = null;
257 PreparedStatement ps = null;
261 conn = getConnection();
262 // Get the user role names
263 if (logger.isDebugEnabled()) {
264 logger.debug("Executing query: " + rolesQuery + ", with username: " + username);
267 ps = conn.prepareStatement(rolesQuery);
269 ps.setString(1, username);
270 } catch (ArrayIndexOutOfBoundsException ignore) {
271 // The query may not have any parameters so just try it
273 rs = ps.executeQuery();
274 if (rs.next() == false) {
275 if (logger.isDebugEnabled()) {
276 logger.debug("No roles found");
283 String roleName = rs.getString(1);
287 } catch (SQLException ex) {
288 AccountException ae = new AccountException("Query failed");
291 } catch (Exception e) {
292 AccountException ae = new AccountException("unknown exception");
299 } catch (SQLException e) {
305 } catch (SQLException e) {
311 } catch (Exception ex) {
321 public Set<CSpaceTenant> getTenants(String username) throws AccountException {
322 return getTenants(username, false);
325 private boolean userIsTenantManager(Connection conn, String username) {
326 String acctQuery = "SELECT csid FROM accounts_common WHERE userid=?";
327 PreparedStatement ps = null;
329 boolean accountIsTenantManager = false;
331 ps = conn.prepareStatement(acctQuery);
332 ps.setString(1, username);
333 rs = ps.executeQuery();
335 String acctCSID = rs.getString(1);
336 if(AuthN.TENANT_MANAGER_ACCT_ID.equals(acctCSID)) {
337 accountIsTenantManager = true;
340 } catch (SQLException ex) {
341 if(logger.isDebugEnabled()) {
342 logger.debug("userIsTenantManager query failed on SQL error: " + ex.getLocalizedMessage());
344 } catch (Exception e) {
345 if(logger.isDebugEnabled()) {
346 logger.debug("userIsTenantManager unknown error: " + e.getLocalizedMessage());
352 } catch (SQLException e) {
358 } catch (SQLException e) {
362 return accountIsTenantManager;
366 * Execute the tenantsQuery against the datasourceName to obtain the tenants for
367 * the authenticated user.
368 * @return set containing the roles
371 public Set<CSpaceTenant> getTenants(String username, boolean includeDisabledTenants) throws AccountException {
373 String tenantsQuery = getTenantQuery(includeDisabledTenants);
375 if (logger.isDebugEnabled()) {
376 logger.debug("getTenants using tenantsQuery: " + tenantsQuery + ", username: " + username);
379 Set<CSpaceTenant> tenants = new LinkedHashSet<CSpaceTenant>();
381 Connection conn = null;
382 PreparedStatement ps = null;
386 conn = getConnection();
388 ps = conn.prepareStatement(tenantsQuery);
390 ps.setString(1, username);
391 } catch (ArrayIndexOutOfBoundsException ignore) {
392 // The query may not have any parameters so just try it
394 rs = ps.executeQuery();
395 if (rs.next() == false) {
396 // Check for the tenantManager
397 if(userIsTenantManager(conn, username)) {
398 if (logger.isDebugEnabled()) {
399 logger.debug("GetTenants called with tenantManager - synthesizing the pseudo-tenant");
402 tenants.add(new CSpaceTenant(AuthN.TENANT_MANAGER_ACCT_ID, "PseudoTenant"));
404 if (logger.isDebugEnabled()) {
405 logger.debug("No tenants found");
407 // We are running with an unauthenticatedIdentity so return an
408 // empty Tenants set.
409 // FIXME should this be allowed?
416 String tenantId = rs.getString(1);
417 String tenantName = rs.getString(2);
419 tenants.add(new CSpaceTenant(tenantId, tenantName));
421 } catch (SQLException ex) {
422 AccountException ae = new AccountException("Query failed");
425 } catch (Exception e) {
426 AccountException ae = new AccountException("unknown exception");
433 } catch (SQLException e) {
439 } catch (SQLException e) {
445 } catch (Exception ex) {
454 * This method will attempt to get a connection. If a network error prevents it from getting a connection on the first try
455 * it will retry for the next 'getMaxRetrySeconds()' seconds. If it is unable to get the connection then it will timeout and
456 * throw an exception.
458 public Connection getConnection() throws Exception {
459 Connection result = null;
460 boolean failed = true;
461 Exception lastException = null;
462 int requestAttempts = 0;
464 long quittingTime = System.currentTimeMillis() + getMaxRetrySeconds() * 1000; // This is how long we attempt retries
466 if (requestAttempts > 0) {
467 Thread.sleep(getDelayBetweenAttemptsMillis()); // Wait a little time between reattempts.
471 // proceed to the original request by calling doFilter()
472 result = this.getConnection(getDataSourceName());
473 if (result != null) {
475 break; // the request was successfully executed, so we can break out of this retry loop
478 throw new ConnectException(); // The 'response' argument indicated a network related failure, so let's throw a generic connection exception
480 } catch (Exception e) {
482 if (exceptionChainContainsNetworkError(lastException) == false) {
483 // Break if the exception chain does not contain a
484 // network related exception because we don't want to retry if it's not a network related failure
487 requestAttempts++; // keep track of how many times we've tried the request
489 } while (System.currentTimeMillis() < quittingTime); // keep trying until we run out of time
492 // Add a warning to the logs if we encountered *any* failures on our re-attempts. Only add the warning
493 // if we were eventually successful.
495 if (requestAttempts > 0 && failed == false) {
496 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.",
498 lastException.getClass().getName(),
502 if (failed == true) {
503 // If we get here, it means all of our attempts to get a successful call to chain.doFilter() have failed.
511 * Don't call this method directly. Instead, use the getConnection() method that take no arguments.
513 private Connection getConnection(String dataSourceName) throws AccountException, SQLException {
514 InitialContext ctx = null;
515 Connection conn = null;
516 DataSource ds = null;
519 ctx = new InitialContext();
521 ds = (DataSource) ctx.lookup(dataSourceName);
522 } catch (Exception e) {}
525 Context envCtx = (Context) ctx.lookup("java:comp/env");
526 ds = (DataSource) envCtx.lookup(dataSourceName);
527 } catch (Exception e) {}
530 Context envCtx = (Context) ctx.lookup("java:comp");
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 Context envCtx = (Context) ctx.lookup("java");
541 ds = (DataSource) envCtx.lookup(dataSourceName);
542 } catch (Exception e) {}
545 ds = (DataSource) ctx.lookup("java:/" + dataSourceName);
546 } catch (Exception e) {}
549 ds = AuthN.getDataSource();
553 throw new IllegalArgumentException("datasource not found: " + dataSourceName);
556 conn = ds.getConnection();
558 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.
563 } catch (NamingException ex) {
564 AccountException ae = new AccountException("Error looking up DataSource from: " + dataSourceName);
571 } catch (Exception e) {
572 e.printStackTrace(); // We should be using a logger here instead.
580 * @return the datasourceName
582 public String getDataSourceName() {
583 return datasourceName;
587 * @return the principalQuery
589 public String getPrincipalQuery() {
590 return principalsQuery;
594 * @param principalQuery the principalQuery to set
596 public void setPrincipalQuery(String principalQuery) {
597 this.principalsQuery = principalQuery;
601 * @return the roleQuery
603 public String getRoleQuery() {
608 * @param roleQuery the roleQuery to set
610 public void setRoleQuery(String roleQuery) {
611 this.rolesQuery = roleQuery;
615 * @return the tenantQuery
617 public String getTenantQuery(boolean includeDisabledTenants) {
618 return includeDisabledTenants?tenantsQueryWithDisabled:tenantsQueryNoDisabled;
622 * @param tenantQuery the tenantQuery to set
623 public void setTenantQuery(String tenantQuery) {
624 this.tenantsQueryNoDisabled = tenantQuery;
629 * This method crawls the exception chain looking for network related exceptions and
630 * returns 'true' if it finds one.
632 public static boolean exceptionChainContainsNetworkError(Throwable exceptionChain) {
633 boolean result = false;
634 Throwable cause = exceptionChain;
636 while (cause != null) {
637 if (isExceptionNetworkRelated(cause) == true) {
642 cause = cause.getCause();
649 * Return 'true' if the exception is in the "java.net" package.
651 private static boolean isExceptionNetworkRelated(Throwable cause) {
652 boolean result = false;
654 String className = cause.getClass().getCanonicalName();
655 if (className.contains("java.net") == true) {
663 public String getSalt(String username) throws AccountException {
665 Connection conn = null;
666 PreparedStatement ps = null;
669 conn = getConnection();
671 if (logger.isDebugEnabled()) {
672 logger.debug("Executing query: " + saltQuery + ", with username: " + username);
674 ps = conn.prepareStatement(saltQuery);
675 ps.setString(1, username);
676 rs = ps.executeQuery();
677 if (rs.next() == false) {
678 if (logger.isDebugEnabled()) {
679 logger.debug(saltQuery + " returned no matches from db");
681 throw new AccountNotFoundException("No matching username found");
684 salt = rs.getString(1);
685 } catch (SQLException ex) {
686 // Assuming PostgreSQL
687 if (PSQLState.UNDEFINED_COLUMN.getState().equals(ex.getSQLState())) {
688 String msg = "'USERS' table is missing 'salt' column for password encyrption. Assuming existing passwords are unsalted.";
691 AccountException ae = new AccountException("Authentication query failed: " + ex.getLocalizedMessage());
695 } catch (AccountNotFoundException ex) {
697 } catch (Exception ex) {
698 AccountException ae = new AccountException("Unknown Exception");
705 } catch (SQLException e) {
711 } catch (SQLException e) {
717 } catch (SQLException ex) {
726 public boolean isRequireSSO(String username) throws AccountException {
727 Boolean requireSSO = null;
728 Connection conn = null;
729 PreparedStatement ps = null;
733 conn = getConnection();
735 if (logger.isDebugEnabled()) {
736 logger.debug("Executing query: " + requireSSOQuery + ", with username: " + username);
739 ps = conn.prepareStatement(requireSSOQuery);
741 ps.setString(1, username);
743 rs = ps.executeQuery();
745 if (rs.next() == false) {
746 if (logger.isDebugEnabled()) {
747 logger.debug(requireSSOQuery + " returned no matches from db");
750 throw new AccountNotFoundException("No matching username found");
753 requireSSO = rs.getBoolean(1);
754 } catch (SQLException ex) {
755 if (logger.isTraceEnabled() == true) {
756 logger.error("Could not open database to read AuthN tables.", ex);
759 AccountException ae = new AccountException("Authentication query failed: " + ex.getLocalizedMessage());
764 } catch (AccountNotFoundException ex) {
766 } catch (Exception ex) {
767 AccountException ae = new AccountException("Unknown Exception");
776 } catch (SQLException e) {
783 } catch (SQLException e) {
790 } catch (SQLException ex) {