package votorola.a.voter; // Copyright 2008-2009, 2012, Michael Allan. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Votorola Software"), to deal in the Votorola Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicence, and/or sell copies of the Votorola Software, and to permit persons to whom the Votorola Software is furnished to do so, subject to the following conditions: The preceding copyright notice and this permission notice shall be included in all copies or substantial portions of the Votorola Software. THE VOTOROLA SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE VOTOROLA SOFTWARE OR THE USE OR OTHER DEALINGS IN THE VOTOROLA SOFTWARE. import java.sql.*; import java.util.*; import org.openid4java.discovery.*; import votorola.a.*; import votorola.a.voter.*; import votorola.g.*; import votorola.g.lang.*; import votorola.g.logging.*; import votorola.g.sql.*; /** A user's service preferences and other settings backed by a row of the vote-server's * user-settings table. Unlike the voter input of voter services which may be shared * among multiple sites, the user settings are specific to a single vote-server. They * may also contain private data. */ public @ThreadRestricted("touch") final class UserSettings implements java.io.Serializable { private static final long serialVersionUID = 2L; /** Constructs a UserSettings, reading its initial state from the user table. * * @see #voterEmail() */ public UserSettings( final String voterEmail, final Table table ) throws SQLException { this( voterEmail ); table.get( voterEmail, UserSettings.this ); } /** Retrieves a user's settings from the table, doing the lookup by OpenID. * * @see #getOpenID() * * @return user's settings, or null if the table has none. */ public static UserSettings forOpenID( String openID, final Table table ) throws SQLException { return table.getByOpenID( openID ); } /** Constructs a UserSettings with default initial data. * * @see #voterEmail() */ private UserSettings( String voterEmail ) { if( voterEmail == null ) throw new NullPointerException(); // fail fast this.voterEmail = voterEmail; } // ------------------------------------------------------------------------------------ /** The secure hash of the user's persisted login. * * @return key, or null if the user's login is not persisted. * * @see #setLoginPersistKey(String) * @see votorola.a.web.wic.authen.OpenIDAuthenticator#newPersistKey(String,String) */ public String getLoginPersistKey() { return loginPersistKey; } private String loginPersistKey; /** Sets the secure hash of the user's persisted login. * * @see #getLoginPersistKey() */ public void setLoginPersistKey( final String newLoginPersistKey ) { if( ObjectX.nullEquals( loginPersistKey, newLoginPersistKey )) return; loginPersistKey = newLoginPersistKey; isChanged = true; } /** An authenticated OpenID identifier for the user. * * @return OpenID in canonical form, or null if none is set. * * @see #clearOpenID() * @see #setOpenID(Identifier) */ public String getOpenID() { return openID; } private String openID; /** Clears the OpenID by setting it to null. * * @see #getOpenID() */ public void clearOpenID() { if( openID == null ) return; openID = null; isChanged = true; } /** Changes the OpenID, setting it to an authenticated value. * * @param verifiedID the {@linkplain * org.openid4java.consumer.VerificationResult#getVerifiedId() verified * identifier} from the positive authentication result. * * @see #getOpenID() */ public void setOpenID( final Identifier verifiedID ) { final String newOpenID = verifiedID.getIdentifier(); if( newOpenID.equals( openID )) return; openID = newOpenID; isChanged = true; } /** Identifies the user for voting purposes. A user may have other identifiers for * other purposes (such as OpenID for web login), but this email address is the * primary identifier for voting purposes. * * @return canonical email address. * * @see votorola.g.mail.InternetAddressX#canonicalAddress(String) */ public String voterEmail() { return voterEmail; } private final String voterEmail; /** Writes these user settings to the table if they have unwritten changes, or deletes * them if they are at default. */ public void write( Table table, ServiceSession userSession ) throws SQLException, VoterInputTable.BadInputException { write( table, userSession, /*toForce*/false ); } /** Writes these user settings to the table, or deletes them if they are all at * default. * * @param toForce false to write only if changes were made; true to force the * write regardless. */ public void write( final Table table, final ServiceSession userSession, final boolean toForce ) throws SQLException, VoterInputTable.BadInputException { if( !( toForce || isChanged )) return; isChanged = false; // early, in case clobbering another thread's true - isChanged must be volatile for this try { assert serialVersionUID == 1L : "user settings fields have not changed"; // else this logic may need updating if( loginPersistKey == null && openID == null ) { LoggerX.i(getClass()).finest( "removing storage, all data is at default" ); table.delete( voterEmail ); } else table.put( UserSettings.this, userSession ); } catch( RuntimeException x ) { isChanged = true; throw x; } // rollback catch( SQLException x ) { isChanged = true; throw x; } } // ==================================================================================== /** The user-settings table of a vote-server, storing the settings in relational form. */ public static @ThreadSafe final class Table { // Apparently PostgreSQL has a built in user table. To avoid conflict with it, // place quotes around the table name thus: "user". /** Constructs a Table, physically creating it if it does not already exist. * * @see #database() */ public Table( Database _database ) throws SQLException { database = _database; // If executeUpdate(CREATE TABLE IF NOT EXISTS) returns a clear indication of // prior existence (not sure it does), then we could remove the separate // exists() test and clean up the following code. if( exists() ) { final HashMap columnMap = new HashMap( /*initial capacity*/8 ); final String sKey = statementKeyBase + "init1"; synchronized( database ) { PreparedStatement s = database.statementCache().get( sKey ); if( s == null ) { s = database.connection().prepareStatement( "SELECT column_name FROM information_schema.columns" + " WHERE table_name = '" + tableName + "'" ); database.statementCache().put( sKey, s ); } final ResultSet r = s.executeQuery(); try { while( r.next() ) { final String name = r.getString(1); columnMap.put( name, name ); } } finally{ r.close(); } if( columnMap.get("loginPersistKey".toLowerCase()) == null ) // column added 2009-10 { final String command = "ALTER TABLE \"" + tableName + "\"" + " ADD COLUMN loginPersistKey character varying"; LoggerX.i(getClass()).config( command ); database.connection().createStatement().execute( command ); } } } else { final String sKey = statementKeyBase + "init2"; synchronized( database ) { PreparedStatement s = database.statementCache().get( sKey ); if( s == null ) { s = database.connection().prepareStatement( "CREATE TABLE \"" + tableName + "\"" + " (voterEmail character varying PRIMARY KEY," + " openID character varying UNIQUE," + " loginPersistKey character varying)" ); database.statementCache().put( sKey, s ); } s.execute(); } } } // -------------------------------------------------------------------------------- /** The database in which this table is stored. */ public @Warning("thread restricted object") Database database() { return database; } private final Database database; //// P r i v a t e /////////////////////////////////////////////////////////////////// /** Removes a user's data from the table if any is stored there. */ public void delete( final String voterEmail ) throws SQLException { final String sKey = statementKeyBase + "delete"; synchronized( database ) { PreparedStatement s = database.statementCache().get( sKey ); if( s == null ) { s = database.connection().prepareStatement( "DELETE FROM \"" + tableName + "\" WHERE voterEmail = ?" ); database.statementCache().put( sKey, s ); } s.setString( 1, voterEmail ); s.executeUpdate(); } } /** Returns true if this table exists, false otherwise. */ private boolean exists() throws SQLException { final String sKey = statementKeyBase + "exists"; synchronized( database ) { PreparedStatement s = database.statementCache().get( sKey ); if( s == null ) { s = database.connection().prepareStatement( "SELECT * FROM \"" + tableName + "\"" ); s.setMaxRows( 1 ); database.statementCache().put( sKey, s ); } try { s.execute(); } catch( SQLException x ) { if( "42P01".equals( x.getSQLState() )) return false; // 42P01 = UNDEFINED TABLE throw x; } } return true; } /** Retrieves a user's settings from the table, initializing the fields * of a UserSettings instance. * * @param settings the instance to initialize. All fields are assumed to be * at default values. * * @return true if data retrieved, false if the table has none. */ private boolean get( final String voterEmail, UserSettings settings ) throws SQLException { final String sKey = statementKeyBase + "get"; synchronized( database ) { PreparedStatement s = database.statementCache().get( sKey ); if( s == null ) { s = database.connection().prepareStatement( "SELECT openID, loginPersistKey FROM \"" + tableName + "\"" + " WHERE voterEmail = ?" ); database.statementCache().put( sKey, s ); } s.setString( 1, voterEmail ); final ResultSet r = s.executeQuery(); try { if( !r.next() ) return false; settings.openID = r.getString( 1 ); settings.loginPersistKey = r.getString( 2 ); return true; } finally{ r.close(); } } } private UserSettings getByOpenID( final String openID ) throws SQLException { final String sKey = statementKeyBase + "getByOpenID"; synchronized( database ) { PreparedStatement s = database.statementCache().get( sKey ); if( s == null ) { s = database.connection().prepareStatement( "SELECT voterEmail, loginPersistKey FROM \"" + tableName + "\"" + " WHERE openID = ?" ); database.statementCache().put( sKey, s ); } s.setString( 1, openID ); final ResultSet r = s.executeQuery(); try { if( !r.next() ) return null; final UserSettings settings = new UserSettings( r.getString( 1 )); settings.loginPersistKey = r.getString( 2 ); settings.openID = openID; return settings; } finally{ r.close(); } } } /** Stores a user's settings to this table. * * @throws VotorolaSecurityException if settings.voterEmail is unequal to * userSession.userEmail() (failsafe bug trap). */ private void put( final UserSettings settings, final ServiceSession userSession ) throws SQLException, VoterInputTable.BadInputException { testAccessAllowed( settings.voterEmail, userSession ); VoterInputTable.lengthConstrained( settings.openID ); VoterInputTable.lengthConstrained( settings.voterEmail ); LoggerX.i(getClass()).finer( "storing settings for user " + settings.voterEmail ); synchronized( database ) { // effect an "upsert" in PostgreSQL // http://stackoverflow.com/questions/1109061/insert-on-duplicate-update-postgresql/6527838#6527838 final Connection c = database.connection(); { final String sKey = statementKeyBase + "putU"; PreparedStatement s = database.statementCache().get( sKey ); if( s == null ) { s = c.prepareStatement( "UPDATE \"" + tableName + "\"" + " SET openID = ?, loginPersistKey = ? WHERE voterEmail = ?" ); database.statementCache().put( sKey, s ); } s.setString( 1, settings.openID ); s.setString( 2, settings.loginPersistKey ); s.setString( 3, settings.voterEmail ); final int updatedRows = s.executeUpdate(); if( updatedRows > 0 ) { assert updatedRows == 1; return; } } { final String sKey = statementKeyBase + "putI"; PreparedStatement s = database.statementCache().get( sKey ); if( s == null ) { s = c.prepareStatement( "INSERT INTO \"" + tableName + "\"" + " (voterEmail, openID, loginPersistKey) SELECT ?, ?, ? WHERE NOT" + " EXISTS (SELECT 1 FROM \"" + tableName + "\" WHERE voterEmail = ?)" ); database.statementCache().put( sKey, s ); } s.setString( 1, settings.voterEmail ); s.setString( 2, settings.openID ); s.setString( 3, settings.loginPersistKey ); s.setString( 4, settings.voterEmail ); s.executeUpdate(); } } } private static final String tableName = "user"; private static final String statementKeyBase = Table.class.getName() + ":" + tableName + "."; /** Throws a VotorolaSecurityException if voterEmail is unequal to * userSession.userEmail(). */ private static void testAccessAllowed( final String voterEmail, final ServiceSession session ) throws VotorolaSecurityException { final String userEmail = session.user().email(); if( !voterEmail.equals( userEmail )) { throw new VotorolaSecurityException( "attempt by user " + userEmail + " to modify settings of " + voterEmail ); } } } //// P r i v a t e /////////////////////////////////////////////////////////////////////// private volatile boolean isChanged; // volatile per write() }