001/******************************************************************************* 002 * Copyright 2018 The MIT Internet Trust Consortium 003 * 004 * Licensed under the Apache License, Version 2.0 (the "License"); 005 * you may not use this file except in compliance with the License. 006 * You may obtain a copy of the License at 007 * 008 * http://www.apache.org/licenses/LICENSE-2.0 009 * 010 * Unless required by applicable law or agreed to in writing, software 011 * distributed under the License is distributed on an "AS IS" BASIS, 012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 013 * See the License for the specific language governing permissions and 014 * limitations under the License. 015 *******************************************************************************/ 016 017package org.mitre.oauth2.web; 018 019import java.net.URI; 020import java.net.URISyntaxException; 021import java.util.Collection; 022import java.util.Date; 023import java.util.HashMap; 024import java.util.LinkedHashSet; 025import java.util.Map; 026import java.util.Set; 027import java.util.UUID; 028 029import javax.servlet.http.HttpSession; 030 031import org.apache.http.client.utils.URIBuilder; 032import org.mitre.oauth2.exception.DeviceCodeCreationException; 033import org.mitre.oauth2.model.ClientDetailsEntity; 034import org.mitre.oauth2.model.DeviceCode; 035import org.mitre.oauth2.model.SystemScope; 036import org.mitre.oauth2.service.ClientDetailsEntityService; 037import org.mitre.oauth2.service.DeviceCodeService; 038import org.mitre.oauth2.service.SystemScopeService; 039import org.mitre.oauth2.token.DeviceTokenGranter; 040import org.mitre.openid.connect.config.ConfigurationPropertiesBean; 041import org.mitre.openid.connect.view.HttpCodeView; 042import org.mitre.openid.connect.view.JsonEntityView; 043import org.mitre.openid.connect.view.JsonErrorView; 044import org.slf4j.Logger; 045import org.slf4j.LoggerFactory; 046import org.springframework.beans.factory.annotation.Autowired; 047import org.springframework.http.HttpStatus; 048import org.springframework.http.MediaType; 049import org.springframework.security.access.prepost.PreAuthorize; 050import org.springframework.security.core.Authentication; 051import org.springframework.security.oauth2.common.exceptions.InvalidClientException; 052import org.springframework.security.oauth2.common.util.OAuth2Utils; 053import org.springframework.security.oauth2.common.util.RandomValueStringGenerator; 054import org.springframework.security.oauth2.provider.AuthorizationRequest; 055import org.springframework.security.oauth2.provider.OAuth2Authentication; 056import org.springframework.security.oauth2.provider.OAuth2Request; 057import org.springframework.security.oauth2.provider.OAuth2RequestFactory; 058import org.springframework.stereotype.Controller; 059import org.springframework.ui.ModelMap; 060import org.springframework.web.bind.annotation.RequestMapping; 061import org.springframework.web.bind.annotation.RequestMethod; 062import org.springframework.web.bind.annotation.RequestParam; 063 064import com.google.common.collect.Sets; 065 066/** 067 * Implements https://tools.ietf.org/html/draft-ietf-oauth-device-flow 068 * 069 * @see DeviceTokenGranter 070 * 071 * @author jricher 072 * 073 */ 074@Controller 075public class DeviceEndpoint { 076 077 public static final String URL = "devicecode"; 078 public static final String USER_URL = "device"; 079 080 public static final Logger logger = LoggerFactory.getLogger(DeviceEndpoint.class); 081 082 @Autowired 083 private ClientDetailsEntityService clientService; 084 085 @Autowired 086 private SystemScopeService scopeService; 087 088 @Autowired 089 private ConfigurationPropertiesBean config; 090 091 @Autowired 092 private DeviceCodeService deviceCodeService; 093 094 @Autowired 095 private OAuth2RequestFactory oAuth2RequestFactory; 096 097 @RequestMapping(value = "/" + URL, method = RequestMethod.POST, consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE, produces = MediaType.APPLICATION_JSON_VALUE) 098 public String requestDeviceCode(@RequestParam("client_id") String clientId, @RequestParam(name="scope", required=false) String scope, Map<String, String> parameters, ModelMap model) { 099 100 ClientDetailsEntity client; 101 try { 102 client = clientService.loadClientByClientId(clientId); 103 104 // make sure this client can do the device flow 105 106 Collection<String> authorizedGrantTypes = client.getAuthorizedGrantTypes(); 107 if (authorizedGrantTypes != null && !authorizedGrantTypes.isEmpty() 108 && !authorizedGrantTypes.contains(DeviceTokenGranter.GRANT_TYPE)) { 109 throw new InvalidClientException("Unauthorized grant type: " + DeviceTokenGranter.GRANT_TYPE); 110 } 111 112 } catch (IllegalArgumentException e) { 113 logger.error("IllegalArgumentException was thrown when attempting to load client", e); 114 model.put(HttpCodeView.CODE, HttpStatus.BAD_REQUEST); 115 return HttpCodeView.VIEWNAME; 116 } 117 118 if (client == null) { 119 logger.error("could not find client " + clientId); 120 model.put(HttpCodeView.CODE, HttpStatus.NOT_FOUND); 121 return HttpCodeView.VIEWNAME; 122 } 123 124 // make sure the client is allowed to ask for those scopes 125 Set<String> requestedScopes = OAuth2Utils.parseParameterList(scope); 126 Set<String> allowedScopes = client.getScope(); 127 128 if (!scopeService.scopesMatch(allowedScopes, requestedScopes)) { 129 // client asked for scopes it can't have 130 logger.error("Client asked for " + requestedScopes + " but is allowed " + allowedScopes); 131 model.put(HttpCodeView.CODE, HttpStatus.BAD_REQUEST); 132 model.put(JsonErrorView.ERROR, "invalid_scope"); 133 return JsonErrorView.VIEWNAME; 134 } 135 136 // if we got here the request is legit 137 138 try { 139 DeviceCode dc = deviceCodeService.createNewDeviceCode(requestedScopes, client, parameters); 140 141 Map<String, Object> response = new HashMap<>(); 142 response.put("device_code", dc.getDeviceCode()); 143 response.put("user_code", dc.getUserCode()); 144 response.put("verification_uri", config.getIssuer() + USER_URL); 145 if (client.getDeviceCodeValiditySeconds() != null) { 146 response.put("expires_in", client.getDeviceCodeValiditySeconds()); 147 } 148 149 if (config.isAllowCompleteDeviceCodeUri()) { 150 URI verificationUriComplete = new URIBuilder(config.getIssuer() + USER_URL) 151 .addParameter("user_code", dc.getUserCode()) 152 .build(); 153 154 response.put("verification_uri_complete", verificationUriComplete.toString()); 155 } 156 157 model.put(JsonEntityView.ENTITY, response); 158 159 160 return JsonEntityView.VIEWNAME; 161 } catch (DeviceCodeCreationException dcce) { 162 163 model.put(HttpCodeView.CODE, HttpStatus.BAD_REQUEST); 164 model.put(JsonErrorView.ERROR, dcce.getError()); 165 model.put(JsonErrorView.ERROR_MESSAGE, dcce.getMessage()); 166 167 return JsonErrorView.VIEWNAME; 168 } catch (URISyntaxException use) { 169 logger.error("unable to build verification_uri_complete due to wrong syntax of uri components"); 170 model.put(HttpCodeView.CODE, HttpStatus.INTERNAL_SERVER_ERROR); 171 172 return HttpCodeView.VIEWNAME; 173 } 174 175 } 176 177 @PreAuthorize("hasRole('ROLE_USER')") 178 @RequestMapping(value = "/" + USER_URL, method = RequestMethod.GET) 179 public String requestUserCode(@RequestParam(value = "user_code", required = false) String userCode, ModelMap model, HttpSession session) { 180 181 if (!config.isAllowCompleteDeviceCodeUri() || userCode == null) { 182 // if we don't allow the complete URI or we didn't get a user code on the way in, 183 // print out a page that asks the user to enter their user code 184 // user must be logged in 185 return "requestUserCode"; 186 } else { 187 188 // complete verification uri was used, we received user code directly 189 // skip requesting code page 190 // user must be logged in 191 return readUserCode(userCode, model, session); 192 } 193 } 194 195 @PreAuthorize("hasRole('ROLE_USER')") 196 @RequestMapping(value = "/" + USER_URL + "/verify", method = RequestMethod.POST) 197 public String readUserCode(@RequestParam("user_code") String userCode, ModelMap model, HttpSession session) { 198 199 // look up the request based on the user code 200 DeviceCode dc = deviceCodeService.lookUpByUserCode(userCode); 201 202 // we couldn't find the device code 203 if (dc == null) { 204 model.addAttribute("error", "noUserCode"); 205 return "requestUserCode"; 206 } 207 208 // make sure the code hasn't expired yet 209 if (dc.getExpiration() != null && dc.getExpiration().before(new Date())) { 210 model.addAttribute("error", "expiredUserCode"); 211 return "requestUserCode"; 212 } 213 214 // make sure the device code hasn't already been approved 215 if (dc.isApproved()) { 216 model.addAttribute("error", "userCodeAlreadyApproved"); 217 return "requestUserCode"; 218 } 219 220 ClientDetailsEntity client = clientService.loadClientByClientId(dc.getClientId()); 221 222 model.put("client", client); 223 model.put("dc", dc); 224 225 // pre-process the scopes 226 Set<SystemScope> scopes = scopeService.fromStrings(dc.getScope()); 227 228 Set<SystemScope> sortedScopes = new LinkedHashSet<>(scopes.size()); 229 Set<SystemScope> systemScopes = scopeService.getAll(); 230 231 // sort scopes for display based on the inherent order of system scopes 232 for (SystemScope s : systemScopes) { 233 if (scopes.contains(s)) { 234 sortedScopes.add(s); 235 } 236 } 237 238 // add in any scopes that aren't system scopes to the end of the list 239 sortedScopes.addAll(Sets.difference(scopes, systemScopes)); 240 241 model.put("scopes", sortedScopes); 242 243 AuthorizationRequest authorizationRequest = oAuth2RequestFactory.createAuthorizationRequest(dc.getRequestParameters()); 244 245 session.setAttribute("authorizationRequest", authorizationRequest); 246 session.setAttribute("deviceCode", dc); 247 248 return "approveDevice"; 249 } 250 251 @PreAuthorize("hasRole('ROLE_USER')") 252 @RequestMapping(value = "/" + USER_URL + "/approve", method = RequestMethod.POST) 253 public String approveDevice(@RequestParam("user_code") String userCode, @RequestParam(value = "user_oauth_approval") Boolean approve, ModelMap model, Authentication auth, HttpSession session) { 254 255 AuthorizationRequest authorizationRequest = (AuthorizationRequest) session.getAttribute("authorizationRequest"); 256 DeviceCode dc = (DeviceCode) session.getAttribute("deviceCode"); 257 258 // make sure the form that was submitted is the one that we were expecting 259 if (!dc.getUserCode().equals(userCode)) { 260 model.addAttribute("error", "userCodeMismatch"); 261 return "requestUserCode"; 262 } 263 264 // make sure the code hasn't expired yet 265 if (dc.getExpiration() != null && dc.getExpiration().before(new Date())) { 266 model.addAttribute("error", "expiredUserCode"); 267 return "requestUserCode"; 268 } 269 270 ClientDetailsEntity client = clientService.loadClientByClientId(dc.getClientId()); 271 272 model.put("client", client); 273 274 // user did not approve 275 if (!approve) { 276 model.addAttribute("approved", false); 277 return "deviceApproved"; 278 } 279 280 // create an OAuth request for storage 281 OAuth2Request o2req = oAuth2RequestFactory.createOAuth2Request(authorizationRequest); 282 OAuth2Authentication o2Auth = new OAuth2Authentication(o2req, auth); 283 284 DeviceCode approvedCode = deviceCodeService.approveDeviceCode(dc, o2Auth); 285 286 // pre-process the scopes 287 Set<SystemScope> scopes = scopeService.fromStrings(dc.getScope()); 288 289 Set<SystemScope> sortedScopes = new LinkedHashSet<>(scopes.size()); 290 Set<SystemScope> systemScopes = scopeService.getAll(); 291 292 // sort scopes for display based on the inherent order of system scopes 293 for (SystemScope s : systemScopes) { 294 if (scopes.contains(s)) { 295 sortedScopes.add(s); 296 } 297 } 298 299 // add in any scopes that aren't system scopes to the end of the list 300 sortedScopes.addAll(Sets.difference(scopes, systemScopes)); 301 302 model.put("scopes", sortedScopes); 303 model.put("approved", true); 304 305 return "deviceApproved"; 306 } 307}