-
Notifications
You must be signed in to change notification settings - Fork 43
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Create an approved site when using device code flow #821
Open
federicaagostini
wants to merge
49
commits into
develop
Choose a base branch
from
issue-595
base: develop
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from 15 commits
Commits
Show all changes
49 commits
Select commit
Hold shift + click to select a range
058697b
Assign oidc-agent clients to whom approved it (#812)
federicaagostini 19224bd
Fix Sonar issues
federicaagostini 636f7b6
Add tests
federicaagostini 6ee8e23
Restore IamOAuthConfirmationController class
federicaagostini e7d89e5
Add more tests
federicaagostini cc52027
Add some test
federicaagostini 46ba6ad
Hopefully increase coverage
federicaagostini 541bff2
Rename test class
federicaagostini df46a08
Fake commit to trigger Sonar
federicaagostini 46dd366
Fix issues
federicaagostini 100ff9d
Cosmetic fix
federicaagostini aa04ce1
Add approved sites when using device code flow
federicaagostini 3dd7862
Add test
federicaagostini bf2b4a2
Remove issues
federicaagostini 9ade9b1
Filter scope before device consent page
rmiccoli 0e1954a
Hopefully fix tests
federicaagostini 18d7c6a
Remove failing test
federicaagostini 7fabb49
WIP: import device code approval phase in IAM
federicaagostini 7781b7a
Change tests expectation
federicaagostini 8a2cfd1
Use same consent logic as authz code flow
federicaagostini eb9690d
Fix tests
federicaagostini 754cca5
Fix Sonar issues
federicaagostini e471749
Migrate TofuUserApprovalHandler class into IAM
federicaagostini ced7bfc
Use OAuth2 parameters
federicaagostini 63aeda9
Fix Sonar issues
federicaagostini 41df699
Improve coverage
federicaagostini 911c1c3
Fix Sonar issues and improve coverage
federicaagostini 732e487
Hopefully fix test
federicaagostini e333548
Fix Sonar issues
federicaagostini b4973c1
Cleanup
federicaagostini 1bf90fd
Fix authZ code flow consent
federicaagostini cab9012
An already approved device code cannot be reused
federicaagostini e8d8f6b
Hopefully cleanup redundancy checks
federicaagostini 2823adc
Small fixes
federicaagostini cd4b906
More tests
federicaagostini 0204c94
Fix and add tests
federicaagostini 337428a
Fix Sonar issue
federicaagostini d37f47f
Move scope filter into dedicated class
federicaagostini c3c77f1
Fix and add test
federicaagostini 49dccc4
Change scope filter logic
federicaagostini eaaf0c6
Cleanup
federicaagostini e405c5d
Fix Sonar issue
federicaagostini af82375
One more test
federicaagostini c33c406
Correct sorting method and add tests
federicaagostini fefa959
Unify methods for showing info in consent page
federicaagostini b712586
Fix Sonar issues
federicaagostini f8ecfdd
Remove oidc-agent client linking
federicaagostini f682797
Rename test class
federicaagostini 3795bd7
Remove unused import
federicaagostini File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
314 changes: 314 additions & 0 deletions
314
iam-login-service/src/main/java/it/infn/mw/iam/core/oauth/IamDeviceEndpointController.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,314 @@ | ||
/** | ||
* 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.net.URI; | ||
import java.net.URISyntaxException; | ||
import java.util.Collection; | ||
import java.util.Date; | ||
import java.util.HashMap; | ||
import java.util.LinkedHashSet; | ||
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.service.ApprovedSiteService; | ||
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.OAuth2RequestFactory; | ||
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 com.google.common.collect.Sets; | ||
|
||
import it.infn.mw.iam.api.account.AccountUtils; | ||
import it.infn.mw.iam.api.client.service.ClientService; | ||
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; | ||
import static it.infn.mw.iam.core.oauth.IamUserApprovalHandler.OIDC_AGENT_PREFIX_NAME; | ||
|
||
@SuppressWarnings("deprecation") | ||
@Controller | ||
public class IamDeviceEndpointController { | ||
|
||
public static final String URL = "devicecode"; | ||
public static final String USER_URL = "device"; | ||
|
||
private static final String REQUEST_USER_CODE_STRING = "requestUserCode"; | ||
private static final String ERROR_STRING = "error"; | ||
|
||
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 OAuth2RequestFactory oAuth2RequestFactory; | ||
|
||
@Autowired | ||
private AccountUtils accountUtils; | ||
|
||
@Autowired | ||
private ClientService clientService; | ||
|
||
@Autowired | ||
private ApprovedSiteService approvedSiteService; | ||
|
||
@Autowired | ||
private ScopePolicyPDP pdp; | ||
|
||
@RequestMapping(value = "/" + 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<String, String> 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<String> requestedScopes = OAuth2Utils.parseParameterList(scope); | ||
Set<String> 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<String, Object> response = new HashMap<>(); | ||
response.put("device_code", dc.getDeviceCode()); | ||
response.put("user_code", dc.getUserCode()); | ||
response.put("verification_uri", config.getIssuer() + USER_URL); | ||
if (client.getDeviceCodeValiditySeconds() != null) { | ||
response.put("expires_in", client.getDeviceCodeValiditySeconds()); | ||
} | ||
|
||
if (config.isAllowCompleteDeviceCodeUri()) { | ||
URI verificationUriComplete = new URIBuilder(config.getIssuer() + USER_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_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_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); | ||
model.put("dc", dc); | ||
|
||
IamAccount account = accountUtils.getAuthenticatedUserAccount(authn) | ||
.orElseThrow(() -> NoSuchAccountError.forUsername(authn.getName())); | ||
|
||
sortScopesForApproval(dc, model, account); | ||
|
||
AuthorizationRequest authorizationRequest = | ||
oAuth2RequestFactory.createAuthorizationRequest(dc.getRequestParameters()); | ||
|
||
session.setAttribute("authorizationRequest", authorizationRequest); | ||
session.setAttribute("deviceCode", dc); | ||
|
||
return "approveDevice"; | ||
} | ||
|
||
@PreAuthorize("hasRole('ROLE_USER')") | ||
@RequestMapping(value = "/" + USER_URL + "/approve", method = RequestMethod.POST) | ||
public String approveDevice(@RequestParam("user_code") String userCode, | ||
@RequestParam(value = "user_oauth_approval") Boolean approve, 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("approved", false); | ||
return "deviceApproved"; | ||
} | ||
|
||
OAuth2Request o2req = oAuth2RequestFactory.createOAuth2Request(authorizationRequest); | ||
OAuth2Authentication o2Auth = new OAuth2Authentication(o2req, auth); | ||
|
||
deviceCodeService.approveDeviceCode(dc, o2Auth); | ||
|
||
IamAccount account = accountUtils.getAuthenticatedUserAccount(auth) | ||
.orElseThrow(() -> NoSuchAccountError.forUsername(auth.getName())); | ||
|
||
sortScopesForApproval(dc, model, account); | ||
|
||
model.put("approved", true); | ||
|
||
Date timeout = null; | ||
approvedSiteService.createApprovedSite(client.getClientId(), auth.getName(), timeout, | ||
dc.getScope()); | ||
|
||
if (client.getClientName().startsWith(OIDC_AGENT_PREFIX_NAME)) { | ||
clientService.linkClientToAccount(client, account); | ||
} | ||
|
||
return "deviceApproved"; | ||
} | ||
|
||
private void checkAuthzGrant(ClientDetailsEntity client) { | ||
Collection<String> authorizedGrantTypes = client.getAuthorizedGrantTypes(); | ||
if (authorizedGrantTypes != null && !authorizedGrantTypes.isEmpty() | ||
&& !authorizedGrantTypes.contains(DeviceTokenGranter.GRANT_TYPE)) { | ||
throw new InvalidClientException("Unauthorized grant type: " + DeviceTokenGranter.GRANT_TYPE); | ||
} | ||
} | ||
|
||
private void sortScopesForApproval(DeviceCode dc, ModelMap model, IamAccount account) { | ||
|
||
Set<SystemScope> scopes = scopeService.fromStrings(dc.getScope()); | ||
|
||
Set<SystemScope> sortedScopes = new LinkedHashSet<>(scopes.size()); | ||
Set<SystemScope> systemScopes = scopeService.getAll(); | ||
|
||
Set<String> filteredScopes = pdp.filterScopes(scopeService.toStrings(scopes), account); | ||
|
||
for (SystemScope s : systemScopes) { | ||
if (scopeService.fromStrings(filteredScopes).contains(s)) { | ||
sortedScopes.add(s); | ||
} | ||
} | ||
|
||
sortedScopes.addAll(Sets.difference(scopeService.fromStrings(filteredScopes), systemScopes)); | ||
|
||
model.put("scopes", sortedScopes); | ||
|
||
} | ||
|
||
} |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The problem here is the case where there is a scope policy. Since it is evaluated after this step, in the approved sites we will see also the filtered scope - and in fact we see it on the consent page and actually approve it, even though it is then removed from the scope policy filter in the access token.
In general, this mitre code needs to be improved later, but as a start I think it might be fine like this!
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Scope policy filter added before the device consent page!