001/* 002 * oauth2-oidc-sdk 003 * 004 * Copyright 2012-2016, Connect2id Ltd and contributors. 005 * 006 * Licensed under the Apache License, Version 2.0 (the "License"); you may not use 007 * this file except in compliance with the License. You may obtain a copy of the 008 * License at 009 * 010 * http://www.apache.org/licenses/LICENSE-2.0 011 * 012 * Unless required by applicable law or agreed to in writing, software distributed 013 * under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR 014 * CONDITIONS OF ANY KIND, either express or implied. See the License for the 015 * specific language governing permissions and limitations under the License. 016 */ 017 018package com.nimbusds.oauth2.sdk.http; 019 020 021import com.nimbusds.common.contenttype.ContentType; 022import com.nimbusds.oauth2.sdk.ParseException; 023import com.nimbusds.oauth2.sdk.util.URLUtils; 024import com.nimbusds.oauth2.sdk.util.X509CertificateUtils; 025import jakarta.servlet.ServletRequest; 026import jakarta.servlet.http.HttpServletRequest; 027import jakarta.servlet.http.HttpServletResponse; 028import net.jcip.annotations.ThreadSafe; 029 030import java.io.BufferedReader; 031import java.io.IOException; 032import java.io.PrintWriter; 033import java.net.MalformedURLException; 034import java.net.URL; 035import java.security.cert.X509Certificate; 036import java.util.Enumeration; 037import java.util.LinkedList; 038import java.util.List; 039import java.util.Map; 040 041 042/** 043 * HTTP Jakarta Servlet utilities. 044 * 045 * <p>Requires the optional {@code jakarta.servlet} dependency (or newer): 046 * 047 * <pre> 048 * jakarta.servlet:jakarta.servlet-api:5.0.0 049 * </pre> 050 */ 051@ThreadSafe 052public class JakartaServletUtils { 053 054 055 /** 056 * Reconstructs the request URL string for the specified servlet 057 * request. The host part is always the local IP address. The query 058 * string and fragment is always omitted. 059 * 060 * @param request The servlet request. Must not be {@code null}. 061 * 062 * @return The reconstructed request URL string. 063 */ 064 private static String reconstructRequestURLString(final HttpServletRequest request) { 065 066 StringBuilder sb = new StringBuilder("http"); 067 068 if (request.isSecure()) 069 sb.append('s'); 070 071 sb.append("://"); 072 073 String localAddress = request.getLocalAddr(); 074 075 if (localAddress == null || localAddress.trim().isEmpty()) { 076 // Unknown local address (hostname / IP address) 077 } else if (localAddress.contains(".")) { 078 // IPv3 address 079 sb.append(localAddress); 080 } else if (localAddress.contains(":")) { 081 082 // IPv6 address, see RFC 2732 083 084 // Handle non-compliant "Jetty" formatting of IPv6 address: 085 // https://bitbucket.org/connect2id/oauth-2.0-sdk-with-openid-connect-extensions/issues/376/ 086 087 if (! localAddress.startsWith("[")) { 088 sb.append('['); 089 } 090 091 sb.append(localAddress); 092 093 if (! localAddress.endsWith("]")) { 094 sb.append(']'); 095 } 096 } 097 098 if (! request.isSecure() && request.getLocalPort() != 80) { 099 // HTTP plain at port other than 80 100 sb.append(':'); 101 sb.append(request.getLocalPort()); 102 } 103 104 if (request.isSecure() && request.getLocalPort() != 443) { 105 // HTTPS at port other than 443 (default TLS) 106 sb.append(':'); 107 sb.append(request.getLocalPort()); 108 } 109 110 String path = request.getRequestURI(); 111 112 if (path != null) 113 sb.append(path); 114 115 return sb.toString(); 116 } 117 118 119 /** 120 * Creates a new HTTP request from the specified HTTP servlet request. 121 * 122 * <p><strong>Warning about servlet filters: </strong> Processing of 123 * HTTP POST and PUT requests requires the entity body to be available 124 * for reading from the {@link HttpServletRequest}. If you're getting 125 * unexpected exceptions, please ensure the entity body is not consumed 126 * or modified by an upstream servlet filter. 127 * 128 * @param sr The servlet request. Must not be {@code null}. 129 * 130 * @return The HTTP request. 131 * 132 * @throws IllegalArgumentException The the servlet request method is 133 * not GET, POST, PUT or DELETE or the 134 * content type header value couldn't 135 * be parsed. 136 * @throws IOException For a POST or PUT body that 137 * couldn't be read due to an I/O 138 * exception. 139 */ 140 public static HTTPRequest createHTTPRequest(final HttpServletRequest sr) 141 throws IOException { 142 143 return createHTTPRequest(sr, -1); 144 } 145 146 147 /** 148 * Creates a new HTTP request from the specified HTTP servlet request. 149 * 150 * <p><strong>Warning about servlet filters: </strong> Processing of 151 * HTTP POST and PUT requests requires the entity body to be available 152 * for reading from the {@link HttpServletRequest}. If you're getting 153 * unexpected exceptions, please ensure the entity body is not consumed 154 * or modified by an upstream servlet filter. 155 * 156 * @param sr The servlet request. Must not be 157 * {@code null}. 158 * @param maxEntityLength The maximum entity length to accept, -1 for 159 * no limit. 160 * 161 * @return The HTTP request. 162 * 163 * @throws IllegalArgumentException The the servlet request method is 164 * not GET, POST, PUT or DELETE or the 165 * content type header value couldn't 166 * be parsed. 167 * @throws IOException For a POST or PUT body that 168 * couldn't be read due to an I/O 169 * exception. 170 */ 171 public static HTTPRequest createHTTPRequest(final HttpServletRequest sr, final long maxEntityLength) 172 throws IOException { 173 174 HTTPRequest.Method method = HTTPRequest.Method.valueOf(sr.getMethod().toUpperCase()); 175 176 String urlString = reconstructRequestURLString(sr); 177 178 URL url; 179 try { 180 url = new URL(urlString); 181 } catch (MalformedURLException e) { 182 throw new IllegalArgumentException("Invalid request URL: " + e.getMessage() + ": " + urlString, e); 183 } 184 185 HTTPRequest request = new HTTPRequest(method, url); 186 187 try { 188 request.setContentType(sr.getContentType()); 189 190 } catch (ParseException e) { 191 192 throw new IllegalArgumentException("Invalid Content-Type header value: " + e.getMessage(), e); 193 } 194 195 Enumeration<String> headerNames = sr.getHeaderNames(); 196 197 while (headerNames.hasMoreElements()) { 198 199 final String headerName = headerNames.nextElement(); 200 201 Enumeration<String> headerValues = sr.getHeaders(headerName); 202 203 if (headerValues == null || ! headerValues.hasMoreElements()) 204 continue; 205 206 List<String> headerValuesList = new LinkedList<>(); 207 while (headerValues.hasMoreElements()) { 208 headerValuesList.add(headerValues.nextElement()); 209 } 210 211 request.setHeader(headerName, headerValuesList.toArray(new String[0])); 212 } 213 214 if (method.equals(HTTPRequest.Method.GET) || method.equals(HTTPRequest.Method.DELETE)) { 215 216 request.appendQueryString(sr.getQueryString()); 217 218 } else if (method.equals(HTTPRequest.Method.POST) || method.equals(HTTPRequest.Method.PUT)) { 219 220 // Impossible to read application/x-www-form-urlencoded request content on which parameters 221 // APIs have been used. To be safe we recreate the content based on the parameters in this case. 222 // See issues 223 // https://bitbucket.org/connect2id/oauth-2.0-sdk-with-openid-connect-extensions/issues/184 224 // https://bitbucket.org/connect2id/oauth-2.0-sdk-with-openid-connect-extensions/issues/186 225 if (ContentType.APPLICATION_URLENCODED.matches(request.getEntityContentType())) { 226 227 // Recreate the content based on parameters 228 request.setBody(URLUtils.serializeParametersAlt(sr.getParameterMap())); 229 } else { 230 // read body 231 StringBuilder body = new StringBuilder(256); 232 233 BufferedReader reader = sr.getReader(); 234 235 char[] cbuf = new char[256]; 236 237 int readChars; 238 239 while ((readChars = reader.read(cbuf)) != -1) { 240 241 body.append(cbuf, 0, readChars); 242 243 if (maxEntityLength > 0 && body.length() > maxEntityLength) { 244 throw new IOException( 245 "Request entity body is too large, limit is " + maxEntityLength + " chars"); 246 } 247 } 248 249 reader.close(); 250 request.setBody(body.toString()); 251 } 252 } 253 254 // Extract validated client X.509 if we have mutual TLS 255 X509Certificate cert = extractClientX509Certificate(sr); 256 if (cert != null) { 257 request.setClientX509Certificate(cert); 258 request.setClientX509CertificateSubjectDN(cert.getSubjectDN() != null ? cert.getSubjectDN().getName() : null); 259 260 // The root DN cannot be reliably set for a CA-signed 261 // client cert from a servlet request, unless self-issued 262 if (X509CertificateUtils.hasMatchingIssuerAndSubject(cert)) { 263 request.setClientX509CertificateRootDN(cert.getIssuerDN() != null ? cert.getIssuerDN().getName() : null); 264 } 265 } 266 267 // Extract client IP address, X-Forwarded-For not checked 268 request.setClientIPAddress(sr.getRemoteAddr()); 269 270 return request; 271 } 272 273 274 /** 275 * Applies the status code, headers and content of the specified HTTP 276 * response to a HTTP servlet response. 277 * 278 * @param httpResponse The HTTP response. Must not be {@code null}. 279 * @param servletResponse The HTTP servlet response. Must not be 280 * {@code null}. 281 * 282 * @throws IOException If the response content couldn't be written. 283 */ 284 public static void applyHTTPResponse(final HTTPResponse httpResponse, 285 final HttpServletResponse servletResponse) 286 throws IOException { 287 288 // Set the status code 289 servletResponse.setStatus(httpResponse.getStatusCode()); 290 291 292 // Set the headers, but only if explicitly specified 293 for (Map.Entry<String,List<String>> header : httpResponse.getHeaderMap().entrySet()) { 294 for (String headerValue: header.getValue()) { 295 servletResponse.addHeader(header.getKey(), headerValue); 296 } 297 } 298 299 if (httpResponse.getEntityContentType() != null) 300 servletResponse.setContentType(httpResponse.getEntityContentType().toString()); 301 302 303 // Write out the content 304 if (httpResponse.getBody() != null) { 305 PrintWriter writer = servletResponse.getWriter(); 306 writer.print(httpResponse.getBody()); 307 writer.close(); 308 } 309 } 310 311 312 /** 313 * Extracts the client's X.509 certificate from the specified servlet 314 * request. The first found certificate is returned, if any. 315 * 316 * @param servletRequest The HTTP servlet request. Must not be 317 * {@code null}. 318 * 319 * @return The first client X.509 certificate, {@code null} if none is 320 * found. 321 */ 322 public static X509Certificate extractClientX509Certificate(final ServletRequest servletRequest) { 323 324 X509Certificate[] certs = (X509Certificate[]) servletRequest.getAttribute("jakarta.servlet.request.X509Certificate"); 325 326 if (certs == null || certs.length == 0) { 327 return null; 328 } 329 330 return certs[0]; 331 } 332 333 334 /** 335 * Prevents public instantiation. 336 */ 337 private JakartaServletUtils() { } 338}