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}