001/*
002 * Licensed to the Apache Software Foundation (ASF) under one or more
003 * contributor license agreements.  See the NOTICE file distributed with
004 * this work for additional information regarding copyright ownership.
005 * The ASF licenses this file to You under the Apache License, Version 2.0
006 * (the "License"); you may not use this file except in compliance with
007 * the License.  You may obtain a copy of the License at
008 *
009 *      http://www.apache.org/licenses/LICENSE-2.0
010 *
011 * Unless required by applicable law or agreed to in writing, software
012 * distributed under the License is distributed on an "AS IS" BASIS,
013 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
014 * See the License for the specific language governing permissions and
015 * limitations under the License.
016 */
017package org.apache.juneau.rest.server.auth;
018
019import static org.apache.juneau.commons.utils.Shorts.*;
020import static org.apache.juneau.commons.utils.StringUtils.*;
021
022import java.security.*;
023
024import org.apache.juneau.rest.server.*;
025import org.apache.juneau.rest.server.guard.*;
026
027/**
028 * AuthN {@link RestGuard} that authenticates requests via a raw API-key string.
029 *
030 * <p>
031 * On every request, the guard:
032 * <ol>
033 *    <li>Extracts the key from the configured {@linkplain Source source} &mdash; header (default
034 *       {@code X-API-Key}), query parameter, or cookie.
035 *    <li>Delegates lookup to the configured {@link ApiKeyStore}.
036 *    <li>On success, stashes the returned {@link Principal} on the request attributes under
037 *       {@code "juneau.principal"}.
038 *    <li>On any failure path (missing key, unknown key), throws {@link AuthenticationException}
039 *       with a {@code WWW-Authenticate: ApiKey realm="<realm>"} response header.
040 * </ol>
041 *
042 * <h5 class='topic'>Usage</h5>
043 *
044 * <p class='bjava'>
045 *    <jc>// Default source: X-API-Key header.</jc>
046 *    ApiKeyGuard.<jsm>create</jsm>().store(<jv>store</jv>).build();
047 *
048 *    <jc>// Custom header.</jc>
049 *    ApiKeyGuard.<jsm>create</jsm>().store(<jv>store</jv>).fromHeader(<js>"X-Acme-Token"</js>).build();
050 *
051 *    <jc>// Query-param source (handy for browser clients that can't set custom headers).</jc>
052 *    ApiKeyGuard.<jsm>create</jsm>().store(<jv>store</jv>).fromQuery(<js>"apiKey"</js>).build();
053 *
054 *    <jc>// Cookie source.</jc>
055 *    ApiKeyGuard.<jsm>create</jsm>().store(<jv>store</jv>).fromCookie(<js>"api_key"</js>).build();
056 * </p>
057 *
058 * <h5 class='topic'>Security notes</h5>
059 *
060 * <ul>
061 *    <li>API keys are <b>credentials</b>; never log them, always pair with TLS.
062 *    <li>Query-string keys leak into proxy / access logs and browser history &mdash; prefer header or
063 *       cookie sources for production traffic. Query-string is offered for legacy / debugging use only.
064 *    <li>{@link ApiKeyStore} implementations should compare keys in constant time
065 *       ({@link MessageDigest#isEqual(byte[], byte[])}).
066 * </ul>
067 *
068 * <h5 class='section'>See Also:</h5><ul>
069 *    <li class='jc'>{@link ApiKeyStore}
070 *    <li class='jc'>{@link AuthenticationException}
071 *    <li class='jc'>{@link Auth}
072 *    <li class='link'><a class="doclink" href="https://juneau.apache.org/docs/topics/RestServerAuthGuards">AuthN Guards — Bearer / API-Key / JWT</a>
073 * </ul>
074 *
075 * @since 10.0.0
076 */
077@SuppressWarnings({
078   "java:S1192" // Duplicate string literals are HTTP header names and API key parameter names; intentional
079})
080public class ApiKeyGuard extends RestGuard {
081
082   /** WWW-Authenticate response header name (RFC 7235 §4.1). */
083   private static final String WWW_AUTHENTICATE = "WWW-Authenticate";
084
085   /** Source of the API-key string. */
086   public enum Source {
087      /** Read from a request header (default; default header name is {@code X-API-Key}). */
088      HEADER,
089      /** Read from a query parameter. */
090      QUERY,
091      /** Read from a cookie. */
092      COOKIE
093   }
094
095   /**
096    * Static creator.
097    *
098    * @return A new builder.
099    */
100   public static Builder create() {
101      return new Builder();
102   }
103
104   /**
105    * Builder class.
106    */
107   public static class Builder {
108
109      private ApiKeyStore store;
110      private Source source = Source.HEADER;
111      private String name = "X-API-Key";
112      private String realm = "api";
113
114      /**
115       * Constructor.
116       */
117      protected Builder() {}
118
119      /**
120       * Sets the API-key store the guard delegates to.
121       *
122       * @param value The store. Must not be <jk>null</jk>.
123       * @return This object.
124       */
125      public Builder store(ApiKeyStore value) {
126         store = reqnn("value", value);
127         return this;
128      }
129
130      /**
131       * Reads the key from the supplied request header.
132       *
133       * <p>
134       * Calls equivalent to {@code source(Source.HEADER)} plus {@code name(value)}. Defaults to
135       * {@code "X-API-Key"} when not set.
136       *
137       * @param value The header name. Must not be <jk>null</jk> or blank.
138       * @return This object.
139       */
140      public Builder fromHeader(String value) {
141         source = Source.HEADER;
142         name = reqnb("value", value);
143         return this;
144      }
145
146      /**
147       * Reads the key from the supplied query parameter.
148       *
149       * @param value The query parameter name. Must not be <jk>null</jk> or blank.
150       * @return This object.
151       */
152      public Builder fromQuery(String value) {
153         source = Source.QUERY;
154         name = reqnb("value", value);
155         return this;
156      }
157
158      /**
159       * Reads the key from the supplied cookie.
160       *
161       * @param value The cookie name. Must not be <jk>null</jk> or blank.
162       * @return This object.
163       */
164      public Builder fromCookie(String value) {
165         source = Source.COOKIE;
166         name = reqnb("value", value);
167         return this;
168      }
169
170      /**
171       * Sets the {@code WWW-Authenticate: ApiKey realm="<value>"} challenge realm.
172       *
173       * @param value The realm. Must not be <jk>null</jk> or blank.
174       * @return This object.
175       */
176      public Builder realm(String value) {
177         realm = reqnb("value", value);
178         return this;
179      }
180
181      /**
182       * Builds the guard.
183       *
184       * @return A new {@link ApiKeyGuard}.
185       */
186      public ApiKeyGuard build() {
187         if (store == null)
188            throw new IllegalStateException("ApiKeyGuard requires an ApiKeyStore");
189         return new ApiKeyGuard(this);
190      }
191   }
192
193   private final ApiKeyStore store;
194   private final Source source;
195   private final String name;
196   private final String challenge;
197
198   /**
199    * Constructor.
200    *
201    * @param builder The builder to read configuration from. Must not be <jk>null</jk>.
202    */
203   protected ApiKeyGuard(Builder builder) {
204      this.store = builder.store;
205      this.source = builder.source;
206      this.name = builder.name;
207      this.challenge = "ApiKey realm=\"" + builder.realm + "\"";
208   }
209
210   /**
211    * Convenience constructor: header source with default name {@code "X-API-Key"} and default realm.
212    *
213    * @param store The API-key store. Must not be <jk>null</jk>.
214    */
215   public ApiKeyGuard(ApiKeyStore store) {
216      this(create().store(store));
217   }
218
219   @Override /* Overridden from RestGuard */
220   public boolean guard(RestRequest req, RestResponse res) {
221      var key = readKey(req);
222      if (isBlank(key)) {
223         res.setHeader(WWW_AUTHENTICATE, challenge);
224         throw missing();
225      }
226      Principal p;
227      try {
228         var result = store.lookup(key);
229         if (result.isEmpty()) {
230            res.setHeader(WWW_AUTHENTICATE, challenge);
231            throw unknown();
232         }
233         p = result.get();
234      } catch (AuthenticationException e) {
235         res.setHeader(WWW_AUTHENTICATE, challenge);
236         throw e;
237      } catch (RuntimeException e) {
238         res.setHeader(WWW_AUTHENTICATE, challenge);
239         throw new AuthenticationException(e, "API key lookup failed").wwwAuthenticate(challenge);
240      }
241      req.getAttributes().set("juneau.principal", p);
242      return true;
243   }
244
245   @Override /* Overridden from RestGuard */
246   public boolean isRequestAllowed(RestRequest req) {
247      // Not used: this guard overrides guard(req, res) directly because it needs to mutate request
248      // attributes and set the WWW-Authenticate challenge on the rejection path.
249      return false;
250   }
251
252   private String readKey(RestRequest req) {
253      return switch (source) {
254         case HEADER -> req.getHeader(name);
255         case QUERY -> req.getQueryParam(name).orElse(null);
256         case COOKIE -> ApiKeyExtractor.readCookie(req, name);
257      };
258   }
259
260   private AuthenticationException missing() {
261      return new AuthenticationException("API key missing").wwwAuthenticate(challenge);
262   }
263
264   private AuthenticationException unknown() {
265      return new AuthenticationException("API key not recognized").wwwAuthenticate(challenge);
266   }
267}