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 字段中
    • 浏览器访问http://localhost:8080/hello,会重定向到默认生成的登录页面http://localhost:8080/login
      • 输入用户名密码登录,成功响应hello world

创建配置文件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
    • 根据当前请求来确定应调用哪些过滤器实例
  • DefaultSecurityFilterChain
  • 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
    • 授权管理器接口

是 SecurityFilterChain 的实现类,程序启动后,查看默认加载了以下16个过滤器

DisableEncodeUrlFilter WebAsyncManagerIntegrationFilter SecurityContextHolderFilter HeaderWriterFilter CsrfFilter 处理Csrf LogoutFilter 注销 UsernamePasswordAuthenticationFilter 认证 DefaultResourcesFilter 生成默认css样式 DefaultLoginPageGeneratingFilter 生成默认登录页 DefaultLogoutPageGeneratingFilter 生成默认登出页 BasicAuthenticationFilter 处理Http Basic请求认证 RequestCacheAwareFilter SecurityContextHolderAwareRequestFilter AnonymousAuthenticationFilter ExceptionTranslationFilter 异常处理 AuthorizationFilter 授权 

二、身份认证

1、基于内存的认证

  • 使用useradmin登录

创建配置类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,则被UsernamePasswordAuthenticationFilterdoFilter()方法处理
    • 将用户输入的账号密码封装成 UsernamePasswordAuthenticationToken对象
    • 调用认证管理器的实现类ProviderManagerAuthentication 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
  • 通过 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>

Read more

二叉树 二叉平衡树 B树 B+树

1 二叉树 这是最基础的树形结构。 * 定义:每个节点最多只有两个子节点(左子树和右子树)。 * 优点:比链表快,插入和查找的平均时间复杂度是 O(log N)。 缺点:不稳定。 在极端情况下(比如插入的数据本身已经是有序的,如 6,7,8,9),它会退化成一条链表。 此时查找时间复杂度会降到 O(N),性能急剧下降。 2 二叉平衡树 为了解决二叉树的退化问题,平衡树诞生了。 * 定义:在二叉树的基础上增加了约束:左右子树的高度差不能超过 1 * 工作原理:每次插入或删除数据时,如果平衡被破坏,它会通过旋转操作来自动调整结构,使其始终保持平衡。 * 优点:避免了二叉树退化成链表的问题,查找效率非常稳定,始终维持在 O(log N)。 * 缺点: 树太高:当数据量非常大时(例如几百万条)

By Ne0inhk
【线性表系列终篇】链表试炼:LeetCode Hot 100 经典题目实战解析

【线性表系列终篇】链表试炼:LeetCode Hot 100 经典题目实战解析

🏠个人主页:黎雁 🎬作者简介:C/C++/JAVA后端开发学习者 ❄️个人专栏:C语言、数据结构(C语言)、EasyX、游戏、规划、程序人生 ✨ 从来绝巘须孤往,万里同尘即玉京 文章目录 * 【线性表系列终篇】链表试炼:LeetCode Hot 100 经典题目实战解析 * 文章摘要 * 一、试炼前的准备:链表解题核心技巧回顾 * 二、试炼开始:经典题目实战解析 * 题目一:反转链表 (LeetCode 206) * 解法一:迭代(双指针) * 解法二:递归 * 题目二:环形链表 (LeetCode 141) * 解法:快慢指针(Floyd判圈算法) * 题目三:合并两个有序链表 (LeetCode 21)

By Ne0inhk

Min-Max(算法)归一化实例解析(内容由 AI 生成)

Min-Max归一化实例解析 Min-Max 归一化的简单理解是: 当前值 - 该维度的最小值) / 该维度的数值范围(最大值 - 最小值) 再简单理解,就是比例化,当前维度范围的比例化 Min-Max 归一化是数据预处理领域的标准算法,其核心价值是通过 “固定步骤 + 数学公式” 居然是一个算法。 “固定步骤 + 数学公式” 一、Min-Max归一化核心概念 Min-Max归一化(也称为离差标准化)是数据预处理中常用的线性归一化方法,其核心作用是将原始数据映射到指定的固定区间(最常用区间为[0,1],也可根据需求调整为[1,5]、[-1,1]等),消除不同特征间的量纲和尺度差异。 其核心公式为(以目标区间[0,1]为例): Xnorm=X−XminXmax−XminX_{norm} = \frac{X -

By Ne0inhk
【强化学习】演员评论家Actor-Critic算法(万字长文、附代码)

【强化学习】演员评论家Actor-Critic算法(万字长文、附代码)

📢本篇文章是博主强化学习(RL)领域学习时,用于个人学习、研究或者欣赏使用,并基于博主对相关等领域的一些理解而记录的学习摘录和笔记,若有不当和侵权之处,指出后将会立即改正,还望谅解。文章分类在👉强化学习专栏:        【强化学习】- 【单智能体强化学习】(7)---《演员评论家Actor-Critic算法》 演员评论家Actor-Critic算法 目录 Actor-Critic算法理解 1. 角色设定 2. 两者如何协作 3. 学习的核心 4. 为什么叫Actor-Critic? 生活中例子: Actor-Critic算法的背景与来源 1. 强化学习的起源 2. 策略梯度方法的局限性 3. Actor-Critic的提出 4. 历史发展与应用 Actor-Critic算法流程的推导 1. 强化学习的优化目标 2. 策略梯度定理 3. Critic:值函数估计 4. Actor:策略优化 5.

By Ne0inhk