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