上一篇博客講了如何使用Shiro和JWT做認證和授權(傳送門:http://www.lxweimin.com/p/0b1131be7ace),總的來說shiro是一個比較早期和簡單的框架,這個從最近已經基本不做版本更新就可以看出來。這篇文章我們講一下如何使用更加流行和完整的spring security來實現同樣的需求。
Spring Security的架構
按照慣例,在使用之前我們先講一下簡單的架構。不知道是因為spring-security后出來還是因為優秀的設計殊途同歸,對于核心模塊,spring-security和shiro有80%以上的設計相似度。所以下面介紹中會多跟shiro做對比,如果你對shiro不了解也沒關系,跟shiro對比的部分跳過就好。
spring-security中核心概念
-
AuthenticationManager, 用戶認證的管理類,所有的認證請求(比如login)都會通過提交一個token給
AuthenticationManager
的authenticate()
方法來實現。當然事情肯定不是它來做,具體校驗動作會由AuthenticationManager
將請求轉發給具體的實現類來做。根據實現反饋的結果再調用具體的Handler來給用戶以反饋。這個類基本等同于shiro的SecurityManager
。 -
AuthenticationProvider, 認證的具體實現類,一個provider是一種認證方式的實現,比如提交的用戶名密碼我是通過和DB中查出的user記錄做比對實現的,那就有一個
DaoProvider
;如果我是通過CAS請求單點登錄系統實現,那就有一個CASProvider
。這個是不是和shiro的Realm的定義很像?基本上你可以幫他們當成同一個東西。按照Spring一貫的作風,主流的認證方式它都已經提供了默認實現,比如DAO、LDAP、CAS、OAuth2等。
前面講了AuthenticationManager
只是一個代理接口,真正的認證就是由AuthenticationProvider
來做的。一個AuthenticationManager
可以包含多個Provider,每個provider通過實現一個support方法來表示自己支持那種Token的認證。AuthenticationManager
默認的實現類是ProviderManager
。 -
UserDetailService, 用戶認證通過Provider來做,所以Provider需要拿到系統已經保存的認證信息,獲取用戶信息的接口spring-security抽象成
UserDetailService
。雖然叫Service,但是我更愿意把它認為是我們系統里經常有的UserDao
。 -
AuthenticationToken, 所有提交給
AuthenticationManager
的認證請求都會被封裝成一個Token的實現,比如最容易理解的UsernamePasswordAuthenticationToken
。這個就不多講了,連名字都跟Shiro中一樣。 -
SecurityContext,當用戶通過認證之后,就會為這個用戶生成一個唯一的
SecurityContext
,里面包含用戶的認證信息Authentication
。通過SecurityContext我們可以獲取到用戶的標識Principle
和授權信息GrantedAuthrity
。在系統的任何地方只要通過SecurityHolder.getSecruityContext()
就可以獲取到SecurityContext
。在Shiro中通過SecurityUtils.getSubject()
到達同樣的目的。
我們大概通過一個認證流程來認識下上面幾個關鍵的概念
認證流程
對web系統的支持
毫無疑問,對于spring框架使用最多的還是web系統。對于web系統來說進入認證的最佳入口就是Filter了。spring security不僅實現了認證的邏輯,還通過filter實現了常見的web攻擊的防護。
常用Filter
下面按照request進入的順序列舉一下常用的Filter:
- SecurityContextPersistenceFilter,用于將
SecurityContext
放入Session的Filter - UsernamePasswordAuthenticationFilter, 登錄認證的Filter,類似的還有CasAuthenticationFilter,BasicAuthenticationFilter等等。在這些Filter中生成用于認證的token,提交到AuthenticationManager,如果認證失敗會直接返回。
- RememberMeAuthenticationFilter,通過cookie來實現remember me功能的Filter
- AnonymousAuthenticationFilter,如果一個請求在到達這個filter之前SecurityContext沒有初始化,則這個filter會默認生成一個匿名SecurityContext。這在支持匿名用戶的系統中非常有用。
- ExceptionTranslationFilter,捕獲所有Spring Security拋出的異常,并決定處理方式
- FilterSecurityInterceptor, 權限校驗的攔截器,訪問的url權限不足時會拋出異常
Filter的順序
既然用了上面那么多filter,它們在FilterChain中的先后順序就顯得非常重要了。對于每一個系統或者用戶自定義的filter,spring security都要求必須指定一個order,用來做排序。對于系統的filter的默認順序,是在一個FilterComparator
類中定義的,核心實現如下。
FilterComparator() {
int order = 100;
put(ChannelProcessingFilter.class, order);
order += STEP;
put(ConcurrentSessionFilter.class, order);
order += STEP;
put(WebAsyncManagerIntegrationFilter.class, order);
order += STEP;
put(SecurityContextPersistenceFilter.class, order);
order += STEP;
put(HeaderWriterFilter.class, order);
order += STEP;
put(CorsFilter.class, order);
order += STEP;
put(CsrfFilter.class, order);
order += STEP;
put(LogoutFilter.class, order);
order += STEP;
filterToOrder.put(
"org.springframework.security.oauth2.client.web.OAuth2AuthorizationRequestRedirectFilter",
order);
order += STEP;
put(X509AuthenticationFilter.class, order);
order += STEP;
put(AbstractPreAuthenticatedProcessingFilter.class, order);
order += STEP;
filterToOrder.put("org.springframework.security.cas.web.CasAuthenticationFilter",
order);
order += STEP;
filterToOrder.put(
"org.springframework.security.oauth2.client.web.OAuth2LoginAuthenticationFilter",
order);
order += STEP;
put(UsernamePasswordAuthenticationFilter.class, order);
order += STEP;
put(ConcurrentSessionFilter.class, order);
order += STEP;
filterToOrder.put(
"org.springframework.security.openid.OpenIDAuthenticationFilter", order);
order += STEP;
put(DefaultLoginPageGeneratingFilter.class, order);
order += STEP;
put(ConcurrentSessionFilter.class, order);
order += STEP;
put(DigestAuthenticationFilter.class, order);
order += STEP;
put(BasicAuthenticationFilter.class, order);
order += STEP;
put(RequestCacheAwareFilter.class, order);
order += STEP;
put(SecurityContextHolderAwareRequestFilter.class, order);
order += STEP;
put(JaasApiIntegrationFilter.class, order);
order += STEP;
put(RememberMeAuthenticationFilter.class, order);
order += STEP;
put(AnonymousAuthenticationFilter.class, order);
order += STEP;
put(SessionManagementFilter.class, order);
order += STEP;
put(ExceptionTranslationFilter.class, order);
order += STEP;
put(FilterSecurityInterceptor.class, order);
order += STEP;
put(SwitchUserFilter.class, order);
}
對于用戶自定義的filter,如果要加入spring security 的FilterChain中,必須指定加到已有的那個filter之前或者之后,具體下面我們用到自定義filter的時候會說明。
JWT認證的實現
關于使用JWT認證的原因,上一篇介紹Shiro的文章中已經說過了,這里不再多說。需求也還是那3個:
- 支持用戶通過用戶名和密碼登錄
- 登錄后通過http header返回token,每次請求,客戶端需通過header將token帶回,用于權限校驗
- 服務端負責token的定期刷新
下面我們直接進入Spring Secuiry的項目搭建。
項目搭建
gradle配置
最新的spring項目開始默認使用gradle來做依賴管理了,所以這個項目也嘗試下gradle的配置。除了springmvc和security的starter之外,還依賴了auth0的jwt工具包。JSON處理使用了fastjson。
buildscript {
ext {
springBootVersion = '2.0.4.RELEASE'
}
repositories {
mavenCentral()
}
dependencies {
classpath("org.springframework.boot:spring-boot-gradle-plugin:${springBootVersion}")
}
}
apply plugin: 'java'
apply plugin: 'eclipse'
apply plugin: 'org.springframework.boot'
apply plugin: 'io.spring.dependency-management'
group = 'com.github.springboot'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = 1.8
repositories {
mavenCentral()
}
dependencies {
compile('org.springframework.boot:spring-boot-starter-security')
compile('org.springframework.boot:spring-boot-starter-web')
compile('org.apache.commons:commons-lang3:3.8')
compile('com.auth0:java-jwt:3.4.0')
compile('com.alibaba:fastjson:1.2.47')
testCompile('org.springframework.boot:spring-boot-starter-test')
testCompile('org.springframework.security:spring-security-test')
}
登錄認證流程
Filter
對于用戶登錄行為,security通過定義一個Filter來攔截/login來實現的。spring security默認支持form方式登錄,所以對于使用json發送登錄信息的情況,我們自己定義一個Filter,這個Filter直接從AbstractAuthenticationProcessingFilter
繼承,只需要實現兩部分,一個是RequestMatcher,指名攔截的Request類型;另外就是從json body中提取出username和password提交給AuthenticationManager。
public class MyUsernamePasswordAuthenticationFilter extends AbstractAuthenticationProcessingFilter {
public MyUsernamePasswordAuthenticationFilter() {
//攔截url為 "/login" 的POST請求
super(new AntPathRequestMatcher("/login", "POST"));
}
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)
throws AuthenticationException, IOException, ServletException {
//從json中獲取username和password
String body = StreamUtils.copyToString(request.getInputStream(), Charset.forName("UTF-8"));
String username = null, password = null;
if(StringUtils.hasText(body)) {
JSONObject jsonObj = JSON.parseObject(body);
username = jsonObj.getString("username");
password = jsonObj.getString("password");
}
if (username == null)
username = "";
if (password == null)
password = "";
username = username.trim();
//封裝到token中提交
UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(
username, password);
return this.getAuthenticationManager().authenticate(authRequest);
}
}
Provider
前面的流程圖中講到了,封裝后的token最終是交給provider來處理的。對于登錄的provider,spring security已經提供了一個默認實現DaoAuthenticationProvider
我們可以直接使用,這個類繼承了AbstractUserDetailsAuthenticationProvider
我們來看下關鍵部分的源代碼是怎么做的。
public abstract class AbstractUserDetailsAuthenticationProvider implements
AuthenticationProvider, InitializingBean, MessageSourceAware {
...
//這個方法返回true,說明支持該類型的token
public boolean supports(Class<?> authentication) {
return (UsernamePasswordAuthenticationToken.class
.isAssignableFrom(authentication));
}
public Authentication authenticate(Authentication authentication)
throws AuthenticationException {
...
try {
// 獲取系統中存儲的用戶信息
user = retrieveUser(username,
(UsernamePasswordAuthenticationToken) authentication);
}
catch (UsernameNotFoundException notFound) {
logger.debug("User '" + username + "' not found");
if (hideUserNotFoundExceptions) {
throw new BadCredentialsException(messages.getMessage(
"AbstractUserDetailsAuthenticationProvider.badCredentials",
"Bad credentials"));
}
else {
throw notFound;
}
}
}
try {
//檢查user是否已過期或者已鎖定
preAuthenticationChecks.check(user);
//將獲取到的用戶信息和登錄信息做比對
additionalAuthenticationChecks(user,
(UsernamePasswordAuthenticationToken) authentication);
}
catch (AuthenticationException exception) {
...
throw exception;
}
...
//如果認證通過,則封裝一個AuthenticationInfo, 放到SecurityContext中
return createSuccessAuthentication(principalToReturn, authentication, user);
}
...
}
上面的代碼中,核心流程就是retrieveUser()
獲取系統中存儲的用戶信息,再對用戶信息做了過期和鎖定等校驗后交給additionalAuthenticationChecks()
和用戶提交的信息做比對。
這兩個方法我們看他的繼承類DaoAuthenticationProvider
是怎么實現的。
public class DaoAuthenticationProvider extends AbstractUserDetailsAuthenticationProvider {
/**
* 加密密碼比對
*/
protected void additionalAuthenticationChecks(UserDetails userDetails,
UsernamePasswordAuthenticationToken authentication)
throws AuthenticationException {
if (authentication.getCredentials() == null) {
logger.debug("Authentication failed: no credentials provided");
throw new BadCredentialsException(messages.getMessage(
"AbstractUserDetailsAuthenticationProvider.badCredentials",
"Bad credentials"));
}
String presentedPassword = authentication.getCredentials().toString();
if (!passwordEncoder.matches(presentedPassword, userDetails.getPassword())) {
logger.debug("Authentication failed: password does not match stored value");
throw new BadCredentialsException(messages.getMessage(
"AbstractUserDetailsAuthenticationProvider.badCredentials",
"Bad credentials"));
}
}
/**
* 系統用戶獲取
*/
protected final UserDetails retrieveUser(String username,
UsernamePasswordAuthenticationToken authentication)
throws AuthenticationException {
prepareTimingAttackProtection();
try {
UserDetails loadedUser = this.getUserDetailsService().loadUserByUsername(username);
if (loadedUser == null) {
throw new InternalAuthenticationServiceException(
"UserDetailsService returned null, which is an interface contract violation");
}
return loadedUser;
}
catch (UsernameNotFoundException ex) {
mitigateAgainstTimingAttack(authentication);
throw ex;
}
catch (InternalAuthenticationServiceException ex) {
throw ex;
}
catch (Exception ex) {
throw new InternalAuthenticationServiceException(ex.getMessage(), ex);
}
}
}
上面的方法實現中,用戶獲取是調用了UserDetailsService
來完成的。這個是一個只有一個方法的接口,所以我們自己要做的,就是將自己的UserDetailsService
實現類配置成一個Bean。下面是實例代碼,真正的實現需要從數據庫或者緩存中獲取。
public class JwtUserService implements UserDetailsService{
//真實系統需要從數據庫或緩存中獲取,這里對密碼做了加密
return User.builder().username("Jack").password(passwordEncoder.encode("jack-password")).roles("USER").build();
}
我們再來看另外一個密碼比對的方法,也是委托給一個PasswordEncoder
類來實現的。一般來說,存在數據庫中的密碼都是要經過加密處理的,這樣萬一數據庫數據被拖走,也不會泄露密碼。spring一如既往的提供了主流的加密方式,如MD5,SHA等。如果不顯示指定的話,Spring會默認使用BCryptPasswordEncoder
,這個是目前相對比較安全的加密方式。具體介紹可參考spring-security 的官方文檔 - Password Endcoding
認證結果處理
filter將token交給provider做校驗,校驗的結果無非兩種,成功或者失敗。對于這兩種結果,我們只需要實現兩個Handler接口,set到Filter里面,Filter在收到Provider的處理結果后會回調這兩個Handler的方法。
先來看成功的情況,針對jwt認證的業務場景,登錄成功需要返回給客戶端一個token。所以成功的handler的實現類中需要包含這個邏輯。
public class JsonLoginSuccessHandler implements AuthenticationSuccessHandler{
private JwtUserService jwtUserService;
public JsonLoginSuccessHandler(JwtUserService jwtUserService) {
this.jwtUserService = jwtUserService;
}
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
Authentication authentication) throws IOException, ServletException {
//生成token,并把token加密相關信息緩存,具體請看實現類
String token = jwtUserService.saveUserLoginInfo((UserDetails)authentication.getPrincipal());
response.setHeader("Authorization", token);
}
}
再來看失敗的情況,登錄失敗比較簡單,只需要回復一個401的Response即可。
public class HttpStatusLoginFailureHandler implements AuthenticationFailureHandler{
@Override
public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response,
AuthenticationException exception) throws IOException, ServletException {
response.setStatus(HttpStatus.UNAUTHORIZED.value());
}
}
JsonLoginConfigurer
以上整個登錄的流程的組件就完整了,我們只需要把它們組合到一起就可以了。這里繼承一個AbstractHttpConfigurer
,對Filter做配置。
public class JsonLoginConfigurer<T extends JsonLoginConfigurer<T, B>, B extends HttpSecurityBuilder<B>> extends AbstractHttpConfigurer<T, B> {
private MyUsernamePasswordAuthenticationFilter authFilter;
public JsonLoginConfigurer() {
this.authFilter = new MyUsernamePasswordAuthenticationFilter();
}
@Override
public void configure(B http) throws Exception {
//設置Filter使用的AuthenticationManager,這里取公共的即可
authFilter.setAuthenticationManager(http.getSharedObject(AuthenticationManager.class));
//設置失敗的Handler
authFilter.setAuthenticationFailureHandler(new HttpStatusLoginFailureHandler());
//不將認證后的context放入session
authFilter.setSessionAuthenticationStrategy(new NullAuthenticatedSessionStrategy());
MyUsernamePasswordAuthenticationFilter filter = postProcess(authFilter);
//指定Filter的位置
http.addFilterAfter(filter, LogoutFilter.class);
}
//設置成功的Handler,這個handler定義成Bean,所以從外面set進來
public JsonLoginConfigurer<T,B> loginSuccessHandler(AuthenticationSuccessHandler authSuccessHandler){
authFilter.setAuthenticationSuccessHandler(authSuccessHandler);
return this;
}
}
這樣Filter就完整的配置好了,當調用configure方法時,這個filter就會加入security FilterChain的指定位置。這個是在全局定義的地方,我們放在最后說。在全局配置的地方,也會將DaoAuthenticationProvider
放到ProviderManager
中,這樣filter中提交的token就可以被處理了。
帶Token請求校驗流程
用戶除登錄之外的請求,都要求必須攜帶JWT Token。所以我們需要另外一個Filter對這些請求做一個攔截。這個攔截器主要是提取header中的token,跟登錄一樣,提交給AuthenticationManager
做檢查。
Filter
public class JwtAuthenticationFilter extends OncePerRequestFilter{
...
public JwtAuthenticationFilter() {
//攔截header中帶Authorization的請求
this.requiresAuthenticationRequestMatcher = new RequestHeaderRequestMatcher("Authorization");
}
protected String getJwtToken(HttpServletRequest request) {
String authInfo = request.getHeader("Authorization");
return StringUtils.removeStart(authInfo, "Bearer ");
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
//header沒帶token的,直接放過,因為部分url匿名用戶也可以訪問
//如果需要不支持匿名用戶的請求沒帶token,這里放過也沒問題,因為SecurityContext中沒有認證信息,后面會被權限控制模塊攔截
if (!requiresAuthentication(request, response)) {
filterChain.doFilter(request, response);
return;
}
Authentication authResult = null;
AuthenticationException failed = null;
try {
//從頭中獲取token并封裝后提交給AuthenticationManager
String token = getJwtToken(request);
if(StringUtils.isNotBlank(token)) {
JwtAuthenticationToken authToken = new JwtAuthenticationToken(JWT.decode(token));
authResult = this.getAuthenticationManager().authenticate(authToken);
} else { //如果token長度為0
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) {
// Authentication failed
failed = e;
}
if(authResult != null) { //token認證成功
successfulAuthentication(request, response, filterChain, authResult);
} else if(!permissiveRequest(request)){
//token認證失敗,并且這個request不在例外列表里,才會返回錯誤
unsuccessfulAuthentication(request, response, failed);
return;
}
filterChain.doFilter(request, response);
}
...
protected boolean requiresAuthentication(HttpServletRequest request,
HttpServletResponse response) {
return requiresAuthenticationRequestMatcher.matches(request);
}
protected boolean permissiveRequest(HttpServletRequest request) {
if(permissiveRequestMatchers == null)
return false;
for(RequestMatcher permissiveMatcher : permissiveRequestMatchers) {
if(permissiveMatcher.matches(request))
return true;
}
return false;
}
}
這個Filter的實現跟登錄的Filter有幾點區別:
- 經過這個Filter的請求,會繼續過
FilterChain
中的其它Filter。因為跟登錄請求不一樣,token只是為了識別用戶。 - 如果header中沒有認證信息或者認證失敗,還會判斷請求的url是否強制認證的(通過
permissiveRequest
方法判斷)。如果請求不是強制認證,也會放過,這種情況比如博客類應用匿名用戶訪問查看頁面;比如登出操作,如果未登錄用戶點擊登出,我們一般是不會報錯的。
其它邏輯跟登錄一樣,組裝一個token提交給AuthenticationManager
。
JwtAuthenticationProvider
同樣我們需要一個provider來接收jwt的token,在收到token請求后,會從數據庫或者緩存中取出salt,對token做驗證,代碼如下:
public class JwtAuthenticationProvider implements AuthenticationProvider{
private JwtUserService userService;
public JwtAuthenticationProvider(JwtUserService userService) {
this.userService = userService;
}
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
DecodedJWT jwt = ((JwtAuthenticationToken)authentication).getToken();
if(jwt.getExpiresAt().before(Calendar.getInstance().getTime()))
throw new NonceExpiredException("Token expires");
String username = jwt.getSubject();
UserDetails user = userService.getUserLoginInfo(username);
if(user == null || user.getPassword()==null)
throw new NonceExpiredException("Token expires");
String encryptSalt = user.getPassword();
try {
Algorithm algorithm = Algorithm.HMAC256(encryptSalt);
JWTVerifier verifier = JWT.require(algorithm)
.withSubject(username)
.build();
verifier.verify(jwt.getToken());
} catch (Exception e) {
throw new BadCredentialsException("JWT token verify fail", e);
}
//成功后返回認證信息,filter會將認證信息放入SecurityContext
JwtAuthenticationToken token = new JwtAuthenticationToken(user, jwt, user.getAuthorities());
return token;
}
@Override
public boolean supports(Class<?> authentication) {
return authentication.isAssignableFrom(JwtAuthenticationToken.class);
}
}
認證結果Handler
如果token認證失敗,并且不在permissive列表中話,就會調用FailHandler,這個Handler和登錄行為一致,所以都使用HttpStatusLoginFailureHandler
返回401錯誤。
token認證成功,在繼續FilterChain中的其它Filter之前,我們先檢查一下token是否需要刷新,刷新成功后會將新token放入header中。所以,新增一個JwtRefreshSuccessHandler
來處理token認證成功的情況。
public class JwtRefreshSuccessHandler implements AuthenticationSuccessHandler{
private static final int tokenRefreshInterval = 300; //刷新間隔5分鐘
private JwtUserService jwtUserService;
public JwtRefreshSuccessHandler(JwtUserService jwtUserService) {
this.jwtUserService = jwtUserService;
}
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
Authentication authentication) throws IOException, ServletException {
DecodedJWT jwt = ((JwtAuthenticationToken)authentication).getToken();
boolean shouldRefresh = shouldTokenRefresh(jwt.getIssuedAt());
if(shouldRefresh) {
String newToken = jwtUserService.saveUserLoginInfo((UserDetails)authentication.getPrincipal());
response.setHeader("Authorization", newToken);
}
}
protected boolean shouldTokenRefresh(Date issueAt){
LocalDateTime issueTime = LocalDateTime.ofInstant(issueAt.toInstant(), ZoneId.systemDefault());
return LocalDateTime.now().minusSeconds(tokenRefreshInterval).isAfter(issueTime);
}
}
JwtLoginConfigurer
跟登錄邏輯一樣,我們定義一個configurer,用來初始化和配置JWTFilter。
public class JwtLoginConfigurer<T extends JwtLoginConfigurer<T, B>, B extends HttpSecurityBuilder<B>> extends AbstractHttpConfigurer<T, B> {
private JwtAuthenticationFilter authFilter;
public JwtLoginConfigurer() {
this.authFilter = new JwtAuthenticationFilter();
}
@Override
public void configure(B http) throws Exception {
authFilter.setAuthenticationManager(http.getSharedObject(AuthenticationManager.class));
authFilter.setAuthenticationFailureHandler(new HttpStatusLoginFailureHandler());
//將filter放到logoutFilter之前
JwtAuthenticationFilter filter = postProcess(authFilter);
http.addFilterBefore(filter, LogoutFilter.class);
}
//設置匿名用戶可訪問url
public JwtLoginConfigurer<T, B> permissiveRequestUrls(String ... urls){
authFilter.setPermissiveUrl(urls);
return this;
}
public JwtLoginConfigurer<T, B> tokenValidSuccessHandler(AuthenticationSuccessHandler successHandler){
authFilter.setAuthenticationSuccessHandler(successHandler);
return this;
}
}
配置集成
整個登錄和無狀態用戶認證的流程都已經講完了,現在我們需要吧spring security集成到我們的web項目中去。spring security和spring mvc做了很好的集成,一共只需要做兩件事,給web配置類加上@EanbleWebSecurity
,繼承WebSecurityConfigurerAdapter
定義個性化配置。
配置類WebSecurityConfig
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter{
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.antMatchers("/image/**").permitAll() //靜態資源訪問無需認證
.antMatchers("/admin/**").hasAnyRole("ADMIN") //admin開頭的請求,需要admin權限
.antMatchers("/article/**").hasRole("USER") //需登陸才能訪問的url
.anyRequest().authenticated() //默認其它的請求都需要認證,這里一定要添加
.and()
.csrf().disable() //CRSF禁用,因為不使用session
.sessionManagement().disable() //禁用session
.formLogin().disable() //禁用form登錄
.cors() //支持跨域
.and() //添加header設置,支持跨域和ajax請求
.headers().addHeaderWriter(new StaticHeadersWriter(Arrays.asList(
new Header("Access-control-Allow-Origin","*"),
new Header("Access-Control-Expose-Headers","Authorization"))))
.and() //攔截OPTIONS請求,直接返回header
.addFilterAfter(new OptionRequestFilter(), CorsFilter.class)
//添加登錄filter
.apply(new JsonLoginConfigurer<>()).loginSuccessHandler(jsonLoginSuccessHandler())
.and()
//添加token的filter
.apply(new JwtLoginConfigurer<>()).tokenValidSuccessHandler(jwtRefreshSuccessHandler()).permissiveRequestUrls("/logout")
.and()
//使用默認的logoutFilter
.logout()
// .logoutUrl("/logout") //默認就是"/logout"
.addLogoutHandler(tokenClearLogoutHandler()) //logout時清除token
.logoutSuccessHandler(new HttpStatusReturningLogoutSuccessHandler()) //logout成功后返回200
.and()
.sessionManagement().disable();
}
//配置provider
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.authenticationProvider(daoAuthenticationProvider()).authenticationProvider(jwtAuthenticationProvider());
}
@Bean
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
@Bean("jwtAuthenticationProvider")
protected AuthenticationProvider jwtAuthenticationProvider() {
return new JwtAuthenticationProvider(jwtUserService());
}
@Bean("daoAuthenticationProvider")
protected AuthenticationProvider daoAuthenticationProvider() throws Exception{
//這里會默認使用BCryptPasswordEncoder比對加密后的密碼,注意要跟createUser時保持一致
DaoAuthenticationProvider daoProvider = new DaoAuthenticationProvider();
daoProvider.setUserDetailsService(userDetailsService());
return daoProvider;
}
...
}
以上的配置類主要關注一下幾個點:
- 訪問權限配置,使用url匹配是放過還是需要角色和認證
- 跨域支持,這個我們下面再講
- 禁用csrf,csrf攻擊是針對使用session的情況,這里是不需要的,關于CSRF可參考 Cross Site Request Forgery
- 禁用默認的form登錄支持
- logout支持,spring security已經默認支持logout filter,會攔截/logout請求,交給logoutHandler處理,同時在logout成功后調用
LogoutSuccessHandler
。對于logout,我們需要清除保存的token salt信息,這樣再拿logout之前的token訪問就會失敗。請參考TokenClearLogoutHandler:
public class TokenClearLogoutHandler implements LogoutHandler {
private JwtUserService jwtUserService;
public TokenClearLogoutHandler(JwtUserService jwtUserService) {
this.jwtUserService = jwtUserService;
}
@Override
public void logout(HttpServletRequest request, HttpServletResponse response, Authentication authentication) {
clearToken(authentication);
}
protected void clearToken(Authentication authentication) {
if(authentication == null)
return;
UserDetails user = (UserDetails)authentication.getPrincipal();
if(user!=null && user.getUsername()!=null)
jwtUserService.deleteUserLoginInfo(user.getUsername());
}
}
角色配置
Spring Security對于訪問權限的檢查主要是通過AbstractSecurityIntercepter
來實現,進入這個攔截器的基礎一定是在context有有效的Authentication。
回顧下上面實現的UserDetailsService
,在登錄或token認證時返回的Authentication
包含了GrantedAuthority
的列表。
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
//調用roles("USER")會將USER角色加入GrantedAuthority
return User.builder().username("Jack").password(passwordEncoder.encode("jack-password")).roles("USER").build();
}
然后我們上面的配置類中有對url的role做了配置。比如下面的配置表示/admin開頭的url支持有admin和manager權限的用戶訪問:
.antMatchers("/admin/**").hasAnyRole("ADMIN,MANAGER")
對于Intecepter來說只需要吧配置中的信息和GrantedAuthority
的信息一起提交給AccessDecisionManager
來做比對。
跨域支持
前后端分離的項目需要支持跨域請求,需要做下面的配置。
CORS配置
首先需要在HttpSecurity配置中啟用cors支持
http.cors()
這樣spring security就會從CorsConfigurationSource
中取跨域配置,所以我們需要定義一個Bean:
@Bean
protected CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOrigins(Arrays.asList("*"));
configuration.setAllowedMethods(Arrays.asList("GET","POST","HEAD", "OPTION"));
configuration.setAllowedHeaders(Arrays.asList("*"));
configuration.addExposedHeader("Authorization");
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration);
return source;
}
Header配置
對于返回給瀏覽器的Response的Header也需要添加跨域配置:
http..headers().addHeaderWriter(new StaticHeadersWriter(Arrays.asList(
//支持所有源的訪問
new Header("Access-control-Allow-Origin","*"),
//使ajax請求能夠取到header中的jwt token信息
new Header("Access-Control-Expose-Headers","Authorization"))))
OPTIONS請求配置
對于ajax的跨域請求,瀏覽器在發送真實請求之前,會向服務端發送OPTIONS請求,看服務端是否支持。對于options請求我們只需要返回header,不需要再進其它的filter,所以我們加了一個OptionsRequestFilter
,填充header后就直接返回:
public class OptionsRequestFilter extends OncePerRequestFilter{
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
if(request.getMethod().equals("OPTIONS")) {
response.setHeader("Access-Control-Allow-Methods", "GET,POST,OPTIONS,HEAD");
response.setHeader("Access-Control-Allow-Headers", response.getHeader("Access-Control-Request-Headers"));
return;
}
filterChain.doFilter(request, response);
}
}
總結
Spring Security在和shiro使用了類似的認證核心設計的情況下,提供了更多的和web的整合,以及更豐富的第三方認證支持。同時在安全性方面,也提供了足夠多的默認支持,對得上security這個名字。
所以這兩個框架的選擇問題就相對簡單了:
1)如果系統中本來使用了spring,那優先選擇spring security;
2)如果是web系統,spring security提供了更多的安全性支持
3)除次之外可以選擇shiro
文章內使用的源碼已經放在git上:Spring Security and JWT demo
[參考資料]
Spring Security Reference