SpringSecurity的使用

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"));

    }
相关推荐
绝无仅有2 小时前
面试经验之mysql高级问答深度解析
后端·面试·github
fliter2 小时前
12分钟讲解Python核心理念
后端
绝无仅有2 小时前
Java技术复试面试:全面解析
后端·面试·github
用户297994363792 小时前
门户功能技术方案实战:搞定动态更新与高频访问的核心玩法
后端
对不起初见2 小时前
如何在后端优雅地生成并传递动态错误提示?
java·spring boot
tingyu2 小时前
JAXB 版本冲突踩坑记:SPI 项目中的 XML 处理方案升级
java
我不是混子2 小时前
为什么不建议使用SELECT * ?
后端·mysql
NightDW2 小时前
amqp-client源码解析1:数据格式
java·后端·rabbitmq
程序员清风3 小时前
美团二面:KAFKA能保证顺序读顺序写吗?
java·后端·面试