Vue + Volo.Abp 实现OAuth2.0客户端授权模式认证

Volo.Abp的身份服务器模块默认使用 IdentityServer4实现身份认证。

IdentityServer4是一个开源的OpenID Connect和OAuth 2.0框架,它实现了这些规范中的所有必需功能。

OAuth 2.0支持多种认证模式,本文主要介绍客户端授权模式认证。客户端授权模式流程如下图所示:

在这里插入图片描述

(A)用户访问客户端,后者将前者导向认证服务器。

(B)用户选择是否给予客户端授权。

(C)假设用户给予授权,认证服务器将用户导向客户端事先指定的”重定向URI”(redirection URI),同时附上一个授权码。

(D)客户端收到授权码,附上早先的”重定向URI”,向认证服务器申请令牌。这一步是在客户端的后台的服务器上完成的,对用户不可见。

(E)认证服务器核对了授权码和重定向URI,确认无误后,向客户端发送访问令牌(access token)和更新令牌(refresh token)。

更多关于OAuth 2.0的介绍,可以参考阮一峰的理解OAuth 2.0

注册Client

Client是第三方应用,它获得资源所有者的授权后便可以去访问资源。通常第三方登录中的第三方应用就是Client。如微博登录、QQ登录等。

要使用OAuth API需要先注册Client。通常,授权服务器需要提供一个管理页面,其中设置第三方应用的信息,包括应用名称、应用网站地址、应用简介、应用logo、授权回调地址等。

在本示例中,Client的注册是通过SeedData实现的。SeedData是在应用启动时自动执行的,它可以用来初始化数据库,如创建初始用户、角色、权限、和创建Client。

使用Abp.Cli创建一个认证服务分离的项目,

在认证服务项目AuthServer中打开appsettings.json,将vue的地址(http://localhost:8081)配置到ClientUrl和RedirectAllowedUrls

1
2
3
4
5
6
7
"App": {
"SelfUrl": "https://localhost:44350",
"ClientUrl": "http://localhost:8081",
"CorsOrigins": "https://*.Matoapp.com,https://localhost:44328,https://localhost:44377,http://localhost:8081,http://localhost:8082,http://localhost:8083",
"RedirectAllowedUrls": "http://localhost:8081/continue,https://localhost:44380,https://localhost:44328,https://localhost:44369"
},

项目中已经包含了一个SeedData类和一些配置,我们需要理解并修改这些配置。

我们将创建一个名为Matoapp的Client

appsettings.json配置如下

1
2
3
4
5
6
7
8
9
"OpenIddict": {
"Applications": {
"Matoapp_App": {
"ClientId": "Matoapp_App",
"RootUrl": "http://localhost:8081"
},
...
}
}

在OpenIddictDataSeedContributor中配置ClientType为public

grantTypes为authorization_codepassword

scopes为该项目的服务名称以及需要的额外userinfo信息;

redirectUri客户端的回调地址。设置为http://localhost:8081/continue

完整的CreateApplicationsAsync方法如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
private async Task CreateApplicationsAsync()
{
var commonScopes = new List<string>
{
OpenIddictConstants.Permissions.Scopes.Address,
OpenIddictConstants.Permissions.Scopes.Email,
OpenIddictConstants.Permissions.Scopes.Phone,
OpenIddictConstants.Permissions.Scopes.Profile,
OpenIddictConstants.Permissions.Scopes.Roles,
"Matoapp",
};

var configurationSection = _configuration.GetSection("OpenIddict:Applications");



//Console Test / Angular Client
var consoleAndAngularClientId = configurationSection["Matoapp_App:ClientId"];
if (!consoleAndAngularClientId.IsNullOrWhiteSpace())
{
var consoleAndAngularClientRootUrl = configurationSection["Matoapp_App:RootUrl"]?.TrimEnd('/');
await CreateApplicationAsync(
name: consoleAndAngularClientId!,
type: OpenIddictConstants.ClientTypes.Public,
consentType: OpenIddictConstants.ConsentTypes.Implicit,
displayName: "Console Test / Angular Application",
secret: null,
grantTypes: new List<string>
{
OpenIddictConstants.GrantTypes.AuthorizationCode,
OpenIddictConstants.GrantTypes.Password,
},
scopes: commonScopes,
redirectUri: $"{consoleAndAngularClientRootUrl}/continue",
clientUri: consoleAndAngularClientRootUrl,
postLogoutRedirectUri: consoleAndAngularClientRootUrl,
permissions: new List<string> {
OpenIddictConstants.Permissions.Scopes.Roles,
OpenIddictConstants.Permissions.Scopes.Profile,
OpenIddictConstants.Permissions.Scopes.Email,
OpenIddictConstants.Permissions.Scopes.Address,
OpenIddictConstants.Permissions.Scopes.Phone,
"Matoapp",
}
);
}
...
}


OAuth2.0授权

我们将使用oidc-client-ts简化Vue中的OAuth2.0授权。有关oidc-client-ts的更多信息,请参阅oidc-client-ts

创建一个vue项目,前端使用element-ui

安装oidc-client-ts

1
yarn add oidc-client-ts

创建vue-oidc-client

创建vue-oidc-client.ts文件,编写代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
// vue 2 version
import Router from 'vue-router'
import Vue from 'vue'
import {
UserManagerSettings,
Log,
User,
UserManager,
UserProfile,
WebStorageStateStore,
UserManagerEvents
} from 'oidc-client-ts'

/**
* Indicates the sign in behavior.
*/
export enum SignInType {
/**
* Uses the main browser window to do sign-in.
*/
Window,
/**
* Uses a popup window to do sign-in.
*/
Popup
}

/**
* Logging level values used by createOidcAuth().
*/
export enum LogLevel {
/**
* No logs messages.
*/
None = 0,
/**
* Only error messages.
*/
Error = 1,
/**
* Error and warning messages.
*/
Warn = 2,
/**
* Error, warning, and info messages.
*/
Info = 3,
/**
* Everything.
*/
Debug = 4
}



/**
* Creates an openid-connect auth instance.
* @param authName - short alpha-numeric name that identifies the auth instance for routing purposes.
* This is used to generate default redirect urls (slugified) and identifying routes that needs auth.
* @param defaultSignInType - the signin behavior when `signIn()` and `signOut()` are called.
* @param appUrl - url to the app using this instance for routing purposes. Something like `https://domain/app/`.
* @param oidcConfig - config object for oidc-client.
* See https://github.com/IdentityModel/oidc-client-js/wiki#configuration for details.
* @param logger - logger used by oidc-client. Defaults to console.
* @param logLevel - minimum level to log. Defaults to LogLevel.Error.
*/
export function createOidcAuth(
authName: string,
defaultSignInType: SignInType,
appUrl: string,
oidcConfig: UserManagerSettings,
) {
// arg check
if (!authName) {
throw new Error('Auth name is required.')
}
if (
defaultSignInType !== SignInType.Window &&
defaultSignInType !== SignInType.Popup
) {
throw new Error('Only window or popup are valid default signin types.')
}
if (!appUrl) {
throw new Error('App base url is required.')
}
if (!oidcConfig) {
throw new Error('No config provided to oidc auth.')
}



const nameSlug = slugify(authName)

// merge passed oidcConfig with defaults
const config = {
automaticSilentRenew: true,
userStore: new WebStorageStateStore({
store: sessionStorage
}),
...oidcConfig // everything can be overridden!
}

const mgr = new UserManager(config)

let _inited = false
const auth = new Vue({
data() {
return {
user: null as User | null,
myRouter: null as Router | null
}
},
computed: {
appUrl(): string {
return appUrl
},
authName(): string {
return authName
},
isAuthenticated(): boolean {
return !!this.user && !this.user.expired
},
accessToken(): string {
return !!this.user && !this.user.expired ? this.user.access_token : ''
},
userProfile(): UserProfile {
return !!this.user && !this.user.expired
? this.user.profile
: {
iss: '',
sub: '',
aud: '',
exp: 0,
iat: 0
}
},
events(): UserManagerEvents {
return mgr.events
}
},

methods: {
startup() {
let isCB = false // CB = callback
if (matchesPath(config.popup_redirect_uri)) {
mgr.signinPopupCallback()
isCB = true
} else if (matchesPath(config.silent_redirect_uri)) {
mgr.signinSilentCallback()
isCB = true
} else if (matchesPath(config.popup_post_logout_redirect_uri)) {
mgr.signoutPopupCallback()
isCB = true
}
if (isCB) return Promise.resolve(false)

if (_inited) {
return Promise.resolve(true)
} else {
// load user from storage
return mgr
.getUser()
.then(test => {
_inited = true
if (test && !test.expired) {
this.user = test
}
return true
})
.catch(err => {
return false
})
}
},
signIn(args?: any) {
return signInReal(defaultSignInType, args)
},
signOut(args?: any) {
if (defaultSignInType === SignInType.Popup) {
const router = this.myRouter
return mgr
.signoutPopup(args)
.then(() => {
redirectAfterSignout(router)
})
.catch(() => {
// could be window closed
redirectAfterSignout(router)
})
}
return mgr.signoutRedirect(args)
},
startSilentRenew() {
mgr.startSilentRenew()
},
stopSilentRenew() {
mgr.stopSilentRenew()
}
}
})

function signInIfNecessary() {
if (auth.myRouter) {
const current = auth.myRouter.currentRoute
if (current && current.meta.authName === authName) {
signInReal(defaultSignInType, { state: { current } })
.then(() => {
// auth.myRouter()
})
.catch(() => {
setTimeout(signInIfNecessary, 5000)
})
// window.location.reload();
// auth.myRouter.go(); //replace('/');
}
}
}

function signInReal(type: SignInType, args?: any) {
switch (type) {
case SignInType.Popup:
return mgr.signinPopup(args)
// case SignInType.Silent:
// return mgr.signinSilent(args)
}
return mgr.signinRedirect(args)
}

function redirectAfterSignout(router: Router | null) {
if (router) {
const current = router.currentRoute
if (current && current.meta.authName === authName) {
router.replace('/')
return
}
}
// window.location.reload(true);
if (appUrl) window.location.href = appUrl
}

/**
* Translates user manager events to vue events and perform default actions
* if necessary.
*/
function handleManagerEvents() {
mgr.events.addUserLoaded(user => {
auth.user = user
})

mgr.events.addUserUnloaded(() => {
auth.user = null

// redirect if on protected route (best method here?)
// signInIfNecessary()
})

mgr.events.addAccessTokenExpired(() => {
auth.user = null
signInIfNecessary()
// if (auth.isAuthenticated) {
// mgr
// .signinSilent()
// .then(() => {
// Log.debug(`${authName} auth silent signin after token expiration`)
// })
// .catch(() => {
// Log.debug(
// `${authName} auth silent signin error after token expiration`
// )
// signInIfNecessary()
// })
// }
})

mgr.events.addSilentRenewError(e => {
// TODO: need to restart renew manually?
if (auth.isAuthenticated) {
setTimeout(() => {
mgr.signinSilent()
}, 5000)
} else {
signInIfNecessary()
}
})

mgr.events.addUserSignedOut(() => {
auth.user = null
signInIfNecessary()
})
}

handleManagerEvents()
return auth
}

// general utilities

/**
* Gets the path portion of a url.
* @param url - full url
* @returns
*/
function getUrlPath(url: string) {
const a = document.createElement('a')
a.href = url
let p = a.pathname
if (p[0] !== '/') p = '/' + p
return p
}

/**
* Checks if current url's path matches given url's path.
* @param {String} testUrl - url to test against.
*/
function matchesPath(testUrl: string) {
return (
window.location.pathname.toLocaleLowerCase() ===
getUrlPath(testUrl).toLocaleLowerCase()
)
}

function slugify(str: string) {
str = str.replace(/^\s+|\s+$/g, '') // trim
str = str.toLowerCase()

// remove accents, swap ñ for n, etc
const from = 'ãàáäâẽèéëêìíïîõòóöôùúüûñç·/_,:;'
const to = 'aaaaaeeeeeiiiiooooouuuunc------'
for (let i = 0, l = from.length; i < l; i++) {
str = str.replace(new RegExp(from.charAt(i), 'g'), to.charAt(i))
}

str = str
.replace(/[^a-z0-9 -]/g, '') // remove invalid chars
.replace(/\s+/g, '-') // collapse whitespace and replace by -
.replace(/-+/g, '-') // collapse dashes

return str
}

这里基于vue-oidc-client进行修改,因为vue-oidc-client是基于oidc-client,而oidc-client已经不再维护,所以我们使用oidc-client-ts

创建OAuth2.0认证跳转

在登录页面login/index.vue中,添加OAuth2.0认证跳转按钮

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
<el-row
type="flex"
class="row-bg"
justify="center"
:gutter="10"
>
<el-col :span="10">
<el-button
:loading="loading"
type="primary"
@click.native.prevent="handleLogin"
>
登录
</el-button>
</el-col>
<el-col :span="10">
<el-button
:loading="loading"
@click.native.prevent="handleLoginAuth2"
>
Auth2.0
</el-button>
</el-col>
</el-row>
</el-form>

在这里插入图片描述

点击跳转后,自动跳转到认证服务器的登录页面

在这里插入图片描述

当正确输入用户名和密码后,跳转到客户端的回调地址,并携带授权码code参数

环境变量中配置CLIENT_ID,资源服务器地址和认证服务器地址

1
2
3
VUE_APP_BASE_API = 'https://localhost:44377/'
VUE_APP_BASE_IDENTITY_SERVER = 'https://localhost:44350/'
VUE_APP_CLIENT_ID = 'Matoapp_App'

在login/index.vue中添加handleLoginAuth2方法

注意此处的redirect_uri要和认证服务器中的配置一致,否则跳转时会引发400 Bad Request

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
async handleLoginAuth2() {
var loco = window.location;
var appRootUrl = `${loco.protocol}//${loco.host}`;
var idsrvAuth = createOidcAuth("main", SignInType.Window, appRootUrl, {
authority: baseUrl,
client_id: "Matoapp_App", // 'implicit.shortlived',
response_type: "code",
scope: "Matoapp",
// test use
prompt: "login",
metadata: {
issuer: baseUrl,
authorization_endpoint: `${baseUrl}connect/authorize`,
end_session_endpoint: `${baseUrl}logout`,
token_endpoint: `${baseUrl}connect/token`,
},
redirect_uri: appRootUrl + "/continue",
disablePKCE: true,
});
await idsrvAuth.signIn();
}

获取令牌

获取完成授权码后,需要通过授权码获取令牌(Token),此处将调用后端的接口,认证服务器核对了授权码和重定向URI后发放令牌

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
function Login(data, baseURL?: string) {
return await ajaxRequest('/connect/token', 'POST', data, "/", baseURL)
}


@Action
public async LoginByCode(codeInfo: {
code: string
redirect_uri: string
}) {
var code = codeInfo.code;
var redirect_uri = codeInfo.redirect_uri;
var data = null;
var baseUrl = process.env.VUE_APP_BASE_IDENTITY_SERVER;
await Login({
grant_type: 'authorization_code',
code: code,
client_id: process.env.VUE_APP_CLIENT_ID,
redirect_uri: redirect_uri
}, baseUrl)
.then(async (res) => {
data = res;
setToken(data.access_token);
this.SET_TOKEN(data.access_token);
return data
})
return data
}

获取Token后将其保存到vuex或Cookies中

创建回调页面

回调页面是在登录成功后,从回调到登录完成的过渡页面。

创建continue/index.vue,简单的显示登录成功的提示,常用的提示有“登录成功,正在为您继续”,“登录成功,正在为您跳转”等友好提示

1
2
3
4
5
6
7
8
9
10
11

<template>
<div class="login-container">
<el-result
icon="success"
title="登录成功"
subTitle="正在继续,请稍候.."
></el-result>
</div>
</template>

在这里插入图片描述

在登录成功后,会跳转到continue页面,回调地址会携带授权码,然后调用认证服务器的connect/token接口,获取token

创建onRouteChange函数,在此解析页面地址中的参数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
export default class extends BaseVue {
redirect?: string;
otherQuery: Dictionary<string> = {};

@Watch("$route", { immediate: true })
private async onRouteChange(route: Route) {
var loco = window.location;
var appRootUrl = `${loco.protocol}//${loco.host}`;
var query = route.query as Dictionary<string>;
if (query) {
this.redirect = query.redirect;
this.otherQuery = this.getOtherQuery(query);
if (this.otherQuery.code) {
await UserModule.LoginByCode({
code: this.otherQuery.code,
redirect_uri: appRootUrl + "/continue",
}).then(async (re) => {
GetCurrentUserInfo(baseUrl)
.then((re) => {
var result = re as any;
this.afterLoginSuccess(result);
})
.catch((err) => {
console.warn(err);
});
});
}
}
}

成功后将跳转到首页或者redirect指定的页面

1
2
3
4
5
6
7
8
9
10
11
12
async afterLoginSuccess(userinfo) {
this.$router
.push({
// path: this.redirect || "/",
path: "/",
query: this.otherQuery,
})
.catch((err) => {
console.warn(err);
});
}

创建退出登录

只需要清除vuex或Cookies中的token即可,可以调用vue-oidc-client的signOut,但只是跳转到配置的登出地址,不会清除token(前提是redirectAfterSignout为true,并设置了post_logout_redirect_uri)

最终效果

在这里插入图片描述

Vue + Volo.Abp 实现OAuth2.0客户端授权模式认证

https://blog.matoapp.net/posts/a1a42/

作者

林晓lx

发布于

2023-07-07

更新于

2024-09-11

许可协议

评论