版本
- SpringBoot:2.2.5.RELEASE
- jjwt:0.9.0
- Jdk:1.8
- Maven:3.5.2
- Idea:2019.3
依賴
項目pom.xml文件中引入Spring Security和Jwt的依賴座標
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.0</version>
</dependency>
準備
整合Redis,將登錄用戶信息緩存進Redis,整合參考鏈接部分
食用
0:配置Spring Security
這是Spring Security的參考配置項,主要包括以下內容:URL攔截、匿名用戶訪問無權限資源處理器(AuthenticationEntryPointHandler)、登出處理器(LogoutSuccessHandler)、登出URL、過濾器(TokenFilter)、UserDetailsService實現類等
- 指定不同URL訪問權限
- 指定密碼加密方式
- 指定訪問無權資源處理器
- 指定登出URL及處理器
- 指定UserDetailsService實現
- 指定自定義過濾器
import com.liu.gardenia.security.security.filter.TokenFilter;
import com.liu.gardenia.security.security.handler.AuthenticationEntryPointHandler;
import com.liu.gardenia.security.security.handler.MyLogoutSuccessHandler;
import com.liu.gardenia.security.security.service.UserDetailsServiceImpl;
import org.springframework.context.annotation.Bean;
import org.springframework.http.HttpMethod;
import org.springframework.security.authentication.AuthenticationManager;
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.WebSecurityConfigurerAdapter;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
/**
* Spring Security配置
*
* @author liujiazhong
*/
@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {
private final AuthenticationEntryPointHandler unauthorizedHandler;
private final MyLogoutSuccessHandler logoutSuccessHandler;
private final TokenFilter tokenFilter;
public SecurityConfig(AuthenticationEntryPointHandler unauthorizedHandler, MyLogoutSuccessHandler logoutSuccessHandler,
TokenFilter tokenFilter) {
this.unauthorizedHandler = unauthorizedHandler;
this.logoutSuccessHandler = logoutSuccessHandler;
this.tokenFilter = tokenFilter;
}
@Bean
@Override
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
@Bean
@Override
public UserDetailsService userDetailsService() {
return new UserDetailsServiceImpl();
}
@Bean
public BCryptPasswordEncoder bCryptPasswordEncoder() {
return new BCryptPasswordEncoder();
}
/**
* hasRole 如果有參數,參數表示角色,則其角色可以訪問
* hasAnyRole 如果有參數,參數表示角色,則其中任何一個角色可以訪問
* hasAuthority 如果有參數,參數表示權限,則其權限可以訪問
* hasAnyAuthority 如果有參數,參數表示權限,則其中任何一個權限可以訪問
* hasIpAddress 如果有參數,參數表示IP地址,如果用戶IP和參數匹配,則可以訪問
* permitAll 用戶可以任意訪問
* anonymous 匿名可以訪問
* rememberMe 允許通過remember-me登錄的用戶訪問
* denyAll 用戶不能訪問
* authenticated 用戶登錄後可訪問
* fullyAuthenticated 用戶完全認證可以訪問(非remember-me下自動登錄)
* access SpringEl表達式結果爲true時可以訪問
* anyRequest 匹配所有請求路徑
*/
@Override
protected void configure(HttpSecurity httpSecurity) throws Exception {
httpSecurity
.csrf().disable()
.exceptionHandling().authenticationEntryPoint(unauthorizedHandler).and()
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS).and()
.authorizeRequests()
.antMatchers("/api/user/login").anonymous()
.antMatchers(
HttpMethod.GET,
"/*.html",
"/**/*.html",
"/**/*.css",
"/**/*.js",
"/**/*.ico"
).permitAll()
.antMatchers("/swagger-ui.html").anonymous()
.antMatchers("/swagger-resources/**").anonymous()
.anyRequest().authenticated()
.and()
.headers().frameOptions().disable();
httpSecurity.logout().logoutUrl("/api/user/logout").logoutSuccessHandler(logoutSuccessHandler);
httpSecurity.addFilterBefore(tokenFilter, UsernamePasswordAuthenticationFilter.class);
}
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(userDetailsService()).passwordEncoder(bCryptPasswordEncoder());
}
}
1:實現匿名用戶訪問無權限資源異常處理器
無權訪問時可以根據自己業務需求做相應操作,例如拋出異常、返回提示信息給前端、打印日誌等
/**
* @author liujiazhong
*/
@Slf4j
@Component
public class AuthenticationEntryPointHandler implements AuthenticationEntryPoint, Serializable {
private static final long serialVersionUID = 1L;
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException e) {
log.warn("認證失敗,無法訪問系統資源:{}", request.getRequestURI());
ServletUtils.renderString(response, "無權訪問");
}
}
2:實現登出處理器
實現登出時的相關操作,例如從redis中移除緩存的登陸用戶信息、把登出成功的提示信息返回給前端等
/**
* @author liujiazhong
*/
@Slf4j
@Component
public class MyLogoutSuccessHandler implements LogoutSuccessHandler {
private final TokenService tokenService;
public MyLogoutSuccessHandler(TokenService tokenService) {
this.tokenService = tokenService;
}
@Override
public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) {
UserInfo userInfo = tokenService.getUserInfo(request);
if (Objects.nonNull(userInfo)) {
tokenService.removeUserInfo(userInfo.getUuid());
}
ServletUtils.renderString(response, "logout success.");
}
}
3:自定義過濾器進行授權操作
從Redis緩存中取出用戶信息,生成Spring Security身份認證令牌放入Security上下文中,這裏可以選擇不同的授權方式
/**
* @author liujiazhong
*/
@Slf4j
@Component
public class TokenFilter extends OncePerRequestFilter {
private final TokenService tokenService;
public TokenFilter(TokenService tokenService) {
this.tokenService = tokenService;
}
@Override
protected void doFilterInternal(@NonNull HttpServletRequest request, @NonNull HttpServletResponse response, @NonNull FilterChain chain)
throws IOException, ServletException {
log.info("into token filter...");
UserInfo userInfo = tokenService.getUserInfo(request);
if (Objects.nonNull(userInfo) && Objects.isNull(SecurityUtils.getAuthentication())) {
tokenService.verifyToken(userInfo);
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(userInfo, null, userInfo.getAuthorities());
authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authenticationToken);
}
chain.doFilter(request, response);
}
}
4:準備登錄用戶信息實體
這裏需要實現Spring Security中的UserDetails接口,重寫接口中的一些方法,比如指定用戶名和密碼等,可以根據業務情況告訴Security當前賬戶是否過期、是否鎖定、是否禁用等信息,還可以直接通過getAuthorities()方法把該賬號對應的角色和權限返回給Security,也可以自己手動實現權限驗證,我這裏選擇手動實現
/**
* @author liujiazhong
*/
@Getter
@Setter
public class UserInfo implements UserDetails {
private Long userId;
private String username;
private String password;
private Set<String> permissions;
private String uuid;
private Long loginTime;
private Long expireTime;
@JsonIgnore
@Override
public String getPassword() {
return password;
}
@Override
public String getUsername() {
return username;
}
@JsonIgnore
@Override
public boolean isAccountNonExpired() {
return true;
}
@JsonIgnore
@Override
public boolean isAccountNonLocked() {
return true;
}
@JsonIgnore
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@JsonIgnore
@Override
public boolean isEnabled() {
return true;
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return null;
}
}
5:實現UserDetailsService
這裏重寫接口中的loadUserByUsername()方法,從數據庫查詢到用戶信息和該用戶對應的權限列表,返回UserInfo
/**
* @author liujiazhong
*/
@Slf4j
@Service
public class UserDetailsServiceImpl implements UserDetailsService {
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
UserInfo userInfo = userInfo(username);
if (Objects.isNull(userInfo)) {
throw new RuntimeException("user not found.");
}
// todo check user: status...
return userInfo;
}
private UserInfo userInfo(String username) {
// todo find userInfo from mysql
UserInfo userInfo = null;
if (Objects.equals("liu", username)) {
userInfo = new UserInfo();
userInfo.setUserId(1001L);
userInfo.setUsername("liu");
userInfo.setPassword(SecurityUtils.encryptPassword("1111"));
userInfo.setPermissions(userPermissionByUserId(userInfo.getUserId()));
}
return userInfo;
}
private Set<String> userPermissionByUserId(Long userId) {
// todo find permissions from mysql
Set<String> permissions = new HashSet<>(1);
permissions.add("*:*:*");
return permissions;
}
}
6:Jwt相關
這裏涉及到了Token的生成與解析,用戶信息的緩存等操作,RedisCache的實現參考連接部分整合Redis
/**
* token驗證處理
*
* @author liujiazhong
*/
@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_TEN = 20 * 60 * 1000L;
private final RedisCache redisCache;
private final TokenConfig tokenConfig;
public TokenService(RedisCache redisCache, TokenConfig tokenConfig) {
this.redisCache = redisCache;
this.tokenConfig = tokenConfig;
}
public UserInfo getUserInfo(HttpServletRequest request) {
String token = getToken(request);
if (StringUtils.isEmpty(token)) {
return null;
}
Claims claims = parseToken(token);
String uuid = (String) claims.get(Constants.LOGIN_USER_KEY);
String userKey = getTokenKey(uuid);
Object value = redisCache.getCacheObject(userKey);
if (Objects.isNull(value)) {
return null;
}
if (value instanceof UserInfo) {
return (UserInfo) value;
}
throw new RuntimeException("UserInfo Cache Type Error.");
}
public void setUserInfo(UserInfo userInfo) {
if (Objects.nonNull(userInfo) && StringUtils.isNotEmpty(userInfo.getUuid())) {
refreshToken(userInfo);
}
}
public void removeUserInfo(String uuid) {
if (StringUtils.isNotEmpty(uuid)) {
String userKey = getTokenKey(uuid);
redisCache.deleteObject(userKey);
}
}
public String createToken(UserInfo userInfo) {
String uuid = IdUtils.uuid();
userInfo.setUuid(uuid);
refreshToken(userInfo);
Map<String, Object> claims = new HashMap<>(1);
claims.put(Constants.LOGIN_USER_KEY, uuid);
return Jwts.builder().setClaims(claims).signWith(SignatureAlgorithm.HS512, tokenConfig.getSecret()).compact();
}
public void verifyToken(UserInfo userInfo) {
long expireTime = userInfo.getExpireTime();
long currentTime = System.currentTimeMillis();
if (expireTime - currentTime <= MILLIS_MINUTE_TEN) {
refreshToken(userInfo);
}
}
public void refreshToken(UserInfo userInfo) {
userInfo.setLoginTime(System.currentTimeMillis());
userInfo.setExpireTime(userInfo.getLoginTime() + tokenConfig.getExpireTime() * MILLIS_MINUTE);
String userKey = getTokenKey(userInfo.getUuid());
redisCache.setCacheObject(userKey, userInfo, tokenConfig.getExpireTime(), TimeUnit.MINUTES);
}
public String getUsernameFromToken(String token) {
Claims claims = parseToken(token);
return claims.getSubject();
}
private Claims parseToken(String token) {
return Jwts.parser().setSigningKey(tokenConfig.getSecret()).parseClaimsJws(token).getBody();
}
private String getToken(HttpServletRequest request) {
String token = request.getHeader(tokenConfig.getHeader());
if (StringUtils.isNotEmpty(token) && token.startsWith(Constants.TOKEN_PREFIX)) {
token = token.replace(Constants.TOKEN_PREFIX, "");
}
return token;
}
private String getTokenKey(String uuid) {
return Constants.LOGIN_TOKEN_KEY + uuid;
}
}
7:鑑權
鑑權操作的實現,從緩存中獲取到登錄用戶信息,判定當前用戶擁有的權限中是否包含該資源的權限
/**
* @author liujiazhong
*/
@Slf4j
@Service("ps")
public class PermissionServiceImpl {
private static final String ALL_PERMISSION = "*:*:*";
private final TokenService tokenService;
public PermissionServiceImpl(TokenService tokenService) {
this.tokenService = tokenService;
}
public boolean hasPermission(String permission) {
if (StringUtils.isBlank(permission)) {
return false;
}
UserInfo info = tokenService.getUserInfo(ServletUtils.getRequest());
if (Objects.isNull(info) || CollectionUtils.isEmpty(info.getPermissions())) {
return false;
}
return check(info.getPermissions(), permission);
}
private boolean check(Set<String> permissions, String permission) {
return permissions.contains(ALL_PERMISSION) || permissions.contains(StringUtils.trim(permission));
}
}
8:給資源加權限
使用@PreAuthorize註解標記資源,“ps”爲步驟7中的鑑權實現類,“hasPermission”爲鑑權方法,“gardenia:demo:info”爲自定義的資源權限
/**
* @author liujiazhong
*/
@RestController
@RequestMapping("api/demo")
public class DemoController {
@GetMapping("hello")
public String hello() {
return "hello";
}
@PreAuthorize("@ps.hasPermission('gardenia:demo:info')")
@GetMapping("info")
public String info() {
return "liu";
}
}
9:登錄
認證通過後直接返回Token
/**
* @author liujiazhong
*/
@Slf4j
@Service
public class UserLoginServiceImpl implements UserLoginService {
private final TokenService tokenService;
private final AuthenticationManager authenticationManager;
public UserLoginServiceImpl(TokenService tokenService, AuthenticationManager authenticationManager) {
this.tokenService = tokenService;
this.authenticationManager = authenticationManager;
}
@Override
public String login(String username, String password) {
Authentication authentication;
try {
authentication = authenticationManager.authenticate(new UsernamePasswordAuthenticationToken(username, password));
} catch (Exception e) {
if (e instanceof BadCredentialsException) {
throw new PasswordException();
} else {
throw new RuntimeException(e.getMessage());
}
}
UserInfo userInfo = (UserInfo) authentication.getPrincipal();
return tokenService.createToken(userInfo);
}
}
補充
補充上文中使用到的幾個自定義工具類
IdUtils
public class IdUtils {
public static String uuid() {
return UUID.randomUUID().toString();
}
}
SecurityUtils
public class SecurityUtils {
public static Authentication getAuthentication() {
return SecurityContextHolder.getContext().getAuthentication();
}
public static String encryptPassword(String password) {
BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
return passwordEncoder.encode(password);
}
public static boolean validPassword(String password, String encodedPassword) {
BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
return passwordEncoder.matches(password, encodedPassword);
}
}
ServletUtils
@Slf4j
public class ServletUtils {
public static HttpServletRequest getRequest() {
return getRequestAttributes().getRequest();
}
public static ServletRequestAttributes getRequestAttributes() {
RequestAttributes attributes = RequestContextHolder.getRequestAttributes();
return (ServletRequestAttributes) attributes;
}
public static void renderString(HttpServletResponse response, String string) {
try {
response.setStatus(200);
response.setContentType("application/json");
response.setCharacterEncoding("utf-8");
response.getWriter().print(string);
} catch (Exception e) {
log.error("ServletUtils.renderString exception...", e);
}
}
}
鏈接
SpringBoot整合Spring Data Redis:https://blog.csdn.net/momo57l/article/details/105427898
Spring Security:https://spring.io/projects/spring-security#overview
CSRF:https://docs.spring.io/spring-security/site/docs/5.3.2.BUILD-SNAPSHOT/reference/html5/#csrf
JWT:https://jwt.io/