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}