首页 / 资讯中心 / 文章详情

Java登录页面开发全流程:从技术选型到跨域安全踩坑复盘

Java登录页面开发全流程:从技术选型到跨域安全踩坑复盘 ★ FEATURED ARTICLE
大家别被“登录页面”这三个字骗了。作为一个用Java写过多年后端的老码农我见过太多项目在登录这个“最简单”的模块上翻车要么是密码明文存数据库被安全审计直接打回要么是前后端联调时Session怎么都取不到要么是换个浏览器就登录态失效用户骂声一片。写一个能跑通的登录页面确实不难但写一个在生产环境里稳定、安全、可维护的登录流程里面的门道比你想象中要多得多。这篇文章我会完整复盘一次用Java生态实现登录页面的全过程包括技术方案怎么定、代码怎么分层、以及我实际开发过程中踩过的那些坑——不是“看文档就能避开的坑”而是那种查半天资料才发现根因很隐蔽的问题。我把排查过程也一并写出来希望你能顺着我的思路走一遍而不是只拿到一个最终答案。1. 登录页面的技术方案怎么定一次选择后面全是连锁反应先说结论登录页面的实现方式决定了你后续要面对多少复杂度。这一步选型没做好后面所有“困难”都会提前爆发。我当时接手的项目是一个某企业内部的管理系统需求方要求登录页面要美观、要支持记住账号、要有验证码后续还规划了第三方扫码登录。项目整体用的是Java技术栈前端团队只有一个人后端有三个人。摆在面前的有两条路线路线A服务端渲染Thymeleaf / JSP Spring MVC登录页由后端模板引擎直接渲染表单提交也是直接提交到后端Controller。优点是架构简单Session天然可用CSRF防护容易做适合没有专职前端的团队。缺点是页面交互能力弱如果要做得太“现代”会很别扭。路线B前后端分离Vue/React Spring Boot 纯接口后端只提供JSON接口前端SPA负责页面渲染登录态通过Token或Cookie维护。优点是前后端可以并行开发界面交互上限高后续多端复用接口方便。缺点是引入跨域、Token刷新、拦截器放行规则、CORS配置等一系列并发症团队没有经验的话光是联调就能磨掉一周。我当时选择的是“折中方案”登录页本身用Thymeleaf服务端渲染登录成功后跳转到系统主页主页及后续功能模块逐步引入前后端分离。这个决策的出发点很简单——登录页是整个系统的门面它需要最稳定、最少依赖而服务端渲染的登录页就像一个只负责开门和验票的保安没有多余的表演反而出问题概率最低。注意不要因为网上都在吹前后端分离就无脑跟风。你的团队如果前端能力弱、项目周期紧、登录页交互不复杂服务端渲染就是最务实的方案。技术选型先看团队现状再看项目需求最后才看技术潮流。选型确定之后我列了一个实现清单用户实体与数据库表设计用户表、角色表这个项目暂时只做账号密码登录密码加密和校验选定BCrypt后面细说为什么验证码生成与校验Java原生实现不引入第三方服务登录接口与登录拦截器核心业务逻辑登录页面模板与静态资源HTML、CSS、JavaScript少量交互会话管理与登录态保持Session Cookie以及“记住我”功能说实话这个清单里的每一项单拿出来都不算难但合在一起之后问题就出现了。下面按顺序讲。2. 从数据库表到登录校验用户登录的完整实现链路现在的需求很简单——用户通过输入框提交用户名、密码、验证码服务端校验通过后进入系统主页面未登录用户访问任何系统页面都会被拦截并跳回登录页。2.1 用户表设计的几个关键字段登录功能的数据基础是用户表。我见过有人图省事把性别、年龄、手机号全塞进去结果接口还没写完表就推倒重建了。登录场景下用户表最核心的字段是这几个CREATE TABLE sys_user ( id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT 主键, username VARCHAR(64) NOT NULL UNIQUE COMMENT 登录账号, password VARCHAR(100) NOT NULL COMMENT BCrypt加密后的密码, nickname VARCHAR(64) COMMENT 显示名称, status TINYINT NOT NULL DEFAULT 1 COMMENT 状态1启用 0禁用, last_login_time DATETIME COMMENT 最后登录时间, created_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ) COMMENT 系统用户表;注意几个细节password字段长度我特意设计成了100。BCrypt加密结果长度一般为60但我预留了空间防止以后换更长的算法时还要改表。加了一个status字段这很重要——“该用户被管理员禁用”这种需求在登录校验时就要拦住而不是等用户登录成功后才提示无权限。last_login_time用来记录登录时间既方便排查用户安全问题也是运营侧的数据参考。2.2 密码存储为什么我放弃了MD5和SHA很多初学者写登录功能时密码都是MD5(password)塞进数据库。这个做法在2024年就是给黑客送人头。MD5和SHA1这类摘要算法本质上是为了“校验文件完整性”设计的它们的计算速度非常快。这意味着什么意味着攻击者拿到数据库后可以用GPU每秒暴力尝试数十亿次密码组合配上彩虹表简单的密码几乎瞬间就被还原。正确的做法是使用BCrypt或scrypt、Argon2这类慢哈希算法。它们故意设计了计算成本参数让每次哈希计算需要几十毫秒甚至上百毫秒。单个密码验证慢几十毫秒用户感知不到但攻击者想暴力穷举就难如登天了。而且BCrypt自带随机盐相同密码每次加密结果都不一样彩虹表直接失效。我在项目里用的正是Spring Security内置的BCryptPasswordEncoder。不过当时没有直接引入完整的Spring Security——项目还不需要那么重的安全框架我只需要它的密码加密工具类。所以我在pom.xml里只加了Spring Security Crypto模块的依赖dependency groupIdorg.springframework.security/groupId artifactIdspring-security-crypto/artifactId version5.7.11/version /dependency加密和校验的代码很直观import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; public class PasswordUtil { private static final BCryptPasswordEncoder ENCODER new BCryptPasswordEncoder(); // 用户注册/创建时加密 public static String encode(String rawPassword) { return ENCODER.encode(rawPassword); } // 登录时校验 public static boolean matches(String rawPassword, String encodedPassword) { return ENCODER.matches(rawPassword, encodedPassword); } }注意虽然只引入了crypto模块但它的传递依赖可能带着一些不需要的类如果项目对依赖体积敏感也可以自己单独引入jBCrypt库。我选这个是图省事反正都在Spring生态内。2.3 登录逻辑的分层设计与核心代码登录业务放在Service层Controller只做参数接收和结果转发。这个分层不是形式主义——后面排查问题的时候你会发现清晰的分层能让你快速定位问题在哪个环节。我写了一个LoginService核心登录校验流程如下Service public class LoginService { Autowired private UserMapper userMapper; // 验证码校验放在Controller层做Service只处理账号密码 public SysUser authenticate(String username, String rawPassword) { if (StringUtils.isBlank(username) || StringUtils.isBlank(rawPassword)) { throw new BusinessException(用户名和密码不能为空); } SysUser user userMapper.findByUsername(username.trim()); if (user null) { // 刻意不区分“用户不存在”和“密码错误”防止用户枚举 throw new BusinessException(用户名或密码错误); } if (user.getStatus() 0) { throw new BusinessException(账号已被禁用请联系管理员); } boolean passwordMatched PasswordUtil.matches(rawPassword, user.getPassword()); if (!passwordMatched) { throw new BusinessException(用户名或密码错误); } // 登录成功更新最后登录时间异步做避免拖慢响应 userMapper.updateLastLoginTime(user.getId()); return user; } }Controller层要做的就是把HTTP请求参数绑定进来调用Service返回结果然后处理会话逻辑Controller RequestMapping(/login) public class LoginController { Autowired private LoginService loginService; Autowired private CaptchaService captchaService; // 显示登录页 GetMapping(/page) public String loginPage() { return login; } // 处理登录请求 PostMapping(/doLogin) public String doLogin(LoginForm form, RequestParam(captchaInput) String captchaInput, HttpSession session, Model model) { // 1. 校验验证码 String sessionCaptcha (String) session.getAttribute(captcha); if (sessionCaptcha null || !sessionCaptcha.equalsIgnoreCase(captchaInput.trim())) { model.addAttribute(errorMsg, 验证码错误); return login; } // 验证码一次性使用无论对错先移除 session.removeAttribute(captcha); // 2. 账号密码校验 try { SysUser user loginService.authenticate(form.getUsername(), form.getPassword()); // 3. 会话写入 session.setAttribute(LOGIN_USER, user); session.setMaxInactiveInterval(30 * 60); // 30分钟 return redirect:/index; } catch (BusinessException e) { model.addAttribute(errorMsg, e.getMessage()); return login; } } }这一段逻辑看着没什么但真正让我折腾到半夜的问题恰恰就藏在这些看似理所当然的代码里。3. 第一个大坑登录成功却被“拦截”重定向回登录页的递归陷阱功能写完部署到测试环境我自己点了一遍登录页能打开、验证码能刷新、账号密码正确时能跳转index页面。但接下来一个诡异的事情发生了——登录成功后跳转到index页面还没等我点任何菜单页面又被弹回了登录页而且地址栏的URL变成了这样的结构http://localhost:8080/login/page?redirectUrl/index这个redirectUrl参数我没写过啊哪里来的查了很久才发现根因藏在登录拦截器里。当时我写了一个LoginInterceptor来拦截所有未登录的请求public class LoginInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { HttpSession session request.getSession(false); if (session ! null session.getAttribute(LOGIN_USER) ! null) { return true; } // 未登录重定向到登录页 String loginPageUrl /login/page; response.sendRedirect(request.getContextPath() loginPageUrl); return false; } }这个拦截器本身逻辑没什么问题问题出在注册拦截器时过滤路径的配置Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(new LoginInterceptor()) .addPathPatterns(/**) .excludePathPatterns(/login/page, /login/doLogin, /captcha, /css/**, /js/**); }我以为是/login/page和/login/doLogin都被放行了但实际跑起来才发现——表单提交到/login/doLogin后controller返回的是redirect:/index这个重定向请求是浏览器发起的新请求会再次经过拦截器。如果此时Session里已经有用户就没问题。但就是这个看似没问题的逻辑在我最初调试时暴露了一个更隐蔽的问题。实际上让我掉进坑里的真正原因是静态资源路径没被放行。登录页加载时会请求/css/demo.css和/js/main.js这些路径我确实在excludePathPatterns里写了。但问题是登录页里面引用的资源有的路径带了版本号/css/demo.css?v20231015。你表面上看这还是一个/css/开头的路径但Spring MVC的路径匹配规则里/css/**是可以匹配/css/demo.css?v20231015的。那问题出在哪呢真正的问题是我在HTML里引入了一个图片路径是/image/logo.png。而这个路径既不在静态资源放行列表里也不在登录页放行列表里。于是浏览器加载登录页时向服务器请求logo图片被拦截器拦住重定向到了/login/page而这个重定向请求本身又被拦截器放行了所以静态资源加载变成了一个无限重定向链。浏览器里显示的效果就是登录页一直在刷新页面上的CSS瘫了一半。排查过程用了最笨也最有效的方法——打开浏览器F12 Network面板逐个请求看状态码。我发现图片请求返回302Location指向/login/page而这个/login/page又被302指向自己循环就产生了。最后我在拦截器注册的excludePathPatterns里加上了/image/**问题才消失。经验总结拦截器放行静态资源时不要只放行前几个路径就完事要把所有静态资源目录都列出来。并且登录页引用的每个静态资源都建议在浏览器Network面板里过一遍状态码看到302就要警惕——它可能不是登录跳转而是一个隐藏的循环。4. 第二个大坑验证码在“前进后退”时复用被安全测试直接扣分登录功能第一个版本交付给测试组之后有一个安全测试用例差点让我社死测试人员在一个浏览器里登录一次看到验证码图片然后点“后退”回到登录页再刷新验证码发现验证码居然还是同一个。更糟糕的是同一个验证码多开几个浏览器标签页竟然能重复使用。这个问题的根子在于验证码的存取逻辑。当时我的验证码生成逻辑是这样的GetMapping(/captcha) public void captcha(HttpSession session, HttpServletResponse response) throws IOException { String code CaptchaGenerator.generate(4); session.setAttribute(captcha, code); // 每次生成都覆盖session里的验证码 // 画图片输出到响应流... }表面上看每次请求/captcha都会生成一个新的验证码覆盖旧的登录时校验一次就removeAttribute。逻辑没毛病——但问题出在浏览器对图片URL的缓存策略。当用户点击“后退”时浏览器会从本地缓存里恢复之前的页面包括验证码图片。如果此时图片响应头里没有Cache-Control: no-store这种禁止缓存的标记浏览器就直接用缓存的图片不会重新请求/captcha。那么页面显示的验证码还是Session里之前存的那个。用户多开标签页共享同一个Session自然只要图片缓存不被清掉看到的就是同一个验证码。而且我刚才说的“移除验证码”逻辑也有漏洞removeAttribute只在用户提交表单时执行。如果用户不提交、来回刷页面验证码会一直存在Session里攻击者可以不断尝试同一个验证码。修复方案有两个层面。层面一生成验证码时在响应头里禁止缓存response.setHeader(Cache-Control, no-store, no-cache, must-revalidate); response.setHeader(Pragma, no-cache); response.setDateHeader(Expires, 0);这一行代码必须写在写图片数据之前。加了这个头浏览器每次刷新/后退时都会老老实实到服务器重新请求验证码图片不会再拿缓存顶替。层面二在Session里存一个验证码的“过期时间”而不仅仅是一个字符串// 生成验证码时 session.setAttribute(captcha, code); session.setAttribute(captcha_expire, System.currentTimeMillis() 60_000); // 1分钟有效校验时先判断是否过期过期就直接当错误处理并且无论结果如何都立刻删除。这样就算用户不提交表单验证码也只是在Session里躺一分钟过期后自动失效。关于“验证码图片被缓存导致复用”这个问题不只是登录页有任何带验证码的动态图片都可能中招。设置了Cache-Control: no-store之后几乎不会再复现。5. 第三个大坑Session在跨域场景下“神秘丢失”排查到半夜才发现是Cookie作祟项目最初是单体应用登录没问题。后来需求方要求把系统部署到另一台服务器上并且前端通过一个单独的域名访问接口。为了满足这个部署我把后端接口和页面拆成了两个域名。结果一上线用户在A域名登录成功后跳到B域名时登录态直接消失被拦截器踢回登录页。我当时的第一反应是Session机制崩了。用curl直接调接口带Cookie访问也能正常通过拦截器。但浏览器里就是不行。我甚至怀疑是Nginx代理配置有问题来回改了半小时还不行。最后看了一眼浏览器的Application面板——Cookie压根没有设置成功。原因很简单Cookie是域名级别的你在A域名下设置的Cookie浏览器默认不会带给B域名。我的登录页在login.xxx.com登录成功后跳转到系统主页www.xxx.com浏览器在请求www.xxx.com时根本不带login.xxx.com域下的CookieSession自然拿不到。解决这个问题通常有几套方案方案一把登录页和系统主页面保持在同一个域名下通过路径区分。这是最省事的方式但反过来限制了系统的多域名部署场景。方案二登录成功后不再跳跨域地址而是由前端把用户相关信息比如token在跳转时通过URL参数传给目标域名目标域名的后端校验后重新建立会话。方案三配置Cookie的Domain为父级域名让子域名共享Cookie。因为我们的场景是从login.xxx.com跳到www.xxx.com它们其实是同一个主域名的两个子域名所以方案三最合理为Cookie设置Domainxxx.com。通过Java设置Cookie时需要明确指定Domain属性Cookie cookie new Cookie(SESSION, sessionId); cookie.setDomain(xxx.com); cookie.setPath(/); cookie.setHttpOnly(true); cookie.setMaxAge(-1); // 会话Cookie response.addCookie(cookie);这个坑的核心教训是Session机制依赖Cookie而Cookie是无法跨域携带的。你以为是Session的锅其实是Cookie的锅。排查这类问题时不要一门心思在Java代码和Nginx配置里找先打开浏览器的Application面板看一眼Cookie到底有没有种上、种对了没有能省下大量时间。6. 安全性的最后一道防线密码策略、并发登录与CSRF基础防护登录功能安全方面的深度可以无限做但结合项目实际我至少做了以下三层防护每一层都是拿真实事故换来的经验。6.1 密码强度校验与强制改密有一种登录场景特别危险管理员给员工创建初始账号初始密码通常有规律比如123456或者身份证后几位。员工如果不改密码被撞库只是时间问题。我的做法是用户表中增加一个password_initial布尔字段初始密码登录成功后检查该字段若为真则强制跳转到改密页面改密完成后再清除标记。同时注册时校验密码长度至少8位必须包含字母和数字。public boolean isInitialPassword(Long userId) { // 查询字段后返回 }6.2 登录失败次数限制与锁定暴力破解是最常见的攻击手段。我在sys_user表上加了login_fail_count和lock_time两个字段。登录失败时递增失败次数连续失败5次后锁定账号15分钟。这里有一个细节锁定的时间点应该存储在数据库中而不是Session里否则用户换个IP或者清掉Cookie锁就被绕过了。// 认证失败时 userMapper.incrementFailCount(username); if (userMapper.getFailCount(username) 5) { userMapper.lockAccount(username, LocalDateTime.now().plusMinutes(15)); } // 认证成功时 userMapper.resetFailCount(username);6.3 CSRF防护从根源上阻止跨站请求伪造登录接口天然是CSRF攻击的重点目标——攻击者可以伪造一个表单诱导用户访问自动提交到你的登录接口用受害者的身份登录攻击者自己的账号后续就能诱导受害者执行一些操作。对于登录中心这种关键接口我加了一个简单的Spring Security CSRF Token机制。最简单的做法在Session里存储一个随机Token在渲染登录表单时把Token写入隐藏字段input typehidden name_csrf th:value${session.csrfToken}/登录提交时比对请求参数和Session中的值不一致直接拒绝。这样就保证了表单是从当前页面提交过来的而不是外部伪造的。不过要小心的坑是如果你的登录页面做的是“记住密码”自动登录或验证码刷新等异步操作CSRF Token可能会因为Session的改变而失效。所以在实现时要注意Token的生成时机和更新策略避免用户还没输入完密码Token已经换掉了。7. 个人体会登录页面写得好不好决定了你后面几个月的幸福指数整个项目做完复盘时我发现登录页面这个模块在代码量上占比可能不到5%但在前期调试和后期安全测试上消耗的时间却占了整个项目交付周期将近三分之一。原因也很简单登录是所有功能入口它的链路长、涉及节点多——数据库、缓存、网络、浏览器策略、安全机制、跳转逻辑任何一个环节出问题体验都会瞬间暴露在最前线。有人可能会问那为什么不直接用现成的Spring Security框架把登录认证全包了我实话实说Spring Security确实能解决大部分问题但它的配置复杂度和学习曲线也是实打实的。如果你的项目只是内部系统、登录场景简单手写一套反而好维护。如果涉及多因素认证、OAuth2、细粒度权限控制那还是老老实实上Spring Security别自己造轮子。最后放一个我在这次开发里最值钱的心得写登录功能时把Session、Cookie、拦截器、跨域、缓存这几个概念先彻底捋一遍比你多会几个框架的花活重要得多。这几个基础概念虽然老但是登录页面百分之八十的“困难”最终的根因都能落在它们身上。我这次踩过的三个大坑有一个算一个全是基础概念的堆叠。服务器端渲染的登录页配合合理分层的Service逻辑、BCrypt加密、验证码一次性使用、Cookie域设置、以及基础的CSRF防护这套组合拳对于一个常规的Java Web项目完全够用了。代码整洁度、安全性、可维护性都能落地也不至于把自己卷进Spring Security配置地狱里。如果你正在经历登录页面的各种疑难杂症建议从本文提到的几个排查方向入手大概率能少走很多弯路。
阅读完成 · 觉得有帮助?
咨询建站