From 11b3c25728deb39cc70279159c93928ecb56c692 Mon Sep 17 00:00:00 2001 From: Vincent Zurczak Date: Thu, 2 Mar 2017 17:22:00 +0100 Subject: [PATCH] #632 User authentication The hardest part is done, integrate with Karaf's JAAS implementation. Next step: implement a servlet filter that uses it --- core/roboconf-dm-rest-commons/pom.xml | 8 +- .../security/AuthenticationManager.java | 242 ++++++++++++++++++ .../security/AuthenticationManagerTest.java | 134 ++++++++++ 3 files changed, 383 insertions(+), 1 deletion(-) create mode 100644 core/roboconf-dm-rest-commons/src/main/java/net/roboconf/dm/rest/commons/security/AuthenticationManager.java create mode 100644 core/roboconf-dm-rest-commons/src/test/java/net/roboconf/dm/rest/commons/security/AuthenticationManagerTest.java diff --git a/core/roboconf-dm-rest-commons/pom.xml b/core/roboconf-dm-rest-commons/pom.xml index 7fabad1c..82930807 100644 --- a/core/roboconf-dm-rest-commons/pom.xml +++ b/core/roboconf-dm-rest-commons/pom.xml @@ -86,7 +86,13 @@ roboconf-dm ${project.version} test - + + + + org.mockito + mockito-core + test + diff --git a/core/roboconf-dm-rest-commons/src/main/java/net/roboconf/dm/rest/commons/security/AuthenticationManager.java b/core/roboconf-dm-rest-commons/src/main/java/net/roboconf/dm/rest/commons/security/AuthenticationManager.java new file mode 100644 index 00000000..04dff006 --- /dev/null +++ b/core/roboconf-dm-rest-commons/src/main/java/net/roboconf/dm/rest/commons/security/AuthenticationManager.java @@ -0,0 +1,242 @@ +/** + * Copyright 2017 Linagora, Université Joseph Fourier, Floralis + * + * The present code is developed in the scope of the joint LINAGORA - + * Université Joseph Fourier - Floralis research program and is designated + * as a "Result" pursuant to the terms and conditions of the LINAGORA + * - Université Joseph Fourier - Floralis research program. Each copyright + * holder of Results enumerated here above fully & independently holds complete + * ownership of the complete Intellectual Property rights applicable to the whole + * of said Results, and may freely exploit it in any manner which does not infringe + * the moral rights of the other copyright holders. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package net.roboconf.dm.rest.commons.security; + +import java.io.IOException; +import java.util.Date; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.logging.Logger; + +import javax.security.auth.callback.Callback; +import javax.security.auth.callback.CallbackHandler; +import javax.security.auth.callback.NameCallback; +import javax.security.auth.callback.PasswordCallback; +import javax.security.auth.callback.UnsupportedCallbackException; +import javax.security.auth.login.LoginContext; +import javax.security.auth.login.LoginException; + +/** + * A class in charge of managing authentication and sessions. + *

+ * Authentication is delegated to various implementations. + * By default, it is handled by Karaf's JAAS implementation, but it is possible + * to override it by using {@link #setAuthService(IAuthService)}. You will HAVE + * TO use this method if you run the REST services outside Karaf. + *

+ *

+ * When the authentication succeeds, a token is generated by this class + * (a random UUID in fact). The token is stored by this class and associated + * with the login time. + *

+ *

+ * Since sessions can be limited in time (depending on admin preferences), + * we can verify on every action that the session is still valid. + *

+ *

+ * To prevent "man in the middle" "attacks, authentication should be + * used along with HTTPS. + *

+ * + * @author Vincent Zurczak - Linagora + */ +public class AuthenticationManager { + + private final ConcurrentHashMap tokenToLoginTime = new ConcurrentHashMap<> (); + private final Logger logger = Logger.getLogger( getClass().getName()); + private final String realm; + + private IAuthService authService; + + + + /** + * Constructor. + * @param realm + */ + public AuthenticationManager( String realm ) { + this.realm = realm; + this.authService = new KarafAuthService(); + this.authService.setRealm( this.realm ); + } + + + /** + * @param authenticater the authService to set + */ + public void setAuthService( IAuthService authService ) { + this.authService = authService; + authService.setRealm( this.realm ); + } + + + /** + * Authenticates a user and creates a new session. + * @param user a user name + * @param pwd a pass word + * @return a token if authentication worked, null if it failed + */ + public String login( String user, String pwd ) { + + String token = null; + try { + this.authService.authenticate( user, pwd ); + + token = UUID.randomUUID().toString(); + Long now = new Date().getTime(); + this.tokenToLoginTime.put( token, now ); + + } catch( LoginException e ) { + this.logger.severe( "Invalid login attempt by user " + user ); + } + + return token; + } + + + /** + * Determines whether a session is valid. + * @param token a token + * @param validityPeriod the validity period for a session (in seconds) + * @return true if the session is valid, false otherwise + */ + public boolean isSessionValid( final String token, int validityPeriod ) { + + boolean valid = false; + Long loginTime = null; + if( token != null ) + loginTime = this.tokenToLoginTime.get( token ); + + if( validityPeriod < 0 ) { + valid = loginTime != null; + + } else if( loginTime != null ) { + long now = new Date().getTime(); + valid = (now - loginTime) <= validityPeriod * 1000; + + // Invalid sessions should be deleted + if( ! valid ) + logout( token ); + } + + return valid; + } + + + /** + * Invalidates a session. + *

+ * No error is thrown if the session was already invalid. + *

+ * + * @param token a token + */ + public void logout( String token ) { + if( token != null ) + this.tokenToLoginTime.remove( token ); + } + + + /** + * An abstraction to manage authentication. + * @author Vincent Zurczak - Linagora + */ + public interface IAuthService { + + /** + * Authenticates someone by user and password. + * @param user a user name + * @param pwd a password + * @throws LoginException if authentication failed + */ + void authenticate( String user, String pwd ) throws LoginException; + + /** + * Sets the REALM to use. + * @param realm a realm name + */ + void setRealm( String realm ); + } + + + /** + * Authentication managed by Apache Karaf. + *

+ * Karaf uses JAAS and by default supports several login modules + * (properties files, databases, LDAP, etc). + *

+ * @author Vincent Zurczak - Linagora + */ + public static class KarafAuthService implements IAuthService { + private String realm; + + + @Override + public void authenticate( String user, String pwd ) throws LoginException { + LoginContext loginCtx = new LoginContext( this.realm, new RoboconfCallbackHandler( user, pwd )); + loginCtx.login(); + } + + @Override + public void setRealm( String realm ) { + this.realm = realm; + } + } + + + /** + * A callback handler for JAAS. + * @author Vincent Zurczak - Linagora + */ + static final class RoboconfCallbackHandler implements CallbackHandler { + private final String username, password; + + + /** + * Constructor. + * @param username + * @param password + */ + public RoboconfCallbackHandler( String username, String password ) { + this.username = username; + this.password = password; + } + + + @Override + public void handle( Callback[] callbacks ) throws IOException, UnsupportedCallbackException { + + for( Callback callback : callbacks ) { + if (callback instanceof NameCallback ) + ((NameCallback) callback).setName( this.username ); + else if( callback instanceof PasswordCallback ) + ((PasswordCallback) callback).setPassword( this.password.toCharArray()); + else + throw new UnsupportedCallbackException( callback ); + } + } + } +} diff --git a/core/roboconf-dm-rest-commons/src/test/java/net/roboconf/dm/rest/commons/security/AuthenticationManagerTest.java b/core/roboconf-dm-rest-commons/src/test/java/net/roboconf/dm/rest/commons/security/AuthenticationManagerTest.java new file mode 100644 index 00000000..028632d0 --- /dev/null +++ b/core/roboconf-dm-rest-commons/src/test/java/net/roboconf/dm/rest/commons/security/AuthenticationManagerTest.java @@ -0,0 +1,134 @@ +/** + * Copyright 2017 Linagora, Université Joseph Fourier, Floralis + * + * The present code is developed in the scope of the joint LINAGORA - + * Université Joseph Fourier - Floralis research program and is designated + * as a "Result" pursuant to the terms and conditions of the LINAGORA + * - Université Joseph Fourier - Floralis research program. Each copyright + * holder of Results enumerated here above fully & independently holds complete + * ownership of the complete Intellectual Property rights applicable to the whole + * of said Results, and may freely exploit it in any manner which does not infringe + * the moral rights of the other copyright holders. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package net.roboconf.dm.rest.commons.security; + +import javax.security.auth.callback.Callback; +import javax.security.auth.callback.LanguageCallback; +import javax.security.auth.callback.NameCallback; +import javax.security.auth.callback.PasswordCallback; +import javax.security.auth.callback.UnsupportedCallbackException; +import javax.security.auth.login.LoginException; + +import org.junit.Assert; +import org.junit.Test; +import org.mockito.Mockito; + +import net.roboconf.dm.rest.commons.security.AuthenticationManager.IAuthService; +import net.roboconf.dm.rest.commons.security.AuthenticationManager.RoboconfCallbackHandler; + +/** + * @author Vincent Zurczak - Linagora + */ +public class AuthenticationManagerTest { + + @Test + public void testAuthenticationChain_success() { + + AuthenticationManager mngr = new AuthenticationManager( "realm" ); + IAuthService authService = Mockito.mock( IAuthService.class ); + mngr.setAuthService( authService ); + + String token = mngr.login( "me", "my password" ); + Assert.assertNotNull( token ); + Assert.assertTrue( mngr.isSessionValid( token, 1 )); + Assert.assertTrue( mngr.isSessionValid( token, -1 )); + + mngr.logout( token ); + Assert.assertFalse( mngr.isSessionValid( token, 1 )); + Assert.assertFalse( mngr.isSessionValid( token, -1 )); + } + + + @Test + public void testAuthenticationChain_failure() throws Exception { + + AuthenticationManager mngr = new AuthenticationManager( "realm" ); + IAuthService authService = Mockito.mock( IAuthService.class ); + Mockito.doThrow( new LoginException( "for test" )).when( authService ).authenticate( Mockito.anyString(), Mockito.anyString()); + mngr.setAuthService( authService ); + + String token = mngr.login( "me", "my password" ); + Assert.assertNull( token ); + Assert.assertFalse( mngr.isSessionValid( token, 1 )); + Assert.assertFalse( mngr.isSessionValid( token, -1 )); + + mngr.logout( token ); + Assert.assertFalse( mngr.isSessionValid( token, 1 )); + Assert.assertFalse( mngr.isSessionValid( token, -1 )); + } + + + @Test + public void testAuthenticationChain_validityPeriodExpired() throws Exception { + + AuthenticationManager mngr = new AuthenticationManager( "realm" ); + IAuthService authService = Mockito.mock( IAuthService.class ); + mngr.setAuthService( authService ); + + String token = mngr.login( "me", "my password" ); + Assert.assertNotNull( token ); + Assert.assertTrue( mngr.isSessionValid( token, 1 )); + Thread.sleep( 1020 ); + Assert.assertFalse( mngr.isSessionValid( token, 1 )); + + // The session was removed, it should not be marked as valid anymore + Assert.assertFalse( mngr.isSessionValid( token, 10 )); + } + + + @Test + public void testAuthenticationChain_withKaraf_butOutsideKaraf() throws Exception { + + AuthenticationManager mngr = new AuthenticationManager( "realm" ); + + String token = mngr.login( "me", "my password" ); + Assert.assertNull( token ); + Assert.assertFalse( mngr.isSessionValid( token, -1 )); + } + + + @Test + public void testRoboconfCallbackHandler_success() throws Exception { + + RoboconfCallbackHandler handler = new RoboconfCallbackHandler( "user", "password" ); + handler.handle( new Callback[] { + new NameCallback( "Username: " ), + new PasswordCallback( "Password: ", false ) + }); + } + + + @Test( expected = UnsupportedCallbackException.class ) + public void testRoboconfCallbackHandler_failure() throws Exception { + + RoboconfCallbackHandler handler = new RoboconfCallbackHandler( "user", "password" ); + handler.handle( new Callback[] { + new NameCallback( "Username: " ), + new PasswordCallback( "Password: ", false ), + new LanguageCallback() + }); + } +}