001/*
002 * Licensed to the Apache Software Foundation (ASF) under one
003 * or more contributor license agreements.  See the NOTICE file
004 * distributed with this work for additional information
005 * regarding copyright ownership.  The ASF licenses this file
006 * to you under the Apache License, Version 2.0 (the
007 * "License"); you may not use this file except in compliance
008 * with the License.  You may obtain a copy of the 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
013 * distributed under the License is distributed on an "AS IS" BASIS,
014 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
015 * See the License for the specific language governing permissions and
016 * limitations under the License.
017 */
018package org.apache.hadoop.hbase.rest;
019
020import static org.apache.hadoop.hbase.rest.RESTServlet.HBASE_REST_SUPPORT_PROXYUSER;
021import static org.junit.jupiter.api.Assertions.assertEquals;
022import static org.junit.jupiter.api.Assertions.assertTrue;
023
024import com.fasterxml.jackson.databind.ObjectMapper;
025import java.io.File;
026import java.io.IOException;
027import java.net.HttpURLConnection;
028import java.net.URL;
029import java.security.Principal;
030import java.security.PrivilegedExceptionAction;
031import org.apache.commons.io.FileUtils;
032import org.apache.hadoop.conf.Configuration;
033import org.apache.hadoop.hbase.HBaseTestingUtil;
034import org.apache.hadoop.hbase.SingleProcessHBaseCluster;
035import org.apache.hadoop.hbase.StartTestingClusterOption;
036import org.apache.hadoop.hbase.TableName;
037import org.apache.hadoop.hbase.client.ColumnFamilyDescriptorBuilder;
038import org.apache.hadoop.hbase.client.Connection;
039import org.apache.hadoop.hbase.client.ConnectionFactory;
040import org.apache.hadoop.hbase.client.Put;
041import org.apache.hadoop.hbase.client.Table;
042import org.apache.hadoop.hbase.client.TableDescriptor;
043import org.apache.hadoop.hbase.client.TableDescriptorBuilder;
044import org.apache.hadoop.hbase.coprocessor.CoprocessorHost;
045import org.apache.hadoop.hbase.http.ssl.KeyStoreTestUtil;
046import org.apache.hadoop.hbase.rest.model.CellModel;
047import org.apache.hadoop.hbase.rest.model.CellSetModel;
048import org.apache.hadoop.hbase.rest.model.RowModel;
049import org.apache.hadoop.hbase.security.HBaseKerberosUtils;
050import org.apache.hadoop.hbase.security.access.AccessControlClient;
051import org.apache.hadoop.hbase.security.access.AccessControlConstants;
052import org.apache.hadoop.hbase.security.access.AccessController;
053import org.apache.hadoop.hbase.security.access.Permission.Action;
054import org.apache.hadoop.hbase.security.token.TokenProvider;
055import org.apache.hadoop.hbase.testclassification.MediumTests;
056import org.apache.hadoop.hbase.testclassification.MiscTests;
057import org.apache.hadoop.hbase.util.Bytes;
058import org.apache.hadoop.hbase.util.Pair;
059import org.apache.hadoop.hdfs.DFSConfigKeys;
060import org.apache.hadoop.http.HttpConfig;
061import org.apache.hadoop.minikdc.MiniKdc;
062import org.apache.hadoop.security.UserGroupInformation;
063import org.apache.hadoop.security.authentication.util.KerberosName;
064import org.apache.http.HttpEntity;
065import org.apache.http.HttpHost;
066import org.apache.http.auth.AuthSchemeProvider;
067import org.apache.http.auth.AuthScope;
068import org.apache.http.auth.Credentials;
069import org.apache.http.client.AuthCache;
070import org.apache.http.client.CredentialsProvider;
071import org.apache.http.client.config.AuthSchemes;
072import org.apache.http.client.methods.CloseableHttpResponse;
073import org.apache.http.client.methods.HttpGet;
074import org.apache.http.client.methods.HttpPut;
075import org.apache.http.client.protocol.HttpClientContext;
076import org.apache.http.config.Registry;
077import org.apache.http.config.RegistryBuilder;
078import org.apache.http.conn.HttpClientConnectionManager;
079import org.apache.http.entity.ContentType;
080import org.apache.http.entity.StringEntity;
081import org.apache.http.impl.auth.SPNegoSchemeFactory;
082import org.apache.http.impl.client.BasicAuthCache;
083import org.apache.http.impl.client.BasicCredentialsProvider;
084import org.apache.http.impl.client.CloseableHttpClient;
085import org.apache.http.impl.client.HttpClients;
086import org.apache.http.impl.conn.PoolingHttpClientConnectionManager;
087import org.apache.http.util.EntityUtils;
088import org.junit.jupiter.api.AfterAll;
089import org.junit.jupiter.api.BeforeAll;
090import org.junit.jupiter.api.Tag;
091import org.junit.jupiter.api.Test;
092import org.slf4j.Logger;
093import org.slf4j.LoggerFactory;
094
095import org.apache.hbase.thirdparty.com.fasterxml.jackson.jaxrs.json.JacksonJaxbJsonProvider;
096import org.apache.hbase.thirdparty.javax.ws.rs.core.MediaType;
097
098/**
099 * Test class for SPNEGO authentication on the HttpServer. Uses Kerby's MiniKDC and Apache
100 * HttpComponents to verify that a simple Servlet is reachable via SPNEGO and unreachable w/o.
101 */
102@Tag(MiscTests.TAG)
103@Tag(MediumTests.TAG)
104public class TestSecureRESTServer {
105
106  private static final Logger LOG = LoggerFactory.getLogger(TestSecureRESTServer.class);
107  private static final HBaseTestingUtil TEST_UTIL = new HBaseTestingUtil();
108  private static final HBaseRESTTestingUtility REST_TEST = new HBaseRESTTestingUtility();
109  private static SingleProcessHBaseCluster CLUSTER;
110
111  private static final String HOSTNAME = "localhost";
112  private static final String CLIENT_PRINCIPAL = "client";
113  private static final String WHEEL_PRINCIPAL = "wheel";
114  // The principal for accepting SPNEGO authn'ed requests (*must* be HTTP/fqdn)
115  private static final String SPNEGO_SERVICE_PRINCIPAL = "HTTP/" + HOSTNAME;
116  // The principal we use to connect to HBase
117  private static final String REST_SERVER_PRINCIPAL = "rest";
118  private static final String SERVICE_PRINCIPAL = "hbase/" + HOSTNAME;
119
120  private static URL baseUrl;
121  private static MiniKdc KDC;
122  private static RESTServer server;
123  private static File restServerKeytab;
124  private static File clientKeytab;
125  private static File wheelKeytab;
126  private static File serviceKeytab;
127
128  @BeforeAll
129  public static void setupServer() throws Exception {
130    final File target = new File(System.getProperty("user.dir"), "target");
131    assertTrue(target.exists());
132
133    /*
134     * Keytabs
135     */
136    File keytabDir = new File(target, TestSecureRESTServer.class.getSimpleName() + "_keytabs");
137    if (keytabDir.exists()) {
138      FileUtils.deleteDirectory(keytabDir);
139    }
140    keytabDir.mkdirs();
141    // Keytab for HBase services (RS, Master)
142    serviceKeytab = new File(keytabDir, "hbase.service.keytab");
143    // The keytab for the REST server
144    restServerKeytab = new File(keytabDir, "spnego.keytab");
145    // Keytab for the client
146    clientKeytab = new File(keytabDir, CLIENT_PRINCIPAL + ".keytab");
147    // Keytab for wheel
148    wheelKeytab = new File(keytabDir, WHEEL_PRINCIPAL + ".keytab");
149
150    /*
151     * Update UGI
152     */
153    Configuration conf = TEST_UTIL.getConfiguration();
154
155    /*
156     * Start KDC
157     */
158    KDC = TEST_UTIL.setupMiniKdc(serviceKeytab);
159    KDC.createPrincipal(clientKeytab, CLIENT_PRINCIPAL);
160    KDC.createPrincipal(wheelKeytab, WHEEL_PRINCIPAL);
161    KDC.createPrincipal(serviceKeytab, SERVICE_PRINCIPAL);
162    // REST server's keytab contains keys for both principals REST uses
163    KDC.createPrincipal(restServerKeytab, SPNEGO_SERVICE_PRINCIPAL, REST_SERVER_PRINCIPAL);
164
165    // Set configuration for HBase
166    HBaseKerberosUtils.setPrincipalForTesting(SERVICE_PRINCIPAL + "@" + KDC.getRealm());
167    HBaseKerberosUtils.setKeytabFileForTesting(serviceKeytab.getAbsolutePath());
168    // Why doesn't `setKeytabFileForTesting` do this?
169    conf.set("hbase.master.keytab.file", serviceKeytab.getAbsolutePath());
170    conf.set("hbase.unsafe.regionserver.hostname", "localhost");
171    conf.set("hbase.master.hostname", "localhost");
172    HBaseKerberosUtils.setSecuredConfiguration(conf, SERVICE_PRINCIPAL + "@" + KDC.getRealm(),
173      SPNEGO_SERVICE_PRINCIPAL + "@" + KDC.getRealm());
174    setHdfsSecuredConfiguration(conf);
175    conf.setStrings(CoprocessorHost.REGION_COPROCESSOR_CONF_KEY, TokenProvider.class.getName(),
176      AccessController.class.getName());
177    conf.setStrings(CoprocessorHost.MASTER_COPROCESSOR_CONF_KEY, AccessController.class.getName());
178    conf.setStrings(CoprocessorHost.REGIONSERVER_COPROCESSOR_CONF_KEY,
179      AccessController.class.getName());
180    // Enable EXEC permission checking
181    conf.setBoolean(AccessControlConstants.EXEC_PERMISSION_CHECKS_KEY, true);
182    conf.set("hbase.superuser", "hbase");
183    conf.set("hadoop.proxyuser.rest.hosts", "*");
184    conf.set("hadoop.proxyuser.rest.users", "*");
185    conf.set("hadoop.proxyuser.wheel.hosts", "*");
186    conf.set("hadoop.proxyuser.wheel.users", "*");
187    UserGroupInformation.setConfiguration(conf);
188
189    updateKerberosConfiguration(conf, REST_SERVER_PRINCIPAL, SPNEGO_SERVICE_PRINCIPAL,
190      restServerKeytab);
191
192    // Start HDFS
193    TEST_UTIL.startMiniCluster(StartTestingClusterOption.builder().numMasters(1).numRegionServers(1)
194      .numZkServers(1).build());
195
196    // Start REST
197    UserGroupInformation restUser = UserGroupInformation
198      .loginUserFromKeytabAndReturnUGI(REST_SERVER_PRINCIPAL, restServerKeytab.getAbsolutePath());
199    restUser.doAs(new PrivilegedExceptionAction<Void>() {
200      @Override
201      public Void run() throws Exception {
202        REST_TEST.startServletContainer(conf);
203        return null;
204      }
205    });
206    baseUrl = new URL("http://localhost:" + REST_TEST.getServletPort());
207
208    LOG.info("HTTP server started: " + baseUrl);
209    TEST_UTIL.waitTableAvailable(TableName.valueOf("hbase:acl"));
210
211    // Let the REST server create, read, and write globally
212    UserGroupInformation superuser = UserGroupInformation
213      .loginUserFromKeytabAndReturnUGI(SERVICE_PRINCIPAL, serviceKeytab.getAbsolutePath());
214    superuser.doAs(new PrivilegedExceptionAction<Void>() {
215      @Override
216      public Void run() throws Exception {
217        try (Connection conn = ConnectionFactory.createConnection(TEST_UTIL.getConfiguration())) {
218          AccessControlClient.grant(conn, REST_SERVER_PRINCIPAL, Action.CREATE, Action.READ,
219            Action.WRITE);
220        } catch (Throwable t) {
221          if (t instanceof Exception) {
222            throw (Exception) t;
223          } else {
224            throw new Exception(t);
225          }
226        }
227        return null;
228      }
229    });
230    instertData();
231  }
232
233  @AfterAll
234  public static void stopServer() throws Exception {
235    try {
236      if (null != server) {
237        server.stop();
238      }
239    } catch (Exception e) {
240      LOG.info("Failed to stop info server", e);
241    }
242    try {
243      if (CLUSTER != null) {
244        CLUSTER.shutdown();
245      }
246    } catch (Exception e) {
247      LOG.info("Failed to stop HBase cluster", e);
248    }
249    try {
250      if (null != KDC) {
251        KDC.stop();
252      }
253    } catch (Exception e) {
254      LOG.info("Failed to stop mini KDC", e);
255    }
256  }
257
258  private static void setHdfsSecuredConfiguration(Configuration conf) throws Exception {
259    // Set principal+keytab configuration for HDFS
260    conf.set(DFSConfigKeys.DFS_NAMENODE_KERBEROS_PRINCIPAL_KEY,
261      SERVICE_PRINCIPAL + "@" + KDC.getRealm());
262    conf.set(DFSConfigKeys.DFS_NAMENODE_KEYTAB_FILE_KEY, serviceKeytab.getAbsolutePath());
263    conf.set(DFSConfigKeys.DFS_DATANODE_KERBEROS_PRINCIPAL_KEY,
264      SERVICE_PRINCIPAL + "@" + KDC.getRealm());
265    conf.set(DFSConfigKeys.DFS_DATANODE_KEYTAB_FILE_KEY, serviceKeytab.getAbsolutePath());
266    conf.set(DFSConfigKeys.DFS_WEB_AUTHENTICATION_KERBEROS_PRINCIPAL_KEY,
267      SPNEGO_SERVICE_PRINCIPAL + "@" + KDC.getRealm());
268    // Enable token access for HDFS blocks
269    conf.setBoolean(DFSConfigKeys.DFS_BLOCK_ACCESS_TOKEN_ENABLE_KEY, true);
270    // Only use HTTPS (required because we aren't using "secure" ports)
271    conf.set(DFSConfigKeys.DFS_HTTP_POLICY_KEY, HttpConfig.Policy.HTTPS_ONLY.name());
272    // Bind on localhost for spnego to have a chance at working
273    conf.set(DFSConfigKeys.DFS_NAMENODE_HTTPS_ADDRESS_KEY, "localhost:0");
274    conf.set(DFSConfigKeys.DFS_DATANODE_HTTPS_ADDRESS_KEY, "localhost:0");
275
276    // Generate SSL certs
277    File keystoresDir = new File(TEST_UTIL.getDataTestDir("keystore").toUri().getPath());
278    keystoresDir.mkdirs();
279    String sslConfDir = KeyStoreTestUtil.getClasspathDir(TestSecureRESTServer.class);
280    KeyStoreTestUtil.setupSSLConfig(keystoresDir.getAbsolutePath(), sslConfDir, conf, false);
281
282    // Magic flag to tell hdfs to not fail on using ports above 1024
283    conf.setBoolean("ignore.secure.ports.for.testing", true);
284  }
285
286  private static void updateKerberosConfiguration(Configuration conf, String serverPrincipal,
287    String spnegoPrincipal, File serverKeytab) {
288    KerberosName.setRules("DEFAULT");
289
290    // Enable Kerberos (pre-req)
291    conf.set("hbase.security.authentication", "kerberos");
292    conf.set(RESTServer.REST_AUTHENTICATION_TYPE, "kerberos");
293    // User to talk to HBase as
294    conf.set(RESTServer.REST_KERBEROS_PRINCIPAL, serverPrincipal);
295    // User to accept SPNEGO-auth'd http calls as
296    conf.set("hbase.rest.authentication.kerberos.principal", spnegoPrincipal);
297    // Keytab for both principals above
298    conf.set(RESTServer.REST_KEYTAB_FILE, serverKeytab.getAbsolutePath());
299    conf.set("hbase.rest.authentication.kerberos.keytab", serverKeytab.getAbsolutePath());
300    conf.set(HBASE_REST_SUPPORT_PROXYUSER, "true");
301  }
302
303  private static void instertData() throws IOException, InterruptedException {
304    // Create a table, write a row to it, grant read perms to the client
305    UserGroupInformation superuser = UserGroupInformation
306      .loginUserFromKeytabAndReturnUGI(SERVICE_PRINCIPAL, serviceKeytab.getAbsolutePath());
307    final TableName table = TableName.valueOf("publicTable");
308    superuser.doAs(new PrivilegedExceptionAction<Void>() {
309      @Override
310      public Void run() throws Exception {
311        try (Connection conn = ConnectionFactory.createConnection(TEST_UTIL.getConfiguration())) {
312          TableDescriptor desc = TableDescriptorBuilder.newBuilder(table)
313            .setColumnFamily(ColumnFamilyDescriptorBuilder.of("f1")).build();
314          conn.getAdmin().createTable(desc);
315          try (Table t = conn.getTable(table)) {
316            Put p = new Put(Bytes.toBytes("a"));
317            p.addColumn(Bytes.toBytes("f1"), new byte[0], Bytes.toBytes("1"));
318            t.put(p);
319          }
320          AccessControlClient.grant(conn, CLIENT_PRINCIPAL, Action.READ);
321        } catch (Throwable e) {
322          if (e instanceof Exception) {
323            throw (Exception) e;
324          } else {
325            throw new Exception(e);
326          }
327        }
328        return null;
329      }
330    });
331  }
332
333  public void testProxy(String extraArgs, String PRINCIPAL, File keytab, int responseCode)
334    throws Exception {
335    UserGroupInformation superuser = UserGroupInformation
336      .loginUserFromKeytabAndReturnUGI(SERVICE_PRINCIPAL, serviceKeytab.getAbsolutePath());
337    final TableName table = TableName.valueOf("publicTable");
338
339    // Read that row as the client
340    Pair<CloseableHttpClient, HttpClientContext> pair = getClient();
341    CloseableHttpClient client = pair.getFirst();
342    HttpClientContext context = pair.getSecond();
343
344    HttpGet get = new HttpGet(new URL("http://localhost:" + REST_TEST.getServletPort()).toURI()
345      + "/" + table + "/a" + extraArgs);
346    get.addHeader("Accept", "application/json");
347    UserGroupInformation user =
348      UserGroupInformation.loginUserFromKeytabAndReturnUGI(PRINCIPAL, keytab.getAbsolutePath());
349    String jsonResponse = user.doAs(new PrivilegedExceptionAction<String>() {
350      @Override
351      public String run() throws Exception {
352        try (CloseableHttpResponse response = client.execute(get, context)) {
353          final int statusCode = response.getStatusLine().getStatusCode();
354          assertEquals(responseCode, statusCode, response.getStatusLine().toString());
355          HttpEntity entity = response.getEntity();
356          return EntityUtils.toString(entity);
357        }
358      }
359    });
360    if (responseCode == HttpURLConnection.HTTP_OK) {
361      ObjectMapper mapper = new JacksonJaxbJsonProvider().locateMapper(CellSetModel.class,
362        MediaType.APPLICATION_JSON_TYPE);
363      CellSetModel model = mapper.readValue(jsonResponse, CellSetModel.class);
364      assertEquals(1, model.getRows().size());
365      RowModel row = model.getRows().get(0);
366      assertEquals("a", Bytes.toString(row.getKey()));
367      assertEquals(1, row.getCells().size());
368      CellModel cell = row.getCells().get(0);
369      assertEquals("1", Bytes.toString(cell.getValue()));
370    }
371  }
372
373  @Test
374  public void testPositiveAuthorization() throws Exception {
375    testProxy("", CLIENT_PRINCIPAL, clientKeytab, HttpURLConnection.HTTP_OK);
376  }
377
378  @Test
379  public void testDoAs() throws Exception {
380    testProxy("?doAs=" + CLIENT_PRINCIPAL, WHEEL_PRINCIPAL, wheelKeytab, HttpURLConnection.HTTP_OK);
381  }
382
383  @Test
384  public void testDoas() throws Exception {
385    testProxy("?doas=" + CLIENT_PRINCIPAL, WHEEL_PRINCIPAL, wheelKeytab, HttpURLConnection.HTTP_OK);
386  }
387
388  @Test
389  public void testWithoutDoAs() throws Exception {
390    testProxy("", WHEEL_PRINCIPAL, wheelKeytab, HttpURLConnection.HTTP_FORBIDDEN);
391  }
392
393  @Test
394  public void testNegativeAuthorization() throws Exception {
395    Pair<CloseableHttpClient, HttpClientContext> pair = getClient();
396    CloseableHttpClient client = pair.getFirst();
397    HttpClientContext context = pair.getSecond();
398
399    StringEntity entity = new StringEntity(
400      "{\"name\":\"test\", \"ColumnSchema\":[{\"name\":\"f\"}]}", ContentType.APPLICATION_JSON);
401    HttpPut put = new HttpPut("http://localhost:" + REST_TEST.getServletPort() + "/test/schema");
402    put.setEntity(entity);
403
404    UserGroupInformation unprivileged = UserGroupInformation
405      .loginUserFromKeytabAndReturnUGI(CLIENT_PRINCIPAL, clientKeytab.getAbsolutePath());
406    unprivileged.doAs(new PrivilegedExceptionAction<Void>() {
407      @Override
408      public Void run() throws Exception {
409        try (CloseableHttpResponse response = client.execute(put, context)) {
410          final int statusCode = response.getStatusLine().getStatusCode();
411          HttpEntity entity = response.getEntity();
412          assertEquals(HttpURLConnection.HTTP_FORBIDDEN, statusCode,
413            "Got response: " + EntityUtils.toString(entity));
414        }
415        return null;
416      }
417    });
418  }
419
420  private Pair<CloseableHttpClient, HttpClientContext> getClient() {
421    HttpClientConnectionManager pool = new PoolingHttpClientConnectionManager();
422    HttpHost host = new HttpHost("localhost", REST_TEST.getServletPort());
423    Registry<AuthSchemeProvider> authRegistry = RegistryBuilder.<AuthSchemeProvider> create()
424      .register(AuthSchemes.SPNEGO, new SPNegoSchemeFactory(true, true)).build();
425    CredentialsProvider credentialsProvider = new BasicCredentialsProvider();
426    credentialsProvider.setCredentials(AuthScope.ANY, EmptyCredentials.INSTANCE);
427    AuthCache authCache = new BasicAuthCache();
428
429    CloseableHttpClient client = HttpClients.custom().setDefaultAuthSchemeRegistry(authRegistry)
430      .setConnectionManager(pool).build();
431
432    HttpClientContext context = HttpClientContext.create();
433    context.setTargetHost(host);
434    context.setCredentialsProvider(credentialsProvider);
435    context.setAuthSchemeRegistry(authRegistry);
436    context.setAuthCache(authCache);
437
438    return new Pair<>(client, context);
439  }
440
441  private static class EmptyCredentials implements Credentials {
442    public static final EmptyCredentials INSTANCE = new EmptyCredentials();
443
444    @Override
445    public String getPassword() {
446      return null;
447    }
448
449    @Override
450    public Principal getUserPrincipal() {
451      return null;
452    }
453  }
454}