001package votorola.a.web.wic.authen; // Copyright 2012-2013, 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 com.sun.jersey.api.uri.*; 004import java.io.*; 005import java.net.*; 006import java.sql.SQLException; 007import java.util.*; 008import java.util.logging.*; import votorola.g.logging.*; 009import java.util.regex.*; 010import javax.mail.internet.AddressException; 011import javax.servlet.http.*; 012import javax.xml.stream.*; 013import org.apache.wicket.Session; 014import org.apache.wicket.ISessionListener; 015import org.apache.wicket.request.IRequestHandler; 016import org.apache.wicket.request.Request; 017import org.apache.wicket.request.cycle.*; 018import org.apache.wicket.request.handler.*; 019import org.apache.wicket.request.http.WebResponse; 020import org.apache.wicket.request.mapper.parameter.PageParameters; 021import votorola.a.*; 022import votorola.a.voter.*; 023import votorola.a.web.wic.*; 024import votorola.g.*; 025import votorola.g.hold.*; 026import votorola.g.lang.*; 027import votorola.g.mail.*; 028import votorola.g.net.*; 029import votorola.g.web.*; 030import votorola.g.web.CookieX; 031 032 033/** An authenticator based on the authentication facilities of the {@linkplain 034 * votorola.a.VoteServer#pollwiki() pollwiki}, enabling synchronized login between it and 035 * the Wicket interface. Example configuration for {@linkplain 036 * VOWicket#startupConfigurationFile vowicket.js}:<pre> 037 * 038 * function constructingVOWicket( wicCC ) 039 * { 040 * wicCC.{@linkplain votorola.a.web.wic.VOWicket.ConstructionContext#setAuthenticatorClass(Class) setAuthenticatorClass}( 041 * Packages.votorola.a.web.wic.authen.WikiAuthenticator ); 042 * } 043 * 044 * function initializingVOWicket( wic ) 045 * { 046 * wic.{@linkplain VOWicket#authenticator() authenticator}().setCookiePrefix( 'pollwiki' ); } 047 * }</pre> 048 * 049 * <p>Pollwiki configuration changes will usually be required in order for this to work 050 * correctly. The pollwiki's <em>cookie</em> domain must match the <em>request</em> 051 * domain of the Wicket interface. Also the pollwiki must not send the cookies HttpOnly, 052 * which is the default mode.</p> 053 * 054 * @see <a href='http://www.mediawiki.org/wiki/Manual:$wgCookieDomain' target='_top' 055 * >Manual:$wgCookieDomain</a> 056 * @see <a href='http://www.mediawiki.org/wiki/Manual:$wgCookieHttpOnly' target='_top' 057 * >Manual:$wgCookieHttpOnly</a> 058 * @see <a href='http://www.mediawiki.org/wiki/Manual:Configuration_settings#Cookies' 059 * target='_top' >Manual:Configuration_settings#Cookies</a> 060 */ 061public @ThreadSafe final class WikiAuthenticator extends Authenticator implements ISessionListener 062{ 063 064 065 /** Creates a WikiAuthenticator. 066 */ 067 public WikiAuthenticator( VOWicket _app ) 068 { 069 app = _app; 070 app.getRequestCycleListeners().add( new AbstractRequestCycleListener() // no need to unregister, registry does not outlive this listener 071 { 072 public @Override @ThreadSafe void onRequestHandlerResolved( final RequestCycle cycle, 073 final IRequestHandler handler ) 074 { 075 if( handler instanceof ListenerInterfaceRequestHandler // submitting form 076 || handler instanceof RenderPageRequestHandler ) // rendering page 077 { 078 if( WP_WikiLogin.class.equals( 079 ((IPageClassRequestHandler)handler).getPageClass() )) return; 080 // not the login page, that would be weird 081 082 syncFromPersistence( (VRequestCycle)cycle ); 083 } 084 } 085 }); 086 if( PRELOGIN_EMAIL != null ) app.getSessionListeners().add( WikiAuthenticator.this ); // no need to unregister, registry does not outlive this listener 087 } 088 089 090 091 // ------------------------------------------------------------------------------------ 092 093 094 /** Compares two email addresses for the wiki user: (1) the email address 095 * authenticated by the wiki, and (2) the email address implied by the user 096 * identifier. Returns null if they match, in which case the caller may accept the 097 * user as authenticated for the vote-server. Otherwise returns the localization key 098 * of a failure message. 099 * 100 * <p>If the user is a pipe, then the implied address (2) is instead effectively 101 * taken from the pipe minder's username; while the authenticated address (1) remains 102 * that of the pipe user. The comparison is therefore the same as when calling this 103 * method to authenticate the pipe minder as herself. But here she is instead 104 * authenticated as the pipe. We know it is she because (1) the pipe's email address 105 * is privately set to her address, and she has answered the resulting mail 106 * challenge. We further require that (2) the minder property of the pipe page be 107 * publicly set to her username, so everyone knows who can login as the minder.</p> 108 * 109 * @param wikiUser who is persistently logged into the wiki via cookies. 110 * @param cookieRelayer a cookie handler that 'gets' its cookies from the 111 * incoming client request. 112 * 113 * @see <a href='http://reluk.ca/w/Category:Pipe' target='_top'>Category:Pipe</a> 114 */ 115 FailureMessage authenticateEmail( final IDPair wikiUser, final CookieHandler cookieRelayer ) 116 throws VotorolaException 117 { 118 final PollwikiVS wiki = app.vsRun().voteServer().pollwiki(); 119 final String username = wikiUser.username(); 120 if( wiki.pipeRecognizer().isPipeName( username )) 121 { 122 final StringBuilder qB = new StringBuilder(); 123 qB.append( wiki.scriptURI() ); 124 qB.append( "/api.php?format=xml&action=query&prop=info&inprop=minder&titles=User:" ); 125 // http://reluk.ca/project/_/mailish/MailishUsername.xht#minderAPI 126 qB.append( UriComponent.encode( username, UriComponent.Type.QUERY_PARAM )); 127 final URI queryURI; 128 try{ queryURI = new URI( qB.toString() ); } 129 catch( URISyntaxException x ) { throw new RuntimeException( x ); } 130 131 logger.fine( "querying pollwiki for user's minder: " + queryURI ); 132 final Spool spool = new Spool1(); 133 try 134 { 135 final URLConnection http = queryURI.toURL().openConnection(); // not actually open yet 136 URLConnectionX.setRequestCookies( queryURI, http, cookieRelayer ); // after headers set 137 final XMLStreamReader xml = MediaWiki.requestXML( http, spool ); 138 while( xml.hasNext() ) 139 { 140 xml.next(); 141 if( !xml.isStartElement() ) continue; 142 143 if( "minder".equals( xml.getLocalName() )) 144 { 145 final String minderName = xml.getAttributeValue( /*ns*/null, "username" ); 146 if( minderName == null ) 147 { 148 final String f = xml.getAttributeValue( /*ns*/null, "fail" ); 149 if( f != null ) return new FailureMessage( f, /*isLocalized*/true ); 150 151 throw new MediaWiki.MalformedResponse( 152 "missing username in minder response" ); 153 } 154 155 return null; // authenticated per checks in mailish/MailishMinder.php 156 } 157 158 MediaWiki.test_error( xml ); 159 } 160 throw new MediaWiki.MalformedResponse( "missing 'minder' element" ); 161 } 162 catch( IOException|XMLStreamException x ) 163 { 164 throw new VotorolaException( "unable to authenticate email address: " + queryURI, x ); 165 } 166 finally{ spool.unwind(); } 167 } 168 else // non-pipe, normal user 169 { 170 final URI queryURI; 171 try{ queryURI = new URI( wiki.scriptURI() 172 + "/api.php?action=query&meta=userinfo&uiprop=email&format=xml" ); } 173 // Querying for user 'email' property. There is also group/right 174 // 'emailconfirmed', but it is disabled by default since 1.13. 175 // http://www.mediawiki.org/wiki/Special:Code/MediaWiki/33800 176 catch( URISyntaxException x ) { throw new RuntimeException( x ); } 177 178 logger.fine( "querying pollwiki for user's authenticated email address: " + queryURI ); 179 final Spool spool = new Spool1(); 180 try 181 { 182 final URLConnection http = queryURI.toURL().openConnection(); // not actually open yet 183 URLConnectionX.setRequestCookies( queryURI, http, cookieRelayer ); // after headers set 184 final XMLStreamReader xml = MediaWiki.requestXML( http, spool ); 185 while( xml.hasNext() ) 186 { 187 xml.next(); 188 if( !xml.isStartElement() ) continue; 189 190 if( "userinfo".equals( xml.getLocalName() )) 191 { 192 // Get email address (1) as authenticated by wiki. 193 // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 194 final String timeString = xml.getAttributeValue( /*ns*/null, 195 "emailauthenticated" ); 196 if( timeString == null ) 197 { 198 return new FailureMessage( 199 "a.web.wic.authen.WP_WikiLogin.mailFailMessage.none" ); 200 } 201 202 String authEmail1 = xml.getAttributeValue( /*ns*/null, "email" ); // (2) 203 if( authEmail1 == null || authEmail1.length() == 0 204 || timeString.length() == 0 ) 205 { 206 throw new MediaWiki.MalformedResponse( "emailauthenticated" ); 207 } 208 209 authEmail1 = InternetAddressX.canonicalAddress( authEmail1 ); 210 211 // Get email address (2) as implied by user identifier. 212 // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 213 final String impEmail2 = wikiUser.email(); 214 215 // Compare the two email addresses. 216 // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 217 if( !impEmail2.equals( authEmail1 )) 218 { 219 return new FailureMessage( 220 "a.web.wic.authen.WP_WikiLogin.mailFailMessage.wrong" ); 221 } 222 223 return null; // authenticated 224 } 225 226 MediaWiki.test_error( xml ); 227 } 228 throw new MediaWiki.MalformedResponse( "missing 'userinfo' element" ); 229 } 230 catch( AddressException|IOException|XMLStreamException x ) 231 { 232 throw new VotorolaException( "unable to authenticate email address: " + queryURI, x ); 233 } 234 finally{ spool.unwind(); } 235 } 236 } 237 238 239 240 /** The prefix used by the pollwiki for cookie names. 241 * 242 * @see #setCookiePrefix(String) 243 * @see <a href='http://www.mediawiki.org/wiki/Manual:$wgCookiePrefix' target='_top' 244 * >Manual:$wgCookiePrefix</a> 245 * @see <a href='http://www.mediawiki.org/wiki/Manual:Configuration_settings#Cookies' 246 * target='_top' >Manual:Configuration_settings#Cookies</a> 247 */ 248 public String getCookiePrefix() { return cookiePrefix; } 249 250 // Config item because API does not expose it. It exposes the default value as 251 // "wikiID" in a "general" meta query, but not the actual value. 252 // http://www.mediawiki.org/wiki/API:Meta#Parameters 253 254 255 private volatile String cookiePrefix = "wikidb"; // OPT as proper config item 256 257 258 /** Sets prefix used by the pollwiki for cookie names. The default value is 259 * "wikidb". 260 * 261 * @see #getCookiePrefix() 262 */ 263 public void setCookiePrefix( final String cP ) { cookiePrefix = cP; } 264 265 266 267 // - A u t h e n t i c a t o r -------------------------------------------------------- 268 269 270 public Class<? extends VPageHTML> loginPageClass() { return WP_WikiLogin.class; } 271 272 273 274 public void logOut() 275 { 276 // Log out locally. 277 // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 278 final VRequestCycle cycle = VRequestCycle.get(); 279 VSession.get().clearUser( cycle ); 280 281 // Clear any persistence cookies, logging out of wiki. 282 // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 283 final HttpServletRequest reqHS = (HttpServletRequest) 284 cycle.getRequest().getContainerRequest(); 285 if( persistedUsername(reqHS) != null ) unpersist( reqHS, cycle ); 286 // else will be logged back in next onRequestHandlerResolved 287 } 288 289 290 291 public VPageHTML newLoginPage( PageParameters _pP ) { return new WP_WikiLogin( _pP ); } 292 293 294 295 // - I - S e s s i o n - L i s t e n e r ---------------------------------------------- 296 297 298 public void onCreated( final Session s ) 299 { 300 final VSession session = (VSession)s; 301 if( session.user() != null ) return; 302 303 // User unlikely to be logged in persistently. syncFromPersistence was called 304 // (onCreated is apparently executed after onRequestHandlerResolved) and there is 305 // no user. So init the session according to PRELOGIN_EMAIL. 306 try 307 { 308 final IDPair initUser = IDPair.fromEmail( PRELOGIN_EMAIL ); 309 session.setUser( initUser, /*persistent*/false, 310 app.vsRun().trustserver().getTraceNode(/*list ref*/null,initUser), 311 VRequestCycle.get() ); 312 } 313 catch( IOException|SQLException x ) { throw new RuntimeException( x ); } 314 } 315 316 317 318 // ==================================================================================== 319 320 321 /** A container for a failure message that might or might not be localized. 322 */ 323 static final class FailureMessage 324 { 325 326 /** Constructs an un-localized FailureMessage based on a localization key. 327 */ 328 private FailureMessage( final String key ) { this( key, /*isLocalized*/false ); } 329 330 331 /** Constructs a FailureMessage. 332 * 333 * @see #content() 334 * @see #isLocalized() 335 */ 336 private FailureMessage( String _content, boolean _isLocalized ) 337 { 338 content = _content; 339 isLocalized = _isLocalized; 340 } 341 342 343 // -------------------------------------------------------------------------------- 344 345 346 /** The message content, which might or might not be localized. 347 */ 348 String content() { return content; } 349 350 private final String content; 351 352 353 /** True if the message content is a localization key, false if the message 354 * content is already localized. 355 */ 356 boolean isLocalized() { return isLocalized; } 357 358 private final boolean isLocalized; 359 360 } 361 362 363 364//// P r i v a t e /////////////////////////////////////////////////////////////////////// 365 366 367 private final VOWicket app; 368 369 370 371 /** Verifies the persisted username and sets it in the session, or clears persistence. 372 * 373 * @return true iff authentication succeeds. 374 */ 375 private boolean authenticate( final String apparentNewUsername, final HttpServletRequest reqHS, 376 final VRequestCycle cycle, final VSession session ) 377 { 378 final IDPair newUser = authenticatedUser( apparentNewUsername, reqHS ); 379 if( newUser == null ) 380 { 381 unpersist( reqHS, cycle ); // avoid busy retry 382 return false; 383 } 384 else // user logged into wiki, so log in locally too: 385 { 386 try 387 { 388 session.setUser( newUser, /*persistent*/true, 389 app.vsRun().trustserver().getTraceNode(/*list ref*/null,newUser), cycle ); 390 } 391 catch( IOException|SQLException x ) { throw new RuntimeException( x ); } 392 393 return true; 394 } 395 } 396 397 398 399 /** Verifies that the persisted username belongs to the user and returns the 400 * corresponding identifier, or null if verification fails. 401 */ 402 private IDPair authenticatedUser( final String persistedUsername, 403 final HttpServletRequest reqHS ) 404 { 405 final IDPair claimedID; 406 try{ claimedID = IDPair.fromUsername( persistedUsername ); } 407 catch( javax.mail.internet.AddressException x ) 408 { 409 logger.log( LoggerX.INFO, "ignoring non-mailish username in persistence cookie", x ); 410 return null; 411 } 412 413 try 414 { 415 return authenticateEmail(claimedID,new CookieGetRelayer(reqHS)) == null? claimedID: null; 416 } 417 catch( VotorolaException x ) 418 { 419 logger.log( LoggerX.WARNING, "unable to verify persistently authenticated user", x ); 420 return null; 421 } 422 } 423 424 425 426 private static final Logger logger = LoggerX.i( WikiAuthenticator.class ); 427 428 429 430 /** A mailish username that might be that of the persistently authenticated user, or 431 * null if well-formed persistence data cannot be retrieved from the request cookies. 432 * The persistence data are not authenticated. 433 */ 434 private String persistedUsername( final HttpServletRequest reqHS ) 435 { 436 final Cookie[] cookies = reqHS.getCookies(); 437 if( cookies == null ) return null; 438 439 String username = null; 440 String token = null; 441 { 442 final String tokenCookieName = cookiePrefix + "Token"; 443 final String usernameCookieName = cookiePrefix + "UserName"; 444 for( final Cookie cookie: cookies ) 445 { 446 final String c = cookie.getName(); 447 if( c.equals( tokenCookieName )) 448 { 449 token = cookie.getValue(); 450 if( username != null ) break; // that's both of them 451 } 452 else if( c.equals( usernameCookieName )) 453 { 454 username = CookieX.decodedValue( cookie.getValue() ); /* MediaWiki 455 encodes spaces here as '+', which CookieX (URLEncoder) will 456 correctly decode */ 457 458 if( token != null ) break; // that's both of them 459 } 460 } 461 } 462 463 return username == null || token == null? null: username; 464 } 465 466 467 468 private void syncFromPersistence( final VRequestCycle cycle ) 469 { 470 final HttpServletRequest reqHS = (HttpServletRequest) 471 cycle.getRequest().getContainerRequest(); 472 final String apparentNewUsername = persistedUsername( reqHS ); 473 final VSession session = VSession.get(); 474 final VSession.User oldUser = session.user(); 475 if( oldUser == null ) 476 { 477 if( apparentNewUsername != null ) 478 { 479 // set session from persistence, else clear persistence 480 authenticate( apparentNewUsername, reqHS, cycle, session ); 481 } 482 } 483 else if( apparentNewUsername == null ) 484 { 485 if( oldUser.isPersistent() ) session.clearUser( cycle ); 486 // user logged out of wiki, so log out locally too, unless login was 487 // strictly local 488 } 489 else if( !oldUser.username().equals( apparentNewUsername )) 490 { 491 // set session from persistence, else clear persistence and (unless login was 492 // strictly local) clear session 493 final boolean isSet = authenticate( apparentNewUsername, reqHS, cycle, session ); 494 if( !isSet && oldUser.isPersistent() ) session.clearUser( cycle ); 495 } 496 } 497 498 499 500 private void unpersist( final HttpServletRequest reqHS, final VRequestCycle cycle ) 501 { 502 if( persistedUsername(reqHS) == null ) return; 503 504 final URL logoutURL; // clear any persistent authentication 505 try{ logoutURL = new URL( app.vsRun().voteServer().pollwiki().scriptURI() 506 + "/api.php?action=logout&format=xml" ); } 507 catch( MalformedURLException x ) { throw new RuntimeException( x ); } 508 509 logger.fine( "logging user out of pollwiki: " + logoutURL ); 510 final Spool spool = new Spool1(); 511 try 512 { 513 final URLConnection http = logoutURL.openConnection(); // not actually open yet 514 HTTPServletRequestX.relayHeaders( "Cookie", reqHS, http ); 515 // from incoming client request, to outgoing pollwiki request 516 final XMLStreamReader xml = MediaWiki.requestXML( http, spool ); 517 while( xml.hasNext() ) 518 { 519 xml.next(); 520 if( !xml.isStartElement() ) continue; 521 522 MediaWiki.test_error( xml ); // though none currently defined for logout 523 } 524 HTTPServletResponseX.relayHeaders( "Set-Cookie", http, // from incoming pollwiki response 525 (HttpServletResponse)cycle.getResponse().getContainerResponse() ); 526 // to outgoing client response 527 } 528 catch( IOException|XMLStreamException x ) 529 { 530 logger.log( LoggerX.WARNING, "unable to log user out of pollwiki: " + logoutURL, x ); 531 } 532 finally{ spool.unwind(); } 533 } 534 535 536 537 // ==================================================================================== 538 539 540 /** A cookie handler that 'gets' cookies from the incoming client request for relaying 541 * to the pollwiki. Effectively the client requests directly from the wiki as far as 542 * cookies are concerned. But this is only a half relay; 'put' is not implemented 543 * (not required) and therefore the client will receive no cookie alterations. 544 */ 545 private static final class CookieGetRelayer extends CookieHandler 546 { 547 548 CookieGetRelayer( HttpServletRequest _reqHS ) { reqHS = _reqHS; } 549 550 551 public Map<String,List<String>> get( URI _uri, // from incoming client request 552 Map<String,List<String>> _requestHeadersToWiki ) 553 { 554 final String value = reqHS.getHeader( "Cookie" ); 555 final Map<String,List<String>> map; 556 if( value == null ) map = Collections.emptyMap(); 557 else map = Collections.singletonMap( "Cookie", Collections.singletonList( value )); 558 // no point breaking semicolon separated key/value pairs into separate list 559 // elements as CookieManager does; it happens the return value is used only 560 // by CookieHandlerX.newRequestHeader, which would just re-concatentate them 561 return map; 562 } 563 564 565 /** Does nothing. 566 */ 567 public void put( URI _uri, final Map<String,List<String>> _responseHeaders ) {} 568 569 570 private final HttpServletRequest reqHS; 571 572 } 573 574 575}