最近在学习spring security,看到网上的都是比较老的版本,所以从github上找了一个开源的最新的spring security6 的demo借鉴,然后结合官网文档自己鼓捣了一个demo出来,做个笔记方便以后忘记怎么搞了来看看,哈哈哈OVO。(本项目用的是jdk17,有些表达式jdk8是无法编译的,所以要跑起来的话记得安装17哦,反正现在也免费了)
因为文章是我的一些理解,如果有错误的话请狠狠喷
maven配置
先贴上pom.xml,项目中采用sprngboot3.0.5版本,然后是老n样,myabits-plus,redis,lombok,hutool等老朋友了
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.0.5</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<properties>
<java.version>17</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>redis.clients</groupId>
<artifactId>jedis</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- redis依赖start-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>redis.clients</groupId>
<artifactId>jedis</artifactId>
</dependency>
<dependency>
<groupId>io.lettuce</groupId>
<artifactId>lettuce-core</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.22</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>5.8.16</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.11.5</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.11.5</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.11.5</version>
</dependency>
<!-- https://mvnrepository.com/artifact/com.baomidou/mybatis-plus-boot-starter -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.5.3</version>
</dependency>
<!-- Mysql驱动包 -->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>8.0.30</version>
</dependency>
</dependencies>
授权核心类配置
spring security的核心配置,包含了自定义的鉴权
@Configuration
@RequiredArgsConstructor
public class AuthConfig {
/**
* 自定义的权限读取类
*/
private final MyUserDetailService myUserDetailService;
/**
* springsecurity权限校验提供类
*
* @return
*/
@Bean
public AuthenticationProvider authenticationProvider() {
DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider();
//使用自定义的用户校验
authProvider.setUserDetailsService(myUserDetailService);
authProvider.setPasswordEncoder(passwordEncoder());
return authProvider;
}
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception {
return config.getAuthenticationManager();
}
/**
* 密码加密
*
* @return
*/
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
MyUserDetailService
自定义的权限读取类,后面会具体讲到
AuthenticationProvider
提供权限校验方法类,把上面的MyUserDetailService类和PasswordEncoder 类作为入参,实现我们的自定义权限校验
AuthenticationManager
鉴权工具的管理bean,用默认提供的就行
PasswordEncoder
使用BCryptPasswordEncoder作为我们密码加密的方法
用户详细权限获取类
MyUserDetailService是我们实现自定义权限认证的核心方法之一,通过实现UserDetailsService的loadUserByUsername方法实现获取用户拥有的详细权限,然后springsecurity框架会帮助我们对需要拦截的请求进行权限校验
在这之前我们先要创建我们的一个用户类,因为是demo所以我们只要最简单的几个字段,
id,username,password,然后实现UserDetails接口,UserDetails是springsecurity框架的授权用户实体,我们需要继承这个接口才能让框架去为我们实现权限校验
这两个类都需要去继承框架的接口,这样才能让框架帮我们管理
##class MyUserDetailService
@RequiredArgsConstructor
@Service
@Slf4j
public class MyUserDetailService implements UserDetailsService, UserDetailsPasswordService {
//获取当前用户的方法,使用框架的上下文获取当前请求的用户
public static Authentication getCurrentUser() {
return SecurityContextHolder.getContext().getAuthentication();
}
//刷新密码,如果用户更改密码了会重新刷新token或者退出登录,我也没用过,大家可以试一下
@Override
public UserDetails updatePassword(UserDetails user, String newPassword) {
return null;
}
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
log.info("进入查找用户的方法");
UserDO userDO = new UserDO().selectOne(new LambdaQueryWrapper<UserDO>().eq(UserDO::getUsername, username));
if (userDO != null) {
//从数据库中获取用户,真实环境下需要获取用户拥有的权限,现在我们就先写死
Set<SimpleGrantedAuthority> permissions = Set.of("admin:read", "admin:create", "admin:update", "admin:delete").stream().map(permission -> new SimpleGrantedAuthority(permission)).collect(Collectors.toSet());
//返回生成的用户
var sysUser = new SysUser().setId(1).setPassword(userDO.getPassword()).setUsername(username).setAuthorities(permissions);
return sysUser;
}
return null;
}
}
##class SysUser
@Data
@NoArgsConstructor
@AllArgsConstructor
@Accessors(chain = true)
public class SysUser implements UserDetails, Serializable {
private Integer id;
private String username;
private String password;
//该用户所拥有的权限,如果细分为角色和权限,可以把两个都放到这个集合里面,比如ROLE_ADMIN,admin:user:create可以同时存入
private Collection<? extends GrantedAuthority> authorities;
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return this.authorities;
}
@Override
public String getPassword() {
return this.password;
}
@Override
public String getUsername() {
return this.username;
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
}
JWT加解密方法
JWT (全称:Json Web Token)是一个开放标准(RFC 7519),它定义了一种紧凑的、自包含的方式,用于作为 JSON 对象在各方之间安全地传输信息。该信息可以被验证和信任,因为它是数字签名的。因为在分布式架构下,原来的session方式不能共享,所以引入一种新的方式,开发者可以将在每个服务间传递的信息(比如用户的id,name,权限集合等)通过加密生成一个token,然后在服务端解析出这个token就可以获取到这个请求携带的用户的信息
我们可以通过JwtService类来对token进行加解密
##application.yml
application:
security:
jwt:
secret-key: 404E635266556A586E3272357538782F413F4428472B4B6250645367566B5970
expiration: 86400000 # a day
refresh-token:
expiration: 604800000 # 7 days
## class JwtService
@Service
public class JwtService {
@Value("${application.security.jwt.secret-key}")
private String secretKey;
@Value("${application.security.jwt.expiration}")
private long jwtExpiration;
@Value("${application.security.jwt.refresh-token.expiration}")
private long refreshExpiration;
public String extractUsername(String token) {
return extractClaim(token, Claims::getSubject);
}
public <T> T extractClaim(String token, Function<Claims, T> claimsResolver) {
final Claims claims = extractAllClaims(token);
return claimsResolver.apply(claims);
}
public String generateToken(UserDetails userDetails) {
return generateToken(new HashMap<>(), userDetails);
}
public String generateToken(
Map<String, Object> extraClaims,
UserDetails userDetails
) {
return buildToken(extraClaims, userDetails, jwtExpiration);
}
public String generateRefreshToken(
UserDetails userDetails
) {
return buildToken(new HashMap<>(), userDetails, refreshExpiration);
}
private String buildToken(
Map<String, Object> extraClaims,
UserDetails userDetails,
long expiration
) {
return Jwts
.builder()
.setClaims(extraClaims)
.setSubject(userDetails.getUsername())
.setIssuedAt(new Date(System.currentTimeMillis()))
.setExpiration(new Date(System.currentTimeMillis() + expiration))
.signWith(getSignInKey(), SignatureAlgorithm.HS256)
.compact();
}
public boolean isTokenValid(String token, SysUser sysUser) {
final String username = extractUsername(token);
return (username.equals(sysUser.getUsername())) && !isTokenExpired(token);
}
private boolean isTokenExpired(String token) {
return extractExpiration(token).before(new Date());
}
private Date extractExpiration(String token) {
return extractClaim(token, Claims::getExpiration);
}
private Claims extractAllClaims(String token) {
return Jwts
.parserBuilder()
.setSigningKey(getSignInKey())
.build()
.parseClaimsJws(token)
.getBody();
}
private Key getSignInKey() {
byte[] keyBytes = Decoders.BASE64.decode(secretKey);
return Keys.hmacShaKeyFor(keyBytes);
}
}
jwt token校验过滤器
然后是最关键的类之一,在spring框架下jwt基本都是通过过滤器方式去校验的,因为实现的方式非常简单,只要继承spring自带的filter就可以了,下面代码中每一步动作都有注释
@Component
@RequiredArgsConstructor
@Slf4j
public class JwtFilter extends OncePerRequestFilter {
private final MyUserDetailService myUserDetailService;
private final JwtService jwtService;
private final StringRedisTemplate stringRedisTemplate;
@Override
protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, FilterChain filterChain) throws ServletException, IOException, TokenVerifyErrorException, NoTokenException {
//如果是登录接口就自动放行
if (req.getServletPath().contains("/api/v1/auth/login")) {
filterChain.doFilter(req, res);
return;
}
//从header中获取token
final String authHeader = req.getHeader("Authorization");
final String jwt;
String username = null;
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
throw new NoTokenException();
}
//截取有效的token字符串
jwt = authHeader.substring(7);
//解析jwt获取用户名
try {
username = jwtService.extractUsername(jwt);
} catch (Exception e) {
log.error("token格式异常:{}", e);
throw new TokenVerifyErrorException();
}
//如果当前请求没有带用户的话就去查询用户并授权
if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
SysUser sysUser = (SysUser) myUserDetailService.loadUserByUsername(username);
boolean isTokenValid = false;
//校验token准确性和是否过期,此处可以自定义校验,可以使用jwt自带的校验也可以通过数据库和redis等共同判断
String userInfo= (String) stringRedisTemplate.opsForValue().get(StrUtil.format(OauthTestApplication.REDIS_TOKEN_KEY,jwt));
if(StrUtil.isNotBlank(userInfo))isTokenValid=true;
if (sysUser != null && jwtService.isTokenValid(jwt, sysUser) && isTokenValid) {
/**
* 封装成用户的凭证,传入用户实体类和用户的权限集合,spring security会自动去判断是否拥有权限
*/
UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(
sysUser,
null,
sysUser.getAuthorities()
);
/**
* 在 Spring Security 中,当用户进行认证时,会先通过 AuthenticationFilter 进行请求过滤,
* 然后调用 AuthenticationManager 进行认证。在认证过程中,Spring Security 会自动创建一个
* Authentication 对象,其中包含了认证请求的相关信息。如果你需要自定义 Authentication
* 对象的详细信息,就可以使用 WebAuthenticationDetailsSource 来创建 WebAuthenticationDetails 对象。
*/
authToken.setDetails(
new WebAuthenticationDetailsSource().buildDetails(req)
);
SecurityContextHolder.getContext().setAuthentication(authToken);
}
}
filterChain.doFilter(req, res);
}
}
SecurityConfig
然后是开启springsecurity的配置
##class SecurityConfig
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
@EnableMethodSecurity
public class SecurityConfig {
/**
* AuthConfig中自定义的bean
*/
private final AuthenticationProvider authenticationProvider;
private final JwtFilter jwtFilter;
private final LogoutService logoutService;
private final MyAccessDeniedHandler myAccessDeniedHandler;
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.csrf()//关闭session
.disable()
.authorizeHttpRequests()
.requestMatchers("/api/v1/auth/**")// 放行/api/v1/auth/下的所有请求,主要是登录登出
.permitAll()
.anyRequest()
.authenticated()// 剩下的请求都需要拦截
.and()
.exceptionHandling()
.accessDeniedHandler(myAccessDeniedHandler) // 配置无权访问的处理
.and()
.sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.authenticationProvider(authenticationProvider)// 使用我们自定义的权限查询校验方法
.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class) // 配置请求拦截
.logout()
.logoutUrl("/api/v1/auth/logout") // 配置登出路径
.addLogoutHandler(logoutService)
.logoutSuccessHandler((request, response, authentication) -> SecurityContextHolder.clearContext()) // 登出后清楚请求的上下文,也就是用户信息
;
return http.build();
}
}
##class LogoutService
/**
* 登出具体实现类
*/
@Component
public class LogoutService implements LogoutHandler {
/**
* 登出实现
* @param request
* @param response
* @param authentication
*/
@Override
public void logout(HttpServletRequest request, HttpServletResponse response, Authentication authentication) {
//先获取token
final String authHeader = request.getHeader("Authorization");
final String jwt;
if (authHeader == null ||!authHeader.startsWith("Bearer ")) {
return;
}
jwt = authHeader.substring(7);
//从db或者redis中获取token并且对比
TokenDO tokenDO = new TokenDO().selectOne(new LambdaQueryWrapper<TokenDO>().eq(TokenDO::getToken, jwt));
if (tokenDO != null) {
tokenDO.setExpired(true);
tokenDO.setRevoked(true);
tokenDO.insertOrUpdate();
SecurityContextHolder.clearContext();
}
}
}
##class MyAccessDeniedHandler
/**
* 自定义无权访问处理类
*/
@Component
public class MyAccessDeniedHandler implements AccessDeniedHandler {
@Override
@SneakyThrows(UnAuthorizationException.class)
public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) {
//这里是想直接抛出一个无权访问的异常然后全局捕捉,但是不知道为什么没有捕捉到
throw new UnAuthorizationException();
}
}
到这里springsecurity相关的一些配置都已经完成,现在开始我们的登录模块
用户相关类
因为用的是mybaits-plus,所以简单的crud不需要写sql了
用户实体类,同时需要写一个mapper来对应实体类
## class UserDO
@Data
@TableName("sys_user")
@EqualsAndHashCode(callSuper = true)
public class UserDO extends Model<UserDO> {
@TableId(type = IdType.AUTO)
private Integer id;
private String username;
private String password;
private String name;
}
## class UserMapper
@Repository
public interface UserMapper extends BaseMapper<UserDO> {
}
用户业务处理
## class UserService
public interface UserService {
AjaxResult login(String username,String password);
}
## class UserServiceImpl
@Service
@RequiredArgsConstructor
public class UserServiceImpl implements UserService {
private final PasswordEncoder passwordEncoder;
private final JwtService jwtService;
private final StringRedisTemplate stringRedisTemplate;
private final int tokenTimeOut=60*1000;
/**
* 用户登录
*
* @param username
* @param password
* @return
*/
@Override
public AjaxResult login(String username, String password) {
//简单测试security内容暂时不考虑校验用户名密码复杂度等
UserDO userDO = new UserDO().selectOne(new LambdaQueryWrapper<UserDO>().eq(UserDO::getUsername, username));
//校验密码是否正确
if (passwordEncoder.matches(password, userDO.getPassword())) {
//用户校验成功,从数据库中查询出用户的权限,现在为了方便测试直接写死
Set<SimpleGrantedAuthority> permissions = Set.of("admin:read", "admin:create", "admin:update", "admin:delete").stream().map(permission -> new SimpleGrantedAuthority(permission)).collect(Collectors.toSet());
//生成用户的token
var sysUser = new SysUser().setId(1).setPassword(userDO.getPassword()).setUsername(username).setAuthorities(permissions);
var accssToken = jwtService.generateToken(sysUser);
var refreshToken = jwtService.generateRefreshToken(sysUser);
//此处有两个选择,可以把token存到db或者redis中,这里方便演示两个都保存
new TokenDO().setToken(accssToken).setUserId(userDO.getId()).setRevoked(false).setExpired(false).insert();
//可以配合jwt设置一个超时时间双重判断是否过期,也可以作为手动使token失效的功能
stringRedisTemplate.opsForValue().set(StrUtil.format(OauthTestApplication.REDIS_TOKEN_KEY,accssToken), JSONUtil.toJsonStr(sysUser));
//返回生成的两个token
return AjaxResult.success(TokenResponse.builder().accessToken(accssToken).refreshToken(refreshToken).build());
}
return AjaxResult.error();
}
}
最后是我们的接口控制层,在springsecurity中我们试用
@PreAuthorize注解来控制该接口的权限
@PreAuthorize("hasAuthority('admin:create')")标识拥有admin:create权限的用户才可以访问
@RestController
@RequiredArgsConstructor
public class TestController {
private final UserService userService;
private final PasswordEncoder passwordEncoder;
@PostMapping("/api/v1/auth/login")
public AjaxResult login(String username, String password) {
String encode = passwordEncoder.encode("123456");
return userService.login(username, password);
}
@GetMapping("/api/v1/testAuth")
@PreAuthorize("hasAuthority('admin:create')")
public AjaxResult testAuth() {
System.out.println("当前用户为:" + MyUserDetailService.getCurrentUser().getPrincipal());
return AjaxResult.success("本接口你有权限");
}
@GetMapping("/api/v1/testUnAuth")
@PreAuthorize("hasAuthority('admin:noauth')")
public AjaxResult testUnAuth() {
System.out.println("当前用户为:" + MyUserDetailService.getCurrentUser().getPrincipal());
return AjaxResult.success("本接口你没有权限");
}
@GetMapping("/api/v1/testNoAuth")
public AjaxResult testNoAuth() {
System.out.println("当前用户为:" + MyUserDetailService.getCurrentUser().getPrincipal());
return AjaxResult.success("本接口不需要权限");
}
@RequestMapping("/pass")
public void pass(String password) {
System.out.println("生成密码:"+passwordEncoder.encode(password));
}
}
这里有一些代码没有贴出来,包括token实体类,sql文件等,大家可以去github上拿 GitHub - hepeike001/spring-security-demo
启动成功,使用默认的8080端口
首先不登录测试我们的三个接口
可以看到3个接口都没有返回,状态码都是403 ,这里有个bug是我使用了全局异常捕捉,应该是要捕捉到我自定义的NoTokenException异常的,但是这里失败了,我怀疑可能是跟springsecurity框架的机制冲突了,具体原因还要再找找
然后我们进行登录操作
成功获取到token了,在redis中也能看到我们的token信息
然后携带获取到的token再进行调用吧
此处也是跟之前一样的问题,没有捕捉到没有权限的异常,其余的接口都调用成功,至此整个流程都完整走完了。