免費開源 基於Vue和Quasar的前端SPA項目實戰之用戶登陸(二)

基於Vue和Quasar的前端SPA項目實戰之用戶登陸(二)

回顧

經過上一篇文章 基於Vue和Quasar的前端SPA項目實戰之環境搭建(一)的介紹,咱們已經搭建好本地開發環境而且運行成功了,今天主要介紹登陸功能。javascript

簡介

一般爲了安全考慮,須要用戶登陸以後才能夠訪問。crudapi admin web項目也須要引入登陸功能,用戶登陸成功以後,跳轉到管理頁面,不然提示沒有權限。css

技術調研

SESSION

SESSION一般會用到Cookie,Cookie有時也用其複數形式Cookies。類型爲「小型文本文件」,是某些網站爲了辨別用戶身份,進行Session跟蹤而儲存在用戶本地終端上的數據(一般通過加密),由用戶客戶端計算機暫時或永久保存的信息。
用戶登陸成功後,後臺服務記錄登陸狀態,並用SESSIONID進行惟一識別。瀏覽器經過Cookie記錄了SESSIONID以後,下一次訪問同一域名下的任何網頁的時候會自動帶上包含SESSIONID信息的Cookie,這樣後臺就能夠判斷用戶是否已經登陸過了,從而進行下一步動做。優勢是使用方便,瀏覽器自動處理Cookie,缺點是容易受到XSS攻擊。html

JWT Token

Json web token (JWT), 是爲了在網絡應用環境間傳遞聲明而執行的一種基於JSON的開放標準((RFC 7519).該token被設計爲緊湊且安全的,特別適用於分佈式站點的單點登陸(SSO)場景。JWT的聲明通常被用來在身份提供者和服務提供者間傳遞被認證的用戶身份信息,以便於從資源服務器獲取資源,也能夠增長一些額外的其它業務邏輯所必須的聲明信息,該token也可直接被用於認證,也可被加密。
JWT校驗方式更加簡單便捷化,無需經過緩存,而是直接根據token取出保存的用戶信息,以及對token可用性校驗,單點登陸更爲簡單。缺點是註銷不是很方便,而且由於JWT Token是base64加密,可能有安全方面隱患。
由於目前系統主要是在瀏覽器環境中使用,因此選擇了SESSION的登陸方式,後續考慮使用JWT登陸方式,JWT更適合APP和小程序場景。前端

登陸流程

登陸流程圖
主要流程以下:vue

  1. 用戶打開頁面的時候,首先判斷是否屬於白名單列表,若是屬於,好比/login, /403, 直接放行。
  2. 本地local Storage若是保存了登陸信息,說明以前登陸過,直接放行。
  3. 若是沒有登陸過,本地local Storage爲空,跳轉到登陸頁面。
  4. 雖然本地登陸過了,可是可能過時了,這時候訪問任意一個API時候,會自動根據返回結果判斷是否登陸。

UI界面

登陸頁面
登陸頁面比較簡單,主要包括用戶名、密碼輸入框和登陸按鈕,點擊登陸按鈕會調用登陸API。java

代碼結構

代碼結構

  1. api: 經過axios與後臺api交互
  2. assets:主要是一些圖片之類的
  3. boot:動態加載庫,好比axios、i18n等
  4. components:自定義組件
  5. css:css樣式
  6. i18n:多語言信息
  7. layouts:佈局
  8. pages:頁面,包括了html,css和js三部份內容
  9. router:路由相關
  10. service:業務service,對api進行封裝
  11. store:Vuex狀態管理,Vuex 是實現組件全局狀態(數據)管理的一種機制,能夠方便的實現組件之間數據的共享

配置文件

quasar.conf.js是全局配置文件,全部的配置相關內容均可以這個文件裏面設置。ios

核心代碼

配置quasar.conf.js

plugins: [
    'LocalStorage',
    'Notify',
    'Loading'
]

由於須要用到本地存儲LocalStorage,消息提示Notify和等待提示Loading插件,因此在plugins裏面添加。git

配置全局樣式

修改文件quasar.variables.styl和app.styl, 好比設置主顏色爲淡藍色github

$primary = #35C8E8

封裝axios

import Vue from 'vue'
import axios from 'axios'
import { Notify } from "quasar";
import qs from "qs";
import Router from "../router/index";
import { permissionService } from "../service";

Vue.prototype.$axios = axios

// We create our own axios instance and set a custom base URL.
// Note that if we wouldn't set any config here we do not need
// a named export, as we could just `import axios from 'axios'`
const axiosInstance = axios.create({
  baseURL: process.env.API
});

axiosInstance.defaults.transformRequest = [
  function(data, headers) {
    // Do whatever you want to transform the data
    let contentType = headers["Content-Type"] || headers["content-type"];
    if (!contentType) {
      contentType = "application/json";
      headers["Content-Type"] = "application/json";
    }

    if (contentType.indexOf("multipart/form-data") >= 0) {
      return data;
    } else if (contentType.indexOf("application/x-www-form-urlencoded") >= 0) {
      return qs.stringify(data);
    }

    return JSON.stringify(data);
  }
];

// Add a request interceptor
axiosInstance.interceptors.request.use(
  function(config) {
    if (config.permission && !permissionService.check(config.permission)) {
      throw {
        message: "403 forbidden"
      };
    }

    return config;
  },
  function(error) {
    // Do something with request error
    return Promise.reject(error);
  }
);

function login() {
  setTimeout(() => {
    Router.push({
      path: "/login"
    });
  }, 1000);
}

// Add a response interceptor
axiosInstance.interceptors.response.use(
  function(response) {
    // Any status code that lie within the range of 2xx cause this function to trigger
    // Do something with response data
    return response;
  },
  function(error) {
    // Any status codes that falls outside the range of 2xx cause this function to trigger
    // Do something with response error

    if (error.response) {
      if (error.response.status === 401) {
        Notify.create({
          message:  error.response.data.message,
          type: 'negative'
        });
        login();
      } else if (error.response.data && error.response.data.message) {
        Notify.create({
          message: error.response.data.message,
          type: 'negative'
        });
      } else {
        Notify.create({
          message: error.response.statusText || error.response.status,
          type: 'negative'
        });
      }
    } else if (error.message.indexOf("timeout") > -1) {
      Notify.create({
        message: "Network timeout",
        type: 'negative'
      });
    } else if (error.message) {
      Notify.create({
        message: error.message,
        type: 'negative'
      });
    } else {
      Notify.create({
        message: "http request error",
        type: 'negative'
      });
    }

    return Promise.reject(error);
  }
);

// for use inside Vue files through this.$axios
Vue.prototype.$axios = axiosInstance

// Here we define a named export
// that we can later use inside .js files:
export { axiosInstance }

axios配置一個實例,作一些統一處理,好比網絡請求數據預處理,驗證權限,401跳轉,403提示等。web

用戶api和service

import { axiosInstance } from "boot/axios";

const HEADERS = {
  "Content-Type": "application/x-www-form-urlencoded"
};

const user = {
  login: function(data) {
    return axiosInstance.post("/api/auth/login",
      data,
      {
        headers: HEADERS
      }
    );
  },
  logout: function() {
    return axiosInstance.get("/api/auth/logout",
      {
        headers: HEADERS
      }
    );
  }
};

export { user };

登陸api爲/api/auth/login,註銷api爲/api/auth/logout

import { user} from "../api";
import { LocalStorage } from "quasar";

const userService = {
  login: async function(data) {
    var res = await user.login(data);
    return res.data;
  },
  logout: async function() {
    var res = await user.logout();
    return res.data;
  },
  getUserInfo: async function() {
    return LocalStorage.getItem("userInfo") || {};
  },
  setUserInfo: function(userInfo) {
    LocalStorage.set("userInfo", userInfo);
  }
};

export { userService };

用戶service主要是對api的封裝,而後還提供保存用戶信息到LocalStorage接口

Vuex管理登陸狀態

import { userService } from "../../service";
import { permissionService } from "../../service";

export const login = ({ commit }, userInfo) => {
  return new Promise((resolve, reject) => {
    userService
      .login(userInfo)
      .then(data => {
          //session方式登陸,其實不須要token,這裏爲了JWT登陸預留,用username代替。
          //經過Token是否爲空判斷本地有沒有登陸過,方便後續處理。
          commit("updateToken", data.principal.username);

          const newUserInfo = {
            username: data.principal.username,
            realname: data.principal.realname,
            avatar: "",
            authorities: data.principal.authorities || [],
            roles: data.principal.roles || []
          };
          commit("updateUserInfo", newUserInfo);

          let permissions = data.authorities || [];
          let isSuperAdmin = false;
          if (permissions.findIndex(t => t.authority === "ROLE_SUPER_ADMIN") >= 0) {
            isSuperAdmin = true;
          }

          permissionService.set({
            permissions: permissions,
            isSuperAdmin: isSuperAdmin
          });

          resolve(newUserInfo);
      })
      .catch(error => {
        reject(error);
      });
  });
};

export const logout = ({ commit }) => {
  return new Promise((resolve, reject) => {
    userService
      .logout()
      .then(() => {
        resolve();
      })
      .catch(error => {
        reject(error);
      })
      .finally(() => {
        commit("updateToken", "");
        commit("updateUserInfo", {
          username: "",
          realname: "",
          avatar: "",
          authorities: [],
          roles: []
        });

        permissionService.set({
          permissions: [],
          isSuperAdmin: false
        });
      });
  });
};

export const getUserInfo = ({ commit }) => {
  return new Promise((resolve, reject) => {
    userService
      .getUserInfo()
      .then(data => {
        commit("updateUserInfo", data);
        resolve();
      })
      .catch(error => {
        reject(error);
      });
  });
};

登陸成功以後,會把利用Vuex把用戶和權限信息保存在全局狀態中,而後LocalStorage也保留一份,這樣刷新頁面的時候會從LocalStorage讀取到Vuex中。

路由跳轉管理

import Vue from 'vue'
import VueRouter from 'vue-router'

import routes from './routes'
import { authService } from "../service";
import store from "../store";

Vue.use(VueRouter)

/*
 * If not building with SSR mode, you can
 * directly export the Router instantiation;
 *
 * The function below can be async too; either use
 * async/await or return a Promise which resolves
 * with the Router instance.
 */
const Router = new VueRouter({
  scrollBehavior: () => ({ x: 0, y: 0 }),
  routes,

  // Leave these as they are and change in quasar.conf.js instead!
  // quasar.conf.js -> build -> vueRouterMode
  // quasar.conf.js -> build -> publicPath
  mode: process.env.VUE_ROUTER_MODE,
  base: process.env.VUE_ROUTER_BASE
});

const whiteList = ["/login", "/403"];

function hasPermission(router) {
  if (whiteList.indexOf(router.path) !== -1) {
    return true;
  }

  return true;
}

Router.beforeEach(async (to, from, next) => {
  let token = authService.getToken();
  if (token) {
    let userInfo = store.state.user.userInfo;
    if (!userInfo.username) {
      try {
        await store.dispatch("user/getUserInfo");
        next();
      } catch (e) {
        if (whiteList.indexOf(to.path) !== -1) {
          next();
        } else {
          next("/login");
        }
      }
    } else {
      if (hasPermission(to)) {
        next();
      } else {
        next({ path: "/403", replace: true });
      }
    }
  } else {
    if (whiteList.indexOf(to.path) !== -1) {
      next();
    } else {
      next("/login");
    }
  }
});

export default Router;

經過複寫Router.beforeEach方法,在頁面跳轉以前進行預處理,實現前面登陸流程圖裏面的功能。

登陸頁面

submit() {
  if (!this.username) {
    this.$q.notify("用戶名不能爲空!");
    return;
  }

  if (!this.password) {
    this.$q.notify("密碼不能爲空!");
    return;
  }

  this.$q.loading.show({
    message: "登陸中"
  });

  this.$store
    .dispatch("user/login", {
      username: this.username,
      password: this.password,
    })
    .then(async (data) => {
      this.$router.push("/");
      this.$q.loading.hide();
    })
    .catch(e => {
      this.$q.loading.hide();
      console.error(e);
    });
}

submit方法中執行this.$store.dispatch("user/login")進行登陸,表示調用user store action裏面的login方法,若是成功,執行this.$router.push("/")

配置devServer代理

devServer: {
  https: false,
  port: 8080,
  open: true, // opens browser window automatically
  proxy: {
    "/api/*": {
      target: "https://demo.crudapi.cn",
      changeOrigin: true
    }
  }
}

配置proxy以後,全部的api開頭的請求就會轉發到後臺服務器,這樣就能夠解決了跨域訪問的問題。

驗證

登陸失敗
首先,故意輸入一個錯誤的用戶名,提示登陸失敗。

登陸成功
輸入正確的用戶名和密碼,登陸成功,自動跳轉到後臺管理頁面。

localstorage
F12開啓chrome瀏覽器debug模式,查看localstorage,發現userInfo,permission,token內容和預期一致,其中權限permission相關內容在後續rbac章節中詳細介紹。

小結

本文主要介紹了用戶登陸功能,用到了axios網絡請求,Vuex狀態管理,Router路由,localStorage本地存儲等Vue基本知識,而後還用到了Quasar的三個插件,LocalStorage, Notify和Loading。雖然登陸功能比較簡單,可是它完整地實現了前端到後端之間的交互過程。

demo演示

官網地址:https://crudapi.cn
測試地址:https://demo.crudapi.cn/crudapi/login

附源碼地址

GitHub地址

https://github.com/crudapi/crudapi-admin-web

Gitee地址

https://gitee.com/crudapi/crudapi-admin-web

因爲網絡緣由,GitHub可能速度慢,改爲訪問Gitee便可,代碼同步更新。

相關文章
相關標籤/搜索