Spring Cloud 入门 ---- Security 权限管理【随笔】

Spring Cloud Security

Spring Security 是一个能够为基于 Spring 的企业应用系统提供声明式的安全访问控制解决方案的安全框架。由于它是 Spring 生态系统中的一员,因此他伴随着整个 Spring 生态系统不断修正、升级,在 Spring boot 项目中加入 Spring Security 更是十分简单,使用 Spring Security 减少了企业系统安全控制编写大量重复代码的工作。

官网:https://spring.io/projects/spring-security#learn

创建演示工程
准备工作

数据库准备工作,创建权限5表

//账户表
CREATE TABLE `sys_account` (
  `id` int(11) NOT NULL AUTO_INCREMENT COMMENT '账户ID',
  `account_name` varchar(30) NOT NULL COMMENT '账号',
  `password` varchar(75) NOT NULL COMMENT '密码',
  `nick_name` varchar(30) DEFAULT NULL COMMENT '昵称',
  `salt` varchar(40) NOT NULL COMMENT '盐值',
  `email` varchar(50) DEFAULT NULL COMMENT '邮箱',
  `phone` varchar(18) DEFAULT NULL COMMENT '联系电话',
  `status` char(1) NOT NULL DEFAULT 'N' COMMENT '状态 (''N'': 正常, ''F'': 禁用, ''D'': 删除)',
  `gmt_create` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  `create_account_id` int(11) DEFAULT '0' COMMENT '创建者用户ID',
  `gmt_modified` datetime DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间',
  `modified_account_id` int(11) DEFAULT '0' COMMENT '修改者用户ID',
  PRIMARY KEY (`id`) USING BTREE,
  UNIQUE KEY `uq_index_account_name` (`account_name`) USING BTREE COMMENT '唯一索引---用户名'
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8 ROW_FORMAT=COMPACT;

//账户--角色表
CREATE TABLE `sys_account_role` (
  `id` int(11) NOT NULL AUTO_INCREMENT COMMENT '账户--角色表',
  `account_id` int(11) NOT NULL COMMENT '账户ID',
  `role_id` int(11) NOT NULL COMMENT '角色ID',
  `gmt_create` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  PRIMARY KEY (`id`) USING BTREE,
  UNIQUE KEY `uq_index_account_role` (`account_id`,`role_id`) USING BTREE COMMENT '一个用户对于一个角色只能存在一条对应关系'
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8 ROW_FORMAT=COMPACT;

//角色表
CREATE TABLE `sys_role` (
  `id` int(11) NOT NULL AUTO_INCREMENT COMMENT '角色主键ID',
  `name` varchar(30) NOT NULL COMMENT '角色名称',
  `describe` varchar(150) DEFAULT NULL COMMENT '角色描述信息',
  `gmt_create` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  `create_account_id` int(11) DEFAULT NULL COMMENT '创建者用户ID',
  `gmt_modified` datetime DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间',
  `modified_account_id` int(11) DEFAULT NULL COMMENT '修改者用户ID',
  PRIMARY KEY (`id`) USING BTREE,
  UNIQUE KEY `uq_index_role_name` (`name`) USING BTREE COMMENT '角色名称唯一'
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8 ROW_FORMAT=COMPACT;

//角色--资源权限表
CREATE TABLE `sys_role_menu` (
  `id` int(11) NOT NULL AUTO_INCREMENT COMMENT '角色-菜单表主键ID',
  `role_id` int(11) NOT NULL COMMENT '角色ID',
  `menu_id` int(11) NOT NULL COMMENT '菜单ID',
  `gmt_create` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  PRIMARY KEY (`id`) USING BTREE,
  UNIQUE KEY `uq_index_role_menu` (`role_id`,`menu_id`) USING BTREE COMMENT '一个角色对于一个权限只能存在一条对应关系'
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8 ROW_FORMAT=COMPACT;

//资源权限表
CREATE TABLE `sys_menu` (
  `id` int(11) NOT NULL AUTO_INCREMENT COMMENT '菜单主键ID',
  `parent_id` int(11) DEFAULT '0' COMMENT '父菜单ID,一级菜单为0',
  `name` varchar(30) NOT NULL COMMENT '菜单名称',
  `url` varchar(100) DEFAULT NULL COMMENT '菜单URL',
  `authorization` varchar(200) DEFAULT NULL COMMENT '授权(多个用逗号分隔,如:user:list,user:create)',
  `type` char(1) NOT NULL COMMENT '类型 (‘C'': 目录,  ''M'': 菜单,  ''B'': 按钮)',
  `icon` varchar(100) DEFAULT NULL COMMENT '菜单图标',
  `order_num` int(3) NOT NULL COMMENT '排序',
  `gmt_create` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  `create_account_id` int(11) DEFAULT NULL COMMENT '创建者用户ID',
  `gmt_modified` datetime DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间',
  `modified_account_id` int(11) DEFAULT NULL COMMENT '修改者用户ID',
  PRIMARY KEY (`id`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8 ROW_FORMAT=COMPACT;

导入基础数据

// 注意这里 zhangsan 的密码是 123456  lisi 的密码是 654321
INSERT INTO `sys_account` VALUES (1, 'zhangsan', '$2a$10$aHwVVj4lt7FliXp.dHeXSOAuRGWgM3oeM47fckdALL.VPhBlVFreK', '张三', '$2a$10$aHwVVj4lt7FliXp.dHeXSO', NULL, NULL, 'N', '2020-11-25 23:41:46', 0, '2020-11-25 23:42:41', 0);
INSERT INTO `sys_account` VALUES (2, 'lisi', '$2a$10$YZE7qSS/P1OeI.jkflya2.LmspeszvLc3YG/vpnjaTrz0OrBzUpRO', '李四', '$2a$10$YZE7qSS/P1OeI.jkflya2.', NULL, NULL, 'N', '2020-11-25 23:41:50', 0, '2020-11-25 23:42:43', 0);

INSERT INTO `sys_account_role` VALUES (1, 1, 1, '2020-11-26 23:20:37');
INSERT INTO `sys_account_role` VALUES (2, 2, 2, '2020-11-26 23:20:50');

INSERT INTO `sys_role` VALUES (1, '演示角色1', '演示 Spring Security', '2020-11-26 17:42:45', 1, '2020-11-26 23:20:23', 1);
INSERT INTO `sys_role` VALUES (2, '演示角2', '演示 Spring Security', '2020-11-26 17:42:45', 1, '2020-11-26 23:20:25', 1);

INSERT INTO `sys_role_menu` VALUES (1, 1, 1, '2020-11-26 23:21:16');
INSERT INTO `sys_role_menu` VALUES (2, 1, 2, '2020-11-26 23:21:25');
INSERT INTO `sys_role_menu` VALUES (3, 1, 3, '2020-11-26 23:21:35');
INSERT INTO `sys_role_menu` VALUES (4, 2, 1, '2020-11-26 23:23:02');
INSERT INTO `sys_role_menu` VALUES (5, 2, 2, '2020-11-26 23:23:06');

INSERT INTO `sys_menu` VALUES (1, 0, 'r1接口', NULL, 'r:r1', '2', 'fa fa-cog', 0, '2020-11-26 23:15:20', 1, '2020-11-26 23:17:10', 1);
INSERT INTO `sys_menu` VALUES (2, 0, 'r2接口', NULL, 'r:r2', '2', 'fa fa-cog', 1, '2020-11-26 23:15:20', 1, '2020-11-26 23:17:10', 1);
INSERT INTO `sys_menu` VALUES (3, 0, 'r3接口', NULL, 'r:r3', '2', 'fa fa-cog', 2, '2020-11-26 23:15:20', 1, '2020-11-26 23:17:10', 1);

以上我们关于数据库的准备工作就完成,创建了权限5表,导入了基础数据;其中 'zhangsan' 赋予了 'r:r1'、'r:r2'、'r:r3' 三种权限,'lisi' 赋予了 'r:r1'、'r:r2' 两种权限。

明确父工程依赖版本

20201127111208399

新建 security-auth-service 模块

导入 pom 依赖

<!--security-->
<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-security</artifactId>
</dependency>

<!--mysql-->
<dependency>
    <groupId>mysql</groupId>
    <artifactId>mysql-connector-java</artifactId>
</dependency>
<!-- druid-->
<dependency>
    <groupId>com.alibaba</groupId>
    <artifactId>druid</artifactId>
</dependency>
<!--mybatis-plus-->
<dependency>
    <groupId>com.baomidou</groupId>
    <artifactId>mybatis-plus-boot-starter</artifactId>
</dependency>

添加 yml 配置

server:
  port: 3018
  servlet:
    session:
#      cookie:
#        http-only: true
#        secure: true
      timeout: 3600s

spring:
  application:
    name: security-auth-service
  datasource:
    type: com.alibaba.druid.pool.DruidDataSource
    driver-class-name: com.mysql.cj.jdbc.Driver
    url: jdbc:mysql://localhost:3600/cloud?allowMultiQueries=true&useUnicode=true&characterEncoding=UTF-8&useSSL=false&serverTimezone=GMT%2B8
    username: root
    password: root
    #   数据源其他配置
    initialSize: 5
    minIdle: 5
    maxActive: 20
    maxWait: 60000
    timeBetweenEvictionRunsMillis: 60000
    minEvictableIdleTimeMillis: 300000
    validationQuery: SELECT 1 FROM DUAL
    testWhileIdle: true
    testOnBorrow: false
    testOnReturn: false
    poolPreparedStatements: true
    #   配置监控统计拦截的filters,去掉后监控界面sql无法统计,'wall'用于防火墙
    filters: stat,wall
    maxPoolPreparedStatementPerConnectionSize: 20
    useGlobalDataSourceStat: true
    connectionProperties: druid.stat.mergeSql=true;druid.stat.slowSqlMillis=500

#mybatis-plus
mybatis-plus:
  mapper-locations: classpath*:mapper/*.xml
  #实体扫描,多个package用逗号或者分号分隔
  typeAliasesPackage: com.akieay.cloud.oauth2.entity;
  configuration:
    #是否开启驼峰命名自动映射
    map-underscore-to-camel-case: true
    #全局性地开启或关闭所有映射器配置文件中已配置的任何缓存。
    cache-enabled: false
    #指定当结果集中值为 null 的时候是否调用映射对象的 setter(map 对象时为 put)方法
    call-setters-on-nulls: true
    #指定 MyBatis 所用日志的具体实现,未指定时将自动查找。
    log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
  global-config:
    db-config:
      logic-delete-value: 1 # 逻辑已删除值(默认为 1)
      logic-not-delete-value: 0 # 逻辑未删除值(默认为 0)

logging:
  level:
    root: info

主启动

@SpringBootApplication
@MapperScan(value = "com.akieay.cloud.security.mapper")
public class SecurityAuthApplication {

    public static void main(String[] args) {
        SpringApplication.run(SecurityAuthApplication.class, args);
    }
}

创建 Security 配置类

@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(securedEnabled = true, prePostEnabled = true)
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {

    /**
     * 密码加密算法 BCrypt 推荐使用
     *
     * @return
     */
    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    /**
     * 通过自定义的 UserDetailsService 来实现查询数据库用户数据
     **/
    @Bean
    @Override
    protected UserDetailsService userDetailsService() {
        return new CustomUserDetailsService();
    }

    /**
     * 描述:设置授权处理相关的具体类以及加密方式
     */
    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        DaoAuthenticationProvider provider = new DaoAuthenticationProvider();
        // 设置不隐藏 未找到用户异常
        provider.setHideUserNotFoundExceptions(true);
        // 用户认证service - 查询数据库的逻辑
        provider.setUserDetailsService(userDetailsService());
        // 设置密码加密算法
        provider.setPasswordEncoder(passwordEncoder());
        auth.authenticationProvider(provider);
    }

    /**
     * 注入AuthenticationManager管理器
     **/
    @Bean
    @Override
    public AuthenticationManager authenticationManager() throws Exception {
        return super.authenticationManager();
    }

    /**
     * 按权拦截机制
     *
     * @param http
     * @throws Exception
     */
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.csrf().disable().authorizeRequests()
                .antMatchers("/login").permitAll()
                .anyRequest().authenticated()
                .and()
                .formLogin().successForwardUrl("/login-success")
                .and()
                .sessionManagement()
                .invalidSessionUrl("/login")
                .sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED);
    }

    @Override
    public void configure(WebSecurity web) throws Exception {
        // 可以直接访问的静态数据
        web.ignoring()
                .antMatchers("/css/**")
                .antMatchers("/404.html")
                .antMatchers("/500.html")
                .antMatchers("/html/**")
                .antMatchers("/js/**");
    }

}

创建 AccountInfo 并继承 org.springframework.security.core.userdetails.User,用来替代原有的 User 作为 UserDetails 的实现类,作为存储用户信息的实体,以便添加我们自定义的信息。

/**
 * @ClassName: AccountInfo
 * @Author: akieay
 * @Date: 2020/11/27 - 9:30
 * @Description: 用户信息类 主要提供给 Security 框架验证用,并且将用户基本信息保存到其中
 */
public class AccountInfo extends User {

    /**
     * 账户ID
     */
    private int accountId;

    public AccountInfo(String username, String password, Collection<? extends GrantedAuthority> authorities) {
        super(username, password, authorities);
    }

    public AccountInfo(String username, String password, boolean enabled, boolean accountNonExpired, boolean credentialsNonExpired, boolean accountNonLocked, Collection<? extends GrantedAuthority> authorities) {
        super(username, password, enabled, accountNonExpired, credentialsNonExpired, accountNonLocked, authorities);
    }

    public int getAccountId() {
        return accountId;
    }

    public void setAccountId(int accountId) {
        this.accountId = accountId;
    }
}

实现 UserDetailsService,自定义获取用户信息的逻辑

@Service
public class CustomUserDetailsService implements UserDetailsService {

    @Resource
    private SysAccountMapper sysAccountMapper;

    /**
     * 根据用户名加载用户信息【包含权限信息】
     * @param username
     * @return
     * @throws UsernameNotFoundException
     */
    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        SysAccountEntity sysAccount = sysAccountMapper.getByAccountName(username);
        if (null == sysAccount) {
            return null;
        }

        List<String> permissions = sysAccountMapper.listPermissionByAccountId(sysAccount.getId());
        String[] authorities = permissions.toArray(new String[permissions.toArray().length]);
        AccountInfo accountInfo = new AccountInfo(sysAccount.getAccountName(), sysAccount.getPassword(), AuthorityUtils.createAuthorityList(authorities));
        accountInfo.setAccountId(sysAccount.getId());
        return accountInfo;
    }
}

创建 SecurityUtils 工具类

/**
 * @ClassName: SecurityUtils
 * @Author: akieay
 * @Date: 2020/11/27 - 1:09
 * @Description:
 */
@Slf4j
public class SecurityUtils {

    /**
     * 描述根据账号密码进行调用 security 进行认证授权 主动调
     * 用 AuthenticationManager 的 authenticate 方法实现
     * 授权成功后将用户信息存入 SecurityContext 当中
     * @param username 用户名
     * @param password 密码
     * @param authenticationManager 认证授权管理器,
     * @see  AuthenticationManager
     * @return AccountInfo  用户信息
     */
    public static AccountInfo login(String username, String password, AuthenticationManager authenticationManager) throws AuthenticationException {
        //使用security框架自带的验证token生成器  也可以自定义。
        UsernamePasswordAuthenticationToken token =new UsernamePasswordAuthenticationToken(username,password );
        Authentication authenticate = authenticationManager.authenticate(token);
        SecurityContextHolder.getContext().setAuthentication(authenticate);
        AccountInfo accountInfo = (AccountInfo) authenticate.getPrincipal();
        return accountInfo;
    }

    /**
     * 获取当前登录的所有认证信息
     * @return
     */
    public static Authentication getAuthentication() {
        SecurityContext context = SecurityContextHolder.getContext();
        return context.getAuthentication();
    }

    /**
     * 获取当前登录用户信息
     * @return
     */
    public static AccountInfo getAccountInfo() {
        Authentication authentication = getAuthentication();
        if (null != authentication) {
            Object principal = authentication.getPrincipal();
            if (null != principal) {
                AccountInfo accountInfo = (AccountInfo) authentication.getPrincipal();
                return accountInfo;
            }
        }
        throw new RuntimeException("请先登录");
    }

    /**
     * 获取当前登录用户ID
     * @return
     */
    public static int getAccountId() {
        AccountInfo accountInfo = getAccountInfo();
        return accountInfo.getAccountId();
    }

    /**
     * 获取当前登录用户名
     * @return
     */
    public static String getAccountName() {
        AccountInfo accountInfo = getAccountInfo();
        return accountInfo.getUsername();
    }

    /**
     * 生成BCryptPasswordEncoder密码
     *
     * @param password 密码
     * @return 加密字符串
     */
    public static String encryptPassword(String password) {
        BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
        return passwordEncoder.encode(password);
    }
}

至于基础的 entity、mapper、service、controller 请自行实现这里就不给出了

20201127113048432

SysAccountMapper 的 SQL

    <!--根据账户名获取账户信息-->
    <select id="getByAccountName" resultMap="sysAccountMap">
        SELECT
            id, account_name, password, nick_name, salt, email, phone, status, gmt_create, create_account_id,
            gmt_modified, modified_account_id
        FROM
            sys_account
        WHERE
            account_name = #{accountName}
    </select>

    <!--根据用户ID 获取权限列表-->
    <select id="listPermissionByAccountId" parameterType="int" resultType="string">
        SELECT
            DISTINCT D.authorization
        FROM
            sys_account_role A INNER JOIN sys_role B ON A.role_id = B.id
            INNER JOIN sys_role_menu C ON B.id = C.role_id
            INNER JOIN sys_menu D ON C.menu_id = D.id
        WHERE
            A.account_id = #{accountId} AND D.authorization IS NOT NULL AND D.authorization != ''
    </select>

测试业务类

@RestController
public class TestController {

    @GetMapping("/r/r1")
    @PreAuthorize("hasAuthority('r:r1')")
    public String testR1() {
        return SecurityUtils.getAccountName() + "访问资源r1";
    }

    @GetMapping("/r/r2")
    @PreAuthorize("hasAuthority('r:r2')")
    public String testR2() {
        return SecurityUtils.getAccountName() + "访问资源r2";
    }

    @GetMapping("/r/r3")
    @PreAuthorize("hasAuthority('r:r1') and hasAuthority('r:r3')")
    public String testR3() {
        return SecurityUtils.getAccountName() + "访问资源r3";
    }

    @PostMapping("/login-success")
    @PreAuthorize("hasAnyAuthority('r:r1', 'r:r2')")
    public String loginSuccess() {
        return SecurityUtils.getAccountName() + "登录成功";
    }

}

以上测试类:testR1 方法访问需要 r:r1 权限,testR2 方法访问需要 r:r2 权限,testR3 方法访问需要 r:r1 与 r:r3 权限,loginSuccess 方法访问需要 r:r1r:r2 权限

测试

访问:http://localhost:3018/login,分别使用 zhangsanlisi 登录并访问 /r/r1/r/r2/r/r3;可以看到 张三都能访问,而 lisi 无权访问 /r/r3 访问会报 403【无权限】错误。注意:登录后想要切换账号,需要先调用 http://localhost:3018/logout 退出登录。

20201127115936423

20201127120104805

20201127120150449

以上为基本的 Security 的使用,下面将介绍具体的工作原理及其扩展。

工作原理
结构总览

Spring Security 所解决的问题就是安全访问控制,而安全访问控制功能其实就是对所有进入系统的请求进行拦截,校验每个请求是否能够访问它所期望的资源。可以通过 FilterAOP 等技术实现这个功能,Spring SecurityWeb 资源的保护是靠 Filter 实现的,所以从这个 Filter 来入手,逐步深入 Spring Security 原理。

当初始化 Spring Security 时,会创建一个名为 SpringSecurityFilterChainServlet 过滤器,类型为 org.springframework.security.web.FilterChainProxy ,它实现了 javax.servlet.Filter ,因此外部的请求会经过此类,下图是 Spring Security 过滤器链结构图:

20201124111103734

FilterChainProxy 是一个代理,真正起作用的是 FilterChainProxySecurityFilterChain 所包含的各个 Filter,同时这些 Filter 作为 BeanSpring 管理,它们是 Spring Security 的核心,各有各的职责,但它们并不直接处理用户的认证,也不直接处理用户的授权,而是把它们交给了认证管理器(AuthenticationManager)和决策管理器(AccessDecisionManager)进行处理,下图是 FilterChainProxy 相关类下UML 图示。

20201124111211981

Spring Security 功能的实现主要是由一系列过滤器链相互配合完成。

20201124111326767

主要过滤器:

  • **SecurityContextPersistenceFilter:**这个 Filter 是整个拦截过程的入口和出口(也就是第一个和最后一个拦截器),会在请求开始时从配置好的 SecurityContextRepository 中获取 SecurityContext,然后把它设置给 SecurityContextHolder。在请求完成后将 SecurityContextHolder 持有的 SecurityContext 再保存到配置好的 SecurityContextRepository,同时清除 SecurityContextHolder 所持有的 SecurityContext
  • **UsernamePasswordAuthenticationFilter:**用于处理来自表单提交的认证。该表单必须提供对应的用户名和密码,其内部还有登陆成功或失败后进行处理的 AuthenticationSuccessHandlerAuthenticationFailureHandler ,这些都可以根据需求做相关修改;
  • **FilterSecurityInterceptor:**是用于保护 web 资源的,使用 AccessDecisionManager 对当前用户进行授权访问;
  • **ExceptionTranslationFilter:**能够捕获来自 FilterChain 所有的异常,并进行处理。但是它只会处理两类异常:AuthenticationExceptionAccessDeniedException,其它异常它会继续抛出。
Security 认证流程
认证流程

20201124112803949

  • 一、用户提交用户名、密码被 SecurityFilterChain 中的 UsernamePasswordAuthenticationFilter 过滤器获取到,封装为请求 Authentication,通常情况下是 UsernamePasswordAuthenticationToken 这个实现类。
  • 二、然后过滤器将 Authentication 提交至认证管理器(AuthenticationManager)进行认证。
  • 三、认证成功后,AuthenticationManager 身份管理器返回一个被填充了信息的(包括上面提到的权限信息,身份信息,细节信息,但密码通常会被移除)Authentication 实例。
  • 四、SecurityContextHolder 安全上下文容器将 第三步 填充了信息的 Authentication,通过 SecurityContextHolder.getContext().setAuthentication(...) 方法,设置到其中。AuthenticationManage 接口(认证管理器)是认证相关的核心接口,也是发起认证的出发点,它的实现类为 ProviderManager。而 Spring Security 支持多种认证方式,因此 ProviderManager 维护着一个 List<AuthenticationProvider> 列表,存放多种认证方式,最终实际的认证工作是由 AuthenticationProvider 完成的。如:web 表单 的对应的 AuthenticationProvider 实现类为DaoAuthenticationProvider,它的内部又维护着一个 UserDetailsService 负责 UserDetails 的获取。最终 AuthenticationProviderUserDetails 填充至 Authentication

认证核心组件的大体关系如下:

20201124124142249

AuthenticationProvider

通过前面的认证流程的介绍,我们可知 认证管理器(AuthenticationManager)委托 AuthenticationProvider 完成认证工作。AuthenticationProvider 是一个接口,定义如下:

public interface AuthenticationProvider {
    Authentication authenticate(Authentication var1) throws AuthenticationException;

    boolean supports(Class<?> var1);
}

authenticate 方法定义了认证的实现过程,它的参数是一个 Authentication ,里面包含了登录用户所提交的用户名、密码等。而返回值也是一个 Authentication ,这个 Authentication 则是在认证成功后,将用户的权限及其它信息重新组装后生成。

Spring Security 中维护着一个 List<AuthenticationProvider> 列表,存放多种认证方式,不同的认证方式使用不同的 AuthenticationProvider 。如:使用用户名密码登录时,使用 AuthenticationProvider1;短信登陆时,使用 AuthenticationProvider2 等等。

每个 AuthenticationProvider 需要实现 supports() 方法来表明自己支持的认证方式。如:我们使用表单方式认证,在提交请求时 Spring Security 会生成 UsernamePasswordAuthenticationToken,它就是一个 Authentication,里面封装着用户提交的用户名、密码信息。而对应的,由哪个 AuthenticationProvider 来处理它呢?

我们在 DaoAuthenticationProvider 的基类 AbstractUserDetailsAuthenticationProvider 发现如下方法:

public boolean supports(Class<?> authentication) {
    return UsernamePasswordAuthenticationToken.class.isAssignableFrom(authentication);
}

也就是说当 web 表单提交用户名密码时,Spring Security 使用 DaoAuthenticationProvider 处理认证。最后,我们来看下 Authentication(认证信息)的结构,它是一个接口,我们之前提到的 UsernamePasswordAuthenticationToken 就是它的实现之一。

public interface Authentication extends Principal, Serializable {1)
    Collection<? extends GrantedAuthority> getAuthorities();2)

    Object getCredentials();3)

    Object getDetails();4)

    Object getPrincipal();5boolean isAuthenticated(); (6)

    void setAuthenticated(boolean var1) throws IllegalArgumentException;
}
  • (1)AuthenticationSpring Security 包中的接口,直接继承自 Principal 类,而 Principal 是位于 java.security 包中的,他表示一个抽象主体身份,任何主体都有一个名称,因此包含一个 getName() 方法。
  • (2)getAuthorities(),权限信息列表,默认是 GrantedAuthority 接口的一些实现类,通常是代表权限信息的一系列字符串。
  • (3)getCredentials(),凭证信息,用户输入的密码字符串,在认证过后通常会被删除,用于保障安全。
  • (4)getDetails(),细节信息,web 应用中的实现接口通常为 WebAuthenticationDetails ,它记录了访问者的 ip 地址和 sessionId 的值。
  • (5)getPrincipal(),身份信息,大部分情况下返回的是 UserDetails 接口的实现类,UserDetails 代表用户的详细信息,从 Authentication 中取出来的 UserDetails 就是当前登录用户信息,它也是框架中常用接口之一。
  • (6)isAuthenticated(),表示是否用户认证通过。
UserDetailsService

由上可知,DaoAuthenticationProvider 处理了 web 表单认证逻辑,认证成功后即得到一个 AuthenticationUsernamePasswordAuthenticationToken 实现),里面包含了身份信息(Principal)。这个身份信息就是一个 Object,大多数情况下它可以被强转为 UserDetails 对象。

DaoAuthenticationProvider 中包含一个 UserDetailsService 实例,它负责根据用户名提取用户信息 UserDetails(含密码) ,而后 DaoAuthenticationProvider 会去对比 UserDetailsService 提取的用户密码与用户提交的密码是否匹配作为认证成功的关键依据。由此我们可以通过自定义 UserDetailsService 来实现自定义的身份验证逻辑。

public interface UserDetailsService {
    UserDetails loadUserByUsername(String var1) throws UsernameNotFoundException;
}

注意 DaoAuthenticationProviderUserDetailsService 的职责区分,UserDetailsService 只负责从特定的地方(通常是数据库)加载用户信息;而 DaoAuthenticationProvider 负责完整的认证流程,同时会把 UserDetails 填充到 Authentication中。

PasswordEncoder

由上可知,DaoAuthenticationProvider 通过 UserDetailsService 获取到 UserDetails,那么它是如何与请求 Authentication 中的密码做对比的呢?

在这里 Spring Security 为了适应多种多样的加密类型,又做了抽象,DaoAuthenticationProvider 通过 PasswordEncoder 接口的 matches 方法进行密码的比对,而具体的密码比对细节取决于实现类:

public interface PasswordEncoder {
    String encode(CharSequence var1);

    boolean matches(CharSequence var1, String var2);

    default boolean upgradeEncoding(String encodedPassword) {
        return false;
    }
}

Spring Security 提供了很多内置的 PasswordEncoder ,能够开箱即用,使用某种 PasswordEncoder 只需要进行如下声明即可:

    @Bean
    public PasswordEncoder passwordEncoder() {
        return NoOpPasswordEncoder.getInstance();
    }

NoOpPasswordEncoder 采用字符串匹配方法,不对密码进行加密比较,其实现如下:

20201124222729234

密码的比较流程如下:

  • 用户输入密码(明文)
  • DaoAuthenticationProvider 获取 UserDetails(其中存储了用户的密码)
  • DaoAuthenticationProvider 使用 PasswordEncoder 对输入密码与正确密码进行校验,密码一致则通过校验,否则校验失败;具体的校验规则取决于 PasswordEncoder 的实现类。

在实际项目中推荐使用 BCryptPasswordEncoderPbkdf2PasswordEncoderSCryptPasswordEncoder 等;感兴趣的可以看看其具体实现,具体实现类如下:【其中中间划横线的是不再建议使用的】

20201124223254050

在这里我使用 BCryptPasswordEncoder,具体修改如下:

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

注意一旦修改了PasswordEncoder ,其对应的密码也应该改为其实现的密码加密后的字符串。

Security 授权流程
授权流程

Spring Security 可以通过 http.authorizeRequests() 对 web 请求进行授权保护。Spring Security 使用标准的 Filter 建立了对 web 请求的拦截,最终实现对资源的授权访问。

Spring Security 授权流程如下:

20201125002139366

分析授权流程:

  • 一、拦截请求,已认证的用户访问受保护的 web 资源时,将被 SecurityFilterChain 中的 FilterSecurityInterceptor 的子类拦截。

  • 二、获取资源访问策略FilterSecurityInterceptor 会从 SecurityMetadataSource 的子类 DefaultFilterInvocationSecurityMetadataSource 获取要访问当前资源所需要的权限 Collection<ConfigAttribute>SecurityMetadataSource 其实就是读取访问策略的抽象,而读取的内容,其实就是我们配置的访问规则,读取访问策略如:

    http.csrf().disable().authorizeRequests()
                    .antMatchers("/r/r1").hasAuthority("p1")
                    .antMatchers("/r/r2").hasAuthority("p2")
    
  • 三、最后FilterSecurityInterceptor 会调用 AccessDecisionManager 进行授权决策,若决策通过,则允许访问资源,否则将禁止访问。

AccessDecisionManager

AccessDecisionManager (访问决策管理器)的核心接口如下:

public interface AccessDecisionManager {
    /**
    * 通过传递的参数来决定用户是否有访问对应受保护资源的权限
    */
    void decide(Authentication var1, Object var2, Collection<ConfigAttribute> var3) throws AccessDeniedException, InsufficientAuthenticationException;

    boolean supports(ConfigAttribute var1);

    boolean supports(Class<?> var1);
}

decide() 方法参数介绍:

Authentication:要访问资源的访问者的身份【包含用户权限信息】;

Object:要访问的受保护资源,web 请求对应 FilterInvocation

Collection:受保护资源的访问策略【访问资源所需的权限】,通过 SecurityMetadataSource 获取。

decide方法就是用来鉴定当前用户是否有访问对应受保护资源的权限

其实现类如下:

20201125110128916

AccessDecisionManager 授权决策

AccessDecisionManager 采用投票的方式来确定是否能够访问受保护的资源。

20201125105041753

AccessDecisionManager 中包含的一系列 AccessDecisionVoter 将会被用来对 Authentication 是否有权访问受保护对象进行投票,AccessDecisionManager 根据投票结果,做出最终决策。

AccessDecisionVoter 是一个接口,其中定义有三个方法,具体结构如下:

public interface AccessDecisionVoter<S> {
    int ACCESS_GRANTED = 1;  //同意
    int ACCESS_ABSTAIN = 0;  //弃权
    int ACCESS_DENIED = -1;  //拒绝

    boolean supports(ConfigAttribute var1);

    boolean supports(Class<?> var1);

    int vote(Authentication var1, S var2, Collection<ConfigAttribute> var3);
}

vote() 方法用来返回投票结果,其返回结果会是 AccessDecisionVoter 中定义的三个常量之一,ACCESS_GRANTED 表示同意、ACCESS_ABSTAIN 表示弃权、ACCESS_DENIED 表示拒绝。当一个 AccessDecisionVoter 不能判定当前 Authentication 是否拥有访问对应资源的权限时,其 vote() 方法的返回结果应为 ACCESS_ABSTAIN【弃权】。

Spring Security 内置了三个基于投票的 AccessDecisionManager 实现类,分别是:AffirmativeBasedConsensusBasedUnanimousBased,默认采用 AffirmativeBased

AffirmativeBased

20201125134834393

AffirmativeBased的决策逻辑为:

(1)只要有 AccessDecisionVoter 的投票为 ACCESS_GRANTED,则同意用户进行访问;

(2)如果全部弃权,也表示通过;

(3)如果没有一个投同意票,但是有人投反对票,则将抛出 AccessDeniedException 异常。

总结:当没有人投同意票且有人投反对票时,则拒绝访问;否则,将允许用户访问。

Spring Security 默认使用的是 AffirmativeBased

ConsensusBased

20201125135304319

ConsensusBased 的决策逻辑为:

(1)如果赞成票多于反对票则表示通过;

(2)反过来,如果反对票多余赞成票则将抛出 AccessDeniedException

(3)如果赞成票与反对票相同且不等于0,并且属性 allowIfEqualGrantedDeniedDecisions 的值为 true,则表示通过,否则将抛出异常 AccessDeniedException 。参数 allowIfEqualGrantedDeniedDecisions 的值默认为 true。

(4)如果所有的 AccessDecisionVoter 都弃权了,则将视参数 allowIfAllAbstainDecisions 的值而定,如果该值为 true 则表示通过,否则将抛出异常 AccessDeniedException 。参数 allowIfAllAbstainDecisions 的默认值为 false。

UnanimousBased

20201125135332028

UnanimousBased 的逻辑与另外两种实现有点不一样,另外两种会一次性把受保护对象的配置属性全部传递给 AccessDecisionVoter 进行投票,而 UnanimousBased 会一次只传递一个 ConfigAttributeAccessDecisionVoter 进行投票。

UnanimousBased 的决策逻辑具体来说是这样的:

(1)如果受保护对象配置的某一个 ConfigAttribute 被任意的 AccessDecisionVoter 反对了,则将抛出 AccessDeniedException

(3)如果没有反对票,但是有赞成票,则表示通过。

(3)如果全部弃权了,则将视参数 allowIfAllAbstainDecisions 的值而定,true 则通过,false 则抛出 AccessDeniedException

总结:只要有一个反对了,就拒绝;没有反对票且有赞成票,则通过;若是全部弃权了,则根据参数 allowIfAllAbstainDecisions 而定;

Spring Security 也内置一些投票者实现类,如:RoleVoter、AuthenticatedVoter、WebExpressionBoter 等,可以自行查阅学习。

20201125115257491

自定义认证
会话

用户认证通过以后,为了避免用户的每次操作都进行认证,可将用户信息保存在会话中。Spring Security 提供会话管理,认证通过后将身份信息放入 SecurityContextHolder 上下文,SecurityContext 与当前线程进行绑定,方便获取用户身份。

获取用户身份

修改 TestController,添加 getUsername 方法,注意 Spring Security 获取当前登录用户信息的方法为 SecurityContextHolder.getContext().getAuthentication()

@RestController
public class TestController {

    @GetMapping("/r/r1")
    public String testR1() {
        return getUsername() + "访问资源r1";
    }

    @GetMapping("/r/r2")
    public String testR2() {
        return getUsername() + "访问资源r2";
    }

    @PostMapping("/login-success")
    public String loginSuccess() {
        return getUsername() + "登录成功";
    }

    /**
    * 获取登录用户名
    */
    private String getUsername() {
        String username = null;
        //当前认证通过的用户身份
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        //用户身份
        Object principal = authentication.getPrincipal();
        if (null == principal) {
            username = "匿名";
        }

        if (principal instanceof UserDetails) {
            UserDetails userDetails = (UserDetails) principal;
            username = userDetails.getUsername();
        } else {
            username = principal.toString();
        }

        return username;
    }
}

会话控制

我们可以通过以下选项准确的控制会话何时创建以及 Spring Security 如何与之交互:

机制描述
ALWAYS如果没有 Session 存在就创建一个
IF_REQUIRED如果需要就创建一个 Session(默认)登陆时
NEVERSpring Security 将不会创建 Session,但是如果应用中其它地方创建了 Session,那么 Spring Security 将会使用它。
STATELESSSpring Security 将绝对不会创建 Session,也不使用 Session

通过以下配置方式可以对该选项进行配置:

@Override
protected void configure(HttpSecurity http) throws Exception {
    http.sessionManagement()
        .sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED);
}

默认情况下,Spring Security 会为每个登陆成功的用户新建一个 Session ,就是 IF_REQUIRED。

若选用 NEVER ,Spring Security 对登陆成功的用户不会创建 Session,但若你的应用程序在某个地方新建了 session,那么 Spring Security 会用它的。

若使用 STATELESS,Spring Security 对登陆成功的用户不会创建 Session,你的应用程序也不会允许新建 Session。并且它会暗示不适用 cookie,所以每个请求都需要重新进行身份验证。这种无状态架构适用于REST API 及其它无状态认证机制。

会话超时

可以在 servlet 容器中设置 Session 的超时时间,如下设置 Session 有效期为 3600s;

server:
  servlet:
    session:
      timeout: 3600s

session 超时之后,可以通过 Spring Security 设置跳转的路径。

 http.sessionManagement()
     .invalidSessionUrl("/login")

invalidSessionUrl 指传入的 sessionId 无效。

安全会话 cookie

我们可以使用 httpOnly 和 secure 标签来保护我们的会话 cookie:

  • httpOnly:如果为 true,那么浏览器脚本将无法访问 cookie
  • secure:如果为 true,则 cookie 将仅通过 HTTPS 连接发送
server:
  servlet:
    session:
      cookie:
        http-only: true
        secure: true
授权

授权的方式包括 web 授权和方法授权,web 授权是通过 url 拦截进行授权,方法授权是通过 方法拦截进行授权。它们都会调用 accessDecisionManager 进行授权决策,若为 web 授权则拦截器为 FilterSecurityInterceptor;若为方法授权则拦截器为 MethodSecurityInterceptor。如果同时通过 web 授权和方法授权则优先执行 web 授权,再执行方法授权;最后决策通过,则允许访问资源,否则将禁止访问。

类关系如下:

20201126151612616

web 授权

再上面的例子中我们完成了认证拦截,并对 /r/** 下的某些资源进行了简单的授权保护,但是我们想进行灵活的授权控制该怎么做呢?通过给 http.authorizeRequests() 添加多个子节点来定制需求到我们的 URL,如下面的案例:

@Override
protected void configure(HttpSecurity http) throws Exception {
   (1) http.authorizeRequests()
   (2)    .antMatchers("/r/r1").hasAuthority("p1")
   (3)    .antMatchers("/r/r2").hasAuthority("p2")
   (4)    .antMatchers("/r/r3").access("hasAuthority('p1') and hasAuthority('p2')")
   (5)    .antMatchers("/r/**").authenticated()
   (6)    .anyRequest().permitAll()
        .and()
        .formLogin()
        // .....
}

(1)http.authorizeRequests() 方法有多个子节点,每个 matcher 安装它们的声明顺序执行。

(2)指定 “/r/r1” URL,拥有 p1 权限才能访问

(3)指定 “/r/r2” URL,拥有 p2 权限才能访问

(4)指定 “/r/r3” URL,拥有 p1 和 p2 权限才能访问

(5)指定 除了上面的资源外 “/r/**” 的其它所有资源,只要通过身份认证就可以访问

(6)剩余的尚未匹配的资源,不做保护。

注意:规则的顺序是很重要的,更具体的规则应该先写;否则一旦前面的规则匹配上了,就不会再匹配下面的规则;如:若是把 第(5)条规则放在第(2)条规则之前,则后面的(2)(3)(4)都不会被匹配到

http.authorizeRequests()
    .antMatchers("/r/**").authenticated()
    .antMatchers("/r/r1").hasAuthority("p1")
    .antMatchers("/r/r2").hasAuthority("p2")
    .antMatchers("/r/r3").access("hasAuthority('p1') and hasAuthority('p2')")
    .anyRequest().permitAll()
    .and()
    .formLogin()
    // .....

这时,只需要登录即可访问 r1、r2、r3 不会再验证是否具有 p1、p2 权限;所以需要特别注意规则的添加顺序。

常见的方法有:

authenticated(): 保护 URL,需要用户登录

permitAll(): 指定 URL 无需保护,一般应用与静态资源文件

hasRole(String role): 限制单个角色访问,角色将被增加 “ROLE_” 所以 “ADMIN” 角色的实际写法为 “ROLE_ADMIN”

hasAuthority(String authority): 限制单个权限访问

hasAnyRole(String …role): 允许多个角色访问

hasAnyAuthority(String …authority): 允许多个权限访问

access(String attribute): 该方法使用 SpEL 表达式,所以可以创建复杂的限制

**hasIpAddress(String ipaddressExpression):**限制 IP 地址或子网

方法授权

现在我们已经掌握了如何使用 http.authorizeRequests() 对 web 资源进行授权保护,从 Spring Security 2.0 版本开始,它提供了服务层方法的安全性支持。本节学习如何使用 @PreAuthorize、@PostAuthorize、@Secured 注解。

首先 我们需要添加 @EnableGlobalMethodSecurity 开启基于注解的安全性规则,然后再方法(在类或接口上)添加注解就会限制对该方法的访问

@Configuration
@EnableGlobalMethodSecurity( securedEnabled = true, prePostEnabled = true)
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {

一、使用 @Secured 注解

@GetMapping("/r/r3")
@Secured("ROLE_TELLER")
public String testR3() {
    return getUsername() + "访问资源r3";
}

@PostMapping("/login-success")
@Secured("IS_AUTHENTICATED_ANONYMOUSLY")
public String loginSuccess() {
    return getUsername() + "登录成功";
}

以上配置标明 loginSuccess 方法可以匿名访问,testR3 方法需要 TELLER 角色才能访问。

二、使用 @PreAuthorize 注解

@GetMapping("/r/r1")
@PreAuthorize("hasAuthority('r:r1')")
public String testR1() {
    return SecurityUtils.getAccountName() + "访问资源r1";
}

@GetMapping("/r/r2")
@PreAuthorize("hasAuthority('r:r2')")
public String testR2() {
    return SecurityUtils.getAccountName() + "访问资源r2";
}

@GetMapping("/r/r3")
@PreAuthorize("hasAuthority('r:r1') and hasAuthority('r:r3')")
public String testR3() {
    return SecurityUtils.getAccountName() + "访问资源r3";
}

@PostMapping("/login-success")
@PreAuthorize("isAnonymous()")
public String loginSuccess() {
    return SecurityUtils.getAccountName() + "登录成功";
}

以上配置标明:testR1 需要 ‘r:r1’ 权限,testR2 需要 ‘r:r2’ 权限才能访问;testR3 需要 ‘r:r1’ 与 ‘r:r3’ 权限才能访问;loginSuccess 可以匿名访问。

三、使用 @PostAuthorize 注解

由于 @PostAuthorize 与 @PreAuthoriz 使用方法差不多,这里就不单独介绍了。有兴趣的可以自己试试。

@PreAuthorize 与 @PostAuthorize 的区别在于:@PreAuthoriz 在方法执行前进行拦截,@PostAuthorize 在方法执行之后进行拦截。

  • 1
    点赞
  • 8
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值