001/*
002 * (C) Copyright 2017-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 *     Thomas Roger
018 *
019 */
020package org.nuxeo.ecm.platform.oauth2;
021
022import static javax.servlet.http.HttpServletResponse.SC_BAD_REQUEST;
023import static javax.servlet.http.HttpServletResponse.SC_METHOD_NOT_ALLOWED;
024import static javax.servlet.http.HttpServletResponse.SC_NOT_FOUND;
025import static javax.servlet.http.HttpServletResponse.SC_OK;
026import static org.nuxeo.ecm.platform.oauth2.Constants.AUTHORIZATION_CODE_GRANT_TYPE;
027import static org.nuxeo.ecm.platform.oauth2.Constants.AUTHORIZATION_CODE_PARAM;
028import static org.nuxeo.ecm.platform.oauth2.Constants.CLIENT_ID_PARAM;
029import static org.nuxeo.ecm.platform.oauth2.Constants.CODE_CHALLENGE_METHOD_PARAM;
030import static org.nuxeo.ecm.platform.oauth2.Constants.CODE_CHALLENGE_PARAM;
031import static org.nuxeo.ecm.platform.oauth2.Constants.CODE_VERIFIER_PARAM;
032import static org.nuxeo.ecm.platform.oauth2.Constants.GRANT_TYPE_PARAM;
033import static org.nuxeo.ecm.platform.oauth2.Constants.REDIRECT_URI_PARAM;
034import static org.nuxeo.ecm.platform.oauth2.Constants.REFRESH_TOKEN_GRANT_TYPE;
035import static org.nuxeo.ecm.platform.oauth2.Constants.RESPONSE_TYPE_PARAM;
036import static org.nuxeo.ecm.platform.oauth2.Constants.SCOPE_PARAM;
037import static org.nuxeo.ecm.platform.oauth2.Constants.STATE_PARAM;
038import static org.nuxeo.ecm.platform.oauth2.Constants.TOKEN_SERVICE;
039
040import java.io.IOException;
041import java.util.HashMap;
042import java.util.Map;
043
044import javax.servlet.RequestDispatcher;
045import javax.servlet.ServletException;
046import javax.servlet.http.HttpServlet;
047import javax.servlet.http.HttpServletRequest;
048import javax.servlet.http.HttpServletResponse;
049
050import org.apache.commons.lang3.StringUtils;
051import org.apache.commons.logging.Log;
052import org.apache.commons.logging.LogFactory;
053import org.nuxeo.common.utils.URIUtils;
054import org.nuxeo.ecm.platform.oauth2.clients.OAuth2Client;
055import org.nuxeo.ecm.platform.oauth2.clients.OAuth2ClientService;
056import org.nuxeo.ecm.platform.oauth2.request.AuthorizationRequest;
057import org.nuxeo.ecm.platform.oauth2.request.TokenRequest;
058import org.nuxeo.ecm.platform.oauth2.tokens.NuxeoOAuth2Token;
059import org.nuxeo.ecm.platform.oauth2.tokens.OAuth2TokenStore;
060import org.nuxeo.runtime.api.Framework;
061import org.nuxeo.runtime.transaction.TransactionHelper;
062
063import com.fasterxml.jackson.databind.ObjectMapper;
064
065/**
066 * @since 9.2
067 */
068public class NuxeoOAuth2Servlet extends HttpServlet {
069
070    private static final long serialVersionUID = 1L;
071
072    private static final Log log = LogFactory.getLog(NuxeoOAuth2Servlet.class);
073
074    public static final String ENDPOINT_AUTH = "authorize";
075
076    public static final String ENDPOINT_TOKEN = "token";
077
078    public static final String ENDPOINT_AUTH_SUBMIT = "authorize_submit";
079
080    public static final String ERROR_PARAM = "error";
081
082    public static final String ERROR_DESCRIPTION_PARAM = "error_description";
083
084    public static final String CLIENT_NAME = "client_name";
085
086    public static final String GRANT_JSP_PAGE_PATH = "/oauth2Grant.jsp";
087
088    public static final String GRANT_ACCESS_PARAM = "grant_access";
089
090    public static final String ERROR_JSP_PAGE_PATH = "/oauth2error.jsp";
091
092    public static final int ACCESS_TOKEN_EXPIRATION_TIME = 3600 * 1000;
093
094    protected final OAuth2TokenStore tokenStore = new OAuth2TokenStore(TOKEN_SERVICE);
095
096    @Override
097    protected void doGet(HttpServletRequest request, HttpServletResponse response)
098            throws ServletException, IOException {
099        String pathInfo = request.getPathInfo();
100        if (pathInfo.endsWith(ENDPOINT_AUTH)) {
101            doGetAuthorize(request, response);
102        } else if (pathInfo.endsWith(ENDPOINT_AUTH_SUBMIT)) {
103            doGetNotAllowed(ENDPOINT_AUTH_SUBMIT, request, response);
104        } else if (pathInfo.endsWith(ENDPOINT_TOKEN)) {
105            doGetNotAllowed(ENDPOINT_TOKEN, request, response);
106        } else {
107            response.sendError(SC_NOT_FOUND);
108        }
109    }
110
111    @Override
112    protected void doPost(HttpServletRequest request, HttpServletResponse response)
113            throws ServletException, IOException {
114        String pathInfo = request.getPathInfo();
115        if (pathInfo.endsWith(ENDPOINT_AUTH_SUBMIT)) {
116            doPostAuthorizeSubmit(request, response);
117        } else if (pathInfo.endsWith(ENDPOINT_TOKEN)) {
118            doPostToken(request, response);
119        } else {
120            response.sendError(SC_NOT_FOUND);
121        }
122    }
123
124    protected void doGetAuthorize(HttpServletRequest request, HttpServletResponse response)
125            throws IOException, ServletException {
126        OAuth2ClientService clientService = Framework.getService(OAuth2ClientService.class);
127        AuthorizationRequest authRequest = AuthorizationRequest.fromRequest(request);
128        OAuth2Error error = authRequest.checkError();
129        if (error != null) {
130            handleError(error, request, response);
131            return;
132        }
133
134        // If auto-grant is checked on the client or a token exists for the (client, username) passed in the
135        // authorization request, bypass the grant page and redirect to the redirect_uri
136        // with an authorization code parameter
137        String clientId = authRequest.getClientId();
138        OAuth2Client client = clientService.getClient(clientId);
139        if (client.isAutoGrant() || tokenStore.getToken(clientId, authRequest.getUsername()) != null) {
140            String redirectURI = getRedirectURI(authRequest);
141            String authorizationCode = storeAuthorizationRequest(authRequest);
142            String state = request.getParameter(STATE_PARAM);
143            Map<String, String> params = new HashMap<>();
144            params.put(AUTHORIZATION_CODE_PARAM, authorizationCode);
145            if (StringUtils.isNotBlank(state)) {
146                params.put(STATE_PARAM, state);
147            }
148            sendRedirect(request, response, redirectURI, params);
149            return;
150
151        }
152
153        // Set the required request attributes and redirect to the grant page
154        request.setAttribute(RESPONSE_TYPE_PARAM, authRequest.getResponseType());
155        request.setAttribute(CLIENT_ID_PARAM, clientId);
156        String redirectURI = authRequest.getRedirectURI();
157        if (StringUtils.isNotBlank(redirectURI)) {
158            request.setAttribute(REDIRECT_URI_PARAM, redirectURI);
159        }
160        String scope = authRequest.getScope();
161        if (StringUtils.isNotBlank(scope)) {
162            request.setAttribute(SCOPE_PARAM, scope);
163        }
164        String state = request.getParameter(STATE_PARAM);
165        if (StringUtils.isNotBlank(state)) {
166            request.setAttribute(STATE_PARAM, state);
167        }
168        String codeChallenge = authRequest.getCodeChallenge();
169        String codeChallengeMethod = authRequest.getCodeChallengeMethod();
170        if (codeChallenge != null && codeChallengeMethod != null) {
171            request.setAttribute(CODE_CHALLENGE_PARAM, codeChallenge);
172            request.setAttribute(CODE_CHALLENGE_METHOD_PARAM, codeChallengeMethod);
173        }
174        request.setAttribute(CLIENT_NAME, client.getName());
175
176        RequestDispatcher requestDispatcher = request.getRequestDispatcher(GRANT_JSP_PAGE_PATH);
177        requestDispatcher.forward(request, response);
178    }
179
180    protected void doGetNotAllowed(String endpoint, HttpServletRequest request, HttpServletResponse response)
181            throws IOException, ServletException {
182        OAuth2Error error = OAuth2Error.invalidRequest(
183                String.format("The /oauth2/%s endpoint only accepts POST requests.", endpoint));
184        handleError(error, SC_METHOD_NOT_ALLOWED, request, response);
185    }
186
187    protected void doPostAuthorizeSubmit(HttpServletRequest request, HttpServletResponse response)
188            throws IOException, ServletException {
189        AuthorizationRequest authRequest = AuthorizationRequest.fromRequest(request);
190        OAuth2Error error = authRequest.checkError();
191        if (error != null) {
192            handleError(error, request, response);
193            return;
194        }
195
196        String redirectURI = getRedirectURI(authRequest);
197        String state = request.getParameter(STATE_PARAM);
198        String grantAccess = request.getParameter(GRANT_ACCESS_PARAM);
199        if (grantAccess == null) {
200            // the user deny access
201            error = OAuth2Error.accessDenied("Access denied by the user");
202            Map<String, String> params = new HashMap<>();
203            params.put(ERROR_PARAM, error.getId());
204            String errorDescription = error.getDescription();
205            if (StringUtils.isNotBlank(errorDescription)) {
206                params.put(ERROR_DESCRIPTION_PARAM, errorDescription);
207            }
208            if (StringUtils.isNotBlank(state)) {
209                params.put(STATE_PARAM, state);
210            }
211            sendRedirect(request, response, redirectURI, params);
212            return;
213        }
214
215        // now store the authorization request according to its code
216        // to be able to retrieve it in the "/oauth2/token" endpoint
217        String authorizationCode = storeAuthorizationRequest(authRequest);
218        Map<String, String> params = new HashMap<>();
219        params.put(AUTHORIZATION_CODE_PARAM, authorizationCode);
220        if (StringUtils.isNotBlank(state)) {
221            params.put(STATE_PARAM, state);
222        }
223
224        sendRedirect(request, response, redirectURI, params);
225    }
226
227    /**
228     * Returns the redirect URI included in the given authorization request or fall back on the first one registered for
229     * the related client.
230     */
231    protected String getRedirectURI(AuthorizationRequest authRequest) {
232        String redirectURI = authRequest.getRedirectURI();
233        if (StringUtils.isBlank(redirectURI)) {
234            return Framework.getService(OAuth2ClientService.class)
235                            .getClient(authRequest.getClientId())
236                            .getRedirectURIs()
237                            .get(0);
238        } else {
239            return redirectURI;
240        }
241    }
242
243    protected String storeAuthorizationRequest(AuthorizationRequest authRequest) {
244        String authorizationCode = authRequest.getAuthorizationCode();
245        AuthorizationRequest.store(authorizationCode, authRequest);
246        return authorizationCode;
247    }
248
249    protected void doPostToken(HttpServletRequest request, HttpServletResponse response) throws IOException {
250        TokenRequest tokenRequest = new TokenRequest(request);
251        OAuth2ClientService clientService = Framework.getService(OAuth2ClientService.class);
252        String grantType = tokenRequest.getGrantType();
253        // Process Authorization code
254        if (AUTHORIZATION_CODE_GRANT_TYPE.equals(grantType)) {
255            String authorizationCode = tokenRequest.getCode();
256            AuthorizationRequest authRequest = AuthorizationRequest.get(authorizationCode);
257            final String clientId = tokenRequest.getClientId();
258            OAuth2Error error = null;
259            if (authRequest == null) {
260                error = OAuth2Error.invalidGrant("Invalid authorization code");
261            }
262            // Check that clientId is the good one, already verified in authorization request
263            else {
264                if (!authRequest.getClientId().equals(clientId)) {
265                    error = OAuth2Error.invalidClient(String.format("Invalid client id: %s", clientId));
266                } else {
267                    OAuth2Client client = clientService.getClient(clientId);
268                    // Validate client secret
269                    if (client == null
270                            || !client.isValidWith(tokenRequest.getClientId(), tokenRequest.getClientSecret())) {
271                        error = OAuth2Error.invalidClient("Disabled client or invalid client secret");
272                    } else {
273                        // Ensure redirect URIs are identical if the redirect_uri parameter was included in the
274                        // authorization request
275                        String authRequestRedirectURI = authRequest.getRedirectURI();
276                        String tokenRequestRedirectURI = tokenRequest.getRedirectURI();
277                        if (StringUtils.isNotBlank(authRequestRedirectURI)
278                                && !authRequestRedirectURI.equals(tokenRequestRedirectURI)) {
279                            error = OAuth2Error.invalidGrant(
280                                    String.format("Invalid redirect URI: %s", tokenRequestRedirectURI));
281                        } else {
282                            // Check PKCE
283                            String codeChallenge = authRequest.getCodeChallenge();
284                            if (codeChallenge != null) {
285                                String codeVerifier = tokenRequest.getCodeVerifier();
286                                if (codeVerifier == null) {
287                                    error = OAuth2Error.invalidRequest(
288                                            String.format("Missing %s parameter", CODE_VERIFIER_PARAM));
289                                } else if (!authRequest.isCodeVerifierValid(codeVerifier)) {
290                                    error = OAuth2Error.invalidGrant(
291                                            String.format("Invalid %s parameter", CODE_VERIFIER_PARAM));
292                                }
293                            }
294                        }
295                    }
296                }
297            }
298
299            if (authRequest != null) {
300                AuthorizationRequest.remove(authorizationCode);
301            }
302
303            if (error != null) {
304                handleJsonError(error, response);
305                return;
306            }
307
308            // If no token exists for the client id and username passed in the token request store a new one,
309            // else retrieve the existing token, refreshing it if needed
310            String username = authRequest.getUsername();
311            NuxeoOAuth2Token token = tokenStore.getToken(clientId, username);
312            if (token == null) {
313                final NuxeoOAuth2Token newToken = new NuxeoOAuth2Token(ACCESS_TOKEN_EXPIRATION_TIME, clientId);
314                TransactionHelper.runInTransaction(() -> tokenStore.store(username, newToken));
315                token = newToken;
316            } else if (token.isExpired()) {
317                final String refreshToken = token.getRefreshToken();
318                token = TransactionHelper.runInTransaction(() -> tokenStore.refresh(refreshToken, clientId));
319            }
320
321            handleTokenResponse(token, response);
322        } else if (REFRESH_TOKEN_GRANT_TYPE.equals(grantType)) {
323            OAuth2Error error = null;
324            if (StringUtils.isBlank(tokenRequest.getClientId())) {
325                error = OAuth2Error.invalidRequest("Empty client id");
326            } else if (!clientService.isValidClient(tokenRequest.getClientId(), tokenRequest.getClientSecret())) {
327                error = OAuth2Error.invalidClient("Disabled client or invalid client secret");
328            }
329
330            if (error != null) {
331                handleJsonError(error, response);
332                return;
333            }
334
335            NuxeoOAuth2Token refreshed = TransactionHelper.runInTransaction(
336                    () -> tokenStore.refresh(tokenRequest.getRefreshToken(), tokenRequest.getClientId()));
337
338            if (refreshed == null) {
339                handleJsonError(OAuth2Error.invalidGrant("Cannot refresh token"), response);
340            } else {
341                handleTokenResponse(refreshed, response);
342            }
343        } else {
344            handleJsonError(OAuth2Error.unsupportedGrantType(
345                    String.format("Unknown %s: got \"%s\", expecting \"%s\" or \"%s\".", GRANT_TYPE_PARAM, grantType,
346                            AUTHORIZATION_CODE_GRANT_TYPE, REFRESH_TOKEN_GRANT_TYPE)),
347                    response);
348        }
349    }
350
351    protected void handleTokenResponse(NuxeoOAuth2Token token, HttpServletResponse response) throws IOException {
352        response.setHeader("Content-Type", "application/json");
353        response.setStatus(SC_OK);
354        ObjectMapper mapper = new ObjectMapper();
355        mapper.writeValue(response.getWriter(), token.toJsonObject());
356    }
357
358    protected void handleError(OAuth2Error error, HttpServletRequest request, HttpServletResponse response)
359            throws IOException, ServletException {
360        handleError(error, SC_BAD_REQUEST, request, response);
361    }
362
363    protected void handleError(OAuth2Error error, int status, HttpServletRequest request, HttpServletResponse response)
364            throws IOException, ServletException {
365        log.warn(String.format("OAuth2 authorization request error: %s", error));
366        response.reset();
367        response.setStatus(status);
368        request.setAttribute("error", error);
369        RequestDispatcher requestDispatcher = request.getRequestDispatcher(ERROR_JSP_PAGE_PATH);
370        requestDispatcher.forward(request, response);
371    }
372
373    protected void handleJsonError(OAuth2Error error, HttpServletResponse response) throws IOException {
374        log.warn(String.format("OAuth2 token request error: %s", error));
375        response.setHeader("Content-Type", "application/json");
376        response.setStatus(SC_BAD_REQUEST);
377
378        Map<String, String> object = new HashMap<>();
379        object.put(ERROR_PARAM, error.getId());
380        if (StringUtils.isNotBlank(error.getDescription())) {
381            object.put(ERROR_DESCRIPTION_PARAM, error.getDescription());
382        }
383        ObjectMapper mapper = new ObjectMapper();
384        mapper.writeValue(response.getWriter(), object);
385    }
386
387    protected void sendRedirect(HttpServletRequest request, HttpServletResponse response, String redirectURI,
388            Map<String, String> params) throws IOException {
389        if (redirectURI == null) {
390            response.sendError(SC_BAD_REQUEST, "No redirect URI");
391            return;
392        }
393
394        String url = URIUtils.addParametersToURIQuery(redirectURI, params);
395        response.sendRedirect(url);
396    }
397}