先讲讲传统的登录方式
用cookie作为媒介存放用户凭证。
用户登录系统之后,会返回一个加密的cookie,当用户访问子应用的时候会带上这个cookie,授权以解密cookie并进行校验,校验通过后即可登录当前用户。
缺点:
Cookie不安全,Cookie是存到客户端的,攻击者可以伪造Cookie伪造成特定用户。
最经典的一种
因为HTTP协议是一种无状态的协议,这就意味着,某个用户登录之后,下一次他再请求时,用户还得登录,因为客户端和服务器是多对一的关系,所以我们不知道那些用户登录了,哪些没登录。
传统解决方式:
1. 用户第一次发送登录请求时,用Session存下来用户的信息,将SessionID用Cookie传给客户端。
2. 之后这个浏览器每次访问服务器的时候,看它的Cookie里面有没有SessionID,用SessionID找到对应的Session,找到了那就说明登录了,没找到就是没登录。
简单示例
SpringBoot项目中写一个简单的接口测试一下:
访问接口
可以在浏览器的Cookie中看到有JSESSIONID,就是SessionID
缺点
为了解决分布式系统中,多服务器是不共享session,传统的单机session不适用于分布式系统中,所以这里使用分布式session。
实现分布式session有四种方案:
具体请看:分布式session解决方案_半格咖啡的博客-CSDN博客
流程:
(1) 用户第一次登录时,将会话信息(用户Id和用户信息),比如以用户Id为Key,写入分布式Session;
(2) 用户再次登录时,获取分布式Session,是否有会话信息,如果没有则调到登录页;
(3) 一般采用Cache中间件实现,建议使用Redis,因此它有持久化功能,方便分布式Session宕机后,可以从持久化存储中加载会话信息;
(4) 存入会话时,可以设置会话保持的时间,比如15分钟,超过后自动超时;
缺点
(1)服务器压力增大:通常session是存储在内存中的,每个用户通过认证之后都会将session数据保存在服务器的内存中,而当用户量增大时,服务器的压力增大。(可以将数据保存在磁盘中)
(2)扩展性不强:如果将来搭建了多个服务器,虽然每个服务器都执行的是同样的业务逻辑,但是session数据是保存在内存中的(不是共享的),用户第一次访问的是服务器1,当用户再次请求时可能访问的是另外一台服务器2,服务器2获取不到session信息,就判定用户没有登陆过。(可以使用分布式session将session在各个集群中保持一致)
(3)CSRF跨站伪造请求攻击:session是基于cookie进行用户识别的, cookie如果被截获,用户就会很容易受到跨站请求伪造的攻击。
就是上面所说的 统一在数据库存储的方法。
最终实现的效果
这种方式实现了:允许在分布式环境中实现线程隔离的单点登录,确保用户在不同的请求之间共享登录状态,并使用Redis作为分布式会话存储来保持会话一致性。
其实就是解决了两个问题
解释
在分布式的环境下,因为Session是存到服务器上面的,使用session会出现Session不共享数据的问题。那么可以将共享数据存入数据库中,然后应用服务器就可以去数据库获取共享数据。
对于每一次请求,可以在一开始从数据库里取到数据,然后将其临时存放在本地的内存里。
考虑到线程安全的问题,所以使用threadlocal进行线程隔离,这样在本次请求的过程中,就可以随时获取到这份共享数据了。
所以,session的替代方案是数据库,ThreadLocal在这里起辅助的作用。
数据库不建议用mysql,访问慢,用Redis的话访问快。
具体实现流程:
登录凭证类:
//登录凭证表
@Data
@ApiModel("登录凭证类")
public class LoginTicket {
private int id;
private int userId;
//登录凭证字符串
private String ticket;
private int status;
private Date expired;
}
SpringMVC拦截器使用介绍:
1.创建拦截器类
创建一个DemoInterceptor类实现HandlerInterceptor接口。
重写preHandle(),postHandle(),afterCompletion() 三个方法,如下代码,我们就创建了一个Spring的拦截器。
2.编写拦截器配置类
选择要拦截哪些请求,放行那些资源
@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new DemoInterceptor())
//放行静态资源
.excludePathPatterns("/**/*.css","/**/*.js","/**/*.png","/**/*.jpg","/**/*.jpeg")
//拦截 注册 登录请求
.addPathPatterns("/register","/login");
}
}
@Component
public class LoginTicketInterceptor implements HandlerInterceptor {
@Autowired
private UserService userService;
@Autowired
private HostHolder hostHolder;
// 保存登录信息
// 调用时间:Controller方法处理之前
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
//从cookie中获取凭证
String ticket = CookieUtil.getValue(request, "ticket");
if (ticket != null){
//查询凭证
LoginTicket loginTicket = userService.findLoginTicket(ticket);
//检查凭证是否有效
if (loginTicket != null && loginTicket.getStatus() == 0 && loginTicket.getExpired().after(new Date())){
//根据凭证查询用户
User user = userService.findByUserId(loginTicket.getUserId());
//在本次请求中持有用户
hostHolder.setUser(user);
//构建用户认证的结果,并存入SecurityContext,以便于Security进行授权
Authentication authentication = new UsernamePasswordAuthenticationToken(
user, user.getPassword(), userService.getAuthorities(user.getId()));
SecurityContextHolder.setContext(new SecurityContextImpl(authentication));
}
}
return true;
}
// 调用时间:Controller方法处理完之后
@Override
public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
//得到当前线程持有的user
User user = hostHolder.getUser();
if (user != null && modelAndView != null){
modelAndView.addObject("loginUser", user);
}
}
// 调用时间:DispatcherServlet进行视图的渲染之后
// 请求结束,把保存的用户信息清除掉
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
hostHolder.clear();
SecurityContextHolder.clearContext();
}
}
他是什么?
解决并发带来的问题,有助于确保每个用户的登录状态在不同的线程中得到正确维护和隔离,防止了混淆、安全问题、并发问题和会话管理问题,从而提高了系统的可靠性和安全性。
为了防止多进程对用户信息修改造成的数据不一致,因此需要保证每个请求(线程)访问自己的资源(用户信息)。隔离起来,就不会发送资源错误的问题了。
所以在后续请求中,这个线程一直是存活的,ThreadLocal里面的数据也是一直在的。
当请求处理完,服务器向浏览器做出响应之后,这个线程就被销毁了,我们会把保存的用户信息清除掉,防止内存泄漏。
/**
*持有用户信息,用于代替session对象
*/
@Component
public class HostHolder {
//ThreadLocal本质是以线程为key存储元素
private ThreadLocal users = new ThreadLocal<>();
public void setUser(User user){
users.set(user);
}
public User getUser(){
return users.get();
}
public void clear(){
users.remove();
}
}
登录功能的实现可以有多种方式,具体取决于开发人员的需求和技术选择。有传统的Session会话机制的,也有JWT的。
JSON Web Token 是个令牌,就是通过Json形式作为web应用中的令牌,用于在各方之间安全的将信息作为JSON对象传输。
JSON Web Token是在各方之间安全地传输信息的好方法。
因为可以对JWT进行签名(例如,使用公钥/私钥对),所以您可以确保发件人是他们所说的人。此外,由于签名是使用标头和有效负载计算的,因此您还可以验证内容是否遭到篡改。
JWT是存储在客户端的。
token string ====> header.payload.singnature token
JWT令牌组成
{
"alg": "HS256",
"typ": "JWT"
}
{
"sub": "1234567890",
"name": "John Doe",
"admin": true
}
前面两部分都是使用 Base64 进行编码的,即前端可以解开知道里面的信息。
Signature 需要使用编码后的 header 和 payload以及我们提供的一个密钥
然后使用 header 中指定的签名算法(HS256)进行签名。
也就是:3.Signature = 编码后的1.Header + 编码后的2.Payload + 密钥
签名的作用是保证 JWT 没有被篡改过
例如
HMACSHA256(base64UrlEncode(header) + "." + base64UrlEncode(payload),secret);
最后一步签名的过程,实际上是对头部以及负载内容进行签名,防止内容被窜改。
如果有人对头部以及负载的内容解码之后进行修改,再进行编码,最后加上之前的签名组合形成新的JWT的话,那么服务器端会判断出新的头部和负载形成的签名和JWT附带上的签名是不一样的。
如果要对新的头部和负载进行签名,在不知道服务器加密时用的密钥的话,得出来的签名也是不一样的。
通常,密钥存储在服务器的配置文件或环境变量中,以确保只有授权的人员可以访问它。
密钥的安全性非常重要,因为如果密钥泄漏,攻击者可能会伪造有效的JWT令牌来访问受保护的资源。
不同的请求,服务器的密钥通常是相同的。
密钥管理的方式: