使用 IdentityServer4 隱式流(Implicit)保護 Vue(SPA)客戶端

前言:

  • 該文章 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的解決方案和其下ApiIdentityServer三個項目,並且在 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-clientUserManager 進行封裝。

修改 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>

這樣即可訪問被保護的客戶端。


參考文檔

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章