在很多小型的运营系统中,经常使用账号名/密码或手机号/验证码的方式进行运营系统登录。这里介绍一种利用OAuth2特性实现微信扫码进行系统登录的方式
使用的工具
说明
实现微信扫码需要使用微信公众平台的网页授权获取用户基本信息的功能
在本文的所有示例中使用的是基于session和cookie保持用户状态,安全及授权框架使用的spring security oauth2。
因为该方式已经集成到公司的业务系统里,里面有些代码不方便放在公网上,如果有兴趣做一下需要帮助的伙伴,可以私信我。
微信扫码登录原理
实现微信扫码登录,必须把微信用户的open_id和业务后台的用户绑定,这里可以开放设计,比如统一关注公众号,实现业务系统信息通知
微信扫码登录是利用oauth2开放协议中的authorize_code授权模式中的state参数。生成一个不重复的id(推荐雪花Id),给该Id设置状态,默认生成时为未登录,在前端把授权地址使用前端二维码插件生成二维码。
当用户使用微信扫码后,会跳转到授权页,用户点击同意授权后。微信会给配置好的授权地址一个回调,该回调携带一个authorize_code和我们在前面设置的雪花Id(state参数),使用这个authorize_code换取访问token,然后使用access_token即可换取用户的基本信息。因为携带了state参数,前面微信用户也已经和我们的业务系统用户进行了绑定。然后我们即可修改state参数的id状态为已登录,前端页面轮询这个id的状态,当发现状态变为登录时,自动进行submit
实现步骤
- 在系统登录的controller中实现一个生成微信网页开放授权的URL
@SneakyThrows
@RequestMapping("/oauth/url")
public String weixinOauthLoginUrl() {
...
//生成雪花Id
snowflakeId = String.valueOf(snowflake.nextId());
String result = "https://open.weixin.qq.com/connect/oauth2/authorize?appid=%s&redirect_uri=%s&response_type=code&scope=snsapi_userinfo&state=%s#wechat_redirect";
//设置雪花Id,使用redis的map特性标注该Id的状态为未登录
redisUtil.hset(WX_LOGIN_KEY + snowflakeId, WX_LOGIN_ID_KEY_NAME, snowflakeId);
redisUtil.hset(WX_LOGIN_KEY + snowflakeId, WX_LOGIN_STATE_KEY_NAME, PublicEnum.NO.getValue(), weixinStateIdInvalidSeconds);
//构造String长链接给Web端生成二维码使用
finalUrl = String.format(result, weixinAppId, new URLEncoder().encode(wxOauthRedirectUri, StandardCharsets.UTF_8), snowflakeId);
try {
//使用了微信的短链接API,减少生成二维码的密度,提高识别率
finalUrl = wxMpService.shortUrl(finalUrl);
} catch (Exception e) {}
...
JSONObject jsonObject = new JSONObject();
jsonObject.put("url", finalUrl);
jsonObject.put("stateId", snowflakeId);
return jsonObject.toJSONString();
}
- web端使用controller生产的text,利用qrcode生成二维码
...
//声明QRcode
var qrcode = new QRCode("scan_qrcode", {
text: "",
width: 180,
height: 180,
render: "canvas",
colorDark: "#000000",
colorLight: "#ffffff",
correctLevel: QRCode.CorrectLevel.H});
//使用ajax获取URL
$.ajax({
type: "post",
url: "/wx/oauth/url",
dataType: "text",
async: false,
contentType: "application/x-www-form-urlencoded",
success: function (data) {
onInitSuccess(qrcode,data);
}
});
...
- 提供一个接口,为前端轮询state状态使用
...
@RequestMapping("/oauth/loop")
public String loopState(@RequestParam String stateId) {
Boolean bool = redisUtil.hasKey(WX_LOGIN_KEY + stateId);
//如果Key不存在则说明已失效或根本没有,返回失效状态
if (Boolean.FALSE.equals(bool)) {
return String.valueOf(PublicEnum.OTHER.getValue());
}
Object value = redisUtil.hget(WX_LOGIN_KEY + stateId, WX_LOGIN_STATE_KEY_NAME);
return String.valueOf(value);}
...
- 前端轮询这个state参数状态
...
//轮询用户登录状态
window.setInterval(getUserLoginState, 2000);
function getUserLoginState() {
$.ajax({
type: "post",
url: "/wx/oauth/loop",
dataType: "text",
async: false,
cache: false,
timeout: 2000,
contentType: "application/x-www-form-urlencoded",
data: {stateId: window.snowflakeId},
success: function (data) {
if(data == "1") {
//state参数变为已登录时,处理登录成功逻辑
onSuccessLogin();
} else if(data =="2") {
//如果状态为这个id不存在,则重新加载页面
window.snowflakeId="";
location.reload();
}
},
});
}
...
到这一步,整体的轮询登录逻辑已经成型,下面需要处理微信用户扫码授权后的回调处理。这里加入了一步确认登录的步骤
...
//这里是在微信公众平台配置的回调地址,把code和state参数返回到thymeleaf模板中
@RequestMapping("/login/confirm")
public ModelAndView loginConfirm(@RequestParam String code, @RequestParam String state, ModelMap param)
{
if(!redisUtil.hasKey(WX_LOGIN_KEY + state))
{
throw new IllegalArgumentException("no args exists");
}
log.info("receive callback code:{}, state:{}", code, state);
param.put("code", code);
param.put("state", state);
return new ModelAndView("confirm");
}
//当用户点击确认登录后,进入这个controller
@RequestMapping(value = "/oauth/login")
public void authorizeCode(@RequestParam String code, @RequestParam String state)
{
String msg = StringUtils.EMPTY;
if(!redisUtil.hasKey(WX_LOGIN_KEY + state))
{
throw new IllegalArgumentException("no args exists");
}
try
{
//获取AccessToken
WxMpOAuth2AccessToken wxMpOAuth2AccessToken = wxMpService.getOAuth2Service().getAccessToken(code);
//获取User
WxMpUser mpUser = wxMpService.getOAuth2Service().getUserInfo(wxMpOAuth2AccessToken, null);
log.info(mpUser.toString());
//根据openId查询对应已绑定用户
UimsUserDO user = userService.getByWxOpenId(mpUser.getOpenId());
if(Objects.nonNull(user))
{
//设置登录状态为成功,并设置对应用户的账号
redisUtil.hset(WX_LOGIN_KEY + state, WX_LOGIN_STATE_KEY_NAME, PublicEnum.YES.getValue());
redisUtil.hset(WX_LOGIN_KEY + state, WX_LOGIN_USER_KEY_NAME, user.getAccount()); //返回提示登录成功
msg = "扫码登录成功";
}
else
{
msg = "微信未绑定系统用户";
}
}
catch(Exception e)
{
log.error("{}", e.getMessage());
}
if(StringUtils.isBlank(msg))
{
msg = "获取微信用户相关信息失败";
}
//客户端302跳转,跳转到消息提示页
response.setStatus(HttpStatus.MOVED_PERMANENTLY.value());
response.setHeader("Location", "/tips?msg=" + new URLEncoder().encode(msg, StandardCharsets.UTF_8));
}
...
在前面中,因为已经设置了轮询state状态,在微信回调业务后台后,因为已经绑定了微信的openid和业务系统用户的关联关系。即可知道该Id对应的是哪一个系统用户,后面处理自动登录逻辑即可。
自动登录因为我这里使用的是spring security oauth2。手动构造了一个认证token,这里放出部分代码,如果有需要帮助的欢迎私信
...
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException
{
if(StringUtils.contains(request.getRequestURI(), WxAuthenticationUtil.AUTHENTICATION_URL) && StringUtils.equalsIgnoreCase(request.getMethod(), WxAuthenticationUtil.SUPPORT_METHOD_NAME))
{
try
{
//扫码登录逻辑
String codeInRequest = ServletRequestUtils.getStringParameter(request, WxAuthenticationUtil.STATE_PARAMTER_NAME);
//如果Redis中不存在这个Id,则抛出异常
if(!redisUtil.hasKey(Consts.WX_LOGIN_KEY + codeInRequest))
{
throw new BadCredentialsException("bad credentials");
}
//如果该Id的状态为未登陆,则抛出异常
Integer state = Integer.valueOf(redisUtil.hget(Consts.WX_LOGIN_KEY + codeInRequest, Consts.WX_LOGIN_STATE_KEY_NAME).toString());
if(!state.equals(PublicEnum.YES.getValue()))
{
throw new BadCredentialsException("bad credentials");
}
//如果未设置对应的账号信息,则抛出异常
String account = String.valueOf(redisUtil.hget(Consts.WX_LOGIN_KEY + codeInRequest, Consts.WX_LOGIN_USER_KEY_NAME));
if(StringUtils.isEmpty(account))
{
throw new BadCredentialsException("bad credentials");
}
//传递account信息,用于后面构建Token使用
request.setAttribute(ACCOUNT_PARAMTER_NAME, redisUtil.hget(Consts.WX_LOGIN_KEY + codeInRequest, Consts.WX_LOGIN_USER_KEY_NAME));
//在Redis中删除Key
redisUtil.del(Consts.WX_LOGIN_KEY + codeInRequest);
}
catch(AuthenticationException e)
{
authenticationFailureHandler.onAuthenticationFailure(request, response, e);
return;
}
}
filterChain.doFilter(request, response);
}
...
声明一个微信token
public class WxAuthenticationToken extends AbstractAuthenticationToken
{
private static final long serialVersionUID = SpringSecurityCoreVersion.SERIAL_VERSION_UID;
private final Object principal;
public WxAuthenticationToken(Object principal)
{
super(null);
this.principal = principal;
setAuthenticated(false);
}
public WxAuthenticationToken(Object principal, Collection <? extends GrantedAuthority > authorities)
{
super(authorities);
this.principal = principal;
super.setAuthenticated(true);
}@
Override
public Object getCredentials()
{
return null;
}@
Override
public Object getPrincipal()
{
return this.principal;
}@
Override
public void setAuthenticated(boolean isAuthenticated) throws IllegalArgumentException
{
if(isAuthenticated)
{
throw new IllegalArgumentException("Cannot set this token to trusted - use constructor which takes a GrantedAuthority list instead");
}
super.setAuthenticated(false);
}@
Override
public void eraseCredentials()
{
super.eraseCredentials();
}
}
请求匹配器
public class WxAuthenticationFilter extends AbstractAuthenticationProcessingFilter
{
private boolean postOnly = true;
//请求的匹配器,乳沟请求地址为特定地址,并且方法为指定的方法
public WxAuthenticationFilter()
{
super(new AntPathRequestMatcher(WxAuthenticationUtil.AUTHENTICATION_URL, WxAuthenticationUtil.SUPPORT_METHOD_NAME));
}@
Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException
{
//是否仅 POST 方式
if(this.postOnly && !request.getMethod().equals("POST"))
{
throw new AuthenticationServiceException("Authentication method not supported: " + request.getMethod());
}
else
{
//取出账号
String account = (String) request.getAttribute(WxAuthenticationUtil.ACCOUNT_PARAMTER_NAME);
if(StringUtils.isBlank(account))
{
throw new BadCredentialsException("bad credentials");
}
//这里封装未认证的Token
WxAuthenticationToken authRequest = new WxAuthenticationToken(StringUtils.trimToEmpty(account));
//将请求信息也放入到Token中。
this.setDetails(request, authRequest);
return this.getAuthenticationManager().authenticate(authRequest);
}
}
//将请求信息也放入到Token中。
protected void setDetails(HttpServletRequest request, WxAuthenticationToken authRequest)
{
authRequest.setDetails(this.authenticationDetailsSource.buildDetails(request));
}
}
常量类
public class WxAuthenticationUtil {
public static final String STATE_PARAMTER_NAME = "stateId";
public static final String ACCOUNT_PARAMTER_NAME = "account";
public static final String AUTHENTICATION_URL = "/authentication/wx";
public static final String SUPPORT_METHOD_NAME = HttpMethod.POST.name();
}
实现效果
其他需要注意的一些点
- 需要使用内网穿透工具,在微信开放平台中配置扫码后的回调地址
- 如果是自己测试的话,直接使用测试平台即可
- 微信扫码可以开放思路,利用上面的原理实现用户绑定,用户确认等各种操作,有兴趣的同学可以自己去尝试
- url短链接可以做缓存,方式刷新页面重复调用的调用