认识
作用
保护应用程序URL,要求对应用程序的任何交互进行身份验证
只要项目里引入了 Spring Security 的依赖,它默认就会把整个网站的所有页面和接口都“锁起来”。任何人想访问,都必须先登录(证明自己是合法用户)。除了少数几个默认的端点(如 /login, /error),其他所有请求路径默认都需要认证。你可以通过配置来改变哪些路径需要认证,哪些可以公开访问。
程序启动时生成一个默认用户:user
为了方便你第一次启动项目就能登录测试,Spring Security 会自动创建一个临时账户,用户名就叫 user。只要你没有在配置文件(如 application.properties)中指定自己的用户名和密码,这个默认用户就会被创建。
生成一个默认的随机密码,并将此密码记录在控制台上
每次启动应用密码都会变,所以你必须在程序启动时,去 IDE 的控制台日志里找到它才能登录。
生成默认的登录表单,提供了基于表单的登录,以及注销流程
它自动给你做了一个简单的登录页面(你访问任何受保护的页面都会跳转到这个页面)和一个处理退出的功能。你不需要自己写这两个页面的 HTML 代码。它帮你把“用户输入用户名密码提交登录”和“用户点击退出”这一整套逻辑都做好了。你只需要用就行了。
对于Web请求,重定向到登录页面;对于服务请求,返回401未经授权
Web请求:比如你在浏览器里直接输入网站地址。如果没登录,它会跳转到登录页面,让你去登录。
服务请求:比如前端 JavaScript 用 Ajax 调用一个后端 API 接口。如果没登录,它不会返回一个 HTML 登录页面(因为前端程序看不懂),而是直接返回一个标准的 HTTP 401 状态码,告诉前端“你未经授权,请先认证”。
防止 CSRF 攻击(跨站请求伪造攻击) (默认开启)
CSRF 攻击的过程:
你登录了网银系统,网银系统给你颁发了令牌,令牌存储在浏览器的 cookie 中。
你没有关闭浏览器,在同一个浏览器中,你浏览了其他网站,黑客诱导你点击了某个链接。
这个链接自动向网银发起转账请求,只要请求路径是网银系统的路径,那么请求路径关联的 Cookie 就会自动提交,这是浏览器的默认行为。
因为网银收到了合法 Cookie,网银会认为这是合法的请求,完成向黑客账户的转账。
Spring Security 的做法:
它会为每个表单生成一个唯一的、随机的“令牌”(Token)。
当提交表单时,必须把这个令牌也一起提交上来。服务器会验证令牌是否正确。
原理:
Cookie:浏览器自动带,黑客能用
Spring Security 为表单生成的 Token:藏在表单里,每次不同,黑客拿不到(受同源策略保护)
提供了多种安全机制来防御会话相关的攻击 (HttpOnly 默认开启,Secure 视情况而定)
安全的 Cookie 标志:登录凭证 Cookie(如 JSESSIONID)默认带 HttpOnly 属性。Secure 属性不是无条件加上的,通常在请求本身是 HTTPS 时由 Servlet 容器加上,也可以通过 server.servlet.session.cookie.secure=true 强制开启。HttpOnly 能防止 JavaScript 窃取 Cookie,Secure 要求 Cookie 只能在 HTTPS 连接下传输。
写入 Strict-Transport-Security 以确保 HTTPS (非默认开启)
Spring Security 默认不会把 Strict-Transport-Security 写入响应头。开启之后,用户通过 HTTPS 成功访问网站一次,浏览器就会记住"此站点必须使用HTTPS"的规则,后续所有访问(包括HTTP链接)都会自动升级为HTTPS请求。
写入 X-Content-Type-Options 以处理嗅探攻击 (默认开启)
有些浏览器很“热心”,会主动猜测服务器返回内容的类型(比如一个文本文件,浏览器可能会猜它是 HTML 或 JavaScript)。这就有安全风险,攻击者可能利用这个特性执行恶意代码。
Spring Security 的做法:通过设置 X-Content-Type-Options: nosniff 这个响应头,直接命令浏览器:“别瞎猜!我说这个文件是什么类型,它就是什么类型。” 这样就关闭了浏览器的内容类型猜测行为。
写入 Cache-Control 头来保护经过身份验证的资源 (默认开启)
登录后的一些敏感页面可能被浏览器或中间代理服务器缓存下来。Spring Security 默认会对这些需要认证的请求加上 Cache-Control 头,告诉浏览器和缓存服务器“不要缓存这个页面的内容”。
写入 X-Frame-Options 以处理点击劫持攻击 (默认开启)
点击劫持就是攻击者把一个透明的、你已登录的网站(如邮箱)用 <iframe> 嵌入到他的恶意网页上,然后诱骗你去点击网页上的某个按钮(比如“抽大奖”),实际上你点的是邮箱里的“删除所有邮件”按钮。
你先正常登录了126(有Cookie)
你不关浏览器,点开黑客发给你的链接(比如抽奖活动)
黑客的网页里偷偷藏了一个透明的126 iframe
你点"抽奖"按钮,实际点的是iframe里126的"删除邮件"按钮
Spring Security 的做法:通过设置 X-Frame-Options: DENY 这个响应头,告诉浏览器:“不允许任何网站用 iframe 把我嵌入进去。”这样就防止了这种攻击。
底层原理
过滤器链
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 // 简化版的过滤器链顺序 HttpServletRequest ↓ → SecurityContextHolderFilter (恢复SecurityContext,作用:避免每次请求都重新认证) → WebAsyncManagerIntegrationFilter (集成异步管理,解决异步请求中如何访问SecurityContext) → HeaderWriterFilter (写入安全头,如HSTS、X-Frame-Options) → CorsFilter (处理跨域) → CsrfFilter (CSRF防护) → LogoutFilter (处理注销:如果请求是 /logout 则执行注销逻辑) → UsernamePasswordAuthenticationFilter (如果请求是 /login 则执行登录认证) → AuthorizationFilter (授权检查) → ... 其他自定义过滤器 ... → 你的业务Controller ↓ HttpServletResponse
注:Spring Security 6 中由 SecurityContextHolderFilter 负责恢复 SecurityContext,5.x 中对应的是 SecurityContextPersistenceFilter(6 中已弃用)。
一个过滤器负责一个安全任务,排队执行,像工厂的流水线一样,每个过滤器只负责自己的"工序"。
这种设计的好处:
单一职责:每个过滤器只做一件事,代码清晰
灵活组合:可以根据需要启用/禁用特定过滤器
可插拔:可以把自定义过滤器插入到链中任意位置
灵活控制顺序:过滤器的执行顺序很关键(比如先认证后授权)
新建一个springboot项目
依赖:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 <?xml version="1.0" encoding="UTF-8" ?> <project xmlns ="http://maven.apache.org/POM/4.0.0" xmlns:xsi ="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation ="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd" > <modelVersion > 4.0.0</modelVersion > <parent > <groupId > org.springframework.boot</groupId > <artifactId > spring-boot-starter-parent</artifactId > <version > 3.3.0</version > <relativePath /> </parent > <groupId > com.zzh</groupId > <artifactId > spring-security-demo</artifactId > <version > 0.0.1-SNAPSHOT</version > <name > spring-security-demo</name > <description > spring-security-demo</description > <url /> <licenses > <license /> </licenses > <developers > <developer /> </developers > <scm > <connection /> <developerConnection /> <tag /> <url /> </scm > <properties > <java.version > 17</java.version > </properties > <dependencies > <dependency > <groupId > org.springframework.boot</groupId > <artifactId > spring-boot-starter-security</artifactId > </dependency > <dependency > <groupId > org.springframework.boot</groupId > <artifactId > spring-boot-starter-thymeleaf</artifactId > </dependency > <dependency > <groupId > org.springframework.boot</groupId > <artifactId > spring-boot-starter-web</artifactId > </dependency > <dependency > <groupId > org.thymeleaf.extras</groupId > <artifactId > thymeleaf-extras-springsecurity6</artifactId > </dependency > <dependency > <groupId > org.springframework.boot</groupId > <artifactId > spring-boot-starter-test</artifactId > <scope > test</scope > </dependency > <dependency > <groupId > org.springframework.security</groupId > <artifactId > spring-security-test</artifactId > <scope > test</scope > </dependency > </dependencies > <build > <plugins > <plugin > <groupId > org.springframework.boot</groupId > <artifactId > spring-boot-maven-plugin</artifactId > </plugin > </plugins > </build > </project >
基于内存的用户认证
配置类
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 package com.zzh.config;import org.springframework.context.annotation.Bean;import org.springframework.context.annotation.Configuration;import org.springframework.security.core.userdetails.User;import org.springframework.security.core.userdetails.UserDetailsService;import org.springframework.security.provisioning.InMemoryUserDetailsManager;@Configuration public class WebSecurityConfig { @Bean public UserDetailsService userDetailsService () { InMemoryUserDetailsManager manager = new InMemoryUserDetailsManager (); manager.createUser( User .withDefaultPasswordEncoder() .username("user" ) .password("123456" ) .roles("USER" ) .build() ); return manager; } }
UserDetailsService 是 Spring Security 的"用户信息查询器",把它的实现类 InMemoryUserDetailsManager 注入到 IOC 容器中即可实现用户认证。
InMemoryUserDetailsManager 基于内存管理用户信息。
认证流程
一、启动阶段(准备用户数据)
加载配置:Spring Boot 启动时,安全自动配置检测到 classpath 中有 Spring Security,自动启用 @EnableWebSecurity 的效果,所以示例中注释掉这个注解也能启用安全框架。
创建用户服务:执行 userDetailsService() 方法,创建一个内存版的用户管理器(InMemoryUserDetailsManager)。
存储用户:使用 User.build() 创建用户对象(用户名、加密的密码、角色),并将其存入内存管理器的内部 Map 中(key 是用户名,value 是 UserDetails 对象)。
结果:框架现在知道有一个合法的用户 user,密码是 123456。
二、认证阶段(验证用户登录)
拦截请求:当访问受保护页面时,过滤器链发现用户未登录,将其重定向到登录页。
提交凭证:用户在登录页输入用户名和密码,点击登录。
验证:
查用户:认证管理器调用 InMemoryUserDetailsManager.loadUserByUsername("user"),根据用户名 user 从内存Map里查找对应的用户信息(UserDetails)。
比密码:认证器将用户提交的明文密码(123456)进行编码,然后与查出来的用户信息中存储的已编码密码进行比对。
成功/失败:
成功:密码一致,认证通过。用户信息被存入本次会话的安全上下文中(SecurityContext),然后跳转到最初想访问的页面。
失败:密码不一致,返回登录错误页。
核心代码
用户名密码认证过滤器UsernamePasswordAuthenticationFilter中的 attemptAuthentication 方法负责获取用户提交的用户名和密码,并进入认证流程,代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 @Override public Authentication attemptAuthentication (HttpServletRequest request, HttpServletResponse response) throws AuthenticationException { if (this .postOnly && !request.getMethod().equals("POST" )) { throw new AuthenticationServiceException ("Authentication method not supported: " + request.getMethod()); } String username = obtainUsername(request); username = (username != null ) ? username.trim() : "" ; String password = obtainPassword(request); password = (password != null ) ? password : "" ; UsernamePasswordAuthenticationToken authRequest = UsernamePasswordAuthenticationToken.unauthenticated(username, password); setDetails(request, authRequest); return this .getAuthenticationManager().authenticate(authRequest); }
InMemoryUserDetailsManager 中的 loadUserByUsername 方法根据用户名找到内存中存储的用户,代码如下:
1 2 3 4 5 6 7 8 public UserDetails loadUserByUsername (String username) throws UsernameNotFoundException { UserDetails user = (UserDetails)this .users.get(username.toLowerCase()); if (user == null ) { throw new UsernameNotFoundException (username); } else { return new User (user.getUsername(), user.getPassword(), user.isEnabled(), user.isAccountNonExpired(), user.isCredentialsNonExpired(), user.isAccountNonLocked(), user.getAuthorities()); } }
DaoAuthenticationProvider 中的 additionalAuthenticationChecks 方法拿内存中的密码和用户提交的密码对比,密码一致则认证成功,代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 protected void additionalAuthenticationChecks (UserDetails userDetails, UsernamePasswordAuthenticationToken authentication) throws AuthenticationException { if (authentication.getCredentials() == null ) { this .logger.debug("Failed to authenticate since no credentials provided" ); throw new BadCredentialsException (this .messages .getMessage("AbstractUserDetailsAuthenticationProvider.badCredentials" , "Bad credentials" )); } String presentedPassword = authentication.getCredentials().toString(); if (!this .passwordEncoder.get().matches(presentedPassword, userDetails.getPassword())) { this .logger.debug("Failed to authenticate since password does not match stored value" ); throw new BadCredentialsException (this .messages .getMessage("AbstractUserDetailsAuthenticationProvider.badCredentials" , "Bad credentials" )); } }
基于数据库的用户认证
环境
准备数据库表和数据
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 CREATE DATABASE `spring- security`;USE `spring- security`; CREATE TABLE `user `( `id` INT NOT NULL AUTO_INCREMENT PRIMARY KEY , `username` VARCHAR (50 ) DEFAULT NULL , `password` VARCHAR (500 ) DEFAULT NULL , `enabled` BOOLEAN NOT NULL ); CREATE UNIQUE INDEX `user_username_unique_index` ON `user `(`username`); INSERT INTO `user ` (`username`, `password`, `enabled`) VALUES ('admin' , '{bcrypt}$2a$10$.GyF8EJtw4xeX/MrYTp6xOA2Sz1gC.jj7Hv7v5o8j/B4.rvtlwvKW' , TRUE ), ('jack' , '{bcrypt}$2a$10$bsuU4Bxx9ks3jFmp5GKymOvZKGeXsgGJR.acmIqELyVRN3VjLQkIq' , TRUE ), ('lucy' , '{bcrypt}$2a$10$UUpDXTOLhFUEBCAcuHKQjOnPWKcCt4n893aMDInjVZveR1xWlsO9G' , TRUE );
引入依赖
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 <?xml version="1.0" encoding="UTF-8" ?> <project xmlns ="http://maven.apache.org/POM/4.0.0" xmlns:xsi ="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation ="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd" > <modelVersion > 4.0.0</modelVersion > <parent > <groupId > org.springframework.boot</groupId > <artifactId > spring-boot-starter-parent</artifactId > <version > 3.3.0</version > <relativePath /> </parent > <groupId > com.zzh</groupId > <artifactId > spring-security-demo</artifactId > <version > 0.0.1-SNAPSHOT</version > <name > spring-security-demo</name > <description > spring-security-demo</description > <properties > <java.version > 17</java.version > </properties > <repositories > <repository > <id > aliyun</id > <url > https://maven.aliyun.com/repository/public</url > <releases > <enabled > true</enabled > </releases > <snapshots > <enabled > false</enabled > </snapshots > </repository > </repositories > <dependencies > <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 > org.springframework.boot</groupId > <artifactId > spring-boot-starter-thymeleaf</artifactId > </dependency > <dependency > <groupId > org.thymeleaf.extras</groupId > <artifactId > thymeleaf-extras-springsecurity6</artifactId > </dependency > <dependency > <groupId > org.springframework.boot</groupId > <artifactId > spring-boot-starter-test</artifactId > <scope > test</scope > </dependency > <dependency > <groupId > org.springframework.security</groupId > <artifactId > spring-security-test</artifactId > <scope > test</scope > </dependency > <dependency > <groupId > com.mysql</groupId > <artifactId > mysql-connector-j</artifactId > <scope > runtime</scope > </dependency > <dependency > <groupId > com.baomidou</groupId > <artifactId > mybatis-plus-boot-starter</artifactId > <version > 3.5.2</version > </dependency > <dependency > <groupId > org.projectlombok</groupId > <artifactId > lombok</artifactId > <scope > annotationProcessor</scope > </dependency > </dependencies > <build > <plugins > <plugin > <groupId > org.springframework.boot</groupId > <artifactId > spring-boot-maven-plugin</artifactId > </plugin > </plugins > </build > </project >
配置
1 2 3 4 5 6 7 8 spring.datasource.driver-class-name =com.mysql.cj.jdbc.Driver spring.datasource.url =jdbc:mysql://localhost:3306/spring-security spring.datasource.username =root spring.datasource.password =123456 mybatis-plus.configuration.log-impl =org.apache.ibatis.logging.stdout.StdOutImpl
代码
pojo类
1 2 3 4 5 6 7 8 9 10 11 12 13 14 package com.zzh.pojo;import com.baomidou.mybatisplus.annotation.IdType;import com.baomidou.mybatisplus.annotation.TableId;import lombok.Data;@Data public class User { @TableId(value = "id", type = IdType.AUTO) private Integer id; private String username; private String password; private Boolean enabled; }
mapper
1 2 3 4 5 6 7 8 9 10 package com.zzh.mapper;import com.baomidou.mybatisplus.core.mapper.BaseMapper;import com.zzh.pojo.User;import org.apache.ibatis.annotations.Mapper;@Mapper public interface UserMapper extends BaseMapper <User> {}
service
1 2 3 4 5 6 7 8 package com.zzh.service;import com.baomidou.mybatisplus.extension.service.IService;import com.zzh.pojo.User;public interface UserService extends IService <User> {}
1 2 3 4 5 6 7 8 9 10 11 package com.zzh.service.impl;import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;import com.zzh.mapper.UserMapper;import com.zzh.pojo.User;import com.zzh.service.UserService;import org.springframework.stereotype.Service;@Service public class UserServiceImpl extends ServiceImpl <UserMapper, User> implements UserService {}
controller
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 package com.zzh.controller;import com.zzh.pojo.User;import com.zzh.service.UserService;import lombok.RequiredArgsConstructor;import org.springframework.web.bind.annotation.GetMapping;import org.springframework.web.bind.annotation.RequestMapping;import org.springframework.web.bind.annotation.RestController;import java.util.List;@RestController @RequiredArgsConstructor @RequestMapping("/user") public class UserController { private final UserService userService; @GetMapping("/list") public List<User> listUser () { return userService.list(); } }
启动类加上MapperScan注解
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 package com.zzh;import org.mybatis.spring.annotation.MapperScan;import org.springframework.boot.SpringApplication;import org.springframework.boot.autoconfigure.SpringBootApplication;@SpringBootApplication @MapperScan("com.zzh.mapper") public class SpringSecurityDemoApplication { public static void main (String[] args) { SpringApplication.run(SpringSecurityDemoApplication.class, args); } }
基于数据库的用户认证
参考InMemoryUserDetailsManager实现DBUserDetailsManager
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 package com.zzh.config;import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;import com.zzh.mapper.UserMapper;import com.zzh.pojo.User;import jakarta.annotation.Resource;import org.springframework.security.core.GrantedAuthority;import org.springframework.security.core.userdetails.UserDetails;import org.springframework.security.core.userdetails.UserDetailsPasswordService;import org.springframework.security.core.userdetails.UsernameNotFoundException;import org.springframework.security.provisioning.UserDetailsManager;import java.util.ArrayList;import java.util.Collection;public class DBUserDetailsManager implements UserDetailsManager , UserDetailsPasswordService { @Override public UserDetails updatePassword (UserDetails user, String newPassword) { return null ; } @Override public void createUser (UserDetails user) { } @Override public void updateUser (UserDetails user) { } @Override public void deleteUser (String username) { } @Override public void changePassword (String oldPassword, String newPassword) { } @Override public boolean userExists (String username) { return false ; } @Resource private UserMapper userMapper; @Override public UserDetails loadUserByUsername (String username) throws UsernameNotFoundException { QueryWrapper<User> queryWrapper = new QueryWrapper <>(); queryWrapper.eq("username" , username); User user = userMapper.selectOne(queryWrapper); if (user == null ){ throw new UsernameNotFoundException (username); }else { Collection<GrantedAuthority> authorities = new ArrayList <>(); return new org .springframework.security.core.userdetails.User( user.getUsername(), user.getPassword(), user.getEnabled(), true , true , true , authorities ); } } }
加入IOC容器
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 package com.zzh.config;import org.springframework.context.annotation.Bean;import org.springframework.context.annotation.Configuration;import org.springframework.security.config.Customizer;import org.springframework.security.config.annotation.web.builders.HttpSecurity;import org.springframework.security.core.userdetails.User;import org.springframework.security.core.userdetails.UserDetailsService;import org.springframework.security.provisioning.InMemoryUserDetailsManager;import org.springframework.security.web.SecurityFilterChain;@Configuration public class WebSecurityConfig { @Bean public DBUserDetailsManager userDetailsService () { DBUserDetailsManager dbUserDetailsManager = new DBUserDetailsManager (); return dbUserDetailsManager; } }
测试:输入用户名 admin,密码 password,查看是否登录成功。
也可以直接在 DBUserDetailsManager 类上添加 @Component 注解,只要纳入 IoC 容器的管理即可。
过滤器链配置
默认的过滤器链配置
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 @Bean public SecurityFilterChain securityWebFilterChain (HttpSecurity http) throws Exception{ http.authorizeHttpRequests(authorizeHttpRequestsCustomizer -> { authorizeHttpRequestsCustomizer.anyRequest().authenticated(); }); http.csrf(csrf -> csrf.disable()); http.formLogin(Customizer.withDefaults()); http.httpBasic(Customizer.withDefaults()); return http.build(); }
默认情况下安全过滤器链上有 16 个过滤器,可以在 DefaultSecurityFilterChain 中查看,如下图所示:
实现用户添加
代码
关闭csrf攻击防御
1 2 3 4 http.csrf(csrf -> { csrf.disable(); });
controller类
1 2 3 4 @PostMapping("/add") public void add (@RequestBody User user) { userService.saveUserDetails(user); }
service类
1 2 3 4 5 6 7 8 9 10 11 12 13 @Resource private DBUserDetailsManager manager;@Override public void saveUserDetails (User user) { UserDetails userDetails = org.springframework.security.core.userdetails.User .withDefaultPasswordEncoder() .username(user.getUsername()) .password(user.getPassword()) .build(); manager.createUser(userDetails); }
过滤器配置
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 @Bean public SecurityFilterChain securityWebFilterChain (HttpSecurity http) throws Exception{ http.csrf(csrf -> csrf.disable()); return http.build(); }
测试
接口地址:http://localhost:8080/user/add
1 2 3 4 { "username" : "zenty" , "password" : "123456" }
密码加密
参考文档:https://docs.spring.io/spring-security/reference/features/authentication/password-storage.html
Spring Security 的 PasswordEncoder 是专门用于密码加密和验证的接口。它有多种实现类,不同的实现采用不同的密码哈希算法(如 BCrypt、PBKDF2 等),用于安全地存储和验证用户密码。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 public interface PasswordEncoder { String encode (CharSequence rawPassword) ; boolean matches (CharSequence rawPassword, String encodedPassword) ; default boolean upgradeEncoding (String encodedPassword) { return false ; } }
注册到项目中示例
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 import org.springframework.context.annotation.Bean;import org.springframework.context.annotation.Configuration;import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;import org.springframework.security.crypto.password.PasswordEncoder;@Configuration public class WebSecurityConfig { @Bean public PasswordEncoder passwordEncoder () { return new BCryptPasswordEncoder (10 ); } }
前后端分离认证
流程
用户提交登录
前端 POST 发送 JSON({"username": "admin", "password": "password"})到登录接口(如 /login)
认证过滤器拦截
UsernamePasswordAuthenticationFilter 拦截请求
将用户名密码封装为 UsernamePasswordAuthenticationToken
AuthenticationManager 认证
调用 UserDetailsService.loadUserByUsername(username)
使用 PasswordEncoder 校验密码
认证成功
生成 Authentication 对象(凭证对象)
存入 SecurityContextHolder
调用 AuthenticationSuccessHandler → 返回成功JSON
认证失败
调用 AuthenticationFailureHandler → 返回错误JSON
关键在两个回调:
认证成功自动调用:AuthenticationSuccessHandler
认证失败自动调用:AuthenticationFailureHandler
依赖
1 2 3 4 5 <dependency > <groupId > com.alibaba.fastjson2</groupId > <artifactId > fastjson2</artifactId > <version > 2.0.59</version > </dependency >
代码
登录功能
controller类
1 2 3 4 5 6 7 8 9 10 11 12 13 package com.zzh.controller;import org.springframework.stereotype.Controller;import org.springframework.web.bind.annotation.GetMapping;@Controller public class LoginController { @GetMapping("/login") public String login () { return "login" ; } }
登录页面login.html
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 <!DOCTYPE html > <html lang ="en" xml:ns ="https://www.thymeleaf.org" > <head > <meta charset ="UTF-8" > <title > 自定义登录页</title > </head > <body > <h1 > 自定义登录页</h1 > <form th:action ="@{/login}" method ="post" > 用户名:<input type ="text" name ="username" > <br > 密码:<input type ="password" name ="password" > <br > <input type ="submit" value ="登录" > </form > </body > </html >
进行跨域
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 @Bean public CorsConfigurationSource corsConfigurationSource () { CorsConfiguration config = new CorsConfiguration (); config.setAllowedOrigins(Arrays.asList("http://localhost:5173" )); config.setAllowedMethods(Arrays.asList("GET" , "POST" , "PUT" , "DELETE" , "OPTIONS" )); config.setAllowedHeaders(Arrays.asList("*" )); config.setAllowCredentials(true ); UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource (); source.registerCorsConfiguration("/**" , config); return source; }
回调配置
请求成功回调
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 public class MyAuthenticationSuccessHandler implements AuthenticationSuccessHandler { @Override public void onAuthenticationSuccess (HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException { Object principal = authentication.getPrincipal(); Map<String, Object> map = new HashMap <>(); map.put("code" , "1" ); map.put("msg" , "登录成功" ); map.put("data" , principal); String json = JSON.toJSONString(map); response.setContentType("application/json;charset=UTF-8" ); response.getWriter().print(json); } }
请求失败回调
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 public class MyAuthenticationFailureHandler implements AuthenticationFailureHandler { @Override public void onAuthenticationFailure (HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException { String localizedMessage = exception.getLocalizedMessage(); Map<String, Object> map = new HashMap <>(); map.put("code" , "-1" ); map.put("msg" , "用户名或密码错误" ); String json = JSON.toJSONString(map); response.setContentType("application/json;charset=UTF-8" ); response.getWriter().print(json); } }
注销回调
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 public class MyLogoutSuccessHandler implements LogoutSuccessHandler { @Override public void onLogoutSuccess (HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException { Map<String,Object> map = new HashMap <>(); map.put("code" , "0" ); map.put("msg" , "注销成功" ); String json = JSON.toJSONString(map); response.setContentType("application/json;charset=UTF-8" ); response.getWriter().print(json); } }
请求未认证的接口的回调
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 public class MyAuthenticationEntryPoint implements AuthenticationEntryPoint { @Override public void commence (HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException { String localizedMessage = authException.getLocalizedMessage(); Map<String,Object> map = new HashMap <>(); map.put("code" , -1 ); map.put("msg" , "没有登录" ); map.put("localizedMessage" , localizedMessage); String json = JSON.toJSONString(map); response.setContentType("application/json;charset=UTF-8" ); response.getWriter().print(json); } }
过滤器配置
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 @Bean public SecurityFilterChain securityWebFilterChain (HttpSecurity http) throws Exception{ http.authorizeHttpRequests(auth -> auth.anyRequest().authenticated() ); http.csrf(csrf -> csrf.disable()); http.exceptionHandling(exception -> { exception.authenticationEntryPoint(new MyAuthenticationEntryPoint ()); }); http.logout(logout -> { logout.logoutSuccessHandler(new MyLogoutSuccessHandler ()); }); http.formLogin(form->{ form.loginPage("/login" ).permitAll(); form.failureHandler(new MyAuthenticationFailureHandler ()); form.successHandler(new MyAuthenticationSuccessHandler ()); }); http.cors(cors -> cors.configurationSource(corsConfigurationSource())); return http.build(); }
获取用户信息
Spring Security 框架中,SecurityContextHolder、SecurityContext、Authentication、Principal 和 Credentials 是与身份验证和授权相关的核心概念。它们之间的关系如下:
SecurityContextHolder:安全上下文持有者,存储在 ThreadLocal 中,线程安全。
SecurityContext:安全上下文,包含并管理当前用户的 Authentication 对象。
Authentication:该对象在安全上下文中扮演两个角色:
在身份验证之前,它代表用户提交的登录请求,其中包含 Principal(通常是用户名)和 Credentials(通常是密码)。
在身份验证之后,它代表已认证的用户,此时 Principal 通常是一个包含更多详细信息的用户对象(如 UserDetails 实例),而 Credentials 出于安全原因会被清除(通常为 null)。此外,它还包含用户的权限信息(GrantedAuthority 集合)。
Principal:通过 Authentication 对象的 getPrincipal() 方法获取,代表主体的身份标识。认证成功后,它是一个能够识别用户的实体对象。
Credentials:代表证明主体身份的凭证,最常见的是密码。认证流程完成后,为了安全起见,框架通常会把这个凭证信息从 Authentication 对象中清除掉,调用 getCredentials() 方法通常返回 null。(密码可以被存储,可以被用于比较,不可用于返回前端)
GrantedAuthority:表示授予主体的权限。认证成功后,可以从 Authentication 对象的 getAuthorities() 方法获取。
controller 类
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 @RestController public class IndexController { @GetMapping("/") public Map<String, Object> index () { SecurityContext context = SecurityContextHolder.getContext(); Authentication authentication = context.getAuthentication(); String name = authentication.getName(); Object principal = authentication.getPrincipal(); Object credentials = authentication.getCredentials(); Collection<? extends GrantedAuthority > authorities = authentication.getAuthorities(); System.out.println(name); System.out.println(principal); System.out.println(credentials); System.out.println(authorities); Map<String, Object> map = new HashMap <>(); map.put("code" , 0 ); map.put("data" , name); return map; } }
测试的时候先访问/login登录页面,再请求http://localhost:8080/
授权
两种常见的权限控制方式
Spring Security 的授权管理可以实现两种常见的权限控制需求:
第一种:用户 → 权限 → 资源
这种方式比较直接,直接规定每个用户有哪些具体的操作权限。比如:
张三拥有“添加用户”和“查看用户列表”的权限
李四只有“查看用户列表”的权限
第二种:用户 → 角色 → 权限 → 资源
这种方式更加常用,通过角色来管理权限分组。比如:
张三的角色是“管理员”,拥有所有操作权限
李四的角色是“普通用户”,只能查看信息,不能修改
简单来说,第一种是直接给用户分配具体权限,第二种是先给角色分配权限,再把角色分配给用户,这样管理起来更方便。
这里提到的资源 是什么?资源就是权限控制所要保护的具体对象。它可以是:
一个URL地址
一个页面元素(如按钮、菜单)
一条数据或一个数据字段
授权策略
Spring Security中提供了两种授权策略:
方面
基于 Request 的授权
基于方法的授权
控制层面
全局层面(过滤器)
业务方法层面(AOP)
控制粒度
粗粒度(按URL分组控制)
细粒度(按具体操作控制)
好比
大楼保安
部门经理
常用场景
控制页面/菜单访问
控制具体业务操作、数据权限
两者通常结合使用:
先用基于 Request 的授权把守大门,阻止非法用户进入敏感区域。
再用基于方法的授权在内部做精细控制,确保用户只能执行被允许的具体操作。
基于 Request 授权的代码实现
用户-权限-资源
需求:
具有 USER_LIST 权限的用户可以访问 /user/list 接口
具有 USER_ADD 权限的用户可以访问 /user/add 接口
第一步:配置哪个权限可以访问哪个资源
先在 SecurityFilterChain 中配好规则:
1 2 3 4 5 6 7 8 9 10 11 12 http.authorizeHttpRequests( authorize -> authorize .requestMatchers("/user/list" ).hasAuthority("USER_LIST" ) .requestMatchers("/user/add" ).hasAuthority("USER_ADD" ) .anyRequest() .authenticated() )
第二步:授予哪个用户拥有哪些权限
在DBUserDetailsManager 中的 loadUserByUsername()方法上编写代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 @Override public UserDetails loadUserByUsername (String username) throws UsernameNotFoundException { QueryWrapper<User> queryWrapper = new QueryWrapper <>(); queryWrapper.eq("username" , username); User user = userMapper.selectOne(queryWrapper); if (user == null ){ throw new UsernameNotFoundException ("Username not found" ); } Collection<GrantedAuthority> authorities = new ArrayList <>(); authorities.add(() -> "USER_LIST" ); authorities.add(() -> "USER_ADD" ); return new org .springframework.security.core.userdetails.User( user.getUsername(), user.getPassword(), user.getEnabled(), true , true , true , authorities ); }
登录响应
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 { "msg" : "登录成功" , "code" : "1" , "data" : { "accountNonExpired" : true , "accountNonLocked" : true , "authorities" : [ { "authority" : "USER_ADD" } , { "authority" : "USER_LIST" } ] , "credentialsNonExpired" : true , "enabled" : true , "username" : "zenty" } }
去测试 /user/list 和 /user/add 接口是否可以访问。
编写权限不足的回调处理器
handler 类
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 package com.zzh.handler;import com.alibaba.fastjson2.JSON;import jakarta.servlet.ServletException;import jakarta.servlet.http.HttpServletRequest;import jakarta.servlet.http.HttpServletResponse;import org.springframework.security.access.AccessDeniedException;import org.springframework.security.web.access.AccessDeniedHandler;import java.io.IOException;import java.util.HashMap;import java.util.Map;public class MyAccessDeniedHandler implements AccessDeniedHandler { @Override public void handle (HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException { Map<String, Object> map = new HashMap <>(); map.put("code" , -1 ); map.put("msg" , "没有权限" ); String json = JSON.toJSONString(map); response.setContentType("application/json;charset=utf-8" ); response.getWriter().write(json); } }
过滤器配置
1 2 3 4 5 http.exceptionHandling(exception -> { exception.authenticationEntryPoint(new MyAuthenticationEntryPoint ()); exception.accessDeniedHandler(new MyAccessDeniedHandler ()); });
用户-角色-资源
需求:角色为 ADMIN 的用户才可以访问 /user/** 路径下的资源
配置哪个角色可以访问哪些资源
在 SecurityFilterChain中进行配置,先将之前的权限配置注释掉,进行角色配置:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 http.authorizeHttpRequests( authorize -> authorize .requestMatchers("/user/**" ).hasRole("ADMIN" ) .anyRequest() .authenticated() )
授予哪个用户拥有哪些角色
在实际开发中,用户对应的角色信息同样是存储到数据库表当中的,应该查询数据库,动态获取该用户拥有哪些角色。学习阶段就硬编码了。先把之前授予权限的代码注释掉,再编写以下代码授予角色:
在 DBUserDetailsManager 中的 loadUserByUsername 方法中编写代码:
1 2 3 4 5 6 7 8 9 10 return org.springframework.security.core.userdetails.User .withUsername(user.getUsername()) .password(user.getPassword()) .accountLocked(false ) .credentialsExpired(false ) .disabled(false ) .roles("ADMIN" ) .build();
访问 /login 接口结果
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 { "msg" : "登录成功" , "code" : "1" , "data" : { "accountNonExpired" : true , "accountNonLocked" : true , "authorities" : [ { "authority" : "ROLE_ADMIN" } ] , "credentialsNonExpired" : true , "enabled" : true , "username" : "zenty" } }
去测试 /user/list 和 /user/add 接口是否可以访问
用户-角色-权限-资源
RBAC(Role-Based Access Control,基于角色的访问控制)是一种常用的数据库设计方案,它将用户的权限分配和管理与角色相关联。以下是一个基本的 RBAC 数据库设计方案的示例:
用户表(User table):包含用户的基本信息,例如用户名、密码和其他身份验证信息。
列名
数据类型
描述
user_id
int
用户ID
username
varchar
用户名
password
varchar
密码
email
varchar
电子邮件地址
…
…
…
角色表(Role table):存储所有可能的角色及其描述。
列名
数据类型
描述
role_id
int
角色ID
role_name
varchar
角色名称
description
varchar
角色描述
…
…
…
权限表(Permission table):定义系统中所有可能的权限。
列名
数据类型
描述
permission_id
int
权限ID
permission_name
varchar
权限名称
description
varchar
权限描述
…
…
…
用户角色关联表(User-Role table):将用户与角色关联起来。
列名
数据类型
描述
user_role_id
int
用户角色关联ID
user_id
int
用户ID
role_id
int
角色ID
…
…
…
角色权限关联表(Role-Permission table):将角色与权限关联起来。
列名
数据类型
描述
role_permission_id
int
角色权限关联ID
role_id
int
角色ID
permission_id
int
权限ID
…
…
…
在这个设计方案中,用户可以被分配一个或多个角色,而每个角色又可以具有一个或多个权限。通过对用户角色关联和角色权限关联表进行操作,可以实现灵活的权限管理和访问控制。
它的好处是可以批量管理:不用给成百上千的用户一个个分配具体权限,只需要给几个角色分配好权限,再把用户放进对应角色里;修改权限时也只需修改角色,所有属于该角色的用户权限会自动更新。
基于方法授权的代码实现
开启方法授权
如果要开启细粒度的方法授权,需要在配置类 WebSecurityConfig 上添加如下注解:
去除之前的授权代码
1 2 3 4 5 http.authorizeHttpRequests(auth -> auth.requestMatchers("/login" ).permitAll() .anyRequest().authenticated() );
给用户授予角色
在 DBUserDetailsManager的 loadUserByUsername方法中授予角色:
1 2 3 4 5 6 7 8 return org.springframework.security.core.userdetails.User .withUsername(user.getUsername()) .password(user.getPassword()) .accountLocked(false ) .credentialsExpired(false ) .disabled(false ) .roles("ADMIN" ) .build();
使用授权注解标注要控制的方法,找到你要精确控制的方法,例如 UserController类中的方法:
1 2 3 4 5 6 7 8 9 10 11 12 13 @PreAuthorize("hasRole('ADMIN') and authentication.name == 'admin'") @GetMapping("/list") public List<User> listUser () { return userService.list(); } @PreAuthorize("hasRole('USER')") @PostMapping("/add") public void add (@RequestBody User user) { userService.saveUserDetails(user); }
测试:
第一步:使用 admin 用户登录,看看能不能访问 /user/list
第二步:使用 admin 用户登录,看看能不能访问 /user/add
给用户授予权限
在 DBUserDetailsManager的 loadUserByUsername方法中授予权限:
1 2 3 4 5 6 7 8 9 return org.springframework.security.core.userdetails.User .withUsername(user.getUsername()) .password(user.getPassword()) .accountLocked(false ) .credentialsExpired(false ) .disabled(false ) .authorities("USER_ADD" , "USER_EDIT" ) .build();
使用授权注解标注要控制的方法,找到你要精确控制的方法,例如 UserController类中的方法:
1 2 3 4 5 6 7 8 9 10 11 12 13 @PreAuthorize("hasAuthority('USER_LIST')") @GetMapping("/list") public List<User> listUser () { return userService.list(); } @PreAuthorize("hasAuthority('USER_ADD')") @PostMapping("/add") public void add (@RequestBody User user) { userService.saveUserDetails(user); }
测试:
第一步:使用任意用户登录,看看能不能访问 /user/list(预期是不能访问)
第二步:使用任意用户登录,看看能不能访问 /user/add(预期是能访问)
方法上的 SpEL 表达式
针对方法进行授权时,@PreAuthorize 注解的 value 属性值是一个 SpEL 表达式。Spring Security 中常用的表达式如下:
表达式
说明
hasRole('ROLE')
是否有指定角色
hasAnyRole('ROLE1','ROLE2')
是否有任一角色
hasAuthority('AUTH')
是否有指定权限
hasAnyAuthority('AUTH1','AUTH2')
是否有任一权限
authentication
当前认证对象
principal
当前用户主体
#argumentName
方法参数
@beanName.method()
调用Spring Bean方法
returnObject
返回值(PostAuthorize)
permitAll()
永远允许
denyAll()
永远拒绝
isAnonymous()
是否匿名用户
isAuthenticated()
是否已认证
isRememberMe()
是否记住我登录
isFullyAuthenticated()
是否完整认证(非记住我登录)