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