使用OAuth2特性实现业务系统微信扫码登录

在很多小型的运营系统中,经常使用账号名/密码或手机号/验证码的方式进行运营系统登录。这里介绍一种利用OAuth2特性实现微信扫码进行系统登录的方式

使用的工具

内网穿透工具 - 39nat

微信开发工具包 - WxJava

说明

实现微信扫码需要使用微信公众平台网页授权获取用户基本信息的功能

在本文的所有示例中使用的是基于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

实现步骤

  1. 在系统登录的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();
}
  1. 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);
    }
});
...
  1. 提供一个接口,为前端轮询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);}
...
  1. 前端轮询这个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();

}

实现效果

1612147016(1).png

其他需要注意的一些点

  1. 需要使用内网穿透工具,在微信开放平台中配置扫码后的回调地址
  2. 如果是自己测试的话,直接使用测试平台即可
  3. 微信扫码可以开放思路,利用上面的原理实现用户绑定,用户确认等各种操作,有兴趣的同学可以自己去尝试
  4. url短链接可以做缓存,方式刷新页面重复调用的调用
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,684评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 87,143评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,214评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,788评论 1 277
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,796评论 5 368
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,665评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,027评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,679评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 41,346评论 1 299
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,664评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,766评论 1 331
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,412评论 4 321
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,015评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,974评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,203评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,073评论 2 350
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,501评论 2 343

推荐阅读更多精彩内容