项目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实现类等
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);
}
}
}
TokenConfig
/**
* @author liujiazhong
*/
@Data
@Component
@ConfigurationProperties("gardenia.security.token")
public class TokenConfig {
private String header;
private String secret;
private Integer expireTime;
}
gardenia:
security:
token:
header: Authorization
secret: secret
expire-time: 30
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/