diff --git a/openid-connect-common/src/main/java/org/mitre/oauth2/model/DeviceCode.java b/openid-connect-common/src/main/java/org/mitre/oauth2/model/DeviceCode.java new file mode 100644 index 000000000..9f7fa8eba --- /dev/null +++ b/openid-connect-common/src/main/java/org/mitre/oauth2/model/DeviceCode.java @@ -0,0 +1,207 @@ +/******************************************************************************* + * Copyright 2017 The MITRE Corporation + * and the MIT Internet Trust Consortium + * + * 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 org.mitre.oauth2.model; + +import java.util.Map; +import java.util.Set; + +import javax.persistence.Basic; +import javax.persistence.CollectionTable; +import javax.persistence.Column; +import javax.persistence.ElementCollection; +import javax.persistence.Entity; +import javax.persistence.FetchType; +import javax.persistence.GeneratedValue; +import javax.persistence.GenerationType; +import javax.persistence.Id; +import javax.persistence.JoinColumn; +import javax.persistence.ManyToOne; +import javax.persistence.MapKeyColumn; +import javax.persistence.Table; + +/** + * @author jricher + * + */ +@Entity +@Table(name = "device_code") +public class DeviceCode { + + private Long id; + private String deviceCode; + private String userCode; + private Set scope; + private String clientId; + private Map requestParameters; + private boolean approved; + private AuthenticationHolderEntity authenticationHolder; + + public DeviceCode() { + + } + + public DeviceCode(String deviceCode, String userCode, Set scope, String clientId, Map params) { + this.deviceCode = deviceCode; + this.userCode = userCode; + this.scope = scope; + this.clientId = clientId; + this.requestParameters = params; + this.setApproved(false); + } + + /** + * @return the id + */ + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + public Long getId() { + return id; + } + + /** + * @param id the id to set + */ + public void setId(Long id) { + this.id = id; + } + + /** + * @return the deviceCode + */ + @Basic + @Column(name = "device_code") + public String getDeviceCode() { + return deviceCode; + } + + /** + * @param deviceCode the deviceCode to set + */ + public void setDeviceCode(String deviceCode) { + this.deviceCode = deviceCode; + } + + /** + * @return the userCode + */ + @Basic + @Column(name = "user_code") + public String getUserCode() { + return userCode; + } + + /** + * @param userCode the userCode to set + */ + public void setUserCode(String userCode) { + this.userCode = userCode; + } + + /** + * @return the scope + */ + @ElementCollection(fetch = FetchType.EAGER) + @CollectionTable( + name="device_code_scope", + joinColumns=@JoinColumn(name="owner_id") + ) + @Column(name="scope") + public Set getScope() { + return scope; + } + + /** + * @param scope the scope to set + */ + public void setScope(Set scope) { + this.scope = scope; + } + + /** + * @return the clientId + */ + @Basic + @Column(name = "client_id") + public String getClientId() { + return clientId; + } + + /** + * @param clientId the clientId to set + */ + public void setClientId(String clientId) { + this.clientId = clientId; + } + + /** + * @return the params + */ + @ElementCollection(fetch = FetchType.EAGER) + @CollectionTable( + name="device_code_request_parameter", + joinColumns=@JoinColumn(name="owner_id") + ) + @Column(name="val") + @MapKeyColumn(name="param") + public Map getRequestParameters() { + return requestParameters; + } + + /** + * @param params the params to set + */ + public void setRequestParameters(Map params) { + this.requestParameters = params; + } + + /** + * @return the approved + */ + @Basic + @Column(name = "approved") + public boolean isApproved() { + return approved; + } + + /** + * @param approved the approved to set + */ + public void setApproved(boolean approved) { + this.approved = approved; + } + + /** + * The authentication in place when this token was created. + * @return the authentication + */ + @ManyToOne + @JoinColumn(name = "auth_holder_id") + public AuthenticationHolderEntity getAuthenticationHolder() { + return authenticationHolder; + } + + /** + * @param authentication the authentication to set + */ + public void setAuthenticationHolder(AuthenticationHolderEntity authenticationHolder) { + this.authenticationHolder = authenticationHolder; + } + + +} diff --git a/openid-connect-common/src/main/java/org/mitre/oauth2/service/DeviceCodeService.java b/openid-connect-common/src/main/java/org/mitre/oauth2/service/DeviceCodeService.java new file mode 100644 index 000000000..ed354744f --- /dev/null +++ b/openid-connect-common/src/main/java/org/mitre/oauth2/service/DeviceCodeService.java @@ -0,0 +1,52 @@ +/******************************************************************************* + * Copyright 2017 The MITRE Corporation + * and the MIT Internet Trust Consortium + * + * 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 org.mitre.oauth2.service; + +import org.mitre.oauth2.model.DeviceCode; +import org.springframework.security.oauth2.provider.ClientDetails; + +/** + * @author jricher + * + */ +public interface DeviceCodeService { + + /** + * @param dc + */ + public DeviceCode save(DeviceCode dc); + + /** + * @param userCode + * @return + */ + public DeviceCode lookUpByUserCode(String userCode); + + /** + * @param dc + */ + public DeviceCode approveDeviceCode(DeviceCode dc); + + /** + * @param deviceCode + * @param client + * @return + */ + public DeviceCode consumeDeviceCode(String deviceCode, ClientDetails client); + +} diff --git a/openid-connect-server/src/main/java/org/mitre/oauth2/token/ChainedTokenGranter.java b/openid-connect-server/src/main/java/org/mitre/oauth2/token/ChainedTokenGranter.java index 566395742..acb36a5d2 100644 --- a/openid-connect-server/src/main/java/org/mitre/oauth2/token/ChainedTokenGranter.java +++ b/openid-connect-server/src/main/java/org/mitre/oauth2/token/ChainedTokenGranter.java @@ -45,7 +45,7 @@ import com.google.common.collect.Sets; @Component("chainedTokenGranter") public class ChainedTokenGranter extends AbstractTokenGranter { - private static final String grantType = "urn:ietf:params:oauth:grant_type:redelegate"; + public static final String GRANT_TYPE = "urn:ietf:params:oauth:grant_type:redelegate"; // keep down-cast versions so we can get to the right queries private OAuth2TokenEntityService tokenServices; @@ -53,11 +53,11 @@ public class ChainedTokenGranter extends AbstractTokenGranter { /** * @param tokenServices * @param clientDetailsService - * @param grantType + * @param GRANT_TYPE */ @Autowired public ChainedTokenGranter(OAuth2TokenEntityService tokenServices, ClientDetailsEntityService clientDetailsService, OAuth2RequestFactory requestFactory) { - super(tokenServices, clientDetailsService, requestFactory, grantType); + super(tokenServices, clientDetailsService, requestFactory, GRANT_TYPE); this.tokenServices = tokenServices; } diff --git a/openid-connect-server/src/main/java/org/mitre/oauth2/token/DeviceTokenGranter.java b/openid-connect-server/src/main/java/org/mitre/oauth2/token/DeviceTokenGranter.java new file mode 100644 index 000000000..5fd7fbd34 --- /dev/null +++ b/openid-connect-server/src/main/java/org/mitre/oauth2/token/DeviceTokenGranter.java @@ -0,0 +1,85 @@ +/******************************************************************************* + * Copyright 2017 The MITRE Corporation + * and the MIT Internet Trust Consortium + * + * 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 org.mitre.oauth2.token; + +import org.mitre.oauth2.model.DeviceCode; +import org.mitre.oauth2.service.DeviceCodeService; +import org.mitre.oauth2.web.DeviceEndpoint; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.oauth2.common.exceptions.InvalidGrantException; +import org.springframework.security.oauth2.provider.ClientDetails; +import org.springframework.security.oauth2.provider.ClientDetailsService; +import org.springframework.security.oauth2.provider.OAuth2Authentication; +import org.springframework.security.oauth2.provider.OAuth2RequestFactory; +import org.springframework.security.oauth2.provider.TokenRequest; +import org.springframework.security.oauth2.provider.token.AbstractTokenGranter; +import org.springframework.security.oauth2.provider.token.AuthorizationServerTokenServices; + +/** + * Implements https://tools.ietf.org/html/draft-ietf-oauth-device-flow + * + * @see DeviceEndpoint + * + * @author jricher + * + */ +public class DeviceTokenGranter extends AbstractTokenGranter { + + public static final String GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code"; + + @Autowired + private DeviceCodeService deviceCodeService; + + /** + * @param tokenServices + * @param clientDetailsService + * @param requestFactory + * @param grantType + */ + protected DeviceTokenGranter(AuthorizationServerTokenServices tokenServices, ClientDetailsService clientDetailsService, OAuth2RequestFactory requestFactory) { + super(tokenServices, clientDetailsService, requestFactory, GRANT_TYPE); + } + + /* (non-Javadoc) + * @see org.springframework.security.oauth2.provider.token.AbstractTokenGranter#getOAuth2Authentication(org.springframework.security.oauth2.provider.ClientDetails, org.springframework.security.oauth2.provider.TokenRequest) + */ + @Override + protected OAuth2Authentication getOAuth2Authentication(ClientDetails client, TokenRequest tokenRequest) { + + String deviceCode = tokenRequest.getRequestParameters().get("device_code"); + + // look up the device code and consume it + DeviceCode dc = deviceCodeService.consumeDeviceCode(deviceCode, client); + + if (dc != null) { + // inherit the (approved) scopes from the original request + tokenRequest.setScope(dc.getScope()); + + OAuth2Authentication auth = new OAuth2Authentication(getRequestFactory().createOAuth2Request(client, tokenRequest), dc.getAuthenticationHolder().getUserAuth()); + + return auth; + } else { + throw new InvalidGrantException("Invalid device code: " + deviceCode); + } + + } + + + + +} diff --git a/openid-connect-server/src/main/java/org/mitre/oauth2/web/DeviceEndpoint.java b/openid-connect-server/src/main/java/org/mitre/oauth2/web/DeviceEndpoint.java new file mode 100644 index 000000000..18e83a167 --- /dev/null +++ b/openid-connect-server/src/main/java/org/mitre/oauth2/web/DeviceEndpoint.java @@ -0,0 +1,229 @@ +/******************************************************************************* + * Copyright 2017 The MITRE Corporation + * and the MIT Internet Trust Consortium + * + * 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 org.mitre.oauth2.web; + +import java.util.Collection; +import java.util.LinkedHashSet; +import java.util.Map; +import java.util.Set; +import java.util.UUID; + +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.oauth2.common.exceptions.InvalidClientException; +import org.springframework.security.oauth2.common.exceptions.OAuth2Exception; +import org.springframework.security.oauth2.common.util.OAuth2Utils; +import org.springframework.security.oauth2.common.util.RandomValueStringGenerator; +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.ImmutableMap; +import com.google.common.collect.Sets; + +/** + * Implements https://tools.ietf.org/html/draft-ietf-oauth-device-flow + * + * @see DeviceTokenGranter + * + * @author jricher + * + */ +@Controller +public class DeviceEndpoint { + + public static final String URL = "/device"; + + public static final Logger logger = LoggerFactory.getLogger(DeviceEndpoint.class); + + @Autowired + private ClientDetailsEntityService clientService; + + @Autowired + private SystemScopeService scopeService; + + @Autowired + private ConfigurationPropertiesBean config; + + @Autowired + private DeviceCodeService deviceCodeService; + + private RandomValueStringGenerator randomGenerator = new RandomValueStringGenerator(); + + @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 parameters, ModelMap model) { + + ClientDetailsEntity client; + try { + client = clientService.loadClientByClientId(clientId); + + // make sure this client can do the device flow + + Collection authorizedGrantTypes = client.getAuthorizedGrantTypes(); + if (authorizedGrantTypes != null && !authorizedGrantTypes.isEmpty() + && !authorizedGrantTypes.contains(DeviceTokenGranter.GRANT_TYPE)) { + throw new InvalidClientException("Unauthorized grant type: " + DeviceTokenGranter.GRANT_TYPE); + } + + } catch (OAuth2Exception e) { + logger.error("OAuth2Exception was thrown when attempting to load client", e); + model.put(HttpCodeView.CODE, HttpStatus.BAD_REQUEST); + return HttpCodeView.VIEWNAME; + } catch (IllegalArgumentException e) { + logger.error("IllegalArgumentException was thrown when attempting to load client", e); + model.put(HttpCodeView.CODE, HttpStatus.BAD_REQUEST); + return HttpCodeView.VIEWNAME; + } + + if (client == null) { + logger.error("could not find client " + clientId); + model.put(HttpCodeView.CODE, HttpStatus.NOT_FOUND); + return HttpCodeView.VIEWNAME; + } + + // make sure the client is allowed to ask for those scopes + Set requestedScopes = OAuth2Utils.parseParameterList(scope); + Set allowedScopes = client.getScope(); + + if (!scopeService.scopesMatch(allowedScopes, requestedScopes)) { + // client asked for scopes it can't have + logger.error("Client asked for " + requestedScopes + " but is allowed " + allowedScopes); + model.put(HttpCodeView.CODE, HttpStatus.BAD_REQUEST); + model.put(JsonErrorView.ERROR, "invalid_scope"); + return JsonErrorView.VIEWNAME; + } + + // if we got here the request is legit + + // create a device code, should be big and random + String deviceCode = UUID.randomUUID().toString(); + + // create a user code, should be random but small and typable + String userCode = randomGenerator.generate(); + + // TODO: expiration + model.put(JsonEntityView.ENTITY, ImmutableMap.of( + "device_code", deviceCode, + "user_code", userCode, + "verification_uri", config.getIssuer() + URL + )); + + DeviceCode dc = new DeviceCode(deviceCode, userCode, requestedScopes, clientId, parameters); + + + deviceCodeService.save(dc); + + return JsonEntityView.VIEWNAME; + + } + + @PreAuthorize("hasRole('ROLE_USER')") + @RequestMapping(value = URL, method = RequestMethod.GET) + public String requestUserCode(ModelMap model) { + + // print out a page that asks the user to enter their user code + // user must be logged in + + return "requestUserCode"; + } + + @PreAuthorize("hasRole('ROLE_USER')") + @RequestMapping(value = URL + "/verify", method = RequestMethod.POST) + public String readUserCode(@RequestParam("userCode") String userCode, ModelMap model) { + + // look up the request based on the user code + DeviceCode dc = deviceCodeService.lookUpByUserCode(userCode); + + ClientDetailsEntity client = clientService.loadClientByClientId(dc.getClientId()); + + model.put("client", client); + model.put("dc", dc); + + // pre-process the scopes + Set scopes = scopeService.fromStrings(dc.getScope()); + + Set sortedScopes = new LinkedHashSet<>(scopes.size()); + Set systemScopes = scopeService.getAll(); + + // sort scopes for display based on the inherent order of system scopes + for (SystemScope s : systemScopes) { + if (scopes.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); + + return "approveDevice"; + } + + @PreAuthorize("hasRole('ROLE_USER')") + @RequestMapping(value = URL + "/approve", method = RequestMethod.POST) + public String approveDevice(@RequestParam("userCode") String userCode, @RequestParam(value = "approve", required = false) String approve, ModelMap model) { + + + DeviceCode dc = deviceCodeService.lookUpByUserCode(userCode); + + DeviceCode approvedCode = deviceCodeService.approveDeviceCode(dc); + + ClientDetailsEntity client = clientService.loadClientByClientId(dc.getClientId()); + + model.put("client", client); + + // pre-process the scopes + Set scopes = scopeService.fromStrings(dc.getScope()); + + Set sortedScopes = new LinkedHashSet<>(scopes.size()); + Set systemScopes = scopeService.getAll(); + + // sort scopes for display based on the inherent order of system scopes + for (SystemScope s : systemScopes) { + if (scopes.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); + + + return "deviceApproved"; + } +}