在這裡插入圖檔描述
原來一直使用shiro做安全架構,配置起來相當友善,正好有機會接觸下SpringSecurity,學習下這個。順道結合下jwt,把安全資訊管理的問題扔給用戶端,
準備
首先用的是SpringBoot,省去寫各種xml的時間。然後把依賴加入一下
org.springframework.bootspring-boot-starter-security
io.jsonwebtokenjjwt0.9.1
application.yml加上一點配置資訊,後面會用
jwt:
secret: secret
expiration: 7200000
token: Authorization
可能用到代碼,目錄結構放出來一下
配置
SecurityConfig配置
首先是配置SecurityConfig,代碼如下
@Configuration
@EnableWebSecurity// 這個注解必須加,開啟Security
@EnableGlobalMethodSecurity(prePostEnabled = true)//保證post之前的注解可以使用
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;
@Autowired
JwtUserDetailsService jwtUserDetailsService;
@Autowired
JwtAuthorizationTokenFilter authenticationTokenFilter;
//先來這裡認證一下
@Autowired
public void configureGlobal(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(jwtUserDetailsService).passwordEncoder(passwordEncoderBean());
}
//攔截在這配
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.exceptionHandling().authenticationEntryPoint(jwtAuthenticationEntryPoint)
.and()
.authorizeRequests()
.antMatchers("/login").permitAll()
.antMatchers("/haha").permitAll()
.antMatchers("/sysUser/test").permitAll()
.antMatchers(HttpMethod.OPTIONS, "/**").anonymous()
.anyRequest().authenticated() // 剩下所有的驗證都需要驗證
.and()
.csrf().disable() // 禁用 Spring Security 自帶的跨域處理
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);
// 定制我們自己的 session 政策:調整為讓 Spring Security 不建立和使用 session
http.addFilterBefore(authenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);
}
@Bean
public PasswordEncoder passwordEncoderBean() {
return new BCryptPasswordEncoder();
}
@Bean
@Override
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
}
ok,下面娓娓道來。首先我們這個配置類繼承了WebSecurityConfigurerAdapter,這裡面有三個重要的方法需要我們重寫一下:
-
configure(HttpSecurity http):這個方法是我們配置攔截的地方,exceptionHandling().authenticationEntryPoint(),這裡面主要配置如果沒有憑證,可以進行一些操作,這個後面會看jwtAuthenticationEntryPoint這個裡面的代碼。進行下一項配置,為了區分必須加入.and()。authorizeRequests()這個後邊配置那些路徑有需要什麼權限,比如我配置的那幾個url都是permitAll(),及不需要權限就可以通路。值得一提的是antMatchers(HttpMethod.OPTIONS, "/**"),是為了友善後面寫前後端分離的時候前端過來的第一次驗證請求,這樣做,會減少這種請求的時間和資源使用。csrf().disable()是為了防止csdf攻擊的,至于什麼是csdf攻擊,請自行百度。
另起一行,以示尊重。sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);因為我們要使用jwt托管安全資訊,是以把Session禁止掉。看下SessionCreationPolicy枚舉的幾個參數:
http.addFilterBefore(authenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);這行代碼主要是用于JWT驗證,後面再說。public enum SessionCreationPolicy { ALWAYS,//總是會建立一個Session。 NEVER,//不會建立HttpSession,但是如果有Session存在,就會使用它。 IF_REQUIRED,//如果有要求的話,會建立一個Session。 STATELESS;//這個是我們用的,不會建立,也不會使用一個HttpSession。 private SessionCreationPolicy() { } }
- configure(WebSecurity web):這個方法我代碼中沒有用,這個方法主要用于通路一些靜态的東西控制。其中ignoring()方法可以讓通路跳過filter驗證。
- configureGlobal(AuthenticationManagerBuilder auth):這個方法是主要進行驗證的地方,其中jwtUserDetailsService代碼待會會看,passwordEncoder(passwordEncoderBean())是密碼的一種加密方式。
還有兩個注解:@EnableWebSecurity,這個注解必須加,開啟Security。@EnableGlobalMethodSecurity(prePostEnabled = true),保證post之前的注解可以使用
以上,我們可以确定了哪些路徑通路不需要任何權限了,至于哪些路徑需要什麼權限接着往下看。
SecurityUserDetails
Security 中也有類似于shiro中主體的概念,就是在記憶體中存了一個東西,友善程式判斷目前請求的使用者有什麼權限,需要實作UserDetails這個接口,是以我寫了這個類,并且繼承了我自己的類SysUser。
@Data
@EqualsAndHashCode(callSuper = false)
@Accessors(chain = true)
public class SecurityUserDetails extends SysUser implements UserDetails {
private Collection extends GrantedAuthority> authorities;
@Override
public Collection extends GrantedAuthority> getAuthorities() {
return authorities;
}
public SecurityUserDetails(String userName, Collection extends GrantedAuthority> authorities){
this.authorities = authorities;
this.setUsername(userName);
String encode = new BCryptPasswordEncoder().encode("123456");
this.setPassword(encode);
this.setAuthorities(authorities);
}
/**
* 賬戶是否過期
* @return
*/
@Override
public boolean isAccountNonExpired() {
return true;
}
/**
* 是否禁用
* @return
*/
@Override
public boolean isAccountNonLocked() {
return true;
}
/**
* 密碼是否過期
* @return
*/
@Override
public boolean isCredentialsNonExpired() {
return true;
}
/**
* 是否啟用
* @return
*/
@Override
public boolean isEnabled() {
return true;
}
}
authorities就是我們的權限,構造方法中我手動把密碼set進去了,這不合适,包括權限我也是手動傳進去的。這些東西都應該從資料庫搜出來,我現在隻是體驗一把Security,角色權限那一套都沒寫,是以說明一下就好了,這個構造方法就是傳進來一個标志(我這裡用的是username,或者應該用userId什麼的都可以),然後給你一個完整的主體資訊,供其他地方使用。ok,next。
JwtUserDetailsService
SecurityConfig配置裡面不是有個方法是做真正的認證嘛,或者說從資料庫拿資訊,具體那認證資訊的方法就是在這個方法裡面。
@Service
public class JwtUserDetailsService implements UserDetailsService {
@Override
public UserDetails loadUserByUsername(String user) throws UsernameNotFoundException {
System.out.println("JwtUserDetailsService:" + user);
List authorityList = new ArrayList<>();
authorityList.add(new SimpleGrantedAuthority("ROLE_USER"));
return new SecurityUserDetails(user,authorityList);
}
}
繼承了Security提供的UserDetailsService接口,實作loadUserByUsername這個方法,我們這裡手動模拟從資料庫搜出來一個叫USER的權限,通過剛才的構造方法,模拟生成目前user的資訊,供後面jwt Filter一大堆驗證。至于為什麼USER權限要加上“ROLE_”字首,待會會說。
ok,現在我們知道了怎麼配置各種url是否需要權限才能通路,也知道了哪裡可以拿到我們的主體資訊,那麼繼續。
JwtAuthorizationTokenFilter
千呼萬喚始出來,JWT終于可以上場了。至于怎麼生成這個token憑證,待會會說,現在假設前端已經拿到了token憑證,要通路某個接口了,看看怎麼進行jwt業務的攔截吧。
@Component
public class JwtAuthorizationTokenFilter extends OncePerRequestFilter {
private final UserDetailsService userDetailsService;
private final JwtTokenUtil jwtTokenUtil;
private final String tokenHeader;
public JwtAuthorizationTokenFilter(@Qualifier("jwtUserDetailsService") UserDetailsService userDetailsService,
JwtTokenUtil jwtTokenUtil, @Value("${jwt.token}") String tokenHeader) {
this.userDetailsService = userDetailsService;
this.jwtTokenUtil = jwtTokenUtil;
this.tokenHeader = tokenHeader;
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException {
final String requestHeader = request.getHeader(this.tokenHeader);
String username = null;
String authToken = null;
if (requestHeader != null && requestHeader.startsWith("Bearer ")) {
authToken = requestHeader.substring(7);
try {
username = jwtTokenUtil.getUsernameFromToken(authToken);
} catch (ExpiredJwtException e) {
}
}
if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
UserDetails userDetails = this.userDetailsService.loadUserByUsername(username);
if (jwtTokenUtil.validateToken(authToken, userDetails)) {
UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
SecurityContextHolder.getContext().setAuthentication(authentication);
}
}
chain.doFilter(request, response);
}
}
提前說一下,關于@Value注解參數開頭寫了。
doFilterInternal() 這個方法就是這個過濾器的精髓了。首先從header中擷取憑證authToken,從中挖掘出來我們的username,然後看看上下文中是否有我們以這個username為辨別的主體。沒有,ok,去new一個(如果對象也可以new就好了。。。)。然後就是驗證這個authToken 是否在有效期呢啊,驗證token是否對啊等等吧。其實我們剛剛把我們SecurityUserDetails這個對象叫做主體,到這裡我才發現有點自做多情了,因為生成Security承認的主體是通過UsernamePasswordAuthenticationToken類似與這種類去實作的,之前之是以叫SecurityUserDetails為主體,隻是它存了一些關鍵資訊。然後将主體資訊————authentication,存入上下文環境,供後面使用。
我的很多工具類代碼都放到了jwtTokenUtil,下面貼一下代碼:
@Component
public class JwtTokenUtil implements Serializable {
private static final long serialVersionUID = -3301605591108950415L;
@Value("${jwt.secret}")
private String secret;
@Value("${jwt.expiration}")
private Long expiration;
@Value("${jwt.token}")
private String tokenHeader;
private Clock clock = DefaultClock.INSTANCE;
public String generateToken(UserDetails userDetails) {
Map claims = new HashMap<>();
return doGenerateToken(claims, userDetails.getUsername());
}
private String doGenerateToken(Map claims, String subject) {
final Date createdDate = clock.now();
final Date expirationDate = calculateExpirationDate(createdDate);
return Jwts.builder()
.setClaims(claims)
.setSubject(subject)
.setIssuedAt(createdDate)
.setExpiration(expirationDate)
.signWith(SignatureAlgorithm.HS512, secret)
.compact();
}
private Date calculateExpirationDate(Date createdDate) {
return new Date(createdDate.getTime() + expiration);
}
public Boolean validateToken(String token, UserDetails userDetails) {
SecurityUserDetails user = (SecurityUserDetails) userDetails;
final String username = getUsernameFromToken(token);
return (username.equals(user.getUsername())
&& !isTokenExpired(token)
);
}
public String getUsernameFromToken(String token) {
return getClaimFromToken(token, Claims::getSubject);
}
public T getClaimFromToken(String token, Function claimsResolver) {
final Claims claims = getAllClaimsFromToken(token);
return claimsResolver.apply(claims);
}
private Claims getAllClaimsFromToken(String token) {
return Jwts.parser()
.setSigningKey(secret)
.parseClaimsJws(token)
.getBody();
}
private Boolean isTokenExpired(String token) {
final Date expiration = getExpirationDateFromToken(token);
return expiration.before(clock.now());
}
public Date getExpirationDateFromToken(String token) {
return getClaimFromToken(token, Claims::getExpiration);
}
}
根據注釋你能猜個大概吧,就不再說了,有些東西是jwt方面的東西,今天就不再多說了。
JwtAuthenticationEntryPoint
前面還說了一個發現沒有憑證走一個方法,代碼也貼一下。
@Component
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest request,
HttpServletResponse response,
AuthenticationException authException)
throws IOException, ServletException {
System.out.println("JwtAuthenticationEntryPoint:"+authException.getMessage());
response.sendError(HttpServletResponse.SC_UNAUTHORIZED,"沒有憑證");
}
}
實作AuthenticationEntryPoint這個接口,發現沒有憑證,往response中放些東西。
run code
下面跑一下幾個接口,看看具體是怎麼具體通路某個方法的吧,還有前面一點懸念一并解決。
登入
先登入一下,看看怎麼生成token扔給前端的吧。
@RestController
public class LoginController {
@Autowired
@Qualifier("jwtUserDetailsService")
private UserDetailsService userDetailsService;
@Autowired
private JwtTokenUtil jwtTokenUtil;
@PostMapping("/login")
public String login(@RequestBody SysUser sysUser, HttpServletRequest request){
final UserDetails userDetails = userDetailsService.loadUserByUsername(sysUser.getUsername());
final String token = jwtTokenUtil.generateToken(userDetails);
return token;
}
@PostMapping("haha")
public String haha(){
UserDetails userDetails = (UserDetails) org.springframework.security.core.context.SecurityContextHolder.getContext().getAuthentication().getPrincipal();
return "haha:"+userDetails.getUsername()+","+userDetails.getPassword();
}
}
我們前面配置中已經把login設定為随便通路了,這邊通過jwt生成一個token串,具體方法請看jwtTokenUtil.generateToken,已經寫了。隻要知道這裡面存了username、加密規則、過期時間就好了。
然後跑下haha接口,發現沒問題,正常列印,說明主體也在上下文中了。
需要權限
然後我們通路一個需要權限的接口吧。
@RestController
@RequestMapping("/sysUser")
public class SysUserController {
@GetMapping(value = "/test")
public String test() {
return "Hello Spring Security";
}
@PreAuthorize("hasAnyRole('USER')")
@PostMapping(value = "/testNeed")
public String testNeed() {
return "testNeed";
}
}
通路testNeed接口,看到沒,@PreAuthorize("hasAnyRole('USER')")這個說明需要USER權限!我們在剛剛生成SecurityUserDetails這個的時候已經模拟加入了USER權限了,是以可以通路。現在說說為什麼權重限的時候需要加入字首“ROLE_”.看hasAnyRole源碼:
public final boolean hasAnyRole(String... roles) {
return hasAnyAuthorityName(defaultRolePrefix, roles);
}
private boolean hasAnyAuthorityName(String prefix, String... roles) {
Set roleSet = getAuthoritySet();
for (String role : roles) {
String defaultedRole = getRoleWithDefaultPrefix(prefix, role);
if (roleSet.contains(defaultedRole)) {
return true;
}
}
return false;
}
private static String getRoleWithDefaultPrefix(String defaultRolePrefix, String role) {
if (role == null) {
return role;
}
if (defaultRolePrefix == null || defaultRolePrefix.length() == 0) {
return role;
}
if (role.startsWith(defaultRolePrefix)) {
return role;
}
return defaultRolePrefix + role;
}
關鍵是 defaultRolePrefix 看這個類最上面
private String defaultRolePrefix = "ROLE_";
人家源碼這麼幹的,咱們就這麼寫呗,咱也不敢問。其實也有不需要字首的方式,去看看SecurityExpressionRoot這個類吧,用的方法不一樣,也就是@PreAuthorize裡面有另外一個參數。
一個重要的問題
先說結論:Security上下文環境(裡面有主體)生命周期隻限于一次請求。
我做了一個測試:
把SecurityConfig裡面configure(HttpSecurity http)這個方法裡面
http.addFilterBefore(authenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);
這行代碼注釋掉,不走那個jwt filter。就是不每次都添加上下上下文環境。
然後loginController改成
@RestController
public class LoginController {
@Autowired
@Qualifier("jwtUserDetailsService")
private UserDetailsService userDetailsService;
@Autowired
private JwtTokenUtil jwtTokenUtil;
@PostMapping("/login")
public String login(@RequestBody SysUser sysUser, HttpServletRequest request){
final UserDetails userDetails = userDetailsService.loadUserByUsername(sysUser.getUsername());
final String token = jwtTokenUtil.generateToken(userDetails);
//添加 start
UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
SecurityContextHolder.getContext().setAuthentication(authentication);
//添加 end
return token;
}
@PostMapping("haha")
public String haha(){
UserDetails userDetails = (UserDetails) org.springframework.security.core.context.SecurityContextHolder.getContext().getAuthentication().getPrincipal();
return "haha:"+userDetails.getUsername()+","+userDetails.getPassword();
}
}
然後登陸,然後通路/haha,崩了,發現userDetails裡面沒資料。說明這會上下文環境中我們主體不存在。
為什麼會這樣呢?
SecurityContextPersistenceFilter 一次請求,filter鍊結束之後 會清除掉Context裡面的東西。所說以,主體資料生命周期是一次請求。
源碼如下:
public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
throws IOException, ServletException {
...假裝有一堆代碼...
try {
}
finally {
SecurityContext contextAfterChainExecution = SecurityContextHolder
.getContext();
// Crucial removal of SecurityContextHolder contents - do this before anything
// else.
SecurityContextHolder.clearContext();
repo.saveContext(contextAfterChainExecution, holder.getRequest(),
holder.getResponse());
request.removeAttribute(FILTER_APPLIED);
}
}
關鍵就是finally裡面 SecurityContextHolder.clearContext(); 這句話。這才展現了那句,把維護資訊的事扔給了用戶端,你不請求,我也不知道你有啥。
體驗小結
配置起來感覺還可以吧,使用jwt方式,生成token.由于上下文環境的生命周期是一次請求,是以在不請求的情況下,服務端不清楚使用者有那些權限,真正實作了用戶端維護安全資訊,是以項目中也沒有登出接口,因為沒必要。即使前端退出了,你有token,依然可以通過postman請求接口(token沒有過期)。不同于shiro可以把資訊維護在服務端,要是登出,clear主體資訊,通路接口就需要在登入。不過Security這樣也有好處,可以實作單點登陸了,也友善做分布式。(隻要你不同子系統中驗證那一套邏輯相同,或者在分布式的情況下有單獨的驗證系統)。