研究了好久的springCloud微服務架構,在這裏整理總結一下,做個梳理和備忘。
這次總結的是微服務之間的認證。最近實現了一個基於spring security的適合單體應用和分佈式應用,適合app和瀏覽器的一套自用鑑權框架。算是對spring security有了點比較深入的認識了,這裏說一下通過OAuth2+JWT來解決微服務之間的鑑權問題。
這裏不會涉及到細節問題,關於spring security和OAuth2,JWT有很多很多的內容。特別是想要用好spring security,讀它的源碼是必不可少的。這裏提供一個在學習過程中的一些筆記,記得比較混亂。
另外說一下筆記中沒有但是很重要也很基礎的內容,spring security是通過filter鏈實現鑑權的。驗證碼的校驗通過對比redis中的值進行驗證,驗證邏輯需要擴展一個filter並且放在UsernamePasswordAuthenticationFilter之前。短信驗證碼由於提供的是手機號,因此需要通過UserDetailsService去判斷手機號是否存在,因此需要提供一個AuthenticationProvider調用UserDetailsService來做驗證。驗證的手機信息需要通過一個filter封裝,該filter應該放在UsernamePasswordAuthenticationFilter之後,保證最後的Authentication類的對象是以手機號的檢驗爲準。
其實spring security本身提供了一個sso功能,也是使用OAuth2,也可以用JWT,但是有一點很尷尬,它不能在微服務中實現自己的資源服務器。我在微服務中提供自己的資源服務器後,就拿不到token了。如果沒有自己的資源服務器,前後端分離後使用認證服務器中的token是不起作用的。這樣的話對於瀏覽器,前後端沒有分離的應用是可以的。但是對於app和前後端分離的應用就麻煩了。所以這裏就來說一說怎麼去做適用於app,前後端分離的OAuth2+JWT的單點登錄。
最後還有一個問題,如果用feign進行微服務之間的調用,請求頭中的token會丟失。這個需要通過讀feign的源碼,進行相應的擴展,在調用httpClient之前將header中的信息加入被調用的微服務http請求中。
一、思路:
我們需要統一的一個認證服務器進行鑑權,包括圖形驗證碼和短信驗證碼,獲得微服務均認可的token。
由於前後端分離,資源服務器必須實現在微服務上,這樣才能保證對token的識別,傳統做法中資源服務器上鑑權就不能做了。那麼需要在認證服務器上用瀏覽器的鑑權方式進行配置。圖形驗證碼和短信驗證碼也必須在瀏覽器認證中進行擴展。所以思路是,先通過瀏覽器的方式進行用戶名密碼,圖形驗證碼或短信驗證碼的鑑權,然後通過認證服務器用OAuth2的密碼模式獲取基於JWT的token。在微服務上實現資源服務器對token進行解析。
這樣一來,鑑權和頒發token就必須要分別去實現了。
對於feign方式進行微服務之間調用丟失請求頭中的token的問題,需要實現RequestInterceptor攔截器,在其中將token信息加入被調用微服務的請求頭中。
1、認證服務器+瀏覽器鑑權
提供一個認證服務器,用來頒發基於JWT的token。提供基於瀏覽器的鑑權配置,自定義自己的鑑權邏輯。對鑑權進行擴展,提供圖形驗證碼和短信驗證碼的鑑權。具體的擴展方式就不說了,如果說的話,免不了要分析spring security的源碼,這足夠寫另一篇文章了。
這裏說一下擴展後的結果,通過authentication/form進行用戶名密碼加圖形驗證碼的鑑權,需要在請求頭中提供deviceId。通過authentication/mobile進行手機驗證碼的鑑權,同樣在請求頭中需要提供deviceId。然後,鑑權通過後,再通過oauth/token獲得token,需要在請求中提供基於basic的請求頭驗證,值爲clientId:clientSecret的base64編碼。
在這裏有一個問題,手機驗證碼的驗證擴展中是不需要提供密碼的,但是我們獲得token的OAuth2模式爲密碼模式,需要提供密碼。如何解決這個矛盾?我的做法如下:
a、在實現的AuthenticationProvider接口中提供一個表示短信驗證的標誌。每次在該類中調用userDetailsService.loadUserByUsername方法時用戶名加上該標誌。
這樣一來,通過UsernamePasswordAuthenticationFilter調用的loadUserByUsername方法中將沒有改標誌,從而判斷是否是來自短信驗證。
b、如果是短信驗證碼的驗證,需要在redis中加入這樣一個鍵值對,鍵爲手機驗證碼標誌加上手機號,值爲手機驗證碼。注意過期時間不能太長。
c、OAuth2密碼模式驗證的時候,密碼爲手機驗證碼,通過redis可以判斷出來鑑權是否來自於手機驗證碼,如果來自於手機驗證碼,則使用手機號和手機驗證碼作爲User返回。
這個實現還是上一下代碼吧
/**
*
* 通過userDetailsService的loadUserByUsername方法返回UserDetails判斷是否有該用戶
* 將結果封裝到token中,這裏的token是指基類爲Authentication的類,不是JWT的token
*
* @author wulinfeng
*
*/
public class SmsCodeAuthenticationProvider implements AuthenticationProvider {
public static final String SMS_FLAG = "20447a87-4947-11e8-95a3-a087c5481799";
private UserDetailsService userDetailsService;
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
UserDetails userDetails = userDetailsService.loadUserByUsername(SMS_FLAG+":"+((String) authentication.getPrincipal()));
Optional.ofNullable(userDetails)
.orElseThrow(()->{
return new InternalAuthenticationServiceException("無法獲取用戶信息!");
});
SmsCodeAuthenticationToken smsCodeAuthenticationToken = new SmsCodeAuthenticationToken(userDetails.getUsername(), userDetails.getAuthorities());
smsCodeAuthenticationToken.setDetails(authentication.getDetails());
return smsCodeAuthenticationToken;
}
@Override
public boolean supports(Class<?> authentication) {
return (SmsCodeAuthenticationToken.class.isAssignableFrom(authentication));
}
public UserDetailsService getUserDetailsService() {
return userDetailsService;
}
public void setUserDetailsService(UserDetailsService userDetailsService) {
this.userDetailsService = userDetailsService;
}
}
@Component
public class SsoUserDetailsService implements UserDetailsService {
@Autowired
private PasswordEncoder passwordEncoder;
@Autowired
private RedisTemplate<String, String> redisTemplate;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
User user = null;
String[] userSegment = username.split(":");
if(userSegment.length > 1) {
// sms驗證
if(SmsCodeAuthenticationProvider.SMS_FLAG.equals(userSegment[0])) {
String phone = userSegment[2];
String sms = userSegment[1];
user = loadUserBySms(phone, sms);
} else {
user = loadUser(username);
}
} else {
user = loadUser(username);
}
return user;
}
private User loadUser(String username) {
User user = null;
String sms = redisTemplate.opsForValue().get(SmsCodeAuthenticationProvider.SMS_FLAG+":"+username);
if(!StringUtils.isEmpty(sms)) {
redisTemplate.delete(SmsCodeAuthenticationProvider.SMS_FLAG+":"+username);
user = new User(username, passwordEncoder.encode(sms), AuthorityUtils.commaSeparatedStringToAuthorityList("ROLE_USER"));
}
else {
// 根據用戶名或手機號查詢用戶信息
user = new User(username, passwordEncoder.encode("123456"), AuthorityUtils.commaSeparatedStringToAuthorityList("ROLE_USER"));
}
return user;
}
private User loadUserBySms(String phone, String sms) {
// 根據手機號查詢用戶信息
redisTemplate.opsForValue().set(SmsCodeAuthenticationProvider.SMS_FLAG+":"+phone, sms, 30, TimeUnit.SECONDS);
return new User(phone, passwordEncoder.encode(phone), AuthorityUtils.commaSeparatedStringToAuthorityList("ROLE_USER"));
}
}
2、微服務實現資源服務器
微服務只需要實現最基本的資源服務器即可,主要是用來校驗和解析token。注意這裏的校驗只是通過祕鑰進行驗證,並且判斷token是否過期。
3、微服務之間feign調用請求頭丟失的問題
實現RequestInterceptor攔截器,在其中將請求頭中的token信息加入到被調用微服務的請求頭中
public class SsoFeignConfig implements RequestInterceptor {
public static String TOKEN_HEADER = "authorization";
@Override
public void apply(RequestTemplate template) {
template.header(TOKEN_HEADER, getHeaders(getHttpServletRequest()).get(TOKEN_HEADER));
}
private HttpServletRequest getHttpServletRequest() {
try {
return ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest();
} catch (Exception e) {
return null;
}
}
private Map<String, String> getHeaders(HttpServletRequest request) {
Map<String, String> map = new LinkedHashMap<>();
Enumeration<String> enumeration = request.getHeaderNames();
while (enumeration.hasMoreElements()) {
String key = enumeration.nextElement();
String value = request.getHeader(key);
map.put(key, value);
}
return map;
}
}
@FeignClient(name="client1-server/client1", fallback=UserServiceImpl.class, configuration = SsoFeignConfig.class)
public interface UserService {
@GetMapping(value="/user")
Authentication user(Authentication user);
@GetMapping(value="/test")
String test();
}
綜上就是微服務的基於OAuth2+JWT的單點登錄的一個實現方案。具體的實現細節還有非常非常多的內容。這裏就不說了,只提供一個大體思路備忘。