Strapi 是一个无头内容管理系统,其管理界面默认提供了邮箱登录的方式。不过,其邮箱密码账户是其单独的用户系统,需要单独维护。对于一个组织来说,很可能已经有一个甚至多个现有的用户系统了,再额外增加一套用户系统,不仅增加了管理成本,对于终端用户使用也极为不便。所以,非常有必要为 Strapi 的管理界面提供一种单点登录的方式。
Strapi 系统其实是已经支持了单点登录的特性的,不过该功能并非免费,需要联系销售购买许可证。
image.png
假设你已经有了 Strapi 的企业级许可证,那么你现在可以为 Strapi 的管理界面开启单点登录功能。
image.png
不过这还不够,在适配身份源时,还需要做更多工作。如果你的身份源不在 Strapi 默认的身份提供者列表里,那就还需要写一丢丢代码来做这个适配。本文正是针对这个场景,为在这方面碰到困难的读者们提供指引。

:::success 联系 Strapi 的销售购买许可证,不是一个能够立即完成的事情。如果你只想在测试环境快速验证一下该功能,那么建议你仔细阅读《修改 node_modules 的三种方式,隆重推荐 patch-package - Jeff Tian的文章 - 知乎 》一文,当你真正读懂了,你是完全可以在你的测试场景中使用 Strapi 完整的企业版功能的(请自觉不要正式使用)。 :::

本文将给出两个示例,分别是对接 Authing 和其他的 OIDC 身份提供者(比如 Keycloak、Duende IdentityServer 等)。其实对接 Authing 也是对接 OIDC 的一个特例,但是特别值得推荐,所以单独列出。

最终效果

我们实现的最终效果是可以在 Strapi 的管理界面上,在默认的邮箱登录方式之外增加两个额外的选项。
image.png
不过目前由于 OIDC 对接了一个内网环境的提供者,暂时有 IP 白名单限制。

Passport

首先要知道 Strapi 的管理后台的身份认证系统是基于 Passport 库的,Passport 是一个个人开发者开发的 nodejs 库,基于策略模式可以非常方便地适配各种不同的身份源,所以其生态特别丰富,基本上主流的身份平台都有对应的策略。 :::success 我也曾在 2019 年实现过一个 Passport 策略,用来对接花旗银行的开放 API,源代码见: https://github.com/Jeff-Tian/passport-citi 。 :::

对接 Authing

在 Authing 的控制台里,通过添加集成应用到单点登录 SSO,可以直接选择 Strapi,就能看见一个非常棒的接入教程。
image.png
该教程基于 Passport 和 OAuth 2 策略,自行写了一个适配 Authing 的 OIDC 策略来实现。本文想给它做一个补充,即通过开源的 Authing 策略,用更少的代码接入。这个开源的 Authing Passport 策略库是: “passport-authing”。

通过在 strapi 项目的 config/admin.js 添加一些代码,即可完成 strapi 与 Authing 的对接,代码如下:

javascript const AuthingStrategy = require(passport-authing).Strategy;

module.exports = ({env}) => { const authing = { uid: authing, displayName: Authing, icon: , createStrategy: (strapi) => { return new AuthingStrategy({ // 从 Authing 的控制面板里复制过来 domain: xt1o6lgf.authing.cn,
// 从 Authing 的控制面板复制具体的值,建议存储在环境变量里 clientID: env(AUTHING_CLIENT_ID), clientSecret: env(AUTHING_CLIENT_SECRET), scope: [ email, profile, openid ], callbackURL: strapi.admin.services.passport.getStrategyCallbackURL( authing // 需要与上面的 provider 一致 ), }, (request, accessToken, refreshToken, profile, done) => { done(null, { email: profile.emails[0].value, firstname: profile.givenName ?? profile.displayName ?? profile.emails[0].value, lastname: profile.familyName ?? profile.nickname ?? profile.emails[0].value }) }); } };

return ({ auth: { secret: env(ADMIN_JWT_SECRET), providers: [ oidc ] }, apiToken: { salt: env(API_TOKEN_SALT), }, transfer: { token: { salt: env(TRANSFER_TOKEN_SALT), }, }, flags: { nps: env.bool(FLAG_NPS, true), promoteEE: env.bool(FLAG_PROMOTE_EE, true), }, }); });

对接其他的 OIDC 提供者

其实和对接 Authing 很像,但是为了展示如何做更多的定制化,我们先自己写一个 passport oidc 策略。更多的定制化功能,我们以 IP 白名单举例。这需要重载默认的授权方法,检测到用户的 IP 地址不在白名单里时,就拒绝进行授权。完整代码如下,注意获取用户的 IP 地址时,需要从 x-forwarded-forHTTP 头里获取,而不能使用 request.socket.remoteAddress 的方式。 因为请求从用户的出口 IP 到达 strapi 的服务,中间可能会经过多个代理服务器的跳转,所以只有使用 x-forwarded-for才能获取到用户的稳定出口 IP,而 request.socket.remoteAddress的值可能每次都不一样。除非用户的机器到 strapi 的服务只经过一跳,否则它们的值是不相等的。 javascript const util = require(util) // passport-oauth2 需要 npm/yarn 等安装 const OAuth2Strategy = require(passport-oauth2) const InternalOAuthError = OAuth2Strategy.InternalOAuthError const request = require(request);

function Strategy(options, verify) { options = options || {}

options.scope = options.scope || openid profile email

this.userInfoURL = options.userInfoURL; // 从选项中读取 IP 白名单列表 this.ipWhitelist = options.ipWhitelist ?? [];

OAuth2Strategy.call(this, options, verify)

this.name = options.provider || oidc }

// 从 OAuth2Strategy 继承出 Strategy util.inherits(Strategy, OAuth2Strategy)

// 记住原有的授权方法 const authenticate = Strategy.prototype.authenticate;

// 改造授权方法,以检测 IP 是否在白名单中 Strategy.prototype.authenticate = function (req, options) { const clientIp = req.get(x-forwarded-for);

if (this.ipWhitelist.indexOf(clientIp) < 0) { throw this.fail({message: IP 地址 ${clientIp} 不在白名单中!}); }

return authenticate.call(this, req, options); };

// 获取用户信息 Strategy.prototype.userProfile = function (accessToken, done) { const self = this

const options = { method: GET, url: self.userInfoURL, headers: { Authorization: Bearer + accessToken } };

request(options, function (err, response) { if (err) { return done(new InternalOAuthError(Failed to fetch user profile, err)) }

try {
  const json = JSON.parse(response.body)

  done(null, json);
} catch (ex) {
  return done(new Error(Failed to parse user profile))
}

}); }

// 对外暴露策略 module.exports = { Strategy, }

有了以上策略,我们就可以在 admin.js 中添加 OIDC 登录方式了,这里以对接我部署好的 Duende IdentityServer 为例:

diff const AuthingStrategy = require(passport-authing).Strategy;

  • const OIDCStrategy = require(./passport-oidc).Strategy;

module.exports = ({env}) => {

  • const oidc = {
  • uid: oidc,
  • displayName: OIDC,
  • icon: ,
  • createStrategy: (strapi) => {
  •  return new OIDCStrategy({
    
  •    issuer: https://id6.azurewebsites.net/,
    
  •    authorizationURL: https://id6.azurewebsites.net/connect/authorize,
    
  •    tokenURL: https://id6.azurewebsites.net/connect/token,
    
  •    userInfoURL: https://id6.azurewebsites.net/connect/userinfo,
    
  •    provider: oidc,
    
  •    clientID: strapi,
    
  •    clientSecret: strapi,
    
  •    callbackURL: strapi.admin.services.passport.getStrategyCallbackURL(
    
  •      oidc // 需要与上面的 provider 一致
    
  •    ),
    
  •    ipWhitelist: env(IP_WHITE_LIST, ).split(,).map(ip => ip.trim()).filter(ip => ip.length > 0),
    
  •  }, (request, accessToken, refreshToken, profile, done) => {
    
  •    done(null, {
    
  •      email: profile.email,
    
  •      firstname: profile.givenName ?? profile.displayName ?? profile.email,
    
  •      lastname: profile.familyName ?? profile.nickname ?? profile.email
    
  •    })
    
  •  });
    
  • }
  • };

return ({ auth: { secret: env(ADMIN_JWT_SECRET), providers: [ authing,

  •    oidc
    ]
    
    },

总结

本文详细说明了如何使用 passport 策略给 Strapi 管理界面添加新的单点登录方式。