001/*
002 * (C) Copyright 2014-2018 Nuxeo (http://nuxeo.com/) and others.
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 * Contributors:
017 *     Arnaud Kervern
018 */
019package org.nuxeo.ecm.platform.oauth2.request;
020
021import static org.nuxeo.ecm.platform.oauth2.Constants.CLIENT_ID_PARAM;
022import static org.nuxeo.ecm.platform.oauth2.Constants.CODE_CHALLENGE_METHODS_SUPPORTED;
023import static org.nuxeo.ecm.platform.oauth2.Constants.CODE_CHALLENGE_METHOD_PARAM;
024import static org.nuxeo.ecm.platform.oauth2.Constants.CODE_CHALLENGE_METHOD_PLAIN;
025import static org.nuxeo.ecm.platform.oauth2.Constants.CODE_CHALLENGE_METHOD_S256;
026import static org.nuxeo.ecm.platform.oauth2.Constants.CODE_CHALLENGE_PARAM;
027import static org.nuxeo.ecm.platform.oauth2.Constants.CODE_RESPONSE_TYPE;
028import static org.nuxeo.ecm.platform.oauth2.Constants.REDIRECT_URI_PARAM;
029import static org.nuxeo.ecm.platform.oauth2.Constants.RESPONSE_TYPE_PARAM;
030import static org.nuxeo.ecm.platform.oauth2.Constants.SCOPE_PARAM;
031
032import java.io.Serializable;
033import java.security.Principal;
034import java.util.Date;
035import java.util.HashMap;
036import java.util.List;
037import java.util.Map;
038
039import javax.servlet.http.HttpServletRequest;
040
041import org.apache.commons.codec.binary.Base64;
042import org.apache.commons.codec.digest.DigestUtils;
043import org.apache.commons.collections.CollectionUtils;
044import org.apache.commons.lang3.StringUtils;
045import org.apache.commons.logging.Log;
046import org.apache.commons.logging.LogFactory;
047import org.apache.commons.text.CharacterPredicates;
048import org.apache.commons.text.RandomStringGenerator;
049import org.apache.commons.text.RandomStringGenerator.Builder;
050import org.nuxeo.ecm.core.transientstore.api.TransientStore;
051import org.nuxeo.ecm.core.transientstore.api.TransientStoreService;
052import org.nuxeo.ecm.platform.oauth2.OAuth2Error;
053import org.nuxeo.ecm.platform.oauth2.clients.OAuth2Client;
054import org.nuxeo.ecm.platform.oauth2.clients.OAuth2ClientService;
055import org.nuxeo.runtime.api.Framework;
056
057/**
058 * @author <a href="mailto:ak@nuxeo.com">Arnaud Kervern</a>
059 * @since 5.9.2
060 */
061public class AuthorizationRequest extends OAuth2Request {
062
063    private static final Log log = LogFactory.getLog(AuthorizationRequest.class);
064
065    private static final RandomStringGenerator GENERATOR = new Builder().filteredBy(CharacterPredicates.LETTERS,
066            CharacterPredicates.DIGITS).withinRange('0', 'z').build();
067
068    public static final String MISSING_REQUIRED_FIELD_MESSAGE = "Missing required field \"%s\".";
069
070    public static final String STORE_NAME = "authorizationRequestStore";
071
072    protected String responseType;
073
074    protected String scope;
075
076    protected Date creationDate;
077
078    protected String authorizationCode;
079
080    protected String username;
081
082    protected String codeChallenge;
083
084    protected String codeChallengeMethod;
085
086    public static AuthorizationRequest fromRequest(HttpServletRequest request) {
087        return new AuthorizationRequest(request);
088    }
089
090    public static AuthorizationRequest fromMap(Map<String, Serializable> map) {
091        return new AuthorizationRequest(map);
092    }
093
094    public static void store(String key, AuthorizationRequest authorizationRequest) {
095        TransientStoreService transientStoreService = Framework.getService(TransientStoreService.class);
096        TransientStore store = transientStoreService.getStore(STORE_NAME);
097        store.putParameters(key, authorizationRequest.toMap());
098    }
099
100    public static AuthorizationRequest get(String key) {
101        TransientStoreService transientStoreService = Framework.getService(TransientStoreService.class);
102        TransientStore store = transientStoreService.getStore(STORE_NAME);
103        Map<String, Serializable> parameters = store.getParameters(key);
104        if (parameters != null) {
105            AuthorizationRequest authorizationRequest = AuthorizationRequest.fromMap(parameters);
106            return authorizationRequest.isExpired() ? null : authorizationRequest;
107        }
108        return null;
109    }
110
111    public static void remove(String key) {
112        TransientStoreService transientStoreService = Framework.getService(TransientStoreService.class);
113        TransientStore store = transientStoreService.getStore(STORE_NAME);
114        store.remove(key);
115    }
116
117    protected AuthorizationRequest(HttpServletRequest request) {
118        super(request);
119        responseType = request.getParameter(RESPONSE_TYPE_PARAM);
120        scope = request.getParameter(SCOPE_PARAM);
121
122        Principal principal = request.getUserPrincipal();
123        if (principal != null) {
124            username = principal.getName();
125        }
126
127        creationDate = new Date();
128
129        codeChallenge = request.getParameter(CODE_CHALLENGE_PARAM);
130        codeChallengeMethod = request.getParameter(CODE_CHALLENGE_METHOD_PARAM);
131    }
132
133    protected AuthorizationRequest(Map<String, Serializable> map) {
134        clientId = (String) map.get("clientId");
135        redirectURI = (String) map.get("redirectURI");
136        responseType = (String) map.get("responseType");
137        scope = (String) map.get("scope");
138        creationDate = (Date) map.get("creationDate");
139        authorizationCode = (String) map.get("authorizationCode");
140        username = (String) map.get("username");
141        codeChallenge = (String) map.get("codeChallenge");
142        codeChallengeMethod = (String) map.get("codeChallengeMethod");
143    }
144
145    public OAuth2Error checkError() {
146        // Check mandatory fields
147        if (StringUtils.isBlank(clientId)) {
148            return OAuth2Error.invalidRequest(String.format(MISSING_REQUIRED_FIELD_MESSAGE, CLIENT_ID_PARAM));
149        }
150        if (StringUtils.isBlank(responseType)) {
151            return OAuth2Error.invalidRequest(String.format(MISSING_REQUIRED_FIELD_MESSAGE, RESPONSE_TYPE_PARAM));
152        }
153        // Check response type
154        if (!CODE_RESPONSE_TYPE.equals(responseType)) {
155            return OAuth2Error.unsupportedResponseType(String.format("Unknown %s: got \"%s\", expecting \"%s\".",
156                    RESPONSE_TYPE_PARAM, responseType, CODE_RESPONSE_TYPE));
157        }
158
159        // Check if client exists
160        OAuth2ClientService clientService = Framework.getService(OAuth2ClientService.class);
161        OAuth2Client client = clientService.getClient(clientId);
162        if (client == null) {
163            return OAuth2Error.invalidRequest(String.format("Invalid %s: %s.", CLIENT_ID_PARAM, clientId));
164        }
165        if (!client.isEnabled()) {
166            return OAuth2Error.accessDenied(String.format("Client %s is disabled.", clientId));
167        }
168
169        String clientName = client.getName();
170        if (StringUtils.isBlank(clientName)) {
171            log.error(String.format(
172                    "No name set for OAuth2 client %s. It is a required field, please make sure you update this OAuth2 client.",
173                    client));
174            // Here we are just checking that the client has a name since it is now a required field but it might be
175            // empty for an old client.
176            // Yet we don't return an error for backward compatibility since an empty name is not a security issue and
177            // should not prevent the authorization request from working.
178        }
179
180        List<String> clientRedirectURIs = client.getRedirectURIs();
181        if (CollectionUtils.isEmpty(clientRedirectURIs)) {
182            log.error(String.format(
183                    "No redirect URI set for OAuth2 client %s, at least one is required. Please make sure you update this OAuth2 client.",
184                    client));
185            // Checking that the client has at least one redirect URI since it is now a required field but it might be
186            // empty for an old client.
187            // In this case we return an error since we cannot trust the redirect_uri parameter for security reasons.
188            return OAuth2Error.accessDenied("No redirect URI configured for the app.");
189        }
190
191        String clientRedirectURI;
192        // No redirect_uri parameter, use the first redirect URI registered for this client
193        if (StringUtils.isBlank(redirectURI)) {
194            clientRedirectURI = clientRedirectURIs.get(0);
195        } else {
196            // Check that the redirect_uri parameter matches one of the the redirect URIs registered for this client
197            if (!clientRedirectURIs.contains(redirectURI)) {
198                return OAuth2Error.invalidRequest(String.format(
199                        "Invalid %s parameter: %s. It must exactly match one of the redirect URIs configured for the app.",
200                        REDIRECT_URI_PARAM, redirectURI));
201            }
202            clientRedirectURI = redirectURI;
203        }
204
205        // Check redirect URI validity
206        if (!OAuth2Client.isRedirectURIValid(clientRedirectURI)) {
207            log.error(String.format(
208                    "The redirect URI %s set for OAuth2 client %s is invalid: it must not be empty and start with https for security reasons. Please make sure you update this OAuth2 client.",
209                    clientRedirectURI, client));
210            return OAuth2Error.invalidRequest(String.format(
211                    "Invalid redirect URI configured for the app: %s. It must not be empty and start with https for security reasons.",
212                    clientRedirectURI));
213        }
214
215        // Check PKCE parameters
216        if (codeChallenge != null && codeChallengeMethod == null
217                || codeChallenge == null && codeChallengeMethod != null) {
218            return OAuth2Error.invalidRequest(String.format(
219                    "Invalid PKCE parameters: either both %s and %s parameters must be sent or none of them.",
220                    CODE_CHALLENGE_PARAM, CODE_CHALLENGE_METHOD_PARAM));
221        }
222        if (codeChallengeMethod != null && !CODE_CHALLENGE_METHODS_SUPPORTED.contains(codeChallengeMethod)) {
223            return OAuth2Error.invalidRequest(String.format(
224                    "Invalid %s parameter: transform algorithm %s not supported. The server only supports %s.",
225                    CODE_CHALLENGE_METHOD_PARAM, codeChallengeMethod, CODE_CHALLENGE_METHODS_SUPPORTED));
226        }
227
228        return null;
229    }
230
231    public boolean isExpired() {
232        // RFC 4.1.2, Authorization code lifetime is 10
233        return new Date().getTime() - creationDate.getTime() > 10 * 60 * 1000;
234    }
235
236    public String getResponseType() {
237        return responseType;
238    }
239
240    public String getScope() {
241        return scope;
242    }
243
244    public String getUsername() {
245        return username;
246    }
247
248    public String getAuthorizationCode() {
249        if (StringUtils.isBlank(authorizationCode)) {
250            authorizationCode = GENERATOR.generate(10);
251        }
252        return authorizationCode;
253    }
254
255    public String getCodeChallenge() {
256        return codeChallenge;
257    }
258
259    public String getCodeChallengeMethod() {
260        return codeChallengeMethod;
261    }
262
263    public Map<String, Serializable> toMap() {
264        Map<String, Serializable> map = new HashMap<>();
265        if (clientId != null) {
266            map.put("clientId", clientId);
267        }
268        if (redirectURI != null) {
269            map.put("redirectURI", redirectURI);
270        }
271        if (responseType != null) {
272            map.put("responseType", responseType);
273        }
274        if (scope != null) {
275            map.put("scope", scope);
276        }
277        if (creationDate != null) {
278            map.put("creationDate", creationDate);
279        }
280        if (authorizationCode != null) {
281            map.put("authorizationCode", authorizationCode);
282        }
283        if (username != null) {
284            map.put("username", username);
285        }
286        if (codeChallenge != null) {
287            map.put("codeChallenge", codeChallenge);
288        }
289        if (codeChallengeMethod != null) {
290            map.put("codeChallengeMethod", codeChallengeMethod);
291        }
292        return map;
293    }
294
295    public boolean isCodeVerifierValid(String codeVerifier) {
296        if (codeChallenge == null || codeChallengeMethod == null) {
297            return false;
298        }
299        switch (codeChallengeMethod) {
300        case CODE_CHALLENGE_METHOD_S256:
301            return codeChallenge.equals(Base64.encodeBase64URLSafeString(DigestUtils.sha256(codeVerifier)));
302        case CODE_CHALLENGE_METHOD_PLAIN:
303            return codeChallenge.equals(codeVerifier);
304        default:
305            return false;
306        }
307    }
308
309}