使用OAuth2特性實現(xiàn)業(yè)務(wù)系統(tǒng)微信掃碼登錄

在很多小型的運營系統(tǒng)中,經(jīng)常使用賬號名/密碼或手機號/驗證碼的方式進行運營系統(tǒng)登錄。這里介紹一種利用OAuth2特性實現(xiàn)微信掃碼進行系統(tǒng)登錄的方式

使用的工具

內(nèi)網(wǎng)穿透工具 - 39nat

微信開發(fā)工具包 - WxJava

說明

實現(xiàn)微信掃碼需要使用微信公眾平臺網(wǎng)頁授權(quán)獲取用戶基本信息的功能

在本文的所有示例中使用的是基于session和cookie保持用戶狀態(tài),安全及授權(quán)框架使用的spring security oauth2。

因為該方式已經(jīng)集成到公司的業(yè)務(wù)系統(tǒng)里,里面有些代碼不方便放在公網(wǎng)上,如果有興趣做一下需要幫助的伙伴,可以私信我。

微信掃碼登錄原理

實現(xiàn)微信掃碼登錄,必須把微信用戶的open_id和業(yè)務(wù)后臺的用戶綁定,這里可以開放設(shè)計,比如統(tǒng)一關(guān)注公眾號,實現(xiàn)業(yè)務(wù)系統(tǒng)信息通知

微信掃碼登錄是利用oauth2開放協(xié)議中的authorize_code授權(quán)模式中的state參數(shù)。生成一個不重復(fù)的id(推薦雪花Id),給該Id設(shè)置狀態(tài),默認(rèn)生成時為未登錄,在前端把授權(quán)地址使用前端二維碼插件生成二維碼。

當(dāng)用戶使用微信掃碼后,會跳轉(zhuǎn)到授權(quán)頁,用戶點擊同意授權(quán)后。微信會給配置好的授權(quán)地址一個回調(diào),該回調(diào)攜帶一個authorize_code和我們在前面設(shè)置的雪花Id(state參數(shù)),使用這個authorize_code換取訪問token,然后使用access_token即可換取用戶的基本信息。因為攜帶了state參數(shù),前面微信用戶也已經(jīng)和我們的業(yè)務(wù)系統(tǒng)用戶進行了綁定。然后我們即可修改state參數(shù)的id狀態(tài)為已登錄,前端頁面輪詢這個id的狀態(tài),當(dāng)發(fā)現(xiàn)狀態(tài)變?yōu)榈卿洉r,自動進行submit

實現(xiàn)步驟

  1. 在系統(tǒng)登錄的controller中實現(xiàn)一個生成微信網(wǎng)頁開放授權(quán)的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";

    //設(shè)置雪花Id,使用redis的map特性標(biāo)注該Id的狀態(tài)為未登錄
    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);
    //構(gòu)造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生產(chǎn)的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狀態(tài)使用
...
    
@RequestMapping("/oauth/loop")
public String loopState(@RequestParam String stateId) {    
Boolean bool = redisUtil.hasKey(WX_LOGIN_KEY + stateId);    
//如果Key不存在則說明已失效或根本沒有,返回失效狀態(tài)    
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參數(shù)狀態(tài)
...

//輪詢用戶登錄狀態(tài)
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參數(shù)變?yōu)橐训卿洉r,處理登錄成功邏輯
               onSuccessLogin();
           } else if(data =="2") {
               //如果狀態(tài)為這個id不存在,則重新加載頁面
               window.snowflakeId="";
               location.reload();
           }
         },
      });
}
...

到這一步,整體的輪詢登錄邏輯已經(jīng)成型,下面需要處理微信用戶掃碼授權(quán)后的回調(diào)處理。這里加入了一步確認(rèn)登錄的步驟

...  

//這里是在微信公眾平臺配置的回調(diào)地址,把code和state參數(shù)返回到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");
      }
  

//當(dāng)用戶點擊確認(rèn)登錄后,進入這個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());
          //根據(jù)openId查詢對應(yīng)已綁定用戶        
          UimsUserDO user = userService.getByWxOpenId(mpUser.getOpenId());
          if(Objects.nonNull(user))
          {
              //設(shè)置登錄狀態(tài)為成功,并設(shè)置對應(yīng)用戶的賬號            
              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 = "微信未綁定系統(tǒng)用戶";
          }
      }
      catch(Exception e)
      {
          log.error("{}", e.getMessage());
      }
      if(StringUtils.isBlank(msg))
      {
          msg = "獲取微信用戶相關(guān)信息失敗";
      }
      //客戶端302跳轉(zhuǎn),跳轉(zhuǎn)到消息提示頁    
      response.setStatus(HttpStatus.MOVED_PERMANENTLY.value());
      response.setHeader("Location", "/tips?msg=" + new URLEncoder().encode(msg, StandardCharsets.UTF_8));
  }

...

在前面中,因為已經(jīng)設(shè)置了輪詢state狀態(tài),在微信回調(diào)業(yè)務(wù)后臺后,因為已經(jīng)綁定了微信的openid和業(yè)務(wù)系統(tǒng)用戶的關(guān)聯(lián)關(guān)系。即可知道該Id對應(yīng)的是哪一個系統(tǒng)用戶,后面處理自動登錄邏輯即可。

自動登錄因為我這里使用的是spring security oauth2。手動構(gòu)造了一個認(rèn)證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的狀態(tài)為未登陸,則拋出異常
            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");
            }
            //如果未設(shè)置對應(yīng)的賬號信息,則拋出異常
            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信息,用于后面構(gòu)建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");
                }
                //這里封裝未認(rèn)證的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();

}

實現(xiàn)效果

1612147016(1).png

其他需要注意的一些點

  1. 需要使用內(nèi)網(wǎng)穿透工具,在微信開放平臺中配置掃碼后的回調(diào)地址
  2. 如果是自己測試的話,直接使用測試平臺即可
  3. 微信掃碼可以開放思路,利用上面的原理實現(xiàn)用戶綁定,用戶確認(rèn)等各種操作,有興趣的同學(xué)可以自己去嘗試
  4. url短鏈接可以做緩存,方式刷新頁面重復(fù)調(diào)用的調(diào)用
最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容