自作アプリケーションにOneLogin認証を導入する方法
こんにちは、ズッ友です。ナビタイムジャパンでBFF開発環境・運用改善業務を担当しています。
先日、社内ツールに対してOneLogin認証を取り入れました。
この記事では、その際に得た認証の仕組みと開発方法の知見についてご紹介させていただきます。
紹介する対象言語はKotlinとなりますが、他の言語でも認証全体の仕組みは同じです。
これからOneLogin認証によりアカウント連携を簡易化したい方々の参考になれば幸いです。
OneLogin認証とは
OneLogin, Incが提供するシングルサインオン(SSO)サービスです。
一度のユーザー認証処理によって、あらかじめ連携設定しておいた複数のサービスが追加認証なしで利用できます。
都度IDやパスワードを入力するといった作業が不要になります。
ナビタイムジャパンにおけるOneLogin認証
ナビタイムジャパンで利用している様々なWebサービス、例えばSlackやSentryなどへの認証もOneLoginを利用しています。
今回作成した社内ツールにおいても独自の認証ではなくOneLoginを利用して認証処理を簡易化しました。
Security Assertion Markup Language(SAML)
OneLogin認証にはSAMLを利用しています。
そのため、自作アプリケーションへの導入ではSAMLを実装していくことになります。
ここではSAMLとはなにかを簡単に説明します。
XML関連の標準化団体OASISによって策定された、異なるインターネットドメイン間でユーザー認証を行うためのXMLをベースにした標準規格です。
認証情報を提供する側をIdentity Provider(IdP)、認証情報を利用する側をService Provider(SP)と言います。
OneLoginがIdP、アプリケーションがSPにあたります。
SAML認証の流れ
以下の流れで認証が行われます。
ユーザーがログインするためSPへアクセス
SPがSAML認証要求を作成
SPが2で作成した認証要求とユーザーアクセスとともにIdPへリダイレクト
IdPは渡された認証情報を検証
IdPはユーザーの認証を実施
ユーザー認証が切れている場合、ユーザーはID・パスワードを入力し再認証
IdPはSAML認証応答を作成し
認証応答とともにSPへリダイレクト
SPはSAML認証応答を検証し、ユーザーログインを許可/拒否
導入方法
OneLogin認証を導入する方法を説明します。
連携済み・自作それぞれ導入方法が異なるため、その概要を記載します。
連携済みサービスへの導入
OneLoginと連携済みのサービスを利用する場合は簡単です。
OneLoginAppを作成する際に連携対象のサービスを選択することで、設定に必要な項目がわかります。
公式サイトには G Suite, Salesforceの連携方法が載っています。
連携したい対象サービスの公式サイトに連携方法が記載されていることが多いため、そちらをご確認ください。
自作アプリケーションへの導入
本記事の主題です。
前述したように、自作アプリケーションへの導入の場合はSAML認証を実装する必要があります。
実装に際して、OneLoginからOpen SourceのSAML Toolkitが提供されているためそちらを利用します。
サポート言語は下記です。
PHP
Java
Python
Ruby
.NET
開発ドキュメントがありますのでそちらもご覧ください。
SAML Toolkitを利用した導入手順
導入対象
導入したツールの概要は下記です。
※OneLogin連携に関係するものだけ記載しています。
Webアプリケーション
言語:Kotlin
フレームワーク:Jersey
プロジェクト管理:Maven
ディレクトリ構成
- src
- main.kotlin.tool
- filter
- RequestFilter.kt # 認証処理を行うリクエストフィルター
- infra # 後述するフレームワーク仕様吸収用のインフラ層
- OneloginAuth
- OneloginLogoutRequest
- OneloginLogoutResponse
- resource
- AuthResource # OneLogin用エントリーポイント
- TopResource # ツールトップ画面
- resources
- onelogin.saml.properties # OneLogin認証設定
- webapp
- top.jsp # ツールトップ画面
- pom.xml
連携設定:OneLoginAppの作成
公式サイトの手順に従い作成します。
Go to Apps > Add Apps.
Search for SAML Test Connector.
Select the SAML Test Connector (IdP w/ attr) app.
連携設定:IdP設定
作成したアプリケーション → OneLoginの接続設定を行います。
作成したOneLoginAppに記載されている情報をonelogin.saml.propertiesに記載します。
onelogin.saml.propertiesは下記となります。
onelogin.saml2.idp.entityid=https://app.onelogin.com/saml/metadata/XXXXX
onelogin.saml2.idp.single_sign_on_service.url=https://ntj.onelogin.com/trust/saml2/http-post/sso/XXXXX
onelogin.saml2.idp.single_logout_service.url=https://ntj.onelogin.com/trust/saml2/http-redirect/slo/XXXXX
onelogin.saml2.idp.x509cert=-----BEGIN CERTIFICATE-----\
XXXXXX
-----END CERTIFICATE-----
連携設定:SP設定
OneLogin → 作成したアプリケーションの接続設定を行います。
アプリケーション側に作成したエンドポイントの設定をonelogin.saml.propertiesとOneLogin両者に設定します。
onelogin.saml.propertiesは下記となります。
# 2で記載したものに追記
onelogin.saml2.sp.entityid=https://example.com/auth/metadata
onelogin.saml2.sp.assertion_consumer_service.url=https://example.com/auth/acs
onelogin.saml2.sp.single_logout_service.url=https://example.com/auth/sls
以上でアプリケーション側の設定は完了です。
実装:Toolkitのインポート
mavenライブラリとして提供されているため、pom.xmlに記載するだけで利用可能です。
<dependency>
<groupId>com.onelogin</groupId>
<artifactId>java-saml</artifactId>
<version>2.9.0</version>
</dependency>
実装:フィルター層
アプリケーションにリクエストした際にOneLogin認証をかけたいため、共通で通過するリクエストフィルターで認証処理を実施します。
フィルターの実装は下記です。
@Provider
class RequestFilter: ContainerRequestFilter {
@Context
private lateinit var req: HttpServletRequest
@Context
private lateinit var resp: HttpServletResponse
override fun filter(reqCtx: ContainerRequestContext?) {
// 認証を実施するリクエストを判別します①
val needAuthPath = PageConstant.needAuth(req.servletPath)
?:throw NotFoundException()
// 認証済みのリクエストか判別します②
val hasSession = req.session.getAttribute("nameId")?.toString()?.isNotEmpty()?:false
if (!needAuthPath || hasSession) return
// ログイン処理を実施します③
val auth = Auth(req, resp)
auth.login(req.requestURI)
reqCtx?.abortWith(Response.ok().build())
}
}
認証を実施するリクエストを判別します①
ユーザー操作ではないヘルスチェック用のリクエストは除外します。
後述するOneLogin認証に利用されるリクエストは除外します。
認証済みのリクエストか判別します②
ログインに成功した場合、セッションにnameIdを入れて管理します。
そのためnameIdを保持していれば再認証不要と判断しています。
ログイン処理を実施します③
Toolkit提供のAuthクラスのインスタンスを生成します。
loginメソッドを呼び出します。
引数にリクエストされたURL情報を渡すことで、認証→ログイン成功後に指定URLにリダイレクトしてくれます。
実装:API
OneLoginからの応答を受け取るAPI3つと、ログアウト処理実行用API1つを作成します。
ログイン応答API (/acs)
フィルター層でOneLoginへログイン要求した後の結果受付用APIです。
acsはassertion consumer serviceの略で、onelogin.saml.propetiesに本APIのURLを記載します。
重要な処理にはコメントで説明を記載しています。
@POST
@Path("/acs")
@Consumes(MediaType.APPLICATION_FORM_URLENCODED)
@Produces(MediaType.TEXT_HTML)
@Template(name = "/top.jsp")
fun acs(
// OneLoginから2つのパラメータを受け付けます。これは後述するフレームワーク用の対応です。
@FormParam("SAMLResponse") samlResponse: String?,
@FormParam("RelayState") relayState: String?): Response {
// ログイン結果の確認を実施します
val auth = OneloginAuth(request, response)
auth.processResponse(null, samlResponse)
// 認証失敗している場合のハンドリング
if (!auth.isAuthenticated()) {
throw RuntimeException("Not authenticated")
}
val errors = auth.getErrors()
if (errors.isNotEmpty()) {
if (auth.isDebugActive()) {
auth.getLastErrorReason()?.let { println(it) }
}
throw RuntimeException("Error Authentication. Please check log.")
} else {
// 認証成功しエラーもない場合はセッションに認証情報を付与します
val attributes = auth.getAttributes()
request.session.setAttribute("attributes", attributes);
request.session.setAttribute("nameId", auth.getNameId())
request.session.setAttribute("nameIdFormat", auth.getNameIdFormat())
request.session.setAttribute("sessionIndex", auth.getSessionIndex())
request.session.setAttribute("nameidNameQualifier", auth.getNameIdNameQualifier())
request.session.setAttribute("nameidSPNameQualifier", auth.getNameIdSPNameQualifier())
// 指定したリダイレクト先が存在する場合はリダイレクトします
return if (relayState != null && relayState != ServletUtils.getSelfRoutedURLNoQuery(request)) {
Response.seeOther(URI(relayState)).build()
} else {
Response.ok().entity("status" to "ok").build()
}
}
}
ログアウト応答API(/sls)
OneLoginへログアウト要求を行った後の結果受付用APIです。
slsはsingle logout serviceの略で、onelogin.saml.propetiesに本APIのURLを記載します。
重要な処理にはコメントで説明を記載しています。
@GET
@Path("/sls")
@Produces(MediaType.TEXT_HTML)
@Template(name = "/auth/logout.jsp")
fun sls(): Response {
// ログアウト結果を確認します
val auth = OneloginAuth(request, response)
auth.processSLO(false, null, false)
// エラー確認。本実装ではエラーは画面に結果のみ表示する形式としています。
return Response.ok()
.entity(mapOf("success" to auth.getErrors().isEmpty()))
.build()
}
メタデータ受信API(/metadata)
メタデータ受信APIです。設定情報を確認することができます。
本APIはOneLoginからの認証照合に利用されるAPIのため、サービス上で表示することはありません。
onelogin.saml.propetiesに本APIのURLを記載します。
重要な処理にはコメントで説明を記載しています。
@POST
@Path("/metadata")
@Produces(MediaType.APPLICATION_XHTML_XML)
fun metadata(): Response {
val auth = Auth()
val settings = auth.settings
val metadata = settings.spMetadata
// メタデータ情報のバリデーションを実施します
val errors = Saml2Settings.validateMetadata(metadata)
// バリデーション結果に問題があるか確認します
if (errors.isEmpty()) {
return Response.ok().entity(metadata).build()
} else {
throw RuntimeException(errors.joinToString("\n"))
}
}
ログアウト実行API(/logout)
画面に配置したログアウトボタンを押した時などにリクエストされるAPIです。
OneLoginへログアウト要求を送信します。
重要な処理にはコメントで説明を記載しています。
@GET
@Path("/logout")
fun logout(): Response {
val auth = Auth(request, response)
// ログアウト対象の情報を設定します。セッションに保持している情報を利用します。
val logoutParam = LogoutRequestParams(
request.session.getAttribute("sessionIndex")?.toString(),
request.session.getAttribute("nameId")?.toString(),
request.session.getAttribute("nameIdFormat")?.toString(),
request.session.getAttribute("nameidNameQualifier")?.toString(),
request.session.getAttribute("nameidSPNameQualifier")?.toString(),
)
// ログアウト要求を実施します
auth.logout(null, logoutParam)
return Response.ok().build()
}
以上が必要な実装となります。
フレームワーク仕様吸収
前述したAPI実装において、OneloginAuth はライブラリが持っているクラスをコピーしてカスタマイズしたものです。
ライブラリの実装ではHttpServletRequest.getParameter()を用いてPOSTのフォームパラメータを取得しています。
しかし、Jerseyを利用した場合、フレームワーク側でラップされてしまい、上記関数では取得ができなくなります。
そのため、@FormParamアノテーションで変数に格納したものを利用して処理が行えるものを作成しています。
下記が対応例です。
// ライブラリのAuthクラス。
// 記述はJavaです。
public void processResponse(String requestId) throws Exception {
this.authenticated = false;
HttpRequest httpRequest = ServletUtils.makeHttpRequest(this.request);
String samlResponseParameter = httpRequest.getParameter("SAMLResponse");
if (samlResponseParameter == null) {
this.errors.add("invalid_binding");
String errorMsg = "SAML Response not found, Only supported HTTP_POST Binding";
LOGGER.error("processResponse error." + errorMsg);
throw new Error(errorMsg, 3);
} else {
SamlResponse samlResponse = this.samlMessageFactory.createSamlResponse(this.settings, httpRequest);
// 自作したOneloginAuthクラス。samlResponseParameterを外から渡せるようにしています。
// 記述はKotlinに変更しています。
fun processResponse(requestId: String?, samlResponseParameter: String?) {
this.authenticated = false
val httpRequest = ServletUtils.makeHttpRequest(this.request)
if (samlResponseParameter.isNullOrEmpty()) {
this.errors.plus("invalid_binding")
val errorMsg = "SAML Response not found, Only supported HTTP_POST Binding"
LOGGER.error("processResponse error.$errorMsg")
throw Error(errorMsg, 3)
} else {
val samlResponse = SamlResponse(this.settings, parseHttpToHttps(httpRequest.requestURL), samlResponseParameter)
さいごに
今回はOneLogin認証を自作アプリケーションに導入する方法を、認証方式・実装を含めた手順とともに紹介させていただきました。
作成した社内ツールにOneLogin認証を取り入れたことで、新しく入った方でも専用アカウントを作る手間なく利用できるようになりました。
また、ツール利用者の管理も一元化されました。
本記事が今後OneLogin連携に取り組まれる方の参考になれば幸いです。
最後までお読みいただき、ありがとうございました。