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}