diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/IamLoginService.java b/iam-login-service/src/main/java/it/infn/mw/iam/IamLoginService.java index aa5a5004e..64f9c071f 100644 --- a/iam-login-service/src/main/java/it/infn/mw/iam/IamLoginService.java +++ b/iam-login-service/src/main/java/it/infn/mw/iam/IamLoginService.java @@ -17,6 +17,7 @@ import org.mitre.discovery.web.DiscoveryEndpoint; import org.mitre.oauth2.web.CorsFilter; +import org.mitre.oauth2.web.DeviceEndpoint; import org.mitre.oauth2.web.OAuthConfirmationController; import org.mitre.openid.connect.web.DynamicClientRegistrationEndpoint; import org.mitre.openid.connect.web.JWKSetPublishingEndpoint; @@ -77,7 +78,9 @@ @ComponentScan.Filter(type=FilterType.ASSIGNABLE_TYPE, value=CorsFilter.class), @ComponentScan.Filter(type=FilterType.ASSIGNABLE_TYPE, - value=OAuthConfirmationController.class) + value=OAuthConfirmationController.class), + @ComponentScan.Filter(type=FilterType.ASSIGNABLE_TYPE, + value=DeviceEndpoint.class) }) @EnableCaching @EnableAutoConfiguration( diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/config/IamConfig.java b/iam-login-service/src/main/java/it/infn/mw/iam/config/IamConfig.java index 7aafae459..14996494f 100644 --- a/iam-login-service/src/main/java/it/infn/mw/iam/config/IamConfig.java +++ b/iam-login-service/src/main/java/it/infn/mw/iam/config/IamConfig.java @@ -47,6 +47,7 @@ import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.oauth2.provider.ClientDetailsService; +import org.springframework.security.oauth2.provider.approval.UserApprovalHandler; import org.springframework.security.oauth2.provider.code.AuthorizationCodeServices; import org.springframework.security.oauth2.provider.token.TokenEnhancer; @@ -55,6 +56,7 @@ import it.infn.mw.iam.api.account.AccountUtils; import it.infn.mw.iam.authn.ExternalAuthenticationInfoProcessor; import it.infn.mw.iam.core.oauth.IamIntrospectionResultAssembler; +import it.infn.mw.iam.core.oauth.IamUserApprovalHandler; import it.infn.mw.iam.core.oauth.attributes.AttributeMapHelper; import it.infn.mw.iam.core.oauth.profile.IamTokenEnhancer; import it.infn.mw.iam.core.oauth.profile.JWTProfile; @@ -307,6 +309,11 @@ ServletRegistrationBean h2Console() { UsernameValidator usernameRegExpValidator() { return new UsernameValidator(); } + + @Bean + UserApprovalHandler iamUserApprovalHandler() { + return new IamUserApprovalHandler(); + } @Bean(destroyMethod = "shutdown") public ScheduledExecutorService taskScheduler() { diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/config/MitreServicesConfig.java b/iam-login-service/src/main/java/it/infn/mw/iam/config/MitreServicesConfig.java index 32fa1eaf6..ecee7c223 100644 --- a/iam-login-service/src/main/java/it/infn/mw/iam/config/MitreServicesConfig.java +++ b/iam-login-service/src/main/java/it/infn/mw/iam/config/MitreServicesConfig.java @@ -59,7 +59,6 @@ import org.mitre.openid.connect.service.impl.MatchLoginHintsAgainstUsers; import org.mitre.openid.connect.service.impl.UUIDPairwiseIdentiferService; import org.mitre.openid.connect.token.ConnectTokenEnhancer; -import org.mitre.openid.connect.token.TofuUserApprovalHandler; import org.mitre.openid.connect.web.AuthenticationTimeStamper; import org.mitre.openid.connect.web.ServerConfigInterceptor; import org.mitre.uma.service.ResourceSetService; @@ -70,7 +69,6 @@ import org.springframework.context.annotation.Configuration; import org.springframework.security.oauth2.provider.OAuth2RequestFactory; import org.springframework.security.oauth2.provider.OAuth2RequestValidator; -import org.springframework.security.oauth2.provider.approval.UserApprovalHandler; import org.springframework.security.oauth2.provider.endpoint.RedirectResolver; import org.springframework.security.oauth2.provider.error.OAuth2AuthenticationEntryPoint; import org.springframework.security.oauth2.provider.token.TokenEnhancer; @@ -129,8 +127,9 @@ public ConfigurationPropertiesBean config(IamProperties properties) { config.setForceHttps(false); config.setLocale(Locale.ENGLISH); - - config.setAllowCompleteDeviceCodeUri(properties.getDeviceCode().getAllowCompleteVerificationUri()); + + config + .setAllowCompleteDeviceCodeUri(properties.getDeviceCode().getAllowCompleteVerificationUri()); return config; } @@ -162,12 +161,6 @@ OAuth2RequestValidator requestValidator(ScopeMatcherRegistry registry) { return new ScopeMatcherOAuthRequestValidator(registry); } - @Bean - UserApprovalHandler tofuApprovalHandler() { - - return new TofuUserApprovalHandler(); - } - @Bean OAuth2RequestFactory requestFactory(IamScopeFilter scopeFilter, JWTProfileResolver profileResolver) { @@ -203,8 +196,7 @@ public ServerConfigInterceptor serverConfigInterceptor() { public FilterRegistrationBean disabledMitreFilterRegistration( AuthorizationRequestFilter f) { - FilterRegistrationBean b = - new FilterRegistrationBean<>(f); + FilterRegistrationBean b = new FilterRegistrationBean<>(f); b.setEnabled(false); return b; } diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/core/oauth/IamDeviceEndpointController.java b/iam-login-service/src/main/java/it/infn/mw/iam/core/oauth/IamDeviceEndpointController.java new file mode 100644 index 000000000..c26ce218d --- /dev/null +++ b/iam-login-service/src/main/java/it/infn/mw/iam/core/oauth/IamDeviceEndpointController.java @@ -0,0 +1,333 @@ +/** + * Copyright (c) Istituto Nazionale di Fisica Nucleare (INFN). 2016-2021 + * + * 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 it.infn.mw.iam.core.oauth; + +import static it.infn.mw.iam.core.oauth.IamOauthRequestParameters.APPROVAL_ATTRIBUTE_KEY; +import static it.infn.mw.iam.core.oauth.IamOauthRequestParameters.APPROVE_DEVICE_PAGE; +import static it.infn.mw.iam.core.oauth.IamOauthRequestParameters.DEVICE_APPROVED_PAGE; +import static it.infn.mw.iam.core.oauth.IamOauthRequestParameters.DEVICE_CODE_URL; +import static it.infn.mw.iam.core.oauth.IamOauthRequestParameters.ERROR_STRING; +import static it.infn.mw.iam.core.oauth.IamOauthRequestParameters.REMEMBER_PARAMETER_KEY; +import static it.infn.mw.iam.core.oauth.IamOauthRequestParameters.REQUEST_USER_CODE_STRING; +import static it.infn.mw.iam.core.oauth.IamOauthRequestParameters.USER_CODE_URL; +import static org.mitre.openid.connect.request.ConnectRequestParameters.APPROVED_SITE; + +import java.net.URI; +import java.net.URISyntaxException; +import java.util.Collection; +import java.util.Date; +import java.util.HashMap; +import java.util.Map; +import java.util.Set; + +import javax.servlet.http.HttpSession; + +import org.apache.http.client.utils.URIBuilder; +import org.mitre.oauth2.exception.DeviceCodeCreationException; +import org.mitre.oauth2.model.ClientDetailsEntity; +import org.mitre.oauth2.model.DeviceCode; +import org.mitre.oauth2.model.SystemScope; +import org.mitre.oauth2.service.ClientDetailsEntityService; +import org.mitre.oauth2.service.DeviceCodeService; +import org.mitre.oauth2.service.SystemScopeService; +import org.mitre.oauth2.token.DeviceTokenGranter; +import org.mitre.openid.connect.config.ConfigurationPropertiesBean; +import org.mitre.openid.connect.view.HttpCodeView; +import org.mitre.openid.connect.view.JsonEntityView; +import org.mitre.openid.connect.view.JsonErrorView; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.Authentication; +import org.springframework.security.oauth2.common.exceptions.InvalidClientException; +import org.springframework.security.oauth2.common.util.OAuth2Utils; +import org.springframework.security.oauth2.provider.AuthorizationRequest; +import org.springframework.security.oauth2.provider.OAuth2Authentication; +import org.springframework.security.oauth2.provider.OAuth2Request; +import org.springframework.security.oauth2.provider.approval.UserApprovalHandler; +import org.springframework.security.oauth2.provider.request.DefaultOAuth2RequestFactory; +import org.springframework.stereotype.Controller; +import org.springframework.ui.ModelMap; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestMethod; +import org.springframework.web.bind.annotation.RequestParam; + +import it.infn.mw.iam.api.account.AccountUtils; +import it.infn.mw.iam.api.common.error.NoSuchAccountError; +import it.infn.mw.iam.core.oauth.scope.pdp.ScopePolicyPDP; +import it.infn.mw.iam.persistence.model.IamAccount; + +@SuppressWarnings("deprecation") +@Controller +public class IamDeviceEndpointController { + + public static final Logger logger = LoggerFactory.getLogger(IamDeviceEndpointController.class); + + @Autowired + private ClientDetailsEntityService clientEntityService; + + @Autowired + private SystemScopeService scopeService; + + @Autowired + private ConfigurationPropertiesBean config; + + @Autowired + private DeviceCodeService deviceCodeService; + + @Autowired + private DefaultOAuth2RequestFactory oAuth2RequestFactory; + + @Autowired + private UserApprovalHandler iamUserApprovalHandler; + + @Autowired + private IamUserApprovalUtils userApprovalUtils; + + @Autowired + private AccountUtils accountUtils; + + @Autowired + private ScopePolicyPDP pdp; + + @RequestMapping(value = "/" + DEVICE_CODE_URL, method = RequestMethod.POST, + consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE, + produces = MediaType.APPLICATION_JSON_VALUE) + public String requestDeviceCode(@RequestParam("client_id") String clientId, + @RequestParam(name = "scope", required = false) String scope, Map parameters, + ModelMap model) { + + ClientDetailsEntity client; + try { + client = clientEntityService.loadClientByClientId(clientId); + checkAuthzGrant(client); + + } catch (IllegalArgumentException e) { + logger.error("IllegalArgumentException was thrown when attempting to load client", e); + model.put(HttpCodeView.CODE, HttpStatus.BAD_REQUEST); + return HttpCodeView.VIEWNAME; + } + + Set requestedScopes = OAuth2Utils.parseParameterList(scope); + Set allowedScopes = client.getScope(); + + if (!scopeService.scopesMatch(allowedScopes, requestedScopes)) { + logger.error("Client asked for {} but is allowed {}", requestedScopes, allowedScopes); + model.put(HttpCodeView.CODE, HttpStatus.BAD_REQUEST); + model.put(JsonErrorView.ERROR, "invalid_scope"); + return JsonErrorView.VIEWNAME; + } + + try { + DeviceCode dc = deviceCodeService.createNewDeviceCode(requestedScopes, client, parameters); + + Map response = new HashMap<>(); + response.put("device_code", dc.getDeviceCode()); + response.put("user_code", dc.getUserCode()); + response.put("verification_uri", config.getIssuer() + USER_CODE_URL); + if (client.getDeviceCodeValiditySeconds() != null) { + response.put("expires_in", client.getDeviceCodeValiditySeconds()); + } + + if (config.isAllowCompleteDeviceCodeUri()) { + URI verificationUriComplete = new URIBuilder(config.getIssuer() + USER_CODE_URL) + .addParameter("user_code", dc.getUserCode()) + .build(); + + response.put("verification_uri_complete", verificationUriComplete.toString()); + } + + model.put(JsonEntityView.ENTITY, response); + + + return JsonEntityView.VIEWNAME; + } catch (DeviceCodeCreationException dcce) { + + model.put(HttpCodeView.CODE, HttpStatus.BAD_REQUEST); + model.put(JsonErrorView.ERROR, dcce.getError()); + model.put(JsonErrorView.ERROR_MESSAGE, dcce.getMessage()); + + return JsonErrorView.VIEWNAME; + } catch (URISyntaxException use) { + logger + .error("unable to build verification_uri_complete due to wrong syntax of uri components"); + model.put(HttpCodeView.CODE, HttpStatus.INTERNAL_SERVER_ERROR); + + return HttpCodeView.VIEWNAME; + } + + } + + @PreAuthorize("hasRole('ROLE_USER')") + @RequestMapping(value = "/" + USER_CODE_URL, method = RequestMethod.GET) + public String requestUserCode( + @RequestParam(value = "user_code", required = false) String userCode, ModelMap model, + HttpSession session, Authentication authn) { + + if (!config.isAllowCompleteDeviceCodeUri() || userCode == null) { + return REQUEST_USER_CODE_STRING; + } else { + + return readUserCode(userCode, model, session, authn); + } + } + + @PreAuthorize("hasRole('ROLE_USER')") + @RequestMapping(value = "/" + USER_CODE_URL + "/verify", method = RequestMethod.POST) + public String readUserCode(@RequestParam("user_code") String userCode, ModelMap model, + HttpSession session, Authentication authn) { + + DeviceCode dc = deviceCodeService.lookUpByUserCode(userCode); + + if (dc == null) { + model.addAttribute(ERROR_STRING, "noUserCode"); + return REQUEST_USER_CODE_STRING; + } + + if (dc.getExpiration() != null && dc.getExpiration().before(new Date())) { + model.addAttribute(ERROR_STRING, "expiredUserCode"); + return REQUEST_USER_CODE_STRING; + } + + if (dc.isApproved()) { + model.addAttribute(ERROR_STRING, "userCodeAlreadyApproved"); + return REQUEST_USER_CODE_STRING; + } + + ClientDetailsEntity client = clientEntityService.loadClientByClientId(dc.getClientId()); + + model.put("client", client); + + AuthorizationRequest authorizationRequest = + oAuth2RequestFactory.createAuthorizationRequest(dc.getRequestParameters()); + + Set filteredScopes = filterScopes(scopeService.fromStrings(dc.getScope()), authn); + filteredScopes = userApprovalUtils.sortScopes(scopeService.fromStrings(filteredScopes)); + + authorizationRequest.setScope(filteredScopes); + authorizationRequest.setClientId(client.getClientId()); + + iamUserApprovalHandler.checkForPreApproval(authorizationRequest, authn); + + if (authorizationRequest.getExtensions().get(APPROVED_SITE) != null + || authorizationRequest.isApproved()) { + + model.addAttribute(APPROVAL_ATTRIBUTE_KEY, true); + return DEVICE_APPROVED_PAGE; + } + + setModelForConsentPage(model, authn, dc, filteredScopes, client); + + session.setAttribute("authorizationRequest", authorizationRequest); + session.setAttribute("deviceCode", dc); + + return APPROVE_DEVICE_PAGE; + } + + @PreAuthorize("hasRole('ROLE_USER')") + @RequestMapping(value = "/" + USER_CODE_URL + "/approve", method = RequestMethod.POST) + public String approveDevice(@RequestParam("user_code") String userCode, + @RequestParam(value = OAuth2Utils.USER_OAUTH_APPROVAL) Boolean approve, + @RequestParam(value = REMEMBER_PARAMETER_KEY, required = false) String remember, + ModelMap model, Authentication auth, HttpSession session) { + + AuthorizationRequest authorizationRequest = + (AuthorizationRequest) session.getAttribute("authorizationRequest"); + DeviceCode dc = (DeviceCode) session.getAttribute("deviceCode"); + + if (!dc.getUserCode().equals(userCode)) { + model.addAttribute(ERROR_STRING, "userCodeMismatch"); + return REQUEST_USER_CODE_STRING; + } + + if (dc.getExpiration() != null && dc.getExpiration().before(new Date())) { + model.addAttribute(ERROR_STRING, "expiredUserCode"); + return REQUEST_USER_CODE_STRING; + } + + ClientDetailsEntity client = clientEntityService.loadClientByClientId(dc.getClientId()); + model.put("client", client); + + if (!approve) { + model.addAttribute(APPROVAL_ATTRIBUTE_KEY, false); + return DEVICE_APPROVED_PAGE; + } + + OAuth2Request o2req = oAuth2RequestFactory.createOAuth2Request(authorizationRequest); + OAuth2Authentication o2Auth = new OAuth2Authentication(o2req, auth); + + deviceCodeService.approveDeviceCode(dc, o2Auth); + + setAuthzRequestAfterApproval(authorizationRequest, remember, approve); + iamUserApprovalHandler.updateAfterApproval(authorizationRequest, o2Auth); + + model.put(APPROVAL_ATTRIBUTE_KEY, true); + + return DEVICE_APPROVED_PAGE; + } + + private void checkAuthzGrant(ClientDetailsEntity client) { + Collection authorizedGrantTypes = client.getAuthorizedGrantTypes(); + if (authorizedGrantTypes != null && !authorizedGrantTypes.isEmpty() + && !authorizedGrantTypes.contains(DeviceTokenGranter.GRANT_TYPE)) { + throw new InvalidClientException("Unauthorized grant type: " + DeviceTokenGranter.GRANT_TYPE); + } + } + + private Set filterScopes(Set scopes, Authentication authentication) { + + IamAccount account = accountUtils.getAuthenticatedUserAccount(authentication) + .orElseThrow(() -> NoSuchAccountError.forUsername(authentication.getName())); + + return pdp.filterScopes(scopeService.toStrings(scopes), account); + } + + private void setModelForConsentPage(ModelMap model, Authentication authn, DeviceCode dc, + Set scopes, ClientDetailsEntity client) { + + model.put("dc", dc); + model.put("scopes", scopeService.fromStrings(scopes)); + model.put("claims", userApprovalUtils.claimsForScopes(authn, scopeService.fromStrings(scopes))); + + Integer count = userApprovalUtils.approvedSiteCount(client.getClientId()); + + model.put("count", count); + model.put("gras", userApprovalUtils.isSafeClient(count, client.getCreatedAt())); + model.put("contacts", userApprovalUtils.getClientContactsAsString(client.getContacts())); + + // just for tests validation + model.put("scope", OAuth2Utils.formatParameterList(scopes)); + } + + private void setAuthzRequestAfterApproval(AuthorizationRequest authorizationRequest, + String remember, Boolean approve) { + + Map approvalParameters = new HashMap<>(); + + approvalParameters.put(REMEMBER_PARAMETER_KEY, remember); + approvalParameters.put(OAuth2Utils.USER_OAUTH_APPROVAL, approve.toString()); + + Set scopes = authorizationRequest.getScope(); + + scopes.forEach(s -> approvalParameters.put(OAuth2Utils.SCOPE_PREFIX + s, "true")); + + authorizationRequest.setApprovalParameters(approvalParameters); + } + +} diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/core/oauth/IamOAuthConfirmationController.java b/iam-login-service/src/main/java/it/infn/mw/iam/core/oauth/IamOAuthConfirmationController.java index 970924df1..dd7ae45a5 100644 --- a/iam-login-service/src/main/java/it/infn/mw/iam/core/oauth/IamOAuthConfirmationController.java +++ b/iam-login-service/src/main/java/it/infn/mw/iam/core/oauth/IamOAuthConfirmationController.java @@ -15,26 +15,22 @@ */ package it.infn.mw.iam.core.oauth; +import static it.infn.mw.iam.core.oauth.IamOauthRequestParameters.APPROVE_AUTHZ_PAGE; +import static it.infn.mw.iam.core.oauth.IamOauthRequestParameters.AUTHZ_CODE_URL; +import static it.infn.mw.iam.core.oauth.IamOauthRequestParameters.ERROR_STRING; +import static it.infn.mw.iam.core.oauth.IamOauthRequestParameters.STATE_PARAMETER_KEY; import static org.mitre.openid.connect.request.ConnectRequestParameters.PROMPT; import static org.mitre.openid.connect.request.ConnectRequestParameters.PROMPT_SEPARATOR; import java.net.URISyntaxException; -import java.util.Date; -import java.util.HashMap; -import java.util.LinkedHashSet; import java.util.List; import java.util.Map; import java.util.Set; import org.apache.http.client.utils.URIBuilder; import org.mitre.oauth2.model.ClientDetailsEntity; -import org.mitre.oauth2.model.SystemScope; import org.mitre.oauth2.service.ClientDetailsEntityService; import org.mitre.oauth2.service.SystemScopeService; -import org.mitre.openid.connect.model.UserInfo; -import org.mitre.openid.connect.service.ScopeClaimTranslationService; -import org.mitre.openid.connect.service.StatsService; -import org.mitre.openid.connect.service.UserInfoService; import org.mitre.openid.connect.view.HttpCodeView; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -43,6 +39,7 @@ import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.Authentication; import org.springframework.security.oauth2.common.exceptions.OAuth2Exception; +import org.springframework.security.oauth2.common.util.OAuth2Utils; import org.springframework.security.oauth2.provider.AuthorizationRequest; import org.springframework.security.oauth2.provider.endpoint.RedirectResolver; import org.springframework.stereotype.Controller; @@ -52,74 +49,37 @@ import org.springframework.web.bind.annotation.SessionAttributes; import org.springframework.web.bind.support.SessionStatus; -import com.google.common.base.Joiner; import com.google.common.base.Splitter; import com.google.common.base.Strings; -import com.google.common.collect.Sets; -import com.google.gson.JsonObject; -import it.infn.mw.iam.api.account.AccountUtils; -import it.infn.mw.iam.api.common.NoSuchAccountError; -import it.infn.mw.iam.core.oauth.scope.pdp.ScopePolicyPDP; -import it.infn.mw.iam.persistence.model.IamAccount; -/** - * @author jricher - * - */ @SuppressWarnings("deprecation") @Controller @SessionAttributes("authorizationRequest") public class IamOAuthConfirmationController { - @Autowired private ClientDetailsEntityService clientService; @Autowired private SystemScopeService scopeService; - @Autowired - private ScopeClaimTranslationService scopeClaimTranslationService; - - @Autowired - private UserInfoService userInfoService; - - @Autowired - private StatsService statsService; - @Autowired private RedirectResolver redirectResolver; @Autowired - private ScopePolicyPDP pdp; - - @Autowired - private AccountUtils accountUtils; - + private IamUserApprovalUtils userApprovalUtils; - /** - * Logger for this class - */ private static final Logger logger = LoggerFactory.getLogger(IamOAuthConfirmationController.class); - public IamOAuthConfirmationController() { - - } - - public IamOAuthConfirmationController(ClientDetailsEntityService clientService) { - this.clientService = clientService; - } @PreAuthorize("hasRole('ROLE_USER')") - @RequestMapping(path = "/oauth/confirm_access", method = RequestMethod.GET) + @RequestMapping(path = AUTHZ_CODE_URL, method = RequestMethod.GET) public String confimAccess(Map model, @ModelAttribute("authorizationRequest") AuthorizationRequest authRequest, Authentication authUser, SessionStatus status) { - // Check the "prompt" parameter to see if we need to do special processing - String prompt = (String) authRequest.getExtensions().get(PROMPT); List prompts = Splitter.on(PROMPT_SEPARATOR).splitToList(Strings.nullToEmpty(prompt)); ClientDetailsEntity client = null; @@ -144,17 +104,15 @@ public String confimAccess(Map model, } if (prompts.contains("none")) { - // if we've got a redirect URI then we'll send it String url = redirectResolver.resolveRedirect(authRequest.getRedirectUri(), client); try { URIBuilder uriBuilder = new URIBuilder(url); - uriBuilder.addParameter("error", "interaction_required"); + uriBuilder.addParameter(ERROR_STRING, "interaction_required"); if (!Strings.isNullOrEmpty(authRequest.getState())) { - uriBuilder.addParameter("state", authRequest.getState()); // copy the state parameter if - // one was given + uriBuilder.addParameter(STATE_PARAMETER_KEY, authRequest.getState()); } status.setComplete(); @@ -167,97 +125,38 @@ public String confimAccess(Map model, } } - model.put("auth_request", authRequest); model.put("client", client); - String redirectUri = authRequest.getRedirectUri(); - - model.put("redirect_uri", redirectUri); - - - // pre-process the scopes - Set scopes = scopeService.fromStrings(authRequest.getScope()); + // the authorization request already contains PDP filtered + // scopes among the request parameters due to the + // IamOAuth2RequestFactory.createAuthorizationRequest() object + Set scopes = + OAuth2Utils.parseParameterList(authRequest.getRequestParameters().get("scope")); + scopes = userApprovalUtils.sortScopes(scopeService.fromStrings(scopes)); - Set sortedScopes = new LinkedHashSet<>(scopes.size()); - Set systemScopes = scopeService.getAll(); + authRequest.setScope(scopes); - // filter requested scopes according to the scope policy - IamAccount account = accountUtils.getAuthenticatedUserAccount(authUser) - .orElseThrow(() -> NoSuchAccountError.forUsername(authUser.getName())); + setModelForConsentPage(model, authRequest, authUser, client); - Set filteredScopes = pdp.filterScopes(scopeService.toStrings(scopes), account); - - // sort scopes for display based on the inherent order of system scopes - for (SystemScope s : systemScopes) { - if (scopeService.fromStrings(filteredScopes).contains(s)) { - sortedScopes.add(s); - } - } - - // add in any scopes that aren't system scopes to the end of the list - sortedScopes.addAll(Sets.difference(scopes, systemScopes)); - - model.put("scopes", sortedScopes); - - authRequest.setScope(scopeService.toStrings(sortedScopes)); - - // get the userinfo claims for each scope - UserInfo user = userInfoService.getByUsername(authUser.getName()); - Map> claimsForScopes = new HashMap<>(); - if (user != null) { - JsonObject userJson = user.toJson(); + return APPROVE_AUTHZ_PAGE; + } - for (SystemScope systemScope : sortedScopes) { - Map claimValues = new HashMap<>(); + private void setModelForConsentPage(Map model, AuthorizationRequest authRequest, + Authentication authUser, ClientDetailsEntity client) { - Set claims = scopeClaimTranslationService.getClaimsForScope(systemScope.getValue()); - for (String claim : claims) { - if (userJson.has(claim) && userJson.get(claim).isJsonPrimitive()) { - // TODO: this skips the address claim - claimValues.put(claim, userJson.get(claim).getAsString()); - } - } - - claimsForScopes.put(systemScope.getValue(), claimValues); - } - } + model.put("auth_request", authRequest); + model.put("redirect_uri", authRequest.getRedirectUri()); + model.put("scopes", scopeService.fromStrings(authRequest.getScope())); + model.put("claims", userApprovalUtils.claimsForScopes(authUser, + scopeService.fromStrings(authRequest.getScope()))); - model.put("claims", claimsForScopes); + Integer count = userApprovalUtils.approvedSiteCount(client.getClientId()); - // client stats - Integer count = statsService.getCountForClientId(client.getClientId()).getApprovedSiteCount(); model.put("count", count); + model.put("gras", userApprovalUtils.isSafeClient(count, client.getCreatedAt())); + model.put("contacts", userApprovalUtils.getClientContactsAsString(client.getContacts())); - // contacts - if (client.getContacts() != null) { - String contacts = Joiner.on(", ").join(client.getContacts()); - model.put("contacts", contacts); - } - - // if the client is over a week old and has more than one registration, don't give such a big - // warning - // instead, tag as "Generally Recognized As Safe" (gras) - Date lastWeek = new Date(System.currentTimeMillis() - (60 * 60 * 24 * 7 * 1000)); - Boolean expression = count > 1 && client.getCreatedAt() != null && client.getCreatedAt().before(lastWeek); - model.put("gras", expression); - - return "iam/approveClient"; } - /** - * @return the clientService - */ - public ClientDetailsEntityService getClientService() { - return clientService; - } - - /** - * @param clientService the clientService to set - */ - public void setClientService(ClientDetailsEntityService clientService) { - this.clientService = clientService; - } - - } diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/core/oauth/IamOauthRequestParameters.java b/iam-login-service/src/main/java/it/infn/mw/iam/core/oauth/IamOauthRequestParameters.java new file mode 100644 index 000000000..06b5ace5f --- /dev/null +++ b/iam-login-service/src/main/java/it/infn/mw/iam/core/oauth/IamOauthRequestParameters.java @@ -0,0 +1,39 @@ +/** + * Copyright (c) Istituto Nazionale di Fisica Nucleare (INFN). 2016-2021 + * + * 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 it.infn.mw.iam.core.oauth; + +public abstract class IamOauthRequestParameters { + + public static final String AUTHZ_CODE_URL = "/oauth/confirm_access"; + public static final String DEVICE_CODE_URL = "devicecode"; + public static final String USER_CODE_URL = "device"; + + public static final String REQUEST_USER_CODE_STRING = "requestUserCode"; + + public static final String APPROVE_AUTHZ_PAGE = "iam/approveClient"; + public static final String APPROVE_DEVICE_PAGE = "iam/approveDevice"; + public static final String DEVICE_APPROVED_PAGE = "deviceApproved"; + + public static final String STATE_PARAMETER_KEY = "state"; + public static final String REMEMBER_PARAMETER_KEY = "remember"; + + public static final String ERROR_STRING = "error"; + + public static final String APPROVAL_ATTRIBUTE_KEY = "approved"; + + private IamOauthRequestParameters() {} + +} diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/core/oauth/IamUserApprovalHandler.java b/iam-login-service/src/main/java/it/infn/mw/iam/core/oauth/IamUserApprovalHandler.java new file mode 100644 index 000000000..08b69c010 --- /dev/null +++ b/iam-login-service/src/main/java/it/infn/mw/iam/core/oauth/IamUserApprovalHandler.java @@ -0,0 +1,213 @@ +/** + * Copyright (c) Istituto Nazionale di Fisica Nucleare (INFN). 2016-2021 + * + * 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 it.infn.mw.iam.core.oauth; + +import static it.infn.mw.iam.core.oauth.IamOauthRequestParameters.REMEMBER_PARAMETER_KEY; +import static org.mitre.openid.connect.request.ConnectRequestParameters.APPROVED_SITE; +import static org.mitre.openid.connect.request.ConnectRequestParameters.PROMPT; +import static org.mitre.openid.connect.request.ConnectRequestParameters.PROMPT_CONSENT; +import static org.mitre.openid.connect.request.ConnectRequestParameters.PROMPT_SEPARATOR; +import static org.springframework.security.oauth2.common.util.OAuth2Utils.USER_OAUTH_APPROVAL; + +import java.util.Calendar; +import java.util.Collection; +import java.util.Date; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import javax.servlet.http.HttpSession; + +import org.mitre.oauth2.model.ClientDetailsEntity; +import org.mitre.oauth2.service.ClientDetailsEntityService; +import org.mitre.oauth2.service.SystemScopeService; +import org.mitre.openid.connect.model.ApprovedSite; +import org.mitre.openid.connect.model.WhitelistedSite; +import org.mitre.openid.connect.service.ApprovedSiteService; +import org.mitre.openid.connect.service.WhitelistedSiteService; +import org.mitre.openid.connect.web.AuthenticationTimeStamper; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.core.Authentication; +import org.springframework.security.oauth2.provider.AuthorizationRequest; +import org.springframework.security.oauth2.provider.approval.UserApprovalHandler; +import org.springframework.stereotype.Component; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; + +import com.google.common.base.Splitter; +import com.google.common.base.Strings; +import com.google.common.collect.Sets; + +import it.infn.mw.iam.api.account.AccountUtils; +import it.infn.mw.iam.api.client.service.ClientService; + +@SuppressWarnings("deprecation") +@Component("iamUserApprovalHandler") +public class IamUserApprovalHandler implements UserApprovalHandler { + + @Autowired + private ClientDetailsEntityService clientDetailsService; + + @Autowired + private ClientService clientService; + + @Autowired + private AccountUtils accountUtils; + + @Autowired + private ApprovedSiteService approvedSiteService; + + @Autowired + private WhitelistedSiteService whitelistedSiteService; + + @Autowired + private SystemScopeService systemScopeService; + + + @Override + public boolean isApproved(AuthorizationRequest authorizationRequest, + Authentication userAuthentication) { + + if (authorizationRequest.isApproved()) { + return true; + } else { + return Boolean + .parseBoolean(authorizationRequest.getApprovalParameters().get(USER_OAUTH_APPROVAL)); + } + } + + @Override + public AuthorizationRequest checkForPreApproval(AuthorizationRequest authorizationRequest, + Authentication userAuthentication) { + + String prompt = (String) authorizationRequest.getExtensions().get(PROMPT); + List prompts = Splitter.on(PROMPT_SEPARATOR).splitToList(Strings.nullToEmpty(prompt)); + if (prompts.contains(PROMPT_CONSENT)) { + return authorizationRequest; + } + + String userId = userAuthentication.getName(); + String clientId = authorizationRequest.getClientId(); + Set scopes = authorizationRequest.getScope(); + + boolean alreadyApproved = false; + + Collection aps = approvedSiteService.getByClientIdAndUserId(clientId, userId); + + for (ApprovedSite ap : aps) { + + if (!ap.isExpired() && systemScopeService.scopesMatch(ap.getAllowedScopes(), scopes)) { + + + ap.setAccessDate(new Date()); + approvedSiteService.save(ap); + + String apId = ap.getId().toString(); + authorizationRequest.getExtensions().put(APPROVED_SITE, apId); + authorizationRequest.setApproved(true); + alreadyApproved = true; + + setAuthTime(authorizationRequest); + } + } + + if (!alreadyApproved) { + WhitelistedSite ws = whitelistedSiteService.getByClientId(clientId); + if (ws != null && systemScopeService.scopesMatch(ws.getAllowedScopes(), scopes)) { + + authorizationRequest.setApproved(true); + setAuthTime(authorizationRequest); + } + } + + return authorizationRequest; + } + + @Override + public AuthorizationRequest updateAfterApproval(AuthorizationRequest authorizationRequest, + Authentication userAuthentication) { + + String userId = userAuthentication.getName(); + String clientId = authorizationRequest.getClientId(); + ClientDetailsEntity client = clientDetailsService.loadClientByClientId(clientId); + Map approvalParams = authorizationRequest.getApprovalParameters(); + + if (!Boolean.parseBoolean(approvalParams.get(USER_OAUTH_APPROVAL))) { + return authorizationRequest; + } + + Set requestedScopes = authorizationRequest.getScope(); + Set allowedScopes = Sets.newHashSet(); + + // why filtering again? + requestedScopes.forEach(rs -> { + if (systemScopeService.scopesMatch(client.getScope(), Sets.newHashSet(rs))) { + allowedScopes.add(rs); + } + }); + + boolean approved = true; + if (allowedScopes.isEmpty() && !requestedScopes.isEmpty()) { + approved = false; + } + authorizationRequest.setApproved(approved); + + String remember = approvalParams.get(REMEMBER_PARAMETER_KEY); + if (!Strings.isNullOrEmpty(remember) && !remember.equals("none")) { + + Date timeout = null; + if (remember.equals("one-hour")) { + Calendar cal = Calendar.getInstance(); + cal.add(Calendar.HOUR, 1); + timeout = cal.getTime(); + } + + ApprovedSite newSite = + approvedSiteService.createApprovedSite(clientId, userId, timeout, allowedScopes); + String newSiteId = newSite.getId().toString(); + authorizationRequest.getExtensions().put(APPROVED_SITE, newSiteId); + } + + setAuthTime(authorizationRequest); + + return authorizationRequest; + + } + + private void setAuthTime(AuthorizationRequest authorizationRequest) { + ServletRequestAttributes attr = + (ServletRequestAttributes) RequestContextHolder.currentRequestAttributes(); + HttpSession session = attr.getRequest().getSession(); + if (session != null) { + Date authTime = (Date) session.getAttribute(AuthenticationTimeStamper.AUTH_TIMESTAMP); + if (authTime != null) { + String authTimeString = Long.toString(authTime.getTime()); + authorizationRequest.getExtensions() + .put(AuthenticationTimeStamper.AUTH_TIMESTAMP, authTimeString); + } + } + } + + @Override + public Map getUserApprovalRequest(AuthorizationRequest authorizationRequest, + Authentication userAuthentication) { + Map model = new HashMap<>(); + model.putAll(authorizationRequest.getRequestParameters()); + return model; + } + +} diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/core/oauth/IamUserApprovalUtils.java b/iam-login-service/src/main/java/it/infn/mw/iam/core/oauth/IamUserApprovalUtils.java new file mode 100644 index 000000000..db330629f --- /dev/null +++ b/iam-login-service/src/main/java/it/infn/mw/iam/core/oauth/IamUserApprovalUtils.java @@ -0,0 +1,111 @@ +/** + * Copyright (c) Istituto Nazionale di Fisica Nucleare (INFN). 2016-2021 + * + * 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 it.infn.mw.iam.core.oauth; + +import java.util.Date; +import java.util.HashMap; +import java.util.LinkedHashSet; +import java.util.Map; +import java.util.Set; + +import org.mitre.oauth2.model.SystemScope; +import org.mitre.oauth2.service.SystemScopeService; +import org.mitre.openid.connect.model.UserInfo; +import org.mitre.openid.connect.service.ScopeClaimTranslationService; +import org.mitre.openid.connect.service.StatsService; +import org.mitre.openid.connect.service.UserInfoService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.core.Authentication; +import org.springframework.stereotype.Component; + +import com.google.common.base.Joiner; +import com.google.common.collect.Sets; +import com.google.gson.JsonObject; + +@Component +public class IamUserApprovalUtils { + + @Autowired + private SystemScopeService scopeService; + + @Autowired + private StatsService statsService; + + @Autowired + private ScopeClaimTranslationService scopeClaimTranslationService; + + @Autowired + private UserInfoService userInfoService; + + + public Set sortScopes(Set scopes) { + + Set sortedScopes = new LinkedHashSet<>(scopes.size()); + Set systemScopes = scopeService.getAll(); + + systemScopes.forEach(s -> { + if (scopes.contains(s)) { + sortedScopes.add(s); + } + }); + + sortedScopes.addAll(Sets.difference(scopes, systemScopes)); + + return scopeService.toStrings(sortedScopes); + } + + public Map> claimsForScopes(Authentication authUser, + Set scopes) { + UserInfo user = userInfoService.getByUsername(authUser.getName()); + Map> claimsForScopes = new HashMap<>(); + if (user != null) { + JsonObject userJson = user.toJson(); + + for (SystemScope systemScope : scopes) { + Map claimValues = new HashMap<>(); + + Set claims = scopeClaimTranslationService.getClaimsForScope(systemScope.getValue()); + for (String claim : claims) { + if (userJson.has(claim) && userJson.get(claim).isJsonPrimitive()) { + claimValues.put(claim, userJson.get(claim).getAsString()); + } + } + + claimsForScopes.put(systemScope.getValue(), claimValues); + } + } + return claimsForScopes; + } + + public Integer approvedSiteCount(String clientId) { + + return statsService.getCountForClientId(clientId).getApprovedSiteCount(); + } + + public Boolean isSafeClient(Integer count, Date clientCreatedAt) { + + Date lastWeek = new Date(System.currentTimeMillis() - (60 * 60 * 24 * 7 * 1000)); + return count > 1 && clientCreatedAt != null && clientCreatedAt.before(lastWeek); + } + + public String getClientContactsAsString(Set clientContacts) { + + if (clientContacts != null) { + return Joiner.on(", ").join(clientContacts); + } + return "No contacts"; + } +} diff --git a/iam-login-service/src/main/webapp/WEB-INF/views/approveDevice.jsp b/iam-login-service/src/main/webapp/WEB-INF/views/approveDevice.jsp deleted file mode 100644 index c49e1e874..000000000 --- a/iam-login-service/src/main/webapp/WEB-INF/views/approveDevice.jsp +++ /dev/null @@ -1,287 +0,0 @@ -<%@ page language="java" contentType="text/html; charset=utf-8" pageEncoding="utf-8"%> -<%@ page import="org.springframework.security.core.AuthenticationException"%> -<%@ page import="org.springframework.security.oauth2.common.exceptions.UnapprovedClientAuthenticationException"%> -<%@ page import="org.springframework.security.web.WebAttributes"%> -<%@ taglib prefix="authz" uri="http://www.springframework.org/security/tags"%> -<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core"%> -<%@ taglib prefix="fmt" uri="http://java.sun.com/jsp/jstl/fmt"%> -<%@ taglib prefix="o" tagdir="/WEB-INF/tags"%> -<%@ taglib prefix="fn" uri="http://java.sun.com/jsp/jstl/functions" %> -<%@ taglib prefix="spring" uri="http://www.springframework.org/tags"%> - - - - -
- <% if (session.getAttribute(WebAttributes.AUTHENTICATION_EXCEPTION) != null && !(session.getAttribute(WebAttributes.AUTHENTICATION_EXCEPTION) instanceof UnapprovedClientAuthenticationException)) { %> -
- × - -

- (<%= ((AuthenticationException) session.getAttribute(WebAttributes.AUTHENTICATION_EXCEPTION)).getMessage() %>) -

-
- <% } %> - - -
-

  - - - - - - - - -

- -
- -
-
- - - - -
-

- - - - -

-
-
- - -
"> -

- : -

- -

- -

-

- - - - - - - - - - - -

-
-
-
-
- - -
    -
  • - -
  • -
- - -
- -
- - -
- -
-
-
    - -
  • : ">
  • -
    - -
  • : ">
  • -
    - -
  • : ">
  • -
    - -
  • :
  • -
    -
-
-
-
-
- - -
- -
-
- -
-
-
- : - - -
-

- : -

-

- -

-
-
- -
    - -
  • - - - - - - - - - - - - - - -
  • - : - -
  • -
    -
-
- " - > - - - - - - - - - -
- -
- -
-

- - " - - - - - - - "? -

- - - - - - -   - -
- - - -
- - - diff --git a/iam-login-service/src/main/webapp/WEB-INF/views/iam/approveClient.jsp b/iam-login-service/src/main/webapp/WEB-INF/views/iam/approveClient.jsp index 652f18eed..7e55b0e50 100644 --- a/iam-login-service/src/main/webapp/WEB-INF/views/iam/approveClient.jsp +++ b/iam-login-service/src/main/webapp/WEB-INF/views/iam/approveClient.jsp @@ -135,9 +135,9 @@

- + diff --git a/iam-login-service/src/main/webapp/WEB-INF/views/iam/approveDevice.jsp b/iam-login-service/src/main/webapp/WEB-INF/views/iam/approveDevice.jsp new file mode 100644 index 000000000..5283cd157 --- /dev/null +++ b/iam-login-service/src/main/webapp/WEB-INF/views/iam/approveDevice.jsp @@ -0,0 +1,304 @@ +<%-- + + Copyright (c) Istituto Nazionale di Fisica Nucleare (INFN). 2016-2021 + + 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. + +--%> +<%@ page language="java" contentType="text/html; charset=utf-8" + pageEncoding="utf-8"%> +<%@ page + import="org.springframework.security.core.AuthenticationException"%> +<%@ page + import="org.springframework.security.oauth2.common.exceptions.UnapprovedClientAuthenticationException"%> +<%@ page import="org.springframework.security.web.WebAttributes"%> +<%@ taglib prefix="authz" + uri="http://www.springframework.org/security/tags"%> +<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core"%> +<%@ taglib prefix="fmt" uri="http://java.sun.com/jsp/jstl/fmt"%> +<%@ taglib prefix="o" tagdir="/WEB-INF/tags"%> +<%@ taglib prefix="t" tagdir="/WEB-INF/tags/iam"%> +<%@ taglib prefix="fn" uri="http://java.sun.com/jsp/jstl/functions"%> +<%@ taglib prefix="spring" uri="http://www.springframework.org/tags"%> + + + + + + + <% + if (session.getAttribute(WebAttributes.AUTHENTICATION_EXCEPTION) != null && !(session + .getAttribute(WebAttributes.AUTHENTICATION_EXCEPTION) instanceof UnapprovedClientAuthenticationException)) { + %> +

+ × + +

+ + (<%=((AuthenticationException) session.getAttribute(WebAttributes.AUTHENTICATION_EXCEPTION)).getMessage()%>) +

+
+ <% + } + %> + + + + +
+

+ + + + + + + + + +

+ + +
+ + +
+ +
+ +
+
+
+ +
+   +
+
+ + + : + + + +
+

+ + + : +

+

+ +

+
+
+ + + +

+ + + + + + + + + + + + + + +

  • :
  • +
    + +
    + " > + + +

    + + + +   + + + + : + + +
    +
    + + + + + +   +
    +
    +   + + +
    +
    + + +

    +
    +
    + + + + + + + + + + +

    + + + + + + + + + + + +

    +
    +
    +
    +
    +
    + +
    + + +

    +
    +
    +
    +
    + + + +
    \ No newline at end of file diff --git a/iam-login-service/src/test/java/it/infn/mw/iam/test/oauth/WhitelistedSiteTests.java b/iam-login-service/src/test/java/it/infn/mw/iam/test/oauth/WhitelistedSiteTests.java new file mode 100644 index 000000000..54c841efc --- /dev/null +++ b/iam-login-service/src/test/java/it/infn/mw/iam/test/oauth/WhitelistedSiteTests.java @@ -0,0 +1,124 @@ +/** + * Copyright (c) Istituto Nazionale di Fisica Nucleare (INFN). 2016-2021 + * + * 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 it.infn.mw.iam.test.oauth; + +import static org.hamcrest.Matchers.equalTo; +import static org.springframework.http.MediaType.APPLICATION_FORM_URLENCODED; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.httpBasic; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.redirectedUrl; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.view; + +import java.util.Set; + +import org.assertj.core.util.Sets; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mitre.openid.connect.model.WhitelistedSite; +import org.mitre.openid.connect.service.impl.DefaultWhitelistedSiteService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; +import org.springframework.mock.web.MockHttpSession; +import org.springframework.test.context.junit4.SpringRunner; + +import com.fasterxml.jackson.databind.JsonNode; + +import it.infn.mw.iam.IamLoginService; +import it.infn.mw.iam.test.oauth.devicecode.DeviceCodeTestsConstants; +import it.infn.mw.iam.test.util.annotation.IamMockMvcIntegrationTest; + + +@RunWith(SpringRunner.class) +@IamMockMvcIntegrationTest +@SpringBootTest(classes = {IamLoginService.class}, webEnvironment = WebEnvironment.MOCK) +public class WhitelistedSiteTests extends EndpointsTestUtils implements DeviceCodeTestsConstants { + + @Autowired + private DefaultWhitelistedSiteService whitelistedSiteService; + + public WhitelistedSite getApprovedSiteFor(String creator, String clientId, Set scopes) { + + WhitelistedSite ws = new WhitelistedSite(); + + ws.setCreatorUserId(creator); + ws.setClientId(clientId); + ws.setAllowedScopes(scopes); + + return ws; + } + + @Test + public void testWhitelistedSiteWithDeviceCodeDoesNotPromptToConsentPage() throws Exception { + + WhitelistedSite ws = getApprovedSiteFor("admin", DEVICE_CODE_CLIENT_ID, + Sets.newLinkedHashSet("openid", "profile")); + ws = whitelistedSiteService.saveNew(ws); + + String response = mvc + .perform(post(DEVICE_CODE_ENDPOINT).contentType(APPLICATION_FORM_URLENCODED) + .with(httpBasic(DEVICE_CODE_CLIENT_ID, DEVICE_CODE_CLIENT_SECRET)) + .param("client_id", "device-code-client") + .param("scope", "openid")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.user_code").isString()) + .andExpect(jsonPath("$.device_code").isString()) + .andExpect(jsonPath("$.verification_uri", equalTo(DEVICE_USER_URL))) + .andReturn() + .getResponse() + .getContentAsString(); + + JsonNode responseJson = mapper.readTree(response); + String userCode = responseJson.get("user_code").asText(); + + MockHttpSession session = (MockHttpSession) mvc.perform(get(DEVICE_USER_URL)) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("http://localhost:8080/login")) + .andReturn() + .getRequest() + .getSession(); + + session = (MockHttpSession) mvc.perform(get("http://localhost:8080/login").session(session)) + .andExpect(status().isOk()) + .andExpect(view().name("iam/login")) + .andReturn() + .getRequest() + .getSession(); + + session = (MockHttpSession) mvc + .perform(post(LOGIN_URL).param("username", TEST_USERNAME) + .param("password", TEST_PASSWORD) + .param("submit", "Login") + .session(session)) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl(DEVICE_USER_URL)) + .andReturn() + .getRequest() + .getSession(); + + mvc + .perform(post(DEVICE_USER_VERIFY_URL).param("user_code", userCode).session(session)) + .andExpect(status().isOk()) + .andExpect(view().name("deviceApproved")); + + whitelistedSiteService.remove(ws); + + } + +} diff --git a/iam-login-service/src/test/java/it/infn/mw/iam/test/oauth/authzcode/AuthorizationCodeTests.java b/iam-login-service/src/test/java/it/infn/mw/iam/test/oauth/authzcode/AuthorizationCodeTests.java index 3d1fa7718..ad5175ee8 100644 --- a/iam-login-service/src/test/java/it/infn/mw/iam/test/oauth/authzcode/AuthorizationCodeTests.java +++ b/iam-login-service/src/test/java/it/infn/mw/iam/test/oauth/authzcode/AuthorizationCodeTests.java @@ -16,9 +16,16 @@ package it.infn.mw.iam.test.oauth.authzcode; import static java.lang.String.format; +import static org.hamcrest.Matchers.empty; +import static org.hamcrest.Matchers.is; +import static org.springframework.security.core.authority.AuthorityUtils.commaSeparatedStringToAuthorityList; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.securityContext; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.forwardedUrl; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.redirectedUrl; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.view; @@ -31,6 +38,8 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.mock.web.MockHttpSession; import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.userdetails.User; +import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors; import org.springframework.test.context.junit4.SpringRunner; import org.springframework.test.web.servlet.MockMvc; import org.springframework.web.util.UriComponents; @@ -192,4 +201,42 @@ public void testOidcAuthorizationCodeFlowWithAUPSignature() throws Exception { } + @Test + public void testNormalClientNotLinkedToUser() throws Exception { + + User testUser = new User(TEST_USER_ID, TEST_USER_PASSWORD, + commaSeparatedStringToAuthorityList("ROLE_USER")); + + MockHttpSession session = (MockHttpSession) mvc + .perform(get(AUTHORIZE_URL).param("response_type", RESPONSE_TYPE_CODE) + .param("client_id", TEST_CLIENT_ID) + .param("redirect_uri", TEST_CLIENT_REDIRECT_URI) + .param("scope", SCOPE) + .param("nonce", "1") + .param("state", "1") + .with(SecurityMockMvcRequestPostProcessors.user(testUser))) + .andExpect(status().isOk()) + .andExpect(forwardedUrl("/oauth/confirm_access")) + .andReturn() + .getRequest() + .getSession(); + + mvc + .perform(post("/authorize").session(session) + .param("user_oauth_approval", "true") + .param("scope_openid", "openid") + .param("scope_profile", "profile") + .param("authorize", "Authorize") + .param("remember", "none") + .with(csrf())) + .andExpect(status().is3xxRedirection()) + .andReturn(); + + mvc.perform(get("/iam/account/me/clients").session(session)) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.Resources", is(empty()))); + + } + } diff --git a/iam-login-service/src/test/java/it/infn/mw/iam/test/oauth/devicecode/DeviceCodeApprovalTests.java b/iam-login-service/src/test/java/it/infn/mw/iam/test/oauth/devicecode/DeviceCodeApprovalTests.java new file mode 100644 index 000000000..4e9adb959 --- /dev/null +++ b/iam-login-service/src/test/java/it/infn/mw/iam/test/oauth/devicecode/DeviceCodeApprovalTests.java @@ -0,0 +1,1043 @@ +/** + * Copyright (c) Istituto Nazionale di Fisica Nucleare (INFN). 2016-2021 + * + * 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 it.infn.mw.iam.test.oauth.devicecode; + +import static org.hamcrest.CoreMatchers.notNullValue; +import static org.hamcrest.CoreMatchers.nullValue; +import static org.hamcrest.Matchers.empty; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.is; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.springframework.http.MediaType.APPLICATION_FORM_URLENCODED; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.httpBasic; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.redirectedUrl; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.view; + +import java.util.Collection; +import java.util.Date; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mitre.oauth2.model.ClientDetailsEntity; +import org.mitre.oauth2.model.DeviceCode; +import org.mitre.openid.connect.config.ConfigurationPropertiesBean; +import org.mitre.openid.connect.model.ApprovedSite; +import org.mitre.openid.connect.service.ApprovedSiteService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; +import org.springframework.mock.web.MockHttpSession; +import org.springframework.test.context.junit4.SpringRunner; + +import com.fasterxml.jackson.databind.JsonNode; + +import it.infn.mw.iam.IamLoginService; +import it.infn.mw.iam.persistence.repository.client.IamClientRepository; +import it.infn.mw.iam.test.oauth.EndpointsTestUtils; +import it.infn.mw.iam.test.util.annotation.IamMockMvcIntegrationTest; + + +@RunWith(SpringRunner.class) +@IamMockMvcIntegrationTest +@SpringBootTest(classes = {IamLoginService.class}, webEnvironment = WebEnvironment.MOCK) +public class DeviceCodeApprovalTests extends EndpointsTestUtils + implements DeviceCodeTestsConstants { + + @Autowired + private IamClientRepository clientRepo; + + @Autowired + private ConfigurationPropertiesBean config; + + @Autowired + private ApprovedSiteService approvedSiteService; + + + @Test + public void testDeviceCodeReturnsBadRequestForEmptyClientId() throws Exception { + + mvc + .perform(post(DEVICE_CODE_ENDPOINT).contentType(APPLICATION_FORM_URLENCODED) + .with(httpBasic(DEVICE_CODE_CLIENT_ID, DEVICE_CODE_CLIENT_SECRET)) + .param("client_id", "")) + .andExpect(status().isBadRequest()) + .andExpect(view().name("httpCodeView")); + + } + + @Test + public void testParenthesisInRequestedScopesDoesNotMatchAllowedScopes() throws Exception { + + mvc + .perform(post(DEVICE_CODE_ENDPOINT).contentType(APPLICATION_FORM_URLENCODED) + .with(httpBasic(DEVICE_CODE_CLIENT_ID, DEVICE_CODE_CLIENT_SECRET)) + .param("client_id", DEVICE_CODE_CLIENT_ID) + .param("scope", "op [en ]id")) + .andExpect(status().isBadRequest()) + .andExpect(view().name("jsonErrorView")); + + } + + @Test + public void testDeviceCodeNotReturnCompleteUri() throws Exception { + + config.setAllowCompleteDeviceCodeUri(false); + + mvc + .perform(post(DEVICE_CODE_ENDPOINT).contentType(APPLICATION_FORM_URLENCODED) + .with(httpBasic(DEVICE_CODE_CLIENT_ID, DEVICE_CODE_CLIENT_SECRET)) + .param("client_id", DEVICE_CODE_CLIENT_ID)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.user_code").isString()) + .andExpect(jsonPath("$.device_code").isString()) + .andExpect(jsonPath("$.verification_uri_complete").doesNotExist()) + .andExpect(jsonPath("$.verification_uri", equalTo(DEVICE_USER_URL))); + + config.setAllowCompleteDeviceCodeUri(true); + + } + + @Test + public void testDeviceCodeFailsWhenVerificationUriHasSyntaxErrors() throws Exception { + + config.setIssuer("local host"); + + mvc + .perform(post(DEVICE_CODE_ENDPOINT).contentType(APPLICATION_FORM_URLENCODED) + .with(httpBasic(DEVICE_CODE_CLIENT_ID, DEVICE_CODE_CLIENT_SECRET)) + .param("client_id", DEVICE_CODE_CLIENT_ID)) + .andExpect(status().isInternalServerError()) + .andExpect(view().name("httpCodeView")); + + config.setIssuer("http://localhost:8080/"); + + } + + @Test + public void testDeviceCodeVerificationUriCompleteWithoutUserCodeFails() throws Exception { + + mvc + .perform(post(DEVICE_CODE_ENDPOINT).contentType(APPLICATION_FORM_URLENCODED) + .with(httpBasic(DEVICE_CODE_CLIENT_ID, DEVICE_CODE_CLIENT_SECRET)) + .param("client_id", DEVICE_CODE_CLIENT_ID) + .param("scope", "openid profile")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.user_code").isString()) + .andExpect(jsonPath("$.device_code").isString()) + .andExpect(jsonPath("$.verification_uri_complete").exists()) + .andExpect(jsonPath("$.verification_uri", equalTo(DEVICE_USER_URL))) + .andReturn() + .getResponse() + .getContentAsString(); + + MockHttpSession session = (MockHttpSession) mvc.perform(get(WRONG_VERIFICATION_URI_COMPLETE)) + .andExpect(status().is3xxRedirection()) + .andReturn() + .getRequest() + .getSession(); + + session = (MockHttpSession) mvc.perform(get(LOGIN_URL).session(session)) + .andExpect(status().isOk()) + .andExpect(view().name("iam/login")) + .andReturn() + .getRequest() + .getSession(); + + session = (MockHttpSession) mvc + .perform(post(LOGIN_URL).param("username", TEST_USERNAME) + .param("password", TEST_PASSWORD) + .param("submit", "Login") + .session(session)) + .andExpect(status().is3xxRedirection()) + .andReturn() + .getRequest() + .getSession(); + + mvc.perform(get(WRONG_VERIFICATION_URI_COMPLETE).session(session)) + .andExpect(status().isOk()) + .andExpect(view().name("requestUserCode")); + + } + + @Test + public void testDeviceCodeVerificationUriCompleteWorks() throws Exception { + + String response = mvc + .perform(post(DEVICE_CODE_ENDPOINT).contentType(APPLICATION_FORM_URLENCODED) + .with(httpBasic(DEVICE_CODE_CLIENT_ID, DEVICE_CODE_CLIENT_SECRET)) + .param("client_id", DEVICE_CODE_CLIENT_ID) + .param("scope", "openid profile")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.user_code").isString()) + .andExpect(jsonPath("$.device_code").isString()) + .andExpect(jsonPath("$.verification_uri_complete").exists()) + .andExpect(jsonPath("$.verification_uri", equalTo(DEVICE_USER_URL))) + .andReturn() + .getResponse() + .getContentAsString(); + + JsonNode responseJson = mapper.readTree(response); + + String verificationUriComplete = responseJson.get("verification_uri_complete").asText(); + + MockHttpSession session = (MockHttpSession) mvc.perform(get(verificationUriComplete)) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("http://localhost:8080/login")) + .andReturn() + .getRequest() + .getSession(); + + session = (MockHttpSession) mvc.perform(get("http://localhost:8080/login").session(session)) + .andExpect(status().isOk()) + .andExpect(view().name("iam/login")) + .andReturn() + .getRequest() + .getSession(); + + session = (MockHttpSession) mvc + .perform(post(LOGIN_URL).param("username", TEST_USERNAME) + .param("password", TEST_PASSWORD) + .param("submit", "Login") + .session(session)) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl(verificationUriComplete)) + .andReturn() + .getRequest() + .getSession(); + + mvc.perform(get(verificationUriComplete).session(session)) + .andExpect(status().isOk()) + .andExpect(view().name("iam/approveDevice")); + + } + + @Test + public void testDeviceCodeWithExpiredCodeFails() throws Exception { + + ClientDetailsEntity entity = clientRepo.findByClientId(DEVICE_CODE_CLIENT_ID).orElseThrow(); + entity.setDeviceCodeValiditySeconds(-1); + clientRepo.save(entity); + + String response = mvc + .perform(post(DEVICE_CODE_ENDPOINT).contentType(APPLICATION_FORM_URLENCODED) + .with(httpBasic(DEVICE_CODE_CLIENT_ID, DEVICE_CODE_CLIENT_SECRET)) + .param("client_id", DEVICE_CODE_CLIENT_ID) + .param("scope", "openid profile")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.user_code").isString()) + .andExpect(jsonPath("$.device_code").isString()) + .andExpect(jsonPath("$.verification_uri_complete").exists()) + .andExpect(jsonPath("$.verification_uri", equalTo(DEVICE_USER_URL))) + .andReturn() + .getResponse() + .getContentAsString(); + + JsonNode responseJson = mapper.readTree(response); + + String verificationUriComplete = responseJson.get("verification_uri_complete").asText(); + + MockHttpSession session = (MockHttpSession) mvc.perform(get(verificationUriComplete)) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("http://localhost:8080/login")) + .andReturn() + .getRequest() + .getSession(); + + session = (MockHttpSession) mvc.perform(get("http://localhost:8080/login").session(session)) + .andExpect(status().isOk()) + .andExpect(view().name("iam/login")) + .andReturn() + .getRequest() + .getSession(); + + session = (MockHttpSession) mvc + .perform(post(LOGIN_URL).param("username", TEST_USERNAME) + .param("password", TEST_PASSWORD) + .param("submit", "Login") + .session(session)) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl(verificationUriComplete)) + .andReturn() + .getRequest() + .getSession(); + + mvc.perform(get(verificationUriComplete).session(session)) + .andExpect(status().isOk()) + .andExpect(view().name("requestUserCode")); + + entity.setDeviceCodeValiditySeconds(600); + clientRepo.save(entity); + + } + + @Test + public void testAlreadyApprovedDeviceCodeFailsCodeVerification() throws Exception { + + String response = mvc + .perform(post(DEVICE_CODE_ENDPOINT).contentType(APPLICATION_FORM_URLENCODED) + .with(httpBasic(DEVICE_CODE_CLIENT_ID, DEVICE_CODE_CLIENT_SECRET)) + .param("client_id", DEVICE_CODE_CLIENT_ID) + .param("scope", "openid profile")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.user_code").isString()) + .andExpect(jsonPath("$.device_code").isString()) + .andExpect(jsonPath("$.verification_uri_complete").exists()) + .andExpect(jsonPath("$.verification_uri", equalTo(DEVICE_USER_URL))) + .andReturn() + .getResponse() + .getContentAsString(); + + JsonNode responseJson = mapper.readTree(response); + + String verificationUriComplete = responseJson.get("verification_uri_complete").asText(); + String userCode = responseJson.get("user_code").asText(); + + MockHttpSession session = (MockHttpSession) mvc.perform(get(verificationUriComplete)) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("http://localhost:8080/login")) + .andReturn() + .getRequest() + .getSession(); + + session = (MockHttpSession) mvc.perform(get("http://localhost:8080/login").session(session)) + .andExpect(status().isOk()) + .andExpect(view().name("iam/login")) + .andReturn() + .getRequest() + .getSession(); + + session = (MockHttpSession) mvc + .perform(post(LOGIN_URL).param("username", TEST_USERNAME) + .param("password", TEST_PASSWORD) + .param("submit", "Login") + .session(session)) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl(verificationUriComplete)) + .andReturn() + .getRequest() + .getSession(); + + session = (MockHttpSession) mvc.perform(get(verificationUriComplete).session(session)) + .andExpect(status().isOk()) + .andExpect(view().name("iam/approveDevice")) + .andReturn() + .getRequest() + .getSession(); + + session = (MockHttpSession) mvc + .perform(post(DEVICE_USER_APPROVE_URL).param("user_code", userCode) + .param("user_oauth_approval", "true") + .session(session)) + .andExpect(status().isOk()) + .andExpect(view().name("deviceApproved")) + .andReturn() + .getRequest() + .getSession(); + + mvc.perform(get(verificationUriComplete).session(session)) + .andExpect(status().isOk()) + .andExpect(view().name("requestUserCode")); + + } + + @Test + public void testUserCodeMismatch() throws Exception { + + String response = mvc + .perform(post(DEVICE_CODE_ENDPOINT).contentType(APPLICATION_FORM_URLENCODED) + .with(httpBasic(DEVICE_CODE_CLIENT_ID, DEVICE_CODE_CLIENT_SECRET)) + .param("client_id", DEVICE_CODE_CLIENT_ID) + .param("scope", "openid profile")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.user_code").isString()) + .andExpect(jsonPath("$.device_code").isString()) + .andExpect(jsonPath("$.verification_uri_complete").exists()) + .andExpect(jsonPath("$.verification_uri", equalTo(DEVICE_USER_URL))) + .andReturn() + .getResponse() + .getContentAsString(); + + JsonNode responseJson = mapper.readTree(response); + + String verificationUriComplete = responseJson.get("verification_uri_complete").asText(); + + MockHttpSession session = (MockHttpSession) mvc.perform(get(verificationUriComplete)) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("http://localhost:8080/login")) + .andReturn() + .getRequest() + .getSession(); + + session = (MockHttpSession) mvc.perform(get("http://localhost:8080/login").session(session)) + .andExpect(status().isOk()) + .andExpect(view().name("iam/login")) + .andReturn() + .getRequest() + .getSession(); + + session = (MockHttpSession) mvc + .perform(post(LOGIN_URL).param("username", TEST_USERNAME) + .param("password", TEST_PASSWORD) + .param("submit", "Login") + .session(session)) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl(verificationUriComplete)) + .andReturn() + .getRequest() + .getSession(); + + session = (MockHttpSession) mvc.perform(get(verificationUriComplete).session(session)) + .andExpect(status().isOk()) + .andExpect(view().name("iam/approveDevice")) + .andReturn() + .getRequest() + .getSession(); + + mvc + .perform(post(DEVICE_USER_APPROVE_URL).param("user_code", "1234") + .param("user_oauth_approval", "true") + .session(session)) + .andExpect(status().isOk()) + .andExpect(view().name("requestUserCode")); + + } + + @Test + public void testExpiredDeviceCodeFailsUserApproval() throws Exception { + + String response = mvc + .perform(post(DEVICE_CODE_ENDPOINT).contentType(APPLICATION_FORM_URLENCODED) + .with(httpBasic(DEVICE_CODE_CLIENT_ID, DEVICE_CODE_CLIENT_SECRET)) + .param("client_id", DEVICE_CODE_CLIENT_ID) + .param("scope", "openid profile")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.user_code").isString()) + .andExpect(jsonPath("$.device_code").isString()) + .andExpect(jsonPath("$.verification_uri_complete").exists()) + .andExpect(jsonPath("$.verification_uri", equalTo(DEVICE_USER_URL))) + .andReturn() + .getResponse() + .getContentAsString(); + + JsonNode responseJson = mapper.readTree(response); + + String verificationUriComplete = responseJson.get("verification_uri_complete").asText(); + String userCode = responseJson.get("user_code").asText(); + + MockHttpSession session = (MockHttpSession) mvc.perform(get(verificationUriComplete)) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("http://localhost:8080/login")) + .andReturn() + .getRequest() + .getSession(); + + session = (MockHttpSession) mvc.perform(get("http://localhost:8080/login").session(session)) + .andExpect(status().isOk()) + .andExpect(view().name("iam/login")) + .andReturn() + .getRequest() + .getSession(); + + session = (MockHttpSession) mvc + .perform(post(LOGIN_URL).param("username", TEST_USERNAME) + .param("password", TEST_PASSWORD) + .param("submit", "Login") + .session(session)) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl(verificationUriComplete)) + .andReturn() + .getRequest() + .getSession(); + + session = (MockHttpSession) mvc.perform(get(verificationUriComplete).session(session)) + .andExpect(status().isOk()) + .andExpect(view().name("iam/approveDevice")) + .andReturn() + .getRequest() + .getSession(); + + DeviceCode dc = (DeviceCode) session.getAttribute("deviceCode"); + dc.setExpiration(new Date()); + + mvc + .perform(post(DEVICE_USER_APPROVE_URL).param("user_code", userCode) + .param("user_oauth_approval", "true") + .session(session)) + .andExpect(status().isOk()) + .andExpect(view().name("requestUserCode")); + } + + @Test + public void testNormalClientNotLinkedToUser() throws Exception { + + String response = mvc + .perform(post(DEVICE_CODE_ENDPOINT).contentType(APPLICATION_FORM_URLENCODED) + .with(httpBasic(DEVICE_CODE_CLIENT_ID, DEVICE_CODE_CLIENT_SECRET)) + .param("client_id", "device-code-client") + .param("scope", "openid profile offline_access")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.user_code").isString()) + .andExpect(jsonPath("$.device_code").isString()) + .andExpect(jsonPath("$.verification_uri", equalTo(DEVICE_USER_URL))) + .andReturn() + .getResponse() + .getContentAsString(); + + JsonNode responseJson = mapper.readTree(response); + String userCode = responseJson.get("user_code").asText(); + + MockHttpSession session = (MockHttpSession) mvc.perform(get(DEVICE_USER_URL)) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("http://localhost:8080/login")) + .andReturn() + .getRequest() + .getSession(); + + session = (MockHttpSession) mvc.perform(get("http://localhost:8080/login").session(session)) + .andExpect(status().isOk()) + .andExpect(view().name("iam/login")) + .andReturn() + .getRequest() + .getSession(); + + session = (MockHttpSession) mvc + .perform(post(LOGIN_URL).param("username", TEST_USERNAME) + .param("password", TEST_PASSWORD) + .param("submit", "Login") + .session(session)) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl(DEVICE_USER_URL)) + .andReturn() + .getRequest() + .getSession(); + + session = (MockHttpSession) mvc + .perform(post(DEVICE_USER_VERIFY_URL).param("user_code", userCode).session(session)) + .andExpect(status().isOk()) + .andExpect(view().name("iam/approveDevice")) + .andReturn() + .getRequest() + .getSession(); + + session = (MockHttpSession) mvc + .perform(post(DEVICE_USER_APPROVE_URL).param("user_code", userCode) + .param("user_oauth_approval", "true") + .session(session)) + .andExpect(status().isOk()) + .andExpect(view().name("deviceApproved")) + .andReturn() + .getRequest() + .getSession(); + + mvc.perform(get("/iam/account/me/clients").session(session)) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.Resources", is(empty()))); + + + } + + @Test + public void testRememberParameterAllowsToAddAnApprovedSite() throws Exception { + + String response = mvc + .perform(post(DEVICE_CODE_ENDPOINT).contentType(APPLICATION_FORM_URLENCODED) + .with(httpBasic(DEVICE_CODE_CLIENT_ID, DEVICE_CODE_CLIENT_SECRET)) + .param("client_id", "device-code-client") + .param("scope", "openid profile offline_access")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.user_code").isString()) + .andExpect(jsonPath("$.device_code").isString()) + .andExpect(jsonPath("$.verification_uri", equalTo(DEVICE_USER_URL))) + .andReturn() + .getResponse() + .getContentAsString(); + + JsonNode responseJson = mapper.readTree(response); + String userCode = responseJson.get("user_code").asText(); + + MockHttpSession session = (MockHttpSession) mvc.perform(get(DEVICE_USER_URL)) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("http://localhost:8080/login")) + .andReturn() + .getRequest() + .getSession(); + + session = (MockHttpSession) mvc.perform(get("http://localhost:8080/login").session(session)) + .andExpect(status().isOk()) + .andExpect(view().name("iam/login")) + .andReturn() + .getRequest() + .getSession(); + + session = (MockHttpSession) mvc + .perform(post(LOGIN_URL).param("username", TEST_USERNAME) + .param("password", TEST_PASSWORD) + .param("submit", "Login") + .session(session)) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl(DEVICE_USER_URL)) + .andReturn() + .getRequest() + .getSession(); + + session = (MockHttpSession) mvc + .perform(post(DEVICE_USER_VERIFY_URL).param("user_code", userCode).session(session)) + .andExpect(status().isOk()) + .andExpect(view().name("iam/approveDevice")) + .andReturn() + .getRequest() + .getSession(); + + session = (MockHttpSession) mvc + .perform(post(DEVICE_USER_APPROVE_URL).param("user_code", userCode) + .param("user_oauth_approval", "true") + .param("remember", "until-revoked") + .session(session)) + .andExpect(status().isOk()) + .andExpect(view().name("deviceApproved")) + .andReturn() + .getRequest() + .getSession(); + + mvc.perform(get("/api/approved").session(session)) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].userId", is(TEST_USERNAME))) + .andExpect(jsonPath("$[0].clientId", is(DEVICE_CODE_CLIENT_ID))) + .andExpect(jsonPath("$[0].timeoutDate", nullValue())); + + } + + @Test + public void testNoneRememberParameterDoesNotAddAnApprovedSite() throws Exception { + + String response = mvc + .perform(post(DEVICE_CODE_ENDPOINT).contentType(APPLICATION_FORM_URLENCODED) + .with(httpBasic(DEVICE_CODE_CLIENT_ID, DEVICE_CODE_CLIENT_SECRET)) + .param("client_id", "device-code-client") + .param("scope", "openid profile offline_access")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.user_code").isString()) + .andExpect(jsonPath("$.device_code").isString()) + .andExpect(jsonPath("$.verification_uri", equalTo(DEVICE_USER_URL))) + .andReturn() + .getResponse() + .getContentAsString(); + + JsonNode responseJson = mapper.readTree(response); + String userCode = responseJson.get("user_code").asText(); + + MockHttpSession session = (MockHttpSession) mvc.perform(get(DEVICE_USER_URL)) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("http://localhost:8080/login")) + .andReturn() + .getRequest() + .getSession(); + + session = (MockHttpSession) mvc.perform(get("http://localhost:8080/login").session(session)) + .andExpect(status().isOk()) + .andExpect(view().name("iam/login")) + .andReturn() + .getRequest() + .getSession(); + + session = (MockHttpSession) mvc + .perform(post(LOGIN_URL).param("username", TEST_USERNAME) + .param("password", TEST_PASSWORD) + .param("submit", "Login") + .session(session)) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl(DEVICE_USER_URL)) + .andReturn() + .getRequest() + .getSession(); + + session = (MockHttpSession) mvc + .perform(post(DEVICE_USER_VERIFY_URL).param("user_code", userCode).session(session)) + .andExpect(status().isOk()) + .andExpect(view().name("iam/approveDevice")) + .andReturn() + .getRequest() + .getSession(); + + mvc + .perform(post(DEVICE_USER_APPROVE_URL).param("user_code", userCode) + .param("user_oauth_approval", "true") + .param("remember", "none") + .session(session)) + .andExpect(status().isOk()) + .andExpect(view().name("deviceApproved")); + + Collection approvedSites = + approvedSiteService.getByClientIdAndUserId(DEVICE_CODE_CLIENT_ID, TEST_USERNAME); + + assertTrue(approvedSites.isEmpty()); + + } + + @Test + public void testAddAnApprovedSiteFor1Hour() throws Exception { + + String response = mvc + .perform(post(DEVICE_CODE_ENDPOINT).contentType(APPLICATION_FORM_URLENCODED) + .with(httpBasic(DEVICE_CODE_CLIENT_ID, DEVICE_CODE_CLIENT_SECRET)) + .param("client_id", "device-code-client") + .param("scope", "openid profile offline_access")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.user_code").isString()) + .andExpect(jsonPath("$.device_code").isString()) + .andExpect(jsonPath("$.verification_uri", equalTo(DEVICE_USER_URL))) + .andReturn() + .getResponse() + .getContentAsString(); + + JsonNode responseJson = mapper.readTree(response); + String userCode = responseJson.get("user_code").asText(); + + MockHttpSession session = (MockHttpSession) mvc.perform(get(DEVICE_USER_URL)) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("http://localhost:8080/login")) + .andReturn() + .getRequest() + .getSession(); + + session = (MockHttpSession) mvc.perform(get("http://localhost:8080/login").session(session)) + .andExpect(status().isOk()) + .andExpect(view().name("iam/login")) + .andReturn() + .getRequest() + .getSession(); + + session = (MockHttpSession) mvc + .perform(post(LOGIN_URL).param("username", TEST_USERNAME) + .param("password", TEST_PASSWORD) + .param("submit", "Login") + .session(session)) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl(DEVICE_USER_URL)) + .andReturn() + .getRequest() + .getSession(); + + session = (MockHttpSession) mvc + .perform(post(DEVICE_USER_VERIFY_URL).param("user_code", userCode).session(session)) + .andExpect(status().isOk()) + .andExpect(view().name("iam/approveDevice")) + .andReturn() + .getRequest() + .getSession(); + + session = (MockHttpSession) mvc + .perform(post(DEVICE_USER_APPROVE_URL).param("user_code", userCode) + .param("user_oauth_approval", "true") + .param("remember", "one-hour") + .session(session)) + .andExpect(status().isOk()) + .andExpect(view().name("deviceApproved")) + .andReturn() + .getRequest() + .getSession(); + + mvc.perform(get("/api/approved").session(session)) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].userId", is(TEST_USERNAME))) + .andExpect(jsonPath("$[0].clientId", is(DEVICE_CODE_CLIENT_ID))) + .andExpect(jsonPath("$[0].timeoutDate", notNullValue())); + + } + + @Test + public void testAlreadyApprovedSiteSkipsConsentPage() throws Exception { + + String response = mvc + .perform(post(DEVICE_CODE_ENDPOINT).contentType(APPLICATION_FORM_URLENCODED) + .with(httpBasic(DEVICE_CODE_CLIENT_ID, DEVICE_CODE_CLIENT_SECRET)) + .param("client_id", "device-code-client") + .param("scope", "openid profile offline_access")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.user_code").isString()) + .andExpect(jsonPath("$.device_code").isString()) + .andExpect(jsonPath("$.verification_uri", equalTo(DEVICE_USER_URL))) + .andReturn() + .getResponse() + .getContentAsString(); + + JsonNode responseJson = mapper.readTree(response); + String userCode = responseJson.get("user_code").asText(); + + MockHttpSession session = (MockHttpSession) mvc.perform(get(DEVICE_USER_URL)) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("http://localhost:8080/login")) + .andReturn() + .getRequest() + .getSession(); + + session = (MockHttpSession) mvc.perform(get("http://localhost:8080/login").session(session)) + .andExpect(status().isOk()) + .andExpect(view().name("iam/login")) + .andReturn() + .getRequest() + .getSession(); + + session = (MockHttpSession) mvc + .perform(post(LOGIN_URL).param("username", TEST_USERNAME) + .param("password", TEST_PASSWORD) + .param("submit", "Login") + .session(session)) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl(DEVICE_USER_URL)) + .andReturn() + .getRequest() + .getSession(); + + session = (MockHttpSession) mvc + .perform(post(DEVICE_USER_VERIFY_URL).param("user_code", userCode).session(session)) + .andExpect(status().isOk()) + .andExpect(view().name("iam/approveDevice")) + .andReturn() + .getRequest() + .getSession(); + + session = (MockHttpSession) mvc + .perform(post(DEVICE_USER_APPROVE_URL).param("user_code", userCode) + .param("user_oauth_approval", "true") + .param("remember", "until-revoked") + .session(session)) + .andExpect(status().isOk()) + .andExpect(view().name("deviceApproved")) + .andReturn() + .getRequest() + .getSession(); + + response = mvc + .perform(post(DEVICE_CODE_ENDPOINT).contentType(APPLICATION_FORM_URLENCODED) + .with(httpBasic(DEVICE_CODE_CLIENT_ID, DEVICE_CODE_CLIENT_SECRET)) + .param("client_id", "device-code-client") + .param("scope", "openid profile offline_access")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.user_code").isString()) + .andExpect(jsonPath("$.device_code").isString()) + .andExpect(jsonPath("$.verification_uri", equalTo(DEVICE_USER_URL))) + .andReturn() + .getResponse() + .getContentAsString(); + + responseJson = mapper.readTree(response); + userCode = responseJson.get("user_code").asText(); + + mvc.perform(post(DEVICE_USER_VERIFY_URL).param("user_code", userCode).session(session)) + .andExpect(status().isOk()) + .andExpect(view().name("deviceApproved")); + + } + + @Test + public void testNewRequestedScopePromptToConsentPage() throws Exception { + + String response = mvc + .perform(post(DEVICE_CODE_ENDPOINT).contentType(APPLICATION_FORM_URLENCODED) + .with(httpBasic(DEVICE_CODE_CLIENT_ID, DEVICE_CODE_CLIENT_SECRET)) + .param("client_id", "device-code-client") + .param("scope", "openid")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.user_code").isString()) + .andExpect(jsonPath("$.device_code").isString()) + .andExpect(jsonPath("$.verification_uri", equalTo(DEVICE_USER_URL))) + .andReturn() + .getResponse() + .getContentAsString(); + + JsonNode responseJson = mapper.readTree(response); + String userCode = responseJson.get("user_code").asText(); + + MockHttpSession session = (MockHttpSession) mvc.perform(get(DEVICE_USER_URL)) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("http://localhost:8080/login")) + .andReturn() + .getRequest() + .getSession(); + + session = (MockHttpSession) mvc.perform(get("http://localhost:8080/login").session(session)) + .andExpect(status().isOk()) + .andExpect(view().name("iam/login")) + .andReturn() + .getRequest() + .getSession(); + + session = (MockHttpSession) mvc + .perform(post(LOGIN_URL).param("username", TEST_USERNAME) + .param("password", TEST_PASSWORD) + .param("submit", "Login") + .session(session)) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl(DEVICE_USER_URL)) + .andReturn() + .getRequest() + .getSession(); + + session = (MockHttpSession) mvc + .perform(post(DEVICE_USER_VERIFY_URL).param("user_code", userCode).session(session)) + .andExpect(status().isOk()) + .andExpect(view().name("iam/approveDevice")) + .andReturn() + .getRequest() + .getSession(); + + session = (MockHttpSession) mvc + .perform(post(DEVICE_USER_APPROVE_URL).param("user_code", userCode) + .param("user_oauth_approval", "true") + .param("remember", "until-revoked") + .session(session)) + .andExpect(status().isOk()) + .andExpect(view().name("deviceApproved")) + .andReturn() + .getRequest() + .getSession(); + + response = mvc + .perform(post(DEVICE_CODE_ENDPOINT).contentType(APPLICATION_FORM_URLENCODED) + .with(httpBasic(DEVICE_CODE_CLIENT_ID, DEVICE_CODE_CLIENT_SECRET)) + .param("client_id", "device-code-client") + .param("scope", "openid profile")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.user_code").isString()) + .andExpect(jsonPath("$.device_code").isString()) + .andExpect(jsonPath("$.verification_uri", equalTo(DEVICE_USER_URL))) + .andReturn() + .getResponse() + .getContentAsString(); + + responseJson = mapper.readTree(response); + userCode = responseJson.get("user_code").asText(); + + mvc.perform(post(DEVICE_USER_VERIFY_URL).param("user_code", userCode).session(session)) + .andExpect(status().isOk()) + .andExpect(view().name("iam/approveDevice")); + + } + + @Test + public void testASubsetOfApprovedScopesUpdatesApprovedSiteAccessDate() throws Exception { + + String response = mvc + .perform(post(DEVICE_CODE_ENDPOINT).contentType(APPLICATION_FORM_URLENCODED) + .with(httpBasic(DEVICE_CODE_CLIENT_ID, DEVICE_CODE_CLIENT_SECRET)) + .param("client_id", "device-code-client") + .param("scope", "openid profile")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.user_code").isString()) + .andExpect(jsonPath("$.device_code").isString()) + .andExpect(jsonPath("$.verification_uri", equalTo(DEVICE_USER_URL))) + .andReturn() + .getResponse() + .getContentAsString(); + + JsonNode responseJson = mapper.readTree(response); + String userCode = responseJson.get("user_code").asText(); + + MockHttpSession session = (MockHttpSession) mvc.perform(get(DEVICE_USER_URL)) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("http://localhost:8080/login")) + .andReturn() + .getRequest() + .getSession(); + + session = (MockHttpSession) mvc.perform(get("http://localhost:8080/login").session(session)) + .andExpect(status().isOk()) + .andExpect(view().name("iam/login")) + .andReturn() + .getRequest() + .getSession(); + + session = (MockHttpSession) mvc + .perform(post(LOGIN_URL).param("username", TEST_USERNAME) + .param("password", TEST_PASSWORD) + .param("submit", "Login") + .session(session)) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl(DEVICE_USER_URL)) + .andReturn() + .getRequest() + .getSession(); + + session = (MockHttpSession) mvc + .perform(post(DEVICE_USER_VERIFY_URL).param("user_code", userCode).session(session)) + .andExpect(status().isOk()) + .andExpect(view().name("iam/approveDevice")) + .andReturn() + .getRequest() + .getSession(); + + session = (MockHttpSession) mvc + .perform(post(DEVICE_USER_APPROVE_URL).param("user_code", userCode) + .param("user_oauth_approval", "true") + .param("remember", "until-revoked") + .session(session)) + .andExpect(status().isOk()) + .andExpect(view().name("deviceApproved")) + .andReturn() + .getRequest() + .getSession(); + + response = mvc + .perform(post(DEVICE_CODE_ENDPOINT).contentType(APPLICATION_FORM_URLENCODED) + .with(httpBasic(DEVICE_CODE_CLIENT_ID, DEVICE_CODE_CLIENT_SECRET)) + .param("client_id", "device-code-client") + .param("scope", "openid")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.user_code").isString()) + .andExpect(jsonPath("$.device_code").isString()) + .andExpect(jsonPath("$.verification_uri", equalTo(DEVICE_USER_URL))) + .andReturn() + .getResponse() + .getContentAsString(); + + responseJson = mapper.readTree(response); + userCode = responseJson.get("user_code").asText(); + + Thread.sleep(1000); + + mvc.perform(post(DEVICE_USER_VERIFY_URL).param("user_code", userCode).session(session)) + .andExpect(status().isOk()) + .andExpect(view().name("deviceApproved")); + + response = mvc.perform(get("/api/approved").session(session)) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].userId", is(TEST_USERNAME))) + .andExpect(jsonPath("$[0].clientId", is(DEVICE_CODE_CLIENT_ID))) + .andExpect(jsonPath("$[0].timeoutDate", nullValue())) + .andExpect(jsonPath("$[0].creationDate", notNullValue())) + .andExpect(jsonPath("$[0].accessDate", notNullValue())) + .andReturn() + .getResponse() + .getContentAsString(); + + responseJson = mapper.readTree(response); + assertFalse( + responseJson.get(0).get("accessDate").equals(responseJson.get(0).get("creationDate"))); + + } + +} diff --git a/iam-login-service/src/test/java/it/infn/mw/iam/test/oauth/devicecode/DeviceCodeTests.java b/iam-login-service/src/test/java/it/infn/mw/iam/test/oauth/devicecode/DeviceCodeTests.java index e41ae79d0..a6c078f3b 100644 --- a/iam-login-service/src/test/java/it/infn/mw/iam/test/oauth/devicecode/DeviceCodeTests.java +++ b/iam-login-service/src/test/java/it/infn/mw/iam/test/oauth/devicecode/DeviceCodeTests.java @@ -42,6 +42,7 @@ import org.junit.Test; import org.junit.runner.RunWith; import org.mitre.oauth2.model.ClientDetailsEntity; +import org.mitre.openid.connect.web.ApprovedSiteAPI; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; @@ -162,7 +163,7 @@ public void testDeviceCodeNoApproval() throws Exception { session = (MockHttpSession) mvc .perform(post(DEVICE_USER_VERIFY_URL).param("user_code", userCode).session(session)) .andExpect(status().isOk()) - .andExpect(view().name("approveDevice")) + .andExpect(view().name("iam/approveDevice")) .andReturn() .getRequest() .getSession(); @@ -255,7 +256,7 @@ public void testDevideCodeFlowWithAudience() throws Exception { session = (MockHttpSession) mvc .perform(post(DEVICE_USER_VERIFY_URL).param("user_code", userCode).session(session)) .andExpect(status().isOk()) - .andExpect(view().name("approveDevice")) + .andExpect(view().name("iam/approveDevice")) .andReturn() .getRequest() .getSession(); @@ -365,7 +366,7 @@ public void testDeviceCodeApprovalFlowWorks() throws Exception { session = (MockHttpSession) mvc .perform(post(DEVICE_USER_VERIFY_URL).param("user_code", userCode).session(session)) .andExpect(status().isOk()) - .andExpect(view().name("approveDevice")) + .andExpect(view().name("iam/approveDevice")) .andReturn() .getRequest() .getSession(); @@ -373,6 +374,7 @@ public void testDeviceCodeApprovalFlowWorks() throws Exception { session = (MockHttpSession) mvc .perform(post(DEVICE_USER_APPROVE_URL).param("user_code", userCode) .param("user_oauth_approval", "true") + .param("remember", "until-revoked") .session(session)) .andExpect(status().isOk()) .andExpect(view().name("deviceApproved")) @@ -380,6 +382,11 @@ public void testDeviceCodeApprovalFlowWorks() throws Exception { .getRequest() .getSession(); + mvc.perform(get("/" + ApprovedSiteAPI.URL).session(session)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].clientId", equalTo(DEVICE_CODE_CLIENT_ID))) + .andExpect(jsonPath("$[0].userId", equalTo(TEST_USERNAME))); + String tokenResponse = mvc .perform( @@ -407,8 +414,6 @@ public void testDeviceCodeApprovalFlowWorks() throws Exception { String authorizationHeader = String.format("Bearer %s", accessToken); - - // Check that the token can be used for userinfo and introspection mvc.perform(get(USERINFO_ENDPOINT).header("Authorization", authorizationHeader)) .andExpect(status().isOk()); @@ -565,7 +570,7 @@ public void deviceCodeWorksForDynamicallyRegisteredClient() session = (MockHttpSession) mvc .perform(post(DEVICE_USER_VERIFY_URL).param("user_code", userCode).session(session)) .andExpect(status().isOk()) - .andExpect(view().name("approveDevice")) + .andExpect(view().name("iam/approveDevice")) .andReturn() .getRequest() .getSession(); @@ -603,7 +608,6 @@ public void deviceCodeWorksForDynamicallyRegisteredClient() String authorizationHeader = String.format("Bearer %s", accessToken); - // Check that the token can be used for userinfo and introspection mvc.perform(get(USERINFO_ENDPOINT).header("Authorization", authorizationHeader)) .andExpect(status().isOk()); @@ -688,7 +692,7 @@ public void publicClientDeviceCodeWorks() throws Exception { session = (MockHttpSession) mvc .perform(post(DEVICE_USER_VERIFY_URL).param("user_code", userCode).session(session)) .andExpect(status().isOk()) - .andExpect(view().name("approveDevice")) + .andExpect(view().name("iam/approveDevice")) .andReturn() .getRequest() .getSession(); @@ -724,7 +728,6 @@ public void publicClientDeviceCodeWorks() throws Exception { String authorizationHeader = String.format("Bearer %s", accessToken); - // Check that the token can be used for userinfo mvc.perform(get(USERINFO_ENDPOINT).header("Authorization", authorizationHeader)) .andExpect(status().isOk()); } @@ -798,7 +801,7 @@ public void testRefreshedTokenAfterDeviceCodeApprovalFlowWorks() throws Exceptio session = (MockHttpSession) mvc .perform(post(DEVICE_USER_VERIFY_URL).param("user_code", userCode).session(session)) .andExpect(status().isOk()) - .andExpect(view().name("approveDevice")) + .andExpect(view().name("iam/approveDevice")) .andReturn() .getRequest() .getSession(); @@ -843,7 +846,6 @@ public void testRefreshedTokenAfterDeviceCodeApprovalFlowWorks() throws Exceptio String authorizationHeader = String.format("Bearer %s", accessToken); - // Check that the token can be used for userinfo and introspection mvc.perform(get(USERINFO_ENDPOINT).header("Authorization", authorizationHeader)) .andExpect(status().isOk()); @@ -897,4 +899,5 @@ public void testRefreshedTokenAfterDeviceCodeApprovalFlowWorks() throws Exceptio .andExpect(status().isForbidden()); } + } diff --git a/iam-login-service/src/test/java/it/infn/mw/iam/test/oauth/devicecode/DeviceCodeTestsConstants.java b/iam-login-service/src/test/java/it/infn/mw/iam/test/oauth/devicecode/DeviceCodeTestsConstants.java index 737c01793..6a3ebcab4 100644 --- a/iam-login-service/src/test/java/it/infn/mw/iam/test/oauth/devicecode/DeviceCodeTestsConstants.java +++ b/iam-login-service/src/test/java/it/infn/mw/iam/test/oauth/devicecode/DeviceCodeTestsConstants.java @@ -37,5 +37,7 @@ public interface DeviceCodeTestsConstants { public static final String LOGIN_URL = "/login"; public static final String TEST_USERNAME = "test"; public static final String TEST_PASSWORD = "password"; + + public static final String WRONG_VERIFICATION_URI_COMPLETE = "/device?user_code="; } diff --git a/iam-login-service/src/test/java/it/infn/mw/iam/test/oauth/scope/StructuredScopeRequestIntegrationTests.java b/iam-login-service/src/test/java/it/infn/mw/iam/test/oauth/scope/StructuredScopeRequestIntegrationTests.java index 0a1fcb993..a3ef605c2 100644 --- a/iam-login-service/src/test/java/it/infn/mw/iam/test/oauth/scope/StructuredScopeRequestIntegrationTests.java +++ b/iam-login-service/src/test/java/it/infn/mw/iam/test/oauth/scope/StructuredScopeRequestIntegrationTests.java @@ -207,7 +207,7 @@ public void testDeviceCodeStructuredScopeRequest() throws Exception { session = (MockHttpSession) mvc .perform(post(DEVICE_USER_VERIFY_URL).param("user_code", userCode).session(session)) .andExpect(status().isOk()) - .andExpect(view().name("approveDevice")) + .andExpect(view().name("iam/approveDevice")) .andReturn() .getRequest() .getSession(); diff --git a/iam-login-service/src/test/java/it/infn/mw/iam/test/oauth/scope/pdp/ScopePolicyFilteringDeviceCodeTests.java b/iam-login-service/src/test/java/it/infn/mw/iam/test/oauth/scope/pdp/ScopePolicyFilteringDeviceCodeTests.java new file mode 100644 index 000000000..371a63d81 --- /dev/null +++ b/iam-login-service/src/test/java/it/infn/mw/iam/test/oauth/scope/pdp/ScopePolicyFilteringDeviceCodeTests.java @@ -0,0 +1,198 @@ +/** + * Copyright (c) Istituto Nazionale di Fisica Nucleare (INFN). 2016-2021 + * + * 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 it.infn.mw.iam.test.oauth.scope.pdp; + +import static com.google.common.collect.Sets.newHashSet; +import static it.infn.mw.iam.persistence.model.IamScopePolicy.MatchingPolicy.PATH; +import static org.springframework.http.MediaType.APPLICATION_FORM_URLENCODED; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.httpBasic; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.model; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.redirectedUrl; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.view; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mitre.oauth2.model.SystemScope; +import org.mitre.oauth2.service.SystemScopeService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.mock.web.MockHttpSession; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.junit4.SpringRunner; +import org.springframework.test.web.servlet.MockMvc; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.common.collect.Sets; + +import it.infn.mw.iam.persistence.model.IamAccount; +import it.infn.mw.iam.persistence.model.IamScopePolicy; +import it.infn.mw.iam.persistence.model.PolicyRule; +import it.infn.mw.iam.persistence.repository.IamAccountRepository; +import it.infn.mw.iam.persistence.repository.IamScopePolicyRepository; +import it.infn.mw.iam.test.repository.ScopePolicyTestUtils; +import it.infn.mw.iam.test.util.annotation.IamMockMvcIntegrationTest; + +@ActiveProfiles({"h2-test", "h2", "wlcg-scopes"}) +@RunWith(SpringRunner.class) +@IamMockMvcIntegrationTest +public class ScopePolicyFilteringDeviceCodeTests extends ScopePolicyTestUtils { + + @Autowired + private IamAccountRepository accountRepo; + + @Autowired + private IamScopePolicyRepository scopePolicyRepo; + + @Autowired + private SystemScopeService scopeService; + + @Autowired + private MockMvc mvc; + + @Autowired + protected ObjectMapper mapper; + + IamAccount findTestAccount() { + return accountRepo.findByUsername("test") + .orElseThrow(() -> new AssertionError("Expected test account not found!")); + } + + private void setupPolicyAndScopes() { + IamScopePolicy up = initDenyScopePolicy(); + up.setRule(PolicyRule.DENY); + up.setScopes(newHashSet("storage.read:/", "storage.write:/")); + up.setMatchingPolicy(PATH); + + scopePolicyRepo.save(up); + + scopeService.save(new SystemScope("storage.read:/")); + scopeService.save(new SystemScope("storage.write:/")); + } + + @Test + public void deviceCodeFlowScopeFilteringByAccountWorks() throws Exception { + + IamAccount testAccount = findTestAccount(); + + IamScopePolicy up = initDenyScopePolicy(); + up.setAccount(testAccount); + up.setRule(PolicyRule.DENY); + up.setScopes(Sets.newHashSet("profile")); + + scopePolicyRepo.save(up); + + String response = mvc + .perform(post("/devicecode").contentType(APPLICATION_FORM_URLENCODED) + .with(httpBasic("device-code-client", "secret")) + .param("client_id", "device-code-client") + .param("scope", "openid profile email")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.user_code").isString()) + .andExpect(jsonPath("$.device_code").isString()) + .andReturn() + .getResponse() + .getContentAsString(); + + JsonNode responseJson = mapper.readTree(response); + String userCode = responseJson.get("user_code").asText(); + + MockHttpSession session = (MockHttpSession) mvc.perform(get("/device")) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("http://localhost/login")) + .andReturn() + .getRequest() + .getSession(); + + session = (MockHttpSession) mvc.perform(get("http://localhost/login").session(session)) + .andExpect(status().isOk()) + .andExpect(view().name("iam/login")) + .andReturn() + .getRequest() + .getSession(); + + session = (MockHttpSession) mvc + .perform(post("/login").param("username", "test") + .param("password", "password") + .param("submit", "Login") + .session(session)) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("http://localhost/device")) + .andReturn() + .getRequest() + .getSession(); + + mvc.perform(post("/device/verify").param("user_code", userCode).session(session)) + .andExpect(status().isOk()) + .andExpect(view().name("iam/approveDevice")) + .andExpect(model().attribute("scope", "openid email")); + + } + + @Test + public void deviceCodeMatchingPolicyFilteringWorks() throws Exception { + setupPolicyAndScopes(); + + String response = mvc + .perform(post("/devicecode").contentType(APPLICATION_FORM_URLENCODED) + .with(httpBasic("device-code-client", "secret")) + .param("client_id", "device-code-client") + .param("scope", "openid profile storage.read:/ storage.read:/that/thing storage.write:/")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.user_code").isString()) + .andExpect(jsonPath("$.device_code").isString()) + .andReturn() + .getResponse() + .getContentAsString(); + + JsonNode responseJson = mapper.readTree(response); + String userCode = responseJson.get("user_code").asText(); + + MockHttpSession session = (MockHttpSession) mvc.perform(get("/device")) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("http://localhost/login")) + .andReturn() + .getRequest() + .getSession(); + + session = (MockHttpSession) mvc.perform(get("http://localhost/login").session(session)) + .andExpect(status().isOk()) + .andExpect(view().name("iam/login")) + .andReturn() + .getRequest() + .getSession(); + + session = (MockHttpSession) mvc + .perform(post("/login").param("username", "test") + .param("password", "password") + .param("submit", "Login") + .session(session)) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("http://localhost/device")) + .andReturn() + .getRequest() + .getSession(); + + mvc.perform(post("/device/verify").param("user_code", userCode).session(session)) + .andExpect(status().isOk()) + .andExpect(view().name("iam/approveDevice")) + .andExpect(model().attribute("scope", "openid profile")); + } + +} diff --git a/iam-login-service/src/test/java/it/infn/mw/iam/test/oauth/scope/pdp/ScopePolicyFilteringIntegrationTests.java b/iam-login-service/src/test/java/it/infn/mw/iam/test/oauth/scope/pdp/ScopePolicyFilteringIntegrationTests.java index dd6882b24..2e22ebd8c 100644 --- a/iam-login-service/src/test/java/it/infn/mw/iam/test/oauth/scope/pdp/ScopePolicyFilteringIntegrationTests.java +++ b/iam-login-service/src/test/java/it/infn/mw/iam/test/oauth/scope/pdp/ScopePolicyFilteringIntegrationTests.java @@ -35,6 +35,7 @@ import org.springframework.test.context.junit4.SpringRunner; import org.springframework.test.web.servlet.MockMvc; +import com.fasterxml.jackson.databind.ObjectMapper; import com.google.common.collect.Sets; import it.infn.mw.iam.persistence.model.IamAccount; @@ -72,6 +73,9 @@ public class ScopePolicyFilteringIntegrationTests extends ScopePolicyTestUtils { @Autowired private MockMvc mvc; + @Autowired + protected ObjectMapper mapper; + IamAccount findTestAccount() { return accountRepo.findByUsername("test") .orElseThrow(() -> new AssertionError("Expected test account not found!")); diff --git a/iam-persistence/src/main/resources/db/migration/test/V100000___test_data.sql b/iam-persistence/src/main/resources/db/migration/test/V100000___test_data.sql index 0d2b46151..388333c5d 100644 --- a/iam-persistence/src/main/resources/db/migration/test/V100000___test_data.sql +++ b/iam-persistence/src/main/resources/db/migration/test/V100000___test_data.sql @@ -123,6 +123,8 @@ INSERT INTO client_scope (owner_id, scope) VALUES (12, 'address'), (12, 'phone'), (12, 'offline_access'), + (12, 'storage.read:/'), + (12, 'storage.write:/'), (13, 'openid'), (13, 'profile'), (13, 'email'),