001package votorola.a.web.wic.authen; // Copyright 2008-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.util.*; 004import javax.mail.internet.*; 005import javax.servlet.http.*; 006import org.apache.wicket.AttributeModifier; 007import org.apache.wicket.markup.html.IHeaderResponse; 008import org.apache.wicket.markup.html.basic.*; 009import org.apache.wicket.markup.html.form.*; 010import org.apache.wicket.model.*; 011import org.apache.wicket.request.Request; 012import org.apache.wicket.request.cycle.*; 013import org.apache.wicket.request.http.WebResponse; 014import org.apache.wicket.request.http.handler.RedirectRequestHandler; 015import org.apache.wicket.request.mapper.parameter.PageParameters; 016import org.apache.wicket.validation.*; 017import org.openid4java.consumer.*; 018import org.openid4java.discovery.*; 019import org.openid4java.message.AuthRequest; 020import votorola.a.*; 021import votorola.a.voter.*; 022import votorola.a.web.wic.*; 023import votorola.g.*; 024import votorola.g.lang.*; 025import votorola.g.locale.*; 026import votorola.g.mail.*; 027import votorola.g.web.CookieX; 028import votorola.g.web.wic.*; 029 030import static votorola.a.web.wic.authen.OpenIDAuthenticator.COOKIE_PERSIST_KEY; 031import static votorola.a.web.wic.authen.OpenIDAuthenticator.COOKIE_PERSIST_USER_EMAIL; 032 033 034/** An OpenID login page. It provides the option of logging in by OpenID or email 035 * authentication. In the latter case, this page performs the same function as 036 * {@linkplain WP_EmailAuthen1 WP_EmailAuthen1}. 037 * 038 * <p>The constructer accepts a {@linkplain #requestCycleRunner() request cycle runner} 039 * as an argument, to handle any post-login processing. The runner will be called 040 * exactly once if authentication terminates, either successfully or by cancellation. 041 * Typically, the runner is coded to set a response page for the user.</p> 042 * 043 * @see <a href='../../../../../../../a/web/wic/authen/WP_OpenIDLogin.html' 044 * target='_top'>WP_OpenIDLogin.html</a> 045 */ 046public @ThreadRestricted("wicket") final class WP_OpenIDLogin extends LoginPage 047{ 048 049 // This would be easier to code (and use) if each input field were in a separate form. 050 // Then a press of the enter key would activate the field's associated submit button, 051 // instead of the top button regardless. Validation would be simpler, too. The only 052 // benefit of a single form, in fact, is that it simplifies the alignment of component 053 // views by allowing the use of a single alignment table. 054 // 055 // There was minor degradation after moving to Wicket 1.5. This page now appears in 056 // navigation history, which it never used to. 057 058 059 /** Constructs a WP_OpenIDLogin that redirects to a newly constructed, bookmarkable, 060 * return page if authentication succeeds. 061 * 062 * @see #respondWithReturnPage(org.apache.wicket.request.cycle.RequestCycle) 063 */ 064 public WP_OpenIDLogin( final PageParameters pP ) { this( pP, null, null, null ); } 065 // must be public, per LoginPage 066 067 068 069 /** Constructs a WP_OpenIDLogin that continues from a previous, failed authentication 070 * attempt. Called by {@linkplain WP_OpenIDReturn WP_OpenIDReturn}. 071 * 072 * @param previousLoginPage the login page of the previous, failed 073 * authentication attempt. 074 * @param previousFailureString a message to present to the user relating the 075 * failure of the previous authentication attempt. 076 */ 077 WP_OpenIDLogin( final WP_OpenIDLogin previousLoginPage, final String previousFailureString ) 078 { 079 this( previousLoginPage.getPageParameters(), previousLoginPage, previousFailureString, 080 previousLoginPage.requestCycleRunner ); 081 } 082 083 084 085 private WP_OpenIDLogin( final PageParameters pP, final WP_OpenIDLogin previousLoginPage, 086 final String previousFailureString, RequestCycleRunner runner ) 087 { 088 super( pP ); 089 if( runner == null ) runner = new RequestCycleRunner() // after super(), or won't compile 090 { 091 public void run( final RequestCycle cycle ) 092 { 093 respondWithReturnPage( cycle ); 094 final VSession.User user = VSession.get().user(); 095 if( user == null ) return; // no successful authentication 096 097 final String userEmail = user.email(); 098 final VRequestCycle cycleV = (VRequestCycle)cycle; 099 if( persistent ) 100 { 101 final Request req = cycle.getRequest(); 102 final HttpServletRequest reqHS = (HttpServletRequest) 103 cycleV.vRequest().getContainerRequest(); 104 final WebResponse resW = cycleV.vResponse(); 105 WebResponseX.addCookie( resW, OpenIDAuthenticator.newPersistCookie( 106 COOKIE_PERSIST_USER_EMAIL, userEmail, /*toEncode*/true, req )); 107 108 final VOWicket app = VOWicket.get(); 109 final OpenIDAuthenticator auth = (OpenIDAuthenticator)app.authenticator(); 110 final String key = auth.newPersistKey( userEmail, reqHS.getRemoteAddr() ); 111 WebResponseX.addCookie( resW, OpenIDAuthenticator.newPersistCookie( 112 COOKIE_PERSIST_KEY, key, /*toEncode*/false, req )); 113 114 final UserSettings.Table table = app.vsRun().userTable(); 115 try 116 { 117 final UserSettings settings = new UserSettings( userEmail, table ); 118 settings.setLoginPersistKey( key ); 119 settings.write( table, VSession.get() ); 120 } 121 catch( Exception x ) { throw VotorolaRuntimeException.castOrWrapped( x ); } 122 } 123 else clearPersistence( userEmail, cycleV ); 124 } 125 }; 126 requestCycleRunner = runner; 127 128 final VRequestCycle cycle = VRequestCycle.get(); 129 final BundleFormatter bun = cycle.bunW(); 130 add( new Label( "title", bun.l( "a.web.wic.authen.WP_Login" ) )); 131 final Form<Void> y = new LoginForm(); 132 add( y ); 133 134 // OpenID. 135 // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 136 y.add( new Label( "openid_identifierLabel", 137 bun.l( "a.web.wic.authen.WP_Login.openid_identifier" ))); 138 final TextField<String> openidField = 139 invalidStyled( inputLengthConstrained( new TextField<String>( "openid_identifier", 140 new PropertyModel<String>( WP_OpenIDLogin.this, "openid_identifier" )))); 141 openidField.add( new IValidator<String>() 142 { 143 public void validate( final IValidatable<String> v ) 144 { 145 assert v.getValue() != null : "no null if not INullAcceptingValidator"; 146 openIDDiscoveryInformation = null; openIDAuthRequest = null; // till proven otherwise 147 148 final VRequestCycle cycle = VRequestCycle.get(); 149 final VOWicket app = VOWicket.get(); 150 final OpenIDAuthenticator auth = (OpenIDAuthenticator)app.authenticator(); 151 try 152 { 153 synchronized( auth ) 154 { 155 final ConsumerManager m = auth.consumerManager(); 156 final List<?> discoveryList = m.discover( v.getValue() ); 157 if( discoveryList.isEmpty() ) 158 { 159 v.error( new ValidationError().setMessage( cycle.bunW().l( 160 "a.web.wic.authen.WP_Login.openid_identifier.xDiscovery.noEndPoint" ))); 161 return; 162 } 163 164 openIDDiscoveryInformation = m.associate( discoveryList ); 165 if( openIDDiscoveryInformation == null ) 166 { 167 throw new IllegalStateException( 168 "silent association failure, on non-empty discovery list" ); 169 } 170 171 final String returnToURLString = 172 cycle.uriFor(WP_OpenIDReturn.class).toString(); 173 openIDAuthRequest = m.authenticate( // misnomer, does not authenticate 174 openIDDiscoveryInformation, returnToURLString ); 175 } 176 } 177 catch( org.openid4java.OpenIDException x ) 178 { 179 v.error( new ValidationError().setMessage( cycle.bunW().l( 180 "a.web.wic.authen.WP_Login.openid_identifier.xDiscovery", 181 ThrowableX.toStringExpanded( x )))); // getMessageExpanded() would not provide enough context when toString is 'java.net.UnknownHostException: HOST' 182 } 183 } 184 }); 185 y.add( openidField ); 186 if( previousLoginPage != null ) 187 { 188 openid_identifier = previousLoginPage.openid_identifier; 189 // VSession.get().getFeedbackMessages().warn( /*reporter*/openidField, previousFailureString ); 190 //// does not style field invalid, so (though it's not really validation time): 191 openidField.error( (IValidationError) 192 new ValidationError().setMessage( previousFailureString )); 193 } 194 195 final VoteServer vS = VOWicket.get().vsRun().voteServer(); 196 y.add( new Label( "openid_identifierDescription", 197 bun.l( "a.web.wic.authen.WP_Login.openid_identifierDescription_XHT", 198 vS.pollwiki().uri().toASCIIString() )).setEscapeModelStrings( false )); 199 200 // Submit 201 // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 202 { 203 persistent = cycle.vRequest().getCookie(COOKIE_PERSIST_BUTTON) != null; 204 final CheckBox button = new CheckBox( "persist", 205 new PropertyModel<Boolean>( WP_OpenIDLogin.this, "persistent" )); 206 y.add( button ); 207 208 final Label label = new Label( "persistLabel", 209 bun.l( "a.web.wic.authen.WP_Login.persist" )); 210 label.add( AttributeModifier.replace( "title", 211 bun.l( "a.web.wic.authen.WP_Login.persistFull" ))); 212 y.add( label ); 213 } 214 { 215 final Button button = new Button( "submit" ); 216 button.add( AttributeModifier.replace( "value", 217 bun.l( "a.web.wic.authen.WP_Login.submit" ))); 218 y.add( button ); 219 } 220 221 // Email 222 // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 223 y.add( new Label( "or", 224 bun.l( "a.web.wic.authen.WP_Login.or_XHT" )).setEscapeModelStrings( false )); 225 y.add( new Label( "or-loginByEmail", bun.l( "a.web.wic.authen.WP_Login.or-loginByEmail" ))); 226 227 y.add( new Label( "userEmailLabel", bun.l( "a.web.wic.authen.WP_EmailAuthen1.userEmail" ))); 228 { 229 final TextField<String> emailField = new TextField<String>( "userEmail", 230 new PropertyModel<String>( WP_OpenIDLogin.this, "userEmailInput" )); 231 invalidStyled( inputLengthConstrained( emailField )); 232 emailField.add( new WicEmailAddressValidator() 233 { 234 // extracting conversions from a validator. Improper. Consider using a 235 // converter instead. Cf. votorola.g.util.regex.WicPatternConverter 236 public @Override void validate( final IValidatable<String> v ) 237 { 238 claimedUserIAddress = null; // till proven otherwise 239 super.validate( v ); 240 } 241 public @Override void onSuccess( final InternetAddress iAddress ) 242 { 243 claimedUserIAddress = iAddress; 244 InternetAddressX.canonicalize( claimedUserIAddress ); 245 } 246 }); 247 y.add( emailField ); 248 249 y.add( new Label( "userEmailDescription", 250 bun.l( "a.web.wic.authen.WP_EmailAuthen1.userEmailDescription" ))); 251 } 252 253 // Alias 254 // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 255 if( vS.testUseMode() != VoteServer.TestUseMode.FULL ) y.add( newNullComponent( "test" )); 256 else y.add( new WC_Alias( "test", WP_OpenIDLogin.this, cycle )); 257 258 // Feedback 259 // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 260 y.add( new WC_Feedback( "feedback" )); 261 262 // Control footer 263 // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 264 { 265 final CheckBox button = new CheckBox( "reauth", 266 new PropertyModel<Boolean>( WP_OpenIDLogin.this, "reauthenticationRequested" )); 267 y.add( button ); 268 269 final Label label = new Label( "reauthLabel", 270 bun.l( "a.web.wic.authen.WP_Login.reauth" )); 271 y.add( label ); 272 273 final Label labelD = new Label( "reauthDescription", 274 bun.l( "a.web.wic.authen.WP_Login.reauthDescription" )); 275 y.add( labelD ); 276 } 277 } 278 279 280 281 // ------------------------------------------------------------------------------------ 282 283 284 /** Clears any cookie-persisted authentication. 285 */ 286 public static void clearPersistence( final String userEmail, final VRequestCycle cycle ) 287 { 288 final Request req = cycle.getRequest(); 289 final WebResponse resW = cycle.vResponse(); 290 WebResponseX.clearCookie( resW, OpenIDAuthenticator.newPersistCookie( 291 COOKIE_PERSIST_USER_EMAIL, null, /*toEncode*/true, req )); 292 WebResponseX.clearCookie( resW, OpenIDAuthenticator.newPersistCookie( 293 COOKIE_PERSIST_KEY, null, /*toEncode*/false, req )); 294 295 final UserSettings.Table table = VOWicket.get().vsRun().userTable(); 296 try 297 { 298 final UserSettings settings = new UserSettings( userEmail, table ); 299 settings.setLoginPersistKey( null ); 300 settings.write( table, VSession.get() ); 301 } 302 catch( Exception x ) { throw VotorolaRuntimeException.castOrWrapped( x ); } 303 } 304 305 306 307 /** Answers whether a successful authentication is to be persisted in client cookies. 308 * 309 * @see votorola.a.web.wic.VSession.User#isPersistent() 310 */ 311 boolean isPersistent() { return persistent; } 312 313 314 private boolean persistent; // PropertyModel accesses it by java.lang.reflect.Field.setAccessible() 315 316 317 318 /** Answers whether the user is requesting reauthentication of his/her email address. 319 * This is false when the page is yet unsubmitted. 320 */ 321 boolean isReauthenticationRequested() { return reauthenticationRequested; } 322 323 324 private boolean reauthenticationRequested; // PropertyModel accesses it by java.lang.reflect.Field.setAccessible() 325 326 327 328 /** The results of discovery on the user's OpenID, if any. 329 * 330 * @return the discovery information, or null if nothing yet discovered. 331 */ 332 DiscoveryInformation openIDDiscoveryInformation() { return openIDDiscoveryInformation; } 333 334 335 private DiscoveryInformation openIDDiscoveryInformation; // from field validator 336 337 338 339 /** Persists the state of the "keep me logged in" button in a cookie. 340 * 341 * @see #COOKIE_PERSIST_BUTTON 342 */ 343 static void persistPersistButton( final boolean state, final WebResponse resW ) 344 { 345 final Cookie cookie = new CookieX( COOKIE_PERSIST_BUTTON, "1" ); 346 final int age; 347 if( state ) age = CookieX.DURATION_YEAR_S; 348 else age = 0; // client might postpone this deletion, but not likely 349 cookie.setMaxAge( age ); 350 WebResponseX.addCookie( resW, cookie ); 351 } 352 353 354 355 /** The runner to do any post-login processing, such as redirecting back to the 356 * original referring page. If the authentication is successful, it is guaranteed to 357 * run; otherwise it may or may not. 358 * 359 * @return the runner, which is never null. 360 */ 361 RequestCycleRunner requestCycleRunner() { return requestCycleRunner; } 362 363 364 private final RequestCycleRunner requestCycleRunner; 365 366 367 368 /** Sets the newly authenticated user in the session without replacing it. The 369 * session is not replaced here because going back to this login page after OpenID 370 * authentication alters the navigation history by removing the forward pages (Wicket 371 * 1.5.4) such that the user cannot return to them. 372 * 373 * @param _method the name of the authentication method for logging purposes. 374 * @see #isPersistent() 375 */ 376 static void setUserInSession( final String email, String _method, boolean _persistent, 377 VRequestCycle _cycle ) 378 { 379 setUserInSession( IDPair.fromEmail(email), _method, _persistent, /*toReplaceSession*/false, 380 _cycle ); 381 } 382 383 384 385 // - I - H e a d e r - C o n t r i b u t o r ------------------------------------------ 386 387 388 public @Override void renderHead( IHeaderResponse r ) 389 { 390 super.renderHead( r ); 391 if( !getSession().getFeedbackMessages().isEmpty() ) 392 { 393 r.renderOnLoadJavaScript( "location.hash = 'feedback'" ); 394 } 395 } 396 397 398 399//// P r i v a t e /////////////////////////////////////////////////////////////////////// 400 401 402 private String aliasInput; // PropertyModel accesses it by java.lang.reflect.Field.setAccessible() 403 404 405 406 private transient InternetAddress claimedUserIAddress; // from field validator, in canonical form 407 408 409 410 private String openid_identifier; // PropertyModel accesses it by java.lang.reflect.Field.setAccessible() 411 412 413 414 private transient AuthRequest openIDAuthRequest; // from field validator 415 416 417 418 private String userEmailInput; // PropertyModel accesses it by java.lang.reflect.Field.setAccessible() 419 420 421 422 // ==================================================================================== 423 424 425 private final class LoginForm extends Form<Void> 426 { 427 428 private LoginForm() 429 { 430 super( "form" ); 431 add( new IDFieldValidator() 432 { 433 List<TextField<?>> idFields() 434 { 435 final ArrayList<TextField<?>> fields = new ArrayList<>( /*initial capacity*/4 ); 436 fields.add( (TextField<?>)get( "openid_identifier" )); 437 fields.add( (TextField<?>)get( "userEmail" )); 438 final TextField<?> field = (TextField<?>) 439 // get( "test.alias" ); 440 /// always gives null for some reason, so: 441 ((WC_Alias)get( "test" )).get( "alias" ); 442 if( field != null ) fields.add( field ); 443 return fields; 444 } 445 }); 446 } 447 448 449 protected @Override void onSubmit() 450 { 451 super.onSubmit(); 452 final VRequestCycle cycle = VRequestCycle.get(); 453 persistPersistButton( persistent, cycle.vResponse() ); 454 455 // Authenticate 456 // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 457 if( openid_identifier != null ) onSubmitOpenID( cycle ); 458 else if( userEmailInput != null ) onSubmitEmail( cycle ); // not claimedUserIAddress, which is not properly nulled by an empty field 459 else onSubmitAlias( cycle ); 460 } 461 462 463 private void onSubmitAlias( final VRequestCycle cycle ) 464 { 465 if( aliasInput == null ) throw new IllegalStateException(); 466 467 setUserInSession( IDPair.fromEmail(WC_Alias.toEmail(aliasInput)), "alias", persistent, 468 /*toReplaceSession*/false, cycle ); 469 requestCycleRunner.run( cycle ); 470 } 471 472 473 private void onSubmitEmail( final VRequestCycle cycle ) 474 { 475 if( claimedUserIAddress == null ) throw new IllegalStateException(); 476 477 final WP_EmailAuthen2 authenticationPage = 478 new WP_EmailAuthen2( claimedUserIAddress, persistent, cycle, requestCycleRunner ); 479 cycle.setResponsePage( authenticationPage.sendMessage(cycle)? authenticationPage: 480 new WP_Message() ); 481 } 482 483 484 private void onSubmitOpenID( final VRequestCycle cycle ) 485 { 486 if( openIDDiscoveryInformation == null || openIDAuthRequest == null ) 487 { 488 throw new IllegalStateException(); 489 } 490 491 VSession.get().scopeOpenIDReturn().setLoginPage( WP_OpenIDLogin.this ); 492 cycle.scheduleRequestHandlerAfterCurrent( new RedirectRequestHandler( 493 openIDAuthRequest.getDestinationUrl( /*for GET redirect*/true ))); 494 } 495 496 } 497 498 499}