Spring Security

基于表达式的控制访问

access() 方法的使用

之前所学的用户权限判定: hasRole 、 hasAnyRole、 hasAnyAuthority 、permitAll 等实际上底层源码都是调用access(表达式)

如hasRole :

public ExpressionUrlAuthorizationConfigurer<H>.ExpressionInterceptUrlRegistry hasRole(String role) {
          return this.access(ExpressionUrlAuthorizationConfigurer.hasRole(role));
      }

permitAll:

public ExpressionUrlAuthorizationConfigurer<H>.ExpressionInterceptUrlRegistry permitAll() {
          return this.access("permitAll");
      }

所以:可以通过access()实现与其权限控制相同的功能

access()结合自定义方法实现权限控制

第一步:定义一个接口

public interface MyAccessService {
    //定义权限判断方法
    boolean hasPermission(HttpServletRequest request, Authentication authentication);
}

第二步:实现类

@Service
public class MyAccessServiceImpl implements MyAccessService {
    @Override
    public boolean hasPermission(HttpServletRequest request, Authentication authentication) {
        Object principal = authentication.getPrincipal();
        if(principal instanceof UserDetails){
            UserDetails userDetails=(UserDetails)principal;
            Collection<? extends GrantedAuthority> authorities = userDetails.getAuthorities();
            //判断用户的所有权限中是否包含所要访问的该URL,没有返回false,有的话返回true
            return authorities.contains(new SimpleGrantedAuthority(request.getRequestURI()));
        }
        return false;
    }
}

第三步:配置

.antMatchers("/main1.html").access("@myAccessServiceImpl.hasPermission(request,authentication)")
    
    //注意,hasPermission方法中的参数必须与实现类MyAccessServiceImpl中的一样

当我们访问/main1.html时,显示无权访问,因为当前用户的权限中没有加入该URL:**”/main1.html”**

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
<h1>登录成功</h1>
<h1><a href="/main1.html">前往main1页面</a></h1>
</body>
</html>
{"status":"error","msg":"权限不足,请联系管理员"}

在自定义登录逻辑中给该用户的权限中加上”/main1.html”,便可成功访问。

如下:

.......

 return new User(username,password,
                    AuthorityUtils.commaSeparatedStringToAuthorityList("admin,normal,/main1.html"));
  
.......

基于注解的访问控制

在Spring Security中提供了一些访问控制的注解。这些注解都是默认不可用的,需要通过**@EnableGlobalMethodSecurity**进行开启后使用。

如果设置的条件允许,程序正常执行,如果不允许会报500

org.springframework.security.access.AccessDeniedException: 不允许访问

这些注解可以写到Service接口或方法上,也可以写到Controller或Controller的方法上。通常情况下都是写在控制器方法上的,控制器接口URL是否允许被访问。

@Secured

@Secured是专门用来判断是否具有角色的。能够写在方法和类向。参数要以ROLE_ 开头。

开启注解

在启动类(也可以在配置类等能够扫描的类上)上添加@EnableGlobalMethodSecurity(securedEnabled = true)

@SpringBootApplication
@EnableGlobalMethodSecurity(securedEnabled = true)
public class SpringsecurityDemoApplication {
    public static void main(String[] args) {      SpringApplication.run(SpringsecurityDemoApplication.class, args);
    }
}

使用注解

    @Secured("ROLE_aaa")
    @RequestMapping("/toMain")
    public String toMain(){
        return "redirect:main.html";
    }

//如果当前登录用户没有角色aaa,则无法跳转到main.html,且会报500

@PreAuthorize/@PostAuthorize

@PreAuthorize/@PostAuthorize都是方法和类级别的注解。

  • @PreAuthorize:表示访问方法或类在执行之前先判断权限,大多数情况下都是使用这个注解,注解的参数和access()方法参数取值相同,都是权限表达式。
  • @PostAuthorize:表示方法或类执行结束后判断权限,此注解很少使用到。

开启注解

@SpringBootApplication
@EnableGlobalMethodSecurity(securedEnabled = true,prePostEnabled = true)
public class SpringsecurityDemoApplication {
    public static void main(String[] args) {
        SpringApplication.run(SpringsecurityDemoApplication.class, args);
    }
}

使用注解

//PreAuthorize的表达式允许以 ROLE_ 开头,也可以不以ROLE_开头,直接写角色名,如下:
 @PreAuthorize("hasRole('admin')")
 @RequestMapping("/toMain")
 public String toMain(){
     return "redirect:main.html";
 }

RememberMe功能

Spring Security中Remember Me为”记住我”功能,用户只需在登录时添加remember-me复选框,取值为true。Spring Security会自动把用户信息存储在数据源中,以后就可以不登录进行访问。

Demo

添加依赖

导入mybatis启动器用时导入MySQL驱动

<!--mybatis依赖-->
<dependency>
    <groupId>org.mybatis.spring.boot</groupId>
    <artifactId>mybatis-spring-boot-starter</artifactId>
    <version>2.1.2</version>
</dependency>
<!--mysql-->
<dependency>
    <groupId>mysql</groupId>
    <artifactId>mysql-connector-java</artifactId>
    <version>8.0.19</version>
</dependency>

配置文件:

spring:
  #mybatis配置
  datasource:
    username: root
    password: 123456
    url: jdbc:mysql://localhost:3306/security?serverTimezone=UTC&useUnicode=true&characterEncoding=utf-8
    driver-class-name: com.mysql.cj.jdbc.Driver

登录页面:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
<form action="/login" method="post">
    用户名:<input type="text" name="username"><br/>
    密码:<input type="password" name="password"><br/>
    <!--记住我 的name必须为remember-me,如果想自定义的话必须在配置类中指定-->
    记住我:<input type="checkbox" name="remember-me" value="true"><br/>
    <input type="submit" value="登录">
</form>
</body>
</html>

配置类:

@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    @Autowired
    private DataSource dataSource;
    @Autowired
    private UserDetailsServiceImpl userDetailsService;
    @Autowired
    private PersistentTokenRepository persistentTokenRepository;
    //重写configure方法来自定义登录页面
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        //表单提交
       http.formLogin()
               .loginProcessingUrl("/login")
               .loginPage("/login.html")
               .successForwardUrl("/toMain")
               .failureHandler(new MyAuthenticationFailureHandler("error.html"));
       //授权认证
        http.authorizeRequests()
                .antMatchers("/error.html").permitAll()
                .antMatchers("/login.html").permitAll()
            .antMatchers("/main1.html").access("@myAccessServiceImpl.hasPermission(request,authentication)")
                .anyRequest().authenticated();
        //关闭csrf防护
        http.csrf().disable();
        //403处理
        http.exceptionHandling()
                .accessDeniedHandler(myAccessDeniedHandler);
        //remember me
//---------------------------------------remember me-------------------------------------
        http.rememberMe()
                //自定义登录逻辑
                .userDetailsService(userDetailsService)
                //持久层对象
                .tokenRepository(persistentTokenRepository)
                //失效时间,默认是两周,单位秒
                .tokenValiditySeconds(60);
//---------------------------------------end-------------------------------------
    }

    @Bean
    public PasswordEncoder getPw(){
        //返回一个PasswordEncoder实例
        return new BCryptPasswordEncoder();
    }
//------------------------------persistentTokenRepository-------------------------------
    @Bean
    public PersistentTokenRepository getPersistentTokenRepository(){
        JdbcTokenRepositoryImpl jdbcTokenRepository= new JdbcTokenRepositoryImpl();
         //设置数据源
        jdbcTokenRepository.setDataSource(dataSource);
        //第一次启动时自动建表,用户存储"记住我"的数据,第二次启动时一定要注释掉,不注释会报错
        //jdbcTokenRepository.setCreateTableOnStartup(true);
        return jdbcTokenRepository;
    }
//---------------------------------------end-------------------------------------
}

项目启动会生成一个表:

mysql> use security
Database changed
mysql> show tables
    -> ;
+--------------------+
| Tables_in_security |
+--------------------+
| persistent_logins  |
+--------------------+
1 row in set (0.00 sec)

mysql> desc persistent_logins;
+-----------+-------------+------+-----+-------------------+-----------------------------+
| Field     | Type        | Null | Key | Default           | Extra                       |
+-----------+-------------+------+-----+-------------------+-----------------------------+
| username  | varchar(64) | NO   |     | NULL              |                             |
| series    | varchar(64) | NO   | PRI | NULL              |                             |
| token     | varchar(64) | NO   |     | NULL              |                             |
| last_used | timestamp   | NO   |     | CURRENT_TIMESTAMP | on update CURRENT_TIMESTAMP |
+-----------+-------------+------+-----+-------------------+-----------------------------+
4 rows in set (0.01 sec)

在登录时,选定记住我,会将用户的登录信息保存在这个表中,在有效时间内,不许登录就能访问。

mysql> select * from persistent_logins;
+----------+--------------------------+--------------------------+---------------------+
| username | series                   | token                    | last_used           |
+----------+--------------------------+--------------------------+---------------------+
| admin    | MwHzropZq3D8GKK4Vz3wgg== | jqLKe2uduDhJY0wPJ+JQeA== | 2021-05-09 12:00:19 |
+----------+--------------------------+--------------------------+---------------------+
1 row in set (0.00 sec)

在Thymeleaf中SpringSecurity的使用

Spring Security可以在一些视图技术中进行控制显示效果。例如JSPThymeleaf,在非前后端分离的且使用SpringBoot的项目中多使用Thymeleaf作为视图展示技术。

Thymeleaf对SpringSecurity的支持都放在thymeleaf-extras-springsecurityX中,目前最新版为5,。

添加依赖:

<!--thymeleaf整合SpringSecurity-->
     <dependency>
         <groupId>org.thymeleaf.extras</groupId>
         <artifactId>thymeleaf-extras-springsecurity5</artifactId>
     </dependency>
     <!--thymeleaf-->
     <dependency>
         <groupId>org.springframework.boot</groupId>
         <artifactId>spring-boot-starter-thymeleaf</artifactId>
     </dependency>

在HTML页面中引入thymeleaf和security的命名空间:

<html xmlns="http://www.w3.org/1999/xhtml"
       xmlns:th="http://www.thymeleaf.org"
       xmlns:sec="http://www.thymeleaf.org/extras/spring-security">

获取属性

在UsernamePasswordAuthenticationToken中保存着当前用户的所有信息

public class UsernamePasswordAuthenticationToken extends AbstractAuthenticationToken {
    private static final long serialVersionUID = 540L;
    private final Object principal;
    private Object credentials;

    public UsernamePasswordAuthenticationToken(Object principal, Object credentials) {
        super((Collection)null);
        this.principal = principal;
        this.credentials = credentials;
        this.setAuthenticated(false);
    }

 .........get方法和构造略
        
     
public abstract class AbstractAuthenticationToken implements Authentication, CredentialsContainer {
    private final Collection<GrantedAuthority> authorities;
    private Object details;
    private boolean authenticated = false;

 .........get方法和构造略

可以在HTML中通过使用sec:authentication=""获取UsernamePasswordAuthenticationToken中所有的getXXX的内容,包括父类AbstractAuthenticationToken中的getXXX的内容。

根据源码得出下列属性:

  • name:登录账户名
  • principal:登录主体,在自定义登录逻辑中是UserDetails
  • credentials:凭证
  • authorities:权限和角色
  • details:实际上是WebAuthenticationDetails实例,可以获取remoteAddress(客户端IP)和sessionID(当前SessionID)

WebAuthenticationDetails:

public class WebAuthenticationDetails implements Serializable {
    private static final long serialVersionUID = 540L;
    private final String remoteAddress;
    private final String sessionId;

    
    .....略get和构造方法

demo页面:

<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml"
       xmlns:th="http://www.thymeleaf.org"
       xmlns:sec="http://www.thymeleaf.org/extras/spring-security">
<head>
    <meta charset="UTF-8">
    <title>demo</title>
</head>
<body>
登录账号:<span sec:authentication="name"></span><br/>
登录账号:<span sec:authentication="principal.username"></span><br/>
凭证:<span sec:authentication="credentials"></span><br/>
权限和角色:<span sec:authentication="authorities"></span><br/>
客户端地址:<span sec:authentication="details.remoteAddress"></span><br/>
sessionid:<span sec:authentication="details.sessionId"></span><br/>
</body>
</html>

controller:

@RequestMapping("/demo")
   public String demo(){
     return "demo";
   }

登录后访问:http://localhost:8080/demo

结果:

登录账号:admin
登录账号:admin
凭证:
权限和角色:[/main1.html, admin, normal]
客户端地址:0:0:0:0:0:0:0:1
sessionid:C7C0776F3F35DE05B13BB53E79BE1E48

权限判断

SpringSecurity在thymeleaf中的权限判断和Shiro差不多:

通过权限判断
<button sec:authorize="hasAuthority('insert')">新增</button>
<button sec:authorize="hasAuthority('admin')">修改</button>
通过角色判断
<button sec:authorize="hasRole('delete')">删除</button>
<button sec:authorize="hasRole('admin')">查询</button>

退出登录

一个超链接即可:

<a href="/logout">退出登录</a>

但是退出后URL中会携带参数logout:

http://localhost:8080/login.html?logout

如果不想显示这个参数的话,可以配置类中指定退出要跳转的页面:

//退出登录
     http.logout()
             .logoutSuccessUrl("/login.html");

SpringSecurity中的CSRF

//关闭csrf防护
        http.csrf().disable();

如果没有这行代码会导致用户无法被认证。

什么是CSRF

CSRF(Cross-site request forgery)跨站请求伪造,也被称为”OneClick Attack”或者”Session Riding”,通过伪造用户请求访问受信任站点的非法请求访问。

跨域:只要网络协议,IP地址,端口中任何一个不相同就是跨域请求。

客户端与服务进行交互时,由于http 协议本身是无状态协议,所以引入了cookie进行记录客户端身份。在cookie中会存放session id用来识别客户端身份的。在跨域的情况下,session id 可能被第三方恶意劫持,通过这个session id 向服务端发起请求时,服务端会认为这个请求是合法的,可能发生很多意想不到的事情。

SpringSecurity中的CSRF

从Spring Security4开始CSRF防护默认开启。默认会拦截请求。进行CSRF处理。CSRF为了保证不是其他第三方网站访问,要求访问时携带参数名为_csrf值为token(token 在服务端产生)的内容,如果token和服务端的token匹配成功,则正常访问。

如果不关闭CSRF,则在请求登录时,要携带服务端生成的CSRF的token:

<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml"
      xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
<form action="/login" method="post">
    <input type="hidden" th:value="${_csrf.token}" name="_csrf" th:if="${_csrf}">
    用户名:<input type="text" name="username"><br/>
    密码:<input type="password" name="password"><br/>
    <!--记住我 的name必须为remember-me,如果想自定义的话必须在配置类中指定-->
    记住我:<input type="checkbox" name="remember-me" value="true"><br/>
    <input type="submit" value="登录">
</form>
</body>
</html>

demo地址: https://sovzn.lanzous.com/i7Iomozfkwb