本文主要介紹了使用者登入功能,用到了axios網絡請求,Vuex狀态管理,Router路由,localStorage本地存儲等Vue基本知識,然後還用到了Quasar的三個插件,LocalStorage, Notify和Loading。
基于Vue和Quasar的前端SPA項目實戰之使用者登入(二)
回顧
通過上一篇文章 基于Vue和Quasar的前端SPA項目實戰之環境搭建(一)的介紹,我們已經搭建好本地開發環境并且運作成功了,今天主要介紹登入功能。
簡介
通常為了安全考慮,需要使用者登入之後才可以通路。crudapi admin web項目也需要引入登入功能,使用者登入成功之後,跳轉到管理頁面,否則提示沒有權限。
技術調研
SESSION
SESSION通常會用到Cookie,Cookie有時也用其複數形式Cookies。類型為“小型文本檔案”,是某些網站為了辨識使用者身份,進行Session跟蹤而儲存在使用者本地終端上的資料(通常經過加密),由使用者用戶端計算機暫時或永久儲存的資訊。
使用者登入成功後,背景服務記錄登入狀态,并用SESSIONID進行唯一識别。浏覽器通過Cookie記錄了SESSIONID之後,下一次通路同一域名下的任何網頁的時候會自動帶上包含SESSIONID資訊的Cookie,這樣背景就可以判斷使用者是否已經登入過了,進而進行下一步動作。優點是使用友善,浏覽器自動處理Cookie,缺點是容易受到XSS攻擊。
JWT Token
Json web token (JWT), 是為了在網絡應用環境間傳遞聲明而執行的一種基于JSON的開放标準((RFC 7519).該token被設計為緊湊且安全的,特别适用于分布式站點的單點登入(SSO)場景。JWT的聲明一般被用來在身份提供者和服務提供者間傳遞被認證的使用者身份資訊,以便于從資源伺服器擷取資源,也可以增加一些額外的其它業務邏輯所必須的聲明資訊,該token也可直接被用于認證,也可被加密。
JWT校驗方式更加簡單便捷化,無需通過緩存,而是直接根據token取出儲存的使用者資訊,以及對token可用性校驗,單點登入更為簡單。缺點是登出不是很友善,并且因為JWT Token是base64加密,可能有安全方面隐患。
因為目前系統主要是在浏覽器環境中使用,是以選擇了SESSION的登入方式,後續考慮使用JWT登入方式,JWT更适合APP和小程式場景。
登入流程
主要流程如下:
- 使用者打開頁面的時候,首先判斷是否屬于白名單清單,如果屬于,比如/login, /403, 直接放行。
- 本地local Storage如果儲存了登入資訊,說明之前登入過,直接放行。
- 如果沒有登入過,本地local Storage為空,跳轉到登入頁面。
- 雖然本地登入過了,但是可能過期了,這時候通路任意一個API時候,會自動根據傳回結果判斷是否登入。
UI界面
登入頁面比較簡單,主要包括使用者名、密碼輸入框和登入按鈕,點選登入按鈕會調用登入API。
代碼結構
- api: 通過axios與背景api互動
- assets:主要是一些圖檔之類的
- boot:動态加載庫,比如axios、i18n等
- components:自定義元件
- css:css樣式
- i18n:多語言資訊
- layouts:布局
- pages:頁面,包括了html,css和js三部分内容
- router:路由相關
- service:業務service,對api進行封裝
- store:Vuex狀态管理,Vuex 是實作元件全局狀态(資料)管理的一種機制,可以友善的實作元件之間資料的共享
配置檔案
quasar.conf.js是全局配置檔案,所有的配置相關内容都可以這個檔案裡面設定。
核心代碼
配置quasar.conf.js
plugins: [
'LocalStorage',
'Notify',
'Loading'
]
因為需要用到本地存儲LocalStorage,消息提示Notify和等待提示Loading插件,是以在plugins裡面添加。
配置全局樣式
修改檔案quasar.variables.styl和app.styl, 比如設定主顔色為淡藍色
$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提示等。
使用者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: "xx.xx.xx.xx",
changeOrigin: true
}
}
}
配置proxy之後,所有的api開頭的請求就會轉發到背景伺服器,這樣就可以解決了跨域通路的問題。
驗證
首先,故意輸入一個錯誤的使用者名,提示登入失敗。
輸入正确的使用者名和密碼,登入成功,自動跳轉到背景管理頁面。
F12開啟chrome浏覽器debug模式,檢視localstorage,發現userInfo,permission,token内容和預期一緻,其中權限permission相關内容在後續rbac章節中詳細介紹。
小結
本文主要介紹了使用者登入功能,用到了axios網絡請求,Vuex狀态管理,Router路由,localStorage本地存儲等Vue基本知識,然後還用到了Quasar的三個插件,LocalStorage, Notify和Loading。雖然登入功能比較簡單,但是它完整地實作了前端到後端之間的互動過程。
完整可運作源碼請在基于Vue和Quasar的前端SPA項目實戰之環境搭建(一)中檢視!