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 private Logger logger = LoggerFactory.getLogger(CSpaceDbRealm.class);
82 private String datasourceName;
83 private String principalsQuery;
84 private String saltQuery;
85 private String rolesQuery;
86 private String tenantsQueryNoDisabled;
87 private String tenantsQueryWithDisabled;
88 private boolean suspendResume;
90 private long maxRetrySeconds = MAX_RETRY_SECONDS;
91 private static final int MAX_RETRY_SECONDS = 5;
92 private static final String MAX_RETRY_SECONDS_STR = "maxRetrySeconds";
94 private long delayBetweenAttemptsMillis = DELAY_BETWEEN_ATTEMPTS_MILLISECONDS;
95 private static final String DELAY_BETWEEN_ATTEMPTS_MILLISECONDS_STR = "delayBetweenAttemptsMillis";
96 private static final long DELAY_BETWEEN_ATTEMPTS_MILLISECONDS = 200;
98 protected void setMaxRetrySeconds(Map<String, ?> options) {
99 Object optionsObj = options.get(MAX_RETRY_SECONDS_STR);
100 if (optionsObj != null) {
101 String paramValue = optionsObj.toString();
103 maxRetrySeconds = Long.parseLong(paramValue);
104 } catch (NumberFormatException e) {
105 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.",
106 MAX_RETRY_SECONDS_STR, paramValue, maxRetrySeconds));
111 protected long getMaxRetrySeconds() {
112 return this.maxRetrySeconds;
115 protected void setDelayBetweenAttemptsMillis(Map<String, ?> options) {
116 Object optionsObj = options.get(DELAY_BETWEEN_ATTEMPTS_MILLISECONDS_STR);
117 if (optionsObj != null) {
118 String paramValue = optionsObj.toString();
120 delayBetweenAttemptsMillis = Long.parseLong(paramValue);
121 } catch (NumberFormatException e) {
122 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.",
123 MAX_RETRY_SECONDS_STR, paramValue, delayBetweenAttemptsMillis));
128 protected long getDelayBetweenAttemptsMillis() {
129 return this.delayBetweenAttemptsMillis;
133 * CSpace Database Realm
134 * @param datasourceName datasource name
136 public CSpaceDbRealm(Map<String, ?> options) {
137 datasourceName = (String) options.get("dsJndiName");
138 if (datasourceName == null) {
139 datasourceName = "java:/DefaultDS";
141 Object tmp = options.get("principalsQuery");
143 principalsQuery = tmp.toString();
145 tmp = options.get("saltQuery");
147 saltQuery = tmp.toString();
149 tmp = options.get("rolesQuery");
151 rolesQuery = tmp.toString();
153 tmp = options.get("tenantsQueryNoDisabled");
155 tenantsQueryNoDisabled = tmp.toString();
157 tmp = options.get("tenantsQueryWithDisabled");
159 tenantsQueryWithDisabled = tmp.toString();
161 tmp = options.get("suspendResume");
163 suspendResume = Boolean.valueOf(tmp.toString()).booleanValue();
166 this.setMaxRetrySeconds(options);
167 this.setDelayBetweenAttemptsMillis(options);
169 if (logger.isTraceEnabled()) {
170 logger.trace("DatabaseServerLoginModule, dsJndiName=" + datasourceName);
171 logger.trace("principalsQuery=" + principalsQuery);
172 logger.trace("rolesQuery=" + rolesQuery);
173 logger.trace("suspendResume=" + suspendResume);
179 public String getPassword(String username) throws AccountException {
181 String password = null;
182 Connection conn = null;
183 PreparedStatement ps = null;
186 conn = getConnection();
188 if (logger.isDebugEnabled()) {
189 logger.debug("Executing query: " + principalsQuery + ", with username: " + username);
191 ps = conn.prepareStatement(principalsQuery);
192 ps.setString(1, username);
193 rs = ps.executeQuery();
194 if (rs.next() == false) {
195 if (logger.isDebugEnabled()) {
196 logger.debug(principalsQuery + " returned no matches from db");
198 throw new AccountNotFoundException("No matching username found");
201 password = rs.getString(1);
202 } catch (SQLException ex) {
203 if (logger.isTraceEnabled() == true) {
204 logger.error("Could not open database to read AuthN tables.", ex);
206 AccountException ae = new AccountException("Authentication query failed: " + ex.getLocalizedMessage());
209 } catch (AccountNotFoundException ex) {
211 } catch (Exception ex) {
212 AccountException ae = new AccountException("Unknown Exception");
219 } catch (SQLException e) {
225 } catch (SQLException e) {
231 } catch (SQLException ex) {
239 public Set<String> getRoles(String username) throws AccountException {
240 if (logger.isDebugEnabled()) {
241 logger.debug("getRoleSets using rolesQuery: " + rolesQuery + ", username: " + username);
244 Set<String> roles = new LinkedHashSet<String>();
246 Connection conn = null;
247 PreparedStatement ps = null;
251 conn = getConnection();
252 // Get the user role names
253 if (logger.isDebugEnabled()) {
254 logger.debug("Executing query: " + rolesQuery + ", with username: " + username);
257 ps = conn.prepareStatement(rolesQuery);
259 ps.setString(1, username);
260 } catch (ArrayIndexOutOfBoundsException ignore) {
261 // The query may not have any parameters so just try it
263 rs = ps.executeQuery();
264 if (rs.next() == false) {
265 if (logger.isDebugEnabled()) {
266 logger.debug("No roles found");
273 String roleName = rs.getString(1);
277 } catch (SQLException ex) {
278 AccountException ae = new AccountException("Query failed");
281 } catch (Exception e) {
282 AccountException ae = new AccountException("unknown exception");
289 } catch (SQLException e) {
295 } catch (SQLException e) {
301 } catch (Exception ex) {
311 public Set<CSpaceTenant> getTenants(String username) throws AccountException {
312 return getTenants(username, false);
315 private boolean userIsTenantManager(Connection conn, String username) {
316 String acctQuery = "SELECT csid FROM accounts_common WHERE userid=?";
317 PreparedStatement ps = null;
319 boolean accountIsTenantManager = false;
321 ps = conn.prepareStatement(acctQuery);
322 ps.setString(1, username);
323 rs = ps.executeQuery();
325 String acctCSID = rs.getString(1);
326 if(AuthN.TENANT_MANAGER_ACCT_ID.equals(acctCSID)) {
327 accountIsTenantManager = true;
330 } catch (SQLException ex) {
331 if(logger.isDebugEnabled()) {
332 logger.debug("userIsTenantManager query failed on SQL error: " + ex.getLocalizedMessage());
334 } catch (Exception e) {
335 if(logger.isDebugEnabled()) {
336 logger.debug("userIsTenantManager unknown error: " + e.getLocalizedMessage());
342 } catch (SQLException e) {
348 } catch (SQLException e) {
352 return accountIsTenantManager;
356 * Execute the tenantsQuery against the datasourceName to obtain the tenants for
357 * the authenticated user.
358 * @return set containing the roles
361 public Set<CSpaceTenant> getTenants(String username, boolean includeDisabledTenants) throws AccountException {
363 String tenantsQuery = getTenantQuery(includeDisabledTenants);
365 if (logger.isDebugEnabled()) {
366 logger.debug("getTenants using tenantsQuery: " + tenantsQuery + ", username: " + username);
369 Set<CSpaceTenant> tenants = new LinkedHashSet<CSpaceTenant>();
371 Connection conn = null;
372 PreparedStatement ps = null;
376 conn = getConnection();
378 ps = conn.prepareStatement(tenantsQuery);
380 ps.setString(1, username);
381 } catch (ArrayIndexOutOfBoundsException ignore) {
382 // The query may not have any parameters so just try it
384 rs = ps.executeQuery();
385 if (rs.next() == false) {
386 // Check for the tenantManager
387 if(userIsTenantManager(conn, username)) {
388 if (logger.isDebugEnabled()) {
389 logger.debug("GetTenants called with tenantManager - synthesizing the pseudo-tenant");
392 tenants.add(new CSpaceTenant(AuthN.TENANT_MANAGER_ACCT_ID, "PseudoTenant"));
394 if (logger.isDebugEnabled()) {
395 logger.debug("No tenants found");
397 // We are running with an unauthenticatedIdentity so return an
398 // empty Tenants set.
399 // FIXME should this be allowed?
406 String tenantId = rs.getString(1);
407 String tenantName = rs.getString(2);
409 tenants.add(new CSpaceTenant(tenantId, tenantName));
411 } catch (SQLException ex) {
412 AccountException ae = new AccountException("Query failed");
415 } catch (Exception e) {
416 AccountException ae = new AccountException("unknown exception");
423 } catch (SQLException e) {
429 } catch (SQLException e) {
435 } catch (Exception ex) {
444 * This method will attempt to get a connection. If a network error prevents it from getting a connection on the first try
445 * it will retry for the next 'getMaxRetrySeconds()' seconds. If it is unable to get the connection then it will timeout and
446 * throw an exception.
448 private Connection getConnection() throws Exception {
449 Connection result = null;
450 boolean failed = true;
451 Exception lastException = null;
452 int requestAttempts = 0;
454 long quittingTime = System.currentTimeMillis() + getMaxRetrySeconds() * 1000; // This is how long we attempt retries
456 if (requestAttempts > 0) {
457 Thread.sleep(getDelayBetweenAttemptsMillis()); // Wait a little time between reattempts.
461 // proceed to the original request by calling doFilter()
462 result = this.getConnection(getDataSourceName());
463 if (result != null) {
465 break; // the request was successfully executed, so we can break out of this retry loop
468 throw new ConnectException(); // The 'response' argument indicated a network related failure, so let's throw a generic connection exception
470 } catch (Exception e) {
472 if (exceptionChainContainsNetworkError(lastException) == false) {
473 // Break if the exception chain does not contain a
474 // network related exception because we don't want to retry if it's not a network related failure
477 requestAttempts++; // keep track of how many times we've tried the request
479 } while (System.currentTimeMillis() < quittingTime); // keep trying until we run out of time
482 // Add a warning to the logs if we encountered *any* failures on our re-attempts. Only add the warning
483 // if we were eventually successful.
485 if (requestAttempts > 0 && failed == false) {
486 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.",
488 lastException.getClass().getName(),
492 if (failed == true) {
493 // If we get here, it means all of our attempts to get a successful call to chain.doFilter() have failed.
501 * Don't call this method directly. Instead, use the getConnection() method that take no arguments.
503 private Connection getConnection(String dataSourceName) throws AccountException, SQLException {
504 InitialContext ctx = null;
505 Connection conn = null;
506 DataSource ds = null;
509 ctx = new InitialContext();
511 ds = (DataSource) ctx.lookup(dataSourceName);
512 } catch (Exception e) {}
515 Context envCtx = (Context) ctx.lookup("java:comp/env");
516 ds = (DataSource) envCtx.lookup(dataSourceName);
517 } catch (Exception e) {}
520 Context envCtx = (Context) ctx.lookup("java:comp");
521 ds = (DataSource) envCtx.lookup(dataSourceName);
522 } catch (Exception e) {}
525 Context envCtx = (Context) ctx.lookup("java:");
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 ds = (DataSource) ctx.lookup("java:/" + dataSourceName);
536 } catch (Exception e) {}
539 ds = AuthN.getDataSource();
543 throw new IllegalArgumentException("datasource not found: " + dataSourceName);
546 conn = ds.getConnection();
548 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.
553 } catch (NamingException ex) {
554 AccountException ae = new AccountException("Error looking up DataSource from: " + dataSourceName);
561 } catch (Exception e) {
562 e.printStackTrace(); // We should be using a logger here instead.
570 * @return the datasourceName
572 public String getDataSourceName() {
573 return datasourceName;
577 * @return the principalQuery
579 public String getPrincipalQuery() {
580 return principalsQuery;
584 * @param principalQuery the principalQuery to set
586 public void setPrincipalQuery(String principalQuery) {
587 this.principalsQuery = principalQuery;
591 * @return the roleQuery
593 public String getRoleQuery() {
598 * @param roleQuery the roleQuery to set
600 public void setRoleQuery(String roleQuery) {
601 this.rolesQuery = roleQuery;
605 * @return the tenantQuery
607 public String getTenantQuery(boolean includeDisabledTenants) {
608 return includeDisabledTenants?tenantsQueryWithDisabled:tenantsQueryNoDisabled;
612 * @param tenantQuery the tenantQuery to set
613 public void setTenantQuery(String tenantQuery) {
614 this.tenantsQueryNoDisabled = tenantQuery;
619 * This method crawls the exception chain looking for network related exceptions and
620 * returns 'true' if it finds one.
622 public static boolean exceptionChainContainsNetworkError(Throwable exceptionChain) {
623 boolean result = false;
624 Throwable cause = exceptionChain;
626 while (cause != null) {
627 if (isExceptionNetworkRelated(cause) == true) {
632 cause = cause.getCause();
639 * Return 'true' if the exception is in the "java.net" package.
641 private static boolean isExceptionNetworkRelated(Throwable cause) {
642 boolean result = false;
644 String className = cause.getClass().getCanonicalName();
645 if (className.contains("java.net") == true) {
653 public String getSalt(String username) throws AccountException {
655 Connection conn = null;
656 PreparedStatement ps = null;
659 conn = getConnection();
661 if (logger.isDebugEnabled()) {
662 logger.debug("Executing query: " + saltQuery + ", with username: " + username);
664 ps = conn.prepareStatement(saltQuery);
665 ps.setString(1, username);
666 rs = ps.executeQuery();
667 if (rs.next() == false) {
668 if (logger.isDebugEnabled()) {
669 logger.debug(saltQuery + " returned no matches from db");
671 throw new AccountNotFoundException("No matching username found");
674 salt = rs.getString(1);
675 } catch (SQLException ex) {
676 // Assuming PostgreSQL
677 if (PSQLState.UNDEFINED_COLUMN.getState().equals(ex.getSQLState())) {
678 String msg = "'USERS' table is missing 'salt' column for password encyrption. Assuming existing passwords are unsalted.";
681 AccountException ae = new AccountException("Authentication query failed: " + ex.getLocalizedMessage());
685 } catch (AccountNotFoundException ex) {
687 } catch (Exception ex) {
688 AccountException ae = new AccountException("Unknown Exception");
695 } catch (SQLException e) {
701 } catch (SQLException e) {
707 } catch (SQLException ex) {