Spring Security 入门(一):认证和原理分析

Spring Security是一种基于Spring AOPServlet Filter的安全框架,其核心是一组过滤器链,实现 Web 请求和方法调用级别的用户鉴权和权限控制。本文将会介绍该安全框架的身份认证和退出登录的基本用法,并对其相关源码进行分析。

表单认证

Spring Security提供了两种认证方式:HttpBasic 认证和 HttpForm 表单认证。HttpBasic 认证不需要我们编写登录页面,当浏览器请求 URL 需要认证才能访问时,页面会自动弹出一个登录窗口,要求用户输入用户名和密码进行认证。大多数情况下,我们还是通过编写登录页面进行 HttpForm 表单认证。

快速入门

☕️ 工程的整体目录

Spring Security 入门(一):认证和原理分析

☕️ 在 pom.xml 添加依赖

<parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>2.3.3.RELEASE</version>
    <relativePath/>
</parent>

<dependencies>
    <!-- spring web 依赖 -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>

    <!-- spring security 依赖 -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-security</artifactId>
    </dependency>

    <!-- thymeleaf 模板引擎 -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-thymeleaf</artifactId>
    </dependency>

    <!-- 热部署 -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-devtools</artifactId>
        <scope>runtime</scope>
        <optional>true</optional>
    </dependency>

    <!-- lombok -->
    <dependency>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
        <optional>true</optional>
    </dependency>
    
    <!-- 封装了一些常用的工具类 -->
    <dependency>
        <groupId>org.apache.commons</groupId>
        <artifactId>commons-lang3</artifactId>
        <version>3.10</version>
    </dependency>   

    <!-- 测试 -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
        <scope>test</scope>
        <exclusions>
            <exclusion>
                <groupId>org.junit.vintage</groupId>
                <artifactId>junit-vintage-engine</artifactId>
            </exclusion>
        </exclusions>
    </dependency>    
</dependencies>

☕️ 在 application.properties 添加配置

# 关闭 thymeleaf 缓存
spring.thymeleaf.cache=false

☕️ 编写 Controller 层

package com.example.contorller;

import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ResponseBody;

@Controller
public class HomeController {

    @GetMapping({"/", "/index"})
    @ResponseBody
    public String index() {   // 跳转到主页
        return "欢迎您登录!!!";
    }
}
package com.example.contorller;

import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;

@Controller
public class LoginController {

    @GetMapping("/login/page")
    public String loginPage() {  // 获取登录页面
        return "login";   
    }
}

☕️ 编写 login.html 页面

<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>登录</title>
</head>
<body>
    <h3>表单登录</h3>
    <form method="post" th:action="@{/login/form}">
        <input type="text" name="name" placeholder="用户名"><br>
        <input type="password" name="pwd" placeholder="密码"><br>
        <div th:if="${param.error}">
            <span th:text="${session.SPRING_SECURITY_LAST_EXCEPTION.message}" style="color:red">用户名或密码错误</span>
        </div>
        <button type="submit">登录</button>
    </form>
</body>
</html>

☕️ 编写安全配置类 SpringSecurityConfig

package com.example.config;

import org.springframework.context.annotation.Bean;
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.builders.WebSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;

@EnableWebSecurity       // 开启 MVC Security 安全配置
public class SpringSecurityConfig extends WebSecurityConfigurerAdapter {

    /**
     * 密码编码器,密码不能明文存储
     */
    @Bean
    public BCryptPasswordEncoder passwordEncoder() {
        // 使用 BCryptPasswordEncoder 密码编码器,该编码器会将随机产生的 salt 混入最终生成的密文中
        return new BCryptPasswordEncoder();
    }

    /**
     * 定制用户认证管理器来实现用户认证
     */
    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        // 采用内存存储方式,用户认证信息存储在内存中
        auth.inMemoryAuthentication()
                .withUser("admin").password(passwordEncoder()
                .encode("123456")).roles("ROLE_ADMIN");
    }

    /**
     * 定制基于 HTTP 请求的用户访问控制
     */
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        // 启动 form 表单登录
        http.formLogin()
                // 设置登录页面的访问路径,默认为 /login,GET 请求;该路径不设限访问
                .loginPage("/login/page")
                // 设置登录表单提交路径,默认为 loginPage() 设置的路径,POST 请求
                .loginProcessingUrl("/login/form")
                // 设置登录表单中的用户名参数,默认为 username
                .usernameParameter("name")
                // 设置登录表单中的密码参数,默认为 password
                .passwordParameter("pwd")
                // 认证成功处理,如果存在原始访问路径,则重定向到该路径;如果没有,则重定向 /index
                .defaultSuccessUrl("/index")
                // 认证失败处理,重定向到指定地址,默认为 loginPage() + ?error;该路径不设限访问
                .failureUrl("/login/page?error");

        // 开启基于 HTTP 请求访问控制
        http.authorizeRequests()
                // 以下访问不需要任何权限,任何人都可以访问
                .antMatchers("/login/page").permitAll()
                // 其它任何请求访问都需要先通过认证
                .anyRequest().authenticated();

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

    /**
     * 定制一些全局性的安全配置,例如:不拦截静态资源的访问
     */
    @Override
    public void configure(WebSecurity web) throws Exception {
        // 静态资源的访问不需要拦截,直接放行
        web.ignoring().antMatchers("/**/*.css", "/**/*.js", "/**/*.png", "/**/*.jpg", "/**/*.jpeg");
    }
}

上述的安全配置类继承了WebSecurityConfigurerAdapter抽象类,并重写了三个重载的 configure() 方法:

/**
 * 定制用户认证管理器来实现用户认证
 *  1. 提供用户认证所需信息(用户名、密码、当前用户的资源权)
 *  2. 可采用内存存储方式,也可能采用数据库方式
 */
void configure(AuthenticationManagerBuilder auth);

/**
 * 定制基于 HTTP 请求的用户访问控制
 *  1. 配置拦截的哪一些资源
 *  2. 配置资源所对应的角色权限
 *  3. 定义认证方式:HttpBasic、HttpForm
 *  4. 定制登录页面、登录请求地址、错误处理方式
 *  5. 自定义 Spring Security 过滤器等
 */
void configure(HttpSecurity http);

/**
 * 定制一些全局性的安全配置,例如:不拦截静态资源的访问
 */
void configure(WebSecurity web);

安全配置类需要使用 @EnableWebSecurity 注解修饰,该注解是一个组合注解,内部包含了 @Configuration 注解,所以安全配置类不需要添加 @Configuration 注解即可被 Spring 容器识别。具体定义如下:

@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.TYPE})
@Documented
@Import({WebSecurityConfiguration.class, SpringWebMvcImportSelector.class, OAuth2ImportSelector.class})
@EnableGlobalAuthentication
@Configuration
public @interface EnableWebSecurity {
    boolean debug() default false;
}

☕️ 测试

启动项目,访问localhost:8080,重定向到/login/page登录页面要求身份认证:

Spring Security 入门(一):认证和原理分析

输入正确的用户名和密码认证成功后,重定向到原始访问路径:

Spring Security 入门(一):认证和原理分析

UserDetailsService 和 UserDetails 接口

在实际开发中,Spring Security应该动态的从数据库中获取信息进行自定义身份认证,采用数据库方式进行身份认证一般需要实现两个核心接口 UserDetailsService 和 UserDetails。

⭐️ UserDetailService 接口

该接口只有一个方法 loadUserByUsername(),用于定义从数据库中获取指定用户信息的逻辑。如果未获取到用户信息,则需要手动抛出 UsernameNotFoundException 异常;如果获取到用户信息,则将该用户信息封装到 UserDetails 接口的实现类中并返回。

public interface UserDetailsService {
    // 输入参数 username 是前端传入的用户名
    UserDetails loadUserByUsername(String username) throws UsernameNotFoundException;
}

⭐️ UserDetails 接口

UserDetails 接口定义了用于描述用户信息的方法,具体定义如下:

public interface UserDetails extends Serializable {
    // 返回用户权限集合
    Collection<? extends GrantedAuthority> getAuthorities();

    // 返回用户的密码
    String getPassword();

    // 返回用户的用户名
    String getUsername();

    // 账户是否未过期(true 未过期, false 过期)
    boolean isAccountNonExpired();

    // 账户是否未锁定(true 未锁定, false 锁定)
    // 用户账户可能会被*,达到一定要求可恢复
    boolean isAccountNonLocked();

    // 密码是否未过期(true 未过期, false 过期)
    // 一些安全级别高的系统,可能要求 30 天更换一次密码
    boolean isCredentialsNonExpired();

    // 账户是否可用(true 可用, false 不可用)
    // 系统一般不会真正的删除用户信息,而是假删除,通过一个状态码标志用户是否被删除
    boolean isEnabled();
}

自定义用户认证

✏️ 数据库准备

DROP TABLE IF EXISTS `user`;
CREATE TABLE `user` (
	`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键',
	`username` varchar(50) NOT NULL COMMENT '用户名',
	`password` varchar(64) COMMENT '密码',
	`mobile` varchar(20) COMMENT '手机号',
	`enabled` tinyint NOT NULL DEFAULT '1' COMMENT '用户是否可用',
	`roles` text COMMENT '用户角色,多个角色之间用逗号隔开',
	PRIMARY KEY (`id`),
	KEY `index_username`(`username`),
	KEY `index_mobile`(`mobile`)
) COMMENT '用户表';
	
-- 密码明文都为 123456	
INSERT INTO `user` VALUES ('1', 'admin', '$2a$10$JNVWTh5Yq56kJtrCZkcDk.DL/L/i8g3KrTAshcHW3mFf8//lnfG56', '11111111111', '1', 'ROLE_ADMIN,ROLE_USER');
INSERT INTO `user` VALUES ('2', 'user', '$2a$10$JNVWTh5Yq56kJtrCZkcDk.DL/L/i8g3KrTAshcHW3mFf8//lnfG56', '22222222222', '1', 'ROLE_USER');

我们将用户信息和角色信息放在同一张表中,roles 字段设定为 text 类型,多个角色之间用逗号隔开。

✏️ 在 pom.xml 中添加依赖

<!-- mysql 驱动 -->
<dependency>
    <groupId>mysql</groupId>
    <artifactId>mysql-connector-java</artifactId>
    <scope>runtime</scope>
</dependency>

<!-- mybatis -->
<dependency>
    <groupId>org.mybatis.spring.boot</groupId>
    <artifactId>mybatis-spring-boot-starter</artifactId>
    <version>2.1.3</version>
</dependency>

✏️ 在 application.properties 中添加配置

# 配置数据库连接的基本信息
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
spring.datasource.url=jdbc:mysql://localhost:3306/spring_security_test?characterEncoding=utf-8&useSSL=false&serverTimezone=Hongkong
spring.datasource.username=root
spring.datasource.password=123456

# 开启自动驼峰命名规则(camel case)映射
mybatis.configuration.map-underscore-to-camel-case=true
# 配置 Mapper 映射文件位置
mybatis.mapper-locations=classpath*:/mapper/**/*.xml
# 别名包扫描路径,通过该属性可以给指定包中的类注册别名
mybatis.type-aliases-package=com.example.entity

✏️ 创建 User 实体类,实现 UserDetails 接口

package com.example.entity;

import lombok.Data;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import java.util.Collection;
import java.util.List;

@Data
public class User implements UserDetails {

    private Long id;   // 主键

    private String username;  // 用户名

    private String password;   // 密码
    
    private String mobile;    // 手机号

    private String roles;    // 用户角色,多个角色之间用逗号隔开

    private boolean enabled;  // 用户是否可用

    private List<GrantedAuthority> authorities;  // 用户权限集合

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {  // 返回用户权限集合
        return authorities;
    }

    @Override
    public boolean isAccountNonExpired() {  // 账户是否未过期
        return true;
    }

    @Override
    public boolean isAccountNonLocked() {  // 账户是否未锁定
        return true;
    }

    @Override
    public boolean isCredentialsNonExpired() {  // 密码是否未过期
        return true;
    }

    @Override
    public boolean isEnabled() {  // 账户是否可用
        return enabled;
    }

    @Override
    public boolean equals(Object obj) {  // equals() 方法一般要重写
        return obj instanceof User && this.username.equals(((User) obj).username);
    }

    @Override
    public int hashCode() {   // hashCode() 方法一般要重写
        return this.username.hashCode();
    }
}

✏️ 创建 UserMapper 接口

package com.example.mapper;

import com.example.entity.User;
import org.apache.ibatis.annotations.Select;

public interface UserMapper {
    @Select("select * from user where username = #{username}")
    User selectByUsername(String username);
}

Mapper 接口需要注册到 Spring 容器中,所以在启动类上添加 Mapper 的包扫描路径:

@SpringBootApplication
@MapperScan("com.example.mapper")
public class Application {

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

✏️ 创建 CustomUserDetailsService 类,实现 UserDetailsService 接口

package com.example.service;

import com.example.entity.User;
import com.example.mapper.UserMapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.authority.AuthorityUtils;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;

@Service
public class CustomUserDetailsService implements UserDetailsService {

    @Autowired
    private UserMapper userMapper;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        //(1) 从数据库尝试读取该用户
        User user = userMapper.selectByUsername(username);
        // 用户不存在,抛出异常
        if (user == null) {
            throw new UsernameNotFoundException("用户不存在");
        }

        //(2) 将数据库形式的 roles 解析为 UserDetails 的权限集合
        // AuthorityUtils.commaSeparatedStringToAuthorityList() 是 Spring Security 提供的方法,用于将逗号隔开的权限集字符串切割为可用权限对象列表
        user.setAuthorities(AuthorityUtils.commaSeparatedStringToAuthorityList(user.getRoles()));

        //(3) 返回 UserDetails 对象
        return user;
    }
}

✏️ 修改安全配置类 SpringSecurityConfig

@EnableWebSecurity       // 开启 MVC Security 安全配置
public class SpringSecurityConfig extends WebSecurityConfigurerAdapter {
    //...
    @Autowired
    private CustomUserDetailsService userDetailsService;
    //...
        
    /**
     * 定制用户认证管理器来实现用户认证
     */
    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        // 采用内存存储方式,用户认证信息存储在内存中
        // auth.inMemoryAuthentication()
        //        .withUser("admin").password(passwordEncoder()
        //        .encode("123456")).roles("ROLE_ADMIN");
        
        // 不再使用内存方式存储用户认证信息,而是动态从数据库中获取
        auth.userDetailsService(userDetailsService).passwordEncoder(passwordEncoder());
    }
    
    /**
     * 定制基于 HTTP 请求的用户访问控制
     */
    @Override
    protected void configure(HttpSecurity http) throws Exception {
		//...
        // 开启基于 HTTP 请求访问控制
        http.authorizeRequests()
                // 以下访问不需要任何权限,任何人都可以访问
                .antMatchers("/login/page").permitAll()
                // 以下访问需要 ROLE_ADMIN 权限
                .antMatchers("/admin/**").hasRole("ADMIN")
                // 以下访问需要 ROLE_USER 权限
                .antMatchers("/user/**").hasAuthority("ROLE_USER")
                // 其它任何请求访问都需要先通过认证
                .anyRequest().authenticated();
		//...
    }    
    //...
}

此处需要简单介绍下Spring Security的授权方式,在Spring Security中角色属于权限的一部分。对于角色ROLE_ADMIN的授权方式有两种:hasRole("ADMIN")hasAuthority("ROLE_ADMIN"),这两种方式是等价的。可能有人会疑惑,为什么在数据库中的角色名添加了ROLE_前缀,而 hasRole() 配置时不需要加ROLE_前缀,我们查看相关源码:

private static String hasRole(String role) {
    Assert.notNull(role, "role cannot be null");
    if (role.startsWith("ROLE_")) {
        throw new IllegalArgumentException("role should not start with 'ROLE_' since it is automatically inserted. Got '" + role + "'");
    } else {
        return "hasRole('ROLE_" + role + "')";
    }
}

由上可知,hasRole() 在判断权限时会自动在角色名前添加ROLE_前缀,所以配置时不需要添加ROLE_前缀,同时这也要求 UserDetails 对象的权限集合中存储的角色名要有ROLE_前缀。如果不希望匹配这个前缀,那么改为调用 hasAuthority() 方法即可。

✏️ 创建 AdminController 和 UserController

package com.example.contorller;

import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseBody;

@Controller
@RequestMapping("/admin")
public class AdminController {   // 只能拥有 ROLE_ADMIN 权限的用户访问

    @GetMapping("/hello")
    @ResponseBody
    public String hello() {   
        return "hello,admin!!!";
    }
}
package com.example.contorller;

import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseBody;

@Controller
@RequestMapping("/user")
public class UserController {  // 只能拥有 ROLE_USER 权限的用户访问

    @GetMapping("/hello")
    @ResponseBody
    public String hello() {
        return "hello,User!!!";
    }
}

✏️ 测试

访问localhost:8080/user/hello,重定向到/login/page登录页面要求身份认证:

Spring Security 入门(一):认证和原理分析

用户名输入 user,密码输入 123456,认证成功后重定向到原始访问路径:

Spring Security 入门(一):认证和原理分析

访问localhost:8080/admin/hello,访问受限,页面显示 403。


基本流程分析

Spring Security采取过滤链实现认证与授权,只有当前过滤器通过,才能进入下一个过滤器:

Spring Security 入门(一):认证和原理分析

绿色部分是认证过滤器,需要我们自己配置,可以配置多个认证过滤器。认证过滤器可以使用Spring Security提供的认证过滤器,也可以自定义过滤器(例如:短信验证)。认证过滤器要在configure(HttpSecurity http)方法中配置,没有配置不生效。下面会重点介绍以下三个过滤器:

  • UsernamePasswordAuthenticationFilter过滤器:该过滤器会拦截前端提交的 POST 方式的登录表单请求,并进行身份认证。

  • ExceptionTranslationFilter过滤器:该过滤器不需要我们配置,对于前端提交的请求会直接放行,捕获后续抛出的异常并进行处理(例如:权限访问限制)。

  • FilterSecurityInterceptor过滤器:该过滤器是过滤器链的最后一个过滤器,根据资源权限配置来判断当前请求是否有权限访问对应的资源。如果访问受限会抛出相关异常,并由ExceptionTranslationFilter过滤器进行捕获和处理。

认证流程

认证流程是在UsernamePasswordAuthenticationFilter过滤器中处理的,具体流程如下所示:

Spring Security 入门(一):认证和原理分析

上一篇:【MySql8.0安装时Authentication Method默认选中第一项后Navicat等工具连接不上问题处理】


下一篇:Spring Security认证流程分析--练气后期