登录功能看着简单,真正做起来全是细节。这个"用户登录案例"是我最近接手的一个实际项目,从最初"能跑通就行"的demo,到后来被安全评审打回重做,中间踩了不少坑。我把整个思考链路和最终落地方案完整记录下来,希望对正在做登录功能的朋友有帮助。
1. 登录功能为什么值得单独拆出来讲
很多人觉得登录不就是"查一下用户名密码对不对,然后存个标记"吗?确实,一个demo级别的登录十分钟就能写完。但登录是系统的第一道门,它直接面对互联网上的所有流量,包括扫描器、暴力破解脚本、撞库攻击、钓鱼工具,这些我都真实遇到过。
我从输入框收到用户数据开始讲,一直到用户关闭浏览器后再次访问还能保持登录状态,完整梳理一遍这个案例里所有的决策点。读完你会发现,登录牵扯的东西比想象中多得多:密码怎么存、会话怎么维持、接口怎么防刷、多端登录怎么处理、找回密码怎么做、日志怎么记,每一块都有坑。
这个案例适合刚入行的后端开发者、正在写全栈项目的学生,也适合那些"登录已经上线但总觉得心里没底"的团队。我会结合真实项目中踩过的坑来讲,尽量少说教科书废话,多给能直接落地的方案。
2. 需求界定与整体方案设计
2.1 这个登录案例到底要解决什么问题
先明确需求范围。这个项目的背景是一个内部业务系统的前端重构,需要重新实现完整的用户认证链路。业务方提的需求很朴素:用户能注册、能登录、能退出,忘记密码能找回来,最好支持"记住我"。
但如果只做这些,系统上线一周就会被安全测试打回。所以我把需求重新拆了一遍,加上了安全与运维侧的底线要求:
- 密码不能明文存储,即使数据库泄露,密码也不能被直接还原
- 登录接口不能被脚本无限重试,否则就是给暴力破解开了一扇大门
- 会话状态不能被伪造,用户的身份标识不能简单是一个可猜测的自增ID
- 登录过程要有完整审计记录,出问题能定位到人、定位到时间、定位到IP
- 前端做不了真正的安全,安全重心必须放在服务端
2.2 技术栈与整体架构的选择
技术栈是在现有系统约束下选的。后端是Java生态,用的是Spring Boot 2.x,数据库是MySQL,前端是Vue 3。这些是项目既定技术栈,登录认证部分需要融入这个体系,而不是另起炉灶。
在会话方案上,我对比了几种常见做法:
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 传统Session + Cookie | 实现简单,服务端可控性最强,可以随时吊销 | 分布式环境下Session共享麻烦 | 单体应用、内部系统 |
| JWT无状态令牌 | 天然支持分布式,无需共享存储 | 吊销困难,令牌泄露后有效期内的窗口期很被动 | 微服务、跨端API |
| OAuth2/SSO统一认证 | 认证与业务系统解耦,安全度高 | 实施成本大,需要额外的基础设施 | 多系统、大规模企业 |
我最终选择的是Session + Cookie方案,并辅以Redis做会话存储。理由很实际:这是一个单体架构的内部管理系统,用户量级在千人左右,QPS不高,后续可能的微服务拆分还有很长的路要走。在这个阶段,Session方案的控制力是最强的——管理员想踢人下线就踢人下线,想查在线用户就查在线用户,想控制并发登录就控制并发登录。JWT虽然时髦,但在这种场景下,"能随时吊销会话"比"免查询验证令牌"值钱得多。
Redis作为Session存储是因为部署环境里已经有Redis在跑,不需要新增组件。Session数据不放在应用内存里,重启服务不会导致全员掉线,这对生产环境很重要。
2.3 登录流程的设计蓝图
整个登录链路我是这样规划的,每一步都有明确的目的:
- 用户访问登录页,前台先做格式校验(空值、格式、验证码状态)
- 用户点击登录,前端携带账号、密码、验证码标识发起请求
- 后端先校验验证码,验证码不过直接拒绝,避免无意义地查数据库
- 后端根据用户名查用户记录,取出密码哈希值进行比对
- 比对成功,检查用户状态(是否禁用、是否锁定)
- 生成会话标识,写入Redis,同时种下Cookie
- 记录登录日志,异步写入操作审计表
- 返回用户基本信息给前端,前端跳转到业务首页
这个流程看起来很常规,但每个环节都有细节要打磨。下面我逐个环节展开讲。
3. 密码存储与用户表设计——登录安全的地基
3.1 密码加盐哈希到底是怎么做的
密码存储是整个登录安全的核心。任何一套登录系统,密码泄露的风险都不取决于密码本身,而取决于数据库泄露后密码被还原的难度。
这里有个常见的错误认知:很多人觉得"我把密码加密存起来就行",于是用AES、DES之类的对称加密算法加密密码。这完全错了。加密是可逆的,攻击者拿到密钥就能还原所有密码;正确的做法是使用不可逆的哈希算法,加上随机盐值,让密码的存储结果是单向的、不可反推的。
推荐使用的是bcrypt算法。相比老的MD5、SHA1这些快速哈希,bcrypt的设计初衷就是"慢"——它通过增加计算成本来拖慢暴力破解的速度。MD5在普通显卡上每秒能算几十亿次,而bcrypt严格限制了这种速度。我选的bcrypt成本因子是10,实测单次哈希计算大约耗时80~120毫秒,对用户体验无感,但让暴力破解的代价提升了几个数量级。
在Spring项目中,我直接用spring-security-crypto包提供的BCryptPasswordEncoder,不需要引入完整的Spring Security框架,只要这个加密工具类就够了。
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; public class PasswordUtil { private static final BCryptPasswordEncoder ENCODER = new BCryptPasswordEncoder(10); // 加密明文密码 public static String encode(String rawPassword) { return ENCODER.encode(rawPassword); } // 校验明文密码与哈希值是否匹配 public static boolean matches(String rawPassword, String encodedPassword) { return ENCODER.matches(rawPassword, encodedPassword); } }注意一个细节:BCryptPasswordEncoder(10)中的10是成本因子,代表着哈希计算迭代的次数是2的10次方。成本因子越大越安全但也越慢,10是当前公认的性价比平衡点。如果业务系统对安全性要求更高(比如金融系统),可以调到12甚至更高,但要注意登录接口的响应时间会明显变长。
3.2 用户表字段设计的取舍
用户表是登录功能的数据底座,字段设计直接影响后面所有功能。这个案例中,用户表的关键字段如下:
CREATE TABLE sys_user ( id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '用户ID', username VARCHAR(64) NOT NULL UNIQUE COMMENT '用户名,登录标识', password_hash VARCHAR(128) NOT NULL COMMENT '密码哈希值', nickname VARCHAR(64) DEFAULT NULL COMMENT '昵称', avatar_url VARCHAR(255) DEFAULT NULL COMMENT '头像地址', email VARCHAR(128) DEFAULT NULL COMMENT '邮箱,可用于找回密码', phone VARCHAR(20) DEFAULT NULL COMMENT '手机号', status TINYINT NOT NULL DEFAULT 1 COMMENT '账号状态: 1正常, 0禁用', failed_login_count INT NOT NULL DEFAULT 0 COMMENT '连续登录失败次数', locked_flag TINYINT NOT NULL DEFAULT 0 COMMENT '锁定标记: 1锁定, 0未锁定', lock_time DATETIME DEFAULT NULL COMMENT '锁定时间', last_login_time DATETIME DEFAULT NULL COMMENT '最后登录时间', last_login_ip VARCHAR(64) DEFAULT NULL COMMENT '最后登录IP', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='系统用户表';几个字段设计的思路值得说明:
password_hash字段长度设置为128,虽然bcrypt输出的哈希固定是60字符,但留出空间是为了将来算法升级。如果哪天要换成scrypt或argon2,不需要改表结构。
failed_login_count和locked_flag是为了应对暴力破解的锁定机制。后面登录接口的逻辑会详细讲,但字段设计阶段就要留好位置。
status字段用于账号禁用。很多人用删除标记代替禁用,这导致账号无法追溯。禁用是一个安全操作,应该保留账号记录,只是不允许登录而已。
last_login_time和last_login_ip看似可有可无,但实际上非常有价值。用户自己能看到"上次登录时间",一旦发现异常登录可以及时改密;管理员排查问题时也需要这些信息。
3.3 为什么不能直接用用户名查数据库
这个点比较容易被忽略。传统登录验证逻辑是这样的:前端传来用户名和密码,后端SELECT * FROM user WHERE username = ?,查到记录后取出密码哈希进行比对。
问题出在哪?当用户名不存在时,后端会立刻返回"用户名不存在";当密码错误时,返回"密码错误"。这个差异,就可以被工具用来探测哪些用户名是真实存在的。配合上一组常用密码,攻击者就能锁定攻击目标,这就是"用户名枚举"攻击。
更好的做法是:无论用户名是否存在,都执行一次密码学意义上的比对,并且返回完全相同的提示信息。具体实现上,当用户不存在时,用一个预先计算好的"虚拟密码哈希"执行一次比对(结果当然是失败),让接口耗时和真实用户比对基本一致。提示信息统一为"用户名或密码错误"。
这个方案无法完全消除时间侧信道,但能拉平绝大多数情况下的响应差异。在安全测试看来,一个不泄露用户名是否存在登录接口,才算是合格的设计。
4. 登录接口的后端实现——从验证码到会话建立
4.1 验证码:第一道防线要怎么设计
验证码的目的是防止脚本批量尝试密码。现在主流有两种验证码:传统图形验证码(算是半弱的防线)和行为验证码(需要集成第三方SDK,比如滑块拼图)。这个案例是内部系统,没有接入第三方验证码服务的预算,所以采用了传统的图形验证码方案。
技术选型上,使用开源的easy-captcha库生成图片验证码。逻辑是这样的:
- 前端加载登录页时,调用验证码生成接口
- 后端生成图片,并把验证码文本存入Redis,key为
captcha:uuid,value为验证码文本,有效期120秒 - 后端返回图片base64字符串和uuid给前端
- 用户输入验证码后,登录请求带上uuid和验证码文本
- 后端先取
captcha:uuid对比,比对成功则删除这个key,确保验证码只能使用一次;比对失败则拒绝登录
关于验证码存储位置,必须强调一点:验证码不能放在前端代码或者签名里,那等于没有验证码。一定要放服务端(Redis),并且设置短有效期。
验证码生成接口的大致实现:
@GetMapping("/captcha") public Result<CaptchaVO> getCaptcha() { // 生成算术验证码:比如 23 + 45 = ? ArithmeticCaptcha captcha = new ArithmeticCaptcha(130, 48); captcha.setLen(2); // 算术式长度 String uuid = UUID.randomUUID().toString().replace("-", ""); String code = captcha.text(); // 运算结果,比如 68 // 存入Redis,5分钟有效 redisTemplate.opsForValue().set("captcha:" + uuid, code, 5, TimeUnit.MINUTES); CaptchaVO vo = new CaptchaVO(); vo.setUuid(uuid); vo.setBase64(captcha.toBase64()); return Result.success(vo); }算术验证码比纯字符验证码好一点的地方在于:它不需要额外的OCR库来处理字体变形,纯字符的干扰线、扭曲效果容易被识别算法破解,算术式因为要理解"加号"和"等号"的语义,破解成本稍高一些。
实际使用中我发过一个问题:验证码图片生成的时候,如果字体渲染偶发异常,会出现"验证码无法辨认但答案其实是正确"的情况,用户重试几次就抱怨了。这个问题的根源是字体库缺失,容器环境里执行apt-get install fonts-dejavu-core安装字体包就能解决。
4.2 登录接口的完整逻辑与防暴力破解
登录接口的完整实现核心是"层层校验,顺序有讲究"。我按这个顺序写的:
@PostMapping("/login") public Result<LoginVO> login(@RequestBody LoginRequest request, HttpServletResponse response) { // 第一层:验证码校验 String captchaKey = "captcha:" + request.getCaptchaUuid(); String captchaValue = redisTemplate.opsForValue().get(captchaKey); if (captchaValue == null || !captchaValue.equalsIgnoreCase(request.getCaptchaCode())) { return Result.error("验证码错误或已过期"); } redisTemplate.delete(captchaKey); // 验证码一次性使用 // 第二层:账号锁定校验 User user = userMapper.selectByUsername(request.getUsername()); if (user == null) { // 执行一次虚拟密码比对,拉平接口耗时 passwordEncoder.matches(request.getPassword(), DUMMY_HASH); return Result.error("用户名或密码错误"); } if (user.getLockedFlag() == 1) { // 检查锁定是否过期(比如锁定30分钟) if (user.getLockTime() != null && lockExpired(user.getLockTime())) { unlockUser(user.getId()); // 自动解锁 } else { return Result.error("账号已锁定,请30分钟后再试"); } } // 第三层:密码校验 if (!passwordEncoder.matches(request.getPassword(), user.getPasswordHash())) { handleLoginFailure(user); // 失败计数+1,超过5次锁定 return Result.error("用户名或密码错误"); } // 第四层:账号状态校验 if (user.getStatus() == 0) { return Result.error("账号已被禁用,请联系管理员"); } // 第五层:成功后的清理与登录处理 resetLoginFailures(user.getId()); createSessionAndSetCookie(user, request, response); return Result.success(LoginVO.from(user)); }密码校验失败时的处理函数,是防暴力破解的关键:
private void handleLoginFailure(User user) { int count = user.getFailedLoginCount() + 1; if (count >= 5) { userMapper.lockUser(user.getId(), 1, new Date()); userMapper.resetFailedLoginCount(user.getId()); } else { userMapper.updateFailedLoginCount(user.getId(), count); } }锁定策略是5次失败锁定30分钟。为什么是30分钟不是更久?因为攻击者可以换账号、换IP来绕过单个账号的锁定,太长的锁定时间反而会让正常用户被攻击者恶意锁定(管理员能通过这套机制做账户冻结),30分钟是一个折中——既让暴力破解的节奏被打断,又不至于误伤太深。
另外,配合IP维度的限制:同一个IP在15分钟内失败超过20次,直接拒绝该IP的登录请求半小时。这个记录可以放Redis,用IP做key,失败计数做value。实际的攻击脚本很少在一个IP上耗着,绝大多数是分布式代理池,所以账号锁定加IP限速双管齐下,防御效果比单一手段好很多。
4.3 Session创建与Cookie设置
密码校验通过后,就是创建会话、种Cookie的环节。在Spring Boot里,原生的HttpSession可以直接用,但如果想控制会话的粒度、便于后续拆分布式,建议自己管理。
我这个案例的Session结构很简单:用户登录成功后,生成一个随机的sessionId(UUID),把userId、用户基本信息、登录时间存进Redis,key格式为session:{sessionId},有效期为2小时。然后把sessionId写入Cookie,Cookie中还要设置以下属性:
httpOnly=true:禁止JavaScript读取Cookie,有效防范XSS攻击窃取会话sameSite=Lax:大部分跨站请求不携带Cookie,降低CSRF攻击面secure:仅当部署环境启用HTTPS时开启,否则登录会被远程抓包截获
Redis中的会话数据写入时,需要注意序列化格式。我用的是Fastjson序列化后存入STRING类型,而不是直接用RedisTemplate的JDK序列化。这样存储的数据可读、可控,排查问题时直接用客户端看Redis一眼就知道是什么。
private void createSessionAndSetCookie(User user, LoginRequest request, HttpServletResponse response) { // 1. 生成会话ID String sessionId = UUID.randomUUID().toString().replace("-", ""); // 2. 组装会话数据(主要存用户ID和登录元信息) SessionData sessionData = new SessionData(); sessionData.setUserId(user.getId()); sessionData.setUsername(user.getUsername()); sessionData.setLoginTime(new Date()); sessionData.setLoginIp(request.getIp()); // 3. 写入Redis,过期时间2小时 String sessionJson = JSON.toJSONString(sessionData); redisTemplate.opsForValue().set("session:" + sessionId, sessionJson, 2, TimeUnit.HOURS); // 4. 种Cookie Cookie cookie = new Cookie("SESSION_ID", sessionId); cookie.setHttpOnly(true); cookie.setPath("/"); cookie.setMaxAge(2 * 60 * 60); // 单位是秒 cookie.setSecure(request.isHttps()); response.addCookie(cookie); // 5. 更新用户最后登录信息 userMapper.updateLastLoginInfo(user.getId(), new Date(), request.getIp()); }这里还得加一个冗余设计:一个用户可能从多个浏览器或设备登录。要不要限制单点登录?业务方明确要求"允许同账号多端登录,但同一端重复登录要踢掉前面的"。这个需求用Redis来实现很容易——每个用户在Redis里维护一个登录设备集合user_sessions:{userId},新登录时如果同一个 "设备标识"(前端生成的唯一设备ID)已有会话,就把旧的sessionId删除。设备标识由前端生成并保存在本地存储里。
4.4 登录认证拦截器的实现
会话建好了,需要在每个需要登录才能访问的接口上做校验。Spring Boot里用拦截器实现最方便:
public class LoginInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 放行登录相关接口(登录、验证码、注册) String uri = request.getRequestURI(); if (uri.startsWith("/api/auth/")) { return true; } // 从Cookie中获取会话ID Cookie[] cookies = request.getCookies(); if (cookies != null) { for (Cookie cookie : cookies) { if ("SESSION_ID".equals(cookie.getName())) { String sessionId = cookie.getValue(); String sessionJson = redisTemplate.opsForValue().get("session:" + sessionId); if (sessionJson != null) { // 解析会话数据,放入ThreadLocal供后续使用 SessionData sessionData = JSON.parseObject(sessionJson, SessionData.class); UserContext.set(sessionData); // 滑动过期:每次访问刷新过期时间 redisTemplate.expire("session:" + sessionId, 2, TimeUnit.HOURS); return true; } break; } } } // 未登录,返回401 response.setStatus(401); response.setContentType("application/json;charset=UTF-8"); response.getWriter().write("{\"code\":401,\"message\":\"未登录或会话已过期\"}"); return false; } }这里有个容易被忽略的细节:会话滑动过期。会话默认有效期2小时,但并不意味着用户登录2小时后必须重新登录——而是"如果用户连续2小时没有任何操作,才会被踢下线"。所以拦截器每次放行后都会调用expire命令刷新会话的过期时间。这个体验对用户来说最友好:常活跃的登录永远不会莫名断开,闲置的会话才会自动失效。
另一个细节是未登录返回的响应。如果直接返回HTTP 401,前端拦截到401后会统一跳转到登录页,这个设计我在后面讲"登录体验优化"时再展开。
5. 前端登录页与请求交互——体验和安全要两头抓
5.1 登录表单的校验逻辑
前端登录页基于Vue 3 + Element Plus搭建。表单校验放在前端做有一个很实际的作用:减少无意义的请求,把最基础的错误(空值、格式不对)拦截在浏览器里。
const loginFormRef = ref(null) const loginForm = reactive({ username: '', password: '', captchaUuid: '', captchaCode: '' }) const rules = { username: [ { required: true, message: '请输入用户名', trigger: 'blur' }, { min: 3, max: 64, message: '用户名长度应为3到64个字符', trigger: 'blur' } ], password: [ { required: true, message: '请输入密码', trigger: 'blur' }, { min: 6, max: 32, message: '密码长度应为6到32个字符', trigger: 'blur' } ], captchaCode: [ { required: true, message: '请输入验证码', trigger: 'blur' }, { len: 4, message: '验证码长度为4位', trigger: 'blur' } ] }这里需要注意的是,前端校验只是体验优化,真正的规则约束必须依赖后端。比如密码长度6到32位,如果后端不校验,攻击者直接构造超过32字符的请求就能绕到业务层。所以后端接口里仍然要做同样的参数校验,前端校验是为了不打无用请求,后端校验才是安全底线。
5.2 Axios拦截器与登录态管理
前端通过Axios发请求,全局接口封装里做统一拦截:
const service = axios.create({ baseURL: '/api', timeout: 15000 }) // 请求拦截器:可在这里附加公共参数,比如是否需要刷新验证码 service.interceptors.request.use(config => { return config }, error => { return Promise.reject(error) }) // 响应拦截器:统一处理401和业务错误 service.interceptors.response.use( response => { const res = response.data if (res.code !== 200) { // 业务错误,统一弹出提示 ElMessage.error(res.message || '请求失败') return Promise.reject(new Error(res.message)) } return res }, error => { if (error.response && error.response.status === 401) { // 会话过期,跳转登录页 router.push('/login') ElMessage.warning('登录已过期,请重新登录') } else { ElMessage.error('网络异常,请稍后重试') } return Promise.reject(error) } )登录成功后,前端要把用户信息存到Pinia或Vuex里,同时跳转到首页。路由守卫的作用是:当访问一个需要登录的页面而本地没有用户信息时,直接跳转登录页:
router.beforeEach((to, from, next) => { const userStore = useUserStore() if (to.path !== '/login' && !userStore.token) { next('/login') } else { next() } })这里存在一个现实问题:前端本地存的token其实只是Cookie存在性的一个"信心标记"。真正的会话校验完全靠服务端。前端存用户信息是为了渲染界面,但不能依赖前端状态判断登录是否有效——所以路由守卫的跳转只是引导,最终放行与否完全由服务端的401响应决定。
5.3 记住我功能的实现细节
"记住我"是一个很容易做糊的需求。多数人的第一反应是"把Cookie有效期调长一点"。这么做的问题是:记住你就是2小时变30天,安全风险直线上升。
我采用的方案是"令牌自动续期"。用户勾选"记住我"后,登录信息存Cookie,有效期设为7天。但Redis里存的sessionId有效期仍是2小时。关键差异在于:拦截器检测到会话即将过期时,如果Cookie带着"记住我"标记,就自动创建一个新的sessionId并写入新的Cookie——相当于注销后自动重生。
具体逻辑是:
- 登录时写入Cookie时,会写入两个Cookie:
SESSION_ID(2小时有效)和REMEMBER_ME(7天有效,值为1) - 拦截器校验时,如果
SESSION_ID不存在但REMEMBER_ME存在,就重新根据用户ID创建会话 - 重新创建会话的前提是用户属于"可信设备"——为了简化,前端生成的设备标识存在本地,服务端在用户首次登录时记录设备标识与用户的绑定关系
这个方案的体验是:关了浏览器、隔了两天再打开,依然处于登录状态,非常自然。安全上比固定7天session要好,因为每次交互都会刷新短期session,实际风险窗口仍然被压缩在2小时内。
5.4 登录失败与跳转的交互细节
登录失败后的处理,在细节上很容易被忽略。一个是从哪来回哪去——用户之前访问/order/list被踢到登录页,登录成功后应该回到/order/list而不是登录页。这需要在路由守卫里记录redirect参数:
router.beforeEach((to, from, next) => { if (to.path !== '/login' && !userStore.token) { next({ path: '/login', query: { redirect: to.fullPath } }) } else { next() } })登录成功后:
const redirect = route.query.redirect || '/dashboard' router.push(redirect)另一个细节是验证码刷新时机。实际踩过的坑是:用户输入了3次错误密码,每次后端都提示"用户名或密码错误",但验证码不刷新,导致一个验证码被反复用于重试,验证码完全失去了意义。正确的做法是:登录接口收到一次失败的请求后,无论是因为验证码不对还是密码不对,都立即让前端刷新验证码。前端在密码错误时主动调用一次验证码刷新接口,或者后端在返回错误时携带一个captchaExpired字段,前端据此刷新。
这个细节虽然小,但对拦截脚本的效率影响很大——脚本如果持续使用有效验证码尝试登录,暴力破解的成本会低很多。
6. 找回密码与注册流程——容易被忽略的旁路攻击面
6.1 找回密码的安全设计
找回密码是登录系统最容易出安全问题的地方。它的本质是"证明你是账号主人才行"。常见的实现有邮箱验证码、手机短信验证码、密保问题三种方式。这个案例中没有接入短信服务的预算,选了邮箱验证码方案。
整个流程是这样设计的:
- 用户输入邮箱,点击"发送验证码"
- 后端生成6位数字验证码,存入Redis,key为
reset_pwd:{email},有效期10分钟 - 邮件发送异步任务把验证码发到用户邮箱
- 用户点击"重置密码",输入新密码和验证码
- 后端校验验证码,校验通过后重置密码
这个流程有个隐藏风险:任意人只要知道受害者的邮箱,就能不停给受害者发骚扰邮件。所以邮件发送频率要限制:同一个邮箱5分钟内只能发送一次验证码,单IP一天最多发送10次。
另一个关键点:邮箱验证码校验成功后,需要立刻删除Redis里的验证码,防止重放攻击。攻击者截获了邮件里的验证码,在用户改完密码后还能不能再用?如果验证码不删,就能再次使用。所以每次校验成功必须删除,用完即焚。
还有一点容易被忽略:找回密码时也要做账号锁定校验。如果一个账号已经被锁定,就应该拒绝发送验证码。否则攻击者可以绕过锁定限制,通过找回密码直接重置密码继续暴力尝试。我一开始漏了这个逻辑,后来安全评审指出来才补上——锁定状态下,找回密码接口必须拒绝服务。
6.2 注册接口的防滥用设计
注册接口同样是攻击面。脚本可以批量注册垃圾账号,消耗系统资源。这个案例的注册流程做了这些限制:
- 用户名唯一性校验,保留字(例如admin、root、system)不允许注册
- 密码强度校验:至少8位,必须包含大小写字母和数字
- 邮箱格式校验:不允许使用一次性邮箱域名(如mailinator.com等常见的临时邮箱服务)
- 同一IP一天最多注册3个账号
- 注册后账号默认状态为正常,但需要邮箱验证才能激活(防止使用他人邮箱注册后骚扰真实用户)
邮箱验证的激活链接需要设置有效期,我用的是24小时。激活链接里带一个加密token,token里包含了用户ID和过期时间,用HMAC签名防止伪造。
6.3 邮箱验证码发送的异步化处理
邮件发送是IO操作,会阻塞登录接口的响应。这个场景已经够快,不需要额外优化,但如果邮件服务本身响应慢(我这里测试下来平均要200毫秒),就会拖慢找回密码接口。所以在找回密码场景,我把邮件发送逻辑扔进了线程池,核心接口只负责"验证码已发送"的响应,邮件后台慢慢发。
Java里用@Async注解或者手动ExecutorService都可以。线程池的配置需要小心,核心线程数不建议太大(这里两台实例,每台核心线程数配5就够),邮件服务的QPS本来就不高。还要注意异步任务里的异常捕获,邮件发送失败绝不能影响接口的主流程,最多记条日志、丢个告警。
7. 登录安全的上线前检查清单与真实案例复盘
7.1 安全测试给出的整改项
上线前的安全测试给我列了几个问题,每一个都很常见,我整理出来给大家参考:
问题一:登录接口未做请求频率限制。验证码虽然限制了脚本效率,但攻击者可以绕过验证码直接重放同一个请求吗?实际上有了Redis的验证码一次性校验,重放已经被挡住。但安全测试仍然要求在整个网关层做IP频率限制——这是纵深防御,不依赖应用层逻辑。
问题二:Session中的用户信息明文存储。我的Redis会话里存了明文user_id、username。安全测试认为这些信息如果被Redis泄露(比如Redis未授权访问),攻击者可以直接篡改会话数据伪造登录状态。这个判断有一定道理,但考虑到Redis是我们自己运维的内网服务,并且应用层还有网络隔离,我在会话数据里加了HMAC签名,数据被篡改时能及时发现。
// 会话数据写入时附带签名 String payload = userId + "|" + username + "|" + loginTime + "|" + loginIp; String signature = HmacUtil.sign(payload, SESSION_SECRET_KEY); String sessionJson = "{\"id\":\"" + sessionId + "\",\"uid\":" + userId + ",\"userName\":\"" + username + "\",\"sig\":\"" + signature + "\"}";读取时重新计算签名并比对,不匹配直接删除会话。虽然过程繁琐,但在"Redis可能被动过手脚"的假设下,这是必要的保险。
问题三:登录成功未输出安全日志。安全测试指出:登录成功、登录失败、账号锁定、密码重置这些关键操作都没有日志记录,出了问题无法审计。我补上了操作日志表,并且用AOP注解统一记录:
@Log("用户登录") @PostMapping("/login") public Result<LoginVO> login(...) { ... }AOP切面里记录操作人、操作时间、IP、请求路径、参数摘要、结果状态。这个审计日志非常有用,后来真的有用户反馈"账号在异地登录",我通过日志定位到IP和会话创建时间,几小时内就把问题查清了。
7.2 真实踩坑:一次登录超时引发的"连环掉线"
上线后,我遇到过一起典型的登录事故,很能说明会话管理上的盲点。
事情的经过是这样的:某天上午,大量用户反馈"操作到一半突然被踢回登录页",频率极高。排查发现,Redis中会话的过期时间设置成了2小时,而这批用户的登录时间是凌晨,正好卡在早晨9点半左右集体过期——这就是"会话超时"不是"被人踢下线"的典型症状。
这个问题的表象是:用户在页面停留超过2小时,期间没有任何操作(比如一直开着某个统计页面在看数据),然后突然操一下,发现会话过期了。这还不是最糟糕的——更麻烦的是,他们输入密码重新登录后,跳转逻辑出了问题,跳到登录页而不是原来的页面。
根因分析后,我做了修复:一是把session过期时间从固定2小时改成"滑动过期",即前面拦截器里讲到的每次访问刷新过期时间;二是在前端增加"会话即将过期"的弹窗提示,在过期前5分钟询问"是否继续操作",用户点击后前端主动发一个保活请求。这里用一次性刷新接口,而不是定时刷新,避免对服务器产生不必要的压力。
7.3 密码错误与"多次错误导致锁定"的边界
锁定策略上线后,我马上收到客服反馈:"我们部门有个同事改完密码后一直登录不上,提示账号被锁定了,无法自己解锁。"
排查后发现问题出在"改密码"的接口没有顺带重置failed_login_count。用户改完密码后,旧密码的失败记录还留在表里,等他去登录时一旦输错一次新密码,加上原来的失败计数,直接达到5次锁定线。这是很尴尬的场景:用户确实是本人,但被系统锁在门外。
修复方法:改密码成功、找回密码成功的接口里,必须同时重置failed_login_count和locked_flag。另外,锁定到期后,不能只解除锁定,累计失败次数也要清零,否则用户下次失败一次又达到锁定线。这个逻辑看着简单,不做的话就等着收获"锁死用户"的抱怨。
8. 登录系统上线后的持续运维观察
登录功能上线不代表工作结束,它是持续运维的起点。我这边几项日常运营动作,简单分享下:
每天看一遍Nginx访问日志里/api/auth/login的请求量。正常业务量是每分钟十几次。如果某个时刻突然飙到每秒几十次,极大概率是有人在扫接口。这时候配合Redis里login_fail_ip:{ip}的计数,基本能确认攻击来源。处理方式就是网关层拉黑这个IP,观察是否有进一步动作。
第二,每周例行检查用户密码强度分布。你可以从日志或者数据库定期抽样,看看有没有大量用户密码是"123456"这种弱密码。如果有,就要考虑强制修改策略或者启用策略引导(登录后弹窗提示)。安全工作不是做了就完了,是要反复应对现实世界里"用户图省事"这个最大的漏洞。
第三,关注第三方库的安全通告。Spring Boot自身、加密库、前端依赖库,任何一个被曝出严重漏洞,都需要评估是否需要升级。这个是我每周至少花半小时做的事情,别指望有人提醒你。
这套登录系统上线到现在跑了半年多,没有出过安全事故。期间经历了几次暴力破解尝试,都被验证码加锁定机制挡了下来。要问我最大的感受,就是登录这事儿看着简单,但每一步决策都牵一发动全身。数据表设计决定后续扩展空间,会话方案决定分布式改造成本,验证码方案决定抗脚本能力,日志方案决定排查问题效率——每一步都值得认真想清楚再做。
后面如果有时间,我准备把单点登录(SSO)和多因素认证(MFA)的实践也整理出来,这两个方向是这套系统下一阶段的演进目标。这次就先写到这里,希望对正在做登录功能的朋友有所帮助。