LoginSignup
22
22

More than 5 years have passed since last update.

OpenAM + Node.js(+ Express + passport-saml)でSAML認証

Posted at

はじめに

以下の構成で、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は以下のようにします。

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]:
  • Step2: 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]:

最後に「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」をクリックします。

「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がミスマッチしている場合の例>
22
22
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
22
22