以前老的項目大多采用的是session進行用戶身份的管理,目前很多公司的項目都是前后端分離, 這樣使用session 成本就會變高,然后多數(shù)項目會采用Jwt進行用戶的身份的管理。
JSON Web Token(JWT)是目前最流行的跨域身份驗證解決方案。有不清楚的同學可以先看看者篇文章:10分鐘了解JSON Web令牌(JWT)
今天要做的事就是將Jwt認證和授權(quán)的過程封裝成一個Springboot 的Stater包,只需要使用EnableJwtSecurity注解,就可以一鍵開啟Jwt認證功能,無需在每個項目都拷貝同樣的認證代碼,非常的麻煩。代碼已經(jīng)放在github上,鏈接地址在文末,有興趣的可以親自使用一下,歡迎提出您的寶貴意見。
該Starter包含的功能
1、支持白名單過濾功能
2、支持配置token刷新時間
3、支持生成隨機鹽的方式生成Jwt(默認是使用的固定的鹽)
4、最重要的當然是Jwt認證功能啦
接下來我們開始講解該Starter的認證思路,篇幅有點長,請耐心閱讀
Spring Security 核心類簡介
AuthenticationManager, 用戶認證的管理類,所有的認證請求(比如login)都會通過提交一個token給AuthenticationManager的authenticate()方法來實現(xiàn)。當然事情肯定不是它來做,具體校驗動作會由AuthenticationManager將請求轉(zhuǎn)發(fā)給具體的實現(xiàn)類來做。根據(jù)實現(xiàn)反饋的結(jié)果再調(diào)用具體的Handler來給用戶以反饋。這個類基本等同于shiro的SecurityManager。
AuthenticationProvider, 認證的具體實現(xiàn)類,一個provider是一種認證方式的實現(xiàn),比如提交的用戶名密碼我是通過和DB中查出的user記錄做比對實現(xiàn)的,那就有一個DaoProvider;如果我是通過CAS請求單點登錄系統(tǒng)實現(xiàn),那就有一個CASProvider。這個是不是和shiro的Realm的定義很像?基本上你可以幫他們當成同一個東西。按照Spring一貫的作風,主流的認證方式它都已經(jīng)提供了默認實現(xiàn),比如DAO、LDAP、CAS、OAuth2等。。
UserDetailService, 用戶認證通過Provider來做,所以Provider需要拿到系統(tǒng)已經(jīng)保存的認證信息,獲取用戶信息的接口spring-security抽象成UserDetailService。雖然叫Service,但是我更愿意把它認為是我們系統(tǒng)里經(jīng)常有的UserDao。
AuthenticationToken, 所有提交給AuthenticationManager的認證請求都會被封裝成一個Token的實現(xiàn),比如最容易理解的UsernamePasswordAuthenticationToken。這個就不多講了,連名字都跟Shiro中一樣。
SecurityContext,當用戶通過認證之后,就會為這個用戶生成一個唯一的SecurityContext,里面包含用戶的認證信息Authentication。通過SecurityContext我們可以獲取到用戶的標識Principle和授權(quán)信息GrantedAuthrity。在系統(tǒng)的任何地方只要通過SecurityHolder.getSecruityContext()就可以獲取到SecurityContext。在Shiro中通過SecurityUtils.getSubject()到達同樣的目的。
Spring Security Filter
Spring Security 的底層是通過一系列的 Filter 來管理的,每個 Filter 都有其自身的功能,而且各個 Filter 在功能上還有關(guān)聯(lián)關(guān)系,所以它們的順序也是非常重要的。
Filter 順序
1.Spring Security 已經(jīng)定義了一些 Filter,不管實際應(yīng)用中你用到了哪些,它們應(yīng)當保持如下順序。
2.ChannelProcessingFilter,如果你訪問的 channel 錯了,那首先就會在 channel 之間進行跳轉(zhuǎn),如 http 變?yōu)?https。
3.SecurityContextPersistenceFilter,這樣的話在一開始進行 request 的時候就可以在 SecurityContextHolder 中建立一個 SecurityContext,然后在請求結(jié)束的時候,任何對 SecurityContext 的改變都可以被 copy 到 HttpSession。
4.ConcurrentSessionFilter,因為它需要使用 SecurityContextHolder 的功能,而且更新對應(yīng) session 的最后更新時間,以及通過 SessionRegistry 獲取當前的 SessionInformation 以檢查當前的 session 是否已經(jīng)過期,過期則會調(diào)用 LogoutHandler。
認證處理機制,如 UsernamePasswordAuthenticationFilter,CasAuthenticationFilter,BasicAuthenticationFilter 等,以至于 SecurityContextHolder 可以被更新為包含一個有效的 Authentication 請求。
5.SecurityContextHolderAwareRequestFilter,它將會把 HttpServletRequest 封裝成一個繼承自 HttpServletRequestWrapper 的 SecurityContextHolderAwareRequestWrapper,同時使用 SecurityContext 實現(xiàn)了 HttpServletRequest 中與安全相關(guān)的方法。
6.JaasApiIntegrationFilter,如果 SecurityContextHolder 中擁有的 Authentication 是一個 JaasAuthenticationToken,那么該 Filter 將使用包含在 JaasAuthenticationToken 中的 Subject 繼續(xù)執(zhí)行 FilterChain。
7.RememberMeAuthenticationFilter,如果之前的認證處理機制沒有更新 SecurityContextHolder,并且用戶請求包含了一個 Remember-Me 對應(yīng)的 cookie,那么一個對應(yīng)的 Authentication 將會設(shè)給 SecurityContextHolder。
8.AnonymousAuthenticationFilter,如果之前的認證機制都沒有更新 SecurityContextHolder 擁有的 Authentication,那么一個 AnonymousAuthenticationToken 將會設(shè)給 SecurityContextHolder。
9.ExceptionTransactionFilter,用于處理在 FilterChain 范圍內(nèi)拋出的 AccessDeniedException 和 AuthenticationException,并把它們轉(zhuǎn)換為對應(yīng)的 Http 錯誤碼返回或者對應(yīng)的頁面。
10.FilterSecurityInterceptor,保護 Web URI,并且在訪問被拒絕時拋出異常。
-
以下是Spring Security 大致的認證流程:
認證流程
Jwt 認證流程
看了以上的知識我們要做的無非就是定義一個Filter用于過濾需要認證的請求,再定義一個Provider用于做真正的認證操作,我們來看一下我們要做的具體步驟:
1、首先定義一個JwtAuthenticationToken 繼承于 AbstractAuthenticationToken
2、定義一個JwtAuthenticationFilter,過濾需要認證url及token
3、定義JwtAuthenticationConfigurer,用于注冊上面定義的Filter
4、定義JwtAuthenticationProvider,主要認證邏輯在這里
做完這些,我們主要的認證過程基本就已經(jīng)實現(xiàn)了,其它的就是成功或失敗等那些操作可以根據(jù)自己項目需求來定義。接下來我們將按上面具體介紹這些類。
Step.1
public class JwtAuthenticationToken extends AbstractAuthenticationToken {
private static final long serialVersionUID = 1L;
private UserDetails principal;
private String credentials;
private DecodedJWT token;
...
這里的作用有兩個,第一用于我們自定義Provider中的Supports方法的對比;第二,用于保存Jwt token
Step.2
這一步總的來說只需要做兩件事
第一,驗證此次請求是否需要攔截,也就是我們配置的白名單
第二,將token 提取出來交給我們自定義的Provider進行處理
由于篇幅原因,這里我只貼出主要邏輯,github地址在文末
public class JwtAuthenticationFilter extends OncePerRequestFilter {
....
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
// 驗證是否是白名單
if (permissiveRequest(request)) {
filterChain.doFilter(request, response);
return;
}
Authentication authResult = null;
AuthenticationException failed = null;
//驗證是否是登錄狀態(tài)
failed = requireLogin();
if (failed == null) {
try {
// 提取token 并委托給JwtAuthenticationProvider進行認證
String token = getJwtToken(request);
if (StringUtils.isNotBlank(token)) {
JwtAuthenticationToken authToken = new JwtAuthenticationToken(JWT.decode(token));
authResult = this.getAuthenticationManager().authenticate(authToken);
} else {
failed = new InsufficientAuthenticationException("JWT is Empty");
}
} catch (JWTDecodeException e) {
logger.error("JWT format error", e);
failed = new InsufficientAuthenticationException("JWT format error", failed);
} catch (InternalAuthenticationServiceException e) {
logger.error("An internal error occurred while trying to authenticate the user.", failed);
failed = e;
} catch (AuthenticationException e) {
failed = e;
}
}
if (authResult != null) {
successfulAuthentication(request, response, filterChain, authResult);
} else if (!permissiveRequest(request)) {
unsuccessfulAuthentication(request, response, failed);
return;
}
filterChain.doFilter(request, response);
}
protected boolean permissiveRequest(HttpServletRequest request) {
if (permissiveRequestMatchers == null)
return false;
for (RequestMatcher permissiveMatcher : permissiveRequestMatchers) {
if (permissiveMatcher.matches(request))
return true;
}
return false;
}
private AuthenticationException requireLogin() {
SecurityContext context = SecurityContextHolder.getContext();
if (context.getAuthentication() == null) {
return new BadCredentialsException("Login expired or logout");
}
return null;
}
protected String getJwtToken(HttpServletRequest request) {
String authInfo = request.getHeader(AUTHORIZATION_HEADER);
return StringUtils.removeStart(authInfo, AUTHORIZATION_START_STRING);
}
...
}
Step.3
這一步主要向Spring Security 過濾器鏈注冊我們剛才定義的Filter,以及向Filter中注入我們需要使用的Bean,如AuthenticationManager
public class JwtAuthenticationConfigurer<T extends JwtAuthenticationConfigurer<T, B>, B extends HttpSecurityBuilder<B>> extends AbstractHttpConfigurer<T, B> {
private JwtAuthenticationFilter authFilter;
public JwtAuthenticationConfigurer() {
authFilter = new JwtAuthenticationFilter();
}
@Override
public void configure(B builder) throws Exception {
authFilter.setAuthenticationManager(builder.getSharedObject(AuthenticationManager.class));
authFilter.setAuthenticationFailureHandler(new JwtAuthenticationFailureHandler());
authFilter = postProcess(authFilter);
//這里將該過濾器放在logout 后面 是因為會判斷前面的login和logout 中的token
builder.addFilterAfter(authFilter, LogoutFilter.class);
}
/**
* 設(shè)置白名單
* @param urls
* @return
*/
public JwtAuthenticationConfigurer<T, B> permissiveRequestUrls(String ... urls){
authFilter.setPermissiveUrl(urls);
return this;
}
/**
* 設(shè)置認證成功后的操作
* @param successHandler
* @return
*/
public JwtAuthenticationConfigurer<T, B> authenticationSuccessHandler(AuthenticationSuccessHandler successHandler){
authFilter.setAuthenticationSuccessHandler(successHandler);
return this;
}
}
Step.4
這里就是最重要的一步,Jwt token 的認證邏輯就在這里,在我們自定義的Filter中調(diào)用的 this.getAuthenticationManager().authenticate(authToken) 最終調(diào)用的就是我們這個Provider,可能有同學會有這樣的疑問,為什么一定會調(diào)用我們自定義的Provider,其實步驟1我們也提到了,主要就是在該Provider 中的supports(Class<?> authentication)方法中,具體我們可以看一下代碼:
public class JwtAuthenticationProvider implements AuthenticationProvider, InitializingBean {
private JwtUserDetailsService userDetailsService;
private UserCache userCache = new NullUserCache();
@Override
public void afterPropertiesSet() throws Exception {
Assert.notNull(this.userCache, "A user cache must be set");
Assert.notNull(this.userDetailsService, "A UserDetailsService must be set");
}
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
DecodedJWT jwt = ((JwtAuthenticationToken) authentication).getToken();
Assert.notNull(jwt, "Jwt token is null");
//驗證token是否過期
if(jwt.getExpiresAt().before(Calendar.getInstance().getTime()))
throw new BadCredentialsException("Jwt is expired");
//從緩存或數(shù)據(jù)庫中獲取user對象
String username = jwt.getSubject();
UserDetails user = userCache.getUserFromCache(username);
if (user == null) {
user = userDetailsService.loadUserByUsername(username);
if (user == null) {
return null;
}
}
String salt = getSalt(username);
try {
JwtUtils.checkJWT(jwt.getToken(), salt, username);
} catch (Exception e) {
throw new BadCredentialsException("Jwt verify fail", e);
}
JwtAuthenticationToken token = new JwtAuthenticationToken(user, jwt, user.getAuthorities());
return token;
}
//這里就會判斷是否是我們自己定義的token,如果返回true 才會進入authenticate方法中
@Override
public boolean supports(Class<?> authentication) {
return authentication.isAssignableFrom(JwtAuthenticationToken.class);
}
public void setUserDetailsService(JwtUserDetailsService userDetailsService) {
this.userDetailsService = userDetailsService;
}
public void setUserCache(UserCache userCache) {
this.userCache = userCache;
}
protected String getSalt(String username) {
String salt = userDetailsService.getSalt(username);
if (StringUtils.isBlank(salt)) {
salt = JwtUtils.TOKEN_SALT;
}
return salt;
}
}
至此 Jwt認證的主要邏輯已經(jīng)實現(xiàn)了,但還有一些細節(jié)需要我們完善,不然怎么做到開箱即用呢,比如我們還需要配置一個用于登錄驗證Filter,Let's do it!
定義LoginAuthenticationFilter
這個過濾器的作用只會攔截登錄請求,驗證用戶名密碼,這里我們只重新定義了Filter和Configurer,為什么呢?因為Spring Security已經(jīng)為我們提供了默認的認證實現(xiàn)方式,具體實現(xiàn)邏輯在AbstractUserDetailsAuthenticationProvider中的authenticate(Authentication authentication)方法中,有興趣的同學可以翻看下源碼,里面會調(diào)用retrieveUser; 這個方法在子類中實現(xiàn),其中會調(diào)用我們一會會定義的UserDetailsSevice。
所以我們這個過濾器只需要把用戶名和密碼提取至UsernamePasswordAuthenticationToken中,就可以完成認證。具體代碼如下:
public class LoginAuthenticationFilter extends AbstractAuthenticationProcessingFilter {
public static final String SPRING_SECURITY_FORM_USERNAME_KEY = "username";
public static final String SPRING_SECURITY_FORM_PASSWORD_KEY = "password";
private String usernameParameter = SPRING_SECURITY_FORM_USERNAME_KEY;
private String passwordParameter = SPRING_SECURITY_FORM_PASSWORD_KEY;
private JwtUserDetailsService jwtUserDetailsService;
public LoginAuthenticationFilter() {
super(new AntPathRequestMatcher("/**/login", "POST"));
}
@Override
public void afterPropertiesSet() {
Assert.notNull(getAuthenticationManager(), "authenticationManager must be specified");
Assert.notNull(getSuccessHandler(), "AuthenticationSuccessHandler must be specified");
Assert.notNull(getFailureHandler(), "AuthenticationFailureHandler must be specified");
}
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)
throws AuthenticationException, IOException, ServletException {
String body = StreamUtils.copyToString(request.getInputStream(), Charset.forName("UTF-8"));
String username = obtainUsername(body);
String password = obtainPassword(body);
if (username == null)
username = "";
if (password == null)
password = "";
username = username.trim();
UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(
username, password);
return this.getAuthenticationManager().authenticate(authRequest);
}
protected String obtainPassword(String body) throws IOException {
return getStringFromRequest(body, passwordParameter);
}
protected String obtainUsername(String body) throws IOException {
return getStringFromRequest(body, usernameParameter);
}
protected String getStringFromRequest(String body, String key) throws IOException {
String result = null;
if(StringUtils.hasText(body) && !StringUtils.isEmpty(key)) {
JSONObject jsonObj = JSON.parseObject(body);
result = jsonObj.getString(key);
}
return result;
}
public JwtUserDetailsService getJwtUserDetailsService() {
return jwtUserDetailsService;
}
public void setJwtUserDetailsService(JwtUserDetailsService jwtUserDetailsService) {
this.jwtUserDetailsService = jwtUserDetailsService;
}
}
定義LoginConfigurer
定義了過濾器 當然也需要定義Configurer,當然你也可以直接在HttpSecurity中添加到指定位置,但是這樣做的區(qū)別就是無法注入自己想用的Bean,這部分代碼比較簡單,直接上代碼:
public class LoginConfigurer<T extends LoginConfigurer<T, B>, B extends HttpSecurityBuilder<B>> extends AbstractHttpConfigurer<T, B> {
private LoginAuthenticationFilter loginAuthenticationFilter;
private JwtUserDetailsService jwtUserDetailsService;
public LoginConfigurer(JwtUserDetailsService jwtUserDetailsService) {
loginAuthenticationFilter = new LoginAuthenticationFilter();
this.jwtUserDetailsService = jwtUserDetailsService;
}
@Override
public void configure(B builder) throws Exception {
loginAuthenticationFilter.setAuthenticationManager(builder.getSharedObject(AuthenticationManager.class));
loginAuthenticationFilter.setAuthenticationFailureHandler(new JwtAuthenticationFailureHandler());
loginAuthenticationFilter.setJwtUserDetailsService(jwtUserDetailsService);
loginAuthenticationFilter = postProcess(loginAuthenticationFilter);
builder.addFilterBefore(loginAuthenticationFilter, LogoutFilter.class);
}
public LoginConfigurer<T, B> authenticationSuccessHandler(AuthenticationSuccessHandler successHandler){
loginAuthenticationFilter.setAuthenticationSuccessHandler(successHandler);
return this;
}
}
定義JwtUserDetailsService
我在這里僅僅使用了抽象類實現(xiàn)UserDetailsService,這樣做的目標就是讓開發(fā)者自己定義loadUserByUsername(String username),而我在這里添加了三個方法,如果項目不使用隨機鹽可以不用管這幾個方法,這里提供了默認實現(xiàn),如果需要在數(shù)據(jù)庫或緩存保存中實現(xiàn)可以重寫這幾個方法。
劃重點,loadUserByUsername(String username)必須要實現(xiàn),不然框架會報錯
配置WebSecurityConfiguration
前面寫了那么多,如果不配置進Spring Security中那是不起作用的,所以我們需要把這些類配置進去。這些配置也包括了我們的白名單,token刷新時間等,看代碼
@Configuration
@EnableWebSecurity
public class WebSecurityConfiguration extends WebSecurityConfigurerAdapter {
@Autowired
private JwtUserDetailsService userDetailsService;
@Autowired(required = false)
private UserCache userCache;
@Autowired
private JwtProperties p;
private static final String DEFAULT_PERMITURL = "/login/**,/logout/**";
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.authenticationProvider(getJwtAuthenticationProvider())
.authenticationProvider(getDaoAuthenticationProvider());
}
@Override
public void configure(WebSecurity web) throws Exception {
super.configure(web);
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.antMatchers(getPermitUrls()).permitAll()
.anyRequest().authenticated()
.and()
.csrf().disable()
.formLogin().disable()
.sessionManagement().disable()
.cors()
.and()
.headers().addHeaderWriter(new StaticHeadersWriter(Arrays.asList(
new Header("Access-control-Allow-Origin","*"),
new Header("Access-Control-Expose-Headers","Authorization"))))
.and()
.addFilterAfter(new OptionsRequestFilter(), CorsFilter.class)
.apply(new LoginConfigurer<>(userDetailsService)).authenticationSuccessHandler(loginSuccessHandler())
.and()
.apply(new JwtAuthenticationConfigurer<>())
.authenticationSuccessHandler(jwtRefreshSuccessHandler())
.permissiveRequestUrls(getPermitUrls())
.and()
.logout()
.addLogoutHandler(tokenClearLogoutHandler())
.logoutSuccessHandler(new HttpStatusReturningLogoutSuccessHandler())
.and()
.sessionManagement().disable();
}
@Bean("jwtAuthenticationProvider")
public AuthenticationProvider getJwtAuthenticationProvider() {
JwtAuthenticationProvider authenticationProvider = new JwtAuthenticationProvider();
authenticationProvider.setUserDetailsService(userDetailsService);
if (userCache != null) {
authenticationProvider.setUserCache(userCache);
}
return authenticationProvider;
}
@Bean("daoAuthenticationProvider")
protected AuthenticationProvider getDaoAuthenticationProvider() throws Exception{
DaoAuthenticationProvider daoProvider = new DaoAuthenticationProvider();
daoProvider.setUserDetailsService(userDetailsService);
daoProvider.setPasswordEncoder(new BCryptPasswordEncoder());
return daoProvider;
}
@Bean
protected JwtRefreshSuccessHandler jwtRefreshSuccessHandler() {
JwtRefreshSuccessHandler refreshSuccessHandler = new JwtRefreshSuccessHandler(userDetailsService);
refreshSuccessHandler.setTokenRefreshInterval(p.getTokenRefreshInterval());
return refreshSuccessHandler;
}
@Bean
public LoginSuccessHandler loginSuccessHandler() {
return new LoginSuccessHandler(userDetailsService);
}
@Bean
public TokenClearLogoutHandler tokenClearLogoutHandler() {
return new TokenClearLogoutHandler(userDetailsService);
}
String[] getPermitUrls() {
String urls = p.getPermitUrls() + DEFAULT_PERMITURL;
return StringUtils.split(urls.trim(), ",");
}
//跨域支持,配置了CorsConfigurationSource ,CorsFilter 會去讀取該Bean中的配置
@Bean
protected CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOrigins(Arrays.asList("*"));
configuration.setAllowedMethods(Arrays.asList("GET","POST","DELETE","PUT","HEAD", "OPTION"));
configuration.setAllowedHeaders(Arrays.asList("*"));
configuration.addExposedHeader("Authorization");
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration);
return source;
}
}
當然還有一個程序的入口,項目只需要加入這個在Springboot啟動類加上該注解,就會啟用Spring Security 包括我們定義的Jwt的相關(guān)功能。
@Target({ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Inherited
@Import({WebSecurityConfiguration.class, JwtProperties.class})
public @interface EnableJwtSecurity {
}
到這里, 我們基本已經(jīng)完成了所有邏輯,還有些成功,失敗的Handler我就不在這里貼了,有興趣的同學可以進github看相應(yīng)的源碼。
如果有什么疑問或者寫的不好也或者有錯誤的地方,可以提出來,我會一一為各位答復,努力改進。
最后附上github 項目地址: spring-boot-starter-security-jwt
喜歡的可以留下你的小Star哦,萬分感謝。
參考資料:
初識Spring Security
Spring Security 官方指南
Spring Security做JWT認證和授權(quán)
