認證和授權學習6:前后端分離狀態下使用springsecurity
本文使用的springboot版本是2.1.3.RELEASE
一、簡要描述
默認情況下,spring security登錄成功或失敗,都會返回一個302跳轉。登錄成功跳轉到主頁,失敗跳轉到登錄頁。如果未認證直接訪問受保護資源也會跳轉到登錄頁 。
而在前后端分離項目中,前后端是通過json數據進行交互,前端通過ajax請求和后端進行交互,ajax是無法處理302跳轉的,所以我們希望不管是未登錄還是登錄成功,spring security都給前端返回json數據,而前端自己根據返回結果進行邏輯控制。
springsecurity默認采用的是表單登錄,而我們希望的登錄流程是這樣的:
(1) 前端帶着用戶名和密碼用ajax請求登錄,認證成功后返回一個token值給前端
(2) 下次請求時在請求頭中攜帶這個token,后端校驗這個token通過后放行請求,否則提示未登錄(返回json數據)
二、配置讓springsecurity返回 json數據
2.1 未登錄時訪問受限資源的處理
未登錄時訪問資源,請求會被FilterSecurityInterceptor這個過濾器攔截到,然后拋出異常,這個異常會被
ExceptionTranslationFilter這個過濾器捕獲到,並最終交給AuthenticationEntryPoint接口的commence方法處理。
所以處理辦法是自定義一個AuthenticationEntryPoint的實現類並配置到springsecurity中
/**
* 未登錄時訪問受限資源的處理方式
*/
public class UnLoginHandler implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
ObjectMapper mapper = new ObjectMapper();
ObjectNode objectNode = mapper.createObjectNode();
if(authException instanceof BadCredentialsException){
//賬號或密碼錯誤
objectNode.put("code", "501");
objectNode.put("message", "賬號或者密碼錯誤");
}else {
objectNode.put("code", "500");
objectNode.put("message", "未登錄或token無效");
}
response.setHeader("Content-Type", "application/json;charset=UTF-8");
response.getWriter().print(objectNode);
}
}
配置到spring security中,
@Configuration
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
...省略其他配置
//安全配置
@Override
protected void configure(HttpSecurity http) throws Exception {
...省略其他配置
//設置未登錄或登錄失敗時訪問資源的處理方式
http.exceptionHandling().authenticationEntryPoint(new UnLoginHandler());
...
}
}
2.2 訪問資源權限不足時的處理
當一個已登錄用戶訪問了一個沒有權限的資源時,springsecurity默認會重定向到一個403頁面。可以通過自己實現 AccessDeniedHandler接口然后配置到springsecurity中來自定義
/**
* 當前登錄的用戶沒有權限訪問資源時的處理器
*/
public class NoAccessDeniedHandler implements AccessDeniedHandler {
@Override
public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
ObjectMapper mapper = new ObjectMapper();
ObjectNode objectNode = mapper.createObjectNode();
objectNode.put("code","500");
objectNode.put("message","訪問失敗,權限不夠");
response.setHeader("Content-Type","application/json;charset=UTF-8");
response.getWriter().print(objectNode);
}
}
配置到springsecurity中
@Configuration
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
...省略其他配置
//安全配置
@Override
protected void configure(HttpSecurity http) throws Exception {
...省略其他配置
//設置權限不足,無法訪問當前資源時的處理方式
http.exceptionHandling().accessDeniedHandler(new NoAccessDeniedHandler());
...
}
}
這樣配置后,未登錄,登錄失敗,權限不足這些場景下springsecurity就會返回json數據給前端。
三、如何發token
這一節來解決發token的問題。現在已經去掉了表單登錄的功能,那如何讓springsecurity驗證賬號和密碼並創建token呢。
可以自定義一個接口給前端請求,用來發token,前端提交賬號和密碼到這個接口,在其中調用springsecurity的認證管理器來認證賬號密碼,認證成功后創建一個token返回給前端
@RestController
@RequestMapping("/authenticate")
public class AuthenticationController {
private final static ObjectMapper MAPPER=new ObjectMapper();
//注入springsecurity的認證管理器
@Autowired
private AuthenticationManager authenticationManager;
/**
* 創建token
* @return
*/
@PostMapping("/applyToken")
public JsonNode applyToken(@RequestBody UserDto userDto){
ObjectNode tokenNode = MAPPER.createObjectNode();
//1.創建UsernamePasswordAuthenticationToken對象
UsernamePasswordAuthenticationToken authenticationToken=new UsernamePasswordAuthenticationToken(userDto.getUsername(),userDto.getPassword());
//2.交給認證管理器進行認證
Authentication authenticate = authenticationManager.authenticate(authenticationToken);
if(null!=authenticate){
//認證成功,生成token返回給前端
String token = JwtUtils.createToken(userDto.getUsername());
if(StringUtils.isEmpty(token)){
tokenNode.put("code","401");
tokenNode.put("message","生成token失敗");
}else {
tokenNode.put("code","200");
tokenNode.put("token", token);
tokenNode.put("message","success");
}
tokenNode.put("code","200");
tokenNode.put("token", JwtUtils.createToken(userDto.getUsername()));
tokenNode.put("message","success");
return tokenNode;
}else{
tokenNode.put("code","401");
tokenNode.put("message","登錄失敗");
}
return tokenNode;
}
}
其中JwtUtils是一個自定義的jwt 工具類,提供了生成token和驗證token的功能
四、如何讓springsecurity驗證token
上邊實現了發token的功能,那如何讓springsecurity驗證這個token,並放行請求。可以自定義一個過濾器,在springsecurity的登錄過濾器之前先攔截請求,然后進行token,如果驗證通過了就把當前用戶設置到SecurityContextHolder中,這樣就完成了驗證和登錄。
自定義過濾器
/**
* 驗證請求攜帶的token是否有效
*/
@Component
public class TokenVerifyFilter extends GenericFilterBean {
@Autowired
private UserDetailsService userDetailsService;
@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
try {
HttpServletRequest request = (HttpServletRequest) servletRequest;
//從請求頭中獲取token
String token = request.getHeader("Authorization-Token");
if (StringUtils.hasText(token)) {
//從token中解析用戶名
String username = JwtUtils.getUserInfo(token);
//查詢當前用戶
if(!StringUtils.isEmpty(username)){
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
if(null!=userDetails){
//查詢不到表示用戶不存在
//從token中獲取用戶信息封裝成 UsernamePasswordAuthenticationToken
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(token, "", userDetails.getAuthorities());
//設置用戶信息
SecurityContextHolder.getContext().setAuthentication(authenticationToken);
}
}
}
} catch (Exception e) {
//登錄發生異常,但要繼續走其余過濾器的邏輯
e.printStackTrace();
}
//繼續執行springsecurity的過濾器
filterChain.doFilter(servletRequest, servletResponse);
}
}
把這個過濾器設置到UsernamePasswordAuthenticationFilter之前。
完整的springsecurity安全配置如下
/**
* 配置springsecurity
*/
@Configuration
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
//用戶配置,
@Bean
public UserDetailsService userDetailsService(){
//在內存中配置用戶
InMemoryUserDetailsManager manager=new InMemoryUserDetailsManager();
manager.createUser(User.withUsername("lyy").password("123").authorities("ROLE_P1").build());
manager.createUser(User.withUsername("zs").password("456").authorities("ROLE_P2").build());
return manager;
}
//配置自定義的對token進行驗證的過濾器
@Autowired
private TokenVerifyFilter tokenVerifyFilter;
//密碼加密方式配置
@Bean
public PasswordEncoder passwordEncoder(){
return NoOpPasswordEncoder.getInstance();
}
//安全配置
@Override
protected void configure(HttpSecurity http) throws Exception {
http.csrf().disable();
//匹配路徑時越具體的路徑要先匹配
http.authorizeRequests().antMatchers("/","/index.html").permitAll();
//放行申請token的url
http.authorizeRequests().antMatchers("/authenticate/**").permitAll();
//需要p1權限才能訪問
http.authorizeRequests().antMatchers("/resource/r1").hasRole("P1");
//需要p2權限才能訪問
http.authorizeRequests().antMatchers("/resource/r2").hasRole("P2")
.antMatchers("/resource/r3").hasRole("P3");//需要p3權限才能訪問
http.authorizeRequests().anyRequest().authenticated();
http.formLogin().disable();//禁用表單登錄
//設置未登錄或登錄失敗時訪問資源的處理方式
http.exceptionHandling().authenticationEntryPoint(new UnLoginHandler());
//設置權限不足,無法訪問當前資源時的處理方式
http.exceptionHandling().accessDeniedHandler(new NoAccessDeniedHandler());
http.addFilterBefore(tokenVerifyFilter, UsernamePasswordAuthenticationFilter.class);
//設置不使用session,無狀態
http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);
}
/**
* 配置認證管理器:
* @return
* @throws Exception
*/
@Bean
@Override
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
}
五、總結
按上邊這樣配置后,前端向先請求發token的接口獲取一個token,然后在每次訪問后端時都在請求頭中帶上這個token,后端驗證了這個token后就會放行請求。
完整的示例工程:
