一般Web应用的需要进行认证和授权。
而认证和授权也是SpringSecurity作为安全框架的核心功能。
视频地址:https://www.bilibili.com/video/BV1mm4y1X7Hc?p=1
Web系统中登录认证(Authentication)的核心就是凭证机制,无论是Session还是JWT,都是在用户成功登录时返回给用户一个凭证,后续用户访问接口需携带凭证来标明自己的身份。后端会对需要进行认证的接口进行安全判断,若凭证没问题则代表已登录就放行接口,若凭证有问题则直接拒绝请求。这个安全判断都是放在过滤器里统一处理的:
登录认证是对用户的身份进行确认,权限授权(Authorization)是对用户能否访问某个资源进行确认,授权发生在认证之后。 这种通用逻辑都是放在过滤器里进行的统一操作:
LoginFilter先进行登录认证判断,认证通过后再由AuthFilter进行权限授权判断,一层一层没问题后才会执行我们真正的业务逻辑。
Spring Security对Web系统的支持就是基于这一个个过滤器组成的过滤器链:
用户请求都会经过Servlet的过滤器链
在Servlet过滤器链中,Spring Security向其添加了一个FilterChainProxy过滤器,这个代理过滤器会创建一套Spring Security自定义的过滤器链,然后执行一系列过滤器。我们可以大概看一下FilterChainProxy的大致源码:
@Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { ...省略其他代码 // 获取Spring Security的一套过滤器 Listfilters = getFilters(request); // 将这一套过滤器组成Spring Security自己的过滤链,并开始执行 VirtualFilterChain vfc = new VirtualFilterChain(fwRequest, chain, filters); vfc.doFilter(request, response); ...省略其他代码 }
我们可以看一下Spring Security默认会启用多少过滤器:
这里面我们只需要重点关注两个过滤器即可:UsernamePasswordAuthenticationFilter负责登录认证,FilterSecurityInterceptor负责权限授权。
Spring Security的核心逻辑全在这一套过滤器中,过滤器里会调用各种组件完成功能,掌握了这些过滤器和组件你就掌握了Spring Security!这个框架的使用方式就是对这些过滤器和组件进行扩展。
一定要记住这句话,带着这句话去使用和理解Spring Security,你会像站在高处俯瞰,整个框架的脉络一目了然。
刚才我们总览了一下全局,现在我们就开始进行代码编写了。
要使用Spring Security肯定是要先引入依赖包(Web项目其他必备依赖我在之前文章中已讲解,这里就不过多阐述了):
依赖包导入后,Spring Security就默认提供了许多功能将整个应用给保护了起来:
要求经过身份验证的用户才能与应用程序进行交互
创建好了默认登录表单
生成用户名为user的随机密码并打印在控制台上
CSRF攻击防护、Session Fixation攻击防护
等等等等…
在实际开发中,这些默认配置好的功能往往不符合我们的实际需求,所以我们一般会自定义一些配置。配置方式很简单,新建一个配置类即可:
在该类中重写WebSecurityConfigurerAdapter的方法就能对Spring Security进行自定义配置。
依赖包和配置类准备好后,接下来我们要完成的第一个功能那自然是登录认证,毕竟用户要使用我们系统第一步就是登录。之前文章介绍了Session和JWT两种认证方式,这里我们来用Spring Security实现这两种认证。
不管哪种认证方式和框架,有些核心概念是不会变的,这些核心概念在安全框架中会以各种组件来体现,了解各个组件的同时功能也就跟着实现了功能。
我们系统中会有许多用户,确认当前是哪个用户正在使用我们系统就是登录认证的最终目的。这里我们就提取出了一个核心概念:当前登录用户/当前认证用户。整个系统安全都是围绕当前登录用户展开的!这个不难理解,要是当前登录用户都不能确认了,那A下了一个订单,下到了B的账户上这不就乱套了。这一概念在Spring Security中的体现就是 Authentication,它存储了认证信息,代表当前登录用户。
我们在程序中如何获取并使用它呢?我们需要通过 SecurityContext 来获取Authentication,看了之前文章的朋友大概就猜到了这个SecurityContext就是我们的上下文对象!
这种在一个线程中横跨若干方法调用,需要传递的对象,我们通常称之为上下文(Context)。上下文对象是非常有必要的,否则你每个方法都得额外增加一个参数接收对象,实在太麻烦了。
这个上下文对象则是交由 SecurityContextHolder 进行管理,你可以在程序任何地方使用它:
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
可以看到调用链路是这样的:SecurityContextHolder SecurityContext Authentication。
SecurityContextHolder原理非常简单,就是和我们之前实现的上下文对象一样,使用ThreadLocal来保证一个线程中传递同一个对象!源码我就不贴了,具体可看之前文章写的上下文对象实现。
现在我们已经知道了Spring Security中三个核心组件:
Authentication:存储了认证信息,代表当前登录用户
SeucirtyContext:上下文对象,用来获取Authentication SecurityContextHolder:上下文管理对象,用来在程序任何地方获取SecurityContext
他们关系如下:
Authentication中那三个玩意就是认证信息:
Principal:用户信息,没有认证时一般是用户名,认证后一般是用户对象
Credentials:用户凭证,一般是密码
Authorities:用户权限
现在我们知道如何获取并使用当前登录用户了,那这个用户是怎么进行认证的呢?总不能我随便new一个就代表用户认证完毕了吧。所以我们还缺一个生成Authentication对象的认证过程!
认证过程就是登录过程,不使用安全框架时咱们的认证过程是这样的:
查询用户数据 判断账号密码是否正确 正确则将用户信息存储到上下文中 上下文中有了这个对象则代表该用户登录了
Spring Security的认证流程也是如此:
Authentication authentication = new UsernamePasswordAuthenticationToken(用户名, 用户密码, 用户的权限集合); SecurityContextHolder.getContext().setAuthentication(authentication);
和不使用安全框架一样,将认证信息放到上下文中就代表用户已登录。上面代码演示的就是Spring Security最简单的认证方式,直接将Authentication放置到SecurityContext中就完成认证了!
这个流程和之前获取当前登录用户的流程自然是相反的:Authentication SecurityContext``SecurityContextHolder。
是不是觉得,就这?这就完成认证啦?这也太简单了吧。对于Spring Security来说,这样确实就完成了认证,但对于我们来说还少了一步,那就是判断用户的账号密码是否正确。用户进行登录操作时从会传递过来账号密码,我们肯定是要查询用户数据然后判断传递过来的账号密码是否正确,只有正确了咱们才会将认证信息放到上下文对象中,不正确就直接提示错误:
// 调用service层执行判断业务逻辑 if (!userService.login(用户名, 用户密码)) { return "账号密码错误"; } // 账号密码正确了才将认证信息放到上下文中(用户权限需要再从数据库中获取,后面再说,这里省略) Authentication authentication = new UsernamePasswordAuthenticationToken(用户名, 用户密码, 用户的权限集合); SecurityContextHolder.getContext().setAuthentication(authentication);
这样才算是一个完整的认证过程,和不使用安全框架时的流程是一样的哦,只是一些组件之前是我们自己实现的。
这里查询用户信息并校验账号密码是完全由我们自己在业务层编写所有逻辑,其实这一块Spring Security也有组件供我们使用:
AuthenticationManager 就是Spring Security用于执行身份验证的组件,只需要调用它的authenticate方法即可完成认证。Spring Security默认的认证方式就是在UsernamePasswordAuthenticationFilter这个过滤器中调用这个组件,该过滤器负责认证逻辑。
我们要按照自己的方式使用这个组件,先在之前配置类配置一下:
@EnableWebSecurity public class SecurityConfig extends WebSecurityConfigurerAdapter { @Bean @Override protected AuthenticationManager authenticationManager() throws Exception { return super.authenticationManager(); } }
这里我们写上完整的登录接口代码:
@RestController @RequestMapping("/API") public class LoginController { @Autowired private AuthenticationManager authenticationManager; @PostMapping("/login") public String login(@RequestBody LoginParam param) { // 生成一个包含账号密码的认证信息 Authentication token = new UsernamePasswordAuthenticationToken(param.getUsername(), param.getPassword()); // AuthenticationManager校验这个认证信息,返回一个已认证的Authentication Authentication authentication = authenticationManager.authenticate(token); // 将返回的Authentication存到上下文中 SecurityContextHolder.getContext().setAuthentication(authentication); return "登录成功"; } }
注意,这里流程和之前说的流程是完全一样的,只是用户身份验证改成了使用AuthenticationManager来进行。
AuthenticationManager的校验逻辑非常简单:
根据用户名先查询出用户对象(没有查到则抛出异常) 将用户对象的密码和传递过来的密码进行校验,密码不匹配则抛出异常
这个逻辑没啥好说的,再简单不过了。重点是这里每一个步骤Spring Security都提供了组件:
是谁执行 根据用户名查询出用户对象 逻辑的呢?用户对象数据可以存在内存中、文件中、数据库中,你得确定好怎么查才行。这一部分就是交由 UserDetialsService 处理,该接口只有一个方法loadUserByUsername(String username),通过用户名查询用户对象,默认实现是在内存中查询。
那查询出来的 用户对象 又是什么呢?每个系统中的用户对象数据都不尽相同,咱们需要确认我们的用户数据是啥样的才行。Spring Security中的用户数据则是由 **UserDetails**来体现,该接口中提供了账号、密码等通用属性。
对密码进行校验大家可能会觉得比较简单,if、else搞定,就没必要用什么组件了吧?但框架毕竟是框架考虑的比较周全,除了if、else外还解决了密码加密的问题,这个组件就是**PasswordEncoder**,负责密码加密与校验。
我们可以看下AuthenticationManager校验逻辑的大概源码:
public Authentication authenticate(Authentication authentication) throws AuthenticationException { ...省略其他代码 // 传递过来的用户名 String username = authentication.getName(); // 调用UserDetailService的方法,通过用户名查询出用户对象UserDetail(查询不出来UserDetailService则会抛出异常) UserDetails userDetails = this.getUserDetailsService().loadUserByUsername(username); String presentedPassword = authentication.getCredentials().toString(); // 传递过来的密码 String password = authentication.getCredentials().toString(); // 使用密码解析器PasswordEncoder传递过来的密码是否和真实的用户密码匹配 if (!passwordEncoder.matches(password, userDetails.getPassword())) { // 密码错误则抛出异常 throw new BadCredentialsException("错误信息..."); } // 注意哦,这里返回的已认证Authentication,是将整个UserDetails放进去充当Principal UsernamePasswordAuthenticationToken result = new UsernamePasswordAuthenticationToken(userDetails, authentication.getCredentials(), userDetails.getAuthorities()); return result; ...省略其他代码 }
UserDetialsService UserDetails PasswordEncoder,这三个组件Spring Security都有默认实现,这一般是满足不了我们的实际需求的,所以这里我们自己来实现这些组件!
首先是PasswordEncoder,这个接口很简单就两个重要方法:
public interface PasswordEncoder { /** * 加密 */ String encode(CharSequence rawPassword); /** * 将未加密的字符串(前端传递过来的密码)和已加密的字符串(数据库中存储的密码)进行校验 */ boolean matches(CharSequence rawPassword, String encodedPassword); }
你可以实现此接口定义自己的加密规则和校验规则,不过Spring Security提供了很多加密器实现,我们这里选定一个就好。可以在之前所说的配置类里进行如下配置:
@Bean public PasswordEncoder passwordEncoder() { // 这里我们使用bcrypt加密算法,安全性比较高 return new BCryptPasswordEncoder(); }
因为密码加密是我前面文章少数没有介绍的功能,所以这里额外提一嘴。往数据库中添加用户数据时就要将密码进行加密,否则后续进行密码校验时从数据库拿出来的还是明文密码,是无法通过校验的。比如我们有一个用户注册的接口:
@Autowired private PasswordEncoder passwordEncoder; @PostMapping("/register") public String register(@RequestBody UserParam param) { UserEntity user = new UserEntity(); // 调用加密器将前端传递过来的密码进行加密 user.setUsername(param.getUsername()).setPassword(passwordEncoder.encode(param.getPassword())); // 将用户实体对象添加到数据库 userService.save(user); return "注册成功"; }
这样数据库中存储的密码都是已加密的了:
该接口就是我们所说的用户对象,它提供了用户的一些通用属性:
public interface UserDetails extends Serializable { /** * 用户权限集合(这个权限对象现在不管它,到权限时我会讲解) */ Collection extends GrantedAuthority> getAuthorities(); /** * 用户密码 */ String getPassword(); /** * 用户名 */ String getUsername(); /** * 用户没过期返回true,反之则false */ boolean isAccountNonExpired(); /** * 用户没锁定返回true,反之则false */ boolean isAccountNonLocked(); /** * 用户凭据(通常为密码)没过期返回true,反之则false */ boolean isCredentialsNonExpired(); /** * 用户是启用状态返回true,反之则false */ boolean isEnabled(); }
实际开发中我们的用户属性各种各样,这些默认属性必然是满足不了,所以我们一般会自己实现该接口,然后设置好我们实际的用户实体对象。实现此接口要重写很多方法比较麻烦,我们可以继承Spring Security提供的org.springframework.security.core.userdetails.User类,该类实现了UserDetails接口帮我们省去了重写方法的工作:
public class UserDetail extends User { /** * 我们自己的用户实体对象,要调取用户信息时直接获取这个实体对象。(这里我就不写get/set方法了) */ private UserEntity userEntity; public UserDetail(UserEntity userEntity, Collection extends GrantedAuthority> authorities) { // 必须调用父类的构造方法,以初始化用户名、密码、权限 super(userEntity.getUsername(), userEntity.getPassword(), authorities); this.userEntity = userEntity; } }
该接口很简单只有一个方法:
public interface UserDetailsService { /** * 根据用户名获取用户对象(获取不到直接抛异常) */ UserDetails loadUserByUsername(String username) throws UsernameNotFoundException; }
咱们自己的用户业务类该接口即可完成自己的逻辑:
@Service public class UserServiceImpl implements UserService, UserDetailsService { @Autowired private UserMapper userMapper; @Override public UserDetails loadUserByUsername(String username) { // 从数据库中查询出用户实体对象 UserEntity user = userMapper.selectByUsername(username); // 若没查询到一定要抛出该异常,这样才能被Spring Security的错误处理器处理 if (user == null) { throw new UsernameNotFoundException("没有找到该用户"); } // 走到这代表查询到了实体对象,那就返回我们自定义的UserDetail对象(这里权限暂时放个空集合,后面我会讲解) return new UserDetail(user, Collections.emptyList()); } }
AuthenticationManager校验所调用的三个组件我们就已经做好实现了!
不知道大家注意到没有,当我们查询用户失败时或者校验密码失败时都会抛出Spring Security的自定义异常。这些异常不可能放任不管,Spring Security对于这些异常都是在ExceptionTranslationFilter过滤器中进行处理(可以回顾一下前面的过滤器截图),而**AuthenticationEntryPoint**则专门处理认证异常!
该接口也只有一个方法:
public interface AuthenticationEntryPoint { /** * 接收异常并处理 */ void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException); }
我们来自定义一个类实现我们自己的错误处理逻辑:
public class MyEntryPoint implements AuthenticationEntryPoint { @Override public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException e) throws IOException { response.setContentType("application/json;charset=utf-8"); PrintWriter out = response.getWriter(); // 直接提示前端认证错误 out.write("认证错误"); out.flush(); out.close(); } }
用户传递过来账号密码 认证校验 异常处理,这一整套流程的组件我们就都给定义完了!现在只差最后一步,就是在Spring Security配置类里面进行一些配置,才能让这些生效。
Spring Security对哪些接口进行保护、什么组件生效、某些功能是否启用等等都需要在配置类中进行配置,注意看代码注释:
@EnableWebSecurity public class SecurityConfig extends WebSecurityConfigurerAdapter { @Autowired private UserServiceImpl userDetailsService; @Override protected void configure(HttpSecurity http) throws Exception { // 关闭csrf和frameOptions,如果不关闭会影响前端请求接口(这里不展开细讲了,感兴趣的自行了解) http.csrf().disable(); http.headers().frameOptions().disable(); // 开启跨域以便前端调用接口 http.cors(); // 这是配置的关键,决定哪些接口开启防护,哪些接口绕过防护 http.authorizeRequests() // 注意这里,是允许前端跨域联调的一个必要配置 .requestMatchers(CorsUtils::isPreFlightRequest).permitAll() // 指定某些接口不需要通过验证即可访问。登陆、注册接口肯定是不需要认证的 .antMatchers("/API/login", "/API/register").permitAll() // 这里意思是其它所有接口需要认证才能访问 .anyRequest().authenticated() // 指定认证错误处理器 .and().exceptionHandling().authenticationEntryPoint(new MyEntryPoint()); } @Override protected void configure(AuthenticationManagerBuilder auth) throws Exception { // 指定UserDetailService和加密器 auth.userDetailsService(userDetailsService).passwordEncoder(passwordEncoder()); } @Bean @Override protected AuthenticationManager authenticationManager() throws Exception { return super.authenticationManager(); } @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } }
其中用的最多的就是configure(HttpSecurity http)方法,可以通过HttpSecurity进行许多配置。当我们重写这个方法时,就已经关闭了默认的表单登录方式,然后我们再配置好启用哪些组件、指定哪些接口需要认证,就搞定了!
假设现在我们有一个/API/test接口,在没有登录的时候调用该接口看下效果:
我们登录一下:
然后再调用测试接口:
可以看到未登录时测试接口是无法正常访问的,会按照我们在EntryPoint中的逻辑返回错误提示。
有人可能会问,用AuthenticationManager认证方式要配置好多东西啊,我就用之前说的那种最简单的方式不行吗?当然是可以的啦,用哪种方式都随便,只要完成功能都行。其实不管哪种方式我们的认证的逻辑代码一样都没少,只不过一个是我们自己业务类全部搞定,一个是可以集成框架的组件。这里也顺带再总结一下流程:
刚才我们讲的认证方式都是基于session机制,认证后Spring Security会将Authentication存入到session中,Key为
HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY。也就是说,你完全可以通过如下方式获取Authentication:
Authentication = (Authentication)session.getAttribute(HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY)
当然,官方还是不推荐这样直接操作的,因为统一通过SecurityContextHolder操作更利于管理!使用SecurityContextHolder除了获取当前用户外,退出登录的操作也是很方便的:
@GetMapping("/logout") public String logout() { SecurityContextHolder.clearContext(); return "退出成功"; }
整个Spring Security就讲解完毕了,我们对两个过滤器、N多个组件进行了自定义实现,从而达到了我们的功能。这里做了一个思维导图方便大家理解:
别看组件这么多,认证授权的核心流程和一些概念是不会变的,什么安全框架都万变不离其宗。比如Shiro,其中最基本的概念Subject就代表当前用户,SubjectManager就是用户管理器……
在我前两篇文章中有人也谈到用安全框架还不如自己手写,确实,手写可以最大灵活度按照自己的想法来(并且也不复杂),使用安全框架反而要配合框架的定式,好像被束缚了。那安全框架对比手写有什么优势呢?我觉得优势主要有如下两点:
我们先要搭建一个简单的SpringBoot工程
① 设置父工程 添加依赖
4.0.0 org.springframework.boot spring-boot-starter-parent 2.7.10-SNAPSHOT com.example security8 0.0.1-SNAPSHOT security8 Demo project for Spring Boot 1.8 org.springframework.boot spring-boot-starter-web org.springframework.boot spring-boot-starter-test test org.springframework.boot spring-boot-starter-security org.springframework.boot spring-boot-starter-data-redis com.alibaba fastjson 1.2.33 io.jsonwebtoken jjwt 0.9.0 com.baomidou mybatis-plus-boot-starter 3.4.3 mysql mysql-connector-java 8.0.25 org.projectlombok lombok true org.springframework.boot spring-boot-maven-plugin spring-milestones Spring Milestones https://repo.spring.io/milestone false spring-snapshots Spring Snapshots https://repo.spring.io/snapshot false spring-milestones Spring Milestones https://repo.spring.io/milestone false spring-snapshots Spring Snapshots https://repo.spring.io/snapshot false
② 创建启动类
package com.example.security8; import org.mybatis.spring.annotation.MapperScan; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; @SpringBootApplication @MapperScan("com.example.security8.mapper") public class Security8Application { public static void main(String[] args) { SpringApplication.run(Security8Application.class, args); } }
③ 创建Controller
package com.example.security8.controller; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @RestController public class HelloController { @RequestMapping("/hello") @PreAuthorize("hasAuthority('test')") public String hello(){ return "hello"; } }
引入依赖后我们在尝试去访问之前的接口就会自动跳转到一个SpringSecurity的默认登陆页面,默认用户名是user,密码会输出在控制台。
必须登陆之后才能对接口进行访问。
SpringSecurity的原理其实就是一个过滤器链,内部包含了提供各种功能的过滤器。这里我们可以看看入门案例中的过滤器。
图中只展示了核心过滤器,其它的非核心过滤器并没有在图中展示。
我们可以通过Debug查看当前系统中SpringSecurity过滤器链中有哪些过滤器及它们的顺序。
概念速查:
登录
①自定义登录接口
调用ProviderManager的方法进行认证 如果认证通过生成jwt
把用户信息存入redis中
②自定义UserDetailsService
在这个实现类中去查询数据库
校验:
①定义Jwt认证过滤器
获取token
解析token获取其中的userid
从redis中获取用户信息
存入SecurityContextHolder
①添加依赖
4.0.0 org.springframework.boot spring-boot-starter-parent 2.7.10-SNAPSHOT com.example security8 0.0.1-SNAPSHOT security8 Demo project for Spring Boot 1.8 org.springframework.boot spring-boot-starter-web org.springframework.boot spring-boot-starter-test test org.springframework.boot spring-boot-starter-security org.springframework.boot spring-boot-starter-data-redis com.alibaba fastjson 1.2.33 io.jsonwebtoken jjwt 0.9.0 com.baomidou mybatis-plus-boot-starter 3.4.3 mysql mysql-connector-java 8.0.25 org.projectlombok lombok true org.springframework.boot spring-boot-maven-plugin spring-milestones Spring Milestones https://repo.spring.io/milestone false spring-snapshots Spring Snapshots https://repo.spring.io/snapshot false spring-milestones Spring Milestones https://repo.spring.io/milestone false spring-snapshots Spring Snapshots https://repo.spring.io/snapshot false
② 添加Redis相关配置
package com.example.security8.tool; import com.alibaba.fastjson.JSON; import com.alibaba.fastjson.serializer.SerializerFeature; import com.fasterxml.jackson.databind.JavaType; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.type.TypeFactory; import org.springframework.data.redis.serializer.RedisSerializer; import org.springframework.data.redis.serializer.SerializationException; import com.alibaba.fastjson.parser.ParserConfig; import org.springframework.util.Assert; import java.nio.charset.Charset; /** * Redis使用FastJson序列化 * * @author gusheng */ public class FastJsonRedisSerializerimplements RedisSerializer { public static final Charset DEFAULT_CHARSET = Charset.forName("UTF-8"); private Class clazz; static { ParserConfig.getGlobalInstance().setAutoTypeSupport(true); } public FastJsonRedisSerializer(Class clazz) { super(); this.clazz = clazz; } @Override public byte[] serialize(T t) throws SerializationException { if (t == null) { return new byte[0]; } return JSON.toJSONString(t, SerializerFeature.WriteClassName).getBytes(DEFAULT_CHARSET); } @Override public T deserialize(byte[] bytes) throws SerializationException { if (bytes == null || bytes.length <= 0) { return null; } String str = new String(bytes, DEFAULT_CHARSET); return JSON.parseObject(str, clazz); } protected JavaType getJavaType(Class> clazz) { return TypeFactory.defaultInstance().constructType(clazz); } }
package com.example.security8.tool; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.data.redis.connection.RedisConnectionFactory; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.data.redis.serializer.StringRedisSerializer; @Configuration public class RedisConfig { @Bean @SuppressWarnings(value = { "unchecked", "rawtypes" }) public RedisTemplate
③ 响应类
package com.example.security8.common; import com.fasterxml.jackson.annotation.JsonInclude; /** * @Author 三更 B站: https://space.bilibili.com/663528522 */ @JsonInclude(JsonInclude.Include.NON_NULL) public class ResponseResult{ /** * 状态码 */ private Integer code; /** * 提示信息,如果有错误时,前端可以获取该字段进行提示 */ private String msg; /** * 查询到的结果数据, */ private T data; public ResponseResult(Integer code, String msg) { this.code = code; this.msg = msg; } public ResponseResult(Integer code, T data) { this.code = code; this.data = data; } public Integer getCode() { return code; } public void setCode(Integer code) { this.code = code; } public String getMsg() { return msg; } public void setMsg(String msg) { this.msg = msg; } public T getData() { return data; } public void setData(T data) { this.data = data; } public ResponseResult(Integer code, String msg, T data) { this.code = code; this.msg = msg; this.data = data; } }
④工具类
JWT工具类
package com.example.security8.tool; import io.jsonwebtoken.Claims; import io.jsonwebtoken.JwtBuilder; import io.jsonwebtoken.Jwts; import io.jsonwebtoken.SignatureAlgorithm; import javax.crypto.SecretKey; import javax.crypto.spec.SecretKeySpec; import java.util.Base64; import java.util.Date; import java.util.UUID; /** * JWT工具类 */ public class JwtUtil { //有效期为 public static final Long JWT_TTL = 60 * 60 *1000L;// 60 * 60 *1000 一个小时 //设置秘钥明文 public static final String JWT_KEY = "sangeng"; public static String getUUID(){ String token = UUID.randomUUID().toString().replaceAll("-", ""); return token; } /** * 生成jtw * @param subject token中要存放的数据(json格式) * @return */ public static String createJWT(String subject) { JwtBuilder builder = getJwtBuilder(subject, null, getUUID());// 设置过期时间 return builder.compact(); } /** * 生成jtw * @param subject token中要存放的数据(json格式) * @param ttlMillis token超时时间 * @return */ public static String createJWT(String subject, Long ttlMillis) { JwtBuilder builder = getJwtBuilder(subject, ttlMillis, getUUID());// 设置过期时间 return builder.compact(); } private static JwtBuilder getJwtBuilder(String subject, Long ttlMillis, String uuid) { SignatureAlgorithm signatureAlgorithm = SignatureAlgorithm.HS256; SecretKey secretKey = generalKey(); long nowMillis = System.currentTimeMillis(); Date now = new Date(nowMillis); if(ttlMillis==null){ ttlMillis=JwtUtil.JWT_TTL; } long expMillis = nowMillis + ttlMillis; Date expDate = new Date(expMillis); return Jwts.builder() .setId(uuid) //唯一的ID .setSubject(subject) // 主题 可以是JSON数据 .setIssuer("sg") // 签发者 .setIssuedAt(now) // 签发时间 .signWith(signatureAlgorithm, secretKey) //使用HS256对称加密算法签名, 第二个参数为秘钥 .setExpiration(expDate); } /** * 创建token * @param id * @param subject * @param ttlMillis * @return */ public static String createJWT(String id, String subject, Long ttlMillis) { JwtBuilder builder = getJwtBuilder(subject, ttlMillis, id);// 设置过期时间 return builder.compact(); } public static void main(String[] args) throws Exception { String token = "eyJhbGciOiJIUzI1NiJ9.eyJqdGkiOiJjYWM2ZDVhZi1mNjVlLTQ0MDAtYjcxMi0zYWEwOGIyOTIwYjQiLCJzdWIiOiJzZyIsImlzcyI6InNnIiwiaWF0IjoxNjM4MTA2NzEyLCJleHAiOjE2MzgxMTAzMTJ9.JVsSbkP94wuczb4QryQbAke3ysBDIL5ou8fWsbt_ebg"; Claims claims = parseJWT(token); System.out.println(claims); } /** * 生成加密后的秘钥 secretKey * @return */ public static SecretKey generalKey() { byte[] encodedKey = Base64.getDecoder().decode(JwtUtil.JWT_KEY); SecretKey key = new SecretKeySpec(encodedKey, 0, encodedKey.length, "AES"); return key; } /** * 解析 * * @param jwt * @return * @throws Exception */ public static Claims parseJWT(String jwt) throws Exception { SecretKey secretKey = generalKey(); return Jwts.parser() .setSigningKey(secretKey) .parseClaimsJws(jwt) .getBody(); } }
RedisCache
package com.example.security8.tool; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.redis.core.BoundSetOperations; import org.springframework.data.redis.core.HashOperations; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.data.redis.core.ValueOperations; import org.springframework.stereotype.Component; import java.util.*; import java.util.concurrent.TimeUnit; @SuppressWarnings(value = { "unchecked", "rawtypes" }) @Component public class RedisCache { @Autowired public RedisTemplate redisTemplate; /** * 缓存基本的对象,Integer、String、实体类等 * * @param key 缓存的键值 * @param value 缓存的值 */ publicvoid setCacheObject(final String key, final T value) { redisTemplate.opsForValue().set(key, value); } /** * 缓存基本的对象,Integer、String、实体类等 * * @param key 缓存的键值 * @param value 缓存的值 * @param timeout 时间 * @param timeUnit 时间颗粒度 */ public void setCacheObject(final String key, final T value, final Integer timeout, final TimeUnit timeUnit) { redisTemplate.opsForValue().set(key, value, timeout, timeUnit); } /** * 设置有效时间 * * @param key Redis键 * @param timeout 超时时间 * @return true=设置成功;false=设置失败 */ public boolean expire(final String key, final long timeout) { return expire(key, timeout, TimeUnit.SECONDS); } /** * 设置有效时间 * * @param key Redis键 * @param timeout 超时时间 * @param unit 时间单位 * @return true=设置成功;false=设置失败 */ public boolean expire(final String key, final long timeout, final TimeUnit unit) { return redisTemplate.expire(key, timeout, unit); } /** * 获得缓存的基本对象。 * * @param key 缓存键值 * @return 缓存键值对应的数据 */ public T getCacheObject(final String key) { ValueOperations operation = redisTemplate.opsForValue(); return operation.get(key); } /** * 删除单个对象 * * @param key */ public boolean deleteObject(final String key) { return redisTemplate.delete(key); } /** * 删除集合对象 * * @param collection 多个对象 * @return */ public long deleteObject(final Collection collection) { return redisTemplate.delete(collection); } /** * 缓存List数据 * * @param key 缓存的键值 * @param dataList 待缓存的List数据 * @return 缓存的对象 */ public long setCacheList(final String key, final List dataList) { Long count = redisTemplate.opsForList().rightPushAll(key, dataList); return count == null ? 0 : count; } /** * 获得缓存的list对象 * * @param key 缓存的键值 * @return 缓存键值对应的数据 */ public List getCacheList(final String key) { return redisTemplate.opsForList().range(key, 0, -1); } /** * 缓存Set * * @param key 缓存键值 * @param dataSet 缓存的数据 * @return 缓存数据的对象 */ public BoundSetOperations setCacheSet(final String key, final Set dataSet) { BoundSetOperations setOperation = redisTemplate.boundSetOps(key); Iterator it = dataSet.iterator(); while (it.hasNext()) { setOperation.add(it.next()); } return setOperation; } /** * 获得缓存的set * * @param key * @return */ public Set getCacheSet(final String key) { return redisTemplate.opsForSet().members(key); } /** * 缓存Map * * @param key * @param dataMap */ public void setCacheMap(final String key, final Map dataMap) { if (dataMap != null) { redisTemplate.opsForHash().putAll(key, dataMap); } } /** * 获得缓存的Map * * @param key * @return */ public Map getCacheMap(final String key) { return redisTemplate.opsForHash().entries(key); } /** * 往Hash中存入数据 * * @param key Redis键 * @param hKey Hash键 * @param value 值 */ public void setCacheMapValue(final String key, final String hKey, final T value) { redisTemplate.opsForHash().put(key, hKey, value); } /** * 获取Hash中的数据 * * @param key Redis键 * @param hKey Hash键 * @return Hash中的对象 */ public T getCacheMapValue(final String key, final String hKey) { HashOperations opsForHash = redisTemplate.opsForHash(); return opsForHash.get(key, hKey); } /** * 删除Hash中的数据 * * @param key * @param hkey */ public void delCacheMapValue(final String key, final String hkey) { HashOperations hashOperations = redisTemplate.opsForHash(); hashOperations.delete(key, hkey); } /** * 获取多个Hash中的数据 * * @param key Redis键 * @param hKeys Hash键集合 * @return Hash对象集合 */ public List getMultiCacheMapValue(final String key, final Collection hKeys) { return redisTemplate.opsForHash().multiGet(key, hKeys); } /** * 获得缓存的基本对象列表 * * @param pattern 字符串前缀 * @return 对象列表 */ public Collection keys(final String pattern) { return redisTemplate.keys(pattern); } }
WebUtils
package com.example.security8.tool; import javax.servlet.http.HttpServletResponse; import java.io.IOException; public class WebUtils { /** * 将字符串渲染到客户端 * * @param response 渲染对象 * @param string 待渲染的字符串 * @return null */ public static String renderString(HttpServletResponse response, String string) { try { response.setStatus(200); response.setContentType("application/json"); response.setCharacterEncoding("utf-8"); response.getWriter().print(string); } catch (IOException e) { e.printStackTrace(); } return null; } }
⑤实体类
package com.example.security8.model; import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableName; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; import java.io.Serializable; import java.util.Date; /** * 用户表(User)实体类 * * @author 三更 */ @Data @AllArgsConstructor @NoArgsConstructor @TableName(value = "sys_user") public class User implements Serializable { private static final long serialVersionUID = -40356785423868312L; /** * 主键 */ @TableId private Long id; /** * 用户名 */ private String userName; /** * 昵称 */ private String nickName; /** * 密码 */ private String password; /** * 账号状态(0正常 1停用) */ private String status; /** * 邮箱 */ private String email; /** * 手机号 */ private String phonenumber; /** * 用户性别(0男,1女,2未知) */ private String sex; /** * 头像 */ private String avatar; /** * 用户类型(0管理员,1普通用户) */ private String userType; /** * 创建人的用户id */ private Long createBy; /** * 创建时间 */ private Date createTime; /** * 更新人 */ private Long updateBy; /** * 更新时间 */ private Date updateTime; /** * 删除标志(0代表未删除,1代表已删除) */ private Integer delFlag; }
从之前的分析我们可以知道,我们可以自定义一个UserDetailsService,让SpringSecurity使用我们的UserDetailsService。我们自己的UserDetailsService可以从数据库中查询用户名和密码。
准备工作
我们先创建一个用户表, 建表语句如下:
Mysql版本
CREATE TABLE `sys_user` ( `id` BIGINT(20) NOT NULL AUTO_INCREMENT COMMENT '主键', `user_name` VARCHAR(64) NOT NULL DEFAULT 'NULL' COMMENT '用户名', `nick_name` VARCHAR(64) NOT NULL DEFAULT 'NULL' COMMENT '昵称', `password` VARCHAR(64) NOT NULL DEFAULT 'NULL' COMMENT '密码', `status` CHAR(1) DEFAULT '0' COMMENT '账号状态(0正常 1停用)', `email` VARCHAR(64) DEFAULT NULL COMMENT '邮箱', `phonenumber` VARCHAR(32) DEFAULT NULL COMMENT '手机号', `sex` CHAR(1) DEFAULT NULL COMMENT '用户性别(0男,1女,2未知)', `avatar` VARCHAR(128) DEFAULT NULL COMMENT '头像', `user_type` CHAR(1) NOT NULL DEFAULT '1' COMMENT '用户类型(0管理员,1普通用户)', `create_by` BIGINT(20) DEFAULT NULL COMMENT '创建人的用户id', `create_time` DATETIME DEFAULT NULL COMMENT '创建时间', `update_by` BIGINT(20) DEFAULT NULL COMMENT '更新人', `update_time` DATETIME DEFAULT NULL COMMENT '更新时间', `del_flag` INT(11) DEFAULT '0' COMMENT '删除标志(0代表未删除,1代表已删除)', PRIMARY KEY (`id`) ) ENGINE=INNODB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4 COMMENT='用户表'
配置数据库信息(MYSQL)
spring.datasource.url=jdbc:mysql://localhost:3306/system?characterEncoding=utf-8&serverTimezone=UTC spring.datasource.username=root spring.datasource.password=gsazyy1314 driver-class-name: com.mysql.cj.jdbc.Driver spring.redis.host=127.0.0.1
配置数据库信息(SqlServer)
定义Mapper接口
package com.example.security8.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.example.security8.model.User; import org.apache.ibatis.annotations.Mapper; public interface UserMapper extends BaseMapper{ }
修改User实体类
类名上加@TableName(value = "sys_user") ,id字段上加 @TableId
配置Mapper扫描
package com.example.security8.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.example.security8.model.User; import org.apache.ibatis.annotations.Mapper; public interface UserMapper extends BaseMapper{ }
测试MP是否能正常使用
package com.example.security8; import com.example.security8.mapper.UserMapper; import com.example.security8.model.User; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import java.util.List; /** * @Author 三更 B站: https://space.bilibili.com/663528522 */ @SpringBootTest public class MapperTest { @Autowired private UserMapper userMapper; @Test public void testUserMapper(){ Listusers = userMapper.selectList(null); System.out.println(users); } }
核心代码实现
创建一个类实现UserDetailsService接口,重写其中的方法。更加用户名从数据库中查询用户信息
package com.example.security8.service.impl; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.example.security8.mapper.UserMapper; import com.example.security8.model.LoginUser; import com.example.security8.model.User; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.core.userdetails.UsernameNotFoundException; import org.springframework.stereotype.Service; import java.util.ArrayList; import java.util.Arrays; import java.util.List; import java.util.Objects; /** * @Author 三更 B站: https://space.bilibili.com/663528522 */ @Service public class UserDetailsServiceImpl implements UserDetailsService { @Autowired private UserMapper userMapper; @Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { try { //根据用户名查询用户信息 LambdaQueryWrapperwrapper = new LambdaQueryWrapper<>(); wrapper.eq(User::getUserName,username); User user = userMapper.selectOne(wrapper); //如果查询不到数据就通过抛出异常来给出提示 if(Objects.isNull(user)){ throw new RuntimeException("用户名或密码错误"); } //TODO 根据用户查询权限信息 添加到LoginUser中 //封装成UserDetails对象返回 //TODO 根据用户查询权限信息 添加到LoginUser中 List list = new ArrayList<>(Arrays.asList("test")); return new LoginUser(user,list); }catch (Exception e){ e.printStackTrace(); } return null; } }
因为UserDetailsService方法的返回值是UserDetails类型,所以需要定义一个类,实现该接口,把用户信息封装在其中。
package com.example.security8.model; import com.alibaba.fastjson.annotation.JSONField; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.userdetails.UserDetails; import java.util.Collection; import java.util.List; import java.util.stream.Collectors; /** * @Author 三更 B站: https://space.bilibili.com/663528522 */ @Data @NoArgsConstructor @AllArgsConstructor public class LoginUser implements UserDetails { private User user; //存储权限信息 private Listpermissions; //存储SpringSecurity所需要的权限信息的集合 @JSONField(serialize = false) private List authorities; public LoginUser(User user,List permissions) { this.user = user; this.permissions = permissions; } @Override public Collection extends GrantedAuthority> getAuthorities() { if(authorities!=null){ return authorities; } //把permissions中字符串类型的权限信息转换成GrantedAuthority对象存入authorities中 authorities = permissions.stream(). map(SimpleGrantedAuthority::new) .collect(Collectors.toList()); return authorities; } @Override public String getPassword() { return user.getPassword(); } @Override public String getUsername() { return user.getUserName(); } @Override public boolean isAccountNonExpired() { return true; } @Override public boolean isAccountNonLocked() { return true; } @Override public boolean isCredentialsNonExpired() { return true; } @Override public boolean isEnabled() { return true; } }
注意:如果要测试,需要往用户表中写入用户数据,并且如果你想让用户的密码是明文存储,需要在密码前加{noop}。例如
这样登陆的时候就可以用sg作为用户名,1234作为密码来登陆了。
实际项目中我们不会把密码明文存储在数据库中。
/** * @Author 三更 B站: https://space.bilibili.com/663528522 */ @Configuration public class SecurityConfig extends WebSecurityConfigurerAdapter { @Bean public PasswordEncoder passwordEncoder(){ return new BCryptPasswordEncoder(); } }
接下我们需要自定义登陆接口,然后让SpringSecurity对这个接口放行,让用户访问这个接口的时候不用登录也能访问。
在接口中我们通过AuthenticationManager的authenticate方法来进行用户认证,所以需要在SecurityConfig中配置把AuthenticationManager注入容器。
认证成功的话要生成一个jwt,放入响应中返回。并且为了让用户下回请求时能通过jwt识别出具体的是哪个用户,我们需要把用户信息存入redis,可以把用户id作为key。
@RestController public class LoginController { @Autowired private LoginServcie loginServcie; @PostMapping("/user/login") public ResponseResult login(@RequestBody User user){ return loginServcie.login(user); } }
package com.example.security8.config; import com.example.security8.filter.JwtAuthenticationTokenFilter; import com.example.security8.service.impl.UserDetailsServiceImpl; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder; import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter; import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.web.AuthenticationEntryPoint; import org.springframework.security.web.access.AccessDeniedHandler; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; /** * @Author 三更 B站: https://space.bilibili.com/663528522 */ @Configuration @EnableGlobalMethodSecurity(prePostEnabled = true) public class SecurityConfig extends WebSecurityConfigurerAdapter { @Autowired private UserDetailsServiceImpl userDetailsService; @Autowired JwtAuthenticationTokenFilter jwtAuthenticationTokenFilter; @Autowired private AuthenticationEntryPoint authenticationEntryPoint; @Autowired private AccessDeniedHandler accessDeniedHandler; @Bean public PasswordEncoder passwordEncoder(){ return new BCryptPasswordEncoder(); } @Override protected void configure(AuthenticationManagerBuilder auth) throws Exception { // 指定UserDetailService和加密器 auth.userDetailsService(userDetailsService).passwordEncoder(passwordEncoder()); } @Override protected void configure(HttpSecurity http) throws Exception { http //关闭csrf .csrf().disable() //不通过Session获取SecurityContext .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) .and() .authorizeRequests() // 对于登录接口 允许匿名访问 .antMatchers("/user/login","/user/register").anonymous() // 除上面外的所有请求全部需要鉴权认证 .anyRequest().authenticated(); http.exceptionHandling().authenticationEntryPoint(authenticationEntryPoint). accessDeniedHandler(accessDeniedHandler); //把token校验过滤器添加到过滤器链中 http.addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class); //允许跨域 http.cors(); } @Bean @Override public AuthenticationManager authenticationManagerBean() throws Exception { return super.authenticationManagerBean(); } }
package com.example.security8.service.impl; import com.example.security8.common.ResponseResult; import com.example.security8.mapper.UserMapper; import com.example.security8.model.LoginUser; import com.example.security8.model.User; import com.example.security8.service.LoginServcie; import com.example.security8.tool.JwtUtil; import com.example.security8.tool.RedisCache; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import java.util.HashMap; import java.util.Objects; /** * @Author 三更 B站: https://space.bilibili.com/663528522 */ @Service public class LoginServiceImpl implements LoginServcie { @Autowired private AuthenticationManager authenticationManager; @Autowired private RedisCache redisCache; @Autowired private UserMapper userMapper; @Override public ResponseResult login(User user) { UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(user.getUserName(),user.getPassword()); Authentication authenticate = authenticationManager.authenticate(authenticationToken); if(Objects.isNull(authenticate)){ throw new RuntimeException("用户名或密码错误"); } //使用userid生成token LoginUser loginUser = (LoginUser) authenticate.getPrincipal(); String userId = loginUser.getUser().getId().toString(); String jwt = JwtUtil.createJWT(userId); //authenticate存入redis redisCache.setCacheObject("login:"+userId,loginUser); //把token响应给前端 HashMapmap = new HashMap<>(); map.put("token",jwt); return new ResponseResult(200,"登陆成功",map); } @Override public ResponseResult logout() { Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); LoginUser loginUser = (LoginUser) authentication.getPrincipal(); Long userid = loginUser.getUser().getId(); redisCache.deleteObject("login:"+userid); return new ResponseResult(200,"退出成功"); } @Override public void register(User user) { userMapper.insert(user); } }
我们需要自定义一个过滤器,这个过滤器会去获取请求头中的token,对token进行解析取出其中的userid。
使用userid去redis中获取对应的LoginUser对象。
然后封装Authentication对象存入SecurityContextHolder
package com.example.security8.filter; import com.example.security8.model.LoginUser; import com.example.security8.tool.JwtUtil; import com.example.security8.tool.RedisCache; import io.jsonwebtoken.Claims; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Component; import org.springframework.util.StringUtils; import org.springframework.web.filter.OncePerRequestFilter; import javax.servlet.FilterChain; import javax.servlet.ServletException; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.io.IOException; import java.util.Objects; @Component public class JwtAuthenticationTokenFilter extends OncePerRequestFilter { @Autowired private RedisCache redisCache; @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { //获取token String token = request.getHeader("token"); if (!StringUtils.hasText(token)) { //放行 filterChain.doFilter(request, response); return; } //解析token String userid; try { Claims claims = JwtUtil.parseJWT(token); userid = claims.getSubject(); } catch (Exception e) { e.printStackTrace(); throw new RuntimeException("token非法"); } //从redis中获取用户信息 String redisKey = "login:" + userid; LoginUser loginUser = redisCache.getCacheObject(redisKey); if(Objects.isNull(loginUser)){ throw new RuntimeException("用户未登录"); } //存入SecurityContextHolder //TODO 获取权限信息封装到Authentication中 UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(loginUser,null,null); SecurityContextHolder.getContext().setAuthentication(authenticationToken); //放行 filterChain.doFilter(request, response); } }
我们只需要定义一个登陆接口,然后获取SecurityContextHolder中的认证信息,删除redis中对应的数据即可。
package com.example.security8.service.impl; import com.example.security8.common.ResponseResult; import com.example.security8.mapper.UserMapper; import com.example.security8.model.LoginUser; import com.example.security8.model.User; import com.example.security8.service.LoginServcie; import com.example.security8.tool.JwtUtil; import com.example.security8.tool.RedisCache; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import java.util.HashMap; import java.util.Objects; /** * @Author 三更 B站: https://space.bilibili.com/663528522 */ @Service public class LoginServiceImpl implements LoginServcie { @Autowired private AuthenticationManager authenticationManager; @Autowired private RedisCache redisCache; @Autowired private UserMapper userMapper; @Override public ResponseResult login(User user) { UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(user.getUserName(),user.getPassword()); Authentication authenticate = authenticationManager.authenticate(authenticationToken); if(Objects.isNull(authenticate)){ throw new RuntimeException("用户名或密码错误"); } //使用userid生成token LoginUser loginUser = (LoginUser) authenticate.getPrincipal(); String userId = loginUser.getUser().getId().toString(); String jwt = JwtUtil.createJWT(userId); //authenticate存入redis redisCache.setCacheObject("login:"+userId,loginUser); //把token响应给前端 HashMapmap = new HashMap<>(); map.put("token",jwt); return new ResponseResult(200,"登陆成功",map); } @Override public ResponseResult logout() { Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); LoginUser loginUser = (LoginUser) authentication.getPrincipal(); Long userid = loginUser.getUser().getId(); redisCache.deleteObject("login:"+userid); return new ResponseResult(200,"退出成功"); } @Override public void register(User user) { userMapper.insert(user); } }
模拟前端登录
拷贝token访问资源
成功退出登录
例如一个学校图书馆的管理系统,如果是普通学生登录就能看到借书还书相关的功能,不可能让他看到并且去使用添加书籍信息,删除书籍信息等功能。但是如果是一个图书馆管理员的账号登录了,应该就能看到并使用添加书籍信息,删除书籍信息等功能。
总结起来就是不同的用户可以使用不同的功能。这就是权限系统要去实现的效果。
我们不能只依赖前端去判断用户的权限来选择显示哪些菜单哪些按钮。因为如果只是这样,如果有人知道了对应功能的接口地址就可以不通过前端,直接去发送请求来实现相关功能操作。
所以我们还需要在后台进行用户权限的判断,判断当前用户是否有相应的权限,必须具有所需权限才能进行相应的操作。
在SpringSecurity中,会使用默认的FilterSecurityInterceptor来进行权限校验。在FilterSecurityInterceptor中会从SecurityContextHolder获取其中的Authentication,然后获取其中的权限信息。当前用户是否拥有访问当前资源所需的权限。
所以我们在项目中只需要把当前登录用户的权限信息也存入Authentication。
然后设置我们的资源所需要的权限即可。
SpringSecurity为我们提供了基于注解的权限控制方案,这也是我们项目中主要采用的方式。我们可以使用注解去指定访问对应的资源所需的权限。
但是要使用它我们需要先开启相关配置springSecurity里面加。
然后就可以使用对应的注解。@PreAuthorize
@RestController public class HelloController { @RequestMapping("/hello") @PreAuthorize("hasAuthority('test')") public String hello(){ return "hello"; } }
我们前面在写UserDetailsServiceImpl的时候说过,在查询出用户后还要获取对应的权限信息,封装到UserDetails中返回。
我们先直接把权限信息写死封装到UserDetails中进行测试。
我们之前定义了UserDetails的实现类LoginUser,想要让其能封装权限信息就要对其进行修改。
package com.example.security8.model; import com.alibaba.fastjson.annotation.JSONField; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.userdetails.UserDetails; import java.util.Collection; import java.util.List; import java.util.stream.Collectors; /** * @Author 三更 B站: https://space.bilibili.com/663528522 */ @Data @NoArgsConstructor @AllArgsConstructor public class LoginUser implements UserDetails { private User user; //存储权限信息 private Listpermissions; //存储SpringSecurity所需要的权限信息的集合 @JSONField(serialize = false) private List authorities; public LoginUser(User user,List permissions) { this.user = user; this.permissions = permissions; } @Override public Collection extends GrantedAuthority> getAuthorities() { if(authorities!=null){ return authorities; } //把permissions中字符串类型的权限信息转换成GrantedAuthority对象存入authorities中 authorities = permissions.stream(). map(SimpleGrantedAuthority::new) .collect(Collectors.toList()); return authorities; } @Override public String getPassword() { return user.getPassword(); } @Override public String getUsername() { return user.getUserName(); } @Override public boolean isAccountNonExpired() { return true; } @Override public boolean isAccountNonLocked() { return true; } @Override public boolean isCredentialsNonExpired() { return true; } @Override public boolean isEnabled() { return true; } }
LoginUser修改完后我们就可以在UserDetailsServiceImpl中去把权限信息封装到LoginUser中了。我们写死权限进行测试,后面我们再从数据库中查询权限信息。
package com.example.security8.service.impl; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.example.security8.mapper.UserMapper; import com.example.security8.model.LoginUser; import com.example.security8.model.User; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.core.userdetails.UsernameNotFoundException; import org.springframework.stereotype.Service; import java.util.ArrayList; import java.util.Arrays; import java.util.List; import java.util.Objects; /** * @Author 三更 B站: https://space.bilibili.com/663528522 */ @Service public class UserDetailsServiceImpl implements UserDetailsService { @Autowired private UserMapper userMapper; @Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { try { //根据用户名查询用户信息 LambdaQueryWrapperwrapper = new LambdaQueryWrapper<>(); wrapper.eq(User::getUserName,username); User user = userMapper.selectOne(wrapper); //如果查询不到数据就通过抛出异常来给出提示 if(Objects.isNull(user)){ throw new RuntimeException("用户名或密码错误"); } //TODO 根据用户查询权限信息 添加到LoginUser中 //封装成UserDetails对象返回 //TODO 根据用户查询权限信息 添加到LoginUser中 List list = new ArrayList<>(Arrays.asList("test")); return new LoginUser(user,list); }catch (Exception e){ e.printStackTrace(); } return null; } }
3.2.3.1 RBAC权限模型
RBAC权限模型(Role-Based Access Control)即:基于角色的权限控制。这是目前最常被开发者使用也是相对易用、通用权限模型。
3.2.3.2 准备工作
MySQL
CREATE DATABASE /*!32312 IF NOT EXISTS*/`sg_security` /*!40100 DEFAULT CHARACTER SET utf8mb4 */; USE `sg_security`; /*Table structure for table `sys_menu` */ DROP TABLE IF EXISTS `sys_menu`; CREATE TABLE `sys_menu` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `menu_name` varchar(64) NOT NULL DEFAULT 'NULL' COMMENT '菜单名', `path` varchar(200) DEFAULT NULL COMMENT '路由地址', `component` varchar(255) DEFAULT NULL COMMENT '组件路径', `visible` char(1) DEFAULT '0' COMMENT '菜单状态(0显示 1隐藏)', `status` char(1) DEFAULT '0' COMMENT '菜单状态(0正常 1停用)', `perms` varchar(100) DEFAULT NULL COMMENT '权限标识', `icon` varchar(100) DEFAULT '#' COMMENT '菜单图标', `create_by` bigint(20) DEFAULT NULL, `create_time` datetime DEFAULT NULL, `update_by` bigint(20) DEFAULT NULL, `update_time` datetime DEFAULT NULL, `del_flag` int(11) DEFAULT '0' COMMENT '是否删除(0未删除 1已删除)', `remark` varchar(500) DEFAULT NULL COMMENT '备注', PRIMARY KEY (`id`) ) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4 COMMENT='菜单表'; /*Table structure for table `sys_role` */ DROP TABLE IF EXISTS `sys_role`; CREATE TABLE `sys_role` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `name` varchar(128) DEFAULT NULL, `role_key` varchar(100) DEFAULT NULL COMMENT '角色权限字符串', `status` char(1) DEFAULT '0' COMMENT '角色状态(0正常 1停用)', `del_flag` int(1) DEFAULT '0' COMMENT 'del_flag', `create_by` bigint(200) DEFAULT NULL, `create_time` datetime DEFAULT NULL, `update_by` bigint(200) DEFAULT NULL, `update_time` datetime DEFAULT NULL, `remark` varchar(500) DEFAULT NULL COMMENT '备注', PRIMARY KEY (`id`) ) ENGINE=InnoDB AUTO_INCREMENT=3 DEFAULT CHARSET=utf8mb4 COMMENT='角色表'; /*Table structure for table `sys_role_menu` */ DROP TABLE IF EXISTS `sys_role_menu`; CREATE TABLE `sys_role_menu` ( `role_id` bigint(200) NOT NULL AUTO_INCREMENT COMMENT '角色ID', `menu_id` bigint(200) NOT NULL DEFAULT '0' COMMENT '菜单id', PRIMARY KEY (`role_id`,`menu_id`) ) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4; /*Table structure for table `sys_user` */ DROP TABLE IF EXISTS `sys_user`; CREATE TABLE `sys_user` ( `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键', `user_name` varchar(64) NOT NULL DEFAULT 'NULL' COMMENT '用户名', `nick_name` varchar(64) NOT NULL DEFAULT 'NULL' COMMENT '昵称', `password` varchar(64) NOT NULL DEFAULT 'NULL' COMMENT '密码', `status` char(1) DEFAULT '0' COMMENT '账号状态(0正常 1停用)', `email` varchar(64) DEFAULT NULL COMMENT '邮箱', `phonenumber` varchar(32) DEFAULT NULL COMMENT '手机号', `sex` char(1) DEFAULT NULL COMMENT '用户性别(0男,1女,2未知)', `avatar` varchar(128) DEFAULT NULL COMMENT '头像', `user_type` char(1) NOT NULL DEFAULT '1' COMMENT '用户类型(0管理员,1普通用户)', `create_by` bigint(20) DEFAULT NULL COMMENT '创建人的用户id', `create_time` datetime DEFAULT NULL COMMENT '创建时间', `update_by` bigint(20) DEFAULT NULL COMMENT '更新人', `update_time` datetime DEFAULT NULL COMMENT '更新时间', `del_flag` int(11) DEFAULT '0' COMMENT '删除标志(0代表未删除,1代表已删除)', PRIMARY KEY (`id`) ) ENGINE=InnoDB AUTO_INCREMENT=3 DEFAULT CHARSET=utf8mb4 COMMENT='用户表'; /*Table structure for table `sys_user_role` */ DROP TABLE IF EXISTS `sys_user_role`; CREATE TABLE `sys_user_role` ( `user_id` bigint(200) NOT NULL AUTO_INCREMENT COMMENT '用户id', `role_id` bigint(200) NOT NULL DEFAULT '0' COMMENT '角色id', PRIMARY KEY (`user_id`,`role_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
通过userid查询用户权限
SELECT DISTINCT m.`perms` FROM sys_user_role ur LEFT JOIN `sys_role` r ON ur.`role_id` = r.`id` LEFT JOIN `sys_role_menu` rm ON ur.`role_id` = rm.`role_id` LEFT JOIN `sys_menu` m ON m.`id` = rm.`menu_id` WHERE user_id = 2 AND r.`status` = 0 AND m.`status` = 0
package com.sangeng.domain; import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableName; import com.fasterxml.jackson.annotation.JsonInclude; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; import java.io.Serializable; import java.util.Date; /** * 菜单表(Menu)实体类 * * @author makejava * @since 2021-11-24 15:30:08 */ @TableName(value="sys_menu") @Data @AllArgsConstructor @NoArgsConstructor @JsonInclude(JsonInclude.Include.NON_NULL) public class Menu implements Serializable { private static final long serialVersionUID = -54979041104113736L; @TableId private Long id; /** * 菜单名 */ private String menuName; /** * 路由地址 */ private String path; /** * 组件路径 */ private String component; /** * 菜单状态(0显示 1隐藏) */ private String visible; /** * 菜单状态(0正常 1停用) */ private String status; /** * 权限标识 */ private String perms; /** * 菜单图标 */ private String icon; private Long createBy; private Date createTime; private Long updateBy; private Date updateTime; /** * 是否删除(0未删除 1已删除) */ private Integer delFlag; /** * 备注 */ private String remark; }
3.2.3.3 代码实现
我们只需要根据用户id去查询到其所对应的权限信息即可。
所以我们可以先定义个mapper,其中提供一个方法可以根据userid查询权限信息。
import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.sangeng.domain.Menu; import java.util.List; /** * @Author 三更 B站: https://space.bilibili.com/663528522 */ public interface MenuMapper extends BaseMapper
尤其是自定义方法,所以需要创建对应的mapper文件,定义对应的sql语句
然后我们可以在UserDetailsServiceImpl中去调用该mapper的方法查询权限信息封装到LoginUser对象中即可。
/** * @Author 三更 B站: https://space.bilibili.com/663528522 */ @Service public class UserDetailsServiceImpl implements UserDetailsService { @Autowired private UserMapper userMapper; @Autowired private MenuMapper menuMapper; @Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { LambdaQueryWrapperwrapper = new LambdaQueryWrapper<>(); wrapper.eq(User::getUserName,username); User user = userMapper.selectOne(wrapper); if(Objects.isNull(user)){ throw new RuntimeException("用户名或密码错误"); } List permissionKeyList = menuMapper.selectPermsByUserId(user.getId()); // //测试写法 // List list = new ArrayList<>(Arrays.asList("test")); return new LoginUser(user,permissionKeyList); } }
我们还希望在认证失败或者是授权失败的情况下也能和我们的接口一样返回相同结构的json,这样可以让前端能对响应进行统一的处理。要实现这个功能我们需要知道SpringSecurity的异常处理机制。
在SpringSecurity中,如果我们在认证或者授权的过程中出现了异常会被ExceptionTranslationFilter捕获到。在ExceptionTranslationFilter中会去判断是认证失败还是授权失败出现的异常。
如果是认证过程中出现的异常会被封装成AuthenticationException然后调用AuthenticationEntryPoint对象的方法去进行异常处理。
如果是授权过程中出现的异常会被封装成AccessDeniedException然后调用AccessDeniedHandler对象的方法去进行异常处理。
所以如果我们需要自定义异常处理,我们只需要自定义AuthenticationEntryPoint和AccessDeniedHandler然后配置给SpringSecurity即可。
①自定义实现类
②配置给SpringSecurity
先注入对应的处理器
然后我们可以使用HttpSecurity对象的方法去配置。
浏览器出于安全的考虑,使用 XMLHttpRequest对象发起 HTTP请求时必须遵守同源策略,否则就是跨域的HTTP请求,默认情况下是被禁止的。 同源策略要求源相同才能正常进行通信,即协议、域名、端口号都完全一致。
前后端分离项目,前端项目和后端项目一般都不是同源的,所以肯定会存在跨域请求的问题。
所以我们就要处理一下,让前端能进行跨域请求。
①先对SpringBoot配置,运行跨域请求
②开启SpringSecurity的跨域访问
由于我们的资源都会收到SpringSecurity的保护,所以想要跨域访问还要让SpringSecurity运行跨域访问。