001package 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.
002
003import java.sql.*;
004import java.util.*;
005import org.openid4java.discovery.*;
006import votorola.a.*;
007import votorola.a.voter.*;
008import votorola.g.*;
009import votorola.g.lang.*;
010import votorola.g.logging.*;
011import votorola.g.sql.*;
012
013
014/** A user's service preferences and other settings backed by a row of the vote-server's
015  * user-settings table.  Unlike the voter input of voter services which may be shared
016  * among multiple sites, the user settings are specific to a single vote-server.  They
017  * may also contain private data.
018  */
019public @ThreadRestricted("touch") final class UserSettings implements java.io.Serializable
020{
021
022    private static final long serialVersionUID = 2L;
023
024
025
026    /** Constructs a UserSettings, reading its initial state from the user table.
027      *
028      *     @see #voterEmail()
029      */
030    public UserSettings( final String voterEmail, final Table table ) throws SQLException
031    {
032        this( voterEmail );
033        table.get( voterEmail, UserSettings.this );
034    }
035
036
037
038    /** Retrieves a user's settings from the table, doing the lookup by OpenID.
039      *
040      *     @see #getOpenID()
041      *
042      *     @return user's settings, or null if the table has none.
043      */
044    public static UserSettings forOpenID( String openID, final Table table ) throws SQLException
045    {
046        return table.getByOpenID( openID );
047    }
048
049
050
051    /** Constructs a UserSettings with default initial data.
052      *
053      *     @see #voterEmail()
054      */
055    private UserSettings( String voterEmail )
056    {
057        if( voterEmail == null ) throw new NullPointerException(); // fail fast
058
059        this.voterEmail = voterEmail;
060    }
061
062
063
064   // ------------------------------------------------------------------------------------
065
066
067    /** The secure hash of the user's persisted login.
068      *
069      *     @return key, or null if the user's login is not persisted.
070      *
071      *     @see #setLoginPersistKey(String)
072      *     @see votorola.a.web.wic.authen.OpenIDAuthenticator#newPersistKey(String,String)
073      */
074    public String getLoginPersistKey() { return loginPersistKey; }
075
076
077        private String loginPersistKey;
078
079
080        /** Sets the secure hash of the user's persisted login.
081          *
082          *     @see #getLoginPersistKey()
083          */
084        public void setLoginPersistKey( final String newLoginPersistKey )
085        {
086            if( ObjectX.nullEquals( loginPersistKey, newLoginPersistKey )) return;
087
088            loginPersistKey = newLoginPersistKey;
089            isChanged = true;
090        }
091
092
093
094    /** An authenticated OpenID identifier for the user.
095      *
096      *     @return OpenID in canonical form, or null if none is set.
097      *
098      *     @see #clearOpenID()
099      *     @see #setOpenID(Identifier)
100      */
101    public String getOpenID() { return openID; }
102
103
104        private String openID;
105
106
107        /** Clears the OpenID by setting it to null.
108          *
109          *     @see #getOpenID()
110          */
111        public void clearOpenID()
112        {
113            if( openID == null ) return;
114
115            openID = null;
116            isChanged = true;
117        }
118
119
120        /** Changes the OpenID, setting it to an authenticated value.
121          *
122          *     @param verifiedID the {@linkplain
123          *       org.openid4java.consumer.VerificationResult#getVerifiedId() verified
124          *       identifier} from the positive authentication result.
125          *
126          *     @see #getOpenID()
127          */
128        public void setOpenID( final Identifier verifiedID )
129        {
130            final String newOpenID = verifiedID.getIdentifier();
131            if( newOpenID.equals( openID )) return;
132
133            openID = newOpenID;
134            isChanged = true;
135        }
136
137
138
139    /** Identifies the user for voting purposes.  A user may have other identifiers for
140      * other purposes (such as OpenID for web login), but this email address is the
141      * primary identifier for voting purposes.
142      *
143      *     @return canonical email address.
144      *
145      *     @see votorola.g.mail.InternetAddressX#canonicalAddress(String)
146      */
147    public String voterEmail() { return voterEmail; }
148
149
150        private final String voterEmail;
151
152
153
154    /** Writes these user settings to the table if they have unwritten changes, or deletes
155      * them if they are at default.
156      */
157    public void write( Table table, ServiceSession userSession )
158      throws SQLException, VoterInputTable.BadInputException
159    {
160        write( table, userSession, /*toForce*/false );
161    }
162
163
164
165    /** Writes these user settings to the table, or deletes them if they are all at
166      * default.
167      *
168      *     @param toForce false to write only if changes were made; true to force the
169      *       write regardless.
170      */
171    public void write( final Table table, final ServiceSession userSession,
172      final boolean toForce ) throws SQLException, VoterInputTable.BadInputException
173    {
174        if( !( toForce || isChanged )) return;
175
176        isChanged = false; // early, in case clobbering another thread's true - isChanged must be volatile for this
177        try
178        {
179            assert serialVersionUID == 1L : "user settings fields have not changed"; // else this logic may need updating
180            if( loginPersistKey == null && openID == null )
181            {
182                LoggerX.i(getClass()).finest( "removing storage, all data is at default" );
183                table.delete( voterEmail );
184            }
185            else table.put( UserSettings.this, userSession );
186        }
187        catch( RuntimeException x ) { isChanged = true; throw x; } // rollback
188        catch( SQLException x ) { isChanged = true; throw x; }
189    }
190
191
192
193   // ====================================================================================
194
195
196    /** The user-settings table of a vote-server, storing the settings in relational form.
197      */
198    public static @ThreadSafe final class Table
199    {
200
201        // Apparently PostgreSQL has a built in user table.  To avoid conflict with it,
202        // place quotes around the table name thus: "user".
203
204
205        /** Constructs a Table, physically creating it if it does not already exist.
206          *
207          *     @see #database()
208          */
209        public Table( Database _database ) throws SQLException
210        {
211            database = _database;
212            // If executeUpdate(CREATE TABLE IF NOT EXISTS) returns a clear indication of
213            // prior existence (not sure it does), then we could remove the separate
214            // exists() test and clean up the following code.
215            if( exists() )
216            {
217                final HashMap<String,String> columnMap =
218                  new HashMap<String,String>( /*initial capacity*/8 );
219                final String sKey = statementKeyBase + "init1";
220                synchronized( database )
221                {
222                    PreparedStatement s = database.statementCache().get( sKey );
223                    if( s == null )
224                    {
225                        s = database.connection().prepareStatement(
226                          "SELECT column_name FROM information_schema.columns"
227                          + " WHERE table_name = '" + tableName + "'" );
228                        database.statementCache().put( sKey, s );
229                    }
230                    final ResultSet r = s.executeQuery();
231                    try
232                    {
233                        while( r.next() )
234                        {
235                            final String name = r.getString(1);
236                            columnMap.put( name, name );
237                        }
238                    }
239                    finally{ r.close(); }
240
241                    if( columnMap.get("loginPersistKey".toLowerCase()) == null ) // column added 2009-10
242                    {
243                        final String command = "ALTER TABLE \"" + tableName + "\""
244                          + " ADD COLUMN loginPersistKey character varying";
245                        LoggerX.i(getClass()).config( command );
246                        database.connection().createStatement().execute( command );
247                    }
248                }
249            }
250            else
251            {
252                final String sKey = statementKeyBase + "init2";
253                synchronized( database )
254                {
255                    PreparedStatement s = database.statementCache().get( sKey );
256                    if( s == null )
257                    {
258                        s = database.connection().prepareStatement(
259                         "CREATE TABLE \"" + tableName + "\""
260                          + " (voterEmail character varying PRIMARY KEY,"
261                          +  " openID character varying UNIQUE,"
262                          +  " loginPersistKey character varying)" );
263                        database.statementCache().put( sKey, s );
264                    }
265                    s.execute();
266                }
267            }
268        }
269
270
271
272       // --------------------------------------------------------------------------------
273
274
275        /** The database in which this table is stored.
276          */
277        public @Warning("thread restricted object") Database database() { return database; }
278
279
280            private final Database database;
281
282
283
284    //// P r i v a t e ///////////////////////////////////////////////////////////////////
285
286
287        /** Removes a user's data from the table if any is stored there.
288          */
289        public void delete( final String voterEmail ) throws SQLException
290        {
291            final String sKey = statementKeyBase + "delete";
292            synchronized( database )
293            {
294                PreparedStatement s = database.statementCache().get( sKey );
295                if( s == null )
296                {
297                    s = database.connection().prepareStatement(
298                      "DELETE FROM \"" + tableName + "\" WHERE voterEmail = ?" );
299                    database.statementCache().put( sKey, s );
300                }
301                s.setString( 1, voterEmail );
302                s.executeUpdate();
303            }
304        }
305
306
307
308        /** Returns true if this table exists, false otherwise.
309          */
310        private boolean exists() throws SQLException
311        {
312            final String sKey = statementKeyBase + "exists";
313            synchronized( database )
314            {
315                PreparedStatement s = database.statementCache().get( sKey );
316                if( s == null )
317                {
318                    s = database.connection().prepareStatement(
319                     "SELECT * FROM \"" + tableName + "\"" );
320                    s.setMaxRows( 1 );
321                    database.statementCache().put( sKey, s );
322                }
323                try
324                {
325                    s.execute();
326                }
327                catch( SQLException x )
328                {
329                    if( "42P01".equals( x.getSQLState() )) return false; // 42P01 = UNDEFINED TABLE
330
331                    throw x;
332                }
333            }
334            return true;
335        }
336
337
338
339        /** Retrieves a user's settings from the table, initializing the fields
340          * of a UserSettings instance.
341          *
342          *     @param settings the instance to initialize.  All fields are assumed to be
343          *       at default values.
344          *
345          *     @return true if data retrieved, false if the table has none.
346          */
347        private boolean get( final String voterEmail, UserSettings settings ) throws SQLException
348        {
349            final String sKey = statementKeyBase + "get";
350            synchronized( database )
351            {
352                PreparedStatement s = database.statementCache().get( sKey );
353                if( s == null )
354                {
355                    s = database.connection().prepareStatement(
356                     "SELECT openID, loginPersistKey FROM \"" + tableName + "\""
357                       + " WHERE voterEmail = ?" );
358                    database.statementCache().put( sKey, s );
359                }
360                s.setString( 1, voterEmail );
361                final ResultSet r = s.executeQuery();
362                try
363                {
364                    if( !r.next() ) return false;
365
366                    settings.openID = r.getString( 1 );
367                    settings.loginPersistKey = r.getString( 2 );
368                    return true;
369                }
370                finally{ r.close(); }
371            }
372        }
373
374
375
376        private UserSettings getByOpenID( final String openID ) throws SQLException
377        {
378            final String sKey = statementKeyBase + "getByOpenID";
379            synchronized( database )
380            {
381                PreparedStatement s = database.statementCache().get( sKey );
382                if( s == null )
383                {
384                    s = database.connection().prepareStatement(
385                     "SELECT voterEmail, loginPersistKey FROM \"" + tableName + "\""
386                       + " WHERE openID = ?" );
387                    database.statementCache().put( sKey, s );
388                }
389                s.setString( 1, openID );
390                final ResultSet r = s.executeQuery();
391                try
392                {
393                    if( !r.next() ) return null;
394
395                    final UserSettings settings = new UserSettings( r.getString( 1 ));
396                    settings.loginPersistKey = r.getString( 2 );
397                    settings.openID = openID;
398                    return settings;
399                }
400                finally{ r.close(); }
401            }
402        }
403
404
405
406        /** Stores a user's settings to this table.
407          *
408          *     @throws VotorolaSecurityException if settings.voterEmail is unequal to
409          *       userSession.userEmail() (failsafe bug trap).
410          */
411        private void put( final UserSettings settings, final ServiceSession userSession )
412          throws SQLException, VoterInputTable.BadInputException
413        {
414            testAccessAllowed( settings.voterEmail, userSession );
415            VoterInputTable.lengthConstrained( settings.openID );
416            VoterInputTable.lengthConstrained( settings.voterEmail );
417            LoggerX.i(getClass()).finer( "storing settings for user " + settings.voterEmail );
418            synchronized( database )
419            {
420                // effect an "upsert" in PostgreSQL
421                // http://stackoverflow.com/questions/1109061/insert-on-duplicate-update-postgresql/6527838#6527838
422                final Connection c = database.connection();
423                {
424                    final String sKey = statementKeyBase + "putU";
425                    PreparedStatement s = database.statementCache().get( sKey );
426                    if( s == null )
427                    {
428                        s = c.prepareStatement( "UPDATE \"" + tableName + "\""
429                          + " SET openID = ?, loginPersistKey = ? WHERE voterEmail = ?" );
430                        database.statementCache().put( sKey, s );
431                    }
432                    s.setString( 1, settings.openID );
433                    s.setString( 2, settings.loginPersistKey );
434                    s.setString( 3, settings.voterEmail );
435                    final int updatedRows = s.executeUpdate();
436                    if( updatedRows > 0 ) { assert updatedRows == 1; return; }
437                }
438                {
439                    final String sKey = statementKeyBase + "putI";
440                    PreparedStatement s = database.statementCache().get( sKey );
441                    if( s == null )
442                    {
443                        s = c.prepareStatement( "INSERT INTO \"" + tableName + "\""
444                          + " (voterEmail, openID, loginPersistKey) SELECT ?, ?, ? WHERE NOT"
445                          + " EXISTS (SELECT 1 FROM \"" + tableName + "\" WHERE voterEmail = ?)" );
446                        database.statementCache().put( sKey, s );
447                    }
448                    s.setString( 1, settings.voterEmail );
449                    s.setString( 2, settings.openID );
450                    s.setString( 3, settings.loginPersistKey );
451                    s.setString( 4, settings.voterEmail );
452                    s.executeUpdate();
453                }
454            }
455        }
456
457
458
459        private static final String tableName = "user";
460
461
462            private static final String statementKeyBase = Table.class.getName() + ":" + tableName
463              + ".";
464
465
466
467        /** Throws a VotorolaSecurityException if voterEmail is unequal to
468          * userSession.userEmail().
469          */
470        private static void testAccessAllowed( final String voterEmail,
471          final ServiceSession session ) throws VotorolaSecurityException
472        {
473            final String userEmail = session.user().email();
474            if( !voterEmail.equals( userEmail ))
475            {
476                throw new VotorolaSecurityException( "attempt by user " + userEmail
477                  + " to modify settings of " + voterEmail );
478            }
479        }
480    }
481
482
483
484//// P r i v a t e ///////////////////////////////////////////////////////////////////////
485
486
487    private volatile boolean isChanged; // volatile per write()
488
489
490
491}