图源:简书 (jianshu.com)
在之前的文章从零开始 Spring Boot 9:Shiro - 魔芋红茶’s blog (icexmoon.cn)中我介绍了如何给Spring Boot开发的Web应用添加Shiro身份验证模块,这样我们就可以通过用户/密码的方式让用户进行登录。
当然了,这种方式就必须要求用户先进行注册,或者管理员为用户预先添加帐号才行,所以如果我们需要降低用户使用系统干的门槛,让用户更容易登录系统,就可以接入第三方登录。而天朝最流行的第三方登录就是微信,这点应该是没有争议的。下面就介绍如何在现有用户/密码登录体系外额外接入微信登录。
如果我们需要接入某个服务,第一选择自然是去该服务官方的开放平台获取开发文档,微信这里有点不同:它同时有两个“开放平台”:
估计很多人会一开始和我一样一脸懵逼,傻傻分不清,更别提这两个平台实际上都具备将网站应用接入微信登录的能力。
实际上微信公众平台是为微信自家的应用和服务提供的支持,比如公众号和小程序之类的,这里接入网站更像是在一个公众号下依托公众号来进行接入,只能说是为了方便公众号引流等顺带提供的一个接入服务。所以该方式的接入很受限,比如一个公众号下只能绑定两个域名进行接入。此外,这种方式接入的网站只能是通过在微信浏览器内让用户点击授权的方式进行登录,不支持web端展示二维码后微信扫码登录。
但相对的,好处是这种方式接入成本更低,只要有一个服务号级别的公众号就可以接入。而如果要接入微信开放平台,则需要有现成的网站应用,并提交给微信进行审核,审核通过后才能进行开发和接入,相对来说门槛更高,但好处是可以支持微信扫码登录,限制会更少。
所以,必须先明确自己的需求和具体接入方式,是选择“开放平台”还是“公众平台”,否则就很容易和我一开始一样看着“开放平台”的文档用公众平台的测试号接入,结果除了莫名其妙的报错信息外一无所获。
下面将介绍如何通过微信公众平台进行接入。
首先你需要一个服务号级别的微信公众号,当然这并非必须,如果没有可以使用测试号,这点后边会说明。
首先需要通过微信公众平台登录服务号,如果没有相应的权限可以让服务号的管理者通过服务号设置页面进行添加,比如这样:
这样你就可以登录并管理公众号了,除此之外,还需要添加开发者的权限,这个在开发者工具>web开发者工具
:
同样的,绑定后就可以通过微信开发工具进行这个公众号相关接入的开发。
之前说过了,微信出于“安全考虑”,会限制一个公众号只能接入两个域名下的网站应用,所以还需要将我们web应用所在服务器的域名绑定到公众号设置中,具体是在接口权限>网页授权
的修改
链接中:
可以通过设置添加两个域名,这里需要的注意的是,填入的必须是域名,不包含http
等额外信息,比如icexmoon.cn
这样。并且这个域名不支持子域名,比如你添加了icexmoon.cn
,而你的web应用是放在blog.icexmoon.cn
这个子域名下,那是不能正常接入的。
并且正式的服务号接入还需要将一个文件放在域名根目录下,作为安全机制的一部分,这个按照网页上的说明去设置就行。
如果是没有拥有域名的公网服务器运行web应用,或者是为了本地方便调试开发,可以选择使用内网穿透服务。
这类服务应该有很多可以选择,这里介绍一个免费的内网穿透服务NATAPP-内网穿透 基于ngrok的国内高速内网映射工具。
注册登录网站,然后点击购买隧道>免费隧道
:
就可以创建一条免费的内网穿透线路,隧道设置中比较重要的是内网ip和端口:
设置为你web应用监听的ip和端口即可,这里是Spring Boot默认的8080端口。
然后需要点击上方的下载客户端
来下载客户端。
客户端本身是一个小巧的natapp.exe
文件,下载好后需要在.exe
文件的同级目录下添加一个config.ini
:
[default]
authtoken=xxx #对应一条隧道的authtoken
clienttoken=
log=none
loglevel=ERROR
http_proxy=
这里只要将xxx
替换为隧道设置中的authtoken
的值即可。
然后启动natapp.exe
看到这样的信息就是成功了,其中xxx.natappfree.cc
就是内网穿透后在公网的域名。
花生壳是一个更好的内网穿透工具,能提供一个免费的HTTPS的固定域名进行穿透。
如果手头上没有可以直接使用的公众号,也可以使用微信公众平台提供的测试号进行开发工作:
测试号比正式的公众号要简单很多,首页上直接显示公众号开发最关键的appId
和secret
:
要注意的是,测试号同样需要设置接入web应用的域名:
与正式公众号不同的是不需要额外的文件设置。
前面说过,通过这种方式接入被限制在微信浏览器中,所以我们需要一个能模拟微信浏览器的开发环境,微信官方提供了:
下载最新的稳定版即可。
最后就是需要准备接入微信的web应用,这里是展示在Spring boot+shiro的应用上添加额外的微信认证登录,所以这里使用从零开始 Spring Boot 17:MyBatis Plus 续 - 魔芋红茶’s blog (icexmoon.cn)中的最终示例代码进行接入,当然你也可以在自己的已有项目上进行尝试。如果你同样需要一个基础的示例项目,可以通过下面的链接进行下载:
可以执行示例项目中的相关sql文件生成配套的示例数据库。
方便起见,修改配置中的应用域名为穿透后的公网域名:
#应用使用的域名
books.web.host=xxxxx.oicp.vip
在配置中添加公众号的appid和secret:
#微信公众号信息
books.appid=xxx
books.secret=xxx
当然,这里的appid和secret也可以保存在数据库中,再配合相应的配置修改页面来进行维护和更换,具体取决于你的需要。
按照之前的惯例,在SysProperties
中添加相应的属性以方便读取配置信息:
@Data
@Component
public class SysProperties {
...
@Value("${books.appid}")
private String wxAppid;
@Value("${books.secret}")
private String wxSecret;
}
接入微信的第一步需要通过一个微信授权url来获取用户授权并通过微信回调的方式获取到code
,使用获取的code
就可以进一步获取到用户的信息和标记用户身份的openId
。
之所以这样设计,是微信出于安全性方面的考虑,这里获取到的
code
是一个一次性的安全码,使用后就会失效。
这里是一个前后端分离的示例,所以我这里会添加一个生成授权url的接口,由前端获取授权url并重定向页面,回调地址也可以使用一个前端页面的地址,这样前端再用code
去请求后端的微信登录接口进行登录。
整个过程可以用下边的时序图表示:
不过这里只是进行演示,所以后端提供一个回调用的url
。
具体代码如下:
@RestController
@RequestMapping("/wechat/wechat-login")
@Api(tags = "微信认证")
public class WechatLoginController {
@Autowired
SysProperties sysProperties;
@GetMapping("/auth-url")
public Result getAuthUrl() {
String url = "https://open.weixin.qq.com/connect/oauth2/authorize?appid=%s&redirect_uri=%s&response_type=code&scope=snsapi_userinfo&state=STATE#wechat_redirect";
String callback = "http://%s/wechat/wechat-login/callback";
callback = String.format(callback, sysProperties.getHost());
try {
callback = URLEncoder.encode(callback, "UTF-8");
} catch (UnsupportedEncodingException e) {
e.printStackTrace();
return Result.fail(Result.ErrorCode.DEFAULT_ERROR, e.getLocalizedMessage());
}
url = String.format(url, sysProperties.getWxAppid(), callback);
return Result.success(url);
}
@GetMapping("/callback")
public String callback(
@RequestParam("code") String code,
@RequestParam("state") String state
) {
return Result.success().toString();
}
}
需要注意的是:
现在我们就可以通过http://localhost:8080/wechat-login/auth-url
接口获取到一个类似下边这样的微信认证url:
https://open.weixin.qq.com/connect/oauth2/authorize?appid=wx934590a6bf2965da&redirect_uri=http%3A%2F%2Fxxxx.natappfree.cc%2Fwechat-login%2Fcallback&response_type=code&scope=snsapi_userinfo&state=STATE#wechat_redirect
当然具体因为你的回调host和appId不同会是不一样的。
将这个认证url填入微信开发工具的地址栏后回车,就能看到请求授权的页面,点击授权后就会跳转到回调地址,并且通过附带的调试工具就能看到作为回调地址参数传递的code
:
- 如果第一次请求,可能会失败,并显示没有关注公众号,这时候就需要关注一下公众号,测试号是在测试号的页面扫描二维码关注测试号的公众号。
- 如果现实
redirect_url
参数错误,要检查redirect_url
参数中的回调地址是否与前边说的公众号设置中的web域名一致。
拿到code
后就可以获取到用户的openId
和access_token
,通过后者还可以继续获取用户的个人信息。
在进行下一步前需要先给数据的user
表添加字段open_id
以保存用户的微信唯一标识,用于微信登录。此外还可以添加用于保存昵称和用户头像的字段。
CREATE TABLE `user` (
`id` int NOT NULL AUTO_INCREMENT,
`name` varchar(45) NOT NULL,
`password` varchar(45) NOT NULL,
`open_id` varchar(45) NOT NULL COMMENT '微信openId',
`real_name` varchar(45) NOT NULL COMMENT '姓名或昵称',
`icon` varchar(255) NOT NULL,
`del_flag` tinyint NOT NULL DEFAULT '0',
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=3 DEFAULT CHARSET=utf8mb3
拿到code
后,我们就可以用code
以及公众号的secret
获取微信用户在当前公众号下的openId
了。当然,在我们这个系统中体现为一个微信登录接口,前端传code
到后端,由后端通过相应的公众平台接口获取openId
并发放本系统的令牌给前端,完成整个微信认证登录的过程。
首先,为了方便起见,我们先将后续要用到的微信公众平台接口进行封装:
public interface IWechatAccessTokenService {
@Data
class SuccessResp {
//获取到的凭证
private String access_token;
//凭证有效时间,单位:秒
private String expires_in;
}
/**
* 获取公众号token
*
* @return
*/
SuccessResp getAccessToken();
}
public interface IWechatSupperAccessTokenService {
@Data
class SuccessResp {
//网页授权接口调用凭证,注意:此access_token与基础支持的access_token不同
private String access_token;
//access_token接口调用凭证超时时间,单位(秒)
private Integer expires_in;
//用户刷新access_token
private String refresh_token;
//用户唯一标识,请注意,在未关注公众号时,用户访问公众号的网页,也会产生一个用户和公众号唯一的OpenID
private String openid;
//用户授权的作用域,使用逗号(,)分隔
private String scope;
}
/**
* 根据授权时获取的code获取access_token
*
* @param code
* @return
*/
SuccessResp getAccessToken(String code);
}
public interface IWechatUserInfoService {
@Data
class Response {
//用户的唯一标识
private String openId;
//用户昵称
private String nickname;
//用户的性别,值为1时是男性,值为2时是女性,值为0时是未知
private Integer sex;
//用户个人资料填写的省份
private String province;
//普通用户个人资料填写的城市
private String city;
//国家,如中国为CN
private String country;
//用户头像,最后一个数值代表正方形头像大小(有0、46、64、96、132数值可选,0代表640*640正方形头像),用户没有头像时该项为空。若用户更换头像,原有头像 URL 将失效。
private String headimgurl;
//用户特权信息,json 数组,如微信沃卡用户为(chinaunicom)
private List<String> privilege;
//只有在用户将公众号绑定到微信开放平台帐号后,才会出现该字段。
private String unionid;
}
/**
* 获取微信账号信息
*
* @param accessToken 微信访问令牌
* @param openId 微信账号的openId
* @return
*/
Response getUserInfo(String accessToken, String openId);
}
这三个接口分别用于获取普通的accessToken
,可以用于获取个人信息的accessToken
,以及获取个人信息。
具体这三个接口的实现使用在从零开始 Spring Boot 15:Http Client - 魔芋红茶’s blog (icexmoon.cn)中介绍的HTTP Client,这里不过多介绍,可以参考文末附上的完整源码。
因为要在原来的帐号/密码登录体系下添加一套登录逻辑,所以需要修改Shiro模块以让其支持两套认证登录。
首先创建一个用于微信登录认证所需的令牌:
public class WeChatToken implements AuthenticationToken {
private String openId;
private String accessToken;
public String getAccessToken() {
return accessToken;
}
@Override
public Object getPrincipal() {
return openId;
}
@Override
public Object getCredentials() {
return null;
}
public WeChatToken(String openId, String accessToken) {
this.openId = openId;
this.accessToken = accessToken;
}
}
在从零开始 Spring Boot 9:Shiro - 魔芋红茶’s blog (icexmoon.cn)中我们介绍过,利用Shiro进行身份验证的核心机制是Realm
,所以这里同样实现一个用于微信登录的Realm
:
public class WechatRealm extends AuthorizingRealm {
@Autowired
private IUserService userService;
@Override
protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principalCollection) {
//获取登录用户名
String name = (String) principalCollection.getPrimaryPrincipal();
//查询用户名称
User user = userService.getUserByName(name);
//添加角色和权限
SimpleAuthorizationInfo simpleAuthorizationInfo = new SimpleAuthorizationInfo();
for (Role role : user.getRoles()) {
//添加角色
simpleAuthorizationInfo.addRole(role.getName());
//添加权限
for (Permission permission : role.getPermissions()) {
simpleAuthorizationInfo.addStringPermission(permission.getName());
}
}
return simpleAuthorizationInfo;
}
@Override
protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authenticationToken) throws AuthenticationException {
WeChatToken token = (WeChatToken) authenticationToken;
User sysUser = userService.getUserByOpenId((String) token.getPrincipal(), token.getAccessToken());
if (sysUser == null) {
throw new AuthenticationException("用户不存在");
} else {
;
}
AuthenticationInfo authenticationInfo = new SimpleAuthenticationInfo(sysUser.getName(), null, this.getName());
return authenticationInfo;
}
@Override
public boolean supports(AuthenticationToken token) {
return token != null && token instanceof WeChatToken;
}
@Override
protected void assertCredentialsMatch(AuthenticationToken token, AuthenticationInfo info) throws AuthenticationException {
;
}
}
和之前有所不同的是,这里覆盖了AuthorizingRealm
的supports
方法和assertCredentialsMatch
方法。
当我们采用多realm
验证的时候,多个Realm
会构成一个验证链,一个AuthenticationToken
会依次经过多个Realm
进行验证,当一个或多个Realm
通过验证时,就算是验证通过,实现了登录。
这里的supports
方法是告诉Shiro,在什么情况下当前的Realm
需要生效。
这里WechatRealm
的doGetAuthorizationInfo
方法用途和之前通过帐号密码登录时相同,都是实现为帐号赋权,所以代码也完全一致。而doGetAuthenticationInfo
方法有所不同,需要通过WeChatToken
类型的token来查找用户信息,并且在用户信息不存在时(任何微信账户第一次授权登录系统)为其创建一条用户信息。
为此我们需要为UserServiceImpl
添加一个方法:
@Service
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements IUserService {
@Autowired
private IUserRoleService userRoleService;
@Autowired
private IWechatUserInfoService wechatUserInfoService;
...
@Override
public User getUserByOpenId(String openId, String accessToken) {
QueryWrapper<User> qw = new QueryWrapper<>();
qw.eq("open_id", openId);
qw.last("LIMIT 1");
User user = this.getOne(qw);
if (user == null) {
IWechatUserInfoService.Response res = wechatUserInfoService.getUserInfo(accessToken, openId);
User newUser = User.newInstance(openId);
String nickname = res.getNickname();
newUser.setRealName(MyStringUtil.convert2OtherEncoding(nickname, "ISO-8859-1", "UTF-8"));
newUser.setIcon(res.getHeadimgurl());
this.save(newUser);
user = newUser;
}
return user;
}
}
这里的关键用户信息,都是由获取到的openId
和accessToken
进而请求微信开放平台接口获取到的。
获取到的用户昵称并不是UTF-8编码,所以需要进行转码。
此外,因为原系统的帐号体系是用户自定义,并要求唯一,所以我们要为微信帐号分配唯一的帐号名,这里我利用时间戳+随机数分配:
@Data
@EqualsAndHashCode(callSuper = false)
@TableName("user")
public class User implements Serializable {
...
public static User newInstance(String openId) {
User sysUser = new User();
Random random = new Random();
String flag = Long.toString(System.currentTimeMillis()) + Integer.toString(random.nextInt(1000));
sysUser.setName(MyStringUtil.md5(flag));
sysUser.setPassword("");
sysUser.setIcon("");
sysUser.setRealName("");
sysUser.setOpenId(openId);
return sysUser;
}
}
当然也可以直接使用
openId
。
我们还需要修改Shiro配置,将原来的单一Realm
验证的方式修改为多Realm
:
@Configuration
public class ShiroConfig {
...
//权限管理,配置主要是Realm的管理认证
@Bean
public SecurityManager securityManager() {
DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager();
Collection<Realm> realms = new ArrayList<>();
realms.add(customRealm());
realms.add(wechatRealm());
securityManager.setRealms(realms);
securityManager.setSessionManager(sessionManager());
return securityManager;
}
...
@Bean(name = "wechatRealm")
public WechatRealm wechatRealm() {
return new WechatRealm();
}
...
}
最后改造处理登录业务的Login
类:
@Component
@Log4j2
public class Login {
@Autowired
private IUserService userService;
@Autowired
private IWechatSupperAccessTokenService wechatSupperAccessTokenService;
...
@Data
@AllArgsConstructor
@NoArgsConstructor
public static class WeChatLoginInfo implements IResult {
@ApiModelProperty("ccsp的访问令牌")
private String token;
@ApiModelProperty("微信openid")
private String openId;
}
/**
* 使用微信授权后的code登录系统并返回系统token
*
* @param code 微信授权后的code
* @return
*/
public WeChatLoginInfo weChatLogin(String code) {
Subject subject = SecurityUtils.getSubject();
if (subject.getPrincipal() != null) {
String name = (String) subject.getPrincipal();
User sysUser = userService.getUserByName(name);
return new WeChatLoginInfo(subject.getSession().getId().toString(), sysUser.getOpenId());
}
IWechatSupperAccessTokenService.SuccessResp response = wechatSupperAccessTokenService.getAccessToken(code);
String openid = response.getOpenid();
subject.login(new WeChatToken(openid, response.getAccess_token()));
log.info("session.timeout" + subject.getSession().getTimeout());
return new WeChatLoginInfo(subject.getSession().getId().toString(), openid);
}
}
最后的最后,添加微信登录的接口:
@RestController
@RequestMapping("/wechat/wechat-login")
@Api(tags = "微信认证")
public class WechatLoginController {
@Autowired
SysProperties sysProperties;
@Autowired
Login login;
...
@ApiOperation("微信登录")
@PostMapping("/login/{code}")
public Result login(@ApiParam("用户授权后获取到的code") @PathVariable String code) {
Login.WeChatLoginInfo info = login.weChatLogin(code);
return Result.success(info.getToken());
}
}
现在可以将之前获得code
的步骤重新执行一遍,并在获得code
后调用微信登录接口进行登录。
大概一部分人会在这个过程中遇到麻烦,可以看到类似下面的报错信息:
{
"success": false,
"msg": "密码不正确或帐号被锁定,请联系管理员",
"data": null,
"code": 400
}
进一步排查问题就会发现,实际上并没有正确生成新的用户信息,实际上在执行SQL的时候出错了:
...
### SQL: INSERT INTO user ( name, password, open_id, real_name, icon ) VALUES ( ?, ?, ?, ?, ? )
### Cause: java.sql.SQLException: Incorrect string value: '\xF0\x9F\x98\xBC' for column 'real_name' at row 1
; uncategorized SQLException; SQL state [HY000]; error code [1366]; Incorrect string value: '\xF0\x9F\x98\xBC' for column 'real_name' at
...
这是因为微信昵称中的emoji符号导致的,微信昵称中可以使用emoji符号,而emoji符号使用的是4字节编码的UTF-8,而我们这里数据库实际使用的是3字节编码的utf8mb3
,所以需要将数据库编码修改为4字节编码的utf8mb4
。
方法也很简单:
ALTER DATABASE books CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci;
ALTER TABLE books.`user` CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
现在再重新尝试就能正常保存数据并返回系统token了:
{
"success": true,
"msg": "",
"data": "30affd79-47ac-41de-a53d-38a2ffc172f0",
"code": 200
}
需要注意的是,这里必须重新获取
code
,前边说过,code
是一次性的。
虽然数据库里显示的是?
,但用能正常显示emoji的系统就能看到相应的emoji图标(比如微信内置的浏览器)。
为了测试微信登录,我额外添加了一个获取当前用户信息的接口:
接口本身比较简单,这里就不详细说明了,需要的可以自行查看源码。
当然,原本的帐号/密码体系依然是这可以正常登录的,这里不一一展示。
最后,本文最终的完整示例代码见learn_spring_boot/ch18 (github.com)。
谢谢阅读。