微信扫码登录(服务号)实现
微信扫码登录有两种实现方式
- 方式一:微信开放平台实现,为了接入更多第三方而开放的接口
- 方式二:微信公众号扫码实现,借助服务号,生成带参数的二维码,实现扫码关注后登录
还有一种场景,H5页面通过点击某个按钮从而获取用户信息,实现登录
前置准备
(开发者ID、开发者密码、服务器地址、令牌、消息加解密密钥)
- 注册服务号:前往微信公众平台注册
服务器配置:服务号=>设置与开发=>开发接口管理=>服务器配置,修改配置
- 服务器地址(URL):用于接收服务号推送的消息
- 令牌(Token):自定义
- 消息加解密密钥(EncodingAESKey):消息加解密密钥将用于消息体加解密过程
- 消息加解密方式:明文模式、兼容模式、安全模式
流程
- 前端请求后端生成二维码
- 后端带上appid、secret和grant_type向微信服务器获取 access_token,并存储到Redis中,设置过期时间
- 后端带上expire_seconds、action_name、scene_id向微信服务器获取ticket,并存入Redis中,值设为wait,并设置过期时间
- 后端携带 ticket 获取带参二维码,返回二维码url和 ticket 给前端
- 前端携带 ticket 使用 轮询获取登录状态
- 用户扫码,后端收到回调,获取 openid 和 ticket,将Redis中的ticket值改为openid
- 后端执行用户登录逻辑,查询用户信息返回给前端,并保存登录态
- 前端收到登录结果更新,正常页面
相关文档
- 微信公众号测试平台:https://mp.weixin.qq.com/debug/cgi-bin/sandboxinfo?action=showinfo&t=sandbox/index(无需申请公众号即可完成测试)
- 获取AccessToken文档:https://developers.weixin.qq.com/doc/offiaccount/Basic\_Information/Get\_access\_token.html
- 生成带参数的二维码:https://developers.weixin.qq.com/doc/offiaccount/Account\_Management/Generating\_a\_Parametric\_QR\_Code.html (opens new window)(最终就是用户扫描的二维码)
- 内网穿透:花生壳
目录结构
代码:
RedisConfig
package com.example.demo.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.StringRedisTemplate;
@Configuration
public class RedisConfig {
@Bean(name = "redisTemplate")
public StringRedisTemplate stringRedisTemplate(RedisConnectionFactory redisConnectionFactory) {
return new StringRedisTemplate(redisConnectionFactory);
}
}
WechatMpProperties
package com.example.demo.config;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Configuration;
@Configuration
@ConfigurationProperties(prefix = "wx.mp")
@Data
public class WechatMpProperties {
private String appId;
private String secret;
private String token;
// Getters and Setters
}
Constant
package com.example.demo.constant;
public interface Constant {
/**
* access_token 的缓存key
*/
String ACCESSTOKEN = "access_token";
/**
* 用户登录态键
*/
String USER_LOGIN_STATE = "login:userLogin";
}
DemoController
package com.example.demo.controller;
import com.example.demo.domain.dto.RequestBodyDTO;
import com.example.demo.domain.vo.LoginVO;
import com.example.demo.service.WechatService;
import com.fasterxml.jackson.dataformat.xml.XmlMapper;
import lombok.extern.slf4j.Slf4j;
import me.chanjar.weixin.mp.api.WxMpService;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.web.bind.annotation.*;
import javax.annotation.Resource;
import javax.servlet.http.HttpServletRequest;
import java.util.concurrent.TimeUnit;
import static com.example.demo.constant.Constant.USER_LOGIN_STATE;
@RestController
@Slf4j
public class DemoController {
@Resource
private StringRedisTemplate redisTemplate;
@Resource
private WxMpService wxMpService;
@Resource
private WechatService wechatService;
@Value("${wx.mp.app-id}")
private String appid;
/**
*验签
* @param signature
* @param timestamp
* @param nonce
* @param echostr
* @return
*/
@GetMapping("/")
public String authGet(@RequestParam(name = "signature", required = false) String signature,
@RequestParam(name = "timestamp", required = false) String timestamp,
@RequestParam(name = "nonce", required = false) String nonce,
@RequestParam(name = "echostr", required = false) String echostr) {
log.info("接收微信验证请求:appid={}, signature={}, timestamp={}, nonce={}, echostr={}",
appid, signature, timestamp, nonce, echostr);
if (StringUtils.isAnyBlank(signature, timestamp, nonce, echostr)) {
log.error("请求参数不完整");
return "非法请求";
}
if (!this.wxMpService.switchover(appid)) {
log.error("未找到对应appid=[{}]的配置", appid);
return "非法请求";
}
if (wxMpService.checkSignature(timestamp, nonce, signature)) {
return echostr;
}
log.error("签名验证失败");
return "非法请求";
}
/**
* 前端获取登录请求的操作
*/
@GetMapping("/login")
public LoginVO login() {
return wechatService.login();
}
/**
* 回调
*/
@PostMapping(value = "/", produces = "application/xml; charset=UTF-8")
public String authPost(@RequestBody String requestBody,
@RequestParam(name = "signature", required = false) String signature,
@RequestParam(name = "timestamp", required = false) String timestamp,
@RequestParam(name = "nonce", required = false) String nonce,
@RequestParam(name = "openid", required = false) String openid,
@RequestParam(name = "encrypt_type", required = false) String encType,
@RequestParam(name = "msg_signature", required= false) String msgSignature) {
try {
log.info("接收微信验证请求:openid={}, signature={}, timestamp={}, nonce={}", openid, signature, timestamp, nonce);
// 消息转换
XmlMapper xmlMapper = new XmlMapper();
RequestBodyDTO message = xmlMapper.readValue(requestBody, RequestBodyDTO.class);
// 扫码登录【消息类型和事件】
if ("event".equals(message.getMsgType()) && "SCAN".equals(message.getEvent())) {
redisTemplate.opsForValue().set(message.getTicket(), openid, 1, TimeUnit.MINUTES);
// 登录成功
// 推送登录成功消息
}
log.info("接收微信公众号信息请求{}完成{}", openid, requestBody);
}catch (Exception e) {
log.error("微信回调异常", e);
}
return "";
}
/**
* 结果查询
*/
@GetMapping("/checkLogin")
public String checkLogin(@RequestParam String ticket, HttpServletRequest request) {
if (ticket == null) {
return "未登录";
}
String openId = redisTemplate.opsForValue().get(ticket);
if (openId == null) {
return "二维码已失效";
}
// 判断值是否为wait,如果为wait,则返回未登录,否则返回openId
if ("wait".equals(openId)) {
return "未登录";
}
// todo 查询openId对应的用户信息
// 登录操作,存储登录态
request.getSession().setAttribute(USER_LOGIN_STATE, openId);
return openId;
}
/**
* 获取当前登录信息
*/
@GetMapping("/getCurrentUser")
public String getCurrentUser(HttpServletRequest request) {
Object user = request.getSession().getAttribute(USER_LOGIN_STATE);
if (user == null) {
return "未登录";
}
return user.toString();
}
}
GetAccessTokenRequestDTO
package com.example.demo.domain.dto;
import lombok.Data;
@Data
public class GetAccessTokenRequestDTO {
/**
* 授权模式
*/
private String grant_type;
/**
* 应用ID
*/
private String appid;
/**
* 应用密钥
*/
private String secret;
}
RequestBodyDTO
package com.example.demo.domain.dto;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Data;
@Data
@JsonIgnoreProperties(ignoreUnknown = true)
public class RequestBodyDTO {
@JsonProperty("ToUserName")
private String toUserName;
@JsonProperty("FromUserName")
private String fromUserName;
@JsonProperty("CreateTime")
private String createTime;
@JsonProperty("MsgType")
private String msgType;
@JsonProperty("Event")
private String event;
@JsonProperty("EventKey")
private String eventKey;
@JsonProperty("Ticket")
private String ticket;
}
WechatQrCodeResponseDTO
package com.example.demo.domain.dto;
import lombok.Data;
@Data
public class WechatQrCodeResponseDTO {
/**
* 临时二维码的ticket,凭借此ticket可以在有效时间内换取二维码。
*/
private String ticket;
/**
* 该二维码有效时间,以秒为单位。 最大不超过2592000(即30天)。
*/
private String expire_seconds;
/**
* 二维码图片解析后的地址,开发者可根据该地址自行生成需要的二维码图片
*/
private String url;
}
WechatTokenResponseDTO
package com.example.demo.domain.dto;
import lombok.Data;
@Data
public class WechatTokenResponseDTO {
/**
* 获取到的凭证
*/
private String access_token;
/**
* 凭证有效时间,单位:秒
*/
private Long expires_in;
}
LoginVO
package com.example.demo.domain.vo;
import lombok.Data;
@Data
public class LoginVO {
/**
* 扫码返回的ticket
*/
private String ticket;
/**
* 二维码图片的url
*/
private String url;
}
WechatService
package com.example.demo.service;
import com.example.demo.domain.dto.GetAccessTokenRequestDTO;
import com.example.demo.domain.dto.WechatQrCodeResponseDTO;
import com.example.demo.domain.dto.WechatTokenResponseDTO;
import com.example.demo.domain.vo.LoginVO;
public interface WechatService {
/**
* 获取AccessToken
*/
WechatTokenResponseDTO getAccessToken(String appId);
/**
* 获取ticket凭证
*/
WechatQrCodeResponseDTO createQRCode(String accessToken, int sceneId, int expireSeconds);
/**
* 获取带参数的二维码图片
*/
String getQRCodeImageUrl(String ticket);
/**
* 获取登录凭证
* @return
*/
LoginVO login();
}
WechatServiceImpl
package com.example.demo.service.impl;
import com.example.demo.domain.dto.WechatQrCodeResponseDTO;
import com.example.demo.domain.dto.WechatTokenResponseDTO;
import com.example.demo.domain.vo.LoginVO;
import com.example.demo.service.WechatService;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import me.chanjar.weixin.common.error.WxErrorException;
import me.chanjar.weixin.mp.api.WxMpService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;
import javax.annotation.Resource;
import java.util.concurrent.TimeUnit;
import static com.example.demo.constant.Constant.ACCESSTOKEN;
@Service
public class WechatServiceImpl implements WechatService {
private static final Logger log = LoggerFactory.getLogger(WechatServiceImpl.class);
@Resource
private WxMpService wxMpService;
@Resource
private StringRedisTemplate stringRedisTemplate;
private final RestTemplate restTemplate = new RestTemplate();
private final ObjectMapper objectMapper = new ObjectMapper();
@Value("${wx.mp.app-id}")
private String appId;
/**
* 获取登录凭证
* @return
*/
@Override
public LoginVO login() {
String accessToken = null;
// 查询redis中是否有accessToken,如果有则返回,如果没有则调用接口获取
if (Boolean.TRUE.equals(stringRedisTemplate.hasKey(ACCESSTOKEN))) {
accessToken = stringRedisTemplate.opsForValue().get(ACCESSTOKEN);
}else {
String access_token = getAccessToken(appId).getAccess_token();
stringRedisTemplate.opsForValue().set(ACCESSTOKEN, access_token, 1, TimeUnit.HOURS);
accessToken = access_token;
}
// 请求ticket
WechatQrCodeResponseDTO qrCode = createQRCode(accessToken, 1, 1800);
String ticket = qrCode.getTicket();
// 将ticket存储到redis
stringRedisTemplate.opsForValue().set(ticket, "wait", 2, TimeUnit.MINUTES);
// 请求获取带参码的二维码图片的url
String qrCodeImageUrl = getQRCodeImageUrl(ticket);
// 返回ticket和url
LoginVO loginVO = new LoginVO();
loginVO.setTicket(ticket);
loginVO.setUrl(qrCodeImageUrl);
return loginVO;
}
/**
* 获取微信 access_token
* @return
*/
@Override
public WechatTokenResponseDTO getAccessToken(String appId) {
WechatTokenResponseDTO wechatTokenResponseDTO = new WechatTokenResponseDTO();
if (!this.wxMpService.switchover(appId)) {
log.error("未找到对应appid=[{}]的配置", appId);
return null;
}
try {
String accessToken = wxMpService.getAccessToken();
wechatTokenResponseDTO.setAccess_token(accessToken);
wechatTokenResponseDTO.setExpires_in(wxMpService.getWxMpConfigStorage().getExpiresTime());
return wechatTokenResponseDTO;
} catch (WxErrorException e) {
throw new RuntimeException(e);
}
}
/**
* 创建临时二维码
* @param accessToken
* @param sceneId
* @param expireSeconds
* @return
*/
@Override
public WechatQrCodeResponseDTO createQRCode(String accessToken, int sceneId, int expireSeconds) {
WechatQrCodeResponseDTO wechatQrCodeResponseDTO = new WechatQrCodeResponseDTO();
// 创建临时二维码
String url = "https://api.weixin.qq.com/cgi-bin/qrcode/create?access_token=" + accessToken;
String requestBody = String.format("{\"expire_seconds\":%d,\"action_name\":\"QR_SCENE\",\"action_info\":{\"scene\":{\"scene_id\":%d}}}", expireSeconds, sceneId);
String response = restTemplate.postForObject(url, requestBody, String.class);
log.info("requestBody: {}", response);
try {
JsonNode jsonNode = objectMapper.readTree(response);
System.out.println("response: " + response);
String ticket = jsonNode.get("ticket").asText(); // 获取ticket
String expireSecondsStr = jsonNode.get("expire_seconds").asText(); // 获取expire_seconds
String urlStr = jsonNode.get("url").asText();
wechatQrCodeResponseDTO.setTicket(ticket);
wechatQrCodeResponseDTO.setExpire_seconds(expireSecondsStr);
wechatQrCodeResponseDTO.setUrl(urlStr);
return wechatQrCodeResponseDTO;
} catch (Exception e) {
e.printStackTrace();
}
return null;
}
/**
* 获取二维码图片的url
* @param ticket
* @return
*/
@Override
public String getQRCodeImageUrl(String ticket) {
if (ticket != null) {
return "https://mp.weixin.qq.com/cgi-bin/showqrcode?ticket=" + ticket;
}
return null;
}
}
Application.yml
server:
port: 8080
wx:
mp:
app-id: "XXX"
secret: "XXX"
token: XXX
spring:
session:
timeout: 60480000
store-type: redis
redis:
database: 1
host: XX.XX.XX.XX
port: 6379
password: "XXX"