当我们开发个人项目的时候,为了用户登录的便捷性,经常会给我们的项目加上一些除了注册之外的方式,其中最常见的就是微信登录,但作为个人开发者,是无法使用微信的授权登录的,但是通过微信公众号可以获得同样的结果。
我们首先需要去注册一个属于我们的公众号,微信公众平台:https://mp.weixin.qq.com/,
注意注册类型一定要选择订阅号,服务号需要企业身份,而且选择后是无法更改的(一个邮箱只能绑定一种账号),选择的时候务必注意,跟着走完注册流程后我们就获得了自己的公众号。
为了开发的便捷性,我们使用开发者工具中提供的公众平台的测试号来尝试实验,测试号省去了一些繁琐的部分,我们开发完成后将代码上线后再去改回使用我们的公众号即可。
公众号开发是需要向我们后端发送请求的,我们不可能再服务器上进行代码开发,为了本地开发的便捷,这里使用内网穿透工具来让微信服务器可以访问我们本地的端口。
这里我们选择一个免费的内网穿透工具(提供的免费服务供给我们开发足够了),
netapp:https://natapp.cn/,根据系统的提示下载即可,配置参考官方给的图文即可https://natapp.cn/article/natapp_newbie,唯一需要注意的点就是这个 config.ini 文件的编写,
我们将这两个文件放在同一个目录下直接启动即可,启动后的界面是这样
文章后面括号中会标识这个操作对应的开发者文档中的哪个模块,方便大家进行学习和查阅,我们可以再开发者工具中很容易的找到开发者文档:
首先我们打开我们的微信测试号,填写接口配置信息,微信服务器发送请求的时候会向我们填写的 url 发送请求,有了上面的内网穿透工具,微信服务器已经可以发送请求到我们的内网。
将想要微信服务器访问到的组件粘贴到接口配置信息中,这里我们使用 /wx 来接收所有微信发送的请求。
@RequestMapping("/wx")
@Slf4j
@RestController
@CrossOrigin(origins = "http://localhost:8000", allowCredentials = "true")
public class WxController {
/**
* 微信 url 验证接口
*/
@GetMapping("/")
public String wxCheck(@RequestParam("signature") String signature,
@RequestParam("timestamp") Integer timestamp,
@RequestParam("nonce") String nonce,
@RequestParam("echostr") String echostr) {
log.info("开始验证 url signature={},timestamp={},nonce={},echostr={}",
signature,timestamp, nonce, echostr);
//TODO:验证是微信发送的
return echostr;
}
这时候微信会向我们填写的地址发送一个 GET 请求,同时会附带上面写的参数,我们接收到这个信息后需要将随机数作为返回值返回给微信服务器才能通过验证**(开始开发 / 接入指南)**,微信发送给我们的参数为:
通过开发文档提供的方式可以验证这个消息是否来自微信,这里先不提供具体的实现方法。
想要实现扫码登录,需要一个能导航到我们公众号的二维码,二维码开发的具体信息在**(账号管理 / 生成带参数的二维码)**,简单来说就是我们拿我们的 accessToken 和我们希望二维码中携带的信息去向微信服务器换取一个 ticket,再通过这个 ticket 去获得我们的二维码图片地址。
access_toke n是公众号的全局唯一接口调用凭据,公众号调用各接口时都需使用access_token。开发者需要进行妥善保存。
关于获取 AccessToken 的具体流程在**(开始开发 / 获取 Access token)**,我们发送 GET 请求到这个地址
https请求方式: GET https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=APPID&secret=APPSECRET
注意将参数改为自己的,就能获取到类似于这样的 JSON 字符串
{"access_token":"ACCESS_TOKEN","expires_in":7200}
示例实现方法
我在项目中是封装了一个 WxUtil 作为工具类来存放这些方法,发送请求是通过 HuTool 的 HttpUtil,这里就不再赘述使用方法,可以当作参考:
/**
* 获取 accessToken
*/
public static String getAccessToken() throws JsonProcessingException {
String url = "https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential" +
"&appid=" +
"&secret=";
HttpResponse execute = HttpUtil.createGet(url)
.execute();
String body = execute.body();
ObjectMapper objectMapper = new ObjectMapper();
// 将 JSON 字符串转化为 Map
Map<String, Object> stringObjectMap = objectMapper.readValue(body,
new TypeReference<>(){});
return (String)stringObjectMap.get("access_token");
}
这里的序列化工具采用的是 Jackson
<dependency>
<groupId>com.fasterxml.jackson.dataformatgroupId>
<artifactId>jackson-dataformat-xmlartifactId>
<version>2.9.8version>
dependency>
接下来让我们再次回到主线上来,继续开发带参数的二维码,参考官方文档的说明:
临时二维码请求说明
http请求方式: POST URL: https://api.weixin.qq.com/cgi-bin/qrcode/create?access_token=TOKEN POST数据格式:json POST数据例子:{“expire_seconds”: 604800, “action_name”: “QR_SCENE”, “action_info”: {“scene”: {“scene_id”: 123}}} 或者也可以使用以下POST数据创建字符串形式的二维码参数:{“expire_seconds”: 604800, “action_name”: “QR_STR_SCENE”, “action_info”: {“scene”: {“scene_str”: “test”}}}
永久二维码请求说明
http请求方式: POST URL: https://api.weixin.qq.com/cgi-bin/qrcode/create?access_token=TOKEN POST数据格式:json POST数据例子:{“action_name”: “QR_LIMIT_SCENE”, “action_info”: {“scene”: {“scene_id”: 123}}} 或者也可以使用以下POST数据创建字符串形式的二维码参数: {“action_name”: “QR_LIMIT_STR_SCENE”, “action_info”: {“scene”: {“scene_str”: “test”}}}
这里我们获取的是临时的二维码,用户如果长时间不扫码则二维码会失效,我们需要构造一个 Post 请求将微信文档中需要参数传递过去,下面给出我的示例代码:
public static String getTemporaryQRCode(Boolean isTemplate, Boolean isSTR) throws JsonProcessingException {
HashMap<String, Object> map = new HashMap<>();
//获取临时二维码
//该二维码有效时间,以秒为单位。
//最大不超过2592000(即30天),此字段如果不填,则默认有效期为60秒。
map.put("expire_seconds", 120);
if (isTemplate) {
//二维码类型,QR_SCENE为临时的整型参数值,QR_STR_SCENE为临时的字符串参数值,
// QR_LIMIT_SCENE为永久的整型参数值,QR_LIMIT_STR_SCENE为永久的字符串参数值
if (isSTR) {
map.put("action_name", "QR_STR_SCENE");
}else {
map.put("action_name", "QR_SCENE");
}
} else {
if (isSTR) {
map.put("action_name", "QR_LIMIT_STR_SCENE");
} else {
map.put("action_name", "QR_LIMIT_SCENE");
}
}
// 构建 actionInfo,内嵌
HashMap<String, Object> actionInfo = new HashMap<>();
HashMap<String, Object> scene = new HashMap<>();
scene.put("scene_id", "1");
actionInfo.put("scene", scene);
map.put("action_info", actionInfo);
//发送请求来获取 ticket
String ticket = getTicket(map);
// 通过 ticket 来获取到二维码的地址
String encode = URLEncoder.encode(ticket);
String codeUrl = "https://mp.weixin.qq.com/cgi-bin/showqrcode?ticket=" + encode;
return codeUrl;
}
这段代码中,我需要传入两个参数来判断请求的是否是临时的二维码,场景值 ID 是否是字符串,通过判断语句来修改 POST 参数的内容,这里可以采用直接编写 JSON 字符串的方式实现,但如果想通过 Map 来实现的话需要注意请求的参数中有一个内嵌的 JSON 对象。
通过这个方法我们就能获取到我们二维码的 URL,这个二维码能干什么呢?
我们将这些信息封装到二维码后,用户扫码关注我们的公众号的时候就会将这些信息同时提交给我们的服务器**(基础消息能力 / 接收事件推送)**,当用户扫码后可以有两种事件推送,已经关注公众号和未关注但通过我们提供的二维码扫码关注两种事件。
<xml>
<ToUserName>ToUserName>
<FromUserName>FromUserName>
<CreateTime>123456789CreateTime>
<MsgType>MsgType>
<Event>Event>
<EventKey>EventKey>
<Ticket>Ticket>
xml>
<xml>
<ToUserName>ToUserName>
<FromUserName>FromUserName>
<CreateTime>123456789CreateTime>
<MsgType>MsgType>
<Event>Event>
<EventKey>EventKey>
<Ticket>Ticket>
xml>
这两个事件仅有 Event 参数不同,我们可以通过获取这个参数来判断用户是否是第一次关注公众号
我们在二维码的参数中设置一个标识登录的参数,当我们接收到这个参数的时候就执行登录的逻辑,同时为这个用户构建一个登录的密钥,将密钥和用户的 openId 作为一个键值对存储在 Redis 数据库中,同时将密钥返回给用户。
需要获取当用户的具体信息的时候,就需要学习公众号开发的具体授权**(微信网页开发 / 网页授权)**
请求地址
https://open.weixin.qq.com/connect/oauth2/authorize?appid=APPID&redirect_uri=REDIRECT_URI&response_type=code&scope=SCOPE&state=STATE#wechat_redirect
参考示例
https://open.weixin.qq.com/connect/oauth2/authorize?appid=wx807d86fb6b3d4fd2&redirect_uri=http%3A%2F%2Fdevelopers.weixin.qq.com&response_type=code&scope=snsapi_userinfo&state=STATE#wechat_redirect
这个地址的逻辑是用户访问这个地址后会请求授权,用户同意授权后会跳转到我们指定的回调界面,我这里希望在回调界面中为用户提供他们的登录密钥。
设置回调接口
找到测试公众号界面中的这个修改按钮,来指定回调的地址的域名,注意不要带 http,微信会检测我们的回调地址是否在这个域名下,如果在就无法实现跳转功能。
当用户扫码进入我们的公众号,且验证事件信息后发现是登录请求的时候,就向他提供我们的授权地址
代码示例
下面提供我实现的方式,仅供参考
/**
* 接收消息并自动回复的功能
*/
@PostMapping("/")
public String receiveMessage(HttpServletRequest request) throws IOException, DocumentException {
ServletInputStream inputStream = request.getInputStream();
// 将传来的信息封装到 map 中
HashMap<String, String> map = new HashMap<>();
SAXReader saxReader = new SAXReader();
Document read = saxReader.read(inputStream);
Element rootElement = read.getRootElement();
List<Element> elements = rootElement.elements();
for (Element element : elements) {
map.put(element.getName(), element.getStringValue());
}
log.info("开始返回信息,请求参数{}", map);
// 拿到用户的 OpenId
String openId = map.get("FromUserName");
// 验证是扫码登录且事件值是1 || (map.get("Event").equals("subscribe"))
//TODO:给事件值封装一个常量类
if ((map.get("Event").equals("subscribe") || map.get("Event").equals("SCAN"))) {
//String token = wxService.WxLogin(openId);
String url = "https://open.weixin.qq.com/connect/oauth2/authorize?" +
"appid=" +
"&redirect_uri=http://pab2mw.natappfree.cc/wx/wxCallBack" +
"&response_type=code" +
"&scope=snsapi_userinfo" +
"&state=STATE#wechat_redirect";
return WxUtil.getReplyMessage(map, "请跳转到授权网址:" + url);
}
return "";
先从 request 中接收到提供的参数,再去验证这个请求是否为登录请求,再将我们前面构造的回调地址传回去,这里需要学习被动回复用户信息**(基础消息能力 / 被动回复用户信息)**,这里我只解析这个回复文本消息的能力
被动回复用户信息
我们需要在 return 中返回一个 XML 格式的字符串
<xml>
<ToUserName>ToUserName>
<FromUserName>FromUserName>
<CreateTime>12345678CreateTime>
<MsgType>MsgType>
<Content>Content>
xml>
这是给出的字符串的示例,其中 ToUserName 和 FromUSerName 都可以通过请求的 XML 拿取,我们只需要指定创建时间和回复的具体消息即可。
public static String getReplyMessage(Map<String, String> map, String message) throws JsonProcessingException {
// 设置基础的信息
TestMessage testMessage = new TestMessage();
testMessage.setFromUserName(map.get("ToUserName"));
testMessage.setToUserName(map.get("FromUserName"));
testMessage.setMsgType("text");
testMessage.setCreateTime(System.currentTimeMillis() / 1000);
//设置需要返回给用户的信息
testMessage.setContent(message);
// 利用 JackSon 实现序列化
return JsonUtil.getXmlString(testMessage);
}
这个方法我也封装到了 WxUtil 这个类中,可以作为参考,序列化库使用的 Jackson,导入方式上面已经讲过了。
当用户授权成功后,微信会调用我们提供的回调地址,并且将 code 传递过来,比如我们上面指定的 /wxcallback,我们在这个回调接口中实现注册和密钥的返回。
我们拿到用户的 code 就可以向微信请求用户的具体信息了,首先要请求获取一个 accessToken,这和我们上面讲到的 accessToken 不是同一种,是用于网页授权的 accessToken。
我们请求这个地址:
获取code后,请求以下链接获取access_token:
https://api.weixin.qq.com/sns/oauth2/access_token?appid=APPID&secret=SECRET&code=CODE&grant_type=authorization_code
{
"access_token":"ACCESS_TOKEN",
"expires_in":7200,
"refresh_token":"REFRESH_TOKEN",
"openid":"OPENID",
"scope":"SCOPE",
"is_snapshotuser": 1,
"unionid": "UNIONID"
}
通过这个 accessToken 我们就可以获取到用户的信息了
如果网页授权作用域为snsapi_userinfo,则此时开发者可以通过access_token和openid拉取用户信息了。
http:GET(请使用https协议):
https://api.weixin.qq.com/sns/userinfo?access_token=ACCESS_TOKEN&openid=OPENID&lang=zh_CN
向这个地址发送请求信息就能得到用户的详细信息:
{
"openid": "OPENID",
"nickname": NICKNAME,
"sex": 1,
"province":"PROVINCE",
"city":"CITY",
"country":"COUNTRY",
"headimgurl":"https://thirdwx.qlogo.cn/mmopen/g3MonUZtNHkdmzicIlibx6iaFqAc56vxLSUfpb6n5WKSYVY0ChQKkiaJSgQ1dZuTOgvLLrhJbERQQ4eMsv84eavHiaiceqxibJxCfHe/46",
"privilege":[ "PRIVILEGE1" "PRIVILEGE2" ],
"unionid": "o6_bmasdasdsad6_2sgVt7hMZOPfL"
}
/**
* 获取用户详细信息
*/
public static Map<String, Object> getUserMes(String pageAccessToken, String openId) throws JsonProcessingException {
HttpResponse execute = HttpUtil.createGet("https://api.weixin.qq.com/sns/userinfo?" +
"access_token=" + pageAccessToken +
"&openid=OPENID" + openId +
"&lang=zh_CN").execute();
String body = execute.body();
return JsonUtil.getParamMap(body);
}
Controller 层
@RequestMapping("/wxCallBack")
@ResponseBody
public String wxCallBack(String code) throws JsonProcessingException {
Map<String, Object> pageMap = WxUtil.getPageMap(code);
String pageAccessToken = (String) pageMap.get("access_token");
String openId = (String) pageMap.get("openid");
String token = wxService.WxLogin(pageAccessToken, openId);
return "你的登录密钥为" + token;
}
Service 层
@Override
public String WxLogin(String pageAccessToken, String openId) throws JsonProcessingException {
// 先去数据库中查询是否有 openId 如果有则表明已经注册
QueryWrapper<User> queryWrapper = new QueryWrapper<>();
queryWrapper.eq("mpOpenId", openId);
User one = userService.getOne(queryWrapper);
String token = TokenUtil.generateShortToken();
String key = "LoginToken" + token;
if (one == null) {
// 用户还没有注册,调用注册方法
Map<String, Object> userMes = WxUtil.getUserMes(pageAccessToken, openId);
User user = new User();
// 保存用户的 unionId 和 openId
user.setMpOpenId(openId);
user.setUnionId((String) userMes.get("unionid"));
user.setUserName((String)userMes.get("nickname"));
user.setUserAvatar((String)userMes.get("headimgurl"));
//TODO:完成 API 调用的 accessKey 的获取
boolean save = userService.save(user);
if (!save) {
throw new BusinessException(400, "系统内部错误");
}
}
// 在 Redis 中存储密钥信息
redisService.setExpireString(key, openId, 120, TimeUnit.SECONDS);
return token;
}
在业务层实现了注册的业务,并且设置了向 Redis 中设置密钥。
这时候用户就能在授权地址中看到我们的 token,在前端界面输入 token,验证后即可完成登录逻辑。
@GetMapping("/verifyToken")
public BaseResponse<User> verifyToken(String wxToken, HttpServletRequest request) {
log.info("接收到的 token 为{}", wxToken);
String key = "LoginToken" + wxToken;
String openId = redisService.getString(key);
if (openId == null) {
throw new BusinessException(ErrorCode.OPERATION_ERROR, "密钥错误");
}
QueryWrapper<User> wrapper = new QueryWrapper<>();
wrapper.eq("mpOpenId", openId);
User one = userService.getOne(wrapper);
request.getSession().setAttribute(USER_LOGIN_STATE,one);
return ResultUtils.success(one);
}
具体根据自己的业务调整,这只是给出一个实例。
这样就完成了微信登录功能的开发,当然还有很多其他的实现方式,这个方式或许不是最好的,也存在一些问题,比如用户恰好输成别人的密钥就会导致登录到别人的账号,可以后期调整,当然用户授权后可以将信息保存到 session 中,前端轮询查看这个 session 是否有登录数据,查询后完成登录跳转。。。
总之实现的方法有很多,希望这篇文章能为大家带来启发。