简介 Spring Security是一个功能强大且可高度自定义的身份验证和访问控制框架。它是保护基于Spring的应用程序的事实上的标准。
Spring Security是一个专注于为Java应用程序提供身份验证和授权的框架。与所有Spring项目一样,Spring Security的真正强大之处在于它可以轻松扩展以满足自定义要求。
参考视频
总览
实现前后端分离,按钮级权限控制(接口权限控制)
围绕 RBAC 权限模型进行设计
IDEA 2022.1
JDK 17.0.2
Gradle 7.4.1
SpringBoot 2.6.4
Spring Security 5.6.2
Spring Security 是一个功能强大且可高度自定义的身份验证和访问控制框架。它是保护基于Spring的应用程序的事实上的标准。
Spring Security 是一个专注于为Java应用程序提供身份验证和授权的框架。与所有Spring项目一样,Spring Security的真正强大之处在于它可以轻松扩展以满足自定义要求。
一般来说中大型的项目都是使用SpringSecurity 来做安全框架。小项目有Shiro的比较多,因为相比与SpringSecurity,Shiro的上手更加的简单。
一般Web应用的需要进行认证
和授权
1️⃣ 认证: 验证当前访问系统的是不是本系统的用户,并且要确认具体是哪个用户 2️⃣ 授权: 经过认证后判断当前用户是否有权限进行某个操作 3️⃣ 而认证和授权也是SpringSecurity作为安全架的核心功能。
Spring Security 是 Spring 家族中的一个安全管理框架。相比与另外一个安全框架Shiro ,它提供了更丰富的功能,社区资源也比Shiro丰富。
Spring 以简单而闻名,但讽刺的是很多人发现安装Spring Security很难
Spring Security有更好的社区支持
Apache Shiro 在 Spring Security 处理密码学方面有一个额外的模块
Spring Security 对 Spring 结合较好,如果项目用的 SpringMVC,使用起来很方便。但是如果项目中没有用到Spring,那就不要考虑它了。
在 SpringBoot 工程快速整合 Security
在 SpringBoot 项目中使用 SpringSecurity 我们只需要引入启动器的依赖后重启项目
访问 http://localhost:port/login 进行登录
用户名默认为:user
密码在启动的控制台有打印
可以自行实现一下,访问一个接口,如果没有登录会自动重定向到 login 页面,也就是说没有登录就无法访问接口
1 implementation("org.springframework.boot:spring-boot-starter-security" )
认证 想要知道如何实现自己的登陆流程就必须要先知道入门案例中 Spring Security的流程。
SpringSecurity完整流程
Spring Security 的原理其实就是一个过滤器链,内部包含了提供各种功能的过滤器。这里我们可以看看入门案例中的过滤器。 过滤器链: 多个过滤器组成一个过滤器链
图中只展示了快速入门中的核心过滤器,其它的非核心过滤器并没有在图中展示。
UsernamePasswordAuthenticationFilter :负责处理我们在登陆页面填写了用户名密码后的登陆请求。入门案例的认证工作主要有它负责。
ExceptionTranslationFilter: 处理过滤器链中抛出的任何AccessDeniedException和AuthenticationException,也就是说在处理中捕获到了异常都可以可以用它处理
FilterSecurityInterceptor: 负责权限校验的过滤器。
完整的过滤器
通过Debug查看当前系统中 Spring Security 过滤器链中有哪些过滤器及它们的顺序
1 run.getBean(DefaultSecurityFilterChain.class)
登录认证流程详解
概念速查 1️⃣ Authentication接口: 它的实现类,表示当前访问系统的用户,封装了用户相关信息 2️⃣ AuthenticationManager接口: 定义了认证Authentication的方法 3️⃣ UserDetailsService接口: 加载用户特定数据的核心接口。里面定义了一个根据用户名查询用户信息的方法 4️⃣ UserDetails接口: 提供核心用户信息。通过UserDetailsService根据用户名获取处理的用户信息要封装成UserDetails对象返回。然后将这些信息封装到Authentication对象中
登录改造思路
上面的流程对我们前后端分离的项目有两个地方需要改造 1️⃣ 5.1 步骤 我们需要去数据库中查询而不是在内存中查询,因此我们要实现接口重新一个实现类 2️⃣ 步骤 1 步骤 10 我们应该让前端来掉我们自己的 controller 然后我们去掉对应的认证,最后这个 controller 返回的时候需要封装一个 token 回去 UsernamePasswordAuthenticationFilter 换成我们自己的登录接口InMemoryUserDetailManager 换成我们自己实现了 UserDetailsService 接口的实现了去数据查询用户
认证思路
上面只是改造了登录,登录后,请求接口时我们怎么去判断是否已经登录呢?是当前用户是谁呢? 其实我们在上面返回的 jwt 生成的 token 用了用户 id 去生成 所以解析一下 token 就知道是谁了
用户详情获取
登录 –> 返回 token –> JWT 认证 –> 如何获取用户的详情?比如权限? 简单粗暴:登录后每次都带有 token,token 一解析就知道用户 ID,然后根据用户 ID 去数据库查询对应的权限(这种方式就是每次请求都需要去查询用户的权限,对数据库来说并不友好) 现实实践:如果能在我们每次登录成功以后就把我们的用户信息写到缓存里面就好了,缓存的 key 就是用户 ID, value 就是用户详情 会不会刚好有那么一个东西叫做 Redis
登录
自定义登录接口: 1️⃣ 调用ProviderManager 的方法进行认证 如果认证通过生成jwt 2️⃣ 把用户信息存入redis中 自定义UserDetailsService ,在这个实现类中去查询数据库
校验
定义Jwt认证过滤器 获取token 解析token获取其中的userid 从redis中获取用户信息 存入SecurityContextHolder
准备工作 依赖准备 集成完整的环境 Redis
fastjson
jwt
1 2 3 implementation("org.springframework.boot:spring-boot-starter-data-redis" ) implementation("com.alibaba:fastjson:1.2.80" ) implementation("io.jsonwebtoken:jjwt:0.9.1" )
配置类 创建 Redis 配置类
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 package com.adalucky.config;import com.adalucky.utils.redis.FastJsonRedisSerializer;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); template.setKeySerializer(new StringRedisSerializer ()); template.setValueSerializer(serializer); template.setHashKeySerializer(new StringRedisSerializer ()); template.setHashValueSerializer(serializer); template.afterPropertiesSet(); return template; } }
工具类 创建一些工具类 JWT工具类
FastJson序列化Redis
RedisTemplate工具类
respons工具类
JWT工具类
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 package com.adalucky.utils.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;public class JwtUtil { public static final Long JWT_TTL = 60 * 60 * 1000L ; public static final String JWT_KEY = "sangeng" ; public static String getUUID () { String token = UUID.randomUUID().toString().replaceAll("-" , "" ); return token; } public static String createJWT (String subject) { JwtBuilder builder = getJwtBuilder(subject, null , getUUID()); return builder.compact(); } 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) .setSubject(subject) .setIssuer("sg" ) .setIssuedAt(now) .signWith(signatureAlgorithm, secretKey) .setExpiration(expDate); } 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); } public static SecretKey generalKey () { byte [] encodedKey = Base64.getDecoder().decode(JwtUtil.JWT_KEY); SecretKey key = new SecretKeySpec (encodedKey, 0 , encodedKey.length, "AES" ); return key; } public static Claims parseJWT (String jwt) throws Exception { SecretKey secretKey = generalKey(); return Jwts.parser() .setSigningKey(secretKey) .parseClaimsJws(jwt) .getBody(); } }
FastJson序列化Redis
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 package com.adalucky.utils.redis;import com.alibaba.fastjson.JSON;import com.alibaba.fastjson.parser.ParserConfig;import com.alibaba.fastjson.serializer.SerializerFeature;import com.fasterxml.jackson.databind.JavaType;import com.fasterxml.jackson.databind.type.TypeFactory;import org.springframework.data.redis.serializer.RedisSerializer;import org.springframework.data.redis.serializer.SerializationException;import java.nio.charset.Charset;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); } }
RedisTemplate工具类
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 package com.adalucky.utils.redis;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; public <T> void setCacheObject (final String key, final T value) { redisTemplate.opsForValue().set(key, value); } public <T> void setCacheObject (final String key, final T value, final Integer timeout, final TimeUnit timeUnit) { redisTemplate.opsForValue().set(key, value, timeout, timeUnit); } public boolean expire (final String key, final long timeout) { return expire(key, timeout, TimeUnit.SECONDS); } public boolean expire (final String key, final long timeout, final TimeUnit unit) { return redisTemplate.expire(key, timeout, unit); } public <T> T getCacheObject (final String key) { ValueOperations<String, T> operation = redisTemplate.opsForValue(); return operation.get(key); } public boolean deleteObject (final String key) { return redisTemplate.delete(key); } public long deleteObject (final Collection collection) { return redisTemplate.delete(collection); } public <T> long setCacheList (final String key, final List<T> dataList) { Long count = redisTemplate.opsForList().rightPushAll(key, dataList); return count == null ? 0 : count; } public <T> List<T> getCacheList (final String key) { return redisTemplate.opsForList().range(key, 0 , -1 ); } 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; } public <T> Set<T> getCacheSet (final String key) { return redisTemplate.opsForSet().members(key); } public <T> void setCacheMap (final String key, final Map<String, T> dataMap) { if (dataMap != null ) { redisTemplate.opsForHash().putAll(key, dataMap); } } public <T> Map<String, T> getCacheMap (final String key) { return redisTemplate.opsForHash().entries(key); } public <T> void setCacheMapValue (final String key, final String hKey, final T value) { redisTemplate.opsForHash().put(key, hKey, value); } public <T> T getCacheMapValue (final String key, final String hKey) { HashOperations<String, String, T> opsForHash = redisTemplate.opsForHash(); return opsForHash.get(key, hKey); } public void delCacheMapValue (final String key, final String hkey) { HashOperations hashOperations = redisTemplate.opsForHash(); hashOperations.delete(key, hkey); } public <T> List<T> getMultiCacheMapValue (final String key, final Collection<Object> hKeys) { return redisTemplate.opsForHash().multiGet(key, hKeys); } public Collection<String> keys (final String pattern) { return redisTemplate.keys(pattern); } }
respons工具类
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 package com.adalucky.utils.web;import javax.servlet.http.HttpServletResponse;import java.io.IOException;public class WebUtils { 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 ; } }
数据库配置 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 # spring 相关集成配置 spring: # 数据源配置 datasource: driver-class -name: com.p6spy.engine.spy.P6SpyDriver url: jdbc:p6spy:mysql: username: root password: 123456 redis: # Redis本地服务器地址,注意要开启redis服务 host: 192.168 .1 .115 # Redis服务器端口,默认为6379. 若有改动按改动后的来 port: 6379 #Redis服务器连接密码,默认为空,若有设置按设置的来 password: 123456 jedis: pool: # 连接池最大连接数,若为负数则表示没有任何限制 max-active: 8 # 连接池最大阻塞等待时间,若为负数则表示没有任何限制 max-wait: -1 # 连接池中的最大空闲连接 max-idle: 8
核心代码实现 库表创建 建立 user 表(密码前先展示用 {noop} )
重写接口的方法
实现 UserDetails 接口,重写里面的方法
重写 UserDetailsService 接口下的 loadUserByUsername 方法,因为这个方法默认是从内存中查的用户信息,我们需要改为去数据库查询
实现 UserDetails 接口
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 package com.adalucky.modules.system.service.impl;import com.adalucky.modules.system.model.entity.SysUser;import org.springframework.security.core.GrantedAuthority;import org.springframework.security.core.userdetails.UserDetails;import java.util.Collection;public class LoginUser implements UserDetails { private SysUser sysUser; public SysUser getSysUser () { return sysUser; } public void setSysUser (SysUser sysUser) { this .sysUser = sysUser; } public LoginUser (SysUser sysUser) { this .sysUser = sysUser; } @Override public Collection<? extends GrantedAuthority > getAuthorities() { return null ; } @Override public String getPassword () { return sysUser.getPassword(); } @Override public String getUsername () { return sysUser.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 ; } }
重写loadUserByUsername方法
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 package com.adalucky.modules.system.service.impl;import com.adalucky.handler.BusinessException;import com.adalucky.modules.system.mapper.SysUserMapper;import com.adalucky.modules.system.model.entity.SysUser;import com.adalucky.response.ResultCode;import com.adalucky.utils.str.StringUtils;import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;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;@Service public class UserDetailServiceImpl implements UserDetailsService { @Autowired SysUserMapper mapper; @Override public UserDetails loadUserByUsername (String userName) throws UsernameNotFoundException { if (!StringUtils.isEffective(userName)) { throw new BusinessException ( ResultCode.PARAM_IS_BLANK.getCode(), ResultCode.PARAM_IS_BLANK.getMessage() ); } LambdaQueryWrapper<SysUser> queryWrapper = new LambdaQueryWrapper <>(); queryWrapper.eq(SysUser::getUserName, userName); var user = mapper.selectOne(queryWrapper); return new LoginUser (user); } }
启动 测试
重写了上面的方法后我们可以启动一下进行测试,这个时候就需要输入我们数据的用户名和密码了
如果数据库直接是明文存储的话这里就会抛出 There is no PasswordEncoder mapped for the id "null"
问题分析 Spring Security 的用户认证流程 1️⃣ 把前端传过来的用户名去数据查询对应的用户 也就是调用我们重写的loadUserByUsername(String userName) 方法
2️⃣ 查询到以后通过 return new LoginUser(user)
封装用户信息到 UserDetails
3️⃣ 会自动调用 UserDetails
的实现类,也就是我们这里的 LoginUser
的 getPassword() 获取用户的密码 4️⃣ 按照 默认或者我们指定的加密方式去和 getPassword() 获取用户的密码 比对 5️⃣ 比对通过登录成功,默认返回用户名或密码错误提示
解决方案
告诉 Spring Security 我就是要用明文存,你就用明文比对就行了其它的安全不安全,泄露不泄露,与你不相干
这种方式比较粗暴,直接直接再数据库的密码前面加上 {noop} 例如 123456 –> {noop}123456
简介
实际项目中我们不会把密码明文存储在数据库中,采用密码加密存储方式,也就是在我们注册用户的时候把明文密码以 Spring Security 的加密方式进行加密,存储在数据库里面,验证的时候就把前端的明文再按照之前的加密方式加密一般和数据库返回的用户密码比较一次,一样的话密码就是正确的,校验通过 默认使用的PasswordEncoder要求数据库中的密码格式为:{id}password 。它会根据id去判断密码的加密方式。但是我们一般不会采用这种方式。所以就需要替换PasswordEncoder。(也就是要像上面写成 {noop}password ) 我们一般使用SpringSecurity为我们提供的BCryptPasswordEncoder。 我们只需要使用把BCryptPasswordEncoder对象注入Spring容器中,SpringSecurity就会使用该PasswordEncoder来进行密码校验。 我们可以定义一个SpringSecurity的配置类,SpringSecurity要求这个配置类要继承WebSecurityConfigurerAdapte 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 package com.adalucky.auth;import org.springframework.context.annotation.Bean;import org.springframework.context.annotation.Configuration;import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;import org.springframework.security.crypto.password.PasswordEncoder;@Configuration public class WebSecurityConfig extends WebSecurityConfigurerAdapter { @Bean public PasswordEncoder passwordEncoder () { return new BCryptPasswordEncoder (); } }
代码测试
PasswordEncoder 加密encode()
从下面图片可以看出来,同一个明文通过同一种方式最后加密的结果不一样,究其原因就是每次使用的 盐值
不一样
$2a$10$tSn8ALjHUOtWPUziO8xIM.Auer5/yLLWsZspo3tsWI5OyBDea5mRS 1️⃣ $2a$10$
+ 22位盐值
+ 加密后的密文
2️⃣ $2a
是 BCrypt 的版本 3️⃣ $10
是 10 次哈希
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 package com.adalucky.auth;import org.junit.jupiter.api.Test;import org.springframework.beans.factory.annotation.Autowired;import org.springframework.boot.test.context.SpringBootTest;import org.springframework.security.crypto.password.PasswordEncoder;@SpringBootTest class WebSecurityConfigTest { @Autowired PasswordEncoder passwordEncoder; @Test void passwordEncoder () { var encode = passwordEncoder.encode("123456" ); var encode1 = passwordEncoder.encode("123456" ); System.out.println(encode); System.out.println(encode1); } }
PasswordEncoder.matches(rawPassword,encodedPassword) 认证密码
密码认证结果返回布尔值
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 package com.adalucky.auth;import org.junit.jupiter.api.Test;import org.springframework.beans.factory.annotation.Autowired;import org.springframework.boot.test.context.SpringBootTest;import org.springframework.security.crypto.password.PasswordEncoder;@SpringBootTest class WebSecurityConfigTest { @Autowired PasswordEncoder passwordEncoder; @Test void passwordEncoder () { System.out.println(passwordEncoder.matches("123456" , "$2a$10$tSn8ALjHUOtWPUziO8xIM.Auer5/yLLWsZspo3tsWI5OyBDea5mRS" )); System.out.println(passwordEncoder.matches("123654" , "$2a$10$tSn8ALjHUOtWPUziO8xIM.Auer5/yLLWsZspo3tsWI5OyBDea5mRS" )); } }
找一个刚才加密后密文替换数据库之前的 {noop}123456
重启项目用明文进行登录即可
其实很简单,就是在实体类的构造方法中对 密码进行一次加密即可
1 2 3 4 5 6 7 8 9 10 public SysUser (UserDto userDto) { this .userId = userDto.userId(); this .username = userDto.username(); this .nikename = userDto.nikename(); this .email = userDto.email(); this .phone = userDto.phone(); this .sex = userDto.sex(); this .avatar = userDto.avatar(); this .password = new BCryptPasswordEncoder ().encode(userDto.password()); }
登录接口实现 注意:登录 登出的接口地址不要用默认的 /login /logout 会和 Spring Security 的重合防止出现一些意向不到的错误,可以命名为 /user/login 这种
分析
再理一下思路,上面我们的 登录认证流程详解图中: 前端请求登录 –> 默认用 AbstractAuthenticationProcessingFilter
的实现类 UsernamePasswordAuthenticationFilter
会去封装前端的账户和密码 –> 调用 ProviderManager
的 authentication
方法认证 –> xxx… –> 调用重写的 UserDetailsService 接口下的 loadUserByUsername 去数据库查询用户 —> 封装到 UserDetails 的实现类 LoginUser –> 返回给 authentication
在接口中我们通过 AuthenticationManager 的 authenticate 方法来进行用户认证,所以需要在 SecurityConfig 中配置 把AuthenticationManager注入容器
。
登录接口实现类注入 AuthenticationManager 调用 authenticate 方法进行认证
调用认证时需要传入 Authentication 对象,所以我们用 new UsernamePasswordAuthenticationToken() 把前端传过来的数据封装成 Authentication 对象,调用 authenticate 方法进行认证 时候就传这个对象
认证返回的结果不是空的就表示有用户信息认证写进去了,这个写入 Redis 和返回 JWT 给前端即可即可
如果认证通过生成jwt写入Redis
那么我们只要重写实现类,在接口登录的时候调用即可
在登录接口需要放行,包括注册接口,主要涉及到一些不需要用户进行登录就能操作的接口 都需要进行放行
接下我们需要自定义登陆接口,然后让SpringSecurity对这个接口放行
,让用户访问这个接口的时候不用登录也能访问。
认证成功的话要生成一个jwt,放入响应中返回。并且为了让用户下回请求时能通过jwt识别出具体的是哪个用户,我们需要把用户信息存入redis
,可以把用户id作为key。
配置类
重写这个认证方法 authenticationManagerBean() 注入到容器中
配置请求放行等操作
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 package com.adalucky.auth;import org.springframework.context.annotation.Bean;import org.springframework.context.annotation.Configuration;import org.springframework.security.authentication.AuthenticationManager;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;@Configuration public class WebSecurityConfig extends WebSecurityConfigurerAdapter { @Bean public PasswordEncoder passwordEncoder () { return new BCryptPasswordEncoder (); } @Bean @Override public AuthenticationManager authenticationManagerBean () throws Exception { return super .authenticationManagerBean(); } @Override protected void configure (HttpSecurity http) throws Exception { http .csrf().disable() .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) .and() .authorizeRequests() .antMatchers("/user/login" , "/user/register" ).anonymous() .anyRequest().authenticated(); } }
实现
登录接口实现:1️⃣ new UsernamePasswordAuthenticationToken() 封装前端的传参 2️⃣调用注入容器中的 authenticate 认证 3️⃣ 返回的结果不为空后写入 Redis 缓存和生成 JWT 返回给前端
@Autowired 分别注入 AuthenticationManager RedisCache
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 public Result login (UserDto userDto) { UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken (userDto.username(), userDto.password()); var authenticate = authenticationManager.authenticate(authenticationToken); if (Objects.isNull(authenticate)) { throw new RuntimeException ("认证失败,用户名或密码错误" ); } var user = (LoginUser) authenticate.getPrincipal(); var userId = user.getSysUser().getUserId().toString(); redisCache.setCacheObject("login" + userId, user); return Result.ok().message("登录成功" ).data("token" , JwtUtil.createJWT(userId)); }
JWT认证过滤器
上面的我们已经生成了JWT返回给前端了,那么前端请求未放行的接口我们就要去解析 token 1️⃣ 是否是正确有效的 2️⃣ 请求的用户是谁 3️⃣ 根据解析的用户 id 去 Redis中查询用户的信息存入 SecurityContextHolder
为什么叫 JWT 认证过滤器呢,因为 Spring Security 框架有自带的过滤器链,然后我们在某个过滤器前面或者后面加入一个自己的过滤器,组成一个新的过滤器链
创建认证过滤器
这个过滤器的作用是为了拦截请求 校验用户的身份 1️⃣ 如果 token 为空的话就放行,因为可能是登录接口,或者注册接口,这里放行了后面拦截器中发现不是 登录或者注册接口又没有用户信息传过来就认证失败 403 2️⃣ 如果 token 解析失败证明就是非法的请求,抛出异常用户未登录 3️⃣ 解析成功了的话就去根据用户 ID 在 Redis 中取数据
注意事项:1️⃣ 登录成功那个接口写入的 key 和这里取时候的 key 要一致 2️⃣ 登录成功写入的数据类型和这里取时候接收的数据类型要兼容最好一致
过滤器尽量不要用 implements Filter
实现,因为这种方式可能在不同得 Severlet 版本中,一个请求过来过滤器会被调用多次,用 extends OncePerRequestFilter
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 package com.adalucky.filter;import com.adalucky.modules.system.model.entity.SysUser;import com.adalucky.utils.redis.RedisCache;import com.adalucky.utils.str.StringUtils;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.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;import static com.adalucky.utils.jwt.JwtUtil.parseJWT;@Component public class JwtAuthenticationTokenFilter extends OncePerRequestFilter { @Autowired RedisCache redisCache; @Override protected void doFilterInternal (HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { var token = request.getHeader("token" ); if (!StringUtils.isEffective(token)) { filterChain.doFilter(request, response); return ; } String userid; try { userid = parseJWT(token).getSubject(); } catch (Exception e) { e.printStackTrace(); throw new RuntimeException ("token非法" ); } SysUser user = redisCache.getCacheObject("login" + userid); if (Objects.isNull(user)) { throw new RuntimeException ("用户未登录" ); } UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken (user, null , null ); SecurityContextHolder.getContext().setAuthentication(authenticationToken); filterChain.doFilter(request, response); } }
添加JWT 认证过滤器
将我们写的过滤器添加到 Spring Security 的过滤器链里面
添加的位置根据具体的业务逻辑而定,我们这里就需要在最前面添加,UsernamePasswordAuthenticationFilter 之前
在 WebSecurityConfig 这个配置类中.addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 @Override protected void configure (HttpSecurity http) throws Exception { http .csrf().disable() .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) .and() .authorizeRequests() .antMatchers("/user/login" , "/user/register" ).anonymous() .anyRequest().authenticated() .and() .addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class); }
验证
到这里为止我们的期望是当前端传过token 有效后我们去解析 userId,然后去 Redis 中获取用户的信息,并把查询存到SecurityContextHolder 中
写入 SecurityContextHolder 的时候需要用 new UsernamePasswordAuthenticationToken 构造对应的对象 authentication 对象
这个 new 构造函数用两个参数和三个参数的,两个参数是用来构造账号+密码用于认证的,我们用的三个参数的
登出操作
注意接口命名之前也提过了不要和自带的重名了
这一步当我们登出的时候需要做的事情: 删 Redis 里面的用户信息
注意: 1️⃣ 调用这个接口不需要传参,只需要前端传 Token 2️⃣ 前端调用这个接口时候最先会走我们的 JWT 认证过滤器,这个时候认证通过会把我们的用户信息存在 SecurityContextHolder 没认证通过的话接口根本访问不到这里直接就会抛出 403 或者其它异常 3️⃣ 删除 Redis 缓存的时候也就是从 SecurityContextHolder 获取到用户 ID 然后拼接以后删除1 2 3 4 5 6 7 8 9 10 11 12 13 14 @Override public Result logout () { var authentication = SecurityContextHolder.getContext().getAuthentication(); var user = (SysUser) authentication.getPrincipal(); redisCache.deleteObject("login" + user.getUserId()); return Result.ok().message("退出登录成功" ); }
配置项简介
我们之前重写了 SecurityConfig 配置类,这里做一个配置项,configure 的一些粗浅简介
重写这个不需要注入到 Bean 中,我估摸这这个配置类重写以后 Security 就会调用我们自己的配置类算是一种多态吧
上层调用会传入一个 HttpSecurity 对象,那么我们就可以对用对用的方法进行设置了,具体还有那些配置需要用的时候再 Baidu 一下
下列示例中的没一行表示一个配置项的结束 返回的还是一个 HttpSecurity 所以还是可以继续调用设置
and() 方法可以理解为一组设置结束,返回的还是一个 HttpSecurity 对象,可以继续设置 这是我们常见的链式编程
设置 .antMatchers("/user/login", "/user/register").anonymous()
对于登录接口、注册接口 允许匿名访问 注意不带 token 才能正常访问
,带了反而会抛异常
,这就匿名访问
.antMatchers("/user/login", "/user/register").permitAll()
下面这种 permitAll() 带不带 token 都可以访问
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 @Override protected void configure (HttpSecurity http) throws Exception { http .csrf().disable() .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) .and() .authorizeRequests() .antMatchers("/user/login" , "/user/register" ).anonymous() .anyRequest().authenticated() .and() .addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class); }
权限 权限系统的作用
例如一个学校图书馆的管理系统,如果是普通学生登录就能看到借书还书相关的功能,不可能让他看到并且去使用添加书籍信息,删除书籍信息等功能。但是如果是一个图书馆管理员的账号登录了,应该就能看到并使用添加书籍信息,删除书籍信息等功能。
总结起来就是不同的用户可以使用不同的功能 。这就是权限系统要去实现的效果。
我们不能只依赖前端去判断用户的权限来选择显示哪些菜单哪些按钮
。因为如果只是这样,如果有人知道了对应功能的接口地址就可以不通过前端,直接去发送请求来实现相关功能操作。
所以我们还需要在后台进行用户权限的判断,判断当前用户是否有相应的权限,必须具有所需权限才能进行相应的操作。
授权基本流程
在SpringSecurity中,会使用默认的FilterSecurityInterceptor来进行权限校验。在FilterSecurityInterceptor中会从SecurityContextHolder获取其中的Authentication,然后获取其中的权限信息,然后确认当前用户是否拥有访问当前资源所需的权限,如果有权限的话 FilterSecurityInterceptor 才会放行
所以我们项目在登录的时候需要把用户的权限信息也存入 Authentication 对象,最后写入到 Redis 中。
其它接口进行请求时经过 JWT 认证锅炉器,去 Redis 取用户信息和和拥有的资源权限 并写入到 SecurityContextHolder
授权实现
这里记录一下授权的实现,采用自顶向下编写代码
这里先采用写死权限的方式快速体验下,后面用权限模型优化代码
接口限制访问权限
就是对我们的 API 接口做权限校验,当你需要有某个权限的时候我才让你访问我的接口,没有这个权限的话,不好意思,你谁啊,403 拜拜
这里所谓的权限我们就是采用一个字符串来标识,当你有这个标识的时候那我就给你放行,让你请求我的接口
一般实现的方式有两种:1️⃣ 基于配置的方案(这种一般是针对静态资源,也就是 springboot 项目下的静态资源)2️⃣ 基于注解的方案(现在前后端分离的项目都是用这种)
具体实现:1️⃣ 在 Spring Security 的配置类上添加一个注解 @EnableGlobalMethodSecurity(prePostEnabled = true)
开启对应的功能 2️⃣ 在接口上添加注解@PreAuthorize("hasAuthority('你的权限关键字')")
Spring Security 在运行的时候会去读取这个注解里面的参数当做一个表达式,读取到了 hasAuthority() 后就会去调用这个方法,然后传入你自己的权限关键字去 SecurityContextHolder 的权限里面比对有没有一模一样的关键字,最后返回一个布尔值确定你是否能访问这个接口1 2 3 4 5 @EnableGlobalMethodSecurity(prePostEnabled = true) @PreAuthorize("hasAuthority('system:user:list')")
封装权限信息
思路:在登录的时候我们就需要去数据库查询出来一些封装给用户对象,然后写入 Redis,(这里先写死,不去数据查询)
注意事项:我们是用一个集合来接受,最好是用一个不能重复的集合,并且在数据查询的时候也需要去重双重保障吧(后面查数据库权限的时候会再记录)
涉及变动:之前我的代码往 Redis 中存的都是 SysUser 的对象,现在加了一个 权限对象 Set<String> permissions
所以存的时候只能把拥有这两个对象的 LoginUser
存进去了, SysUser
和 Set<String> permissions
都是这个对象的属性1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 package com.adalucky.modules.system.service.impl;import com.adalucky.handler.BusinessException;import com.adalucky.modules.system.mapper.SysUserMapper;import com.adalucky.modules.system.model.entity.SysUser;import com.adalucky.response.ResultCode;import com.adalucky.utils.str.StringUtils;import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;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.Arrays;import java.util.HashSet;import java.util.Objects;import java.util.Set;@Service public class UserDetailServiceImpl implements UserDetailsService { @Autowired SysUserMapper mapper; @Override public UserDetails loadUserByUsername (String username) throws UsernameNotFoundException { if (!StringUtils.isEffective(username)) { throw new BusinessException ( ResultCode.PARAM_IS_BLANK.getCode(), ResultCode.PARAM_IS_BLANK.getMessage() ); } LambdaQueryWrapper<SysUser> queryWrapper = new LambdaQueryWrapper <>(); queryWrapper.eq(SysUser::getUsername, username); SysUser user; try { user = mapper.selectOne(queryWrapper); } catch (Exception e) { throw new BusinessException ( ResultCode.USER_REPEAT.getCode(), ResultCode.USER_REPEAT.getMessage() ); } if (Objects.isNull(user)) { throw new BusinessException ( ResultCode.PARAM_IS_BLANK.getCode(), ResultCode.PARAM_IS_BLANK.getMessage() ); } Set<String> permissions = new HashSet <String>(Arrays.asList("system:user:list" )); return new LoginUser (user, permissions); } }
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 public Result login (UserDto userDto) { UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken (userDto.username(), userDto.password()); var authenticate = authenticationManager.authenticate(authenticationToken); if (Objects.isNull(authenticate)) { throw new RuntimeException ("认证失败,用户名或密码错误" ); } var loginUser = (LoginUser) authenticate.getPrincipal(); var user = loginUser.getSysUser(); var userId = user.getUserId().toString(); redisCache.setCacheObject("login" + userId, loginUser); return Result.ok().message("登录成功" ).data("token" , JwtUtil.createJWT(userId)); }
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 package com.adalucky.modules.system.service.impl;import com.adalucky.modules.system.model.entity.SysUser;import com.alibaba.fastjson.annotation.JSONField;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.Set;import java.util.stream.Collectors;public class LoginUser implements UserDetails { private SysUser sysUser; private Set<String> permissions; public SysUser getSysUser () { return sysUser; } public void setSysUser (SysUser sysUser) { this .sysUser = sysUser; } public Set<String> getPermissions () { return permissions; } public void setPermissions (Set<String> permissions) { this .permissions = permissions; } public void setAuthorities (Set<SimpleGrantedAuthority> authorities) { this .authorities = authorities; } public LoginUser () { } public LoginUser (SysUser user, Set<String> permissions) { this .sysUser = user; this .permissions = permissions; } @JSONField(serialize = false) private Set<SimpleGrantedAuthority> authorities; @Override public Collection<? extends GrantedAuthority > getAuthorities() { if (authorities != null ) { return authorities; } authorities = permissions.stream().map(SimpleGrantedAuthority::new ).collect(Collectors.toSet()); return authorities; } @Override public String getPassword () { return sysUser.getPassword(); } @Override public String getUsername () { return sysUser.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 ; } }
RBAC权限模型 简介 RBAC权限模型(Role-Based Access Control)即:基于角色的权限控制。这是目前最常被开发者使用也是相对易用、通用权限模型。
建表 按照简介所示图,一个我们需要 5 张表进行组成一个基本的 RBAC 的模型
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 -- 创建用户表 DROP TABLE IF EXISTS `sys_user`; CREATE TABLE `sys_user` ( `user_id` int NOT NULL AUTO_INCREMENT COMMENT '用户ID', `dept_id` int DEFAULT NULL COMMENT '部门ID', `username` varchar(11) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL COMMENT '登录账号', `nikename` varchar(10) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL COMMENT '用户昵称', `type` varchar(2) CHARACTER SET utf8 COLLATE utf8_general_ci DEFAULT '00' COMMENT '用户类型(00系统用户)', `email` varchar(50) DEFAULT '' COMMENT '用户邮箱', `phone` varchar(20) CHARACTER SET utf8 COLLATE utf8_general_ci DEFAULT '' COMMENT '手机号码', `sex` int DEFAULT '2' COMMENT '用户性别(0女 1男 2保密)', `avatar` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci DEFAULT 'https://www.adalucky.com/medias/16229390604952.png' COMMENT '头像路径', `password` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci DEFAULT '' COMMENT '密码', `status` int DEFAULT '0' COMMENT '帐号状态(0 启用 1 禁用)', `deleted` int DEFAULT '0' COMMENT '删除标志(0代表未删除 1代表已删除)', `project_id` int DEFAULT NULL COMMENT '默认项目ID', `login_ip` varchar(50) DEFAULT '' COMMENT '最后登陆IP', `login_date` datetime DEFAULT NULL COMMENT '最后登陆时间', `create_by` varchar(30) CHARACTER SET utf8 COLLATE utf8_general_ci DEFAULT NULL COMMENT '创建者', `create_time` timestamp NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', `update_by` varchar(30) CHARACTER SET utf8 COLLATE utf8_general_ci DEFAULT NULL COMMENT '更新者', `update_time` timestamp NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', `version` int DEFAULT '1' COMMENT 'Mybatis-Plus 乐观锁标记', `remark` varchar(500) DEFAULT '' COMMENT '备注', PRIMARY KEY (`user_id`) ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT ='用户信息表'; -- 创建权限表 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, `deleted` 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 ='菜单表'; -- 创建角色表 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 ='角色表'; -- 创建角色权限中间表 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 COMMENT ='角色权限中间表'; -- 创建用户角色中间表 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 COMMENT ='用户角色中间表';
实现
实现思路:新建一个 sys_menu 的实体类、Mapper 接口、xml
编写查询用户权限的 SQL 放在 xml 中,Mapper 中声明对应的方法
Mapper 传入的参数类型需要和 实体类中的 userId 兼容
生成 xml 文件和自定义 SQL 可在类上合方法上进行提示填充(MybatisX 插件)
最后把我们写死的权限换成查询的(Mapper 的 bean 要注入)1 2 3 var permissions = sysMenuMapper.selectPermsByUserId(user.getUserId());return new LoginUser (user, permissions);
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 package com.adalucky.modules.system.model.entity;import com.baomidou.mybatisplus.annotation.FieldFill;import com.baomidou.mybatisplus.annotation.IdType;import com.baomidou.mybatisplus.annotation.TableField;import com.baomidou.mybatisplus.annotation.TableId;import com.baomidou.mybatisplus.annotation.TableName;import java.io.Serializable;import java.time.LocalDateTime;import io.swagger.annotations.ApiModel;import io.swagger.annotations.ApiModelProperty;import lombok.AllArgsConstructor;import lombok.Data;import lombok.NoArgsConstructor;@Data @AllArgsConstructor @NoArgsConstructor @TableName("sys_menu") @ApiModel(value = "SysMenu对象", description = "菜单表") public class SysMenu implements Serializable { private static final long serialVersionUID = 1L ; @TableId(value = "id", type = IdType.AUTO) private Long id; @ApiModelProperty("菜单名") private String menuName; @ApiModelProperty("路由地址") private String path; @ApiModelProperty("组件路径") private String component; @ApiModelProperty("菜单状态(0显示 1隐藏)") private String visible; @ApiModelProperty("菜单状态(0正常 1停用)") private String status; @ApiModelProperty("权限标识") private String perms; @ApiModelProperty("菜单图标") private String icon; private Long createBy; @TableField(fill = FieldFill.INSERT) private LocalDateTime createTime; private Long updateBy; @TableField(fill = FieldFill.INSERT_UPDATE) private LocalDateTime updateTime; @ApiModelProperty("删除标志(0代表未删除 1代表已删除)") private Integer deleted; @ApiModelProperty("备注") private String remark; }
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 package com.adalucky.modules.system.mapper;import com.adalucky.modules.system.model.entity.SysMenu;import com.baomidou.mybatisplus.core.mapper.BaseMapper;import org.apache.ibatis.annotations.Mapper;import java.util.Set;@Mapper public interface SysMenuMapper extends BaseMapper <SysMenu> { Set<String> selectPermsByUserId (Integer id) ; }
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 <?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd"> <mapper namespace="com.adalucky.modules.system.mapper.SysMenuMapper"> <select id="selectPermsByUserId" resultType="java.lang.String"> 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 RM.menu_id = M.id WHERE UR.user_id = #{id} AND M.`status` = '0' AND R.`status` = '0' </select> </mapper>
异常处理 简介
我们还希望在认证失败或者是授权失败的情况下也能和我们的接口一样返回相同结构的json,这样可以让前端能对响应进行统一的处理。要实现这个功能我们需要知道SpringSecurity的异常处理机制。
在SpringSecurity中,如果我们在认证或者授权的过程中出现了异常会被ExceptionTranslationFilter捕获到
。在ExceptionTranslationFilter中会去判断
是认证失败还是授权失败出现的异常。
如果是认证过程中出现的异常
会被封装成AuthenticationException然后调用AuthenticationEntryPoint
对象的方法去进行异常处理。
如果是授权过程中出现的异常
会被封装成AccessDeniedException然后调用AccessDeniedHandle
对象的方法去进行异常处理。
所以如果我们需要自定义异常处理,我们只需要自定义AuthenticationEntryPoint和AccessDeniedHandler然后配置给SpringSecurity即可。
自定义实现
需要重写两个实现类
注意如果有全局异常处理,并且捕获的异常类型为 Exception
或者包含了下面实现类抛出的异常,全局的异常捕获会覆盖掉当前的自定义,给你的感觉就像是没有生效
记得要注入到容器 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 package com.adalucky.handler;import com.adalucky.response.Result;import com.adalucky.utils.web.WebUtils;import com.alibaba.fastjson.JSON;import org.springframework.http.HttpStatus;import org.springframework.security.core.AuthenticationException;import org.springframework.security.web.AuthenticationEntryPoint;import org.springframework.stereotype.Component;import javax.servlet.ServletException;import javax.servlet.http.HttpServletRequest;import javax.servlet.http.HttpServletResponse;import java.io.IOException;@Component public class AuthenticationEntryPointImpl implements AuthenticationEntryPoint { @Override public void commence (HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException { var result = Result.error(false , HttpStatus.UNAUTHORIZED.value(), "用户认证失败,请重新登录" ); String json = JSON.toJSONString(result); WebUtils.renderString(response, json); } }
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 package com.adalucky.handler;import com.adalucky.response.Result;import com.adalucky.utils.web.WebUtils;import com.alibaba.fastjson.JSON;import org.springframework.http.HttpStatus;import org.springframework.security.access.AccessDeniedException;import org.springframework.security.web.access.AccessDeniedHandler;import org.springframework.stereotype.Component;import javax.servlet.ServletException;import javax.servlet.http.HttpServletRequest;import javax.servlet.http.HttpServletResponse;import java.io.IOException;@Component public class AccessDeniedHandlerImpl implements AccessDeniedHandler { @Override public void handle (HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException { var result = Result.error(false , HttpStatus.FORBIDDEN.value(), "您的权限不足,请联系管理员开通权限" ); String json = JSON.toJSONString(result); WebUtils.renderString(response, json); } }
配置类 在之前的配置类中加入配置容器中要注入接口 AuthenticationEntryPoint
AccessDeniedHandler
然后调用 http.exceptionHandling()
下对应的配置项
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 package com.adalucky.auth;import com.adalucky.filter.JwtAuthenticationTokenFilter;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.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;@Configuration @EnableGlobalMethodSecurity(prePostEnabled = true) public class WebSecurityConfig extends WebSecurityConfigurerAdapter { final JwtAuthenticationTokenFilter jwtAuthenticationTokenFilter; private final AuthenticationEntryPoint authenticationEntryPoint; private final AccessDeniedHandler accessDeniedHandler; public WebSecurityConfig (JwtAuthenticationTokenFilter jwtAuthenticationTokenFilter, AuthenticationEntryPoint authenticationEntryPoint, AccessDeniedHandler accessDeniedHandler) { this .jwtAuthenticationTokenFilter = jwtAuthenticationTokenFilter; this .authenticationEntryPoint = authenticationEntryPoint; this .accessDeniedHandler = accessDeniedHandler; } @Bean public PasswordEncoder passwordEncoder () { return new BCryptPasswordEncoder (); } @Bean @Override public AuthenticationManager authenticationManagerBean () throws Exception { return super .authenticationManagerBean(); } @Override protected void configure (HttpSecurity http) throws Exception { http .csrf().disable() .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) .and() .authorizeRequests() .antMatchers("/user/login" , "/user/register" ).anonymous() .anyRequest().authenticated(); http.addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class); http.exceptionHandling() .authenticationEntryPoint(authenticationEntryPoint) .accessDeniedHandler(accessDeniedHandler); } }
跨域 简介
浏览器出于安全的考虑,使用 XMLHttpRequest对象发起 HTTP请求时必须遵守同源策略,否则就是跨域的HTTP请求,默认情况下是被禁止的。 同源策略要求源相同才能正常进行通信,即协议、域名、端口号都完全一致。
前后端分离项目,前端项目和后端项目一般都不是同源的,所以肯定会存在跨域请求的问题。
所以我们就要处理一下,让前端能进行跨域请求,总体分两步:1️⃣ 配置 SpringBoot 的跨域策略 2️⃣ Security 配置开启允许跨域(由于我们的资源都会收到Spring Security的保护,所以想要跨域访问还要让Spring Security允许跨域访问。)
SpringBoot跨域配置 当前我的版本为 SpringBoot 2.6.4
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 package com.adalucky.config;import org.springframework.context.annotation.Bean;import org.springframework.context.annotation.Configuration;import org.springframework.web.cors.CorsConfiguration;import org.springframework.web.cors.UrlBasedCorsConfigurationSource;import org.springframework.web.filter.CorsFilter;@Configuration public class CrosConfig { private CorsConfiguration corsConfig () { CorsConfiguration corsConfiguration = new CorsConfiguration (); corsConfiguration.addAllowedOriginPattern("*" ); corsConfiguration.addAllowedHeader("*" ); corsConfiguration.addAllowedMethod("*" ); corsConfiguration.setAllowCredentials(true ); corsConfiguration.setMaxAge(3600L ); return corsConfiguration; } @Bean public CorsFilter corsFilter () { UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource (); source.registerCorsConfiguration("/**" , corsConfig()); return new CorsFilter (source); } }
Spring Security跨域开启 实际发现我上面的配置以后这里不开启也是能跨域的,估计和版本以及我上面注入了那个 Bean
其它权限认证方式 简介
在上面我们想认证一个用户是否有接口的访问权限,我们的方式是在入Spring Security 的配置类上添加一个注解 @EnableGlobalMethodSecurity(prePostEnabled = true)
然后在接口上添加注解@PreAuthorize("hasAuthority('你的权限关键字')")
来进行实现的,这里的本质是一种 SPEL
的表达式
我们前面都是使用@PreAuthorize注解,然后在在其中使用的是hasAuthority方法进行校验。SpringSecurity还为我们提供了其它方法例如:hasAnyAuthority,hasRole,hasAnyRole等。
可以通过 DEBUG 去理解hasAuthority的原理,hasAuthority方法实际是执行到了SecurityExpressionRoot的hasAuthority,大家只要断点调试既可知道它内部的校验原理。
它内部其实是调用authentication的getAuthorities方法获取用户的权限列表。然后判断我们存入的方法参数数据在权限列表中。
hasAuthority 还是最好用的
hasAnyAuthority hasAnyAuthority方法可以传入多个权限,只有用户有其中任意一个权限都可以访问对应资源
1 2 3 4 @PreAuthorize("hasAnyAuthority('admin','test','system:dept:list')") public String hello () { return "hello" ; }
hasRole
hasRole 要求有对应的角色才可以访问,但是它内部会把我们传入的参数拼接上 ROLE_ 后再去比较。所以这种情况下要用用户对应的权限也要有 ROLE_ 这个前缀才可以。
也就是说如果使用这个方法做权限校验,那么在我们数据库存储的权限标识符 system:dept:list
–> ROLE_system:dept:list
1 2 3 4 @PreAuthorize("hasRole('system:dept:list')") public String hello () { return "hello" ; }
hasAnyRole hasAnyRole 有任意的角色就可以访问。它内部也会把我们传入的参数拼接上 ROLE_ 后再去比较。所以这种情况下要用用户对应的权限也要有 ROLE_ 这个前缀才可以。
1 2 3 4 @PreAuthorize("hasAnyRole('admin','system:dept:list')") public String hello () { return "hello" ; }
自定义认证方法 思路
上面的原理就是在@PreAuthorize注解中加入 Spring Security自带的认证方法,然后传入一个或者多个参数
那么我们也可以定义自己的权限校验方法,在@PreAuthorize注解中使用我们的方法
方法实现
Component 需要起一个别名,到时候获取这个 Bean 的时候就通过这个别名来引用
这个里面只是一个简单的示例,可以以把 集合是否包含换成模糊匹配最终返回一个布尔值,比如接口中定义调用的时候传入的是 system:* 那么就表示 这个用户有 sytem 下的所有权限(大概就是这种,有可能会用到,我这里暂时不涉及就不实现了)1 2 3 4 5 6 7 8 9 10 11 12 @Component("automation") public class ExpressionRoot { public boolean hasAuthority (String authority) { Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); LoginUser loginUser = (LoginUser) authentication.getPrincipal(); Set<String> permissions = loginUser.getPermissions(); return permissions.contains(authority); } }
引用 在SPEL表达式中使用 @automation 相当于获取容器中bean的名字为 automation 的对象。然后再调用这个对象的hasAuthority方法
1 2 3 4 5 @RequestMapping("/hello") @PreAuthorize("@automation.hasAuthority('system:dept:list')") public String hello () { return "hello" ; }
配置文件认证
上面我们都是通过注解来实现,之前也提到过认证一般有两种,一种是注解,一种是配置,配置一般用于对静态文件,但是也可以对接口进行这样的权限认证
示例如下,这里可以把所有需要权限校验的接口都配上,看个人喜好,可以在这里全配上或者在接口加注解1 http.authorizeRequests().antMatchers("/user/list" ).hasAuthority("system:user:list" );
其它 CSRF
CSRF是指跨站请求伪造(Cross-site request forgery),是web常见的攻击之一
SpringSecurity去防止CSRF攻击的方式就是通过csrf_token。后端会生成一个csrf_token,前端发起请求的时候需要携带这个csrf_token,后端会有过滤器进行校验,如果没有携带或者是伪造的就不允许访问。
我们可以发现CSRF攻击依靠的是cookie中所携带的认证信息。但是在前后端分离的项目中我们的认证信息其实是token,而token并不是存储中cookie中,并且需要前端代码去把token设置到请求头中才可以,所以CSRF攻击也就不用担心了。所以我们就关掉了,因为我们请求的时候也没带 csrf_token 不关闭反而认证不通过
自定义处理器 以下的这些方案的前是不采用我们上面哪一套设计方案,因为我们上面那一套方案重新了很多过滤器的方法,调用的和默认的不一样,所以在这里可能就无效了,没有去执行父类的 super 默认配置项
认证成功处理器
实际上在UsernamePasswordAuthenticationFilter进行登录认证的时候,如果登录成功了是会调用AuthenticationSuccessHandler的方法进行认证成功后的处理的。AuthenticationSuccessHandler就是登录成功处理器。
我们也可以自己去自定义成功处理器进行成功后的相应处理。
1 2 3 4 5 6 7 8 @Component public class SGSuccessHandler implements AuthenticationSuccessHandler { @Override public void onAuthenticationSuccess (HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException { System.out.println("认证成功了" ); } }
1 2 3 4 5 6 7 8 9 10 11 12 13 @Configuration public class SecurityConfig extends WebSecurityConfigurerAdapter { @Autowired private AuthenticationSuccessHandler successHandler; @Override protected void configure (HttpSecurity http) throws Exception { http.formLogin().successHandler(successHandler); http.authorizeRequests().anyRequest().authenticated(); } }
认证失败处理器
实际上在UsernamePasswordAuthenticationFilter进行登录认证的时候,如果认证失败了是会调用AuthenticationFailureHandler的方法进行认证失败后的处理的。AuthenticationFailureHandler就是登录失败处理器。
我们也可以自己去自定义失败处理器进行失败后的相应处理。
1 2 3 4 5 6 7 @Component public class SGFailureHandler implements AuthenticationFailureHandler { @Override public void onAuthenticationFailure (HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException { System.out.println("认证失败了" ); } }
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 @Configuration public class SecurityConfig extends WebSecurityConfigurerAdapter { @Autowired private AuthenticationSuccessHandler successHandler; @Autowired private AuthenticationFailureHandler failureHandler; @Override protected void configure (HttpSecurity http) throws Exception { http.formLogin() .successHandler(successHandler) .failureHandler(failureHandler); http.authorizeRequests().anyRequest().authenticated(); } }
登出成功处理器 1 2 3 4 5 6 7 @Component public class SGLogoutSuccessHandler implements LogoutSuccessHandler { @Override public void onLogoutSuccess (HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException { System.out.println("注销成功" ); } }
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 @Configuration public class SecurityConfig extends WebSecurityConfigurerAdapter { @Autowired private AuthenticationSuccessHandler successHandler; @Autowired private AuthenticationFailureHandler failureHandler; @Autowired private LogoutSuccessHandler logoutSuccessHandler; @Override protected void configure (HttpSecurity http) throws Exception { http.formLogin() .successHandler(successHandler) .failureHandler(failureHandler); http.logout() .logoutSuccessHandler(logoutSuccessHandler); http.authorizeRequests().anyRequest().authenticated(); } }