Spring Boot 实现三模安全登录:微信扫码 + 手机号验证码 + 邮箱验证码

一、 引言

本文档提供了一套完整的解决方案,在 Spring Boot 项目中集成 微信扫码登录阿里云短信验证码登录/绑定邮箱验证码登录/绑定 三大功能。方案严格遵循安全最佳实践,代码结构清晰,可直接作为项目开发的基础。


二、 前置准备
2.1 微信开放平台配置
  1. 微信开放平台创建"网站应用"。
  2. 获取 AppIDAppSecret
  3. 正确配置"授权回调域名",例如 api.yourdomain.com
2.2 阿里云短信服务 (SMS) 配置
  1. 登录 阿里云短信控制台,开通服务。
  2. 申请签名:进入签名管理,添加您的应用或公司名称。
  3. 申请模板 :进入模板管理,添加类型为"验证码"的模板,内容如:您的验证码为:${code},5分钟内有效。
  4. 记录 AccessKey IDAccessKey Secret短信签名模板CODE
2.3 邮箱 SMTP 配置
  1. 使用 QQ 邮箱为例,在"设置 -> 账户"中开启 SMTP 服务
  2. 获取 授权码(非邮箱密码)。
  3. 记录 SMTP 服务器为 smtp.qq.com,端口为 465
2.4 数据库变更
sql 复制代码
CREATE TABLE sys_user (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    username VARCHAR(50) NOT NULL UNIQUE,
    password VARCHAR(100) NOT NULL, -- (密码存储应明确推荐使用BCrypt)
    nickname VARCHAR(50),
    wechat_open_id VARCHAR(64),
    phone VARCHAR(20),
    email VARCHAR(100),
    create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
    INDEX idx_wechat_open_id (wechat_open_id),
    INDEX idx_phone (phone),
    INDEX idx_email (email)
);
2.5 项目依赖 (pom.xml)
xml 复制代码
<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-redis</artifactId>
    </dependency>
     <!-- 微信 -->
    <dependency>
        <groupId>com.github.binarywang</groupId>
        <artifactId>weixin-java-open</artifactId>
        <version>4.4.0</version>
    </dependency>
    <!-- 邮件 -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-mail</artifactId>
    </dependency>
    <!-- 阿里云 -->
    <dependency>
        <groupId>com.aliyun</groupId>
        <artifactId>aliyun-java-sdk-core</artifactId>
        <version>4.6.3</version>
    </dependency>
    <dependency>
        <groupId>com.aliyun</groupId>
        <artifactId>aliyun-java-sdk-dysmsapi</artifactId>
        <version>2.3.0</version>
    </dependency>
    <!-- 工具包 -->
    <dependency>
        <groupId>cn.hutool</groupId>
        <artifactId>hutool-all</artifactId>
        <version>5.8.16</version>
    </dependency>
    <!-- 其他的省略,比如spring security、fastjson什么的都知道 -->
</dependencies>
2.6 配置文件 (application.yml)
yaml 复制代码
server:
  port: 8080

wechat:
  open:
    app-id: wxXXXXXXXXXXXXXX
    app-secret: XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
    redirect-uri: https://api.yourdomain.com/admin/auth/wechat/callback

aliyun:
  sms:
    access-key-id: LTAI5tXXXXXX
    access-key-secret: your-access-key-secret
    region-id: cn-hangzhou
    sign-name: YourAppName
    template-code: SMS_XXXXXXXX

spring:
  redis:
    host: localhost
    port: 6379
  mail:
    host: smtp.qq.com
    port: 465
    username: your-email@qq.com
    password: your-email-auth-code
    properties:
      mail:
        smtp:
          auth: true
          starttls:
            enable: true
            required: true
          ssl:
            enable: true

三、 核心代码实现
3.1 公共 DTO/VO
java 复制代码
// Result.java (通用返回体)
@Data
@AllArgsConstructor
public class Result<T> {
    private int code;
    private String message;
    private T data;
    public static <T> Result<T> success(T data) {
        return new Result<>(200, "成功", data);
    }
}

// WechatQrcodeVO.java
@Data
@AllArgsConstructor
public class WechatQrcodeVO {
    private String sceneId;
    private String qrcodeUrl;
    private Integer expireTime;
}

// ScanStatusVO.java
@Data
@AllArgsConstructor
public class ScanStatusVO {
    private Integer status; // 0:等待, 3:成功, 4:过期, 5:需绑定
    private String token;
    private Object userInfo;
}

// SendCodeRequest.java
@Data
public class SendCodeRequest {
    private String account; // 手机号或邮箱
    private String type;    // "PHONE" or "EMAIL"
}

// VerifyCodeRequest.java
@Data
public class VerifyCodeRequest {
    private String account;
    private String code;
    private String bizType; // "WECHAT_BIND", "PHONE_LOGIN", "EMAIL_LOGIN"
    private String sceneId; // for binding
}
3.2 阿里云短信服务 (AliyunSmsService.java)
java 复制代码
@Service
@Slf4j
public class AliyunSmsService {

    @Value("${aliyun.sms.access-key-id}")
    private String accessKeyId;
    @Value("${aliyun.sms.access-key-secret}")
    private String accessKeySecret;
    @Value("${aliyun.sms.region-id}")
    private String regionId;
    @Value("${aliyun.sms.sign-name}")
    private String signName;
    @Value("${aliyun.sms.template-code}")
    private String templateCode;

    public void sendSmsCode(String phone, String code) {
        DefaultProfile profile = DefaultProfile.getProfile(regionId, accessKeyId, accessKeySecret);
        IAcsClient client = new DefaultAcsClient(profile);
        SendSmsRequest request = new SendSmsRequest();
        request.setPhoneNumbers(phone);
        request.setSignName(signName);
        request.setTemplateCode(templateCode);
        request.setTemplateParam("{\"code\":\"" + code + "\"}");

        try {
            SendSmsResponse response = client.getAcsResponse(request);
            if (!"OK".equals(response.getCode())) {
                throw new RuntimeException("短信发送失败: " + response.getMessage());
            }
        } catch (ClientException e) {
            log.error("调用阿里云短信API异常", e);
            throw new RuntimeException("服务暂时不可用");
        } finally {
            client.shutdown();
        }
    }
}
3.3 邮箱服务 (EmailService.java)
java 复制代码
@Service
@Slf4j
public class EmailService {

    @Autowired
    private JavaMailSender mailSender;
    @Value("${spring.mail.username}")
    private String fromEmail;

    public void sendEmailCode(String to, String code) {
        SimpleMailMessage message = new SimpleMailMessage();
        message.setFrom(fromEmail);
        message.setTo(to);
        message.setSubject("【YourApp】邮箱验证码");
        message.setText("您的验证码是:" + code + ",5分钟内有效。如非本人操作,请忽略此邮件。");
        try {
            mailSender.send(message);
        } catch (Exception e) {
            log.error("发送邮件失败", e);
            throw new RuntimeException("邮件发送失败");
        }
    }
}
3.4 主控制器 (UnifiedLoginController.java)
java 复制代码
@RestController
@RequestMapping("/admin/auth")
@Slf4j
public class UnifiedLoginController {

    @Autowired
    private RedisTemplate<String, String> redisTemplate;
    @Autowired
    private UserService userService;
    @Autowired
    private JwtService jwtService;
    @Autowired
    private AliyunSmsService aliyunSmsService;
    @Autowired
    private EmailService emailService;

    @Value("${wechat.open.app-id}")
    private String appId;
    @Value("${wechat.open.app-secret}")
    private String appSecret;
    @Value("${wechat.open.redirect-uri}")
    private String redirectUri;

    // --- 微信相关 ---
    @PostMapping("/wechat/qrcode/generate")
    public Result<WechatQrcodeVO> generateQrcode() {
        String sceneId = "LOGIN_" + System.currentTimeMillis() + "_" + RandomUtil.randomString(16);
        String qrcodeUrl = "https://open.weixin.qq.com/connect/qrconnect?" +
                "appid=" + appId +
                "&redirect_uri=" + UrlUtil.encode(redirectUri) +
                "&response_type=code&scope=snsapi_login" +
                "&state=" + sceneId + "#wechat_redirect";

        String redisKey = "login:scene:" + sceneId;
        redisTemplate.opsForHash().put(redisKey, "status", "0");
        redisTemplate.expire(redisKey, 180, TimeUnit.SECONDS);

        return Result.success(new WechatQrcodeVO(sceneId, qrcodeUrl, 180));
    }

    @GetMapping("/qrcode/status")
    public Result<ScanStatusVO> checkStatus(@RequestParam String sceneId) {
        String redisKey = "login:scene:" + sceneId;
        String status = (String) redisTemplate.opsForHash().get(redisKey, "status");
        if (status == null) {
            return Result.success(new ScanStatusVO(4, null, null));
        }
        if ("3".equals(status)) {
            String token = (String) redisTemplate.opsForHash().get(redisKey, "token");
            Long userId = Long.valueOf((String) redisTemplate.opsForHash().get(redisKey, "userId"));
            redisTemplate.delete(redisKey);
            return Result.success(new ScanStatusVO(3, token, userService.getUserInfo(userId)));
        }
        return Result.success(new ScanStatusVO(Integer.parseInt(status), null, null));
    }

    @GetMapping("/wechat/callback")
    public String callback(@RequestParam String code, @RequestParam String state) {
        String sceneId = state;
        String redisKey = "login:scene:" + sceneId;
        if (!redisTemplate.hasKey(redisKey)) {
            return "<script>alert('二维码已过期');window.close();</script>";
        }

        try {
            String accessTokenUrl = "https://api.weixin.qq.com/sns/oauth2/access_token?" +
                    "appid=" + appId + "&secret=" + appSecret + "&code=" + code + "&grant_type=authorization_code";
            String resp = HttpUtil.get(accessTokenUrl);
            JSONObject json = JSONUtil.parseObj(resp);
            String openId = json.getStr("openid");

            User user = userService.findByWechatOpenId(openId);
            if (user != null) {
                handleLoginSuccess(redisKey, user);
                return "<script>alert('扫码成功,请在电脑端查看');window.close();</script>";
            } else {
                redisTemplate.opsForHash().put(redisKey, "status", "5");
                redisTemplate.opsForHash().put(redisKey, "openId", openId);
                redisTemplate.expire(redisKey, 180, TimeUnit.SECONDS);
                return "<script>alert('请在电脑端绑定手机号或邮箱');window.close();</script>";
            }
        } catch (Exception e) {
            log.error("微信回调处理失败", e);
            return "<script>alert('系统错误');</script>";
        }
    }

    // --- 验证码相关 ---
    @PostMapping("/code/send")
    public Result<?> sendCode(@RequestBody SendCodeRequest request) {
        String account = request.getAccount();
        String type = request.getType();

        // 频率限制
        String rateLimitKey = "code:rate_limit:" + account;
        if (Boolean.TRUE.equals(redisTemplate.hasKey(rateLimitKey))) {
            throw new RuntimeException("操作过于频繁,请60秒后再试");
        }

        String code = RandomUtil.randomNumbers(6);
        String codeKey = "code:verify:" + account;

        if ("PHONE".equals(type)) {
            if (!Validator.isMobile(account)) throw new IllegalArgumentException("手机号格式错误");
            aliyunSmsService.sendSmsCode(account, code);
        } else if ("EMAIL".equals(type)) {
            if (!Validator.isEmail(account)) throw new IllegalArgumentException("邮箱格式错误");
            emailService.sendEmailCode(account, code);
        } else {
            throw new IllegalArgumentException("不支持的账号类型");
        }

        redisTemplate.opsForValue().set(codeKey, code, 5, TimeUnit.MINUTES);
        redisTemplate.opsForValue().set(rateLimitKey, "1", 60, TimeUnit.SECONDS);
        return Result.success("验证码发送成功");
    }

    @PostMapping("/code/verify")
    public Result<?> verifyCode(@RequestBody VerifyCodeRequest request) {
        String account = request.getAccount();
        String inputCode = request.getCode();
        String bizType = request.getBizType();

        String codeKey = "code:verify:" + account;
        String correctCode = redisTemplate.opsForValue().get(codeKey);
        if (correctCode == null || !correctCode.equals(inputCode)) {
            throw new RuntimeException("验证码错误或已过期");
        }
        redisTemplate.delete(codeKey); // 一次性使用

        if ("WECHAT_BIND".equals(bizType)) {
            return handleWechatBind(account, request.getSceneId());
        } else if ("PHONE_LOGIN".equals(bizType) || "EMAIL_LOGIN".equals(bizType)) {
            return handleAccountLogin(account);
        } else {
            throw new IllegalArgumentException("不支持的业务类型");
        }
    }

    // --- 私有方法 ---
    private void handleLoginSuccess(String redisKey, User user) {
        String token = jwtService.generateToken(user);
        redisTemplate.opsForHash().put(redisKey, "status", "3");
        redisTemplate.opsForHash().put(redisKey, "token", token);
        redisTemplate.opsForHash().put(redisKey, "userId", String.valueOf(user.getId()));
        redisTemplate.expire(redisKey, 180, TimeUnit.SECONDS);
    }

    private Result<?> handleWechatBind(String account, String sceneId) {
        String redisKey = "login:scene:" + sceneId;
        String openId = (String) redisTemplate.opsForHash().get(redisKey, "openId");
        if (openId == null) throw new RuntimeException("无效的绑定会话");

        User user = userService.bindWechatWithAccount(openId, account);
        handleLoginSuccess(redisKey, user);
        return Result.success("绑定成功");
    }

    private Result<ScanStatusVO> handleAccountLogin(String account) {
        User user = userService.findByAccount(account);
        if (user == null) {
            user = userService.registerByAccount(account);
        }
        String token = jwtService.generateToken(user);
        return Result.success(new ScanStatusVO(3, token, userService.getUserInfo(user.getId())));
    }
}

四、 关键安全措施总结
  1. HTTPS 强制:所有对外接口必须通过 HTTPS。

  2. 验证码安全 :

    • 短有效期:5分钟。
    • 一次性使用:验证后立即删除。
    • 频率限制:60秒内只能请求一次。
  3. 会话安全:

    • 强随机 sceneId :使用 RandomUtil.randomString(16) 生成。
    • Redis 隔离与过期:每个会话独立存储,180秒过期。
  4. 输入校验:严格校验手机号、邮箱格式。

  5. 敏感信息保护AppSecretAccessKey授权码 等均在服务端配置,不暴露给前端。


五、最后

三连了吗?回答我 looking my eyes!

tell me why ,why,baby why!

相关推荐
怪兽源码2 小时前
基于SpringBoot的选课调查系统
java·spring boot·后端·选课调查系统
恒悦sunsite2 小时前
Redis之配置只读账号
java·redis·bootstrap
春生野草3 小时前
Redis
数据库·redis·缓存
m0_740043734 小时前
【无标题】
java·spring boot·spring·spring cloud·微服务
重整旗鼓~5 小时前
1.外卖项目介绍
spring boot
编程彩机5 小时前
互联网大厂Java面试:从微服务到分布式缓存的技术场景解析
redis·spring cloud·消息队列·微服务架构·openfeign·java面试·分布式缓存
一点技术6 小时前
基于SpringBoot的选课调查系统
java·spring boot·后端·选课调查系统
shuair6 小时前
redis实现布隆过滤器
spring boot·redis·bootstrap
万象.6 小时前
redis持久化:AOF和RDB
数据库·redis·缓存