简介
SpringSecurity
属于Spring家族中的一款安全管理框架,,它提供了一套Web应用安全性的完整解决方案。主要的功能是
认证
和
授权
。
认证
:验证当前访问系统的是不是本系统的用户,并且要确定具体是哪个用户。
授权
:经过认证后判断当前用户是否有权限进行某个操作。
1、快速入门
1.1、创建一个SpringBoot工程
1、先创建一个最基本的SpringBoot工程,配置好相关数据库,并且编写一个Controller进行测试。
① 导入依赖
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.3.7.RELEASE</version>
</parent>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.4.2</version>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
② yml文件配置
server:
port: 8080
spring:
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://xxxxxxxx:3306/security?characterEncoding=utf-8&serverTimezone=UTC
username: root
password: xxxxxx
mybatis-plus:
configuration:
map-underscore-to-camel-case: true
call-setters-on-nulls: true
③创建数据库表编写启动类、实体类、Mapper
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 '密码',
PRIMARY KEY (`id`)
) ENGINE=INNODB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4 COMMENT='用户表'
import java.io.Serializable;
import java.util.Date;
/**
* 用户表(User)实体类
*/
@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;
}
@Mapper
public interface UserMapper extends BaseMapper<User> {
}
④Controller
@RestController
public class HelloController {
@Autowired
private UserMapper userMapper;
@GetMapping("/hello")
public List hello(){
List<User> list = userMapper.selectList(null);
return list;
}
}
⑤随便插入一条数据然后 测试
2.1、引入SpringSecurity
①引入依赖
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
②重新访问hello接口测试
请求会被SpringSecurity拦截,然后跳转到SpringSecurity的登录页面,默认的用户名为user, 默认的密码会控制台显示
输入user和密码即可登录,然后就会请求到hello接口。
2、认证
2.1、原理初探
2.1.1、SpringSecurity完整流程
SpringSecurity的原理其实就是一个过滤器链,内部包含了各种各样的过滤器,入门案例的过滤器链如图所示。
UsernamePasswordAuthenticationFilter
:负责处理我们登录页填写了用户名密码后的登录请求。入门案例中的认证工作主要又他负责。
ExceptionTranslationFilter
:处理过滤器中抛出的任何AccessDeniedException(权限异常) 和AuthenticationException(认证异常)。
FilterSecurityInterceptor
:负责权限校验的过滤器。
2.2.2、认证流程详解(登录)
就是 SpringSecurity 登录的这个功能是如何实现的。
由上面的图上首先经过UsernamePasswordAuthenticationFilter 那么他做了哪些动作呢?首先他会接受到默认页面提交的/login 的from中action请求,然后走认证的流程。
具体流程如下:
说明:
Authentication
:它他的实现类表示当前访问系统的用户,封装了用户的相关信息。
AuthenticationManager
: 这个接口中定义了Authentication的方法。(就是说可以直接调用AuthenticationManager中的方法认证用户的信息是否正确)。
UserDetailsService
:这个接口里面定义了一个查询用户信息的方法,将用户的信息封装成一个
UserDetails
对象返回,在然后会在Provider中对比前面传递过来的Authentication和后面返回的Authentication。如果正确则会给Authentication设置上权限信息然后返回。
问题:
①在前后端分离的情况下,我们肯定不能用系统默认的UsernamePasswordAuthenticationFilter 去接收username和password然后它调用AuthenticationManager中的方法继续去走认证流程
②我们也不能使用系统默认的用户名和密码进行登录,而是应该是用户输入后我们去数据库查询才对。
③登录完之后UsernamePasswordAuthenticationFilter 会将Authentication
存入SecurityContextHolder的上下文之中,表示我们确实登录了,然后才可以访问其他接口
2.2、解决问题
2.2.1、思路分析
根据上述的问题,我们则可以自定义封路接口然后手动调用AuthenticationManager中的方法进行校验,并且手动编写UserDetails接口,让它能从数据库中查询数据。然后登陆成功后,访问其他接口的时候我们得手动去设置SecurityContextHolder的上下文
登录
① 自定义登录接口
调用ProviderManager的方法进行认证,如果认证通过则生成JWT
(JWT中包含用户的userId)并且为了之后减少数据库的交互,我们把查到的用户信息存入redis
② 自定义UserDetailService
在我们自己的实现类中查询数据库
校验
当登录成功后,我们前端请求其他接口的时候就需要在请求头中携带token
① 自定义Jwt认证过滤器
获取token,解析token拿到用户userId,查询redis获取到用户的信息,然后手动存入SecurityContextHolder的上下文中。
2.2.2、准备工作
①添加依赖
<!--redis依赖-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!--fastjson依赖-->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>1.2.33</version>
</dependency>
<!--jwt依赖-->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.0</version>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
<version>3.12.0</version>
</dependency>
②添加redis的序列化规则配置(使用json格式的序列化)
注意:因为添加了redis所以记得要在yml中配置redis的连接哦
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 sg
*/
public class FastJsonRedisSerializer<T> implements RedisSerializer<T>
{
public static final Charset DEFAULT_CHARSET = Charset.forName("UTF-8");
private Class<T> clazz;
static
{
ParserConfig.getGlobalInstance().setAutoTypeSupport(true);
}
public FastJsonRedisSerializer(Class<T> clazz)
{
super();
this.clazz = clazz;
}
@Override
public byte[] serialize(T t) throws SerializationException
{
if (t == null)
{
return new byte[0];
}
return JSON.toJSONString(t, 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);
}
}
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<Object, Object> redisTemplate(RedisConnectionFactory connectionFactory)
{
RedisTemplate<Object, Object> template = new RedisTemplate<>();
template.setConnectionFactory(connectionFactory);
FastJsonRedisSerializer serializer = new FastJsonRedisSerializer(Object.class);
// 使用StringRedisSerializer来序列化和反序列化redis的key值
template.setKeySerializer(new StringRedisSerializer());
template.setValueSerializer(serializer);
// Hash的key也采用StringRedisSerializer的序列化方式
template.setHashKeySerializer(new StringRedisSerializer());
template.setHashValueSerializer(serializer);
template.afterPropertiesSet();
return template;
}
}
③ 前后端分离,所以使用统一响应类
import com.fasterxml.jackson.annotation.JsonInclude;
@JsonInclude(JsonInclude.Include.NON_NULL)
public class ResponseResult<T> {
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的工具类
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 = "yzh";
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("yzh") // 签发者
.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();
}
}
⑤redis的工具类
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 缓存的值
*/
public <T> void setCacheObject(final String key, final T value)
{
redisTemplate.opsForValue().set(key, value);
}
/**
* 缓存基本的对象,Integer、String、实体类等
*
* @param key 缓存的键值
* @param value 缓存的值
* @param timeout 时间
* @param timeUnit 时间颗粒度
*/
public <T> 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> T getCacheObject(final String key)
{
ValueOperations<String, T> 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 <T> long setCacheList(final String key, final List<T> dataList)
{
Long count = redisTemplate.opsForList().rightPushAll(key, dataList);
return count == null ? 0 : count;
}
/**
* 获得缓存的list对象
*
* @param key 缓存的键值
* @return 缓存键值对应的数据
*/
public <T> List<T> getCacheList(final String key)
{
return redisTemplate.opsForList().range(key, 0, -1);
}
/**
* 缓存Set
*
* @param key 缓存键值
* @param dataSet 缓存的数据
* @return 缓存数据的对象
*/
public <T> BoundSetOperations<String, T> setCacheSet(final String key, final Set<T> dataSet)
{
BoundSetOperations<String, T> setOperation = redisTemplate.boundSetOps(key);
Iterator<T> it = dataSet.iterator();
while (it.hasNext())
{
setOperation.add(it.next());
}
return setOperation;
}
/**
* 获得缓存的set
*
* @param key
* @return
*/
public <T> Set<T> getCacheSet(final String key)
{
return redisTemplate.opsForSet().members(key);
}
/**
* 缓存Map
*
* @param key
* @param dataMap
*/
public <T> void setCacheMap(final String key, final Map<String, T> dataMap)
{
if (dataMap != null) {
redisTemplate.opsForHash().putAll(key, dataMap);
}
}
/**
* 获得缓存的Map
*
* @param key
* @return
*/
public <T> Map<String, T> getCacheMap(final String key)
{
return redisTemplate.opsForHash().entries(key);
}
/**
* 往Hash中存入数据
*
* @param key Redis键
* @param hKey Hash键
* @param value 值
*/
public <T> 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> T getCacheMapValue(final String key, final String hKey)
{
HashOperations<String, String, T> 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 <T> List<T> getMultiCacheMapValue(final String key, final Collection<Object> hKeys)
{
return redisTemplate.opsForHash().multiGet(key, hKeys);
}
/**
* 获得缓存的基本对象列表
*
* @param pattern 字符串前缀
* @return 对象列表
*/
public Collection<String> keys(final String pattern)
{
return redisTemplate.keys(pattern);
}
}
2.2.3、UserDetailsService接口实现
创建UserDetailsService的实现类重写其中的方法,因为需要返回一个UserDetails接口对象,所以我们得先定义一个实现类
@Data
@NoArgsConstructor
@AllArgsConstructor
public class LoginUser implements UserDetails {
private User user;
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return null; //该用户有哪些权限
}
@Override
public String getPassword() {
return this.user.getPassword();
}
@Override
public String getUsername() {
return this.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;
}
}
UserDetailsService的实现
@Service
public class UserDetailsServiceImpl implements UserDetailsService {
@Autowired
private UserMapper userMapper;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
if(StringUtils.isBlank(username)){
throw new RuntimeException("请输入用户名");
}
QueryWrapper<User> wrapper = new QueryWrapper<>();
wrapper.eq("user_name",username).last("limit 1");
User user = userMapper.selectOne(wrapper);
if(Objects.isNull(user)){
throw new RuntimeException("用户名不存在");
}
return new LoginUser(user);
}
}
注意,如果你现在想测试是否能使用自定义的UserDetailsService的逻辑进行登录的话,因为在密码比较中SpringSecurity会使用默认的PasswordEncoder编码加密将用户传递的密码加上{noop},所以想测试的话数据库密码记得修改
2.2.4、密码加密存储
实际过程中我们是不会使用明文进行加密的,所以我们需要将PasswordEncoder进行替换,在SpringSecurity我们一般使用BCryptPasswordEncoder进行加密
编写SpringSecurity的配置
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Bean
public PasswordEncoder passwordEncoder(){
return new BCryptPasswordEncoder();
}
}
测试 (之后我们数据库中存储的密码都得是这种加密后的数据)
2.2.5、登录接口
我们需要自定义登录的接口
@RestController
@RequestMapping("/user")
public class LoginController {
@Autowired
private LoginService loginService;
@PostMapping("/login")
public ResponseResult login(@RequestBody User user){
return loginService.login(user);
}
}
我们需要对这个接口进行放行,并且我们在接口中需要手动去调用AuthenticationManager的authenticate的方法,所以我们还需要在将AuthenticationManager注入到IOC容器。
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http
//关闭csrf
.csrf().disable()
//不通过Session获取SecurityContext
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.authorizeRequests()
// 对于登录接口 允许匿名访问
.antMatchers("/user/login").anonymous() //表示匿名可访问
// 除上面外的所有请求全部需要鉴权认证
.anyRequest().authenticated();
}
@Bean
@Override
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
@Bean
public PasswordEncoder passwordEncoder(){
return new BCryptPasswordEncoder();
}
}
登录接口具体实现
@Service
public class LoginServiceImpl implements LoginService {
@Autowired
private AuthenticationManager authenticationManager;
@Autowired
private RedisCache redisCache;
@Override
public ResponseResult login(User user) {
//使用Authentication的实现类
Authentication authentication = new UsernamePasswordAuthenticationToken(user.getUserName(), user.getPassword());
//手动调用方法去认证 他会自动调用UserDetailsService查 然后对比啥的
Authentication authenticate = authenticationManager.authenticate(authentication);
if(Objects.isNull(authenticate)){ //说明输入错误
throw new RuntimeException("用户名或密码错误");
}
//拿到用户信息 然后生成jwt返回给前端,并且将用户的信息存入redis
LoginUser loginUser = (LoginUser)authenticate.getPrincipal(); // 这个其实就是UserDetails 也就是LoginUser
String userId = loginUser.getUser().getId().toString();
String jwt = JwtUtil.createJWT(userId);
redisCache.setCacheObject("login:"+userId,loginUser);//将用户信息直接存入redis
Map<String, String> map = new HashMap<>();
map.put("token",jwt);
return new ResponseResult(200,map);
}
}
测试
到这里,我们的登录接口就完事了,前端就获取到了token,之后前端如果想访问其他接口,那么就直接请求头中携带上token即可
2.2.6、认证过滤器
回顾:
③登录完之后UsernamePasswordAuthenticationFilter 会将Authentication
存入SecurityContextHolder的上下文之中,表示我们确实登录了,然后才可以访问其他接口
…
解决:
因为现在是前后端分离 所以在访问其他接口之前我们需要手动在SecurityContextHolder设置上下文。这样SpringSecurity才会认为他确实登录了。才会选择放行
@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.isBlank(token)){
//说明没有携带token 那么直接放行 之后的过滤器肯定会报错,那么就说明用户没有登录
filterChain.doFilter(request,response);
return;
}
//解析token
String userid;
try {
Claims claims = JwtUtil.parseJWT(token);
userid = claims.getSubject();
} catch (Exception e) {
e.printStackTrace();
//就说明token失效了 或者是token无效
throw new RuntimeException("token无效");
}
//从redis中拿到用户的信息,给SecurityContextHolder设置上下文
LoginUser loginUser = (LoginUser)redisCache.getCacheObject("login:" + userid);
if(Objects.isNull(loginUser)){
throw new RuntimeException("用户未登录");
}
//存入SecurityContextHolder上下文当中 注意 这里必须得使用三个参数的authentication
//第三个参数为授权 也就是用户是啥身份 先不管
Authentication authentication = new UsernamePasswordAuthenticationToken(loginUser,null,null);
SecurityContextHolder.getContext().setAuthentication(authentication);
//放行
filterChain.doFilter(request,response); //那么就正常的请求接口去啦!!!
}
}
我们还需要SpringSecurity中配置上该过滤器
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private JwtAuthenticationTokenFilter jwtAuthenticationTokenFilter;
@Override
protected void configure(HttpSecurity http) throws Exception {
http
//关闭csrf
.csrf().disable()
//不通过Session获取SecurityContext
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.authorizeRequests()
// 对于登录接口 允许匿名访问
.antMatchers("/user/login").anonymous() //表示匿名可访问
// 除上面外的所有请求全部需要鉴权认证
.anyRequest().authenticated();
http.addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);
}
@Bean
@Override
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
@Bean
public PasswordEncoder passwordEncoder(){
return new BCryptPasswordEncoder();
}
}
测试 登录成功之后携带token再请求hello接口
2.2.7、退出登录
退出接口
@PostMapping("/logout")
public ResponseResult logout(){
return loginService.logout();
}
具体实现 直接删除redis中的数据即可
@Override
public ResponseResult logout() {
//因为这个方法 是通过了jwt过滤器执行到这里的 所以SecurityContextHolder上下文是一样的
LoginUser loginUser = (LoginUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
//拿到用户id删除redis中的数据
String userId = loginUser.getUser().getId().toString();
redisCache.deleteObject("login:"+userId);
return new ResponseResult(200,"退出成功");
}
测试
再次访问hello接口,就会提示没有权限
3、授权
在很多系统中,比如xx管理系统,不同的角色所具备权限也不一样,比如管理员能删除或者修改信息或者查询,而阅读者只能查询信息。
虽然说前端可以动态的显示菜单,但如果有人拿到了接口,是不是也同样可以做其他操作呢?所以我们可以对接口进行设置上响应的权限,然后带有该权限的用户才能访问
3.1、授权的流程
根据最上面的调用API接口过滤器的流程,FilterSecurityInterceptor会判断用户是否具有响应的权限,他会从SecurityContextHolder中拿到Authentication中拿到用户用户所具有的权限,
3.2、授权实现
3.2.1、先给具体的资源设置上所需权限
SpringSecurity给我们提供了基于注解的权限控制,我们先开启相关配置
@EnableGlobalMethodSecurity(prePostEnabled = true)
之后就可以使用注解了
@PreAuthorize("hasAuthority('test')")
@GetMapping("/hello")
public List hello(){
List<User> list = userMapper.selectList(null);
return list;
}
访问接口测试,会发现没有权限
3.2.2、配置权限信息
先写死做测试,我们现在LoginUser中加入两个参数
注意,之所以不直接传递authorities,而是传递permission,在getAuthorities 时动态的使用permission封装authorities,那是因为redis对于
List<GrantedAuthority>
没法序列化。反序列化时会报错。
@Data
@NoArgsConstructor
public class LoginUser implements UserDetails {
private User user;
private List<String> permission;
public LoginUser(User user,List<String> permission) {
this.user = user;
this.permission=permission;
}
@JSONField(serialize = false)
private List<GrantedAuthority> authorities;
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
authorities=new ArrayList<>();
//注意 为什么这里不直接返回
for (String perm : permission) {
authorities.add(new SimpleGrantedAuthority(perm));
}
return authorities; //该用户有哪些权限
}
@Override
public String getPassword() {
return this.user.getPassword();
}
@Override
public String getUsername() {
return this.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;
}
}
接着我们在UserDetailsService中传递所具有的权限
@Service
public class UserDetailsServiceImpl implements UserDetailsService {
@Autowired
private UserMapper userMapper;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
if(StringUtils.isBlank(username)){
throw new RuntimeException("请输入用户名");
}
QueryWrapper<User> wrapper = new QueryWrapper<>();
wrapper.eq("user_name",username).last("limit 1");
User user = userMapper.selectOne(wrapper);
if(Objects.isNull(user)){
throw new RuntimeException("用户名不存在");
}
//传递用户所具有的权限
List<String> list = new ArrayList<>(Arrays.asList("test", "admin"));
return new LoginUser(user,list);
}
}
最后在过滤器中设置SecurityContextHolder上下文时也设置上权限,那么后面的权限过滤器就会拿到对应的权限去判断我们是否有权限
@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.isBlank(token)){
//说明没有携带token 那么直接放行 之后的过滤器肯定会报错,那么就说明用户没有登录
filterChain.doFilter(request,response);
return;
}
//解析token
String userid;
try {
Claims claims = JwtUtil.parseJWT(token);
userid = claims.getSubject();
} catch (Exception e) {
e.printStackTrace();
//就说明token失效了 或者是token无效
throw new RuntimeException("token无效");
}
//从redis中拿到用户的信息,给SecurityContextHolder设置上下文
LoginUser loginUser = (LoginUser)redisCache.getCacheObject("login:" + userid);
if(Objects.isNull(loginUser)){
throw new RuntimeException("用户未登录");
}
//存入SecurityContextHolder上下文当中 注意 这里必须得使用三个参数的authentication
//第三个参数则为权限
Authentication authentication = new UsernamePasswordAuthenticationToken(loginUser,null,loginUser.getAuthorities());
SecurityContextHolder.getContext().setAuthentication(authentication);
//放行
filterChain.doFilter(request,response); //那么就正常的请求接口去啦!!!
}
}
测试
3.2.3、从数据库中查询信息
这里就不演示了,写累了,相信可爱的你一定能搞定…
4、自定义异常处理器
从上面测试的例子中我们可以看到,在如果认证失败或者是没有权限的话,就会返回对应的json数据,但是这与我们统一返回的json格式不一致,所以我们需要自定义异常处理器
{
"timestamp": "2022-04-27T08:49:11.475+00:00",
"status": 403,
"error": "Forbidden",
"message": "",
"path": "/login"
}
准备工作,我们可以先导入一个WebUtil工具类,以便向前端写出json数据
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;
}
}
4.1、自定义异常处理
在SpringSecurity中,认证或授权出现异常会被ExceptionTranslationFilter捕获到,他会去判断是认证失败还是授权失败出现异常。
如果是认证过程中出现异常,那么异常会被封装成AuthenticationException然后调用
AuthenticationEntryPoint
的方法对异常进行处理。
如果是授权过程中出现异常,会被封装成AccessDeniedException然后调用
AccessDeniedHandler
的方法对异常进行处理。
所以,对于自定义
AuthenticationEntryPoint
和
AccessDeniedHandler
然后配置给SpringSecurity即可。
①自定义AuthenticationEntryPoint实现类
@Component
public class AuthenticationEntryPointImpl implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException e) throws IOException, ServletException {
response.setStatus(HttpStatus.UNAUTHORIZED.value());//401 表示没有授权
ResponseResult result = new ResponseResult(401,"认证失败请重新登录");
WebUtils.renderString(response, JSON.toJSONString(result));
}
}
②自定义AccessDeniedHandler实现类
@Component
public class AccessDeniedHandlerImpl implements AccessDeniedHandler {
@Override
public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException e) throws IOException, ServletException {
response.setStatus(HttpStatus.FORBIDDEN.value()); //403
ResponseResult result = new ResponseResult(403, "权限不足无法访问");
WebUtils.renderString(response, JSON.toJSONString(result));
}
}
配置给SpringSecurity
@Autowired
private AuthenticationEntryPoint authenticationEntryPoint;
@Autowired
private AccessDeniedHandler accessDeniedHandler;
http.exceptionHandling()
.authenticationEntryPoint(authenticationEntryPoint)
.accessDeniedHandler(accessDeniedHandler);
测试,如果帐号和密码输入错误
如果权限不足
5、跨域
即使我们通过Cors解决了跨域,但我们的资源都会被SpringSecurity进行保护,所以还是会存在跨域
解决跨域需要对SpringSecurity进行配置
①先配置SpringBoot的跨域
@Configuration
public class CorsConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
// 设置允许跨域的路径
registry.addMapping("/**")
.allowedOriginPatterns("*")// 设置允许跨域请求的域名
.allowCredentials(true) // 是否允许cookie
.allowedMethods("GET", "POST", "DELETE", "PUT") // 设置允许的请求方式
.allowedHeaders("*") // 设置允许的header属性
.maxAge(3600);// 跨域允许时间
}
}
②开启SpringSecurity的跨域
直接在SpringSecurity的配置中加上一行代码即可
http.cors();
到这里,SpringSecurity+Jwt前后端分离就完结了,就是这么简单,完结撒花。
6、后记
生活朗朗,万物可爱,人间值得。。未来可期。