ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

前后端分离登录实战:SpringBoot+Redis+VueX 从假登录到上线

前后端分离登录实战:SpringBoot+Redis+VueX 从假登录到上线 很多朋友做前后端分离项目时登录功能都是“假登录”——前端写死一个 token后端用过滤器拦一下就算完事。等到要接真实数据库、做会话共享、存用户状态的时候才发现里面全是坑。这期咱们就老老实实把登录功能从“玩具”升级成“能上线”的版本后端接 MySQL 做账号校验用 Redis 存会话前端用 VueX 管理登录态顺便把 axios 拦截器、路由守卫这些配套逻辑一并理顺。适合正在做毕设、或者在公司维护老 Vue2 项目的同学参考。1. 整体思路登录功能为什么非要拉上 Redis 和 VueX先别急着写代码想清楚“登录”这件事到底在解决什么问题。用户输入账号密码后端校验通过后怎么让系统记住“这个人已经登录了”传统单体项目用 Session 就行但前后端分离架构下前端部署在一台服务器后端在另一台Session 默认存在内存里跨域请求带不上 Cookie 不说后端一重启用户就得重新登录。所以业界通用做法是后端生成一个凭证token前端拿着这个凭证访问受保护的接口后端校验通过就放行。那 Redis 在这里扮演什么角色一句话Session 的“外置仓库”。把登录状态从后端内存里挪到 Redis 中多台后端实例都能共享同一份会话数据重启也不会丢。你可能会问为什么不用 JWTJWT 确实能无状态化但有个硬伤无法服务端主动失效。用户修改密码、管理员封禁账号、检测到异地登录强制下线JWT 在过期前都拦不住。用 Redis 存 token 就能随时删掉这也是很多内部管理系统选择 Redis 方案的原因。VueX 的作用则是解决“前端怎么知道用户是谁”。登录成功后后端返回用户信息总不能让每个页面都重新请求一遍吧存到 VueX 里所有组件都能直接读取。但 VueX 有个特点刷新页面状态就没了所以还得配合 localStorage 持久化刷新后重新把状态塞回 VueX。这套流程跑通后整个登录闭环才算是完整的。2. 技术选型与准备工作想跑通这套流程先把环境搭好。我这里用的是最稳妥的“老项目全家桶”组合版本搭配经实测不打架。2.1 后端环境清单JDK 1.8、Maven 3.6、SpringBoot 2.7别用 3.xjavax 命名空间和 springfox 兼容性会折腾到你怀疑人生、MySQL 5.78.0 也 OK注意驱动依赖、Redis 任意稳定版5.0 以上就行。pom.xml 里核心依赖加这几个dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId /dependency dependency groupIdorg.mybatis.spring.boot/groupId artifactIdmybatis-spring-boot-starter/artifactId version2.3.1/version /dependency dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId scoperuntime/scope /dependency dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId optionaltrue/optional /dependencyRedis 别用 lettuce 客户端坑多换成 jedis 更顺手。排除 lettuce、引入 jedisdependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId exclusions exclusion groupIdio.lettuce/groupId artifactIdlettuce-core/artifactId /exclusion /exclusions /dependency dependency groupIdredis.clients/groupId artifactIdjedis/artifactId /dependency2.2 前端环境清单Node 14 或 16更高版本跑老 Vue2 项目的 node-sass 会崩、Vue 2.6、Vue Router 3.x、VueX 3.x、axios 0.27最新版在部分老项目里有兼容警告不过不影响使用。HBuilderX 跑 Vue2 项目我个人试过启动速度还行但依赖安装和调试还是命令行更顺手看个人习惯。2.3 数据库准备建个 user 表字段设计直接给你参考CREATE TABLE user ( id int(11) NOT NULL AUTO_INCREMENT, username varchar(50) NOT NULL COMMENT 用户名, password varchar(100) NOT NULL COMMENT 密码BCrypt加密, nickname varchar(50) DEFAULT NULL COMMENT 昵称, avatar varchar(255) DEFAULT NULL COMMENT 头像地址, status tinyint(1) DEFAULT 1 COMMENT 1启用 0禁用, create_time datetime DEFAULT CURRENT_TIMESTAMP, update_time datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_username (username) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;密码字段必须用 BCrypt 加密存储千万别用 MD5。MD5 撞库太容易了这个后面展开讲。3. 后端实现从数据库校验到 Redis 会话3.1 登录接口逻辑拆解登录接口的核心逻辑分四步接收参数、校验账号密码、生成 token 写入 Redis、返回结果。先写个统一的返回体 Result让前端好统一处理Data public class ResultT { private Integer code; private String message; private T data; public static T ResultT success(T data) { ResultT result new Result(); result.setCode(200); result.setMessage(success); result.setData(data); return result; } public static T ResultT error(String message) { ResultT result new Result(); result.setCode(500); result.setMessage(message); return result; } }Service 层别把业务写在 Controller 里不利于复用和维护。登录核心逻辑参考Service public class UserService { Autowired private UserMapper userMapper; Autowired private StringRedisTemplate stringRedisTemplate; public ResultLoginVO login(LoginDTO dto) { // 1. 查数据库 User user userMapper.selectByUsername(dto.getUsername()); // 2. 判断用户是否存在 if (user null) { return Result.error(用户名不存在); } if (user.getStatus() ! 1) { return Result.error(账号已被禁用); } // 3. 校验密码 if (!BCrypt.checkpw(dto.getPassword(), user.getPassword())) { return Result.error(密码错误); } // 4. 生成token存入Redis String token UUID.randomUUID().toString().replace(-, ); // key设计login:token:{uuid} stringRedisTemplate.opsForValue().set( login:token: token, String.valueOf(user.getId()), 60 * 30, TimeUnit.SECONDS ); // 5. 返回前端需要的信息 LoginVO vo new LoginVO(); vo.setToken(token); vo.setNickname(user.getNickname()); vo.setAvatar(user.getAvatar()); return Result.success(vo); } }这里有几个细节值得说说。用户名不存在和密码错误为什么要分开提示从安全角度说分开提示方便攻击者探测“哪些用户名是注册过的”统一提示会更安全。但作为内部系统或练手项目分开提示在用户体验上更友好怎么取舍看你场景。Redis key 设计我用了login:token:{uuid}这种带前缀的格式好处有两个一是避免和业务缓存混在一起排查问题时用keys login:token:*一下就能筛出来二是加过期时间后 Redis 自动清理不需要写定时任务。3.2 BCrypt为什么非它不可很多人图省事密码存成 MD5觉得反正内部系统无所谓。但 MD5 有个特性同样的明文永远产生同样的密文。这意味着两个用户密码相同数据库中密文也相同再加上彩虹表的存在弱密码几乎等于裸奔。BCrypt 每次加密都会混入随机盐同样的明文每次密文都不同而且内部有轮次计算暴力破解成本高得多。Spring Security 提供了现成的 BCrypt 实现单独引个spring-security-crypto依赖就能用不用把整个安全框架引进来。dependency groupIdorg.springframework.security/groupId artifactIdspring-security-crypto/artifactId /dependency生成加密密码就一行String encoded BCrypt.hashpw(123456, BCrypt.gensalt()); System.out.println(encoded);校验时用刚才代码中的BCrypt.checkpw(明文, 密文)返回 true 就通过了。注册功能里新用户创建密码时同样用BCrypt.hashpw加密后落库。注意不要把 gensalt 每次手动传入同样的盐BCrypt.gensalt()默认带随机盐直接调用就好。3.3 Redis 序列化策略怎么存、存什么实际开发中最容易出问题的就是 Redis key 和 value 乱码。我见过不少同事用 RedisTemplate 默认配置存进去的 key 变成\xac\xed\x00\x05t\x00...看着像乱码其实是 JDK 序列化器搞的鬼。JDK 序列化对象会带一堆类型信息存字符串时白白占用存储空间还会让可视化工具看起来非常难受。解决办法用 StringRedisTemplate 操作所有缓存数据key 和 value 都当字符串处理。如果非要存对象就手动把对象转成 JSON 字符串再存。上面代码里写法就是典型的字符串方式token 作为 key用户 ID 作为 value不涉及序列化器问题。取的时候String userIdStr stringRedisTemplate.opsForValue().get(login:token: token); if (userIdStr null) { // token不存在或已过期 }关于 token 存什么有人存用户 ID有人存用户对象 JSON还有人只存个1表示有效。我建议存用户 ID 字符串。因为后续任何接口需要知道“当前登录用户是谁”用 userId 查一次数据库就完事了。如果存对象 JSON一旦用户昵称、头像变了Redis 里的旧数据还留着旧信息反而产生脏数据。3.4 注册功能顺手搞定没有注册功能的登录是没法闭环的。趁热打铁把注册接口也加上。逻辑不复杂但要注意先查用户名是否存在再加密密码最后插入。别让 Service 吞掉重复键异常得主动查询判断再插入给用户一个明确的“用户名已被占用”提示。public ResultString register(RegisterDTO dto) { User exist userMapper.selectByUsername(dto.getUsername()); if (exist ! null) { return Result.error(用户名已被注册); } User user new User(); user.setUsername(dto.getUsername()); user.setPassword(BCrypt.hashpw(dto.getPassword(), BCrypt.gensalt())); user.setNickname(dto.getNickname() null ? dto.getUsername() : dto.getNickname()); userMapper.insert(user); return Result.success(注册成功); }电商系统一般还有手机号验证码注册、扫码登录这些玩法今天先把基础账号密码版搞定后续想加手机号登录核心想法不变验证码校验通过后同样方式生成 token 进 Redis。4. 前端实现VueX 管理登录态 封装请求拦截后端接口有了前端怎么把登录态串起来核心思路是登录接口拿 tokentoken 交给 VueXVueX 配上 localStorage再由 axios 拦截器统一把 token 拼到请求头。这样任何请求都自动携带凭证路由守卫负责检查“没登录别进来”。4.1 VueX store 模块设计VueX 按模块管理逻辑更清晰。store 根目录下建 modules/user.jsconst state { token: localStorage.getItem(token) || , userInfo: JSON.parse(localStorage.getItem(userInfo) || {}) } const mutations { SET_TOKEN(state, token) { state.token token localStorage.setItem(token, token) }, SET_USER_INFO(state, userInfo) { state.userInfo userInfo localStorage.setItem(userInfo, JSON.stringify(userInfo)) }, CLEAR_STATE(state) { state.token state.userInfo {} localStorage.removeItem(token) localStorage.removeItem(userInfo) } } const actions { async login({ commit }, loginForm) { const { data } await loginApi(loginForm) if (data.code 200) { commit(SET_TOKEN, data.data.token) commit(SET_USER_INFO, { nickname: data.data.nickname, avatar: data.data.avatar }) } return data }, logout({ commit }) { // 退出要调后端接口让token失效 return logoutApi().finally(() { commit(CLEAR_STATE) }) } } export default { namespaced: true, state, mutations, actions }为什么要模块化因为商城项目后面还要维护购物车状态、订单状态、商品浏览记录全堆在根 store 里几百行代码看得头大。拆成模块后一个 store 文件只维护一份职责配合 namespaced: true组件里调用this.$store.dispatch(user/login)语义清晰。刷新页面时VueX 数据没了但 localStorage 还在。模块初始化 state 时直接从 localStorage 取这就是“刷新不掉登录态”的核心前端拿 localStorage 的 token 继续访问后端后端从 Redis 校验 token 仍有效整个会话就衔接上了。4.2 axios 拦截器与 token 传递请求封装的核心代码// utils/request.js import axios from axios import { Message } from element-ui import router from /router const service axios.create({ baseURL: process.env.VUE_APP_BASE_API, timeout: 10000 }) // 请求拦截器自动带上token service.interceptors.request.use(config { const token localStorage.getItem(token) if (token) { config.headers[Authorization] Bearer token } return config }, error { return Promise.reject(error) }) // 响应拦截器统一处理错误状态 service.interceptors.response.use(response { const res response.data // 业务错误 if (res.code ! 200) { Message.error(res.message || 请求失败) if (res.code 401) { // token过期 localStorage.clear() router.push(/login) } return Promise.reject(new Error(res.message)) } return res }, error { if (error.response error.response.status 401) { Message.error(登录状态已过期请重新登录) localStorage.clear() router.push(/login) } else { Message.error(网络异常请稍后重试) } return Promise.reject(error) }) export default service请求拦截器解决“token 怎么带”的问题响应拦截器解决“token 失效怎么办”的问题。后端如果发现 token 无效返回 401 状态码前端拦截到后清空本地信息跳转登录页这个 UX 顺滑度很关键否则用户会看到一个红条报错而不知道要重新登录。4.3 登录页逻辑登录页模板就不赘述了核心是提交逻辑async handleLogin() { this.$refs.loginForm.validate(async valid { if (!valid) return this.loading true try { const res await this.$store.dispatch(user/login, this.loginForm) if (res.code 200) { this.$message.success(登录成功) // 跳转到重定向的页面没有则回首页 const redirect this.$route.query.redirect || / this.$router.push(redirect) } } finally { this.loading false } }) }登录成功后跳哪很多新手直接写router.push(/)固定跳首页但用户是从某个页面被拦到登录页的登录完应该回到他原本想去的地方。Vue Router 提供了路由守卫里的 redirect 参数登录成功判断一下体验高下立判。无验证码的登录页在商城项目里其实不太安全。加验证码的思路后端生成图片 base64 一个 uuid 标识Redis 存验证码值前端提交时带上 uuid 和填写的验证码。这期先不铺开后面单独写一期“验证码接入 Redis 的正确姿势”。4.4 路由守卫别让未登录用户乱闯路由守卫是登录态的最后一道防线写在 router/index.jsconst whiteList [/login, /register, /home, /goods] router.beforeEach((to, from, next) { // 已登录状态 if (localStorage.getItem(token)) { if (to.path /login) { next({ path: from.path || / }) } else { next() } } else { // 白名单直接放行 if (whiteList.indexOf(to.path) ! -1) { next() } else { next(/login?redirect${to.fullPath}) } } })注意守卫里判断是否登录依据的是 localStorage 还是 VueX都能用但推荐用 localStorage 做判断。原因刷新后 VueX 初始数据本来就是从 localStorage 读的直接读 localStorage 更保险还避免了一个潜在时序问题——VueX 还没初始化完毕就去读读到空值误判。whiteList 很值得一说。商城首页、商品列表这些 B 端页面不需要登录也能看但购物车、结算、个人中心必须登录。哪些接口要登录不只是路由层面拦截后端也要做鉴权。有个常见的“前端防了后端没防”的情况——用户直接用 Postman 调接口后端没校验直接返回数据。所以后端必须每条接口做 token 校验前端路由守卫只是用户体验层面的优化真正的安全边界在后端。5. 后端登录拦截器让受保护接口强制校验 token大家常犯的一个错误是只在 Controller 里手动判断“头部有没有 token”这又陷到“每条接口写一遍”的重复劳动里。正确姿势是做一个拦截器Interceptor统一处理。SpringBoot 里注册一个 HandlerInterceptor配置到 WebMvcConfigurer 的拦截器注册表里。5.1 登录拦截器代码设计Component public class LoginInterceptor implements HandlerInterceptor { Autowired private StringRedisTemplate stringRedisTemplate; Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 放行预检请求 if (OPTIONS.equalsIgnoreCase(request.getMethod())) { return true; } String authHeader request.getHeader(Authorization); if (StringUtils.hasText(authHeader) authHeader.startsWith(Bearer )) { String token authHeader.substring(7); String userId stringRedisTemplate.opsForValue().get(login:token: token); if (userId ! null) { // 用户ID存进 request 上下文后续接口直接用 request.setAttribute(userId, userId); return true; } } // 校验失败返回401 response.setStatus(401); response.setContentType(application/json;charsetUTF-8); response.getWriter().write({\code\:401,\message\:\未登录或登录已过期\}); return false; } }关键点OPTIONS 预检请求必须放行。前后端分离项目跨域请求会先发一个 OPTIONS 请求浏览器预检服务器允不允许跨域。如果不放行前端所有非简单请求都会被 CORS 拦死表现就是“明明后端在跑前端却一直报跨域错误”。校验通过后把 userId 塞到 request attribute后续 Controller 直接取RestController RequestMapping(/user) public class UserController { GetMapping(/info) public ResultUserInfoVO info(HttpServletRequest request) { String userId (String) request.getAttribute(userId); User user userService.getById(userId); // ... return Result.success(vo); } }不用每次都在接口方法里写“获取 token 再解析”拦截器把脏活干完了业务代码只关注“当前是谁”。5.2 注册拦截器与放行白名单Configuration public class WebMvcConfig implements WebMvcConfigurer { Resource private LoginInterceptor loginInterceptor; Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(loginInterceptor) .addPathPatterns(/**) .excludePathPatterns( /user/login, /user/register, /user/captcha, /goods/**, /home/** ); } }.excludePathPatterns的作用和前端 whiteList 异曲同工登录注册这些端点不需要 token 就能访问商品查询、首页聚合数据这种展示型接口不登录也放行。如果你做的是纯后台管理系统所有接口都要求登录那就把.addPathPatterns(/**)留着不放行任何必登录白名单外的东西。注意excludePathPatterns 里写的是路径规则支持通配符/goods/**别配成/goods/*前者只匹配一层后者匹配多层。这两个写错的人不在少数。5.3 全局 CORS 解决跨域配置拦截器好办跨域配置也顺手做了。写一个 CorsFilter Bean 或实现 WebMvcConfigurer 的 addCorsMappings。我用的是 WebMvcConfigurer 实现类里加这段Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping(/**) // 前端地址 .allowedOrigins(http://localhost:8080) .allowedMethods(GET, POST, PUT, DELETE, OPTIONS) .allowedHeaders(*) .allowCredentials(true) .maxAge(3600); }allowedOrigins 要注意端口必须写对。写http://localhost:8080但前端跑在http://localhost:8081就跨域失败。生产环境建议用配置项读取这里不写死。6. 前后端联调登个录要跨几道坎代码全写完了前后端联调才是真正考验耐心的时候。把最常见的几个问题列个“排雷手册”对照排查能省一大半时间。6.1 VueX 数据持久化遗漏问题现象登录成功后刷新页面用户信息全没了路由守卫还把人踢回登录页。排查思路先看 localStorage 里到底有没有 token。有 token 但 VueX 状态是空的多半是 state 初始化没从 localStorage 读连 token 都没了说明登录成功后没写 localStorage。我见过有人把 token 存 VueX 就完事了忘了 localStorage 同步刷新必掉线。老话重提localStorage是 VueX 的持久化层两者必须同步。6.2 Redis key 乱码与过期时间误区现象Redis Desktop Manager 里看到\xac\xed\x00开头的 key数据也读不出来。排查思路检查代码里是用了 RedisTemplate 的默认序列化器还是 StringRedisTemplate。默认的 JdkSerializationRedisSerializer 就会这样换成 StringRedisTemplate 即可。另一个高频误区Redis 过期时间单位是秒很多人写成60*30忘了单位导致 30 毫秒过期用户刚登录完第二次请求就被弹出去。也别用set(key, value)不带过期时间那 token 永远不过期清不掉的“永久登录”隐患很大。6.3 401 状态码误判与路由死循环现象登录页登录成功跳转首页路由守卫又给踢回登录页死循环。排查思路路由守卫里写“已登录用户访问 login 就跳首页”但判断逻辑有漏洞——比如 token 有值但过期了用户又手动访问 /login守卫看到 token 存在就跳首页首页请求接口返回 401响应拦截器调用 router.push(/login)又触发守卫死循环。根治办法登录成功后清理旧路由记录响应拦截器做 401 处理时判断当前路由不在 /login 再跳转。细节代码参考if (error.response.status 401 router.currentRoute.path ! /login) { localStorage.clear() router.push(/login) }6.4 接口提示“未登录”但前端明明带了 token排查顺序前端浏览器 Network 面板看请求头 Authorization 是否带上 → 后端日志看拦截器有没有进去 → Redis 里手动get login:token:xxx看 key 是否存在。这三个点逐个排查一般都能定位。最常见原因是登录接口被路由守卫拦截了压根没送出去其次就是 CORS 预检请求没放行非预检测试接口都跨域失败。7. 会话过期、登出与“记住我”差异化设计登录不只有“进”还得有“出”和“过期处理”。这块做不好用户会骂产品“登录状态总掉”“退出登录怎么没反应”。7.1 登出功能双端清理退出登录不能只清前端 localStorage。如果 token 还在 Redis 里存活任何人拿到这个 token 还能继续调用接口账号处于“假退出”状态。登出接口逻辑PostMapping(/logout) public ResultString logout(HttpServletRequest request) { String authHeader request.getHeader(Authorization); if (StringUtils.hasText(authHeader) authHeader.startsWith(Bearer )) { String token authHeader.substring(7); // 删除Redis中的会话 stringRedisTemplate.delete(login:token: token); } return Result.success(退出成功); }前端登出先调接口再清本地状态async logout() { try { await logoutApi() } finally { this.$store.commit(user/CLEAR_STATE) this.$router.push(/login) } }顺序上即使后端 delete 失败前端也要清掉本地 token保证用户侧状态一致性。7.2 记住我功能的实现技巧登录页勾选“记住我”本质是调整 token 有效期。记住我时 7 天过期不记时 30 分钟过期。后端登录接口接收一个 rememberMe 字段long expire dto.getRememberMe() ! null dto.getRememberMe() ? 60 * 60 * 24 * 7 : 60 * 30;存 Redis 时用变量 expire 替代固定的 1800 秒即可。注意过期时间太长确实方便但安全风险也高。如果这是电商后台涉及资金操作建议默认短过期、敏感操作二次验证。7.3 登录状态过期前端怎么优雅处理token 过期后用户点某个按钮接口返回 401响应拦截器直接弹“登录过期”踢到登录页这种体验其实挺生硬的。优雅一点的方案拦截器里遇到 401弹一个确认框“登录已过期是否重新登录”点击确认再跳登录页。移动端可以弹 toast 跳转。另外页面停着不动、token 在后台悄悄过期用户回来发现明明停在自己看过的页面上一点击却被弹走那体验约等于“白干了”。局部校验、静默刷新这类功能属于进阶话题跟网关、权限模型放以后单独聊。8. 最终代码结构与后续扩展思路完整跑一遍后项目的目录结构大概是这个形态给你对照检查别漏文件后端 ├── config │ ├── WebMvcConfig.java │ └── RedisConfig.java ├── interceptor │ └── LoginInterceptor.java ├── controller │ ├── UserController.java │ └── GoodsController.java ├── service │ └── UserService.java ├── mapper │ ├── UserMapper.java │ └── UserMapper.xml └── entity / dto / vo ├── User.java ├── LoginDTO.java ├── RegisterDTO.java └── LoginVO.java 前端 ├── src │ ├── api │ │ ├── login.js │ │ └── user.js │ ├── store │ │ └── modules │ │ └── user.js │ ├── router │ │ └── index.js │ ├── utils │ │ └── request.js │ └── views │ ├── login │ │ └── index.vue │ └── profile │ └── index.vue写代码容易写好边界很难。很多人做到“登录功能能跑通”就转去做购物车、商品列表了但登录是后续所有“需要知道当前用户”功能的地基——购物车归属哪个用户、订单用谁的地址、优惠券谁领的、操作日志记录谁做的。这期把数据库、Redis、VueX、拦截器、路由守卫一条链路串下来的方案正好支撑这些后续功能无缝复用。想快速验收成果建议从“注册新用户 → 登录 → 刷新页面 → 停 30 分钟 → 再访问受保护接口被踢出 → 重新登录”这条完整链路测一遍把所有断点记下来挨个排查。最后再留一个小彩蛋现在登录只用账号密码商城项目真正上线时大概率要接第三方登录。你可以在现有框架上扩展一个 OAuth 登录入口用户在第三方回调后用同样的逻辑生成 token 写入 Redis前端走同一套 VueX 路由守卫业务代码零改动。这就是把登录逻辑收敛得干净、统一的好处。
返回列表