最終更新日:190510 原本2018/12/15

Jetty 9.4 をHTTPS (SSL/TLS) モードで実行する。Javaコードのみでの実現方法を詳解。Keytool、Servlet、クライアント証明書認証までハンズオン

概要

  • Javaの軽量Web APサーバーであるJetty9 をつかってHTTPS(SSL/TLS)サーバーを作る最小コードを掲載します。
  • Jetty関連の外部設定ファイル不要、コードのみで起動できます。
  • 実験用にオレオレ証明書をつくります。OpenSSL等の外部ソフトウェアは不要です。JDK環境だけでOKです。
  • 他のカスタマイズ例も掲載します
    • Step1.Jettyの仕組み(コネクション、ハンドラの概念あたりを中心に)おさらい
    • Step2.HTTPとHTTPSを両方ともホストする方法の最小コード
    • Step3.HTTPでアクセスがあったら、自動的にHTTPSにリダイレクトする方法の最小コード
    • Step4.Servletが共存する場合の最小コード
    • Step5.SSLクライアント認証のやり方の最小コード

環境

  • Jettyのバージョンは2018年12月現在最新の Jetty 9.4.14.v20181114
  • Windows環境で作成(MacやLinuxでも同等)
  • JDK(JDK9,JDK10)、ブラウザ(Chrome 70、Firefox 63、Edge) にて動作確認済

ソースコード

20行で実動するHTTPSサーバーのソースコード

JettyをHTTPSサーバーとして起動するコードは以下のとおり。
たったこれだけ。

package com.example;
import org.eclipse.jetty.server.*;
import org.eclipse.jetty.server.handler.ResourceHandler;
import org.eclipse.jetty.util.ssl.SslContextFactory;

public class MinimalSSLServer {
    public static void main(String[] args) throws Exception {
        final Server server = new Server();
        final SslContextFactory sslContextFactory = new SslContextFactory();
        sslContextFactory.setKeyStorePath(System.getProperty("user.dir") + "/mykeystore.jks");
        sslContextFactory.setKeyStorePassword("mypassword");
        final ServerConnector httpsConnector = new ServerConnector(server, sslContextFactory);
        httpsConnector.setPort(443);
        final ResourceHandler resourceHandler = new ResourceHandler();
        resourceHandler.setResourceBase(System.getProperty("user.dir") + "/htdocs");
        server.setConnectors(new Connector[] { httpsConnector });
        server.setHandler(resourceHandler);
        server.start();
        server.join();
    }
}

以下、それぞれの行をみていく

Jettyサーバーを生成する
final Server server = new Server();
キーストアの保存場所とキーストアのパスワードを指定
final SslContextFactory sslContextFactory = new SslContextFactory();
sslContextFactory.setKeyStorePath(System.getProperty("user.dir") + "/mykeystore.jks");
sslContextFactory.setKeyStorePassword("mypassword");
HTTPSポート(443)をListenするコネクタを生成
final ServerConnector httpsConnector = new ServerConnector(server, sslContextFactory);
httpsConnector.setPort(443);
静的コンテンツ用のハンドラを準備して/htdocsディレクトリ以下を公開
final ResourceHandler resourceHandler = new ResourceHandler();
resourceHandler.setResourceBase(System.getProperty("user.dir") + "/htdocs");
HTTPS用のコネクタをセットし、かつ静的コンテンツ用のハンドラをサーバーにセットする
server.setConnectors(new Connector[] { httpsConnector });
server.setHandler(resourceHandler);
jettyを起動する
server.start();
server.join();

Maven/Gradleの設定

依存関係を記述する

POM.xml
    <dependencies>
        <dependency>
            <groupId>org.eclipse.jetty</groupId>
            <artifactId>jetty-webapp</artifactId>
            <version>9.4.14.v20181114</version>
        </dependency>
    </dependencies>
Gradle
compile group: 'org.eclipse.jetty', name: 'jetty-webapp', version: '9.4.14.v20181114'

Javaのkeytoolをつかってオレオレ証明書を作る

サーバーはあっという間にできたが、
証明書(オレオレ証明書でOK)が無いとサーバーを起動できないので、サーバー起動のために証明書を作成する。
JDKに標準で添付されているkeytoolをつかってキーストアを作る。

keytool の使い方

keytoolは [JAVA_HOME]/bin にあるので、そこにパスを通しておく。

Windowsなら C:/Program Files/Java/jdk-x.x.x/bin 以下にkeytoolコマンドがある。

keytoolをつかってキーストア(keystore)を作成する

コマンドラインで以下のようにする

keytool -genkey -dname "cn=localhost, ou=Example div., o=Example Inc., l=Minato-ku, st=Tokyo, c=JP" -alias jetty -keystore mykeystore.jks -storepass mypassword -keypass mypassword -keyalg RSA -keysize 2048 -sigalg SHA256withRSA -validity 3650 -ext SAN=dns:localhost
オプション 意味
-genkeypair 鍵のペアを生成する
-dname ※1.下で説明 サブジェクトの内容を指定する
-alias jetty キーストア内で鍵を特定するための名前
-keystore mykeystore.jks キーストアのファイル名
-storepass mypassword キーストアのパスワード
-keypass mypassword 秘密鍵のパスワード
-keyalg RSA 鍵ペアの生成アルゴリズムにRSAを指定
-keysize 2048 キー長を2048ビットに指定
-sigalg SHA256withRSA 自己署名証明書への署名アルゴリズムにSHA256指定。
-validity 3650 証明書の有効日数 10年
-ext ※2.下で説明 X.509証明書の拡張機能を指定

※1 dnameで指定するコモンネーム(cn)は、対象のドメイン名(FQDN)にあわせる

-dname に指定している"cn=localhost, ou=Example div., o=Example Inc., c=JP"の意味は以下のとおり。

重要なのはコモンネーム(cn)の項目で、これをHTTPSをホストするドメイン名(FQDN)にあわせる必要がある。
ここではドメイン名を localhost にしている。

サブジェクト 説明
cn localhost コモンネーム
HTTPSしたいURL(FQDN)と一致させる
ou Example div. 小さな組織
o Example inc. 大きな組織
l Minato-ku 地域名
st Tokyo 地方名
c JP 国コード

※2 SAN は Chromeブラウザ対策で。

-ext に "SAN=dns:localhost"を指定している件。

SANは Subject Alternative Name の略でサブジェクトの別名という意味。

本来はSANに代替のサブドメイン名つきドメイン名(FQDN)など書いておくとコモンネームで指定したドメイン名以外にそちらも有効になるという効能がある。

今回はオレオレ証明書を使うホストがlocalhostを想定しているが、SANとしてlocalhostを再度指定した。
これはchromeブラウザ対策で、chromeブラウザはSANが無いと警告を出す仕様になっているため。

これでキーストア mykeystore.jksが完成した。

(コラム)KeytoolまわりのTips

・PKCS12形式 のキーストアができる
ここで作成されるのはPKCS12形式のキーストアとなる。PKCS12形式の場合はPKCS12キーストアが生成される。
ストアのパスワード(storepassword)と鍵のパスワード(keypassword)は一致させる必要がある。

・java.io.IOException: Incorrect AVA formatエラーが出る場合
-dnameで指定した oやouにカンマ(,)など指定できない文字列が入っていると発生する

サーバーを起動して、HTTPSでアクセスする

正常動作するようにディレクトリにファイルを配置していく

プロジェクト用のディレクトリを作る

(1)作業ディレクトリjetty9-minimal-sslを作り、ソースコード MinimalSSLServer.java
`
を以下のように配置した(mavenプロジェクトの標準的な形式)

jetty9-minimal-ssl
├── src/main/java
│   └── com.example
│       └── MinimalSSLServer.java

(2) ここに、さきほど作ったキーストアファイルmykeystore.jksを配置する

ここまでのフォルダ構成

jetty9-minimal-ssl
├── src/main/java
│   └── com.example
│       └── MinimalSSLServer.java
└── mykeystore.jks

(3)さらに、静的コンテンツをおくために、/htdocsディレクトリをつくって、その下にindex.htmlを配置する

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
<h1>Hello jetty9-https</h1>

</body>
</html>

ここまでのフォルダ構成

jetty9-minimal-ssl
├── src/main/java
│   └── com.example
│       └── MinimalSSLServer.java
├── htdocs
│   └── index.html
└── mykeystore.jks

(4)最後にmaven用のpom.xmlを配置する

<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <groupId>org.riversun</groupId>
    <artifactId>jetty-minimal-ssl</artifactId>
    <version>1.0.0</version>
    <packaging>jar</packaging>
    <name>jetty-minimal-ssl</name>
    <description></description>

    <dependencies>
        <dependency>
            <groupId>org.eclipse.jetty</groupId>
            <artifactId>jetty-webapp</artifactId>
            <version>9.4.14.v20181114</version>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-compiler-plugin</artifactId>
                <configuration>
                    <source>1.8</source>
                    <target>1.8</target>
                </configuration>
            </plugin>
            <plugin>
                <groupId>org.codehaus.mojo</groupId>
                <artifactId>exec-maven-plugin</artifactId>
                <version>1.6.0</version>
                <executions>
                    <execution>
                        <goals>
                            <goal>java</goal>
                        </goals>
                    </execution>
                </executions>
                <configuration>
                    <mainClass>com.example.MinimalSSLServer</mainClass>
                </configuration>
            </plugin>
        </plugins>
    </build>
</project>

ここまでのフォルダ構成

jetty9-minimal-ssl
├── src/main/java
│   └── com.example
│       └── MinimalSSLServer.java
├── htdocs
│   └── index.html
├── mykeystore.jks
└── pom.xml

サーバーを起動する

cd jetty9-minimal-ssl
mvn compile exec:java

で起動する
(または、Eclipse等から run しても良い)

2018-12-08 14:26:02.242:INFO::com.example.MinimalSSLServer.main(): Logging initialized @2191ms to or
g.eclipse.jetty.util.log.StdErrLog
2018-12-08 14:26:02.306:INFO:oejs.Server:com.example.MinimalSSLServer.main(): jetty-9.4.14.v20181114
; built: 2018-11-14T21:20:31.478Z; git: c4550056e785fb5665914545889f21dc136ad9e6; jvm 9.0.1+11
2018-12-08 14:26:02.591:INFO:oejus.SslContextFactory:com.example.MinimalSSLServer.main(): x509=X509@
2ef809c3(jetty,h=[localhost],w=[]) for SslContextFactory@2b97d413[provider=null,keyStore=file:/dev/jetty9-minimal-ssl/mykeystore.jks,trustStore=null]
2018-12-08 14:26:02.783:WARN:oejusS.config:com.example.MinimalSSLServer.main(): No Client EndPointId
entificationAlgorithm configured for SslContextFactory@2b97d413[provider=null,keyStore=file:/dev/jetty9-minimal-ssl/mykeystore.jks,trustStore=null]
2018-12-08 14:26:02.805:INFO:oejs.AbstractConnector:com.example.MinimalSSLServer.main(): Started Ser
verConnector@3e7c7cea{SSL,[ssl, http/1.1]}{0.0.0.0:443}
2018-12-08 14:26:02.807:INFO:oejs.Server:com.example.MinimalSSLServer.main(): Started @2756ms

HTTPSサーバーが無事起動した

ブラウザからアクセスしてみる

ローカルサーバーとして起動できたので、早速、httpsでアクセスする

https://localhost

ブラウザにおこられる

Windows環境でchromeブラウザで https://localhost にアクセスしたらブラウザに怒られる。オレオレ証明書なので当然。

image.png

そこで、怒られないようにするために、オレオレ証明書をブラウザにインポートする。

Javaのキーストア(keystore)から証明書をエクスポートする

コマンドラインで以下を実行すると、さきほどつくったキーストアmykeystore.jksにある証明書をエクスポートできる。

keytool -export -alias jetty -storepass mypassword -file oreore.crt -keystore mykeystore.jks

証明書がファイル<oreore.crt>に保存されました

オレオレ証明書をブラウザにインポートする

Windows環境の場合で説明する。

oreore.crt をダブルクリックすると、以下のようなダイアログがでるので
[証明書のインストール]を選択するとインポートウィザードが開く

image.png

証明書ストアをどこにするか聞かれるので、 証明書をすべて次のストアに配置するを選択して、[参照]をクリック。

image.png

信頼されたルート証明機関 を選択する。
image.png

[次へ]で次の画面へ

image.png

警告が出る(オレオレ証明書をインポートしようとしているので当然)が[はい]を押す
image.png

ブラウザから再度アクセスする

今度は、エラーも無く、ちゃんとhttpsでアクセスできた
(いったん、ブラウザをすべて閉じてからブラウザを再起動しないと証明書が反映されないことがある)

image.png

脆弱性のあるSSLはデフォルト無効になってるか?

YES。SSLまわりの既知の一般的な問題には最新のJettyならデフォルトで対応している。

ある意味あたりまえだが、例えば、POODLEで有名になったSSLv3などはデフォルトで無効化されている。
ほかにも、脆弱性のある"SSL", "SSLv2", "SSLv2Hello"のようなものはデフォルトで無効化されているし、"MD5","SHA","SHA-1"など弱いハッシュアルゴリズムが含まれている暗号も無効となっている。

Jettyをカスタマイズして、もう少し、こまやかに制御してみる

HTTPSの起動コードは20行で動作させられたので、ひとまず目的は達成できました。
カンタンにつかえるので開発用途で活用できそうです。

最近では、HTTPSは例えばAWSならELB(ロードバランサー)の層で片付けることが多いのですが、Jettyはコードのみで柔軟にサーバーの挙動を制御できるので、参考用にJettyの仕組みと、その他のHTTPS処理コード例も掲載しておきます。

コネクションやハンドラの概念もあるので、以下の順番で説明します

  • Step1.Jettyの仕組み(コネクション、ハンドラの概念あたりを中心に)おさらい
  • Step2.HTTPとHTTPSを両方ともホストする方法の最小コード
  • Step3.HTTPでアクセスがあったら、自動的にHTTPSにリダイレクトする方法の最小コード
  • Step4.Servletが共存する場合の最小コード
  • Step5.SSLクライアント認証のやり方の最小コード

ステップ1:Jettyの仕組み

コードだけではイメージしにくいので、まずJettyの仕組みをざっくりと理解する。

Jettyがリクエストを処理する手順は以下のようになっている。

(1)コネクタ(connector):コネクタでTCP通信を解釈する。
 ↓
(2)サーバー(server):Jettyサーバー本体。処理用スレッドプールをもっており、コネクタとハンドラを制御する。
 ↓
(3)ハンドラ(handler):HTTPリクエストとHTTPレスポンスを処理する。バケツリレー的に複数のハンドラを順次処理していく。

image.png

コネクタの処理

コネクタ(ServletConnector)はTCP接続を受け取るコンポーネントで、TCP接続があるたびに ConnectionFactoryがコネクションを生成する。SSLを取り扱うときには、SslConnectionFactoryとHttpConnectionFactoryをコネクタ(ServerConnector)にセットする。SSL接続要求があればまず、SslConnectionFactoryがSslConnectionを生成する。SslConnectionによってSSLの暗号化/複合化処理が実行され、処理がおわると、次のプロトコルとしてHTTP/1.1を処理するため、次のConnectionに渡される。具体的にはHttpConnectionFactoryがHttpConnectionを生成する。

コネクタはTCP接続ごとに作るので、HTTP(TCPポート80)とHTTPS(TCPポート443)に対応させたければコネクタを2つ準備することになる。
2つのコネクタを登録するにいは以下のようにする。

server.setConnectors(new Connector[] { httpConnector, httpsConnector });

ハンドラの処理

ハンドラ(handler)はHTTPリクエスト/HTTPレスポンスを処理するコンポーネント。
ハンドラにはRequestHandlerのようにリクエストに応じて静的コンテンツを提供するものや、ServletContextHandlerのようにリクエストに応じてサーブレットを起動するもの、ConstraintSecurityHandlerのようにリクエストに応じて、セキュリティ機能を提供するものなどがある。

ハンドラのメインの処理は以下の#handleメソッドになっている。

public void handle(String target, Request baseRequest, HttpServletRequest request, HttpServletResponse response) throws IOException, ServletException

このようにリクエストを処理して、レスポンスを返すというシンプルなインタフェースを実装している。
しかけはシンプルだが、複数のハンドラをチェーンのようにつないで処理させることができる。

いちばん上流にハンドラをセットしたい場合にはServer#setHandlerをつかってハンドラを登録できる。
複数のハンドラをセットしたい場合には HandlerList というクラスをつかって、Server#setHandler(HandlerList)としてあげれば良い。

また、各ハンドラの中に下位のハンドラをセットすることができるため、ハンドラーを直列でつなぎ、さらにハンドラーにハンドラーがネストされているような処理形態を作ることも可能。

実際に、この後の例ではハンドラのつなぎかたを工夫してHTTPS関連処理を実現する例も掲載した。

ステップ2:HTTPとHTTPSを両方ホストする

いままでは20行で動作するHTTPSサーバーということで、HTTPSだけをみてきたが、もう少し実用的にしていく。ただしなるべく最小コードで。

まず、HTTPとHTTPSの共存から。

public class Step2HttpAndHttpsServer {

    public static void main(String[] args) throws Exception {

        final Server server = new Server();
        final SslContextFactory sslContextFactory = new SslContextFactory();
        sslContextFactory.setKeyStorePath(System.getProperty("user.dir") + "/mykeystore.jks");
        sslContextFactory.setKeyStorePassword("mypassword");

        ServerConnector httpConnector = new ServerConnector(server);
        httpConnector.setPort(80);

        ServerConnector httpsConnector = new ServerConnector(server, sslContextFactory);
        httpsConnector.setPort(443);

        final ResourceHandler resourceHandler = new ResourceHandler();
        resourceHandler.setResourceBase(System.getProperty("user.dir") + "/htdocs");

        server.setConnectors(new Connector[] { httpConnector, httpsConnector });
        server.setHandler(resourceHandler);

        server.start();
        server.join();
    }
}

変更点を中心にみていくと、以下のようにHTTP用のコネクタを作成し、
HTTPとHTTPSの両方に対応させる。

HTTPポート(80)をListenするコネクタを作成
ServerConnector httpConnector = new ServerConnector(server);
httpConnector.setPort(80);
HTTPとHTTPSの両方のコネクタをサーバーにセットする
server.setConnectors(new Connector[] { httpConnector, httpsConnector });

これで実行すると、 http://localhosthttps://localhost の双方でアクセスできるようになる

ステップ3:HTTPで来たアクセスをHTTPSにリダイレクトする

HTTPでアクセスがあったときに、自動的にHTTPS側に切り替えられるようにする

public class Step3AutoRedirect2HttpsServer {

    public static void main(String[] args) throws Exception {

        Server server = new Server();
        SslContextFactory sslContextFactory = new SslContextFactory();
        sslContextFactory.setKeyStorePath(System.getProperty("user.dir") + "/mykeystore.jks");
        sslContextFactory.setKeyStorePassword("mypassword");

        HttpConfiguration httpConfig = new HttpConfiguration();
        httpConfig.setSecureScheme("https");
        httpConfig.setSecurePort(443);

        ServerConnector httpConnector = new ServerConnector(server, new HttpConnectionFactory(httpConfig));
        httpConnector.setPort(80);

        ServerConnector httpsConnector = new ServerConnector(server, sslContextFactory);
        httpsConnector.setPort(443);

        ConstraintSecurityHandler securityHandler = new ConstraintSecurityHandler();
        Constraint constraint = new Constraint();
        constraint.setDataConstraint(Constraint.DC_CONFIDENTIAL);

        ConstraintMapping mapping = new ConstraintMapping();
        mapping.setPathSpec("/*");
        mapping.setConstraint(constraint);

        securityHandler.addConstraintMapping(mapping);

        final ResourceHandler resourceHandler = new ResourceHandler();
        resourceHandler.setResourceBase(System.getProperty("user.dir") + "/htdocs");
        securityHandler.setHandler(resourceHandler);

        server.setConnectors(new Connector[] { httpConnector, httpsConnector });
        server.setHandler(securityHandler);

        server.start();
        server.join();
    }
}

こちらも変更点、重要な部分をみていく

HTTP用のコネクタを作るときにセキュア設定をしたConfigを指定する
HttpConfiguration httpConfig = new HttpConfiguration();
httpConfig.setSecureScheme("https");
httpConfig.setSecurePort(443);

ServerConnector httpConnector = new ServerConnector(server, new HttpConnectionFactory(httpConfig));
httpConnector.setPort(80);

HTTP用のコネクタのConfigなのにHttpConfiguration#setSecureScheme("https")となっていたり、HttpConfiguration#setSecurePort(443)となっているので、「えっ?」となる。

そこで、それに関して少し説明してみる。

Jettyでは、パス(コンテキストパス)ごとにセキュリティ設定をすることができる。ざっくりいうと、パスごとにアクセスする際のルールを設定できる。あるパスにアクセスが来た場合には通信を保護したい、という場合、サーバーとして挙動を設定することができる。

たとえば、 "/private" というパスにHTTPでアクセスされたら、403を返すとか、 "/confidential"というパスにHTTPでアクセスされた場合は HTTPS に転送する、などである。
そういった挙動による制御をデータ制約(Data Constraint)という考え方で行う。

今回は HTTPS に転送する挙動になるように実装したが、これは CONFIDENTIALという名の制約となる。CONFIDENTIALというのはJetty実装としてはセキュアな手段への転送を意味している。
(ほかの制約の例としてFORBIDDENという名の制約があるが、これをセットすると403返す挙動となる)

もとの話にもどると、setSecureScheme("https")setSecurePort(443)というのは、CONFIDENTIALという種類の制約がセキュリティとして設定されている状態の場合に、「HTTP用コネクタにアクセスがあった場合のセキュアな手段として"https"というschemeで、portは443です」というのをHttpConfigurationであらかじめ定義しているということになる。

次は前の同じようにHTTPS用コネクタを生成する

HTTPS用のコネクタを生成する
        ServerConnector httpsConnector = new ServerConnector(server, sslContextFactory);
        httpsConnector.setPort(443);
セキュリティ設定をする
ConstraintSecurityHandler securityHandler = new ConstraintSecurityHandler();
Constraint constraint = new Constraint();
constraint.setDataConstraint(Constraint.DC_CONFIDENTIAL);

ConstraintMapping mapping = new ConstraintMapping();
mapping.setPathSpec("/*");
mapping.setConstraint(constraint);

securityHandler.addConstraintMapping(mapping);

ConstraintSecurityHandlerクラスはセキュリティ系設定をするハンドラーで、設定したセキュリティ系の挙動を有効にしたい場合はこのハンドラーをServerにセットする。

次にでてくるConstraint クラスはそうしたセキュリティを実現するための各種制約をセットするためのもので、ここではconstraint.setDataConstraint(Constraint.DC_CONFIDENTIAL);CONFIDENTIALという制約をセットしている。
ConstraintMapping クラスは、実際にブラウザからアクセスされるパスと制約のマッピングをしている

mapping.setPathSpec("/*");
mapping.setConstraint(constraint);
securityHandler.addConstraintMapping(mapping);

この設定によって、パス "/*"つまり、アクセスされるすべてのパスに対して CONFIDENTIALという制約が有効になる。
securityHandler.addConstraintMapping(mapping);でConstraintSecurityHandlerに制約とパスのマッピングを追加する。

静的コンテンツのハンドラを生成する
final ResourceHandler resourceHandler = new ResourceHandler();
resourceHandler.setResourceBase(System.getProperty("user.dir") + "/htdocs");
securityHandler.setHandler(resourceHandler);

securityHandler.setHandler(resourceHandler);に注目。
静的コンテンツにアクセスするときに、ConstraintSecurityHandlerのハンドラーをまず経由させるために、ConstraintSecurityHandlerに対してsetHandlerする。

serverに対してsecurityHandlerをセットする
server.setHandler(securityHandler);

最後に、serverに対してConstraintSecurityHandlerをセットする。
これで、アクセスがあった場合に、まずConstraintSecurityHandlerで評価され、ConstraintSecurityHandlerの処理プロセスの中でResourceHandlerが処理されるのでさきほどセットした一連のセキュリティ設定が機能する。

ステップ4.Servletの導入

コード全体
public class Step4HttpsWithServletServer {

    public static void main(String[] args) throws Exception {
        final Server server = new Server();
        final SslContextFactory sslContextFactory = new SslContextFactory();
        sslContextFactory.setKeyStorePath(System.getProperty("user.dir") + "/mykeystore.jks");
        sslContextFactory.setKeyStorePassword("mypassword");

        HttpConfiguration httpConfig = new HttpConfiguration();
        httpConfig.setSecureScheme("https");
        httpConfig.setSecurePort(443);
        ServerConnector httpConnector = new ServerConnector(server,
                new HttpConnectionFactory(httpConfig));
        httpConnector.setPort(80);

        ServerConnector httpsConnector = new ServerConnector(server, sslContextFactory);
        httpsConnector.setPort(443);

        ConstraintSecurityHandler securityHandler = new ConstraintSecurityHandler();
        Constraint constraint = new Constraint();
        constraint.setDataConstraint(Constraint.DC_CONFIDENTIAL);

        ConstraintMapping mapping = new ConstraintMapping();
        mapping.setPathSpec("/*");
        mapping.setConstraint(constraint);

        securityHandler.addConstraintMapping(mapping);

        final ResourceHandler resourceHandler = new ResourceHandler();
        resourceHandler.setResourceBase(System.getProperty("user.dir") + "/htdocs");
        server.setConnectors(new Connector[] { httpConnector, httpsConnector });
        ServletContextHandler servletContextHandler = new ServletContextHandler(ServletContextHandler.SESSIONS);
        servletContextHandler.addServlet(ExampleServlet.class, "/test");

        HandlerList handlerList = new HandlerList();
        handlerList.addHandler(resourceHandler);
        handlerList.addHandler(servletContextHandler);

        securityHandler.setHandler(handlerList);

        server.setHandler(securityHandler);

        server.start();
        server.join();
    }

    @SuppressWarnings("serial")
    public static class ExampleServlet extends HttpServlet {

        @Override
        protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
            resp.setContentType("text/plain; charset=UTF-8");

            final PrintWriter out = resp.getWriter();

            out.println("Hello Servlet");
            out.close();

        }
    }
}

すこし長くなってきたが、重要なところだけみていく。

ServletContextHandler servletContextHandler = new ServletContextHandler(ServletContextHandler.SESSIONS);
servletContextHandler.addServlet(ExampleServlet.class, "/test");

HandlerList handlerList = new HandlerList();
handlerList.addHandler(resourceHandler);
handlerList.addHandler(servletContextHandler);

securityHandler.setHandler(handlerList);

Servletを追加したいので、ServletContextHandlerに登場してもらった。
ここでは /test というパスにサーブレットをアサインしている。

静的コンテンツとサーブレットと両方ともセキュリティが効くようにしておきたいので、securityHandlerに両方ともセットするために、HandlerListをつくって、そちらにResourcehandler と ServletContextHandler の両方のハンドラを追加。

これで、 http://localhost/test にアクセスすると、 https://localhost/test に転送され、意図どおり動作する。

ステップ5.クライアント証明書のハンドリング

ここでは、クライアント証明書の認証に対応させてみる。

クライアント証明書認証とは、PCなどデバイスに証明書(クライアント証明書)をインストールしておいて、サーバーに証明書を添えてアクセスすることで、サーバー側で証明書を検証し利用者を認証する仕組み。一般にユーザー名+パスワードの認証よりも安全性が高いといわれている。

いわゆるSSL/TLS対応したサーバーとクライアント証明書をもったクライアントの双方で認証することを相互認証(mutual authentication)と呼ぶ。

以下、クライアント証明書をハンドリングできるコードを掲載する。
(認証処理まで入れていないが、コードをみれば、どうすれば良いかわかるはず)

クライアント証明書認証をハンドリングできる
public class Step5ClientCertificateAuthServer {

    public static void main(String[] args) throws Exception {

        final Server server = new Server();
        final SslContextFactory sslContextFactory = new SslContextFactory();

        sslContextFactory.setKeyStorePath(System.getProperty("user.dir") + "/mykeystore.jks");
        sslContextFactory.setKeyStorePassword("mypassword");
        sslContextFactory.setWantClientAuth(true);

        HttpConfiguration httpConfig = new HttpConfiguration();
        httpConfig.setSecureScheme("https");
        httpConfig.setSecurePort(443);
        ServerConnector httpConnector = new ServerConnector(server,
                new HttpConnectionFactory(httpConfig));
        httpConnector.setPort(80);

        HttpConfiguration httpsConfig = new HttpConfiguration(httpConfig);
        httpsConfig.addCustomizer(new SecureRequestCustomizer());

        ServerConnector httpsConnector = new ServerConnector(server,
                new SslConnectionFactory(sslContextFactory, HttpVersion.HTTP_1_1.asString()),
                new HttpConnectionFactory(httpsConfig));
        httpsConnector.setPort(443);

        ConstraintSecurityHandler securityHandler = new ConstraintSecurityHandler();
        Constraint constraint = new Constraint();
        constraint.setDataConstraint(Constraint.DC_CONFIDENTIAL);

        ConstraintMapping mapping = new ConstraintMapping();
        mapping.setPathSpec("/*");
        mapping.setConstraint(constraint);

        securityHandler.addConstraintMapping(mapping);

        final ResourceHandler resourceHandler = new ResourceHandler();
        resourceHandler.setResourceBase(System.getProperty("user.dir") + "/htdocs");

        server.setConnectors(new Connector[] { httpConnector, httpsConnector });
        ServletContextHandler servletContextHandler = new ServletContextHandler(ServletContextHandler.SESSIONS);
        servletContextHandler.addServlet(ExampleServlet.class, "/test");

        HandlerList handlerList = new HandlerList();
        handlerList.addHandler(resourceHandler);
        handlerList.addHandler(servletContextHandler);

        securityHandler.setHandler(handlerList);

        server.setHandler(securityHandler);

        server.start();
        server.join();
    }

    @SuppressWarnings("serial")
    public static class ExampleServlet extends HttpServlet {

        @Override
        protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {

            final X509Certificate[] clientCerts = (X509Certificate[]) req.getAttribute("javax.servlet.request.X509Certificate");
            final String cipherSuite = (String) req.getAttribute("javax.servlet.request.cipher_suite");
            final Integer keySize = (Integer) req.getAttribute("javax.servlet.request.key_size");
            final String idStr = (String) req.getAttribute("javax.servlet.request.ssl_session_id");

            final StringBuilder sb = new StringBuilder();

            if (clientCerts != null) {
                for (int i = 0; i < clientCerts.length; i++) {
                    final X509Certificate cert = clientCerts[i];
                    sb.append("cert[" + i + "] subjectDN=" + cert.getSubjectDN()).append("\n");
                }
            }
            sb.append("cipherSuite=" + cipherSuite).append("\n");
            sb.append("keySize=" + keySize).append("\n");
            sb.append("idStr=" + idStr).append("\n");

            resp.setContentType("text/plain; charset=UTF-8");

            final PrintWriter out = resp.getWriter();

            out.println("Hello Servlet");
            out.println("Client Certificate Info");
            out.println(sb.toString());
            out.close();

        }
    }
}

Jettyで、クライアント証明書認証を有効にするには以下のようにする。

クライアント認証を有効にする
   sslContextFactory.setWantClientAuth(true);

これで、ブラウザからJettyにリクエストがあったときに、クライアント証明書を要求するようになる。

認証のやりかたは色々あるが、クライアント証明書が取り出せないとはじまらない。
以下のコードでその設定をする

HTTPS用のConfigを生成する。
HttpConfiguration httpsConfig = new HttpConfiguration(httpConfig);
httpsConfig.addCustomizer(new SecureRequestCustomizer());

SecureRequestCustomizerをセットしておくと、クライアント証明書をJettyのSSL処理エンジン(SslEngine)から取り出してRequestのAttributeに保存しておいてくれる。これをservlet等からHttpRequest#getAttributeで取り出しが可能になる。

具体的には以下のようにする

クライアント証明書情報の取得
final X509Certificate[] clientCerts = (X509Certificate[])req.getAttribute("javax.servlet.request.X509Certificate");
final String cipherSuite = (String) req.getAttribute("javax.servlet.request.cipher_suite");
final Integer keySize = (Integer) req.getAttribute("javax.servlet.request.key_size");
final String idStr = (String) req.getAttribute("javax.servlet.request.ssl_session_id");

Keytoolを使ってクライアント証明書を作成する

上の例をためすために再び keytool をつかってクライアント証明書(オレオレ)をつくってみる。
今回は、CA(認証局)を使わないのでサーバー側のKeystoreにクライアント証明書の公開鍵を登録することでクライアント認証が成立する環境をつくる。

(1)クライアント証明書を含むキーストアの作成

keytool -genkey -dname "cn=user, ou=User div., o=User Inc., l=Minato-ku, st=Tokyo, c=JP" -alias user -keystore myclientkeystore.jks -storepass mypassword -keypass mypassword -keyalg RSA -keysize 2048 -sigalg SHA256withRSA -validity 3650

クライアント証明書用のキーストア myclientkeystore.jksができた。

(2)クライアント証明書の公開鍵をサーバー側のキーストアに取り込む

クライアント証明書の発行元をサーバーが信頼していないと、クライアント証明書認証ができないので、Jettyサーバー側のキーストア(mykeystore.jks)に、いまつくったクライアント証明書の公開鍵を取り込む。

まず、クライアント証明書用のキーストアから公開鍵をエクスポートする。

クライアント証明書用のキーストアから公開鍵を含む証明書をエクスポートする
keytool -export -alias user -storepass mypassword -file myclient.crt -keystore myclientkeystore.jks

これでクライアントの公開鍵を含む証明書 myclient.crt ができた。

開いてみるとこんな感じ。

image.png

こいつを、サーバー側のキーストアに取り込むことでサーバーにこのクライアント証明書(の発行元)を信頼させる状況をつくる。

クライアント証明書の公開鍵を、サーバー側のキーストアにインポート
keytool -importcert -alias user -file myclient.crt -keystore mykeystore.jks -storepass mypassword

所有者: CN=user, OU=User div., O=User Inc., L=Minato-ku, ST=Tokyo, C=JP
発行者: CN=user, OU=User div., O=User Inc., L=Minato-ku, ST=Tokyo, C=JP
シリアル番号: 5dd8a186
有効期間の開始日: Sun Dec 09 17:44:03 JST 2018終了日: Wed Dec 06 17:44:03 JST 2028
証明書のフィンガプリント:
         SHA1: E1:2F:E0:23:A0:0A:50:18:22:26:28:28:E9:95:E9:3C:B3:87:BC:4D
         SHA256: EC:2A:54:61:57:0C:19:70:4D:5E:29:47:FF:DF:C3:27:5E:03:A9:5B:E9:39:4C:A0:83:B3:D6:03
:72:76:04:12
署名アルゴリズム名: SHA256withRSA
サブジェクト公開鍵アルゴリズム: 2048ビットRSA鍵
バージョン: 3

拡張:

#1: ObjectId: 2.5.29.14 Criticality=false
SubjectKeyIdentifier [
KeyIdentifier [
0000: AF 47 BB 4E 18 CF 79 13   6F EE C4 B6 29 FF 3C 91  .G.N..y.o...).<.
0010: BF BC 4C 10                                        ..L.
]
]

この証明書を信頼しますか。 [いいえ]:  yes
証明書がキーストアに追加されました

信頼するか聞かれるので yes とこたえれば、インポート成功。

以下のようにすれば、サーバー側のキーストアの中身が見られる。ちゃんと2つ登録されているのがわかる。

サーバー側のキーストアの中身をみてみる
keytool -list -v -storepass mypassword -keystore  mykeystore.jks

キーストアのタイプ: PKCS12
キーストア・プロバイダ: SUN

キーストアには2エントリが含まれます

別名: jetty
作成日: 2018/12/08
エントリ・タイプ: PrivateKeyEntry
証明書チェーンの長さ: 1
証明書[1]:
所有者: CN=localhost, OU=Example div., O=Example Inc., L=Minato-ku, ST=Tokyo, C=JP
発行者: CN=localhost, OU=Example div., O=Example Inc., L=Minato-ku, ST=Tokyo, C=JP
シリアル番号: 26088242
有効期間の開始日: Sat Dec 08 12:53:11 JST 2018終了日: Tue Dec 05 12:53:11 JST 2028
証明書のフィンガプリント:
         SHA1: 80:F1:F4:BC:19:64:55:57:C7:4B:B8:7C:CB:B0:CC:B2:BC:D3:E0:45
         SHA256: 23:90:61:3F:2D:61:B7:42:D2:C3:02:E6:58:09:34:D2:50:C0:28:DF:BE:E8:98:51:0B:14:21:8F
:76:12:A0:DD
署名アルゴリズム名: SHA256withRSA
サブジェクト公開鍵アルゴリズム: 2048ビットRSA鍵
バージョン: 3

略

*******************************************
*******************************************

別名: user
作成日: 2018/12/09
エントリ・タイプ: trustedCertEntry

所有者: CN=user, OU=User div., O=User Inc., L=Minato-ku, ST=Tokyo, C=JP
発行者: CN=user, OU=User div., O=User Inc., L=Minato-ku, ST=Tokyo, C=JP
シリアル番号: 5dd8a186
有効期間の開始日: Sun Dec 09 17:44:03 JST 2018終了日: Wed Dec 06 17:44:03 JST 2028
証明書のフィンガプリント:
         SHA1: E1:2F:E0:23:A0:0A:50:18:22:26:28:28:E9:95:E9:3C:B3:87:BC:4D
         SHA256: EC:2A:54:61:57:0C:19:70:4D:5E:29:47:FF:DF:C3:27:5E:03:A9:5B:E9:39:4C:A0:83:B3:D6:03
:72:76:04:12
署名アルゴリズム名: SHA256withRSA
サブジェクト公開鍵アルゴリズム: 2048ビットRSA鍵
バージョン: 3

略

*******************************************
*******************************************

これでクライアント側の公開鍵がサーバーにキーストアに入ったので、サーバーはクライアント証明書(の発行元)が信頼できる状態になった。

(3)ブラウザにクライアント証明書を仕込む

ブラウザにクライアント証明書を仕込む、つまりブラウザにインポートするために、今度は秘密鍵がはいった証明書としてキーストアから取り出す。形式はPKCS12形式(*.p12)とする。

PKCS12(p12)形式でクライアント証明書をエクスポート
keytool -importkeystore -srckeystore myclientkeystore.jks -srcstorepass mypassword -destkeystore myclientcert.p12 -deststoretype PKCS12 -deststorepass  mypassword

キーストアmyclientkeystore.jksをmyclientcert.p12にインポートしています...
別名userのエントリのインポートに成功しました。
インポート・コマンドが完了しました: 1件のエントリのインポートが成功しました。0件のエントリのインポー
トが失敗したか取り消されました

これでクライアント証明書がP12ファイル(myclientcert.p12)として取り出せた

クライアント証明書をインポートする

さて、いま作った myclientcert.p12 をブラウザから使えるようにインポートする。

myclientcert.p12をダブルクリックすると、証明書インポートウィザードが起動する

image.png

image.png

パスワードをきかれるので、さきほどセットして mypassword をいれる。
image.png

証明書をすべて次のストアに配置するを選択して、参照をクリック
image.png

個人を選択する
image.png

あとは画面に従って、完了をクリックして、クライアント証明書の取り込み終了

ブラウザからアクセスしてみる

クライアント証明書のお膳立てがなかなか手間だったが、いよいよブラウザからアクセスしてみる。

Step5ClientCertificateAuthServer.java でサーバーを起動しておく。

https://localhost/test にアクセスすると、

image.png

クライアント認証の証明書選択画面に、さきほど登録したオレオレ・クライアント証明書が表示されたので、ここで[OK]を押すと、

image.png

無事、クライアント証明書情報をサーブレット側で取得できた。
(クライアント証明書情報が取得できたので、あとはHandlerでやるなり、サーブレットでやるなり認証処理をすれば良い)

オレオレ証明書の後始末

いくつか作ってきたが、開発用途でつくったオレオレ証明書は目的がおわったら削除したほうがいいのでそのやり方。

コマンドライン等から、証明書のユーティリティを起動する

  • Windows7の場合
certmgr.msc
  • Windows 8、Windows 10の場合
certlm.msc

すると、以下のように信頼されたルート証明機関を開くと、
発行先が localhost となっている証明書がみつかる。これがさっきインポートしたオレオレ証明書となる。

image.png

右クリックメニューから削除を選択する

image.png

削除するときにもこんな警告ダイアログがでるが、オレオレ証明書なので、はいで削除完了
image.png

まとめ

  • 今回は、最新のJetty9で最小コードでHTTPSサーバーを起動する方法を紹介しました。
  • さらに、HTTPやHTTPSの共存や、セキュリティ設定についてもハンズ音しながらみてきました。
  • ソースコードは https://github.com/riversun/jetty9-minimal-ssl にあります
  • JettyはGoogleが採用するなど実績もあり、かつ軽量で使い勝手が良いコンテナの1つなので引き続き活用していこうとおもいます
  • コネクタやハンドラの構成などさらにJettyのアーキテクチャの深い部分知りたい場合はこちらが参考になります。https://www.eclipse.org/jetty/documentation/9.4.x/architecture.html