Vue + Volo.Abp 實現Auth2.0使用者端授權模式認證

2023-07-07 12:00:27

@


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

  "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設定如下

"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方法如下:

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",
                }
        );
    }
    ... 
}


Auth2.0授權

我們將使用oidc-client-ts簡化Vue中的OAuth2.0授權。有關oidc-client-ts的更多資訊,請參閱oidc-client-ts

建立一個vue專案,前端使用element-ui

安裝oidc-client-ts

yarn add oidc-client-ts

建立vue-oidc-client

建立vue-oidc-client.ts檔案,編寫程式碼如下:

// 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

建立Auth2.0認證跳轉

在登入頁面login/index.vue中,新增Auth2.0認證跳轉按鈕

<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,資源伺服器地址和認證伺服器地址

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

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後發放令牌

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,簡單的顯示登入成功的提示,常用的提示有「登入成功,正在為您繼續」,「登入成功,正在為您跳轉」等友好提示


<template>
  <div class="login-container">
    <el-result
      icon="success"
      title="登入成功"
      subTitle="正在繼續,請稍候.."
    ></el-result>
  </div>
</template>

在登入成功後,會跳轉到continue頁面,回撥地址會攜帶授權碼,然後呼叫認證伺服器的connect/token介面,獲取token

建立onRouteChange函數,在此解析頁面地址中的引數

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指定的頁面

  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)

最終效果