SpringSecurity配置
SecurityConfig.java
@Override
protected void configure(HttpSecurity http) throws Exception {
// CRSF禁用,不使用session
http.csrf().disable();
// 基於token,所以不需要session
http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);
// 過濾請求
http.authorizeRequests()
.antMatchers("/login", "/captchaImage").anonymous()
.anyRequest().authenticated();
....
// 新增JWT filter
http.addFilterBefore(authenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);
}
從上面可以看到我們設定了放行 /login
和 /captchaImage
請求,而其他請求就需要通過認證才可訪問。下面正式分析登入認證流程
登入認證
首先分析一下流程
1、前端填寫完表單資料後,傳送請求 [post] /login
,傳遞 username、password、code、uuid這四個資料
2、後端收到 [post] /login
請求,來到相應的 controller 處理方法
@RestController
public class SysLoginController {
@Autowired
private SysLoginService loginService;
@PostMapping("login")
public Result login(String username, String password, String code, String uuid){
Result result = Result.success();
String token = loginService.login(username, password, code, uuid);
result.put(Constants.TOKEN, token);
return result;
}
在這裡呼叫 loginService 的 login方法,其中內部是具體的驗證登入邏輯,驗證通過後返回一個token,然後回傳給前端。前端這時就可以將token資料存入本地了
3、接下來進入 loginService.login()
內,進行詳細分析
@Component
public class SysLoginService {
@Autowired private RedisCache redisCache;
@Autowired private TokenService tokenService;
@Autowired private AuthenticationManager authenticationManager;
public String login(String username, String password, String code, String uuid) {
String verifyKey = Constants.CAPTCHA_CODE_TAG + uuid;
String verifyCode = redisCache.getCacheObject(verifyKey);
...省去驗證碼校驗邏輯,校驗失敗會丟擲異常
Authentication authenticate = null;
try {
// 認證 該方法會去呼叫UserDetailsServiceImpl.loadUserByUsername
authenticate = authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(username, password));
} catch (Exception e) {
if (e instanceof BadCredentialsException) {
throw new UserPasswordNotMatchException();
} else {
throw new CustomException(e.getMessage());
}
}
return tokenService.createToken((LoginUser) authenticate.getPrincipal());
}
}
3.1 首先進行驗證碼校驗邏輯,不通過時會丟擲異常
3.2 通過 AuthenticationManager
的authenticate()
獲取認證資訊
下面來詳細分析一下 authenticationManager.authenticate()
的認證過程
在分析之前先來了解一下 UsernamePasswordAuthenticationToken
這個類,它擁有兩個構造方法
// 1、只有兩個引數的構造方法表示[當前沒有認證]
public UsernamePasswordAuthenticationToken(Object principal, Object credentials)
// 2、擁有三個引數的構造方法表示[當前已經認證完畢]
public UsernamePasswordAuthenticationToken(Object principal, Object credentials, Collection<? extends GrantedAuthority> authorities)
下面開始分析認證過程
1)上面的AuthenticationManager呼叫了authenticate()方法,深入進去發現ProviderManger
實現了AuthenticationMananger
介面,然後我們檢視authenticate()方法內部。
2)然後遍歷所有的 AuthenticProvider,其中的supports
方法,返回時一個boolean值,引數是一個Class,就是根據Token的類來確定用什麼Provider來處理
而原始碼中的toTest類,就是我們認證傳遞的 UsernamePasswordAuthenticationToken
,而他對應的provider就是AbstractUserDetailsAuthenticationProvider
AbstractUserDetailsAuthenticationProvider.java
@Override
public boolean supports(Class<?> authentication) {
return (UsernamePasswordAuthenticationToken.class.isAssignableFrom(authentication));
}
3)找到合適的Provider後,在本例中也即是AbstractUserDetailsAuthenticationProvider(抽象類),會呼叫provider 的authenticate 方法
4)從下面可以看到 retrieveUser 方法返回一個 UserDetails
5)接著深入,可以發現 DaoAuthenticationProvider 繼承了 AbstractUserDetailsAuthenticationProvider,所以DaoAuthenticationProvider 才是真正的實現類,他會呼叫 retrieveUser 方法,接著呼叫 loaderUserByUsername() 方法
看到 loaderUserByUsername(),應該就很熟悉了,因為這就是我們自己實現 UserDetailsService
介面,自定義的認證過程
6)接著看我們自定義的 UserDetailServiceImpl
@Service("userDetailsService")
public class UserDetailServiceImpl implements UserDetailsService {
...
@Autowired private ISysUserService userService;
@Autowired private SysPermissionService permissionService;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
SysUser user = userService.selectUserByUserName(username);
if (null == user) {
log.info("登入使用者:{} 不存在.", username);
throw new UsernameNotFoundException("登入使用者:" + username + " 不存在");
} else if (UserStatus.DELETED.getCode().equals(user.getDelFlag())) {
log.info("登入使用者:{} 已被刪除.", username);
throw new BaseException("對不起,您的賬號:" + username + " 已被刪除");
} else if (UserStatus.DISABLE.getCode().equals(user.getStatus())) {
log.info("登入使用者:{} 已被停用.", username);
throw new BaseException("對不起,您的賬號:" + username + " 已停用");
}
return createLoginUser(user);
}
public UserDetails createLoginUser(SysUser user) {
return new LoginUser(user, permissionService.getMenuPermission(user));
}
}
上面就是我們自己的認證邏輯。通過一個唯一標識查詢使用者,在這裡就是username,當所有校驗都通過後就會呼叫 createLoginUser 方法,裝填使用者擁有的許可權以及從資料庫中獲取的密碼,返回一個 LoginUser 物件,而這個物件實現了 UserDetails介面。(建立token的方法以及LoginUser.java可以參考文末的相關程式碼)
7)然後我們回看AbstractUserDetailsAuthenticationProvider 的 authenticate 方法
8)接著深入,可以發現createSuccessAuthentication
方法建立了一個UsernamePasswordAuthenticationToken,並且他的構造方法有三個引數,這表明這個token是已近認證過後的
4、至此認證已經結束,我再回到 loginService.login()
這個我們自己寫的方法內,上面分析的8個步驟,也就是呼叫 authenticationManager.authenticate()
的過程會返回一個 Authentication,然後就可以利用這個 Authentication 生成一個 token。
loginService.java
return tokenService.createToken((LoginUser) authenticate.getPrincipal());
5、接著回到前面第二步controller呼叫的 loginService.login()
,這時它已近拿到了 token ,於是將其返回到前端。前端收到相應後,就可以把這個token存在本地,以後每次訪問請求時都帶上這個token 資訊。
@PostMapping("login")
public AjaxResult login(String username, String password, String code, String uuid){
AjaxResult result = AjaxResult.success();
String token = loginService.login(username, password, code, uuid);
result.put(Constants.TOKEN, token);
return result;
}
請求認證
1、上面說到前端每次傳送請求都帶上這個 token,但是為啥帶上這個 token,SpringSecurity就會認為此次請求已近認證通過了呢,別忘了,因為之前配置 SecurityConfig,如下
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
...
// 過濾請求
http.authorizeRequests()
.antMatchers("/login", "/captchaImage").anonymous()
.anyRequest().authenticated();
// 新增JWT filter,後面馬上就講
http.addFilterBefore(authenticationTokenFilter,
UsernamePasswordAuthenticationFilter.class);
}
因為我們設定了放行 /login
請求,所以才沒遭受攔截,而其他請求都是要被攔截的
2、因此我們就要自定義一個JWT filter,使用 addFilterBefore
把它新增到過濾器列表中,下面來看我們寫的 JWT過濾器
JwtAuthenticationTokenFilter.java
@Component
public class JwtAuthenticationTokenFilter extends OncePerRequestFilter {
@Autowired
private TokenService tokenService;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException {
// 1)
LoginUser loginUser = tokenService.getLoginUser(request);
// 2)
if (ObjectUtil.isNotNull(loginUser) && ObjectUtil.isNull(SecurityUtils.getAuthentication()))
{
// 3.1)
tokenService.verifyToken(loginUser);
// 3.2)
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(loginUser, null, loginUser.getAuthorities());
authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authenticationToken);
}
chain.doFilter(request, response);
}
}
1)TokenService#getLoginUser() 是我們編寫的一個方法,可以從 request 中獲取 token,然後再解析成LoginUser,也就是我們之前編寫繼承了UserDetails的類
2)如果1)能解析成功,也就是loginUser不為空,就說明請求含帶了token認證資訊,又因為這是個前後端分離專案,SecurityUtils.getAuthentication()
肯定獲取不到當前請求的認證資訊
public static Authentication getAuthentication() {
return SecurityContextHolder.getContext().getAuthentication();
}
3)而沒有認證資訊,這次請求勢必會被攔截下來,所以我們要手動加上這個認證資訊
3.1)我們先重新整理一下token,也就是在redis快取中更新一下到期時間(重新整理token的方法可以參考文末的相關程式碼)
3.2)然後建立UsernamePasswordAuthenticationToken,注意這是個有三個 引數的構造方法,前面也說了,這代表已經經過認證,然後 SecurityContextHolder.getContext().setAuthentication();
設定一下認證資訊
這樣每次帶token的請求,都會有了認證資訊,也就不會被攔截了
3、
但是為了更深刻的瞭解,我們接下來具體分析一下流程。
再次之前先介紹一個類 FilterSecurityInterceptor
:是一個方法級的許可權過濾器,基本位於過濾鏈的最底部,下面來看看原始碼。
下面來打個斷點,檢視一下
這說明來到beforeInvocation
方法時我們前面編寫的jwtFilter已經被執行,認證資訊已近被手動新增過了
進入beforeInvocation()
裡面,由除錯資訊可以看到當前請求需要被認證
接著我們進入authenticateIfRequired
方法的內部
因為我們之前jwtfilter手動新增了認證資訊,所以authenticateIfRequired
就直接返回了authentication,
否則的還要進行authenticationManager.authenticate();
進行驗證,如果沒有之前jwtfilter手動新增認證資訊,那麼中途一定會丟擲異常,導致此次請求失敗被攔截
至此也沒啥好講了,filterInvocation.getChain().doFilter() 呼叫我們的後臺服務了
相關程式碼
TokenService.java
@Component
public class TokenService {
protected static final long MILLIS_SECOND = 1000;
protected static final long MILLIS_MINUTE = 60 * MILLIS_SECOND;
private static final long MILLIS_MINUTE_20 = 20 * 60 * 1000L;
// 令牌自定義標識
@Value("${token.header}")
private String header;
// 令牌祕鑰
@Value("${token.secret}")
private String secret;
// 令牌有效期(預設30分鐘)
@Value("${token.expireTime}")
private int expireTime;
@Autowired
private RedisCache redisCache;
/**
* 建立令牌
*
* @param loginUser 使用者資訊
* @return 令牌
*/
public String createToken(LoginUser loginUser) {
String token = IdUtil.fastUUID();
loginUser.setToken(token);
refreshToken(loginUser);
Map<String, Object> claims = new HashMap<>();
claims.put(Constants.LOGIN_TOKEN_KEY, token);
return createToken(claims);
}
/**
* 驗證令牌有效期,相差不足20分鐘,自動重新整理快取
*
* @param loginUser 登入使用者
* @return 令牌
*/
public void verifyToken(LoginUser loginUser) {
long expireTime = loginUser.getExpireTime();
long currentTime = System.currentTimeMillis();
if (expireTime - currentTime <= MILLIS_MINUTE_20) {
refreshToken(loginUser);
}
}
/**
* 重新整理令牌有效期
*
* @param loginUser 登入資訊
*/
public void refreshToken(LoginUser loginUser) {
loginUser.setLoginTime(System.currentTimeMillis());
loginUser.setExpireTime(loginUser.getLoginTime() + expireTime * MILLIS_MINUTE);
// 根據uuid將loginUser快取
String userKey = getTokenKey(loginUser.getToken());
redisCache.setCacheObject(userKey, loginUser, expireTime, TimeUnit.MINUTES);
}
/**
* 獲取 token 的 redis 鍵字首
*
* @param uuid
* @return
*/
private String getTokenKey(String uuid) {
return Constants.LOGIN_TOKEN_TAG + uuid;
}
/**
* 從資料宣告生成令牌
*
* @param claims 資料宣告
* @return 令牌
*/
private String createToken(Map<String, Object> claims) {
return Jwts.builder().setClaims(claims).signWith(SignatureAlgorithm.HS512, secret).compact();
}
/**
* 從令牌中獲取資料宣告
*
* @param token 令牌
* @return 資料宣告
*/
private Claims parseToken(String token) {
return Jwts.parser().setSigningKey(secret).parseClaimsJws(token).getBody();
}
/**
* 獲取使用者身份資訊
*
* @return 使用者資訊
*/
public LoginUser getLoginUser(HttpServletRequest request) {
// 獲取請求攜帶的令牌
String token = getRespToken(request);
if (StringUtils.isNotEmpty(token)) {
Claims claims = parseToken(token);
// 解析對應的許可權以及使用者資訊
String uuid = (String) claims.get(Constants.LOGIN_TOKEN_KEY);
String userKey = getTokenKey(uuid);
return redisCache.getCacheObject(userKey);
}
return null;
}
/**
* 獲取請求token
*
* @param request
* @return token
*/
private String getRespToken(HttpServletRequest request) {
String token = request.getHeader(this.header);
if (StringUtils.isNotEmpty(token) && token.startsWith(Constants.REQ_TOKEN_PREFIX)) {
token = token.replace(Constants.REQ_TOKEN_PREFIX, "");
}
return token;
}
}
LoginUser.java
public class LoginUser implements UserDetails {
private static final long serialVersionUID = 1821121071052157802L;
/** 使用者唯一標識 */
private String token;
/** 登陸時間 */
private Long loginTime;
/** 過期時間 */
private Long expireTime;
...
/** 許可權列表 */
private Set<String> permissions;
/** 使用者資訊 */
private SysUser user;
...
@Override
public String getPassword() {
return user.getPassword();
}
@Override
public String getUsername() {
return user.getUserName();
}
...
參考
https://www.cnblogs.com/ymstars/p/10626786.html