SpringSecurity
一、快速入门
- 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 - 启动应用,会生成默认用户名
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创建启动类
packagecn.freyfang;importorg.springframework.boot.SpringApplication;importorg.springframework.boot.autoconfigure.SpringBootApplication;@SpringBootApplicationpublicclassMain{publicstaticvoidmain(String[] args){SpringApplication.run(Main.class, args);}}创建测试接口IndexController
packagecn.freyfang.controller;importorg.springframework.web.bind.annotation.GetMapping;importorg.springframework.web.bind.annotation.RestController;@ControllerpublicclassIndexController{@GetMapping("/hello")@ResponseBodypublicStringhello(){return"hello world";}}引入依赖
<?xml version="1.0" encoding="UTF-8"?><projectxmlns="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>2、默认行为
- 访问任何端点都需要经过身份验证
- 在启动时生成一个默认用户及随机密码
- 采用
BCrypt等算法对密码存储进行保护 - 生成默认的登录和注销页面
- 提供基于表单的登录和注销流程
- 对基于表单的登录以及
HTTP Basic进行验证 - 提供内容协商功能:对于web请求,会重定向到登录页面;对于服务请求,则返回401未授权
- 处理跨站请求伪造(CSRF)攻击
- 处理会话劫持攻击
- 写入
Strict-Transport-Security以确保HTTPS - 写入
X-Content-Type-Options以处理嗅探攻击 - 写入
Cache Control头来保护经过身份验证的资源 - 写入
X-Frame-Options以处理点击劫持攻击 - 发布认证成功和失败事件
3、自动配置
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)publicclassUserDetailsServiceAutoConfiguration{privatestaticfinalStringNOOP_PASSWORD_PREFIX="{noop}";privatestaticfinalPatternPASSWORD_ALGORITHM_PATTERN=Pattern.compile("^\\{.+}.*$");privatestaticfinalLog logger =LogFactory.getLog(UserDetailsServiceAutoConfiguration.class);@BeanpublicInMemoryUserDetailsManagerinMemoryUserDetailsManager(SecurityProperties properties,ObjectProvider<PasswordEncoder> passwordEncoder){SecurityProperties.User user = properties.getUser();List<String> roles = user.getRoles();returnnewInMemoryUserDetailsManager(User.withUsername(user.getName()).password(getOrDeducePassword(user, passwordEncoder.getIfAvailable())).roles(StringUtils.toStringArray(roles)).build());}privateStringgetOrDeducePassword(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;}returnNOOP_PASSWORD_PREFIX+ password;// 密码前使用`{noop}`,即使用 NoOpPasswordEncoder 比对明文密码}// ...}SecurityProperties中指定了默认用户名和默认密码
@ConfigurationProperties("spring.security")publicclassSecurityProperties{publicstaticfinalintBASIC_AUTH_ORDER=2147483642;publicstaticfinalintDEFAULT_FILTER_ORDER=-100;privatefinalFilter filter =newFilter();privatefinalUser user =newUser();// ...publicstaticclassFilter{// ...}publicstaticclassUser{privateString name ="user";privateString password =UUID.randomUUID().toString();privateList<String> roles =newArrayList();privateboolean passwordGenerated =true;// ...}}SpringBootWebSecurityConfiguration注入了默认的SecurityFilterChain
@Bean@Order(2147483642)SecurityFilterChaindefaultSecurityFilterChain(HttpSecurity http)throwsException{ 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();}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})//publicclassSecurityAutoConfiguration{@Bean@ConditionalOnMissingBean({AuthenticationEventPublisher.class})publicDefaultAuthenticationEventPublisherauthenticationEventPublisher(ApplicationEventPublisher publisher){returnnewDefaultAuthenticationEventPublisher(publisher);}}4、核心组件
- DelegatingFilterProxy
- 可以将 Servlet 容器中的 Filter 实例放在 Spring 容器中进行管理,方便按需加载
- FilterChainProxy
- 负责管理 SecurityFilterChain, 通过 SecurityFilterChain 将请求转发给多个过滤器实例
- SecurityFilterChain
- 根据当前请求来确定应调用哪些过滤器实例
DefaultSecurityFilterChainUserDetailsService- 认证过程中会通过
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
- 授权管理器接口
是 SecurityFilterChain 的实现类,程序启动后,查看默认加载了以下16个过滤器
DisableEncodeUrlFilter WebAsyncManagerIntegrationFilter SecurityContextHolderFilter HeaderWriterFilter CsrfFilter 处理Csrf LogoutFilter 注销 UsernamePasswordAuthenticationFilter 认证 DefaultResourcesFilter 生成默认css样式 DefaultLoginPageGeneratingFilter 生成默认登录页 DefaultLogoutPageGeneratingFilter 生成默认登出页 BasicAuthenticationFilter 处理Http Basic请求认证 RequestCacheAwareFilter SecurityContextHolderAwareRequestFilter AnonymousAuthenticationFilter ExceptionTranslationFilter 异常处理 AuthorizationFilter 授权 二、身份认证
1、基于内存的认证
- 使用
user或admin登录
创建配置类SecurityConfig,注入InMemoryUserDetailsManager
packagecn.freyfang.config;importorg.springframework.context.annotation.Bean;importorg.springframework.context.annotation.Configuration;importorg.springframework.security.core.userdetails.User;importorg.springframework.security.core.userdetails.UserDetails;importorg.springframework.security.core.userdetails.UserDetailsService;importorg.springframework.security.provisioning.InMemoryUserDetailsManager;@ConfigurationpublicclassSecurityConfig{/** * 向容器中注入 UserDetailsService 实例 */@BeanpublicUserDetailsServiceuserDetailsService(){// 会对密码进行加密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();// 向内存中存入两个用户returnnewInMemoryUserDetailsManager(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 - 启动应用,访问
http://localhost:8080/hello,输入数据库中的用户名和密码进行认证
创建测试类TestUser,新增测试用户
packagecn.freyfang;importcn.freyfang.mapper.UserMapper;importcn.freyfang.model.SysUser;importorg.junit.jupiter.api.Test;importorg.springframework.beans.factory.annotation.Autowired;importorg.springframework.boot.test.context.SpringBootTest;importorg.springframework.security.crypto.password.PasswordEncoder;@SpringBootTestpublicclassTestUser{@AutowiredprivateUserMapper userMapper;@AutowiredprivatePasswordEncoder passwordEncoder;@TestpublicvoidaddUser(){SysUser user =newSysUser(); user.setUsername("admin"); user.setPassword(passwordEncoder.encode("123456"));System.out.println("user = "+ user);int insert = userMapper.insert(user);System.out.println("insert = "+ insert);}}自定义UserDetailsService的实现类UserDetailsServiceImpl,并将实例注入到容器中
packagecn.freyfang.service;importcn.freyfang.mapper.UserMapper;importcn.freyfang.model.SysUser;importcom.baomidou.mybatisplus.core.toolkit.Wrappers;importlombok.extern.slf4j.Slf4j;importorg.springframework.beans.factory.annotation.Autowired;importorg.springframework.security.core.GrantedAuthority;importorg.springframework.security.core.userdetails.User;importorg.springframework.security.core.userdetails.UserDetails;importorg.springframework.security.core.userdetails.UserDetailsService;importorg.springframework.security.core.userdetails.UsernameNotFoundException;importorg.springframework.stereotype.Service;importjava.util.ArrayList;importjava.util.Collection;importjava.util.Set;@Slf4j@ServicepublicclassUserDetailsServiceImplimplementsUserDetailsService{@AutowiredprivateUserMapper userMapper;@AutowiredprivateUserService userService;@OverridepublicUserDetailsloadUserByUsername(String username)throwsUsernameNotFoundException{// 从数据库中加载用户信息SysUser user = userMapper.selectOne(Wrappers.lambdaQuery(SysUser.class).eq(SysUser::getUsername, username));if(user ==null){thrownewUsernameNotFoundException("user '"+ username +"' not found");}// 从数据库中加载权限信息Set<String> permissions = userService.selectPermissions(user);Collection<GrantedAuthority> authorities =newArrayList<>();for(String permission : permissions){ authorities.add(()-> permission);}// 封装成 UserDetailsreturnnewUser(user.getUsername(), user.getPassword(),true,// 是否启用true,// 用户账号是否过期true,// 用户凭证是否过期true,// 用户是否未被锁定 authorities);// 权限列表}}修改配置类,向容器中注册密码器
// 指定密码编码器@BeanpublicPasswordEncoderpasswordEncoder(){// 参数strength: 工作因子,默认值是10,最小值是4,最大值是31,值越大运算速度越慢,从而提高破解的门槛returnnewBCryptPasswordEncoder();}创建实体类SysUser、UserMapper、UserService
@Data@TableName("user")publicclassSysUser{privateLong id;privateString username;privateString password;}@MapperpublicinterfaceUserMapperextendsBaseMapper<SysUser>{}/** * 模拟数据库操作 */@ServicepublicclassUserService{publicSet<String>selectPermissions(SysUser user){// 如果是角色,需要添加`ROLE_`前缀,这是约定,用于区分角色和权限returnSet.of("index:hello","ROLE_admin");}}配置数据源
spring:datasource:driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/security?useUnicode=true&characterEncoding=utf-8username: root password:123456mybatis-plus:configuration:log-impl: org.apache.ibatis.logging.stdout.StdOutImpl logging:level:org.springframework.security: DEBUG 引入依赖
<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>4、常用配置
- 自定义登录页,开启后会自动禁用以下过滤器:DefaultResourcesFilter、DefaultLoginPageGeneratingFilter、DefaultLogoutPageGeneratingFilter、BasicAuthenticationFilter
- 自定义登录请求参数名
- 自定义登录成功/失败处理器、自定义退出成功处理器
- 获取用户信息
- 会话并发管理,开启后会自动启用以下过滤器:ConcurrentSessionFilter、SessionManagementFilter
测试
- 访问
http://localhost:8080/,会重定向到/to-login,响应自定义登录页面 - 输入错误的用户名或密码,会转发到
/login-fail,响应loginFail - 输入正确的用户名或密码,会转发到
/,响应自定义首页 - 使用另一个浏览器登录相同的账号,然后当前浏览器访问
/hello,响应{"msg":"该账号已从其他设备登录","code":500}
修改配置类,自定义SecurityFilterChain实例
@BeanSecurityFilterChainsecurityFilterChain(HttpSecurity http)throwsException{ 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 =newObjectMapper().writeValueAsString(Map.of("code",500,"msg","该账号已从其他设备登录"));HttpServletResponse response = event.getResponse(); response.setContentType("application/json;charset=UTF-8"); response.getWriter().write(result);});});return http.build();}修改IndexController,新增方法
packagecn.freyfang.controller;importorg.springframework.security.core.Authentication;importorg.springframework.security.core.context.SecurityContextHolder;importorg.springframework.stereotype.Controller;importorg.springframework.ui.Model;importorg.springframework.web.bind.annotation.GetMapping;importorg.springframework.web.bind.annotation.PostMapping;importorg.springframework.web.bind.annotation.RequestMapping;importorg.springframework.web.bind.annotation.ResponseBody;@ControllerpublicclassIndexController{@GetMapping("/hello")@ResponseBodypublicStringhello(){return"hello world";}// 跳转到登录页面@GetMapping("/to-login")publicStringtoLogin(){return"login";}// 用于 用户登录成功/失败 后转发,因为登录请求是post,所以转发的请求方式还是post@RequestMapping("/")publicStringindex(Model model){// 获取已认证用户信息Authentication authentication =SecurityContextHolder.getContext().getAuthentication();String username = authentication.getName(); model.addAttribute("username", username);return"index";}@PostMapping("/login-fail")@ResponseBodypublicStringloginFail(){return"loginFail";}}创建首页src/main/resources/templates/index.html
<!DOCTYPEhtml><htmllang="en"xmlns:th="https://www.thymeleaf.org"><head><metacharset="UTF-8"><title>Title</title></head><body><h1>首页</h1><divth:text="'欢迎:'+${username}"></div><formth:action="@{/logout}"method="post"><inputtype="submit"value="退出"></input></form><ath:href="@{/hello}">hello</a></body></html>创建登录页面src/main/resources/templates/login.html
<!DOCTYPEhtml><htmlxmlns="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><divth:if="${param.error}"> Invalid username and password. </div><divth:if="${param.logout}"> You have been logged out. </div><formth:action="@{/login}"method="post"><div><inputtype="text"name="name"placeholder="Username"/></div><div><inputtype="password"name="pwd"placeholder="Password"/></div><inputtype="submit"value="Log in"/></form></body></html>引入依赖
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-thymeleaf</artifactId></dependency>5、记住我
- 两种实现方式
- 使用
TokenBasedRememberMeServices,即基于cookie保存token。如果Cookie被窃取,攻击者可以在有效期内伪造身份。后台无法主动使令牌失效(除非修改密码或等待过期) - 使用
PersistentTokenBasedRememberMeServices(推荐),即使用数据库持久化token。服务端维护令牌的状态,支持主动撤销令牌或删除记录。每次成功验证后,旧令牌会被替换为新令牌,防止令牌被重复使用
- 使用
1)步骤
- 访问登录页面,勾选
记住我复选框,登录成功后,会自动向cookies和persistent_logins表中写入token - 关闭浏览器后重新打开访问,会自动进行认证,无需再输入用户名密码
修改配置类,注入多个组件
@BeanSecurityFilterChainsecurityFilterChain(HttpSecurity http ,RememberMeServices rememberMeServices)throwsException{// ......// 记住我 http.rememberMe(rememberMe -> rememberMe .rememberMeServices(rememberMeServices)//注意:结合rememberMeServices使用时,以下参数不会生效,因为rememberMeServices会覆盖该参数// .tokenValiditySeconds(60 * 60 * 24 * 20)// .rememberMeParameter("abc"));return http.build();}@AutowiredprivateUserDetailsService userDetailsService;@AutowiredprivateDataSource dataSource;@BeanpublicRememberMeServicesrememberMeServices(PersistentTokenRepository tokenRepository){PersistentTokenBasedRememberMeServices services =newPersistentTokenBasedRememberMeServices("123456", userDetailsService, tokenRepository); services.setTokenValiditySeconds(60*60*24*7);// 默认14天 services.setParameter("remember");// 记住我参数,默认是`remember-me`return services;}@BeanpublicPersistentTokenRepositorypersistentTokenRepository(){// 用于持久化操作JdbcTokenRepositoryImpl jdbcTokenRepository =newJdbcTokenRepositoryImpl(); jdbcTokenRepository.setDataSource(dataSource);return jdbcTokenRepository;}登录页面增加复选框
<div> 记住我:<inputtype="checkbox"name="remember"/></div>创建表,用于持久化token
createtable persistent_logins (username varchar(64)notnull, series varchar(64)primarykey, token varchar(64)notnull, last_used timestampnotnull)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")@ResponseBodypublicStringhey(){return"hey";}@GetMapping("/hi")@ResponseBodypublicStringhi(){return"hi";}@GetMapping("/ok")@ResponseBodypublicStringok(){return"ok";}2)为资源设置所需权限
- 方式2:基于Controller方法
- 在配置类上使用
@EnableMethodSecurity开启方法授权 - 常用注解
- @PreAuthorize 适合进入方法前的权限验证
- @PostAuthorize 在方法执行后再进行权限验证,适合验证带有返回值的权限
- @PreFilter 进入方法之前对数据进行过滤
- @PostFilter 权限验证之后对数据进行过滤
- 在配置类上使用
修改IndexController,添加鉴权注解。同时删除方式1的权限配置
// 测试授权的三个方法@PreAuthorize("hasAuthority('index:hey')")@GetMapping("/hey")@ResponseBodypublicStringhey(){return"hey";}@PreAuthorize("hasAuthority('index:hi')")@GetMapping("/hi")@ResponseBodypublicStringhi(){return"hi";}@PreAuthorize("hasRole('admin')")@GetMapping("/ok")@ResponseBodypublicStringok(){return"ok";}方式1:基于请求地址。修改配置类
@BeanSecurityFilterChainsecurityFilterChain(HttpSecurity http)throwsException{ 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();}3)为用户授权
用户登录时,从数据库中查询权限信息保存到Authentication对象中。修改UserService,假如用户具有"index:hey"和"ROLE_admin"权限
@ServicepublicclassUserService{publicSet<String>selectPermissions(SysUser user){// 如果是角色,需要添加`ROLE_`前缀,这是约定,用于区分角色和权限returnSet.of("index:hey","ROLE_admin");}}4)请求未授权的接口
- 访问
/hey、/ok正常响应 - 访问
/hi,响应默认403 Forbidden页面
2、自定义异常处理
- 访问
/hi,响应未授权
修改Controller,新增方法
// 响应未授权@GetMapping("/unauthorized")@ResponseBodypublicStringunauthorized(){return"未授权";}修改配置类的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();// }));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
创建UserService模拟数据库操作
packagecn.freyfang.service;importcn.freyfang.model.SysUser;importorg.springframework.stereotype.Service;importjava.util.Set;/** * 模拟操作数据库 */@ServicepublicclassUserService{/** * 根据用户名查询用户信息 */publicSysUsergetUserByName(String username){// 先将密码改为密文:passwordEncoder.encode("123456")returnnewSysUser(1L,"admin","$2a$10$79iMOUW67OL6s1SaAsYgp.67.ivR34LT6h80uJNUvxEnnrgKhqVJq");}/** * 获取用户权限 */publicSet<String>getPermissionsByUser(SysUser user){returnSet.of("index:hello","ROLE_admin");}/** * 获取用户角色 */publicSet<String>getRolesByUser(SysUser user){returnSet.of("ROLE_admin");}}创建实体类SysUser映射数据库用户表
packagecn.freyfang.model;importcom.fasterxml.jackson.annotation.JsonProperty;importlombok.AllArgsConstructor;importlombok.Data;importlombok.NoArgsConstructor;importjava.io.Serializable;@Data@AllArgsConstructor@NoArgsConstructorpublicclassSysUserimplementsSerializable{privatestaticfinallong serialVersionUID =1L;privateLong userId;privateString username;@JsonProperty(access =JsonProperty.Access.WRITE_ONLY)privateString password;}创建全局异常处理器GlobalExceptionHandler
packagecn.freyfang.exception;importcn.freyfang.model.R;importjakarta.servlet.http.HttpServletRequest;importlombok.extern.slf4j.Slf4j;importorg.springframework.web.bind.annotation.ExceptionHandler;importorg.springframework.web.bind.annotation.RestControllerAdvice;@Slf4j@RestControllerAdvicepublicclassGlobalExceptionHandler{@ExceptionHandler(RuntimeException.class)publicRhandleRuntimeException(RuntimeException e,HttpServletRequest request){String requestURI = request.getRequestURI(); log.error("请求地址'{}',发生未知异常.", requestURI, e);returnR.error(e.getMessage());}@ExceptionHandler(Exception.class)publicRhandleException(Exception e,HttpServletRequest request){String requestURI = request.getRequestURI(); log.error("请求地址'{}',发生系统异常.", requestURI, e);returnR.error(e.getMessage());}}定义统一响应类R
packagecn.freyfang.model;importlombok.AllArgsConstructor;importlombok.Data;importlombok.NoArgsConstructor;@Data@AllArgsConstructor@NoArgsConstructorpublicclassR{privateInteger code;privateString msg;privateObject data;publicstaticRok(){returnok("操作成功");}publicstaticRok(String msg){returnok(msg,null);}publicstaticRok(Object data){returnok("操作成功", data);}publicstaticRok(String msg,Object data){returnnewR(200, msg, data);}publicstaticRerror(){returnerror("操作失败");}publicstaticRerror(String msg){returnerror(msg,null);}publicstaticRerror(String msg,Object data){returnnewR(500, msg, data);}publicstaticRerror(Integer code,String msg){returnnewR(code, msg,null);}}创建工具类ResponseUtil
packagecn.freyfang.util;importcn.freyfang.contstant.Constants;importjakarta.servlet.http.HttpServletResponse;importorg.springframework.http.HttpStatus;importorg.springframework.http.MediaType;importjava.io.IOException;publicclassResponseUtil{publicstaticvoidout(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){thrownewRuntimeException(e);}}}创建配置类RedisConfig
packagecn.freyfang.config;importcn.freyfang.contstant.Constants;importcom.alibaba.fastjson2.JSON;importcom.alibaba.fastjson2.JSONReader;importcom.alibaba.fastjson2.JSONWriter;importcom.alibaba.fastjson2.filter.Filter;importorg.springframework.context.annotation.Bean;importorg.springframework.context.annotation.Configuration;importorg.springframework.data.redis.connection.RedisConnectionFactory;importorg.springframework.data.redis.core.RedisTemplate;importorg.springframework.data.redis.serializer.RedisSerializer;importorg.springframework.data.redis.serializer.SerializationException;importorg.springframework.data.redis.serializer.StringRedisSerializer;importjava.nio.charset.Charset;@ConfigurationpublicclassRedisConfig{@BeanpublicRedisTemplate<Object,Object>redisTemplate(RedisConnectionFactory connectionFactory){RedisTemplate<Object,Object> template =newRedisTemplate<>(); template.setConnectionFactory(connectionFactory);FastJson2JsonRedisSerializer serializer =newFastJson2JsonRedisSerializer(Object.class);// 使用StringRedisSerializer来序列化和反序列化redis的key值 template.setKeySerializer(newStringRedisSerializer()); template.setValueSerializer(serializer);// Hash的key也采用StringRedisSerializer的序列化方式 template.setHashKeySerializer(newStringRedisSerializer()); template.setHashValueSerializer(serializer); template.afterPropertiesSet();return template;}/** * Redis使用FastJson序列化 */privateclassFastJson2JsonRedisSerializer<T>implementsRedisSerializer<T>{publicstaticfinalCharsetDEFAULT_CHARSET=Charset.forName(Constants.UTF8);staticfinalFilterAUTO_TYPE_FILTER=JSONReader.autoTypeFilter(Constants.JSON_WHITELIST_STR);privateClass<T> clazz;publicFastJson2JsonRedisSerializer(Class<T> clazz){super();this.clazz = clazz;}@Overridepublicbyte[]serialize(T t)throwsSerializationException{if(t ==null){returnnewbyte[0];}returnJSON.toJSONString(t,JSONWriter.Feature.WriteClassName).getBytes(DEFAULT_CHARSET);}@OverridepublicTdeserialize(byte[] bytes)throwsSerializationException{if(bytes ==null|| bytes.length <=0){returnnull;}String str =newString(bytes,DEFAULT_CHARSET);returnJSON.parseObject(str, clazz,AUTO_TYPE_FILTER);}}}定义常量类Constants
packagecn.freyfang.contstant;importio.jsonwebtoken.Claims;publicclassConstants{publicstaticfinalStringUTF8="UTF-8";publicstaticfinalStringTOKEN="token";publicstaticfinalStringTOKEN_PREFIX="Bearer ";publicstaticfinalStringLOGIN_USER_KEY="login_user_key";publicstaticfinalStringLOGIN_TOKEN_KEY="login_tokens:";publicstaticfinalStringJWT_USERNAME=Claims.SUBJECT;// 自动识别json对象白名单配置(仅允许解析的包名,范围越小越安全)publicstaticfinalString[]JSON_WHITELIST_STR={"cn.freyfang"};}引入依赖
<?xml version="1.0" encoding="UTF-8"?><projectxmlns="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>3、创建核心配置
创建配置类SecurityConfig
packagecn.freyfang.config;importcn.freyfang.filter.JwtAuthenticationTokenFilter;importcn.freyfang.model.LoginUser;importcn.freyfang.model.R;importcn.freyfang.util.ResponseUtil;importcn.freyfang.util.TokenUtil;importcom.alibaba.fastjson2.JSON;importorg.springframework.beans.factory.annotation.Autowired;importorg.springframework.context.annotation.Bean;importorg.springframework.context.annotation.Configuration;importorg.springframework.http.HttpMethod;importorg.springframework.security.authentication.AuthenticationManager;importorg.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;importorg.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;importorg.springframework.security.config.annotation.web.builders.HttpSecurity;importorg.springframework.security.config.http.SessionCreationPolicy;importorg.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;importorg.springframework.security.crypto.password.PasswordEncoder;importorg.springframework.security.web.SecurityFilterChain;importorg.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;@Configuration@EnableMethodSecuritypublicclassSecurityConfig{@AutowiredprivateJwtAuthenticationTokenFilter jwtAuthenticationTokenFilter;@AutowiredprivateTokenUtil tokenUtil;// 指定密码加密器@BeanpublicPasswordEncoderpasswordEncoder(){returnnewBCryptPasswordEncoder();}// 注入认证管理器,在UserController登录请求中使用@BeanpublicAuthenticationManagerauthenticationManager(AuthenticationConfiguration authenticationConfiguration)throwsException{return authenticationConfiguration.getAuthenticationManager();}@BeanpublicSecurityFilterChainsecurityFilterChain(HttpSecurity http)throwsException{// 关闭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();}}创建UserController
packagecn.freyfang.controller;importcn.freyfang.contstant.Constants;importcn.freyfang.model.LoginUser;importcn.freyfang.model.R;importcn.freyfang.model.SysUser;importcn.freyfang.service.UserService;importcn.freyfang.util.TokenUtil;importjakarta.annotation.Resource;importorg.springframework.beans.factory.annotation.Autowired;importorg.springframework.security.authentication.AuthenticationManager;importorg.springframework.security.authentication.BadCredentialsException;importorg.springframework.security.authentication.UsernamePasswordAuthenticationToken;importorg.springframework.security.core.Authentication;importorg.springframework.security.core.context.SecurityContextHolder;importorg.springframework.web.bind.annotation.GetMapping;importorg.springframework.web.bind.annotation.PostMapping;importorg.springframework.web.bind.annotation.RequestBody;importorg.springframework.web.bind.annotation.RestController;importjava.util.Map;importjava.util.Set;@RestControllerpublicclassUserController{@ResourceprivateAuthenticationManager authenticationManager;@AutowiredprivateTokenUtil tokenUtil;@AutowiredprivateUserService userService;/** * 登录方法 */@PostMapping("/login")publicRlogin(@RequestBodySysUser loginBody){String username = loginBody.getUsername();String password = loginBody.getPassword();// 用户验证Authentication authentication =null;try{UsernamePasswordAuthenticationToken authenticationToken =newUsernamePasswordAuthenticationToken(username, password);// 该方法会去调用 UserDetailsServiceImpl.loadUserByUsername authentication = authenticationManager.authenticate(authenticationToken);}catch(Exception e){if(e instanceofBadCredentialsException){thrownewRuntimeException("用户名或密码错误");}else{thrownewRuntimeException(e.getMessage());}}LoginUser loginUser =(LoginUser) authentication.getPrincipal();// 生成token 并写入redisString token = tokenUtil.createToken(loginUser);returnR.ok(Map.of(Constants.TOKEN, token));}/** * 获取用户信息 */@GetMapping("getInfo")publicRgetInfo(){LoginUser loginUser =(LoginUser)SecurityContextHolder.getContext().getAuthentication().getPrincipal();SysUser user = loginUser.getUser();// 从数据库中获取用户的最新角色和权限数据,并更新redisSet<String> roles = userService.getRolesByUser(user);Set<String> permissions = userService.getPermissionsByUser(user);if(!loginUser.getPermissions().equals(permissions)){ loginUser.setPermissions(permissions); tokenUtil.refreshToken(loginUser);}returnR.ok(Map.of("user", user,"roles", roles,"permissions", permissions));}}创建IndexController
packagecn.freyfang.controller;importorg.springframework.security.access.prepost.PreAuthorize;importorg.springframework.web.bind.annotation.GetMapping;importorg.springframework.web.bind.annotation.RestController;@RestControllerpublicclassIndexController{@GetMapping("/")publicStringindex(){return"欢迎";}@PreAuthorize("hasAuthority('index:hello')")@GetMapping("/hello")publicStringhello(){return"hello";}@PreAuthorize("hasAuthority('index:hi')")@GetMapping("/hi")publicStringhi(){return"hi";}@PreAuthorize("hasRole('admin')")@GetMapping("/ok")publicStringok(){return"ok";}}创建JwtAuthenticationTokenFilter
packagecn.freyfang.filter;importcn.freyfang.model.LoginUser;importcn.freyfang.util.TokenUtil;importjakarta.servlet.FilterChain;importjakarta.servlet.ServletException;importjakarta.servlet.http.HttpServletRequest;importjakarta.servlet.http.HttpServletResponse;importorg.springframework.beans.factory.annotation.Autowired;importorg.springframework.security.authentication.UsernamePasswordAuthenticationToken;importorg.springframework.security.core.context.SecurityContextHolder;importorg.springframework.security.web.authentication.WebAuthenticationDetailsSource;importorg.springframework.stereotype.Component;importorg.springframework.web.filter.OncePerRequestFilter;importjava.io.IOException;@ComponentpublicclassJwtAuthenticationTokenFilterextendsOncePerRequestFilter{@AutowiredprivateTokenUtil tokenUtil;@OverrideprotectedvoiddoFilterInternal(HttpServletRequest request,HttpServletResponse response,FilterChain chain)throwsServletException,IOException{// 1、从请求头中获取token,并从redis中获取用户信息LoginUser loginUser = tokenUtil.getLoginUser(request);if(loginUser !=null&&SecurityContextHolder.getContext().getAuthentication()==null){// 2、续期token tokenUtil.verifyToken(loginUser);// 3、将用户信息放入上下文中UsernamePasswordAuthenticationToken authenticationToken =newUsernamePasswordAuthenticationToken(loginUser,null, loginUser.getAuthorities()); authenticationToken.setDetails(newWebAuthenticationDetailsSource().buildDetails(request));SecurityContextHolder.getContext().setAuthentication(authenticationToken);} chain.doFilter(request, response);}}创建TokenUtil
packagecn.freyfang.util;importcn.freyfang.contstant.Constants;importcn.freyfang.model.LoginUser;importio.jsonwebtoken.Claims;importio.jsonwebtoken.Jwts;importio.jsonwebtoken.SignatureAlgorithm;importio.micrometer.common.util.StringUtils;importjakarta.servlet.http.HttpServletRequest;importlombok.extern.slf4j.Slf4j;importorg.springframework.beans.factory.annotation.Autowired;importorg.springframework.beans.factory.annotation.Value;importorg.springframework.data.redis.core.RedisTemplate;importorg.springframework.stereotype.Component;importjava.util.HashMap;importjava.util.Map;importjava.util.UUID;importjava.util.concurrent.TimeUnit;@Slf4j@ComponentpublicclassTokenUtil{// 令牌自定义标识@Value("${token.header:Authorization}")privateString header;// 令牌秘钥@Value("${token.secret:123456}")privateString secret;// 令牌有效期(默认30分钟)@Value("${token.expireTime:30}")privateint expireTime;privatestaticfinalLongMILLIS_MINUTE_TWENTY=20*60*1000L;@AutowiredpublicRedisTemplate redisTemplate;/** * 获取用户身份信息 */publicLoginUsergetLoginUser(HttpServletRequest request){// 1、从请求头中获取tokenString 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());}}returnnull;}/** * 删除用户身份信息 */publicvoiddelLoginUser(String token){if(StringUtils.isNotEmpty(token)){String userKey =getTokenKey(token); redisTemplate.delete(userKey);}}/** * 创建令牌 */publicStringcreateToken(LoginUser loginUser){String token =UUID.randomUUID().toString(); loginUser.setToken(token);refreshToken(loginUser);Map<String,Object> claims =newHashMap<>(); claims.put(Constants.LOGIN_USER_KEY, token); claims.put(Constants.JWT_USERNAME, loginUser.getUsername());returnJwts.builder().setClaims(claims).signWith(SignatureAlgorithm.HS512, secret).compact();}/** * 验证令牌有效期,相差不足20分钟,自动刷新缓存 */publicvoidverifyToken(LoginUser loginUser){long expireTime = loginUser.getExpireTime();long currentTime =System.currentTimeMillis();if(expireTime - currentTime <=MILLIS_MINUTE_TWENTY){refreshToken(loginUser);}}/** * 刷新令牌有效期 */publicvoidrefreshToken(LoginUser loginUser){ loginUser.setLoginTime(System.currentTimeMillis()); loginUser.setExpireTime(loginUser.getLoginTime()+ expireTime *60*1000L);// 根据uuid将loginUser缓存到redisString userKey =getTokenKey(loginUser.getToken()); redisTemplate.opsForValue().set(userKey, loginUser, expireTime,TimeUnit.MINUTES);}privateStringgetTokenKey(String uuid){returnConstants.LOGIN_TOKEN_KEY+ uuid;}}创建UserDetailsService实现类UserDetailsServiceImpl
packagecn.freyfang.service;importcn.freyfang.model.LoginUser;importcn.freyfang.model.SysUser;importlombok.extern.slf4j.Slf4j;importorg.springframework.beans.factory.annotation.Autowired;importorg.springframework.security.core.userdetails.UserDetails;importorg.springframework.security.core.userdetails.UserDetailsService;importorg.springframework.security.core.userdetails.UsernameNotFoundException;importorg.springframework.security.crypto.password.PasswordEncoder;importorg.springframework.stereotype.Service;importjava.util.Set;@Slf4j@ServicepublicclassUserDetailsServiceImplimplementsUserDetailsService{@AutowiredprivatePasswordEncoder passwordEncoder;@AutowiredprivateUserService userService;@OverridepublicUserDetailsloadUserByUsername(String username)throwsUsernameNotFoundException{// 查询用户信息SysUser user = userService.getUserByName(username);if(user ==null){ log.info("登录用户:{} 不存在.", username);thrownewRuntimeException("用户名或密码错误");}// 查询权限信息Set<String> permissions = userService.getPermissionsByUser(user);returnnewLoginUser(user.getUserId(), user, permissions);}}创建UserDetails实现类LoginUser
package 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<?extendsGrantedAuthority> getAuthorities() { Collection<GrantedAuthority> authorities = new ArrayList<>(); for (String permission : permissions) { if (StringUtils.hasLength(permission)) { SimpleGrantedAuthority authority = new SimpleGrantedAuthority(permission); authorities.add(authority); } } return authorities; } } 4、测试
- 禁用了
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
启动应用,查看 DefaultSecurityFilterChain 发现共启用了11个过滤器
DisableEncodeUrlFilter WebAsyncManagerIntegrationFilter SecurityContextHolderFilter HeaderWriterFilter LogoutFilter JwtAuthenticationTokenFilter RequestCacheAwareFilter SecurityContextHolderAwareRequestFilter AnonymousAuthenticationFilter ExceptionTranslationFilter AuthorizationFilter 五、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 - 启动应用,访问自动生成的登录页面
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
创建IndexController接收返回信息
@RestControllerpublicclassIndexController{@GetMapping("/")publicMap<String,Object>index(@RegisteredOAuth2AuthorizedClientOAuth2AuthorizedClient authorizedClient,@AuthenticationPrincipalOAuth2User 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());returnMap.of("OAuth2AuthorizedClient", authorizedClient,"OAuth2User", oauth2User);}}创建配置文件
spring:security:oauth2:client:registration:github:client-id: xxx client-secret: xxx CommonOAuth2Provider中预定义了一些服务商的属性
packageorg.springframework.security.config.oauth2.client;importorg.springframework.security.oauth2.client.registration.ClientRegistration;importorg.springframework.security.oauth2.core.AuthorizationGrantType;importorg.springframework.security.oauth2.core.ClientAuthenticationMethod;publicenumCommonOAuth2Provider{// GOOGLE // FACEBOOK// OKTAGITHUB{publicClientRegistration.BuildergetBuilder(String registrationId){ClientRegistration.Builder builder =this.getBuilder(registrationId,ClientAuthenticationMethod.CLIENT_SECRET_BASIC,"{baseUrl}/{action}/oauth2/code/{registrationId}");// 回调地址 builder.scope(newString[]{"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;}}}引入依赖
<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>