001package votorola.a.web.wic.authen; // Copyright 2008-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 java.io.*;
004import java.math.*;
005import java.security.*;
006import java.sql.SQLException;
007import java.util.logging.*;
008import javax.mail.internet.*;
009import javax.servlet.http.*;
010import org.apache.wicket.Session;
011import org.apache.wicket.ISessionListener;
012import org.apache.wicket.request.Request;
013import org.apache.wicket.request.http.WebResponse;
014import org.apache.wicket.request.mapper.parameter.PageParameters;
015import org.openid4java.consumer.*;
016import votorola.a.voter.*;
017import votorola.a.web.wic.*;
018import votorola.g.io.*;
019import votorola.g.lang.*;
020import votorola.g.logging.*;
021import votorola.g.web.CookieX;
022import votorola.g.web.wic.*;
023
024
025/** An authenticator based on OpenID and email handshakes.
026  */
027public @ThreadSafe final class OpenIDAuthenticator extends Authenticator implements ISessionListener
028{
029
030
031    /** Creates an OpenIDAuthenticator.
032      */
033    public OpenIDAuthenticator( VOWicket _app )
034    {
035        app = _app;
036        persistenceFile = new File( app.vsRun().voteServer().outDirectory(),
037          "login-persistence.serial" );
038        if( persistenceFile.isFile() )
039        {
040            try{ persistence = (Persistence)FileX.readObject( persistenceFile ); }
041            catch( ClassNotFoundException|IOException x )
042            {
043                logger.config( "Unable to recreate persistence from previous run.  Creating anew.  Old persisted logins will be lost.  Reason: " + x.toString() );
044                if( !persistenceFile.delete() ) recreatePersistence();
045            }
046        }
047        app.getSessionListeners().add( OpenIDAuthenticator.this ); // no need to unregister, registry does not outlive this listener
048    }
049
050
051
052   // ------------------------------------------------------------------------------------
053
054
055    /** The name of the cookie that stores the login key of the user if login is
056      * cookie-persisted for that user.
057      *
058      *     @see #newPersistKey(String,String)
059      */
060    static final String COOKIE_PERSIST_KEY = "vo_loginPersistKey";
061      // "voLogin.persistKey" would have been more standard
062
063
064
065    /** The name of the cookie that stores the email address of the user if login is
066      * cookie-persisted for that user.
067      */
068    static final String COOKIE_PERSIST_USER_EMAIL = "vo_loginPersistUserEmail";
069      // "voLogin.persistUserEmail" would have been more standard
070
071
072
073    /** The OpenID consumer manager.
074      */
075    @ThreadRestricted("holds OpenIDAuthenticator.this") ConsumerManager consumerManager()
076    {
077        assert Thread.holdsLock( OpenIDAuthenticator.this ); // actually it's the object that is restricted, not this method
078        return consumerManager;
079    }
080
081        private ConsumerManager consumerManager = new ConsumerManager();
082
083
084
085    /** Constructs a login persistence cookie.
086      */
087    static Cookie newPersistCookie( final String name, final String value, final boolean toEncode,
088      final Request req )
089    {
090        final Cookie cookie = new CookieX( name, value, toEncode );
091        cookie.setMaxAge( COOKIE_PERSIST_DURATION_S );
092        cookie.setPath( req.getContextPath() ); // allow access from pages other than WP_OpenIDLogin
093        return cookie;
094    }
095
096
097
098    /** Calculates a secure hash of the user's login fingerprint and returns it as a
099      * string in radix 36.
100      *
101      *     @param ipAddress the IP address of the client or proxy.
102      */
103    String newPersistKey( final String userEmail, final String ipAddress )
104    {
105        if( !persistenceFile.isFile() ) recreatePersistence();
106        final String fingerprint = persistence.hashSalt1() + "/"  + userEmail
107          + "/" + ipAddress + "/" + persistence.hashSalt2();
108        try
109        {
110            final MessageDigest digester = MessageDigest.getInstance( "SHA-256" );
111            final byte[] digest = digester.digest( fingerprint.getBytes() );
112            final BigInteger digestNumber = new BigInteger( /*positive*/1, digest );
113            return digestNumber.toString( /*radix*/36 ).toUpperCase();
114        }
115        catch( NoSuchAlgorithmException x ) { throw new RuntimeException( x ); }
116    }
117
118
119
120    /** The secure random number generator.
121      */
122    @ThreadRestricted("holds OpenIDAuthenticator.this") SecureRandom secureRandomizer()
123    {
124        assert Thread.holdsLock( OpenIDAuthenticator.this ); // actually it's the object that is restricted, not this method
125        return secureRandomizer;
126    }
127
128        private final SecureRandom secureRandomizer = new SecureRandom();
129
130
131
132   // - A u t h e n t i c a t o r --------------------------------------------------------
133
134
135    public Class<? extends VPageHTML> loginPageClass() { return WP_OpenIDLogin.class; }
136
137
138
139    public void logOut()
140    {
141        final VRequestCycle cycle = VRequestCycle.get();
142        final VSession session = VSession.get();
143        final VSession.User user = session.user();
144        if( user != null ) WP_OpenIDLogin.clearPersistence( user.email(), cycle );
145        session.clearUser( cycle );
146    }
147
148
149
150    public VPageHTML newLoginPage( PageParameters _pP ) { return new WP_OpenIDLogin( _pP ); }
151
152
153
154   // - I - S e s s i o n - L i s t e n e r ----------------------------------------------
155
156
157    public void onCreated( final Session s )
158    {
159        final VRequestCycle cycle = VRequestCycle.get();
160        final Request req = cycle.getRequest();
161        IDPair persistedUser = persistedUser( (HttpServletRequest)req.getContainerRequest(), app,
162          req, (WebResponse)cycle.getResponse() );
163        final boolean isPersistent;
164        if( persistedUser == null )
165        {
166            if( PRELOGIN_EMAIL == null ) return;
167
168            persistedUser = IDPair.fromEmail( PRELOGIN_EMAIL );
169            isPersistent = false;
170        }
171        else isPersistent = true;
172        final VSession session = (VSession)s;
173        try
174        {
175            session.setUser( persistedUser, isPersistent,
176              app.vsRun().trustserver().getTraceNode(/*list ref*/null,persistedUser), cycle );
177        }
178        catch( IOException|SQLException x ) { throw new RuntimeException( x ); }
179    }
180
181
182
183//// P r i v a t e ///////////////////////////////////////////////////////////////////////
184
185
186    private final VOWicket app;
187
188
189
190 // private IDPair apparentlyPersistedUser( HttpServletRequest _reqHS )
191 // {
192 //     return persistedUser( _reqHS, null, null );
193 // }
194
195
196
197    /** The maximum duration of a cookie-persisted login for an inactive user, in seconds.
198      * Cookies are refreshed whenever the user is active during this time, thus
199      * effectively resetting the clock.
200      */
201    private static final int COOKIE_PERSIST_DURATION_S = 86400/*s per day*/ * 14/*day*/;
202
203
204
205    private static final Logger logger = LoggerX.i( OpenIDAuthenticator.class );
206
207
208
209    /** Extracts and verifies the identifier of the persistently authenticated user.
210      * Returns either the verified identifier, or null if well-formed persistence data
211      * cannot be retrieved from the request cookies, or if verification fails.
212      *
213      *     @param authenAppOrNull null to skip authentication and refresh of cookies.
214      *     @param reqOrNull may be null only if authenAppOrNull is null.
215      *     @param resWOrNull the web response in which the persistence data is to be
216      *       refreshed, if necessary.  May be null only if authenAppOrNull is null.
217      */
218    private static IDPair persistedUser( final HttpServletRequest reqHS,
219      final VOWicket authenAppOrNull, final Request reqOrNull, final WebResponse resWOrNull )
220    {
221        // http://stackoverflow.com/questions/1354999/keep-me-logged-in-the-best-approach
222
223        assert !(reqOrNull == null || resWOrNull == null) || authenAppOrNull == null;
224        final Cookie[] cookies = reqHS.getCookies();
225        if( cookies == null ) return null;
226
227        String userEmail = null;
228        String key = null;
229        for( final Cookie cookie: cookies )
230        {
231            final String c = cookie.getName();
232            if( c.equals( COOKIE_PERSIST_USER_EMAIL ))
233            {
234                userEmail = CookieX.decodedValue( cookie.getValue() );
235                if( key != null ) break; // that's both of them
236            }
237            else if( c.equals( COOKIE_PERSIST_KEY ))
238            {
239                key = cookie.getValue();
240                if( userEmail != null ) break; // that's both of them
241            }
242        }
243        if( key == null || userEmail == null ) return null;
244
245        try{ new InternetAddress( userEmail, /*strict*/true ); }
246        catch( AddressException x )
247        {
248            logger.log( LoggerX.INFO, "ignoring invalid email address in cookie", x );
249            return null;
250        }
251
252        final IDPair user = IDPair.fromEmail( userEmail );
253        if( authenAppOrNull != null )
254        {
255            final OpenIDAuthenticator auth = (OpenIDAuthenticator)authenAppOrNull.authenticator();
256            final String expectedKey = auth.newPersistKey( userEmail, reqHS.getRemoteAddr() );
257            if( !expectedKey.equals( key )) return null;
258              // user IP changed, or persistence salts file recreated, or phoney key
259
260            try
261            {
262                final UserSettings settings = new UserSettings( userEmail,
263                  authenAppOrNull.vsRun().userTable() );
264                if( !expectedKey.equals( settings.getLoginPersistKey() )) return null;
265                  // user never originally authenticated, or settings table cleared
266            }
267            catch( SQLException x )
268            {
269                logger.log( LoggerX.WARNING, "unable to authenticate persistent login", x );
270                return null;
271            }
272
273          // refresh cookies
274          // ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` `
275            WebResponseX.addCookie( resWOrNull, newPersistCookie( COOKIE_PERSIST_USER_EMAIL,
276              userEmail, /*toEncode*/true, reqOrNull ));
277            WebResponseX.addCookie( resWOrNull, newPersistCookie( COOKIE_PERSIST_KEY, expectedKey,
278              /*toEncode*/false, reqOrNull ));
279        }
280
281        return user;
282    }
283
284
285
286    private volatile Persistence persistence;
287
288
289
290    private final File persistenceFile;
291
292
293
294    private @Warning("init call") void recreatePersistence()
295    {
296        persistence = new Persistence( OpenIDAuthenticator.this );
297        try
298        {
299            FileX.writeObject( persistence, persistenceFile );
300        }
301        catch( IOException x ) { logger.warning( "unable to store new persistence to file: " + x.toString() ); }
302        persistenceFile.setReadable( false, /*ownerOnly*/false ); // nobody can read/write
303        persistenceFile.setWritable( false, /*ownerOnly*/false );
304        persistenceFile.setReadable( true, /*ownerOnly*/true ); // only owner can read/write
305        persistenceFile.setWritable( true, /*ownerOnly*/true );
306    }
307
308
309
310   // ====================================================================================
311
312
313    /** Parameters required for login persistence.
314      */
315    private static final class Persistence implements Serializable
316    {
317
318        private static final long serialVersionUID = 0L;
319
320
321        Persistence( final OpenIDAuthenticator auth )
322        {
323            final byte[] randomByteArray = new byte[2]; // raw format
324            final BigInteger randomN1, randomN2;
325            synchronized( auth )
326            {
327                auth.secureRandomizer().nextBytes( randomByteArray );
328                randomN1 = new BigInteger( /*positive*/1, randomByteArray );
329                auth.secureRandomizer().nextBytes( randomByteArray );
330            }
331            randomN2 = new BigInteger( /*positive*/1, randomByteArray );
332            hashSalt1 = randomN1.toString( /*radix*/16 );
333            hashSalt2 = randomN2.toString( /*radix*/16 );
334        }
335
336
337        private String hashSalt1() { return hashSalt1; }
338
339            private final String hashSalt1;
340
341
342        private String hashSalt2() { return hashSalt2; }
343
344            private final String hashSalt2;
345
346    }
347
348
349}