大家好,我是技术UP主小傅哥。
作为一个技术码农,在使用社区、论坛或者各类AI服务的时,经常会看到这样一个提示:“使用微信公众号扫码登录”。那因为这种的登录方式除了登录,还可以让用户沉淀到公众号上,以后还能接收到公众号推广,可谓是一举两得。那它是怎么做的呢?🤔
小傅哥,先举个这样登录的例子🌰,让大家熟悉下这个业务场景。
通过这样的一个页面效果展示,我们粗略的可以知道,用户页面不断的 checkScan 检测,是需要用到一个唯一ID值。而当用户用微信扫码后,这个唯一ID值则可以通过微信公众号获取到并保存,同时创建出唯一ID 和 Token 的映射关系。那么当 checkScan 扫描到服务端有这么一个映射,则可以把 Token 取回来存到浏览器中,让用户登录成功。
流程就是这样,那具体的代码实现是如何处理的呢?接下来小傅哥就给大家分享下,怎么来实现一下这个方案。
文末提供了「星球:码农会锁」🧧优惠加入方式,以及本节课程的代码地址。项目演示地址:https://gaga.plus - 8个实战项目
微信扫码登录的流程主要包括;用户、浏览器、后端服务、公众号,这四个部分。我们可以先通过UML流程图,了解下整个调用关系。
有了这样一个流程的理解,接下来,我们就可以看下代码是如何实现的了。
不需要申请公众号即可完成测试,类似沙箱环境
最终就是用户扫描的二维码
小傅哥这里采用了 DDD 的工程模型结构,开发公众号扫码登录服务端案例。如果你对 DDD 还不是太熟悉,可以看下小傅哥写的系列 DDD 教程;《Java 简明教程》-> bugstack.cn -> 路书
从微信官网文档阅读可以知道,为了获取扫码登录的二维码,则需要3步;
public interface IWeixinApiService {
/**
* 获取 Access token
* 文档:<a href="https://developers.weixin.qq.com/doc/offiaccount/Basic_Information/Get_access_token.html">Get_access_token</a>
*
* @param grantType 获取access_token填写client_credential
* @param appId 第三方用户唯一凭证
* @param appSecret 第三方用户唯一凭证密钥,即appsecret
* @return 响应结果
*/
@GET("cgi-bin/token")
Call<WeixinTokenResponseDTO> getToken(
@Query("grant_type") String grantType,
@Query("appid") String appId,
@Query("secret") String appSecret
);
/**
* 获取凭据 ticket
* 文档:<a href="https://developers.weixin.qq.com/doc/offiaccount/Account_Management/Generating_a_Parametric_QR_Code.html">Generating_a_Parametric_QR_Code</a>
* <a href="https://mp.weixin.qq.com/cgi-bin/showqrcode?ticket=TICKET">前端根据凭证展示二维码</a>
*
* @param accessToken getToken 获取的 token 信息
* @param weixinQrCodeRequestDTO 入参对象
* @return 应答结果
*/
@POST("cgi-bin/qrcode/create")
Call<WeixinQrCodeResponseDTO> createQrCode(@Query("access_token") String accessToken, @Body WeixinQrCodeRequestDTO weixinQrCodeRequestDTO);
}
@Slf4j
@Configuration
public class Retrofit2Config {
private static final String BASE_URL = "https://api.weixin.qq.com/";
@Bean
public Retrofit retrofit() {
return new Retrofit.Builder()
.baseUrl(BASE_URL)
.addConverterFactory(JacksonConverterFactory.create())
.build();
}
@Bean
public IWeixinApiService weixinApiService(Retrofit retrofit) {
return retrofit.create(IWeixinApiService.class);
}
}
接口:https://mp.weixin.qq.com/cgi-bin/showqrcode?ticket=TICKET
源码:cn.bugstack.xfg.dev.tech.trigger.http.LoginController
@Slf4j
@RestController()
@CrossOrigin("*")
@RequestMapping("/api/v1/login/")
public class LoginController {
@Resource
private ILoginService loginService;
@RequestMapping(value = "weixin_qrcode_ticket", method = RequestMethod.GET)
public Response<String> weixinQrCodeTicket() {
try {
String qrCodeTicket = loginService.createQrCodeTicket();
log.info("生成微信扫码登录 ticket {}", qrCodeTicket);
return Response.<String>builder()
.code(Constants.ResponseCode.SUCCESS.getCode())
.info(Constants.ResponseCode.SUCCESS.getInfo())
.data(qrCodeTicket)
.build();
} catch (Exception e) {
log.info("生成微信扫码登录 ticket 失败", e);
return Response.<String>builder()
.code(Constants.ResponseCode.UN_ERROR.getCode())
.info(Constants.ResponseCode.UN_ERROR.getInfo())
.build();
}
}
@RequestMapping(value = "check_login", method = RequestMethod.GET)
public Response<String> checkLogin(@RequestParam String ticket) {
try {
String openidToken = loginService.checkLogin(ticket);
log.info("扫描检测登录结果 ticket:{} openidToken:{}", ticket, openidToken);
if (StringUtils.isNotBlank(openidToken)) {
return Response.<String>builder()
.code(Constants.ResponseCode.SUCCESS.getCode())
.info(Constants.ResponseCode.SUCCESS.getInfo())
.data(openidToken)
.build();
} else {
return Response.<String>builder()
.code(Constants.ResponseCode.NO_LOGIN.getCode())
.info(Constants.ResponseCode.NO_LOGIN.getInfo())
.build();
}
} catch (Exception e) {
log.info("扫描检测登录结果失败 ticket:{}", ticket);
return Response.<String>builder()
.code(Constants.ResponseCode.UN_ERROR.getCode())
.info(Constants.ResponseCode.UN_ERROR.getInfo())
.build();
}
}
}
开发两个接口;
/api/v1/login/weixin_qrcode_ticket
- 获取微信 ticket 凭证/api/v1/login/check_login
- 轮训验证登录首先,只要做公众号开发的流程,就必须有公众号的对接。这个对接就是你在自己按照公众号文档开发好对接程序,配置到公众号平台。
如图所示,是你在登录微信公众号测试平台,添加接口配置和JS安全域名以后看到的内容。
测试号二维码
,这样才能看到测试信息。源码:cn.bugstack.xfg.dev.tech.trigger.http.WeixinPortalController
@Slf4j
@RestController()
@CrossOrigin("*")
@RequestMapping("/api/v1/weixin/portal/")
public class WeixinPortalController {
@Value("${weixin.config.originalid}")
private String originalid;
@Resource
private Cache<String, String> openidToken;
/**
* 验签,硬编码 token b8b6 - 按需修改
*/
@GetMapping(value = "receive", produces = "text/plain;charset=utf-8")
public String validate(@RequestParam(value = "signature", required = false) String signature,
@RequestParam(value = "timestamp", required = false) String timestamp,
@RequestParam(value = "nonce", required = false) String nonce,
@RequestParam(value = "echostr", required = false) String echostr) {
try {
log.info("微信公众号验签信息开始 [{}, {}, {}, {}]", signature, timestamp, nonce, echostr);
if (StringUtils.isAnyBlank(signature, timestamp, nonce, echostr)) {
throw new IllegalArgumentException("请求参数非法,请核实!");
}
boolean check = SignatureUtil.check("b8b6", signature, timestamp, nonce);
log.info("微信公众号验签信息完成 check:{}", check);
if (!check) {
return null;
}
return echostr;
} catch (Exception e) {
log.error("微信公众号验签信息失败 [{}, {}, {}, {}]", signature, timestamp, nonce, echostr, e);
return null;
}
}
/**
* 回调,接收公众号消息【扫描登录,会接收到消息】
*/
@PostMapping(value = "receive", produces = "application/xml; charset=UTF-8")
public String post(@RequestBody String requestBody,
@RequestParam("signature") String signature,
@RequestParam("timestamp") String timestamp,
@RequestParam("nonce") String nonce,
@RequestParam("openid") String openid,
@RequestParam(name = "encrypt_type", required = false) String encType,
@RequestParam(name = "msg_signature", required = false) String msgSignature) {
try {
log.info("接收微信公众号信息请求{}开始 {}", openid, requestBody);
// 消息转换
MessageTextEntity message = XmlUtil.xmlToBean(requestBody, MessageTextEntity.class);
// 扫码登录【消息类型和事件】
if ("event".equals(message.getMsgType()) && "SCAN".equals(message.getEvent())) {
// 实际的业务场景,可以生成 jwt 的 token 让前端存储
openidToken.put(message.getTicket(), openid);
return buildMessageTextEntity(openid, "登录成功");
}
log.info("接收微信公众号信息请求{}完成 {}", openid, requestBody);
return buildMessageTextEntity(openid, "测试本案例,需要请扫码登录!");
} catch (Exception e) {
log.error("接收微信公众号信息请求{}失败 {}", openid, requestBody, e);
return "";
}
}
private String buildMessageTextEntity(String openid, String content) {
MessageTextEntity res = new MessageTextEntity();
// 公众号分配的ID
res.setFromUserName(originalid);
res.setToUserName(openid);
res.setCreateTime(String.valueOf(System.currentTimeMillis() / 1000L));
res.setMsgType("text");
res.setContent(content);
return XmlUtil.beanToXml(res);
}
}
http://xfg-studio.natapp1.cc/api/v1/weixin/portal/receive
你需要更换为你的内网穿透域名地址。openidToken.put(message.getTicket(), openid);
实际的业务场景会转换为登录的 jwt token 数据。如果你不是 8091 端口,可以修改为其他的
. ____ _ __ _ _
/\\ / ___'_ __ _ _(_)_ __ __ _ \ \ \ \
( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
\\/ ___)| |_)| | | | | || (_| | ) ) ) )
' |____| .__|_| |_|_| |_\__, | / / / /
=========|_|==============|___/=/_/_/_/
:: Spring Boot :: (v2.7.12)
24-02-25.17:13:00.372 [main ] INFO EndpointLinksResolver - Exposing 1 endpoint(s) beneath base path '/actuator'
24-02-25.17:13:00.405 [main ] INFO Http11NioProtocol - Starting ProtocolHandler ["http-nio-8091"]
24-02-25.17:13:00.440 [main ] INFO TomcatWebServer - Tomcat started on port(s): 8091 (http) with context path ''
24-02-25.17:13:00.461 [main ] INFO Application - Started Application in 4.268 seconds (JVM running for 4.941)
./natapp 启动
访问接口:http://xfg-studio.natapp1.cc/api/v1/login/weixin_qrcode_ticket - 你需要替换为你的地址。
访问接口:http://xfg-studio.natapp1.cc/api/v1/login/check_login - 你需要替换为你的地址。
使用微信扫描二维码,观察服务端日志和手机提示。
24-02-25.17:25:09.096 [http-nio-8091-exec-3] INFO LoginController - 生成微信扫码登录 ticket gQHN7zwAAAAAAAAAAS5odHRwOi8vd2VpeGluLnFxLmNvbS9xLzAycTRMbnB4TDBjckcxT043cjFCMWoAAgR1B9tlAwQ8AAAA
24-02-25.17:25:18.793 [http-nio-8091-exec-5] INFO WeixinPortalController - 接收微信公众号信息请求or0Ab6ivwmypESVp_bYuk92T6SvU开始 <xml><ToUserName><![CDATA[gh_e067c267e056]]></ToUserName>
<FromUserName><![CDATA[or0Ab6ivwmypESVp_bYuk92T6SvU]]></FromUserName>
<CreateTime>1708853118</CreateTime>
<MsgType><![CDATA[event]]></MsgType>
<Event><![CDATA[SCAN]]></Event>
<EventKey><![CDATA[100601]]></EventKey>
<Ticket><![CDATA[gQHN7zwAAAAAAAAAAS5odHRwOi8vd2VpeGluLnFxLmNvbS9xLzAycTRMbnB4TDBjckcxT043cjFCMWoAAgR1B9tlAwQ8AAAA]]></Ticket>
</xml>