Loading

【自用】Vue+Spring Boot前后端分离架构,集成vuex实现登录+登录校验

大致流程

登录

  • 前端输入用户名密码
  • 后端校验用户名密码是否正确
  • 生成token
  • 后端在redis中保存token,
  • 前端保存token

校验

  • 前端请求时,在header中带上token
  • 后端拦截器,从header中获取token并与redis中存储的进行校验
  • 校验成功继续业务
  • 校验失败返回登录页面

具体流程

登录时,当后端校验用户名与密码正确后,生成一个令牌token,并放入Redis中,键值对为:{token: UserLoginResp},其中UserLoginResp为后端返回给前端的用户登录态信息,包含用户的id,loginName,name,token(id,用户名,昵称,token),该键值对在Redis中的有效时间是24H。
UserController.java

    @PostMapping("/login")
    public JsonResult<UserLoginResp> login(@Valid @RequestBody UserLoginReq userLoginReq) {
        // 密码加密存储,变为32bit的HEX字符串
        userLoginReq.setPassword(DigestUtils
                .md5DigestAsHex(
                        userLoginReq
                                .getPassword()
                                .getBytes()));

        JsonResult<UserLoginResp> result = new JsonResult<>();
        UserLoginResp userLoginResp = userService.login(userLoginReq);

        // 生成单点登录token
        String token = IdUtil.fastUUID();
        // 存入redis,24h过期
        redisTemplate.opsForValue().set(token,
                JSONObject.toJSONString(userLoginResp),
                3600 * 24,
                TimeUnit.SECONDS);
        // 返回给前端
        userLoginResp.setToken(token);
        result.setData(userLoginResp);
        return result;
    }

UserLoginResp.java

package cn.codersy.wiki.resp;

public class UserLoginResp {
    private Long id;

    private String loginName;

    private String name;

    private String token;

    // 省略getter、setter
}

前端拿到后端返回的UserLoginResp后,需要进行存储,否则登录态刷新后即会失效,此功能需要集成vuex和sessionStorage
首先自己封装一下内置的sessionStroage(使得它的值不是object类型而是JSON字符串):
public/js/session-storage.js

SessionStorage = {
    get: function (key) {
        var v = sessionStorage.getItem(key);
        if (v && typeof(v) !== "undefined" && v !== "undefined") {
            return JSON.parse(v);
        }
    },
    set: function (key, data) {
        sessionStorage.setItem(key, JSON.stringify(data));
    },
    remove: function (key) {
        sessionStorage.removeItem(key);
    },
    clearAll: function () {
        sessionStorage.clear();
    }
};

然后用vuex再包装一下sessionStorage,这样项目的其他文件就不用操作sessions,只需要操作vuex提供的方法即可。
sec/store/index.ts

import { createStore } from 'vuex'
// 整合sessionStorage
declare let SessionStorage: any;

const USER = "USER";
const store = createStore({
  state: {
    // 全局变量,保存用户登录状态,初始化从缓存取,取不到则为空
    user: SessionStorage.get(USER) || {}
  },
  mutations: {
    setUser(state, user) {
      state.user = user;
      // 存入缓存
      SessionStorage.set(USER, user);
    }
  },
  actions: {
  },
  modules: {
  }
});

export default store;

准备好以上,编写前端登录
the-header.vue

<template>
    <a-layout-header class="header">
        <div class="logo"/>
        <a-menu
                theme="dark"
                mode="horizontal"
                v-model:selectedKeys="selectedKeys1"
                :style="{ lineHeight: '64px' }"
        >
            <a-menu-item key="1">
                <router-link to="/">首页</router-link>
            </a-menu-item>
            <a-menu-item key="2">
                <router-link to="/admin/ebook">电子书管理</router-link>
            </a-menu-item>
            <a-menu-item key="3">
                <router-link to="/admin/category">分类管理</router-link>
            </a-menu-item>
            <a-menu-item key="4">
                <router-link to="/admin/user">用户管理</router-link>
            </a-menu-item>
            <a-menu-item key="5">
                <router-link to="/about">关于我们</router-link>
            </a-menu-item>
            <a class="login-menu" @click="showLoginModal" v-if="!user.id">
                <span>登录</span>
            </a>
            <a class="login-menu" v-if="!!user.id">
                <a-popconfirm
                        title="确认退出登录?"
                        ok-text="是"
                        cancel-text="否"
                        @confirm="logout"
                >
                    <span>退出登录</span>
                </a-popconfirm>
            </a>
            <a class="login-menu" v-if="!!user.id">
                <span>欢迎您,{{ user.loginName }}</span>
            </a>

        </a-menu>

        <!--登录模态框-->
        <a-modal
                title="用户登录"
                ok-text="确认"
                cancel-text="取消"
                v-model:visible="loginModalVisible"
                :confirm-loading="loginModalLoading"
                @ok="login"
        >
            <!--表单-->
            <a-form :model="loginUser" :label-col="{ span: 6 }" :wrapper-col="{ span: 14 }">
                <a-form-item label="用户名">
                    <a-input v-model:value="loginUser.loginName"/>
                </a-form-item>
                <a-form-item label="密码">
                    <a-input v-model:value="loginUser.password" type="password"/>
                </a-form-item>
            </a-form>
        </a-modal>
    </a-layout-header>
</template>

<script lang="ts">
    import {computed, defineComponent, ref} from 'vue';
    import Axios from "axios";
    import {message} from "ant-design-vue";
    import store from "@/store";

    declare let hexMd5: any;
    declare let KEY: any;

    export default defineComponent({
        name: 'the-header',

        setup() {
            const loginModalVisible = ref(false);
            const loginModalLoading = ref(false);
            const loginUser = ref();
            loginUser.value = {};
            // 记录已登录的用户
            const user = computed(() => store.state.user);

            const showLoginModal = () => {
                loginModalVisible.value = true;
            };

            // 点击登录模态框的确认按钮
            const login = () => {
                loginModalLoading.value = true;
		// 密码在传输时即进行加密 
                loginUser.value.password = hexMd5(loginUser.value.password + KEY);
                Axios.post("/user/login", loginUser.value)
                    .then((res) => {
                        loginModalLoading.value = false;
                        if (res.data.success) {
                            loginModalVisible.value = false;
                            loginUser.value = {};
                            // 触发setUser方法,存储登录态
                            store.commit('setUser', res.data.data);
                            message.success('登录成功!', 1.5);
                        } else {
                            message.error(res.data.message, 1.5);
                        }
                    });
            };

            const logout = () => {
                Axios.get("/user/logout/" + user.value.token)
                    .then((res) => {
                        if (res.data.success) {
                            store.commit('setUser', {});
                        } else {
                            message.error(res.data.message, 1.5);
                        }
                    });
            };
            return {
                loginUser,
                user,
                loginModalVisible,
                loginModalLoading,
                showLoginModal,
                login,
                logout
            }
        }
    });

</script>

<style scoped>

    .logo {
        float: left;
        width: 120px;
        height: 31px;
        margin: 16px 24px 16px 50px;
        background: rgba(255, 255, 255, 0.3);
    }

    .login-menu {
        float: right;
        color: white;
        width: 120px;
    }
</style>

以上只是实现了登录功能,为了防止手动通过url绕开界面发送不合法请求,我们需要进行登录校验。

后端增加登录校验

  • 前端在请求头中加入token
  • 后端从请求头中获取token进行校验。

编写登录拦截器:LoginInterceptor.java

package cn.codersy.wiki.interceptor;

import cn.hutool.core.util.ObjectUtil;
import cn.hutool.core.util.StrUtil;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

/**
 * 登录拦截器
 * @author TenMoons
 * @date 2021-08-13 10:04
 */
@Component
public class LoginInterceptor implements HandlerInterceptor {

    private static final Logger LOGGER = LoggerFactory.getLogger(LoginInterceptor.class);

    @Autowired
    private RedisTemplate redisTemplate;

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        // 前后端分离的架构,前端会发送一个OPTIONS做预检,该请求不校验
        if (request.getMethod().toUpperCase().equals("OPTIONS")) {
            return true;
        }

        // 从header中获取token参数
        String token = request.getHeader("token");
        LOGGER.info("登录校验开始,token: {}", token);
        if (StrUtil.isBlank(token)) {
            LOGGER.info("token为空,请求被拦截");
            response.setStatus(HttpStatus.UNAUTHORIZED.value());
            return false;
        } else {
            Object o = redisTemplate.opsForValue().get(token);
            if (ObjectUtil.isEmpty(o)) {
                LOGGER.warn("token无效,请求被拦截");
                response.setStatus(HttpStatus.UNAUTHORIZED.value());
                return false;
            } else {
                LOGGER.info("登录成功: {}", o);
                return true;
            }
        }
    }
}

前端在请求中加入token,此处也需要用到上面的vuex。
使用axios拦截器,同时拦截请求和响应,并打印出日志,在拦截请求时,向header中加入sessionStorage中的token
main.ts

// 使用axios拦截器拦截请求和响应并打印日志
axios.interceptors.request.use(function (config) {
    console.log('请求参数:', config);
    // 向header中加入token
    const token = store.state.user.token;
    if (Tool.isNotEmpty(token)) {
        config.headers.token = token;
    }
    return config;
}, err =>{
    return Promise.reject(err);
});
axios.interceptors.response.use(function (res) {
    console.log('返回结果:', res);
    return res;
}, err => {
    console.log('返回错误:', err);
    return Promise.reject(err)
});

前端界面登录校验

  • 未登录时,管理菜单要隐藏
  • 对路由做判断,防止用户通过手动url访问管理界面
    router/index.ts
import { createRouter, createWebHistory, RouteRecordRaw } from 'vue-router'
import Home from '../views/home.vue'
import About from '../views/about.vue'
import AdminEbook from '../views/admin/admin-ebook.vue'
import AdminCategory from '../views/admin/admin-category.vue'
import AdminDoc from '../views/admin/admin-doc.vue'
import Doc from '../views/doc.vue'
import AdminUser from '../views/admin/admin-user.vue'
import store from "@/store";
import {Tool} from "@/utils/tool";

const routes: Array<RouteRecordRaw> = [
  {
    path: '/',
    name: 'Home',
    component: Home
  },
  {
    path: '/about',
    name: 'About',
    component: About
    // route level code-splitting
    // this generates a separate chunk (about.[hash].js) for this route
    // which is lazy-loaded when the route is visited.
    // component: () => import(/* webpackChunkName: "about" */ '../views/about.vue')
  },
  {
    path: '/admin/ebook',
    name: 'AdminEbook',
    component: AdminEbook,
    meta: {
      loginRequire: true
    }
  },
  {
    path: '/admin/category',
    name: 'AdminCategory',
    component: AdminCategory,
    meta: {
      loginRequire: true
    }
  },
  {
    path: '/admin/doc',
    name: 'AdminDoc',
    component: AdminDoc,
    meta: {
      loginRequire: true
    }
  },
  {
    path: '/doc',
    name: 'Doc',
    component: Doc,
    meta: {
      loginRequire: true
    }
  },
  {
    path: '/admin/user',
    name: 'AdminUser',
    component: AdminUser,
    meta: {
      loginRequire: true
    }
  }
]

const router = createRouter({
  history: createWebHistory(process.env.BASE_URL),
  routes
});

// 路由登录拦截
router.beforeEach((to, from, next) => {
  // 判断目标路由是否需要拦截
  if (to.matched.some((item) => {
    console.log(item, "是否需要登录校验:", item.meta.loginRequire);
    return item.meta.loginRequire;
  })) {
    const loginUser = store.state.user;
    if (Tool.isEmpty(loginUser)) {
      console.log('未登录!');
      // 跳转回首页
      next("/");
    } else {
      // 正常跳转目标路由
      next();
    }
  }
});

export default router

posted @ 2021-08-13 11:43  Standing-Stone  阅读(475)  评论(0)    收藏  举报