文章目录
说明
本文使用的是Security6的版本,先介绍SpringSecurity的使用,然后再去介绍OAuth2。
SpringSecurity也只是入门知识
- Authentication(认证,解决who are you? )
- Authorization (访问控制,也就是what are you allowed to do?)
版本:SpringBoot3.1.4、Security6.1.4
SpringSecurity认证
快速开始
创建一个简单的SpringBoot应用
引入依赖
<parent><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-parent</artifactId><version>3.1.4</version><relativePath/><!-- lookup parent from repository --></parent><dependencies><!-- 接入spring security--><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-security</artifactId></dependency><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><scope>test</scope></dependency><dependency><groupId>org.springframework.security</groupId><artifactId>spring-security-test</artifactId><scope>test</scope></dependency></dependencies>
编写一个简单的Controller
@RestController@RequestMapping("/admin")publicclassAdminController{@GetMapping("/demo")publicStringdemo(){return"spring security demo";}}
启动项目后测试接口调用
引入Spring Security依赖之后 ,访问 API 接口时,需要首先进行登录,才能进行访问。
测试 http://localhost:8080/admin/demo ,会跳转到登录界面
页面生成源码:DefaultLoginPageGeneratingFilter#generateLoginPageHtml
用户名密码认证Filter: UsernamePasswordAuthenticationFilter
需要登录,默认用户名:user,密码可以查看控制台日志获取
登录之后跳转回请求接口
日志中,打印所有要执行的Filter如下
2024-07-22T08:27:34.023+08:00 INFO 10172 --- [ main]o.s.s.web.DefaultSecurityFilterChain : Will secure any request with [org.springframework.security.web.session.DisableEncodeUrlFilter@1f939a0f,
org.springframework.security.web.context.request.async.WebAsyncManagerIntegrationFilter@45658133,
org.springframework.security.web.context.SecurityContextHolderFilter@1e1eeedd,
org.springframework.security.web.header.HeaderWriterFilter@1fbf088b,
org.springframework.security.web.csrf.CsrfFilter@1c3259fa,
org.springframework.security.web.authentication.logout.LogoutFilter@4c6b4ed7,
# 核心Filter,校验用户名密码的
org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter@71ed560f,
# 默认登录页面
org.springframework.security.web.authentication.ui.DefaultLoginPageGeneratingFilter@3aaa3c39,
org.springframework.security.web.authentication.ui.DefaultLogoutPageGeneratingFilter@430b2699,
org.springframework.security.web.authentication.www.BasicAuthenticationFilter@40247d48,
org.springframework.security.web.savedrequest.RequestCacheAwareFilter@7ec95456,
org.springframework.security.web.servletapi.SecurityContextHolderAwareRequestFilter@428bdd72,
org.springframework.security.web.authentication.AnonymousAuthenticationFilter@67536ae0,
# 处理异常的Filter
org.springframework.security.web.access.ExceptionTranslationFilter@2954f6ab,
# Security6,把鉴权相关的专门抽出来 成了一个Filter
org.springframework.security.web.access.intercept.AuthorizationFilter@64c781a9]
退出登录
Spring security默认实现了logout退出,用户只需要向 Spring Security 项目中发送 http://localhost:8080/logout 退出请求即可。
设置用户名密码
了解即可
基于application.yml方式
可以在application.yml中自定义用户名密码
spring:# Spring Security 配置项,对应 SecurityProperties 配置类security:user:name: user # 用户名password:123456# 密码roles:# 拥有角色- admin
原理:
默认情况下,
UserDetailsServiceAutoConfiguration
自动化配置类,会创建一个内存级别的
InMemoryUserDetailsManager
对象,提供认证的用户信息。
- 添加
spring.security.user
配置项,UserDetailsServiceAutoConfiguration
会基于配置的信息在内存中创建一个用户User。 - 未添加
spring.security.user
配置项,UserDetailsServiceAutoConfiguration
会自动在内存中创建一个用户名为 user,密码为 UUID 随机的用户 User
基于Java Bean配置方式
@Configuration@EnableWebSecurity//开启spring sercurity支持publicclassSecurityConfig{/**
* 配置用户信息
* 我们正常的用法是自定义一个类,实现UserDetailsService接口,再通过username去查询DB,再封装一个UserDetails对象返回。
* 这里就简单实现,直接指定
* @return
*/@BeanpublicUserDetailsServiceuserDetailsService(){//使用默认加密方式bcrypt对密码进行加密,添加用户信息UserDetails user =User.withDefaultPasswordEncoder().username("hushang").password("123456").roles("user").build();UserDetails admin =User.withUsername("admin").password("{noop}123456")//对密码不加密.roles("admin","user").build();// 返回一个UserDetailsService的实现类InMemoryUserDetailsManager,从类名可以看出来是基于内存的returnnewInMemoryUserDetailsManager(user, admin);}}
另一种方式
@Configuration@EnableWebSecurity//开启spring sercurity支持publicclassSecurityConfig{/**
* 配置用户信息
* @return
*/@BeanpublicUserDetailsServiceuserDetailsService(){//使用默认加密方式bcrypt对密码进行加密,添加用户信息//加密方式1:{id}encodedPassword ,id为加密算法类型// UserDetails user = User.withDefaultPasswordEncoder()// .username("hushang")// .password("123456")// .roles("user")// .build();//// UserDetails admin = User.withUsername("admin")// .password("{noop}123456") //noop表示对密码不加密// .roles("admin", "user")// .build();// 加密方式2: passwordEncoder().encode("123456")UserDetails user =User.withUsername("hushang").password(passwordEncoder().encode("123456")).roles("user").build();UserDetails admin =User.withUsername("admin")//指定加密算法对密码加密.password(passwordEncoder().encode("123456")).roles("admin","user").build();returnnewInMemoryUserDetailsManager(user, admin);}@BeanpublicPasswordEncoderpasswordEncoder(){//return NoOpPasswordEncoder.getInstance(); //不加密returnnewBCryptPasswordEncoder();//加密方式bcrypt}}
设置加密方式
方式1:{id}encodedPassword
Spring Security密码加密格式为:{id}encodedPassword
UserDetails user =User.withUsername("user").password("{bcrypt}$2a$10$dXJ3SW6G7P50lGmMkkmwe.20cQQubK3.HZWzG3YB1tlRy.fqvM/BG").roles("USER").build();UserDetails admin =User.withUsername("admin").password("{noop}123456")//noop表示对密码不加密.roles("admin","user").build();
如果密码不指定{id}会抛异常:
方式2: passwordEncoder().encode(“123456”)
@Configuration@EnableWebSecurity//开启spring sercurity支持publicclassSecurityConfig{@BeanpublicUserDetailsServiceuserDetailsService(){UserDetails user =User.withUsername("hushang").password(passwordEncoder().encode("123456")).roles("user").build();returnnewInMemoryUserDetailsManager(user);}@BeanpublicPasswordEncoderpasswordEncoder(){//return NoOpPasswordEncoder.getInstance(); //不加密returnnewBCryptPasswordEncoder();//加密方式bcrypt}}
Spring Security支持的加密方式可以通过PasswordEncoderFactories查看
publicfinalclassPasswordEncoderFactories{privatePasswordEncoderFactories(){}publicstaticPasswordEncodercreateDelegatingPasswordEncoder(){// 默认使用的是BCryptPasswordEncoderString encodingId ="bcrypt";Map<String,PasswordEncoder> encoders =newHashMap();
encoders.put(encodingId,newBCryptPasswordEncoder());
encoders.put("ldap",newLdapShaPasswordEncoder());
encoders.put("MD4",newMd4PasswordEncoder());
encoders.put("MD5",newMessageDigestPasswordEncoder("MD5"));
encoders.put("noop",NoOpPasswordEncoder.getInstance());
encoders.put("pbkdf2",Pbkdf2PasswordEncoder.defaultsForSpringSecurity_v5_5());
encoders.put("pbkdf2@SpringSecurity_v5_8",Pbkdf2PasswordEncoder.defaultsForSpringSecurity_v5_8());
encoders.put("scrypt",SCryptPasswordEncoder.defaultsForSpringSecurity_v4_1());
encoders.put("scrypt@SpringSecurity_v5_8",SCryptPasswordEncoder.defaultsForSpringSecurity_v5_8());
encoders.put("SHA-1",newMessageDigestPasswordEncoder("SHA-1"));
encoders.put("SHA-256",newMessageDigestPasswordEncoder("SHA-256"));
encoders.put("sha256",newStandardPasswordEncoder());
encoders.put("argon2",Argon2PasswordEncoder.defaultsForSpringSecurity_v5_2());
encoders.put("argon2@SpringSecurity_v5_8",Argon2PasswordEncoder.defaultsForSpringSecurity_v5_8());returnnewDelegatingPasswordEncoder(encodingId, encoders);}}
测试类
importorg.springframework.security.crypto.bcrypt.BCrypt;importorg.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;publicstaticvoidmain(String[] args){//加密String passwd =BCrypt.hashpw("123456",BCrypt.gensalt());System.out.println(passwd);//校验boolean checkpw =BCrypt.checkpw("123456","$2a$10$KfdyA40l4iElg7ox9GLR9.4ujIv6q9EfOpcRwrM7zYQrDHZuYoIui");System.out.println(checkpw);BCryptPasswordEncoder passwordEncoder =newBCryptPasswordEncoder();// 加密String encode = passwordEncoder.encode("123455");System.out.println(encode);// 校验boolean matches = passwordEncoder.matches("123455","$2a$10$7ZeilxBWjUfv8XP7tlxZK.GKQPHG4dETOOYPscDZy1lVpK0PLGy96");System.out.println(matches);}
自定义用户加载方式
需要自定义从数据库获取用户信息,可以实现UserDetailsService接口
@Configuration@EnableWebSecurity//开启spring sercurity支持publicclassSecurityConfig{@BeanpublicPasswordEncoderpasswordEncoder(){//return NoOpPasswordEncoder.getInstance(); //不加密returnnewBCryptPasswordEncoder();//加密方式bcrypt}}
认证流程中
UsernamePasswordAuthenticationFilter
会找
UserDetailsService
接口类型,会调用到下面我们重写的方法中,把界面上输入的用户名传递过来
packagecom.tuling.helloworld.service;importorg.springframework.beans.factory.annotation.Autowired;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.security.crypto.password.PasswordEncoder;importorg.springframework.stereotype.Service;@ServicepublicclassTulingUserDetailServiceimplementsUserDetailsService{@AutowiredprivatePasswordEncoder passwordEncoder;@OverridepublicUserDetailsloadUserByUsername(String username)throwsUsernameNotFoundException{//TODO 根据用户名可以从数据库获取用户信息,角色以及权限信息// 模拟从数据库获取了用户信息,并封装成UserDetails对象// 这里的API方法和security5没什么改变,UserDetails user =User.withUsername("hushang").password(passwordEncoder.encode("123456")).roles("user").build();return user;}}/*
直接把密文存入也可以
UserDetails user = User
.withUsername("admin")
.password("$2a$10$KfdyA40l4iElg7ox9GLR9.4ujIv6q9EfOpcRwrM7zYQrDHZuYoIui")
.roles("user")
.build();
*/
补充知识点
// 伪代码,roles()指定角色的方法,其实是把我们的角色封装了一下,"ROLE_" + role 封装成了一个Authority权限对象roles(String... roles){List<GrantedAuthority> authorities =newArrayList(roles.length);
authorities.add(newSimpleGrantedAuthority("ROLE_"+ role));}
复制上一篇笔记中的内容,用户状态的判断
在生产环境下还有可能出现用户被禁用等等这些场景,我们这里也需要考虑进去,就比如某个用户现在数据库中是禁用状态,那么这里就不能让认证通过
在
org.springframework.security.core.userdetails.User()
对象的构造方法,除了 用户名+密码+一个角色封装之后的权限集合之外
publicUser(String username,String password,Collection<?extendsGrantedAuthority> authorities){this(username, password,true,true,true,true, authorities);}
其实还有一个更为复杂的构造方法
publicUser(String username,// 用户名String password,// 密码boolean enabled,// 是否可用boolean accountNonExpired,// 账号过期boolean credentialsNonExpired,// 凭证过期boolean accountNonLocked,// 账号锁定Collection<?extendsGrantedAuthority> authorities){......}
我们可以进行测试,现在将其中一个值该为false,然后测试登录认证
就会发现即使输入了正确的用户名和密码,还是登录不进去
自定义登录页面
本小节需了解SpringSecurity的过滤器链的配置
Spring Security默认登录页面通过
DefaultLoginPageGeneratingFilter#generateLoginPageHtml
生成
编写登录页面
<!DOCTYPEhtml><htmllang="en"><head><metacharset="UTF-8"><title>Title</title></head><body><formaction="/user/login"method="post">
用户名:<inputtype="text"name="username"/><br/>
密码:<inputtype="password"name="password"/><br/><inputtype="submit"value="提交"/></form></body></html>
配置Spring Security的过滤器链
@BeanpublicSecurityFilterChainsecurityFilterChain(HttpSecurity http)throwsException{//表单提交// /user/login接口不是我们程序员定义的接口
http.formLogin((formLogin)-> formLogin
.loginPage("/login.html")//指定自定义登录页面地址.loginProcessingUrl("/user/login")//登录访问路径:前台界面提交表单之后跳转到这个路径进行UserDetailsService的验证,必须和表单提交接口一样.defaultSuccessUrl("/admin/demo")//认证成功之后跳转的路径);//对请求进行访问控制设置
http.authorizeHttpRequests((authorizeHttpRequests)-> authorizeHttpRequests
//设置哪些路径可以直接访问,不需要认证.requestMatchers("/login.html","/user/login").permitAll().anyRequest().authenticated()//其他路径的请求都需要认证);//关闭跨站点请求伪造csrf防护
http.csrf((csrf)-> csrf.disable());return http.build();}
测试 http://localhost:8080/admin/demo ,会跳转到自定义登录界面
前后端分离认证
表单登录配置模块提供了
successHandler()
和
failureHandler()
两个方法,分别处理登录成功和登录失败的逻辑。
其中,successHandler()方法带有一个Authentication参数,携带当前登录用户名及其角色等信息;而failureHandler()方法携带一个AuthenticationException异常参数。
//前后端分离认证逻辑
http.formLogin((formLogin)-> formLogin
.loginProcessingUrl("/login")//登录访问接口.successHandler(newLoginSuccessHandler())//登录成功处理逻辑.failureHandler(newLoginFailureHandler())//登录失败处理逻辑);/**
* 认证成功处理逻辑,我们可以在这里生成token返回给前端
* Authentication参数,携带当前登录用户名及其角色等信息
*/publicclassLoginSuccessHandlerimplementsAuthenticationSuccessHandler{@OverridepublicvoidonAuthenticationSuccess(HttpServletRequest request,HttpServletResponse response,Authentication authentication)throwsIOException,ServletException{
response.setContentType("text/html;charset=utf-8");
response.getWriter().write("登录成功");}}///**
* 认证失败处理逻辑
*/publicclassLoginFailureHandlerimplementsAuthenticationFailureHandler{@OverridepublicvoidonAuthenticationFailure(HttpServletRequest request,HttpServletResponse response,AuthenticationException exception)throwsIOException,ServletException{// TODO
response.setContentType("text/html;charset=utf-8");
response.getWriter().write("登录失败");
exception.printStackTrace();}}
认证流程
SpringSecurity授权
授权的方式包括 web授权和方法授权:
- web授权是通过url拦截进行授权
- 方法授权是通过方法拦截进行授权
web授权:基于url的访问控制
Spring Security可以通过
http.authorizeRequests()
对web请求进行授权保护 ,Spring Security使用标准Filter建立了对web请求的拦截,最终实现对资源的授权访问。配置顺序会影响之后授权的效果,越是具体的应该放在前面,越是笼统的应该放到后面。
importcom.tuling.helloworld.handler.BussinessAccessDeniedHandler;importcom.tuling.helloworld.handler.LoginFailureHandler;importcom.tuling.helloworld.handler.LoginSuccessHandler;importorg.springframework.context.annotation.Bean;importorg.springframework.context.annotation.Configuration;importorg.springframework.security.config.annotation.web.builders.HttpSecurity;importorg.springframework.security.config.annotation.web.configuration.EnableWebSecurity;importorg.springframework.security.core.userdetails.User;importorg.springframework.security.core.userdetails.UserDetails;importorg.springframework.security.core.userdetails.UserDetailsService;importorg.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;importorg.springframework.security.crypto.password.NoOpPasswordEncoder;importorg.springframework.security.crypto.password.PasswordEncoder;importorg.springframework.security.provisioning.InMemoryUserDetailsManager;importorg.springframework.security.web.SecurityFilterChain;@Configuration@EnableWebSecuritypublicclassSecurityConfig{@BeanpublicSecurityFilterChainsecurityFilterChain(HttpSecurity http)throwsException{//前后端分离认证逻辑
http.formLogin((formLogin)-> formLogin
.loginProcessingUrl("/login").successHandler(newLoginSuccessHandler()).failureHandler(newLoginFailureHandler()));//对请求进行访问控制设置
http.authorizeHttpRequests((authorizeHttpRequests)-> authorizeHttpRequests
//设置哪些路径可以直接访问,不需要认证.requestMatchers("/login").permitAll()//不需要认证.requestMatchers("/index").hasRole("user")//需要user角色,底层会判断是否有ROLE_admin权限.requestMatchers("/index2").hasRole("admin").requestMatchers("/user/**").hasAuthority("user:api")//需要user:api权限.requestMatchers("/order/**").hasAuthority("order:api").anyRequest().authenticated()//其他路径的请求都需要认证,仅仅认证通过后就可以了,不会去进行鉴权);return http.build();}@BeanpublicUserDetailsServiceuserDetailsService(){UserDetails user =User.withDefaultPasswordEncoder().username("hushang").password("123456").roles("user").build();UserDetails admin =User.withDefaultPasswordEncoder().username("admin").password("123456")// 注意: roles和authorities不能同时配置,同时配置后者会覆盖前者的权限// roles()方法底层对我们角色加一个前缀ROLE_,然后还是调用的authorities()。而authorities()每次都是生成一个新数组赋值.authorities("ROLE_admin","ROLE_user","user:api","order:api").build();returnnewInMemoryUserDetailsManager(user,admin);}}
自定义授权失败异常处理
使用 Spring Security 时经常会看见 403(无权限)。Spring Security 支持自定义权限受限处理,需要实现 AccessDeniedHandler接口
我们先自定义一个类,实现AccessDeniedHandler接口
publicclassBussinessAccessDeniedHandlerimplementsorg.springframework.security.web.access.AccessDeniedHandler{@Overridepublicvoidhandle(HttpServletRequest request,HttpServletResponse response,AccessDeniedException accessDeniedException)throwsIOException,ServletException{ response.setContentType("text/html;charset=utf-8"); response.getWriter().write("没有访问权限"); accessDeniedException.printStackTrace();}}
在配置类中指定我们上面创建的类
@BeanpublicSecurityFilterChainsecurityFilterChain(HttpSecurity http)throwsException{//前后端分离认证逻辑 http.formLogin((formLogin)-> formLogin .loginProcessingUrl("/login").successHandler(newLoginSuccessHandler()).failureHandler(newLoginFailureHandler()));//对请求进行访问控制设置 http.authorizeHttpRequests((authorizeHttpRequests)-> authorizeHttpRequests .requestMatchers("/login").permitAll().requestMatchers("/index").hasRole("user").requestMatchers("/index2").hasRole("admin").requestMatchers("/user/**").hasAuthority("user:api").requestMatchers("/order/**").hasAuthority("order:api").anyRequest().authenticated());//关闭跨站点请求伪造csrf防护 http.csrf((csrf)-> csrf.disable());//访问受限后的异常处理 http.exceptionHandling((exceptionHandling)-> exceptionHandling.accessDeniedHandler(newBussinessAccessDeniedHandler()));return http.build();}
更全面一点的写法如下
importjakarta.servlet.ServletException;importjakarta.servlet.http.HttpServletRequest;importjakarta.servlet.http.HttpServletResponse;importorg.springframework.security.access.AccessDeniedException;importorg.springframework.stereotype.Component;importjava.io.IOException;@ComponentpublicclassBussinessAccessDeniedHandlerimplementsorg.springframework.security.web.access.AccessDeniedHandler{@Overridepublicvoidhandle(HttpServletRequest request,HttpServletResponse response,AccessDeniedException accessDeniedException)throwsIOException,ServletException{
response.setContentType("text/html;charset=utf-8");
response.getWriter().write("没有访问权限");
accessDeniedException.printStackTrace();}}
importcom.alibaba.fastjson.JSON;importjakarta.servlet.ServletException;importjakarta.servlet.http.HttpServletRequest;importjakarta.servlet.http.HttpServletResponse;importorg.springframework.http.HttpStatus;importorg.springframework.security.core.AuthenticationException;importorg.springframework.security.web.AuthenticationEntryPoint;importorg.springframework.stereotype.Component;importjava.io.IOException;importjava.io.PrintWriter;importjava.time.LocalDateTime;importjava.util.HashMap;importjava.util.Map;@ComponentpublicclassLoginAuthenticationEntryPointimplementsAuthenticationEntryPoint{@Overridepublicvoidcommence(HttpServletRequest request,HttpServletResponse response,AuthenticationException authException)throwsIOException,ServletException{// 如果验证失败,统一返回JSON串,并将状态码设置为401,表示未授权
response.setContentType("application/json;charset=utf-8");
response.setStatus(HttpStatus.UNAUTHORIZED.value());PrintWriter out = response.getWriter();Map<String,Object> data =newHashMap<>();
data.put("path", request.getRequestURI());
data.put("time",LocalDateTime.now().toString());
data.put("errCode",HttpStatus.UNAUTHORIZED.value());
data.put("errMsg",HttpStatus.UNAUTHORIZED.getReasonPhrase());
out.write(JSON.toJSONString(data));
out.flush();
out.close();}}
//访问受限后的异常处理
http.exceptionHandling((exceptionHandling)-> exceptionHandling
.authenticationEntryPoint(loginAuthenticationEntryPoint).accessDeniedHandler(bussinessAccessDeniedHandler));
方法授权:基于注解的访问控制
了解即可,注解的方式用的少
Spring Security在方法的权限控制上支持三种类型的注解,JSR-250注解、@Secured注解和支持表达式的注解。这三种注解默认都是没有启用的,需要通过@EnableGlobalMethodSecurity来进行启用。
@Configuration@EnableWebSecurity@EnableGlobalMethodSecurity(jsr250Enabled =true, securedEnabled =true, prePostEnabled =true)publicclassSecurityConfig{}
接下来是Controller层 注解的使用
//Controller@RolesAllowed({"ROLE_user","ROLE_admin"})//配置访问此方法时应该具有的角色@GetMapping("/index5")publicStringindex5(){return"index5";}@Secured("ROLE_admin")//配置访问此方法时应该具有的角色@GetMapping("/index6")publicStringindex6(){return"index6";}
Spring Security中定义了四个支持使用表达式的注解,分别是@PreAuthorize、@PostAuthorize、@PreFilter和@PostFilter。其中前两者可以用来在方法调用前或者调用后进行权限检查,后两者可以用来对集合类型的参数或者返回值进行过滤。
@PreAuthorize("hasRole('ROLE_admin') and #id<10 ")//访问此方法需要具有admin角色,同时限制只能查询id小于10的用户@GetMapping("/findUserById")publicStringfindById(long id){//TODO 查询数据库获取用户信息return"success";}
利用过滤器实现动态权限控制
Spring Security从5.5之后动态权限控制方式已经改变。
5.5之前需要实现接口:
- FilterInvocationSecurityMetadataSource: 获取访问URL所需要的角色信息
- AccessDecisionManager: 用于权限校验,失败抛出AccessDeniedException 异常
5.5之后,利用过滤器动态控制权限,在
AuthorizationFilte
r中,只需要实现接口
AuthorizationManager
,如果没有权限,抛出AccessDeniedException异常
权限校验核心逻辑:
org.springframework.security.web.access.intercept.AuthorizationFilter#doFilter
》org.springframework.security.authorization.AuthorityAuthorizationManager#check
》org.springframework.security.authorization.AuthoritiesAuthorizationManager#isAuthorized
Spring Security整合JWT
接下来的案例是Spring Security整合JWT实现自定义登录认证
自定义登录认证的业务需求
用spring boot + spring security+JWT 框架实现登录认证授权功能,用户登录成功后,服务端利用JWT生成token
之后客户端每次访问接口,都需要在请求头上添加
Authorization:Bearer token
的方式传值到服务器端,服务器端再从token中解析和校验token的合法性
如果合法,则取出用户数据,保存用户信息,不需要在校验登录;否则就需要重新登录
JWT详解
什么是JWT
JSON Web Token(JWT)是一个开放的行业标准(RFC 7519),它定义了一种简介的、自包含的协议格式,用于在通信双方传递json对象,传递的信息经过数字签名可以被验证和信任。
JWT可以使用HMAC算法或使用RSA的公钥/私钥对来签名,防止被篡改。
官网: https://jwt.io/
标准: https://tools.ietf.org/html/rfc7519
JWT令牌的优点:
- jwt基于json,非常方便解析。
- 可以在令牌中自定义丰富的内容,易扩展。
- 通过非对称加密算法及数字签名技术,JWT防止篡改,安全性高。
- 资源服务使用JWT可不依赖授权服务即可完成授权。
缺点:
- JWT令牌较长,占存储空间比较大。
- 安全性取决于密钥管理JWT 的安全性取决于密钥的管理。如果密钥被泄露或者被不当管理,那么 JWT 将会受到攻击。因此,在使用 JWT 时,一定要注意密钥的管理,包括生成、存储、更新、分发等等。
- 无法撤销由于 JWT 是无状态的,一旦 JWT 被签发,就无法撤销。如果用户账户在使用 JWT 认证期间被注销或禁用,那么服务端就无法阻止该用户继续使用之前签发的 JWT。因此,开发人员需要设计额外的机制来撤销 JWT,例如使用黑名单或者设置短期有效期等等。
使用 JWT 主要用来做下面两点:
- 认证(Authorization):这是使用 JWT 最常见的一种情况,一旦用户登录,后面每个请求都会包含 JWT,从而允许用户访问该令牌所允许的路由、服务和资源。单点登录是当今广泛使用 JWT 的一项功能,因为它的开销很小。
- 信息交换(Information Exchange):JWT 是能够安全传输信息的一种方式。通过使用公钥/私钥对 JWT 进行签名认证。此外,由于签名是使用 head 和 payload 计算的,因此你还可以验证内容是否遭到篡改。
JWT组成
一个JWT实际上就是一个字符串,它由三部分组成,头部(header)、载荷(payload)与签名(signature)。
头部(header)
头部用于描述关于该JWT的最基本的信息:类型(即JWT)以及签名所用的算法(如HMACSHA256或RSA)等。这也可以被表示成一个JSON对象:
{"alg":"HS256","typ":"JWT"}
然后将头部进行base64加密(该加密是可以对称解密的),构成了第一部分:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
载荷(payload)
第二部分是载荷,就是存放有效信息的地方。这个名字像是特指飞机上承载的货品,这些有效信息包含三个部分:
标准中注册的声明(建议但不强制使用)iss: jwt签发者sub: jwt所面向的用户aud: 接收jwt的一方exp: jwt的过期时间,这个过期时间必须要大于签发时间nbf: 定义在什么时间之前,该jwt都是不可用的.iat: jwt的签发时间jti: jwt的唯一身份标识,主要用来作为一次性token,从而回避重放攻击。
公共的声明公共的声明可以添加任何的信息,一般添加用户的相关信息或其他业务需要的必要信息.但不建议添加敏感信息,因为该部分在客户端可解密
私有的声明私有声明是提供者和消费者所共同定义的声明,一般不建议存放敏感信息,因为base64是对称解密的,意味着该部分信息可以归类为明文信息。
定义一个payload:
{"sub":"1234567890","name":"John Doe","iat":1516239022}
然后将其进行base64加密,得到Jwt的第二部分:
eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ
签名(signature)
jwt的第三部分是一个签证信息,这个签证信息由三部分组成:
- header (base64后的)
- payload (base64后的)
- secret(盐,一定要保密)
这个部分需要base64加密后的header、base64加密后的payload使用
.
连接组成的字符串,然后通过header中声明的加密方式进行加盐secret组合加密,然后就构成了jwt的第三部分:
var encodedString =base64UrlEncode(header)+'.'+base64UrlEncode(payload);var signature =HMACSHA256(encodedString,'hushang');// km962Qj9Dvkjovs-ZNoALRsB4WRBKh-LjSuMe4yiIHs
将这三部分用.连接成一个完整的字符串,构成了最终的jwt:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.km962Qj9Dvkjovs-ZNoALRsB4WRBKh-LjSuMe4yiIHs
注意:secret是保存在服务器端的,jwt的签发生成也是在服务器端的,secret就是用来进行jwt的签发和jwt的验证,所以,它就是你服务端的私钥,在任何场景都不应该流露出去。一旦客户端得知这个secret, 那就意味着客户端是可以自我签发jwt了。
如何应用
一般是在请求头里加入
Authorization
,并加上
Bearer
标注:
fetch('api/user/1',{headers:{'Authorization':'Bearer '+ token
}})
服务端会验证token,如果验证通过就会返回相应的资源。整个流程就是这样的:
自定义登录核心实现
maven依赖
<?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 https://maven.apache.org/xsd/maven-4.0.0.xsd"><modelVersion>4.0.0</modelVersion><parent><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-parent</artifactId><version>3.1.4</version><relativePath/><!-- lookup parent from repository --></parent><groupId>com.tuling</groupId><artifactId>jwtdemo</artifactId><version>0.0.1-SNAPSHOT</version><name>jwtdemo</name><description>jwtdemo</description><properties><java.version>17</java.version></properties><dependencies><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-security</artifactId></dependency><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-web</artifactId></dependency><!--JWT依赖--><dependency><groupId>io.jsonwebtoken</groupId><artifactId>jjwt</artifactId><version>0.9.1</version></dependency><dependency><groupId>javax.xml.bind</groupId><artifactId>jaxb-api</artifactId><version>2.3.1</version></dependency><dependency><groupId>org.springframework.security</groupId><artifactId>spring-security-jwt</artifactId><version>1.1.1.RELEASE</version></dependency><dependency><groupId>com.alibaba</groupId><artifactId>fastjson</artifactId><version>1.2.83</version></dependency><dependency><groupId>org.projectlombok</groupId><artifactId>lombok</artifactId></dependency><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-test</artifactId><scope>test</scope></dependency><dependency><groupId>org.springframework.security</groupId><artifactId>spring-security-test</artifactId><scope>test</scope></dependency></dependencies><build><plugins><plugin><groupId>org.springframework.boot</groupId><artifactId>spring-boot-maven-plugin</artifactId></plugin></plugins></build></project>
创建一个JWT的工具类
importcom.alibaba.fastjson.JSON;importorg.springframework.security.jwt.Jwt;importorg.springframework.security.jwt.JwtHelper;importorg.springframework.security.jwt.crypto.sign.MacSigner;importjava.util.Map;publicclassJWTUtils{/**
* 创建JWT
* @param secret
* @param claims 创建payload的私有声明(根据特定的业务需要添加,如果要拿这个做验证,一般是需要和jwt的接收方提前沟通好验证方式的)
* @return
*/publicstaticStringgetAccessToken(String secret,Map<String,Object> claims){// 指定签名的时候使用的签名算法。MacSigner rsaSigner=newMacSigner(secret);Jwt jwt =JwtHelper.encode(JSON.toJSONString(claims), rsaSigner);return jwt.getEncoded();}publicstaticMap<String,Object>parseToken(String token){Jwt jwt =JwtHelper.decode(token);returnJSON.parseObject(jwt.getClaims());}/**
* 根据传入的token过期时间判断token是否已过期
* @param expiresIn
* @return true-已过期,false-没有过期
*/publicstaticbooleanisExpiresIn(long expiresIn){long now=System.currentTimeMillis();return now>expiresIn;}}
自定义一个认证成功的处理类,当SpringSecurity认证通过后调用的方法,需要在配置类中进行配置
importcom.alibaba.fastjson.JSON;importcom.tuling.jwtdemo.utils.JWTUtils;importjakarta.servlet.ServletException;importjakarta.servlet.http.HttpServletRequest;importjakarta.servlet.http.HttpServletResponse;importorg.springframework.security.core.Authentication;importorg.springframework.security.core.userdetails.User;importorg.springframework.security.web.authentication.AuthenticationSuccessHandler;importorg.springframework.stereotype.Component;importjava.io.IOException;importjava.io.PrintWriter;importjava.util.HashMap;importjava.util.Map;@ComponentpublicclassLoginSuccessHandlerimplementsAuthenticationSuccessHandler{privateString secret ="123456xxxx";//秘钥privatelong expMillis =3600000;//30分钟过期,可根据实际情况自行修改;@OverridepublicvoidonAuthenticationSuccess(HttpServletRequest request,HttpServletResponse response,Authentication authentication)throwsIOException,ServletException{Object principal = authentication.getPrincipal();
response.setContentType("application/json;charset=utf-8");PrintWriter out = response.getWriter();User user=(User) principal;//1.从authentication 取出用户信息,保存到claims map对象Map<String,Object> claims=newHashMap<>();
claims.put("username",user.getUsername());
claims.put("authorities",user.getAuthorities());
claims.put("enabled",user.isEnabled());
claims.put("expiresIn",(System.currentTimeMillis()+expMillis));//2.生成tokenString token =JWTUtils.getAccessToken(secret, claims);Map<String,Object>result=newHashMap<>();
result.put("accessToken",token);//3.将token以JSON串返回前端
out.write(JSON.toJSONString(result));
out.flush();
out.close();}}
创建一个SpringSecurity的配置类,主要功能是:
.successHandler(loginSuccessHandler)
指定认证成功之后的处理handler类http.addFilterBefore(..)
添加JWT登录拦截器,在登录之前获取token并校验
importcom.tuling.jwtdemo.filter.JwtAuthenticationTokenFilter;importcom.tuling.jwtdemo.handler.*;importjakarta.servlet.ServletException;importjakarta.servlet.http.HttpServletRequest;importjakarta.servlet.http.HttpServletResponse;importorg.springframework.beans.factory.annotation.Autowired;importorg.springframework.context.annotation.Bean;importorg.springframework.context.annotation.Configuration;importorg.springframework.security.access.AccessDeniedException;importorg.springframework.security.config.annotation.web.builders.HttpSecurity;importorg.springframework.security.config.annotation.web.configuration.EnableWebSecurity;importorg.springframework.security.config.http.SessionCreationPolicy;importorg.springframework.security.web.SecurityFilterChain;importorg.springframework.security.web.access.AccessDeniedHandler;importorg.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;importjava.io.IOException;@Configuration@EnableWebSecuritypublicclassWebSecurityConfig{// 登录成功之后处理类@AutowiredprivateLoginSuccessHandler loginSuccessHandler;// 登录失败的处理类,与当前业务关系不大 就不贴代码了@AutowiredprivateLoginFailureHandler loginFailureHandler;// 下面两个是认证和授权失败后自定义处理类,与当前业务关系不大 就不贴代码了@AutowiredprivateLoginAuthenticationEntryPoint loginAuthenticationEntryPoint;@AutowiredprivateBussinessAccessDeniedHandler bussinessAccessDeniedHandler;// 校验JWTtoken的Filter,在下面会有具体代码@AutowiredprivateJwtAuthenticationTokenFilter jwtAuthenticationTokenFilter;// 登出相关的handler,与当前业务关系不大 就不贴代码了@AutowiredprivateMyLogoutSuccessHandler myLogoutSuccessHandler;@BeanpublicSecurityFilterChainsecurityFilterChain(HttpSecurity http)throwsException{//对请求进行访问控制设置
http.authorizeHttpRequests((authorizeHttpRequests)-> authorizeHttpRequests
//设置哪些路径可以直接访问,不需要认证;哪些路径需要什么什么权限.requestMatchers("/login").permitAll().requestMatchers("/user/**").hasRole("admin").anyRequest().authenticated()//其他路径的请求都需要认证);//自定义登录逻辑
http.formLogin((formLogin)-> formLogin
//登录访问路径:前台界面提交表单之后跳转到这个路径进行UserDetailsService的验证,必须和表单提交接口一样 // 并且提交的用户名和密码请求字段名必须为username、password;在UsernamePasswordAuthenticationFilter类中写死了.loginProcessingUrl("/login")// 认证成功之后的处理handler类,也就是上面我们自己定义的类.successHandler(loginSuccessHandler).failureHandler(loginFailureHandler));//添加JWT登录拦截器,在登录之前获取token并校验
http.addFilterBefore(jwtAuthenticationTokenFilter,UsernamePasswordAuthenticationFilter.class);//访问受限后的异常处理
http.exceptionHandling((exceptionHandling)-> exceptionHandling
.authenticationEntryPoint(loginAuthenticationEntryPoint).accessDeniedHandler(bussinessAccessDeniedHandler));//自定义退出登录逻辑
http.logout((logout)-> logout
.logoutSuccessHandler(myLogoutSuccessHandler));//关闭跨站点请求伪造csrf防护
http.csrf((csrf)-> csrf.disable());return http.build();}}
定义JWT 校验token的Filter,主要功能为:
- 验证token是否过期
- 从token中取用户信息
- 因为当前是整合了SpringSecurity,之后还有认证的Filter去处理,所以这里就直接
setAuthentication(null)
置为null就行了。需要注意的是,我们必须在config配置类中指定JWT 验证token的filter 在 认证用户名密码的filter之前。http.addFilterBefore(...)
importcom.tuling.jwtdemo.utils.JWTUtils;importjakarta.servlet.FilterChain;importjakarta.servlet.ServletException;importjakarta.servlet.http.HttpServletRequest;importjakarta.servlet.http.HttpServletResponse;importlombok.extern.slf4j.Slf4j;importorg.springframework.beans.factory.annotation.Autowired;importorg.springframework.http.HttpHeaders;importorg.springframework.security.authentication.UsernamePasswordAuthenticationToken;importorg.springframework.security.web.authentication.WebAuthenticationDetailsSource;importorg.springframework.security.core.userdetails.UserDetails;importorg.springframework.security.core.userdetails.UserDetailsService;importorg.springframework.stereotype.Component;importorg.springframework.util.StringUtils;importorg.springframework.web.filter.OncePerRequestFilter;importorg.springframework.security.core.context.SecurityContextHolder;importjava.io.IOException;importjava.util.Map;@Slf4j@ComponentpublicclassJwtAuthenticationTokenFilterextendsOncePerRequestFilter{@AutowiredprivateUserDetailsService userDetailsService;@OverrideprotectedvoiddoFilterInternal(HttpServletRequest request,HttpServletResponse response,FilterChain filterChain)throwsServletException,IOException{//1.从请求头中取出token,进行判断,如果没有携带token,则继续往下走其他的其他的filter逻辑String tokenValue = request.getHeader(HttpHeaders.AUTHORIZATION);if(!StringUtils.hasText(tokenValue)){
filterChain.doFilter(request, response);return;}//2. 校验token//2.1 将token切割前缀“bearer ”,然后使用封装的JWT工具解析token,得到一个map对象String token = tokenValue.substring("bearer ".length());Map<String,Object> map =JWTUtils.parseToken(token);//2.2 取出token中的过期时间,调用JWT工具中封装的过期时间校验,如果token已经过期,则删除登录的用户,继续往下走其他filter逻辑if(JWTUtils.isExpiresIn((long) map.get("expiresIn"))){//token 已经过期// 因为当前是整合了SpringSecurity,之后还有认证的Filter去处理,所以这里就直接置为null就行了// 当然也可以自定义,比如直接抛自定义的业务异常SecurityContextHolder.getContext().setAuthentication(null);
filterChain.doFilter(request, response);return;}String username =(String) map.get("username");if(StringUtils.hasText(username)&&SecurityContextHolder.getContext().getAuthentication()==null){// 调用实现了UserDetailsService接口的Service方法,获取用户信息// 当然也可以自己改造,自己写service方法,自己查数据库,自己缓存User信息UserDetails userDetails = userDetailsService.loadUserByUsername(username);if(userDetails !=null&& userDetails.isEnabled()){UsernamePasswordAuthenticationToken authentication =newUsernamePasswordAuthenticationToken(userDetails,null, userDetails.getAuthorities());
authentication.setDetails(newWebAuthenticationDetailsSource().buildDetails(request));
log.info("authenticated user {}, setting security context", username);// 设置用户登录状态// 添加了下面这行代码,之后的认证Filter就不会再对当前请求进行认证了SecurityContextHolder.getContext().setAuthentication(authentication);// 其实哪怕设置的全都是null,之后的认证Filter也不会对当前请求进行认证//SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(null,null,null));}}
filterChain.doFilter(request, response);}}
创建一个查询User的service,我这里只是随便写一个UserDetails,正常处理是去查询数据库,然后封装为一个UserDetails对象返回
importorg.springframework.security.core.authority.AuthorityUtils;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;@ServicepublicclassUserDetailsServiceImplimplementsUserDetailsService{@OverridepublicUserDetailsloadUserByUsername(String username)throwsUsernameNotFoundException{//TODO 从数据库获取用户信息returnnewUser("hushang","{noop}123456",AuthorityUtils.commaSeparatedStringToAuthorityList("ROLE_admin,ROLE_user"));}}
测试效果
启动应用后调用登录接口返回token信息,必须是发送post请求
重启微服务之后,不带token信息访问接口,返回401,没有权限
带token信息访问接口,返回正常
我们也可以自定义登录认证接口
@BeanpublicSecurityFilterChainsecurityFilterChain(HttpSecurity http)throwsException{
http.authorizeHttpRequests((authorizeHttpRequests)-> authorizeHttpRequests
.requestMatchers("/user/**").hasRole("admin").requestMatchers("/loginXXX").permitAll()// 对我们自定义的登录接口放行.anyRequest().authenticated());//自定义登录逻辑
http.formLogin((formLogin)-> formLogin
.loginProcessingUrl("/loginXXX")// 自定义登录接口.successHandler(loginSuccessHandler).failureHandler(loginFailureHandler));//......}
测试:
JWT续期问题
JWT通常是在用户登录后签发的,用于验证用户身份和授权。
JWT 的有效期限(或称“过期时间”)通常是一段时间(例如1小时),过期后用户需要重新登录以获取新的JWT。然而,在某些情况下,用户可能会在JWT到期之前使用应用程序,这可能会导致应用程序不可用或需要用户重新登录。
JWT的续期都需要重新生成token,区别就是每一次请求都生成新token,或者是仅仅对即将过期/已经过期生成新token
刷新令牌(Refresh Token)
- 校验当前token是否有效
- 从当前token中取用户信息
- 根据用户信息重新生成一个新token返回
这种方式仅仅只是不需要用户重新登录,但是每次请求都会生成新token,都需要前端来保存新的token,覆盖老的token
伪代码如下
// 方法接收一个刷新令牌作为参数publicStringrefreshAccessToken(String refreshToken){// 验证该令牌是否有效boolean isValid =validateRefreshToken(refreshToken);if(isValid){// 获取与令牌关联的用户信息String userId =getUserIdFromRefreshToken(refreshToken);// 生成一个新的JWT访问令牌String newAccessToken =generateAccessToken(userId);return newAccessToken;}else{thrownewRuntimeException("Invalid refresh token.");}}
自动延长JWT有效期
在某些情况下,JWT可以自动延长其有效期。例如,当用户在JWT过期前继续使用应用程序时,应用重新设置token过期时间。
要自动延长JWT有效期,您可以在每次请求时检查JWT的过期时间,并在必要时更新JWT的过期时间。
具体的思路就是:每次请求检查token是否过期,如果过期了则获取用户信息重新生成一个token返回,如果没有过期但即将过期,也是生成一个新token返回
publicStringgetAccessToken(HttpServletRequest request){// 从请求中提取JWT访问令牌String accessToken =extractAccessTokenFromRequest(request);// 检查JWT的过期时间是否已过期if(isAccessTokenExpired(accessToken)){// 过期// 从token中获取userid,在重新生成一个tokenString userId =extractUserIdFromAccessToken(accessToken);
accessToken =generateNewAccessToken(userId);// 没有过期,检查是不是即将过期}elseif(shouldRefreshAccessToken(accessToken)){String userId =extractUserIdFromAccessToken(accessToken);
accessToken =generateNewAccessToken(userId);}return accessToken;}privatebooleanisAccessTokenExpired(String accessToken){// 提取过期时间Date expirationTime =extractExpirationTimeFromAccessToken(accessToken);// 过期时间是否在当前时间之前,其实通俗理解就是如果过期了就返回truereturn expirationTime.before(newDate());}privatebooleanshouldRefreshAccessToken(String accessToken){// 提取过期时间Date expirationTime =extractExpirationTimeFromAccessToken(accessToken);Date currentTime =newDate();// 距离过期的剩余时间long remainingTime = expirationTime.getTime()- currentTime.getTime();// 如果令牌在接下来的5分钟内到期,则需要刷新令牌return remainingTime <5*60*1000;}privateStringgenerateNewAccessToken(String userId){// 重新生成JWT tokenDate expirationTime =newDate(System.currentTimeMillis()+ACCESS_TOKEN_EXPIRATION_TIME);String accessToken =generateAccessToken(userId, expirationTime);return accessToken;}
SpringSecurity总结
我们使用SpringSecurity主要就是做认证和授权
认证
- 我们先自定义一个认证成功与认证失败的处理handler类,我们会在认证成功handler中生成token返回给前端
- 在config配置类中指定要登录认证的接口路径,并指定认证成功与认证失败的处理handler类
http.formLogin((formLogin)-> formLogin .loginProcessingUrl("/login").successHandler(loginSuccessHandler).failureHandler(loginFailureHandler));
- 前端调用登录认证接口时,用户名密码必须是username和password,这是在
UsernamePasswordAuthenticationFilter
默认值,更改更改需要我们做相应的配置 - 自定义一个Filter,必须要在认证用户名密码filter之前执行;该Filter校验token,验证通过就跳过后续的认证Filter
//添加JWT登录拦截器,在登录之前获取token并校验http.addFilterBefore(jwtAuthenticationTokenFilter,UsernamePasswordAuthenticationFilter.class);// 在Filter验证token通过之后,需要在最后添加下面这条语句,用以跳过UsernamePasswordAuthenticationFilter的认证流程SecurityContextHolder.getContext().setAuthentication(...)
鉴权
- 自定义查询UserService类,实现
UserDetailsService
接口,在重写的抽象方法中利用username去查询DB,将权限信息一起封装成UserDetails对象返回UserDetailsServiceImplimplementsUserDetailsService
- 在config配置类中或者使用注解的方式,定义接口需要的权限
http.authorizeHttpRequests((authorizeHttpRequests)-> authorizeHttpRequests //设置哪些路径可以直接访问,不需要认证.requestMatchers("/login").permitAll().requestMatchers("/user/**").hasRole("admin").anyRequest().authenticated()//其他路径的请求都需要认证);
- 当有请求时,会在
AuthorizationFilter
中对该请求进行鉴权
版权归原作者 胡尚 所有, 如有侵权,请联系我们删除。