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的协议,主要分为以下两部:
-
Client端向授权服务器(本文称为OP,即OIDC Provider)发起授权请求,请求带上 clientId 和 clientSecret 两个参数,OP验证其合法性。然后用户跳转到登录界面,登录成功后,返回给client的信息有id_token和accessToken。
-
Client端拿到id_token后进行验证,验证通过后采用accessToken调用OP接口获取登录的用户信息(如用户名,用户id等)。
在这里我们还得简单介绍下auth2.0的四种授权许可模式:
-
授权码模式(Authorization Code) 这种方式第一步是请求OP获得授权码;client拿到授权码后再向OP发起请求,获取到accessToken;最后通过accessToken获取到登录的用户信息。
-
隐式模式(Implicit) 这种模式是授权码模式的简化版,也是大多数内部的oidc服务采用的模式。它省略了获取授权码的这一步,直接通过clientId 和 clientSecret 获取用户的accessToken;后续步骤同授权码模式。
-
密码授权模式(Resource Owner Password Credentials Grant) 这种模式进一步做了简化,不再需要跳转到登录密码的页面,而是一次性把密码和用户名,clientId等信息传给Resource server。这种模式一般适用于Resource server高度信任第三方Client的情况下。
-
客户端认证授权(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登录模块就是作者实现的授权登录模块:
如上图,我们需要实现OIDC登录模块(红色部分),其核心有以下几步:
-
在 /oidc/login 接口里需要构建请求参数,向OP发起授权请求;
-
接收 id_token 及 accessToken 信息,对id_token信息进行验证,通过 accessToken 信息获取用户的信息;
-
对获取到的用户信息加密后返回给浏览器的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 回调处理
回调处理的代码如下:
-
这里处理了response_mode为post_form的方式,见req.method === 'POST' 。
-
支持授权码模式和隐式模式两种方式。
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 回调处理
回调处理的代码如下:
-
这里处理了response_mode为post_form的方式,见req.method === 'POST' 。
-
支持授权码模式和隐式模式两种方式。
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 基础信息可动态配置
对于模块调用授权过程中使用到的clientId、issuer、clientSecret等字段信息,我们希望通过配置的方式来进行处理,而不是写死在代码里。因为我们整体的模块需要通过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'))
}
如下图所示,是加密后的用户信息:
3 小结
通过实现Nuxt3.0的登录授权模块过程,我们从中可以学习到以下几点:
-
当我们想解决一个未知领域的问题时,首先我们需要对其基本的原理有简单的了解。
-
通过横向对比来学习其他人的解决思路。虽然Nuxt3.0没有实现这个功能,但Nuxt 2.0有类似的模块,我们可以做相应的参考,理解大体的实现思路。
-
实际实践过程中,可能会遇到很多细节上的问题,在这个过程中,需要不断实践和反思,只要我们肯花心思在这上面,任何的技术问题都能得到解决。
npm仓库里的模块名为:nuxt-openid-connect 。
封面图来自 Unsplash (拍摄者: ilya)
--- END ---