使用 IdentityServer 保护 Vue 前端
前情提要
《使用 IdentityServer 保护 Web 应用(AntD Pro 前端 + SpringBoot 后端)》中记录了使用 IdentityServer 保护前后端的过程,其中的前端工程是以 UMI Js 为例。今天,再来记录一下使用 IdentityServer 保护 Vue 前端的过程,和 UMI Js 项目使用 umi plugin 的方式不同,本文没有使用 Vue 相关的插件,而是直接使用了 oidc-client js。
另外,我对 Vue 这个框架非常不熟,在 vue-router 这里稍微卡住了一段时间,后来瞎试居然又成功了。针对这个问题,我还去 StackOverflow 上问了,但并没有收到有效的回复:https://stackoverflow.com/questions/74769607/how-to-access-vues-methods-from-navigation-guard
准备工作
首先,需要在 IdentityServer 服务器端注册该 Vue 前端应用,仍然以代码写死这个客户端为例:
new Client
{
ClientId = "vue-client",
ClientSecrets = { new Secret("vue-client".Sha256()) },
ClientName = "vue client",
AllowedGrantTypes = GrantTypes.Implicit,
AllowAccessTokensViaBrowser = true,
RequireClientSecret = false,
RequirePkce = true,
RedirectUris =
{
//localhost:8080/callback", :
//localhost:8080/static/silent-renew.html", :
},
AllowedCorsOrigins = { "http://localhost:8080" },
AllowedScopes = { "openid", "profile", "email" },
AllowOfflineAccess = true,
AccessTokenLifetime = 90,
AbsoluteRefreshTokenLifetime = 0,
RefreshTokenUsage = TokenUsage.OneTimeOnly,
RefreshTokenExpiration = TokenExpiration.Sliding,
UpdateAccessTokenClaimsOnRefresh = true,
RequireConsent = false,
};
在 Vue 工程里安装 oidc-client
yarn add oidc-client
在 Vue 里配置 IdentityServer 服务器信息
在项目里添加一个 src/security/security.js
文件:
import Oidc from 'oidc-client'
function getIdPUrl() {
return "https://id6.azurewebsites.net";
}
Oidc.Log.logger = console;
Oidc.Log.level = Oidc.Log.DEBUG;
const mgr = new Oidc.UserManager({
authority: getIdPUrl(),
client_id: 'vue-client',
redirect_uri: window.location.origin + '/callback',
response_type: 'id_token token',
scope: 'openid profile email',
post_logout_redirect_uri: window.location.origin + '/logout',
userStore: new Oidc.WebStorageStateStore({store: window.localStorage}),
automaticSilentRenew: true,
silent_redirect_uri: window.location.origin + '/silent-renew.html',
accessTokenExpiringNotificationTime: 10,
})
export default mgr
在 main.js 里注入登录相关的数据和方法
数据
不借助任何状态管理包,直接将相关的数据添加到 Vue 的 app 对象上:
import mgr from "@/security/security";
const globalData = {
isAuthenticated: false,
user: '',
mgr: mgr
}
方法
const globalMethods = {
async authenticate(returnPath) {
console.log('authenticate')
const user = await this.$root.getUser();
if (user) {
this.isAuthenticated = true;
this.user = user
} else {
await this.$root.signIn(returnPath)
}
},
async getUser() {
try {
return await this.mgr.getUser();
} catch (err) {
console.error(err);
}
},
signIn(returnPath) {
returnPath ? this.mgr.signinRedirect({state: returnPath}) : this.mgr.signinRedirect();
}
}
修改 Vue 的实例化代码
new Vue({
router,
data: globalData,
methods: globalMethods,
render: h => h(App),
}).$mount('#app')
修改 router
在 src/router/index.js
中,给需要登录的路由添加 meta 字段:
Vue.use(VueRouter)
const router = new VueRouter({
{
path: '/private',
name: 'private page',
component: resolve => require(['@/pages/private.vue'], resolve),
meta: {
requiresAuth: true
}
}
});
export default router
接着,正如在配置中体现出来的,需要一个回调页面来接收登录后的授权信息,这可以通过添加一个 src/views/CallbackPage.vue
文件来实现:
<template>
<div>
<p>Sign-in in progress... 正在登录中……</p>
</div>
</template>
<script>
export default {
async created() {
try {
const result = await this.$root.mgr.signinRedirectCallback();
const returnUrl = result.state ?? '/';
await this.$router.push({path: returnUrl})
}catch(e){
await this.$router.push({name: 'Unauthorized'})
}
}
}
</script>
然后,需要在路由里配置好这个回调页面:
import CallbackPage from "@/views/CallbackPage.vue";
Vue.use(VueRouter)
const router = new VueRouter({
routes: {
path: '/private',
name: 'private page',
component: resolve => require(['@/pages/private.vue'], resolve),
meta: {
requiresAuth: true
}
},
{
path: '/callback',
name: 'callback',
component: CallbackPage
}
});
export default router
同时,在这个 router 里添加一个所谓的“全局前置守卫”(https://router.vuejs.org/zh/guide/advanced/navigation-guards.html#%E5%85%A8%E5%B1%80%E5%89%8D%E7%BD%AE%E5%AE%88%E5%8D%AB),注意就是这里,我碰到了问题,并且在 StackOverflow 上提了这个问题。在需要调用前面定义的认证方法时,不能使用 router.app.authenticate
,而要使用 router.apps[1].authenticate
,这是我通过 inspect router
发现的:
...
router.beforeEach(async function (to, from, next) {
let app = router.app.$data || {isAuthenticated: false}
if(app.isAuthenticated) {
next()
} else if (to.matched.some(record => record.meta.requiresAuth)) {
router.apps[1].authenticate(to.path).then(()=>{
next()
})
}else {
next()
}
})
export default router
到了这一步,应用就可以跑起来了,在访问 /private 时,浏览器会跳转到 IdentityServer 服务器的登录页面,在登录完成后再跳转回来。
添加 silent-renew.html
注意 security.js
,我们启用了 automaticSilentRenew
,并且配置了 silent_redirect_uri
的路径为 silent-renew.html
。它是一个独立的引用了 oidc-client js 的 html 文件,不依赖 Vue,这样方便移植到任何前端项目。
oidc-client.min.js
首先,将我们安装好的 oidc-client 包下的 node_modules/oidc-client/dist/oidc-client.min.js
文件,复制粘贴到 public/static
目录下。
然后,在这个目录下添加 public/static/silent-renew.html
文件。
<html>
<head>
<title>Silent Renew Token</title>
</head>
<body>
<script src='oidc-client.min.js'></script>
<script>
console.log('renewing tokens');
new Oidc.UserManager({userStore: new Oidc.WebStorageStateStore({ store: window.localStorage })})
.signinSilentCallback();
</script>
</body>
</html>
给 API 请求添加认证头
最后,给 API 请求添加上认证头。前提是,后端接口也使用同样的 IdentityServer 来保护(如果是 SpringBoot 项目,可以参考《[使用 IdentityServer 保护 Web 应用(AntD Pro 前端 + SpringBoot 后端) - Jeff Tian的文章 - 知乎](https://zhuanlan.zhihu.com/p/533197284) 》);否则,如果 API 是公开的,就不需要这一步了。
对于使用 axios 的 API 客户端,可以利用其 request interceptors
,来统一添加这个认证头,比如:
import router from '../router'
import Vue from "vue";
const v = new Vue({router})
const service = axios.create({
// 公共接口--这里注意后面会讲
baseURL: process.env.BASE_API,
// 超时时间 单位是ms,这里设置了3s的超时时间
timeout: 20 * 1000
});
service.interceptors.request.use(config => {
const user = v.$root.user;
if(user) {
const authToken = user.access_token;
if(authToken){
config.headers.Authorization = `Bearer ${authToken}`;
}
}
return config;
}, Promise.reject)
export default service