一、快速入门
- Spring Security 是一个提供身份验证(authentication)、授权(authorization)和抵御常见攻击(protection against common attacks)的框架
- authentication:认证,谁可以访问系统
- authorization:授权,谁可以访问系统中的哪些资源
- 抵御攻击:如CSRF
- 官方文档:https://docs.spring.io/spring-security/reference/6.5/index.html
1、初体验
-
创建Maven项目
security1 -
引入依赖
<?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 http://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion> <groupId>cn.freyfang</groupId> <artifactId>security1</artifactId> <version>1.0-SNAPSHOT</version> <properties> <maven.compiler.source>24</maven.compiler.source> <maven.compiler.target>24</maven.compiler.target> <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> </properties> <parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>3.5.9</version> </parent> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> </dependency> <!-- spring-boot-starter-security 3.5.9使用的spring-security版本是6.5.7 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> </dependency> </dependencies> <build> <plugins> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> </plugin> </plugins> </build> </project> -
创建测试接口
IndexControllerpackage cn.freyfang.controller; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RestController; @Controller public class IndexController { @GetMapping("/hello") @ResponseBody public String hello(){ return "hello world"; } } -
创建启动类
package cn.freyfang; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; @SpringBootApplication public class Main { public static void main(String[] args) { SpringApplication.run(Main.class, args); } } -
启动应用,会生成默认用户名
user和默认随机密码(打印在了控制台) -
测试
- Postman访问
http://localhost:8080/hello,返回状态码401 Unauthorized和响应头WWW-Authenticate: Basic realm="xxx",即需要进行 Basic 认证- Postman使用Http Basic认证方式,在请求头中携带用户名和密码信息,成功响应
hello world - Http Basic:Postman将用户名和密码组合成
username:password的格式,然后使用 Base64 编码后放入请求头的 Authorization 字段中
- Postman使用Http Basic认证方式,在请求头中携带用户名和密码信息,成功响应
- 浏览器访问
http://localhost:8080/hello,会重定向到默认生成的登录页面http://localhost:8080/login- 输入用户名密码登录,成功响应
hello world
- 输入用户名密码登录,成功响应
- Postman访问
-
创建配置文件
application.yml,指定用户名和密码spring: security: user: name: admin password: 123456
2、默认行为
- 访问任何端点都需要经过身份验证
- 在启动时生成一个默认用户及随机密码
- 采用
BCrypt等算法对密码存储进行保护 - 生成默认的登录和注销页面
- 提供基于表单的登录和注销流程
- 对基于表单的登录以及
HTTP Basic进行验证 - 提供内容协商功能:对于web请求,会重定向到登录页面;对于服务请求,则返回401未授权
- 处理跨站请求伪造(CSRF)攻击
- 处理会话劫持攻击
- 写入
Strict-Transport-Security以确保HTTPS - 写入
X-Content-Type-Options以处理嗅探攻击 - 写入
Cache Control头来保护经过身份验证的资源 - 写入
X-Frame-Options以处理点击劫持攻击 - 发布认证成功和失败事件
3、自动配置
-
spring-boot-autoconfigure-3.5.9.jar!\META-INF\spring\org.springframework.boot.autoconfigure.AutoConfiguration.imports文件中配置了SecurityAutoConfiguration自动配置类@AutoConfiguration( before = {UserDetailsServiceAutoConfiguration.class} // ) @ConditionalOnClass({DefaultAuthenticationEventPublisher.class}) @EnableConfigurationProperties({SecurityProperties.class}) // @Import({SpringBootWebSecurityConfiguration.class, SecurityDataConfiguration.class}) // public class SecurityAutoConfiguration { @Bean @ConditionalOnMissingBean({AuthenticationEventPublisher.class}) public DefaultAuthenticationEventPublisher authenticationEventPublisher(ApplicationEventPublisher publisher) { return new DefaultAuthenticationEventPublisher(publisher); } } -
SpringBootWebSecurityConfiguration注入了默认的SecurityFilterChain@Bean @Order(2147483642) SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception { http.authorizeHttpRequests((requests) -> ((AuthorizeHttpRequestsConfigurer.AuthorizedUrl)requests.anyRequest()).authenticated()); http.formLogin(Customizer.withDefaults()); // 开启表单认证 /* HTTP Basic 认证 是一种简单的认证方式,客户端通过在请求头中携带 Authorization: Basic base64(username:password) 来完成认证。 适用于前后端分离或 API 接口的简单认证场景 */ http.httpBasic(Customizer.withDefaults()); // 开启`HTTP Basic`认证 return (SecurityFilterChain)http.build(); } -
SecurityProperties中指定了默认用户名和默认密码@ConfigurationProperties("spring.security") public class SecurityProperties { public static final int BASIC_AUTH_ORDER = 2147483642; public static final int DEFAULT_FILTER_ORDER = -100; private final Filter filter = new Filter(); private final User user = new User(); // ... public static class Filter { // ... } public static class User { private String name = "user"; private String password = UUID.randomUUID().toString(); private List<String> roles = new ArrayList(); private boolean passwordGenerated = true; // ... } } -
UserDetailsServiceAutoConfiguration注入默认的UserDetailsService,即基于内存的InMemoryUserDetailsManager@AutoConfiguration @ConditionalOnClass(AuthenticationManager.class) @Conditional(MissingAlternativeOrUserPropertiesConfigured.class) @ConditionalOnBean(ObjectPostProcessor.class) @ConditionalOnMissingBean(value = { AuthenticationManager.class, AuthenticationProvider.class, UserDetailsService.class, AuthenticationManagerResolver.class }, type = "org.springframework.security.oauth2.jwt.JwtDecoder") // 当容器中不存在以上这些类的实例时,才会注入默认的基于内存的 inMemoryUserDetailsManager @ConditionalOnWebApplication(type = Type.SERVLET) public class UserDetailsServiceAutoConfiguration { private static final String NOOP_PASSWORD_PREFIX = "{noop}"; private static final Pattern PASSWORD_ALGORITHM_PATTERN = Pattern.compile("^\\{.+}.*$"); private static final Log logger = LogFactory.getLog(UserDetailsServiceAutoConfiguration.class); @Bean public InMemoryUserDetailsManager inMemoryUserDetailsManager(SecurityProperties properties, ObjectProvider<PasswordEncoder> passwordEncoder) { SecurityProperties.User user = properties.getUser(); List<String> roles = user.getRoles(); return new InMemoryUserDetailsManager(User.withUsername(user.getName()) .password(getOrDeducePassword(user, passwordEncoder.getIfAvailable())) .roles(StringUtils.toStringArray(roles)) .build()); } private String getOrDeducePassword(SecurityProperties.User user, PasswordEncoder encoder) { String password = user.getPassword(); if (user.isPasswordGenerated()) { logger.warn(String.format( "%n%nUsing generated security password: %s%n%nThis generated password is for development use only. " + "Your security configuration must be updated before running your application in " + "production.%n", user.getPassword())); } if (encoder != null || PASSWORD_ALGORITHM_PATTERN.matcher(password).matches()) { return password; } return NOOP_PASSWORD_PREFIX + password; // 密码前使用`{noop}`,即使用 NoOpPasswordEncoder 比对明文密码 } // ... }
4、核心组件
-
DelegatingFilterProxy
- 可以将 Servlet 容器中的 Filter 实例放在 Spring 容器中进行管理,方便按需加载
-
FilterChainProxy
- 负责管理 SecurityFilterChain, 通过 SecurityFilterChain 将请求转发给多个过滤器实例
-
SecurityFilterChain
- 根据当前请求来确定应调用哪些过滤器实例
-
DefaultSecurityFilterChain-
是 SecurityFilterChain 的实现类,程序启动后,查看默认加载了以下16个过滤器
DisableEncodeUrlFilter WebAsyncManagerIntegrationFilter SecurityContextHolderFilter HeaderWriterFilter CsrfFilter 处理Csrf LogoutFilter 注销 UsernamePasswordAuthenticationFilter 认证 DefaultResourcesFilter 生成默认css样式 DefaultLoginPageGeneratingFilter 生成默认登录页 DefaultLogoutPageGeneratingFilter 生成默认登出页 BasicAuthenticationFilter 处理Http Basic请求认证 RequestCacheAwareFilter SecurityContextHolderAwareRequestFilter AnonymousAuthenticationFilter ExceptionTranslationFilter 异常处理 AuthorizationFilter 授权
-
-
UserDetailsService- 认证过程中会通过
UserDetails loadUserByUsername(String username)方法从内存或DB中获取用户信息 - 实现类
InMemoryUserDetailsManager用来管理基于内存的用户信息 - 实际开发中需要自定义实现类,实现从DB用户表中获取用户信息
- 认证过程中会通过
-
UserDetails- 从内存或DB中获取的用户信息会封装成UserDetails对象
- 实现类
org.springframework.security.core.userdetails.User
-
PasswordEncoder
- 实现类
BCryptPasswordEncoder是官方推荐的密码器
- 实现类
-
SecurityContextHolder- 安全上下文管理器,用于存储已认证用户信息的地方
- 默认使用 ThreadLocal 来存储用户信息,所以同一线程中的方法始终可以访问到 SecurityContext
-
SecurityContext- 安全上下文接口,包含当前已认证用户的信息
-
Authentication
- 认证用户信息接口,常用实现类
UsernamePasswordAuthenticationToken - 两个作用
- 认证前,用于接收用户提交的用户名密码,并传给身份认证管理器 AuthenticationManager 进行认证
- 认证后,用于封装已认证用户的信息,并保存到 SecurityContext
- 包含的信息
- principal:用户主体,通常是 UserDetails 实例
- credentials:通常指密码,在用户认证成功后一般会被清除,防止泄露
- authorities:是 GrantedAuthority 的实例,表示用户被授予的权限
- 认证用户信息接口,常用实现类
-
AuthenticationManager
- 认证管理器接口,定义了执行身份认证的API
- 实现类
ProviderManager
-
AuthenticationProvider
- 认证提供者接口,由 ProviderManager 负责调用,执行特定类型的认证操作
- 实现类
DaoAuthenticationProvider支持基于用户密码的认证
-
AuthorizationManager
- 授权管理器接口
二、身份认证
1、基于内存的认证
-
创建配置类
SecurityConfig,注入InMemoryUserDetailsManagerpackage cn.freyfang.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.UserDetails; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.provisioning.InMemoryUserDetailsManager; @Configuration public class SecurityConfig { /** * 向容器中注入 UserDetailsService 实例 */ @Bean public UserDetailsService userDetailsService() { // 会对密码进行加密 User.UserBuilder users = User.withDefaultPasswordEncoder(); UserDetails user = users .username("user") .password("111111") .roles("USER") .build(); UserDetails admin = users .username("admin") .password("222222") .roles("USER", "ADMIN") .build(); // 向内存中存入两个用户 return new InMemoryUserDetailsManager(user, admin); } } -
使用
user或admin登录
2、认证流程
-
用户输入账号密码,点击登录
-
如果请求地址是
/login且请求方式是Post,则被UsernamePasswordAuthenticationFilter的doFilter()方法处理- 将用户输入的账号密码封装成
UsernamePasswordAuthenticationToken对象 - 调用认证管理器的实现类
ProviderManager的Authentication authenticate(Authentication authentication)方法认证 - 如果认证成功,将Authentication用户信息存入session和SecurityContext,并调用AuthenticationSuccessHandler
- 如果认证失败,清空SecurityContext中,并调用AuthenticationFailureHandler
- 将用户输入的账号密码封装成
-
ProviderManager 实际调用
DaoAuthenticationProvider进行认证- 调用
UserDetailsService某个实现类的UserDetails loadUserByUsername(String username)方法从内存或DB中加载用户信息 - 调用
additionalAuthenticationChecks(userDetails, (UsernamePasswordAuthenticationToken) authentication)方法校验密码,通过PasswordEncoder比较加载出来的用户密码和用户提交的密码是否一致 - 如果认证成功,将用户名密码和加载出来的权限信息封装为UsernamePasswordAuthenticationToken对象
- 调用
3、基于DB的认证
-
创建数据库
security及用户表user -
引入依赖
<dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> </dependency> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-spring-boot3-starter</artifactId> <version>3.5.11</version> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>8.0.23</version> </dependency> -
配置数据源
spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/security?useUnicode=true&characterEncoding=utf-8 username: root password: 123456 mybatis-plus: configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl logging: level: org.springframework.security: DEBUG -
创建实体类SysUser、UserMapper、UserService
@Data @TableName("user") public class SysUser { private Long id; private String username; private String password; } @Mapper public interface UserMapper extends BaseMapper<SysUser> { } /** * 模拟数据库操作 */ @Service public class UserService { public Set<String> selectPermissions(SysUser user) { // 如果是角色,需要添加`ROLE_`前缀,这是约定,用于区分角色和权限 return Set.of("index:hello", "ROLE_admin"); } } -
修改配置类,向容器中注册密码器
// 指定密码编码器 @Bean public PasswordEncoder passwordEncoder() { // 参数strength: 工作因子,默认值是10,最小值是4,最大值是31,值越大运算速度越慢,从而提高破解的门槛 return new BCryptPasswordEncoder(); } -
自定义
UserDetailsService的实现类UserDetailsServiceImpl,并将实例注入到容器中package cn.freyfang.service; import cn.freyfang.mapper.UserMapper; import cn.freyfang.model.SysUser; import com.baomidou.mybatisplus.core.toolkit.Wrappers; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.userdetails.User; 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; import java.util.ArrayList; import java.util.Collection; import java.util.Set; @Slf4j @Service public class UserDetailsServiceImpl implements UserDetailsService { @Autowired private UserMapper userMapper; @Autowired private UserService userService; @Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { // 从数据库中加载用户信息 SysUser user = userMapper.selectOne(Wrappers.lambdaQuery(SysUser.class).eq(SysUser::getUsername, username)); if (user == null) { throw new UsernameNotFoundException("user '" + username + "' not found"); } // 从数据库中加载权限信息 Set<String> permissions = userService.selectPermissions(user); Collection<GrantedAuthority> authorities = new ArrayList<>(); for (String permission : permissions) { authorities.add(() -> permission); } // 封装成 UserDetails return new User(user.getUsername(), user.getPassword(), true, // 是否启用 true, // 用户账号是否过期 true, // 用户凭证是否过期 true, // 用户是否未被锁定 authorities);// 权限列表 } } -
创建测试类
TestUser,新增测试用户package cn.freyfang; import cn.freyfang.mapper.UserMapper; import cn.freyfang.model.SysUser; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.security.crypto.password.PasswordEncoder; @SpringBootTest public class TestUser { @Autowired private UserMapper userMapper; @Autowired private PasswordEncoder passwordEncoder; @Test public void addUser() { SysUser user = new SysUser(); user.setUsername("admin"); user.setPassword(passwordEncoder.encode("123456")); System.out.println("user = " + user); int insert = userMapper.insert(user); System.out.println("insert = " + insert); } } -
启动应用,访问
http://localhost:8080/hello,输入数据库中的用户名和密码进行认证
4、常用配置
- 自定义登录页,开启后会自动禁用以下过滤器:DefaultResourcesFilter、DefaultLoginPageGeneratingFilter、DefaultLogoutPageGeneratingFilter、BasicAuthenticationFilter
- 自定义登录请求参数名
- 自定义登录成功/失败处理器、自定义退出成功处理器
- 获取用户信息
- 会话并发管理,开启后会自动启用以下过滤器:ConcurrentSessionFilter、SessionManagementFilter
测试
-
引入依赖
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-thymeleaf</artifactId> </dependency> -
创建登录页面
src/main/resources/templates/login.html<!DOCTYPE html> <html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="https://www.thymeleaf.org"> <head> <title>Please Log In</title> </head> <body> <h1>Please Log In</h1> <div th:if="${param.error}"> Invalid username and password. </div> <div th:if="${param.logout}"> You have been logged out. </div> <form th:action="@{/login}" method="post"> <div> <input type="text" name="name" placeholder="Username"/> </div> <div> <input type="password" name="pwd" placeholder="Password"/> </div> <input type="submit" value="Log in"/> </form> </body> </html> -
创建首页
src/main/resources/templates/index.html<!DOCTYPE html> <html lang="en" xmlns:th="https://www.thymeleaf.org"> <head> <meta charset="UTF-8"> <title>Title</title> </head> <body> <h1>首页</h1> <div th:text="'欢迎:'+${username}"></div> <form th:action="@{/logout}" method="post"> <input type="submit" value="退出"></input> </form> <a th:href="@{/hello}">hello</a> </body> </html> -
修改
IndexController,新增方法package cn.freyfang.controller; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Controller; import org.springframework.ui.Model; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.ResponseBody; @Controller public class IndexController { @GetMapping("/hello") @ResponseBody public String hello() { return "hello world"; } // 跳转到登录页面 @GetMapping("/to-login") public String toLogin() { return "login"; } // 用于 用户登录成功/失败 后转发,因为登录请求是post,所以转发的请求方式还是post @RequestMapping("/") public String index(Model model) { // 获取已认证用户信息 Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); String username = authentication.getName(); model.addAttribute("username", username); return "index"; } @PostMapping("/login-fail") @ResponseBody public String loginFail() { return "loginFail"; } } -
修改配置类,自定义
SecurityFilterChain实例@Bean SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http.authorizeHttpRequests(requests -> requests .requestMatchers("/to-login", "/login-fail").permitAll() // 允许匿名访问 .anyRequest().authenticated() // 对所有请求开启认证保护 ); // 认证 http.formLogin(form -> form .loginPage("/to-login") // 自定义登录页面 .usernameParameter("name") // 自定义登录用户名参数,默认是username .passwordParameter("pwd") // 自定义登录密码参数,默认是password .loginProcessingUrl("/login") // 登录处理url,默认是/login // 指定登录成功处理器 // 方式1:通过 ForwardAuthenticationSuccessHandler 实现登录成功后转发到指定地址 .successForwardUrl("/") // 方式2:自定义登录成功处理器,实现 AuthenticationSuccessHandler 接口 // .successHandler((request, response, authentication) -> { // System.out.println("=====================successHandler() 登录成功"); // }) // 指定登录失败处理器 // 方式1:通过 ForwardAuthenticationFailureHandler 实现登录失败后转发到指定地址 .failureForwardUrl("/login-fail") // 方式2:自定义登录失败处理器,实现 AuthenticationFailureHandler 接口 // .failureHandler((request, response, exception) -> { // String localizedMessage = exception.getLocalizedMessage(); // System.out.println("=====================failureHandler() 登录失败 " + localizedMessage); // }) ); // 注销 http.logout(logout -> logout.logoutUrl("/logout") // 注销url // 指定登出处理器 // 方式1:使用 SimpleUrlLogoutSuccessHandler 实现注销成功后重定向到指定页面 .logoutSuccessUrl("/to-login") // 方式2:自定义注销成功处理器,实现 LogoutSuccessHandler 接口 // .logoutSuccessHandler((request, response, authentication) -> { // System.out.println("=====================logoutSuccessHandler() 注销成功"); // }) ); // 会话管理 http.sessionManagement(session -> { session.maximumSessions(1) // 限制最大会话数,设置每个用户最多只能有一个活跃会话,后登录的账号会使先登录的账号失效 // 处理会话过期:当会话因并发登录被踢出时,自定义响应 .expiredSessionStrategy(event -> { System.out.println("=====================会话并发"); String result = new ObjectMapper().writeValueAsString(Map.of("code", 500, "msg", "该账号已从其他设备登录")); HttpServletResponse response = event.getResponse(); response.setContentType("application/json;charset=UTF-8"); response.getWriter().write(result); }); }); return http.build(); } -
访问
http://localhost:8080/,会重定向到/to-login,响应自定义登录页面 -
输入错误的用户名或密码,会转发到
/login-fail,响应loginFail -
输入正确的用户名或密码,会转发到
/,响应自定义首页 -
使用另一个浏览器登录相同的账号,然后当前浏览器访问
/hello,响应{"msg":"该账号已从其他设备登录","code":500}
5、记住我
-
两种实现方式
- 使用
TokenBasedRememberMeServices,即基于cookie保存token。如果Cookie被窃取,攻击者可以在有效期内伪造身份。后台无法主动使令牌失效(除非修改密码或等待过期) - 使用
PersistentTokenBasedRememberMeServices(推荐),即使用数据库持久化token。服务端维护令牌的状态,支持主动撤销令牌或删除记录。每次成功验证后,旧令牌会被替换为新令牌,防止令牌被重复使用
- 使用
1)步骤
-
创建表,用于持久化token
create table persistent_logins (username varchar(64) not null, series varchar(64) primary key, token varchar(64) not null, last_used timestamp not null) -
登录页面增加复选框
<div> 记住我:<input type="checkbox" name="remember"/> </div> -
修改配置类,注入多个组件
@Bean SecurityFilterChain securityFilterChain(HttpSecurity http , RememberMeServices rememberMeServices) throws Exception { // ...... // 记住我 http.rememberMe(rememberMe -> rememberMe .rememberMeServices(rememberMeServices) //注意:结合rememberMeServices使用时,以下参数不会生效,因为rememberMeServices会覆盖该参数 // .tokenValiditySeconds(60 * 60 * 24 * 20) // .rememberMeParameter("abc") ); return http.build(); } @Autowired private UserDetailsService userDetailsService; @Autowired private DataSource dataSource; @Bean public RememberMeServices rememberMeServices(PersistentTokenRepository tokenRepository) { PersistentTokenBasedRememberMeServices services = new PersistentTokenBasedRememberMeServices("123456", userDetailsService, tokenRepository); services.setTokenValiditySeconds(60 * 60 * 24 * 7); // 默认14天 services.setParameter("remember"); // 记住我参数,默认是`remember-me` return services; } @Bean public PersistentTokenRepository persistentTokenRepository() { // 用于持久化操作 JdbcTokenRepositoryImpl jdbcTokenRepository = new JdbcTokenRepositoryImpl(); jdbcTokenRepository.setDataSource(dataSource); return jdbcTokenRepository; } -
访问登录页面,勾选
记住我复选框,登录成功后,会自动向cookies和persistent_logins表中写入token -
关闭浏览器后重新打开访问,会自动进行认证,无需再输入用户名密码
2)流程
- 设置rememberMe配置后会启用
RememberMeAuthenticationFilter过滤器拦截请求 - 如果 SecurityContextHolder 中没有已认证用户信息,则执行自动登录
- 从请求头携带的cookie中解析
remember-me,并解码出series和token - 然后根据series从数据库中查找并返回
PersistentRememberMeToken。如果找不到则异常 - 比较cookie中的token和数据库中的token是否一致,如果不一致则异常,可能cookie已被窃取
- 判断token是否过期:数据库中的last_used + token有效时间,如果小于当前时间则异常
- 生成新的token,更新到cookie和数据库
- 使用UserDetailsService 查询用户信息和权限,封装成
RememberMeAuthenticationToken
- 从请求头携带的cookie中解析
- 通过 ProviderManager 认证管理器走认证流程
三、授权
- 基于角色或权限进行访问控制
- 常用方法
hasAuthority(String authority)表示访问资源需要authority权限hasAnyAuthority(String... authorities)需要authorities中的任一权限hasRole(String role)需要role角色hasAnyRole(String... roles)需要roles中的任一角色
1、步骤
1)创建资源
-
修改
IndexController,新增3个方法// 测试授权的三个方法 @GetMapping("/hey") @ResponseBody public String hey() { return "hey"; } @GetMapping("/hi") @ResponseBody public String hi() { return "hi"; } @GetMapping("/ok") @ResponseBody public String ok() { return "ok"; }
2)为资源设置所需权限
-
方式1:基于请求地址。修改配置类
@Bean SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http.authorizeHttpRequests(requests -> requests .requestMatchers("/to-login", "/login-fail").permitAll() // 允许匿名访问 // 为资源配置被访问时所需的权限:方式1 //具有 index:hey 权限的用户可以访问 /hello .requestMatchers("/hey").hasAuthority("index:hey") //具有 index:hi 权限的用户可以访问 /hi .requestMatchers("/hi").hasAuthority("index:hi") //具有 ROLE_admin 角色的用户可以访问 /ok .requestMatchers("/ok").hasRole("admin") // hasRole() 会自动添加ROLE_ 前缀 .anyRequest().authenticated() // 对所有请求开启认证保护 ); //...... return http.build(); } -
方式2:基于Controller方法
-
在配置类上使用
@EnableMethodSecurity开启方法授权 -
修改IndexController,添加鉴权注解。同时删除方式1的权限配置
// 测试授权的三个方法 @PreAuthorize("hasAuthority('index:hey')") @GetMapping("/hey") @ResponseBody public String hey() { return "hey"; } @PreAuthorize("hasAuthority('index:hi')") @GetMapping("/hi") @ResponseBody public String hi() { return "hi"; } @PreAuthorize("hasRole('admin')") @GetMapping("/ok") @ResponseBody public String ok() { return "ok"; } -
常用注解
- @PreAuthorize 适合进入方法前的权限验证
- @PostAuthorize 在方法执行后再进行权限验证,适合验证带有返回值的权限
- @PreFilter 进入方法之前对数据进行过滤
- @PostFilter 权限验证之后对数据进行过滤
-
3)为用户授权
-
用户登录时,从数据库中查询权限信息保存到Authentication对象中。修改
UserService,假如用户具有"index:hey"和"ROLE_admin"权限@Service public class UserService { public Set<String> selectPermissions(SysUser user) { // 如果是角色,需要添加`ROLE_`前缀,这是约定,用于区分角色和权限 return Set.of("index:hey", "ROLE_admin"); } }
4)请求未授权的接口
- 访问
/hey、/ok正常响应 - 访问
/hi,响应默认403 Forbidden页面
2、自定义异常处理
-
修改配置类的SecurityFilterChain实例
// 统一处理认证和授权失败 http.exceptionHandling(exception -> exception // 处理未认证情况:自定义 AuthenticationEntryPoint 覆盖默认处理 // .authenticationEntryPoint((request, response, authenticationException) -> { // System.out.println("=====================未认证"); // // 可以返回json或重定向到页面 // }) // 处理未授权情况:当用户访问无访问权限的资源时 // 方式1:通过 AccessDeniedHandlerImpl 实现重定向到指定页面 .accessDeniedPage("/unauthorized") // 方式2:自定义 AccessDeniedHandler 处理器 // .accessDeniedHandler((request, response, accessDeniedException) -> { // System.out.println("=====================未授权"); // accessDeniedException.printStackTrace(); // }) ); -
修改Controller,新增方法
// 响应未授权 @GetMapping("/unauthorized") @ResponseBody public String unauthorized() { return "未授权"; } -
访问
/hi,响应未授权
3、授权流程
- 授权过滤器 AuthorizationFilter 负责拦截用户请求,先调用授权管理器 RequestMatcherDelegatingAuthorizationManager 处理,它将不同请求委托给特定的授权管理器
- 如果访问无需认证的资源(如
/to-login),则由 SingleResultAuthorizationManager 处理,总是允许 - 如果访问只需认证就能访问的资源(如
/、/hello),则由 AuthenticatedAuthorizationManager 处理,判断当前用户是否已经过认证 - 如果访问需要授权才能访问的资源(如
/hey),则由 AuthorityAuthorizationManager 处理,检查当前已认证身份信息中是否包含所需的权限
- 如果访问无需认证的资源(如
- 如果使用基于方法的授权,即开启了@EnableMethodSecurity,则会import一个方法拦截器 AuthorizationManagerBeforeMethodInterceptor
- 先由 AuthorizationFilter 调用 AuthenticatedAuthorizationManager,判断当前用户是否已经过认证
- 再由 AuthorizationManagerBeforeMethodInterceptor 调用 PreAuthorizeAuthorizationManager,检查当前已认证身份信息中是否包含目标方法上
@PreAuthorize注解中要求的权限
四、前后端分离
1、JWT
- JWT(JSON Web Token)是一种开放标准,用于在各方之间安全地传输信息。它主要用于身份验证和信息交换
- 默认情况下JWT是未加密的,任何人都可以解读其内容,因此不要构建隐私信息字段
- JWT由三部分组成,用点(.)分隔
- Header(头部):包含令牌类型和签名算法,使用Base64Url编码
- Payload(载荷):包含声明信息(用户信息、权限等),使用Base64Url编码
- Signature(签名):用于验证令牌完整性,使用加密签名算法
- 如
HMACSHA256(base64UrlEncode(header) + "." + base64UrlEncode(payload),secret)
- 如
- 工作原理:用户登录 → 服务器生成JWT → 客户端存储 → 后续请求携带JWT → 服务器验证
- JWT特别适合RESTful API和微服务架构的身份认证场景
2、搭建基础环境
-
创建Maven项目
security2 -
引入依赖
<?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 http://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion> <groupId>cn.freyfang</groupId> <artifactId>security2</artifactId> <version>1.0-SNAPSHOT</version> <properties> <maven.compiler.source>24</maven.compiler.source> <maven.compiler.target>24</maven.compiler.target> <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> </properties> <parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>3.5.9</version> </parent> <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-test</artifactId> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt</artifactId> <version>0.9.1</version> </dependency> <!-- 解决jwt报错java.lang.ClassNotFoundException: javax.xml.bind.DatatypeConverter --> <dependency> <groupId>javax.xml.bind</groupId> <artifactId>jaxb-api</artifactId> <version>2.3.1</version> </dependency> <dependency> <groupId>com.alibaba.fastjson2</groupId> <artifactId>fastjson2</artifactId> <version>2.0.60</version> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency> </dependencies> <build> <plugins> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> </plugin> </plugins> </build> </project> -
定义常量类
Constantspackage cn.freyfang.contstant; import io.jsonwebtoken.Claims; public class Constants { public static final String UTF8 = "UTF-8"; public static final String TOKEN = "token"; public static final String TOKEN_PREFIX = "Bearer "; public static final String LOGIN_USER_KEY = "login_user_key"; public static final String LOGIN_TOKEN_KEY = "login_tokens:"; public static final String JWT_USERNAME = Claims.SUBJECT; // 自动识别json对象白名单配置(仅允许解析的包名,范围越小越安全) public static final String[] JSON_WHITELIST_STR = {"cn.freyfang"}; } -
创建配置类
RedisConfigpackage cn.freyfang.config; import cn.freyfang.contstant.Constants; import com.alibaba.fastjson2.JSON; import com.alibaba.fastjson2.JSONReader; import com.alibaba.fastjson2.JSONWriter; import com.alibaba.fastjson2.filter.Filter; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.data.redis.connection.RedisConnectionFactory; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.data.redis.serializer.RedisSerializer; import org.springframework.data.redis.serializer.SerializationException; import org.springframework.data.redis.serializer.StringRedisSerializer; import java.nio.charset.Charset; @Configuration public class RedisConfig { @Bean public RedisTemplate<Object, Object> redisTemplate(RedisConnectionFactory connectionFactory) { RedisTemplate<Object, Object> template = new RedisTemplate<>(); template.setConnectionFactory(connectionFactory); FastJson2JsonRedisSerializer serializer = new FastJson2JsonRedisSerializer(Object.class); // 使用StringRedisSerializer来序列化和反序列化redis的key值 template.setKeySerializer(new StringRedisSerializer()); template.setValueSerializer(serializer); // Hash的key也采用StringRedisSerializer的序列化方式 template.setHashKeySerializer(new StringRedisSerializer()); template.setHashValueSerializer(serializer); template.afterPropertiesSet(); return template; } /** * Redis使用FastJson序列化 */ private class FastJson2JsonRedisSerializer<T> implements RedisSerializer<T> { public static final Charset DEFAULT_CHARSET = Charset.forName(Constants.UTF8); static final Filter AUTO_TYPE_FILTER = JSONReader.autoTypeFilter(Constants.JSON_WHITELIST_STR); private Class<T> clazz; public FastJson2JsonRedisSerializer(Class<T> clazz) { super(); this.clazz = clazz; } @Override public byte[] serialize(T t) throws SerializationException { if (t == null) { return new byte[0]; } return JSON.toJSONString(t, JSONWriter.Feature.WriteClassName).getBytes(DEFAULT_CHARSET); } @Override public T deserialize(byte[] bytes) throws SerializationException { if (bytes == null || bytes.length <= 0) { return null; } String str = new String(bytes, DEFAULT_CHARSET); return JSON.parseObject(str, clazz, AUTO_TYPE_FILTER); } } } -
创建工具类
ResponseUtilpackage cn.freyfang.util; import cn.freyfang.contstant.Constants; import jakarta.servlet.http.HttpServletResponse; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import java.io.IOException; public class ResponseUtil { public static void out(HttpServletResponse response, String string) { response.setStatus(HttpStatus.OK.value()); response.setContentType(MediaType.APPLICATION_JSON_VALUE); response.setCharacterEncoding(Constants.UTF8); try { response.getWriter().print(string); } catch (IOException e) { throw new RuntimeException(e); } } } -
定义统一响应类
Rpackage cn.freyfang.model; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; @Data @AllArgsConstructor @NoArgsConstructor public class R { private Integer code; private String msg; private Object data; public static R ok() { return ok("操作成功"); } public static R ok(String msg) { return ok(msg, null); } public static R ok(Object data) { return ok("操作成功", data); } public static R ok(String msg, Object data) { return new R(200, msg, data); } public static R error() { return error("操作失败"); } public static R error(String msg) { return error(msg, null); } public static R error(String msg, Object data) { return new R(500, msg, data); } public static R error(Integer code, String msg) { return new R(code, msg, null); } } -
创建全局异常处理器
GlobalExceptionHandlerpackage cn.freyfang.exception; import cn.freyfang.model.R; import jakarta.servlet.http.HttpServletRequest; import lombok.extern.slf4j.Slf4j; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; @Slf4j @RestControllerAdvice public class GlobalExceptionHandler { @ExceptionHandler(RuntimeException.class) public R handleRuntimeException(RuntimeException e, HttpServletRequest request) { String requestURI = request.getRequestURI(); log.error("请求地址'{}',发生未知异常.", requestURI, e); return R.error(e.getMessage()); } @ExceptionHandler(Exception.class) public R handleException(Exception e, HttpServletRequest request) { String requestURI = request.getRequestURI(); log.error("请求地址'{}',发生系统异常.", requestURI, e); return R.error(e.getMessage()); } } -
创建实体类
SysUser映射数据库用户表package cn.freyfang.model; import com.fasterxml.jackson.annotation.JsonProperty; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; import java.io.Serializable; @Data @AllArgsConstructor @NoArgsConstructor public class SysUser implements Serializable { private static final long serialVersionUID = 1L; private Long userId; private String username; @JsonProperty(access = JsonProperty.Access.WRITE_ONLY) private String password; } -
创建
UserService模拟数据库操作package cn.freyfang.service; import cn.freyfang.model.SysUser; import org.springframework.stereotype.Service; import java.util.Set; /** * 模拟操作数据库 */ @Service public class UserService { /** * 根据用户名查询用户信息 */ public SysUser getUserByName(String username) { // 先将密码改为密文:passwordEncoder.encode("123456") return new SysUser(1L, "admin", "$2a$10$79iMOUW67OL6s1SaAsYgp.67.ivR34LT6h80uJNUvxEnnrgKhqVJq"); } /** * 获取用户权限 */ public Set<String> getPermissionsByUser(SysUser user) { return Set.of("index:hello", "ROLE_admin"); } /** * 获取用户角色 */ public Set<String> getRolesByUser(SysUser user) { return Set.of("ROLE_admin"); } }
3、创建核心配置
-
创建
UserDetails实现类LoginUserpackage cn.freyfang.model; import com.alibaba.fastjson2.annotation.JSONField; import lombok.Data; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.util.StringUtils; import java.util.ArrayList; import java.util.Collection; import java.util.Set; @Data public class LoginUser implements UserDetails { private static final long serialVersionUID = 1L; private Long userId; // 用户ID private Set<String> permissions; // 权限列表 private SysUser user; // 用户信息 private String token; private Long loginTime; // 登录时间 private Long expireTime; // 过期时间 public LoginUser(Long userId, SysUser user, Set<String> permissions) { this.userId = userId; this.user = user; this.permissions = permissions; } @JSONField(serialize = false) @Override public String getPassword() { return user.getPassword(); } @Override public String getUsername() { return user.getUsername(); } /** * 账户是否未过期,过期无法验证 */ @JSONField(serialize = false) @Override public boolean isAccountNonExpired() { return true; } /** * 指定用户是否解锁,锁定的用户无法进行身份验证 */ @JSONField(serialize = false) @Override public boolean isAccountNonLocked() { return true; } /** * 指示是否已过期的用户的凭据(密码),过期的凭据防止认证 */ @JSONField(serialize = false) @Override public boolean isCredentialsNonExpired() { return true; } /** * 是否可用 ,禁用的用户不能身份验证 */ @JSONField(serialize = false) @Override public boolean isEnabled() { return true; } @JSONField(serialize = false) @Override public Collection<? extends GrantedAuthority> getAuthorities() { Collection<GrantedAuthority> authorities = new ArrayList<>(); for (String permission : permissions) { if (StringUtils.hasLength(permission)) { SimpleGrantedAuthority authority = new SimpleGrantedAuthority(permission); authorities.add(authority); } } return authorities; } } -
创建
UserDetailsService实现类UserDetailsServiceImplpackage cn.freyfang.service; import cn.freyfang.model.LoginUser; import cn.freyfang.model.SysUser; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.core.userdetails.UsernameNotFoundException; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import java.util.Set; @Slf4j @Service public class UserDetailsServiceImpl implements UserDetailsService { @Autowired private PasswordEncoder passwordEncoder; @Autowired private UserService userService; @Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { // 查询用户信息 SysUser user = userService.getUserByName(username); if (user == null) { log.info("登录用户:{} 不存在.", username); throw new RuntimeException("用户名或密码错误"); } // 查询权限信息 Set<String> permissions = userService.getPermissionsByUser(user); return new LoginUser(user.getUserId(), user, permissions); } } -
创建
TokenUtilpackage cn.freyfang.util; import cn.freyfang.contstant.Constants; import cn.freyfang.model.LoginUser; import io.jsonwebtoken.Claims; import io.jsonwebtoken.Jwts; import io.jsonwebtoken.SignatureAlgorithm; import io.micrometer.common.util.StringUtils; import jakarta.servlet.http.HttpServletRequest; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.stereotype.Component; import java.util.HashMap; import java.util.Map; import java.util.UUID; import java.util.concurrent.TimeUnit; @Slf4j @Component public class TokenUtil { // 令牌自定义标识 @Value("${token.header:Authorization}") private String header; // 令牌秘钥 @Value("${token.secret:123456}") private String secret; // 令牌有效期(默认30分钟) @Value("${token.expireTime:30}") private int expireTime; private static final Long MILLIS_MINUTE_TWENTY = 20 * 60 * 1000L; @Autowired public RedisTemplate redisTemplate; /** * 获取用户身份信息 */ public LoginUser getLoginUser(HttpServletRequest request) { // 1、从请求头中获取token String token = request.getHeader(header); if (StringUtils.isNotEmpty(token) && token.startsWith(Constants.TOKEN_PREFIX)) { token = token.replace(Constants.TOKEN_PREFIX, ""); } // 2、从token中解析key,并根据key从redis中获取用户信息 if (StringUtils.isNotEmpty(token)) { try { Claims claims = Jwts.parser() .setSigningKey(secret) .parseClaimsJws(token) .getBody(); String uuid = (String) claims.get(Constants.LOGIN_USER_KEY); // String username = claims.getSubject(); String userKey = getTokenKey(uuid); LoginUser user = (LoginUser) redisTemplate.opsForValue().get(userKey); return user; } catch (Exception e) { log.error("获取用户信息异常'{}'", e.getMessage()); } } return null; } /** * 删除用户身份信息 */ public void delLoginUser(String token) { if (StringUtils.isNotEmpty(token)) { String userKey = getTokenKey(token); redisTemplate.delete(userKey); } } /** * 创建令牌 */ public String createToken(LoginUser loginUser) { String token = UUID.randomUUID().toString(); loginUser.setToken(token); refreshToken(loginUser); Map<String, Object> claims = new HashMap<>(); claims.put(Constants.LOGIN_USER_KEY, token); claims.put(Constants.JWT_USERNAME, loginUser.getUsername()); return Jwts.builder() .setClaims(claims) .signWith(SignatureAlgorithm.HS512, secret).compact(); } /** * 验证令牌有效期,相差不足20分钟,自动刷新缓存 */ public void verifyToken(LoginUser loginUser) { long expireTime = loginUser.getExpireTime(); long currentTime = System.currentTimeMillis(); if (expireTime - currentTime <= MILLIS_MINUTE_TWENTY) { refreshToken(loginUser); } } /** * 刷新令牌有效期 */ public void refreshToken(LoginUser loginUser) { loginUser.setLoginTime(System.currentTimeMillis()); loginUser.setExpireTime(loginUser.getLoginTime() + expireTime * 60 * 1000L); // 根据uuid将loginUser缓存到redis String userKey = getTokenKey(loginUser.getToken()); redisTemplate.opsForValue().set(userKey, loginUser, expireTime, TimeUnit.MINUTES); } private String getTokenKey(String uuid) { return Constants.LOGIN_TOKEN_KEY + uuid; } } -
创建
JwtAuthenticationTokenFilterpackage cn.freyfang.filter; import cn.freyfang.model.LoginUser; import cn.freyfang.util.TokenUtil; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; import org.springframework.stereotype.Component; import org.springframework.web.filter.OncePerRequestFilter; import java.io.IOException; @Component public class JwtAuthenticationTokenFilter extends OncePerRequestFilter { @Autowired private TokenUtil tokenUtil; @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException { // 1、从请求头中获取token,并从redis中获取用户信息 LoginUser loginUser = tokenUtil.getLoginUser(request); if (loginUser != null && SecurityContextHolder.getContext().getAuthentication() == null) { // 2、续期token tokenUtil.verifyToken(loginUser); // 3、将用户信息放入上下文中 UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(loginUser, null, loginUser.getAuthorities()); authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); SecurityContextHolder.getContext().setAuthentication(authenticationToken); } chain.doFilter(request, response); } } -
创建
IndexControllerpackage cn.freyfang.controller; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RestController; @RestController public class IndexController { @GetMapping("/") public String index() { return "欢迎"; } @PreAuthorize("hasAuthority('index:hello')") @GetMapping("/hello") public String hello() { return "hello"; } @PreAuthorize("hasAuthority('index:hi')") @GetMapping("/hi") public String hi() { return "hi"; } @PreAuthorize("hasRole('admin')") @GetMapping("/ok") public String ok() { return "ok"; } } -
创建
UserControllerpackage cn.freyfang.controller; import cn.freyfang.contstant.Constants; import cn.freyfang.model.LoginUser; import cn.freyfang.model.R; import cn.freyfang.model.SysUser; import cn.freyfang.service.UserService; import cn.freyfang.util.TokenUtil; import jakarta.annotation.Resource; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.authentication.BadCredentialsException; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RestController; import java.util.Map; import java.util.Set; @RestController public class UserController { @Resource private AuthenticationManager authenticationManager; @Autowired private TokenUtil tokenUtil; @Autowired private UserService userService; /** * 登录方法 */ @PostMapping("/login") public R login(@RequestBody SysUser loginBody) { String username = loginBody.getUsername(); String password = loginBody.getPassword(); // 用户验证 Authentication authentication = null; try { UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(username, password); // 该方法会去调用 UserDetailsServiceImpl.loadUserByUsername authentication = authenticationManager.authenticate(authenticationToken); } catch (Exception e) { if (e instanceof BadCredentialsException) { throw new RuntimeException("用户名或密码错误"); } else { throw new RuntimeException(e.getMessage()); } } LoginUser loginUser = (LoginUser) authentication.getPrincipal(); // 生成token 并写入redis String token = tokenUtil.createToken(loginUser); return R.ok(Map.of(Constants.TOKEN, token)); } /** * 获取用户信息 */ @GetMapping("getInfo") public R getInfo() { LoginUser loginUser = (LoginUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal(); SysUser user = loginUser.getUser(); // 从数据库中获取用户的最新角色和权限数据,并更新redis Set<String> roles = userService.getRolesByUser(user); Set<String> permissions = userService.getPermissionsByUser(user); if (!loginUser.getPermissions().equals(permissions)) { loginUser.setPermissions(permissions); tokenUtil.refreshToken(loginUser); } return R.ok(Map.of("user", user, "roles", roles, "permissions", permissions)); } } -
创建配置类
SecurityConfigpackage cn.freyfang.config; import cn.freyfang.filter.JwtAuthenticationTokenFilter; import cn.freyfang.model.LoginUser; import cn.freyfang.model.R; import cn.freyfang.util.ResponseUtil; import cn.freyfang.util.TokenUtil; import com.alibaba.fastjson2.JSON; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.http.HttpMethod; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; @Configuration @EnableMethodSecurity public class SecurityConfig { @Autowired private JwtAuthenticationTokenFilter jwtAuthenticationTokenFilter; @Autowired private TokenUtil tokenUtil; // 指定密码加密器 @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } // 注入认证管理器,在UserController登录请求中使用 @Bean public AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception { return authenticationConfiguration.getAuthenticationManager(); } @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { // 关闭csrf,因为不使用 session http.csrf(csrf -> csrf.disable()); // 认证 http.authorizeHttpRequests(request -> request .requestMatchers("/login").permitAll() // 登录接口无需认证 .requestMatchers(HttpMethod.GET, "/", "/*.html", "/**.html", "/**.css", "/**.js").permitAll() // 静态资源无需认证 .anyRequest().authenticated() // 除上面外的所有请求全部需要鉴权认证 ); // 异常 http.exceptionHandling(exception -> exception // 未认证处理类 .authenticationEntryPoint( (request, response, authException) -> { String msg = String.format("请求访问:%s,认证失败,无法访问系统资源", request.getRequestURI()); ResponseUtil.out(response, JSON.toJSONString(R.error(401, msg))); } ) // 未授权处理类 .accessDeniedHandler((request, response, accessDeniedException) -> { String msg = String.format("请求访问:%s,授权失败,无法访问系统资源", request.getRequestURI()); ResponseUtil.out(response, JSON.toJSONString(R.error(403, msg))); }) ); // 注销 http.logout(logout -> logout.logoutUrl("/logout") // 退出成功处理类 .logoutSuccessHandler((request, response, authentication) -> { LoginUser loginUser = tokenUtil.getLoginUser(request); if (loginUser != null) { // 删除redis缓存记录 tokenUtil.delLoginUser(loginUser.getToken()); } ResponseUtil.out(response, JSON.toJSONString(R.ok("退出成功"))); }) ); // 添加JWT filter http.addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class); return http.build(); } }
4、测试
-
启动应用,查看 DefaultSecurityFilterChain 发现共启用了11个过滤器
DisableEncodeUrlFilter WebAsyncManagerIntegrationFilter SecurityContextHolderFilter HeaderWriterFilter LogoutFilter JwtAuthenticationTokenFilter RequestCacheAwareFilter SecurityContextHolderAwareRequestFilter AnonymousAuthenticationFilter ExceptionTranslationFilter AuthorizationFilter- 禁用了
csrf相关过滤器:CsrfFilter - 没有启用
formLogin相关过滤器:UsernamePasswordAuthenticationFilter、DefaultResourcesFilter、DefaultLoginPageGeneratingFilter、DefaultLogoutPageGeneratingFilter - 没有启用
httpBasic相关过滤器:BasicAuthenticationFilter - 注册了自定义过滤器:JwtAuthenticationTokenFilter
- 禁用了
-
使用Postman等工具测试
-
请求
/login(该接口无需认证即可访问),携带请求体参数{"username":"admin","password":"123456"},正确返回JWT- 先使用 AuthenticationManager 进行认证
- 认证成功生成JWT,并将用户信息写入redis。注意:JWT中保存了redis中的key
-
以下请求都要携带请求头
Authorization=Bearer ${token} -
请求
/getInfo(该接口只需认证成功),正确响应用户信息- JwtAuthenticationTokenFilter 先进行处理:从header中解析JWT,然后从readis中取出用户信息,并放入 SecurityContextHolder
- AuthorizationFilter 再进行处理:如果 SecurityContextHolder 中有已认证的用户信息,则通过
- AuthorizationManagerBeforeMethodInterceptor 再判断已认证的用户信息中是否具有访问目标方法的权限
-
请求
/hello(该接口需要授权,且用户有权限):正确响应 -
请求
/hi(该接口需要授权,且用户无权限):抛出 AuthorizationDeniedException
五、OAuth2
-
OAuth 2.0是一种开放授权协议,核心目标是让第三方应用“安全地获取”用户在某个服务商(如微信、GitHub、Google)上的有限权限,而无需用户将账号密码直接告诉第三方应用。主要用于社交登录 -
相关角色
- 资源所有者(Resource Owner):指用户
- 客户应用(Client):这里指我们自己创建的应用,即第三方应用
- 资源服务器(Resource Server):某个服务商,如GitHub
- 授权服务器(Authorization Server):某个服务商,如GitHub
-
四种模式
- 授权码(authorization-code):安全,且支持刷新令牌
- 隐藏式(implicit):适合纯前端应用,授权服务器直接将令牌通过URL片段返回给浏览器。极不安全,且不支持刷新令牌,过期后需要重新授权
- 密码式(password):适合高度信任的应用间授权。用户将自己的账号密码直接交给客户应用,客户应用再去授权服务器换令牌
- 客户端凭证(client credentials):适合服务间授权。该模式与用户无关,是客户应用需要访问自己创建的资源
1、授权码模式
-
最常用、最安全、功能最完整的模式,也是 OAuth 2.0 官方推荐的流程
-
通过一个临时的、一次性的“授权码”来交换最终的访问令牌,从而避免了令牌在浏览器中暴露的风险
-
流程步骤
- 客户应用如需开通某个社交登录功能,需要先在服务商网站上创建应用并获得 client_id 和 client_secret
- 用户访问客户应用的登录页面,选择社交登录方式(GitHub/QQ等)
- 客户应用重定向到授权服务器的授权端点,并携带自己的 client_id、请求的权限范围 scope、一个随机生成的 state(用于防止 CSRF 攻击)和一个回调地址 redirect_uri
- 用户登录与同意:用户在授权服务器的页面上登录,并确认是否同意授予客户应用所请求的权限
- 发放授权码:如果用户同意,授权服务器会将用户重定向回在之前提供的 redirect_uri,并在 URL 参数中附上一个授权码
- 交换访问令牌:客户应用的后端服务拿着这个授权码,加上自己的 client_id、client_secret(只有自己和授权服务器知道),向授权服务器的令牌端点发起请求,换取访问令牌和可选的刷新令牌
- 使用访问令牌:客户应用的后端服务拿到访问令牌后,就可以用它来调用资源服务器的 API,获取用户数据了
2、GitHub社交登录
-
Spring Security 提供了对 OAuth2 客户端的原生支持,可以非常便捷地集成 GitHub 登录功能。其核心原理是:Spring Security 作为 OAuth2 客户端,而 GitHub 作为 OAuth2 授权服务器和资源服务器
-
登录GitHub,在
Settings-> Developer Settings-> OAuth Apps页面创建应用,设置回调地址http://localhost:8080/login/oauth2/code/github,获取Client ID、Client secrets -
创建Maven项目
security3 -
引入依赖
<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-oauth2-client</artifactId> </dependency> -
CommonOAuth2Provider中预定义了一些服务商的属性package org.springframework.security.config.oauth2.client; import org.springframework.security.oauth2.client.registration.ClientRegistration; import org.springframework.security.oauth2.core.AuthorizationGrantType; import org.springframework.security.oauth2.core.ClientAuthenticationMethod; public enum CommonOAuth2Provider { // GOOGLE // FACEBOOK // OKTA GITHUB { public ClientRegistration.Builder getBuilder(String registrationId) { ClientRegistration.Builder builder = this.getBuilder(registrationId, ClientAuthenticationMethod.CLIENT_SECRET_BASIC, "{baseUrl}/{action}/oauth2/code/{registrationId}");// 回调地址 builder.scope(new String[]{"read:user"}); builder.authorizationUri("https://github.com/login/oauth/authorize");// 授权地址 builder.tokenUri("https://github.com/login/oauth/access_token"); // 获取 access_token 地址 builder.userInfoUri("https://api.github.com/user"); // 获取用户信息地址 builder.userNameAttributeName("id"); // 将id作为用户名 builder.clientName("GitHub"); return builder; } } } -
创建配置文件
spring: security: oauth2: client: registration: github: client-id: xxx client-secret: xxx -
创建
IndexController接收返回信息@RestController public class IndexController { @GetMapping("/") public Map<String,Object> index( @RegisteredOAuth2AuthorizedClient OAuth2AuthorizedClient authorizedClient, @AuthenticationPrincipal OAuth2User oauth2User) { String principalName = authorizedClient.getPrincipalName(); System.out.println("principalName = " + principalName); ClientRegistration clientRegistration = authorizedClient.getClientRegistration(); System.out.println("clientRegistration.getClientName() = " + clientRegistration.getClientName()); System.out.println("clientRegistration.getRedirectUri() = " + clientRegistration.getRedirectUri()); System.out.println("oauth2User.getName() = " + oauth2User.getName()); System.out.println("oauth2User.getAttributes() = " + oauth2User.getAttributes()); System.out.println("oauth2User.getAuthorities() = " + oauth2User.getAuthorities()); return Map.of("OAuth2AuthorizedClient", authorizedClient, "OAuth2User", oauth2User); } } -
启动应用,访问自动生成的登录页面
http://localhost:8080/login,点击页面上的GitHub链接http://localhost:8080/oauth2/authorization/github -
该接口会拼接参数让浏览器
重定向到GitHub授权页面:https://github.com/login/oauth/authorize?response_type=code&client_id=xxx&scope=read:user&state=3BNHSDAD2i4zoqOjBQ8Bydps69wnXq6CU3gnqw5xum4%3D&redirect_uri=http://localhost:8080/login/oauth2/code/github -
GitHub检测当前用户未登录,重定向到GitHub登录页面
https://github.com/login?client_id=&return_to=%2Flogin%2Foauth%2Fauthorize%3Fclient_id%3Dxxx%26redirect_uri%3Dhttp%253A%252F%252Flocalhost%253A8080%252Flogin%252Foauth2%252Fcode%252Fgithub%26response_type%3Dcode%26scope%3Dread%253Auser%26state%3D3BNHSDAD2i4zoqOjBQ8Bydps69wnXq6CU3gnqw5xum4%253D,输入GitHub账号密码并登录 -
浏览器重定向到授权页面:
https://github.com/login/oauth/authorize?client_id=xxx&redirect_uri=http%3A%2F%2Flocalhost%3A8080%2Flogin%2Foauth2%2Fcode%2Fgithub&response_type=code&scope=read%3Auser&state=3BNHSDAD2i4zoqOjBQ8Bydps69wnXq6CU3gnqw5xum4%3D,点击授权 -
GitHub携带授权码回调后端服务
http://localhost:8080/login/oauth2/code/github?code=xxx&state=3BNHSDAD2i4zoqOjBQ8Bydps69wnXq6CU3gnqw5xum4%3D -
后端服务访问
https://github.com/login/oauth/access_token获取access_token,携带参数{grant_type=[authorization_code], code=[xxx], redirect_uri=[http://localhost:8080/login/oauth2/code/github]} -
后端服务访问
https://api.github.com/user获取用户信息,携带access_token

4503

被折叠的 条评论
为什么被折叠?



