☰
Spring Boot + Sa-Token 三通道安全登录方案:扫码、手机号、邮箱统一会话管理
2026/10/21 4:22:53 网站建设 项目流程

不用Admin登录的Lightweight登录方案,也可以做的很安全。这里用Spring Boot + Sa-Token实现一个三通道(扫码、验证码、验证码)的登录预研,把登录、绑定、换绑、会话时长都理清楚了。

1. 三通道登录整体设计,为什么要做“安全登录”而不是“三个登录凑一起”

如果你只是想在项目里加多个登录方式,最简单粗暴的做法是三个login接口,分别处理微信、手机、邮箱,各写各的逻辑,各存各的token。这种做法的隐患在项目初期不明显,等到用户量起来、运营要搞风控的时候就会很痛苦——三个登录渠道的用户数据是割裂的,同一个人的微信、手机、邮箱无法关联到同一个账号,客服查单、风控排查、多端会话管理全都乱了。

所以这个项目在设计上,第一原则是“三模接入、统一账号”。不管用户从哪个入口进来,最终都会归一到一个用户ID上,后续所有业务(订单、会员、收藏、账单)都基于这个ID做,而不是基于某次登录方式是微信还是手机。第二原则是“认证与账号解耦”,也就是微信扫码登录的和手机验证码登录的,在认证层是独立事件,但账号层要让它们能互相绑定、合并、解绑,这在实践中比想象中麻烦,因为牵涉到唯一标识的选择、绑定冲突的处理、解绑后何去何从这些细节,我在第三节实操里会展开。

第三原则是“每种登录方式都要有独立的安全兜底”。微信扫码有授权码和state防CSRF,手机验证码有发送频率限制和校验次数防爆破,邮箱验证码有过期时间和模板注入防护。安全不是某一个环节的事,而是从登录入口到会话管理整条链路上的统一约束。一个好的三模登录设计,应该是一个“策略模式+统一会话”的组合。登录方式是可插拔的,但登录成功后的会话(token)、登录态刷新、登出、会话踢人下线的逻辑是统一的。这样后续如果要加抖音小程序登录、苹果登录,只需要新增一个策略实现,不需要动会话层的任何代码。

2. 核心模块拆解:三类登录的策略设计与关键表结构

把设计落地成代码之前,先把三个通道各自的核心交互链路想清楚,否则写代码的时候很容易绕晕。

2.1 微信扫码登录的三个关键环节:二维码生成、轮询校验、绑定回调

微信扫码登录,本质上不是“扫码即登录”。它的完整链路,是不需要用户输入任何账号密码的第三方授权登录。第一步,前端拿一个code(二维码内容)调后端生成二维码;第二步,微信客户端扫码会打开一个确认授权页;第三步,用户点击允许后,微信服务器会带着一次性授权码回跳到配置好的回调地址;第四步,后端拿着授权码调用微信接口换取用户的唯一标识和用户基础信息;第五步,把标识和用户信息落库或关联已有账号,完成登录。

这里最容易踩坑的是二维码的“有效期”和“状态同步”。微信官方扫码二维码的临时授权有效期为5分钟,过期后用户扫了会提示二维码失效。所以工程上一般会在你自己的服务端生成一个扫码会话ID,对应一个状态(待扫码、已扫码待确认、已确认、已过期),前端每隔2到3秒后端轮询一次这个状态。二维码本身用微信官方接口生成,而会话状态是自维护的。

另外一个很关键的坑是微信开放平台的账号体系。很多新同学以为用户扫了码就能拿到手机号、昵称、头像,实际上微信对网站应用扫码登录,普通授权只能拿到唯一标识、昵称、头像,手机号是拿不到的,除非是特定行业类目且用户主动授权。所以微信登录后续如果要手机号,光靠扫码不够,还得设计一个“绑定手机号”的引导流程,这正好可以和手机验证码通道打通,做“微信首次登录后强制绑定手机号”的体验。

2.2 手机号验证码登录的表结构设计:登录账号表是分层的

为了让三种登录方式最终都映射到同一个用户账号,我不会用三张独立的表存“微信用户”“手机用户”“邮箱用户”,而是设计一张登录账号表,里面同时承载认证标识、认证类型、绑定用户ID和当前状态。

这次预研用的是MySQL,建表语句简化如下:

-- 核心用户表,业务上所有数据都关联user_id create table if not exists user ( id bigint primary key auto_increment, nickname varchar(64) default '', avatar varchar(255) default '', status tinyint not null default 1 comment '1正常 2冻结', created_at datetime not null, updated_at datetime not null ) engine = InnoDB; -- 登录账号表:一个用户可以有多条认证标识 create table if not exists user_account ( id bigint primary key auto_increment, user_id bigint not null, identity_type tinyint not null comment '1微信 2手机 3邮箱', identity_key varchar(255) not null comment '手机号/邮箱/微信unionid', secret_value varchar(255) default '' comment '预留:密码等认证凭证', bound_at datetime not null, updated_at datetime not null, unique key uk_type_key (identity_type, identity_key) ) engine = InnoDB; -- 扫码会话表:微信扫码登录的状态流转 create table if not exists login_scan_session ( id varchar(64) primary key, scene varchar(64) not null comment '前端生成的随机场景值', qr_url text comment '二维码内容', status tinyint not null default 0 comment '0待扫码 1已扫码待确认 2已确认 3已过期 4已取消', user_id bigint default null comment '扫码确认后回填', expired_at datetime not null, created_at datetime not null ) engine = InnoDB; -- 验证码记录表:手机号和邮箱共用,记录发送和校验情况 create table if not exists verify_code ( id bigint primary key auto_increment, scene tinyint not null comment '1登录 2绑定 3解绑验证', target varchar(255) not null comment '手机号或邮箱', code varchar(16) not null, status tinyint not null default 0 comment '0待校验 1已用 2过期', expire_at datetime not null, try_count int not null default 0 comment '已尝试次数', created_at datetime not null, key idx_target_scene (target, scene) ) engine = InnoDB;

这样设计的好处,首先是一个用户可以有多个登录账号,微信、手机、邮箱并存;其次是查某个用户通过哪些渠道登录,一条where user_id=?就能查出来;最后是如果要解绑某个渠道,直接对标的一行删除,不影响用户主体数据。

2.3 为什么用Sa-Token做会话而不是手写token

登录会话、token生成、登出踢人、强制下线、多端互踢这些事,看似简单,实际手写很容易出安全漏洞。这里用一个轻量级Java权限认证框架,本身就是一个项目预研时的好选择。这个项目里统一会话用的就是Sa-Token,它可以做到登录时返回一个Token给前端,后续所有请求带上Token即可访问受保护接口,不需要像老式方案那样在每个需要登录的接口里写一遍“从请求头拿token、查用户、校验有效期”的重复逻辑。拦截器配一下,一行注解就能保护接口,而且它的会话模型天然支持同一账号多端登录、顶人下线、踢人下线,后面接后台管理、客服强制下线用户的功能时会非常省事。

很多团队一上来就自研token方案,JWT + Redis + 拦截器写了大几百行,最后还要考虑刷新、续期、黑名单、并发踢人,成本和风险都高。用成熟框架能把精力集中到业务本身,这才是预研阶段该做的取舍。

3. 完整实操演示:三个通道的登录流程,从Controller到Service都给你列出来

下面开始代码实操。项目基于Spring Boot 2.7 + Sa-Token 1.34(当前长期稳态版本)+ MyBatis-Plus,用Redis存验证码和扫码会话的临时状态。为了让代码结构清晰,Controller层分三个类,Service层也按通道拆分,但会话统一走同一个登录方法。

3.1 微信扫码登录:二维码生成、前端轮询、回调、换unionid

3.1.1 生成二维码的接口
@RestController @RequestMapping("/auth/wechat") @Slf4j public class WechatLoginController { @Resource private WechatLoginService wechatLoginService; /** * 生成微信登录二维码 * 返回二维码内容 + 扫码会话ID,前端拿着会话ID轮询状态 */ @GetMapping("/qrcode") public Result<QrCodeVO> createQrCode() { return Result.ok(wechatLoginService.createQrCode()); } /** * 前端轮询二维码状态 * 当status=2时,data中携带loginToken */ @GetMapping("/scan/status") public Result<ScanStatusVO> getScanStatus(@RequestParam String scene) { return Result.ok(wechatLoginService.getScanStatus(scene)); } }

Service的关键逻辑:

@Service @Slf4j public class WechatLoginServiceImpl implements WechatLoginService { @Resource private StringRedisTemplate stringRedisTemplate; @Override public QrCodeVO createQrCode() { String scene = UUID.randomUUID().toString().replace("-", ""); // 1. 调微信开放平台生成二维码内容 // 实际生产中,这里是请求微信接口获取二维码ticket,然后拼接成URL返回给前端 String qrUrl = buildWechatQrUrl(scene); // 2. 在Redis存扫码会话状态:scene -> 待扫码 // 过期时间设置为略长于微信侧2分钟,取5分钟 stringRedisTemplate.opsForValue().set( SCAN_KEY_PREFIX + scene, "0", Duration.ofMinutes(5) ); QrCodeVO vo = new QrCodeVO(); vo.setScene(scene); vo.setQrUrl(qrUrl); return vo; } @Override public ScanStatusVO getScanStatus(String scene) { String statusStr = stringRedisTemplate.opsForValue().get(SCAN_KEY_PREFIX + scene); if (statusStr == null) { // key消失意味着过期 ScanStatusVO vo = new ScanStatusVO(); vo.setStatus(3); // 已过期 return vo; } int status = Integer.parseInt(statusStr); ScanStatusVO vo = new ScanStatusVO(); vo.setStatus(status); if (status == 2) { // 已确认,把前端状态从轮询改为登录成功 String token = stringRedisTemplate.opsForValue().get(LOGIN_TOKEN_PREFIX + scene); vo.setToken(token); // 一次性token,取完即删 stringRedisTemplate.delete(LOGIN_TOKEN_PREFIX + scene); stringRedisTemplate.delete(SCAN_KEY_PREFIX + scene); } return vo; } }

这里把状态设计成前端轮询方案而不是后端主动推送,是有原因的。后端推送需要建立长连接,如果只是普通Web项目没上WebSocket或SSE,轮询是最简单可靠的。二维码的有效期是5分钟,轮询间隔3秒,最多轮询100次,这和微信扫码确认的耗时是匹配的。为了减少无效请求,还可以在扫码会话状态从“待扫码”变成“已扫码待确认”后,把轮询间隔从2秒拉长到3秒,降低服务压力。

3.1.2 微信回调:授权码换唯一标识

用户扫码并确认授权后,微信会回调我们配置的回调地址,带上code和state。state必须回传并校验,否则会有跨站请求伪造的风险。回调接口在微信配置里只能配一个,所以要在回调里根据state判断场景:是登录还是绑定已有账号。

/** * 微信授权回调地址,由微信服务器调用 * 注意:这里不是浏览器直接访问,所以返回的是重定向 */ @GetMapping("/callback") public void callback( @RequestParam String code, @RequestParam(required = false) String state, HttpServletResponse response) throws IOException { // 1. 校验state,防CSRF // 2. 用code换取access_token和openid/unionid // 3. 根据state判断场景:1=登录,2=绑定已有账号 // 4. 组装用户信息,查找或创建用户 // 5. 生成登录token,通过重定向URL回传给前端页面 // 前端拿到token后跳转到首页 }

state里可以拼上scene和场景值,这样回调到来时能定位到对应的扫码会话。

3.1.3 微信账号与系统用户绑定:首次登录、已有账号绑定

微信扫码成功只是拿到了”微信唯一标识”,系统里到底是一个新用户还是一个老用户,需要查表:

public LoginResult wechatLogin(String unionid) { // 1. 先按unionid查登录账号表 UserAccount account = userAccountMapper.selectOne( new LambdaQueryWrapper<UserAccount>() .eq(UserAccount::getIdentityType, IDENTITY_WECHAT) .eq(UserAccount::getIdentityKey, unionid) ); if (account != null) { // 老用户:直接登录 Long userId = account.getUserId(); StpUtil.login(userId); return LoginResult.success(StpUtil.getTokenValue(), userId); } // 2. 新用户:创建用户 + 创建登录账号 User user = new User(); user.setNickname("微信用户_" + unionid.substring(0, 6)); userMapper.insert(user); UserAccount newAccount = new UserAccount(); newAccount.setUserId(user.getId()); newAccount.setIdentityType(IDENTITY_WECHAT); newAccount.setIdentityKey(unionid); newAccount.setBoundAt(LocalDateTime.now()); userAccountMapper.insert(newAccount); // 3. 标记需绑定手机号 // 微信扫码很难拿到手机号,业务上通常要求新用户补绑手机 StpUtil.login(user.getId()); return LoginResult.needBindMobile(StpUtil.getTokenValue(), user.getId()); }

这里有个业务决策:新用户第一次扫码登录后,是直接创建完整账号,还是先创建一个临时账号等绑手机号后再激活?我的选择是直接创建账号并绑定微信,但标记needBindMobile,前端看到这个标记就引导去“绑定手机号”页面,这比先冻结账号再激活的流程顺畅得多。用户在后续任何时候都可以在个人中心绑定手机或邮箱,不一定非得第一次就强制拦截,但金融、社区类业务建议强制绑手机。

3.2 手机号验证码登录:发送验证码、校验登录、防爆破

3.2.1 发送验证码接口与限流设计
@RestController @RequestMapping("/auth/mobile") public class MobileLoginController { @Resource private MobileLoginService mobileLoginService; /** * 发送手机验证码 * @param mobile 手机号 * @param scene 场景:1=登录 2=绑定 3=解绑验证 */ @PostMapping("/code") public Result<Void> sendCode(@RequestBody @Valid SendCodeDTO dto) { mobileLoginService.sendCode(dto.getMobile(), dto.getScene()); return Result.ok(); } }

发出验证码前有个关键动作:防刷判断。同一个手机号60秒内只能发一次,同一个IP一天最多发20条,同一手机号一天最多发10条。这些判断全部在Redis里完成,用计数器和过期时间实现。

public void sendCode(String mobile, int scene) { // 1. 校验手机号格式 if (!RegexUtil.isMobile(mobile)) { throw new BizException("手机号格式不正确"); } // 2. 防刷:同一个手机号60秒不能重复发送 String sendLimitKey = SEND_LIMIT_KEY + mobile; Boolean hasSend = stringRedisTemplate.hasKey(sendLimitKey); if (Boolean.TRUE.equals(hasSend)) { throw new BizException("发送太频繁,请稍后再试"); } // 3. 生成6位随机验证码,存Redis,有效期5分钟 String code = String.valueOf(ThreadLocalRandom.current().nextInt(100000, 999999)); stringRedisTemplate.opsForValue().set( VERIFY_CODE_KEY + scene + ":" + mobile, code, Duration.ofMinutes(5) ); // 4. 调用短信服务商发送短信 // 这里用模拟日志替代真实短信通道,实际项目替换成阿里云或腾讯云短信接口 log.info("模拟发送短信验证码,手机号:{},验证码:{}", mobile, code); // 5. 设置60秒内不可重复发送 stringRedisTemplate.opsForValue().set(sendLimitKey, "1", Duration.ofSeconds(60)); }

真实环境里验证码的发送入口,一定要做设备指纹和极验这类滑块/图形验证码,否则短信接口很容易被脚本打爆,不只是资损问题,还有被运营商关停通道的风险。预研阶段可以先用接口限流模拟,上线前必须补上人机验证。

3.2.2 校验验证码并登录:一次校验,多个细节
public LoginResult loginByMobile(String mobile, String code, int scene) { // 1. 校验验证码:从Redis取,比对,同时标记已用 String key = VERIFY_CODE_KEY + scene + ":" + mobile; String savedCode = stringRedisTemplate.opsForValue().get(key); if (savedCode == null || !savedCode.equals(code)) { throw new BizException("验证码错误或已过期"); } // 2. 验证码校验通过后立即删除,防止重放 stringRedisTemplate.delete(key); // 3. 按手机号查账号 UserAccount account = userAccountMapper.selectOne( new LambdaQueryWrapper<UserAccount>() .eq(UserAccount::getIdentityType, IDENTITY_MOBILE) .eq(UserAccount::getIdentityKey, mobile) ); // 4. 如果不存在,说明是新用户,自动注册 if (account == null) { User user = new User(); user.setNickname(desensitizeMobile(mobile)); userMapper.insert(user); account = new UserAccount(); account.setUserId(user.getId()); account.setIdentityType(IDENTITY_MOBILE); account.setIdentityKey(mobile); userAccountMapper.insert(account); } // 5. 登录 StpUtil.login(account.getUserId()); return LoginResult.success(StpUtil.getTokenValue(), account.getUserId()); }

验证码校验有两个细节值得注意:一是校验失败次数限制,同一个手机号5分钟内最多错5次,超过后该验证码直接作废,需要重新发送,这个计数器可以存在验证码记录表或Redis里;二是校验成功立即删除key,这一步至关重要,否则在5分钟有效期内,同一个验证码可以被反复使用,一旦被攻击者截获就成了无限登录的钥匙。

3.2.3 手机号登录,需不需要校验该手机号是否已被绑定

老用户直接登录没问题,但有一种情况容易翻车:手机号验证码登录时,这个手机号已经绑定在另一个账号下,怎么办?比如用户A用微信登录后绑定了手机13800000001,后来用户B拿同一个手机号发验证码想登录,系统如果直接创建一个账号,就会造成一个手机号对应两个用户,以后绑定的手机号到底归谁?

常见的做法有两种:一是严格落实“手机号是账号的唯一主键”,已存在就直接登录这个账号,即使当前端AN端要切换身份也不主动解绑,避免资产归属纠纷;二是允许“一周内无登录记录的老账号自动解绑转移”,再把相关资产挂到新账号上,适合电商类业务。预研阶段建议先把“已绑定则直接登录原账号”的逻辑写死,运营上再按需放开通融策略。

3.3 邮箱验证码登录:模板、发送、防刷、预留企业邮箱扩展

邮箱验证码和手机号验证码在流程上几乎一样,但有几个特殊点:邮件发送有延迟,不能像短信一样秒到;邮件内容可以做得更丰富,带上用户昵称和过期提醒;邮件服务要支持模板渲染。

@Service @Slf4j public class EmailLoginServiceImpl implements EmailLoginService { @Resource private StringRedisTemplate stringRedisTemplate; @Resource private JavaMailSender mailSender; @Override public void sendCode(String email, int scene) { // 1. 校验邮箱格式 if (!RegexUtil.isEmail(email)) { throw new BizException("邮箱格式不正确"); } // 2. 防刷:同一邮箱60秒内不可重复发送 String sendLimitKey = EMAIL_SEND_LIMIT + email; if (Boolean.TRUE.equals(stringRedisTemplate.hasKey(sendLimitKey))) { throw new BizException("发送太频繁,请稍后再试"); } // 3. 生成验证码 String code = String.valueOf(ThreadLocalRandom.current().nextInt(100000, 999999)); stringRedisTemplate.opsForValue().set( EMAIL_CODE_KEY + scene + ":" + email, code, Duration.ofMinutes(10) ); // 4. 渲染邮件模板并发送 Context context = new Context(); context.setVariable("code", code); context.setVariable("expireMinutes", 10); String content = templateEngine.process("login_code_email.html", context); SimpleMailMessage message = new SimpleMailMessage(); message.setTo(email); message.setSubject("登录验证码"); message.setText(content); mailSender.send(message); // 5. 设置限频 stringRedisTemplate.opsForValue().set(sendLimitKey, "1", Duration.ofSeconds(60)); } }

邮箱验证码的有效期我设计成10分钟,比手机号5分钟更长,因为邮件投递链路长,延迟高的邮箱可能要等一两分钟才能收到。但有效期太长对安全不利,牺牲一点易用性,换取安全空间,时间折中在10分钟是合理的。

3.4 统一登录成功后的会话处理:绑定已有账号、多端登录、登录状态刷新

无论是微信、手机还是邮箱登录成功,最终都调用StpUtil.login(userId)来做会话处理。Sa-Token默认策略是一个账号的旧token在同一个浏览器端会被顶掉,但在不同设备上可以共存,这符合“一个用户可以手机+电脑同时在线”的预期。如果需要做“同一账号最多同时在线3台设备”,配置一行搞定:

sa-token: token-name: satoken timeout: 2592000 active-timeout: 1800 is-concurrent: true is-share: true token-style: uuid is-log: false

这里的timeout是token的固定有效期,30天;active-timeout是活跃超时,如果30分钟内没有任何请求,token自动过期,下次请求会要求重新登录。这个设计比单纯固定过期时间更贴近实际使用场景:短期活跃用户不需要频繁登录,长时间不操作后强制重新登录也是对安全性的提升。

登录成功后返回给前端的数据结构需要统一:

public class LoginResult { private String token; private Long userId; private boolean needBindMobile; private String nickname; private List<String> boundChannels; }

前端拿到token后存本地,每次请求放进Header。如果needBindMobile为true,则弹出绑定手机号的引导页。

4. 常见问题与避坑指南:扫码不动、验证码丢了、身份错乱怎么办

这里记录一下这次预研中踩过的坑,以及很多团队实际运行中高频遇到的问题,整理成一份可以直接抄的排查清单。

4.1 微信扫码后回调地址带code,但前端一直转圈

排查顺序:第一步看后端日志有没有收到回调,没有收到就是开放平台里的授权回调域名配置错误,或者服务器出口IP没加白名单。第二步看回调进来后state有没有过期,扫码会话的过期时间是不是设置得比微信二维码的有效期短,导致用户扫码确认后,回调携带的state在本服务里已经查不到记录。第三步看拿code换access_token这步是否报40029之类错误码,这种通常是code被用了两次,或者回调地址和换取access_token时用到的redirect_uri不一致。

一个隐藏较深的坑:用户扫码确认后,微信回调我们的回调地址,这个回调地址在开放平台配置时不能带自定义参数,只能有一个固定的URL。所以场景判断要做到state里,比如state=scene123_login,密码过期排查起来才有迹可循。

4.2 手机验证码收不到或验证通过后无法登录

收不到短信大概率是短信服务商接口被限流、签名未审核通过或模板变量名不匹配。有一个生产环境非常常见的情况:验证码已发送成功,但用户在短时间内连续点了两次“发送验证码”,第二次接口直接把第一次的验证码覆盖了,用户拿到的是第一个验证码,输入时永远提示错误。解决方式很简单,同一手机号60秒内重复请求直接返回“已发送,请查收”,而不是重新生成验证码,这个在前面的防刷判断里已经处理了。

验证通过后无法登录,重点检查登录账号表里有没有把手机号存成带空格、带86前缀、或者带+号的格式。所有用户输入的手机号在写入和查询前必须统一格式化,不然后端存的是13800000001,前端回传的是+8613800000001,永远查不到账号,自动注册逻辑又触发一遍,号就废了。

4.3 一个用户同时用微信和手机号登录,却产生两个账号

根本原因是账号体系设计时没有统一的“自然人ID”,微信channel存一份,手机channel存一份,互相之间没有关联逻辑。

解决办法有两条路。一条是“手机号为主键”,凡是登录请求里带了手机号或用户主动绑定手机号,优先以手机号查找账号;另一条是做“账号合并”,例如用户在个人中心绑定手机号时,如果该手机号在系统里已存在A账号,而当前登录的是B账号,则把B账号下的业务数据迁移到A账号,B账号的登录通道解绑并合并到A。听起来麻烦,实际业务中却是刚需,尤其要处理B账号下的订单、优惠券、积分、收藏等资产,预研阶段至少要把合并策略和迁移脚本设计好。

4.4 一个微信账号换绑手机号后,旧手机验证码还能不能登录

能。因为手机号登录走的是手机channel,微信换绑手机号只是解除了手机号与账号的绑定关系,并不删除手机号对应的登录账号记录,旧手机号仍然可以登录战场熵。要彻底杜绝这种情况,换绑时要么把旧手机号从登录账号表里物理删除,要么做“解绑冷却期”,解绑后7天内不允许旧手机号登录。我这边采用前者,解绑即删,简单直接。

提示:涉及资产归属,解绑删除前必须二次确认,最好让用户输入验证码做确认,避免误操作。

4.5 多端会话踢人,怎么做比较丝滑

网上流传的简单做法是登录时记录用户ID,新登录时把上一轮token加黑名单。Sa-Token的is-concurrent: false可以做到同一个账号新登录踢掉旧登录,但默认是按单点登录踢浏览器维度。如果业务需要更细粒度,比如“A手机上登录不干扰B手机上登录”,就要用多端独立会话特性,给每次登录分配一个登录设备标识,按设备校验踢人。

实际项目中,用户同时使用手机和电脑很常见,所以我的建议是默认允许多端并存,只在支付、改绑手机、重置密码这类敏感操作时,校验当前token是否是这个账号最新一次登录生成的token,否则要求重新登录。

4.6 验证码发送接口被人刷爆怎么办

这是最容易在项目上线后被攻击的接口。网关层做设备指纹识别,全局限频每IP每分钟最多发5条,加上滑块验证或图形验证码,没有这层,短信服务商随时可能把通道关停。验证码本身也可以设计成“模糊匹配”,比如允许首尾空格大小写不敏感,减少用户输入障碍。但要注意,模糊匹配不能模糊到把两个完全不同的验证码判成同一个,所以只能是去掉头尾空白再比对。

4.7 登录Token被人截获了怎么办

这个问题在纯前端单页应用里很难彻底解决,因为token总得存在浏览器。建议至少做到两点:

一是敏感接口做二次校验,例如修改手机号、解绑微信、提现接口必须校验短信验证码或支付密码,不能只依赖token。二是token过期时间不能设太长,30天固定有效期而不是永不失效,能大幅缩小泄露窗口。三是前端的本地存储,优先考虑把token放在内存里而不是localStorage,刷新页面后要求重新走静默登录,牺牲一点便利换安全。

5. 多端会话与登录态安全补充

标题叫“安全登录”,前面主要聚焦在“登录”上,但登录之后会话怎么管,才是安全感的真正来源。

5.1 登录态过期、主动退出、被动踢人在同一套框架里怎么处理

Sa-Token默认支持StpUtil.logout()登出,把当前token加入黑名单并清除会话缓存。被动踢人是StpUtil.kickout(userId),它会把这个账号刚刚新登录的会话强制下线,适合客服系统或运营后台做账号干预。主动退出和被动踢人返回给前端的状态码不一样,前端需要根据状态码分别跳转:主动退出跳登录页带个toast“已退出”;被动踢人跳登录页带个toast“该账号在其他设备登录,您已被迫下线”,这样用户体验才不懵。

登录态刷新有个常见误区:每次请求都去数据库查用户信息。实际更好的做法是登录时把用户ID和渠道信息放进token或会话里,请求接口时从会话上下文取用户ID,只有用户信息变更(修改昵称、换绑手机、冻结账号)才清理会话缓存,强制重新登录。否则一旦用户ID在请求过程中被修改,就会出现身份错乱。

5.2 安全三件套:Header校验、Referer校验、操作审计

实现了登录登录还不够,一个“相对安全登录”的预研,至少要覆盖三个通用防护:

第一,接口统一加上跨域域校验和自定义Header校验,只允许来自自己域名的请求携带Token。第二,敏感操作(绑定手机、解绑微信、改密)加校验码二次校验,防止撞库。第三,登录、登出、绑定、解绑全部打操作审计日志,记录用户ID、目标渠道、来源IP、设备信息、操作时间,不仅事后排查方便,风控模型也能拉数据训练异常识别。

提示:操作审计日志不要只记成功,失败也要记。验证码错误5次、登录被踢、解绑被拒绝这些异常事件,才是风控真正关注的信号。

6. 后期扩展建议:从三模登录到统一身份认证平台

这次做完三模登录,会发现所有登录通道已经抽象成统一的“认证策略”。如果再往深做,完全可以扩展为一个小型统一身份认证平台:

  • 会话层用Sa-Token,天然支持分布式部署和Redis共享会话,后续多服务调用可以直接通过底层框架校验Token。
  • 登录账号表预留了identity_type字段,后面加其它渠道,只需要扩展枚举值,不用改表结构。
  • 验证码服务已经做了抽象,手机、邮箱、后续短信、邮件都是同一个发送接口,只是实现不同。
  • 如果需要密码登录,可以在登录账号表里加secret_value字段存哈希和加盐,再补一层加密逻辑。
  • 如果再接入扫码登录之外的小程序登录、App登录,注意复用已有的账号绑定逻辑和“是否需要绑定手机号”的新用户引导流程。

实际在生产环境中,我把这套方案用在了某电商中台项目的登录模块预研上,从设计到三模跑通大概花了两个工作日,代码量并不大,主体就集中在策略逻辑和账号关联这几百行。核心思路是“认证方式可插拔、账号体系统一、会话管理统一”,这个设计原则不论换什么框架、加什么登录渠道都适用。

最后再分享一条实操心得:三模登录排坑调试时,最容易绕晕人的是“验证码到底存在哪个key里”。联调到第二阶段,建议把所有临时状态(扫码会话、验证码、限流计数)全部收进一个Redis存储服务,所有渠道统一一个状态操作类,这样一旦出问题,打开Redis一看数据一目了然。别在Service层到处散着操作Redis字符串,否则一场联调下来,你会被自己的key命名和过期时间的组合搞到怀疑人生。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询