Spring Security

216 阅读14分钟

SpringSecurity介绍

SpringSecurity是功能强大且高度定制的身份验证和访问控制框架,通常用于Spring的应用程序的安全性。

  • 身份认证:验证用户访问系统资源,判断用户是否为合法用户。比如验证用户输入用户名和密码是否正确。
  • 用户授权:用户进行身份认证后,控制用户可以访问的系统资源,用于限制用户只能访问指定资源。
  • 防御攻击:比如CSRF(跨站请求访问)、HTTP Headers、HTTP Requests。

SpringSecurity官方文档:docs.spring.io/spring-secu…

SpringSecurity实现用户认证

不同项目可能需要修改的地方:

  1. application.yml:MySQL、Redis信息,这个根据自己数据的账号密码按需修改。
  2. application.yml:loginUrl(登录接口的URL),这个看是否需要修改AuthController中登录接口的路径。

SpringSecurity基于数据库的用户认证底层逻辑:

前端请求放行的接口:

  1. 被JwtAuthenticationTokenFilter类的doFilterInternal方法拦截下来,请求头中不存在token,直接放行让SpringSecurity过滤器进行拦截。
  2. SpringSecurity过滤器根据SecurityConfig中configure配置发现该接口是放行的,直接放行执行对应的请求路径的接口方法。
  3. 接口方法中处理接口所需要处理的逻辑。

前端请求放行的接口比较特殊的是登录接口"/auth/login",前两步和前端请求放行的接口的前两步处理逻辑相同,第三步中接口方法中的逻辑AuthServiceImpl的login方法

  1. UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(vo.getUsername(), vo.getPassword()):将用户名和密码封装成Authentication对象
  2. Authentication authenticate = authenticationManager.authenticate(authenticationToken):SpringSecurity底层会调用UserDetailsService实现类UserDetailsServiceImpl的loadUserByUsername方法获取UserDetails对象(LoginUser类实现UserDetails接口)。
  3. redisCacheUtil.setCacheObject(redisCacheUserPrefix + userId, loginUser):将登录用户信息封装到redis中,后续前端携带token访问未放行的接口,然后可以从redis中获取到登陆用户数据。

前端请求未放行的接口:

  1. 被JwtAuthenticationTokenFilter类的doFilterInternal方法拦截下来,请求头中存在token,解析token获取用户的ID。
  2. 根据用户的ID去redis中获取LoginUser信息(登录用户的信息),将登录用户信息封装到Authorities,方便其他操作获取登录用户的数据,并放行该接口执行对应的请求路径的接口方法。
  3. 接口方法中处理接口所需要处理的逻辑。

密码加密算法

  • 明文密码:起初SpringSecurity密码以明文形式存储,没有经过任何加密算法对密码进行加密。
  • Hash算法:SpringSecurity的PasswordEncoder接口用于对密码进行单向转换,常见的哈希算法有MD5、SHA-256、SHA-512等。
  • 加盐Hash算法:不单单对密码进行哈希算法进行加密,而是对每个密码生成随机字节(盐),盐和密码拼接进行哈希算法加密。
  • 自适应单向函数:使用自适应单向函数验证密码时,故意占用资源,使得系统验证密码需要约一秒钟的时间,从而防御攻击者的暴力破解。常见的自适应单向函数有bcrypt、PBKDF2、scrypt、argon2。

彩虹表:恶意用户创建密码和密文对应关系的查找表,彩虹表是一个庞大的、针对各种可能的字母组合预先生成的哈希值集合,通过彩虹表可以查询密文对应的密码。

环境准备

  1. 添加依赖
<parent>  
    <groupId>org.springframework.boot</groupId>  
    <artifactId>spring-boot-starter-parent</artifactId>  
    <version>2.7.17</version>  
    <relativePath/>  
</parent>

<dependencies>  
    <!-- Springboot-Web依赖 -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>

    <!-- SpringSecurity依赖 -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-security</artifactId>
    </dependency>

    <!-- redis依赖 -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-redis</artifactId>
    </dependency>

    <!-- lombok依赖 -->
    <dependency>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
        <optional>true</optional>
    </dependency>

    <!-- fastjson依赖 -->
    <dependency>
        <groupId>com.alibaba</groupId>
        <artifactId>fastjson</artifactId>
        <version>2.0.33</version>
    </dependency>

    <!-- jwt依赖 -->
    <dependency>
        <groupId>io.jsonwebtoken</groupId>
        <artifactId>jjwt</artifactId>
        <version>0.9.0</version>
    </dependency>

    <!-- mysql驱动依赖 -->
    <dependency>
        <groupId>mysql</groupId>
        <artifactId>mysql-connector-java</artifactId>
        <version>8.0.32</version>
    </dependency>

    <!-- mybatis依赖 -->
    <dependency>
        <groupId>org.mybatis.spring.boot</groupId>
        <artifactId>mybatis-spring-boot-starter</artifactId>
        <version>2.1.3</version>
    </dependency>
    
    <!-- 参数数据校验依赖 -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-validation</artifactId>
    </dependency>
    
</dependencies>
  1. 编写yaml配置

注意:login: /auth/login路径需要根据项目动态变化

server:
  port: 8080

spring:
  datasource:
    url: jdbc:mysql://localhost:3306/security-test?useUnicode=true&characterEncoding=utf-8&useSSL=false&serverTimezone=UTC
    username: root
    password: root
    driver-class-name: com.mysql.cj.jdbc.Driver
  redis:
    host: localhost
    port: 6379
    password:
    database: 0                # 指定操作的0号数据库
  jedis:
    pool:
      # Redis连接池配置
      max-active: 8        # 最大连接数
      max-wait: 1ms        # 连接池最大阻塞等待时间
      max-idle: 4        # 连接池中的最大空闲连接数
      min-idle: 0        # 连接池最小空闲连接

# MyBatis配置
mybatis:
  # 配置mapper映射文件
  mapper-locations: classpath:mybatis/mapper/*.xml
  # 配置mybatis配置文件
  config-location: classpath:mybatis/mybatis-config.xml
# 日志级别可以是:info、debug、error
logging:
  level:
    com.wenxuan.admin.web.mapper: debug

# 登录请求路径
params:
  # 这里的路径在配置类中会用到,用于放行登录接口
  loginUrl: /auth/login
  redisCacheUserPrefix: login-
  1. 编写启动类
@SpringBootApplication
@MapperScan("com.wenxuan.security.web.mapper")
public class SecurityApplication {
    public static void main(String[] args) {
        SpringApplication.run(SecurityApplication.class, args);
    }
}
  1. 编写mybatis的配置文件

Mybatis的配置文件路径:/src/main/java/resources/mybatis/mybatis-config.xml

<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE configuration
        PUBLIC "-//mybatis.org//DTD Config 3.0//EN"
        "http://mybatis.org/dtd/mybatis-3-config.dtd">
<configuration>
    <environments default="development">
        <environment id="development">
            <transactionManager type="JDBC"/>
            <dataSource type="POOLED">
                <property name="driver" value="${driver}"/>
                <property name="url" value="${url}"/>
                <property name="username" value="${username}"/>
                <property name="password" value="${password}"/>
            </dataSource>
        </environment>
    </environments>
</configuration>
  1. 定义异常响应码枚举
/**
 * @author 文轩
 * @create 2024-03-03 17:26
 * 异常响应码枚举
 */
public enum BizCodeEnum {
    SUCCESS(200, "操作成功"),
    UNKNOW_EXCEPTION(201, "系统未知异常"),
    VALID_EXCEPTION(202, "参数格式校验失败"),
    TOKEN_ERROR(203, "token非法"),
    USER_NOT_LOGIN(204, "请先登录再访问")
    ;

    private int code;
    private String message;

    BizCodeEnum(int code, String message) {
        this.code = code;
        this.message = message;
    }

    public int getCode() {
        return code;
    }

    public String getMessage() {
        return message;
    }
}
  1. 定义全局异常错误枚举
/**
 * @author wenxuan
 * @create 2025-01-01 15:30
 * 全局异常错误信息枚举
 */
public enum GlobalErrorMsgEnum {
    USERNAME_OR_PASSWORD_ERROR("用户名或密码错误"),
    USERNAME_ERROR("用户名有误"),
    ;
    private String msg;
    GlobalErrorMsgEnum(String msg) {
        this.msg = msg;
    }

    public String getMsg() {
        return msg;
    }

    public void setMsg(String msg) {
        this.msg = msg;
    }
}
  1. 定义全局异常处理
/**
 * @author wenxuan
 * @create 2025-01-01 15:30
 * 全局异常处理
 */
@RestControllerAdvice
public class GlobalExceptionHandler {
    /**
     * ConstraintViolationException:请求参数校验异常错误
     * BindException:校验表单字段级别的参数
     */
    @ExceptionHandler({ConstraintViolationException.class, BindException.class})
    public ResponseData validatorException(Exception ex, HttpServletRequest request) {
        ex.printStackTrace();
        String msg = null;
        if(ex instanceof ConstraintViolationException){
            ConstraintViolationException constraintViolationException = (ConstraintViolationException)ex;
            Set<ConstraintViolation<?>> violations = constraintViolationException.getConstraintViolations();
            ConstraintViolation<?> next = violations.iterator().next();
            msg = next.getMessage();
        }else if(ex instanceof BindException){
            BindException bindException = (BindException)ex;
            msg = bindException.getBindingResult().getFieldError().getDefaultMessage();
        }

        return ResponseData.error(BizCodeEnum.VALID_EXCEPTION.getCode(), msg);
    }

    /**
     * 其他异常处理
     */
    @ExceptionHandler({Exception.class})
    public ResponseData otherException(Exception ex, HttpServletRequest request) {
        ex.printStackTrace();
        return ResponseData.error(BizCodeEnum.UNKNOW_EXCEPTION.getCode(), ex.getMessage());
    }
}
  1. 定义请求响应数据
/**
 * 请求响应统一类型
 * @param <T>
 */
@NoArgsConstructor
@AllArgsConstructor
@Data
public class ResponseData<T> implements Serializable {

    /**
     * 状态码
     */
    private int code;
    /**
     * 状态码描述信息
     */
    private String message;
    /**
     * 请求返回信息
     */
    private T data;

    public ResponseData(int code, String message) {
        this.code = code;
        this.message = message;
    }


    /**
     * 成功但不携带返回参数
     */
    public static <T> ResponseData<T> success() {
        return new ResponseData<>(BizCodeEnum.SUCCESS.getCode(), BizCodeEnum.SUCCESS.getMessage());
    }

    /**
     * 成功但不携带返回参数
     */
    public static <T> ResponseData<T> success(String message) {
        return new ResponseData<>(BizCodeEnum.SUCCESS.getCode(), message);
    }

    /**
     * 成功,且携带返回参数
     * @param data 返回参数
     */
    public static <T> ResponseData<T> success(T data) {
        return new ResponseData<>(BizCodeEnum.SUCCESS.getCode(), BizCodeEnum.SUCCESS.getMessage(), data);
    }

    /**
     * 成功,且携带返回参数
     * @param data 返回参数
     */
    public static <T> ResponseData<T> success(String message, T data) {
        return new ResponseData<>(BizCodeEnum.SUCCESS.getCode(), message, data);
    }

    /**
     * 失败,自定义返回码且不包含返回参数
     * @param code 返回码
     * @param message 返回信息
     */
    public static <T> ResponseData<T> error(int code, String message) {
        return new ResponseData<>(code, message);
    }

    /**
     * 失败,自定义返回码且包含返回参数
     * @param code 返回码
     * @param message 返回信息
     * @param data 返回参数
     */
    public static <T> ResponseData<T> error(int code, String message, T data) {
        return new ResponseData<>(code, message, data);
    }

    /**
     * 失败,使用枚举类返回码且不包含返回参数
     * @param bizCodeEnum 返回码枚举类
     */
    public static <T> ResponseData<T> error(BizCodeEnum bizCodeEnum) {
        return new ResponseData<>(bizCodeEnum.getCode(), bizCodeEnum.getMessage());
    }

    /**
     * 失败,使用枚举类返回码且包含返回参数
     * @param bizCodeEnum 返回码枚举类
     * @param data 返回参数
     */
    public static <T> ResponseData<T> error(BizCodeEnum bizCodeEnum, T data) {
        return new ResponseData<>(bizCodeEnum.getCode(), bizCodeEnum.getMessage(), data);
    }
}

定义工具类

  1. 定义Redis工具类
/**
 * @author 文轩
 * @create 2024-03-03 17:15
 *
 * Redis缓存工具类
 */
@SuppressWarnings(value = { "all"})
@Component
public class RedisCacheUtil {
    @Autowired
    public RedisTemplate redisTemplate;

    /**
     * 缓存基本的对象,Integer、String、实体类等
     *
     * @param key 缓存的键值
     * @param value 缓存的值
     */
    public <T> void setCacheObject(final String key, final T value) {
        redisTemplate.opsForValue().set(key, value);
    }

    /**
     * 缓存基本的对象,Integer、String、实体类等
     *
     * @param key 缓存的键值
     * @param value 缓存的值
     * @param timeout 时间
     * @param timeUnit 时间颗粒度
     */
    public <T> void setCacheObject(final String key, final T value, final Integer timeout, final TimeUnit timeUnit) {
        redisTemplate.opsForValue().set(key, value, timeout, timeUnit);
    }

    /**
     * 设置有效时间
     *
     * @param key Redis键
     * @param timeout 超时时间
     * @return true=设置成功;false=设置失败
     */
    public boolean expire(final String key, final long timeout) {
        return expire(key, timeout, TimeUnit.SECONDS);
    }

    /**
     * 设置有效时间
     *
     * @param key Redis键
     * @param timeout 超时时间
     * @param unit 时间单位
     * @return true=设置成功;false=设置失败
     */
    public boolean expire(final String key, final long timeout, final TimeUnit unit) {
        return redisTemplate.expire(key, timeout, unit);
    }

    /**
     * 获得缓存的基本对象。
     *
     * @param key 缓存键值
     * @return 缓存键值对应的数据
     */
    public <T> T getCacheObject(final String key) {
        ValueOperations<String, T> operation = redisTemplate.opsForValue();
        return operation.get(key);
    }

    /**
     * 删除单个对象
     *
     * @param key
     */
    public boolean deleteObject(final String key) {
        return redisTemplate.delete(key);
    }

    /**
     * 删除集合对象
     *
     * @param collection 多个对象
     * @return
     */
    public long deleteObject(final Collection collection) {
        return redisTemplate.delete(collection);
    }

    /**
     * 缓存List数据
     *
     * @param key 缓存的键值
     * @param dataList 待缓存的List数据
     * @return 缓存的对象
     */
    public <T> long setCacheList(final String key, final List<T> dataList) {
        Long count = redisTemplate.opsForList().rightPushAll(key, dataList);
        return count == null ? 0 : count;
    }

    /**
     * 获得缓存的list对象
     *
     * @param key 缓存的键值
     * @return 缓存键值对应的数据
     */
    public <T> List<T> getCacheList(final String key) {
        return redisTemplate.opsForList().range(key, 0, -1);
    }

    /**
     * 缓存Set
     *
     * @param key 缓存键值
     * @param dataSet 缓存的数据
     * @return 缓存数据的对象
     */
    public <T> BoundSetOperations<String, T> setCacheSet(final String key, final Set<T> dataSet) {
        BoundSetOperations<String, T> setOperation = redisTemplate.boundSetOps(key);
        Iterator<T> it = dataSet.iterator();
        while (it.hasNext()) {
            setOperation.add(it.next());
        }
        return setOperation;
    }

    /**
     * 获得缓存的set
     *
     * @param key
     * @return
     */
    public <T> Set<T> getCacheSet(final String key) {
        return redisTemplate.opsForSet().members(key);
    }

    /**
     * 缓存Map
     *
     * @param key
     * @param dataMap
     */
    public <T> void setCacheMap(final String key, final Map<String, T> dataMap) {
        if (dataMap != null) {
            redisTemplate.opsForHash().putAll(key, dataMap);
        }
    }

    /**
     * 获得缓存的Map
     *
     * @param key
     * @return
     */
    public <T> Map<String, T> getCacheMap(final String key) {
        return redisTemplate.opsForHash().entries(key);
    }

    /**
     * 往Hash中存入数据
     *
     * @param key Redis键
     * @param hKey Hash键
     * @param value 值
     */
    public <T> void setCacheMapValue(final String key, final String hKey, final T value) {
        redisTemplate.opsForHash().put(key, hKey, value);
    }

    /**
     * 获取Hash中的数据
     *
     * @param key Redis键
     * @param hKey Hash键
     * @return Hash中的对象
     */
    public <T> T getCacheMapValue(final String key, final String hKey) {
        HashOperations<String, String, T> opsForHash = redisTemplate.opsForHash();
        return opsForHash.get(key, hKey);
    }

    /**
     * 删除Hash中的数据
     *
     * @param key
     * @param hkey
     */
    public void delCacheMapValue(final String key, final String hkey) {
        HashOperations hashOperations = redisTemplate.opsForHash();
        hashOperations.delete(key, hkey);
    }

    /**
     * 获取多个Hash中的数据
     *
     * @param key Redis键
     * @param hKeys Hash键集合
     * @return Hash对象集合
     */
    public <T> List<T> getMultiCacheMapValue(final String key, final Collection<Object> hKeys) {
        return redisTemplate.opsForHash().multiGet(key, hKeys);
    }

    /**
     * 获得缓存的基本对象列表
     *
     * @param pattern 字符串前缀
     * @return 对象列表
     */
    public Collection<String> keys(final String pattern) {
        return redisTemplate.keys(pattern);
    }
}
  1. 定义JWT工具类

JWT工具类:提供获取和解析JWT的工具方法。

/**
 * @author 文轩
 * @create 2024-03-03 17:00
 * JWT工具类:提供获取和解析JWT的工具方法。
 */
public class JwtUtil {

    /**
     * JWT的有限期,1小时
     */
    public static final Long JWT_TTL = 60 * 60 * 1000L;

    /**
     * JWT的密钥的明文
     */
    public static final String JWT_KEY = "wenxuan";

    public static String getUUID() {
        return UUID.randomUUID().toString().replaceAll("-", "");
    }

    /**
     * 生成JWT
     * @param subject token中要存放的数据(JSON格式)
     * @return
     */
    public static String createJWT(String subject) {
        return getJwtBuilder(subject, null, getUUID()).compact();
    }

    /**
     * 生成JWT
     * @param subject token中存放的数据(JSON格式)
     * @param ttlMillis token过期时间
     * @return
     */
    public static String createJWT(String subject, Long ttlMillis) {
        return getJwtBuilder(subject, ttlMillis, getUUID()).compact();
    }

    /**
     *  生成JWT
     * @param id 唯一ID
     * @param subject token中存放的数据(JSON格式)
     * @param ttlMillis token过期时间
     * @return
     */
    public static String createJWT(String id, String subject, Long ttlMillis){
        return getJwtBuilder(subject, ttlMillis, id).compact();
    }

    private static JwtBuilder getJwtBuilder(String subject, Long ttlMillis, String uuid) {
        SignatureAlgorithm signatureAlgorithm = SignatureAlgorithm.HS256;
        SecretKey secretKey = generalKey();
        long nowMillis = System.currentTimeMillis();
        Date now = new Date(nowMillis);
        if(ttlMillis == null){
            ttlMillis = JwtUtil.JWT_TTL;
        }
        long expMillis = nowMillis + ttlMillis;
        Date expDate = new Date(expMillis);
        return Jwts.builder()
                .setId(uuid)            // 唯一的ID
                .setSubject(subject)    // 主题,可以是JSON数据
                .setIssuer("wenxuan")   // 签发者
                .setIssuedAt(now)       // 签发时间
                .signWith(signatureAlgorithm, secretKey) // 使用HS256对称算法签名,secretKey为密钥
                .setExpiration(expDate);    //  设置过期时间
    }

    /**
     * 生成加密后的密钥
     * @return
     */
    public static SecretKey generalKey() {
        byte[] encodedKey = Base64.getDecoder().decode(JwtUtil.JWT_KEY);
        return new SecretKeySpec(encodedKey, 0, encodedKey.length, "AES");
    }

    /**
     * 解析JWT
     * @param jwt
     */
    public static Claims parseJWT(String jwt) throws Exception{
        return Jwts.parser()
                .setSigningKey(generalKey())
                .parseClaimsJws(jwt)
                .getBody();
    }
}
  1. Web工具类
/**
 * @author 文轩
 * @create 2024-03-03 17:26
 * Web工具类:提供将字符串渲染到客户端的工具方法
 */
public class WebUtil {

    /**
     * 将字符串渲染倒客户端
     */
    public static void renderString(HttpServletResponse response, String string) {
        try {
            response.setStatus(HttpStatus.OK.value());
            response.setContentType("application/json");
            response.setCharacterEncoding("utf-8");
            response.getWriter().write(string);
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

    /**
     * 将字符串渲染倒客户端
     */
    public static void renderString(HttpServletResponse response, ResponseData responseData) {
        try {
            response.setStatus(HttpStatus.OK.value());
            response.setContentType("application/json");
            response.setCharacterEncoding("utf-8");
            response.getWriter().write(JSON.toJSONString(responseData));
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}
  1. 密码加密工具类
/**
 * @author 文轩
 * @create 2024-03-05 11:10
 * 密码加密工具类
 */
@Component
public class EncryptionUtil {

    /**
     * 加密密码
     * @param password 密码的明文
     * Pbkdf2PasswordEncoder:return new Pbkdf2PasswordEncoder("secret", 185000, 256).encode(password);
     * SCryptPasswordEncoder:return new SCryptPasswordEncoder(16, 8, 1, 32, 64).encode(password);
     * Argon2PasswordEncoder:return new Argon2PasswordEncoder(16, 32, 1, 32, 64).encode(password);
     */
    public String encryptPassword(String password) {
        return password;
    }

    /**
     * 匹配密码
     * @param rawPassword 密码的明文
     * @param encodedPassword 密码的密文
     */
    public boolean  matchPassword(String rawPassword, String encodedPassword) {
        return new BCryptPasswordEncoder().matches(rawPassword, encodedPassword);
    }
}

添加数据库

  1. 执行SQL
-- 创建数据库
CREATE DATABASE `security-test`;
USE `security-test`;

-- 创建用户表
CREATE TABLE `user`(
`id` INT NOT NULL AUTO_INCREMENT PRIMARY KEY COMMENT '主键',
`username` VARCHAR(50) DEFAULT NULL COMMENT '用户名称' ,
`password` VARCHAR(500) DEFAULT NULL COMMENT '用户密码'
) COMMENT '用户表';

-- 插入用户数据
INSERT INTO `user` (`username`, `password`) VALUES
('admin', 'admin'),
('wenxuan', 'wenxuan');
  1. 定义User实体类
@Data
public class UserModel implements Serializable {
    private static final long serialVersionUID = 1L;
    private Integer id;
    private String username;
    private String password;
}
  1. 定义UserMapper
@Mapper
public interface UserMapper {
    UserModel selectUserByUsername(String username);
}
  1. 编写UserMapper.xml
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
        PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
        "http://mybatis.org/dtd/mybatis-3-mapper.dtd">

<!--namespace填写mapper接口名-->
<mapper namespace="com.wenxuan.security.web.mapper.UserMapper">

    <!--Bean属性名和表字段名的对应关系-->
    <resultMap id="userMapper" type="com.wenxuan.security.pojo.dao.UserModel">
        <id column="id" property="id"/>
        <result column="username" property="username"/>
        <result column="password" property="password"/>
    </resultMap>

    <select id="selectUserByUsername" resultMap="userMapper">
        select * from user where username = #{username}
    </select>
</mapper>

定义LoginUser

LoginUser:用户进行登录验证时,登录接口的service中会将LoginUser对象保存到redis中,当请求访问其他接口时会判断redis中是否存在匹配的LoginUser对象来决定是否有权限访问。

/**
 * @author 文轩
 * @create 2024-03-03 17:45
 * 登录用户信息的类型
 */
@Data
@NoArgsConstructor
@AllArgsConstructor
public class LoginUser implements UserDetails {

    private UserModel userModel;


    /**
     * 获取登录用户权限信息
     */
    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return null;
    }

    /**
     * 获取登录用户的密码
     */
    @Override
    public String getPassword() {
        return userModel.getPassword();
    }

    /**
     * 获取登录用户名,UserDetailsServiceImpl的loadUserByUsername方法的参数通过该方法自动获取。
     */
    @Override
    public String getUsername() {
        return userModel.getUsername();
    }

    /**
     * 账户是否未过期
     */
    @Override
    public boolean isAccountNonExpired() {
        return true;
    }

    /**
     * 账户是否未锁定
     */
    @Override
    public boolean isAccountNonLocked() {
        return true;
    }

    /**
     * 密码是否未过期
     */
    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }

    /**
     * 账户是否可用
     */
    @Override
    public boolean isEnabled() {
        return true;
    }
}

定义UserDetailsServiceImpl

UserDetailsService实现类的作用:通过实现loadUserByUsername方法根据用户名从数据库中加载用户的详细信息,这个方法在用户登录时,SpringSecurity会调动这个方法来验证用户输入的用户名来查询用户信息。

/**
 * @author 文轩
 * @create 2024-03-03 17:43
 * SpringSecurity底层会保存一个LoginUser对象,username参数从该对象的getUsername方法自动调用获取参数,来获取满足username条件的用户数据
 */
@Service
public class UserDetailsServiceImpl implements UserDetailsService {

    @Resource
    private UserMapper userMapper;

    // SpringSecurity底层会保存一个LoginUser对象,username参数从该对象的getUsername方法自动调用获取参数
    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        // 根据用户名查询用户信息
        UserModel userModel = userMapper.selectUserByUsername(username);
        // 如果查询不到数据就通过抛出异常来给出提示
        if(Objects.isNull(userModel)) {
            throw new RuntimeException(GlobalErrorMsgEnum.USERNAME_ERROR.getMsg());
        }
        // TODO 根据用户查询权限信息 添加到LoginUser中

        // 封装成UserDetails对象返回
        return new LoginUser(userModel);
    }
}

定义登录和注销接口

  1. 定义注销和登录相关的VO

登录请求的VO

/**
 * @author wenxuan
 * @create 2024-12-31 15:10
 * 登录请求的VO
 */
@Data
@NoArgsConstructor
@AllArgsConstructor
public class LoginRequestVO {
    @Length(min = 4, max = 10, message = "用户名长度在4到10之间")
    private String username;
    @Length(min = 5, max = 20, message = "密码长度在6到20之间")
    private String password;
}

登录响应的VO

/**
 * @author wenxuan
 * @create 2025-01-01 16:48
 * 登录响应的VO
 */
@Data
@NoArgsConstructor
@AllArgsConstructor
public class LoginResponseVO {
    private String token;
}
  1. 编写Controller
/**
 * @author wenxuan
 * @create 2024-12-31 15:04
 * 用户认证的接口
 */
@RestController
@RequestMapping("/auth")
public class AuthController {

    @Resource
    private AuthService authService;

    /**
     * 登录的接口
     */
    @PostMapping("/login")
    public ResponseData<LoginResponseVO> login(@Validated @RequestBody LoginRequestVO vo) {
        return authService.login(vo);
    }

    /**
     * 注销接口
     */
    @PostMapping("/logout")
    public ResponseData logout() {
        return authService.logout();
    }
}
  1. 编写Service
/**
 * @author wenxuan
 * @create 2024-12-31 15:11
 * 用户认证的Service
 */
public interface AuthService {

    /**
     * 登录功能
     */
    ResponseData<LoginResponseVO> login(LoginRequestVO vo);


    /**
     * 注销功能
     */
    ResponseData<String> logout();
}
  1. 编写Service实现类
/**
 * @author wenxuan
 * @create 2024-12-31 15:13
 * 用户认证的Service实现
 */
@Service
public class AuthServiceImpl implements AuthService {

    @Resource
    private AuthenticationManager authenticationManager;

    @Resource
    private RedisCacheUtil redisCacheUtil;

    @Value("${params.redisCacheUserPrefix}")
    private String redisCacheUserPrefix;


    @Override
    public ResponseData<LoginResponseVO> login(LoginRequestVO vo) {
        // 1、将用户名和密码封装成Authentication对象
        UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(vo.getUsername(), vo.getPassword());
        // authenticate方法底层会调用UserDetailsService实现类的loadUserByUsername方法获取UserDetails对象
        Authentication authenticate = authenticationManager.authenticate(authenticationToken);
        if(Objects.isNull(authenticate)) {
            throw new RuntimeException(GlobalErrorMsgEnum.USERNAME_OR_PASSWORD_ERROR.getMsg());
        }
        // 2、使用userId生成token
        LoginUser loginUser = (LoginUser) authenticate.getPrincipal();
        String userId = loginUser.getUserModel().getId().toString();
        String token = JwtUtil.createJWT(userId);
        LoginResponseVO loginResponseVO = new LoginResponseVO(token);

        // 3、将用户信息存入redis
        redisCacheUtil.setCacheObject(redisCacheUserPrefix + userId, loginUser);

        // 4、把token响应给前端
        return ResponseData.success(loginResponseVO);
    }

    @Override
    public ResponseData<String> logout() {
        // 1、获取用户对象
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        LoginUser loginUser = (LoginUser) authentication.getPrincipal();
        Integer userId = loginUser.getUserModel().getId();

        // 2、删除redis缓存的用户信息
        redisCacheUtil.deleteObject(redisCacheUserPrefix + userId);
        return ResponseData.success("退出成功");
    }
}

定义认证过滤器

用户调用需要先认证的接口时,会先被认证过滤器拦截查看是否已被认证,如果被认证则放行访问,如果未被认证则拦截。

/**
 * @author 文轩
 * @create 2024-03-04 9:36
 * 用户认证的过滤器
 */
@Component
public class JwtAuthenticationTokenFilter extends OncePerRequestFilter {

    @Resource
    private RedisCacheUtil redisCacheUtil;

    @Value("${params.redisCacheUserPrefix}")
    private String redisCacheUserPrefix;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {

        // 1、获取token
        String token = request.getHeader("token");
        if(!StringUtils.hasText(token)) {
            // 没有token,直接放行让SpringSecurity过滤器进行拦截
            filterChain.doFilter(request, response);
            return;
        }

        // 2、解析token获取其中的userId
        String userId = null;
        try {
            userId = JwtUtil.parseJWT(token).getSubject();
        } catch (Exception e) {
            WebUtil.renderString(response, ResponseData.error(BizCodeEnum.TOKEN_ERROR));
            return;
        }


        // 3、从redis中获取用户信息
        String redisKey = redisCacheUserPrefix + userId;
        LoginUser loginUser = redisCacheUtil.getCacheObject(redisKey);
        if(Objects.isNull(loginUser)) {
            WebUtil.renderString(response, ResponseData.error(BizCodeEnum.USER_NOT_LOGIN));
            return;
            //throw new RuntimeException(GlobalErrorMsgEnum.USER_NOT_LOGIN.getMsg());
        }

        // 4、存入SecurityContextHolder
        // TODO 获取权限信息封装到Authorities
        UsernamePasswordAuthenticationToken authenticationToken =
                new UsernamePasswordAuthenticationToken(loginUser, null, loginUser.getAuthorities());
        SecurityContextHolder.getContext().setAuthentication(authenticationToken);
        filterChain.doFilter(request, response);

    }
}

定义Security配置类

/**
 * @author 文轩
 * @create 2024-03-03 18:18
 * SpringSecurity的配置类
 */
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Resource
    private JwtAuthenticationTokenFilter jwtAuthenticationTokenFilter;

    /**
     * 登录认证的URL
     */
    @Value("${params.loginUrl}")
    private String loginUrl;

    /**
     * 定义密码加密方式为明文方式,其他加密方式有:
     * Pbkdf2PasswordEncoder:new Pbkdf2PasswordEncoder("secret", 185000, 256);
     * SCryptPasswordEncoder:new SCryptPasswordEncoder(16, 8, 1, 32, 64);
     * Argon2PasswordEncoder:Argon2PasswordEncoder(16, 32, 1, 32, 64);
     * 明文方式:NoOpPasswordEncoder.getInstance()
     */
    @Bean
    public PasswordEncoder passwordEncoder() {
        return NoOpPasswordEncoder.getInstance();
    }

    /**
     * 将用户进行认证接口添加到容器,以便Security进行认证
     */
    @Bean
    @Override
    public AuthenticationManager authenticationManagerBean() throws Exception {
        return super.authenticationManagerBean();
    }

    /**
     * 配置Spring Security的安全规则
     */
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                //关闭csrf
                .csrf().disable()
                //不通过Session获取SecurityContext
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and()
                .cors()  // 启用CORS
                .and()
                .authorizeRequests()
                // 对于登录接口 允许匿名访问
                .antMatchers(loginUrl).permitAll()
                .antMatchers("/v3/**").permitAll()
                // 除上面外的所有请求全部需要鉴权认证
                .anyRequest().authenticated();
        // 将jwt认证过滤器添加到过滤器链中
        http.addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);
    }
}

整合swagger(非必须)

  1. 添加knife4依赖
<dependency>
    <groupId>com.github.xiaoymin</groupId>
    <artifactId>knife4j-spring-boot-starter</artifactId>
    <version>3.0.3</version>
</dependency>
  1. 解决Spring2.6以上匹配规则错误
spring:
  mvc:
    pathmatch:
      matching-strategy: ant_path_matcher
  1. 添加swagger配置类
@Configuration
@EnableOpenApi
public class SwaggerConfig {
    @Bean
    public Docket group1() {
        return new Docket(DocumentationType.OAS_30)
                .groupName("分组1")
                .apiInfo(apiInfo()).enable(true)
                .select()
                .apis(RequestHandlerSelectors.basePackage("com.wenxuan.web.controller1")
                // 可以添加扫描多个包下的Controller
                .or(RequestHandlerSelectors.basePackage("com.wenxuan.web.controller2")))
                .build();
    }

    @Bean
    public Docket group2() {
        return new Docket(DocumentationType.OAS_30)
                .groupName("分组2")
                .apiInfo(apiInfo()).enable(true)
                .select()
                .apis(RequestHandlerSelectors.basePackage("com.wenxuan.web.controller3"))
                .build();
    }

    private ApiInfo apiInfo() {
        return new ApiInfoBuilder()
                .title("wenxuan-Swagger")       // 文档标题
                .description("文轩编程")         // 文档描述信息
                // 文档作者信息
                .contact(new Contact("文轩", "https://xxx","xxx@163.com"))
                // 文档版本号
                .version("v1.0")
                .build();
    }
}
  1. 放行swagger相关文件

修改security的配置类的configure方法即可,将下面路径放行即可:

  • /doc.html
  • /webjars/**
  • /swagger-resources/**
  • /v3/**
@Override
protected void configure(HttpSecurity http) throws Exception {
    http
            //关闭csrf
            .csrf().disable()
            //不通过Session获取SecurityContext
            .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
            .and()
            .cors()  // 启用CORS
            .and()
            .authorizeRequests()
            // 对于登录接口 允许匿名访问
            .antMatchers(loginUrl).permitAll()
            .antMatchers("/doc.html").permitAll()
            .antMatchers("/webjars/**").permitAll()
            .antMatchers("/swagger-resources/**").permitAll()
            .antMatchers("/v3/**").permitAll()
            // 除上面外的所有请求全部需要鉴权认证
            .anyRequest().authenticated();
    // 将jwt认证过滤器添加到过滤器链中
    http.addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);
}

SpringSecurity实现用户授权

系统常见的两种授权需求:

  • 用户-权限-资源:例如张三的权限是添加用户、查看用户列表,李四的权限是查看用户列表。
  • 用户角色-权限-资源:例如张三的角色是管理员、李四的角色是普通用户,管理员能做所有的操作,普通用户只能查看信息。

RBAC基于角色的访问控制

RBAC(Role-Base Access Control,基于角色的访问控制)是一种常用的数据库涉及方案,将用户的权限分配和管理与角色相关联。

系统中用户和角色是多对多的关系,角色和权限也是多对多的关系,则需要建立用户角色关联表,角色权限关联表。

  1. 用户表:包含用户的基本信息。
列名数据类型描述
idchar主键
userNamevarchar用户名
passwordvarchar密码
  1. 角色表:存储所有的角色信息。
列名数据类型描述
idchar主键
role_namevarchar角色名称
remarkvarchar备注
  1. 权限表:存储系统中所有的权限信息。
列名数据类型描述
idchar主键
permission_namevarchar权限名称
remarkvarchar备注
  1. 用户角色关联表:存储用户和角色的关联信息。
列名数据类型描述
idchar主键
user_idchar用户ID
role_idchar角色ID
  1. 角色权限关联表:存储角色和权限的关联信息。
列名数据类型描述
idchar主键
role_idchar角色ID
permission_idchar权限ID

基于注解的授权

基于注解授权官方文档:docs.spring.io/spring-secu…

开启注解授权配置

// 开启SpringSecurity基于注解的授权
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {
}

执行权限相关SQL

-- 创建角色表
CREATE TABLE `role`(
`id` INT NOT NULL AUTO_INCREMENT PRIMARY KEY COMMENT '主键',
`role_name` VARCHAR(50) DEFAULT NULL COMMENT '角色名称',
`remark` VARCHAR(500) DEFAULT NULL COMMENT '角色备注'
) COMMENT '角色表';

-- 创建权限表
CREATE TABLE `permission`(
`id` INT NOT NULL AUTO_INCREMENT PRIMARY KEY COMMENT '主键',
`permission_name` VARCHAR(50) DEFAULT NULL COMMENT '权限名称',
`remark` VARCHAR(500) DEFAULT NULL COMMENT '权限备注'
) COMMENT '权限表';

-- 创建用户角色表
CREATE TABLE `user_role`(
`id` INT NOT NULL AUTO_INCREMENT PRIMARY KEY,
`user_id` INT COMMENT '用户ID',
`role_id` INT COMMENT '角色ID'
) COMMENT '用户角色表';

-- 创建角色权限表
CREATE TABLE `role_permission`(
`id` INT NOT NULL AUTO_INCREMENT PRIMARY KEY,
`role_id` INT COMMENT '角色ID',
`permission_id` INT COMMENT '权限ID'
) COMMENT '角色权限表';

-- 插入角色数据
INSERT INTO `role` (`role_name`, `remark`) VALUES
    ('ADMIN', '管理员'),
    ('USER', '普通用户');

-- 插入权限数据
INSERT INTO `permission` (`permission_name`, `remark`) VALUES
    ('INSERT', '增加'),
    ('QUERY', '查询');

-- 插入用户角色对应关系数据
INSERT INTO `user_role` (`user_id`, `role_id`) VALUES
    (1, 1),
    (2, 2);

-- 插入角色权限对应关系数据
INSERT INTO `role_permission` (`role_id`, `permission_id`) VALUES
    (1, 1),
    (1, 2),
    (2, 2);

修改LoginUser实体类

/**
 * @author 文轩
 * @create 2024-03-03 17:45
 * 登录用户信息的类型
 */
@Data
@NoArgsConstructor
@AllArgsConstructor
public class LoginUser implements UserDetails {

    private UserModel userModel;

    // 存储权限信息
    private List<String> permissions;

    // 存储SpringSecurity所需要的权限信息的集合
    private List<GrantedAuthority> authorities;


    /**
     * 获取权限信息
     */
    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        if(Objects.nonNull(authorities)) {
            return authorities;
        }
        // 根据数据查询出来的权限生成SpringSecurity所需的对象
        authorities = permissions.stream().map(SimpleGrantedAuthority::new).collect(Collectors.toList());
        return authorities;
    }

    /**
     * 获取密码信息
     * @return
     */
    @Override
    public String getPassword() {
        return userModel.getPassword();
    }

    /**
     * 获取用户名
     */
    @Override
    public String getUsername() {
        return userModel.getUsername();
    }

    /**
     * 账户是否未过期
     */
    @Override
    public boolean isAccountNonExpired() {
        return true;
    }

    /**
     * 账户是否未锁定
     */
    @Override
    public boolean isAccountNonLocked() {
        return true;
    }

    /**
     * 密码是否未过期
     */
    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }

    /**
     * 账户是否可用
     */
    @Override
    public boolean isEnabled() {
        return true;
    }

    public LoginUser(UserModel userModel, List<String> permissions) {
        this.userModel = userModel;
        this.permissions = permissions;
    }
}

修改UserDetailsService实现类

@Service
public class UserDetailsServiceImpl implements UserDetailsService {

    @Resource
    private UserMapper userMapper;

    @Resource
    private PermissionMapper permissionMapper;

    // SpringSecurity底层会保存一个LoginUser对象,username参数从该对象的getUsername方法自动调用获取参数
    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        // 根据用户名查询用户信息
        UserModel userModel = userMapper.selectUserByUsername(username);
        // 如果查询不到数据就通过抛出异常来给出提示
        if(Objects.isNull(userModel)) {
            throw new RuntimeException(GlobalErrorMsgEnum.USERNAME_ERROR.getMsg());
        }
        // TODO 根据用户查询权限信息 添加到LoginUser中
        List<String> permissionKeyList =  permissionMapper.selectPermissionByUserId(userModel.getId());

        // 封装成UserDetails对象返回
        return new LoginUser(userModel, permissionKeyList);
    }
}

定义权限的Mapper类

  1. 定义PermissionModel
/**
 * @author wenxuan
 * @create 2025-01-01 22:14
 */
@Data
@AllArgsConstructor
@NoArgsConstructor
public class PermissionModel {
    private Integer id;
    private String permissionName;
    private String remark;
}
  1. 定义PermissionMapper类
/**
 * @author wenxuan
 * @create 2025-01-01 22:13
 */
@Mapper
public interface PermissionMapper {

    List<String> selectPermissionByUserId(Integer id);
}
  1. 定义PermissionMapper.xml
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
        PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
        "http://mybatis.org/dtd/mybatis-3-mapper.dtd">

<!--namespace填写mapper接口名-->
<mapper namespace="com.wenxuan.security.web.mapper.PermissionMapper">

    <!--Bean属性名和表字段名的对应关系-->
    <resultMap id="permissionMapper" type="com.wenxuan.security.pojo.dao.PermissionModel">
        <id column="id" property="id"/>
        <result column="permission_name" property="permissionName"/>
        <result column="remark" property="remark"/>
    </resultMap>

    <select id="selectPermissionByUserId" resultType="java.lang.String">
        select p.permission_name
        from `permission` p
                 left join `role_permission` rp on p.id = rp.permission_id
                 left join `user_role` ur on rp.role_id = ur.role_id
                 left join `user` u on u.id = ur.user_id
        where u.id = #{id}
    </select>
</mapper>

常用的内置授权注解

  1. @PreAuthorize("hasRole('ADMIN')"):表示ADMIN角色才能访问。
  2. @PreAuthorize("hasRole('ADMIN') and authentication.name == 'admim'"):表示ADMIN角色并且用户名是admin才能访问。
  3. @PreAuthorize("hasAuthority('USER_ADD')"):表示具有USER_ADD权限才能访问。

使用自定义授权注解

编写授权类

package com.wenxuan.expression;

import com.wenxuan.bean.LoginUser;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;

import java.util.List;

/**
 * @author 文轩
 * @create 2024-03-05 9:32
 *
 * 自定义授权注解
 */
@Component("expressionRootImpl")
public class ExpressionRootImpl {
    public boolean hasAuthority(String authority){
        // 1、获取当前用户的权限
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        LoginUser loginUser = (LoginUser) authentication.getPrincipal();
        List<String> permissions = loginUser.getPermissions();

        // 2、判断用户权限中是否包含authority
        return permissions.contains(authority);
    }
}

使用自定义授权注解

package com.wenxuan.web.controller;

import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

/**
 * @author 文轩
 * @create 2024-03-03 21:18
 */
@RestController
public class HelloController {

    // 使用SPEL表达式
    @PreAuthorize("@expressionRootImpl.hasAuthority('system:dept:list11')")
    @GetMapping("/hello")
    public String hello() {
        return "hello";
    }
}

SpringSecurity自定义处理器

SpringSecurity的自定义处理机制:可以进行配置,当系统出现认证异常或者权限异常时进行相应的处理,使其返回需要的JSON数据。

在SpringSecurity中,如果我们在认证或者授权的过程中出现了异常会被ExceptionTranslationfilter捕获到。

  • 在ExceptionTranslationFilter中会判断是认证失败还是授权失败出现的异常。
  • 如果是认证过程中出现的异常会被封装成AuthenticationException然后调用AutHienticationEntryPoint对象的方法去进行异常处理。
  • 如果是授权过程中出现的异常会被封装成AccessDeniedException然后调用AccessDeniedHandler对象的方法去进行异常处理。

自定义异常处理,只需要自定义AuthenticationEntryPoint和AccessDeniedHandler然后配置给Springsecurity即可。

认证失败处理器

自定义认证失败处理器

package com.wenxuan.handle;

import com.alibaba.fastjson.JSON;
import com.wenxuan.bean.ResponseResult;
import com.wenxuan.utils.WebUtil;
import org.springframework.http.HttpStatus;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

/**
 * @author 文轩
 * @create 2024-03-03 17:00
 *
 * 自定义认证失败的处理器
 */
@Component
public class AuthenticationEntryPointImpl implements AuthenticationEntryPoint {
    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) {
        String message = authException.getMessage();
        if(!StringUtils.hasText(message)) {
            message = "认证失败请重新登录";
        }
        ResponseResult result = new ResponseResult(HttpStatus.UNAUTHORIZED.value(), message);
        String json = JSON.toJSONString(result);
        WebUtil.renderString(response,json);
    }
}

配置自定义认证失败处理器

package com.wenxuan;

import org.springframework.context.annotation.Configuration;
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.web.AuthenticationEntryPoint;

import javax.annotation.Resource;

/**
 * @author 文轩
 * @create 2024-03-03 18:18
 *
 * SpringSecurity的配置类
 */
@Configuration
// 开启SpringSecurity基于注解的授权
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    /**
     * 认证失败的处理器
     */
    @Resource
    private AuthenticationEntryPoint authenticationEntryPoint;


    /**
     * 配置Spring Security的安全规则
     */
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        // 配置自定义处理器
        http.exceptionHandling()
                // 配置认证失败处理器
                .authenticationEntryPoint(authenticationEntryPoint);
    }
}

权限不足处理器

编写权限不足处理器

package com.wenxuan.handle;

import com.alibaba.fastjson.JSON;
import com.wenxuan.bean.ResponseResult;
import com.wenxuan.utils.WebUtil;
import org.springframework.http.HttpStatus;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

/**
 * @author 文轩
 * @create 2024-03-03 17:00
 *
 * 自定义授权失败处理器
 */
@Component
public class AccessDeniedHandlerImpl implements AccessDeniedHandler {
    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) {
        String message = accessDeniedException.getMessage();
        if(!StringUtils.hasText(message)) {
            message = "操作权限不足";
        }
        ResponseResult result = new ResponseResult(HttpStatus.FORBIDDEN.value(), message);
        String json = JSON.toJSONString(result);
        WebUtil.renderString(response,json);
    }
}

配置权限不足处理器

package com.wenxuan.config;

import org.springframework.context.annotation.Configuration;
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.web.access.AccessDeniedHandler;

import javax.annotation.Resource;

/**
 * @author 文轩
 * @create 2024-03-03 18:18
 *
 * SpringSecurity的配置类
 */
@Configuration
// 开启SpringSecurity基于注解的授权
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    /**
     * 授权失败的处理器
     */
    @Resource
    private AccessDeniedHandler accessDeniedHandler;

    /**
     * 配置Spring Security的安全规则
     */
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        // 配置自定义处理器
        http.exceptionHandling()
                // 配置授权失败处理器
                .accessDeniedHandler(accessDeniedHandler);
    }
}

SpringSecurity解决跨域问题

跨域问题:浏览器出于安全的考虑,使用XMLHttpRequest对象发起HTTP请求时必须遵守同源策略,否则就是跨域的HTTP请求,默认情况下是被禁止的。同源策略要求源相同才能正常进行通信,即协议、域名、端口号都完全一致。

前后端分离项目,前端项目和后端项目一般都不是同源的,所以肯定会存在跨域请求的问题。

SpringBoot解决跨域问题

package com.wenxuan.config;

import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

/**
 * @author 文轩
 * @create 2024-03-05 9:27
 */
@Configuration
public class CorsConfig implements WebMvcConfigurer {

    @Override
    public void addCorsMappings(CorsRegistry registry) {
        // 设置允许跨域的路径
        registry.addMapping("/**")
                // 设置允许跨域请求的域名
                .allowedOriginPatterns("*")
                // 是否允许cookie
                .allowCredentials(true)
                // 设置允许的请求方式
                .allowedMethods("GET", "POST", "DELETE", "PUT")
                // 设置允许的header属性
                .allowedHeaders("*")
                // 跨域允许时间
                .maxAge(3600);
    }
}

SpringSecurity解决跨域问题

package com.wenxuan.config;

import org.springframework.context.annotation.Configuration;
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;


/**
 * @author 文轩
 * @create 2024-03-03 18:18
 *
 * SpringSecurity的配置类
 */
@Configuration
// 开启SpringSecurity基于注解的授权
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    /**
     * 配置Spring Security的安全规则
     */
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        //允许跨域
        http.cors();
    }
}

SpringSecurity底层实现

SpringSecurity的原理其实就是一个过滤器链,内部包含了各种功能的过滤器。

SpringSecurity认证底层实现

image.png

SpringSecurity授权底层实现

image.png

SpringSecurity的完整流程

image.png

  • UsernamePasswordAuthenticationFilter:处理登录页面填写了用户名密码后的登录请求。
  • ExceptionTranslationFilter:处理过滤器链种抛出的任何AccessDeniedException和AuthenticationException。
  • FilterSecurityInterceptor:处理权限校验的过滤器。

SpringSecurity相关的过滤器

  • Filter:Filter是Servlet规范中的一个接口,用于对请求和响应进行过滤。Spring Security的安全功能主要基于Filter实现。
  • DelegationFilterProxy:Spring Security提供的一个Filter的实现,可以在Servlet容器和Spring容器之间建立桥梁,通过使用DelegatingFilterProxy,这样可以将Servlet容器中的Filter实例放在Spring容器中管理。
  • FilterChainProxy:SpringSecurity提供的一个Filter的实现,利用FilterChainProxy可以通过SecurityFilterChain将过滤器的工作委托给多个Bean Filter实例。
  • SecurityFilterChain:提供给FilterChainProxy使用,负责查找请求需要执行的Security Filter列表。
  • Multiple SecurityFilterChain:允许在一个应用程序中定义多个SecurityFilterChain,每个SecurityFilterChain可以包含不同的安全过滤器链,用于处理不同的请求。比如/api/** 请求对应SecurityFilterChain0,/demo/** 请求对应SecurityFilterChain1。
  • DefaultSecurityFilterChain:实现SecurityFilterChain接口的默认安全过滤器链类,定义了一组默认的安全过滤器,用于处理请求的认证、授权、会话管理等功能。DefaultSecurityFilterChain在Spring Security的配置中会被自动创建并生效,在应用程序启动时进行初始化。会在每个请求到达时被执行,按照配置的顺序依次调用内部包含的安全过滤器来进行认证、授权等操作。

SpringSecurity前后端不分离

前后端不分离快速入门

编写pom.xml配置文件

<parent>  
    <groupId>org.springframework.boot</groupId>  
    <artifactId>spring-boot-starter-parent</artifactId>  
    <version>2.7.17</version>  
    <relativePath/>  
</parent>

<dependencies>  
    <dependency>  
        <groupId>org.springframework.boot</groupId>  
        <artifactId>spring-boot-starter-web</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-thymeleaf</artifactId>  
    </dependency>  
</dependencies>

编写Controller

package com.wenxuan.controller;

import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;

/**
 * @author 文轩
 * @create 2024-02-29 15:29
 */
@Controller
public class IndexController {

    @GetMapping("/")
    public String index(){
        return "index";
    }
}

编写index页面

<html xmlns:th="http://www.thymeleaf.org">  
  
<body>  
<h1>Hello Security</h1>  
  
<!-- 可以自动处理路径,以?前面路径为根路径拼接上logout -->  
<a th:href="@{/logout}">logout1</a>  
<!-- 无法自动处理路径,以http://localhost/为根路径拼接上logout -->  
<a href="/logout">logout2</a>  
  
</body>  
</html>

配置自定义的用户名和密码

spring:  
    security:  
        user:  
            name: wenxuan  
            password: 123456

自定义基于内存的用户认证

自定义基于内存的用户认证流程:

  1. 创建InmemoryUserDetailsManager对象,创建User对象封装用户名和密码,使用InMemoryUserDetailsManager将User存入内存。
  2. SpringSecurity自动使用InMemoryUserDetailsManager的loadUserByUsername方法从内存中获取User对象。
  3. 在UsernamePasswordAuthenticationFilter过滤器中的attemptAuthentication方法中将用户输入的用户名密码和从内存中获取到的用户信息进行比较,实现用户认证。
package com.wenxaun.springsecuritydemo.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;

/**
 * @author 文轩
 * @create 2024-02-29 16:47
 */
@Configuration
@EnableWebSecurity  //Spring项目总需要添加此注解,SpringBoot项目中可省略
public class WebSecurityConfig {

    @Bean
    public UserDetailsService userDetailsService() {
        // 创建一个基于内存的用户信息管理器
        InMemoryUserDetailsManager manager = new InMemoryUserDetailsManager();
        // 使用manager管理UserDetails对象,将用户信息保存到内存
        manager.createUser(
                User.withDefaultPasswordEncoder()
                .username("wenxuan")        //自定义用户名
                .password("123456")   //自定义密码
                .roles("USER")          //自定义角色
                .build()
        );
        return manager;
    }
}

自定义基于数据库的用户认证

自定义基于数据库的用户认证流程:

  1. 程序启动时会创建自定义的DBUserDetailsManager对象,自定义的DBUserDetailsManager对象必须实现UserDetailsManager, UserDetailsPasswordService接口。
  2. 校验用户时SpringSecurity自动使用DBUserDetailsManager的loadUserByUsername方法从数据库中获取User对象。
  3. 在UsernamePasswordAuthenticationFilter过滤器中的attemptAuthentication方法中将用户输入的用户名和密码和从数据库中获取到的用户信息进行比较,进行用户认证。

定义DBUserDetailsManager

package com.wenxuan.config;

import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.wenxuan.bean.User;
import com.wenxuan.mapper.UserMapper;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsPasswordService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.provisioning.UserDetailsManager;
import org.springframework.stereotype.Component;

import javax.annotation.Resource;
import java.util.ArrayList;
import java.util.Collection;

/**
 * @author 文轩
 * @create 2024-03-01 9:26
 */
@Component
public class DBUserDetailsManager implements UserDetailsManager, UserDetailsPasswordService {

    @Resource
    private UserMapper userMapper;

    @Override
    public UserDetails updatePassword(UserDetails user, String newPassword) {
        return null;
    }

    @Override
    public void createUser(UserDetails user) {

    }

    @Override
    public void updateUser(UserDetails user) {

    }

    @Override
    public void deleteUser(String username) {

    }

    @Override
    public void changePassword(String oldPassword, String newPassword) {

    }

    @Override
    public boolean userExists(String username) {
        return false;
    }

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        QueryWrapper<User> queryWrapper = new QueryWrapper<>();
        queryWrapper.eq("username", username);
        User user = userMapper.selectOne(queryWrapper);
        if (user == null) {
            throw new UsernameNotFoundException(username);
        } else {
            Collection<GrantedAuthority> authorities = new ArrayList<>();
            return new org.springframework.security.core.userdetails.User(
                    user.getUsername(),
                    user.getPassword(),
                    true, // 用户账号是否可用
                    true, //用户账号是否过期
                    true, //用户凭证是否过期
                    true, //用户是否未被锁定
                    authorities); //权限列表
        }
    }
}

自定义用户认证授权方式

设置基本授权方式

image.png

package com.wenxuan.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.web.SecurityFilterChain;

import static org.springframework.security.config.Customizer.withDefaults;

/**
 * @author 文轩
 * @create 2024-02-29 16:49
 */
@Configuration
@EnableWebSecurity//Spring项目总需要添加此注解,SpringBoot项目中不需要
public class WebSecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        // 开启授权保护
        http.authorizeRequests(
                // 对所有请求开启授权保护
                authorize -> authorize.anyRequest()
                        // 已认证请求会自动被授权
                        .authenticated())
                //基本授权方式
                .httpBasic(withDefaults());

        return http.build();
    }
}

设置为表单授权方式

image.png

package com.wenxuan.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.web.SecurityFilterChain;

import static org.springframework.security.config.Customizer.withDefaults;

/**
 * @author 文轩
 * @create 2024-02-29 16:49
 */
@Configuration
@EnableWebSecurity//Spring项目总需要添加此注解,SpringBoot项目中不需要
public class WebSecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        // 开启授权保护
        http.authorizeRequests(
                // 对所有请求开启授权保护
                authorize -> authorize.anyRequest()
                        // 已认证请求会自动被授权
                        .authenticated())
                .formLogin(withDefaults());//表单授权方式

        return http.build();
    }
}

自定义登录页面

注意:

  1. 自定义登录页面后登录页面的表单的参数必须和WebSecurityConfig中设置的参数名保持一致。
package com.wenxuan.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.web.SecurityFilterChain;


/**
 * @author 文轩
 * @create 2024-02-29 16:49
 */
@Configuration
@EnableWebSecurity//Spring项目总需要添加此注解,SpringBoot项目中不需要
public class WebSecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        // 开启授权保护
        http.authorizeRequests(
                // 对所有请求开启授权保护
                authorize -> authorize.anyRequest()
                        // 已认证请求会自动被授权
                        .authenticated())
                .formLogin( form -> {
                    form.loginPage("/login").permitAll() //登录页面无需授权即可访问
                            .usernameParameter("username") //自定义表单用户名参数,默认是username
                            .passwordParameter("password") //自定义表单密码参数,默认是password
                            .failureUrl("/login?error"); //登录失败的返回地址
                }); //使用表单授权方式
        return http.build();
    }
}

用户认证成功处理器

指定认证成功的处理器

package com.wenxuan.config;

import com.wenxuan.handle.MyAuthenticationFailureHandler;
import com.wenxuan.handle.MyAuthenticationSuccessHandler;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.web.SecurityFilterChain;


/**
 * @author 文轩
 * @create 2024-02-29 16:49
 */
@Configuration
@EnableWebSecurity//Spring项目总需要添加此注解,SpringBoot项目中不需要
public class WebSecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        // 开启授权保护
        http.authorizeRequests(
                // 对所有请求开启授权保护
                authorize -> authorize.anyRequest()
                        // 已认证请求会自动被授权
                        .authenticated())
                .formLogin( form -> {
                    form.loginPage("/login").permitAll() //登录页面无需授权即可访问
                            .usernameParameter("username") //自定义表单用户名参数,默认是username
                            .passwordParameter("password") //自定义表单密码参数,默认是password
                            .failureUrl("/login?error") //登录失败的返回地址
                            .successHandler(new MyAuthenticationSuccessHandler())// 设置认证成功的处理
                }); //使用表单授权方式
        return http.build();
    }
}

编写认证成功的处理器

package com.wenxuan.handle;

import com.alibaba.fastjson2.JSON;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;

import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.Collection;
import java.util.HashMap;
import java.util.Map;

/**
 * @author 文轩
 * @create 2024-03-01 14:52
 *
 * 用户认证成功的处理
 */
public class MyAuthenticationSuccessHandler implements AuthenticationSuccessHandler {

    /**
     * 认证成功的处理
     */
    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {

        // 用户身份信息
        Object principal = authentication.getPrincipal();
        // 用户凭证信息
        Object credentials = authentication.getCredentials();
        // 用户权限信息
        Collection<? extends GrantedAuthority> authorities = authentication.getAuthorities();
        Map<String, Object> result = new HashMap<>();
        result.put("principal", principal);
        result.put("credentials", credentials);

        // 将结果对象转换成JSON字符串
        String json = JSON.toJSONString(result);
        // 设置响应类型,并返回响应数据
        response.setContentType("application/json;charset=UTF-8");
        response.getWriter().println(json);
    }
}

用户认证失败处理器

指定认证失败的处理器

package com.wenxuan.config;

import com.wenxuan.handle.MyAuthenticationFailureHandler;
import com.wenxuan.handle.MyAuthenticationSuccessHandler;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.web.SecurityFilterChain;


/**
 * @author 文轩
 * @create 2024-02-29 16:49
 */
@Configuration
@EnableWebSecurity//Spring项目总需要添加此注解,SpringBoot项目中不需要
public class WebSecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        // 开启授权保护
        http.authorizeRequests(
                // 对所有请求开启授权保护
                authorize -> authorize.anyRequest()
                        // 已认证请求会自动被授权
                        .authenticated())
                .formLogin( form -> {
                    form.loginPage("/login").permitAll() //登录页面无需授权即可访问
                            .usernameParameter("username") //自定义表单用户名参数,默认是username
                            .passwordParameter("password") //自定义表单密码参数,默认是password
                            .failureUrl("/login?error") //登录失败的返回地址
                            .failureHandler(new MyAuthenticationFailureHandler());// 设置认证失败的处理
                }); //使用表单授权方式
        return http.build();
    }
}

编写认证失败的处理器

package com.wenxuan.handle;

import com.alibaba.fastjson2.JSON;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.AuthenticationFailureHandler;

import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.HashMap;
import java.util.Map;

/**
 * @author 文轩
 * @create 2024-03-01 15:01
 *
 * 用户认证失败的处理
 */
public class MyAuthenticationFailureHandler implements AuthenticationFailureHandler {
    @Override
    public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {
        //获取错误信息
        String localizedMessage = exception.getLocalizedMessage();

        // 创建返回结果对象
        Map<String, Object> result = new HashMap<>();
        result.put("code", -1);
        result.put("msg", localizedMessage);
        // 将结果对象转换成JSON字符串
        String json = JSON.toJSONString(result);

        // 设置响应类型,并返回JSON字符串
        response.setContentType("application/json;charset=UTF-8");
        response.getWriter().write(json);
    }
}

用户注销成功处理器

SpringSecurity默认的注销是返回到登录页面,可以通过下面步骤自定义用户注销的响应:

设置注销成功的处理器

package com.wenxuan.config;

import com.wenxuan.handle.MyAuthenticationFailureHandler;
import com.wenxuan.handle.MyAuthenticationSuccessHandler;
import com.wenxuan.handle.MyLogoutSuccessHandler;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.web.SecurityFilterChain;


/**
 * @author 文轩
 * @create 2024-02-29 16:49
 */
@Configuration
@EnableWebSecurity//Spring项目总需要添加此注解,SpringBoot项目中不需要
public class WebSecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        // 设置注销成功的处理器
        http.logout(logout -> {
            logout.logoutSuccessHandler(new MyLogoutSuccessHandler());
        });
        return http.build();
    }
}

编写注销成功的处理器

package com.wenxuan.handle;

import com.alibaba.fastjson2.JSON;
import org.springframework.security.core.Authentication;
import org.springframework.security.web.authentication.logout.LogoutSuccessHandler;

import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.HashMap;
import java.util.Map;

/**
 * @author 文轩
 * @create 2024-03-01 15:21
 */
public class MyLogoutSuccessHandler implements LogoutSuccessHandler {
    @Override
    public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
        // 创建注销的返回对象
        Map<String, Object> result = new HashMap<>();
        result.put("code", 0);
        result.put("message", "注销成功");

        // 将返回对象转换成JSON字符串
        String json = JSON.toJSONString(result);
        // 返回响应信息
        response.setContentType("application/json;charset=UTF-8");
        response.getWriter().println(json);

    }
}

用户未登录处理器

设置用户未登录的处理器

package com.wenxuan.config;

import com.wenxuan.handle.MyAuthenticationEntryPoint;
import com.wenxuan.handle.MyAuthenticationFailureHandler;
import com.wenxuan.handle.MyAuthenticationSuccessHandler;
import com.wenxuan.handle.MyLogoutSuccessHandler;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.web.SecurityFilterChain;


/**
 * @author 文轩
 * @create 2024-02-29 16:49
 */
@Configuration
@EnableWebSecurity//Spring项目总需要添加此注解,SpringBoot项目中不需要
public class WebSecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        //设置错误处理
        http.exceptionHandling(exception  -> {
            exception.authenticationEntryPoint(new MyAuthenticationEntryPoint());//请求未认证的接口
        });

        return http.build();
    }
}

编写用户未登录的处理器

package com.wenxuan.handle;

import com.alibaba.fastjson2.JSON;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;

import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.HashMap;

/**
 * @author 文轩
 * @create 2024-03-01 16:41
 */
public class MyAuthenticationEntryPoint implements AuthenticationEntryPoint {
    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
        //创建结果对象
        HashMap result = new HashMap();
        result.put("code", -1);
        result.put("message", "需要登录");

        //转换成json字符串
        String json = JSON.toJSONString(result);

        //返回响应
        response.setContentType("application/json;charset=UTF-8");
        response.getWriter().println(json);
    }
}

获取用户认证信息

SpringSecurity框架中的用户认证信息涉及SecurityContextHolder、SecurityContext、Authentication、Principal和Credential。

  • SecurityContextHolder:Spring Security 存储已认证用户详细信息的地方。
  • SecurityContext:从 SecurityContextHolder 获取的内容,包含当前已认证用户的 Authentication 信息。
  • Authentication:表示用户的身份认证信息。它包含了用户的Principal、Credential和Authority信息。
  • Principal:表示用户的身份标识。它通常是一个表示用户的实体对象,例如用户名。Principal可以通过Authentication对象的getPrincipal()方法获取。
  • Credentials:表示用户的凭证信息,例如密码、证书或其他认证凭据。Credential可以通过Authentication对象的getCredentials()方法获取。
  • GrantedAuthority:表示用户被授予的权限。

securitycontextholder.png

package com.wenxuan.controller;

import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.Collection;
import java.util.HashMap;
import java.util.Map;

/**
 * @author 文轩
 * @create 2024-02-29 15:29
 */
@RestController
public class IndexController {

    @GetMapping("/")
    public Map index(){
        //存储认证对象的上下文
        SecurityContext context = SecurityContextHolder.getContext();
        //认证对象
        Authentication authentication = context.getAuthentication();
        //用户名
        String username = authentication.getName();
        //身份
        Object principal =authentication.getPrincipal();
        //凭证,但是由于SpringSecurity将其进行了脱敏,所以该对象为null
        Object credentials = authentication.getCredentials();
        //权限信息
        Collection<? extends GrantedAuthority> authorities = authentication.getAuthorities();

        //创建结果对象
        HashMap result = new HashMap();
        result.put("code", 0);
        result.put("data", username);
        return result;
    }
}

会话并发处理

当多台设备登录账号时,可以通过会话并发设置进行处理。后登录的账号会使先登录的账号失效。

设置用户未登录的处理器

package com.wenxuan.config;

import com.wenxuan.handle.*;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.web.SecurityFilterChain;

import static org.springframework.security.config.Customizer.withDefaults;


/**
 * @author 文轩
 * @create 2024-02-29 16:49
 */
@Configuration
@EnableWebSecurity//Spring项目总需要添加此注解,SpringBoot项目中不需要
public class WebSecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        //会话管理
        http.sessionManagement(session -> {
            session
                    .maximumSessions(1)
                    .expiredSessionStrategy(new MySessionInformationExpiredStrategy());
        });
        return http.build();
    }
}

编写会话并发的处理器

package com.wenxuan.handle;

import com.alibaba.fastjson2.JSON;
import org.springframework.security.web.session.SessionInformationExpiredEvent;
import org.springframework.security.web.session.SessionInformationExpiredStrategy;

import javax.servlet.ServletException;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.HashMap;

/**
 * @author 文轩
 * @create 2024-03-01 17:23
 */
public class MySessionInformationExpiredStrategy implements SessionInformationExpiredStrategy {
    @Override
    public void onExpiredSessionDetected(SessionInformationExpiredEvent event) throws IOException, ServletException {

        //创建结果对象
        HashMap result = new HashMap();
        result.put("code", -1);
        result.put("message", "该账号已从其他设备登录");

        //转换成json字符串
        String json = JSON.toJSONString(result);

        HttpServletResponse response = event.getResponse();
        //返回响应
        response.setContentType("application/json;charset=UTF-8");
        response.getWriter().println(json);
    }
}

OAuth2

OAuth2是一种授权框架,用于授权第三方应用在用户身份验证之后获取有限访问用户资源的权限。OAuth2的主要作用是允许用户在不直接向第三方应用提供其用户名和密码的情况下,授权第三方应用访问其受保护的资源。

OAuth2的使用场景

  1. 第三方登录:允许用户使用其在其他服务上的身份验证信息来登录应用程序,提供更方便快捷的登录方式。
  2. API访问控制:通过OAuth2可以对第三方应用对API的访问进行细粒度的控制,确保数据的安全性和隐私性。
  3. 单点登录SSO:OAuth2可以作为实现单点的呢牢固的一种方式,用户登录一个系统后,在访问其他系统时无需重新登录,提供了更好的用户体验。

OAuth2协议包含的角色

  1. 资源所有者:即用户,资源的用有人,想要通过客户应用访问资源服务器上的资源。
  2. 客户应用:通常是一个Web或者无线应用,需要访问用户受保护的资源。
  3. 资源服务器:存储受保护资源的服务器或定义了可以访问到资源的API,接收并验证客户端的访问令牌,以决定是否授权访问资源。
  4. 授权服务器:负责验证资源所有者的身份并向客户端颁发访问令牌。

image.png

image.png

OAuth2的四种授权模式

  1. 授权码授权:适用于Web应用程序,在这种模式下,用户跳转到授权服务器,登录并授权客户端应用,授权服务器返回授权码给客户端应用,然后客户端使用这个授权码和自己的凭证向授权服务器请求访问令牌。这种模式安全性较高,适用于需要保护用户凭据的情况。

image-20231220180422742.png

  1. 简化模式:这种模式适用于纯客户端应用,如JavaScript应用或移动端应用。在这种模式下,客户端直接从授权服务器获取访问令牌,而无需请求授权码。这种模式安全性较低,适用于对安全性要求不高的场景。

image-20231220185958063.png

  1. 密码授权:这种模式允许用户提供用户名和密码直接给客户端应用,客户端应用使用这些凭据向授权服务器请求访问令牌。这种模式简单直接,但安全性较低,不推荐在生产环境下使用,只适用于第三方应用是绝对信任的场景。

image-20231220190152888.png

  1. 客户端凭证授权:这种模式适用于客户端应用之间的通信,而不涉及用户的授权。客户端应用使用自己的凭证向授权服务器请求访问令牌,然后使用这个访问令牌来访问受保护的资源。这种模式适用于机器对机器通信,不涉及用户身份的场景。

image-20231220185958063.png

OAuth2实现GitHub登录

OAuth2的GItHub登录流程

  1. 用户点击网站的GitHub登录,网站跳转到GitHub,并携带参数ClientId和Redirection URI。
  2. 用户点击同意授权,GitHub会重定向回网站,同时携带授权码。
  3. 网站使用授权码向GitHub请求令牌,GitHub返回令牌。
  4. 网站使用令牌向GitHub请求用户数据,GitHub返回用户数据。

创建OAuth2应用

  1. 打开GitHub的设置

image.png

  1. 打开开发者设置

image.png

  1. 打开OAuth应用,并注册一个新的应用

image.png

image.png

  1. 获取并保存Client ID和Client secrets

image.png

实现GitHub登录功能

添加pom.xml依赖

<parent>  
    <groupId>org.springframework.boot</groupId>  
    <artifactId>spring-boot-starter-parent</artifactId>  
    <version>2.7.17</version>  
    <relativePath/>  
</parent>

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</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-thymeleaf</artifactId>
    </dependency>

    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-oauth2-client</artifactId>
    </dependency>
</dependencies>

编写application.yml

spring:
  security:
    oauth2:
      client:
        registration:
          github:
            client-id: xxx
            client-secret: xxx

编写登录成功后返回回调Controller

package com.wenxuan.controller;

import org.springframework.security.oauth2.client.OAuth2AuthorizedClient;
import org.springframework.security.oauth2.client.annotation.RegisteredOAuth2AuthorizedClient;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.security.core.annotation.AuthenticationPrincipal;

/**
 * @author 文轩
 * @create 2024-03-02 22:38
 */
@Controller
public class IndexController {

    @GetMapping("/")
    public String index(
            Model model,
            @RegisteredOAuth2AuthorizedClient OAuth2AuthorizedClient authorizedClient,
            @AuthenticationPrincipal OAuth2User oauth2User) {
        model.addAttribute("userName", oauth2User.getName());
        model.addAttribute("clientName", authorizedClient.getClientRegistration().getClientName());
        model.addAttribute("userAttributes", oauth2User.getAttributes());
        return "index";
    }
}

编写index页面

<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="https://www.thymeleaf.org" xmlns:sec="https://www.thymeleaf.org/thymeleaf-extras-springsecurity5">
<head>
    <title>Spring Security - OAuth 2.0 Login</title>
    <meta charset="utf-8" />
</head>
<body>
<div style="float: right" th:fragment="logout" sec:authorize="isAuthenticated()">
    <div style="float:left">
        <span style="font-weight:bold">User: </span><span sec:authentication="name"></span>
    </div>
    <div style="float:none">&nbsp;</div>
    <div style="float:right">
        <form action="#" th:action="@{/logout}" method="post">
            <input type="submit" value="Logout" />
        </form>
    </div>
</div>
<h1>OAuth 2.0 Login with Spring Security</h1>
<div>
    You are successfully logged in <span style="font-weight:bold" th:text="${userName}"></span>
    via the OAuth 2.0 Client <span style="font-weight:bold" th:text="${clientName}"></span>
</div>
<div>&nbsp;</div>
<div>
    <span style="font-weight:bold">User Attributes:</span>
    <ul>
        <li th:each="userAttribute : ${userAttributes}">
            <span style="font-weight:bold" th:text="${userAttribute.key}"></span>: <span th:text="${userAttribute.value}"></span>
        </li>
    </ul>
</div>
</body>
</html>