醋醋百科网

Good Luck To You!

【SpringBoot系列教程五】一文学会SpringSecurity

对于一些重要的操作,有些请求需要用户验明身份后才可以进行;有时候,可能需要与第三方公司合作,存在系统之间的交互,这时也需要验证合作方身份才能处理业务。这样做的意义在于保护自己的网站安全,避免一些恶意攻击导致数据和服务的不安全。在互联网的世界里,这些往往是必需的,因为互联网中存在太多的恶意攻击,保证自己网站安全是十分必要的。

为了提供安全的机制,Spring提供了其安全框架Spring Security,它是一个能够为基于Spring生态圈,提供安全访问控制解决方案的框架。它提供了一组可以在Spring应用上下文中配置的机制,充分利用了Spring的强大特性,为应用系统提供声明式的安全访问控制功能,减少了为企业系统安全控制编写大量重复代码的工作。

应用程序的安全性通常体现在两个方面:认证和授权。

认证是确认某主体在某系统中是否合法、可用的过程。这里的主体既可以是登录系统的用户,也可以是接入的设备或者其他系统。 授权是指当主体通过认证之后,是否允许其执行某项操作的过程。

这些概念并非Spring Security独有,而是应用安全的基本关注点。Spring Security可以帮助我们更便

捷地完成认证和授权。

5.1 认识SpringSecurity

SpringSecurity提供了声明式的安全访问控制解决方案(仅支持基于Spring的应用程序),对访问权限进行认证和授权,它基于Spring AOP和Servlet过滤器,提供了安全性方面的全面解决方法。其基本原理如下图

5.1.1 核心概念

SpringSecurity的核心概念可以概况为以下3个:

  1. Principe:代表用户的队形Principe(User),不仅指人类,还包括一切可用于验证的设备。
  2. Authority:代表用角色Authority(Role),每个用户都应该有一种角色,如管理员或者会员。
  3. Permission:代表授权(也可以理解为权限),复杂的应用环境需要对角色的权限进行表述。

在SpringSecurity中,Authority和Permission是两个完全独立的概念,两者并没有必然的联系。它们之间需要通过配置进行关联,这种关联可以自定义。

5.1.2 认证和授权

安全主要分为验证(authentication)和授权(authorization)两个部分。

5.1.2.1 验证(authentication)

验证指的是,建立系统使用者信息(Principal)的过程。使用者可以是一个用户、设备,和可以在应用程序中执行某种操作的其他系统。

用户认证一般要求用户提供用户名和密码,系统通过校验用户名和密码的正确性来完成认证的通过或拒绝过程。

Spring Security 支持主流的认证方式,包括 HTTP 基本认证、HTTP 表单验证、HTP 摘要认证、OpenID 和 LDAP 等。

Spring Security 进行验证的步骤如下。

  1. 用户使用用户名和密码登录。
  2. 过滤器(UsernamePasswordAuthenticationFilter)获取到用户名、密码,然后封装成Authentication。
  3. AuthenticationManager 认证 token(Authentication 的实现类传递)。
  4. AuthenticationManager认证成功,返回一个封装了用户权限信息的 Authentication 对象。用户的上下文信息(角色列表等)。
  5. Authentication 对象赋值给当前的 SecurityContext,建立这个用户的安全上下文(通过调用SecurityContextHolder.getContext().setAuthentication())。
  6. 用户进行一些受到访问控制机制保护的操作,访问控制机制会依据当前安全上下文信息检查这个操作所需的权限。

除利用提供的认证外,还可以编写自己的 Filter( 过滤器),提供与那些不是基于 Spring Security的验证系统的操作。

5.1.2.2 授权(authorization)。

在一个系统中,不同用户具有的权限是不同的。一般来说,系统会为不同的用户分配不同的角色,而每个角色则对应一系列的权限。

它判断某个Principe在应用程序中是否允许执行某个操作,在进行授权判断前,要求其所要使用到的规则必须在验证过程中已经建立好了。

对Web资源的保护,最好的办法是使用过滤器。对于方法的保护,最好的办法是利用AOP。

SpringSecurity在进行用户认证及授予权限时,也是通过各种拦截器和AOP来控制权限访问的,从而实现安全。

5.1.3 核心类

5.1.3.1 SecurityContext

SecurityContext中包含当前正则访问系统的用户的详细信息,它只有以下两种方法:

  • getAuthentication():获取当前经过身份验证的主体或者身份验证的请求令牌。
  • setAuthentication():更改或删除当前已经验证的主体身份验证信息

SecurityContext的信息是由SecurityContextHolder来处理的。

5.1.3.2 SecurityContextHolder

SecurityContextHolder用来保存SecurityContext。最常用的是getContext()方法,用来获取当前SecurityContext。

SecurityContextHolder中定义了一系列的类方法,而这些类方法的内部逻辑是通过SecurityContextHolder持有的
SecurityContextHolderStragegy来实现的,如果clearContext()、getContext()、setContext()、createEmptyContext()。
SecurityContextHolderStrategy接口关键代码如下:

public interface SecurityContextHolderStrategy {
    void clearContext();
    SecurityContext getContext();
    void setContext(SecurityContext context);
    SecurityContext createEmptyContext();
}

SpringSecurity使用一个Authentication对象描述当前用户的相关信息。SecurityContextHolder中持有的是当前用户的SecurityContext,而SecurityContext持有的是代表当前用户相关信息的Authentication的引用。

这个Authentication对象不需要自己创建,SpringSecurity会自动创建相应的Authentication对象,然后赋值给当前的SecurityContext。但是,有时需要在程序中获取当前用户的相关信息,比如常见的是获取当前用户的用户名。在程序中任意地方,可以通过如下方式获取到当前用户的用户名。

Object principal = SecurityContextHolder.getContext().getAuthentication().getPrincipal();
if(principal instanceof UserDetails){
	return ((UserDetails)principal).getUsername();
}
if(principal instanceof Principal){
	return ((Principal)principal).getName();
}
return String.valueOf(principal);
  • getAuthentication()方法会返回认证信息。
  • getPrincipal()方法返回身份信息,它是对UserDetails对身份信息的封装

除此以外,获取当前用户名最简单的方式如下:

SecurityContextHolder.getContext().getAuthentication().getName();

5.1.3.3 ProviderManager

ProviderManager会维护一个认证的列表,以便处理不同认证方式的认证,因为系统可能会存在多种认证方式,比如手机号、用户名密码、邮箱方式。

在认证时,如果ProviderManager的认证结果不是null,则说明认证成功,不再进行其他方式的认证,并且作为认证的结果保存在SecurityContext中。如果不成功,则抛出错误信息ProviderNotFoundException。

5.1.3.4 DaoAuthenticationProvider

它是 AuthenticationProvider 最常用的实现,用来获取用户提交的用户名和密码,并进行正确性比对。如果正确,则返回一个数据库中的用户信息。

当用户在前台提交了用户名和密码后,就会被封装成
UseramePasswordAuthenticaionToken。然后,DaoAuthenticationProvider根据 retrieveUser方法,交给
additionalAuthenticatonChecks 方法完成
UsernamePasswordAuthenticationToken 和 UserDetails 密码的比对。如果这个方法没有抛出异常,则认为比对成功。比对密码需要用到 PasswordEncoder和 SaltSource。

5.1.3.5 UserDetails

UserDetais 是 Spring Security 的用户实体类,包含用户名、密码、权限等信息。SpringSecurity默认实现了内置的 User类,供 Spring Security 安全认证使用。当然,也可以自己实现。

UserDetails 接口和 Authentication 接口很类似,都拥有 username 和 authorities。一定要区分清楚 Authentication 的 getCredentials()与 UserDetails 中的 getPassword()。前者是用户提交的密码凭证,不一定是正确的,或数据库不一定存在;后者是用户正确的密码,认证器要进行比对的就是两者是否相同。

Authentication中的getAuthorities()方法是由 UserDetails 的 getAuthorities()传递而形成的。UserDetails 的用户信息是经过 AuthenticationProvider认证之后被填充的。

UserDetails 中提供了以下几种方法。

o String getPassword():返回验证用户密码,无法返回则显示为 null。

o String getUsername():返回验证用户名,无法返回则显示为 null。

o boolean isAccountNonExpired():账户是否过期,过期无法验证。

o boolean isAccountNonLocked():指定用户是否被锁定或解锁,锁定的用户无法进行身份验证。

o boolean isCredentialsNonExpired():指定是否已过期的用户的凭据(密码),过期的凭据无法认证。

o boolean isEnabled():是否被禁用。禁用的用户不能进行身份验证。

5.1.3.6. UserDetailsService

用户相关的信息是通过 UserDetailisService 接口来加载的。该接口的唯一方法是loadUserByUsername(String username),用来根据用户名加载相关信息。这个方法的返回值是UserDetails 接口,其中包含了用户的信息,包括用户名、密码、权限、是否启用、是否被锁定、是否过期等。

5.1.3.7 GrantedAuthority

GrantecAuthorty 中只定义了一个 getAuthority()方法。该方法返回一个字符串,表示对应权限的字符串。如果对应权限不能用字符串表示,则返回null。

GrantedAuthority 接口通过 UserDetaisService 进行加载,然后赋予 UserDetails.

Authentication 的 getAuthorities()方法可以返回当前 Authentication 对象拥有的权限,其返回值是一个 GrantedAuthority 类型的数组。每一个 GrantedAuthority 对象代表赋予当前用户的一种权限。

5.1.3.8 Filter

1. SecurityContextPersistenceFilter

它从 SecurityContextRepository 中取出用户认证信息。为了提高效率,避免每次请求都要查询认证信息,它会从 Session 中取出已认证的用户信息,然后将其放入 SecurityContextHolder中,以便其他 Filter 使用。

2. WebAsyncManagerlntegrationFilter

集成了 SecurityContext和 WebAsyncManager,把 SecurityContext 设置到异步线程,使

其也能获取到用户上下文认证信息。

3. HeaderWriterFilter。

它对请求的 Header 添加相应的信息。

4. CsrfFilter。

跨域请求伪造过滤器。通过客户端传过来的 token 与服务器端存储的 token 进行对比,来判断

请求的合法性。

5. LogoutFilter

匹配登出 URL。匹配成功后,退出用户,并清除认证信息。

6. UsernamePasswordAuthenticationFilter

登录认证过滤器,默认是对“/login”的 POST 请求进行认证。该方法会调用 attemptAuthentication,尝试获取一个Authentication 认证对象,以保存认证信息,然后转向下一个 Filter,最后调SuccessfulAuthentication执行认证后的事件。

7. AnonymousAuthenticationFlIter

如果 SecurityContextHolder 中的认证信息为空,则会创建一个匿名用户到 SecurityContextHolder中

8. SessionManagementFilter

持久化登录的用户信息,用户信息会被保存在Session、Cookie,或者Redis中。

5.2 SpringBoot集成Security

5.2.1 集成SpringSecurity

本小节将创建一个简介的SpringSecurity项目,首先创建一个SpringBoot项目,在依赖中勾选SpringSecurity及web即可。如图所示:

在创建好项目后,启动项目,并在浏览器中输入http://localhost:8080,此时因为已经添加了SpringSecurity,因此会自动跳转到登录页面,如下图所示:

SpringSecurity默认的用户名是user,密码会在项目启动时自动生成,在开发工具控制台即可看到生成的密码,如下图所示:

因为此时我们并没有编写及配置登录成功后的页面,因此当登录成功后,会跳转到错误页面,此处先忽略这个问题,在后面的内容中我们将会了解到相关内容。

当然,在HTTP基本认证中,用户名和密码都是可以配置的,最常见的就是在配置文件中进行修改,代码如下:

spring:
  security:
  .user:
    name: admin
    password: admin

重新启动程序,发现控制台不再打印默认密码串了,此时使用我们自定义的用户名和密码即可登录。

事实上,绝大部分Web应用都不会选择HTTP基本认证这种认证方式,除安全性差、无法携带cookie等因素外,灵活性不足也是它的一个主要缺点。通常大家更愿意选择表单认证,自己实现表单登录页和验证逻辑,从而提高安全性。

5.2.2 表单认证

在上一小节中,我们初步引入了Spring Security,并使用其默认生效的HTTP基本认证来保护URL资源,本章我们使用表单认证来保护URL资源。

首先,新建一个configuration包用于存放通用配置;然后,新建一个WebSecurityConfiguration类,使其继承
WebSecurityConfigurerAdapter。

在给WebSecutiryConfig类中加上@EnableWebSecurity 注解后,便会自动被 Spring发现并注册(查看 @EnableWebSecurity 即可看到@Configuration 注解已经存在,所以此处不需要额外添加)。 代码如下:

package cn.bytecollege.chapter04.configuration;

import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.stereotype.Component;

@Component
@EnableWebSecurity
public class WebSecurityConfiguration extends WebSecurityConfigurerAdapter {
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        super.configure(http);
    }
    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        super.configure(auth);
    }
}

接着查看
WebSecurityConfigurerAdapter类对configure(HttpSecurity http)的定义。

protected void configure(HttpSecurity http) throws Exception {
        this.logger.debug("Using default configure(HttpSecurity). If subclassed this will potentially override subclass configure(HttpSecurity).");
        http.authorizeRequests((requests) -> {
            ((AuthorizedUrl)requests.anyRequest()).authenticated();
        });
        http.formLogin();
        http.httpBasic();
    }

可以看到
WebSecurityConfigurerAdapter已经默认声明了一些安全特性:

  • 验证所有请求。
  • 允许用户使用表单登录进行身份验证(Spring Security 提供了一个简单的表单登录页面)。
  • 允许用户使用HTTP 基本认证。

HttpSecurity实际上对应了=Spring Security命名空间配置方式中XML文件内的标签,允许我们为特 定的HTTP请求配置安全策略。

在XML文件中,声明大量配置早已司空见惯;但在Java配置中,按照传统的方式,我们需要这样来调用。

package cn.bytecollege.chapter04.configuration;

import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.config.annotation.web.configurers.ExpressionUrlAuthorizationConfigurer;
import org.springframework.security.config.annotation.web.configurers.FormLoginConfigurer;
import org.springframework.stereotype.Component;

@Component
@EnableWebSecurity
public class WebSecurityConfiguration extends WebSecurityConfigurerAdapter {
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        ExpressionUrlAuthorizationConfigurer.ExpressionInterceptUrlRegistry
                urlRegistry = http.authorizeRequests();
        ExpressionUrlAuthorizationConfigurer.AuthorizedUrl authorizedUrl
                = (ExpressionUrlAuthorizationConfigurer.AuthorizedUrl) urlRegistry.anyRequest();
        authorizedUrl.authenticated();

        FormLoginConfigurer formLoginConfigurer = http.formLogin();
        formLoginConfigurer.loginPage("/myLogin.html");
        formLoginConfigurer.permitAll();
    }
}

可以看出上述的代码比较繁琐,HttpSecurity首先被设计为链式调用,在执行每个方法后,都会返回一个预期的上下文,便于连续调用。我们不需要关心每个方法究竟返回了什么、如何进行下一个配置等细节。

HttpSecurity提供了很多配置相关的方法,分别对应命名空间配置中的子标签<http>。authorizeRequests()、formLogin()、httpBasic()和 csrf()分别对应<intercept-url>、<form- login>、<http-basic>和<csrf>标签。调用这些方法之后,除非使用and()方法结束当前标签,上下文才会回到HttpSecurity,否则链式调用的上下文将自动进入对应标签域。

authorizeRequests()方法实际上返回了一个 URL 拦截注册器,我们可以调用它提供的 anyanyRequest()、antMatchers()和regexMatchers()等方法来匹配系统的URL,并为其指定安全策略。

formLogin()方法和httpBasic()方法都声明了需要Spring Security提供的表单认证方式,分别返回对应的配置器。其中,formLogin().loginPage("/myLogin.html")指定自定义的登录页/myLogin.html,同时,Spring Security会用/myLogin.html注册一个POST路由,用于接收登录请求。并新建一个index.html页面用于登录成功后跳转(页面比较简单,此处省略首页代码)。

我们也可以自定义一个页面用于替代SpringSecurity提供的登录页面。接着需要进行一些简单的配置。

package cn.bytecollege.chapter04.configuration;

import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;

@EnableWebSecurity
public class WebSecurityConfiguration extends WebSecurityConfigurerAdapter {
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        //设置请求鉴权
        http.authorizeHttpRequests()
                //设置不需要鉴权的页面和路径
                .antMatchers("/login")
                .permitAll()
                //除上述配置的页面以外请求需要鉴权
                .anyRequest().authenticated()
                .and()
                //设置使用表单验证
                .formLogin()
                //设置登录成功后跳转的路径
                .successForwardUrl("/index")
                .and()
                //关闭csrf
                .csrf().disable();
    }
}

在形式上,我们使用了Spring Security的表单认证功能。但实际 上,这还远远不够。例如,在实际系统中,我们正常登录时使用的用户名和密码都来自数据库,这里却都写在配置上。更进一步,我们可以对每个登录用户都设定详细的权限,而并非一个通用角色。借助这个示例,我们可以整理出SpringSecurity整个认证过程的流程如下图所示:

5.2.3 自定义认证

在前面的小节中我们将用户名和密码配置在了配置文件中,但是这么做并不能满足实际开发的需要,因为在实际开发中通常是将用户名密码保存在数据库中,通过从数据库查询的数据来保证用户登录的凭证是否正确。

在本小节中,将学习利于SpringSecurity对存储在数据库中的用户进行认证,首先创建数据库和数据表,在下面的代码中,暂且关注USER表即可,权限表相关的内容会在后续内容中讲解。

CREATE DATABASE SPRINGSECURITY_DEMO;
USE SRPINGSECURITY_DEMO;
CREATE TABLE USER(
  USER_ID INT PRIMARY KEY AUTO_INCREMENT,
  USERNAME VARCHAR(30),
  PASSWORD VARCHAR(100),
  ENABLE BOOLEAN
);
INSERT INTO USER (USERNAME,PASSWORD,ENABLE) VALUES ('admin','$2a$10$ANaPkkJNFu2Totpk1EjxLuwrQ4IiAl2trjkZ2z9uRbFU7ToRiSMgK',1);
CREATE TABLE ROLE(
  ROLE_ID INT PRIMARY KEY AUTO_INCREMENT,
  ROLE_DESCRIPTION  VARCHAR(30);
  ROLE_NAME VARCHAR(20);
)

INSERT INTO ROLE (ROLE_DESCRIPTION,ROLE_NAME) VALUES ('超级管理员','superadmin');
CREATE TABLE USER_ROLE(
  USER_ROLE_ID PRIMARY KEY AUTO_INCREMENT,
  USER_ID INT,
  ROLE_ID INT
);
INSERT INTO USER_ROLE (USER_ID,ROLE_ID) VALUES (1,1);
CREATE TABLE MENU(
  MENU_ID INT PRIMARY KEY AUTO_INCERMENT,
  MENU_NAME VARCAHR(30),
  PERMISSION VARCHAR(30),
  MENU_URL VARCHAR(200)
);
INSERT INTO MENU (MENU_NAME,PERMISSION,MENU_URL) VALUES ('商品管理','sys:produce:manage','/product/');
INSERT INTO MENU (MENU_NAME)
CREATE TABLE ROLE_MENU(
  ROLE_MENU_ID INT PRIMARY KEY AUTO_INCREMENT,
  ROLE_ID INT,
  MENU_ID INT
)
INSERT INTO ROLE_MENU (ROLE_ID,MENU_ID) VALUES (1,1);

创建实体类对象,在这里只创建User对象,其他实体类代码暂时省略:

@Data
@Entity
@Table(name = "user")
public class User  {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Integer userId;
    @Column(name = "username",columnDefinition = "VARCHAR(30)",length = 30)
    private String username;
    @Column(name = "password",columnDefinition = "VARCHAR(100)",length = 100)
    private String password;
    @Column(name = "enable",columnDefinition = "BOOLEAN")
    private boolean enable;
    @OneToMany(fetch = FetchType.EAGER)
    @JoinTable(name = "user_role",joinColumns = @JoinColumn(name = "user_id"),inverseJoinColumns = @JoinColumn(name = "role_id"))
    private List<Role> roles;
}

除此以外,新增数据库查询接口,代码如下:

public interface UserRepository extends JpaRepository<User,Integer> {
    User findUserByUsername(String username);
}

根据上文中提到的SpringSecurity认证流程图可以得知,在认证过程中会调用UserDetails接口的实现类,例如上图中所示的
InMemoryUserDetailsManager类就是该接口的实现类,只是该类是将用户名和密码保存在内存中,这显然不符合开发需要。通常我们需要从数据库中查询用户名和密码。此处,需要我们自定义该接口的实现类。

首先查看该接口的源码:

public interface UserDetailsService {
    //实现了实现该方法即可
	UserDetails loadUserByUsername(String username) throws UsernameNotFoundException;
}

该接口源码比较简单,只有loadUserByUsername()方法,子类可以在该方法内实现数据库查询的逻辑。

新建UserService,代码如下:

@Slf4j
@Service
public class UserService implements UserDetailsService {
    
    @Autowired
    private UserRepository userRepository;
    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        //查询用户名对应的数据信息
        User user = userRepository.findUserByUsername(username);
        //如果用户名不存在,则抛出异常
        if(user==null){
            throw new UsernameNotFoundException("用户名或者密码错误!");
        }
        //暂时省略授权流程
        return new org.springframework.security.core.userdetails.User
                (user.getUsername(),user.getPassword(),null);
    }
}

在上面的代码中使用JPA从数据库中查询了用户名对应的对象信息,并对信息做了判断,如果用户名不存在,则抛出异常。并且在此处省略了权限相关的设置。

需要注意的是loadUserByUsername()的返回值是UserDetails而不是自定义的User,在这里有两种解决办法:

  1. 自定义User类继承UserDetails
  2. 将查询出的信息封装进org.springframework.security.core.userdetails.User对象,其中第一个参数是用户名,第二个参数是从数据库中查询出的密码,第三个参数则是该用户所具有的角色或则权限。因为此处暂时不涉及到授权,因此传入null即可。

接着,需要定义接收表单参数的Controller,代码如下:

@Controller
@Slf4j
public class LoginController {
    @Autowired
    private AuthenticationManager authenticationManager;
    @GetMapping("/login")
    public String login(){
        return "myLogin";
    }
    @PostMapping("/auth/login")
    public String login(String username,String password){
        //封装用户名和密码用于认证
        UsernamePasswordAuthenticationToken authenticationToken =
                new UsernamePasswordAuthenticationToken(username,password);
        //①
        //authenticate()方法会调用UserDetails中loadUserByUsername()方法
        //并比对界面获取的数据和数据库中查询的数据是否一直
        //如果认证成功则返回一个Authentication对象,否则会返回null
        Authentication authenticate = authenticationManager.authenticate(authenticationToken);
        if(Objects.isNull(authenticate)){
            throw new UsernameNotFoundException("用户名或者密码不存在");
        }
        log.info(authenticate.toString());
        //认证成功将authenticate封装进SecurityContext
        SecurityContextHolder.getContext().setAuthentication(authenticate);
        return "index";
    }
}

在LoginController中核心代码即注释1处的认证,AuthenticationManager的authenticate()方法即认证的关键,(此处需要注入一个AuthenticationManager对象,该对象的创建会在接下来的内容中提及)该方法需要传入一个Authentication对象,而
UsernamePasswordAuthenticationToken则是Authentication接口的典型实现类。只需要创建该对象接口。


UsernamePasswordAuthenticationToken的构造器有两个参数,第一个参数是从表单获取的username,第二个参数则是从表单获取的password。

定义登录页面:

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>登录</title>
</head>
<body>
   <form th:action="@{/auth/login}" method="post">
       <!--显示错误提示-->
       <span th:text="${msg}"></span>
       <div>
           <label>用户名:</label>
           <input type="text" name="username" placeholder="请输入用户名"/>
       </div>
       <div>
           <label>密    码:</label>
           <input type="password" name="password" placeholder="请输入密码"/>
       </div>
       <div>
           <input type="submit" value="登录">
       </div>
   </form>
</body>
</html>

最后,还需要对SpringSecurity进行一些配置,并且该配置类需要继承
WebSecurityConfigurerAdapter,代码如下:

public class WebSecurityConfiguration extends WebSecurityConfigurerAdapter {
    //因为SpringSecurity在认证过程中需要对明文密码进行加密
    //因此需要指定加密工具类,SpringSecurity推荐使用BCryptPasswordEncoder
    @Bean
    public PasswordEncoder passwordEncoder(){
        return new BCryptPasswordEncoder();
    }
    //创建AuthenticationManager对象,供LoginController使用。
    @Bean
    @Override
    public AuthenticationManager authenticationManagerBean() throws Exception {
        return super.authenticationManagerBean();
    }
    @Autowired
    private UserService userService;
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        //设置请求鉴权
        http.authorizeHttpRequests()
                //设置不需要鉴权的页面和路径
                .antMatchers("/myLogin.html","/login","/auth/login")
                .permitAll()
                //除上述配置的页面以外请求需要鉴权
                .anyRequest().authenticated()
                .and();
    }
}

至此,已经完成了SpringSecurity的登录流程。

5.2.4 授权

所谓的授权就是授予某个用户或者角色一定的权限,映射到开发中即可理解为用户是否具有某个功能,例如,在公司员工管理系统中,管理者可以查看所有员工的信息,但是员工只能查看自己的信息不能查看其他人的信息,这就属于权限。也就是说只有用户具备了某项权限,才有权对相应的工作进行操作。但是这样做有个弊端:不利于项目的维护,如果某个用户新增或者减少了若干项权限,那么就需要在数据库中为用户添加或者删除若干条权限数据。如果有多个用户的权限相同,那么修改这些用户的权限将是一项繁琐的工作,因此引入了角色的概念,假如多个用户具有相同的权限,直接将这些用户设置为同一角色即可,如果要修改用户的权限,只需要修改用户对应的角色便批量修改了用户的权限。这便是RBAC模型(Role-Based Access Control:基于角色的访问控制)。

在RBAC模型里面,有3个基础组成部分,分别是:用户、角色和权限。RBAC通过定义角色的权限,并对用户授予某个角色从而来控制用户的权限,实现了用户和权限的逻辑分离,极大地方便了权限的管理。RBAC模型通常需要定义5张表:

  1. User(用户表):用于保存用的基本信息,用户名密码等
  2. Role(角色表):用于保存具有不同权限的角色
  3. Permission/Menu(权限/菜单表):角色所具备的访问权限
  4. 用户-角色:用于保存用户与角色之间的映射关系
  5. 角色-权限:用于保存用户与权限之间的映射关系

这5张表关系如下图:

在了解了RCAB基本模型以后,继续使用上一小节中的数据库,除User以外的实体类代码如下:

@Data
@Entity
@Table(name = "role")
public class Role {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Integer roleId;
    @Column(name = "role_name",columnDefinition = "varchar(30)",length = 30)
    private String roleName;
    @Column(name = "role_description",columnDefinition = "varchar(30)",length = 30)
    private String roleDescription;
    @ManyToMany(fetch = FetchType.EAGER)
    @JoinTable(name = "role_menu",joinColumns = @JoinColumn(name = "role_id"),inverseJoinColumns = @JoinColumn(name = "menu_id"))
    private List<Menu> menus;
}
@Data
@Entity
@Table(name = "menu")
public class Menu {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Integer menuId;
    @Column(name = "menu_name",columnDefinition = "varchar(30)",length = 30)
    private String menuName;
    @Column(name = "permission",columnDefinition = "varchar(30)",length = 30)
    private String permission;
    @Column(name = "menu_url",columnDefinition = "varchar(30)",length = 30)
    private String menuUrl;
}
@Data
@Entity
@Table(name = "user_role")
public class UserRole {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Integer userRoleId;
    @Column(name = "user_id",columnDefinition = "int")
    private Integer userId;
    @Column(name = "role_id",columnDefinition = "int")
    private Integer roleId;
}
@Data
@Entity
@Table(name = "role_menu")
public class RoleMenu {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Integer roleMenuId;
    @Column(name = "role_id",columnDefinition = "int")
    private String roleId;
    @Column(name = "menu_id",columnDefinition = "int")
    private String menuId;
}

在上一小节中通过SpringSecurity的认证流程中在UserDetailsService可以为用户设置权限,在上一小节中我们返回
org.springframework.security.core.userdetails.User对象时,并没有为用户设置权限,下面通过重新该方法为用户设置权限。

@Slf4j
@Service
public class UserService implements UserDetailsService {
    @Autowired
    private UserRepository userRepository;
    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        User user = userRepository.findUserByUsername(username);
        log.info(username);
        if(user==null){
            throw new UsernameNotFoundException("用户不存在");
        }
        //①
        //创建Set保存权限,有可能多个角色具有相同的权限,利用set去重
        Set<GrantedAuthority> authorities = new HashSet<>();
        //获取用户对应的角色
        List<Role> roles =  user.getRoles();
        //遍历角色
        roles.forEach((role)->{
            //获取角色中封装的权限
            List<Menu> menus = role.getMenus();
            menus.forEach(menu -> {
                //SimpleGrantedAuthority是GrantedAuthority的实现类
                authorities.add(new SimpleGrantedAuthority(menu.getPermission()));
            });
        });
        return new org.springframework.security.core.userdetails.User
                (user.getUsername(),user.getPassword(),authorities);
    }
}

在上面的代码中重数据库查询出了数据用户角色,并获取了每个角色对应的权限,从源码可以看出
org.springframework.security.core.userdetails.User对象的第三个参数是Collection<?
extends GrantedAuthority> authorities,可以将用户所有的权限放置在Set结合中,Set中保存的类型可以使用

GrantedAuthority的实现类SimpleGrantedAuthority,在创建SimpleGrantedAuthority对象时构造器中传入权限字符串即可。

在编写完上述代码后还需要最后一步,即开启使用注解配置权限,在SpringSecurity配置类上添加如下注解即可。

//开启注解配置权限
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class WebSecurityConfiguration extends WebSecurityConfigurerAdapter {}

接下来,新建Restful接口,并标注注解:

@Controller
public class IndexController {
    @GetMapping("/hello")
    @ResponseBody
    @PreAuthorize("hasAuthority('sys:produce:manage')")
    public String hello(){
        return "hello";
    }
}

可以看出在hello()方法上标注了@PreAuthorize注解,该注解中使用表达式hasAuthority('sys:produce:manage')判断当前登录的用户是否有权限访问该方法,如果有权限则可以返回字符串hello,如果不具备该权限,则会返回403状态码。如下图所示:

5.2.5 授权表达式

除了上述示例中的hasAuthority()表达式,SpringSecurity还提供了其他丰富的权限验证表达式。下面列举SpringSecurity常用的内置表达式:

  • premitAll:任何人都允许访问
  • denyAll:指定任何人都不允许访问
  • anonymous:指定匿名命运允许访问
  • remember:指定已记住的用户允许访问
  • authenticated:指定任何经过身份验证的用户都允许访问,不包含anonymous
  • fullyAuthenticated:指定有经过身份验证的用户允许访问,不包含anonymous和remember
  • hasRole():指定需要特定的角色访问用户允许方法,会自动在角色前加入前缀“ROLE_”
  • hashAnyRole([role1,role2]):指定需要任意一个角色的用允许访问,会自动在角色前加入前缀“ROLE_”
  • hasAuthority():指定需要特定权限的用户允许访问
  • hasAnyAuthority():指定需要任意一个权限的用户访问
  • hasIpAddress(ip):指定需要特定的IP地址可以访问。

5.2.5 认证失败处理

在前面章节中的示例中可以看出,当用户认证失败时,页面直接跳转到登录页面,但是页面中并没有相应的提示信息。这样是很糟糕的体验,如果需要认证失败时返回响应的错误提示信息,需要继AuthenticationEntryPoint接口,并重写commence()方法,该接口源码比较简单,代码如下:

public interface AuthenticationEntryPoint {
    void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException;
}

新建handler包把并定义实现类,代码如下:

@Slf4j
@Component
public class LoginFailureHandle implements AuthenticationEntryPoint {
    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
        log.info("认证失败");
        //获取并封装封装进异常的错误信息
        String message = authException.getMessage();
        request.setAttribute("msg",message);
        //请求转发跳转页面
        request.getRequestDispatcher("/login").forward(request,response);
    }
}

并且在页面中添加显示错误信息的标签,代码如下:

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>登录</title>
</head>
<body>
   <form th:action="@{/auth/login}" method="post">
       //①显示错误信息
       <span th:text="${msg}"></span>
       <div>
           <label>用户名:</label>
           <input type="text" name="username" placeholder="请输入用户名"/>
       </div>
       <div>
           <label>密    码:</label>
           <input type="password" name="password" placeholder="请输入密码"/>
       </div>
       <div>
           <input type="submit" value="登录">
       </div>
   </form>
</body>
</html>

最后,还需要在SpringSecurity配置类中进行相应的配置,代码如下:

@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class WebSecurityConfiguration extends WebSecurityConfigurerAdapter {
    @Bean
    public PasswordEncoder passwordEncoder(){
        return new BCryptPasswordEncoder();
    }
    @Autowired
    private UserService userService;
    @Autowired
    private AuthenticationEntryPoint failureHandle;
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        //设置请求鉴权
        http.authorizeHttpRequests()
                //设置不需要鉴权的页面和路径
                .antMatchers("/login","/auth/login","/logout")
                .permitAll()
                //除上述配置的页面以外请求需要鉴权
                .anyRequest().authenticated()
                .and()
                .exceptionHandling()
                //配置认证失败处理器
                .authenticationEntryPoint(failureHandle)
                .accessDeniedHandler(accessDeniedHandler)
                .and();
    }
    @Bean
    @Override
    public AuthenticationManager authenticationManagerBean() throws Exception {
        return super.authenticationManagerBean();
    }
}

当用户名或者密码错误是,会返回到登录页面,并给出相应提示,效果如下图:

5.2.6 鉴权失败处理

SpringSecurity除了提供认证失败处理以外,也提供了鉴权失败处理的相关类,例如当用户访问自身不具备权限的资源时,SpringSecurity默认是返回403状态码,我们可以通过自定义的方式实现鉴权失败的处理。只需要继承AccessDeniedHandler接口并重写handle()方法即可,该接口源码如下:

public interface AccessDeniedHandler {
    void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException;
}

仍旧在handler包下新建实现类,代码如下:

/**
 * 权限异常自定义处理器
 */
@Component
public class AuthenticatedFailureHandler implements AccessDeniedHandler {
    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
        response.setContentType("application/json;charset=utf-8");
        response.getWriter().write("请暂无权限访问,请联系管理员");
    }
}

最后继续在SpringSecurity配置类中进行配置。

@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class WebSecurityConfiguration extends WebSecurityConfigurerAdapter {
    @Bean
    public PasswordEncoder passwordEncoder(){
        return new BCryptPasswordEncoder();
    }
    @Autowired
    private UserService userService;
    @Autowired
    private AuthenticationEntryPoint failureHandle;
    @Autowired
    private AuthenticationFailureHandler authenticationFailureHandler;
    @Autowired
    private AccessDeniedHandler accessDeniedHandler;
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        //设置请求鉴权
        http.authorizeHttpRequests()
                //设置不需要鉴权的页面和路径
                .antMatchers("/login","/auth/login","/logout")
                .permitAll()
                //除上述配置的页面以外请求需要鉴权
                .anyRequest().authenticated()
                .and()
                .exceptionHandling()
                //配置认证失败处理器
                .authenticationEntryPoint(failureHandle)
                //配置鉴权失败处理器
                .accessDeniedHandler(accessDeniedHandler)
                .and();
    }
    @Bean
    @Override
    public AuthenticationManager authenticationManagerBean() throws Exception {
        return super.authenticationManagerBean();
    }
}

当用户访问不具备权限的资源时,会给出相应提示,效果如下图所示:

5.2.7 图片验证码

在日常开发中,验证码是常见的需求之一,在以往的开发模式中,使用工具类生成验证码后,当用户提交验证码时,在过滤器中进行验证,我们知道SpringSecurity的认证和鉴权过程其实就是依托一系列的过滤器来实现,因此,在JavaWeb中学习的验证码生成及验证流程在这里同样适用。

首先,在pom.xml中添加验证码生成依赖,这里将会使用Hutool工具包,生成验证码并对其进行验证。

<dependency>
    <groupId>cn.hutool</groupId>
    <artifactId>hutool-captcha</artifactId>
    <version>5.8.0.M3</version>
</dependency>

接着定义生成验证码的Controller。

@Slf4j
@Controller
public class ValidController {
    @GetMapping("/validCode")
    public void getValidCode(HttpServletResponse response) throws IOException {
        ICaptcha captcha = ValidCodeUtil.CAPTCHA;
        //生成验证码
        captcha.createCode();
        //设置图片不缓存
        response.setHeader("Pragma","no-cache");
        response.setHeader("Cache-Control","no-cache");
        //设置响应内容格式
        response.setContentType("image/png");
        OutputStream outputStream = response.getOutputStream();
        captcha.write(outputStream);
        outputStream.close();
    }
}
public class ValidCodeUtil {
    public static final ICaptcha CAPTCHA =  CaptchaUtil.createLineCaptcha(100, 25);
}

在定义了生成验证码以后,还需要在用户提交验证码以后对验证码进行验证,这些可以放置在过滤器内。在SpringSecurity中如果要实现过滤器,继承OncePerRequestFilter接口,并实现doFilter()方法即可,代码如下:

@Component
public class ValidCodeFilter extends OncePerRequestFilter {
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        String url = request.getRequestURL().toString();
        //如果不是登录,则放行
        if(!url.contains("/auth/login")){
            filterChain.doFilter(request,response);
            return;
        }
        String validCode = request.getParameter("validCode");
        boolean verify = ValidCodeUtil.CAPTCHA.verify(validCode);
        if(!verify){
            request.setAttribute("msg","验证码错误!");
            request.getRequestDispatcher("/login").forward(request,response);
            return;
        }
        filterChain.doFilter(request,response);
    }
}

最后,还需要在SpringSecutiry中配置生成验证码资源放行及验证码过滤器。代码如下:

@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class WebSecurityConfiguration extends WebSecurityConfigurerAdapter {
    @Bean
    public PasswordEncoder passwordEncoder(){
        return new BCryptPasswordEncoder();
    }
    @Autowired
    private UserService userService;
    @Autowired
    private AuthenticationEntryPoint failureHandle;
    @Autowired
    private AuthenticationFailureHandler authenticationFailureHandler;
    @Autowired
    private AccessDeniedHandler accessDeniedHandler;
    @Autowired
    private ValidCodeFilter validCodeFilter;
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        //设置请求鉴权
        http.authorizeHttpRequests()
                //设置不需要鉴权的页面和路径
                .antMatchers("/login","/auth/login","/logout","/validCode")
                .permitAll()
                //除上述配置的页面以外请求需要认证
                .anyRequest().authenticated()
                .and()
                //在UsernamePasswordAuthenticationFilter过滤器验证用户名及密码前验证用户名
                .addFilterBefore(validCodeFilter, UsernamePasswordAuthenticationFilter.class)
                .exceptionHandling()
                //配置认证失败处理器
                .authenticationEntryPoint(failureHandle)
                //配置鉴权失败处理器
                .accessDeniedHandler(accessDeniedHandler)
                .and();

    }
    @Bean
    @Override
    public AuthenticationManager authenticationManagerBean() throws Exception {
        return super.authenticationManagerBean();
    }
}
控制面板
您好,欢迎到访网站!
  查看权限
网站分类
最新留言