Spring Security 入门(一):认证和原理分析
Spring Security
是一种基于Spring AOP
和Servlet Filter
的安全框架,其核心是一组过滤器链,实现 Web 请求和方法调用级别的用户鉴权和权限控制。本文将会介绍该安全框架的身份认证和退出登录的基本用法,并对其相关源码进行分析。
表单认证
Spring Security
提供了两种认证方式:HttpBasic 认证和 HttpForm 表单认证。HttpBasic 认证不需要我们编写登录页面,当浏览器请求 URL 需要认证才能访问时,页面会自动弹出一个登录窗口,要求用户输入用户名和密码进行认证。大多数情况下,我们还是通过编写登录页面进行 HttpForm 表单认证。
快速入门
☕️ 工程的整体目录
☕️ 在 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
登录页面要求身份认证:
输入正确的用户名和密码认证成功后,重定向到原始访问路径:
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
登录页面要求身份认证:
用户名输入 user,密码输入 123456,认证成功后重定向到原始访问路径:
访问localhost:8080/admin/hello
,访问受限,页面显示 403。
基本流程分析
Spring Security
采取过滤链实现认证与授权,只有当前过滤器通过,才能进入下一个过滤器:
绿色部分是认证过滤器,需要我们自己配置,可以配置多个认证过滤器。认证过滤器可以使用Spring Security
提供的认证过滤器,也可以自定义过滤器(例如:短信验证)。认证过滤器要在configure(HttpSecurity http)
方法中配置,没有配置不生效。下面会重点介绍以下三个过滤器:
-
UsernamePasswordAuthenticationFilter
过滤器:该过滤器会拦截前端提交的 POST 方式的登录表单请求,并进行身份认证。 -
ExceptionTranslationFilter
过滤器:该过滤器不需要我们配置,对于前端提交的请求会直接放行,捕获后续抛出的异常并进行处理(例如:权限访问限制)。 -
FilterSecurityInterceptor
过滤器:该过滤器是过滤器链的最后一个过滤器,根据资源权限配置来判断当前请求是否有权限访问对应的资源。如果访问受限会抛出相关异常,并由ExceptionTranslationFilter
过滤器进行捕获和处理。
认证流程
认证流程是在UsernamePasswordAuthenticationFilter
过滤器中处理的,具体流程如下所示: