package 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. import com.sun.jersey.api.uri.*; import java.io.*; import java.net.*; import java.sql.SQLException; import java.util.*; import java.util.logging.*; import votorola.g.logging.*; import java.util.regex.*; import javax.mail.internet.AddressException; import javax.servlet.http.*; import javax.xml.stream.*; import org.apache.wicket.Session; import org.apache.wicket.ISessionListener; import org.apache.wicket.request.IRequestHandler; import org.apache.wicket.request.Request; import org.apache.wicket.request.cycle.*; import org.apache.wicket.request.handler.*; import org.apache.wicket.request.http.WebResponse; import org.apache.wicket.request.mapper.parameter.PageParameters; import votorola.a.*; import votorola.a.voter.*; import votorola.a.web.wic.*; import votorola.g.*; import votorola.g.hold.*; import votorola.g.lang.*; import votorola.g.mail.*; import votorola.g.net.*; import votorola.g.web.*; import votorola.g.web.CookieX; /** An authenticator based on the authentication facilities of the {@linkplain * votorola.a.VoteServer#pollwiki() pollwiki}, enabling synchronized login between it and * the Wicket interface. Example configuration for {@linkplain * VOWicket#startupConfigurationFile vowicket.js}:
  *
  *   function constructingVOWicket( wicCC )
  *   {
  *       wicCC.{@linkplain votorola.a.web.wic.VOWicket.ConstructionContext#setAuthenticatorClass(Class) setAuthenticatorClass}(
  *         Packages.votorola.a.web.wic.authen.WikiAuthenticator );
  *   }
  *
  *   function initializingVOWicket( wic )
  *   {
  *       wic.{@linkplain VOWicket#authenticator() authenticator}().setCookiePrefix( 'pollwiki' ); }
  *   }
* *

Pollwiki configuration changes will usually be required in order for this to work * correctly. The pollwiki's cookie domain must match the request * domain of the Wicket interface. Also the pollwiki must not send the cookies HttpOnly, * which is the default mode.

* * @see Manual:$wgCookieDomain * @see Manual:$wgCookieHttpOnly * @see Manual:Configuration_settings#Cookies */ public @ThreadSafe final class WikiAuthenticator extends Authenticator implements ISessionListener { /** Creates a WikiAuthenticator. */ public WikiAuthenticator( VOWicket _app ) { app = _app; app.getRequestCycleListeners().add( new AbstractRequestCycleListener() // no need to unregister, registry does not outlive this listener { public @Override @ThreadSafe void onRequestHandlerResolved( final RequestCycle cycle, final IRequestHandler handler ) { if( handler instanceof ListenerInterfaceRequestHandler // submitting form || handler instanceof RenderPageRequestHandler ) // rendering page { if( WP_WikiLogin.class.equals( ((IPageClassRequestHandler)handler).getPageClass() )) return; // not the login page, that would be weird syncFromPersistence( (VRequestCycle)cycle ); } } }); if( PRELOGIN_EMAIL != null ) app.getSessionListeners().add( WikiAuthenticator.this ); // no need to unregister, registry does not outlive this listener } // ------------------------------------------------------------------------------------ /** Compares two email addresses for the wiki user: (1) the email address * authenticated by the wiki, and (2) the email address implied by the user * identifier. Returns null if they match, in which case the caller may accept the * user as authenticated for the vote-server. Otherwise returns the localization key * of a failure message. * *

If the user is a pipe, then the implied address (2) is instead effectively * taken from the pipe minder's username; while the authenticated address (1) remains * that of the pipe user. The comparison is therefore the same as when calling this * method to authenticate the pipe minder as herself. But here she is instead * authenticated as the pipe. We know it is she because (1) the pipe's email address * is privately set to her address, and she has answered the resulting mail * challenge. We further require that (2) the minder property of the pipe page be * publicly set to her username, so everyone knows who can login as the minder.

* * @param wikiUser who is persistently logged into the wiki via cookies. * @param cookieRelayer a cookie handler that 'gets' its cookies from the * incoming client request. * * @see Category:Pipe */ FailureMessage authenticateEmail( final IDPair wikiUser, final CookieHandler cookieRelayer ) throws VotorolaException { final PollwikiVS wiki = app.vsRun().voteServer().pollwiki(); final String username = wikiUser.username(); if( wiki.pipeRecognizer().isPipeName( username )) { final StringBuilder qB = new StringBuilder(); qB.append( wiki.scriptURI() ); qB.append( "/api.php?format=xml&action=query&prop=info&inprop=minder&titles=User:" ); // http://reluk.ca/project/_/mailish/MailishUsername.xht#minderAPI qB.append( UriComponent.encode( username, UriComponent.Type.QUERY_PARAM )); final URI queryURI; try{ queryURI = new URI( qB.toString() ); } catch( URISyntaxException x ) { throw new RuntimeException( x ); } logger.fine( "querying pollwiki for user's minder: " + queryURI ); final Spool spool = new Spool1(); try { final URLConnection http = queryURI.toURL().openConnection(); // not actually open yet URLConnectionX.setRequestCookies( queryURI, http, cookieRelayer ); // after headers set final XMLStreamReader xml = MediaWiki.requestXML( http, spool ); while( xml.hasNext() ) { xml.next(); if( !xml.isStartElement() ) continue; if( "minder".equals( xml.getLocalName() )) { final String minderName = xml.getAttributeValue( /*ns*/null, "username" ); if( minderName == null ) { final String f = xml.getAttributeValue( /*ns*/null, "fail" ); if( f != null ) return new FailureMessage( f, /*isLocalized*/true ); throw new MediaWiki.MalformedResponse( "missing username in minder response" ); } return null; // authenticated per checks in mailish/MailishMinder.php } MediaWiki.test_error( xml ); } throw new MediaWiki.MalformedResponse( "missing 'minder' element" ); } catch( IOException|XMLStreamException x ) { throw new VotorolaException( "unable to authenticate email address: " + queryURI, x ); } finally{ spool.unwind(); } } else // non-pipe, normal user { final URI queryURI; try{ queryURI = new URI( wiki.scriptURI() + "/api.php?action=query&meta=userinfo&uiprop=email&format=xml" ); } // Querying for user 'email' property. There is also group/right // 'emailconfirmed', but it is disabled by default since 1.13. // http://www.mediawiki.org/wiki/Special:Code/MediaWiki/33800 catch( URISyntaxException x ) { throw new RuntimeException( x ); } logger.fine( "querying pollwiki for user's authenticated email address: " + queryURI ); final Spool spool = new Spool1(); try { final URLConnection http = queryURI.toURL().openConnection(); // not actually open yet URLConnectionX.setRequestCookies( queryURI, http, cookieRelayer ); // after headers set final XMLStreamReader xml = MediaWiki.requestXML( http, spool ); while( xml.hasNext() ) { xml.next(); if( !xml.isStartElement() ) continue; if( "userinfo".equals( xml.getLocalName() )) { // Get email address (1) as authenticated by wiki. // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - final String timeString = xml.getAttributeValue( /*ns*/null, "emailauthenticated" ); if( timeString == null ) { return new FailureMessage( "a.web.wic.authen.WP_WikiLogin.mailFailMessage.none" ); } String authEmail1 = xml.getAttributeValue( /*ns*/null, "email" ); // (2) if( authEmail1 == null || authEmail1.length() == 0 || timeString.length() == 0 ) { throw new MediaWiki.MalformedResponse( "emailauthenticated" ); } authEmail1 = InternetAddressX.canonicalAddress( authEmail1 ); // Get email address (2) as implied by user identifier. // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - final String impEmail2 = wikiUser.email(); // Compare the two email addresses. // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - if( !impEmail2.equals( authEmail1 )) { return new FailureMessage( "a.web.wic.authen.WP_WikiLogin.mailFailMessage.wrong" ); } return null; // authenticated } MediaWiki.test_error( xml ); } throw new MediaWiki.MalformedResponse( "missing 'userinfo' element" ); } catch( AddressException|IOException|XMLStreamException x ) { throw new VotorolaException( "unable to authenticate email address: " + queryURI, x ); } finally{ spool.unwind(); } } } /** The prefix used by the pollwiki for cookie names. * * @see #setCookiePrefix(String) * @see Manual:$wgCookiePrefix * @see Manual:Configuration_settings#Cookies */ public String getCookiePrefix() { return cookiePrefix; } // Config item because API does not expose it. It exposes the default value as // "wikiID" in a "general" meta query, but not the actual value. // http://www.mediawiki.org/wiki/API:Meta#Parameters private volatile String cookiePrefix = "wikidb"; // OPT as proper config item /** Sets prefix used by the pollwiki for cookie names. The default value is * "wikidb". * * @see #getCookiePrefix() */ public void setCookiePrefix( final String cP ) { cookiePrefix = cP; } // - A u t h e n t i c a t o r -------------------------------------------------------- public Class loginPageClass() { return WP_WikiLogin.class; } public void logOut() { // Log out locally. // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - final VRequestCycle cycle = VRequestCycle.get(); VSession.get().clearUser( cycle ); // Clear any persistence cookies, logging out of wiki. // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - final HttpServletRequest reqHS = (HttpServletRequest) cycle.getRequest().getContainerRequest(); if( persistedUsername(reqHS) != null ) unpersist( reqHS, cycle ); // else will be logged back in next onRequestHandlerResolved } public VPageHTML newLoginPage( PageParameters _pP ) { return new WP_WikiLogin( _pP ); } // - I - S e s s i o n - L i s t e n e r ---------------------------------------------- public void onCreated( final Session s ) { final VSession session = (VSession)s; if( session.user() != null ) return; // User unlikely to be logged in persistently. syncFromPersistence was called // (onCreated is apparently executed after onRequestHandlerResolved) and there is // no user. So init the session according to PRELOGIN_EMAIL. try { final IDPair initUser = IDPair.fromEmail( PRELOGIN_EMAIL ); session.setUser( initUser, /*persistent*/false, app.vsRun().trustserver().getTraceNode(/*list ref*/null,initUser), VRequestCycle.get() ); } catch( IOException|SQLException x ) { throw new RuntimeException( x ); } } // ==================================================================================== /** A container for a failure message that might or might not be localized. */ static final class FailureMessage { /** Constructs an un-localized FailureMessage based on a localization key. */ private FailureMessage( final String key ) { this( key, /*isLocalized*/false ); } /** Constructs a FailureMessage. * * @see #content() * @see #isLocalized() */ private FailureMessage( String _content, boolean _isLocalized ) { content = _content; isLocalized = _isLocalized; } // -------------------------------------------------------------------------------- /** The message content, which might or might not be localized. */ String content() { return content; } private final String content; /** True if the message content is a localization key, false if the message * content is already localized. */ boolean isLocalized() { return isLocalized; } private final boolean isLocalized; } //// P r i v a t e /////////////////////////////////////////////////////////////////////// private final VOWicket app; /** Verifies the persisted username and sets it in the session, or clears persistence. * * @return true iff authentication succeeds. */ private boolean authenticate( final String apparentNewUsername, final HttpServletRequest reqHS, final VRequestCycle cycle, final VSession session ) { final IDPair newUser = authenticatedUser( apparentNewUsername, reqHS ); if( newUser == null ) { unpersist( reqHS, cycle ); // avoid busy retry return false; } else // user logged into wiki, so log in locally too: { try { session.setUser( newUser, /*persistent*/true, app.vsRun().trustserver().getTraceNode(/*list ref*/null,newUser), cycle ); } catch( IOException|SQLException x ) { throw new RuntimeException( x ); } return true; } } /** Verifies that the persisted username belongs to the user and returns the * corresponding identifier, or null if verification fails. */ private IDPair authenticatedUser( final String persistedUsername, final HttpServletRequest reqHS ) { final IDPair claimedID; try{ claimedID = IDPair.fromUsername( persistedUsername ); } catch( javax.mail.internet.AddressException x ) { logger.log( LoggerX.INFO, "ignoring non-mailish username in persistence cookie", x ); return null; } try { return authenticateEmail(claimedID,new CookieGetRelayer(reqHS)) == null? claimedID: null; } catch( VotorolaException x ) { logger.log( LoggerX.WARNING, "unable to verify persistently authenticated user", x ); return null; } } private static final Logger logger = LoggerX.i( WikiAuthenticator.class ); /** A mailish username that might be that of the persistently authenticated user, or * null if well-formed persistence data cannot be retrieved from the request cookies. * The persistence data are not authenticated. */ private String persistedUsername( final HttpServletRequest reqHS ) { final Cookie[] cookies = reqHS.getCookies(); if( cookies == null ) return null; String username = null; String token = null; { final String tokenCookieName = cookiePrefix + "Token"; final String usernameCookieName = cookiePrefix + "UserName"; for( final Cookie cookie: cookies ) { final String c = cookie.getName(); if( c.equals( tokenCookieName )) { token = cookie.getValue(); if( username != null ) break; // that's both of them } else if( c.equals( usernameCookieName )) { username = CookieX.decodedValue( cookie.getValue() ); /* MediaWiki encodes spaces here as '+', which CookieX (URLEncoder) will correctly decode */ if( token != null ) break; // that's both of them } } } return username == null || token == null? null: username; } private void syncFromPersistence( final VRequestCycle cycle ) { final HttpServletRequest reqHS = (HttpServletRequest) cycle.getRequest().getContainerRequest(); final String apparentNewUsername = persistedUsername( reqHS ); final VSession session = VSession.get(); final VSession.User oldUser = session.user(); if( oldUser == null ) { if( apparentNewUsername != null ) { // set session from persistence, else clear persistence authenticate( apparentNewUsername, reqHS, cycle, session ); } } else if( apparentNewUsername == null ) { if( oldUser.isPersistent() ) session.clearUser( cycle ); // user logged out of wiki, so log out locally too, unless login was // strictly local } else if( !oldUser.username().equals( apparentNewUsername )) { // set session from persistence, else clear persistence and (unless login was // strictly local) clear session final boolean isSet = authenticate( apparentNewUsername, reqHS, cycle, session ); if( !isSet && oldUser.isPersistent() ) session.clearUser( cycle ); } } private void unpersist( final HttpServletRequest reqHS, final VRequestCycle cycle ) { if( persistedUsername(reqHS) == null ) return; final URL logoutURL; // clear any persistent authentication try{ logoutURL = new URL( app.vsRun().voteServer().pollwiki().scriptURI() + "/api.php?action=logout&format=xml" ); } catch( MalformedURLException x ) { throw new RuntimeException( x ); } logger.fine( "logging user out of pollwiki: " + logoutURL ); final Spool spool = new Spool1(); try { final URLConnection http = logoutURL.openConnection(); // not actually open yet HTTPServletRequestX.relayHeaders( "Cookie", reqHS, http ); // from incoming client request, to outgoing pollwiki request final XMLStreamReader xml = MediaWiki.requestXML( http, spool ); while( xml.hasNext() ) { xml.next(); if( !xml.isStartElement() ) continue; MediaWiki.test_error( xml ); // though none currently defined for logout } HTTPServletResponseX.relayHeaders( "Set-Cookie", http, // from incoming pollwiki response (HttpServletResponse)cycle.getResponse().getContainerResponse() ); // to outgoing client response } catch( IOException|XMLStreamException x ) { logger.log( LoggerX.WARNING, "unable to log user out of pollwiki: " + logoutURL, x ); } finally{ spool.unwind(); } } // ==================================================================================== /** A cookie handler that 'gets' cookies from the incoming client request for relaying * to the pollwiki. Effectively the client requests directly from the wiki as far as * cookies are concerned. But this is only a half relay; 'put' is not implemented * (not required) and therefore the client will receive no cookie alterations. */ private static final class CookieGetRelayer extends CookieHandler { CookieGetRelayer( HttpServletRequest _reqHS ) { reqHS = _reqHS; } public Map> get( URI _uri, // from incoming client request Map> _requestHeadersToWiki ) { final String value = reqHS.getHeader( "Cookie" ); final Map> map; if( value == null ) map = Collections.emptyMap(); else map = Collections.singletonMap( "Cookie", Collections.singletonList( value )); // no point breaking semicolon separated key/value pairs into separate list // elements as CookieManager does; it happens the return value is used only // by CookieHandlerX.newRequestHeader, which would just re-concatentate them return map; } /** Does nothing. */ public void put( URI _uri, final Map> _responseHeaders ) {} private final HttpServletRequest reqHS; } }