Spring Security学习笔记(三)Spring Security+JWT认证授权流程代码实例

前言:本系列博客基于Spring Boot 2.6.x依赖的Spring Security5.6.x版本

上两篇文章介绍了Spring Security的整体架构以及认证和鉴权模块原理。本篇文章就是基于Spring Security和JWT的一个demo

一、JWT简介

JWT(JSON Web Token),是一种开放标准(RFC 7519),用于在网络应用环境间安全地传输信息。本质上是一个经过数字签名的JSON对象,能够携带并传递状态信息(如用户身份验证、授权等)

1.1、JWT的结构

JWT由三部分组成,通过点号(.)连接,这三部分分别是头部(Header)、载荷(Payload)和签名(Signature)。类似于xxxx.xxxx.xxxx格式。如下:

eyJhbGciOiJIUzUxMiJ9.eyJMT0dJTl9USU1FIjoxNzIyMzEzMDg4NTU4LCJMT0dJTl9VU0VSIjoidXNlcjIiLCJleHAiOjE3MjIzMTY2ODh9.l-mw4sWCWvIrWSRHUPdiLlgH6tIFxbwx7KwUj0Ldf4CDbdOqQlDuj-x0y6zM4R84vmnRLBBDeH_oLRxx0rcNxQ
  • Header:头部,声明了JWT的类型(通常是JWT)以及所使用的加密算法(例如HMAC SHA256或RSA)
  • Payload:载荷,承载实际数据的部分,可以包含预定义的声明(如iss(签发者)、exp(过期时间)、sub(主题)等)以及其它自定义的数据。这些信息都是铭文的,但不建议存放敏感信息。
  • Signature:签名,通过对前两部分进行编码后的信息,使用指定的密钥通过头部(Header)中声明的加密算法生成,拥有验证数据完整性和防止篡改。

这三部分单独使用base64编码后再通过点号(.)连接。

这里只简单介绍JWT,如果需要详细了解JWT的可以参考以下文章

https://blog.csdn.net/weixin_42753193/article/details/126294904
https://www.cnblogs.com/moonlightL/p/10020732.html
JWT官网

二、Spring Security+JWT认证授权流程代码代码实例

2.1、新建Springboot项目,引入JAR包

新建好Springboot项目,引入用到的jar包
pom文件(只写出了dependencies):

	<!--Springboot父工程,定义好了Springboot集成的其他jar包版本,所以引入某些jar时可以不写版本号-->
	<parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.6.15</version>
    </parent>
    
	<dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
            <exclusions>
                <exclusion>
                    <groupId>org.springframework.boot</groupId>
                    <artifactId>spring-boot-starter-tomcat</artifactId>
                </exclusion>
            </exclusions>
        </dependency>


        <!--使用undertow容器-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-undertow</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>

        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-thymeleaf</artifactId>
        </dependency>

        <!--自定义配置生成元数据信息,这样在配置文件中可以有提示-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-configuration-processor</artifactId>
            <optional>true</optional>
        </dependency>

        <!--Spring Security-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-security</artifactId>
        </dependency>

        <!--mysql-->
        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
        </dependency>

        <!--mybatis-plus-->
        <dependency>
            <groupId>com.baomidou</groupId>
            <artifactId>mybatis-plus-boot-starter</artifactId>
        </dependency>

        <dependency>
            <groupId>com.alibaba.fastjson2</groupId>
            <artifactId>fastjson2</artifactId>
        </dependency>

        <dependency>
            <groupId>org.apache.commons</groupId>
            <artifactId>commons-lang3</artifactId>
        </dependency>

        <!-- JSON Web Token Support -->
        <dependency>
            <groupId>io.jsonwebtoken</groupId>
            <artifactId>jjwt</artifactId>
        </dependency>

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

    </dependencies>

application.yaml配置文件:

server:
  port: 8084
  servlet:
    context-path: /security

mybatis-plus:
  mapper-locations: classpath*:mapper/**/*Mapper.xml
  # 使用驼峰命名
  # 数据库表列:user_name
  # 实体类属性:userName
  configuration:
    map-underscore-to-camel-case: true

Spring:
  redis:
    host: 127.0.0.1
    port: 6379
    lettuce:
      pool:
        max-idle: 16
        max-active: 32
        min-idle: 8
  datasource:
    # 数据源基本配置
    username: root
    password: root1234
    url: jdbc:mysql://127.0.0.1:3306/test?allowPublicKeyRetrieval=true&useUnicode=true&characterEncoding=utf-8&useSSL=false&serverTimezone=Asia/Shanghai
    # driver-class需要注意mysql驱动的版本(com.mysql.cj.jdbc.Driver 或 com.mysql.jdbc.Driver)
    driver-class-name: com.mysql.cj.jdbc.Driver
    type: com.zaxxer.hikari.HikariDataSource
    hikari:
      pool-name: Retail_HikariCP
      minimum-idle: 5 #最小空闲连接数
      idle-timeout: 180000 #空闲连接存活最长时间 默认600000(10分钟)
      maximum-pool-size: 10 #连接池最大连接数,默认10
      auto-commit: true #此属性控制从连接池返回的连接的默认自动提交行为,默认true
      max-lifetime: 1800000 #连接的最长生命周期,0表示无限,默认1800000即30分钟
      connection-timeout: 30000 #数据库连接超时时间,默认30秒,即3000
      connection-test-query: SELECT 1 FROM DUAL

2.2、数据库操作相关类

数据库脚本(mysql):

create table `manager`(
	`id` int NOT NULL AUTO_INCREMENT,
	`login_name` varchar(15) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '登录名',
	`password` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '密码',
	`name` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '姓名',
	`id_number` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '身份证',
	`mobile` varchar(15) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '手机号',
	`email` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '邮箱',
	PRIMARY KEY (`id`) USING BTREE
)ENGINE = InnoDB AUTO_INCREMENT = 5 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci ROW_FORMAT = Dynamic COMMENT = '管理员表';
##密码是123456
INSERT INTO `manager` (`id`, `login_name`, `password`, `name`, `id_number`, `mobile`, `email`) VALUES (1, 'user1', '$2a$10$JrdOPx3zKcNqLQnU7GrdUeE2XA3KXZgu3QqLCeBTJWPxJjOOfOHGG', '张三', NULL, NULL, NULL);
INSERT INTO `manager` (`id`, `login_name`, `password`, `name`, `id_number`, `mobile`, `email`) VALUES (2, 'user2', '$2a$10$JrdOPx3zKcNqLQnU7GrdUeE2XA3KXZgu3QqLCeBTJWPxJjOOfOHGG', '李四', NULL, NULL, NULL);

create table `role`(
	`id` int NOT NULL AUTO_INCREMENT,
	`name` varchar(15) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '角色名',
	`code` varchar(15) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '角色编码',
	`type` varchar(10) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '角色类别',
	`description` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '角色描述',
	PRIMARY KEY (`id`) USING BTREE
)ENGINE = InnoDB AUTO_INCREMENT = 5 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci ROW_FORMAT = Dynamic COMMENT = '角色表';

INSERT INTO `role` (`id`, `name`, `code`, `type`, `description`) VALUES (1, '管理员角色', 'AdminManager', 'admin', NULL);
INSERT INTO `role` (`id`, `name`, `code`, `type`, `description`) VALUES (2, '审批用户角色', 'ApproveUser', 'approve', NULL);

create table `permission`(
	`id` int NOT NULL AUTO_INCREMENT,
	`name` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '权限名',
	`code` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '权限编码',
	`type` varchar(10) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '权限类别',
	`url` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '资源权限路径',
	`anonymous` int NOT NULL COMMENT '是否可以匿名访问 1-是 0-否',
	`description` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '权限描述',
	PRIMARY KEY (`id`) USING BTREE
)ENGINE = InnoDB AUTO_INCREMENT = 5 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci ROW_FORMAT = Dynamic COMMENT = '权限表';

INSERT INTO `permission` (`id`, `name`, `code`, `type`, `url`, `description`, `anonymous`) VALUES (1, '主页接口', 'main', 'interface', '/main', NULL, 0);
INSERT INTO `permission` (`id`, `name`, `code`, `type`, `url`, `description`, `anonymous`) VALUES (2, '测试接口1', 'test1', 'interface', '/adminRole', NULL, 0);
INSERT INTO `permission` (`id`, `name`, `code`, `type`, `url`, `description`, `anonymous`) VALUES (3, '测试接口2', 'test2', 'interface', '/touristRole', NULL, 0);
INSERT INTO `permission` (`id`, `name`, `code`, `type`, `url`, `description`, `anonymous`) VALUES (4, '登录接口', 'login', 'interface', '/login', NULL, 1);
INSERT INTO `permission` (`id`, `name`, `code`, `type`, `url`, `description`, `anonymous`) VALUES (5, '注销接口', 'logout', 'interface', '/myLogout', NULL, 1);

create table `manager_role_rel`(
	`id` int NOT NULL AUTO_INCREMENT,
	`manager_id` int NOT NULL COMMENT '用户id',
	`role_id` int NOT NULL COMMENT '角色id',
	PRIMARY KEY (`id`) USING BTREE
)ENGINE = InnoDB AUTO_INCREMENT = 5 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci ROW_FORMAT = Dynamic COMMENT = '用户角色关联表';

INSERT INTO `manager_role_rel` (`id`, `manager_id`, `role_id`) VALUES (1, 1, 1);
INSERT INTO `manager_role_rel` (`id`, `manager_id`, `role_id`) VALUES (2, 2, 2);
INSERT INTO `manager_role_rel` (`id`, `manager_id`, `role_id`) VALUES (3, 1, 2);

create table `role_permission_rel`(
	`id` int NOT NULL AUTO_INCREMENT,
	`role_id` int NOT NULL COMMENT '用户id',
	`permission_id` int NOT NULL COMMENT '角色id',
	PRIMARY KEY (`id`) USING BTREE
)ENGINE = InnoDB AUTO_INCREMENT = 5 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci ROW_FORMAT = Dynamic COMMENT = '角色权限关联表';

INSERT INTO `role_permission_rel` (`id`, `role_id`, `permission_id`) VALUES (1, 1, 1);
INSERT INTO `role_permission_rel` (`id`, `role_id`, `permission_id`) VALUES (2, 1, 2);
INSERT INTO `role_permission_rel` (`id`, `role_id`, `permission_id`) VALUES (3, 1, 3);
INSERT INTO `role_permission_rel` (`id`, `role_id`, `permission_id`) VALUES (4, 2, 1);
INSERT INTO `role_permission_rel` (`id`, `role_id`, `permission_id`) VALUES (5, 2, 3);

实体类:

@Data
@TableName("manager")
public class ManagerDomain {
    @TableId(type = IdType.AUTO)
    private Integer id;
    //@TableField("user_name")
    private String loginName;
    private String password;
    private String name;
    private String idNumber;
    private String mobile;
    private String email;
}

@Data
@TableName("permission")
public class PermissionDomain {
    @TableId(type = IdType.AUTO)
    private Integer id;
    private String name;
    private String code;
    private String type;
    private String url;
    private String description;
    private Integer anonymous;
}

@Data
@TableName("role")
public class RoleDomain {
    @TableId(type = IdType.AUTO)
    private Integer id;
    private String name;
    private String code;
    private String description;
}

mybatis的Mapper接口及配置文件:

@Mapper
public interface ManagerMapper extends BaseMapper<ManagerDomain> {
}

@Mapper
public interface PermissionMapper extends BaseMapper<PermissionDomain> {
    /**
     * 根据角色code获取该角色的资源权限url
     * @param roleCode
     * @return
     */
    List<String> getPermissionUrlByRole(String roleCode);

    List<String> getAnonymousPermissionUrl();
}

@Mapper
public interface RoleMapper extends BaseMapper<RoleDomain> {
    /**
     * 根据用户id获取该用户拥有的角色的code
     * @param managerId
     * @return
     */
    List<String> getRoleCodeByManagerId(Integer managerId);

    /**
     * 获取所有角色的code
     * @return
     */
    List<String> getAllRoleCode();
}
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
        PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
        "https://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.dmf.demo.jwt.security.dao.ManagerMapper">

</mapper>

<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
        PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
        "https://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.dmf.demo.jwt.security.dao.PermissionMapper">
    <select id="getPermissionUrlByRole" resultType="java.lang.String">
        select p.url
            from role r
        LEFT JOIN role_permission_rel rpr on r.id = rpr.role_id
        left join permission p on rpr.permission_id = p.id
        where r.code = #{roleCode}
    </select>
    <select id="getAnonymousPermissionUrl" resultType="java.lang.String">
        select url from permission where anonymous = 1
    </select>
</mapper>

<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
        PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
        "https://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.dmf.demo.jwt.security.dao.RoleMapper">

    <select id="getRoleCodeByManagerId" resultType="java.lang.String">
        select r.code
            from role r
        left join manager_role_rel mrr on mrr.role_id = r.id
        WHERE mrr.manager_id= #{managerId}
    </select>
    <select id="getAllRoleCode" resultType="java.lang.String">
        select code from role
    </select>
</mapper>

2.3、Controller和Service

@Slf4j
@Controller
public class SystemController {
    @Autowired
    private SystemService systemService;

    /**
     * 登录
     * @param userName
     * @param password
     * @return
     */
    @RequestMapping("/login")
    @ResponseBody
    public String login(String userName, String password){
        log.info("用户{}登录",userName);
        return systemService.login(userName,password);
    }

	@RequestMapping("/myLogout")
    @ResponseBody
    public String logout(HttpServletRequest request){
        systemService.logout(request);
        return "success";
    }
    
    /**
     * @return
     */
    @RequestMapping("/adminRole")
    @ResponseBody
    public String adminRole(){
        return "success";
    }

    @RequestMapping("/touristRole")
    @ResponseBody
    public String touristRole(){
        return "success";
    }
}

service接口及实现类

public interface SystemService {
    String login(String userName,String password);
    void logout(HttpServletRequest request);
}

SystemService 实现类:

@Slf4j
@Service
public class SystemServiceImpl implements SystemService {

    @Resource
    private AuthenticationManager authenticationManager;
    @Resource
    private RedisTemplate<String,String> stringRedisTemplate;

    @Override
    public String login(String userName, String password) {
        //1、根据用户输入的用户名和密码创建认证凭证Authentication
        UsernamePasswordAuthenticationToken authenticationToken =
                new UsernamePasswordAuthenticationToken(userName, password);
        //2、调用AuthenticationManager认证管理器的authenticate方法进行认证操作,返回认证成功后的凭证Authentication
        Authentication authenticate = null;
        try {
            authenticate = authenticationManager.authenticate(authenticationToken);
        } catch (AuthenticationException e) {
            //这里自己捕获认证异常,自己处理,如果自己不处理的话,异常会交给自定义的AuthenticationEntryPoint处理
            //如果没定义AuthenticationEntryPoint,Spring Security会默认返回403
            log.error("登录失败!原因:{}",e.getMessage());
            throw new RuntimeException("登录失败!");
        }
        //3、生成jwt
        //拿到认证成功后的用户信息
        LoginUserDetails userDetails = (LoginUserDetails) authenticate.getPrincipal();
        String accessToken = JwtUtils.createToken(userDetails);

        //4、保存用户信息到redis
        LoginUserInfoDto loginUserInfoDto = LoginUserInfoDto.builder()
                .loginName(userDetails.getUsername())
                .id(userDetails.getManager().getId())
                .name(userDetails.getManager().getName())
                .mobile(userDetails.getManager().getMobile())
                .roles(userDetails.getRoles())
                .build();
        String key = GlobalConstants.LOGIN_CACHE_KEY_PREFIX+userDetails.getUsername();

        stringRedisTemplate.opsForValue().set(key, JSONObject.toJSONString(loginUserInfoDto),60, TimeUnit.MINUTES);
        return accessToken;
    }

    @Override
    public void logout(HttpServletRequest request) {
        String token = request.getHeader("token");
        if(StringUtils.isNotEmpty(token)){
            String userName = JwtUtils.getUserName(token);
            //清除redis
            if(StringUtils.isNotEmpty(userName)){
                String key = GlobalConstants.LOGIN_CACHE_KEY_PREFIX+userName;
                stringRedisTemplate.delete(key);
            }
        }
    }
}

用户登录信息实体类LoginUserInfoDto:

@Data
@Builder
public class LoginUserInfoDto {
    private Integer id;
    private String loginName;
    private String name;
    private String idNumber;
    private String mobile;
    private List<String> roles;

    /**
     * 组装spring security的权限
     * @return
     */
    public Collection<? extends GrantedAuthority> getAuthorities() {
        List<SimpleGrantedAuthority> grantedAuthorities = new ArrayList<>();
        if(!CollectionUtils.isEmpty(roles)){
            roles.forEach(roleCode ->{
                grantedAuthorities.add(new SimpleGrantedAuthority("ROLE_" + roleCode));
            });
        }
        return grantedAuthorities;
    }
}

全局常数类:

public class GlobalConstants {
    /**
     * 请求携带的token参数,参数名
     */
    public static final String HEADER_TOKEN_NAME = "token";
    /**
     * 用户登录信息缓存KEY前缀
     */
    public static final String LOGIN_CACHE_KEY_PREFIX = "USER_INFO:";
    /**
     * 全局资源权限缓存key
     */
    public static final String GLOBAL_PERMISSION_KEY_PREFIX = "GLOBAL_PERMISSION:";
    /**
     * 允许匿名访问资源缓存key
     */
    public static final String GLOBAL_PERMISSION_ANONYMOUS = "GLOBAL_PERMISSION:ANONYMOUS";
}

JWT工具类:

@Slf4j
public class JwtUtils {
    /** jwt加密秘钥*/
    public static final String DEFAULT_SECRET = "abcdefghijk";
    /** jwt数据声明里登录用户key*/
    public static final String LOGIN_USER = "LOGIN_USER";
    /** jwt数据声明里登录时间key*/
    public static final String LOGIN_TIME = "LOGIN_TIME";
    /** jwt默认过期时间*/
    public static Long DEFAULT_TTL = 60*60*1000l; //一个小时


    /**
     * 生成jwt使用默认设置
     * @param claims
     * @return
     */
    public static String createToken(Map<String, Object> claims){
        return createToken(claims,DEFAULT_TTL,DEFAULT_SECRET);
    }

    /**
     * 生成jwt
     * @param claims
     * @param ttl 过期时间 ms
     * @return
     */
    public static String createToken(Map<String, Object> claims,Long ttl){
        return createToken(claims,ttl,DEFAULT_SECRET);
    }

    /**
     *
     * @param userDetails Spring Security用户信息
     * @param ttl 过期时间 ms
     * @return
     */
    public static String createToken(UserDetails userDetails,Long ttl){
        Map<String, Object> claims = new HashMap<>();
        claims.put(LOGIN_USER,userDetails.getUsername());
        claims.put(LOGIN_TIME,new Date());

        return createToken(claims,ttl,DEFAULT_SECRET);
    }

    /**
     *
     * @param userDetails
     * @return
     */
    public static String createToken(UserDetails userDetails){
        return createToken(userDetails,DEFAULT_TTL);
    }

    /**
     * 生成jwt
     * @param claims
     * @return
     */
    public static String createToken(Map<String, Object> claims,Long ttl,String secret){
        return Jwts.builder()
                .setClaims(claims)  //设置数据
                .setExpiration(generateExpirationDate(ttl))
                .signWith(SignatureAlgorithm.HS512, secret) //签名,参数包括算法和秘钥
                .compact(); //压缩生成xxx.xxx.xxx
    }

    /**
     * 生成token的过期时间
     * @param ttl 单位是毫秒
     * @return
     */
    private static Date generateExpirationDate(Long ttl) {
        return new Date(System.currentTimeMillis() + ttl);
    }

    /**
     * 解析jwt拿到数据,使用默认配置
     * @param token
     * @return
     */
    public static Claims parseToken(String token){
        return  parseToken(token,DEFAULT_SECRET);
    }

    /**
     * 解析jwt拿到数据
     * @param token
     * @return
     */
    public static Claims parseToken(String token,String secret){
        Claims claims = Jwts.parser()
                .setSigningKey(secret)
                .parseClaimsJws(token)
                .getBody();
        return claims;
    }

    /**
     * 获取jwt里的用户名称
     * @param token
     * @return
     */
    public static String getUserName(String token){
        return (String)parseToken(token).get(LOGIN_USER);
    }

    /**
     * token是否已经过期
     * @param claims
     * @return
     */
    private static boolean isTokenExpired(Claims claims) {
        Date expire = claims.getExpiration();
        if(expire!=null){
            return expire.before(new Date());
        }
        return false;
    }
}

2.4、Spring Security自定义认证和鉴权

上篇文章已经介绍过Spring Security的认证和鉴权架构。
认证:
Spring Security的认证主要由AuthenticationManager -> AuthenticationProvider流程。而AuthenticationProvider调用UserDetailsService的loadUserByUsername方法先查询系统用户,再和用户输入的用户信息做比对认证。
所以自定义认证,我们只需要在配置类里定义自己的AuthenticationManager和AuthenticationProvider,以及实现UserDetailsService接口。另外UserDetails类的默认实现类User使用不方便,也可以实现自定义的UserDetails来做功能扩展

自定义的UserDetails实现类

@Data
@Builder
public class LoginUserDetails implements UserDetails {

    private ManagerDomain manager;
    private Integer id;
    private String username;
    private String password;
    private boolean enabled;
    private boolean locked;
    private Collection<? extends GrantedAuthority> grantedAuthorities;

    private List<String> roles;

    public Integer getUserId() {
        return this.manager.getId();
    }

    // 返回当前用户的权限列表
    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        if (grantedAuthorities != null)
            return this.grantedAuthorities;
        List<SimpleGrantedAuthority> grantedAuthorities = new ArrayList<>();
        if(!CollectionUtils.isEmpty(roles)){
            roles.forEach(roleCode ->{
                grantedAuthorities.add(new SimpleGrantedAuthority("ROLE_" + roleCode));
            });
        }
        return grantedAuthorities;
    }

    @Override
    public String getPassword() {
        return this.password;
    }
    @Override
    public String getUsername() {
        return this.username;
    }
    //账号是否未过期,直接返回true 表示账户未过期,也可以在数据库中添加该字段
    @Override
    public boolean isAccountNonExpired() {
        return true;
    }
    //账号是否被锁, 这里和数据库中的locked字段刚好相反,所有取反
    @Override
    public boolean isAccountNonLocked() {
        return true;
    }
    //密码是否为过期,数据库中无该字段,直接返回true
    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }
    //账户是否可用,从数据库中获取该字段
    @Override
    public boolean isEnabled() {
        return true;
    }

}

自定义的UserDetailsService实现类:

public class DBUserDetailsManager implements UserDetailsManager, UserDetailsPasswordService {

    @Resource
    private ManagerMapper managerMapper;
    @Resource
    private RoleMapper roleMapper;

    /**
     * UserDetails提供的字段如果不够的话,可以继承 User类,实现自己的UserDetails
     * 用户认证时会调用
     * @param username
     * @return
     * @throws UsernameNotFoundException
     */
    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        ManagerDomain user = managerMapper.selectOne(new LambdaQueryWrapper<ManagerDomain>()
                .eq(ManagerDomain::getLoginName,username)
        );
        if(user == null){
            throw new UsernameNotFoundException(username);
        }
        //查询用户角色
        List<String> roles = roleMapper.getRoleCodeByManagerId(user.getId());
        LoginUserDetails userDetails = LoginUserDetails.builder()
                .username(user.getLoginName())
                .password(user.getPassword())
                .manager(user)
                .roles(roles)
                .build();
        return userDetails;
    }

    @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 updatePassword(UserDetails user, String newPassword) {
        return null;
    }
}

AuthenticationManager和AuthenticationProvider,Spring Security提供了默认的实现ProviderManager和DaoAuthenticationProvider。直接在配置类配置这两个bean即可。

鉴权:
鉴权流程主要由AccessDecisionManager(鉴权管理器)和AccessDecisionVoter(投票器)来处理。鉴权管理器使用默认实现之一的UnanimousBased(一票反对,只要有一票反对就不能通过),然后实现自定义的投票器即可。
在实际鉴权处理前,我们还需要一个过滤器来处理jwt,通过jwt来拿到认证信息。
jwt过滤器:

@Slf4j
//@Component
public class JwtAuthenticationTokenFilter extends OncePerRequestFilter {

    @Resource
    private RedisTemplate<String,String> stringRedisTemplate;
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        //1、拿到token
        String token = request.getHeader("token");
        if(StringUtils.isNotEmpty(token)){
            //2、校验token
            try {
                String username = JwtUtils.getUserName(token);
                String key = GlobalConstants.LOGIN_CACHE_KEY_PREFIX+username;
                String userInfoStr = stringRedisTemplate.opsForValue().get(key);
                if(StringUtils.isNotEmpty(userInfoStr)){
                    //得到用户账号及权限相关信息
                    LoginUserInfoDto loginUserInfoDto = JSONObject.parseObject(userInfoStr,LoginUserInfoDto.class);
                    //设置该用户的权限上下文信息,方便后续过滤器校验
                    UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken =
                            new UsernamePasswordAuthenticationToken(loginUserInfoDto,null,loginUserInfoDto.getAuthorities());
                    SecurityContextHolder.getContext().setAuthentication(usernamePasswordAuthenticationToken);

                }else{
                    throw new RuntimeException("token无效或已过期,请重新登录!");
                }
                //放行
                filterChain.doFilter(request,response);
            } catch (RuntimeException e) {
            	//自行处理认证异常,如果不处理的话,会由Spring Security处理,如果没定义异常处理handler,最后会返回403
                exceptionHandle(request,response,e);
            }
        }else{
            //放行
            filterChain.doFilter(request,response);
        }
    }

    /**
     * jwt认证失败处理
     * @param request
     * @param response
     * @param e
     */
    private void exceptionHandle(HttpServletRequest request, HttpServletResponse response,Exception e) throws IOException {
        log.info("jwt认证失败,原因:{}",e.getMessage());
        //这里就不往下走了,直接返回失败的结果
        Map<String,Object> result = new HashMap();
        result.put("code",-3);
        result.put("message","token认证失败!");

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

自定义AccessDecisionVoter(投票器):

@Slf4j
public class AccessDecisionProcessor implements AccessDecisionVoter<FilterInvocation> {

    @Resource
    private RedisTemplate<String,String> stringRedisTemplate;
    @Resource
    private PermissionMapper permissionMapper;

    @Override
    public boolean supports(ConfigAttribute attribute) {
        return true;
    }

    @Override
    public boolean supports(Class<?> clazz) {
        return true;
    }

    @Override
    public int vote(Authentication authentication, FilterInvocation object, Collection<ConfigAttribute> attributes) {
        //默认否决票
        int result = ACCESS_DENIED;
        String requestUrl = object.getRequest().getServletPath();
        String method = object.getRequest().getMethod();
        log.debug("进入自定义鉴权投票器,URI : {} {}", method, requestUrl);

        //判断请求是否运行匿名访问
        boolean anonymous = stringRedisTemplate.opsForHash().hasKey(GlobalConstants.GLOBAL_PERMISSION_ANONYMOUS,requestUrl);
        if(anonymous){
            //允许匿名访问直接同意
            return ACCESS_GRANTED;
        }
        //拿到用户的角色
        Object principal = authentication.getPrincipal();

        //principal不是LoginUserInfoDto表示是匿名用户或未认证的用户,且请求url未在数据库配置权限
        if(principal instanceof LoginUserInfoDto){
            LoginUserInfoDto dto = (LoginUserInfoDto)principal;
            List<String> roles = dto.getRoles();

            String keyPrefix = GlobalConstants.GLOBAL_PERMISSION_KEY_PREFIX;
            if(!CollectionUtils.isEmpty(roles)){
                for(String roleCode : roles){
                    String key = keyPrefix+roleCode;
                    if(stringRedisTemplate.hasKey(key)){
                        String val = (String)stringRedisTemplate.opsForHash().get(key,requestUrl);
                        if(val!=null){
                            //存在投同意
                            result = ACCESS_GRANTED;
                            //结束循环
                            break;
                        }
                    }else{
                        //如果缓存没有,查库
                        List<String> urls = permissionMapper.getPermissionUrlByRole(roleCode);
                        if(!CollectionUtils.isEmpty(urls)){
                            //存缓存
                            Map<String,Object> map = new HashMap<>();
                            urls.forEach(url ->{
                                map.put(url,"1");
                            });
                            stringRedisTemplate.opsForHash().putAll(key,map);

                            if(urls.contains(requestUrl)){
                                //存在投同意
                                result = ACCESS_GRANTED;
                                //结束循环
                                break;
                            }
                        }
                    }
                }
            }
        }else{
            //匿名用户请求,且请求url未在数据库配置权限,交给WebExpressionVoter处理,这里就不做处理
            result = ACCESS_ABSTAIN;
        }

        return result;
    }
}

这个投票器的主要逻辑是,去redis查询项目启动时初始化的角色权限缓存。没有缓存,则查库。拿到用户认证信息(在jwt过滤器里设置的)里的角色,判断角色权限缓存里有没有请求的url,有则表示该角色能访问该url,即用户有权访问该url。

初始化角色权限缓存:

@Component
@Slf4j
public class PermissionInitRunner implements ApplicationRunner {
    @Resource
    private RedisTemplate<String,String> stringRedisTemplate;
    @Resource
    private RoleMapper roleMapper;
    @Resource
    private PermissionMapper permissionMapper;
    @Override
    public void run(ApplicationArguments args) throws Exception {
        String keyPrefix = GlobalConstants.GLOBAL_PERMISSION_KEY_PREFIX;
        log.info("开始初始化全局资源权限缓存");

        List<String> allRoleCode = roleMapper.getAllRoleCode();
        if(!CollectionUtils.isEmpty(allRoleCode)){
            for(String roleCode : allRoleCode){
                List<String> urls = permissionMapper.getPermissionUrlByRole(roleCode);
                if(!CollectionUtils.isEmpty(urls)){
                    Map<String,Object> map = new HashMap<>();
                    urls.forEach(url ->{
                        map.put(url,"1");
                    });
                    stringRedisTemplate.opsForHash().putAll(keyPrefix+roleCode,map);
                }
            }
        }

        //允许匿名访问的资源权限key
        List<String> urls = permissionMapper.getAnonymousPermissionUrl();
        if(!CollectionUtils.isEmpty(urls)){
            String key = GlobalConstants.GLOBAL_PERMISSION_ANONYMOUS;
            Map<String,Object> map = new HashMap<>();
            urls.forEach(url ->{
                map.put(url,"1");
            });
            stringRedisTemplate.opsForHash().putAll(key,map);
        }
        log.info("初始化全局资源权限缓存结束");
    }
}

自定义认证异常和鉴权异常的处理类:
认证异常处理类:

public class MyAuthenticationEntryPoint implements AuthenticationEntryPoint {
    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
        String localizedMessage = "未认证,请先认证!";//authException.getLocalizedMessage();

        Map<String,Object> result = new HashMap();
        result.put("code",-2);   // 告诉用户需要登录
        result.put("message",localizedMessage);   //


        //  将结果对象转换成json字符串
        String json = JSON.toJSONString(result);
        //  返回json数据到前端
        //  响应头
        response.setContentType("application/json;charset=UTF-8");
        //  响应体
        response.getWriter().println(json);
        //返回登录界面
        //response.sendRedirect(request.getContextPath()+"/myLoginPage");
    }
}

鉴权异常处理类:

public class MyAccessDeniedHandler implements AccessDeniedHandler {
    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
        Map<String,Object> result = new HashMap();
        result.put("code",-1);   // 没有权限
        result.put("message","没有权限");   //


        //  将结果对象转换成json字符串
        String json = JSON.toJSONString(result);

        //  返回json数据到前端
        //  响应头
        response.setContentType("application/json;charset=UTF-8");
        //  响应体
        response.getWriter().println(json);
        //返回页面
        //response.sendRedirect(request.getContextPath()+"/main");
    }
}

Spring Security配置类:

@Configuration
public class WebSecurityConfig {

    /**
     * 密码编码器,会对请求传入的密码进行加密
     * @return
     */
    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Bean
    public UserDetailsService userDetailsService(){
        return new DBUserDetailsManager();
    }

    @Bean
    public AuthenticationProvider authenticationProvider(UserDetailsService  userDetailsService,
                                                         PasswordEncoder passwordEncoder){
        DaoAuthenticationProvider daoAuthenticationProvider = new DaoAuthenticationProvider();
        daoAuthenticationProvider.setUserDetailsService(userDetailsService);
        daoAuthenticationProvider.setPasswordEncoder(passwordEncoder);
        return daoAuthenticationProvider;
    }

    /**
     * 认证管理器
     * @param authenticationProvider
     * @return
     */
    @Bean
    public AuthenticationManager authenticationManager(AuthenticationProvider authenticationProvider){
        // ProviderManager 是 AuthenticationManager 最常用的实现
        return new ProviderManager(authenticationProvider);
    }


    /**
     * jwt过滤器
     * @return
     */
    @Bean
    public JwtAuthenticationTokenFilter jwtAuthenticationTokenFilter(){
        return new JwtAuthenticationTokenFilter();
    }

    /**
     * 自定义鉴权投票器
     * @return
     */
    @Bean
    public AccessDecisionVoter<FilterInvocation> accessDecisionProcessor() {
        return new AccessDecisionProcessor();
    }

    /**
     * 鉴权管理器
     * @return
     */
    @Bean
    public AccessDecisionManager accessDecisionManager() {
        // 构造一个新的AccessDecisionManager 放入两个投票器
        //WebExpressionVoter为配置文件投票器,即在HttpSecurity 的authorizeRequests方法里定义的过滤规则,使用他是为了也可以使用配置定义好放行规则
        List<AccessDecisionVoter<?>> decisionVoters = Arrays.asList(new WebExpressionVoter(), accessDecisionProcessor());
        //UnanimousBased为一票否决鉴权
        //AffirmativeBased为一票通过鉴权,WebExpressionVoter投票如果未配置则默认为通过,所以这里需要配置为UnanimousBased
        return new UnanimousBased(decisionVoters);
    }
    /**
     * Spring Security配置
     * @param http
     * @return
     * @throws Exception
     */
    @Bean
    public SecurityFilterChain mySecurityFilterChain(HttpSecurity http) throws Exception {
        http.authorizeRequests(authorize ->authorize
                // 放行所有OPTIONS请求,跨域请求会先发一个OPTIONS请求
                .antMatchers(HttpMethod.OPTIONS).permitAll()
                .antMatchers("/login").permitAll()
                .antMatchers("/myLogout").permitAll()
                .anyRequest()  //对所有请求开启授权保护
                .authenticated() //已认证的请求会被自动授权
                .accessDecisionManager(accessDecisionManager())
        );
        //添加自定义过滤器
        http.addFilterBefore(jwtAuthenticationTokenFilter(), UsernamePasswordAuthenticationFilter.class);
        http.exceptionHandling(exception -> exception
                .authenticationEntryPoint(new MyAuthenticationEntryPoint()) //请求未认证的处理
                .accessDeniedHandler(new MyAccessDeniedHandler())   //未授权资源请求处理
        );
        //关闭csrf防护,否则所有的POST的请求都需要携带CSRF令牌
        http.csrf(csrf -> csrf.disable());
        // 关闭Session机制
        //http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);
        return http.build();
    }
}
  • 5
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值