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:[email protected]">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}