Security Jwt 动态 URL 权限验证

一、前言

1.1 概述

引用 项目集成Spring Security

Spring Security 一句话概述:一组 filter 过滤器链组成的权限认证。

HOPE

这里仅仅只记录下用法和代码,关于系统了解 Security 方面的内容并不会涉及,毕竟不是不了解而是根本不了解啊 >﹏< 也还指望着用着用着然后顺便 Ctrl+B 一下就慢慢了解了。

如果有有误的地方,希望多多指正哈ヾ(•ω•`)o

实际上教主为了把这个功能弄出来还是看了好多好多的博客和教程,但是仍然费了不少时间。主要原因基本可以概括为对概念的理解力太差。

如果只用 Security 登个录、拦截个 /hello-world、放行个 Swagger 其实还算容易,一是不用太多配置,二是毕竟有太多大神总结的代码可参考。可是关于其它的用户可能就需要了解一下 Security 的过滤器链了。

后面基本上已经列出了所有的代码,这里还是把代码地址给出来:https://gitee.com/icefery/security-demo



1.2 目的

  • 用 JWT 代替 Session 来实现登录
  • 根据数据库里的权限表(资源表)的 url 来判断用户是否可访问该资源
  • 将允许匿名可访问的资源(如注册接口、验证码接口等)也存在数据库中

申明

  1. 这里的权限和资源的概念相对与 RBAC0 来说相对比较模糊一点。(RBAC权限模型:RBAC权限模型——项目实战)
  2. 角色应该是权限的集合,权限应该是操作的集合,API 资源和页面路由资源应该再在权限的基础上建立关系。而在这里大致可以将角色理解为权限,API 理解为资源。而且鉴于很多地方都这样设计,似乎也无妨甚至更加简洁。

注意

  1. 使用 url 来进行权限验证后,意味着一些匿名可访问的资源可能就需要存如资源表了。

  2. 如配置 swagger 允许匿名访问,之前可以直接:

    @Override
    public void configure(WebSecurity web) throws Exception {
        web.ignoring().antMatchers("doc.html",
                                   "swagger-ui.html",
                                   "/webjars/**",
                                   "/swagger-resources/**",
                                   "/v2/**");
    }
    

    但现在可能就需要显示在数据库中申明可匿名访问了:
    Security Jwt 动态 URL 权限验证_第1张图片

    PS:虽然好像 web.ignore()会绕开过滤器,但似乎这里还是会被拦截。(参考:springsecurity的http.permitall与web.ignoring的区别)

引用 Ant 风格路径表达式

通配符:

  • ?:匹配任何单字符
  • *:匹配 0 或者任意数量的字符
  • **:匹配 0 或者更多的目录

1.3 完整 pom.xml


<project xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns="http://maven.apache.org/POM/4.0.0"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0modelVersion>
    <parent>
        <groupId>org.springframework.bootgroupId>
        <artifactId>spring-boot-starter-parentartifactId>
        <version>2.3.1.RELEASEversion>
        <relativePath/>
    parent>
    <groupId>xyz.icefery.demo.securitygroupId>
    <artifactId>security-demoartifactId>
    <version>0.0.1-SNAPSHOTversion>

    <properties>
        <java.version>11java.version>
        <mybatis-plus.version>3.3.2mybatis-plus.version>
        <velocity.version>2.0velocity.version>
        <jwt.version>0.9.0jwt.version>
    properties>

    <dependencies>
        
        <dependency>
            <groupId>com.baomidougroupId>
            <artifactId>mybatis-plus-boot-starterartifactId>
            <version>${mybatis-plus.version}version>
        dependency>
        <dependency>
            <groupId>com.baomidougroupId>
            <artifactId>mybatis-plus-generatorartifactId>
            <version>${mybatis-plus.version}version>
        dependency>
        <dependency>
            <groupId>org.apache.velocitygroupId>
            <artifactId>velocity-engine-coreartifactId>
            <version>${velocity.version}version>
        dependency>

        
        <dependency>
            <groupId>io.jsonwebtokengroupId>
            <artifactId>jjwtartifactId>
            <version>${jwt.version}version>
        dependency>

        
        <dependency>
            <groupId>org.springframework.bootgroupId>
            <artifactId>spring-boot-devtoolsartifactId>
            <scope>runtimescope>
            <optional>trueoptional>
        dependency>

        
        <dependency>
            <groupId>mysqlgroupId>
            <artifactId>mysql-connector-javaartifactId>
            <scope>runtimescope>
        dependency>

        
        <dependency>
            <groupId>org.projectlombokgroupId>
            <artifactId>lombokartifactId>
            <optional>trueoptional>
        dependency>

        
        <dependency>
            <groupId>org.springframework.bootgroupId>
            <artifactId>spring-boot-starter-webartifactId>
        dependency>

        
        <dependency>
            <groupId>org.springframework.bootgroupId>
            <artifactId>spring-boot-starter-securityartifactId>
        dependency>

        
        <dependency>
            <groupId>org.springframework.bootgroupId>
            <artifactId>spring-boot-configuration-processorartifactId>
        dependency>

        
        <dependency>
            <groupId>javax.xml.bindgroupId>
            <artifactId>jaxb-apiartifactId>
        dependency>

        
        <dependency>
            <groupId>org.springframework.bootgroupId>
            <artifactId>spring-boot-starter-testartifactId>
            <scope>testscope>
        dependency>
    dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.bootgroupId>
                <artifactId>spring-boot-maven-pluginartifactId>
            plugin>
        plugins>
    build>

project>

提示

pom.xml 中:

  • spring-boot-configuration-processor:将配置文件注入到 Bean 中
  • jaxb-api:JDK11 缺少的 XML 序列化包

1.4 application.properties

只有数据源的配置:

spring.datasource.driver-class-name = com.mysql.cj.jdbc.Driver
spring.datasource.url = jdbc:mysql://localhost:3306/security_demo
spring.datasource.username = root
spring.datasource.password = root



二、表结构和 MP 生成代码

2.1 E-R 图

Security Jwt 动态 URL 权限验证_第2张图片


2.2 SQL语句

由 Navicat 导出:

SET NAMES utf8mb4;
SET FOREIGN_KEY_CHECKS = 0;

-- ----------------------------
-- Table structure for sys_permission
-- ----------------------------
DROP TABLE IF EXISTS `sys_permission`;
CREATE TABLE `sys_permission`  (
  `id` bigint(0) NOT NULL AUTO_INCREMENT,
  `url` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL,
  `anonymous` tinyint(1) NULL DEFAULT NULL,
  PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 15 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci ROW_FORMAT = Dynamic;

-- ----------------------------
-- Records of sys_permission
-- ----------------------------
INSERT INTO `sys_permission` VALUES (1, '/hello1/**', 0);
INSERT INTO `sys_permission` VALUES (2, '/hello2', 0);
INSERT INTO `sys_permission` VALUES (3, '/hello3', 0);
INSERT INTO `sys_permission` VALUES (4, '/hello4', 1);
INSERT INTO `sys_permission` VALUES (5, '/doc.html', 1);
INSERT INTO `sys_permission` VALUES (6, '/swagger-ui.html', 1);
INSERT INTO `sys_permission` VALUES (7, '/webjars/**', 1);
INSERT INTO `sys_permission` VALUES (8, '/swagger-resources/**', 1);
INSERT INTO `sys_permission` VALUES (9, '/v2/**', 1);
INSERT INTO `sys_permission` VALUES (10, '/hello5', 0);

-- ----------------------------
-- Table structure for sys_role
-- ----------------------------
DROP TABLE IF EXISTS `sys_role`;
CREATE TABLE `sys_role`  (
  `id` bigint(0) NOT NULL AUTO_INCREMENT,
  `name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL,
  PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 4 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci ROW_FORMAT = Dynamic;

-- ----------------------------
-- Records of sys_role
-- ----------------------------
INSERT INTO `sys_role` VALUES (1, 'ROLE_1');
INSERT INTO `sys_role` VALUES (2, 'ROLE_2');
INSERT INTO `sys_role` VALUES (3, 'ROLE_3');

-- ----------------------------
-- Table structure for sys_role_permission
-- ----------------------------
DROP TABLE IF EXISTS `sys_role_permission`;
CREATE TABLE `sys_role_permission`  (
  `role_id` bigint(0) NOT NULL,
  `permission_id` bigint(0) NOT NULL,
  PRIMARY KEY (`role_id`, `permission_id`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci ROW_FORMAT = Dynamic;

-- ----------------------------
-- Records of sys_role_permission
-- ----------------------------
INSERT INTO `sys_role_permission` VALUES (1, 1);
INSERT INTO `sys_role_permission` VALUES (2, 2);
INSERT INTO `sys_role_permission` VALUES (3, 3);

-- ----------------------------
-- Table structure for sys_role_user
-- ----------------------------
DROP TABLE IF EXISTS `sys_role_user`;
CREATE TABLE `sys_role_user`  (
  `role_id` bigint(0) NOT NULL,
  `user_id` bigint(0) NOT NULL,
  PRIMARY KEY (`role_id`, `user_id`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci ROW_FORMAT = Dynamic;

-- ----------------------------
-- Records of sys_role_user
-- ----------------------------
INSERT INTO `sys_role_user` VALUES (1, 1);
INSERT INTO `sys_role_user` VALUES (3, 1);

-- ----------------------------
-- Table structure for sys_user
-- ----------------------------
DROP TABLE IF EXISTS `sys_user`;
CREATE TABLE `sys_user`  (
  `id` bigint(0) NOT NULL AUTO_INCREMENT,
  `username` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL,
  `password` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL,
  `name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL,
  PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 3 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci ROW_FORMAT = Dynamic;

-- ----------------------------
-- Records of sys_user
-- ----------------------------
INSERT INTO `sys_user` VALUES (1, 'admin', '$2a$10$dtpTb./Elr8HDFBP3Hk5EObrLJXjhZQh5AnZdP./cAtyF8TN3EPAC', 'admin');
INSERT INTO `sys_user` VALUES (2, 'user', '$2a$10$z.ySyainS7hrFgYKHCia4ePYgCAxhQ90K3ANtcQHJ6yoack/aHmfS', 'user');

SET FOREIGN_KEY_CHECKS = 1;

提示

例子中:

  • 用户 admin 拥有角色 ROLE_1ROLE_3,密码为admin
  • 用户user,密码为user
  • 角色ROLE_1对应权限(资源)/hello1/**
  • 角色ROLE_2对应权限(资源)/hello2
  • 角色ROLE_3对应权限(资源)/hello3

用户插入:

  • 可以在测试类中注入PasswordEncoder加密后插入记录
  • 在线计算器:Bcrypt密码生成计算器

2.3 MP 配置

@MapperScan("xyz.icefery.demo.security.mapper")
@EnableTransactionManagement
@Configuration
public class MyBatisPlusConfig {
    @Bean
    public PaginationInterceptor paginationInterceptor() {
        PaginationInterceptor paginationInterceptor = new PaginationInterceptor();
        // 开启 count 的 join 优化,只针对部分 left join
        paginationInterceptor.setCountSqlParser(new JsqlParserCountOptimize(true));
        return paginationInterceptor;
    }
}

提示

用到的只有 @MapperScan注解,加在主类上亦可。


2.4 MP 生成代码

public class CodeGenerator {
    public static void main(String[] args) {
        String projectPath = System.getProperty("user.dir");

        // 代码生成器
        AutoGenerator mpg = new AutoGenerator();

        // 全局配置
        GlobalConfig gc = new GlobalConfig()
                .setOutputDir(projectPath + "/src/main/java/")
                .setFileOverride(false)
                .setOpen(false)
                .setAuthor("icefery")
                .setIdType(IdType.AUTO)
                .setServiceName("%sService");
        mpg.setGlobalConfig(gc);

        // 数据源配置
        DataSourceConfig dsc = new DataSourceConfig()
                .setDbType(DbType.MYSQL)
                .setDriverName("com.mysql.cj.jdbc.Driver")
                .setUrl("jdbc:mysql://localhost:3306/security_demo")
                .setUsername("root")
                .setPassword("root");
        mpg.setDataSource(dsc);

        // 包配置
        PackageConfig pc = new PackageConfig()
                .setParent("xyz.icefery.demo.security");
        mpg.setPackageInfo(pc);

        // 策略配置
        StrategyConfig sg = new StrategyConfig()
                .setTablePrefix("sys_")
                .setInclude("sys_user",
                            "sys_role",
                            "sys_permission",
                            "sys_role_user",
                            "sys_role_permission")
                .setNaming(NamingStrategy.underline_to_camel)
                .setColumnNaming(NamingStrategy.underline_to_camel)
                .setRestControllerStyle(true)
                .setControllerMappingHyphenStyle(true)
                .setChainModel(true)
                .setEntityLombokModel(true);
        mpg.setStrategy(sg);
		
        // 执行
        mpg.execute();
    }
}

申明

生成代码时的全局配置的主键策略为自增,而 MP 不支持复合主键。于是把中间表的主键注解去掉(默认为IdType.NONE

如图:
Security Jwt 动态 URL 权限验证_第3张图片Security Jwt 动态 URL 权限验证_第4张图片


2.5 添加查询方法

查询用户的角色列表:(在告知 Security 用户的角色列表时会用到)
Security Jwt 动态 URL 权限验证_第5张图片
查询访问资源需要的角色列表:(在匹配 url 后查询访问资源需要的角色列表时会用到)
Security Jwt 动态 URL 权限验证_第6张图片



三、JWT 工具类

3.1 配置文件

jwt.properties:

jwt.secret = secret

# 一天:1000*60*60*24
#jwt.expiration = 86400000

# 两分钟:1000*60*2
jwt.expiration = 120000

jwt.token-header = Authorization

3.2 注入到 Bean 中

@Data
@Component
@PropertySource("classpath:jwt.properties")
@ConfigurationProperties(prefix = "jwt")
public class JwtConfig {
    // 密钥
    private String secret;
    // 过期时间
    private Long   expiration;
    // 请求头
    private String tokenHeader;

    // 生成 Token
    public String createToken(String username) {
        long now = System.currentTimeMillis();
        return Jwts.builder()
                   .setSubject(username)
                   .setIssuedAt(new Date(now))
                   .setExpiration(new Date(now + expiration))
                   .signWith(SignatureAlgorithm.HS256, secret)
                   .compact();
    }

    // 校验 Token
    public Boolean validateToken(String token, String username) {
        Claims claims = this.extractClaims(token);
        boolean isUsernameCorrect = claims.getSubject().equals(username);
        boolean isTokenExpired = claims.getExpiration().before(new Date());
        return isUsernameCorrect && !isTokenExpired;
    }

    public Claims extractClaims(String token) {
        return Jwts.parser()
                   .setSigningKey(secret)
                   .parseClaimsJws(token)
                   .getBody();
    }

    // 从 Token 中提取用户名
    public String extractUsername(String token) {
        return this.extractClaims(token).getSubject();
    }
}



四、Security 相关

4.1 UserDetails 实现类

public class SecurityUserDetails implements UserDetails {
    private final String username;
    private final String password;
    private final List<GrantedAuthority> grantedAuthorityList;
    
    public SecurityUserDetails(User user, List<Role> roleList) {
        this.username = user.getUsername();
        this.password = user.getPassword();
        this.grantedAuthorityList = roleList
                .stream()
                .map(role -> new SimpleGrantedAuthority(role.getName()))
                .collect(Collectors.toList());
    }

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return this.grantedAuthorityList;
    }

    @Override
    public String getPassword() {
        return this.password;
    }

    @Override
    public String getUsername() {
        return this.username;
    }

    @Override
    public boolean isAccountNonExpired() {
        return true;
    }

    @Override
    public boolean isAccountNonLocked() {
        return true;
    }

    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }

    @Override
    public boolean isEnabled() {
        return true;
    }
}

4.2 UserDetailsService 实现类

用户登录后将用户的信息(用户名、密码、角色列表)加载进 UserDetails (实现类SecurityUserDetails)中。

@Slf4j
@Component
public class SecurityUserDetailsService implements UserDetailsService {
    @Autowired private UserService userService;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        User one = userService.getOne(new LambdaQueryWrapper<User>().eq(User::getUsername, username));
        if (one == null) {
            
            log.info("用户[" + username + "]不存在");
            
            throw new UsernameNotFoundException("用户[" + username + "]不存在");
        }
        List<Role> roleList = userService.getRoleListById(one.getId());
        
        log.info("用户[" + username + "]的角色有" + roleList.stream().map(Role::getName).collect(Collectors.toList()));
        
        return new SecurityUserDetails(one, roleList);
    }
}

4.3 定义 JWT 的过滤器

此过滤器会在 Security 的配置中放在 UsernamePasswordAuthenticationFilter 之前。

// Token 过滤器
@Slf4j
@Component
public class JwtAntuenticationFilter extends OncePerRequestFilter {
    @Autowired private JwtConfig                  jwtConfig;
    @Autowired private SecurityUserDetailsService securityUserDetailsService;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException {
        // 从请求头中拿到 token
        String token = request.getHeader(jwtConfig.getTokenHeader());
        if (token != null && !token.isBlank()) {
            String username = null;
            try {
                username = jwtConfig.extractUsername(token);
            } catch (ExpiredJwtException | UnsupportedJwtException | MalformedJwtException | SignatureException | IllegalArgumentException e) {
                
                log.info("Token 过期或有误");
                
            }
            if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
                // 检验 token
                boolean valid = jwtConfig.validateToken(token, username);
                if (valid) {
                    // 加载登录信息(用户名 | 密码 | 角色)
                    UserDetails userDetails = securityUserDetailsService.loadUserByUsername(username);
                    UsernamePasswordAuthenticationToken authentication =
                            new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
                    SecurityContextHolder.getContext().setAuthentication(authentication);
                }
            }
        }
        chain.doFilter(request, response);
    }
}

4.4 FilterInvocationSecurityMetadataSource 实现类

@Slf4j
@Component
public class UrlFilterInvocationSecurityMetadataSource implements FilterInvocationSecurityMetadataSource {
    @Autowired private AntPathMatcher    antPathMatcher;
    @Autowired private PermissionService permissionService;

    @Override
    public Collection<ConfigAttribute> getAttributes(Object object) throws IllegalArgumentException {
        String requestUrl = ((FilterInvocation) object).getRequestUrl();

        log.info("请求 [" + requestUrl + "]");

        // 匹配 url | 查找角色
        List<Permission> permissionList = permissionService.list();

        // TODO: 排序 url 使其能优先比较更细粒度的 url

        for (Permission p : permissionList) {
            if (antPathMatcher.match(p.getUrl(), requestUrl)) {
                if (p.getAnonymous()) {

                    log.info("[" + requestUrl + "] 允许匿名访问");

                    // 返回 null 不会进入 UrlAccessDecisionManager#decide 方法 | 直接放行
                    // 如果 authentication instanceof AnonymousAuthenticationToken | 即"用户"是匿名访问时用户拥有的角色
                    // 那么"用户"拥有的角色集合 authentication.getAuthorities() 里的角色为 ROLE_ANONYMOUS
                    // 因此也可返回资源需要匿名访问的角色为 ROLE_ANONYMOUS | return SecurityConfig.createList("ROLE_ANONYMOUS")
                    // 然后在 UrlAccessDecisionManager#decide 方法里进行判断 | 如果 "ROLE_ANONYMOUS".equals(needRoleName) 则 return 放行
                    return null;
                }

                List<Role> roleList = permissionService.getRoleListById(p.getId());

                // ROLE_LOGIN 是自定义的需要登录访问的角色
                if (roleList.isEmpty()) {
                    log.info("资源 [" + p.getUrl() + "] 无需角色 | 登录即可访问");

                    return SecurityConfig.createList("ROLE_LOGIN");
                }

                String[] roleNames = roleList.stream().map(Role::getName).toArray(String[]::new);

                log.info("资源 [" + p.getUrl() + "] 需要角色 " + Arrays.toString(roleNames));

                return SecurityConfig.createList(roleNames);
            }
        }

        log.info("请求 [" + requestUrl + "] 未在资源表中, 登录即可访问");

        // ROLE_LOGIN 是自定义的需要登录访问的角色
        return SecurityConfig.createList("ROLE_LOGIN");
    }

    @Override
    public Collection<ConfigAttribute> getAllConfigAttributes() {
        return null;
    }

    @Override
    public boolean supports(Class<?> aClass) {
        return FilterInvocation.class.isAssignableFrom(aClass);
    }

    @Bean
    public AntPathMatcher antPathMatcher() {
        return new AntPathMatcher();
    }
}

提示

有些博文里是用一个 Map 在构造方法中将 url 映射信息先加载出来,而这里希望的是每次请求到来时都去数据库读取最新的映射信息。

策略概要:

  • 匿名访问:用anonymous来标记是否允许匿名访问
  • 需要角色的资源,用户只需具备其中一个角色即可
  • 不需要角色的资源以及未在资源表中的资源,默认登录即可访问

Security 的角色名需要以ROLE_开头。而如果用户未通过 Security 验证,在AccessDecisionManager中的用户角色默认为ROLE_ANONYMOUS


4.5 AccessDecisionManager 实现类

@Slf4j
@Component
public class UrlAccessDecisionManager implements AccessDecisionManager {
    @Override
    public void decide(Authentication authentication, Object o, Collection<ConfigAttribute> collection) throws AccessDeniedException, InsufficientAuthenticationException {
        for (ConfigAttribute configAttribute : collection) {
            String needRoleName = configAttribute.getAttribute();

            if ("ROLE_LOGIN".equals(needRoleName)) {
                if (authentication instanceof AnonymousAuthenticationToken) {
                    log.info("资源需要登录访问 | 当前未登录");
                    throw new InsufficientAuthenticationException("未登录");
                } else if (authentication instanceof UsernamePasswordAuthenticationToken) {
                    log.info("资源需要登录访问 | 当前已登录 | 正常访问");
                    return;
                }
            }

            for (GrantedAuthority authority : authentication.getAuthorities()) {

                log.info("资源需要角色 [" + needRoleName + "] 用户拥有角色 [" + authority.getAuthority() + "]");

                if (authority.getAuthority().equals(needRoleName)) {
                    return;
                }
            }
        }

        log.info("当前登录用户无访问资源所需角色");

        throw new AccessDeniedException("权限不足");
    }

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

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

4.6 SecurityConfig 总配置

@Slf4j
@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true, jsr250Enabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    @Autowired private JwtConfig                                 jwtConfig;
    @Autowired private JwtAntuenticationFilter                   jwtAntuenticationFilter;
    @Autowired private SecurityUserDetailsService                securityUserDetailsService;
    @Autowired private UrlFilterInvocationSecurityMetadataSource urlFilterInvocationSecurityMetadataSource;
    @Autowired private UrlAccessDecisionManager                  urlAccessDecisionManager;

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        // 关闭 csrf
        http.csrf().disable();

        // 开启跨域
        http.cors();

        http.authorizeRequests()
            // 使用 URL 权限验证
            .withObjectPostProcessor(new ObjectPostProcessor<FilterSecurityInterceptor>() {
                @Override
                public <O extends FilterSecurityInterceptor> O postProcess(O object) {
                    object.setSecurityMetadataSource(urlFilterInvocationSecurityMetadataSource);
                    object.setAccessDecisionManager(urlAccessDecisionManager);
                    return object;
                }
            })
            .anyRequest().authenticated();

        // 表单登录
        http.formLogin()
            .loginProcessingUrl("/login")
            // 登录成功
            .successHandler((request, response, authentication) -> {
                UserDetails userDetails = (UserDetails) authentication.getPrincipal();
                String token = jwtConfig.createToken(userDetails.getUsername());
                response.setHeader(jwtConfig.getTokenHeader(), token);
                response.setCharacterEncoding("UTF-8");
                response.setContentType("text/plain;charset=utf-8");
                response.getWriter().write("登录成功");
            })
            // 登录失败
            .failureHandler((request, response, authenticationException) -> {
                String reason;
                if (authenticationException instanceof UsernameNotFoundException || authenticationException instanceof BadCredentialsException) {
                    reason = "用户名不存在或密码错误";
                } else if (authenticationException instanceof AccountExpiredException) {
                    reason = "账号过期";
                } else if (authenticationException instanceof CredentialsExpiredException) {
                    reason = "密码过期";
                } else if (authenticationException instanceof LockedException) {
                    reason = "账号锁定";
                } else if (authenticationException instanceof DisabledException) {
                    reason = "账号禁用";
                } else {
                    reason = "其它原因";
                }
                response.setCharacterEncoding("UTF-8");
                response.setContentType("text/plain;charset=utf-8");
                response.getWriter().write(reason);
            });

        http.exceptionHandling()
            // 登录过期 | 未登录
            .authenticationEntryPoint((request, response, authenticationException) -> {
                response.setCharacterEncoding("UTF-8");
                response.setContentType("text/plain;charset=utf-8");
                response.getWriter().write("登录过期 | 未登录");
            })
            // 权限不足
            .accessDeniedHandler((request, response, accessdeniedException) -> {
                response.setCharacterEncoding("UTF-8");
                response.setContentType("text/plain;charset=utf-8");
                response.getWriter().write("权限不足");
            });

        // JWT 过滤器
        http.addFilterBefore(jwtAntuenticationFilter, UsernamePasswordAuthenticationFilter.class);

        // 禁用 session
        http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Bean
    @Override
    public AuthenticationManager authenticationManagerBean() throws Exception {
        return super.authenticationManagerBean();
    }

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.userDetailsService(securityUserDetailsService).passwordEncoder(passwordEncoder());
    }
}

注意

  1. 跨域问题:在Security 中,除了使用如下的方式配置 CORS 配置:

    @Configuration
    public class CorsConfig implements WebMvcConfigurer {
        @Override
        public void addCorsMappings(CorsRegistry registry) {
            registry.addMapping("/**")
                .allowedHeaders("*")
                .allowedOrigins("*")
                .allowCredentials(true)
                .allowedMethods("*")
                .maxAge(3600);
        }
    }
    

    还需要在 SecurityConfig 中开启 CORS:

    http.cors();
    
  2. session 问题:使用 JWT 进行验证,如果不禁用 session 可能会出现一些问题。

提示

  1. Security 用户名不存在时默认抛出的是BadCredentialsException 并不会抛出UsernameNotFoundException。(解决方案参考:Springboot + SpringSecurity 中不能抛出异常UserNameNotFoundException 解析)
  2. 登录失败的其它异常可以根据需要抛出,在 SecurityUserDetails 有对应的属性。
  3. 当用户验证失败或匿名访问时,登录过期或者未登录处理器中authenticationException异常的实际类型是InsufficientAuthenticationException类型



五、测试

5.1 HelloController

@RestController
public class HelloController {
    // 在权限表中 | 访问资源需要角色 ROLE_1
    @GetMapping("/hello1")
    public String hello1() {
        return "hello1";
    }

    // 在权限表中 | 访问资源需要角色 ROLE_2
    @GetMapping("/hello2")
    public String hello2() {
        return "hello2";
    }

    // 在权限表中 | 访问资源需要角色 ROLE_3
    @GetMapping("/hello3")
    public String hello3() {
        return "hello3";
    }

    // 在权限表中 | 允许匿名访问
    @GetMapping("/hello4")
    public String hello4() {
        return "hello4";
    }

    // 不在权限表中 | 匹配 /hello1/** | 访问资源需要 ROLE_1
    @GetMapping("/hello1/sub")
    public String hello1Sub() {
        return "hello1_sub";
    }

    // 不在权限表中 | 不匹配 /hello3 | 访问资源需要登录
    @GetMapping("/hello3/sub")
    public String hello3Sub() {
        return "hello3_sub";
    }

    // 在权限表中 | 访问资源不需要角色 | 访问资源需要登录
    @GetMapping("/hello5")
    public String hello5() {
        return "Hello5";
    }
}

5.2 测试

  1. 匿名或 Token 错误:只能访问 /hello4
  2. 以用户user登录:只能访问 /hello4/hello3/sub/hello5
  3. 以用户admin登录:不能访问/hello2

申明

登录后的 Token 被写入响应头中
Security Jwt 动态 URL 权限验证_第7张图片

Token 上述代码里 Token 有效实际只有两分钟,且发送请求时 Token 不需要带Bearer
Security Jwt 动态 URL 权限验证_第8张图片

你可能感兴趣的:(Java)