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

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

使用的工具

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

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

說明

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

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

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

微信掃碼登錄原理

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

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

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

實現(xiàn)步驟

  1. 在系統(tǒng)登錄的controller中實現(xiàn)一個生成微信網(wǎng)頁開放授權的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的狀態(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);
    //構造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)成型走越,下面需要處理微信用戶掃碼授權后的回調(diào)處理椭豫。這里加入了一步確認登錄的步驟

...  

//這里是在微信公眾平臺配置的回調(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");
      }
  

//當用戶點擊確認登錄后,進入這個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查詢對應已綁定用戶        
          UimsUserDO user = userService.getByWxOpenId(mpUser.getOpenId());
          if(Objects.nonNull(user))
          {
              //設置登錄狀態(tài)為成功,并設置對應用戶的賬號            
              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 = "獲取微信用戶相關信息失敗";
      }
      //客戶端302跳轉买喧,跳轉到消息提示頁    
      response.setStatus(HttpStatus.MOVED_PERMANENTLY.value());
      response.setHeader("Location", "/tips?msg=" + new URLEncoder().encode(msg, StandardCharsets.UTF_8));
  }

...

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

自動登錄因為我這里使用的是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的狀態(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");
            }
            //如果未設置對應的賬號信息,則拋出異常
            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();

}

實現(xiàn)效果

1612147016(1).png

其他需要注意的一些點

  1. 需要使用內(nèi)網(wǎng)穿透工具戒傻,在微信開放平臺中配置掃碼后的回調(diào)地址
  2. 如果是自己測試的話,直接使用測試平臺即可
  3. 微信掃碼可以開放思路蜂筹,利用上面的原理實現(xiàn)用戶綁定需纳,用戶確認等各種操作,有興趣的同學可以自己去嘗試
  4. url短鏈接可以做緩存艺挪,方式刷新頁面重復調(diào)用的調(diào)用
最后編輯于
?著作權歸作者所有,轉載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末不翩,一起剝皮案震驚了整個濱河市,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌口蝠,老刑警劉巖器钟,帶你破解...
    沈念sama閱讀 217,509評論 6 504
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場離奇詭異亚皂,居然都是意外死亡俱箱,警方通過查閱死者的電腦和手機,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 92,806評論 3 394
  • 文/潘曉璐 我一進店門灭必,熙熙樓的掌柜王于貴愁眉苦臉地迎上來狞谱,“玉大人,你說我怎么就攤上這事禁漓「疲” “怎么了?”我有些...
    開封第一講書人閱讀 163,875評論 0 354
  • 文/不壞的土叔 我叫張陵播歼,是天一觀的道長伶跷。 經(jīng)常有香客問我,道長秘狞,這世上最難降的妖魔是什么叭莫? 我笑而不...
    開封第一講書人閱讀 58,441評論 1 293
  • 正文 為了忘掉前任,我火速辦了婚禮烁试,結果婚禮上雇初,老公的妹妹穿的比我還像新娘。我一直安慰自己减响,他們只是感情好靖诗,可當我...
    茶點故事閱讀 67,488評論 6 392
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著支示,像睡著了一般刊橘。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上颂鸿,一...
    開封第一講書人閱讀 51,365評論 1 302
  • 那天促绵,我揣著相機與錄音,去河邊找鬼嘴纺。 笑死败晴,一個胖子當著我的面吹牛,可吹牛的內(nèi)容都是我干的颖医。 我是一名探鬼主播位衩,決...
    沈念sama閱讀 40,190評論 3 418
  • 文/蒼蘭香墨 我猛地睜開眼裆蒸,長吁一口氣:“原來是場噩夢啊……” “哼熔萧!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 39,062評論 0 276
  • 序言:老撾萬榮一對情侶失蹤佛致,失蹤者是張志新(化名)和其女友劉穎贮缕,沒想到半個月后,有當?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體俺榆,經(jīng)...
    沈念sama閱讀 45,500評論 1 314
  • 正文 獨居荒郊野嶺守林人離奇死亡感昼,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 37,706評論 3 335
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發(fā)現(xiàn)自己被綠了罐脊。 大學時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片定嗓。...
    茶點故事閱讀 39,834評論 1 347
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖萍桌,靈堂內(nèi)的尸體忽然破棺而出宵溅,到底是詐尸還是另有隱情,我是刑警寧澤上炎,帶...
    沈念sama閱讀 35,559評論 5 345
  • 正文 年R本政府宣布恃逻,位于F島的核電站,受9級特大地震影響藕施,放射性物質(zhì)發(fā)生泄漏寇损。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點故事閱讀 41,167評論 3 328
  • 文/蒙蒙 一裳食、第九天 我趴在偏房一處隱蔽的房頂上張望矛市。 院中可真熱鬧,春花似錦胞谈、人聲如沸尘盼。這莊子的主人今日做“春日...
    開封第一講書人閱讀 31,779評論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽卿捎。三九已至,卻和暖如春径密,著一層夾襖步出監(jiān)牢的瞬間午阵,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 32,912評論 1 269
  • 我被黑心中介騙來泰國打工享扔, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留底桂,地道東北人。 一個月前我還...
    沈念sama閱讀 47,958評論 2 370
  • 正文 我出身青樓惧眠,卻偏偏與公主長得像籽懦,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子氛魁,可洞房花燭夜當晚...
    茶點故事閱讀 44,779評論 2 354

推薦閱讀更多精彩內(nèi)容