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}