springCloud微服務系列——單點登錄OAuth2+JWT

    研究了好久的springCloud微服務架構,在這裏整理總結一下,做個梳理和備忘。

    這次總結的是微服務之間的認證。最近實現了一個基於spring security的適合單體應用和分佈式應用,適合app和瀏覽器的一套自用鑑權框架。算是對spring security有了點比較深入的認識了,這裏說一下通過OAuth2+JWT來解決微服務之間的鑑權問題。

    這裏不會涉及到細節問題,關於spring security和OAuth2,JWT有很多很多的內容。特別是想要用好spring security,讀它的源碼是必不可少的。這裏提供一個在學習過程中的一些筆記,記得比較混亂。

     《spring security的一些筆記》

     《spring oauth的一些筆記》

    另外說一下筆記中沒有但是很重要也很基礎的內容,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的單點登錄的一個實現方案。具體的實現細節還有非常非常多的內容。這裏就不說了,只提供一個大體思路備忘。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章