Nuxt 3.0下构建OIDC登录模块实践

avatar
@比心

1、背景

Nuxt 3.0正式版发布已有一段时间,但一直没有提供登录相关的模块。Nuxt 2.0提供了auth 模块用于登录授权,但这个模块无法在Nuxt 3.0新框架下使用。如果项目里需要用到OIDC的登录能力,需要我们自己写代码去实现。本文通过开发一个Nuxt 3.0 OIDC登录模块来实现通用的登录能力。

2、登录模块的实现过程

2.1 基本概念

在实现登录模块前,我们需要对授权认证过程有一些基本的了解。同时,需要对Nuxt的模块化机制有基本认知。

2.1.1 OIDC介绍

OIDC是OpenID Connetction的简称,是构建在auth2.0之上的一种通用授权认证协议。其中授权的过程可复用auth2.0的协议,主要分为以下两部:

  1. Client端向授权服务器(本文称为OP,即OIDC Provider)发起授权请求,请求带上 clientIdclientSecret 两个参数,OP验证其合法性。然后用户跳转到登录界面,登录成功后,返回给client的信息有id_token和accessToken。 

  2. Client端拿到id_token后进行验证,验证通过后采用accessToken调用OP接口获取登录的用户信息(如用户名,用户id等)。

在这里我们还得简单介绍下auth2.0的四种授权许可模式:

  1. 授权码模式(Authorization Code) 这种方式第一步是请求OP获得授权码;client拿到授权码后再向OP发起请求,获取到accessToken;最后通过accessToken获取到登录的用户信息。

  2. 隐式模式(Implicit) 这种模式是授权码模式的简化版,也是大多数内部的oidc服务采用的模式。它省略了获取授权码的这一步,直接通过clientIdclientSecret 获取用户的accessToken;后续步骤同授权码模式。

  3. 密码授权模式(Resource Owner Password Credentials Grant) 这种模式进一步做了简化,不再需要跳转到登录密码的页面,而是一次性把密码和用户名,clientId等信息传给Resource server。这种模式一般适用于Resource server高度信任第三方Client的情况下。

  4. 客户端认证授权(Client Credentials Grant) 这种模式做了更进一步简化,Client直接以自己的名义而不是Resource owner的名义去要求访问Resource server的一些受保护资源。

本文实现了授权码模式和隐式模式两种授权许可方式。

2.1.2 Nuxt 3.0的模块(module)机制

Nuxt 3.0提供了模块(module) 的机制可用于扩展框架没有实现的功能,我们可以通过module来实现登录功能。简单来说,我们可以把模块理解成一种插件的机制。

2.2 授权认证过程

虽然前文中提到了整体的登录过程,因为Nuxt是一个基于Vue之上的SSR框架。这会使得我们整体的登录过程变得更加复杂。也就是说我们需要在Nuxt的Server端来实现这个登录模块。Nuxt前端页面调用Nuxt的Server的登录接口来完成整体的登录过程。整体登录过程如下,下图中标红的OIDC登录模块就是作者实现的授权登录模块:

640.png

如上图,我们需要实现OIDC登录模块(红色部分),其核心有以下几步: 

  1.  在 /oidc/login 接口里需要构建请求参数,向OP发起授权请求;

  2. 接收 id_tokenaccessToken 信息,对id_token信息进行验证,通过 accessToken 信息获取用户的信息;

  3.  对获取到的用户信息加密后返回给浏览器的cookie中进行存储;

OIDC对auth2.0最大的扩展在id_token这个参数上,它相当于一个安全令牌。是授权服务器提供的包含用户信息(由一组Cliams构成以及其他辅助的Cliams)的JWT格式的数据结构。它使用JWS进行签名和JWE加密,从而提供认证的完整性、不可否认性以及可选的保密性。下面是一个具体的例子:id_token值为

eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICItdUxLTnZPRDluSUlndC0yRXlVc3ZiWTV5cGdfS2pMcG1lM1lkZEhRdVJFIn0.eyJleHAiOjE2NzgwODgyMjUsImlhdCI6MTY3ODA4NzkyNSwiYXV0aF90aW1lIjoxNjc4MDg3OTI1LCJqdGkiOiI4MTc3MGQ5Mi02NmE2LTRlMTQtYWRlZC0zNmQwOGEzMWVlMTMiLCJpc3MiOiJodHRwOi8vMTkyLjE2OC4yNi4xMTQ6ODA4MC9yZWFsbXMvdGVzdCIsImF1ZCI6InRlc3RDbGllbnQiLCJzdWIiOiJmYTUzZTViMC1mMDY3LTRkMWMtYjQ3MS1jYmRiMjI2NGQzODgiLCJ0eXAiOiJJRCIsImF6cCI6InRlc3RDbGllbnQiLCJub25jZSI6Ik9sd09yNFpvNVIwdGxTbVRNUzlFbi0ySWItd2toRVd5SnlLa1YycnN5dkEiLCJzZXNzaW9uX3N0YXRlIjoiYmQ4ZjkwNjctODdiOS00YzYxLThhN2EtNDhiOTE5NDNhZDNhIiwiYXRfaGFzaCI6IjMxS0o1eEhrUnI3andJYXhSbzVQMHciLCJhY3IiOiIxIiwic2lkIjoiYmQ4ZjkwNjctODdiOS00YzYxLThhN2EtNDhiOTE5NDNhZDNhIiwiYWRkcmVzcyI6e30sImVtYWlsX3ZlcmlmaWVkIjpmYWxzZSwibmFtZSI6IkFib3JuIEppYW5nIiwicHJlZmVycmVkX3VzZXJuYW1lIjoiYWJvcm4iLCJnaXZlbl9uYW1lIjoiQWJvcm4iLCJmYW1pbHlfbmFtZSI6IkppYW5nIn0.GZ8UyuXv4NRnwj9uhCk9ujPOxE7V3ADaL67aKqiLjcJubtq5YjtwHNfAGFCzjfrz3D_T9HdWmarbmVbtyfNiwNg2wsd19heuHfjc2tg5CDFOmLsbdIfouboKFPVjFYoSYkSOEi8b_79rydmMzgo_nGwhGawRCTUtrSVME8fwP7NPbl8Be0zc_cogBZmYQ6Ma9NS4R1jrY6GoM-QLaEytErC8_BTToLKPEB9poK72HWQLJiBl2DzMGuAdlb9V4NVvVmHl8Jdkmo-f_yjSyyqSELPMG2U8vAJWrFjpJz2O6l8ldsnx9Y5Ndya4rYpqHSxyxPmQzs9wyORIPwDKEtJ3nw

解密后的信息如下

{

    "exp":1678088225,

    "iat":1678087925,

    "auth_time":1678087925,

    "jti":"81770d92-66a6-4e14-aded-36d08a31ee13",

    "iss":"http://192.168.26.114:8080/realms/test",

    "aud":"testClient",

    "sub":"fa53e5b0-f067-4d1c-b471-cbdb2264d388",

    "typ":"ID",

    "azp":"testClient",

    "nonce":"OlwOr4Zo5R0tlSmTMS9En-2Ib-wkhEWyJyKkV2rsyvA",

    "session_state":"bd8f9067-87b9-4c61-8a7a-48b91943ad3a",

    "at_hash":"31KJ5xHkRr7jwIaxRo5P0w",

    "acr":"1",

    "sid":"bd8f9067-87b9-4c61-8a7a-48b91943ad3a",

    "address":{

  


    },

    "email_verified":false,

    "name":"Aborn Jiang",

    "preferred_username":"aborn",

    "given_name":"Aborn",

    "family_name":"Jiang"

}

2.2.1 登录接口

**
**

在这里我们调用开源的node-openid-client来发起登录授权过程。登录接口 /oidc/login整体的逻辑如下:  1)构建请求的 issueClient 端; 

2)发起授权请求,通过调用 issueClient.authorizationUrl() 接口实现发起登录阶段的整体代码如下:


export default defineEventHandler(async (event) => {

  logger.info('[Login]: oidc/login calling')

  const req = event.node.req

  const res = event.node.res

  


  const { op, config } = useRuntimeConfig().openidConnect

  const redirectUrl = getRedirectUrl(req.url)

  const callbackUrl = getCallbackUrl(op.callbackUrl, redirectUrl, req.headers.host)

  const defCallBackUrl = getDefaultBackUrl(redirectUrl, req.headers.host)

  


  const issueClient = await initClient(op, req, [defCallBackUrl, callbackUrl])

  const sessionkey = config.secret

  let sessionid = getCookie(event, config.secret)

  if (!sessionid) {

    logger.trace('[Login]: regenerate sessionid')

    sessionid = generators.nonce() *// uuidv4()*

  }

  


  const responseMode = getResponseMode(config)

  const scopes = op.scope.includes('openid') ? op.scope : [...op.scope, 'openid']

  logger.info('[Login]: cabackurl & op.callbackUrl & redirecturl: ', callbackUrl, op.callbackUrl, redirectUrl)

  logger.info('  response_mode:' + responseMode + ', response_type:' + config.response_type + ', scopes:' + scopes.join(' '))

  


  const parameters = {

    redirect_uri: callbackUrl,

    response_type: config.response_type,

    response_mode: responseMode,

    nonce: sessionid,

    scope: scopes.join(' ')

  }

  const authUrl = issueClient.authorizationUrl(parameters)

  logger.info('[Login]: Auth Url: ' + authUrl + ',    #sessionid:' + sessionid)

  


  if (sessionid) {

    setCookie(event, sessionkey, sessionid, {

      maxAge: config.cookieMaxAge,

      ...config.cookieFlags[sessionkey as keyof typeof config.cookieFlags]

    })

  }

  


  res.writeHead(302, { Location: authUrl })

  res.end()

})

2.2.2 回调处理

回调处理的代码如下:

  1. 这里处理了response_mode为post_form的方式,见req.method === 'POST'

  2. 支持授权码模式和隐式模式两种方式。

export default defineEventHandler(async (event) => {

  logger.info('[Login]: oidc/login calling')

  const req = event.node.req

  const res = event.node.res

  


  const { op, config } = useRuntimeConfig().openidConnect

  const redirectUrl = getRedirectUrl(req.url)

  const callbackUrl = getCallbackUrl(op.callbackUrl, redirectUrl, req.headers.host)

  const defCallBackUrl = getDefaultBackUrl(redirectUrl, req.headers.host)

  


  const issueClient = await initClient(op, req, [defCallBackUrl, callbackUrl])

  const sessionkey = config.secret

  let sessionid = getCookie(event, config.secret)

  if (!sessionid) {

    logger.trace('[Login]: regenerate sessionid')

    sessionid = generators.nonce() *// uuidv4()*

  }
  const responseMode = getResponseMode(config)

  const scopes = op.scope.includes('openid') ? op.scope : [...op.scope, 'openid']

  logger.info('[Login]: cabackurl & op.callbackUrl & redirecturl: ', callbackUrl, op.callbackUrl, redirectUrl)

  logger.info('  response_mode:' + responseMode + ', response_type:' + config.response_type + ', scopes:' + scopes.join(' '))

  


  const parameters = {

    redirect_uri: callbackUrl,

    response_type: config.response_type,

    response_mode: responseMode,

    nonce: sessionid,

    scope: scopes.join(' ')

  }

const authUrl = issueClient.authorizationUrl(parameters)

  logger.info('[Login]: Auth Url: ' + authUrl + ',    #sessionid:' + sessionid)

  


  if (sessionid) {

    setCookie(event, sessionkey, sessionid, {

      maxAge: config.cookieMaxAge,

      ...config.cookieFlags[sessionkey as keyof typeof config.cookieFlags]

    })

  }

  


  res.writeHead(302, { Location: authUrl })

  res.end()

})
  
  

2.2.2 回调处理

回调处理的代码如下:

  1. 这里处理了response_mode为post_form的方式,见req.method === 'POST'

  2. 支持授权码模式和隐式模式两种方式。


export default defineEventHandler(async (event) => {

  const req = event.node.req

  const res = event.node.res

  logger.info('[CALLBACK]: oidc/callback calling, method:' + req.method)

  


  let request = req

  if (req.method === 'POST') {

    *// response_mode=form_post ('POST' method)*

    const body = await readBody(event)

    request = {

      method: req.method,

      url: req.url,

      body

    } as unknown as http.IncomingMessage

  }
  
  const { op, config } = useRuntimeConfig().openidConnect

  const responseMode = getResponseMode(config)

  const sessionid = getCookie(event, config.secret)

  const redirectUrl = getRedirectUrl(req.url)
  
  // logger.info('---Callback. redirectUrl:' + redirectUrl)
  
 // logger.info(' -- req.url:' + req.url + '   #method:' + req.method + ' #response_mode:' + responseMode)
 
 const callbackUrl = getCallbackUrl(op.callbackUrl, redirectUrl, req.headers.host)

  const defCallBackUrl = getDefaultBackUrl(redirectUrl, req.headers.host)
  
  const issueClient = await initClient(op, req, [defCallBackUrl, callbackUrl])

  const params = issueClient.callbackParams(request)
  
  if (params.access_token) {

    *// Implicit ID Token Flow: access_token*

    logger.debug('[CALLBACK]: has access_token in params, accessToken:' + params.access_token)

    await getUserInfo(params.access_token)

    res.writeHead(302, { Location: redirectUrl || '/' })

    res.end()

  } else if (params.code) {

    *// Authorization Code Flow: code -> access_token*

    logger.debug('[CALLBACK]: has code in params, code:' + params.code)

    const tokenSet = await issueClient.callback(callbackUrl, params, { nonce: sessionid })

    *// logger.info('received and validated tokens %j', tokenSet)*

    *// logger.info('validated ID Token claims %j', tokenSet.claims())*

    if (tokenSet.access_token) {

      await getUserInfo(tokenSet.access_token)

    }

    res.writeHead(302, { Location: redirectUrl || '/' })

    res.end()

  }else {

    *// Error dealing.*

    *// eslint-disable-next-line no-lonely-if*

    if (params.error) {

      *// redirct to auth failed error page.*

      logger.error('[CALLBACK]: error callback')

      logger.error(params.error + ', error_description:' + params.error_description)

      res.writeHead(302, { Location: '/oidc/error' })

      res.end()

    } else if (responseMode === 'fragment') {

      logger.warn('[CALLBACK]: callback redirect')

      res.writeHead(302, { Location: '/oidc/cbt?redirect=' + redirectUrl })

      res.end()

    } else {

      logger.error('[CALLBACK]: error callback')

      res.writeHead(302, { Location: redirectUrl || '/' })

      res.end()

    }
   }
  async function getUserInfo(accessToken: string) {

    try {

      const userinfo = await issueClient.userinfo(accessToken)

      *// logger.info(userinfo)*

      setCookie(event, config.cookiePrefix + 'access_token', accessToken, {

        maxAge: config.cookieMaxAge,

        ...config.cookieFlags['access_token' as keyof typeof config.cookieFlags]

      })

      const cookie = config.cookie

      for (const [key, value] of Object.entries(userinfo)) {

        if (cookie && Object.prototype.hasOwnProperty.call(cookie, key)) {

          setCookie(event, config.cookiePrefix + key, JSON.stringify(value), {

            maxAge: config.cookieMaxAge,

            ...config.cookieFlags[key as keyof typeof config.cookieFlags]

          })

        }

      }

      const encryptedText = await encrypt(JSON.stringify(userinfo), config)

      setCookie(event, config.cookiePrefix + 'user_info', encryptedText, { ...config.cookieFlags['user_info' as keyof typeof config.cookieFlags] })

    } catch (err) {

      logger.error('[CALLBACK]: ' + err)

    }

  }

})
 

2.3 遇到的问题

2.3.1 基础信息可动态配置

对于模块调用授权过程中使用到的clientIdissuerclientSecret等字段信息,我们希望通过配置的方式来进行处理,而不是写死在代码里。因为我们整体的模块需要通过npm发布到npm包管理中心。对于Nuxt3.0来说,nuxt.config.ts 提供了可配置化的入口。下面是一个具体案例:

openidConnect: {

    addPlugin: true,

    op: {

      issuer: 'http://192.168.26.114:8080/realms/test', // change to your OP addrress

      clientId: 'testClient',

      clientSecret: 'cnuLA78epx8s8vMbRxcaiXbzlS4u8bSA',

      // callbackUrl: 'http://192.168.26.114:3000/oidc/callback', // optional

      scope: [

        'email',

        'profile',

        'address'

      ]

    },

    config: {

      debug: true,

      response_type: 'id_token token',

      secret: 'oidc._sessionid',

      cookie: { loginName: '' },

      cookiePrefix: 'oidc._',

      cookieEncrypt: true,

      cookieEncryptKey: 'bfnuxt9c2470cb477d907b1e0917oidc',

      cookieEncryptIV: 'ab83667c72eec9e4',

      cookieEncryptALGO: 'aes-256-cbc',

      cookieMaxAge: 24 * 60 * 60, //  default one day

      cookieFlags: {

        access_token: {

          httpOnly: true,

          secure: false

        }

      }
    }
  }

在代码需要使用到这些配置可以通过useRuntimeConfig(这个方法是Nuxt3.0框架自带的)进行导入,如下导入示例:

const { op, config } = useRuntimeConfig().openidConnect

2.3.2 跳转的问题

认证服务器在完成认证后需要执行相应的回调。具体有以下三种类型:

1. query,这种方式比较容易理解,就是通过query的url进行传参,一般通过授权码模式,默认是通过这种方式返回code。这种方式有一个缺点,那就是它受制于url的长度限制。举例来说:http://192.168.26.114:3000/callback?code=SqlxlOBeZQQYbYS6WsSbIA&state=260382f7d672ef3b614a0f6427078847a650612f 这种就是query类型。

2. fragment,这种方式更加常见,相比于query模式它可以携带更多的信息,一般在隐式模式下是通过这种模式进行回调。这种模式回调的信息都包含在#之后。举例来说:http://192.168.26.114:3000/callback#access_token=AT-29760-uTN2-foER4JNB3ZQZoXfoS8ekpSPK325&token_type=bearer&expires_in=432000&id_token=eyJhbGciOiJSUzI1-UjRQUkYyFchIdn10mwcypKto2zso_ZnBx8xoiDkR03xg5cX3xHlFNGAXejfj0OrjQ2i6qRfdeIooyZjLz78PMJLuTNgAQS-SRWxcg_iVvpcuJalujWQpuUO16h1mTwpa3ki-kM011wqZ91PnWp2uylvrsUbgaoPtMkpXaN3NZMy0hu1H3Fncdp_Gx5Sw5qasQ9T-1Ejk8iAXcUqUEtV3vro8smcgMh5-ZonwQRvgtzwhRfTUn38G5tcsvJRcPEV3xGezzW5bMaMYEmi4ri_fhvz0rOYhXLpKD_nzA&nonce=31facb70-1602-42c9-a978-a3acbefd76d8

3. form_post,这种模式是oidc新增的一种返回模式,它通过返回一个html内容,html里包含一个自动提交的form表单(通过发起POST完成)。详情可参openid.net/specs/oauth…

<html>

  <head><title>Auto Submit This Form</title></head>

  <body onload="javascript:document.forms[0].submit()">

  <form method="post" action="http://192.168.26.114:3000/callback">

    <input type="hidden" name="state"

      value="260382f7d672ef3b614a0f6427078847a650612f"/>

    <input type="hidden" name="id_token"

      value="eyJhbGciOiJSUzI1NiIsImtpZCI6IjEifQ.eyJzdWIiOiJqb2huIiw

        iYXVkIjoiZmZzMiIsImp0aSI6ImhwQUI3RDBNbEo0c2YzVFR2cllxUkIiLC

        Jpc3MiOiJodHRwczpcL1wvbG9jYWxob3N0OjkwMzEiLCJpYXQiOjEzNjM5M

        DMxMTMsImV4cCI6MTM2MzkwMzcxMywibm9uY2UiOiIyVDFBZ2FlUlRHVE1B

        SnllRE1OOUlKYmdpVUciLCJhY3IiOiJ1cm46b2FzaXM6bmFtZXM6dGM6U0F

        NTDoyLjA6YWM6Y2xhc3NlczpQYXNzd29yZCIsImF1dGhfdGltZSI6MTM2Mz

        wwq-Qk7LFd3iGYeUWrfjZkmyXeKKs_OtZ2tI2QQqJpcfrpAuiNuEHII-_fk

        IufbGNT_rfHUcY3tGGKxcvZO9uvgKgX9Vs1v04UaCOUfxRjSVlumE6fWGcq

        XVEKhtPadj1elk3r4zkoNt9vjUQt9NGdm1OvaZ2ONprCErBbXf1eJb4NW_h

        nrQ5IKXuNsQ1g9ccT5DMtZSwgDFwsHMDWMPFGax5Lw6ogjwJ4AQDrhzNCFc

        0uVAwBBb772-86HpAkGWAKOK-wTC6ErRTcESRdNRe0iKb47XRXaoz5acA"/>

  </form>

  </body>

</html>

具体使用哪种回调模式可以在发起请求的时候通过response_mode这个参数进行控制。虽然如此,但实际在操作过程中很多依赖于认证服务(OP)是否实现了该协议。如果认证服务没有对这个参数协议做实现,那就只能根据具体情况进行具体分析。在我们所面临的这个场景,实际上是通过fragment方式返回的。这种方式在SSR场景会遇到一个现实问题,因为fragment方式下#后面的内容只会被客户端识别,不会传递到服务端。那如何处理这处情况。 这里,我们受form_post模式的自动提交表单的启发,可以通过一个临时过渡页完成这个跳转,也就是让认证服务器回调到这个临时过渡页,过渡页里通过js进行自动提交(跳转到我们真正的回调页)。过渡页代码如下:

<!DOCTYPE html>
<html>
<body>
  <h1>OIDC Callback Middle Page. Loading...</h1>
  <script>
    const hash = window.location.hash
    if (hash.length > 0 && hash.includes('#')) {
      if (window.location.href.includes('cbt#')) {
        window.location.replace(window.location.href.replace('cbt#', 'callback?'))
      } else if (window.location.href.includes('cbt?redirect')) {
        window.location.replace(window.location.href.replace('/cbt?', '/callback?').replace('#', "&"))
      }
    }
</script>
</body>
</html>

2.3.3 如何提高安全性

通过这个模块,我们能获取到用户的基本信息。个别信息可能会保存在Cookie里,以便后续传输。在这个过程中,为了增加数据的安全性,我们对用户信息的数据做了对称加密的操作。加解密代码实现如下:

export const encrypt = async (text: string, config: Config) => {

  const KEY = config.cookieEncryptKey

  const IV = config.cookieEncryptIV

  const ALGO = config.cookieEncryptALGO

  const NEED_ENCRYPT = config.cookieEncrypt


  if (!NEED_ENCRYPT) { return text }


  const crypto = await import('node:crypto')

  const cipher = crypto.createCipheriv(ALGO, KEY, IV)

  let encrypted = cipher.update(text, 'utf8', 'base64')

  encrypted += cipher.final('base64')

  return encrypted

}

export const decrypt = async (text: string, config: Config) => {

  const KEY = config.cookieEncryptKey

  const IV = config.cookieEncryptIV

  const ALGO = config.cookieEncryptALGO

  const NEED_ENCRYPT = config.cookieEncrypt

  if (!text) { return }

  if (!NEED_ENCRYPT) { return text }

  const crypto = await import('node:crypto')

  const decipher = crypto.createDecipheriv(ALGO, KEY, IV)
  const decrypted = decipher.update(text, 'base64', 'utf8')
  return (decrypted + decipher.final('utf8'))
}

如下图所示,是加密后的用户信息:

640 (1).png

3 小结

通过实现Nuxt3.0的登录授权模块过程,我们从中可以学习到以下几点: 

  1. 当我们想解决一个未知领域的问题时,首先我们需要对其基本的原理有简单的了解。

  2. 通过横向对比来学习其他人的解决思路。虽然Nuxt3.0没有实现这个功能,但Nuxt 2.0有类似的模块,我们可以做相应的参考,理解大体的实现思路。

  3. 实际实践过程中,可能会遇到很多细节上的问题,在这个过程中,需要不断实践和反思,只要我们肯花心思在这上面,任何的技术问题都能得到解决。

npm仓库里的模块名为:nuxt-openid-connect


封面图来自 Unsplash (拍摄者: ilya)

--- END ---


wxg.JPG