在很多小型的運營系統(tǒng)中,經(jīng)常使用賬號名/密碼或手機號/驗證碼的方式進行運營系統(tǒng)登錄。這里介紹一種利用OAuth2特性實現(xiàn)微信掃碼進行系統(tǒng)登錄的方式
使用的工具
說明
實現(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)步驟
- 在系統(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();
}
- 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);
}
});
...
- 提供一個接口弦牡,為前端輪詢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);}
...
- 前端輪詢這個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)效果
其他需要注意的一些點
- 需要使用內(nèi)網(wǎng)穿透工具戒傻,在微信開放平臺中配置掃碼后的回調(diào)地址
- 如果是自己測試的話,直接使用測試平臺即可
- 微信掃碼可以開放思路蜂筹,利用上面的原理實現(xiàn)用戶綁定需纳,用戶確認等各種操作,有興趣的同學可以自己去嘗試
- url短鏈接可以做緩存艺挪,方式刷新頁面重復調(diào)用的調(diào)用