spring security的處理流程
spring security采用一系列鏈?zhǔn)讲僮鱽硗瓿烧J(rèn)證和鑒權(quán)任務(wù)笋妥。它的總體流程在江南一點(diǎn)雨的博客中有寫辱匿,這里就不在羅列了。這里僅對(duì)我們需要自定義的部分進(jìn)行提取挽牢。
登錄處理
Spring Security 默認(rèn)使用form表單進(jìn)行登錄,在基本的無配置情況下他會(huì)在使用自帶的登錄頁面,并且在控制臺(tái)打印出一串密碼以進(jìn)行登錄驗(yàn)證喷户。而這顯然不符合實(shí)際的情況。所以访锻,Spring Security提供基本的配置類來進(jìn)行設(shè)置褪尝。只要在配置類中繼承WebSecurityConfigurerAdapter
就可以對(duì)Spring Security進(jìn)行簡單且高效的配置闹获。
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {@Override
protected void configure(HttpSecurity http) throws Exception {
http.csrf().disable().cors()
.and()
.formLogin()
.loginPage("/login")
.usernameParameter("loginName")
.passwordParameter("passwd")
.successForwardUrl("/index")
.failureForwardUrl("/error");
}
}
登錄過程實(shí)現(xiàn)
只要使用.loginPage()
就可以指定自己編寫的登錄界面了。但是需要注意的是默認(rèn)情況下表單中的參數(shù)名必須得是username
和password
河哑。如果參數(shù)名不一致的話避诽,spring security是無法進(jìn)行匹配的。當(dāng)然你也可以使用.usernameParameter
和.passwordParameter
來指定前端傳來的from表單內(nèi)容璃谨。除此之外沙庐,我們還需要實(shí)現(xiàn)一個(gè)UserDetailService
接口來從數(shù)據(jù)庫或內(nèi)存中獲取正確的User信息用以判斷登錄成功與否。
@Service
public class UserDetailsServiceImpl implements UserDetailsService {
private UserService userService;
@Autowired
public void setUserService(UserService userService) {
this.userService = userService;
}
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
SysUser sysUser = userService.findByLoginName(username);
if (sysUser == null) {
throw new UsernameNotFoundException("用戶名或密碼不正確");
}
return new UserDetails("具體數(shù)據(jù)");
}
}
該接口只有一個(gè)loadUserByUsername
方法需要實(shí)現(xiàn)該方法需要返回一個(gè)UserDetail
類用以存放真實(shí)的用戶信息佳吞,這個(gè)UserDetail
也是一個(gè)接口拱雏。
源碼如下:
public interface UserDetails extends Serializable {
Collection<? extends GrantedAuthority> getAuthorities();
String getPassword();
String getUsername();
boolean isAccountNonLocked();
boolean isCredentialsNonExpired();
boolean isEnabled();
}
spring security也提供實(shí)現(xiàn)類User
,一般情況下拿來即用就行底扳,也可以通過繼承進(jìn)行拓展铸抑。具體根據(jù)實(shí)際的業(yè)務(wù)邏輯來決定。最后在配置類中將寫好的UserDetailService
載入AuthenticationManager
即可花盐。這樣就能將登錄過程完全托管給spring security來處理了羡滑。
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(userDetailsService);
}
登錄成功和登錄失敗處理
但是很遺憾啊,公司里的項(xiàng)目是前后端分離式的算芯,所以上面的配置并不能滿足我們的需求柒昏。前后端分離項(xiàng)目的特點(diǎn)就是前后端之間只是用json數(shù)據(jù)進(jìn)行傳輸,頁面路由完全由前端控制熙揍。所以合理解決方案是职祷,在登錄成功后前端發(fā)送一個(gè)攜帶憑證的json數(shù)據(jù),登錄失敗的話也返回對(duì)應(yīng)的json數(shù)據(jù)届囚。
根據(jù)上面的流程圖可以看出有梆,在登錄驗(yàn)證之后,SpringSecurity 會(huì)根據(jù)驗(yàn)證結(jié)果意系,進(jìn)入AuthenticationFailureHandler
或AuthenticationSuccessHandler
中泥耀,所以只要我們實(shí)現(xiàn)這兩個(gè)接口,就可以對(duì)登錄處理進(jìn)行自定義了蛔添。
登錄失敗的話我們將失敗原因封裝成固定的json格式返回回去即可痰催。
@Component
public class LoginFailureHandler implements AuthenticationFailureHandler {
@Override
public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {
OutputStream out = response.getOutputStream();
String result;
ObjectMapper mapper = new ObjectMapper();
if (exception instanceof BadCredentialsException) {
result = mapper.writeValueAsString(BaseResponse.fail("用戶名或者密碼錯(cuò)誤"));
} else if (exception instanceof DisabledException) {
result = mapper.writeValueAsString(BaseResponse.fail("該賬戶已禁用"));
} else {
result = mapper.writeValueAsString(BaseResponse.fail(exception.getMessage()));
}
out.write(result.getBytes(StandardCharsets.UTF_8));
out.flush();
out.close();
}
}
登錄成功的邏輯也差不多,生成一個(gè)特定的憑證返回給前端使用就好迎瞧,這里我們采用jwt(json web token)作為憑證夸溶。
@Component
public class LoginSuccessHandler implements AuthenticationSuccessHandler {
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
response.setContentType("application/json; charset=UTF-8");
ServletOutputStream out = response.getOutputStream();
out.write(getResponse(authentication).getBytes(StandardCharsets.UTF_8));
out.flush();
out.close();
}
private String getResponse(Authentication authentication) throws JsonProcessingException {
// 獲取spring security user對(duì)象
User user = (User) authentication.getPrincipal();
// 用jwt工具類根據(jù)用戶信息來生成token
String token = JwtUtil.sign(user);
// 將spring security user轉(zhuǎn)化成vo傳回去
LoginUserVo lv = new LoginVo(user,token);
BaseResponse<LoginUserVo> result = BaseResponse.success(lv);
ObjectMapper mapper = new ObjectMapper();
return mapper.writeValueAsString(result);
}
}
最后別忘記將兩個(gè)自定義的實(shí)現(xiàn)類編入spring security中去
@Override
protected void configure(HttpSecurity http) throws Exception {
http.csrf().disable().cors()
.and()
// 登錄驗(yàn)證邏輯
.formLogin()
.loginPage("/login")
.loginProcessingUrl("/login")
.usernameParameter("loginName")
.passwordParameter("passwd")
.successHandler(loginSuccessHandler)
.failureHandler(loginFailureHandler);
登出處理
因?yàn)椴捎玫膉wt機(jī)制的無狀態(tài)服務(wù)凶硅,而且jwt是無法手動(dòng)注銷的缝裁,所以其實(shí)登出操作只要在前端把token從緩存中刪除就可以了。不過由于我們采用了無狀態(tài)服務(wù)足绅,所以還要配置將session給關(guān)閉
http.sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.STATELESS);
雖然session和jwt相互并不沖突捷绑,但是為了效率考量還是關(guān)閉session比較合理韩脑,不然服務(wù)端會(huì)積累太多session id的。
當(dāng)然如果是用了redis等緩存機(jī)制胎食,還要在登出成功后在緩存中也將token進(jìn)行同步刪除扰才。這一點(diǎn)可以通過實(shí)現(xiàn)LogoutSuccessHandler
來實(shí)現(xiàn),詳見上述流程圖厕怜。
認(rèn)證驗(yàn)證
將整個(gè)登錄登出模塊配置完成之后衩匣,就需要解決如何驗(yàn)證已登錄問題,而spring security自生自然使用session id 來進(jìn)行驗(yàn)證的粥航,但是之前我們已經(jīng)將session給關(guān)閉了琅捏。所以就要用到我們自己派發(fā)的jwt了,只要一個(gè)請(qǐng)求的請(qǐng)求頭中攜帶了我們的jwt递雀,且jwt未過期柄延,我們就認(rèn)為他是已經(jīng)登錄。
而要實(shí)現(xiàn)這個(gè)需求缀程,自然是又要自定義一個(gè)過濾器了搜吧。這次我們采用繼承BasicauthenticationFilter
且重寫doFilterInternal
的方式來實(shí)現(xiàn)。
public class JwtAuthenticationFilter extends BasicAuthenticationFilter {
public JwtAuthenticationFilter(AuthenticationManager authenticationManager) {
super(authenticationManager);
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException {
String token = request.getHeader("Authorization");
if (JwtUtil.verify(token)) {
UsernamePasswordAuthenticationToken authToken =
new UsernamePasswordAuthenticationToken("username", null,
"權(quán)限列表");
// 將token中的信息放到security context中用以后續(xù)驗(yàn)證
SecurityContextHolder.getContext().setAuthentication(authToken);
}
chain.doFilter(request, response);
}
}
這里的邏輯也相當(dāng)簡單杨凑,從前端的請(qǐng)求頭中獲取jwt判斷是否有效滤奈,如果有效則在spring security context中設(shè)置相應(yīng)的權(quán)限,否則就直接交給之后的過濾器來驗(yàn)證撩满。唯一需要注意的就是這個(gè)UsernamePasswordAuthenticationToken
這是spring security中authentication 的一個(gè)具體實(shí)現(xiàn)蜒程。可以從中通過getPrncipal
來獲取當(dāng)前的用戶信息伺帘,通過getCredentials
來獲取當(dāng)前用戶的密碼昭躺,通過getDetails
來獲取請(qǐng)求的更多詳細(xì)信息。然后因?yàn)槲覀兪鞘褂胘wt作為憑證的伪嫁,自然不可能在里面存放密碼领炫,所以就將密碼設(shè)為null了。值得一提的是张咳,最后一項(xiàng)權(quán)限列表是必填的不能為null驹吮。即使你的業(yè)務(wù)邏輯里沒有權(quán)限認(rèn)證,你也需要提供一個(gè)權(quán)限作為認(rèn)證權(quán)限晶伦,不然即使已登錄也是無法訪問到任何controller的。
最后將這個(gè)過濾器在配置類中進(jìn)行配置啄枕。
http.addFilter(getJwtAuthenticationFilter());
添加過濾器一共有四種方式addFilterAt
,addFilterBefore
,addFilterAfter
,addFilter
婚陪。基本上可以做到見名知義频祝,而他生效原理么泌参,就和spring security中對(duì)過濾的實(shí)現(xiàn)方式有關(guān)了脆淹。spring security會(huì)維護(hù)一個(gè)filter序列,并通過優(yōu)先級(jí)來判斷當(dāng)前應(yīng)該執(zhí)行哪個(gè)過濾器沽一,spring security對(duì)自己實(shí)現(xiàn)過濾器已經(jīng)有了默認(rèn)的優(yōu)先級(jí)配置盖溺,所以前三個(gè)方法分別可以獲取目標(biāo)過濾器的優(yōu)先級(jí),優(yōu)先級(jí)+1铣缠,優(yōu)先級(jí)-1烘嘱。如果兩個(gè)過濾器的優(yōu)先級(jí)相同的話會(huì)優(yōu)先執(zhí)行我們自定義的過濾器。詳見原文總結(jié)的相當(dāng)全面了蝗蛙。然后就是我們這里用到的addFilter
了,使用這個(gè)方法的話spring security會(huì)去判斷這個(gè)過濾器是否已經(jīng)注冊(cè)到filter列表中蝇庭,如果沒有就會(huì)報(bào)沒有指定優(yōu)先級(jí)錯(cuò)
public HttpSecurity addFilter(Filter filter) {
Class<? extends Filter> filterClass = filter.getClass();
if (!comparator.isRegistered(filterClass)) {
throw new IllegalArgumentException(
"The Filter class "
+ filterClass.getName()
+ " does not have a registered order and cannot be added without a specified order. Consider using addFilterBefore or addFilterAfter instead.");
}
this.filters.add(filter);
return this;
}
public boolean isRegistered(Class<? extends Filter> filter) {
return getOrder(filter) != null;
}
很顯然我們自定義的過濾器沒有被注冊(cè),但是為啥沒有報(bào)錯(cuò)呢捡硅,那肯定能想到是因?yàn)槔^承了父類的優(yōu)先級(jí)哮内,而事實(shí)也是如此:
private Integer getOrder(Class<?> clazz) {
while (clazz != null) {
Integer result = filterToOrder.get(clazz.getName());
if (result != null) {
return result;
}
clazz = clazz.getSuperclass();
}
return null;
}
認(rèn)證和權(quán)限異常處理
在完成了登錄驗(yàn)證之后,自然是要對(duì)未登錄的請(qǐng)求進(jìn)行攔截和向前端發(fā)送對(duì)應(yīng)信息了壮韭。攔截這一部分spring security可以幫我們做北发,但是前后端分離狀態(tài)的消息回送自然要我們來手動(dòng)處理。參考上面的流程圖可以得知喷屋,spring security的異常處理有兩個(gè)入口琳拨,一是認(rèn)證異常處理AuthenticationEntryPoint
,一是權(quán)限不足異常處理AccessDeniedHandler
。所以只要實(shí)現(xiàn)這個(gè)兩個(gè)接口并在配置類中進(jìn)行處理就行了逼蒙。
@Component
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
OutputStream out = response.getOutputStream();
ObjectMapper mapper = new ObjectMapper();
response.setStatus(401);
String result = mapper.writeValueAsString(BaseResponse.fail("用戶未認(rèn)證"));
out.write(result.getBytes(StandardCharsets.UTF_8));
out.flush();
out.close();
}
}
@Component
public class JwtDeniedHandler implements AccessDeniedHandler {
@Override
public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
OutputStream out = response.getOutputStream();
ObjectMapper mapper = new ObjectMapper();
response.setStatus(403);
String result;
if (accessDeniedException instanceof AuthorizationServiceException) {
result = mapper.writeValueAsString(BaseResponse.fail("無訪問權(quán)限"));
} else {
result = mapper.writeValueAsString(BaseResponse.fail(accessDeniedException.getMessage()));
}
out.write(result.getBytes(StandardCharsets.UTF_8));
out.flush();
out.close();
}
}
關(guān)鍵詞
springboot从绘, spring security, 前后端分離是牢, jwt僵井, restful
純小白,寫的有點(diǎn)亂驳棱,如有問題還請(qǐng)指正orz批什,會(huì)不斷完善內(nèi)容的。
參考自江南一點(diǎn)雨大神的博客社搅。原文指路