之前家里的 NAS 上跑了很多 app,他们每个都有自己认证的方式,非常乱。我需要记忆很多密码。

最难受的是很多 app 不支持 passkey 认证,导致我的 yubikey 白买了。

而且这样攻击面很大,一个 app 不安全,黑客就能搞很多事情。

所以为了统一我家里的账户的身份,我决定搭一个 Authentik。

file

搭了以后,一次登录,所有应用都能使用。中心化配置权限,谁能干什么一目了然。统一日志审计。最重要的是:

缩小了攻击面。黑客现在攻击任何应用都只能先攻击一个大门,而这个大门极其坚固。

file

而且,我终于可以使用 Passkey 来登录一切 App 了!安全又方便!

file

什么应用可以接入?

OIDC

最容易接入的就是已经有OIDC的。可以原生接入。

通俗的说 OIDC(OAuth、OpenID Connect),就是应用本身不负责身份认证,而是需要认证时,就带用户 302 到 Authentik 上。用户认证完了,会重定向回来,给应用一个 Code。 应用可以携带 Code、AppId、AppSecret 去获取到这个用户的资料。

file

至于具体应用本身怎么处理,是合并、还是不存在则创建、还是询问用户,甚至存不存数据库里,都是应用自己的业务逻辑决定的。

LDAP

LDAP 仍然是把密码输入给具体的应用的。只是应用在判定密码是否正确的时候,会基于 LDAP 协议访问 Authentik 来查询,并获取到用户的基本信息。

LDAP 虽然没那么轻量好用,但也可以凑和用。

Forward Auth

如果应用什么都不支持,我建议使用 Forward Auth。参考这个图:

forward auth - proxy auth - authentik - outpost

原理就是每个请求来的时候,都先把请求发给门神,问一下门神这个请求合法吗?

门神本身是一个容器,部署在你业务应用的旁边。是 Authentik 提供的容器。

如果合法就反代,如果不合法就听门神的。门神会送用户 302 到 Openid 这条路。

Forward Auth 我使用的是 Caddy 作为反代,其配置首先需要跑一个门神,门神旁边跑具体的业务应用。门神负责和 Authentik 沟通。业务应用是无感的,只是在最终认证成功后,得到一个 HTTP 头。这基本上是万金油的方案,什么应用都能接。

version: '3.9'

services:
  authentik_proxy:
    image: hub.aiursoft.cn/ghcr.io/goauthentik/proxy:2025.6
    environment:
      AUTHENTIK_HOST: "https://auth.aiursoft.cn/"
      AUTHENTIK_INSECURE: "false"
      AUTHENTIK_TOKEN: "{{KOEL_OUTPOST_TOKEN}}"
    networks:
      - proxy_app
      - internal

  koel:
    image: hub.aiursoft.cn/phanan/koel
    volumes:
      - music:/music
      - artifacts:/artifacts
      - covers:/var/www/html/public/img/covers
      - search_index:/var/www/html/storage/search-indexes
      - /swarm-vol/koel/config:/var/www/html/.env # This is a single file, not a directory
    networks:
      - internal
      - proxy_app
# Now we have koel_koel:80
# And we have koel_authentik_proxy:9000

# Protected by Authentik
musics.aiursoft.cn {
    log
    import hsts
    import rate_limit
    header -content-security-policy
    header -x-frame-options
    encode br gzip

    #Use Authentik for forward auth
    route {
        reverse_proxy /manifest.json http://koel_koel:80

        reverse_proxy /outpost.goauthentik.io/* http://koel_authentik_proxy:9000 {
            header_up Host {http.reverse_proxy.upstream.host}
        }

        forward_auth http://koel_authentik_proxy:9000 {
            uri     /outpost.goauthentik.io/auth/caddy
            copy_headers X-Authentik-Username X-Authentik-Groups X-Authentik-Entitlements X-Authentik-Email X-Authentik-Name X-Authentik-Uid X-Authentik-Jwt X-Authentik-Meta-Jwks X-Authentik-Meta-Outpost X-Authentik-Meta-Provider X-Authentik-Meta-App X-Authentik-Meta-Version
        }

        reverse_proxy http://koel_koel:80
    }
}

反代

最笨的方法就是反代。也是最万金油的方案。如果你无法使用 Caddy,你就只能让门神来反代你的应用了。门神会帮你处理身份认证。

这种方法容易让门神形成性能瓶颈。毕竟应用吞吐的所有东西都要额外经过门神。但好处就是什么场景都支持,什么应用都支持。

记忆口诀

考虑到我有大批需要配置的应用,我不得不先写了个口诀,记忆一下它们的配置方法,整理成文档,再开始配。

因为我很多应用已经有大量用户正在使用了,这非常麻烦,需要谨慎的处理用户合并。不同应用对于合并的策略不同,例如gitea是询问用户怎么合并,gist是直接500错误,gitlab是直接按email地址相同就合并,nextcloud是按用户名相同就合并(需要额外配置)等等。非常麻烦。

口诀如下:

未登录、已登录、管理员

* 协议名称
* 传参方式
* 权限继承方式
  * 开启角色管理
  * 管理员组开启
  * 角色信息字段
  * 禁止登录框
* 合并用户方式
* 注销行为

我自己开发的 ASP.NET Core App

对于我自己开发的 ASP.NET Core App,自然是最容易的。毕竟代码就在我手里,我想怎么实现都不会中坑。

一般的,我把配置存在 appsettings.json 里:

{
  "AppSettings": {
    // 可选值为 "Local" 或 "OIDC"
    "AuthProvider": "Local"
  },
  "OIDC": {
    "Authority": "https://your-oidc-provider.com",
    "ClientId": "your-client-id",
    "ClientSecret": "your-client-secret"
  },
}

然后在启动的时候消费这些配置即可。这里,我还使用 mediator 发布一个事件。

        var authProvider = configuration.GetValue<string>("AppSettings:AuthProvider");

        if (authProvider == "OIDC")
        {
            services.AddAuthentication(options =>
                {
                    options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
                    options.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme;
                })
                .AddCookie(CookieAuthenticationDefaults.AuthenticationScheme, options =>
                {
                    options.LoginPath = "/SignIn";
                    options.LogoutPath = "/auth/signout";
                })
                .AddOpenIdConnect(OpenIdConnectDefaults.AuthenticationScheme, options =>
                {
                    var oidcConfig = configuration.GetSection("OIDC");

                    options.Authority = oidcConfig["Authority"];
                    options.ClientId = oidcConfig["ClientId"];
                    options.ClientSecret = oidcConfig["ClientSecret"];
                    options.Scope.Clear();
                    options.Scope.Add("openid");
                    options.Scope.Add("profile");
                    options.Scope.Add("email");

                    options.SignInScheme = CookieAuthenticationDefaults.AuthenticationScheme;
                    options.ResponseType = OpenIdConnectResponseType.Code;

                    options.SaveTokens = true;
                    options.GetClaimsFromUserInfoEndpoint = true;
                    options.MapInboundClaims = false;
                    options.TokenValidationParameters.NameClaimType = JwtRegisteredClaimNames.Name;
                    options.TokenValidationParameters.RoleClaimType = "groups";
                    options.Events = new OpenIdConnectEvents
                    {
                        OnTokenValidated = async context =>
                        {
                            var mediator = context.HttpContext.RequestServices.GetRequiredService<IMediator>();
                            Console.WriteLine("OnTokenValidated. Got claims:");
                            foreach (var c in context.Principal!.Claims)
                            {
                                Console.WriteLine($"  {c.Type} => {c.Value}");
                            }

                            // 从OIDC的claims中提取关键信息
                            var name = context.Principal!.FindFirst(JwtRegisteredClaimNames.Name)?.Value
                                       ?? context.Principal!.FindFirst("name")?.Value;

                            if (string.IsNullOrEmpty(name))
                            {
                                context.Fail("Name claim not found in OIDC token.");
                                return;
                            }

                            // 创建或同步用户,并获取本地用户的UID
                            var uid = await mediator.Send(new OidcUserSyncCommand(name));

                            // 清除OIDC的旧claims,添加我们自己的claims
														var identity = (ClaimsIdentity)context.Principal.Identity;
                            identity!.RemoveClaim(identity.FindFirst(ClaimTypes.NameIdentifier)); // 移除OIDC的sub
                            identity.AddClaim(new("uid", uid.ToString()));
														
      // 我这个应用比较特殊,是单人的博客,只要登录了,我就赋予 Administrator 这个 role。大部分应用不需要
                            identity.AddClaim(new(ClaimTypes.Role, "Administrator"));
                        }
                    };
                });
        }
        else // 默认为 "Local" 认证
        {
            services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
                .AddCookie(CookieAuthenticationDefaults.AuthenticationScheme, options =>
                {
                    options.AccessDeniedPath = "/auth/accessdenied";
                    options.LoginPath = "/auth/signin";
                    options.LogoutPath = "/auth/signout";
                });
        }

最后,别忘了,在数据库里不存在这个用户的时候创建他。

// Command: 包含从OIDC获取的必要信息
public record OidcUserSyncCommand(string Username) : IRequest<Guid>;

// Handler: 处理用户同步的核心逻辑
public class OidcUserSyncCommandHandler(IRepository<LocalAccountEntity> repo)
    : IRequestHandler<OidcUserSyncCommand, Guid>
{
    public async Task<Guid> Handle(OidcUserSyncCommand request, CancellationToken ct)
    {
        // OIDC用户我们以Username为唯一标识
        var account = await repo.GetAsync(p => p.Username == request.Username);

        if (account is not null)
        {
            // 用户已存在,直接返回ID
            return account.Id;
        }

        // 用户不存在,创建一个新用户
        var newAccount = new LocalAccountEntity
        {
            Id = Guid.NewGuid(),
            Username = request.Username,
            CreateTimeUtc = DateTime.UtcNow,
            PasswordHash = "OIDC_USER",
            PasswordSalt = string.Empty
        };

        await repo.AddAsync(newAccount, ct);
        return newAccount.Id;
    }
}

这就完事儿了。到时候直接把 OIDC 的 AppId、AppSecret 和 Authority 填到环境变量里,启动就能认证了。

其它 Controller 的代码一行不用改,继续使用 [Authorize] 来保护,使用 User.Identity?.Name 来获取当前用户名即可。

OpenWeb Chat

未登录完全无法使用,已登录可以使用几乎所有功能,管理员可以管理高级设置。

  • 基于 OpenId Connect 协议。
  • Client ID 和 Client Secret 需要通过环境变量传递给 OpenWeb Chat 服务。
  • 基于环境变量继承权限信息。可以将具有特定 group 的用户添加到 OpenWeb Chat 的管理员组中。
    • 基于环境变量 ENABLE_OAUTH_ROLE_MANAGEMENT 来确保开启了角色管理功能。
    • 基于环境变量 OAUTH_ADMIN_ROLES 来指定哪些 group 的用户可以成为 OpenWeb Chat 的管理员。
    • 基于环境变量 OAUTH_ROLES_CLAIM 来指定 groups 这个字段代表用户的角色信息。
    • 基于环境变量 ENABLE_LOGIN_FORM=False 来禁止 OpenWeb Chat 的登录框。
  • 在合并用户时自动根据 Email 进行匹配。
  • 注销时只会注销 OpenWeb Chat 的会话,不会影响 Authentik 的会话。

注意:需要额外配置环境变量 ENABLE_OAUTH_SIGNUP=True 来允许 OAuth 完成的用户自动注册 OpenWeb Chat。

注意:需要额外配置环境变量 ENABLE_SIGNUP=False 来让 OpenWeb Chat 禁用注册功能。

Jellyfin

未登录完全无法使用,已登录可以使用几乎所有功能,管理员可以管理高级设置。

  • 基于 OpenId Connect 协议。
  • Client ID 和 Client Secret 需要通过应用内的插件配置传给 Jellyfin 服务。
  • 基于插件的配置继承权限信息。可以将具有特定 group 的用户添加到 Jellyfin 的管理员组中。
    • 基于插件配置 Enable Authorization by Plugin 来确保开启了角色管理功能。
    • 基于插件配置 Admin Roles:jellyfin-admins 来指定哪些 group 的用户可以成为 Jellyfin 的管理员。
    • 基于插件配置 Role Claim:groups 来指定 groups 这个字段代表用户的角色信息。
    • 基于 Generic 配置 .manualLoginForm { display: none; } 来禁止 Jellyfin 的登录框。
  • 在合并用户时自动根据用户名进行匹配。
  • 注销时只会注销 Jellyfin 的会话,不会影响 Authentik 的会话。

注意,需要额外配置插件配置 Scheme Override:https 来让 OAuth 正常工作。

注意,需要额外在登录页面注入一段 JavaScript 来使安卓客户端工作:

// 替换为你自己的 Jellyfin OIDC 登录地址
const SSO_AUTH_URL = 'https://media.aiursoft.cn/sso/OID/start/Aiursoft';

// Self-executing function that waits for the document body to be available
(function waitForBody() {
  // If document.body doesn't exist yet, retry in 100ms
  if (!document.body) {
    return setTimeout(waitForBody, 100);
  }

  /**
   * Determines if the current page is a login page by checking multiple indicators
   * @returns {boolean} True if this appears to be a login page
   */
  function isLoginPage() {
    const hash = location.hash.toLowerCase();
    const pathname = location.pathname.toLowerCase();
    
    // Check for URL patterns that typically indicate login pages
    const hasLoginUrl = (
      hash === '' ||
      hash === '#/' ||
      hash === '#/home' ||
      hash === '#/login' ||
      hash.startsWith('#/login') ||
      pathname.includes('/login')
    );

    // Check for DOM elements that indicate a login form is present
    const hasLoginElements = (
      document.querySelector('input[type="password"]') !== null ||
      document.querySelector('.loginPage') !== null ||
      document.querySelector('#txtUserName') !== null
    );

    return hasLoginUrl || hasLoginElements;
  }

  /**
   * Checks if the current page should be excluded from SSO button insertion
   * These are typically pages where users are already authenticated
   * @returns {boolean} True if this page should be excluded
   */
  function shouldExcludePage() {
    const hash = location.hash.toLowerCase();
    
    // List of page patterns where we don't want to show the SSO button
    const excludePatterns = [
      '#/dashboard',
      '#/home.html',
      '#/movies',
      '#/tv',
      '#/music',
      '#/livetv',
      '#/search',
      '#/settings',
      '#/wizardstart',
      '#/wizardfinish',
      '#/mypreferencesmenu',
      '#/userprofile'
    ];

    return excludePatterns.some(pattern => hash.startsWith(pattern));
  }

  /**
   * Initializes the OAuth device ID in localStorage if it doesn't exist
   * This is required for Jellyfin native apps to maintain device identification
   */
  function oAuthInitDeviceId() {
    // Only set device ID if it's not already set and we're in a native shell environment
    if (!localStorage.getItem('_deviceId2') && window.NativeShell?.AppHost?.deviceId) {
      localStorage.setItem('_deviceId2', window.NativeShell.AppHost.deviceId());
    }
  }

  /**
   * Creates and inserts the SSO login button into the login page
   * Only runs if we're on a valid login page and the button doesn't already exist
   */
  function insertSSOButton() {
    // Safety check: ensure we're on the right page before proceeding
    if (!isLoginPage() || shouldExcludePage()) return;

    // Try to find a suitable container for the SSO button
    const loginContainer = document.querySelector('.readOnlyContent') ||
                          document.querySelector('form')?.parentNode ||
                          document.querySelector('.loginPage') ||
                          document.querySelector('#loginPage');

    // Exit if no container found or button already exists
    if (!loginContainer || document.querySelector('#custom-sso-button')) return;

    // Skip insertion for Jellyfin Media Player (JMP) as it may have different auth handling
    const isJMP = navigator.userAgent.includes("JellyfinMediaPlayer");
    if (isJMP) return;

    // Create the SSO button element
    const button = document.createElement('button');
    button.id = 'custom-sso-button';
    button.className = 'raised block emby-button button-submit';
    
    // Style the button to match Jellyfin's design while being visually distinct
    button.style = 'display: flex; align-items: center; justify-content: center; gap: 10px; padding: 12px 20px; font-size: 16px; background-color: #3949ab; color: white; margin-top: 16px;';
    
    // Add icon and text content
    button.innerHTML = '<span class="material-icons">shield</span><span>Login with SSO</span>';
    
    // Handle button click - prevent form submission and redirect to SSO
    button.onclick = function (e) {
      e.preventDefault();
      oAuthInitDeviceId(); // Ensure device ID is set before SSO redirect
      window.location.href = SSO_AUTH_URL;
    };

    // Add the button to the login container
    loginContainer.appendChild(button);
  }

  // Initial setup: Check if we should insert the SSO button when script first loads
  if (isLoginPage() && !shouldExcludePage()) {
    // Delay insertion slightly to ensure all page elements are fully loaded
    setTimeout(insertSSOButton, 500);
  }

  // Set up a MutationObserver to watch for dynamic page changes
  // This handles cases where Jellyfin loads content dynamically via JavaScript
  const observer = new MutationObserver(() => {
    if (isLoginPage() && !shouldExcludePage()) {
      // Check if login elements are ready and button hasn't been inserted yet
      const ready = document.querySelector('.readOnlyContent') ||
                   document.querySelector('form') ||
                   document.querySelector('.loginPage');
      
      if (ready && !document.querySelector('#custom-sso-button')) {
        insertSSOButton();
      }
    }
  });

  // Start observing changes to the entire document body and its children
  observer.observe(document.body, { childList: true, subtree: true });

  // Listen for hash changes (when navigating between pages in Jellyfin's SPA)
  window.addEventListener('hashchange', () => {
    // Small delay to allow page transition to complete
    setTimeout(() => {
      if (isLoginPage() && !shouldExcludePage()) {
        insertSSOButton();
      }
    }, 300);
  });
})();

使用这个插件可以注入这段 JavaScript:https://github.com/johnpc/jellyfin-plugin-custom-javascript

OpenGist

未登录可以匿名浏览,已登录可以使用几乎所有功能,管理员可以管理高级设置。

  • 基于 OpenId Connect 协议。
  • Client ID 和 Client Secret 需要通过环境变量传递给 OpenGist 服务。
  • 基于环境变量继承权限信息。可以将具有特定 group 的用户添加到 OpenGist 的管理员组中。
    • 默认就开启了角色管理功能。
    • 基于环境变量 OG_OIDC_ADMIN_GROUP 来指定哪些 group 的用户可以成为 OpenGist 的管理员。
    • 基于环境变量 OG_OIDC_GROUP_CLAIM_NAME 来指定 groups 这个字段代表用户的角色信息。
    • 基于应用内置的 OAuth2 配置 Disable login form 来禁止 OpenGist 的登录框。
  • 无法合并。需要手工删除老用户。我这里直接给所有老用户的用户名加了个_disabled
  • 注销时只会注销 OpenGist 的会话,不会影响 Authentik 的会话。

注意,需要额外在管理员中心配置 Disable signup 为关,来确保 OAuth 完成的用户可以自动注册 OpenGist。

Gitea

未登录可以匿名浏览,已登录可以使用几乎所有功能,管理员可以管理高级设置。

  • 基于 OpenId Connect 协议。
  • Client ID 和 Client Secret 需要通过应用内置的 OAuth2 配置传给 Gitea 服务。
  • 基于应用内置的 OAuth2 配置继承权限信息。可以将具有特定 group 的用户添加到 Gitea 的管理员组中。
    • 基于应用内置的 OAuth2 配置 Claim name providing group names for this source. (Optional) 来确保开启了角色管理功能。
    • 基于应用内置的 OAuth2 配置 Group Claim value for administrator users. (Optional - requires claim name above) 来指定哪些 group 的用户可以成为 Gitea 的管理员。
    • 基于应用内置的 OAuth2 配置 Claim name providing group names for this source. (Optional) 来指定 groups 这个字段代表用户的角色信息。
    • 基于环境变量 ENABLE_PASSWORD_SIGNIN_FORM = falseENABLE_OPENID_SIGNIN = false 来禁止 Gitea 的登录框。
  • 在合并用户时自动根据 Email 进行匹配。
  • 注销时只会注销 Gitea 的会话,不会影响 Authentik 的会话。

注意,需要额外配置应用的环境变量:

[service]
REGISTER_EMAIL_CONFIRM = false
ENABLE_NOTIFY_MAIL = false
DISABLE_REGISTRATION = true
ALLOW_ONLY_EXTERNAL_REGISTRATION = true
ENABLE_CAPTCHA = true
CAPTCHA_TYPE = image
REQUIRE_SIGNIN_VIEW = false
DEFAULT_KEEP_EMAIL_PRIVATE = false
DEFAULT_ALLOW_CREATE_ORGANIZATION = true
DEFAULT_ENABLE_TIMETRACKING = true
NO_REPLY_ADDRESS = noreply.localhost
ENABLE_PASSWORD_SIGNIN_FORM = false
ENABLE_PASSKEY_AUTHENTICATION = false

[lfs]
PATH = /data/git/lfs

[mailer]
ENABLED = false

[openid]
ENABLE_OPENID_SIGNIN = false
ENABLE_OPENID_SIGNUP = false

[oauth2_client]
ENABLE_AUTO_REGISTRATION = true
ENABLE_AUTO_REGISTRATION = true
ACCOUNT_LINKING = auto

来实现匿名可以浏览、注册无需确认、注册无需邮件通知、禁止注册、禁止密码登录、禁止外部 OpenID 登录、禁止外部 OAuth 登录、自动注册 OAuth 完成的用户、自动合并 OAuth 完成的用户。

Koel

完全不支持 OIDC,全靠 Forward Auth 来保护。

sequenceDiagram
    User->>+Caddy: I want to access app!
    Caddy->>+Outpost: Is this request valid?
    Outpost->>+Caddy: No. No cookie found. 302 to Auth!
    Caddy->>+User: 302 to Auth center!
    User->>+Auth: I want to login!
    Auth->>+User: Enter your passkey!
    User->>+Auth: Here is my passkey!
    Auth->>+User: Okay. You are logged in! 302 to Outpost with Code!
    User->>+Caddy: I have a Code! Take me to Outpost!
    Caddy->>+Outpost: He finished auth! He has a code!
    Outpost->>+Auth: What's his information? Here is the code and secret!
    Auth->>+Outpost: Here is his info..
    Outpost->>+Outpost: Create user. Save in database.
    Outpost->>+Caddy: A cookie for outpost.
    Caddy->>+User: Set a cookie for outpost. 302 to app.
    User->>+Caddy: I want to access the app. With my cookie.
    Caddy->>+Outpost: Is this cookie valid?
    Outpost->>+Caddy: Yes. A valid cookie. 200!
    Caddy->>+App: +HTTP Header: User ID
    App->>+App: Got header. Create user in database.
    App->>+Caddy: App experience
    Caddy->>+User:App experience

未登录完全无法使用,已登录可以使用几乎所有功能。

  • 基于 Forward Auth 协议。
  • 通过 Caddy 的 Forward Auth 模块来验证用户身份。
  • Koel 会基于 IP 地址来确保只有来自 Caddy 的请求才会被接受。
  • Koel 自己会通过 HTTP Header 来获取用户信息。
  • 不支持权限管理。所有人都是 User 角色。
  • 不支持合并用户。需要手工删除老用户。
  • 注销时只会注销 Koel 的会话,不会影响 Authentik 的会话。

注意,需要额外配置应用的环境变量:

PROXY_AUTH_ENABLED=true
PROXY_AUTH_ALLOW_LIST=0.0.0.0/0,::/0 # Actual IP of Caddy
PROXY_AUTH_USER_HEADER=X-Authentik-Uid
PROXY_AUTH_PREFERRED_NAME_HEADER=X-Authentik-Username

GitLab

未登录可以匿名浏览,已登录可以使用几乎所有功能,管理员可以管理高级设置。

  • 基于 OpenId Connect 协议。
  • Client ID 和 Client Secret 需要通过应用内置的配置文件传给 Gitlab 服务。
  • 基于应用内置的配置文件继承权限信息。可以将具有特定 group 的用户添加到 Gitlab 的管理员组中。
    • 默认就开启了角色管理功能。
    • 基于应用内置的配置文件 gitlab_rails['omniauth_providers'] 来指定哪些 group 的用户可以成为 Gitlab 的管理员。
    • 基于应用内置的配置文件 gitlab_rails['omniauth_providers'] 来指定 groups 这个字段代表用户的角色信息。
    • 基于应用内置的配置文件 gitlab_rails['omniauth_allow_single_sign_on'] 来禁止 Gitlab 的登录框。
  • 在合并用户时自动根据 Email 进行匹配。
  • 注销时只会注销 Gitlab 的会话,不会影响 Authentik 的会话。

注意,需要额外配置应用的环境变量:

gitlab_rails['omniauth_allow_single_sign_on'] = ['openid_connect']
gitlab_rails['omniauth_sync_email_from_provider'] = 'openid_connect'
gitlab_rails['omniauth_sync_profile_from_provider'] = ['openid_connect']
gitlab_rails['omniauth_sync_profile_attributes'] = ['email']
gitlab_rails['omniauth_auto_sign_in_with_provider'] = 'openid_connect'
gitlab_rails['omniauth_block_auto_created_users'] = false
# gitlab_rails['omniauth_auto_link_ldap_user'] = false
# gitlab_rails['omniauth_auto_link_saml_user'] = false
gitlab_rails['omniauth_auto_link_user'] = ['openid_connect']
# gitlab_rails['omniauth_external_providers'] = ['twitter', 'google_oauth2']
gitlab_rails['omniauth_allow_bypass_two_factor'] = ['openid_connect']
gitlab_rails['omniauth_providers'] = [
  {
    name: 'openid_connect',
    label: 'Aiursoft Login',
    args: {
      name: 'openid_connect',
      scope: ['openid','profile','email'],
      response_type: 'code',
      issuer: 'https://auth.aiursoft.cn/application/o/gitlab/',
      discovery: true,
      client_auth_method: 'query',
      uid_field: 'preferred_username',
      send_scope_to_token_endpoint: 'true',
      pkce: true,
      client_options: {
        identifier: '<id>',
        secret: '<secret>',
        redirect_uri: 'https://gitlab.aiursoft.cn/users/auth/openid_connect/callback',
        gitlab: {
          groups_attribute: "groups",
          admin_groups: ["gitlab-admins"]
        }
      }
    }
  }
]

Nextcloud

未登录完全无法使用,已登录可以使用几乎所有功能,管理员可以管理高级设置。

  • 基于 OpenId Connect 协议。
  • Client ID 和 Client Secret 需要通过应用内置的插件配置传给 Nextcloud 服务。
  • 基于插件的配置继承权限信息。可以将具有特定特点的用户添加到 Nextcloud 的管理员组中。
    • Nextcloud 的角色管理功能非常特殊,只有 groups 里包含 admin 的用户才会被认为是管理员。
    • 这里可以使用下面的 Python 进行属性映射,确保只有在 nextcloud-admins 组的成员会赋予 admin 权限。
    • 基于命令 sudo docker exec --user www-data -it nextcloud-aio-nextcloud php occ config:app:set --value=0 user_oidc allow_multiple_user_backends 来禁止 Nextcloud 的登录框。
  • 在合并用户时,Nextcloud 只会根据你设置的属性来和现有的 username 进行比对。
    • 首先,我们需要统计每个老 Nextcloud 用户的 user_id,然后将其映射到 Autentik 里的用户表的 nextcloud_user_id 属性上。
    • 然后需要增加一个 Property Mapping,将 nextcloud_user_id,映射为给 Nextcloud 看的 user_id 属性。如果不存在这个属性,则给 Nextcloud 展示 username
    • 在 Nextcloud 的 Attribute Mapping 中,设置 User ID mapping 的 mapping 为 user_id
    • 在 Nextcloud 的 Attribute Mapping 中,设置 quota 的 mapping 为 quota,并在 Authentik 设置默认值为 200G
    • 在 Nextcloud 的 Attribute Mapping 中,设置 Groups 的 mapping 为 groups
    • 禁止:Nextcloud by default every user will get a unique user ID that is a hashed value of the provider and user ID. This can be turned off but uniqueness of users accross multiple user backends and providers is no longer preserved then.
    • 禁止: Nextcloud to keep IDs in plain text, but also preserve uniqueness of them across multiple providers, a prefix with the providers name is added.
    • 设置 'allow_local_remote_servers' => trueconfig.php 来允许 Nextcloud 访问 Authentik 的 API。
    • 在 Authentik 的 Provider 设置里,Subject Mode 设为 Based on the User's UUID
# Extract all groups the user is a member of
groups = [group.name for group in user.ak_groups.all()]

# If the user is in 'nextcloud-admins', remove it and replace with 'admin'
if "nextcloud-admins" in groups:
    groups.remove("nextcloud-admins")
    if "admin" not in groups:
        groups.append("admin")

return {
    "name": request.user.name,
    "groups": groups,
    # Set a quota by using the "nextcloud_quota" property in the user's attributes
    "quota": user.group_attributes().get("nextcloud_quota", "200G"),
    # To connect an existing Nextcloud user, set "nextcloud_user_id" to the Nextcloud username.
    "user_id": user.attributes.get("nextcloud_user_id", str(user.username)),
}

完成上述配置后,用户通过 Authentik 登录 Nextcloud 时,Nextcloud 会使用 nextcloud_user_id 属性来识别用户,并将其映射到 Nextcloud 的用户 ID 上。如果不存在,则会创建一个新的 Nextcloud 用户,并使用 nextcloud_user_id 作为其 ID。

在未给用户设置 nextcloud_user_id 属性的情况下,Nextcloud 会使用 username 作为用户 ID。

总结

给应用接入 OIDC 非常好玩,眼睁睁看着自己的 App 中心里好东西越来越多。而且用户无需反复注册就能打开你的新业务玩,非常无痛。

缺点就是很折腾。很多应用要自己处理属性映射,并且小心的合并真实的用户。大部分情况都需要手工一一检查用户。

建议在业务规模不大的早期,赶紧上 Authentik 。毕竟用户数量200以内都能人工操作。用户多了以后,再加上应用多了,合并起来将非常恐怖。

我就很后悔,Authentik 搭晚了,导致大量用户(他们在不同平台用了不同的Email、Username注册)合并起来非常头痛,还得和他们沟通,一天也就能接入一个app。