Skip to content

SSO とユーザー同期(LDAP・SAML・Windows 認証の役割の違い) ​

第1版作成 最終更新 (日本時間)
確認バージョン1.5.9.0

「LDAPで認証するなら、ユーザー同期は何のためにあるの?」「SAMLはログイン時に登録するのに、LDAPには事前同期が必要なの?」「Windows認証とLDAP認証は、どちらもActive Directoryを使うから同じ?」

プリザンターの外部認証を調べると、本人確認、ユーザー情報の取り込み、権限設定が一緒に説明されがちです。このページではこれらを分け、LDAP・SAML・統合Windows認証を中心に、パスキーや二段階認証との違いも整理します。

INFO

ソースコードの解析対象はバージョン1.5.9.0です。設定名とコードの経路を確認した実装解説であり、各方式を接続して行う運用試験は実施していません。導入時は利用するバージョンの公式手順に従ってください。

最初に分けたい4つの役割 ​

役割確かめる・管理することプリザンターでの例
認証ログインする人は誰かLDAPでのパスワード検証、SAMLの認証結果の受け入れ
プロビジョニングアプリ内にユーザーを作成・更新・無効化するLDAP同期、ログイン時のユーザー登録
認可そのユーザーに何を許可するかサイトやレコードへのアクセス権限
セッション認証後のアクセスを同じ利用者として扱うログイン後のCookie・セッション管理

プロビジョニングとは、ユーザーを利用可能な状態に用意し、管理する処理です。本人確認が成功しても、ユーザー情報やアクセス権限が不要になるわけではありません。

図を読み込み中…

LDAPやSAMLを使っても、プリザンターはUsersテーブルのユーザーIDを必要とします。担当者、更新者、権限などを、プリザンター内のユーザーに結び付けるためです。

LDAP認証とSSOは同じではない ​

SSO(Single Sign-On)は、一度のサインインを複数のサービスで利用できる仕組みです。

Active Directoryの同じIDとパスワードで複数のアプリにログインできても、アプリごとにパスワードの入力を求める構成なら、認証情報を共通化している状態です。LDAP認証を採用しただけで、ブラウザーのサインイン状態まで共有されるわけではありません。

SAMLは、認証サービスであるIdP(Identity Provider)と、利用先アプリであるSP(Service Provider)の間で認証結果を連携する仕組みです。IdPのサインイン状態を使ったWeb SSOを実現できます。統合Windows認証でも、端末のWindows資格情報を使ってWebアプリへアクセスできる構成があります。

参考: OASIS:SAMLのIdP・SPとWeb SSO

LDAP:ログイン時の本人確認とユーザー登録 ​

ログインのたびにLDAPへ問い合わせる ​

解析したNovell経路では、プリザンターは新たなLDAPログイン要求を受けると、次の処理を行います。

  1. 同期用アカウントでLDAPサーバーへ接続します。
  2. 入力されたログインIDに対応するユーザーを検索します。
  3. 見つかったユーザーのDNと入力パスワードで再バインドします。
  4. 成功した場合、取得したユーザー情報をプリザンターへ登録・更新します。
  5. Usersレコードを読み込み、追加のログイン条件を確認します。

DNはLDAP内でユーザーなどのエントリーを識別する名前、バインドはLDAP接続の認証状態を設定する操作です。

図を読み込み中…

csharp
public static bool Authenticate(Context context, string loginId, string password)
{
    foreach (var ldap in Parameters.Authentication.LdapParameters)
    {
        try
        {
            using (var con = LdapConnection(
                ldap.LdapSyncUser,
                ldap.LdapSyncPassword, 
                ldap))
            {
                var entry = con.Search(
                    con.DN(ldap),
                    Novell.Directory.Ldap.LdapConnection.ScopeSub,
                    $"({ldap.LdapSearchProperty}={loginId})",
                    null,
                    false).FindOne();
                if (entry != null)
                {
                    con.Bind(entry.Dn, password);
                    UpdateOrInsert(
                        context: context,
                        entry: entry,
                        ldap: ldap,
                        synchronizedTime: DateTime.Now);
                    return true;
                }
            }

出典: Implem.Pleasanter/Libraries/DataSources/LdapNovellDs.cs(20–47行・1b7b6e6)

ここでの「ログインのたび」は、認証処理を新たに実行するときです。ログイン済みの一覧画面やレコードを開くたびに、このパスワード認証を行うという意味ではありません。

LDAPにもJITに相当する処理がある ​

JIT(Just-In-Time)プロビジョニングは、ログイン時に必要なユーザーを作成・更新する方式です。プリザンターのLDAP認証も、ログイン時にUpdateOrInsertを呼ぶため、JITに相当する処理があります。

同期済みレコードだけで本人確認する方式ではありません。LDAPによる本人確認と、取得した属性の登録・更新を、ログイン処理の中で行います。

csharp
public bool Authenticate(Context context)
{
    bool authenticated;
    switch (Parameters.Authentication.Provider)
    {
        case "LDAP":
        case "Windows":
            authenticated = Ldap.Authenticate(
                context: context,
                loginId: LoginId,
                password: context.Forms.Data("Users_Password"));
            if (authenticated)
            {
                Get(
                    context: context,
                    ss: SiteSettingsUtilities.UsersSiteSettings(context: context),
                    where: Rds.UsersWhere().LoginId(
                        value: context.Sqls.EscapeValue(LoginId),
                        _operator: context.Sqls.LikeWithEscape));
            }
            break;
        case "LDAP+Local":
            authenticated = Ldap.Authenticate(
                context: context,
                loginId: LoginId,
                password: context.Forms.Data("Users_Password"));
            if (authenticated)
            {
                Get(
                    context: context,
                    ss: SiteSettingsUtilities.UsersSiteSettings(context: context),
                    where: Rds.UsersWhere().LoginId(
                        value: context.Sqls.EscapeValue(LoginId),
                        _operator: context.Sqls.LikeWithEscape));
            }
            else
            {
                authenticated = GetByCredentials(
                    context: context,
                    loginId: LoginId,
                    password: Password,
                    tenantId: context.Forms.Int("SelectedTenantId"));
            }
            break;
        case "Extension":

出典: Implem.Pleasanter/Models/Users/UserModel.cs(4803–4847行・1b7b6e6)

LDAP同期:ログインしないユーザーも名簿に取り込む ​

同期する情報 ​

一括のLDAP同期は、LdapSyncPatternsで指定した条件を使って対象ユーザーを検索します。解析した経路では、次の情報を取り込みます。

  • ログインID、ユーザーコード、名前などをUsersへ登録・更新します。
  • 設定された組織コード・組織名をDeptsへ登録・更新します。
  • メールアドレスと設定された追加属性を反映します。
  • 同期日時を記録し、設定に応じてユーザーを無効化・再有効化します。
csharp

private static void UpdateOrInsert(
    Context context,
    string loginId,
    string deptCode,
    string deptName,
    string userCode,
    string name,
    string mailAddress,
    List<KeyValuePair<LdapExtendedAttribute, string>> attributes,
    ParameterAccessor.Parts.Ldap ldap,
    DateTime synchronizedTime)
{
    var deptExists = !deptCode.IsNullOrEmpty() && !deptName.IsNullOrEmpty();
    var deptSettings = !ldap.LdapDeptCode.IsNullOrEmpty() && !ldap.LdapDeptName.IsNullOrEmpty();
    var statements = new List<SqlStatement>();
    if (deptExists)
    {
        statements.Add(Rds.UpdateOrInsertDepts(
            param: Rds.DeptsParam()
                .TenantId(ldap.LdapTenantId)
                .DeptCode(deptCode)
                .DeptName(deptName),
            where: Rds.DeptsWhere().DeptCode(deptCode)));
    }
    var exists = Rds.ExecuteScalar_int(
        context: context,
        statements: Rds.SelectUsers(
            column: Rds.UsersColumn().UsersCount(),
            where: Rds.UsersWhere().LoginId(
                value: context.Sqls.EscapeValue(loginId),
                _operator: context.Sqls.LikeWithEscape))) == 1;
    var param = Rds.UsersParam()
        .TenantId(ldap.LdapTenantId)
        .LoginId(loginId, _using: !exists)
        .UserCode(userCode)
        .Name(name)
        .DeptId(
            sub: Rds.SelectDepts(
                column: Rds.DeptsColumn().DeptId(),
                where: Rds.DeptsWhere().DeptCode(deptCode)),
            _using: deptExists)
        .DeptId(0, _using: deptSettings && !deptExists)
        .LdapSearchRoot(ldap.LdapSearchRoot)
        .SynchronizedTime(synchronizedTime);
    attributes?.ForEach(attributeAndName =>
        param.Add(
            $"\"{attributeAndName.Key.ColumnName}\"",
            attributeAndName.Key.ColumnName,
            attributeAndName.Value));
    statements.Add(Rds.UpdateOrInsertUsers(
        param: param,
        where: Rds.UsersWhere().LoginId(
            value: context.Sqls.EscapeValue(loginId),
            _operator: context.Sqls.LikeWithEscape),
        addUpdatorParam: false,
        addUpdatedTimeParam: false));
    if (!mailAddress.IsNullOrEmpty())
    {
        statements.Add(Rds.PhysicalDeleteMailAddresses(
            where: Rds.MailAddressesWhere()
                .OwnerType("Users")
                .OwnerId(sub: Rds.SelectUsers(
                    column: Rds.UsersColumn().UserId(),
                    where: Rds.UsersWhere().LoginId(
                        value: context.Sqls.EscapeValue(loginId),
                        _operator: context.Sqls.LikeWithEscape)))));
        statements.Add(Rds.InsertMailAddresses(
            param: Rds.MailAddressesParam()
                .OwnerId(sub: Rds.SelectUsers(
                    column: Rds.UsersColumn().UserId(),
                    where: Rds.UsersWhere().LoginId(
                        value: context.Sqls.EscapeValue(loginId),
                        _operator: context.Sqls.LikeWithEscape)))
                .OwnerType("Users")
                .MailAddress(mailAddress)));
    }
    var ss = new SiteSettings();
    statements.Add(StatusUtilities.UpdateStatus(
        tenantId: ldap.LdapTenantId, type: StatusUtilities.Types.DeptsUpdated));
    statements.Add(StatusUtilities.UpdateStatus(
        tenantId: ldap.LdapTenantId, type: StatusUtilities.Types.UsersUpdated));
    Repository.ExecuteNonQuery(
        context: context,
        transactional: true,
        statements: statements.ToArray());

出典: Implem.Pleasanter/Libraries/DataSources/LdapNovellDs.cs(203–288行・1b7b6e6)

LDAP同期は、各ユーザーのパスワードをコピーして、LDAPへ接続せずローカル認証できるようにする処理ではありません。

なぜJITがあるのに同期も必要なのか ​

JITだけでは、まだログインしていない人のUsersレコードがありません。事前に同期しておけば、その人を担当者として選んだり、初回ログイン前に個別のアクセス権限を設定したりできます。

また、JITで属性を更新する機会は、その人がログインしたときです。一括同期なら、本人がログインしなくても、組織変更などを取り込めます。

項目JITのみ一括同期を併用
初回ログイン前のユーザー登録ログイン時に作成同期対象を事前登録
未ログインユーザーの担当者選択未登録なら選べない登録後に選べる
属性更新の機会本人のログイン時同期実行時にも更新
同期対象から外れたユーザーの無効化一括同期の処理は動かないAutoDisableの設定に従う

無効化と再有効化の仕組み ​

解析した同期処理は、今回取り込んだユーザーに今回の同期日時を記録します。AutoDisableが有効なら、同じLDAP検索ルートに属し、過去の同期日時があるものの今回更新されなかったユーザーを無効化します。AutoEnableが有効なら、今回同期された無効ユーザーを再有効化します。

これは、退職者や異動者を単に削除する処理とは異なります。検索条件を変えて同期対象から外した場合にも影響し得るため、対象条件と無効化の設定は合わせて確認します。

csharp
public static void Sync(Context context)
{
    var synchronizedTime = DateTime.Now;
    Parameters.Authentication.LdapParameters
        .ForEach(ldap => {
            ldap.LdapSyncPatterns?
            .ForEach(pattern =>
            {
                Sync(
                  context: context,
                  ldap: ldap,
                  pattern: pattern,
                  synchronizedTime: synchronizedTime);
            });
            if (ldap.AutoDisable)
            {
                Repository.ExecuteNonQuery(
                    context: context,
                    statements: Rds.UpdateUsers(
                        param: Rds.UsersParam().Disabled(true),
                        where: Rds.UsersWhere()
                            .Disabled(false)
                            .LdapSearchRoot(ldap.LdapSearchRoot)
                            .SynchronizedTime(_operator: " is not null")
                            .SynchronizedTime(synchronizedTime, _operator: "<>"),
                        addUpdatorParam: false,
                        addUpdatedTimeParam: false));
            }
            if (ldap.AutoEnable)
            {
                Repository.ExecuteNonQuery(
                    context: context,
                    statements: Rds.UpdateUsers(
                        param: Rds.UsersParam().Disabled(false),
                        where: Rds.UsersWhere()
                            .Disabled(true)
                            .LdapSearchRoot(ldap.LdapSearchRoot)
                            .SynchronizedTime(_operator: " is not null")
                            .SynchronizedTime(synchronizedTime),
                        addUpdatorParam: false,
                        addUpdatedTimeParam: false));
            }
        });
}

出典: Implem.Pleasanter/Libraries/DataSources/LdapNovellDs.cs(291–334行・1b7b6e6)

LDAP同期なしでJITのみの運用はできるのか ​

ソース上は可能です。LDAP認証の接続設定を維持し、未登録ユーザーを受け入れる設定にすると、ログイン時にユーザーを登録できます。

設定役割
Authentication.jsonのProvider: "LDAP"LDAP認証を選択
RejectUnregisteredUser: false未登録ユーザーの認証処理への進行を許可
BackgroundService.jsonのSyncByLdap: falseバックグラウンドの定期同期を停止
LDAP接続先・検索属性・同期用資格情報ログイン時のユーザー検索にも必要

定期同期を止めても、手動同期や外部スケジューラーによる同期まで停止するわけではありません。JITのみで運用するなら、別経路からの一括同期も確認します。

RejectUnregisteredUser: trueでは、プリザンターに未登録のユーザーをLDAP認証より前に拒否します。この場合、事前同期や手動登録が必要です。

csharp
private bool RejectUnregisteredUser(Context context)
{
    return Parameters.Authentication.RejectUnregisteredUser &&
        Repository.ExecuteScalar_int(
            context: context,
            statements: Rds.SelectUsers(
                column: Rds.UsersColumn().UsersCount(),
                where: Rds.UsersWhere()
                .Add(
                    name: "LoginId",
                    value: LoginId,
                    raw: "(lower(\"Users\".\"LoginId\") = lower(@LoginId))")
                    .Disabled(false))) != 1;
}

出典: Implem.Pleasanter/Models/Users/UserModel.cs(4918–4931行・1b7b6e6)

同期用アカウントは削除してよいのか ​

一括同期を止めても、解析したログイン経路はLdapSyncUserとLdapSyncPasswordを使ってユーザーを検索します。「同期用」という設定名でも、用途は一括同期だけではありません。

また、LdapSyncPatternsは一括同期の検索条件です。Novellのログイン経路はLdapSearchPropertyと入力ログインIDで検索しているため、一括同期の対象条件がそのままログイン許可リストになるとは限りません。JITを許可する際には、未登録ユーザーの受け入れ範囲も別に設計します。

SAML:IdPの認証結果と属性を受け取る ​

プリザンターはLDAPパスワードを検証しない ​

SAMLでは、IdPがユーザーを認証し、認証結果と属性をアプリへ伝えます。プリザンターはその連携結果を受け入れ、プリザンター内のユーザーに結び付けます。

一般的なブラウザー経由のSAML SSOでは、利用者がIdPへ移動し、認証結果がブラウザーを介してSPへ戻ります。IdPに有効なサインイン状態があれば、毎回パスワードを入力する必要がない場合があります。

図を読み込み中…

プリザンターのSamlLoginは、外部認証済みの状態を確認してから、クレームと呼ばれる属性情報をマッピングし、ユーザー登録・更新へ進みます。

csharp
public static (string redirectResultUrl, string html) SamlLogin(Context context, string returnUrl = "")
{
    if (!Authentications.SAML()
        || context.AuthenticationType != "Federation"
        || context.IsAuthenticated != true)
    {
        return (Responses.Locations.SamlLoginFailed(context: context), null);
    }
    Authentications.SignOut(context: context);
    var loginId = context.UserClaims?.FirstOrDefault(claim => claim.Type == ClaimTypes.NameIdentifier);
    var attributes = Saml.MapAttributes(context.UserClaims, loginId.Value);
    var name = attributes.UserName;
    TenantModel tenant;
    if (Parameters.Authentication.Provider == "SAML-MultiTenant")

出典: Implem.Pleasanter/Libraries/DataSources/Saml.cs(654–667行・1b7b6e6)

SAMLもユーザー情報を取り込んでいる ​

「SAMLは同期しない」というより、ログイン時に受け取った属性を使ってユーザーを登録・更新する、という説明が正確です。SamlLoginはRejectUnregisteredUserによる存在確認の後、Saml.UpdateOrInsertを呼びます。

csharp
if (Parameters.Authentication.RejectUnregisteredUser)
{
    var exists = Repository.ExecuteScalar_int(
        context: context,
        statements: Rds.SelectUsers(
            column: Rds.UsersColumn().UsersCount(),
            where: Rds.UsersWhere()
                .TenantId(tenant.TenantId)
                .LoginId(resolvedLoginId))) > 0;
    if (!exists)
    {
        return (Responses.Locations.SamlLoginFailed(context: context), null);
    }
}
try
{
    Saml.UpdateOrInsert(
        context: context,
        tenantId: tenant.TenantId,
        loginId: resolvedLoginId,
        name: string.IsNullOrEmpty(name)
            ? resolvedLoginId
            : name,
        mailAddress: attributes["MailAddress"],
        synchronizedTime: System.DateTime.Now,
        attributes: attributes);
}
catch (DbException e)
{
    if (context.SqlErrors.ErrorCode(e) == 2601)
    {
        return (Responses.Locations.LoginIdAlreadyUse(context: context), null);
    }
    throw;
}
var userModel = new UserModel().Get(
    context: context,
    ss: null,
    where: Rds.UsersWhere()
        .TenantId(tenant.TenantId)
        .LoginId(resolvedLoginId));
if (userModel.AccessStatus == Databases.AccessStatuses.Selected)

出典: Implem.Pleasanter/Libraries/DataSources/Saml.cs(706–747行・1b7b6e6)

属性をどの項目へ入れるかは設定と実装によります。SAMLを有効にしただけで、組織やサイトの権限がすべて自動設定されるわけではありません。

JITだけでは退職者の名簿更新までできない ​

SAML JITはログインを契機に情報を更新します。ログインしなくなった人の状態を、JITだけで継続的に反映することはできません。IdPで新規ログインを拒否することと、アプリ内のユーザーを無効化することも別です。

Microsoftの説明でも、SAML JITによる作成・更新と、SCIMによるプロビジョニングを区別しています。SCIMはユーザー管理のためのAPI・プロトコルで、SSOの本人確認方式ではありません。事前登録や無効化を連携する場合は、アプリ側の対応と利用条件を別に確認します。

参考: Microsoft:SAML JITと自動プロビジョニングの違い

統合Windows認証:端末のWindows資格情報を利用する ​

Active Directoryを使っていてもLDAP認証とは違う ​

統合Windows認証では、ブラウザーとWebサーバー側がWindowsの認証方式を使って本人確認します。KerberosやNTLMが使われ、対応する環境ではWindowsの資格情報を利用してログインできます。

LDAP認証では、プリザンターのログイン画面で受け取ったパスワードを使ってLDAPバインドを行います。統合Windows認証では、Webサーバー側で認証されたユーザーの識別情報をプリザンターが受け取ります。同じActive Directoryを使っていても、本人確認の経路が異なります。

参考: Microsoft:統合Windows認証とKerberos・NTLM

プリザンターは認証されたユーザーの情報をLDAPから取り込む ​

解析したSessionMiddlewareは、セッション開始時にWindows認証の経路を判定し、Ldap.UpdateOrInsertでユーザー情報を取り込み、コンテキストを設定します。ここでのLDAPアクセスは、Windows側で本人確認した利用者の情報をプリザンターに結び付けるためのものです。

csharp
SetClientId();
Context context;
try
{
    context = SessionStartContext();
}
catch (InvalidDataException ex)
{
    httpContext.Response.StatusCode = 400;
    await httpContext.Response.WriteAsJsonAsync(new { ex.Message });
    return;
}
SessionUtilities.SetStartTime(context: context);
if (WindowsAuthenticated(context))
{
    context.RotateSessionGuid();
    Ldap.UpdateOrInsert(
        context: context,
        loginId: context.LoginId);
    context.Set();
}
if (context.Authenticated)
{
    StatusesInitializer.Initialize(context: context);
}
switch (httpContext.Request.Path.Value.ToLower())
{

出典: Implem.Pleasanter/Startup.cs(908–934行・1b7b6e6)

WindowsAuthenticatedにはRejectUnregisteredUserの条件もあります。Windowsのユーザーとして認証できることと、プリザンター内でそのユーザーを受け入れることは別の判断です。

csharp
private static bool WindowsAuthenticated(Context context)
{
    return Authentications.Windows(context: context)
        && !context.LoginId.IsNullOrEmpty()
        && (!Parameters.Authentication.RejectUnregisteredUser
        || context.Authenticated);
}

出典: Implem.Pleasanter/Startup.cs(971–977行・1b7b6e6)

Windowsサーバーに置くだけで使えるわけではない ​

Windows上でプリザンターを動かすこと、Windows用のLDAPライブラリを使うこと、統合Windows認証を有効にすることは、それぞれ別です。

統合Windows認証には、Webサーバー・ブラウザー・ドメイン・サービスの識別情報などの設定が関わります。Kerberosを使う構成ではSPN(サービスを識別する名前)なども確認します。プロキシを挟む場合は、その構成で認証を扱えるかも確認します。

参考: Microsoft:ASP.NET CoreのWindows認証

ローカル認証とLDAP・ローカル併用 ​

ローカル認証では、プリザンター内の資格情報を使って本人確認します。LDAPからユーザー情報を取り込むことと、ローカル用パスワードを設定することは別です。

Provider: "LDAP+Local"の解析した経路は、LDAP認証を先に試し、失敗した場合にローカルの資格情報による認証を試します。LDAPとローカルの両方へ毎回問い合わせて、両方の成功を求める方式ではありません。

csharp
case "LDAP+Local":
    authenticated = Ldap.Authenticate(
        context: context,
        loginId: LoginId,
        password: context.Forms.Data("Users_Password"));
    if (authenticated)
    {
        Get(
            context: context,
            ss: SiteSettingsUtilities.UsersSiteSettings(context: context),
            where: Rds.UsersWhere().LoginId(
                value: context.Sqls.EscapeValue(LoginId),
                _operator: context.Sqls.LikeWithEscape));
    }
    else
    {
        authenticated = GetByCredentials(
            context: context,
            loginId: LoginId,
            password: Password,
            tenantId: context.Forms.Int("SelectedTenantId"));
    }
    break;
case "Extension":

出典: Implem.Pleasanter/Models/Users/UserModel.cs(4824–4847行・1b7b6e6)

併用構成では、外部側のアカウント管理だけでなく、ローカル認証できるユーザーとその資格情報の管理も必要です。

パスキーと二段階認証はSSOなのか ​

パスキーは本人確認の手段 ​

パスキーは公開鍵暗号を使った認証資格情報です。WebAuthnでは、アプリが送ったチャレンジに対する応答を検証します。資格情報は利用先に結び付けられるため、パスキーを使うことだけで複数アプリのSSOが成立するわけではありません。

参考: W3C:WebAuthnの公開鍵資格情報

プリザンターのパスキー認証は、MakeAssertionAsyncによる検証後、登録済みパスキーに対応するユーザーを取得し、パスキー認証済みの経路でログイン処理を進めます。LDAPから利用者名簿を作る処理とは異なります。

csharp
var clientResponse = JsonSerializer.Deserialize<AuthenticatorAssertionRawResponse>(context.Forms.Data("data"));
var jsonOptions = PasskeyUtilities.SessionGetString(
    context: context,
    key: Fido2AssertionOptions);
if (jsonOptions.IsNullOrWhiteSpace())
{
    return Messages.ResponsePasskeyResponseInvalid(context: context)
        .ToJson();
}
PasskeyUtilities.SessionRemove(context: context, key: Fido2AssertionOptions);
var options = AssertionOptions.FromJson(jsonOptions);
var passkeys = new PasskeyCollection(
    context: context,
    column: Rds.PasskeysDefaultColumns(),
    where: Rds.PasskeysWhere()
        .CredentialId(clientResponse.Id));
var passkey = passkeys.FirstOrDefault();
if (passkey == null)
{
    return Messages.ResponseIncorrectUser(context: context, data: [Displays.NotFound(context)])
        .ToJson();
}
var storedCounter = passkey?.PasskeyData?.SignCount ?? 0;
async Task<bool> IsUserHandleOwnerOfCredentialIdCallback(IsUserHandleOwnerOfCredentialIdParams args, CancellationToken cancellationToken)
{
    var userId = System.Text.Encoding.UTF8.GetString(args.UserHandle);
    var credentialId = args.CredentialId.ToBase64UrlString();
    return passkey?.UserId.ToString() == userId && passkey?.CredentialId == credentialId;
}
var res = await fido2.MakeAssertionAsync(new MakeAssertionParams
{
    AssertionResponse = clientResponse,
    OriginalOptions = options,
    StoredPublicKey = passkey.PasskeyData.PublicKey,
    StoredSignatureCounter = storedCounter,
    IsUserHandleOwnerOfCredentialIdCallback = IsUserHandleOwnerOfCredentialIdCallback
});
passkey.PasskeyData.SignCount = res.SignCount;
_ = new PasskeyModel(
    context: context,
    passkeyId: passkey.PasskeyId)
{
    PasskeyData = passkey.PasskeyData
}.Update(context, ss);
json = new UserModel(
    context: context,
    ss: ss,
    userId: passkey.UserId)
        .Get(
            context: context,
            ss: ss,
            where: Rds.UsersWhere()
                .UserId(passkey.UserId))
        .Authenticate(
            context: context,
            returnUrl: returnUrl,
            isAuthenticationByMail: false,
            isAuthenticationByPasskey: true);

出典: Implem.Pleasanter/Controllers/PasskeysController.cs(279–336行・1b7b6e6)

IdP側の本人確認にパスキーを使い、その結果をSAMLでプリザンターへ伝える構成と、プリザンターに登録したパスキーで直接ログインする構成も区別します。

二段階認証は本人確認を追加するもの ​

プリザンターのメール・TOTPによる二段階認証は、一次認証の後に追加の確認を行う処理です。TOTPは、時刻に基づいて生成するワンタイムパスワードです。二段階認証そのものは、ユーザー同期やSSOの方式ではありません。

解析したUserModel.Authenticateでは、一次認証後に二段階認証の有効判定と検証へ進みます。パスキー経路には別の分岐があるため、「どの認証方式でも同じ追加認証が必ず動く」とは扱いません。SAMLを利用する場合は、IdP側の多要素認証とプリザンター側の追加認証を別に確認します。

csharp
public string Authenticate(Context context, string returnUrl, bool isAuthenticationByMail, bool noHttpContext = false, bool isAuthenticationByPasskey = false)
{
    if (Parameters.Security?.SecondaryAuthentication?.NotificationType == ParameterAccessor.Parts.SecondaryAuthentication.SecondaryAuthenticationModeNotificationTypes.Mail)
    {
        isAuthenticationByMail = true;
    }
    if (Parameters.Security.RevealUserDisabled && DisabledUser(context: context))
    {
        return UserDisabled(context: context);
    }
    if (RejectUnregisteredUser(context: context))
    {
        return Deny(context: context);
    }
    if (!isAuthenticationByPasskey && !Authenticate(context: context))
    {
        var tenantOptions = TenantOptions(context: context);
        if (tenantOptions?.Count > 0)
        {
            return TenantsDropDown(
                context: context,
                tenantOptions: tenantOptions);
        }
        else
        {
            return Deny(context: context);
        }
    }
    if (LoginExpired())
    {
        return DenyLoginExpired(context: context);
    }
    if (!AllowedIpAddress(context))
    {
        return InvalidIpAddress(context: context);
    }
    if (Lockout)
    {
        return UserLockout(context: context);
    }
    if (!isAuthenticationByPasskey)
    {
        if (EnabledSecondaryAuthentication(context: context))
        {
            var secondaryAuthenticationCode = context
                .Forms
                .Data("SecondaryAuthenticationCode");
            return string.IsNullOrEmpty(secondaryAuthenticationCode)
                ? !EnableSecretKey && !isAuthenticationByMail
                    ? OpenTotpRegisterCode(context: context)
                    : OpenSecondaryAuthentication(
                        context: context,
                        returnUrl: returnUrl,
                        isAuthenticationByMail: isAuthenticationByMail)
                : !SecondaryAuthentication(
                    context: context,
                    secondaryAuthenticationCode: secondaryAuthenticationCode,
                    isAuthenticationByMail: isAuthenticationByMail)
                    ? Messages
                        .ResponseSecondaryAuthentication(
                            context: context,
                            target: "#LoginMessage")
                        .Focus("#SecondaryAuthenticationCode")
                        .ToJson()
                    : HandlePostSecondaryAuthentication(
                        context: context,
                        returnUrl: returnUrl,
                        isAuthenticationByMail: isAuthenticationByMail,
                        noHttpContext: noHttpContext);
        }
        if (PasswordExpired())
        {
            return OpenChangePasswordAtLoginDialog(context: context);
        }
    }
    return Allow(
        context: context,
        returnUrl: returnUrl,
        createPersistentCookie: context.Forms.Bool("Users_RememberMe"),
        noHttpContext: noHttpContext);
}

出典: Implem.Pleasanter/Models/Users/UserModel.cs(4718–4798行・1b7b6e6)

認証プロキシと拡張認証 ​

解析対象には、信頼したプロキシが伝えるユーザー名を受け入れるTrustedProxyAuthenticationMiddlewareもあります。この経路では、送信元プロキシを確認し、指定ヘッダーのユーザー名に対応する、有効かつロックアウトされていないUsersレコードを検索します。該当ユーザーがいればログイン処理を進めます。

このミドルウェアにはユーザーを新規作成する処理がなく、事前登録が必要です。外部プロキシが認証を担い、プリザンターがその認証結果を信頼する構成なので、直接到達する経路の制御と、プロキシ側で利用者が送った同名ヘッダーを除去・上書きする設計も必要です。

csharp
{
    var header = Parameters.Authentication.TrustedProxyParameters?.Header
        ?? string.Empty;
    if (string.IsNullOrEmpty(header))
    {
        if (logger.IsEnabled(LogLevel.Debug)) logger.LogDebug("Trusted proxy authentication skipped: header name is not configured.");
        await next(httpContext);
        return;
    }
    if (httpContext.User?.Identity?.IsAuthenticated == true)
    {
        if (logger.IsEnabled(LogLevel.Debug)) logger.LogDebug("Trusted proxy authentication skipped: request is already authenticated.");
        await next(httpContext);
        return;
    }
    if (!IsTrustedProxy(httpContext, out var trustReason))
    {
        if (logger.IsEnabled(LogLevel.Debug)) logger.LogDebug("Trusted proxy authentication skipped: {Reason}", trustReason);
        await next(httpContext);
        return;
    }
    if (logger.IsEnabled(LogLevel.Debug)) logger.LogDebug("Trusted proxy source accepted: {Reason}", trustReason);
    var proxyUser = httpContext.Request.Headers[header].FirstOrDefault();
    if (string.IsNullOrEmpty(proxyUser))
    {
        if (logger.IsEnabled(LogLevel.Debug)) logger.LogDebug("Trusted proxy authentication skipped: configured header is missing or empty.");
        await next(httpContext);
        return;
    }
    var context = new Context(
            request: false,
            sessionStatus: false,
            sessionData: false,
            user: false,
            item: false);
    try
    {
        var userModel = new UserModel().Get(
            context: context,
            ss: SiteSettingsUtilities.UsersSiteSettings(context),
            where: Rds.UsersWhere()
                .LoginId(
                    value: context.Sqls.EscapeValue(proxyUser),
                    _operator: context.Sqls.LikeWithEscape)
                .Disabled(false)
                .Lockout(false));
        if (userModel.AccessStatus == Databases.AccessStatuses.Selected)
        {
            var claims = new List<Claim>
            {
                new(ClaimTypes.Name, userModel.LoginId)
            };
            var identity = new ClaimsIdentity(claims, "TrustedProxy");
            var principal = new ClaimsPrincipal(identity);
            var properties = new AuthenticationProperties
            {
                IsPersistent = true
            };
            await httpContext.SignInAsync(
                scheme: CookieAuthenticationDefaults.AuthenticationScheme,
                principal: principal,
                properties: properties);
            _ = userModel.AllowAfterUrl(
                context: context,
                returnUrl: "",
                createPersistentCookie: true);
            httpContext.User = principal;
            if (logger.IsEnabled(LogLevel.Debug)) logger.LogDebug("Trusted proxy authentication succeeded.");
        }
        else if (logger.IsEnabled(LogLevel.Debug)) logger.LogDebug("Trusted proxy authentication skipped: no active matching user found.");
    }
    catch (Exception ex)
    {
        _ = new SysLogModel(context, ex);
        if (logger.IsEnabled(LogLevel.Debug)) logger.LogDebug("Trusted proxy authentication failed with exception type: {ExceptionType}", ex.GetType().Name);

出典: Implem.Pleasanter/Middlewares/TrustedProxyAuthenticationMiddleware.cs(26–100行・1b7b6e6)

また、Provider: "Extension"は拡張認証処理を呼ぶ経路です。外部に任せる本人確認やユーザー登録の仕組みは、拡張の実装に依存します。LDAP・SAMLの標準経路と同じ登録動作になると仮定しないようにします。

csharp
authenticated = Extension.Authenticate(
    context: context,
    loginId: LoginId,
    password: Password,
    userModel: this);
break;

出典: Implem.Pleasanter/Models/Users/UserModel.cs(4848–4853行・1b7b6e6)

運用するときに確認すること ​

認証方式を選ぶだけでは、入社・異動・退職に対するユーザー管理は完成しません。次の3つの時点に分けて設計すると整理できます。

時点確認すること
初回ログイン前名簿に事前登録するか、JITで作成するか。事前の担当者・権限設定が必要か
ログイン時誰が本人確認するか。IDの対応関係、属性マッピング、未登録ユーザーの受け入れ範囲
利用停止時新規ログインの拒否、アプリ内ユーザーの無効化、既存セッションの扱い

外部側でアカウントを止めたことと、プリザンター内のユーザーが無効になったこと、既存セッションが失効したことは同じではありません。既存セッションまで即座に失効することを、このユーザー同期・登録のコードだけから保証することはできません。

また、外部から名前や組織を取り込んでも、閲覧・編集権限がすべて自動で付くわけではありません。本人確認、属性更新、認可のそれぞれを確認します。

まとめ ​

  • LDAP認証は、ログイン時にLDAPへ問い合わせて本人確認します。認証情報の共通化だけでSSOになるわけではありません。
  • プリザンターのLDAPにもログイン時の登録・更新があり、設定次第でJITのみの運用が可能です。
  • LDAP同期は、未ログインユーザーの事前登録や、一括の属性更新・無効化に使います。
  • SAMLはIdPの認証結果と属性を受け取り、ログイン時にプリザンターのユーザーへ結び付けます。
  • 統合Windows認証は、Windows側で本人確認されたユーザーの情報を受け取り、プリザンター内のユーザーへ結び付けます。
  • パスキーと二段階認証は本人確認の手段であり、SSOやユーザー同期とは役割が異なります。
  • 認証、ユーザー管理、権限、セッションを分けて設計すると、導入後の運用まで整理できます。

関連ページ ​

変更履歴

第1版SSOとユーザー同期の記事を追加(LDAP・SAML・Windows認証の役割の違い)