前言
SpringSecurity默認採用的基於表單的認證形式,以session識別 從用戶登錄授權、鑑權等都與表單相關。而當前許多應用都採用SpringBoot 基於Resful API風格的開發,使用默認的SpringSecurity無法直接使用滿足。解決方案:不採用默認的登錄鑑權方式,而通過覆寫增加其中的過濾器Filter來實現 登錄和鑑權,並將JWT(JSON WEB TOKEN)作爲認證機制。
原理:
- 首次使用username+password請求登錄接口
- 驗證成功後向server生成並向client返回一個定製化jwt
- 後續clinet的每次HTTP請求都攜帶 header=Authorization, value=jwt
- server接收到請求將獲取jwt中的驗證信息,生成一個在SpringSecurity認證體系中的UsernamePasswordAuthenticationToken (此Token與返給客戶端的jwt不同)
實現步驟:
- JWTLoginFilter implement UsernamePasswordAuthenticationFilter (登錄過濾器)
- JWTAuthenticationFilter extends BasicAuthenticationFilter (token檢驗過濾器)
- JWTUtils (操作jwt的工具類)
源碼地址:https://github.com/YoungerJam/SpringSecurityWithJwtDemo
重點
// JWTLoginFilter.java
package com.SpringSecurityWithJwt.demo.filter;
import com.SpringSecurityWithJwt.demo.entity.User;
import com.SpringSecurityWithJwt.demo.utils.JwtUtils;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Collection;
/**
* 登錄過濾器 client發送POST請求到此驗證
* 驗證成功後將生成返回token給client
*
* @Author: Jam
* @Date: 2020/3/12 17:24
*/
public class JWTLoginFilter extends UsernamePasswordAuthenticationFilter {
private AuthenticationManager authenticationManager;
/**
* 將登錄請求的路徑設置爲 /auth (默認下的 /login)
* @param authenticationManager 認證管理
*/
public JWTLoginFilter(AuthenticationManager authenticationManager) {
this.authenticationManager = authenticationManager;
super.setFilterProcessesUrl("/auth");
}
/**
* 重點覆寫的方法,方法名直接譯 嘗試認證
* @param request 攜帶 用戶名+密碼 的請求體
* @param response
* @return 認證信息
* @throws AuthenticationException
*/
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
try {
User user = new User();
user.setUsername(request.getParameter("username"));
user.setPassword(request.getParameter("password"));
return authenticationManager.authenticate(new UsernamePasswordAuthenticationToken(
user.getUsername(),
user.getPassword(),
new ArrayList<>()
));
}catch (Exception e){
e.printStackTrace();
return null;
}
}
/**
* 覆寫成功認證的方法,若賬號密碼校驗成功,會調用到此方法
* 並生成對應的token返回給client
* @param request
* @param response 返回給client的響應體,(會在頭部加上token認證令牌,此後的訪問需攜帶此token)
* @param chain
* @param auth
* @throws IOException
* @throws ServletException
*/
@Override
protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain,Authentication auth)throws IOException, ServletException{
Collection<? extends GrantedAuthority> authorities=auth.getAuthorities();
String username=((org.springframework.security.core.userdetails.User) auth.getPrincipal()).getUsername();
String role="";
for(GrantedAuthority authority:authorities){
role=authority.getAuthority();
}
System.out.println(username+" "+role);
String token=JwtUtils.createToken(username,role);
response.addHeader(JwtUtils.TOKEN_HEADER, JwtUtils.TOKEN_PREFIX+token);
}
}
// JWTAuthenticationFilter.java
package com.SpringSecurityWithJwt.demo.filter;
import com.SpringSecurityWithJwt.demo.utils.JwtUtils;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.security.web.authentication.www.BasicAuthenticationFilter;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.Collections;
/**
* token校驗過濾器
* 攜帶token的http請求將會在此類進行token校驗
* jwt提供了token檢驗機制
*
* @Author: Jam
* @Date: 2020/3/12 18:00
*/
public class JWTAuthenticationFilter extends BasicAuthenticationFilter {
public JWTAuthenticationFilter(AuthenticationManager authenticationManager) {
super(authenticationManager);
}
public JWTAuthenticationFilter(AuthenticationManager authenticationManager, AuthenticationEntryPoint authenticationEntryPoint) {
super(authenticationManager, authenticationEntryPoint);
}
/**
* 覆寫方法
* @param request 攜帶token的請求體
* @param response
* @param chain
* @throws IOException
* @throws ServletException
*/
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response, FilterChain chain)
throws IOException, ServletException {
String header=request.getHeader(JwtUtils.TOKEN_HEADER);
//過濾沒有token的(可能是無需授權的訪問)
if(header==null||!header.startsWith(JwtUtils.TOKEN_PREFIX)){
chain.doFilter(request,response);
return;
}
UsernamePasswordAuthenticationToken authentication = getAuthentication(header);
//設置該用戶的認證信息,由jwtToken生成UsernamePasswordAuthenticationToken
SecurityContextHolder.getContext().setAuthentication(authentication);
chain.doFilter(request, response);
}
private UsernamePasswordAuthenticationToken getAuthentication(String header) {
String token = header.replace(JwtUtils.TOKEN_PREFIX, "");
String username = JwtUtils.getUsername(token);
String role = JwtUtils.getUserRole(token);
System.out.println("授權:"+username+" "+role);
if (username != null) {
return new UsernamePasswordAuthenticationToken(username, null,
Collections.singleton(new SimpleGrantedAuthority(role))
);
}
return null;
}
}
// JwtUtils.java
package com.SpringSecurityWithJwt.demo.utils;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
/**
* @Author: Jam
* @Date: 2020/3/13 0:36
*/
public class JwtUtils {
public static final String TOKEN_HEADER="Authorization";
public static final String TOKEN_PREFIX="Bearer ";
private static final String SECRET="MyJwtSecret";
private static final long EXPIRATION=7200;
private static final String ROLE_CLAIMS="rol";
public static String createToken(String username,String role){
Map<String,Object> map=new HashMap<>();
map.put(ROLE_CLAIMS,role);
return Jwts.builder()
.signWith(SignatureAlgorithm.HS512,SECRET)
.setClaims(map)
.setSubject(username)
.setExpiration(new Date(System.currentTimeMillis()+EXPIRATION*1000))
.compact();
}
public static String getUsername(String token){
return getTokenBody(token).getSubject();
}
public static String getUserRole(String token){
return (String) getTokenBody(token).get(ROLE_CLAIMS);
}
//解析jwt
private static Claims getTokenBody(String token){
return Jwts.parser()
.setSigningKey(SECRET)
.parseClaimsJws(token)
.getBody();
}
}
// JWTAuthenticationEntryPoint 異常處理類
package com.SpringSecurityWithJwt.demo.exception;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
/**
* @Author: Jam
* @Date: 2020/3/13 15:09
*/
public class JWTAuthenticationEntryPoint implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
response.setCharacterEncoding("UTF-8");
response.setContentType("application/json; charset=utf-8");
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
response.getWriter().write(new ObjectMapper().writeValueAsString("無訪問權限 "+authException.getMessage()));
}
}
// SecurityConfig.java
package com.SpringSecurityWithJwt.demo.config;
import com.SpringSecurityWithJwt.demo.exception.JWTAuthenticationEntryPoint;
import com.SpringSecurityWithJwt.demo.filter.JWTAuthenticationFilter;
import com.SpringSecurityWithJwt.demo.filter.JWTLoginFilter;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import javax.sql.DataSource;
/**
* @Author: Jam
* @Date: 2020/3/9 16:31
*/
@Configuration
@EnableGlobalMethodSecurity(prePostEnabled = true)
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
private final DataSource dataSource;
private final BCryptPasswordEncoder bCryptPasswordEncoder;
@Autowired
public SecurityConfig(DataSource dataSource, BCryptPasswordEncoder bCryptPasswordEncoder) {
this.dataSource = dataSource;
this.bCryptPasswordEncoder = bCryptPasswordEncoder;
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http.cors().and().csrf().disable().authorizeRequests()
.antMatchers(HttpMethod.GET, "/user/query").authenticated()
.antMatchers(HttpMethod.POST, "/user/register").permitAll()
.antMatchers(HttpMethod.POST, "/user/update").hasRole("USER")
.anyRequest().authenticated()
.and()
.addFilter(new JWTLoginFilter(authenticationManager()))
.addFilter(new JWTAuthenticationFilter(authenticationManager()))
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.exceptionHandling().authenticationEntryPoint(new JWTAuthenticationEntryPoint());
}
/**
* 這裏我採用的是jdbc的數據庫表認證
**/
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth
.jdbcAuthentication()
.dataSource(dataSource)
.usersByUsernameQuery(
"select username,password,true from t_user where username=?"
)
.authoritiesByUsernameQuery(
"select username,'ROLE_USER' from t_user where username=?"
)
.passwordEncoder(new BCryptPasswordEncoder());
}
}
測試
- 開放接口 /user/register
開放接口能正常調用,注意爲post請求 在配置時設置了只有 post方法下的該路徑才方通,而 get /user/registrer 則屬於配置.anyRequest().authenticated()
所以如果手誤以get 請求該路徑,會返回無授權訪問的信息
- 登錄接口 /auth
由於沒有覆寫成功的返回體信息,所以Reponse中的body是空的,有需要可以自行添加,重點關注reponse Header中的Authorization 這個頭部即爲服務器返回的 jwt token。 - GET /user/query 和 POST /user/update接口測試
不攜帶token時響應如上,在Header中加上剛剛登錄時返回的Authorzation
測試完成,權限控制正常發揮作用,返回idea可以看到剛剛處理過程中的一些權限相關信息 (我在兩個FIlter裏寫的sout)
剖析
實現過程中,通過繼承UsernamePasswordAuthenticationFilter覆寫了attemptAuthenticaton和successfulAuthentication方法,來看看源碼中的該類
(截取重點部分)
只接接受 /login路徑下的post請求 原則是post請求不可更改,而路徑可以隨意設置 如上述代碼中的 super.setFilterProcessesUrl("/auth");
這裏可以看到 其實在源碼實現層面上,內部使用的便是 UsernamePasswordAuthenticationToken 這也是爲什麼我們在攜帶jwt再次請求的時候要解析jwt生成 APAT(英文簡寫吧),所以不需要調到attempt方法但仍然可以具備授權身份。
// 截留了源碼中的關鍵語句
private AuthenticationSuccessHandler successHandler = new SavedRequestAwareAuthenticationSuccessHandler();
private AuthenticationFailureHandler failureHandler = new SimpleUrlAuthenticationFailureHandler();
protected void successfulAuthentication(HttpServletRequest request,
HttpServletResponse response, FilterChain chain, Authentication authResult)
throws IOException, ServletException {
SecurityContextHolder.getContext().setAuthentication(authResult);
successHandler.onAuthenticationSuccess(request, response, authResult);
}
驗證成功的處理方法,處於AbstractAuthenticat… Filter中,有興趣可以自己調出來看看,默認是調用Handler類處理後續,所以如果選擇不覆寫此方法的話可以自己去是寫一個實現Hadnler類也能達到想要的自定義登錄成功/失敗處理。
再來看看JWTAuthentication的父類 BsicAuthenticationFitler
點開後 - - 有很大一篇的備註,看看重點
我們只需要關注兩個關鍵點
1、我們攜帶token的請求被誰攔截處理(如上圖)所以我們可以擴展它
2、怎麼處理這個token 並完成識別用戶權限。
SecurityContextHolder.getContext().setAuthentication(authResult);
這個語句便是設置認證信息的關鍵所在,再來會看一下這個頻繁提到的UsernamePasswordAuthenticationToken到底是怎樣的數據結構
該類有個主體字段 principal
輸出一下驗證賬號密碼後生成的UPAT看看
org.springframework.security.core.userdetails.User@a0ccb02b:
Username: 771007760;
Password: [PROTECTED];
Enabled: true;
AccountNonExpired: true;
credentialsNonExpired: true;
AccountNonLocked: true;
Granted Authorities: ROLE_USER
幾乎囊括了用戶的的所有信息。該類的一個構造器(上述在JWTAuthenticationFilter調用的構造器)
我們攜帶的JwtToken解析生成回 UPAT,只需寫入username,null,role 便可完成認證。
輸出一下通過token解析反生成的UPAT看看
System.out.println(u.getPrincipal()+" "+u.getAuthorities());
771007760 [ROLE_USER]
沒有其他的信息,因爲此時這就具備了鑑權條件了 username+role
參考了多方文檔與實現方案 也沒辦法標出全部的借鑑,所以在此感謝所有大佬吧。
源碼地址:https://github.com/YoungerJam/SpringSecurityWithJwtDemo