Spring大家族中,一个安全框架
在Spring Security之前的安全框架是Shiro,对于现在的开发而言Shiro已经比较古老
JavaEE在目前开中的主要应用就是以B/S为架构模式的web项目,在一般的web项目中总会有登录和鉴权的需求
基于Spring Boot搭建的Java后端,使用工具IDEA,JDK为1.8
基于初始maven工程,手动更改为Spring Boot项目
①引入Spring Boot相关的依赖
<parent>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-starter-parentartifactId>
<version>2.6.6version>
parent>
<dependencies>
<dependency>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-starter-webartifactId>
dependency>
<dependency>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-starter-testartifactId>
dependency>
<dependency>
<groupId>org.projectlombokgroupId>
<artifactId>lombokartifactId>
dependency>
dependencies>
②创建启动类
@SpringBootApplication
public class SecurityFrameWork {
public static void main(String[] args) {
SpringApplication.run(SecurityFrameWork.class,args);
}
}
③创建controller层
@RestController//返回json字符串
@RequestMapping("/quick")
public class QuickStart {
@GetMapping
public String demo1(){
return "Hello Security";
}
}
④启动测试
测试URL : http://localhost:8080/quick
引入Spring Security依赖
<dependency>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-starter-securityartifactId>
dependency>
再次测试 : http://localhost:8080/quick
出现一个需要登录的页面 : 默认登录认证页面
用户名 : user
密码 : 启动项目时默认密码会打印在控制台,例如 8926bcf1-3098-4095-a9ef-ae6b6c1f72e2
输入用户名和密码后,才会出现Hello Security字符串
在我们引入Spring Security依赖后,我们访问controller层时会默认弹出一个登录页面,我们需要输入用户名和密码之后才能正常访问…
默认登出页面 : http://localhost:8080/logout
Spring Security自带的认证的不足
理想情况下的认证流程
用语言描述Spring Security的认证流程
①前端携带数据以POST方式访问后端URL
②后端与数据库交互查询对应的用户
③查询到的用户如果不为空,生成JWT并返回给前端
④前端接收到JWT令牌,在之后的请求中在请求头中携带JWT令牌
⑤后端获取请求头中的JWT令牌解析得到相应的用户信息
⑥为解析到的的用户设置权限
⑦响应数据给前端
Spring Security的底层实现使用的是过滤器链,经过一系列的过滤器链条后完成认证已经鉴权
核心过滤器
Spring Security的所有过滤器
run.getBean(DefaultSecurityFilterChain.class)
public class UsernamePasswordAuthenticationFilter extends AbstractAuthenticationProcessingFilter
attemptAuthentication方法,将前端传入的用户名和密码封装成Authentication对象
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
if (this.postOnly && !request.getMethod().equals("POST")) {
throw new AuthenticationServiceException("Authentication method not supported: " + request.getMethod());
} else {
//1.获取表单中的用户名
String username = this.obtainUsername(request);
username = username != null ? username : "";
username = username.trim();
//2.获取表单中的密码
String password = this.obtainPassword(request);
password = password != null ? password : "";
//3.将用户名和密码封装成一个Authentication对象
UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(username, password);
//4.为Authentication对象authRequest传入HttpServletRequest对象
this.setDetails(request, authRequest);
//调用AuthenticationManager中的authenticate方法
return this.getAuthenticationManager().authenticate(authRequest);
}
}
public class ProviderManager implements AuthenticationManager
提交Authentication对象里面封装这用户名和密码
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
//1.获得Authentication的class对象
Class<? extends Authentication> toTest = authentication.getClass();
AuthenticationException lastException = null;
AuthenticationException parentException = null;
Authentication result = null;
Authentication parentResult = null;
int currentPosition = 0;
int size = this.providers.size();
Iterator var9 = this.getProviders().iterator();
while(var9.hasNext()) {
AuthenticationProvider provider = (AuthenticationProvider)var9.next();
if (provider.supports(toTest)) {
if (logger.isTraceEnabled()) {
Log var10000 = logger;
String var10002 = provider.getClass().getSimpleName();
++currentPosition;
var10000.trace(LogMessage.format("Authenticating request with %s (%d/%d)", var10002, currentPosition, size));
}
try {
//2.调用AuthenticationProvider中的authenticate方法
result = provider.authenticate(authentication);
if (result != null) {
this.copyDetails(authentication, result);
break;
}
} catch (InternalAuthenticationServiceException | AccountStatusException var14) {
this.prepareException(var14, authentication);
throw var14;
} catch (AuthenticationException var15) {
lastException = var15;
}
}
}
if (result == null && this.parent != null) {
try {
parentResult = this.parent.authenticate(authentication);
result = parentResult;
} catch (ProviderNotFoundException var12) {
} catch (AuthenticationException var13) {
parentException = var13;
lastException = var13;
}
}
if (result != null) {
if (this.eraseCredentialsAfterAuthentication && result instanceof CredentialsContainer) {
((CredentialsContainer)result).eraseCredentials();
}
if (parentResult == null) {
this.eventPublisher.publishAuthenticationSuccess(result);
}
return result;
} else {
if (lastException == null) {
lastException = new ProviderNotFoundException(this.messages.getMessage("ProviderManager.providerNotFound", new Object[]{toTest.getName()}, "No AuthenticationProvider found for {0}"));
}
if (parentException == null) {
this.prepareException((AuthenticationException)lastException, authentication);
}
throw lastException;
}
}
public abstract class AbstractUserDetailsAuthenticationProvider implements AuthenticationProvider
认证流程和权限的分配
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
Assert.isInstanceOf(UsernamePasswordAuthenticationToken.class, authentication, () -> {
return this.messages.getMessage("AbstractUserDetailsAuthenticationProvider.onlySupports", "Only UsernamePasswordAuthenticationToken is supported");
});
String username = this.determineUsername(authentication);
boolean cacheWasUsed = true;
UserDetails user = this.userCache.getUserFromCache(username);
if (user == null) {
cacheWasUsed = false;
try {
user = this.retrieveUser(username, (UsernamePasswordAuthenticationToken)authentication);
} catch (UsernameNotFoundException var6) {
this.logger.debug("Failed to find user '" + username + "'");
if (!this.hideUserNotFoundExceptions) {
throw var6;
}
throw new BadCredentialsException(this.messages.getMessage("AbstractUserDetailsAuthenticationProvider.badCredentials", "Bad credentials"));
}
Assert.notNull(user, "retrieveUser returned null - a violation of the interface contract");
}
try {
this.preAuthenticationChecks.check(user);
//检查输入的密码和系统默认生成的密码是否相同
this.additionalAuthenticationChecks(user, (UsernamePasswordAuthenticationToken)authentication);
} catch (AuthenticationException var7) {
if (!cacheWasUsed) {
throw var7;
}
cacheWasUsed = false;
user = this.retrieveUser(username, (UsernamePasswordAuthenticationToken)authentication);
this.preAuthenticationChecks.check(user);
this.additionalAuthenticationChecks(user, (UsernamePasswordAuthenticationToken)authentication);
}
this.postAuthenticationChecks.check(user);
if (!cacheWasUsed) {
this.userCache.putUserInCache(user);
}
Object principalToReturn = user;
if (this.forcePrincipalAsString) {
principalToReturn = user.getUsername();
}
//添加权限信息将UserDetail的权限设置到Authentication中
return this.createSuccessAuthentication(principalToReturn, authentication, user);
}
public class DaoAuthenticationProvider extends AbstractUserDetailsAuthenticationProvider
获取系统中的用户名和密码并封装成UserDetails对象,在认证和授权时使用
protected final UserDetails retrieveUser(String username, UsernamePasswordAuthenticationToken authentication) throws AuthenticationException {
this.prepareTimingAttackProtection();
try {
//检查用户是否存在
UserDetails loadedUser = this.getUserDetailsService().loadUserByUsername(username);
if (loadedUser == null) {
throw new InternalAuthenticationServiceException("UserDetailsService returned null, which is an interface contract violation");
} else {//返回系统默认的所有用户信息
return loadedUser;
}
} catch (UsernameNotFoundException var4) {
this.mitigateAgainstTimingAttack(authentication);
throw var4;
} catch (InternalAuthenticationServiceException var5) {
throw var5;
} catch (Exception var6) {
throw new InternalAuthenticationServiceException(var6.getMessage(), var6);
}
}
public class InMemoryUserDetailsManager implements UserDetailsManager
根据username取出系统用户的登录密码和权限信息
InMemory是默认内存中存着相应用户信息
将系统用户的所有相关信息封装为UserDetails对象返回
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
//检查并将查询到的结果封装成UserDetails对象
UserDetails user = (UserDetails)this.users.get(username.toLowerCase());
if (user == null) {
throw new UsernameNotFoundException(username);
} else {
return new User(user.getUsername(), user.getPassword(), user.isEnabled(), user.isAccountNonExpired(), user.isCredentialsNonExpired(), user.isAccountNonLocked(), user.getAuthorities());
}
}
以上步骤就已经完成了对认证和授权的操作
总结 :
通过上述分析,我们如何自定义自己的登录流程?
Spring Security框架原有的前后端交互时是通过session/cookie来确定用户,现在大多数项目都是分布式部署,使用session会有一定的不方便,我们应该采用无状态登录token令牌来完成来确定信息
JWT简介
(1)基本概念
JSON Web Token(JWT)是一个非常轻巧的规范。这个规范允许我们使用JWT在用户和服务器之间传递安全可靠的信息。无状态。
优点 : 不需要服务器端 存session
特点 : JWT是可以被看见,但是不能被篡改,因为里面有自定义的部分
一个JWT实际上就是一个字符串,它由三部分组成,头部、载荷与签名组成中间使用.隔开。
头部(Header)
头部用于描述关于该JWT的最基本的信息,例如其类型以及签名所用的算法等。这也可以被表示成一个JSON对象。
{"typ":"JWT","alg":"HS256"}
在头部指明了签名算法是HS256算法。 我们进行BASE64编码https://base64.us/,编码后的字符串如下:
eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9
载荷(playload)
载荷就是存放有效信息的地方。
定义一个payload:
{"name":"HuiEr","age":18}
然后将其进行base64加密,得到Jwt的第二部分。
eyJuYW1lIjoiSHVpRXIiLCJhZ2UiOjE4fQ==
签证(signature)
jwt的第三部分是一个签证信息,这个签证信息由三部分组成:
header (base64后的)
payload (base64后的)
secret
这个部分需要base64加密后的header和base64加密后的payload使用.连接组成的字符串,然后通过header中声明的加密方式进行[自定义]组合加密,然后就构成了jwt的第三部分
hs256("eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJuYW1lIjoiSHVpRXIiLCJhZ2UiOjE4fQ",secret)
将这三部分用.连接成一个完整的字符串,构成了最终的jwt
(2)JJWT签发与验证token
JJWT是一个提供端到端的JWT创建和验证的Java库。永远免费和开源(Apache License,版本2.0),JJWT很容易使用和理解。它被设计成一个以建筑为中心的流畅界面,隐藏了它的大部分复杂性。
官方文档:
https://github.com/jwtk/jjwt
创建token的过程
引入依赖
<dependency>
<groupId>io.jsonwebtokengroupId>
<artifactId>jjwtartifactId>
<version>0.9.0version>
dependency>
<dependency>
<groupId>junitgroupId>
<artifactId>junitartifactId>
<version>4.12version>
<scope>testscope>
dependency>
创建测试类
import io.jsonwebtoken.JwtBuilder;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.junit4.SpringRunner;
import java.util.Date;
/**
* @author HuiEr
* @version 2020
* @date 2022/11/13 21:51
* @Description
*/
@RunWith(SpringRunner.class)//表示测试环境是在Spring的环境下,并且加载SpringBoot测试注解
@SpringBootTest//标记该类为Spring Boot单元测试类,同并加载applicationContext上下文环境
public class SecurityFrameWorkTest {
@Test
public void t1(){
/*
iss: jwt签发者
sub: jwt所面向的用户
aud: 接收jwt的一方
exp: jwt的过期时间,这个过期时间必须要大于签发时间
nbf: 定义在什么时间之前,该jwt都是不可用的.
iat: jwt的签发时间
jti: jwt的唯一身份标识,主要用来作为一次性token,从而回避重放攻击。
*/
JwtBuilder jwtBuilder = Jwts.builder()
.setId("1")//设置Id
.setSubject("JJWT")//设置主题
.setIssuedAt(new Date())//签发日期
.signWith(SignatureAlgorithm.HS256,"HuiEr_h");
String token = jwtBuilder.compact();
System.out.println(token);
}
}
运行结果
eyJhbGciOiJIUzI1NiJ9.eyJqdGkiOiIxIiwic3ViIjoiSkpXVCIsImlhdCI6MTY2ODM0ODU5OX0.ylD417iIUL1cRsVX_BVdZzO0pCGCARnj6TtP3CruA50
再次运行,会发现每次运行的结果是不一样的,因为我们的载荷中包含了时间
验证base64 https://tool.oschina.net/encrypt?type=3
头部 eyJhbGciOiJIUzI1NiJ9
载荷 eyJqdGkiOiIxIiwic3ViIjoiSkpXVCIsImlhdCI6MTY2ODM0ODU5OX0
签证 ylD417iIUL1cRsVX_BVdZzO0pCGCARnj6TtP3CruA50
解析token中的载荷部分
上面创建的token令牌会在前端第一次访问后端验证身份成功后返回给前端,前端会存储并在之后的请求中将token令牌放置在请求头中,后端接收到这个token应该解析出token的信息
String jwt = "eyJhbGciOiJIUzI1NiJ9.eyJqdGkiOiIxIiwic3ViIjoiSkpXVCIsImlhdCI6MTY2ODM0ODU5OX0.ylD417iIUL1cRsVX_BVdZzO0pCGCARnj6TtP3CruA50";
Claims claims = Jwts.parser().setSigningKey("HuiEr_h").parseClaimsJws(jwt).getBody();
System.out.println(claims);
运行结果
{jti=1, sub=JJWT, iat=1668348599}
如果将token签证中的秘钥改变,会发现运行时报错,所以解析token时签证中的秘钥要和设置时相同
设置token令牌过期时间
//当前时间
long curTime = System.currentTimeMillis();
System.out.println(curTime);
Date date = new Date(curTime + 10000);//1s后令牌过期
JwtBuilder jwtBuilder = Jwts.builder()
.setId("1")//设置id
.setSubject("outDate")//主题
.setIssuedAt(new Date())//签发日期
.setExpiration(date)//过期时间
.signWith(SignatureAlgorithm.HS256, "HuiEr_h");
String jwt = jwtBuilder.compact();
System.out.println(jwt);
//线程休眠1.5s
try {
Thread.sleep(15000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
//解析token令牌
Claims itlils = Jwts.parser().setSigningKey("HuiEr_h").parseClaimsJws(jwt).getBody();
System.out.println(itlils);
如果token已经过期,再次解析token字符串时会抛出异常
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-l99DsbZC-1668495781280)(C:\Users\HuiEr\Pictures\截图\Spring Security\token令牌过期.png)]
载荷中存储自定义数据
自定义实体类
@Data
public class User {
private Integer id;
private String name;
}
编写测试类
User user = new User();
user.setId(1);
user.setName("HuiEr");
JwtBuilder jwtBuilder = Jwts.builder()
.claim("userId", "1")
.claim("user", user)
.signWith(SignatureAlgorithm.HS256, "HuiEr_h");
String jwt = jwtBuilder.compact();
System.out.println(jwt);
Claims itlils = Jwts.parser().setSigningKey("HuiEr_h").parseClaimsJws(jwt).getBody();
System.out.println(itlils);
运行结果
eyJhbGciOiJIUzI1NiJ9.eyJ1c2VySWQiOiIxIiwidXNlciI6eyJpZCI6MSwibmFtZSI6Ikh1aUVyIn19.OEz5vKezHMvnIR7kfDD9kzhsgNewu8nYZM7kPYdM6TQ
{userId=1, user={id=1, name=HuiEr}}
尽管JWT是比较安全的一种状态证明,但是我们也不能将大量的信息存储中载荷中去,所以我们应该使用NOSQL存储首次登陆成功的用户信息(包括权限),下次请求到来时只需要解析token再去NOSQL中获取该对象即可;而且这样可以不用每次都去与关系型数据库交互,降低损耗
准备工作
添加依赖
<dependency>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-starter-data-redisartifactId>
dependency>
<dependency>
<groupId>com.alibabagroupId>
<artifactId>fastjsonartifactId>
<version>1.2.76version>
dependency>
配置redis
import com.alibaba.fastjson.support.spring.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;
/**
* @author HuiEr
* @version 2020
* @date 2022/11/14 1:49
* @Description
*/
@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.alibaba.fastjson.JSON;
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 com.alibaba.fastjson.parser.ParserConfig;
import java.nio.charset.Charset;
/**
* @author HuiEr
* @version 2020
* @date 2022/11/14 1:50
* @Description
*/
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);
}
}
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;
/**
* @author HuiEr
* @version 2020
* @date 2022/11/14 1:55
* @Description
*/
public class JwtUtil {
//有效期为
public static final Long JWT_TTL = 60 * 60 * 1000L;// 60 * 60 *1000 一个小时
//设置秘钥明文
public static final String JWT_KEY = "HuiEr_h";
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();
}
/**
* 创建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();
}
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);
//如果没有传入过期时间 使用默认过期时间 1小时
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("HuiEr") // 签发者
.setIssuedAt(now) // 签发时间
.signWith(signatureAlgorithm, secretKey) //使用HS256对称加密算法签名, 第二个参数为秘钥
.setExpiration(expDate);//设置过期时间
}
/**
* 生成加密后的秘钥 secretKey
*
* @return
*/
public static SecretKey generalKey() {
byte[] encodedKey = Base64.getMimeDecoder().decode(JwtUtil.JWT_KEY);
SecretKey key = new SecretKeySpec(encodedKey, 0, encodedKey.length, "AES");
return key;
}
/**
* 解析
*
* @param jwt
* @return
* @throws Exception
*/
public static Claims parseJWT(String jwt) throws Exception {
SecretKey secretKey = generalKey();
return Jwts.parser()
.setSigningKey(secretKey)
.parseClaimsJws(jwt)
.getBody();
}
}
redis工具类
package com.huier.security.utils;
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;
/**
* @author HuiEr
* @version 2020
* @date 2022/11/14 2:00
* @Description redis的工具类
*/
@SuppressWarnings(value = {"unchecked", "rawtypes"})
@Component
public class RedisCache {
@Autowired
public RedisTemplate redisTemplate;//操作redis的连接
/**
* 缓存基本的对象,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);
}
}
前后端交互工具类
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
/**
* @author HuiEr
* @version 2020
* @date 2022/11/14 2:02
* @Description
*/
public class WebUtil {
/**
* 将字符串渲染到客户端
*
* @param response 渲染对象
* @param string 待渲染的字符串
* @return null
*/
public static String renderString(HttpServletResponse response, String string) {
try {
response.setStatus(200);
response.setContentType("application/json");
response.setCharacterEncoding("utf-8");
response.getWriter().print(string);
} catch (IOException e) {
e.printStackTrace();
}
return null;
}
}
实体类
package com.huier.security.pojo.entity;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.io.Serializable;
import java.util.Date;
/**
* @author HuiEr
* @version 2020
* @date 2022/11/14 2:04
* @Description
*/
@Data
@AllArgsConstructor
@NoArgsConstructor
@TableName(value = "sys_user")
public class User implements Serializable {
private static final long serialVersionUID = 1L;
/**
* 主键
*/
@TableId
private Long id;
/**
* 用户名
*/
private String userName;
/**
* 昵称
*/
private String nickName;
/**
* 密码
*/
private String password;
/**
* 账号状态(0正常 1停用)
*/
private String status;
/**
* 邮箱
*/
private String email;
/**
* 手机号
*/
private String phonenumber;
/**
* 用户性别(0男,1女,2未知)
*/
private String sex;
/**
* 头像
*/
private String avatar;
/**
* 用户类型(0管理员,1普通用户)
*/
private String userType;
/**
* 创建人的用户id
*/
private Long createBy;
/**
* 创建时间
*/
private Date createTime;
/**
* 更新人
*/
private Long updateBy;
/**
* 更新时间
*/
private Date updateTime;
/**
* 删除标志(0代表未删除,1代表已删除)
*/
private Integer delFlag;
}
在MySql中建表
CREATE DATABASE src_spring_security
USE src_spring_security
CREATE TABLE `sys_user` (
`id` BIGINT 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 DEFAULT NULL COMMENT '创建人的用户id',
`create_time` DATETIME DEFAULT NULL COMMENT '创建时间',
`update_by` BIGINT DEFAULT NULL COMMENT '更新人',
`update_time` DATETIME DEFAULT NULL COMMENT '更新时间',
`del_flag` INT DEFAULT '0' COMMENT '删除标志(0代表未删除,1代表已删除)',
PRIMARY KEY (`id`)
) ENGINE=INNODB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='用户表';
INSERT INTO `sys_user` VALUES ('1', 'huier', '灰二', '$10$rrrTonQXHOCvoWkQ8BbzkuYjLLVLmX2vDW4tYRRAhHc/bbYheRgvO', '0', '[email protected]', 'xxxxxxxxxxx', '0', 'image', '1', '1', '2022-08-20 18:52:41', '1', NOW(), '0');
引入MP和MySQL依赖
<dependency>
<groupId>com.baomidougroupId>
<artifactId>mybatis-plus-boot-starterartifactId>
<version>3.4.3version>
dependency>
<dependency>
<groupId>mysqlgroupId>
<artifactId>mysql-connector-javaartifactId>
dependency>
配置redis、MP等
server:
port: 8080
spring:
datasource:
url: jdbc:mysql://localhost:3306/src_spring_security?characterEncoding=utf-8&serverTimezone=Asia/Shanghai
username: root
password: root
driver-class-name: com.mysql.cj.jdbc.Driver
#redis
redis:
host: 127.0.0.1
port: 6379
database: 0
pool:
max-active: 8
max-wait: -1ms
max-idle: 8
min-idle: 0
timeout: 5000
lettuce:
shutdown-timeout: 100
mybatis-plus:
configuration:
# 日志
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
定义mapper接口
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.huier.security.pojo.entity.User;
public interface UserMapper extends BaseMapper<User> {
}
开启Mapper扫描
@MapperScan("com.huier.security.dao")
测试
mysql测试
List<User> users = userMapper.selectList(null);
System.out.println(users);
redis测试
redisCache.setCacheObject("test","test");
自定义登录流程
实现UserDetails对象对用户信息的进一步封装与增强
import com.huier.security.pojo.entity.User;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import java.util.Collection;
/**
* @author HuiEr
* @version 2020
* @date 2022/11/14 2:54
* @Description
*/
@Data
public class UserDetailsImpl implements UserDetails {
private User user;
public UserDetailsImpl(User user){
this.user = user;
}
@Override
//获取权限
public Collection<? extends GrantedAuthority> getAuthorities() {
return null;
}
@Override
public String getPassword() {
return user.getPassword();
}
@Override
public String getUsername() {
return user.getUserName();
}
//TODO 下面几个都是和权限相关
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
}
实现UserDetailsService接口将查询到的用户信息封装成UserDetails对象
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.huier.security.dao.UserMapper;
import com.huier.security.pojo.entity.User;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
import java.util.Objects;
/**
* @author HuiEr
* @version 2020
* @date 2022/11/14 2:49
* @Description
*/
@Service//需要被SpringBoot扫描到
public class UserDetailsServiceImpl implements UserDetailsService {
@Autowired
private UserMapper userMapper;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
//根据用户名查询用户信息
LambdaQueryWrapper<User> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(User::getUserName, username);
User user = userMapper.selectOne(wrapper);
//如果查询不到数据就通过抛出异常来给出提示
if (Objects.isNull(user)) {//java工具类 Objects
throw new RuntimeException("用户名错误");
}
//TODO 根据用户查询权限信息 添加到LoginUser中
//封装成UserDetails对象返回
return new LoginUser(user);
}
}
测试是否可以通过用户名查询用户具体信息
可以发现已经可以和数据库交互查询信息
自定义登录接口并替换默认的登录页面
①自定义controller登录层和业务层
@RequestMapping("/login")
@RestController
public class LoginController {
@Autowired
private LoginService loginService;
@PostMapping
public ResponseResult<String> login(@RequestBody User loginUser){
return loginService.login(loginUser);
}
}
public interface LoginService {
ResponseResult<String> login(User loginUser);
}
@Service
public class LoginServiceImpl implements LoginService {
@Override
public ResponseResult<String> login(User loginUser) {
return null;
}
}
②放行登录请求
@Configuration
public class SecurityConfig{
@Bean
//Spring Security过滤器链
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
//关闭csrf
.csrf().disable()
//不通过Session获取SecurityContext
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.authorizeRequests()
// 对于登录接口 允许匿名访问
.antMatchers("/login").anonymous()//放行登录
// 除上面外的所有请求全部需要鉴权认证
.anyRequest().authenticated();
return http.build();
}
@Autowired
private AuthenticationConfiguration authenticationConfiguration;
@Bean
//用来传递用户名和密码的封装对象
public AuthenticationManager authenticationManager() throws Exception{
AuthenticationManager authenticationManager = authenticationConfiguration.getAuthenticationManager();
return authenticationManager;
}
}
③调用AuthenticationManager中的authenticate方法,传递用户名和密码的封装对象
④生成token令牌并传给前端
⑤redis中存储当前登录用户的信息
@Service
public class LoginServiceImpl implements LoginService {
@Autowired
private AuthenticationManager authenticationManager;
@Autowired
private RedisCache redisCache;
@Override
public ResponseResult login(User loginUser) {
//1.将用户名和密码封装成Authentication对象
//UsernamePasswordAuthenticationToken实现了Authentication接口
UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(loginUser.getUserName(),loginUser.getPassword());
//2.调用AuthenticationManager的authenticate方法传递Authentication对象
Authentication authenticate = authenticationManager.authenticate(authentication);//返回最终的Authentication对象
//3.判断是否为空
if(Objects.isNull(authenticate)){
throw new UserIsNotExistException("用户不存在,请检查账号或者密码");
}
//4.一切正常authenticate中包含了UserDetails对象
UserDetailsImpl userDetails = (UserDetailsImpl) authenticate.getPrincipal();
String userId = userDetails.getUser().getId().toString();
String token = JwtUtil.createJWT(userId);//前端接收到的token令牌
//返回给前端的信息
Map<String,String> record = new HashMap<>();
record.put("token",token);
//5.存储用户信息到redis中
redisCache.setCacheObject("curUser:"+userId, userDetails);
return new ResponseResult<>(200,"登录成功",record);
}
}
⑥测试
会抛出这个异常
异常原因 : 没有配置密码加密方式
解决 : 在SecurityConfig中添加配置
@Bean
public PasswordEncoder passwordEncoder(){
return new BCryptPasswordEncoder();
}
登录成功
redies存储成功
认证过滤器 : 每次请求过来时需要检查用户是否登录
①获取请求头中的token字符串
②解析token,获得userId
③根据userId查询redis数据库,获得当前登录信息
④封装Authentication对象,方便后面鉴权
⑤存入SecurityContextHolder全局环境中
import com.huier.security.exception.TokenIsIllegal;
import com.huier.security.exception.UserNotLoggedIn;
import com.huier.security.service.impl.UserDetailsImpl;
import com.huier.security.utils.JwtUtil;
import com.huier.security.utils.RedisCache;
import io.jsonwebtoken.Claims;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.Objects;
/**
* @author HuiEr
* @version 2020
* @date 2022/11/14 13:23
* @Description
*/
//OncePerRequestFilter springMVC中对filter的包装类
@Component
public class SignInFilter extends OncePerRequestFilter {
@Autowired
private RedisCache redisCache;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
//1.获取token
String token = request.getHeader("token");
if (!StringUtils.hasText(token)) {//如果token是空字符串,表示应该去登录,放行
//放行,让后面的过滤器执行
filterChain.doFilter(request, response);
return;//回来的时候不用再走了
}
//2.解析token
String userId;
try {
Claims claims = JwtUtil.parseJWT(token);
userId = claims.getSubject();
} catch (Exception e) {
e.printStackTrace();
throw new TokenIsIllegal();
}
//3.从redis中获取Authentication
JSONObject jsonObject = redisCache.getCacheObject("curUser:" + userId);
UserDetailsImpl userDetails = JSONObject.parseObject(jsonObject.toString(), UserDetailsImpl.class);
if (Objects.isNull(userDetails)) {
throw new UserNotLoggedIn();
}
//4.封装Authentication
UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken
= new UsernamePasswordAuthenticationToken(userDetails, null, null);
//5存入SecurityContextHolder
SecurityContextHolder.getContext().setAuthentication(usernamePasswordAuthenticationToken);
//放行,让后面的过滤器执行
filterChain.doFilter(request, response);
}
}
配置认证过滤器到Spring Security的过滤器链中
@Autowired
private SignInFilter signInFilter;
@Bean
//Spring Security过滤器链
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
//关闭csrf
.csrf().disable()
//不通过Session获取SecurityContext
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.authorizeRequests()
// 对于登录接口 允许匿名访问
.antMatchers("/login").anonymous()//放行登录
// 除上面外的所有请求全部需要鉴权认证
.anyRequest().authenticated();
//把token校验过滤器添加到security的过滤器链中
http.addFilterBefore(signInFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
测试
发送请求时不携带token
403错误是禁止相应。是HTTP协议中的一个状态码(Status Code)。没有权限访问此站。403错误是网站访问过程中,常见的错误提示。资源不可用。服务器理解客户的请求,但拒绝处理它。通常由于服务器上文件或目录的权限设置导致,比如IIS或者apache设置了访问权限不当。
发送请求时携带token : eyJhbGciOiJIUzI1NiJ9.eyJqdGkiOiIxYzU1ZThlNjY4ZWQ0ZjRlYTA3YzUwNjgyYjQxMzMwNyIsInN1YiI6IjEiLCJpc3MiOiJIdWlFciIsImlhdCI6MTY2ODQxNDQyOSwiZXhwIjoxNjY4NDE4MDI5fQ.y9EY-Uqy9-m5fD2OirqGF87x862u0gJtmA3ruNxgOLY
自定义登出流程
登出时我们应该做的事情,删除redis中当前登录用户的信息即可
@RequestMapping("/logout")//由于这个和Spring Security的默认登出uri相同,会出现要你使用默认登录
@RestController
public class LogoutController {
@Autowired
private LogoutService logoutService;
@GetMapping
public ResponseResult logout(){
return new ResponseResult(200,"ok");
}
}
public interface LogoutService {
ResponseResult logout();
}
@Service
public class LogoutServiceImpl implements LogoutService {
@Autowired
private RedisCache redisCache;
@Override
public ResponseResult logout() {
//1.从SecurityContextHolder中获得最终的Authentication对象
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
//2.获得UserDetails对象
UserDetailsImpl userDetailsImpl = (UserDetailsImpl) authentication.getPrincipal();
//3.获得userId
Long userId = userDetailsImpl.getUser().getId();
//4.在redis中删除对应数据
redisCache.deleteObject("curUser:" + userId);
return new ResponseResult(200,"退出成功");
}
}
改变controller中mapping的value
@RequestMapping("/myLogout")//由于这个和Spring Security的默认登出uri相同,会出现要你使用默认登录
@RestController
public class LogoutController {
@Autowired
private LogoutService logoutService;
@GetMapping
public ResponseResult logout(){
return new ResponseResult(200,"ok");
}
}
redis中的用户信息也成功被删除
退出之后再访问资源时,会报错403,后端接收到请求但是没有处理
对于同一个系统而言,不同的用户有着不同的操作权限,比如VIP用户和普通用户在某些功能的使用上就有一定区别,所以我们应该针对不同的用户分配不同的权限
虽然前端可以完成权限管理,但是这个权限管理并不是真正意义上的权限管理,只是将当前用户能够使用的模块展现出来,依旧不安全,而Spring Security可以在后台对权限管理
不同的用户使用不同的功能
我们不能只依赖前端去判断用户的权限来选择显示哪些菜单哪些按钮。因为黑客拿到高权限接口url,模拟访问!
所以我们还需要在后台进行用户权限的判断,判断当前用户是否有相应的权限,必须具有所需权限才能进行相应的操作。
在SpringSecurity中,会使用默认的FilterSecurityInterceptor来进行权限校验。
在FilterSecurityInterceptor中会从SecurityContextHolder获取其中的Authentication,然后获取其中的权限信息。当前用户是否拥有访问当前资源所需的权限。
回顾上述的认证流程,我们与数据库交互是实现了UserDetailsService接口并重写其中的loadUserByUsername方法完成,用户的权限也应该存储在数据库中,所以我们应该在这里将当前用户的权限信息查询查出来
@Service//需要被SpringBoot扫描到
public class UserDetailsServiceImpl implements UserDetailsService {
@Autowired
private UserMapper userMapper;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
//根据用户名查询用户信息
LambdaQueryWrapper<User> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(User::getUserName, username);
User user = userMapper.selectOne(wrapper);
//如果查询不到数据就通过抛出异常来给出提示
if (Objects.isNull(user)) {//java工具类 Objects
throw new RuntimeException("用户名错误");
}
//TODO 根据用户查询权限信息 添加到LoginUser中
//封装成UserDetails对象返回
return new UserDetailsImpl(user);
}
}
最终Authentication对象中具有用户所有信息,我们首次登陆时将权限信息也存储起来,再次发起请求时,如果认证通过需要把权限信息封装进去
@Component
public class SignInFilter extends OncePerRequestFilter {
@Autowired
private RedisCache redisCache;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
//1.获取token
String token = request.getHeader("token");
if (!StringUtils.hasText(token)) {//如果token是空字符串,表示应该去登录,放行
//放行,让后面的过滤器执行
filterChain.doFilter(request, response);
return;//回来的时候不用再走了
}
//2.解析token
String userId;
try {
Claims claims = JwtUtil.parseJWT(token);
userId = claims.getSubject();
} catch (Exception e) {
e.printStackTrace();
throw new TokenIsIllegal();
}
//3.从redis中获取Authentication
JSONObject jsonObject = redisCache.getCacheObject("curUser:" + userId);
UserDetailsImpl userDetails = JSONObject.parseObject(jsonObject.toString(), UserDetailsImpl.class);
if (Objects.isNull(userDetails)) {
throw new UserNotLoggedIn();
}
//4.封装Authentication,同时封装权限信息
UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken
= new UsernamePasswordAuthenticationToken(userDetails, null, null);
//5存入SecurityContextHolder
SecurityContextHolder.getContext().setAuthentication(usernamePasswordAuthenticationToken);
//放行,让后面的过滤器执行
filterChain.doFilter(request, response);
}
}
前面SpringSecurity的认证是通过过滤器链完成,而鉴权则是通过拦截器完成(动态代理)
开启鉴权,使用注解的方式,所以需要在SpringSecurity的配置类中开启注解扫描
@EnableGlobalMethodSecurity(prePostEnabled = true)
鉴权使用的注解@PreAuthorize,将该注解加在controller层具体的方法上,并配置使用该方法需要的权限信息,例如
import com.huier.security.pojo.common.ResponseResult;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* @author HuiEr
* @version 2020
* @date 2022/11/14 18:27
* @Description
*/
@RestController
@RequestMapping("/authority")
public class AuthorityController {
@GetMapping("/demo1")
@PreAuthorize("hasAuthority('demo1')")//访问该方法需要的权限
public ResponseResult demo1(){
return new ResponseResult(200,"拥有权限");
}
}
封装权限信息,快速入门
修改实现了UserDetails接口的类
import com.huier.security.pojo.entity.User;
import lombok.Data;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import java.util.Collection;
import java.util.List;
import java.util.stream.Collectors;
/**
* @author HuiEr
* @version 2020
* @date 2022/11/14 2:54
* @Description
*/
@Data
public class UserDetailsImpl implements UserDetails {
private User user;
//从数据库查询出的当前登录用户的所有权限信息
List<String> authoritiesByDao;
List<SimpleGrantedAuthority> authorities;//对权限信息的进一步封装
public UserDetailsImpl(User user,List<String> authoritiesByDao){
this.user = user;
this.authoritiesByDao = authoritiesByDao;
}
@Override
//获取权限
public Collection<? extends GrantedAuthority> getAuthorities() {
if (authorities!=null){//如果已经存在直接返回
return authorities;
}
//转换
authorities = authoritiesByDao.stream().map(SimpleGrantedAuthority::new).collect(Collectors.toList());
return authorities;
}
@Override
public String getPassword() {
return user.getPassword();
}
@Override
public String getUsername() {
return user.getUserName();
}
//TODO 下面几个都是和权限相关
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
}
修改UserDetailsService接口的类
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.huier.security.dao.UserMapper;
import com.huier.security.pojo.entity.User;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Objects;
/**
* @author HuiEr
* @version 2020
* @date 2022/11/14 2:49
* @Description
*/
@Service//需要被SpringBoot扫描到
public class UserDetailsServiceImpl implements UserDetailsService {
@Autowired
private UserMapper userMapper;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
//根据用户名查询用户信息
LambdaQueryWrapper<User> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(User::getUserName, username);
User user = userMapper.selectOne(wrapper);
//如果查询不到数据就通过抛出异常来给出提示
if (Objects.isNull(user)) {//java工具类 Objects
throw new RuntimeException("用户名错误");
}
//TODO 根据用户查询权限信息 添加到LoginUser中
//快速入门将这里的权限直接写死
List<String> authoritesByDao = new ArrayList<>(Arrays.asList("demo1","demo2"));
//封装成UserDetails对象返回
return new UserDetailsImpl(user,authoritesByDao);
}
}
修改SignInFilter登录授权过滤器
@Component
public class SignInFilter extends OncePerRequestFilter {
@Autowired
private RedisCache redisCache;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
//1.获取token
String token = request.getHeader("token");
if (!StringUtils.hasText(token)) {//如果token是空字符串,表示应该去登录,放行
//放行,让后面的过滤器执行
filterChain.doFilter(request, response);
return;//回来的时候不用再走了
}
//2.解析token
String userId;
try {
Claims claims = JwtUtil.parseJWT(token);
userId = claims.getSubject();
} catch (Exception e) {
e.printStackTrace();
throw new TokenIsIllegal();
}
//3.从redis中获取Authentication
JSONObject jsonObject = redisCache.getCacheObject("curUser:" + userId);
UserDetailsImpl userDetails = JSONObject.parseObject(jsonObject.toString(), UserDetailsImpl.class);
if (Objects.isNull(userDetails)) {
throw new UserNotLoggedIn();
}
//4.封装Authentication
UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken
= new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
//5存入SecurityContextHolder
SecurityContextHolder.getContext().setAuthentication(usernamePasswordAuthenticationToken);
//放行,让后面的过滤器执行
filterChain.doFilter(request, response);
}
}
测试
有权限访问
无权限访问
在快速体验中我们的权限信息是直接固定写死的,在正常开发中,权限信息往往需要从数据库中查询并得到
从数据库中查询权限信息
RBAC权限模型(Role-Based Access Control)即:基于角色的权限控制。这是目前最常被开发者使用也是相对易用、通用权限模型。
①用户表
②角色表
③权限表
④用户权限表
⑤角色权限表
完善数据库表和实体类
数据库SQL语句
-- 用户表
CREATE TABLE `sys_user` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键',
`user_name` VARCHAR (64) NOT NULL DEFAULT 'NULL' COMMENT '用户名',
`password` VARCHAR (100) NOT NULL DEFAULT 'NULL' COMMENT '密码',
PRIMARY KEY (`id`)
) ENGINE = INNODB AUTO_INCREMENT = 2 DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = '用户表';
-- 角色表
CREATE TABLE sys_role(
id INT PRIMARY KEY AUTO_INCREMENT, -- id
`position` VARCHAR(64) NOT NULL UNIQUE-- 职称
);
-- 权限表
CREATE TABLE sys_authorities(
id INT PRIMARY KEY AUTO_INCREMENT,-- id
authority VARCHAR(64) NOT NULL UNIQUE-- 权限
);
-- 用户角色表
CREATE TABLE sys_user_role(
user_id INT,
role_id INT
);
-- 角色权限表
CREATE TABLE sys_role_authorites(
role_id INT,
authority_id INT
);
insert into `sys_authorities`(`id`,`authority`) values
(2,'distribution_wages'),
(4,'get_salary'),
(5,'management_account'),
(7,'payment'),
(1,'personal_change'),
(6,'put_forword_demands'),
(3,'work_on_time');
/*Data for the table `sys_role` */
insert into `sys_role`(`id`,`position`) values
(4,'accountant'),
(3,'consumer'),
(1,'employee'),
(2,'employer');
/*Data for the table `sys_role_authorites` */
insert into `sys_role_authorites`(`role_id`,`authority_id`) values
(1,4),
(1,3),
(2,2),
(2,1),
(3,6),
(3,7),
(4,5),
(4,4),
(4,3);
/*Data for the table `sys_user` */
insert into `sys_user`(`id`,`user_name`,`password`) values
(1,'huier','$10$owKw6Ii.7.JU5a0OwmxKlu9sEEQmvYpbrtXsLaV9/x7CAfiLzO/rS'),
(2,'rixiang','$10$owKw6Ii.7.JU5a0OwmxKlu9sEEQmvYpbrtXsLaV9/x7CAfiLzO/rS'),
(3,'jichuan','$10$owKw6Ii.7.JU5a0OwmxKlu9sEEQmvYpbrtXsLaV9/x7CAfiLzO/rS'),
(4,'zangyuanzou','$10$owKw6Ii.7.JU5a0OwmxKlu9sEEQmvYpbrtXsLaV9/x7CAfiLzO/rS');
/*Data for the table `sys_user_role` */
insert into `sys_user_role`(`user_id`,`role_id`) values
(1,2),
(2,1),
(3,4),
(4,3),
(1,1);
实体类
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* (SysAuthorities)表实体类
*
* @author makejava
* @since 2022-11-15 00:39:57
*/
@SuppressWarnings("serial")
@Data
@NoArgsConstructor
@AllArgsConstructor
@TableName(value = "sys_authorities")
public class Authorities {
@TableId
private Integer id;
private String authority;
}
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* (Role)表实体类
*
* @author makejava
* @since 2022-11-15 00:40:53
*/
@SuppressWarnings("serial")
@Data
@NoArgsConstructor
@AllArgsConstructor
@TableName(value = "sys_role")
public class Role {
@TableId
private Integer id;
private String position;
}
编写mapper
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.huier.security.pojo.entity.User;
import org.springframework.stereotype.Repository;
import java.util.List;
@Repository
public interface UserMapper extends BaseMapper<User> {
List<String> userAuthorities(Integer id);
}
DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd" >
<mapper namespace="com.huier.security.dao.UserMapper">
<select id="userAuthorities" resultType="String">
SELECT
a.`authority`
FROM
sys_user_role ur
LEFT JOIN sys_role_authorites ra
ON ur.`role_id` = ra.`role_id`
LEFT JOIN sys_authorities a
ON a.`id` = ra.`authority_id`
WHERE ur.`user_id` = #{id}
select>
mapper>
测试
@Test
public void t8(){
System.out.println(userMapper.userAuthorities(1));
}
在SpirngBoot中配置
mybatis-plus:
configuration:
# 日志
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
mapper-locations: classpath*:/mapper/**/*.xml
修改UserDetailsService实现类
@Service//需要被SpringBoot扫描到
public class UserDetailsServiceImpl implements UserDetailsService {
@Autowired
private UserMapper userMapper;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
//根据用户名查询用户信息
LambdaQueryWrapper<User> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(User::getUserName, username);
User user = userMapper.selectOne(wrapper);
//如果查询不到数据就通过抛出异常来给出提示
if (Objects.isNull(user)) {//java工具类 Objects
throw new RuntimeException("用户名错误");
}
//TODO 根据用户查询权限信息 添加到LoginUser中
//快速入门将这里的权限直接写死
// List authoritesByDao = new ArrayList<>(Arrays.asList("demo1","demo2"));
List<String> authoritesByDao = userMapper.userAuthorities(user.getId());
//封装成UserDetails对象返回
return new UserDetailsImpl(user,authoritesByDao);
}
}
测试
@GetMapping("/demo2")
@PreAuthorize("hasAuthority('distribution_wages')")
public ResponseResult demo2(){
return new ResponseResult(200,"distribution_wages");
}
@GetMapping("/demo3")
@PreAuthorize("hasAuthority('personal_change')")
public ResponseResult demo3(){
return new ResponseResult(200,"personal_change");
}
@GetMapping("/demo4")
@PreAuthorize("hasAuthority('payment')")
public ResponseResult demo4(){
return new ResponseResult(200,"payment");
}
有权限
没有权限
存储在redis中的用户信息
所以,去权限信息是在用户首次登陆时查询并且存储在redis中的,如果该用户后续向后端发送请求,时直接从redis中取得信息即可
//3.从redis中获取Authentication
JSONObject jsonObject = redisCache.getCacheObject("curUser:" + userId);
UserDetailsImpl userDetails = JSONObject.parseObject(jsonObject.toString(), UserDetailsImpl.class);
if (Objects.isNull(userDetails)) {
throw new UserNotLoggedIn();
}
//4.封装Authentication
UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken
= new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
这样存在一个问题,如果我们在后台对某个用户的权限进行了更改时,用户只要不退出登陆状态,我们最新的权限信息就不能更新给用户,所以我们在后台对用户的权限进行更改时,需要使用户重新登陆;或者在过滤器中
Spring Security动态修改当前登陆用户的权限
①如果我是管理员 ,如何动态地修改用户的权限?比如vip权限?
按照以前的权限使用方法 ,修改数据库的权限信息后,当前用户需要重新登录,才能从数据库获取新的权限信息后再更新当前用户的权限列表,一般是管理员修改权限后,强制用户重新登录,
这样对用户很不友好 ,使用spring security 可以直接更新当前用户的权限 ,其实就是重新注册权限列表信息。
②spring security 不是只能修改当前登录用户的信息么?那么怎么修改别人的?
有两个解决方案:
(1)方案一:管理员在数据库修改用户权限数据后,检查该用户是否已经登录,未登录则操作结束,
如果已经登录,则使用websocket通知用户前端向后端Ajax发送一个修改当前权限的请求。
(2)方案二:管理员在数据库修改用户权限数据,检查该用户是否已经登录,未登录则操作结束,
如果已经登录,获取当前用户存在内存的session,根据session获取该用户的认证信息 ,取出权限列表后对其修改,然后重新注册权限列表。
基本思路
@PostMapping("/update")
public ResponseResult update(@RequestBody List<String> authorities){
//1.获取从前端传过来的新的权限信息 authorities
try{
//2.获取当前的Authentication对象
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
//3.生成新的授权
List<SimpleGrantedAuthority> updateAuthorities = new ArrayList<>();
if(!(authorities == null || authorities.size() == 0)){//如果是空的则直接变更
for (String authority : authorities) {
updateAuthorities.add(new SimpleGrantedAuthority(authority));
}
}
//4.更新UserDetailsImpl中的信息
UserDetailsImpl userDetails = (UserDetailsImpl)authentication.getPrincipal();
userDetails.setAuthoritiesByDao(authorities);
userDetails.setAuthorities(updateAuthorities);
//5.更新redis中的信息
Long userId = userDetails.getUser().getId();
redisCache.setCacheObject("curUser:"+userId, userDetails);
//6.生成新的权限信息
Authentication newAuth = new UsernamePasswordAuthenticationToken(userDetails,authentication.getCredentials(),userDetails.getAuthorities());
//7.存入新的权限信息
SecurityContextHolder.getContext().setAuthentication(newAuth);
return new ResponseResult(200,"ok");
}catch (Exception e){
return new ResponseResult(500,"更新权限失败");
}
}
这个时候更新了该用户的权限信息,当其发送请求时,从redis中获取的就是更新过的userDetails和权限
Spring Security在认证和授权的过程中产生的异常信息是不会被Spring MVC的全局异常机制处理,Spring Security中有一个专门处理认证和授权时异常的过滤器 : ExceptionTranslationFilter
ExceptionTranslationFilter捕获,它会判断是认证失败和授权失败
具体操作
①实现接口
import com.alibaba.fastjson.JSON;
import com.huier.security.pojo.common.ResponseResult;
import com.huier.security.utils.WebUtil;
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 {
//给前端ResponseResult 的json
ResponseResult responseResult = new ResponseResult(HttpStatus.UNAUTHORIZED.value(), "登陆认证失败了,请重新登陆!");
String json = JSON.toJSONString(responseResult);
WebUtil.renderString(response,json);
}
}
import com.alibaba.fastjson.JSON;
import com.huier.security.pojo.common.ResponseResult;
import com.huier.security.utils.WebUtil;
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 {
//给前端ResponseResult 的json
ResponseResult responseResult = new ResponseResult(HttpStatus.FORBIDDEN.value(), "您权限不足!");
String json = JSON.toJSONString(responseResult);
WebUtil.renderString(response,json);
}
}
②配置信息
@Autowired
AuthenticationEntryPointImpl authenticationEntryPoint;
@Autowired
AccessDeniedHandlerImpl accessDeniedHandler;
http.exceptionHandling().authenticationEntryPoint(authenticationEntryPoint)
.accessDeniedHandler(accessDeniedHandler);
出于浏览器的同源策略限制。同源策略(Sameoriginpolicy)是一种约定,它是浏览器最核心也最基本的安全功能,如果缺少了同源策略,则浏览器的正常功能可能都会受到影响。可以说Web是构建在同源策略基础之上的,浏览器只是针对同源策略的一种实现。同源策略会阻止一个域的javascript脚本和另外一个域的内容进行交互。所谓同源(即指在同一个域)就是两个页面具有相同的协议(protocol),主机(host)和端口号(port)
跨域问题:浏览器的同源策略限制。会报错。
如果跨域调用,会出现如下错误:
No ‘Access-Control-Allow-Origin’ header is present on the requested resource. Origin ‘http://localhost:9100’ is therefore not allowed access. The response had HTTP status code 400.
由于我们采用的是前后端分离的编程方式,前端和后端必定存在跨域问题。解决跨域问题可以采用CORS
CORS简介
CORS:跨域资源共享
条件:IE10以上
本质:请求头增加一个参数,开启跨域请求。
CORS 是一个 W3C 标准,全称是"跨域资源共享"(Cross-origin resource sharing)。CORS需要浏览器和服务器同时支持。目前,所有浏览器都支持该功能,IE 浏览器不能低于 IE10。它允许浏览器向跨源服务器,发出 XMLHttpRequest 请求,从而克服了 AJAX 只能同源使用的限制。整个 CORS 通信过程,都是浏览器自动完成,不需要用户参与。对于开发者来说,CORS 通信与同源的 AJAX 通信没有差别,代码完全一样。浏览器一旦发现 AJAX 请求跨源,就会自动添加一些附加的头信息,有时还会多出一次附加的请求,但用户不会有感觉。因此,实现 CORS 通信的关键是服务器。只要服务器实现了 CORS 接口,就可以跨源通信。
在SpringBoot集成SpringSecurity中,前后端分离项目,会存在跨域问题
SpringBoot配置跨域
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@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的跨域访问
//允许跨域
http.cors();