之前家里的 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 正常工作。

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。