基於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項目實戰之環境搭建(一)中查看!