將前面寫的用戶名密碼登錄、短信登錄、第三方賬戶登錄整合成OAuth2協議生成token的模式
AuthenticationSuccessHandler
調用AuthorizationServerTokenServices
返回令牌,AuthenticationSuccessHandler
中含有Authentication
信息,缺少OAuth2Request
信息,組裝OAuth2Request
信息需要ClientDetails
以及TokenRequest
信息
用戶名密碼登錄
-
從請求頭中獲取clientId
package com.cong.security.app.authentication; import java.io.IOException; import javax.servlet.ServletException; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import org.apache.commons.collections.MapUtils; import org.apache.commons.lang3.StringUtils; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; import org.springframework.security.oauth2.common.OAuth2AccessToken; import org.springframework.security.oauth2.common.exceptions.UnapprovedClientAuthenticationException; import org.springframework.security.oauth2.provider.ClientDetails; import org.springframework.security.oauth2.provider.ClientDetailsService; import org.springframework.security.oauth2.provider.OAuth2Authentication; import org.springframework.security.oauth2.provider.OAuth2Request; import org.springframework.security.oauth2.provider.TokenRequest; import org.springframework.security.oauth2.provider.token.AuthorizationServerTokenServices; import org.springframework.security.web.authentication.SavedRequestAwareAuthenticationSuccessHandler; import org.springframework.security.web.authentication.www.BasicAuthenticationConverter; import org.springframework.stereotype.Component; import com.fasterxml.jackson.databind.ObjectMapper; import lombok.extern.slf4j.Slf4j; /** * 成功處理函數 */ @Slf4j @Component("myAuthenticationSuccessHandler") public class MyAuthenticationSuccessHandler extends SavedRequestAwareAuthenticationSuccessHandler { /** * 工具類,將authentication轉換成爲json */ @Autowired private ObjectMapper objectMapper; // 讀取信息 @Autowired private ClientDetailsService clientDetailsService; @Autowired private AuthorizationServerTokenServices authorizationServerTokenServices; private BasicAuthenticationConverter authenticationConverter = new BasicAuthenticationConverter(); /** * Authentication封裝認證信息 */ @Override public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException { // 驗證clientId及secret,SpringSecurity升級之後將方法抽取出來了,所以就不需要拷貝代碼了 UsernamePasswordAuthenticationToken authRequest = this.authenticationConverter.convert(request); if (authRequest == null) { log.info("從請求頭中未獲取到client信息"); throw new UnapprovedClientAuthenticationException("請求頭參數不合法,不予處理"); } String clientId = authRequest.getName(); // 從請求中獲取ClientId,利用ClientDetailsService接口獲取到ClientDetails對象 ClientDetails client = clientDetailsService.loadClientByClientId(clientId); // 簡單的校驗 if (client == null) { log.info("clientId:[{}]不存在", clientId); throw new UnapprovedClientAuthenticationException("clientId " + clientId + " 不存在"); } else if (!StringUtils.equals(authRequest.getCredentials().toString(), client.getClientSecret())) { // 判斷密碼是否匹配 log.info("用戶輸入的clientId:[{}]對應的clientSecret:[{}]與系統存儲的secret:[{}]不匹配", clientId, client.getClientSecret(), authRequest.getCredentials().toString()); throw new UnapprovedClientAuthenticationException("clientSecret不匹配"); } // ClientDetails信息無誤,開始new TokenRequest // authentication已經有信息,不需要重複獲取 TokenRequest tokenRequest = new TokenRequest(MapUtils.EMPTY_MAP, clientId, client.getScope(), "custom"); // 創建OAuth2Request OAuth2Request oAuth2Request = tokenRequest.createOAuth2Request(client); // 拼OAuth2Authentication OAuth2Authentication oAuth2Authentication = new OAuth2Authentication(oAuth2Request, authentication); // 生成access_token OAuth2AccessToken oAuth2AccessToken = authorizationServerTokenServices.createAccessToken(oAuth2Authentication); // 配置返回json response.setContentType("application/json;charset=UTF-8"); response.getWriter().write(objectMapper.writeValueAsString(oAuth2AccessToken)); } }
SpringSecurity
升級之後將從請求頭中獲取client
信息的方法進行了一次封裝,直接調用AuthenticationConverter
接口的convert
方法即可(使用BasicAuthenticationConverter
實現類,如果想更改加密方式或者驗證邏輯可以覆寫)
配置資源服務器安全配置:package com.cong.security.app.authentication; import com.cong.security.core.code.SmsCodeFilter; import com.cong.security.core.code.sms.SmsCodeAuthenticationSecurityConfig; import com.cong.security.core.code.sms.SmsCodeSender; import com.cong.security.core.properties.SecurityProperties; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Configuration; import org.springframework.http.HttpMethod; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.oauth2.config.annotation.web.configuration.EnableResourceServer; import org.springframework.security.oauth2.config.annotation.web.configuration.ResourceServerConfigurerAdapter; import org.springframework.security.web.authentication.AuthenticationFailureHandler; import org.springframework.security.web.authentication.AuthenticationSuccessHandler; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; import org.springframework.social.connect.UsersConnectionRepository; import org.springframework.social.security.SpringSocialConfigurer; import org.springframework.web.cors.CorsUtils; @Configuration @EnableResourceServer public class MyResourceServerConfig<AuthorizeConfigManager> extends ResourceServerConfigurerAdapter { // 安全配置 @Autowired private SecurityProperties securityProperties; // 成功處理器 @Autowired private AuthenticationSuccessHandler myAuthenticationSuccessHandler; // 失敗處理器 @Autowired private AuthenticationFailureHandler myAuthenticationFailureHandler; // 短信登錄 @Autowired private SmsCodeAuthenticationSecurityConfig smsCodeAuthenticationSecurityConfig; // 三方登錄 @Autowired private SpringSocialConfigurer mySocialSecurityConfig; // 短信發送接口 @Autowired private SmsCodeSender smsCodeSender; // 三方賬戶綁定 @Autowired private UsersConnectionRepository usersConnectionRepository; @Override public void configure(HttpSecurity http) throws Exception { SmsCodeFilter smsCodeFilter = new SmsCodeFilter(); smsCodeFilter.setMyAuthenticationFailureHandler(myAuthenticationFailureHandler); smsCodeFilter.setSecurityProperties(securityProperties); smsCodeFilter.setSmsCodeSender(smsCodeSender); smsCodeFilter.setUsersConnectionRepository(usersConnectionRepository); // 初始化方法 smsCodeFilter.afterPropertiesSet(); http.authorizeRequests().antMatchers(HttpMethod.OPTIONS).permitAll();// 放行預請求 http.formLogin() .loginProcessingUrl("/app/login")// 系統登陸請求路徑爲/app/login,此處設置目的是使用UsernamePasswordAuthenticationFilter處理此處登錄請求 .successHandler(myAuthenticationSuccessHandler)// 自定義成功處理器 .failureHandler(myAuthenticationFailureHandler);// 自定義失敗處理器 http.addFilterBefore(smsCodeFilter, UsernamePasswordAuthenticationFilter.class)// 在用戶名密碼校驗之前添加驗證碼校驗 .apply(mySocialSecurityConfig)// 三方登錄 .and().apply(smsCodeAuthenticationSecurityConfig)// 短信驗證碼 .and().authorizeRequests().requestMatchers(CorsUtils::isPreFlightRequest).permitAll()// 解決瀏覽器端預請求直接放過,不作處理 .and().csrf().disable();// 跨站請求訪問 } }
使用postman測試接口
使用返回的access_token即可獲得訪問資源服務器接口。
短信登錄
- 前面開發的時候短信驗證碼圖形驗證碼都保存在redis中,實際項目中一般也是使用redis(無論APP還是PC端,我在開發的時候儘量拋棄session),即使修改也是修改接口實現類(保存和校驗使用同一套)
- 圖形驗證碼我在APP模式下基本不使用(目前只在發送短信的接口可能使用,防止別人盜刷,在接口中添加客戶端設備標識
deviceId
稍微修改一下代碼邏輯即可) - 短信驗證碼發送接口就需要設備標識了(目的是實現第三方賬戶綁定,邏輯在社交登錄中修改)
使用postman調用短信驗證碼發送接口獲取驗證碼,調用SmsCodeFilter
配置的短信驗證碼登錄接口即可實現短信驗證碼登錄。
社交登錄
三方賬戶登錄
服務提供商提供的授權碼模式有兩種
-
簡化模式
第三方直接返回openId,系統可以直接根據openId進行登錄,不需要拿access_token去換取openId(我本來做的APP端走的授權碼模式,但是對接的前端使用QQ登錄和微信登錄的時候直接獲取到openId)
代碼邏輯同短信驗證碼校驗定義
OpenIdAuthenticationToken
封裝登錄信息:package com.cong.security.core.social.app; import java.util.Collection; import lombok.Data; import org.springframework.security.authentication.AbstractAuthenticationToken; import org.springframework.security.core.GrantedAuthority; import lombok.extern.slf4j.Slf4j; @Slf4j @Data public class OpenIdAuthenticationToken extends AbstractAuthenticationToken { private static final long serialVersionUID = 1L; // 用戶openId private Object principal; //用戶登錄方式(本系統中爲qq/weixin) private String providerId; //設備標識(未綁定情況) private String clientId; /** * 構造函數 * * @param openId 用戶openId * @param clientId 客戶端設備編號 * @param providerId 用戶登錄方式 */ public OpenIdAuthenticationToken(String openId, String clientId, String providerId) { super(null); // 用戶openId this.principal = openId; this.clientId = clientId; log.info("當前用戶[{}]在設備[{}]上採用[{}]模式登錄系統", openId, clientId, providerId); // 是哪一個服務提供商的 this.providerId = providerId; setAuthenticated(false); } public OpenIdAuthenticationToken(Object principal, Collection<? extends GrantedAuthority> authorities) { super(authorities); this.principal = principal; super.setAuthenticated(true); } 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 Object getCredentials() { return null; } @Override public Object getPrincipal() { return this.principal; } }
OpenIdAuthenticationFilter
過濾器攔截請求封裝OpenIdAuthenticationToken
package com.cong.security.core.social.app; import java.io.IOException; import javax.servlet.ServletException; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import com.cong.security.core.constant.SecurityConstant; import org.springframework.security.authentication.AuthenticationServiceException; import org.springframework.security.core.Authentication; import org.springframework.security.core.AuthenticationException; import org.springframework.security.web.authentication.AbstractAuthenticationProcessingFilter; import org.springframework.security.web.util.matcher.AntPathRequestMatcher; import lombok.extern.slf4j.Slf4j; @Slf4j public class OpenIdAuthenticationFilter extends AbstractAuthenticationProcessingFilter { //默認openId參數名 private String openIdParameter = SecurityConstant.DEFAULT_PARAMETER_NAME_OPENID; //默認clientId參數名(設備編號,解決用戶未綁定手機號時根據設備編號在緩存中臨時存儲第三方信息) private String deviceIdParameter = SecurityConstant.DEFAULT_PARAMETER_NAME_DEVICEID; //默認登錄方式參數名 private String providerIdParameter = SecurityConstant.DEFAULT_PARAMETER_NAME_PROVIDERID; private boolean postOnly = true; protected OpenIdAuthenticationFilter() { super(new AntPathRequestMatcher(SecurityConstant.DEFAULT_LOGIN_PROCESSING_URL_OPENID, "POST")); } @Override public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException, IOException, ServletException { if (postOnly && !request.getMethod().equals("POST")) { throw new AuthenticationServiceException("Authentication method not supported: " + request.getMethod()); } String openId = obtainOpenId(request); String deviceId = obtainDeviceId(request); String providerId = obtainProviderId(request); log.info("封裝三方[{}]用戶[{}]來源於[{}]的信息", providerId, openId, deviceId); OpenIdAuthenticationToken authRequest = new OpenIdAuthenticationToken(openId, deviceId, providerId); // Allow subclasses to set the "details" property setDetails(request, authRequest); return this.getAuthenticationManager().authenticate(authRequest); } /** * 獲取clientId */ protected String obtainDeviceId(HttpServletRequest request) { String deviceId = request.getParameter(deviceIdParameter); if (deviceId == null) { deviceId = ""; } return deviceId.trim(); } /** * 獲取openId */ protected String obtainOpenId(HttpServletRequest request) { String openId = request.getParameter(openIdParameter); if (openId == null) { openId = ""; } return openId.trim(); } /** * 獲取providerId */ protected String obtainProviderId(HttpServletRequest request) { String providerId = request.getParameter(providerIdParameter); if (providerId == null) { providerId = ""; } return providerId.trim(); } protected void setDetails(HttpServletRequest request, OpenIdAuthenticationToken authRequest) { authRequest.setDetails(authenticationDetailsSource.buildDetails(request)); } }
OpenIdAuthenticationProvider
驗證OpenIdAuthenticationToken
package com.cong.security.core.social.app; import java.util.HashSet; import java.util.Set; import org.apache.commons.collections.CollectionUtils; import org.springframework.security.authentication.AuthenticationProvider; import org.springframework.security.authentication.InternalAuthenticationServiceException; import org.springframework.security.core.Authentication; import org.springframework.security.core.AuthenticationException; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.social.connect.UsersConnectionRepository; import org.springframework.social.security.SocialUserDetailsService; import lombok.extern.slf4j.Slf4j; @Slf4j public class OpenIdAuthenticationProvider implements AuthenticationProvider { private SocialUserDetailsService userDetailsService; // UserConnection表 private UsersConnectionRepository usersConnectionRepository; @Override public Authentication authenticate(Authentication authentication) throws AuthenticationException { OpenIdAuthenticationToken openIdAuthenticationToken = (OpenIdAuthenticationToken) authentication; Set<String> providerUserIds = new HashSet<>(); // 獲取到用戶登陸的openId providerUserIds.add((String) authentication.getPrincipal()); // 用戶選擇的登錄方式 String providerId = openIdAuthenticationToken.getProviderId(); log.info("用戶[{}]登錄方式爲[{}]", providerUserIds, providerId); // 數據庫中是否有記錄 Set<String> userIds = usersConnectionRepository.findUserIdsConnectedTo(providerId, providerUserIds); log.info("匹配到的用戶信息ID爲[{}]", userIds);// 理論上只能拿到一個,一個系統賬號可以綁定多個社交賬號,但是一個社交賬號只能綁定一個系統賬號 if (CollectionUtils.isEmpty(userIds) || userIds.size() != 1) { throw new InternalAuthenticationServiceException("該賬戶尚未綁定至系統賬號,提示用戶執行綁定操作"); } // 根據社交賬號查出來的用戶的唯一標識 String userId = userIds.iterator().next(); UserDetails user = userDetailsService.loadUserByUserId(userId); if (user == null) { // 綁定的用戶標識無法從本系統中查詢到用戶信息,主動拋出異常 throw new InternalAuthenticationServiceException("無法獲取用戶信息"); } // 構建用戶以及用戶權限信息 OpenIdAuthenticationToken openIdAuthenticationResult = new OpenIdAuthenticationToken(user, user.getAuthorities()); // UserDetails或者SocialDetails用戶信息 openIdAuthenticationResult.setDetails(openIdAuthenticationToken.getDetails()); // 返回封裝的token信息,上層進行JWT-token生成 return openIdAuthenticationResult; } @Override public boolean supports(Class<?> authentication) { return OpenIdAuthenticationToken.class.isAssignableFrom(authentication); } public void setUserDetailsService(SocialUserDetailsService userDetailsService) { this.userDetailsService = userDetailsService; } public void setUsersConnectionRepository(UsersConnectionRepository usersConnectionRepository) { this.usersConnectionRepository = usersConnectionRepository; } }
OpenIdAuthenticationSecurityConfig
配置類package com.cong.security.core.social.app; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.config.annotation.SecurityConfigurerAdapter; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.web.DefaultSecurityFilterChain; import org.springframework.security.web.authentication.AuthenticationFailureHandler; import org.springframework.security.web.authentication.AuthenticationSuccessHandler; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; import org.springframework.social.connect.UsersConnectionRepository; import org.springframework.social.security.SocialUserDetailsService; import org.springframework.stereotype.Component; /** * 當前配置爲APP端登錄,和瀏覽器端沒有任何關係,可以單獨修改,不會共用當前配置 */ @Component public class OpenIdAuthenticationSecurityConfig extends SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity> { @Autowired private AuthenticationSuccessHandler myAuthenticationSuccessHandler; @Autowired private AuthenticationFailureHandler myAuthenticationFailureHandler; @Autowired private SocialUserDetailsService userDetailsService; @Autowired private UsersConnectionRepository usersConnectionRepository; @Override public void configure(HttpSecurity http) throws Exception { OpenIdAuthenticationFilter openIdAuthenticationFilter = new OpenIdAuthenticationFilter(); openIdAuthenticationFilter.setAuthenticationManager(http.getSharedObject(AuthenticationManager.class)); // 成功失敗處理器 openIdAuthenticationFilter.setAuthenticationSuccessHandler(myAuthenticationSuccessHandler); openIdAuthenticationFilter.setAuthenticationFailureHandler(myAuthenticationFailureHandler); OpenIdAuthenticationProvider openIdAuthenticationProvider = new OpenIdAuthenticationProvider(); openIdAuthenticationProvider.setUserDetailsService(userDetailsService); openIdAuthenticationProvider.setUsersConnectionRepository(usersConnectionRepository); http.authenticationProvider(openIdAuthenticationProvider).addFilterAfter(openIdAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); } }
上述配置相關常量:
在資源服務器上添加OpenId配置類
postman測試
-
授權碼模式
APP模式使用簡化模式,授權碼模式PC端使用即可,絕大多數用戶執行第三方綁定之後一般都使用三方賬戶登錄,使用授權碼模式反而麻煩,直接使openId登錄更簡單。
三方賬戶註冊
- 用戶未綁定情況下(本文不使用默認註冊邏輯,目前的互聯網項目開發基本都需要用戶實名制,隱式註冊基本不會使用,純粹刷用戶量不算,而且隱式註冊之後後面的賬號整合也是問題)
- 視頻中提供的賬戶註冊邏輯此處不使用
三方賬戶註冊需要和手機號進行綁定,需要發送短信驗證碼,邏輯寫在短信驗證碼登錄的過濾器中,後面文章編寫。