SpringSecurity的学习(包含传统web处理方案以及前后端分离处理方案)

目录

(一)权限管理的基本概念

(1.1) 认证

  • 认证: 识别当前登录的用户是否为本系统的合法用户

(1.2) 授权

  • 授权:该步骤一定是在认证之后,用户只有进行了认证,才会进行授权的操作。授权就是为用户赋予访问某些特定资源的权限。

(1.3)常见的权限控制解决方案(3种)

  • shiro : 是一个轻量级的权限控制框架,优势是无需依赖于其他特定框架,并且学习成本低,使用简单。

  • Spring Security : 这是Spring.io家族中的一员,使用它时必须依赖于Spring框架。 学习成本较高,但是功能更加强大,是未来的趋势。

  • 自定义权限控制框架: 权限控制,实际上就是在用户登录时进行认证与授权,并且之后访问某些特定资源时,需要判断是否有权限。而有一些公司就会自定义一套自己的权限控制解决方案,但是一般比较少。

(1.4)SpringSecurity的常见过滤器链

  • 首先,SpringSecurity的权限控制,是通过很多过滤链来完成的,这些过滤器都装在了DefaultSecurityFilterChain当中

  • 查看这些过滤器链可以通过debug启动类,然后拿到spring的容器,使用getBean来获取
    run.getBean("springSecurityFilterChain")
    image

(1.5)SpringSecurity的认证流程

  • SpringSecurity认证的大致流程如下:

  • 1、客户端发送登录请求,传输用户名与密码等信息

  • 2、UsernamePasswordAuthticationFilter默认会匹配请求地址为/login的认证请求,拦截到之后会调用其attemptAuthentication试图认证方法

    • 2.1 在该方法中,会将用户名和密码封装一个UsernamePasswordAuthenticationToken对象,然后调用AuthenticationManager的authenticate方法来完成认证操作
  • 3、AuthenticationManager是一个接口,因此会使用该接口的实现来完成认证操作,而这个实现默认是ProviderManager,因此实际上是调用了ProviderManagerauthenticate方法,该方法的接受参数与返回参数都是一个Authentication

    • 3.1 在ProviderManager的authenticate方法中,会循环遍历本类的一个List<AuthenticationProvider>成员变量来判断是否支持当前的认证,如果支持则会使用对应的AuthenticationProvider来进行认证操作。
    • 3.2 如果第一次遍历均不支持,ProviderManager当中还有一个成员变量private AuthenticationManager parent;也就是它的父亲,本质还是一个AuthenticationManager来进行认证操作,此时一般有个默认实现:DaoAuthenticationProvider,最终由它来进行认证操作
  • 4、DaoAuthenticationProvider 在进行认证时,需要获取到已有的用户身份信息,以此来进行认证,获取身份信息就会涉及到数据源UserDetailService的获取,默认将会采用InMemoryUserDetailsManager基于内存的数据源

  • 5、在进行认证时,会校验密码,默认会采用DelegatingPasswordEncoder 来进行密码的解析与校验,DelegatingPasswordEncoder采用的是{标识符}密码的格式来进行密码的校验,可以支持同时多种密码校验规则。该默认规则是在WebSecurityConfigurerAdapter中指定的,并且设置默认之前会在容器中获取,如果容器中已经有了,则使用已有的。

(1.6)Spring Security中默认用户(数据源)的生成

  • SpringSecurity的自动配置当中有一个UserDetailServiceAutoConfiguration.java类,其中配置了一个Bean,InMemoryUserDetailsManager,该类是UserDetailsService的孙子类

  • 配置该Bean的时候,是从SecurityProperties中取出User,而这个User的默认用户名为:user,默认的密码为一个UUID,并且在运行时,会将该用户密码打印在控制台

(1.7)Spring Security当中默认的Login页面的生成

  • SpirngSecurity默认的过滤器链中,有一个过滤器叫做DefaultLoginPageGeneratingFilter,该过滤器中的generateLoginPageHtml()方法会为我们生成一个HTML页面
    image

(二)SpringSecurtiy中与认证有关的常用功能

自定义资源认证规则(注意重写后会覆盖默认的)

@Configuration
public class MySecurityConfig extends WebSecurityConfigurerAdapter {

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        // authorizeRequests 设置访问权限
        http.authorizeRequests()
                // 匹配/hello及其/hello/*请求,使其认证或未认证时都可以访问
                .mvcMatchers("/hello").permitAll()
                // 其他所有请求,都需要进行认证
                .anyRequest().authenticated()
                .and()
                // 开启表单认证,这样就会有默认的一个登陆页
                .formLogin()
                .and()
                // 暂时关闭CSRF,避免干扰
                .csrf().disable();
    }
}

自定义登录界面

  • 项目额外引入一个thymeleaf的启动器
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-security</artifactId>
    </dependency>
  • 编写一个简单的登录页面login.html
<form method="post" th:action="@{/login}">
    用户名:<input type="text" name="uname"><br>
    密码:<input type="text" name="pwd"><br>
    <input type="submit"  value="登录"><br>
</form>
  • 编写一个SpringMVC的配置类,设置一下ViewController
@Configuration
public class SpringMvcConfig implements WebMvcConfigurer {
    @Override
    public void addViewControllers(ViewControllerRegistry registry) {
        registry.addViewController("/login.html").setViewName("login");
        registry.addViewController("/index.html").setViewName("index");
    }
}
  • 编写Security的配置
@Configuration
public class MySecurityConfig extends WebSecurityConfigurerAdapter {

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        // authorizeRequests 设置访问权限
        http.authorizeRequests()
                .mvcMatchers("/hello").permitAll()
                // 放行登录页面
                .mvcMatchers("/login.html").permitAll()
                .anyRequest().authenticated()
                .and()
                // 开启表单认证,这样就会有默认的一个登陆页
                .formLogin()
                // 设置登录页
                .loginPage("/login.html")
                // 并设置登录请求的地址(写/login是因为,UsernamePasswordAuthenticationFilter拦截的是/login来进行登录认证)
                .loginProcessingUrl("/login")
                // 设置登录页面的用户名密码请求参数
                .usernameParameter("uname")
                .passwordParameter("pwd")
                .and()
                // 暂时关闭CSRF,避免干扰
                .csrf().disable();
    }
}

登录成功的处理

传统web登录成功的处理

@Configuration
public class MySecurityConfig extends WebSecurityConfigurerAdapter {

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        // authorizeRequests 设置访问权限
        http.authorizeRequests()
                    .mvcMatchers("/hello").permitAll()
                    .mvcMatchers("/login.html").permitAll()
                    .anyRequest().authenticated()
                .and()
                    .formLogin()
                    .loginPage("/login.html")
                    .loginProcessingUrl("/login")
                    .usernameParameter("uname")
                    .passwordParameter("pwd")
                    // 登录成功的页面跳转,如果第二个参数设置为true,
                    // 则不管客户端访问的是哪个页面,都会跳转到/index.html请求
                    // 默认false如果用户在登录前有了访问请求,则会优先去用户的访问请求地址
                    .defaultSuccessUrl("/index.html", true)
                .and()
                    .csrf().disable();
    }
}

前后端分离登录成功的处理

@Configuration
public class MySecurityConfig extends WebSecurityConfigurerAdapter {

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests()
                    .mvcMatchers("/hello").permitAll()
                    .mvcMatchers("/login.html").permitAll()
                    .anyRequest().authenticated()
                .and()
                    .formLogin()
                    .loginPage("/login.html")
                    .loginProcessingUrl("/login")
                    .usernameParameter("uname")
                    .passwordParameter("pwd")
                    // 编写一个处理器
                    .successHandler((request, response, authentication) -> {
                        // 设置反应的MediaType为JSON以及UTF8
                        response.setContentType(MediaType.APPLICATION_JSON_UTF8_VALUE);
                        response.getWriter().write("恭喜你登录成功,其实这里可以响应统一的返回体JSON");
                    })
                .and()
                    .csrf().disable();
    }
}

登录失败的处理

传统web登录失败的处理

  • 其实默认,认证失败后,会抛出认证失败异常,然后该异常会被ExceptionTranslationFilter过滤器给捕获,捕获后会向浏览器再次重新请求登录页面。

  • 当然也可以在配置类中编写错误跳转页面

@Configuration
public class MySecurityConfig extends WebSecurityConfigurerAdapter {

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests()
                    .mvcMatchers("/hello").permitAll()
                    .mvcMatchers("/login.html").permitAll()
                    .anyRequest().authenticated()
                .and()
                    .formLogin()
                    .loginPage("/login.html")
                    .loginProcessingUrl("/login")
                    .usernameParameter("uname")
                    .passwordParameter("pwd")
                    // 设置登录失败的跳转页面
                    .failureUrl("/login.html")
                .and()
                    .csrf().disable();
    }
}
  • 异常信息的存储源码分析
    image

  • 在页面中获取到异常信息

<p th:if="${session.SPRING_SECURITY_LAST_EXCEPTION != null}" th:text="${session.SPRING_SECURITY_LAST_EXCEPTION}"></p>

前后端分离登录失败的处理

@Configuration
public class MySecurityConfig extends WebSecurityConfigurerAdapter {

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests()
                    .mvcMatchers("/hello").permitAll()
                    .mvcMatchers("/login.html").permitAll()
                    .anyRequest().authenticated()
                .and()
                    .formLogin()
                    .loginPage("/login.html")
                    .loginProcessingUrl("/login")
                    .usernameParameter("uname")
                    .passwordParameter("pwd")
                    // 处理登录失败时的响应信息,前后端分离解决方案
                    .failureHandler((req, resp, ex) -> {
                        resp.setContentType(MediaType.APPLICATION_JSON_UTF8_VALUE);
                        resp.getWriter().write("认证出现异常。" + ex.getMessage());
                    })
                .and()
                    .csrf().disable();
    }
}

注销登录处理

传统web注销成功处理

@Configuration
public class MySecurityConfig extends WebSecurityConfigurerAdapter {

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests()
                    .mvcMatchers("/hello").permitAll()
                    .mvcMatchers("/login.html").permitAll()
                    .anyRequest().authenticated()
                .and()
                    .formLogin()
                    .loginPage("/login.html")
                    .loginProcessingUrl("/login")
                    .usernameParameter("uname")
                    .passwordParameter("pwd")
                .and()
                    .logout()
                    // 设置注销的访问地址和请求方式
                    .logoutRequestMatcher(new OrRequestMatcher(
                            Arrays.asList(
                                    new AntPathRequestMatcher("/logout", HttpMethod.GET.name()),
                                    new AntPathRequestMatcher("/bye")
                            )
                         )
                    )
                    // 传统web处理注销成功跳转页面
                    .logoutSuccessUrl("/hello")
                .and()
                    .csrf().disable();
    }

前后端web注销成功处理

@Configuration
public class MySecurityConfig extends WebSecurityConfigurerAdapter {

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests()
                    .mvcMatchers("/hello").permitAll()
                    .mvcMatchers("/login.html").permitAll()
                    .anyRequest().authenticated()
                .and()
                    .formLogin()
                    .loginPage("/login.html")
                    .loginProcessingUrl("/login")
                    .usernameParameter("uname")
                    .passwordParameter("pwd")
                .and()
                    .logout()
                    // 设置注销的访问地址和请求方式
                    .logoutRequestMatcher(new OrRequestMatcher(
                            Arrays.asList(
                                    new AntPathRequestMatcher("/logout", HttpMethod.GET.name()),
                                    new AntPathRequestMatcher("/bye")
                            )
                         )
                    )
                    // 前后端分离,返回json数据
                    .logoutSuccessHandler((req, resp, auth) -> {
                        resp.setContentType(MediaType.APPLICATION_JSON_UTF8_VALUE);
                        resp.getWriter().write("恭喜你注销成功");
                    })
                .and()
                    .csrf().disable();
    }
}

获取用户认证信息

  • 当用户认证成功后,认证信息会被存储到SecurityContextHolder中,因此我们可以通过获取SecurityContextHolder的方式来获取到认证信息。

传统web获取用户认证信息(通过thymeleaf与security的扩展)

  • 添加如下额外依赖
        <dependency>
            <groupId>org.thymeleaf.extras</groupId>
            <artifactId>thymeleaf-extras-springsecurity5</artifactId>
        </dependency>
  • 在需要获取认证信息的页面中,添加命名空间
<html lang="en" xmlns:th="http://www.thymeleaf.org"
      xmlns:sec="http://www.thymeleaf.org/extras/spring-security">
  • 使用标签获取认证信息
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org"
      xmlns:sec="http://www.thymeleaf.org/extras/spring-security">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
<h1>首页</h1>
当前用户名:<span sec:authentication="principal.username"></span> <br>
当前用户的权限列表:<span sec:authentication="principal.authorities"></span> <br>
用户是否未过期:<span sec:authentication="principal.accountNonExpired"></span><br>
用户是否未锁定:<span sec:authentication="principal.accountNonLocked"></span><br>
用户的密码是否未过期:<span sec:authentication="principal.credentialsNonExpired"></span><br>
</body>
</html>

前后端分离获取用户认证信息

    @GetMapping("/getUserInfo")
    public String getUserInfo() throws JsonProcessingException {
        // 注意这里的User是org.springframework.security.core.userdetails.User
        User user = (User) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
        return new ObjectMapper().writeValueAsString(user);
    }

自定义数据源UserDetailsService

  • 默认的InMemoryUserDetailsManager数据源,是在UserDetailsServiceAutoConfiguration自动配置当中进行配置的,而该自动配置类的生效条件有一个是:当Spring的容器中没有UserDetailsService时,则不会自动创建数据源,使用容器当中已有的。

  • 因此可以分析得出,只需要我们往容器中添加一个数据源UserDetailsService,则可以替换掉默认的数据源

  • 引入连接数据库所需要的mybaits-plus、mysql连接驱动

        <dependency>
            <groupId>com.baomidou</groupId>
            <artifactId>mybatis-plus-boot-starter</artifactId>
            <version>3.4.3</version>
        </dependency>
        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
        </dependency>
  • 在application.yml中配置数据源
spring:
  datasource:
    driver-class-name: com.mysql.cj.jdbc.Driver
    url: jdbc:mysql://localhost:3306/security_study?useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai
    username: root
    password: abc123
  • 编写一个UserMapper接口
@Mapper
public interface UserMapper extends BaseMapper<User> {
}
  • 创建一张User表,用于存储用户信息,密码暂时采用明文存储
CREATE TABLE `sys_user` (
  `id` BIGINT(20) NOT NULL AUTO_INCREMENT COMMENT '主键',
  `user_name` VARCHAR(64) NOT NULL DEFAULT 'NULL' COMMENT '用户名',
  `nick_name` VARCHAR(64) NOT NULL DEFAULT 'NULL' COMMENT '昵称',
  `password` VARCHAR(64) NOT NULL DEFAULT 'NULL' COMMENT '密码',
  `status` CHAR(1) DEFAULT '0' COMMENT '账号状态(0正常 1停用)',
  `email` VARCHAR(64) DEFAULT NULL COMMENT '邮箱',
  `phonenumber` VARCHAR(32) DEFAULT NULL COMMENT '手机号',
  `sex` CHAR(1) DEFAULT NULL COMMENT '用户性别(0男,1女,2未知)',
  `avatar` VARCHAR(128) DEFAULT NULL COMMENT '头像',
  `user_type` CHAR(1) NOT NULL DEFAULT '1' COMMENT '用户类型(0管理员,1普通用户)',
  `create_by` BIGINT(20) DEFAULT NULL COMMENT '创建人的用户id',
  `create_time` DATETIME DEFAULT NULL COMMENT '创建时间',
  `update_by` BIGINT(20) DEFAULT NULL COMMENT '更新人',
  `update_time` DATETIME DEFAULT NULL COMMENT '更新时间',
  `del_flag` INT(11) DEFAULT '0' COMMENT '删除标志(0代表未删除,1代表已删除)',
  PRIMARY KEY (`id`)
) ENGINE=INNODB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4 COMMENT='用户表'
  • 编写一个实体类User,实现UserDetail,顺便为Mybatisplus指定一下表名
@Data
@AllArgsConstructor
@NoArgsConstructor
@TableName("sys_user")
public class User implements Serializable, UserDetails {
    private static final long serialVersionUID = -40356785423868312L;

    /**
     * 主键
     */
    private Long id;
    /**
     * 用户名
     */
    private String userName;
    /**
     * 昵称
     */
    private String nickName;
    /**
     * 密码
     */
    private String password;
    /**
     * 账号状态(0正常 1停用)
     */
    private String status;
    /**
     * 邮箱
     */
    private String email;
    /**
     * 手机号
     */
    private String phonenumber;
    /**
     * 用户性别(0男,1女,2未知)
     */
    private String sex;
    /**
     * 头像
     */
    private String avatar;
    /**
     * 用户类型(0管理员,1普通用户)
     */
    private String userType;
    /**
     * 创建人的用户id
     */
    private Long createBy;
    /**
     * 创建时间
     */
    private LocalDateTime createTime;
    /**
     * 更新人
     */
    private Long updateBy;
    /**
     * 更新时间
     */
    private LocalDateTime updateTime;
    /**
     * 删除标志(0代表未删除,1代表已删除)
     */
    private Integer delFlag;

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

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

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

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

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

    @Override
    public boolean isEnabled() {
        return this.delFlag == 0;
    }
}
  • 编写一个类,实现UserDetailsService,返回刚刚定义的User
@Component
public class MyUserDetailServiceImpl implements UserDetailsService {
    @Autowired
    private UserMapper userMapper;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        if(!StringUtils.hasText(username)) {
            return null;
        }
        LambdaQueryWrapper<User> wrapper = new LambdaQueryWrapper<>();
        wrapper.eq(User::getUsername, username);
        return userMapper.selectOne(wrapper);
    }
}

使用全局配置AuthticationManager的方式配置自定义数据源

  • 其实完成如上的步骤,就已经实现了。只要Spring的容器当中有了一个UserDetailsService,那么SpringSecurity就会使用容器中已有的UserDetailsService
    @Autowired
    private UserDetailsService userDetailsService;

    @Autowired
    public void initialize(AuthenticationManagerBuilder builder) {
        try {
            builder.userDetailsService(userDetailsService);
        } catch (Exception e) {
            e.printStackTrace();
            throw new RuntimeException(e);
        }
    }
    @Override
    @Bean
    public AuthenticationManager authenticationManagerBean() throws Exception {
        return super.authenticationManagerBean();
    }

通过自定义AuthticationManager的方式配置自定义数据源

  • 注意: 2种方式的配置,AuthticationManager均不会暴露,因此无法使用依赖注入的方式获取到AuthticationManager,因此需要将其暴露在容器当中。
@Configuration
public class MySecurityConfig extends WebSecurityConfigurerAdapter {
    @Autowired
    private UserDetailsService userDetailsService;

    /**
     * 自定义AuthenticationManager
     * @param auth
     * @throws Exception
     */
    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.userDetailsService(userDetailsService);
    }


    /**
     * 用于将AuthenticationManager 在容器中暴露
     * @return
     * @throws Exception
     */
    @Override
    @Bean
    public AuthenticationManager authenticationManagerBean() throws Exception {
        return super.authenticationManagerBean();
    }

前后端分离时的认证(注意原有过滤器失效后,失败与成功的处理器定义位置)

  • 在传统web开发中,直接使用UsernamePasswordAuthenticationFilter固然是没有问题,因为使用的是表单提交的方式,可以通过request的getParameter来获取

  • 但是在前后端分离时,登录请求将会发送响应体,而UsernamePasswordAuthenticationFilter中进行认证时,仅仅是通过request的getParameter来获取参数,所以肯定获取不到。

  • 为此我们需要自定义一个过滤器,用来继承UsernamePasswordAuthenticationFilter重写其中的attemptAuthentication方法,将获取参数的方式改变。最后再通过配置,将原有的UsernamePasswordAuthenticationFilter替换掉即可。

  • 代码实现,MyUsernamePasswordAuthenticationFilter

public class MyUsernamePasswordAuthenticationFilter extends UsernamePasswordAuthenticationFilter {

    @Override
    @Autowired
    public void setAuthenticationManager(AuthenticationManager authenticationManager) {
        super.setAuthenticationManager(authenticationManager);
    }

    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
        // 只处理POST请求
        if (!request.getMethod().equals("POST")) {
            throw new AuthenticationServiceException("Authentication method not supported: " + request.getMethod());
        } else {
            try {
                // 从request请求体中获取数据
                User user = new ObjectMapper().readValue(request.getInputStream(), User.class);
                // 获取用户名
                String username = user.getUsername();
                username = username != null ? username : "";
                username = username.trim();
                // 获取密码
                String password = user.getPassword();
                password = password != null ? password : "";
                // 封装成 UsernamePasswordAuthenticationToken
                UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(username, password);
                this.setDetails(request, authRequest);
                // 调用AuthenticationManager来进行认证,注意了,需要把AuthenticationManager创建出来
                return this.getAuthenticationManager().authenticate(authRequest);
            } catch (IOException e) {
                throw new RuntimeException(e);
            }
        }
    }
}
  • 修改SpringSecurity的配置
    • 1、将Filter添加到容器,设置其认证成功与失败的处理器
    • 2、替换掉原有的UsernamePasswordAuthenticationFilter过滤器
    • 3、添加认证异常处理器
@Configuration
public class MySecurityConfig extends WebSecurityConfigurerAdapter {
    @Autowired
    private UserDetailsService userDetailsService;

    /**
     * 将自定义的Filter添加到容器当中,并为其添加认证成功与失败的响应处理器
     *
     * @return
     */
    @Bean
    public MyUsernamePasswordAuthenticationFilter myUsernamePasswordAuthenticationFilter() {
        MyUsernamePasswordAuthenticationFilter filter = new MyUsernamePasswordAuthenticationFilter();
        // 设置认证成功的处理器
        filter.setAuthenticationSuccessHandler((req, resp, auth) -> {
            Map<String, Object> map = new HashMap<>();
            map.put("code", 200);
            map.put("auth", auth.getPrincipal());
            resp.getWriter().write(new ObjectMapper().writeValueAsString(map));
        });
        // 设置认证失败的处理器
        filter.setAuthenticationFailureHandler((req, resp, ex) -> {
            Map<String, Object> map = new HashMap<>();
            map.put("code", HttpStatus.UNAUTHORIZED.value());
            map.put("msg", ex.getMessage());
            resp.getWriter().write(new ObjectMapper().writeValueAsString(map));
        });
        return filter;
    }

    /**
     * 自定义AuthenticationManager,修改默认的数据源
     *
     * @param auth
     * @throws Exception
     */
    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.userDetailsService(userDetailsService);
    }

    /**
     * 用于将自定义AuthenticationManager 在容器中暴露,这步很重要
     *
     * @return
     * @throws Exception
     */
    @Override
    @Bean
    public AuthenticationManager authenticationManagerBean() throws Exception {
        return super.authenticationManagerBean();
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                .authorizeRequests()
                .anyRequest().authenticated()
                .and()
                .logout()
                // 前后端分离,返回json数据
                .logoutSuccessHandler((req, resp, auth) -> {
                    resp.setContentType(MediaType.APPLICATION_JSON_UTF8_VALUE);
                    resp.getWriter().write("恭喜你注销成功");
                })
                .and()
                // 异常处理
                .exceptionHandling(exceptionConfig -> {
                    // 当出现认证异常时,返回友好提示
                    exceptionConfig.authenticationEntryPoint((req, resp, ex) -> {
                        Map<String, Object> map = new HashMap<>();
                        map.put("code", HttpStatus.FORBIDDEN.value());
                        map.put("msg", ex.getMessage());
                        resp.getWriter().write(new ObjectMapper().writeValueAsString(map));
                    });
                })
                // 替换原有的UsernamePasswordAuthenticationFilter过滤器
                .addFilterAt(myUsernamePasswordAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class)
                .csrf().disable();
    }
}

实现登录时需要验证码的功能

验证码功能的实现流程

  • 编写一个类继承UsernamePasswordAuthenticationFilter,重写attemptAuthentication()方法,在该方法中加入验证码的判断,并注入AuthenticationManager

  • 在Security的配置中替换原有的UsernamePasswordAuthenticationFilter,并将AuthenticationManager暴露在容器当中

传统web开发时的验证码功能

  • 自定义UsernamePasswordAuthenticationFilter,重写attemptAuthentication方法,在保证原有功能的基础上,新增验证码的校验流程即可。

  • 引入相关依赖

        <!--Kaptcha-->
        <dependency>
            <groupId>com.baomidou</groupId>
            <artifactId>kaptcha-spring-boot-starter</artifactId>
            <version>1.1.0</version>
        </dependency>
  • 编写一个过滤器,继承UsernamePasswordAuthenticationFilter
public class MyUsernamePasswordAuthenticationFilter extends UsernamePasswordAuthenticationFilter {

    @Autowired
    private Kaptcha kaptcha;

    @Override
    @Autowired
    public void setAuthenticationManager(AuthenticationManager authenticationManager) {
        super.setAuthenticationManager(authenticationManager);
    }

    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
        // 只处理POST请求
        if (!request.getMethod().equals("POST")) {
            throw new AuthenticationServiceException("Authentication method not supported: " + request.getMethod());
        } else {
            // 进行验证码的校验
            String code = request.getParameter("code");
            if(StringUtils.hasText(code)) {
                try {
                    kaptcha.validate(code);
                } catch (Exception e) {
                    if (e instanceof KaptchaIncorrectException) {
                        throw new BadCredentialsException("验证码不正确");
                    } else if (e instanceof KaptchaNotFoundException) {
                        throw new BadCredentialsException("验证码未找到");
                    } else if (e instanceof KaptchaTimeoutException) {
                        throw new BadCredentialsException("验证码过期");
                    } else {
                        throw new BadCredentialsException("验证码渲染失败");
                    }
                }
            }else {
                throw new BadCredentialsException("验证码不能为空");
            }
            String username = this.obtainUsername(request);
            username = username != null ? username : "";
            username = username.trim();
            String password = this.obtainPassword(request);
            password = password != null ? password : "";
            UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(username, password);
            this.setDetails(request, authRequest);
            return this.getAuthenticationManager().authenticate(authRequest);
        }
    }
}
  • 修改配置文件,替换默认的UsernamePasswordAuthenticationFilter过滤器
@Configuration
public class MySecurityConfig extends WebSecurityConfigurerAdapter {
    @Autowired
    private UserDetailsService userDetailsService;

    /**
     * 将自定义的Filter添加到容器当中,并为其添加认证成功与失败的响应处理器
     *
     * @return
     */
    @Bean
    public MyUsernamePasswordAuthenticationFilter myUsernamePasswordAuthenticationFilter() {
        MyUsernamePasswordAuthenticationFilter filter = new MyUsernamePasswordAuthenticationFilter();
        return filter;
    }

    /**
     * 自定义AuthenticationManager,修改默认的数据源
     *
     * @param auth
     * @throws Exception
     */
    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.userDetailsService(userDetailsService);
    }

    /**
     * 用于将自定义AuthenticationManager 在容器中暴露,这步很重要
     *
     * @return
     * @throws Exception
     */
    @Override
    @Bean
    public AuthenticationManager authenticationManagerBean() throws Exception {
        return super.authenticationManagerBean();
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                .authorizeRequests()
                // 放行验证码与登录界面
                .mvcMatchers("/login.html").permitAll()
                .mvcMatchers("/captcha.jpg").permitAll()
                .anyRequest().authenticated()
                .and()
                // 开启表单认证,设置自定义登录页以及登录的请求路径
                .formLogin()
                .loginPage("/login.html")
                .loginProcessingUrl("/login")
                .and()
                // 替换掉原有的过滤器
                .addFilterAt(myUsernamePasswordAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class)
                .csrf().disable();
    }
}

前后端分离时的验证码功能

  • 重写一个过滤器继承UsernamePasswordAuthenticationFilter过滤器,重写attemptAuthentication方法
public class MyUsernamePasswordAuthenticationFilter extends UsernamePasswordAuthenticationFilter {

    @Autowired
    private Kaptcha kaptcha;

    @Override
    @Autowired
    public void setAuthenticationManager(AuthenticationManager authenticationManager) {
        super.setAuthenticationManager(authenticationManager);
    }

    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
        // 只处理POST请求
        if (!request.getMethod().equals("POST")) {
            throw new AuthenticationServiceException("Authentication method not supported: " + request.getMethod());
        } else {
            // 从request请求体中获取数据
            User user = null;
            try {
                user = new ObjectMapper().readValue(request.getInputStream(), User.class);
            } catch (IOException e) {
                throw new RuntimeException("请求数据错误,请检查登录认证信息");
            }

            // 进行验证码的校验
            String code = user.getCode();
            if(StringUtils.hasText(code)) {
                try {
                    kaptcha.validate(code);
                } catch (Exception e) {
                    if (e instanceof KaptchaIncorrectException) {
                        throw new BadCredentialsException("验证码不正确");
                    } else if (e instanceof KaptchaNotFoundException) {
                        throw new BadCredentialsException("验证码未找到");
                    } else if (e instanceof KaptchaTimeoutException) {
                        throw new BadCredentialsException("验证码过期");
                    } else {
                        throw new BadCredentialsException("验证码渲染失败");
                    }
                }
            }else {
                throw new BadCredentialsException("验证码不能为空");
            }


            // 获取用户名
            String username = user.getUsername();
            username = username != null ? username : "";
            username = username.trim();
            // 获取密码
            String password = user.getPassword();
            password = password != null ? password : "";
            // 封装成 UsernamePasswordAuthenticationToken
            UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(username, password);
            this.setDetails(request, authRequest);
            return this.getAuthenticationManager().authenticate(authRequest);
        }
    }
}
  • 修改Security的配置类
@Configuration
public class MySecurityConfig extends WebSecurityConfigurerAdapter {
    @Autowired
    private UserDetailsService userDetailsService;

    /**
     * 将自定义的Filter添加到容器当中,并为其添加认证成功与失败的响应处理器
     *
     * @return
     */
    @Bean
    public MyUsernamePasswordAuthenticationFilter myUsernamePasswordAuthenticationFilter() {
        MyUsernamePasswordAuthenticationFilter filter = new MyUsernamePasswordAuthenticationFilter();
        // 设置认证成功的处理器
        filter.setAuthenticationSuccessHandler((req, resp, auth) -> {
            Map<String, Object> map = new HashMap<>();
            map.put("code", 200);
            map.put("auth", auth.getPrincipal());
            resp.getWriter().write(new ObjectMapper().writeValueAsString(map));
        });
        // 设置认证失败的处理器
        filter.setAuthenticationFailureHandler((req, resp, ex) -> {
            resp.setContentType(MediaType.APPLICATION_JSON_UTF8_VALUE);
            Map<String, Object> map = new HashMap<>();
            map.put("code", HttpStatus.UNAUTHORIZED.value());
            map.put("msg", ex.getLocalizedMessage());
            resp.getWriter().write(new ObjectMapper().writeValueAsString(map));
        });
        return filter;
    }

    /**
     * 自定义AuthenticationManager,修改默认的数据源
     *
     * @param auth
     * @throws Exception
     */
    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.userDetailsService(userDetailsService);
    }

    /**
     * 用于将自定义AuthenticationManager 在容器中暴露,这步很重要
     *
     * @return
     * @throws Exception
     */
    @Override
    @Bean
    public AuthenticationManager authenticationManagerBean() throws Exception {
        return super.authenticationManagerBean();
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                .authorizeRequests()
                .mvcMatchers("/captcha.jpg").permitAll()
                .anyRequest().authenticated()
                .and()
                .addFilterAt(myUsernamePasswordAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class)
                .csrf().disable();
    }
}
  • 测试
    image

image

实现记住我功能

记住我功能的实现流程

  • 1、当认证成功时,AbstractAuthenticationProcessingFilter会调用successfulAuthentication()方法

  • 2、在successfulAuthentication()方法中,会调用this.RememberMeServices的loginSuccess方法,其中默认使用的是TokenBasedRememberMeServices,而TokenBasedRememberMeServices当中并没有重写父类的loginSuccess方法,因此会调用父类的loginSuccess方法。

  • 3、父类调用该方法loginSuccess时,会使用request.getParameter("remember-me");来获取是否有该参数,并且如果有,则会判断是否为:true、on、yes、1,满足任意条件,就会调用

  • 3、在TokenBasedRememberMeServices的onLoginSuccess方法中,会调用onLoginSuccess方法,该方法由子类TokenBasedRememberMeServices重写,会存入一个key为:remember-me的cookie响应给浏览器

  • 4、当浏览器再次发送请求时,会由RememberMeAuthenticationFilter过滤器拦截,该过滤器会判断SecurityContextHolder中是否已经有了认证信息,如果已经有了则直接放行。

  • 5、如果没有,则会调用rememberMeServices的autoLogin来进行自动登录(其实就是获取所有cookie,找到key为remember-me的Cookie,进行比较),自动登录成功后,再将认证信息存储到SecurityContextHolder当中

传统web实现记住我功能

  • 1、编写一个登录界面,其中包含登录的表单,表单中添加一个input,为其设置value值为(yes、true、on、1)当中的任意一个
<form method="post" th:action="@{/login}">
    用户名:<input type="text" name="username"><br>
    密码:<input type="text" name="password"><br>
    记住我:<input type="checkbox" value="yes" name="remember-me"><br>
    <input type="submit"  value="登录"><br>
</form>
  • 2、在Security的配置中,开启记住我的功能
@Configuration
public class MySecurityConfig extends WebSecurityConfigurerAdapter {

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                .authorizeRequests()
                .mvcMatchers("/login.html").permitAll()
                .anyRequest().authenticated()
                .and()
                .formLogin()
                .loginPage("/login.html")
                .loginProcessingUrl("/login")
                .and()
                // 开启记住我功能
                .rememberMe()
                .and()
                .csrf().disable();
    }
}

前后端分离实现记住我功能

  • 要知道,前后端分离时,采用的不是表单提交,而是异步请求,发送的是请求体,因此我们需要改变RememberMeServices中的rememberMeRequested方法

  • rememberMeRequested方法会使用request.getParameter("remember-me");来获取请求中的记住我标志,因此我们需要自定义一个RememberMeServices来重写该方法

  • RememberMeServices默认有3个实现,NullRememberMeServices、PersistentTokenBasedRememberMeServices、TokenBasedRememberMeServices其中第一个可以不用关注,主要关注第二个与第三个。

  • PersistentTokenBasedRememberMeServices与TokenBasedRememberMeServices的区别是,前者每次请求都会生成一个新的cookie返回给用户,可以一定程度上保证安全性,但是后者一旦生成了cookie,则在使用过程中,不会进行变更

  • 因此现在编写一个类来继承PersistentTokenBasedRememberMeServices

public class MyRememberMeServices extends PersistentTokenBasedRememberMeServices {
    @Setter
    private boolean alwaysRemember = false;

    public MyRememberMeServices(String key, UserDetailsService userDetailsService, PersistentTokenRepository tokenRepository) {
        super(key, userDetailsService, tokenRepository);
    }

    @Override
    protected boolean rememberMeRequested(HttpServletRequest request, String parameter) {
        if(alwaysRemember) {
            return true;
        }
        // 2、从请求域中获取remember-me的数据
        String paramValue = request.getAttribute(parameter) != null ? (String)request.getAttribute(parameter) : "";
        if (paramValue == null || !paramValue.equalsIgnoreCase("true") && !paramValue.equalsIgnoreCase("on") && !paramValue.equalsIgnoreCase("yes") && !paramValue.equals("1")) {
            this.logger.debug(LogMessage.format("Did not send remey mber-me cookie (principal did not set parameter '%s')", parameter));
            return false;
        } else {
            return true;
        }
    }
}
  • 编写一个类替换掉UsernamePasswordAuthenticationFilter
public class MyUsernamePasswordAuthenticationFilter extends UsernamePasswordAuthenticationFilter {

    @Override
    @Autowired
    public void setAuthenticationManager(AuthenticationManager authenticationManager) {
        super.setAuthenticationManager(authenticationManager);
    }

    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
        // 只处理POST请求
        if (!request.getMethod().equals("POST")) {
            throw new AuthenticationServiceException("Authentication method not supported: " + request.getMethod());
        } else {
            // 从request请求体中获取数据
            User user = null;
            try {
                user = new ObjectMapper().readValue(request.getInputStream(), User.class);
            } catch (IOException e) {
                throw new RuntimeException("请求数据错误,请检查登录认证信息");
            }

            // 将rememberMe存储到request作用域当中
            request.setAttribute("rememberMe", user.getRememberMe());
            // 获取用户名
            String username = user.getUsername();
            username = username != null ? username : "";
            username = username.trim();
            // 获取密码
            String password = user.getPassword();
            password = password != null ? password : "";
            // 封装成 UsernamePasswordAuthenticationToken
            UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(username, password);
            this.setDetails(request, authRequest);
            return this.getAuthenticationManager().authenticate(authRequest);
        }
    }
}
  • 修改Security的配置(注意,使用的filter和config配置当中都需要配置一下使用的RememberMeServices)
@Configuration
public class MySecurityConfig extends WebSecurityConfigurerAdapter {
    @Autowired
    private UserDetailsService userDetailsService;

    @Bean
    public MyRememberMeServices myRememberMeServices() {
        MyRememberMeServices rememberMeServices = new MyRememberMeServices("rememberMe", userDetailsService, new InMemoryTokenRepositoryImpl());
        rememberMeServices.setParameter("rememberMe");
        return rememberMeServices;
    }

    /**
     * 将自定义的Filter添加到容器当中,并为其添加认证成功与失败的响应处理器
     *
     * @return
     */
    @Bean
    public MyUsernamePasswordAuthenticationFilter myUsernamePasswordAuthenticationFilter() {
        MyUsernamePasswordAuthenticationFilter filter = new MyUsernamePasswordAuthenticationFilter();
        // 设置认证成功的处理器
        filter.setAuthenticationSuccessHandler((req, resp, auth) -> {
            Map<String, Object> map = new HashMap<>();
            map.put("code", 200);
            map.put("auth", auth.getPrincipal());
            resp.getWriter().write(new ObjectMapper().writeValueAsString(map));
        });
        // 设置认证失败的处理器
        filter.setAuthenticationFailureHandler((req, resp, ex) -> {
            resp.setContentType(MediaType.APPLICATION_JSON_UTF8_VALUE);
            Map<String, Object> map = new HashMap<>();
            map.put("code", HttpStatus.UNAUTHORIZED.value());
            map.put("msg", ex.getLocalizedMessage());
            resp.getWriter().write(new ObjectMapper().writeValueAsString(map));
        });

        // 为filter指定rememberMeServices
        filter.setRememberMeServices(myRememberMeServices());
        return filter;
    }

    /**
     * 自定义AuthenticationManager,修改默认的数据源
     *
     * @param auth
     * @throws Exception
     */
    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.userDetailsService(userDetailsService);
    }

    /**
     * 用于将自定义AuthenticationManager 在容器中暴露,这步很重要
     *
     * @return
     * @throws Exception
     */
    @Override
    @Bean
    public AuthenticationManager authenticationManagerBean() throws Exception {
        return super.authenticationManagerBean();
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                .authorizeRequests()
                .anyRequest().authenticated()
                .and()
                // 开启记住我功能
                .rememberMe()
                // 设置自动登录时,使用的rememberMeServices
                .rememberMeServices(myRememberMeServices())
                .and()
                // 替换原有的UsernamePasswordAuthenticationFilter
                .addFilterAt(myUsernamePasswordAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class)
                .csrf().disable();
    }
}

RememberMe实现持久化功能

  • 我们创建一个自定义的RememberMeServices调用构造函数时,第三个参数是传递一个RememberMe的仓库,而这个仓库有2个实现,分别是基于内存的与基于数据库的,因此我们只需要在创建RememberMeServices时候,创建基于数据库的仓库即可。

  • 先创建一个数据表

create table persistent_logins (username varchar(64) not null, series varchar(64) primary key, token varchar(64) not null, last_used timestamp not null)
  • 创建一个PersistentTokenRepository仓库的Bean,需要为其指定数据源
    @Autowired
    private DataSource dataSource;

    @Bean
    public JdbcTokenRepositoryImpl jdbcTokenRepositoryImpl() {
        JdbcTokenRepositoryImpl jdbcTokenRepository = new JdbcTokenRepositoryImpl();
        jdbcTokenRepository.setDataSource(dataSource);
        return jdbcTokenRepository;
    }
  • 创建RememberMeServices时,指定刚刚创建的仓库即可
    @Bean
    public MyRememberMeServices myRememberMeServices() {
        MyRememberMeServices rememberMeServices =
                new MyRememberMeServices("rememberMe", userDetailsService,  jdbcTokenRepositoryImpl());
        rememberMeServices.setParameter("rememberMe");
        return rememberMeServices;
    }

自定义密码加密

  • SpringSecurity的配置WebSecurityConfigurerAdapter中,如果当前容器没有找到一个PasswordEncoder,则会创建一个DelegatingPasswordEncoder编码器

  • 因此如果想要自定义一个密码编码器,只需要在容器中添加一个PasswordEncoder的Bean即可,例如

@Configuration
public class MySecurityConfig extends WebSecurityConfigurerAdapter {
    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
}

Session会话管理

会话的并发管理

@Configuration
public class MySecurityConfig extends WebSecurityConfigurerAdapter{

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                .authorizeRequests().anyRequest().authenticated()
                .and()
                .formLogin()
                .and()
                .csrf().disable()
                .sessionManagement()
                // 设置最大的会话数量,一个用户只能登录一次,当出现重复登录时,另一个使用的用户将会被挤下线
                .maximumSessions(1);
    }
}

会话被挤下线时的处理方案

@Configuration
public class MySecurityConfig extends WebSecurityConfigurerAdapter{

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                .authorizeRequests().anyRequest().authenticated()
                .and()
                .formLogin()
                .and()
                .csrf().disable()
                .sessionManagement()
                // 设置最大的会话数量,一个用户只能登录一次,当出现重复登录时,另一个使用的用户将会被挤下线
                .maximumSessions(1)
                // 被挤下线时的处理方案
                .expiredSessionStrategy(event -> {
                    event.getResponse().setContentType(MediaType.APPLICATION_JSON_UTF8_VALUE);
                    Map<String, Object> map = new HashMap<>();
                    map.put("code", HttpStatus.INTERNAL_SERVER_ERROR.value());
                    map.put("msg", "您已被挤下线,您的账号可能存在风险");
                    event.getResponse().getWriter().write(new ObjectMapper().writeValueAsString(map));
                });
    }

禁止重复登录

@Configuration
public class MySecurityConfig extends WebSecurityConfigurerAdapter{

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                .authorizeRequests().anyRequest().authenticated()
                .and()
                .formLogin()
                .and()
                .csrf().disable()
                .sessionManagement()
                // 设置最大的会话数量,一个用户只能登录一次,当出现重复登录时,另一个使用的用户将会被挤下线
                .maximumSessions(1)
                // 被挤下线时的处理方案
                .expiredSessionStrategy(event -> {
                    event.getResponse().setContentType(MediaType.APPLICATION_JSON_UTF8_VALUE);
                    Map<String, Object> map = new HashMap<>();
                    map.put("code", HttpStatus.INTERNAL_SERVER_ERROR.value());
                    map.put("msg", "您已被挤下线,您的账号可能存在风险");
                    event.getResponse().getWriter().write(new ObjectMapper().writeValueAsString(map));
                })
                // 禁止重复登录
                .maxSessionsPreventsLogin(true);
    }
}

集群下会话共享解决方案

  • 引入依赖
        <dependency>
            <groupId>org.springframework.session</groupId>
            <artifactId>spring-session-data-redis</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
        </dependency>
  • 修改application.yml
spring:
  redis:
    host: 192.168.56.10
    port: 6379
  • 在SpringSecurity中配置SpringSessionBackedSEssionRegistry的会话管理注册器
@Configuration
public class MySecurityConfig extends WebSecurityConfigurerAdapter{

    @Autowired
    private RedisTemplate redisTemplate;

    @Autowired
    private FindByIndexNameSessionRepository findByIndexNameSessionRepository;

    @Bean
    public SpringSessionBackedSessionRegistry sessionBackedSessionRegistry() {
        return new SpringSessionBackedSessionRegistry(findByIndexNameSessionRepository);
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                .authorizeRequests().anyRequest().authenticated()
                .and()
                .formLogin()
                .and()
                .csrf().disable()
                .sessionManagement()
                // 设置最大的会话数量,一个用户只能登录一次,当出现重复登录时,另一个使用的用户将会被挤下线
                .maximumSessions(1)
                // 被挤下线时的处理方案
                .expiredSessionStrategy(event -> {
                    event.getResponse().setContentType(MediaType.APPLICATION_JSON_UTF8_VALUE);
                    Map<String, Object> map = new HashMap<>();
                    map.put("code", HttpStatus.INTERNAL_SERVER_ERROR.value());
                    map.put("msg", "您已被挤下线,您的账号可能存在风险");
                    event.getResponse().getWriter().write(new ObjectMapper().writeValueAsString(map));
                })
                // 集群环境下会话管理
                .sessionRegistry(sessionBackedSessionRegistry())
                // 禁止重复登录
                .maxSessionsPreventsLogin(true);
    }
}

CSRF跨站请求伪造(XSRF)

  • CSRF 全称 Cross-site request forgery

  • 跨站请求伪造攻击指的是利用用户对某个网站的信任,而发起的恶意请求

  • 例如: 用户登录了银行网站,然后又登录了一个恶意网站,在恶意网站中点击的某个链接是银行转账的链接,因此而直接向银行发送了转账的请求,而这个时候用户对银行网站是一个信任状态,因此会导致金额丢失。这种情况就称为跨站请求伪造攻击。

  • CSRF的解决方案:其实就是用户在发送请求时,需要携带一个token来进行标志,如果没有携带该token直接访问,则会拒绝该请求。(因此前后端分离项目,使用token进行权限控制时,则天生自带CSRF防护)

传统web的CSRF防护

  • 只要在SpringSecurity的配置中开启了CSRF防护功能,那么在登录界面的表单中,就会自动被加上如下input(即便是自定义的登录界面,只要在Security的配置中进行了响应的配置即可)
    image
@Configuration
public class MySecurityConfig extends WebSecurityConfigurerAdapter{

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                .authorizeRequests()
                .mvcMatchers("/login.html").permitAll()
                .anyRequest().authenticated()
                .and()
                .formLogin()
                .loginPage("/login.html")
                .loginProcessingUrl("/login")
                .and()
                // 开启CSRF防护
                .csrf();
    }
}

SpringMVC与SpringSecurity的跨域解决方案

SpringMVC的三种解决跨域的方案

  • (1)使用@CrossOrigin注解实现跨域(可以标志在控制器的类上,也可以标志在控制器的方法上),该注解有如下属性

    • allowCredentials:是否允许浏览器发送凭证信息,例如Cookie
    • allowedHeaders:请求被允许的请求头字段,*表示所有
    • exposedHeaders:哪些响应头可以作为响应的一部分暴露出来(默认只响应简单首部),(这里只能一个一个列举,不能用通配符*)
    • maxAge:预检options请求的有效期,有效期内不必再次发送预检请求,默认是1800秒
    • methods:允许的请求方式,*代表所有请求方式
    • origins: 允许请求的域,一般指的是ip地址,*代表允许所有域
  • (2)实现SpringMVC的配置类WebMvcConfigurer,重写addCorsMappings方法

@Configuration
public class SpringMvcConfig implements WebMvcConfigurer {

    @Override
    public void addCorsMappings(CorsRegistry registry) {
        // 统一配置所有的请求允许跨域
        registry.addMapping("/**")
                .allowedOrigins("*")
                .allowedMethods("*")
                .allowedHeaders("*")
                .allowCredentials(false)
                .maxAge(3600);
    }
}
  • (3)添加一个CrosFilter过滤器
@Configuration
public class SpringMvcConfig implements WebMvcConfigurer {

    @Bean
    public FilterRegistrationBean corsFilter() {
        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        CorsConfiguration config = new CorsConfiguration();
        config.setAllowCredentials(true);
        config.addAllowedOrigin("*");
        config.addAllowedHeader("*");
        config.addAllowedMethod("*");
        source.registerCorsConfiguration("/**", config);
        FilterRegistrationBean bean = new FilterRegistrationBean(new CorsFilter(source));
        bean.setOrder(0);
        return bean;
    }
}

SpringSecurity解决跨域

  • 为什么SpringMVC已经解决了跨域,我们还要考虑SpringSecurity解决跨域呢?
    因为SpringSecurity在进行权限控制的时候,依靠的都是过滤器,因此SpringMVC通过第一种以及第二种方式解决跨域就失效了。并且第三种:过滤器方式是否生效,需要看具体的执行顺序,如果跨域的过滤器在SpringSecurity过滤器之前,则可以生效,否则将会失效。

  • 通过添加一个Bean,并为SpringSecurity的配置跨域功能

@Configuration
public class MySecurityConfig extends WebSecurityConfigurerAdapter{

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                .authorizeRequests()
                .mvcMatchers("/login.html").permitAll()
                .anyRequest().authenticated()
                .and()
                .formLogin()
                .loginPage("/login.html")
                .loginProcessingUrl("/login")
                .and()
                // 开启跨域并指定跨域资源
                .cors().configurationSource(corsConfigurationSource())
                .and()
                .csrf();
    }

    @Bean
    public CorsConfigurationSource corsConfigurationSource() {
        // 创建跨域配置
        CorsConfiguration corsConfiguration = new CorsConfiguration();
        corsConfiguration.setAllowedOrigins(Arrays.asList("*"));
        corsConfiguration.setAllowedHeaders(Arrays.asList("*"));
        corsConfiguration.setAllowedMethods(Arrays.asList("*"));
        corsConfiguration.setAllowCredentials(false);
        corsConfiguration.setMaxAge(3600L);
        
        // 创建跨域的配置源
        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/**",corsConfiguration);
        return source;
    }
}

异常处理(认证异常与授权异常,不要与登录失败及登录成功的处理产生混淆)

@Configuration
public class MySecurityConfig extends WebSecurityConfigurerAdapter{

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                .authorizeRequests()
                .mvcMatchers("/login.html").permitAll()
                .anyRequest().authenticated()
                .and()
                .formLogin()
                .loginPage("/login.html")
                .loginProcessingUrl("/login")
                .and()
                .exceptionHandling(exceptionHandlingConfigurer -> {
                    // 处理认证异常
                    exceptionHandlingConfigurer.authenticationEntryPoint(authenticationEntryPoint());
                    // 处理授权异常
                    exceptionHandlingConfigurer.accessDeniedHandler(accessDeniedHandler());
                });
    }
    /**
     * 认证异常处理
     * @return
     */
    @Bean
    public AuthenticationEntryPoint authenticationEntryPoint() {
        return (req, resp, authEx) -> {
            System.out.println("认证出现了异常");
            System.out.println("出现的异常为:" + authEx.getLocalizedMessage());
            resp.setContentType(MediaType.APPLICATION_JSON_UTF8_VALUE);
            resp.getWriter().write("认证异常,异常信息为:" + authEx.getLocalizedMessage());
        };
    }

    /**
     * 授权异常处理
     * @return
     */
    @Bean
    public AccessDeniedHandler accessDeniedHandler() {
        return (req, resp, accessEx) -> {
            System.out.println("授权出现了异常");
            System.out.println("出现的异常为:" + accessEx.getLocalizedMessage());
            resp.setContentType(MediaType.APPLICATION_JSON_UTF8_VALUE);
            resp.getWriter().write("授权异常,异常信息为:" + accessEx.getLocalizedMessage());
        };
    }
}

(三)SpringSecurity授权

授权的核心概念RBAC以及权限管理策略

  • 授权:又称为访问控制,是指一个合法用户在进行登录后,能够拥有访问某些特定资源的权限

  • RBAC:可以称为基于角色的权限管理,又可以称为基于资源的权限管理。

    • 基于角色的权限设计就是: 用户 - 角色 - 资源, 返回到是角色的权限
    • 基于资源的权限设计就是: 用户 - 权限 - 资源 ,返回的是用户的权限
    • 基于角色与资源的设计是: 用户 - 角色 - 权限 - 资源,返回统称用户的权限。
  • 我们需要知道,在代码层面时,角色和权限是没有区别的,因为一般而言角色与权限都是一段字符串而已。只是在SpringSecurity当中,会为角色默认的添加ROLE_的前缀

授权的两种管理策略(若两者冲突,优先执行基于URL的权限校验,然后再执行基于AOP的权限校验)

  • 在SpringSecurity当中,授权有2种策略
    • 基于过滤器(URL)的权限管理(用来拦截HTTP请求,根据拦截下来的HTTP请求地址进行权限校验)
    • 基于AOP(方法)的权限管理(用来拦截方法,用于处理方法级别的权限问题)

基于过滤器URL的权限管理

@Configuration
public class MySecurityConfig extends WebSecurityConfigurerAdapter{

    @Override
    @Bean
    public UserDetailsService userDetailsService() {
        InMemoryUserDetailsManager inMemoryUserDetailsManager = new InMemoryUserDetailsManager();
        inMemoryUserDetailsManager.createUser(User.withUsername("normal").password("{noop}123").roles("normal").build());
        inMemoryUserDetailsManager.createUser(User.withUsername("vip1").password("{noop}123").roles("vip1").build());
        inMemoryUserDetailsManager.createUser(User.withUsername("vip2").password("{noop}123").authorities("vip2").build());
        return inMemoryUserDetailsManager;
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                .authorizeRequests()
                // 表示/normal请求可以被任意访问
                .mvcMatchers("/normal").permitAll()
                // 表示/vip1请求只能被拥有vip1的角色访问
                .mvcMatchers("/vip1").hasRole("vip1")
                // 表示 /vip2请求只能被拥有vip2的角色访问
                .mvcMatchers("/vip2").hasAnyAuthority("vip2")
                .anyRequest().authenticated()
                .and()
                .formLogin();
    }
}

基于AOP方法的权限管理(需要开启)

  • 在SpringSecurity配置类中,添加如下注解,开启基于AOP的权限管理
@EnableGlobalMethodSecurity(prePostEnabled = true, 
                            securedEnabled = true, jsr250Enabled = true)
  • 以上参数的含义

    • prePostEnabled: 开启SpringSecurity的四个权限注解,@PostAuthorize、@PostFilter、@PreAuthorize、@PreFilter
    • secureEnabled: 开启SpringSecurity提供的@Secured注解支持,该注解不支持权限表达式
    • jsr250Enabled:开启JSR-250提供的注解,主要是@DenyAll、@PermitAll、@RolesAllowed 同样不支持权限表达式
  • 如上注解的含义

    • @PostAuthorize:在目标方法执行之后进行权限校验
    • @PostFilter: 在目标方法执行之后,对结果进行过滤(要求是List或数组)
    • @PreAuthorize: 在目标方法执行之前进行权限校验
    • @PreFilter: 在目标方法执行之前进行请求参数过滤(要求是List或数组)
    • @Secured: 访问目标方法必须具备相对应的角色。
    • @DenyAll: 拒绝所有访问
    • @PermitAll: 允许所有访问
    • @RolesAllowed : 访问目标方法必须具备相对应的角色

SpringSeurity提供注解的使用示例

SpringSecurity提供的注解有4个,使用这些注解的时候,内部可以使用权限表达式,而这些权限表达式,都定义在:SecurityExpressionRoot类当中,基本上我们通过基于URL配置的方式,都可以在这里配置

@RestController
public class HelloController {

    @GetMapping("/normal")
    // 允许所有请求访问
    @PreAuthorize("permitAll()")
    public String hello() {
        return "say normal~";
    }

    @GetMapping("/vip1")
    @PreAuthorize("hasRole('vip1')")
    public String vip1() {
        return "say vip1~";
    }

    @GetMapping("/vip2")
    @PreAuthorize("hasAuthority('vip2')")
    public String vip2() {
        return "say vip2~";
    }

    @GetMapping("/preFilter")
    // 绑定请求参数,并对请求参数进行过滤
    @PreFilter(filterTarget = "ids", value = "filterObject % 2 == 0")
    public String preFilter(List<Long> ids) {
        ids.forEach(System.out::println);
        return "preFilter";
    }

    @GetMapping("/postFilter")
    // 绑定请求参数,并对返回结果进行过滤
    @PostFilter(value = "filterObject % 2 != 0")
    public List<Long> preFilter() {
        List<Long> list = new ArrayList<>(Arrays.asList(1L, 2L, 3L, 4L , 5L));
        return list;
    }
}

自定义@PreAuthorize的权限表达式

  • 编写一个类,用于进行权限校验
@Component("myExpress")
public class MySecurityExpression {
    /**
     * 判断是否包含某个权限
     * @param authority
     * @return
     */
    public boolean hasAuthority(String authority) {
        Collection authorities = SecurityContextHolder.getContext()
                .getAuthentication()
                .getAuthorities()
                .stream()
                .map(auth -> auth.getAuthority()).collect(Collectors.toList());
        return authorities.contains(authority);
    }
}
  • 在控制器中使用
    @GetMapping("/diy")
    @PreAuthorize("@myExpress.hasAuthority('vip1')")
    public String diy() {
        return "say diy~";
    }

授权实战

库表设计

  • 用户表:存储用户信息

  • 角色表:存储角色信息

  • 菜单表:相当于权限表,存储权限信息

  • 表与表之间的关联关系:

    • 用户与角色是多对多关系,因此需要一张中间表
    • 角色与菜单是多对多关系,因此需要一张中间表
SET NAMES utf8mb4;
SET FOREIGN_KEY_CHECKS = 0;
-- ----------------------------
-- Table structure for menu
-- ----------------------------
DROP TABLE IF EXISTS `menu`;
CREATE TABLE `menu` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `pattern` varchar(128) DEFAULT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=4 DEFAULT CHARSET=utf8;
​
-- ----------------------------
-- Records of menu
-- ----------------------------
BEGIN;
INSERT INTO `menu` VALUES (1, '/admin/**');
INSERT INTO `menu` VALUES (2, '/user/**');
INSERT INTO `menu` VALUES (3, '/guest/**');
COMMIT;
​
-- ----------------------------
-- Table structure for menu_role
-- ----------------------------
DROP TABLE IF EXISTS `menu_role`;
CREATE TABLE `menu_role` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `mid` int(11) DEFAULT NULL,
  `rid` int(11) DEFAULT NULL,
  PRIMARY KEY (`id`),
  KEY `mid` (`mid`),
  KEY `rid` (`rid`)
) ENGINE=InnoDB AUTO_INCREMENT=5 DEFAULT CHARSET=utf8;
​
-- ----------------------------
-- Records of menu_role
-- ----------------------------
BEGIN;
INSERT INTO `menu_role` VALUES (1, 1, 1);
INSERT INTO `menu_role` VALUES (2, 2, 2);
INSERT INTO `menu_role` VALUES (3, 3, 3);
INSERT INTO `menu_role` VALUES (4, 3, 2);
COMMIT;
​
-- ----------------------------
-- Table structure for role
-- ----------------------------
DROP TABLE IF EXISTS `role`;
CREATE TABLE `role` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `name` varchar(32) DEFAULT NULL,
  `nameZh` varchar(32) DEFAULT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=4 DEFAULT CHARSET=utf8;
​
-- ----------------------------
-- Records of role
-- ----------------------------
BEGIN;
INSERT INTO `role` VALUES (1, 'ROLE_ADMIN', '系统管理员');
INSERT INTO `role` VALUES (2, 'ROLE_USER', '普通用户');
INSERT INTO `role` VALUES (3, 'ROLE_GUEST', '游客');
COMMIT;
​
-- ----------------------------
-- Table structure for user
-- ----------------------------
DROP TABLE IF EXISTS `user`;
CREATE TABLE `user` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `username` varchar(32) DEFAULT NULL,
  `password` varchar(255) DEFAULT NULL,
  `enabled` tinyint(1) DEFAULT NULL,
  `locked` tinyint(1) DEFAULT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=4 DEFAULT CHARSET=utf8;
​
-- ----------------------------
-- Records of user
-- ----------------------------
BEGIN;
INSERT INTO `user` VALUES (1, 'admin', '{noop}123', 1, 0);
INSERT INTO `user` VALUES (2, 'user', '{noop}123', 1, 0);
INSERT INTO `user` VALUES (3, 'blr', '{noop}123', 1, 0);
COMMIT;
​
-- ----------------------------
-- Table structure for user_role
-- ----------------------------
DROP TABLE IF EXISTS `user_role`;
CREATE TABLE `user_role` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `uid` int(11) DEFAULT NULL,
  `rid` int(11) DEFAULT NULL,
  PRIMARY KEY (`id`),
  KEY `uid` (`uid`),
  KEY `rid` (`rid`)
) ENGINE=InnoDB AUTO_INCREMENT=5 DEFAULT CHARSET=utf8;
​
-- ----------------------------
-- Records of user_role
-- ----------------------------
BEGIN;
INSERT INTO `user_role` VALUES (1, 1, 1);
INSERT INTO `user_role` VALUES (2, 1, 2);
INSERT INTO `user_role` VALUES (3, 2, 2);
INSERT INTO `user_role` VALUES (4, 3, 3);
COMMIT;
SET FOREIGN_KEY_CHECKS = 1;

初始化项目

创建工程后引入如下依赖

    <parent>
        <artifactId>spring-boot-parent</artifactId>
        <groupId>org.springframework.boot</groupId>
        <version>2.3.9.RELEASE</version>
    </parent>

    <dependencies>
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
        </dependency>
        <dependency>
            <groupId>org.mybatis.spring.boot</groupId>
            <artifactId>mybatis-spring-boot-starter</artifactId>
            <version>2.1.4</version>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-security</artifactId>
        </dependency>
        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
        </dependency>
    </dependencies>

配置application.yml文件,数据源以及mybatis等配置

server:
  port: 80
spring:
  datasource:
    driver-class-name: com.mysql.cj.jdbc.Driver
    url: jdbc:mysql://localhost:3306/security_study
    username: root
    password: abc123
mybatis:
  mapper-locations: classpath:mapper/*.xml
  type-aliases-package: com.codestars.entity
logging:
  level:
    com.codestars.mapper: debug

创建相对应的实体类

@Data
@AllArgsConstructor
@NoArgsConstructor
public class Menu {
    private Integer id;
    private String pattern;
    private List<Role> roles;
}


@Data
@AllArgsConstructor
@NoArgsConstructor
public class Role {
    private Integer id;
    private String name;
    private String nameZh;
}

@Data
@AllArgsConstructor
@NoArgsConstructor
public class User implements UserDetails {
    private Integer id;
    private String password;
    private String username;
    private boolean enabled;
    private boolean locked;
    private List<Role> roles;

    /**
     * 该方法相当于是获取角色
     * @return
     */
    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return roles.stream().map(r -> new SimpleGrantedAuthority(r.getName())).collect(Collectors.toList());
    }

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

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

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

编写UserMapper 来完成对用户的查询

  • UserMapper
public interface UserMapper {

    /**
     * 根据用户名查询用户及其角色信息
     */
    User selectUserByName(@Param("username") String username);
}
  • UserMapper.xml
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
        PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
        "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.codestars.mapper.UserMapper">

    <resultMap id="userMap" type="com.codestars.entity.User">
        <id property="id" column="id"></id>
        <result property="username" column="username"></result>
        <result property="password" column="password"></result>
        <result property="enabled" column="enabled"></result>
        <result property="locked" column="locked"></result>
        <collection property="roles" ofType="com.codestars.entity.Role">
            <id property="id" column="rid"></id>
            <result property="name" column="name"></result>
            <result property="nameZh" column="nameZh"></result>
        </collection>
    </resultMap>

    <select id="selectUserByName" resultMap="userMap">
        select u.*, r.id rid, r.name, r.nameZh
        from user u left join user_role ur on u.id = ur.uid
               left join role r on ur.rid = r.id
        where username = #{username}
    </select>
</mapper>

编写Menu完成对菜单的查询

  • MenuMapper
public interface MenuMapper {
    /**
     * 查询出所有菜单,以及菜单所对应的角色信息
     */
    List<Menu> selectAll();
}
  • MenuMapper.xml
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
        PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
        "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.codestars.mapper.MenuMapper">

    <resultMap id="menuMap" type="com.codestars.entity.Menu">
        <id property="id" column="id"></id>
        <result property="pattern" column="pattern"></result>
        <collection property="roles" ofType="com.codestars.entity.Role">
            <id property="id" column="rid"></id>
            <result property="name" column="name"></result>
            <result property="nameZh" column="nameZh"></result>
        </collection>
    </resultMap>
    <select id="selectAll" resultMap="menuMap">
        SELECT m.id, m.pattern, r.id rid,r.name, r.nameZh
        FROM menu m left join menu_role mr on m.id = mr.mid
        left join role r on r.id = mr.rid
    </select>
</mapper>

编写自定义UserDetail数据源

@Service
public class MyUserDetailService implements UserDetailsService {
    @Autowired
    private UserMapper userMapper;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        return userMapper.selectUserByName(username);
    }
}

编写一个类实现FilterInvocationSecurityMetadataSource,重写获取资源所需要对应的角色数据的代码

@Component
public class CustomSecurityMetadataSource implements FilterInvocationSecurityMetadataSource {
    @Autowired
    private MenuService menuService;

    AntPathMatcher antPathMatcher = new AntPathMatcher();

    @Override
    public Collection<ConfigAttribute> getAttributes(Object object) throws IllegalArgumentException {
        // 获取到当前请求资源的URI
        String requestURI = ((FilterInvocation) object).getRequest().getRequestURI();
        // 查询出所有的菜单
        List<Menu> allMenu = menuService.getAllMenu();
        // 遍历菜单
        for (Menu menu : allMenu) {
            // 遍历菜单时,使用Menu的pattern来匹配URI
            if (antPathMatcher.match(menu.getPattern(), requestURI)) {
                // 如果匹配到了,则将可以访问该URI的角色信息存储到ConfigAttribute中返回,注意这里的角色带了ROLE_前缀
                String[] roles = menu.getRoles().stream().map(r -> r.getName()).toArray(String[]::new);
                return SecurityConfig.createList(roles);
            }
        }
        return null;
    }

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

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

修改Security的配置类(配置自定义的MetedataSource)

@Configuration
public class MySecurityConfig extends WebSecurityConfigurerAdapter {
    @Autowired
    private CustomSecurityMetadataSource customSecurityMetadataSource;

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        ApplicationContext applicationContext = http.getSharedObject(ApplicationContext.class);
        http.apply(new UrlAuthorizationConfigurer<>(applicationContext))
                .withObjectPostProcessor(new ObjectPostProcessor<FilterSecurityInterceptor>() {
                    @Override
                    public <O extends FilterSecurityInterceptor> O postProcess(O object) {
                        object.setSecurityMetadataSource(customSecurityMetadataSource);
                        // 该值如果设置为false,那么非公共资源都直接放行,
                        // 也就是无法通过MetadataSource的getAttributes获取到ConfigAttribute集合的URI
                        object.setRejectPublicInvocations(false);
                        return object;
                    }
                });
        http.formLogin();
    }
}

编写一个控制器用来测试

@RestController
public class HelloController {
    @GetMapping("/admin/hello")
    public String admin() {
        return "hello admin";
    }

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

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

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

使用SpringSecurity来当OAuth2的客户端访问GitHub

OAuth2是什么?

OAuth2是一种开放标准,该标准使用户可以让第三方应用访问该用户在某一网站上存储的私密资源

  • 例如:我登录了Github网站,但是现在我打开了其他的某个第三方网站,此时我并不想在第三方网站上输入用户名密码,而如果使用OAuth2标准,则可以使得我在第三方应用上,访问到GitHub的私密资源。

有哪几种授权模式?

  • 授权码模式:最常用,基本使用此种模式

  • 简化模式:简化模式是不需要第三方服务端参与,直接在浏览器中向授权服务器申请令牌(token),如果网站是纯静态页面,则可以采用这种方式。

  • 密码模式:密码模式是用户把用户名/密码直接告诉客户端,客户端使用这些信息后授权服务器申请令牌(token)。这需要用户对客户端高度信任,例如客户端应用和服务提供商就是同一家公司。

  • 客户端模式:客户端模式是指客户端使用自己的名义而不是用户的名义向服务提估者申请授权。严格来说,客户端模式并不能算作OAuth 协议解决问题的一种解决方案,但是对于开发者而言,在一些为移动端提供的授权服务器上使用这种模式还是非常方便的。

整个OAuth2的授权流程是怎样的?

  • 首先OAuth中有4个概念需要先了解下

    • Client:客户端,一般指的是需要进行授权的第三方应用
      - Resource Owner: 资源所有者,可以想象成github,我们需要获取的私密资源在哪个网站,那个网站就是资源所有者
    • Authorization Server: 授权服务器,也是由服务端来进行提供,例如github的授权服务器
    • Resource Server: 资源服务器,由服务端提供,例如github的资源服务器
  • OAuth的授权流程基本如下

    • 客户端向资源所有者发送授权请求
    • 用户点击确认授权按钮
    • 授权服务器将授权码token返回给客户端
    • 客户端使用接收到的token请求资源服务器
    • 资源服务器将数据进行返回

使用SpringSecurity实现客户端功能,获取GitHub的用户授权数据

打开GitHub的开发者选项,创建一个OAuth2应用

image

创建一个Maven工程,引入依赖

    <parent>
        <artifactId>spring-boot-parent</artifactId>
        <groupId>org.springframework.boot</groupId>
        <version>2.3.9.RELEASE</version>
    </parent>

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

在resources目录下创建application.yml文件,配置github请求

spring:
  security:
    oauth2:
      client:
        registration:
          github:
            client-id: 2233257792cc0528fad6
            client-secret: b2f8c940b43361a4a85f517903d03e5ff2f2c798
            # 注意这里配置的redirect-uri需要与github配置的回调地址一致
            redirect-uri: http://127.0.0.1:8080/login/oauth2/code/github

编写一个SpringSecurity的配置类,用于开启oauth2Login登录

@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.
                 authorizeRequests().anyRequest().authenticated()
                .and()
                .oauth2Login();
    }
}

编写一个Controller来获取数据测试

@RestController
public class HelloController {

    @GetMapping("/hello")
    public DefaultOAuth2User hello() {
        // 强转为DefaultOAuth2User是因为OAuth2LoginAuthenticationFilter认证后,给的就是一个DefaultOAuth2User
        return (DefaultOAuth2User) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
    }
}

目录

(四)SpringSecurity的前后端分离使用最佳实践(包含授权与认证)

知悉SpringSecurity的认证流程

  • 1、客户端发送登录请求,传输用户名与密码等信息

  • 2、UsernamePasswordAuthticationFilter默认会匹配请求地址为/login的认证请求,拦截到之后会调用其attemptAuthentication试图认证方法(当登录认证时还需要做一些其他操作时(验证码、参数获取方式等),可以考虑自定义认证过滤器继承UsernamePasswordAuthticationFilter,或者直接摒弃原有的该过滤器,自定义认证接口)

  • 2.1 在该方法中,会将用户名和密码封装一个UsernamePasswordAuthenticationToken对象,然后调用AuthenticationManager的authenticate方法来完成认证操作

  • 3、AuthenticationManager是一个接口,因此会使用该接口的实现来完成认证操作,而这个实现默认是ProviderManager,因此实际上是调用了ProviderManager的authenticate方法,该方法的接受参数与返回参数都是一个Authentication

    • 3.1 在ProviderManager的authenticate方法中,会循环遍历本类的一个List<AuthenticationProvider>成员变量来判断是否支持当前的认证,如果支持则会使用对应的AuthenticationProvider来进行认证操作。

    • 3.2 如果第一次遍历均不支持,ProviderManager当中还有一个成员变量private AuthenticationManager parent;也就是它的父亲,本质还是一个AuthenticationManager来进行认证操作,此时一般有个默认实现:DaoAuthenticationProvider,最终由它来进行认证操作

  • 4、DaoAuthenticationProvider 在进行认证时,需要获取到已有的用户身份信息,以此来进行认证,获取身份信息就会涉及到数据源UserDetailService的获取,默认将会采用InMemoryUserDetailsManager基于内存的数据源(因此这里也可以考虑替换掉数据源)

  • 5、在进行认证时,会校验密码,默认会采用DelegatingPasswordEncoder 来进行密码的解析与校验,DelegatingPasswordEncoder采用的是{标识符}密码的格式来进行密码的校验,可以支持同时多种密码校验规则。该默认规则是在WebSecurityConfigurerAdapter中指定的,并且设置默认之前会在容器中获取,如果容器中已经有了,则使用已有的。

知悉SpringSecurity的授权流程

  • 1、在项目启动时,就会通过DefaultFilterInvocationSecurityMetadataSource来获取到需要指定权限才能访问的资源,通过其中的getAttributes方法获取到一个Collection<ConfigAttribute>(因此这里可以考虑继承DefaultFilterInvocationSecurityMetadataSource来重写getAttributes方法来实现自定义的资源所需权限的规则

  • 2、FilterSecurityInterceptor会拦截到用户发出的请求,在该过滤器中进行授权流程,在该过滤器的doFilter方法中,调用了一个本类的invoke方法,该方法会执行super.beforeInvocation(fi);方法

  • 3、而该方法中会通过this.obtainSecurityMetadataSource().getAttributes(object);来获取到已配置的URI资源所需要的权限信息。

  • 4、获取到资源所需要的权限信息后,通过AccessDecisionManager的decide方法来进行授权

  • 5、在decide方法中,会通过遍历所有的AccessDecisionVoter投票器,通过投票器来判断用户是否能够访问该资源

本次实践的大致流程

  • 认证

    • 自定义UserDetailService数据源
    • 自定义登录接口(该接口中使用AuthenticationManager进行认证,认证成功后,生成一个JWT存储到redis(redis会存储整个用户对象UserDetails),再将该JWT响应到浏览器的请求头当中)
    • 自定义JWT校验过滤器,该过滤器继承OncePerRequestFilter过滤器中获取浏览器的请求头中的JWT
      • 如果获取不到则直接放行
      • 获取到了之后则使用JWT工具来将其解析,解析后拿到对应的用户数据
      • 再使用之前存储到redis的key规则,来从redis 中获取对应的用户数据,获取到后,则想办法将其封装成Authentication存储到SecurityContextHolder
    • 在security的配置中放行登录接口
    • 在security的配置中,将session关闭,不再生成session
    • 将自定义的JWT校验过滤器添加到UsernamePasswordAuthticationFilter之前
  • 授权

    • 自定义UserDetailService数据源时,就把用户所具备的权限标识存储到自定义的UserDetail中(注意这里直接给的是权限标识)
    • 需要注意角色和权限,在代码的本质是一样的,只是基于AOP的权限校验时,角色字符串之前会拼接一个ROLE_来判断

编写SpringSecurity相关代码前的准备工作

引入redis、fastjson、jwt、lombok、mybatisplus的依赖

    <parent>
        <artifactId>spring-boot-parent</artifactId>
        <groupId>org.springframework.boot</groupId>
        <version>2.3.9.RELEASE</version>
    </parent>

    <dependencies>
        <!--redis依赖-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
        </dependency>
        <!--fastjson依赖-->
        <dependency>
            <groupId>com.alibaba</groupId>
            <artifactId>fastjson</artifactId>
            <version>1.2.70</version>
        </dependency>
        <!--jwt依赖-->
        <dependency>
            <groupId>io.jsonwebtoken</groupId>
            <artifactId>jjwt</artifactId>
            <version>0.9.0</version>
        </dependency>
        <!--lombok-->
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
        </dependency>
        <!--mybatisplus-->
        <dependency>
            <groupId>com.baomidou</groupId>
            <artifactId>mybatis-plus-boot-starter</artifactId>
            <version>3.5.2</version>
        </dependency>
        <!--mysql连接驱动-->
        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
        </dependency>
        <!--spring security-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-security</artifactId>
        </dependency>
        <!--web-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <!--test-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
        </dependency>
    </dependencies>

修改application.yml配置文件

spring:
  datasource:
    driver-class-name: com.mysql.cj.jdbc.Driver
    url: jdbc:mysql://localhost:3306/codestars_security?useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai
    username: root
    password: abc123
  redis:
    host: 192.168.56.10
    port: 6379
mybatis-plus:
  type-aliases-package: com.codestars.security.entity

添加Redis相关配置(配置文件、序列化配置、统一响应类、Web响应String工具类)

  • 配置一个Redis的序列化类
public class FastJsonRedisSerializer<T> implements RedisSerializer<T>
{

    public static final Charset DEFAULT_CHARSET = Charset.forName("UTF-8");

    private Class<T> clazz;

    static
    {
        ParserConfig.getGlobalInstance().setAutoTypeSupport(true);
        ParserConfig.getGlobalInstance().addAccept("org.springframework.security.core.authority.");
    }

    public FastJsonRedisSerializer(Class<T> clazz)
    {
        super();
        this.clazz = clazz;
    }

    @Override
    public byte[] serialize(T t) throws SerializationException
    {
        if (t == null)
        {
            return new byte[0];
        }
        return JSON.toJSONString(t, SerializerFeature.WriteClassName).getBytes(DEFAULT_CHARSET);
    }

    @Override
    public T deserialize(byte[] bytes) throws SerializationException
    {
        if (bytes == null || bytes.length <= 0)
        {
            return null;
        }
        String str = new String(bytes, DEFAULT_CHARSET);

        return JSON.parseObject(str, clazz);
    }


    protected JavaType getJavaType(Class<?> clazz)
    {
        return TypeFactory.defaultInstance().constructType(clazz);
    }
}
  • 添加redis的配置类
@Configuration
public class RedisConfig {

    @Bean
    @SuppressWarnings(value = { "unchecked", "rawtypes" })
    public RedisTemplate<Object, Object> redisTemplate(RedisConnectionFactory connectionFactory)
    {
        RedisTemplate<Object, Object> template = new RedisTemplate<>();
        template.setConnectionFactory(connectionFactory);

        FastJsonRedisSerializer serializer = new FastJsonRedisSerializer(Object.class);

        // 使用StringRedisSerializer来序列化和反序列化redis的key值
        template.setKeySerializer(new StringRedisSerializer());
        template.setValueSerializer(serializer);

        // Hash的key也采用StringRedisSerializer的序列化方式
        template.setHashKeySerializer(new StringRedisSerializer());
        template.setHashValueSerializer(serializer);

        template.afterPropertiesSet();
        return template;
    }
}
  • 统一响应类
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

@JsonInclude(JsonInclude.Include.NON_NULL)
@Data
@AllArgsConstructor
@NoArgsConstructor
public class ResponseResult<T> {
    private Integer code;
    private String msg;
    private T data;
}
  • WebUtil
public class WebUtils
{
    /**
     * 将字符串渲染到客户端
     * 
     * @param response 渲染对象
     * @param string 待渲染的字符串
     * @return null
     */
    public static String renderString(HttpServletResponse response, String string) {
        try
        {
            response.setStatus(200);
            response.setContentType("application/json");
            response.setCharacterEncoding("utf-8");
            response.getWriter().print(string);
        }
        catch (IOException e)
        {
            e.printStackTrace();
        }
        return null;
    }
}

JWT工具类、Redis工具类

  • JWT工具类
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.JwtBuilder;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;

import javax.crypto.SecretKey;
import javax.crypto.spec.SecretKeySpec;
import java.util.Base64;
import java.util.Date;
import java.util.UUID;

/**
 * JWT工具类
 */
public class JwtUtil {

    //有效期为
    public static final Long JWT_TTL = 60 * 60 * 1000L;// 60 * 60 *1000  一个小时
    //设置秘钥明文
    public static final String JWT_KEY = "zhangsan";// 注意这里需要为7位

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

    /**
     * 生成jtw
     *
     * @param subject token中要存放的数据(json格式)
     * @return
     */
    public static String createJWT(String subject) {
        JwtBuilder builder = getJwtBuilder(subject, null, getUUID());// 设置过期时间
        return builder.compact();
    }

    /**
     * 生成jtw
     *
     * @param subject   token中要存放的数据(json格式)
     * @param ttlMillis token超时时间
     * @return
     */
    public static String createJWT(String subject, Long ttlMillis) {
        JwtBuilder builder = getJwtBuilder(subject, ttlMillis, getUUID());// 设置过期时间
        return builder.compact();
    }

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

    /**
     * 创建token
     *
     * @param id
     * @param subject
     * @param ttlMillis
     * @return
     */
    public static String createJWT(String id, String subject, Long ttlMillis) {
        JwtBuilder builder = getJwtBuilder(subject, ttlMillis, id);// 设置过期时间
        return builder.compact();
    }

    /**
     * 测试JWT工具类是否可以正常使用
     * @param args
     * @throws Exception
     */
    public static void main(String[] args) throws Exception {
        // 可以通过Claims来获取到之前的subject数据主体对象
        String token = createJWT("hello");
        Claims claims = parseJWT(token);
        System.out.println(claims);
    }

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

    /**
     * 解析
     *
     * @param jwt
     * @return
     * @throws Exception
     */
    public static Claims parseJWT(String jwt) throws Exception {
        SecretKey secretKey = generalKey();
        return Jwts.parser()
                .setSigningKey(secretKey)
                .parseClaimsJws(jwt)
                .getBody();
    }
}
  • Redis工具类
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.BoundSetOperations;
import org.springframework.data.redis.core.HashOperations;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.ValueOperations;
import org.springframework.stereotype.Component;

import java.util.*;
import java.util.concurrent.TimeUnit;

@SuppressWarnings(value = { "unchecked", "rawtypes" })
@Component
public class RedisCache
{
    @Autowired
    public RedisTemplate redisTemplate;

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

库表的设计(仿照若依的库表设计)

用户表 - 用户角色表 - 角色表 - 角色菜单表 - 菜单表

  • 用户表: 存储用户信息

  • 用户角色表: 由于用户和角色是多对多关系,因此需要一张中间表来进行关联

  • 角色表:存储角色信息

  • 角色菜单表: 存储哪个角色可以访问哪个菜单信息,菜单和角色同样是多对多关系

  • 菜单表: 存储菜单信息,其中有着该菜单所需要具备的具体的权限字符串信息

数据表的创建

/*Table structure for table `sys_user` */

DROP TABLE IF EXISTS `sys_user`;

CREATE TABLE `sys_user` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键',
  `user_name` varchar(64) NOT NULL DEFAULT 'NULL' COMMENT '用户名',
  `nick_name` varchar(64) NOT NULL DEFAULT 'NULL' COMMENT '昵称',
  `password` varchar(64) NOT NULL DEFAULT 'NULL' COMMENT '密码',
  `status` char(1) DEFAULT '0' COMMENT '账号状态(0正常 1停用)',
  `email` varchar(64) DEFAULT NULL COMMENT '邮箱',
  `phonenumber` varchar(32) DEFAULT NULL COMMENT '手机号',
  `sex` char(1) DEFAULT NULL COMMENT '用户性别(0男,1女,2未知)',
  `avatar` varchar(128) DEFAULT NULL COMMENT '头像',
  `user_type` char(1) NOT NULL DEFAULT '1' COMMENT '用户类型(0管理员,1普通用户)',
  `create_by` bigint(20) DEFAULT NULL COMMENT '创建人的用户id',
  `create_time` datetime DEFAULT NULL COMMENT '创建时间',
  `update_by` bigint(20) DEFAULT NULL COMMENT '更新人',
  `update_time` datetime DEFAULT NULL COMMENT '更新时间',
  `del_flag` int(11) DEFAULT '0' COMMENT '删除标志(0代表未删除,1代表已删除)',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=3 DEFAULT CHARSET=utf8mb4 COMMENT='用户表';


/*Table structure for table `sys_role` */

DROP TABLE IF EXISTS `sys_role`;

CREATE TABLE `sys_role` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT,
  `name` varchar(128) DEFAULT NULL,
  `role_key` varchar(100) DEFAULT NULL COMMENT '角色权限字符串',
  `status` char(1) DEFAULT '0' COMMENT '角色状态(0正常 1停用)',
  `del_flag` int(1) DEFAULT '0' COMMENT 'del_flag',
  `create_by` bigint(200) DEFAULT NULL,
  `create_time` datetime DEFAULT NULL,
  `update_by` bigint(200) DEFAULT NULL,
  `update_time` datetime DEFAULT NULL,
  `remark` varchar(500) DEFAULT NULL COMMENT '备注',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=3 DEFAULT CHARSET=utf8mb4 COMMENT='角色表';



/*Table structure for table `sys_menu` */

DROP TABLE IF EXISTS `sys_menu`;

CREATE TABLE `sys_menu` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT,
  `menu_name` varchar(64) NOT NULL DEFAULT 'NULL' COMMENT '菜单名',
  `path` varchar(200) DEFAULT NULL COMMENT '路由地址',
  `component` varchar(255) DEFAULT NULL COMMENT '组件路径',
  `visible` char(1) DEFAULT '0' COMMENT '菜单状态(0显示 1隐藏)',
  `status` char(1) DEFAULT '0' COMMENT '菜单状态(0正常 1停用)',
  `perms` varchar(100) DEFAULT NULL COMMENT '权限标识',
  `icon` varchar(100) DEFAULT '#' COMMENT '菜单图标',
  `create_by` bigint(20) DEFAULT NULL,
  `create_time` datetime DEFAULT NULL,
  `update_by` bigint(20) DEFAULT NULL,
  `update_time` datetime DEFAULT NULL,
  `del_flag` int(11) DEFAULT '0' COMMENT '是否删除(0未删除 1已删除)',
  `remark` varchar(500) DEFAULT NULL COMMENT '备注',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4 COMMENT='菜单表';

/*Table structure for table `sys_role_menu` */

DROP TABLE IF EXISTS `sys_role_menu`;

CREATE TABLE `sys_role_menu` (
  `role_id` bigint(200) NOT NULL AUTO_INCREMENT COMMENT '角色ID',
  `menu_id` bigint(200) NOT NULL DEFAULT '0' COMMENT '菜单id',
  PRIMARY KEY (`role_id`,`menu_id`)
) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4;


/*Table structure for table `sys_user_role` */

DROP TABLE IF EXISTS `sys_user_role`;

CREATE TABLE `sys_user_role` (
  `user_id` bigint(200) NOT NULL AUTO_INCREMENT COMMENT '用户id',
  `role_id` bigint(200) NOT NULL DEFAULT '0' COMMENT '角色id',
  PRIMARY KEY (`user_id`,`role_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

实体类的创建(注意这里并没有创建Role实体类,并且没有添加冗余字段)

  • Menu实体类
@TableName(value = "sys_menu")
@Data
@AllArgsConstructor
@NoArgsConstructor
@JsonInclude(JsonInclude.Include.NON_NULL)
public class Menu implements Serializable {
    private static final long serialVersionUID = -54979041104113736L;

    @TableId
    private Long id;
    /**
     * 菜单名
     */
    private String menuName;
    /**
     * 路由地址
     */
    private String path;
    /**
     * 组件路径
     */
    private String component;
    /**
     * 菜单状态(0显示 1隐藏)
     */
    private String visible;
    /**
     * 菜单状态(0正常 1停用)
     */
    private String status;
    /**
     * 权限标识
     */
    private String perms;
    /**
     * 菜单图标
     */
    private String icon;

    private Long createBy;

    private LocalDateTime createTime;

    private Long updateBy;

    private LocalDateTime updateTime;
    /**
     * 是否删除(0未删除 1已删除)
     */
    private Integer delFlag;
    /**
     * 备注
     */
    private String remark;
}
  • User实体类
@Data
@AllArgsConstructor
@NoArgsConstructor
@TableName("sys_user")
public class User implements Serializable {
    private static final long serialVersionUID = -40356785423868312L;
    
    /**
    * 主键
    */
    private Long id;
    /**
    * 用户名
    */
    private String userName;
    /**
    * 昵称
    */
    private String nickName;
    /**
    * 密码
    */
    private String password;
    /**
    * 账号状态(0正常 1停用)
    */
    private String status;
    /**
    * 邮箱
    */
    private String email;
    /**
    * 手机号
    */
    private String phonenumber;
    /**
    * 用户性别(0男,1女,2未知)
    */
    private String sex;
    /**
    * 头像
    */
    private String avatar;
    /**
    * 用户类型(0管理员,1普通用户)
    */
    private String userType;
    /**
    * 创建人的用户id
    */
    private Long createBy;
    /**
    * 创建时间
    */
    private LocalDateTime createTime;
    /**
    * 更新人
    */
    private Long updateBy;
    /**
    * 更新时间
    */
    private LocalDateTime updateTime;
    /**
    * 删除标志(0代表未删除,1代表已删除)
    */
    private Integer delFlag;
}

自定义UserDetailsService修改用户的数据源

创建UserMapper以及对应的xml,实现通过用户名查询用户、通过用户id获取到相对应的权限标识的方法

  • 这里只写了通过用户id来获取权限标识的方法(UserMapper.java)
public interface UserMapper extends BaseMapper<User> {
    /**
     * 通过用户的id来查询到用户的所有权限信息
     * @param id 用户id
     * @return
     */
    List<String> selectUserPermissionsByUserId(@Param("id") Long id);
}
  • UserMapper.xml
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
        PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
        "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.codestars.security.mapper.UserMapper">
    <select id="selectUserPermissionsByUserId" resultType="java.lang.String">
		SELECT
			DISTINCT m.perms
		FROM
			sys_user_role ur
			LEFT JOIN sys_role r on ur.role_id = r.id
			LEFT JOIN sys_role_menu rm on rm.role_id = r.id
			LEFT JOIN sys_menu m on m.id = rm.menu_id
		WHERE
			user_id = #{id}
			AND r.`status` = 0
			AND m.`status` =  0
    </select>
</mapper>
  • 编写测试类来测试一下
@SpringBootTest
public class UserTest {
    @Autowired
    private UserMapper userMapper;

    @Test
    public void testUserMapper() {
        System.out.println(userMapper.selectUserPermissionsByUserId(1L));
    }
}

创建一个LoginUser类,实现UserDetails接口,用于给UserDetailsService作为返回结果

@Data
public class LoginUser implements UserDetails {

    /**
     * 当前的用户对象
     */
    private User user;

    /**
     * 所包含的权限列表
     */
    private List<String> permissions;

    /**
     * 将权限列表封装成GrantedAuthority后的权限列表
     */
    private List<GrantedAuthority> authorities;

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        // 因为SpringSecurity获取用户权限时,调用的是该方法获取一个List<GrantedAuthority>
        // 因此我们把原有的权限集合给转换一下
        if(authorities == null) {
            authorities = permissions.stream()
                                    .map(SimpleGrantedAuthority::new)
                                    .collect(Collectors.toList());
        }
        return authorities;
    }

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

    @Override
    public String getUsername() {
        return user.getUserName();
    }

    @Override
    public boolean isAccountNonExpired() {
        return user.getDelFlag() == 0;
    }

    @Override
    public boolean isAccountNonLocked() {
        return user.getDelFlag() == 0;
    }

    @Override
    public boolean isCredentialsNonExpired() {
        return user.getDelFlag() == 0;
    }

    @Override
    public boolean isEnabled() {
        return user.getDelFlag() == 0;
    }
}

创建一个UserDetailsServiceImpl来实现UserDetailsService接口

@Service
public class UserDetailsServiceImpl implements UserDetailsService {
    @Autowired
    private UserMapper userMapper;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        // 1、查询出用户信息
        LambdaQueryWrapper<User> wrapper = new LambdaQueryWrapper<User>();
        wrapper.eq(User::getUserName, username);
        User user = userMapper.selectOne(wrapper);
        // 1.1、如果用户不存在,则抛出用户未找到异常
        if (Objects.isNull(user)) {
            throw new UsernameNotFoundException(username);
        }

        // 2、查询出用户的权限信息
        List<String> permissions = userMapper.selectUserPermissionsByUserId(user.getId());

        // 3、封装成一个UserDetails返回
        LoginUser loginUser = new LoginUser();
        loginUser.setUser(user);
        loginUser.setPermissions(permissions);
        return loginUser;
    }
}

自定义登录接口以及注销接口

@RestController
@RequestMapping("/sys")
public class LoginController {
    @Autowired
    private LoginService loginService;

    @PostMapping("/login")
    public ResponseResult login(@RequestBody User user){
        return loginService.login(user);
    }

    @GetMapping("/logout")
    public ResponseResult logout(){
        return loginService.logout();
    }
}

登录以及注销的Service实现

@Service
public class LoginServiceImpl implements LoginService {
    @Autowired
    private AuthenticationManager authenticationManager;

    @Autowired
    private RedisCache redisCache;

    /**
     * 处理登录的业务逻辑
     * @param user 用户通过post请求发送的用户数据
     * @return
     */
    @Override
    public ResponseResult login(User user) {
        // 1、使用AuthenticationManager进行用户认证
        Authentication authenticate = authenticationManager.authenticate(
                new UsernamePasswordAuthenticationToken(user.getUserName(), user.getPassword()));
        // 1.1 校验是否认证通过
        if(Objects.isNull(authenticate)) {
            return new ResponseResult(HttpStatus.UNAUTHORIZED.value(), "用户名或密码错误", null);
        }

        // 2、到此说明认证通过,获取用户信息生成一个JWT
        LoginUser loginUser = (LoginUser)authenticate.getPrincipal();
        String userId = loginUser.getUser().getId().toString();
        String jwt = JwtUtil.createJWT(userId);
        String redisKey = "userId:" + userId;

        // 3、将用户信息存储到Redis当中
        redisCache.setCacheObject(redisKey, loginUser);

        // 4、响应token给浏览器
        Map<String, String> tokenMap = new HashMap<>();
        tokenMap.put("token", jwt);
        return new ResponseResult(HttpStatus.OK.value(), "登录成功", tokenMap);
    }

    /**
     * 用户注销的业务逻辑
     * @return
     */
    @Override
    public ResponseResult logout() {
        // 1、清理redis当中的用户信息
        LoginUser loginUser;
        try {
            loginUser = (LoginUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
        }catch (ClassCastException e) {
            return new ResponseResult(HttpStatus.UNAUTHORIZED.value(), "您还没有登录", null);
        }

        String userId = loginUser.getUser().getId().toString();
        redisCache.deleteObject("userId:" + userId);

        // 2、清理SecurityContextHolder中的用户信息
        SecurityContextHolder.getContext().setAuthentication(null);

        return new ResponseResult(HttpStatus.OK.value(), "注销成功", null);
    }
}

自定义Filter进行Token校验

/**
 * 1、从请求头中获取token,如果没获取到则直接放行
 * 2、获取到了token则通过JwtUtils解析拿到UserId,若解析失败则说明token非法
 * 3、 解析成功后从redis中获取对应的用户数据,如果获取不到则抛出异常:用户未登录
 * 4、 从redis中取出了用户数据就存在SecurityContextHolder中
 */
@Component
public class JwtAuthenticationTokenFilter extends OncePerRequestFilter {
    @Autowired
    private RedisCache redisCache;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException {
        // 1、从请求头中获取token信息
        String token = request.getHeader("token");
        if (!StringUtils.hasText(token)) {
            chain.doFilter(request, response);
            return;
        }

        // 2、 解析token,获取到之前存储的userId
        String userId;
        try {
            Claims claims = JwtUtil.parseJWT(token);
            userId = claims.getSubject();
        } catch (Exception e) {
            throw new RuntimeException("token非法!");
        }

        // 3、 从redis中获取到用户数据
        LoginUser loginUser = redisCache.getCacheObject("userId:" + userId);
        if(Objects.isNull(loginUser)) {
            throw new RuntimeException("用户未登录!");
        }

        // 4、 将登录数据存储到SecurityContextHolder当中
        UsernamePasswordAuthenticationToken authentication =
                new UsernamePasswordAuthenticationToken(loginUser, null, loginUser.getAuthorities());
        SecurityContextHolder.getContext().setAuthentication(authentication);
        // 5、放行
        chain.doFilter(request, response);
    }
}

配置SpringSecurity的配置(开启基于AOP的权限控制、关闭csrf、关闭session的生成、配置允许访问的资源、配置好token校验过滤器、自定义认证和授权异常处理、配置允许跨域)

@Configuration
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class WebSecurityConfig extends WebSecurityConfigurerAdapter{
    @Autowired
    private JwtAuthenticationTokenFilter jwtAuthenticationTokenFilter;

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

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        // 1、使用token进行的授权,因此自带csrf防护,所以不需要了
        http.csrf().disable();

        // 2、不通过Session获取SecurityContext
        http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);

        // 3、配置资源所需权限信息
        http.authorizeRequests()
            // 放行自定义的登录接口
            .mvcMatchers("/sys/login").permitAll()
            .mvcMatchers("/sys/logout").permitAll()
            // 其余请求一律需要认证后才能访问
            .anyRequest().authenticated();

        // 4、将token校验过滤器放在UsernamePasswordAuthenticationFilter之前
        http.addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);

        // 5、自定义认证与授权异常处理
        http.exceptionHandling(handler -> {
            // 认证异常
            handler.authenticationEntryPoint((req, resp, auth) -> {
                ResponseResult result = new ResponseResult(HttpStatus.UNAUTHORIZED.value(), "认证异常", null);
                WebUtils.renderString(resp, JSON.toJSONString(result));
            });
            // 授权异常
            handler.accessDeniedHandler((req, resp, auth) -> {
                ResponseResult result = new ResponseResult(HttpStatus.FORBIDDEN.value(), "没有足够的权限", null);
                WebUtils.renderString(resp, JSON.toJSONString(result));
            });
        });

        // 6、配置允许跨域
        http.cors().configurationSource(corsConfigurationSource());
    }

    /**
     * 跨域配置
     * @return
     */
    @Bean
    public CorsConfigurationSource corsConfigurationSource() {
        // 创建跨域配置
        CorsConfiguration corsConfiguration = new CorsConfiguration();
        corsConfiguration.setAllowedOrigins(Arrays.asList("*"));
        corsConfiguration.setAllowedHeaders(Arrays.asList("*"));
        corsConfiguration.setAllowedMethods(Arrays.asList("*"));
        corsConfiguration.setAllowCredentials(false);
        corsConfiguration.setMaxAge(3600L);

        // 创建跨域的配置源
        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/**",corsConfiguration);
        return source;
    }
}

编写控制器用于权限测试

@RestController
@RequestMapping("/admin")
public class TestController {

    @PreAuthorize("hasAuthority('system:admin:add')")
    @GetMapping("/add")
    public String testAdd() {
        return "添加权限";
    }

    @PreAuthorize("hasAuthority('system:admin:edit')")
    @GetMapping("/edit")
    public String testEdit() {
        return "编辑权限";
    }

    @PreAuthorize("hasAuthority('system:admin:test')")
    @GetMapping("/test")
    public String test() {
        return "测试权限";
    }
}

编写SpringBootApplication应用配置mapper扫描后运行项目

@SpringBootApplication
@MapperScan("com.codestars.security.mapper")
public class SecurityTemplateApplication {
    public static void main(String[] args) {
        SpringApplication.run(SecurityTemplateApplication.class, args);
    }
}

使用postman进行测试

测试登录接口

image

测试注销接口

image

测试权限接口

image

posted @ 2022-10-10 15:40  CodeStars  阅读(23)  评论(0)    收藏  举报