目录
前言:
- 该文章 Vue 用 Vue CLI 3.0 创建的 Vue + TypeScript + Sass 的项目。
一、创建项目
$ mkdir Tutorial-Plus
$ cd Tutorial-Plus
$ mkdir src
$ cd src
$ vue create vue-client // 创建项目自定义为 TypeScript + Sass
$ dotnet new api -n Api --no-https
$ dotnet new is4inmem -n IdentityServer
$ cd ..
$ dotnet new sln -n Tutorial-Plus
$ dotnet sln add ./src/Api/Api.csproj
$ dotnet sln add ./src/IdentityServer/IdentityServer.csproj
此时创建好了名为Tutorial-Plus的解决方案和其下Api、IdentityServer三个项目,并且在 src 文件夹中创建了名为 vue-client 的 Vue 应用。
二、IdentityServer 项目
修改 IdentityServer 的启动端口为 5000
1) 将 json config 修改为 code config
在 IdentityServer 项目的 Startup.cs 文件的 ConfigureServices 方法中,
找到以下代码:
// in-memory, code config
//builder.AddInMemoryIdentityResources(Config.GetIdentityResources());
//builder.AddInMemoryApiResources(Config.GetApis());
//builder.AddInMemoryClients(Config.GetClients());
// in-memory, json config
builder.AddInMemoryIdentityResources(Configuration.GetSection("IdentityResources"));
builder.AddInMemoryApiResources(Configuration.GetSection("ApiResources"));
builder.AddInMemoryClients(Configuration.GetSection("clients"));
将其修改为
// in-memory, code config
builder.AddInMemoryIdentityResources(Config.GetIdentityResources());
builder.AddInMemoryApiResources(Config.GetApis());
builder.AddInMemoryClients(Config.GetClients());
// in-memory, json config
//builder.AddInMemoryIdentityResources(Configuration.GetSection("IdentityResources"));
//builder.AddInMemoryApiResources(Configuration.GetSection("ApiResources"));
//builder.AddInMemoryClients(Configuration.GetSection("clients"));
以上修改的内容为将原来写在配置文件中的配置,改为代码配置。
2) 修改 Config.cs 文件
将 Config.cs 文件的 GetIdentityResources() 方法修改为如下:
// 被保护的 IdentityResource
public static IEnumerable<IdentityResource> GetIdentityResources()
{
return new IdentityResource[]
{
// 如果要请求 OIDC 预设的 scope 就必须要加上 OpenId(),
// 加上他表示这个是一个 OIDC 协议的请求
// Profile Address Phone Email 全部是属于 OIDC 预设的 scope
new IdentityResources.OpenId(),
new IdentityResources.Profile(),
new IdentityResources.Address(),
new IdentityResources.Phone(),
new IdentityResources.Email()
};
}
将 Config.cs 文件的 GetClients() 方法修改为如下:
public static IEnumerable<Client> GetClients()
{
return new[]
{
// SPA client using implicit flow
new Client
{
ClientId = "vue-client",
ClientName = "Vue SPA Client",
ClientUri = "http://localhost:8080",
AllowedGrantTypes = GrantTypes.Implicit,
// AccessToken 是否可以通过浏览器返回
AllowAccessTokensViaBrowser = true,
// 是否需要用户点击同意(待测试)
RequireConsent = true,
// AccessToken 的有效期
AccessTokenLifetime = 60 * 5,
RedirectUris =
{
// 指定登录成功跳转回来的 uri
"http://localhost:8080/signin-oidc",
// AccessToken 有效期比较短,刷新 AccessToken 的页面
"http://localhost:8080/redirect-silentrenew",
"http://localhost:8080/silent.html",
"http://localhost:8080/popup.html",
},
// 登出 以后跳转的页面
PostLogoutRedirectUris = {
"http://localhost:8080/" },
// vue 和 IdentityServer 不在一个域上,需要指定跨域
AllowedCorsOrigins = {
"http://localhost:8080", "http://192.168.118.1:8080" },
AllowedScopes = {
"openid", "profile", "api1" }
}
};
}
在以上代码中,AllowedCorsOrigins 请根据自己的情况而定,,再启动 Vue 项目的时候,有两个打开 网站 的链接,请根据这里的配置谨慎选择,避免在 跨域 浪费时间。
三、Api 项目
修改 Api 项目的启动端口为 5001
1) 配置 Startup.cs
将 Api 项目 Startup.cs 的 ConfigureServices() 方法修改为如下:
public void ConfigureServices(IServiceCollection services)
{
services.AddMvc();
services.AddMvcCore().AddAuthorization().AddJsonFormatters();
services.AddAuthentication("Bearer")
.AddJwtBearer("Bearer", options =>
{
options.Authority = "http://localhost:5000"; // IdentityServer的地址
options.RequireHttpsMetadata = false; // 不需要Https
options.Audience = "api1"; // 和资源名称相对应
// 多长时间来验证以下 Token
options.TokenValidationParameters.ClockSkew = TimeSpan.FromMinutes(1);
// 我们要求 Token 需要有超时时间这个参数
options.TokenValidationParameters.RequireExpirationTime = true;
});
services.AddMemoryCache();
services.AddCors(options =>
{
options.AddPolicy("VueClientOrigin",
builder => builder.WithOrigins("http://localhost:8080")
.AllowAnyHeader()
.AllowAnyMethod());
});
services.Configure<MvcOptions>(options =>
{
options.Filters.Add(new CorsAuthorizationFilterFactory("VueClientOrigin"));
});
}
注意以上代码设置的 Cors 是 http://localhost:8080 注意跨域问题,避免因为跨域浪费时间。
将 Api 项目 Startup.cs 的 Configure() 方法修改为如下:
public void Configure(IApplicationBuilder app, IHostingEnvironment env)
{
app.UseCors("VueClientOrigin");
app.UseAuthentication();
app.UseMvc();
}
2) IdentityController.cs 文件
将 Controllers 文件夹中的 ValuesController.cs
改名为 IdentityController.cs
,
并将其中代码修改为如下:
[Route("[controller]")]
[ApiController]
[Authorize]
public class IdentityController : ControllerBase
{
[HttpGet]
public IActionResult Get()
{
return new JsonResult(from c in User.Claims select new {
c.Type, c.Value });
}
}
3) TodoController.cs 文件
在 Controllers 文件夹中添加文件 TodoController.cs
,
并修改代码为:
[Route("api/[controller]")]
[ApiController]
[Authorize]
public class TodoController : ControllerBase
{
public struct Todo
{
public Guid Id;
public string Title;
public bool Completed;
}
public struct TodoEdit
{
public string Title;
public bool Completed;
}
private readonly List<Todo> _todos;
private const string Key = "TODO_KEY";
private readonly IMemoryCache _memoryCache;
public TodoController(IMemoryCache memoryCache)
{
_memoryCache = memoryCache;
_todos = new List<Todo>
{
new Todo {
Id = Guid.NewGuid(), Title = "吃饭", Completed = true },
new Todo {
Id = Guid.NewGuid(), Title = "学习C#", Completed = false },
new Todo {
Id = Guid.NewGuid(), Title = "学习.NET Core", Completed = false },
new Todo {
Id = Guid.NewGuid(), Title = "学习ASP.NET Core", Completed = false },
new Todo {
Id = Guid.NewGuid(), Title = "学习Entity Framework", Completed = false }
};
if (!memoryCache.TryGetValue(Key, out List<Todo> todos))
{
var options = new MemoryCacheEntryOptions()
.SetAbsoluteExpiration(TimeSpan.FromDays(1));
_memoryCache.Set(Key, todos, options);
}
}
[HttpGet]
public IActionResult Get()
{
if (!_memoryCache.TryGetValue(Key, out List<Todo> todos))
{
todos = _todos;
var options = new MemoryCacheEntryOptions()
.SetAbsoluteExpiration(TimeSpan.FromDays(1));
_memoryCache.Set(Key, todos, options);
}
return Ok(todos);
}
[HttpPost]
public IActionResult Post([FromBody]TodoEdit todoEdit)
{
var todo = new Todo
{
Id = Guid.NewGuid(),
Title = todoEdit.Title,
Completed = todoEdit.Completed
};
if (!_memoryCache.TryGetValue(Key, out List<Todo> todos))
todos = _todos;
todos.Add(todo);
var options = new MemoryCacheEntryOptions()
.SetAbsoluteExpiration(TimeSpan.FromDays(1));
_memoryCache.Set(Key, todos, options);
return Ok(todo);
}
}
Api 接口写好以后。
四、Vue 项目
Vue 项目的默认启动端口为 8080
使用命令 npm i oidc-client -s
添加 oidc-client 的npm 包
首先打开Vue项目,并在src目录中添加 open-id-connect 文件夹
在 open-id-connect 文件夹中创建 Config.ts 文件并写入代码:
export const identityServerBase = 'http://localhost:5000';
export const apiBase = 'http://localhost:5001';
export const vueBase = 'http://localhost:8080';
// 参考文档 https://github.com/IdentityModel/oidc-client-js/wiki
export const openIdConnectSettings = {
authority: `${
identityServerBase}`,
client_id: `vue-client`,
redirect_uri: `${
vueBase}/signin-oidc`,
post_logout_redirect_uri: `${
vueBase}/`,
silent_redirect_uri: `${
vueBase}/redirect-silentrenew`,
scope: 'openid profile api1',
response_type: `id_token token`,
automaticSilentRenew: true,
};
上面代码是配置 OIDC 协议。
在 open-id-connect 文件夹中创建 OpenIdConnectService.ts 文件并写入代码:
import {
UserManager, User } from 'oidc-client';
import {
openIdConnectSettings } from '@/open-id-connect/Config';
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 | null;
private constructor() {
// 清理过期的东西
this.userManager.clearStaleState();
this.userManager.getUser().then((user) => {
if (user) {
this.currentUser = user;
} else {
this.currentUser = null;
}
}).catch((err) => {
this.currentUser = null;
});
// 在建立(或重新建立)用户会话时引发
this.userManager.events.addUserLoaded((user) => {
console.log('addUserLoaded', user);
this.currentUser = user;
});
// 终止用户会话时引发
this.userManager.events.addUserUnloaded((user) => {
console.log('addUserUnloaded', user);
this.currentUser = null;
});
}
// 当前用户是否登录
get userAvailavle(): boolean {
return !!this.currentUser;
}
// 获取当前用户信息
get user(): User {
return this.currentUser as User;
}
// 触发登录
public async triggerSignIn() {
await this.userManager.signinRedirect();
console.log('triggerSignIn');
}
// 登录回调
public async handleCallback() {
const user: User = await this.userManager.signinRedirectCallback();
console.log('handleCallback');
this.currentUser = user;
}
// 自动刷新回调
public async handleSilentCallback() {
const user: User = await this.userManager.signinSilentCallback();
console.log('handleSilentCallback');
this.currentUser = user;
}
// 触发登出
public async triggerSignOut() {
console.log('triggerSignOut');
await this.userManager.signoutRedirect();
}
}
这里我们使用了单例模式。对 oidc-client 的 UserManager 进行封装。
修改 views 文件夹的 Home.vue 文件如下:
<template>
<div class="home">
这是首页
<button @click="triggerSignOut">SignOut</button>
</div>
</template>
<script lang="ts">
import {
Component, Vue, Inject } from 'vue-property-decorator';
import {
OpenIdConnectService } from '@/open-id-connect/OpenIdConnectService';
@Component
export default class Home extends Vue {
@Inject() private oidc!: OpenIdConnectService;
private async created() {
if (!this.oidc.userAvailavle) {
await this.oidc.triggerSignIn();
}
}
private async triggerSignOut() {
await this.oidc.triggerSignOut();
}
}
</script>
在 views 文件夹中创建 RedirectSilentRenew.vue 文件,并写入代码:
<template>
<div>RedirectSilentRenew</div>
</template>
<script lang="ts">
import {
Component, Vue, Inject } from 'vue-property-decorator';
import {
OpenIdConnectService } from '@/open-id-connect/OpenIdConnectService';
@Component
export default class RedirectSilentRenew extends Vue {
@Inject() private oidc!: OpenIdConnectService;
public created() {
this.oidc.handleSilentCallback();
}
}
</script>
在 views 文件夹中创建 SigninOidc.vue 文件,并写入代码:
<template>
<div>登录成功,返回中</div>
</template>
<script lang="ts">
import {
Component, Vue, Inject } from 'vue-property-decorator';
import {
OpenIdConnectService } from '@/open-id-connect/OpenIdConnectService';
@Component
export default class SigninOidc extends Vue {
@Inject() private oidc!: OpenIdConnectService;
public async created() {
await this.oidc.handleCallback();
this.$router.push({
path: '/home' });
}
}
</script>
在 views 文件夹中创建 Loading.vue 文件,并写入代码:
<template>
<div>loading</div>
</template>
<script lang="ts">
import {
Component, Vue, Inject } from 'vue-property-decorator';
import {
OpenIdConnectService } from '@/open-id-connect/OpenIdConnectService';
@Component
export default class Loading extends Vue {
@Inject() private oidc!: OpenIdConnectService;
public created() {
// 这里去 oidc-client 获取是否已经登录
console.log('oidc', this.oidc.userAvailavle, this.oidc);
if (!this.oidc.userAvailavle) {
this.oidc.triggerSignIn();
} else {
this.$router.push({
path: '/home' });
}
}
}
</script>
修改 App.vue 文件:
<template>
<div id="app">
<router-view/>
</div>
</template>
<script lang="ts">
import {
Component, Vue, Provide } from 'vue-property-decorator';
import {
OpenIdConnectService } from '@/open-id-connect/OpenIdConnectService';
@Component
export default class App extends Vue {
@Provide() private oidc: OpenIdConnectService = OpenIdConnectService.getInstance();
}
</script>
最后修改路由 router.ts 文件的配置:
export default new Router({
mode: 'history',
base: process.env.BASE_URL,
routes: [
{
path: '/',
name: 'loading',
component: () => import('./views/Loading.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'),
},
],
});
这样我们就可以启动 IdentityServer 和 Vue 项目 进行登录了。
后面我们开始写访问保护的端口:
首先使用命令 npm i axios -s
添加 axios 的npm 包
在 src 文件夹中 新建common 文件夹
在 common 文件夹中新建 Config.ts 文件如下:
const host = 'http://localhost:5001';
// 这是统一设置接口路径的位置
export const endPoint = {
// tslint:disable-next-line: jsdoc-format
QueryTodos: `${
host}/api/todo`,
// 添加 todo
AddTodo: `${
host}/api/todo`,
};
在 common 文件夹中新建 NetService.ts 文件如下:
import axios, {
AxiosResponse, AxiosRequestConfig } from 'axios';
import {
endPoint } from '@/common/Config';
import {
OpenIdConnectService } from '@/open-id-connect/OpenIdConnectService';
const oidc: OpenIdConnectService = OpenIdConnectService.getInstance();
export interface Todo {
id: string; title: string; completed: boolean; }
export interface TodoEdit {
title: string; completed: boolean; }
// 查询 Todo 列表
export const QueryTodos = (): Promise<Todo[]> => {
return new Promise<Todo[]>(async (resolve, reject) => {
try {
const auth: string = `${
oidc.user.token_type} ${
oidc.user.access_token}`;
const requestConfig: AxiosRequestConfig = {
url: endPoint.QueryTodos, headers: {
Authorization: auth } };
const res: AxiosResponse<Todo[]> = await axios(requestConfig);
resolve(res.data);
} catch (e) {
reject(e);
}
});
};
// 添加 Todo
export const AddTodo = (todo: TodoEdit): Promise<void> => {
return new Promise<void>(async (resolve, reject) => {
try {
await axios({
url: endPoint.AddTodo, method: 'POST', data: todo });
resolve();
} catch (e) {
reject(e);
}
});
};
然后我们修改 Home.vue 文件:
<template>
<div class="home">
<h1>这是首页</h1>
<button @click="triggerSignOut">SignOut</button>
<div>
<table v-if="tableData && tableData.length > 0">
<tr>
<th>Id</th>
<th>Title</th>
<th>Completed</th>
</tr>
<tr v-for="(item,index) in tableData" :key="index">
<td>{
{ item.id }}</td>
<td>{
{ item.title }}</td>
<td>{
{ item.completed }}</td>
</tr>
</table>
</div>
</div>
</template>
<script lang="ts">
import {
Component, Vue, Inject } from 'vue-property-decorator';
import {
OpenIdConnectService } from '@/open-id-connect/OpenIdConnectService';
import {
Todo, QueryTodos } from '@/common/NetService';
@Component
export default class Home extends Vue {
@Inject() private oidc!: OpenIdConnectService;
private tableData: Todo[] = [];
private async created() {
if (!this.oidc.userAvailavle) {
await this.oidc.triggerSignIn();
} else {
this.tableData = await QueryTodos();
}
}
private async triggerSignOut() {
await this.oidc.triggerSignOut();
}
}
</script>
这样即可访问被保护的客户端。