一、 引言
本文档提供了一套完整的解决方案,在 Spring Boot 项目中集成 微信扫码登录 、阿里云短信验证码登录/绑定 和 邮箱验证码登录/绑定 三大功能。方案严格遵循安全最佳实践,代码结构清晰,可直接作为项目开发的基础。
二、 前置准备
2.1 微信开放平台配置
- 在微信开放平台创建"网站应用"。
- 获取
AppID和AppSecret。 - 正确配置"授权回调域名",例如
api.yourdomain.com。
2.2 阿里云短信服务 (SMS) 配置
- 登录 阿里云短信控制台,开通服务。
- 申请签名:进入签名管理,添加您的应用或公司名称。
- 申请模板 :进入模板管理,添加类型为"验证码"的模板,内容如:
您的验证码为:${code},5分钟内有效。 - 记录 AccessKey ID 、AccessKey Secret 、短信签名 和 模板CODE。
2.3 邮箱 SMTP 配置
- 使用 QQ 邮箱为例,在"设置 -> 账户"中开启 SMTP 服务。
- 获取 授权码(非邮箱密码)。
- 记录 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())));
}
}
四、 关键安全措施总结
-
HTTPS 强制:所有对外接口必须通过 HTTPS。
-
验证码安全 :
- 短有效期:5分钟。
- 一次性使用:验证后立即删除。
- 频率限制:60秒内只能请求一次。
-
会话安全:
- 强随机
sceneId:使用RandomUtil.randomString(16)生成。 - Redis 隔离与过期:每个会话独立存储,180秒过期。
- 强随机
-
输入校验:严格校验手机号、邮箱格式。
-
敏感信息保护 :
AppSecret、AccessKey、授权码等均在服务端配置,不暴露给前端。
五、最后
三连了吗?回答我 looking my eyes!

tell me why ,why,baby why!