diff --git a/oap-ws-account-api/src/main/resources/META-INF/oap-ws-roles.yaml b/oap-ws-account-api/src/main/resources/META-INF/oap-ws-roles.yaml index 46f0da30..57cdf124 100644 --- a/oap-ws-account-api/src/main/resources/META-INF/oap-ws-roles.yaml +++ b/oap-ws-account-api/src/main/resources/META-INF/oap-ws-roles.yaml @@ -16,7 +16,9 @@ roles: ban:user, organization:user_apikey, publisher:manage, - campaign:manage,] + campaign:manage, + user:edit_self + ] ORGANIZATION_ADMIN: [ organization:user_passwd, organization:store_user, organization:read, @@ -33,13 +35,15 @@ roles: ban:user, campaign:manage, publisher:manage, - organization:user_apikey + organization:user_apikey, + user:edit_self ] USER: [ user:edit_self, user:passwd, account:read, account:list, campaign:manage, - user:apikey] + user:apikey + ] diff --git a/oap-ws-sso-api/src/main/java/oap/ws/sso/Authenticator.java b/oap-ws-sso-api/src/main/java/oap/ws/sso/Authenticator.java index eb9fb0f3..1190866b 100644 --- a/oap-ws-sso-api/src/main/java/oap/ws/sso/Authenticator.java +++ b/oap-ws-sso-api/src/main/java/oap/ws/sso/Authenticator.java @@ -34,7 +34,7 @@ public interface Authenticator { Result authenticate( String email, Optional tfaCode ); - Result refreshToken( String refreshToken ); + Result refreshToken( String refreshToken, Optional currentOrganization ); Optional authenticateTrusted( String email ); diff --git a/oap-ws-sso-api/src/main/java/oap/ws/sso/interceptor/JWTSecurityInterceptor.java b/oap-ws-sso-api/src/main/java/oap/ws/sso/interceptor/JWTSecurityInterceptor.java index 3d8e2e31..1ecfc45e 100644 --- a/oap-ws-sso-api/src/main/java/oap/ws/sso/interceptor/JWTSecurityInterceptor.java +++ b/oap-ws-sso-api/src/main/java/oap/ws/sso/interceptor/JWTSecurityInterceptor.java @@ -34,6 +34,7 @@ import oap.ws.sso.User; import oap.ws.sso.UserProvider; import oap.ws.sso.WsSecurity; +import org.apache.commons.lang3.StringUtils; import java.util.Arrays; import java.util.List; @@ -124,12 +125,12 @@ private String issuerFromContext( InvocationContext context ) { } private boolean hasRealmMismatchError( String organization, boolean useOrganizationLogin, String realmString ) { - boolean organizationNotNull = organization != null; + boolean organizationNotEmpty = !StringUtils.isEmpty( organization ); boolean realmNotEqualOrganization = !realmString.equals( organization ); boolean realmNotEqualSystem = !SYSTEM.equals( realmString ); boolean organizationNotEqualSystem = !SYSTEM.equals( organization ); - return organizationNotNull && useOrganizationLogin + return organizationNotEmpty && useOrganizationLogin && realmNotEqualOrganization && realmNotEqualSystem && organizationNotEqualSystem; diff --git a/oap-ws-sso/src/main/java/oap/ws/sso/AuthWS.java b/oap-ws-sso/src/main/java/oap/ws/sso/AuthWS.java index cb0d967c..dc60baad 100644 --- a/oap-ws-sso/src/main/java/oap/ws/sso/AuthWS.java +++ b/oap-ws-sso/src/main/java/oap/ws/sso/AuthWS.java @@ -140,15 +140,6 @@ else if( TOKEN_NOT_VALID == result.getFailureValue() ) { return notAuthenticatedResponse( UNAUTHORIZED, "User not found", sessionManager.cookieDomain ); } - @WsMethod( method = GET, path = "/refresh" ) - public Response refreshToken( @WsParam( from = COOKIE ) String refreshToken ) { - var result = authenticator.refreshToken( refreshToken ); - if( result.isSuccess() ) return authenticatedResponse( result.getSuccessValue(), - sessionManager.cookieDomain, sessionManager.cookieExpiration, sessionManager.cookieSecure ); - else - return notAuthenticatedResponse( UNAUTHORIZED, "Token is invalid", sessionManager.cookieDomain ); - } - @WsMethod( method = GET, path = "/logout" ) public Response logout( @WsParam( from = SESSION ) Optional loggedUser, Session session ) { diff --git a/oap-ws-sso/src/main/java/oap/ws/sso/JwtUserAuthenticator.java b/oap-ws-sso/src/main/java/oap/ws/sso/JwtUserAuthenticator.java index ac8b24d7..a6424b52 100644 --- a/oap-ws-sso/src/main/java/oap/ws/sso/JwtUserAuthenticator.java +++ b/oap-ws-sso/src/main/java/oap/ws/sso/JwtUserAuthenticator.java @@ -63,14 +63,14 @@ public Result authenticateWithActiveOrgId if( user.isEmpty() ) { return Result.failure( AuthenticationFailure.UNAUTHENTICATED ); } - return user.filter( u -> validateUserAcess( u, orgId ) ) + return user.filter( u -> validateUserAccess( u, orgId ) ) .map( u -> getAuthenticationTokens( u, orgId ) ) .orElse( Result.failure( AuthenticationFailure.WRONG_ORGANIZATION ) ); } return Result.failure( AuthenticationFailure.TOKEN_NOT_VALID ); } - private boolean validateUserAcess( User user, String orgId ) { + private boolean validateUserAccess( User user, String orgId ) { return user.getRoles().containsKey( orgId ); } @@ -112,17 +112,39 @@ private Authentication generateTokenWithOrgId( User user, String activeOrgId ) { return new Authentication( accessToken, refreshToken, user ); } - @Override - public Result refreshToken( String refreshToken ) { - if( jwtExtractor.verifyToken( refreshToken ) ) { - log.trace( "generating new authentication by refreshToken {} ", refreshToken ); - final Optional authentication = userProvider.getUser( jwtExtractor.getUserEmail( refreshToken ) ).map( user -> - new Authentication( jwtTokenGenerator.generateAccessToken( user ), jwtTokenGenerator.generateRefreshToken( user ), user ) ); - return authentication.>map( Result::success ).orElseGet( () -> Result.failure( AuthenticationFailure.UNAUTHENTICATED ) ); + public Result refreshToken( String refreshToken, Optional orgId ) { + if( !jwtExtractor.verifyToken( refreshToken ) ) { + return Result.failure( AuthenticationFailure.TOKEN_NOT_VALID ); } - return Result.failure( AuthenticationFailure.TOKEN_NOT_VALID ); + return generateAuthentication( refreshToken, orgId ); + } + + private Result generateAuthentication( String refreshToken, Optional orgId ) { + var userEmail = jwtExtractor.getUserEmail( refreshToken ); + var user = userProvider.getUser( userEmail ); + + if( user.isEmpty() ) { + return Result.failure( AuthenticationFailure.UNAUTHENTICATED ); + } + return buildAuthentication( user.get(), orgId ); } + private Result buildAuthentication( User user, Optional orgId ) { + if( orgId.isPresent() && !user.getRoles().containsKey( orgId.get() ) ) { + return Result.failure( AuthenticationFailure.UNAUTHENTICATED ); + } + String activeOrgId = orgId.orElse( user.getDefaultOrganization().orElse( "" ) ); + + var authentication = new Authentication( + jwtTokenGenerator.generateAccessTokenWithActiveOrgId( user, activeOrgId ), + jwtTokenGenerator.generateRefreshToken( user ), + user + ); + + return Result.success( authentication ); + } + + @Override public Optional authenticateTrusted( String email ) { return userProvider.getUser( email ) diff --git a/oap-ws-sso/src/main/java/oap/ws/sso/RefreshWS.java b/oap-ws-sso/src/main/java/oap/ws/sso/RefreshWS.java new file mode 100644 index 00000000..10e37836 --- /dev/null +++ b/oap-ws-sso/src/main/java/oap/ws/sso/RefreshWS.java @@ -0,0 +1,63 @@ +/* + * The MIT License (MIT) + * + * Copyright (c) Open Application Platform Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE 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 SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package oap.ws.sso; + +import lombok.extern.slf4j.Slf4j; +import oap.ws.Response; +import oap.ws.SessionManager; +import oap.ws.WsMethod; +import oap.ws.WsParam; + +import java.util.Optional; + +import static oap.http.Http.StatusCode.UNAUTHORIZED; +import static oap.http.server.nio.HttpServerExchange.HttpMethod.GET; +import static oap.ws.WsParam.From.COOKIE; +import static oap.ws.WsParam.From.QUERY; +import static oap.ws.sso.SSO.authenticatedResponse; +import static oap.ws.sso.SSO.notAuthenticatedResponse; + +@Slf4j +@SuppressWarnings( "unused" ) +public class RefreshWS { + + private final Authenticator authenticator; + private final SessionManager sessionManager; + + public RefreshWS( Authenticator authenticator, SessionManager sessionManager ) { + this.authenticator = authenticator; + this.sessionManager = sessionManager; + } + + @WsMethod( method = GET, path = "/" ) + public Response refreshToken( @WsParam( from = COOKIE ) String refreshToken, + @WsParam( from = QUERY ) Optional organizationId ) { + var result = authenticator.refreshToken( refreshToken, organizationId ); + if( result.isSuccess() ) return authenticatedResponse( result.getSuccessValue(), + sessionManager.cookieDomain, sessionManager.cookieExpiration, sessionManager.cookieSecure ); + else + return notAuthenticatedResponse( UNAUTHORIZED, "Token is invalid", sessionManager.cookieDomain ); + } +} diff --git a/oap-ws-sso/src/main/resources/META-INF/oap-module.conf b/oap-ws-sso/src/main/resources/META-INF/oap-module.conf index e2a1e721..9863f75c 100644 --- a/oap-ws-sso/src/main/resources/META-INF/oap-module.conf +++ b/oap-ws-sso/src/main/resources/META-INF/oap-module.conf @@ -53,4 +53,20 @@ services { ] } } + + refresh-ws { + implementation = oap.ws.sso.RefreshWS + parameters { + authenticator = modules.this.oap-ws-sso-authenticator + sessionManager = modules.oap-ws.session-manager + oauthService = modules.oap-ws-account-social.oauth-service + } + ws-service { + path = refresh + sessionAware = true + interceptors = [ + oap-ws-sso-api.oap-ws-sso-throttle-login-interceptor + ] + } + } } diff --git a/oap-ws-testing/src/test/java/oap/ws/sso/AuthWSTest.java b/oap-ws-testing/src/test/java/oap/ws/sso/AuthWSTest.java index c9ea3075..00e0c8e5 100644 --- a/oap-ws-testing/src/test/java/oap/ws/sso/AuthWSTest.java +++ b/oap-ws-testing/src/test/java/oap/ws/sso/AuthWSTest.java @@ -62,7 +62,7 @@ public void loginWhoami() { @Test public void loginResponseTest() { - final TestUser testUser = userProvider().addUser( new TestUser( "admin@admin.com", "pass", __( "r1", "ADMIN" ) ) ); + userProvider().addUser( new TestUser( "admin@admin.com", "pass", __( "r1", "ADMIN" ) ) ); assertPost( httpUrl( "/auth/login" ), "{ \"email\":\"admin@admin.com\",\"password\": \"pass\"}" ) .hasCode( Http.StatusCode.OK ).satisfies( resp -> { Map response = Binder.json.unmarshal( Map.class, resp.contentString() ); diff --git a/oap-ws-testing/src/test/java/oap/ws/sso/IntegratedTest.java b/oap-ws-testing/src/test/java/oap/ws/sso/IntegratedTest.java index 58505602..c409e0ad 100644 --- a/oap-ws-testing/src/test/java/oap/ws/sso/IntegratedTest.java +++ b/oap-ws-testing/src/test/java/oap/ws/sso/IntegratedTest.java @@ -62,6 +62,11 @@ protected TestUserProvider userProvider() { return kernelFixture.service( "oap-ws-sso-test", TestUserProvider.class ); } + protected JWTExtractor tokenExtractor() { + return kernelFixture.service( "oap-ws-sso-api", JWTExtractor.class ); + } + + @AfterMethod public void afterMethod() { assertLogout(); diff --git a/oap-ws-testing/src/test/java/oap/ws/sso/RefreshWSTest.java b/oap-ws-testing/src/test/java/oap/ws/sso/RefreshWSTest.java new file mode 100644 index 00000000..621b3b5b --- /dev/null +++ b/oap-ws-testing/src/test/java/oap/ws/sso/RefreshWSTest.java @@ -0,0 +1,119 @@ +/* + * The MIT License (MIT) + * + * Copyright (c) Open Application Platform Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE 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 SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package oap.ws.sso; + + +import oap.json.Binder; +import oap.ws.sso.interceptor.ThrottleLoginInterceptor; +import org.testng.annotations.BeforeMethod; +import org.testng.annotations.Test; + +import java.util.Map; + +import static oap.http.Http.StatusCode.OK; +import static oap.http.Http.StatusCode.UNAUTHORIZED; +import static oap.http.testng.HttpAsserts.assertGet; +import static oap.http.testng.HttpAsserts.assertPost; +import static oap.http.testng.HttpAsserts.httpUrl; +import static oap.util.Pair.__; +import static org.testng.Assert.assertNotEquals; +import static org.testng.AssertJUnit.assertEquals; +import static org.testng.AssertJUnit.assertTrue; + +public class RefreshWSTest extends IntegratedTest { + + + @BeforeMethod + public void beforeMethod() { + kernelFixture.service( "oap-ws-sso-api", ThrottleLoginInterceptor.class ).delay = -1; + } + + @Test + public void refreshResponseTest() throws InterruptedException { + userProvider().addUser( new TestUser( "admin@admin.com", "pass", __( "r1", "ADMIN" ) ) ); + final String[] accessToken = new String[1]; + final String[] refreshToken = new String[1]; + assertPost( httpUrl( "/auth/login" ), "{ \"email\":\"admin@admin.com\",\"password\": \"pass\"}" ) + .hasCode( OK ).satisfies( resp -> { + Map response = Binder.json.unmarshal( Map.class, resp.contentString() ); + assertTrue( response.containsKey( "accessToken" ) ); + assertTrue( response.containsKey( "refreshToken" ) ); + accessToken[0] = response.get( "accessToken" ); + refreshToken[0] = response.get( "refreshToken" ); + } ); + Thread.sleep( 2000L ); + assertGet( httpUrl( "/refresh" ), Map.of( "organizationId", "r1" ), Map.of() ).satisfies( resp -> { + Map response = Binder.json.unmarshal( Map.class, resp.contentString() ); + assertTrue( response.containsKey( "accessToken" ) ); + assertNotEquals( response.get( "accessToken" ), accessToken[0] ); + assertTrue( response.containsKey( "refreshToken" ) ); + System.out.println( "RefreshToken in response:" + response.get( "refreshToken" ) ); + System.out.println( "RefreshToken previous:" + refreshToken[0] ); + assertNotEquals( response.get( "refreshToken" ), refreshToken[0] ); + } ); + } + + @Test + public void refreshResponseWithEWrongOrgIdTest() { + userProvider().addUser( new TestUser( "admin@admin.com", "pass", __( "r1", "ADMIN" ) ) ); + assertPost( httpUrl( "/auth/login" ), "{ \"email\":\"admin@admin.com\",\"password\": \"pass\"}" ) + .hasCode( OK ).satisfies( resp -> { + Map response = Binder.json.unmarshal( Map.class, resp.contentString() ); + assertTrue( response.containsKey( "accessToken" ) ); + assertTrue( response.containsKey( "refreshToken" ) ); + } ); + assertGet( httpUrl( "/refresh" ), Map.of( "organizationId", "r2" ), Map.of() ).hasCode( UNAUTHORIZED ); + } + + @Test + public void refreshResponseWithoutOrgIdTest() { + final TestUser testUser = userProvider().addUser( new TestUser( "admin@admin.com", "pass", Map.of( "r1", "ADMIN", "r2", "USER" ) ) ); + testUser.defaultOrganization = "r2"; + final String[] refreshToken = new String[1]; + assertPost( httpUrl( "/auth/login" ), "{ \"email\":\"admin@admin.com\",\"password\": \"pass\"}" ) + .hasCode( OK ).satisfies( resp -> { + Map response = Binder.json.unmarshal( Map.class, resp.contentString() ); + assertTrue( response.containsKey( "accessToken" ) ); + assertTrue( response.containsKey( "refreshToken" ) ); + refreshToken[0] = response.get( "refreshToken" ); + } ); + assertGet( httpUrl( "/refresh" ) ).hasCode( OK ).satisfies( resp -> { + Map response = Binder.json.unmarshal( Map.class, resp.contentString() ); + assertTrue( response.containsKey( "accessToken" ) ); + assertTrue( response.containsKey( "refreshToken" ) ); + final String organizationId = tokenExtractor().getOrganizationId( response.get( "accessToken" ) ); + assertEquals( "r2", organizationId ); + } ); + } + + + @Test + public void refreshWithExpiredRefreshToken() { + final String expiredToken = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyIjoidGVzdEB0ZXN0LmlvIiwiaXNzIjoiPGNoYW5nZSBtZT4iLCJleHAiOjE2OTY5MjYzODJ9.VZdySTBEThoTOwB73JMNpBgCjaXGvlmes8_13Bs3dXg"; + Map headers = Map.of( "Cookie", "refreshToken=" + expiredToken ); + assertGet( httpUrl( "/refresh" ), Map.of(), headers ).hasCode( 401 ); + } + +} diff --git a/pom.xml b/pom.xml index bcb3a97c..2e027405 100644 --- a/pom.xml +++ b/pom.xml @@ -71,7 +71,7 @@ - 21.1.0 + 21.1.1 21.1.0 21.0.0