# SpringSecurity⭐️
# 0 简介🎃
Spring Security 是 Spring 家族中的一个安全管理框架,相比与另外一个安全框架 Shiro, 它提供了更丰富的功能,社区资源也比 Shiro 丰富
一般来说中大型的项目都是使用 SpringSecurity 来做安全框架,小项目用 Shiro 的比较多,因为相比与 SpringSecurity , Shiro 的上手更加的简单
一般 Web 应用的需要进行认证和授权
认证:验证当前访问系统的是不是本系统的用户,并且要确认具体是哪个用户
授权:经过认证后判断当前用户是否有权限进行某个操作
而认证和授权也是 SpringSecurity 作为安全框架的核心功能
# 1 快速入门🕋
# 1.1 准备工作⚡️
我们先要搭建一个简单的 SpringBoot 工程
1. 设置父工程,添加依赖
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" | |
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> | |
<modelVersion>4.0.0</modelVersion> | |
<parent> | |
<groupId>org.springframework.boot</groupId> | |
<artifactId>spring-boot-starter-parent</artifactId> | |
<!-- 在 SpringBoot2.7.10 之后 SpringSecurity 的 WebWebSecurityConfigurerAdapter 已弃用了 --> | |
<version>2.5.4</version> | |
</parent> | |
<groupId>com.dkx</groupId> | |
<artifactId>maven-learn-springSecurity</artifactId> | |
<version>1.0-SNAPSHOT</version> | |
<packaging>jar</packaging> | |
<name>maven-learn-springSecurity</name> | |
<url>http://maven.apache.org</url> | |
<properties> | |
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> | |
</properties> | |
<dependencies> | |
<dependency> | |
<groupId>org.springframework.boot</groupId> | |
<artifactId>spring-boot-starter-web</artifactId> | |
</dependency> | |
<dependency> | |
<groupId>org.springframework.boot</groupId> | |
<artifactId>spring-boot-starter-test</artifactId> | |
</dependency> | |
<dependency> | |
<groupId>org.projectlombok</groupId> | |
<artifactId>lombok</artifactId> | |
<optional>true</optional> | |
</dependency> | |
<dependency> | |
<groupId>junit</groupId> | |
<artifactId>junit</artifactId> | |
<version>4.12</version> | |
<scope>test</scope> | |
</dependency> | |
</dependencies> | |
<build> | |
<plugins> | |
<plugin> | |
<groupId>org.springframework.boot</groupId> | |
<artifactId>spring-boot-maven-plugin</artifactId> | |
</plugin> | |
</plugins> | |
</build> | |
</project> |
2. 创建启动类
@SpringBootApplication | |
public class App | |
{ | |
public static void main( String[] args ) { | |
SpringApplication.run(App.class,args); | |
} | |
} |
3. 创建 Controller
@Controller | |
public class HelloController { | |
@RequestMapping("/hello") | |
@ResponseBody | |
public String hello(){ | |
return "hello"; | |
} | |
} |
# 1.2 引入 SpringSecurity🍰
在 SpringBoot 项目中使用 SpringSecurity 我们只需要引入依赖即可实现入门案例。
<!--SpringSecurity 启动器 --> | |
<dependency> | |
<groupId>org.springframework.boot</groupId> | |
<artifactId>spring-boot-starter-security</artifactId> | |
</dependency> |
引入依赖后我们在尝试去访问之前的接口就会自动跳转到一个 SpringSecurity 的默认登录页面,默认用户名是 user, 密码会输出在控制台。
必须登录之后才能对接口进行访问。
2. 操作:
重新访问 hello 接口后出现的页面需要输入账号和密码,账号默认 user 密码在控制台打印
启动 SpringBoot 后控制台自动打印 password
输入账号,密码后进入页面内
输入默认的退出接口:logout 完整接口路径如下:
http://localhost:8080/logout |
点击后退出到登录页面
# 2 认证🍾
# 2.1 登录校验流程🏷
jwt = json web token
# 2.2 原理初探🎉
想要知道如何实现自己的登录流程就必须要先知道入门案例中 SpringSecurity 的流程。
# 2.2.1 SpringSecurity 完整流程📟
SpringSecurity 的原理其实就是一个 <ins style="color:red"> 过滤器链 </ins>,内部包含了提供各种功能的过滤器,这里我们可以看看入门案例中的过滤器。
图中只展示了核心过滤器,其它的非核心过滤器并没有在图中展示。
UsernamePasswordAuthenticationFilter:负责处理我们的登录页面填写了用户名密码后的登录请求,入门案例的认证工作主要由它负责。
ExceptionTranslationFilter:处理过滤器链中抛出的任何 AccessDeniedException 和 AuthenticationException。
FilterSecurityInterceptor:负责权限校验的过滤器
我们可以通过 Debug 查看当前系统中 SpringSecurity 过滤器链中有哪些过滤器及它们的顺序
# 2.2.2 认证流程详解🏯
概念速查:
Authentication 接口:它的实现类,表示当前访问系统的用户,封装了用户相关信息。
AuthenticationManager 接口:定义了认证 Authentication 的方法。
UserDetailsService 接口:加载用户特定数据的核心接口,里面定义了一个根据用户名查询用户信息的方法。
UserDetails 接口:提供核心用户信息,通过 UserDetailsService 根据用户名获取处理的用户信息要封装成 UserDetails 对象返回,然后将这些信息封装到 Authentication 对象中。
登录验证
思考:Jwt 认证过滤器中从获取到了 userid 后怎么获取到完整的用户信息?
# 2.3. 解决问题🎸
# 2.3.1 思路分析🏭
登录:
① 自定义登录接口
- 调用 providerManager 的方法进行认证,如果认证通过生成 jwt
- 把用户信息存入 redis 中
② 自定义 UserDetailsService
- 在这个实现类中去查询数据库
校验:
① 定义 jwt 认证过滤器
- 获取 token
- 解析 token 获取其中的 userid
- 从 redis 中获取用户信息
- 存入 SecurityContextHolder
# 2.3.2 准备工作🔑
注意事项:
Redis 的配置类使用了 FastJson 来进行序列化的,需要导入自己的 util 报下的 FastJson 否则在存储 redis 对象信息时会出现没有存储 @Type 对象信息情况,后面进行获取的时候我们需要获取的是对象信息但是转换会发生 ClassCaseException 异常。
① 添加依赖
<!--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.47</version> | |
</dependency> | |
<!--jwt 依赖 --> | |
<dependency> | |
<groupId>io.jsonwebtoken</groupId> | |
<artifactId>jjwt</artifactId> | |
<version>0.9.0</version> | |
</dependency> |
② 添加 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; | |
} | |
} |
④ 工具类
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 一个小时 | |
// 设置秘钥明文,设置长度不能大于 11 | |
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) { | |
Claims claims; | |
SecretKey secretKey = generalKey(); | |
try { | |
return Jwts.parser() | |
.setSigningKey(secretKey) | |
.parseClaimsJws(jwt) | |
.getBody(); | |
} catch (ExpiredJwtException e) { | |
claims = e.getClaims(); | |
} | |
return claims; | |
} | |
} |
⑤ 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); | |
} | |
} |
⑥ Web 工具类
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; | |
} | |
} |
⑦ 实体类
import java.io.Serializable; | |
import java.util.Date; | |
/** | |
* 用户表 (User) 实体类 | |
* | |
* @author 三更 | |
*/ | |
@Data | |
@AllArgsConstructor | |
@NoArgsConstructor | |
public class User implements Serializable { | |
private static final long serialVersionUID = -40356785423868312L; | |
/** | |
* 主键 | |
*/ | |
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; | |
} |
# 2.3.3 实现⛵️
# 2.3.3.1 数据库校验用户🎃
从之前的分析我们可以知道,我们可以自定义一个 UserDetailsService,让 SpringSecurity 使用我们的 UserDetailsService,我们自己的 UserDetailsService 可以从数据库中查询用户名和密码
# 准备工作💃
我们先创建一个用户表,建表语句如下:
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='用户表' |
引入 MyBatisPlus 和 MySQL 驱动的依赖
<dependency> | |
<groupId>com.baomidou</groupId> | |
<artifactId>mybatis-plus-boot-starter</artifactId> | |
<version>3.4.1</version> | |
</dependency> | |
<dependency> | |
<groupId>mysql</groupId> | |
<artifactId>mysql-connector-java</artifactId> | |
<version>8.0.28</version> | |
</dependency> | |
<dependency> | |
<groupId>com.alibaba</groupId> | |
<artifactId>druid</artifactId> | |
<version>1.2.8</version> | |
</dependency> |
配置数据库信息
server: | |
port: 80 | |
spring: | |
datasource: | |
type: com.alibaba.druid.pool.DruidDataSource | |
driver-class-name: com.mysql.cj.jdbc.Driver | |
url: jdbc:mysql://localhost:3306/test?characterEncoding=utf-8&serverTimezone=UTC | |
username: root | |
password: dkx | |
mybatis-plus: | |
mapper-locations: classpath:mapper/*.xml | |
config-location: classpath:mybatis/mybatis-config.xml | |
global-config: | |
banner: off |
定义 Mapper 接口
@Mapper | |
public interface UserMapper extends BaseMapper<User> { | |
} |
测试 MP 是否能正常使用
@SpringBootTest(classes = App.class) | |
@RunWith(SpringJUnit4ClassRunner.class) | |
public class AppTest { | |
@Autowired | |
private UserMapper mapper; | |
@Test | |
public void test(){ | |
List<User> list = mapper.selectList(null); | |
System.out.println(list); | |
} | |
} | |
---------------------Result--------------------- | |
2023-05-16 17:27:34.013 INFO 16256 --- [ main] com.alibaba.druid.pool.DruidDataSource : {dataSource-1} inited | |
[User(id=1, userName=123, nickName=123, password=123, status=0, email=null, phonenumber=null, sex=null, avatar=null, userType=1, createBy=null, createTime=null, updateBy=null, updateTime=null, delFlag=0)] |
# 核心代码实现🌵
创建一个类实现 UserDetailsService 接口,重写其中的方法,让其用户名从数据库中查询用户信息
@Service | |
public class UserDetailsServiceImpl implements UserDetailsService { | |
@Autowired | |
private UserMapper mapper; | |
@Override | |
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { | |
// 查询用户信息 | |
LambdaQueryWrapper<User> wrapper = new LambdaQueryWrapper<User>(); | |
wrapper.eq(User::getUserName,username); | |
User user = mapper.selectOne(wrapper); | |
// 如果没有查询到用户就抛出异常 | |
if(Objects.isNull(user)){ | |
throw new RuntimeException("用户名或密码错误"); | |
} | |
//TODO 查询对应的权限信息 | |
return new LoginUser(user); | |
} | |
} |
因为 UserDetailsService 方法的返回值是 UserDetails 类型,所以需要定义一个类,实现该接口,把用户信息封装在其中。
@Data | |
@AllArgsConstructor | |
@NoArgsConstructor | |
public class LoginUser implements UserDetails { | |
private User user; | |
// 返回权限信息 | |
@Override | |
public Collection<? extends GrantedAuthority> getAuthorities() { | |
return null; | |
} | |
// 获取密码 | |
@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}。例如:
<font style="color:red"> 不加 {noop} 登录后端就会报错如下 </font>:
这样登录的时候就可以用 123 作为用户名,123 作为密码了登录了。
总结流程:
- 重写 UserDetailsService,实现了从数据查询用户名和密码,进行校验的工作
- 在 loadUserByUsername (String username) 方法中进行查询用户并做一些安全的校验查询用户是否为 null
- loadUserByUsername (String username) 方法执行没有任何异常则封装成 LoginUser 对象返回
- 密码在数据库中存储是明文存储,需要在前面加上
- 重写后启动 SpringBoot 控制台就不会输出 jwt 生成的 password 了
# 2.3.3.2 密码加密存储🐰
实际项目中我们不会把密码明文存储在数据库中。
默认使用的 PasswordEncoder 要求数据库中的密码格式为: {id} password. 它会根据 id 去判断密码的加密方式,但是我们一般不会采用这种方式,所以就需要替换 PasswordEncoder。
我们一般使用 SpringSecurity 为我们提供的 BCryptPasswordEncoder。
我们只需要使用把 BCryptPasswordEncoder 对象注入 Spring 容器中,SpringSecurity 就会使用该 PasswordEncoder 来进行密码校验。
我们可以定义一个 SpringSecurity 的配置类,SpringSecurity 要求这个配置类要继承 WebSecurityConfigurerAdapter。
@Configuration | |
public class SecurityConfig extends WebSecurityConfigurerAdapter { | |
// 将 BCryptPasswordEncoder 对象注入容器中来替代 PasswordEncoder 的加密方式 | |
@Bean | |
public PasswordEncoder passwordEncoder(){ | |
return new BCryptPasswordEncoder(); | |
} | |
} |
注意:
- BCryptPasswordEncoder 校验的密码加密方式并不是 jwt,否则密码错误会报 403 错误
在测试类中创建 BCryptPasswordEncoder 调用 encode (传入密码) 来生成加密后的密码
@SpringBootTest(classes = App.class) | |
@RunWith(SpringJUnit4ClassRunner.class) | |
public class AppTest { | |
@Autowired | |
private UserMapper mapper; | |
@Test | |
public void test(){ | |
BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder(); | |
String encode = passwordEncoder.encode("123"); | |
System.out.println(encode); | |
} | |
} | |
----------------------------Result---------------------------- | |
$2a$10$jBm.Lr0NFXSuPspDFIGv1ulorD/6cySeE/aWmvrLhT5.YVsrVkSKm |
将加密后的密文先手动设置到数据库一个用户信息中:
# 2.3.3.3 登录接口🍡
接下来我们需要自定义登录接口,然后让 SpringSecurity 对这个接口放行,让用户访问这个接口的时候不用登录也能访问。
在接口中我们通过 AuthenticationManager 的 authenticate 方法来进行用户认证,所以需要在 SecurityConfig 中配置把 AuthenticationManager 注入容器。
认证成功的话要生成一个 jwt,放入响应中返回。并且为了让用户下回请求时能通过 jwt 识别出具体的是哪个用户,我们需要把用户信息存入 redis,可以把用户 id 作为 key。
这里不用在 mapper 中写代码,直接在 loginService 中写返回类型为 ResponseReuslt 的方法参数为对象。
@Controller | |
public class LoginController { | |
@Autowired | |
private LoginServcie loginServcie; | |
// 自定义登录接口 | |
@RequestMapping(value = "/user/login" , method = RequestMethod.POST) | |
@ResponseBody | |
public ResponseResult login(@RequestBody User user){ | |
return loginServcie.login(user); | |
} | |
} |
@Configuration | |
public class SecurityConfig extends WebSecurityConfigurerAdapter { | |
// 配置 BCryptPasswordEncoder 用户密码校验规则 | |
@Bean | |
public PasswordEncoder passwordEncoder(){ | |
return new BCryptPasswordEncoder(); | |
} | |
// 配置放行 | |
@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(); | |
} | |
// 注入 AuthenticationManager 容器 | |
@Bean | |
@Override | |
public AuthenticationManager authenticationManagerBean() throws Exception { | |
return super.authenticationManagerBean(); | |
} | |
} |
@SuppressWarnings("all") | |
@Service | |
public class LoginServiceImpl implements LoginService { | |
// 自动装配 Redis 工具类 | |
@Autowired | |
private RedisCache redisCache; | |
// 自动装配 AuthenticationManager | |
@Autowired | |
private AuthenticationManager authenticationManager; | |
public ResponseResult login(User user){ | |
// AuthenticationManager authenticate 进行用户认证 | |
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(user.getUserName(),user.getPassword()); | |
Authentication authenticate = authenticationManager.authenticate(authenticationToken); | |
// 如果认证没通过,给出对应的提示 | |
if(Objects.isNull(authenticate)){ | |
throw new RuntimeException("登录失败"); | |
} | |
// 如果认证通过了,使用 userid 生成一个 jwt (token) jwt (token) 存入 ResponseResult 返回 | |
LoginUser loginUser = (LoginUser) authenticate.getPrincipal(); | |
String userId = loginUser.getUser().getId().toString(); | |
// 生成 jwt | |
String jwt = JwtUtil.createJWT(userId); | |
// 把 token 响应给前端 | |
Map<String,String> map = new HashMap<>(); | |
map.put("token",jwt); | |
// 把完整的用户信息存入 redis,userid 作为 key | |
redisCache.setCacheObject("login:"+userId,loginUser); | |
return new ResponseResult(200,"登录成功",map); | |
} | |
} |
配置 Redis
spring: | |
redis: | |
host: 192.168.244.142 | |
port: 6379 |
- 记得开启 Redis 否则报错,状态码:500
# 2.3.3.4 认证过滤器🎅
我们需要自定义一个过滤器,这个过滤器会去获取请求头中的 token, 对 token 进行解析取出其中的 userId。
使用 userId 去 redis 中获取对应的 LoginUser 对象。
然后封装 Authentication 对象存入 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.hasText(token)){ | |
// 放行 | |
filterChain.doFilter(request,response); | |
return; | |
} | |
// 解析 token 获取 userId | |
String userId; | |
try{ | |
Claims claims = JwtUtil.parseJWT(token); | |
userId = claims.getSubject(); | |
}catch(Exception e){ | |
throw new RuntimeException("token非法"); | |
} | |
// 通过 userId 从 redis 中获取用户信息 LoginUser | |
String redisKey = "login:" + userId; | |
LoginUser loginUser = redisCache.getCacheObject(redisKey); | |
if(Objects.isNull(loginUser)){ | |
throw new RuntimeException("用户未登录"); | |
} | |
// 如果能从 redis 中获取 LoginUser 就存入 securityContextHolder | |
// TODO 获取权限信息封装到 Authentication 中 | |
UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken | |
(loginUser,null,loginUser.getAuthorities()); | |
SecurityContextHolder.getContext().setAuthentication(authentication); | |
// 放行 | |
filterChain.doFilter(request,response); | |
} | |
} |
@Configuration | |
public class SecurityConfig extends WebSecurityConfigurerAdapter { | |
@Bean | |
public PasswordEncoder passwordEncoder(){ | |
return new BCryptPasswordEncoder(); | |
} | |
@Autowired | |
JwtAuthenticationTokenFilter jwtAuthenticationTokenFilter; | |
@Override | |
protected void configure(HttpSecurity http) throws Exception { | |
http | |
// 关闭 csrf | |
.csrf().disable() | |
// 不通过 Session 获取 SecurityContext | |
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) | |
// 返回值为 HttpSecurity 可以继续调用其它的 | |
.and() | |
.authorizeRequests() | |
// 对于登录接口 允许匿名访问 | |
.antMatchers("/user/login").anonymous()//permitAll 代表无论是否登录都能访问 | |
// 除上面外的所有请求全部需要鉴权认证 | |
.anyRequest().authenticated(); | |
// 把 token 校验过滤器添加到过滤器链中 | |
http.addFilterBefore(jwtAuthenticationTokenFilter, | |
// 添加过滤器 | |
UsernamePasswordAuthenticationFilter.class); | |
} | |
@Bean | |
@Override | |
public AuthenticationManager authenticationManagerBean() throws Exception { | |
return super.authenticationManagerBean(); | |
} | |
} |
# 2.3.3.5 退出登录🦄
我们只需要定义一个登录接口,然后获取 SecurityContextHolder 中的认证信息,删除 redis 中对应的数据即可。
@SuppressWarnings("all") | |
@Service | |
public class LoginServiceImpl implements LoginService { | |
@Autowired | |
private RedisCache redisCache; | |
@Autowired | |
private AuthenticationManager authenticationManager; | |
public ResponseResult login(User user){ | |
// AuthenticationManager authenticate 进行用户认证 | |
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(user.getUserName(),user.getPassword()); | |
Authentication authenticate = authenticationManager.authenticate(authenticationToken); | |
// 如果认证没通过,给出对应的提示 | |
if(Objects.isNull(authenticate)){ | |
throw new RuntimeException("登录失败"); | |
} | |
// 如果认证通过了,使用 userid 生成一个 jwt jwt 存入 ResponseResult 返回 | |
//Authenticatoin.getPrincipal () 可以获取代表当前用户信息。 | |
LoginUser loginUser = (LoginUser) authenticate.getPrincipal(); | |
String userId = loginUser.getUser().getId().toString(); | |
// 生成 jwt | |
String jwt = JwtUtil.createJWT(userId); | |
Map<String,String> map = new HashMap<>(); | |
map.put("token",jwt); | |
// 把完整的用户信息存入 redis,userid 作为 key | |
redisCache.setCacheObject("login:"+userId,loginUser); | |
return new ResponseResult(200,"登录成功",map); | |
} | |
public ResponseResult logout(){ | |
// 获取 SecurityContextHolder 中的用户 id | |
UsernamePasswordAuthenticationToken authentication = (UsernamePasswordAuthenticationToken) SecurityContextHolder.getContext().getAuthentication(); | |
LoginUser loginUser = (LoginUser) authentication.getPrincipal(); | |
Long userId = loginUser.getUser().getId(); | |
// 删除 redis 中的值 | |
redisCache.deleteObject("login:"+userId); | |
return new ResponseResult(200,"注销成功"); | |
} | |
} |
# 3 授权🍨
# 3 权限系统的作用🛴
例如一个学校图书馆的管理系统,如果是普通学生登录就能看到借书还书的功能,不可能让它看到并且去使用添加书籍信息,删除书籍信息等功能,但是如果是一个图书管理员的账号登录了,应该就能看到并使用添加书籍信息,删除书籍信息等功能。
总结起来就是不同的用户可以使用不同的功能。这就是权限系统要去实现的效果。
我们不能只依赖前端去判断用户的权限来选择显示哪些菜单哪些按钮。因为如果只是这样,如果有人知道了对应的接口地址就可以不通过前端,直接去发送请求来实现相关功能操作。
所以我们还需要在后台进行用户权限的判断,判断当前用户是否有相应的权限,必须具有所需权限才能进行响应的操作。
# 3.1 授权基本流程🦅
在 SpringSecurity 中,会使用默认的 FilterSecurityInterceptor 来进行权限校验,在 FilterSecurityInterceptor 中会从 SecurityContextHolder 获取其中的 Authentication,然后获取其中的权限信息。当前用户是否拥有访问当前资源所需的权限。
所以我们在项目中只需要把当前登录用户的权限信息也存入 Authentication。
然后设置我们的资源所需的权限即可。
# 3.2 授权实现🌜
# 3.2.1 限制访问资源所需权限🗑
SpringSecurity 为我们提供了基于注解的权限控制方案,这也是我们项目中主要采用的方式。我们可以使用注解去指定访问对应的资源所需的权限。
但是要使用它我们需要先开启相关配置。
在 SecurityConfig 类中添加注解
@EnableGlobalMethodSecurity(prePostEnabled = true) |
然后就可以使用对应的注解。@PreAuthorize
@SuppressWarnings("all") | |
@Controller | |
public class HelloController { | |
@Autowired | |
private LoginService loginService; | |
//hasAuthoriry:读取注解中的属性值返回结果是 boolean 值,test 权限则返回 true 去执行 | |
@PreAuthorize("hasAuthority('test')") | |
@RequestMapping("/hello") | |
@ResponseBody | |
public String hello(){ | |
return "hello"; | |
} | |
} |
# 3.2.2 封装权限信息🗾
我们前面在写 UserDetailsServiceImpl 的时候说过,在查询出用户后还要获取对应的权限信息,封装到 UserDetails 中返回。
我们先直接把权限信息写死封装到 UserDetails 中进行测试。
我们之前定义了 UserDetails 的实现类 LoginUser,想要让其封装权限信息就要对其进行修改。
package com.sangeng.domain; | |
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; | |
@Data | |
@NoArgsConstructor | |
public class LoginUser implements UserDetails { | |
private User user; | |
// 存储权限信息 | |
private List<String> permissions; | |
// 存储 SpringSecurity 所需要的权限信息的集合,加上该注解表示不会被序列化 | |
@JSONField(serialize = false) | |
private List<GrantedAuthority> authorities; | |
public LoginUser(User user,List<String> 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; | |
} | |
} |
# 3.2.3 从数据库查询权限信息
# 3.2.3.1 RBAC 权限模型
RBAC 权限模型 (Role-Based Access Control) 即:基于角色的权限控制,这是目前最常被开发者使用也是相对易用,通用权限模型。
# 3.2.3.2 准备工作
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='菜单表'; | |
INSERT INTO `sys_menu` (menu_name,path,component,perms) VALUES | |
('部门管理','dept','system/dept/index','system:dept:list'); | |
INSERT INTO `sys_menu` (menu_name,path,component,perms) VALUES | |
('测试','dept','system/test/index','system:test:list'); | |
/*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='角色表'; | |
INSERT INTO `sys_role` (`name`,role_key,`status`) VALUES | |
('CEO','ceo',0); | |
INSERT INTO `sys_role` (`name`,role_key,`status`) VALUES | |
('Coder','coder',0); | |
/*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; | |
INSERT INTO `sys_role_menu` (role_id,menu_id) VALUES | |
(1,1); | |
INSERT INTO `sys_role_menu` (role_id,menu_id) VALUES | |
(1,2); | |
/*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; | |
INSERT INTO `sys_user_role` (user_id,role_id) VALUES | |
(2,1); |
查询用户权限 SQL
SELECT | |
DISTINCT em.perms | |
FROM | |
sys_user_role AS ur | |
#联表查询 | |
LEFT JOIN sys_role AS ro ON ur.role_id = ro.id | |
LEFT JOIN sys_role_menu rm ON ur.role_id = rm.role_id | |
LEFT JOIN sys_menu AS em ON em.id = rm.menu_id | |
WHERE#查询条件 | |
ur.user_id = 2 | |
AND ro.`status` = 0 | |
AND em.`status` = 0 |
菜单表 (Menu) 实体类
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 查询权限信息。
@Mapper | |
public interface MenuMapper extends BaseMapper<Menu> { | |
List<String> selectPermsByUserId(Long userId); | |
} |
创建对应的 mapper 文件,定义对应的 sql 语句。
<?xml version="1.0" encoding="UTF-8" ?> | |
<!DOCTYPE mapper | |
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" | |
"https://mybatis.org/dtd/mybatis-3-mapper.dtd"> | |
<!--namespace 绑定一个对应的 Dao (Mapper) 接口 --> | |
<mapper namespace="com.dkx.mapper.MenuMapper"> | |
<select id="selectPermsByUserId" resultType="String"> | |
SELECT DISTINCT em.perms | |
FROM sys_user_role as ur | |
LEFT JOIN sys_role as ro ON ur.role_id = ro.id | |
LEFT JOIN sys_role_menu rm ON ur.role_id = rm.role_id | |
LEFT JOIN sys_menu as em ON em.id = rm.menu_id | |
WHERE ur.user_id = 2 | |
AND ro.status = 0 | |
AND em.status = 0 | |
</select> | |
</mapper> |
然后我们可以在 UserDetailsServiceImpl 中去调用该 mapperd 的方法查询权限信息封装到 LoginUser 对象中即可。
@SuppressWarnings("all") | |
@Service | |
public class UserDetailsServiceImpl implements UserDetailsService { | |
@Autowired | |
private UserMapper userMapper; | |
@Autowired | |
private MenuMapper menuMapper; | |
@Override | |
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { | |
// 查询用户信息 | |
LambdaQueryWrapper<User> wrapper = new LambdaQueryWrapper<User>(); | |
wrapper.eq(User::getUserName,username); | |
User user = userMapper.selectOne(wrapper); | |
// 如果没有查询到用户就抛出异常 | |
if(Objects.isNull(user)){ | |
throw new RuntimeException("用户名或密码错误"); | |
} | |
//TODO 查询对应的权限信息 | |
List<String> list = menuMapper.selectPermsByUserId(user.getId()); | |
// List<String> list = new ArrayList<>(Arrays.asList("test","admin")); | |
return new LoginUser(user,list); | |
} | |
} |
# 4 自定义失败处理💤
我们还希望在认证失败或者是授权失败的情况下也能和我们的接口一样返回相同结构的 json,这样可以让前端能够响应进行统一的处理,要实现这个功能我们需要知道 SpringSecurity 的异常处理机制。
在 SpringSecurity 中,如果我们在认证或者授权的过程中出现了异常会被 ExceptionTranslationFilter 捕获到。在 ExceptionTranslationFilter 中会去判断是认证失败还是授权失败出现的异常。
如果是认证过程中出现的异常会被封装成 AuthenticationException 然后调用 AuthenticationEntryPoint 对象的方法去进行异常处理。
如果是授权过程中出现的异常会被封装成 AccessDeniedException 然后调用 AccessDeniedHander 对象的方法去进行异常处理。
所以如果我们需要自定义异常处理,我们只需要自定义 AuthenticationEntryPoint 和 AccessDeniedHanler 然后配置给 SpeingSecurity 即可。
① 自定义实现类
@Component | |
public class AuthenticationEntryPointImpl implements AuthenticationEntryPoint { | |
@Override | |
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException e) throws IOException, ServletException { | |
ResponseResult result = new ResponseResult(HttpStatus.UNAUTHORIZED.value(),"用户认证失败请查询登录"); | |
String json = JSON.toJSONString(result); | |
// 处理异常 | |
WebUtils.renderString(response,json); | |
} | |
} |
@Component | |
public class AccessDeniedHandlerImpl implements AccessDeniedHandler { | |
@Override | |
public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException e) throws IOException, ServletException { | |
ResponseResult result = new ResponseResult(HttpStatus.FORBIDDEN.value(),"用户授权失败请查询登录"); | |
String json = JSON.toJSONString(result); | |
WebUtils.renderString(response,json); | |
} | |
} |
② 配置 SpeingSecurity
在 SecurityConfig 类中,先注入对应的处理器
@Autowired | |
private AuthenticationEntryPoint authenticationEntryPoint; | |
@Autowired | |
private AccessDeniedHandler accessDeniedHandler; |
然后我们可以使用 HttpSecurity http 对象的方法去配置。
// 配置异常处理器 | |
http.exceptionHandling() | |
// 配置认证失败处理器 | |
.authenticationEntryPoint(authenticationEntryPoint) | |
// 配置授权失败处理器 | |
.accessDeniedHandler(accessDeniedHandler); |
测试步骤:
- 先测试 login 接口,拿到 token 再去用 token 放到 hello 接口中去请求
测试授权失败场景:
将权限表达式中的权限值改为登录系统中不存在的,再将 hello 接口请求中添加 token 携带 token 去请求
PostMan 结果:
测试认证失败场景:
只需要将用户名请求过去一个数据库中不存在的即可。
# 5 跨域🍂
浏览器处于安全的考虑,使用 XMLHttpRequest 对象发起 HTTP 请求时必须遵守同源策略,否则就是跨域的 HTTP 请求,默认情况下是被禁止的,同源策略要求源相同才能正常进行通信,及协议,域名,端口号都完全一致。
前后端分离项目,前端项目和后端项目一般都不是同源的,所以肯定会存在跨域请求的问题。
所以我们就要处理一下,让前端能进行跨域请求。
① 先对 SpringBoot 配置,运行跨域请求。
@Configuration | |
public class CorsConfig implements WebMvcConfigurer { | |
@Override | |
public void addCorsMappings(CorsRegistry registry) { | |
// 设置允许跨域的路径 | |
registry.addMapping("/**") | |
// 设置允许跨域请求的域名 | |
.allowedOriginPatterns("*") | |
// 是否允许 cookie | |
.allowCredentials(true) | |
// 设置允许的请求方式 | |
.allowedMethods("GET", "POST", "DELETE", "PUT") | |
// 设置允许的 header 属性 | |
.allowedHeaders("*") | |
// 跨域允许时间 | |
.maxAge(3600); | |
} | |
} |
② 开启 SpringSecurity 的跨域访问
由于我们的资源都会收到 SpringSecurity 的保护,所以想要跨域访问还要让 SpringSecurity 运行跨域访问。
@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); | |
// 配置异常处理器 | |
http.exceptionHandling() | |
// 配置认证失败处理器 | |
.authenticationEntryPoint(authenticationEntryPoint) | |
.accessDeniedHandler(accessDeniedHandler); | |
// 允许跨域 | |
http.cors(); | |
} |
# 6 遗留小问题
# 其它权限校验方法
我们前面都是使用 @PreAuthorize 注解,然后在其中使用的是 hasAuthority 方法进行校验,SpringSecurity 还为我们提供了其它方法例如:hasAnyAuthority,hasRole,HasAnyRole 等。
这里我们先不急着去介绍这些方法,我们先去理解 hasAuthority 的原理,然后再去学习其他方法你就更容易理解,而不是死记硬背区别。并且我们也可以选择定义校验方法,实现我们自己的校验逻辑。
hasAuthority 方法实际是执行到了 SecurityExpressionRoot 的 hasAuthority,大家只要断点调试既可知道它内部的校验原理。
它内部其实是调用 authentication 的 getAuthorities 方法获取用户的权限列表。然后判断我们存入的方法参数数据在权限列表中。
hasAnyAuthority 方法可以传入多个权限,只有用户有其中任意一个权限都可以访问对应资源。
@PreAuthorize("hasAnyAuthority('admin','test','system:dept:list')") | |
public String hello(){ | |
return "hello"; | |
} |
hasRole 要求有对应的角色才可以访问,但是它内部会把我们传入的参数拼接上 ROLE_ 后再去比较。所以这种情况下要用用户对应的权限也要有 ROLE_ 这个前缀才可以。
@PreAuthorize("hasRole('system:dept:list')") | |
public String hello(){ | |
return "hello"; | |
} |
hasAnyRole 有任意的角色就可以访问。它内部也会把我们传入的参数拼接上 ROLE_ 后再去比较。所以这种情况下要用用户对应的权限也要有 ROLE_ 这个前缀才可以。
@PreAuthorize("hasAnyRole('admin','system:dept:list')") | |
public String hello(){ | |
return "hello"; | |
} |
# 自定义权限校验方法
我们也可以定义自己的权限校验方法,在 @PreAuthorize 注解中使用我们的方法。
@Component("ex") | |
public class SGExpressionRoot { | |
public boolean hasAuthority(String authority){ | |
// 获取当前用户的权限 | |
Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); | |
LoginUser loginUser = (LoginUser) authentication.getPrincipal(); | |
List<String> permissions = loginUser.getPermissions(); | |
// 判断用户权限集合中是否存在 authority | |
return permissions.contains(authority); | |
} | |
} |
在 SPEL 表达式中使用 @ex 相当于获取容器中 bean 的名字未 ex 的对象。然后再调用这个对象的 hasAuthority 方法
@RequestMapping("/hello") | |
@PreAuthorize("@ex.hasAuthority('system:dept:list')") | |
public String hello(){ | |
return "hello"; | |
} |
# 基于配置的权限控制
我们也可以在配置类中使用使用配置的方式对资源进行权限控制。
@Override | |
protected void configure(HttpSecurity http) throws Exception { | |
http | |
// 关闭 csrf | |
.csrf().disable() | |
// 不通过 Session 获取 SecurityContext | |
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) | |
.and() | |
.authorizeRequests() | |
// 对于登录接口 允许匿名访问 | |
.antMatchers("/user/login").anonymous() | |
.antMatchers("/testCors").hasAuthority("system:dept:list222") | |
// 除上面外的所有请求全部需要鉴权认证 | |
.anyRequest().authenticated(); | |
// 添加过滤器 | |
http.addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class); | |
// 配置异常处理器 | |
http.exceptionHandling() | |
// 配置认证失败处理器 | |
.authenticationEntryPoint(authenticationEntryPoint) | |
.accessDeniedHandler(accessDeniedHandler); | |
// 允许跨域 | |
http.cors(); | |
} |
# CSRF
CSRF 是指跨站请求伪造(Cross-site request forgery),是 web 常见的攻击之一。
https://blog.csdn.net/freeking101/article/details/86537087
SpringSecurity 去防止 CSRF 攻击的方式就是通过 csrf_token。后端会生成一个 csrf_token,前端发起请求的时候需要携带这个 csrf_token, 后端会有过滤器进行校验,如果没有携带或者是伪造的就不允许访问。
我们可以发现 CSRF 攻击依靠的是 cookie 中所携带的认证信息。但是在前后端分离的项目中我们的认证信息其实是 token,而 token 并不是存储中 cookie 中,并且需要前端代码去把 token 设置到请求头中才可以,所以 CSRF 攻击也就不用担心了。
# 认证成功处理器
实际上在 UsernamePasswordAuthenticationFilter 进行登录认证的时候,如果登录成功了是会调用 AuthenticationSuccessHandler 的方法进行认证成功后的处理的。AuthenticationSuccessHandler 就是登录成功处理器。
我们也可以自己去自定义成功处理器进行成功后的相应处理。
@Component | |
public class SGSuccessHandler implements AuthenticationSuccessHandler { | |
@Override | |
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException { | |
System.out.println("认证成功了"); | |
} | |
} |
@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 就是登录失败处理器。
我们也可以自己去自定义失败处理器进行失败后的相应处理。
@Component | |
public class SGFailureHandler implements AuthenticationFailureHandler { | |
@Override | |
public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException { | |
System.out.println("认证失败了"); | |
} | |
} |
@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(); | |
} | |
} |
# 登出成功处理器
@Component | |
public class SGLogoutSuccessHandler implements LogoutSuccessHandler { | |
@Override | |
public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException { | |
System.out.println("注销成功"); | |
} | |
} |
@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(); | |
} | |
} |