はじめに
以下の構成で、SP initiated SSO(Single Sign On)、IdP Initiated SSO を動作検証した時の記録です。
- IdP: OpenAM
- SP: Node.js(+ Express + passport-saml)
ホストOSは、macOS High Sierra。
OpenAMはDockerコンテナとして、Node.jsはOS上で直接稼働させます。
IdP、SPのFQDNは以下のようにします。
- IdP(OpenAM): openam.example.local
- SP(Node.js): www.example2.local
OpenAMの起動
Dockerは、OSに導入済みとします。
こちら のDocker Imageをベースに使用します。
まずは、Gitからイメージをダウンロードし、作業ディレクトリにcdします。
$ git clone https://github.com/vaultsystems/docker-openam.git
$ cd docker-openam
Dockerイメージのベースは、「FROM tomcat:8-jre8」となっていますが、
2017/11現在では、Tomcat 8.5がダウンロードされてしまい、Dockerfileが想定しているTomcat 8.0系ではないため、このままでは動きません。
そこで、「From tomcat:8-jre8」を「From tomcat:8.0-jre8」に変更します。
変更後のDockerfileは、以下の通りです。
(コメント部分はオリジナルのDockerfileに追加した部分になります)
# 8.0系を明示的に指定
FROM tomcat:8.0-jre8
MAINTAINER Christoph Dwertmann <christoph.dwertmann@vaultsystems.com.au>
# OpenAMをダウンロードして、TomcatにDeploy
RUN wget https://github.com/OpenRock/OpenAM/releases/download/13.0.0/OpenAM-13.0.0.zip && \
unzip -d unpacked *.zip && \
mv unpacked/openam/OpenAM*.war $CATALINA_HOME/webapps/openam.war && \
rm -rf *.zip unpacked
# ヒープサイズの最大値を変更、Javaはサーバー用VMを指定
ENV CATALINA_OPTS="-Xmx2048m -server"
# Tomcatの設定ファイルであるserver.xmlのConnector port="8443"行のコメントアウトを外すと共に、
# keystoreファイル、keystoreのパスワードが環境変数で渡されるように変更
# 最後にtomcatを起動
CMD perl -0pi.bak -e 's/<!--\n <Connector port="8443"/<Connector port="8443" maxHttpHeaderSize="102400" keystoreFile="\/opt\/server.keystore" keystorePass="$ENV{'KEYSTORE_PASS'}"/' $CATALINA_HOME/conf/server.xml && \
perl -0pi.bak -e 's/sslProtocol="TLS" \/>\n -->/sslProtocol="TLS" \/>/' $CATALINA_HOME/conf/server.xml && \
catalina.sh run
DockerイメージをDockerfileからビルドします。
$ docker build -t openam .
Sending build context to Docker daemon 140.8kB
Step 1/5 : FROM tomcat:8.0-jre8
8-jre8: Pulling from library/tomcat
3e17c6eae66c: Pull complete
fdfb54153de7: Pull complete
...(省略)
OpenAMのDockerイメージを起動する前に、以下の手順でkeystoreファイルの準備をする必要があります。
- サーバーの秘密鍵を作成します。
$ openssl genrsa -aes256 4096 > server.key
Generating RSA private key, 4096 bit long modulus
................................................................................................................++
........................................................++
e is 65537 (0x10001)
Enter pass phrase: <パスフレーズを指定>
Verifying - Enter pass phrase: <パスフレーズを再入力>
- CSR(Certificate Signing Request、証明書署名要求)を作成します。
証明書署名要求は、認証局(CA)にサーバの公開鍵に電子署名してもらうよう要求するためのメッセージになります。
(今回は自己署名証明書を作成するため、後ほど自身で署名します)
$ openssl req -new -key server.key > server.csr
Enter pass phrase for server.key:
You are about to be asked to enter information that will be incorporated
into your certificate request.
What you are about to enter is what is called a Distinguished Name or a DN.
There are quite a few fields but you can leave some blank
For some fields there will be a default value,
If you enter '.', the field will be left blank.
-----
Country Name (2 letter code) []:JP
State or Province Name (full name) []:Tokyo
Locality Name (eg, city) []:Chuo-ku
Organization Name (eg, company) []:Hoge company
Organizational Unit Name (eg, section) []:Fuga
Common Name (eg, fully qualified host name) []:openam.example.local
Email Address []:
Please enter the following 'extra' attributes
to be sent with your certificate request
A challenge password []:
- CRT(Certificate、サーバー証明書)を作成します。
自己証明書のため、自身の秘密鍵で署名します。
下記の例では、有効期限を10年(3650日)に設定しています。
$ openssl x509 -in server.csr -days 3650 -req -signkey server.key > server.crt
Signature ok
subject=/C=JP/ST=Tokyo/L=Chuo-ku/O=Hoge company/OU=Fuga/CN=openam.example.local
Getting Private key
Enter pass phrase for server.key: <秘密鍵作成時に指定したパスフレーズを入力>
- PKCS#12形式のキーストアを作成します。
上記で作成された証明書(PEMフォーマット)と秘密鍵(PEMフォーマット)を一つのファイルにまとめます。
その際、PKCS#12(Public Key Cryptography Standard#12)という形式になります。
$ openssl pkcs12 -export -in server.crt -inkey server.key -out server.p12 -name tomcat
Enter pass phrase for server.key: <秘密鍵作成時に指定したパスフレーズを入力>
Enter Export Password: <パスフレーズを入力>
Verifying - Enter Export Password: <パスフレーズを入力>
- JKS形式のキーストアを作成します。
ここでは、「-deststorepass」、「-destkeypass」で同じパスフレーズを指定してください。
$ keytool -importkeystore -deststorepass <作成するキーストアのパスフレーズ> -destkeypass <作成するキーのパスフレーズ> -destkeystore server.keystore -srckeystore server.p12 -srcstoretype PKCS12 -srcstorepass <PKCS#12形式のキーストア作成時に指定したパスフレーズ> -alias tomcat
キーストアserver.p12をserver.keystoreにインポートしています...
Warning:
JKSキーストアは独自の形式を使用しています。"keytool -importkeystore -srckeystore server.keystore -destkeystore server.keystore -deststoretype pkcs12"を使用する業界標準の形式であるPKCS12に移行することをお薦めします。
OpenAMのDockerイメージを、openamという名前で起動します。
- --add-hostは、起動するイメージの「/etc/hosts」にエントリーを追加します。ここでは、openam.example.local - 127.0.0.1を追加しています。
- -eオプションは環境変数を設定するオプションです。環境変数KEYSTORE_PASSを指定しています。
- -vオプションはホストOS(この場合、MAC OS)のディレクトリとコンテナ内のディレクトリをマッピングします。
- -pオプションはホストOSのポート番号と、コンテナ内のポート番号をマッピングします(ホストOSの8443/TCPにアクセスすると、コンテナの8443/TCPにマッピングします)
- -dオプションはコンテナをバックグラウンドで起動します。
$ docker run --add-host=openam.example.local:127.0.0.1 -e KEYSTORE_PASS=<キーストアのパスフレーズ> -v $PWD/config:/root -v $PWD/server.keystore:/opt/server.keystore -v /dev/urandom:/dev/random --name openam -p 8443:8443 -d openam
ホストOS(MAC OS)の/etc/hostsにopenam.example.localのエントリーを記述します。
$ sudo vi /private/etc/hosts
127.0.0.1 openam.example.local
ホストOS上でブラウザを起動し、https://openam.example.local:8443/openam/ にアクセスし、OpenAMの画面が表示されることを確認します。
デバッグなどで起動したコンテナにログインしたい場合は、以下のコマンドでコンテナにAttachします(/bin/bashを起動します)。
$ docker exec -it openam /bin/bash
OpenAMの初期構成
ホストOS上でブラウザを起動し、https://openam.example.local:8443/openam/ にアクセスします。
Default configuration / Custom configurationを選択する画面が表示されるので、Custom configurationを選択し、以下のように構成を行います。
-
Step1: General
- Default User Password
- Default User [amAdmin]:
- Default User Password
-
Step2: Server Settings
- Server Settings
- Server URL: https://openam.example.local:8443
- Cookie Domain: .example.local
- Platform Locale: en_US
- Configuration Directory: /root/openam
- Server Settings
-
Step 3: Configuration Data Store Settings
- First Instanceを選択
- Configuration Store Details
- Configuration Data Store: OpenAM
- SSL/TLS Enabled:
- Host Name: localhost
- Port: 50389
- Admin Port: 4444
- JMX Port: 1689
- Encryption Key: <デフォルトのものをそのまま利用>
- Root Suffix: dc=openam,dc=forgerock,dc=org
-
Step 4: User Data Store Settings:
- OpenAM User Data Storeを選択
-
Step 5: Site Configuration
- Will this instance be deployed behind a load balancer as part of a site configuration?: No
-
Step 6: Default Policy Agent User
- Policy Agent Password
- Default Policy Agent [UrlAccessAgent]:
- Policy Agent Password
最後に「Create Configuration」をクリックします。
「Configuration Complete!」と表示されたら、完了です。
アプリケーション(Node.js)の作成
Expressフレームワークを使用して、SAML動作確認用アプリケーションを作成します。
まずは、express-generatorをインストールします。
(-gは、グローバル環境にモジュールをインストール。
express-generatorを使用することで、Expressを使用した雛形プログラムを生成できます。)
$ npm install express-generator -g
express-generatorを使用して、saml-sampleという名前のnode.jsアプリケーションを作成します(テンプレートエンジンはejsを指定しています)。
$ express saml-sample --view=ejs --git
$ cd saml-sample
npmモジュールをインストールします。
$ npm install
今回作成するプログラムで追加で使用するnpmモジュールをインストールします。
(--saveオプションを付与することで、package.jsonに該当npm情報が追記されます)
$ npm install dotenv --save
$ npm install passport --save
$ npm install passport-saml --save
$ npm install express-session --save
上記でインストールしたnpmモジュールの概要は以下の通りです。
- dotenv: .envファイルに環境設定情報を保存し・利用するためのモジュール
- passport、passport-saml: 認証・認可用モジュール
- express-session: expressでセッションを扱うためのモジュール
Stack OverflowのQA を参考に実装したコード例です。
(express-generatorが生成したコードに、今回追加したコードをコメントで追記しています)
var express = require('express');
var path = require('path');
var favicon = require('serve-favicon');
var logger = require('morgan');
var cookieParser = require('cookie-parser');
var bodyParser = require('body-parser');
// 今回使用するモジュールを追加でインポート
var dotenv = require('dotenv');
var passport = require('passport');
var saml = require('passport-saml');
var session = require('express-session');
var index = require('./routes/index');
var users = require('./routes/users');
var app = express();
// .envをロード
dotenv.load();
// SAML認証で取得したユーザー情報をシリアライズしてセッションに埋め込み(req.userでアクセス可能)
// Passportに関しては、http://knimon-software.github.io/www.passportjs.org/guide/configure/を参照
passport.serializeUser(function(user, done) {
done(null, user);
});
// リクエストに含まれるユーザー情報をデシリアライズ
passport.deserializeUser(function(user, done) {
done(null, user);
});
// passport-samlの設定
// process.env.xxxはdotenvでロードした情報(後述する.envに記載されている情報)
// callbackUrl: IdPでのSSOログインが行われた場合に、コールバックされるURL
// entryPoint: SSOログイン時にアクセスするIdPのURL
// issuer: IdPに提供される本アプリケーション(SP)の識別子
// logoutUr: SSOログアウト時にアクセスするIdPのURL
var samlStrategy = new saml.Strategy({
callbackUrl: process.env.CALLBACK_URL,
entryPoint: process.env.ENTRY_POINT,
issuer: process.env.ISSUER,
logoutUrl: process.env.LOGOUT_URL
}, function(profile, done) {
var user = profile;
return done(null, user);
});
// Expressでセッションを使用するための設定
app.use(session({
secret: '123456', // 適切なものに変更してください
resave: false,
saveUninitialized: false,
cookie: {
maxAge: 30 * 60 * 1000
}
}));
// passportを使用するよう構成
passport.use(samlStrategy);
app.use(passport.initialize());
app.use(passport.session());
// view engine setup
app.set('views', path.join(__dirname, 'views'));
app.set('view engine', 'ejs');
// uncomment after placing your favicon in /public
//app.use(favicon(path.join(__dirname, 'public', 'favicon.ico')));
app.use(logger('dev'));
app.use(bodyParser.json());
app.use(bodyParser.urlencoded({ extended: false }));
app.use(cookieParser());
app.use(express.static(path.join(__dirname, 'public')));
app.use('/', index);
app.use('/users', users);
// 認証が通れば、/にリダイレクト
// 認証NGの場合は、認証失敗詳細(failureFlash: true)も含めて/にリダイレクト
app.get('/login', passport.authenticate('saml', {
failureRedirect: '/',
failureFlash: true
}), function(req, res) {
res.redirect('/');
});
// IdPでのSSOログイン後、コールバックされるURL
app.post('/login/callback', passport.authenticate('saml', {
failureRedirect: '/',
failureFlash: true
}), function(req, res) {
res.redirect('/');
});
// ログアウト処理
app.get('/logout', function(req, res) {
// samlStrategy.logut uses req.user
console.log(req.user);
// IdPでのSSOログアウトを実施
samlStrategy.logout(req, function(err, request) {
console.log(err);
console.log(request);
if (!err) {
res.redirect(request);
}
});
});
// IdPでのSSOログアウト後、コールバックされるURL
// アプリケーション(passport)のログアウト処理を実施
app.get('/logout/callback', function(req, res) {
req.logout();
res.redirect('/');
});
// catch 404 and forward to error handler
app.use(function(req, res, next) {
var err = new Error('Not Found');
err.status = 404;
next(err);
});
// error handler
app.use(function(err, req, res, next) {
// set locals, only providing error in development
res.locals.message = err.message;
res.locals.error = req.app.get('env') === 'development' ? err : {};
// render the error page
res.status(err.status || 500);
res.render('error');
});
module.exports = app;
.envファイルの内容は以下の通りです。
アプリケーションは、ローカルホスト(www.example2.local というFQDN)上で稼働させます。
CALLBACK_URL=http://www.example2.local:3000/login/callback
ENTRY_POINT=https://openam.example.local:8443/openam/SSORedirect/metaAlias/idp
ISSUER=http://www.example2.local:3000/
LOGOUT_URL=https://openam.example.local:8443/openam/IDPSloRedirect/metaAlias/idp
ログイン状態かログアウト状態かを判別できるようにするために、
ログイン時に / にアクセスした場合は、Already logged inと表示されるよう変更します。
$ cat routes/index.js
var express = require('express');
var router = express.Router();
/* GET home page. */
router.get('/', function(req, res, next) {
if (req.user) {
res.render('index', { title: 'Express(Already logged in)' });
} else {
res.render('index', { title: 'Express' });
}
});
module.exports = router;
最後にホストOS(MAC OS)の /etc/hosts に www.example2.local のエントリーを記述します。
$ sudo vi /private/etc/hosts
127.0.0.1 openam.example.local
127.0.0.1 www.example2.local
SPメタデータの準備
SP(アプリケーション)に関するメタ情報(識別子、コールバックURLなど)を記述した以下のXMLファイルを作成します。
AssertionCosumerServiceはログイン時のコールバック処理、SingleLogoutServiceはログアウト時のコールバック処理を規定しています。
<EntityDescriptor xmlns="urn:oasis:names:tc:SAML:2.0:metadata" xmlns:ds="http://www.w3.org/2000/09/xmldsig#" entityID="http://www.example2.local:3000/">
<SPSSODescriptor protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">
<SingleLogoutService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" Location="http://www.example2.local:3000/logout/callback"/>
<AssertionConsumerService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="http://www.example2.local:3000/login/callback" index="0"/>
<AssertionConsumerService Binding="urn:oasis:names:tc:SAML:1.0:profiles:browser-post" Location="http://www.example2.local:3000/login/callback" index="1"/>
<AssertionConsumerService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Artifact" Location="http://www.example2.local:3000/login/callback" index="2"/>
<AssertionConsumerService Binding="urn:oasis:names:tc:SAML:1.0:profiles:artifact-01" Location="http://www.example2.local:3000/login/callback" index="3"/>
<AssertionConsumerService Binding="urn:oasis:names:tc:SAML:2.0:profiles:holder-of-key:SSO:browser" Location="http://www.example2.local:3000/login/callback" index="4"/>
</SPSSODescriptor>
<ContactPerson contactType="technical">
<GivenName>Administrator</GivenName>
<EmailAddress>noreply@example.org</EmailAddress>
</ContactPerson>
</EntityDescriptor>
OpenAMの構成
OpenAMをIdPとして構成し、上述したSPの情報を登録します。
ホストOS上でブラウザを起動し、https://openam.example.local:8443/openam/ にアクセスし、以下の情報を入力、ログインします。
- User Name: amAdmin
- Password: インストール時に指定したパスワード
以下の順番にクリックしていきます。
「Top Level Realm」-「Create SAMLv2 Provider」-「Create Hosted Identity Provider」の順にクリックします。
以下の通り構成し、画面右上の「Configure」をクリックします。
- Do you have metadata for this provider?: No
- metadata
- Name: https://openam.example.local:8443/openam
- Signing Key: test
- Circle of Trust
- New Circle of Trust: node(任意の文字列でOK)
「Your Identity Provider has been configured」と表示されるので、「register a service provider」をクリックします。
「Create a SAMLv2 Remote Service Provider」というページが表示されるため、以下の通り入力し、画面右上の「Configure」をクリックします。
- Where does the metadata file reside?: File
- URL where metadata is located: <上で作成したSPメタデータ>
「Service provider is configured.
You can modify the provider's profile under the Federation tab.」と表示されたら、OKをクリックします。
トップページから、画面上の「FEDERATION」を選択し、上記で指定したSPが登録されていることを確認します。
ログインテスト用のユーザーを作成するため、「Realms」をクリックし、トップページに戻ります。
「Top Level Realm」-「Subjects」の順にクリックします。
「New」をクリックし、ユーザーを作成します。
passport-samlでは、デフォルトのidentifierFormat(各ユーザーの識別子)にemailアドレスを使用します。
そのため、ユーザー情報作成時に忘れずにメールアドレスも記述する必要があります
(identifierFormatを変更した場合はその限りではありません)。
identifierFormat: if truthy, name identifier format to request from identity provider (default: urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress)
本ユーザーを用いて、接続テストを実施します。
接続テスト
SP initiated SSO
アプリケーションを起動します。
$ npm start
ホストOS上でブラウザを起動し、http://www.example2.local:3000/ にアクセスします。
「Express Welcome to Express」と表示されるのを確認します。
http://www.example2.local:3000/login にアクセスします。
IdPにリダイレクトされ、OpenAMのログイン画面が表示されるため、
テスト用ユーザーのIDおよびパスワードを入力、ログインします。
ログイン後、http://www.example2.local:3000/ にリダイレクトされ、トップページが表示されます。
「Express(Already logged in) Welcome to Express(Already logged in)」と表示されるのを確認します。
http://www.example2.local:3000/logout にアクセスします。
ログアウト後、http://www.example2.local:3000/ にリダイレクトされ、トップページが表示されます。
「Express Welcome to Express」と表示されるのを確認します。
IP initiated SSO
アプリケーションを起動します。
$ npm start
ホストOS上でブラウザを起動し、https://openam.example.local:8443/openam/idpssoinit?metaAlias=/idp&spEntityID=http://www.example2.local:3000/&binding=urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST
にアクセスします。
OpenAMのログイン画面が表示されるため、
テスト用ユーザーのIDおよびパスワードを入力、ログインします。
ログイン後、http://www.example2.local:3000/ にリダイレクトされ、トップページが表示されます。
「Express(Already logged in) Welcome to Express(Already logged in)」と表示されるのを確認します。
以下の通り、ログイン成功時のprofile情報をコンソール出力するように変更・確認してみると、
SP initiatedの場合、passport-samlのデフォルトidentifierFormatであるメールアドレス形式でnameIDがアサインされていますが、IdP initiatedの場合、そうでないことが分かります。
var samlStrategy = new saml.Strategy({
callbackUrl: process.env.CALLBACK_URL,
entryPoint: process.env.ENTRY_POINT,
issuer: process.env.ISSUER,
logoutUrl: process.env.LOGOUT_URL
}, function(profile, done) {
console.log(profile);
var user = profile;
return done(null, user);
});
IdPの場合、nameIDFormatはurn:oasis:names:tc:SAML:2.0:nameid-format:persistent
になっていることが確認できます。
{ issuer: 'https://openam.example.local:8443/openam',
sessionIndex: 's2f447daa0a7d8ad7c900703d13232df3bffdd1501',
nameID: '8xkuCmTpy+MpSY7hMPPFz3eKTgCW',
nameIDFormat: 'urn:oasis:names:tc:SAML:2.0:nameid-format:persistent',
nameQualifier: 'https://openam.example.local:8443/openam',
spNameQualifier: 'http://www.example2.local:3000/',
getAssertionXml: [Function] }
うまくいかない場合は
http://www.example2.local:3000/login にアクセスした際に、HTTP 500などのエラーが発生する場合は、OpenAMのログを確認します。
以下の手順でOpenAMのデバッグログレベルを変更の上、事象を再現させ、ログを確認します。
デバッグログレベルの変更方法は以下の通りです。
ホストOS上でブラウザを起動し、https://openam.example.local:8443/openam/ にアクセスし、以下の情報を入力、ログインします。
「CONFIGURATION」-「Servers and Sites」-「Servers」-「https://openam.example.local:8443/openam」 の順にクリックします。
「Debugging」の「Debug Level」を「Error」から「Message」に変更し、「Save」をクリックします。
以下のコマンドでコンテナにAttachし、ログファイルを確認します。
$ docker exec -it openam /bin/bash
root@9442b727af19:/usr/local/tomcat# ls /root/openam/openam/debug/
Authentication
Configuration
CoreSystem
EmbeddedDJ
Entitlement
Federation
IdRepo
Session
org.forgerock.caf.authentication.framework.AuthenticationFramework
restAuthenticationFilter
root@9442b727af19:/usr/local/tomcat# cat Federation
...
libSAML2:11/29/2017 04:17:33:484 AM UTC: Thread[http-nio-8443-exec-4,5,main]: TransactionId[23f1c454-c09b-4f58-8315-d5bed890dfbe-1373]
WARNING: UtilProxySAMLAuthenticator.authenticate: Issuer in Request is not valid.
<.envで指定したIssuerとSPメタデータに含まれるentityIDがミスマッチしている場合の例>