SpringSecurity
本篇文章基于自定义的User表来做用户登陆校验与权限访问。 并且基于SpringBoot三层框架来实现。包含使用JWT来做登陆验证和身份权限验证,控制路由资源访问。
因为看官方文档不是很看的懂,并且整套配置流程也比较复杂,在github上又都是一些复杂的项目,代码很难看懂,所以专门写一个文档出来做流程记录。
自定义user表如下
kotlin
package yellow.iblog.model;
import com.baomidou.mybatisplus.annotation.*;
import lombok.AllArgsConstructor;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@TableName("users")
@AllArgsConstructor
public class User {
@TableId(type= IdType.ASSIGN_ID)
private Long uid;
@TableField("user_name")
private String userName;
@TableField("gender")
private Character gender;
@TableField("age")
private Integer age;
@TableField("password") //存储hash值,需要长一点
private String password;
@TableField("role")
private String role;//集成SpringSecurity,值以ROLE_开头(ROLE_USER,ROLE_ADMIN)
// 新增创建时间字段
@TableField(value = "created_at",fill=FieldFill.INSERT)
private LocalDateTime createdAt;
// 新增更新时间字段
@TableField(value="updated_at",fill=FieldFill.INSERT_UPDATE)
private LocalDateTime updatedAt;
//构造函数
public User( String userName, Character gender, Integer age, String password) {
this.userName = userName;
this.gender = gender;
this.age = age;
this.password = password;
this.role="ROLE_USER";
}
public User(String userName, Character gender, Integer age) {
this.userName = userName;
this.gender = gender;
this.age = age;
this.role="ROLE_USER";
}
public User(){}
}
SpringSecurity的作用:
1.身份验证(登录/未登录)
2.授权(控制谁可以访问哪些资源)普通用户、VIP、admin
3.防止攻击
做法
1.引入依赖
xml
<!-- JWT-->
<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>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.11.5</version>
<scope>runtime</scope>
</dependency>
<!-- Security:哈希加密、认证授权 添加这个依赖 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
2.User里面添加role字段
并且只能以ROLE_USER的格式赋值(SpringSecurity的规定)
并且UserName要设置为unique,不然就要用手机号或者邮箱登陆,邮箱还好搞,手机号要上阿里云或其它云那里买服务
默认值设置为ROLE_USER
ini
@TableField("role")
private String role;//集成SpringSecurity,值以ROLE_开头(ROLE_USER,ROLE_ADMIN)
//构造函数
public User( String userName, Character gender, Integer age, String password) {
this.userName = userName;
this.gender = gender;
this.age = age;
this.password = password;
this.role="ROLE_USER";
}
public User(String userName, Character gender, Integer age) {
this.userName = userName;
this.gender = gender;
this.age = age;
this.role="ROLE_USER";
}
问题来了:既然已经有role字段,为什么不在校验的时候直接接收前端发过来的role做校验?为什么还要搞一个jwt来读取校验?
---因为前端发过来的话容易被篡改,并且发请求谁不会啊,一下就给你发个修改Role的请求,给自己整成管理员了,然后全部用户都删光。这样不安全。从上下文读取的话,就会安全,不容易被篡改了。
3.集成JWT环境
因为权限认证从JWT字符串里面解析出role的值,然后再做校验,所以要先搞好JWT
(1)新建一个jwt包,并在里面写jwt的工具类
我的项目比较小,所以单独开了一个jwt包,规范一点可以写在工具类包里面
jwtUtils.java
要包括生成jwt、解析jwt(校验密钥,获取claims身份信息,角色信息)、校验jwt(就是看解析的过程有没有抛出异常)
java
package yellow.iblog.jwt;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.JwtException;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import java.nio.charset.StandardCharsets;
import java.security.Key;
import java.util.Date;
public class jwtUtils {
// 生成一个秘钥(也可以配置在 application.yml)
//这里是随机生成一个密钥
// private static final Key key = Keys.secretKeyFor(SignatureAlgorithm.HS256);
private static final String SECRET = "emily-is-gonna-be-rich-888888888888888888"; // 必须至少32字节(32个字符)
private static final Key key = Keys.hmacShaKeyFor(SECRET.getBytes(StandardCharsets.UTF_8));
private static final long EXPIRATION = 1000 * 60 * 60; // 1小时(以ms为单位)
// 生成 token
public static String generateToken(Long uid, String username,String role) {
return Jwts.builder()
.setSubject(uid.toString())//这个使用uid是因为这个uid是永远不变的,userName还可能会改变
.claim("role", role)//添加构成jwt的参数
.claim("username",username)
.setIssuedAt(new Date())
.setExpiration(new Date(System.currentTimeMillis() + EXPIRATION))
.signWith(key)
.compact();//压缩的意思
}
// 解析 token
public static Claims parseToken(String token) {
return Jwts.parserBuilder() // 创建 JWT 解析器建造器
.setSigningKey(key) // 验证签名密钥,如果失败会抛出异常
.build() // 构建解析器
.parseClaimsJws(token) // 解析 Token
.getBody(); // 获取 Token 的载荷 (Claims) 部分
}
// 校验 token 是否有效
public static boolean validateToken(String token) {
try {
parseToken(token); // 尝试解析 Token,并且验证密钥
return true; // 如果没有异常,说明 Token 有效
} catch (JwtException e) {
return false; // 如果捕获到异常,说明 Token 无效(签名错误或过期)
}
}
}
jwt中subject的含义:
setSubject()
用来存核心身份(谁在用这个 Token)
claims.put()
用来存扩展信息(这个人有什么属性/权限)
subject就是这个用户在jwt字符串里的唯一标识,标识一个唯一的用户,所以是不能改变的
剩下的就是设置一些其它的附属信息,但也很重要,需要设置用户名,角色。这些是写进jwt字符串里面的,后面登陆的时候带上这个jwt,就可以获取到你的用户名和角色,就可以做身份验证。
还需要设置过期时间,签发日期,密钥,最后compact压缩生成jwt。
(2)新建一个JwtAuthenticationFilter类
用来拦截发过来的请求,并且每次都看你有没有带jwt?你的jwt有没有过期?身份是不是可以访问资源?
ini
package yellow.iblog.jwt;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import io.jsonwebtoken.Claims;
import yellow.iblog.Common.ApiResponse;
import java.io.IOException;
import java.util.Collections;
import java.util.List;
@Component //可以装配
public class JwtAuthenticationFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String header = request.getHeader("Authorization");
if (header != null && header.startsWith("Bearer ")) {
String token = header.substring(7);
if (jwtUtils.validateToken(token)) {
Claims claims = jwtUtils.parseToken(token);
String username=claims.getSubject();
String role=(String) claims.get("role");
// 这里你可以把用户信息放到 request
request.setAttribute("uid", claims.get("uid"));
request.setAttribute("username", claims.getSubject());
request.setAttribute("role",claims.get("role"));
// 构造 Spring Security 的认证对象
List<GrantedAuthority> authorities = Collections.singletonList(
new SimpleGrantedAuthority(role));
//这个list就是一个数组,里面的数据类型是角色字符串(GrantedAuthority) 右边的是在将从jwt中解析出来的role构造成一个List,赋值给authorities,所以authorities就是一个装着发过来请求的角色的List
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(username, null, authorities);
//这个左边的类名好长一串,就是使用UserName和密码来做用户验证的,密码我们在登陆接口认证,那个接口不用jwt的。在那里认证,这里就写null,而且我们也没有把密码写进jwt里面,这里也获取不了。这个就是得到一个认证对象。表示你这个jwt的用户名已经认证了,已经登陆了。第三个参数是刚刚生成的角色列表authorities
// 将认证信息存入 Spring Security 的上下文,后面可以通过SecurityContextHolder.getContext().getAuthentication() 拿到用户信息。
SecurityContextHolder.getContext().setAuthentication(authentication);
} else {
//token不对或者没有token
ApiResponse<Object> apiResponse = ApiResponse.fail("您没有足够的权限或者登陆已经过期,请联系工作人员");
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write(new ObjectMapper().writeValueAsString(apiResponse));
return;
}
}
filterChain.doFilter(request, response);
}
}
继承OncePerRequestFilter就可以拦截每一个请求
这个就是在解析你携带的jwt,并且保存到Authentication里面
后续获取Authentication的方法:
ini
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
// 用户名(principal)
String username = (String) auth.getPrincipal();
// 权限(角色)
Collection<? extends GrantedAuthority> authorities = auth.getAuthorities();
// 也可以遍历权限
for (GrantedAuthority authority : authorities) {
System.out.println("角色: " + authority.getAuthority());
}
4.写用户登陆的Sercive
(1)在UserService里面定义login接口
(2)在UserServiceImpl里面写登陆验证逻辑
传进去用户名和密码,如果密码正确,那么发token
scss
//用户登陆
@Override
public ApiResponse<LoginResponse> userLogin(LoginInfo loginInfo){
String userName=loginInfo.getUserName();
User u=userMapper.findUserByUserName(userName);
if(u==null) return ApiResponse.fail("用户名不存在");//如果没有查到user就返回用户名不存在
//校验密码
String password=loginInfo.getPassword();
if(utils.Match(password,u.getPassword())){
//生成token
String token=jwtUtils.generateToken(u.getUid(),u.getUserName(),u.getRole());
LoginResponse response=new LoginResponse();
response.setUserName(userName);
response.setUid(u.getUid());
//token
response.setToken(token);
return ApiResponse.success(response);
}
return ApiResponse.fail("密码不正确");//如果密码输错了,那么就返回失败
}
返回的这个LoginResponse是这样的
kotlin
package yellow.iblog.model;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@AllArgsConstructor
@NoArgsConstructor
public class LoginResponse {
private Long uid;
private String userName;
private String token;
}
这个apiresponse是自己写的响应类
typescript
package yellow.iblog.Common;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
@NoArgsConstructor
@Getter
@Setter
public class ApiResponse<T> {
//给前端的标准返回格式
private int code; // 状态码: 0=成功, 非0=失败
private String message; // 错误提示 / 说明信息
private T data; // 返回的数据
// 构造方法
public ApiResponse(int code, String message, T data) {
this.code = code;
this.message = message;
this.data = data;
}
// ✅ 成功
public static <T> ApiResponse<T> success(T data) {
return new ApiResponse<>(0, "success", data);
}
// ✅ 自定义成功提示
public static <T> ApiResponse<T> success(String message, T data) {
return new ApiResponse<>(0, message, data);
}
// ✅ 失败
public static <T> ApiResponse<T> fail(String message) {
return new ApiResponse<>(-1, message, null);
}
// ✅ 自定义错误码 + 消息
public static <T> ApiResponse<T> fail(int code, String message) {
return new ApiResponse<>(code, message, null);
}
public static <T> ApiResponse<Exception> fail(int code,String message, Exception e) {
return new ApiResponse<>(code,message,e);
}
public boolean IsSuccess(){
return this.getCode() >= 0;
}
}
5.写用户登陆的Controller
less
//用户登陆,使用POST更加安全
@PostMapping("/auth/login")
public ResponseEntity<ApiResponse<LoginResponse>> userLogin(@RequestBody LoginInfo loginInfo){
ApiResponse<LoginResponse> result=userService.userLogin(loginInfo);
if(result.IsSuccess()){
log.info("用户{}登陆成功",loginInfo.getUserName());
return ResponseEntity.ok(result);
} else{
log.warn("用户{}登陆失败",loginInfo.getUserName());
return ResponseEntity.badRequest().body(result);
}
}
LoginInfo就是包含用户名和密码的一个结构体
6.配置路由访问权限
前面写了一个拦截器,现在需要配置路由访问权限
(1)写一个JWT拦截器配置,写在config包里面,这一步真正实现了管理员和普通用户的授权
SecurityAndJwtConfig.java
kotlin
package yellow.iblog.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import yellow.iblog.jwt.JwtAuthenticationFilter;
@Configuration
public class SecurityAndJwtConfig {
private final JwtAuthenticationFilter jwtFilter;
public SecurityAndJwtConfig(JwtAuthenticationFilter jwtFilter) {
this.jwtFilter = jwtFilter;
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable())
.authorizeHttpRequests(auth -> auth
.requestMatchers("/auth/**").permitAll() // 登录/注册接口放行
.requestMatchers("/admin/**").hasRole("ADMIN") // 只有管理员能访问
.anyRequest().authenticated() // 其他都要认证,登陆了就可以访问
)
.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class);
//在这个UsernamePasswordAuthenticationFilter之前加入Filter
return http.build();
}
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception {
return config.getAuthenticationManager();
}
}
.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class)的作用是:
在springboot默认权限访问控制中间件UsernamePasswordAuthenticationFilter之前加入我们写的jwt中间件,
这个默认中间件就是读取请求里面的UserName和password做检查,如果正确,就登陆成功,然后发布token。这个就是有些杂糅的功能了。
加入了我们这个jwt中间件,默认中间件就会跳过了,因为在我们写的jwt中间件里面,我们已经创建了authentication,已经登陆成功了。
整个流程示意
less
请求进入
↓
JWT 过滤器(您的自定义过滤器)
↓ ← 在这里设置 SecurityContextHolder 中的认证信息(设置了上下文,包括角色,UserName,uid)
UsernamePasswordAuthenticationFilter(Spring Security 的默认认证过滤器)
↓
FilterSecurityInterceptor(负责检查 authorizeHttpRequests 配置,就是.authorizeHttpRequests (auth -> auth
.requestMatchers("/auth/**").permitAll()
.requestMatchers("/admin/**").hasRole("ADMIN")
.anyRequest().authenticated()))
↓
跳转到相应的controller
7.如果需要更细粒度的访问控制,可以在controller方法上面使用注解
如果是上面已经配置过的路由,就不需要再写
less
@PreAuthorize("isAuthenticated()") // 只要认证(登录)即可,包括cookie登陆
@PreAuthorize("isAnonymous()") // 必须是未认证用户(如登录页面)
@PreAuthorize("isFullyAuthenticated()") // 完全认证(非remember-me登录)
@PreAuthorize("isRememberMe()") // 通过remember-me认证(基于cookie登陆,下一次登陆的时候就不会要再次重新登录)
@PreAuthorize("hasRole('USER')")// 需要特定角色
@PreAuthorize("hasRole('ADMIN')") // 必须有ADMIN角色
@PreAuthorize("hasAnyRole('ADMIN', 'USER')") // 有ADMIN或USER角色
@PreAuthorize("hasAuthority('READ_PRIVILEGE')") // 有特定权限
@PreAuthorize("hasAnyAuthority('READ', 'WRITE')") // 有READ或WRITE权限
// 检查principal的值(subject的值),如果subject是用户名,那么右边就要变成字符串,使用单引号
@PreAuthorize("authentication.name == 123456")
// 检查principal中的属性(假设principal有email属性)
@PreAuthorize("authentication.principal.email == 'admin@example.com'")
// 复杂的权限检查(如只能修改自己的数据)
@PutMapping("/user")
@PreAuthorize("#u.uid == authentication.name")
public ResponseEntity<ApiResponse<UserResponse>> updateUser(@RequestBody User u){
User savedUser=userService.updateUser(u);
if(savedUser!=null){
UserResponse r=new UserResponse().FromUser(u);
return ResponseEntity.ok(ApiResponse.success(r));
} else{
return ResponseEntity.badRequest().body(ApiResponse.fail("error"));
}
}
// 多个条件组合
@PreAuthorize("hasRole('ADMIN') or hasRole('MODERATOR')")
8.可以在security那里配置permitAll,然后在contoller那里单独配置需要登陆验证的接口
scss
.requestMatchers("/article/**").permitAll()//默认放行
创作文章需要登陆
less
//用户写文章
@PostMapping("")
@PreAuthorize("isAuthenticated()")
public ResponseEntity<ApiResponse<Article>> addArticle(@RequestBody Article article) {
Article a=articleService.createArticle(article);
if(a!=null){
log.info("用户{}发布了一篇文章",a.getUid());
return ResponseEntity.ok(ApiResponse.success(a));
}
//出错了一般不会返回这个,而是会被全局异常捕获器捕。
// 只有业务出错但是代码没有错的时候才会返回
return ResponseEntity.internalServerError().body(ApiResponse.fail("error"));
}
9.使用上下文信息的方法
less
@DeleteMapping("/admin/article")
public ResponseEntity<ApiResponse<Boolean>> adminDeleteArticleByAid(
@RequestParam Long aid){
// 从 SecurityContext 获取认证信息
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
Long uid = Long.valueOf(authentication.getName()); // 获取用户ID
if(articleService.deleteArticleByAid(aid)){
log.info("管理员{}删除了文章{}",uid,aid);
return ResponseEntity.ok(ApiResponse.success(true));
}
return ResponseEntity.internalServerError().body(ApiResponse.fail("error"));
}