OAuth 2.0 隐含式 - Implicit

OAuth 2.0 隐含式 - Implicit

上一篇 OAuth 2.0 客户端凭证模式 的文章中使用 .NET 5 实现了客户端凭证模式,这篇文章中将将继续使用 .NET 5 实现 OAuth 2.0 隐含式( Implicit ),隐含模式现在已经不推荐使用了,应该使用安全度更高的 Authorization Code Flow 或者 Authorization Code Flow With PKCE,现在只适合于一些对安全性要求不高的应用。

分析


隐含式通过跳转到授权中心进行用户授权,用户授权结束后,通过 Redirection URLAccess Token 返回到客户端托管服务器,客户端托管服务器解析 URL 回调以后传递给客户端,隐含模式适合对安全性要求不高的 SPA(Single Page Application)应用,这种方式省略了授权码(Authorization Code)这个中间步骤。

流程图

调用链

实践


● 开发工具和环境



● 创建授权服务器(Authorization Server)

打开 Windows Terminal,先安装 IdentityServer4 Template 的项目模板(因为这一章开始需要用到授权界面,为了方便所以直接使用 IdentityServer4 模板,后面会写一章如何实现自己的授权中心认证页面)

dotnet new -i identityserver4.templates

等待模板安装完成,完成以后输入如下命令创建一个具有 GUIWeb 授权中心项目

dotnet new is4inmem -n Your-Project-Name

项目创建结束以后,先修改根目录的 Config.cs 类,修改如下:

Config.cs
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
using IdentityServer4.Models;
using System.Collections.Generic;
using IdentityServer4.Test;

namespace AuthorizationServer
{
public static class Config
{
//可以访问的授权资源
public static IEnumerable<IdentityResource> IdentityResources =>
new IdentityResource[]
{
//开放ID
new IdentityResources.OpenId(),
//个人资料
new IdentityResources.Profile(),
};

//描述提供外部选择的作用域
public static IEnumerable<ApiScope> ApiScopes =>
new ApiScope[]
{
new ApiScope("scope1")
};

//定义客户端支持的访问规则和加密方式
public static IEnumerable<Client> Clients =>
new Client[]
{
new Client
{
//客户端ID
ClientId = "vue-client",
//客户端名称
ClientName = "Vue SPA Client",
//客户端URL(稍后会使用 Vue 作为客户端,默认8080)
ClientUri = "http://localhost:8080",
//授权模式 - 隐含式
AllowedGrantTypes = GrantTypes.Implicit,
//AccessToken 是否可以通过浏览器返回
AllowAccessTokensViaBrowser = true,
//是否需要用户点击同意(也就是登陆以后需要点击我允许xxx应用访问我的数据)
RequireConsent = true,
//重定向Url
RedirectUris =
{
// 指定登录成功跳转回来的 Url
"http://localhost:8080/signin-oidc",
// AccessToken 有效期比较短,刷新 AccessToken 的页面
"http://localhost:8080/redirect-silent-renew"
},
//退出登录跳转的页面
PostLogoutRedirectUris = { "http://localhost:8080/" },
//允许跨域的源(因为等会的 Vue 项目和咱们的授权服务器不在一个域上)
AllowedCorsOrigins = { "http://localhost:8080" },
//允许访问的作用域
AllowedScopes = { "openid", "profile", "scope1" }
}
};

//测试账户(生产环境应该指定 DbContext 以及 IResourceOwnerPasswordValidator 从数据库绑定)
public static List<TestUser> TestUsers =>
new List<TestUser>
{
new TestUser
{
SubjectId = "1",
Username = "test",
Password = "123456789"
}
};
}
}

然后修改 Startup.cs,把配置注入进去:

Startup.cs -> ConfigureServices
1
2
3
4
5
6
7
8
9
10
11
12
13
public void ConfigureServices(IServiceCollection services)
{
...
//添加授权资源
builder.AddInMemoryIdentityResources(Config.IdentityResources);
//添加作用域
builder.AddInMemoryApiScopes(Config.ApiScopes);
//添加客户端验证配置
builder.AddInMemoryClients(Config.Clients);
//添加测试用户
builder.AddTestUsers(Config.TestUsers);
...
}

到这里授权服务器就完成了,可以验证一下授权服务器的状态,使用 Postman 请求获取已知的配置,不过在此之前,先要添加 Dotnet Core Https 的开发证书,这样请求 https 的时候才不会报 The SSL connection could not be established 的错误,在授权服务器的根目录执行如下命令信任此服务证书:

dotnet dev-certs https --trust

然后在控制台输入运行命令(启动以后会自动生成一个 tempkey.jwk 文件,这是自动创建的临时凭证)

dotnet run

授权服务器启动以后,使用 Postman 发送一个 Get 请求验证一下服务器状态:

https://localhost:5001/.well-known/openid-configuration



● 创建资源服务器(Resource Server)

在授权服务器的同级目录下,创建一个资源服务器,打开 Windows Terminal,然后输入如下命令创建一个 ASP.NET Core Web API 应用

dotnet new webapi -n Your-Project-Name

创建结束后,打开项目,会看到模板自动创建了一个默认的 API,这个 API 随机返回一些天气数据。

就用这个 API 来做演示,但是简单修改一下方便后面演示做对比,如下:

WeatherForecastController.cs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Authorization;

namespace ResourceServer.Controllers
{
[ApiController]
[Route("[controller]/{action}")]
public class WeatherForecastController : ControllerBase
{
[HttpGet]
public IActionResult TestPublic()
{
return Ok(new {data="Hi, this is from public api message"});
}

[HttpGet]
[Authorize]
public IActionResult TestProtected()
{
return Ok(new {data="Hi, this is from protected api message"});
}
}
}

因为模板创建出来的都是 https:5001http:5000,等一下需要同时运行授权服务器和资源服务器,不修改的话就会冲突,打开目录文件 Properties -> launchSettings.json,修改 profiles -> ResourceServer -> applicationUrl

1
"applicationUrl": "https://localhost:6001;http://localhost:6000",

我这里修改为了 https:6001http:6000,用命令把服务跑起来,然后用 Postman 请求一下

https://localhost:6001/weatherforecast/testpublic

>>> 为了方便使用了上次的图,图片中的URL是错误的,其他都正常 <<<

请求没有问题,因为还没有注入身份验证,所以 API 是未受保护的状态,不需要任何认证就会响应,接下来开始注入身份认证,打开 Startup.cs,在 ConfigureServices 方法中添加如下:

Startup.cs -> ConfigureServices
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
...
services.AddAuthorization();
//添加默认的 Authorization 承载头
services.AddAuthentication("Bearer")
.AddJwtBearer("Bearer", options =>
{
//授权中心地址
options.Authority = "https://localhost:5001";
//是否必须为 https
options.RequireHttpsMetadata = false;
//授权中心定义的资源名称
options.Audience = "scope1";
//验证客户端发过来的 Token 参数超时时间
options.TokenValidationParameters.ClockSkew = TimeSpan.FromMinutes(1);
//要求 Token 需要有超时时间这个参数
options.TokenValidationParameters.RequireExpirationTime = true;
//不校验 Audience
options.TokenValidationParameters.ValidateAudience = false;
});

//配置 CORS 跨域设置
services.AddCors(options =>
{
//规则名称
options.AddPolicy("VueClientOrigin",
//vue客户端地址
builder => builder.WithOrigins("http://localhost:8080")
//允许所有请求头
.AllowAnyHeader()
//允许所有方法
.AllowAnyMethod());
});
...

然后在 Configure 方法中启用身份验证和允许跨域的规则(UseAuthentication 必须在 UseAuthorization 之前):

Startup.cs -> Configure
1
2
3
4
5
...
app.UseCors("VueClientOrigin");
app.UseAuthentication();
app.UseAuthorization();
...


● 创建客户端(Client Application)

打开 Windows Termianl,输入如下命令:

vue create vue-client

手动选择功能,第三个

因为用 TypeScript 写,以及 Router 定义路由,所以需要选择这两项

选择 TypeScript 和 Router

后面的 Vue 模板选项就看个人习惯了,项目创建结束后,安装这几个模块:

使用如下命令进行安装(axios 依赖 Promise 所以不需要额外安装):

yarn add oidc-client axios bootstrap-vue bootstrap-icons-vue -s

安装 bootstrap 的目的是为了让写出来的 Demo 更好看,安装结束后,咱们就开始正式进入客户端的编写,首先创建一个 connect 文件夹,里头创建一个 openid-connect-service.ts 服务用于连接授权服务器。

openid-connect-service.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
import { UserManager, User } from 'oidc-client';

const vueBase = 'http://localhost:8080';

const openIdConnectSettings = {
//授权服务器地址
authority: 'https://localhost:5001',
//授权服务器配置的客户端名称
client_id: `vue-client`,
//登陆成功的重定向URL
redirect_uri: `${vueBase}/signin-oidc`,
//退出登录的重定向URL
post_logout_redirect_uri: `${vueBase}/`,
//刷新令牌的URL
silent_redirect_uri: `${vueBase}/redirect-silent-renew`,
//可访问的作用域
scope: 'openid profile scope1',
//响应类型
response_type: `id_token token`,
//自动无感刷新令牌
automaticSilentRenew: true,
};

//单例模式
export class OpenIdConnectService {
public static getInstance(): OpenIdConnectService {
if (!this.instance) {
this.instance = new OpenIdConnectService();
}
return this.instance;
}

private static instance: OpenIdConnectService;

//配置连接的授权服务器
private userManager = new UserManager(openIdConnectSettings);

//当前用户
private currentUser!: User | undefined;

private constructor() {
this.userManager.clearStaleState();

this.userManager
.getUser()
.then((user) => {
if (user) {
this.currentUser = user;
} else {
this.currentUser = undefined;
}
})
.catch((err) => {
this.currentUser = undefined;
});

// 在建立(或重新建立)用户会话时引发
this.userManager.events.addUserLoaded((user) => {
this.currentUser = user;
});

// 终止用户会话时引发
this.userManager.events.addUserUnloaded(() => {
this.currentUser = undefined;
});
}

// 当前用户是否登录
get userAvailavle(): boolean {
return !!this.currentUser;
}

// 获取当前用户信息
get user(): User {
return this.currentUser as User;
}

// 触发登录
public async triggerSignIn() {
await this.userManager.signinRedirect();
}

// 登录回调
public async handleCallback() {
const user: User = await this.userManager.signinRedirectCallback();
this.currentUser = user;
}

// 自动刷新回调
public async handleSilentCallback() {
const user: User | undefined = await this.userManager.signinSilentCallback();
this.currentUser = user;
}

// 退出登录
public async triggerSignOut() {
await this.userManager.signoutRedirect();
}
}

然后在 src 添加一个 services 文件夹,往里面添加一个 resource-service.ts 用于访问测试资源服务器。

resource-service.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
import axios, { AxiosResponse, AxiosRequestConfig } from 'axios';
import { OpenIdConnectService } from '@/connect/openid-connect-service';

const oidc: OpenIdConnectService = OpenIdConnectService.getInstance();

const baseHost = 'https://localhost:6001';

export const Apis = {
Testpublic: `${baseHost}/WeatherForecast/TestPublic`,
Testprotected: `${baseHost}/WeatherForecast/TestProtected`,
};

export interface Result {
data: string;
}

export const PublicApi = (): Promise<Result> => {
return new Promise<Result>(async (resolve, reject) => {
try {
const requestConfig: AxiosRequestConfig = { url: Apis.Testpublic };
const res: AxiosResponse<Result> = await axios(requestConfig);
resolve(res.data);
} catch (e) {
reject(e);
}
});
};

export const ProtectedApi = (): Promise<Result> => {
return new Promise<Result>(async (resolve, reject) => {
try {
const requestConfig: AxiosRequestConfig = {
url: Apis.Testprotected,
headers: { Authorization: GetAuth() },
};
const res: AxiosResponse<Result> = await axios(requestConfig);
resolve(res.data);
} catch (e) {
reject(e);
}
});
};

const GetAuth = (): string => {
let auth: string = ``;
if (oidc.user && oidc.user.token_type && oidc.user.access_token) {
auth = `${oidc.user.token_type} ${oidc.user.access_token}`;
}
return auth;
};

授权服务器和资源服务器的 service 就完成了,接下来就是编写 views 了,首先是欢迎界面,在 views 下面添加一个 Welcome.vue 组件

Welcome.vue
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
<template>
<div>
<b-icon font-scale="8.5" icon="shield-lock-fill" variant="danger" class="mb-4"></b-icon>
<h2 class="mb-4">.NET Core Implicit Flow Auth</h2>
<b-button-group vertical class="w-25">
<b-button variant="danger" @click="signiClick" class="mb-2">Sign-ni</b-button>
<b-button variant="success" @click="publicClick" class="mb-2">Call Public API</b-button>
<b-button @click="protectedClick" class="mb-2">Call Protected API</b-button>
</b-button-group>
</div>
</template>

<script lang="ts">
import { Component, Vue, Inject } from 'vue-property-decorator';
import { OpenIdConnectService } from '@/connect/openid-connect-service';
import { PublicApi, ProtectedApi } from '@/services/resource-service';

@Component
export default class Welcome extends Vue {
@Inject() private oidc!: OpenIdConnectService;

private signiClick() {
console.log('oidc', this.oidc.userAvailavle, this.oidc);
if (!this.oidc.userAvailavle) {
this.oidc.triggerSignIn();
} else {
this.$router.push({ path: '/home' });
}
}

private async publicClick() {
const resultData = await PublicApi();
console.log(resultData);
}

private async protectedClick() {
const resultData = await ProtectedApi();
console.log(resultData);
}
}
</script>

然后是 SigninOidc.vue 组件,这个是登陆成功后跳转回调的页面(也就是比如某些网站用 QQ 登陆以后会出现登陆成功,正在为您跳转回原站点)

SigninOidc.vue
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<template>
<div>
<h1>Login Succuess, callback, waiting</h1>
</div>
</template>

<script lang="ts">
import { Component, Vue, Inject } from 'vue-property-decorator';
import { OpenIdConnectService } from '@/connect/openid-connect-service';
@Component
export default class SigninOidc extends Vue {
@Inject() private oidc!: OpenIdConnectService;
public async created() {
await this.oidc.handleCallback();
this.$router.push({ path: '/home' });
}
}
</script>

然后是 RedirectSilentRenew.vue 组件,这个组件是自动刷新令牌用的

RedirectSilentRenew.vue
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<template>
<div>
</div>
</template>

<script lang="ts">
import { Component, Vue, Inject } from 'vue-property-decorator';
import { OpenIdConnectService } from '@/connect/openid-connect-service';

@Component
export default class RedirectSilentRenew extends Vue {
@Inject() private oidc!: OpenIdConnectService;
public created() {
this.oidc.handleSilentCallback();
}
}
</script>

最后修改 Home.vue 组件,这个是登陆成功以后重定向页面导航过来的目标页面。

Home.vue
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
<template>
<div>
<b-icon font-scale="8.5" icon="patch-check-fll" variant="success" class="mb-4"></b-icon>
<h2 class="mb-4">Login Successfully</h2>
<b-button-group vertical class="w-25">
<b-button variant="danger" @click="signOutClick" class="mb-2">Sign-out</b-button>
<b-button variant="success" @click="publicClick" class="mb-2">Call Public API</b-button>
<b-button variant="success" @click="protectedClick" class="mb-2">Call Protected API</b-button>
</b-button-group>
</div>
</template>

<script lang="ts">
import { Component, Vue, Inject } from 'vue-property-decorator';
import { OpenIdConnectService } from '@/connect/openid-connect-service';
import { PublicApi, ProtectedApi } from '@/services/resource-service';

@Component
export default class Welcome extends Vue {
@Inject() private oidc!: OpenIdConnectService;

private async created() {
if (!this.oidc.userAvailavle) {
await this.oidc.triggerSignIn();
}
}

private async signOutClick() {
await this.oidc.triggerSignOut();
}

private async publicClick() {
const resultData = await PublicApi();
console.log(resultData);
}

private async protectedClick() {
const resultData = await ProtectedApi();
console.log(resultData);
}
}
</script>

这样所有的视图组件就写完了,接下来修改一下 App.vue 完善一下样式以及授权服务器的初始化

App.vue
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
<template>
<div id="main">
<div id="app">
<router-view/>
</div>
</div>
</template>

<script lang="ts">
import { Component, Vue, Provide } from 'vue-property-decorator';
import { OpenIdConnectService } from './connect/openid-connect-service';

@Component
export default class App extends Vue {
@Provide() private oidc: OpenIdConnectService = OpenIdConnectService.getInstance();
}
</script>

<style>
html, body {
height: 100%;
}

#main {
height: 100%;
width: 100%;
display: table;
}

#app {
display: table-cell;
height: 100%;
vertical-align: middle;
text-align: center;
}
</style>

然后修改一下路由,找到 router 文件夹下的 index.ts,修改成如下:

index.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
import Vue from 'vue';
import VueRouter, { RouteConfig } from 'vue-router';

Vue.use(VueRouter);

const routes: Array<RouteConfig> = [
{
path: '/',
name: 'welcome',
component: () => import('../views/Welcome.vue'),
},
{
path: '/home',
name: 'home',
component: () => import('../views/Home.vue'),
},
{
path: '/signin-oidc',
name: 'signin-oidc',
component: () => import('../views/SigninOidc.vue'),
},
{
path: '/redirect-silent-renew',
name: 'redirect-silent-renew',
component: () => import('../views/RedirectSilentRenew.vue'),
},
];

const router = new VueRouter({
mode: 'history',
routes,
});

export default router;

最后在 main.ts 中启用 bootstrap 和 bootstrap-icons 组件,以及装饰路由器

main.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import Vue from 'vue';
import App from './App.vue';
import router from './router';
import { BootstrapVue, BootstrapVueIcons } from 'bootstrap-vue';
import 'bootstrap/dist/css/bootstrap.css';
import 'bootstrap-vue/dist/bootstrap-vue.css';

Vue.use(BootstrapVue);
Vue.use(BootstrapVueIcons);
Vue.config.productionTip = false;

new Vue({
router,
render: (h) => h(App),
}).$mount('#app');

最后修改一下 ESlint 的语法校验 .eslintrc.js,把大小写驼峰检测关掉,以及缩进,要不然编译会出现错误,这是因为 oidc-client 的配置属性使用了 小写+下划线。

.eslintrc.js
1
2
3
4
5
6
7
8
9
10
module.exports = {
...
rules: {
...
'camelcase': [2, {'properties': 'always'}],
'no-mixed-spaces-and-tabs': 'off'
...
}
...
}

到这里客户端也编写完成了,接下来就是测试了。

测试


首先把三个项目都运行起来(授权服务器,资源服务器,客户端):

授权服务器和资源服务器运行命令

dotnet run

客户端运行命令

yarn serve

授权服务器
资源服务器
客户端

然后访问一下客户端

可以看到,不登录之前没有授权保护的 Public API 是可以请求通过的,但是 Protected API 因为有授权保护,所以不登陆之前都是返回 401,登陆以后就可以请求通过了,登陆的时候会跳转到授权中心页面进行授权,用户同意 vue client 访问以后,就会跳转回我们的客户端页面,如果通过 URL 直接访问受保护的路由,也会触发自动登录。

评论