001/*
002 * Licensed under the Apache License, Version 2.0 (the "License");
003 * you may not use this file except in compliance with the License.
004 * You may obtain a copy of the License at
005 *
006 *     http://www.apache.org/licenses/LICENSE-2.0
007 *
008 * Unless required by applicable law or agreed to in writing, software
009 * distributed under the License is distributed on an "AS IS" BASIS,
010 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
011 * See the License for the specific language governing permissions and
012 * limitations under the License.
013 */
014package org.gbif.ws.security;
015
016import org.gbif.ws.server.GbifHttpServletRequestWrapper;
017
018import java.net.URI;
019import java.util.Objects;
020import java.util.regex.Pattern;
021
022import org.apache.commons.lang3.StringUtils;
023import org.slf4j.Logger;
024import org.slf4j.LoggerFactory;
025import org.springframework.beans.factory.annotation.Autowired;
026import org.springframework.http.HttpHeaders;
027import org.springframework.stereotype.Service;
028
029import static org.gbif.ws.util.SecurityConstants.GBIF_SCHEME_PREFIX;
030import static org.gbif.ws.util.SecurityConstants.HEADER_CONTENT_MD5;
031import static org.gbif.ws.util.SecurityConstants.HEADER_GBIF_USER;
032import static org.gbif.ws.util.SecurityConstants.HEADER_ORIGINAL_REQUEST_METHOD;
033import static org.gbif.ws.util.SecurityConstants.HEADER_ORIGINAL_REQUEST_URL;
034
035/**
036 * The GBIF authentication scheme is modelled after the Amazon scheme on how to sign REST HTTP
037 * requests using a private key. It uses the standard HTTP Authorization header to transport the
038 * following information: Authorization: GBIF applicationKey:signature
039 *
040 * <p><br>
041 * The header starts with the authentication scheme (GBIF), followed by the plain applicationKey
042 * (the public key) and a unique signature for the very request which is generated using a fixed set
043 * of request attributes which are then encrypted by a standard HMAC-SHA1 algorithm.
044 *
045 * <p><br>
046 * A POST request with a GBIF header would look like this:
047 *
048 * <pre>
049 * POST /dataset HTTP/1.1
050 * Host: api.gbif.org
051 * Date: Mon, 26 Mar 2007 19:37:58 +0000
052 * x-gbif-user: trobertson
053 * Content-MD5: LiFThEP4Pj2TODQXa/oFPg==
054 * Authorization: GBIF gbif.portal:frJIUN8DYpKDtOLCwo//yllqDzg=
055 * </pre>
056 *
057 * <p>When signing an HTTP request in addition to the Authorization header some additional custom
058 * headers are added which are used to sign and digest the message. <br> x-gbif-user is added to
059 * transport a proxied user in which the application is acting. <br> Content-MD5 is added if a body
060 * entity exists. See Content-MD5 header specs: http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.15
061 */
062@Service
063public class GbifAuthServiceImpl implements GbifAuthService {
064
065  private static final Logger LOG = LoggerFactory.getLogger(GbifAuthServiceImpl.class);
066
067  private static final Pattern COLON_PATTERN = Pattern.compile(":");
068
069  private final SigningService signingService;
070  private final Md5EncodeService md5EncodeService;
071  private final AppKeyProvider appKeyProvider;
072
073  public GbifAuthServiceImpl(
074      SigningService signingService,
075      Md5EncodeService md5EncodeService,
076      @Autowired(required = false) AppKeyProvider appKeyProvider) {
077    this.signingService = signingService;
078    this.md5EncodeService = md5EncodeService;
079    this.appKeyProvider = appKeyProvider;
080  }
081
082  @Override
083  public boolean isValidRequest(final GbifHttpServletRequestWrapper request) {
084    // parse auth header
085    final String authHeader = request.getHeader(HttpHeaders.AUTHORIZATION);
086    if (StringUtils.isEmpty(authHeader) || !authHeader.startsWith(GBIF_SCHEME_PREFIX)) {
087      LOG.info("{} header is no GBIF scheme", HttpHeaders.AUTHORIZATION);
088      return false;
089    }
090
091    String[] values = COLON_PATTERN.split(authHeader.substring(5), 2);
092    if (values.length < 2) {
093      LOG.warn("Invalid syntax for application key and signature: {}", authHeader);
094      return false;
095    }
096
097    final String appKey = values[0];
098    final String signatureFound = values[1];
099    if (appKey == null || signatureFound == null) {
100      LOG.warn("Authentication header missing applicationKey or signature: {}", authHeader);
101      return false;
102    }
103
104    final RequestDataToSign requestDataToSign = buildRequestDataToSign(request);
105    LOG.debug("Request data to sign: {}", requestDataToSign.stringToSign());
106    // sign
107    final String signature;
108    try {
109      signature = signingService.buildSignature(requestDataToSign, appKey);
110    } catch (PrivateKeyNotFoundException e) {
111      LOG.debug("Private key was not found for app key {}", appKey);
112      return false;
113    }
114    // compare signatures
115    if (signatureFound.equals(signature)) {
116      LOG.debug("Trusted application with matching signatures");
117      return true;
118    }
119    LOG.info("Invalid signature: {}", authHeader);
120
121    return false;
122  }
123
124  private RequestDataToSign buildRequestDataToSign(final GbifHttpServletRequestWrapper request) {
125    final HttpHeaders headers = request.getHttpHeaders();
126    final RequestDataToSign dataToSign = new RequestDataToSign();
127
128    // custom header to keep the original method in the case the request is forwarded as the remote
129    // auth does
130    if (headers.containsKey(HEADER_ORIGINAL_REQUEST_METHOD)) {
131      dataToSign.setMethod(headers.getFirst(HEADER_ORIGINAL_REQUEST_METHOD));
132    } else {
133      dataToSign.setMethod(request.getMethod());
134    }
135
136    // custom header set by varnish overrides real URI
137    // see http://dev.gbif.org/issues/browse/GBIFCOM-137
138    if (headers.containsKey(HEADER_ORIGINAL_REQUEST_URL)) {
139      dataToSign.setUrl(headers.getFirst(HEADER_ORIGINAL_REQUEST_URL));
140    } else {
141      dataToSign.setUrl(getCanonicalizedPath(request.getRequestURI()));
142    }
143    dataToSign.setContentType(headers.getFirst(HttpHeaders.CONTENT_TYPE));
144    dataToSign.setContentTypeMd5(headers.getFirst(HEADER_CONTENT_MD5));
145    dataToSign.setUser(headers.getFirst(HEADER_GBIF_USER));
146
147    return dataToSign;
148  }
149
150  /**
151   * @return an absolute uri of the resource path alone, excluding host, scheme and query parameters
152   */
153  private String getCanonicalizedPath(final String strUri) {
154    return URI.create(strUri).normalize().getPath();
155  }
156
157  /**
158   * Signs a request by adding a Content-MD5 and Authorization header. For PUT/POST requests that
159   * contain a body entity the Content-MD5 header is created using the same JSON mapper for
160   * serialization as the clients use.
161   *
162   * <p>Other formats than JSON are not supported currently !!!
163   */
164  @Override
165  public GbifHttpServletRequestWrapper signRequest(
166      final String username, final GbifHttpServletRequestWrapper request) {
167    String appKey = appKeyProvider.get();
168    Objects.requireNonNull(appKey, "To sign the request a single application key is required");
169    // first add custom GBIF headers so we can use them to build the string to sign
170    // the proxied username
171    request.getHttpHeaders().add(HEADER_GBIF_USER, username);
172
173    // the canonical path header
174    request
175        .getHttpHeaders()
176        .add(HEADER_ORIGINAL_REQUEST_URL, getCanonicalizedPath(request.getRequestURI()));
177
178    String content = null;
179    if (request.getContent() != null) {
180      content = request.getContent();
181    }
182
183    // adds content md5
184    if (StringUtils.isNotEmpty(content)) {
185      request.getHttpHeaders().add(HEADER_CONTENT_MD5, md5EncodeService.encode(content));
186    }
187
188    // build the unique request data object to sign
189    final RequestDataToSign requestDataToSign = buildRequestDataToSign(request);
190
191    // sign
192    final String signature;
193    try {
194      signature = signingService.buildSignature(requestDataToSign, appKey);
195    } catch (PrivateKeyNotFoundException e) {
196      LOG.warn("Skip signing request with unknown application key: {}", appKey);
197      return request;
198    }
199
200    // build authorization header string
201    final String header = buildAuthHeader(appKey, signature);
202    // add authorization header
203    LOG.debug(
204        "Adding authentication header to request {} for proxied user {} : {}",
205        request.getRequestURI(),
206        username,
207        header);
208    request.getHttpHeaders().add(HttpHeaders.AUTHORIZATION, header);
209
210    return request;
211  }
212
213  private static String buildAuthHeader(String applicationKey, String signature) {
214    return GBIF_SCHEME_PREFIX + applicationKey + ':' + signature;
215  }
216}