1. 程式人生 > >Spring Security原始碼分析十四:Spring Social 社交登入的繫結與解綁

Spring Security原始碼分析十四:Spring Social 社交登入的繫結與解綁

社交登入又稱作社會化登入(Social Login),是指網站的使用者可以使用騰訊QQ、人人網、開心網、新浪微博、搜狐微博、騰訊微博、淘寶、豆瓣、MSN、Google等社會化媒體賬號登入該網站。

前言

在之前的Spring Social系列中,我們只是實現了使用服務提供商賬號登入到業務系統中,但沒有與業務系統中的賬號進行關聯。本章承接之前社交系列來實現社交賬號與業務系統賬號的繫結與解綁。

UserConnection

create table UserConnection (
    userId varchar(255) not null,
    providerId varchar
(255) not null, providerUserId varchar(255), ...... primary key (userId, providerId, providerUserId));
create unique index UserConnectionRank on UserConnection(userId, providerId, rank);

在使用社交登入的時我們建立的UserConnection表,下面我們來簡單分析一下

  1. userId業務系統的使用者唯一標識(我們使用的是username
  2. providerId用於區分不同的服務提供商(qq
    ,weixin,weibo
  3. providerUserId 服務提供商返回的唯一標識(openid

社交登入註冊實現

取消MyConnectionSignUp

Spring-Security原始碼分析六-Spring-Social社交登入原始碼解析中,我們得知,當配置ConnectionSignUp時,Spring Social會根據我們配置的MyConnectionSignUp返回userId,接著執行userDetailsService.loadUserByUserId(userId),實現社交賬號登入。當取消掉MyConnectionSignUp則會丟擲BadCredentialsException

BadCredentialsExceptionSocialAuthenticationFilter處理,跳轉到預設的/signup註冊請求,跳轉之前會將當前的社交賬號資訊儲存到session中。

新增自定義註冊請求/socialRegister
   @Override
    protected <T> T postProcess(T object) {
        SocialAuthenticationFilter filter = (SocialAuthenticationFilter) super.postProcess(object);
        filter.setFilterProcessesUrl(filterProcessesUrl);
        filter.setSignupUrl("/socialRegister");
        return (T) filter;
    }
新增到.permitAll();
.authorizeRequests().antMatchers(SecurityConstants.DEFAULT_UNAUTHENTICATION_URL,
                ......
                "/socialRegister",//社交賬號註冊和繫結頁面
                "/user/register",//處理社交註冊請求
                ......
                .permitAll()//以上的請求都不需要認證

配置ProviderSignInUtils

從Session中獲取社交賬號資訊

  @Bean
    public ProviderSignInUtils providerSignInUtils(ConnectionFactoryLocator factoryLocator) {
        return new ProviderSignInUtils(factoryLocator, getUsersConnectionRepository(factoryLocator));
    }

建立SocialUserInfo

展示當前社交賬號資訊

@Data
    public class SocialUserInfo {

        private String providerId;

        private String providerUserId;

        private String nickname;

        private String headImg;

    }

實現socialRegister和user/register

/socialRegister
 @GetMapping(value = "/socialRegister")
    public ModelAndView socialRegister(HttpServletRequest request, Map<String, Object> map) {
        SocialUserInfo userInfo = new SocialUserInfo();
        Connection<?> connection = providerSignInUtils.getConnectionFromSession(new ServletWebRequest(request));
        userInfo.setProviderId(connection.getKey().getProviderId());//哪一個服務提供商
        userInfo.setProviderUserId(connection.getKey().getProviderUserId());//openid
        userInfo.setNickname(connection.getDisplayName());//名稱
        userInfo.setHeadImg(connection.getImageUrl());//顯示頭像
        map.put("user", userInfo);
        return new ModelAndView("socialRegister", map);
    }
/user/register
  @PostMapping("/user/register")
    public String register(SysUser user, HttpServletRequest request, HttpServletResponse response) throws IOException {
        String userId = user.getUsername();//獲取使用者名稱
        SysUser result =  sysUserService.findByUsername(userId);//根據使用者名稱查詢使用者資訊
        if(result==null){
            //如果為空則註冊使用者
            sysUserService.save(user);
        }
        //將業務系統的使用者與社交使用者繫結
        providerSignInUtils.doPostSignUp(userId, new ServletWebRequest(request));
        //跳轉到index
        return "redirect:/index";
    }

修改MyUserDetailsService#loadUserByUserId

    @Override
    public SocialUserDetails loadUserByUserId(String userId) throws UsernameNotFoundException {
        SysUser user = repository.findByUsername(userId);//根據使用者名稱查詢使用者
        return user;
    }

效果如下:
註冊效果如下:
https://raw.githubusercontent.com/longfeizheng/longfeizheng.github.io/master/images/security/spring-social-register.gif

繫結與解綁實現

要實現繫結與解綁,首先我們需要知道社交賬號的繫結狀態,繫結就是重新走一下OAuth2流程,關聯當前登入使用者,解綁就是刪除UserConnection表資料。Spring Social預設在ConnectController類上已經幫我們實現了以上的需求。

獲取狀態

/connect獲取狀態。

@RequestMapping(method=RequestMethod.GET)
    public String connectionStatus(NativeWebRequest request, Model model) {
        setNoCache(request);
        processFlash(request, model);
        Map<String, List<Connection<?>>> connections = connectionRepository.findAllConnections();//根據userId查詢UserConnection表
        model.addAttribute("providerIds", connectionFactoryLocator.registeredProviderIds());//系統中已經註冊的服務提供商     
        model.addAttribute("connectionMap", connections);
        return connectView();//返回connectView()
    }
    protected String connectView() {
        return getViewPath() + "status";//connect/status 
    }

由以上可得,實現connect/status檢視即可獲得社交賬號的繫結狀態。

實現connect/status
@Component("connect/status")
public class SocialConnectionStatusView extends AbstractView {

    @Autowired
    private ObjectMapper objectMapper;

    @Override
    protected void renderMergedOutputModel(Map<String, Object> model, HttpServletRequest request, HttpServletResponse response) throws Exception {
        Map<String, List<Connection<?>>> connections = (Map<String, List<Connection<?>>>) model.get("connectionMap");

        Map<String, Boolean> result = new HashMap<>();
        for (String key : connections.keySet()) {
            result.put(key, CollectionUtils.isNotEmpty(connections.get(key)));
        }

        response.setContentType("application/json;charset=UTF-8");
        response.getWriter().write(objectMapper.writeValueAsString(ResultUtil.success(result)));
    }
}

返回結果如下:
https://raw.githubusercontent.com/longfeizheng/longfeizheng.github.io/master/images/security/spring-social-status.png

繫結的實現

/connect/{providerId}繫結社交賬號(POST請求)

////跳轉到授權的頁面
@RequestMapping(value="/{providerId}", method=RequestMethod.POST)
    public RedirectView connect(@PathVariable String providerId, NativeWebRequest request) {
        ConnectionFactory<?> connectionFactory = connectionFactoryLocator.getConnectionFactory(providerId);
        MultiValueMap<String, String> parameters = new LinkedMultiValueMap<String, String>(); 
        preConnect(connectionFactory, parameters, request);
        try {
            return new RedirectView(connectSupport.buildOAuthUrl(connectionFactory, request, parameters));
        } catch (Exception e) {
            sessionStrategy.setAttribute(request, PROVIDER_ERROR_ATTRIBUTE, e);
            return connectionStatusRedirect(providerId, request);
        }
    }

授權成功的回撥地址

//將當前的登入賬戶與社交賬號繫結(寫入到UserConnection表)
@RequestMapping(value="/{providerId}", method=RequestMethod.GET, params="code")
    public RedirectView oauth2Callback(@PathVariable String providerId, NativeWebRequest request) {
        try {
            OAuth2ConnectionFactory<?> connectionFactory = (OAuth2ConnectionFactory<?>) connectionFactoryLocator.getConnectionFactory(providerId);
            Connection<?> connection = connectSupport.completeConnection(connectionFactory, request);
            addConnection(connection, connectionFactory, request);
        } catch (Exception e) {
            sessionStrategy.setAttribute(request, PROVIDER_ERROR_ATTRIBUTE, e);
            logger.warn("Exception while handling OAuth2 callback (" + e.getMessage() + "). Redirecting to " + providerId +" connection status page.");
        }
        return connectionStatusRedirect(providerId, request);
    }

    //返回/connext/qqed檢視
    protected RedirectView connectionStatusRedirect(String providerId, NativeWebRequest request) {
        HttpServletRequest servletRequest = request.getNativeRequest(HttpServletRequest.class);
        String path = "/connect/" + providerId + getPathExtension(servletRequest);
        if (prependServletPath(servletRequest)) {
            path = servletRequest.getServletPath() + path;
        }
        return new RedirectView(path, true);
    }
實現 connect/qqConnected檢視
    @Bean("connect/qqConnected")
    public View qqConnectedView() {
        return new SocialConnectView();
    }

    public class SocialConnectView extends AbstractView {
    @Override
    protected void renderMergedOutputModel(Map<String, Object> model, HttpServletRequest request, HttpServletResponse response) throws Exception {
        String msg = "";
        response.setContentType("text/html;charset=UTF-8");
        if (model.get("connections") == null) {
            msg = "unBindingSuccess";
//            response.getWriter().write("<h3>解綁成功</h3>");
        } else {
            msg = "bindingSuccess";
//            response.getWriter().write("<h3>繫結成功</h3>");
        }

        response.sendRedirect("/message/" + msg);
    }
}

效果如下:
https://raw.githubusercontent.com/longfeizheng/longfeizheng.github.io/master/images/security/spring-social-banding01.gif

解綁的實現

/connect/{providerId}繫結社交賬號(DELETE請求)

//刪除UserConnection表資料,返回connect/qqConnect檢視
@RequestMapping(value="/{providerId}", method=RequestMethod.DELETE)
    public RedirectView removeConnections(@PathVariable String providerId, NativeWebRequest request) {
        ConnectionFactory<?> connectionFactory = connectionFactoryLocator.getConnectionFactory(providerId);
        preDisconnect(connectionFactory, request);
        connectionRepository.removeConnections(providerId);
        postDisconnect(connectionFactory, request);
        return connectionStatusRedirect(providerId, request);
    }
實現connect/qqConnect檢視
/**
     * /connect/qq POST請求,繫結微信返回connect/qqConnected檢視
     * /connect/qq DELETE請求,解綁返回connect/qqConnect檢視
     * @return
     */
    @Bean({"connect/qqConnect", "connect/qqConnected"})
    @ConditionalOnMissingBean(name = "qqConnectedView")
    public View qqConnectedView() {
        return new SocialConnectView();
    }

效果如下:
https://raw.githubusercontent.com/longfeizheng/longfeizheng.github.io/master/images/security/spring-social-banding02.gif

程式碼下載