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} — 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 — 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}