登录方式汇总
先讲讲传统的登录方式
1.Cookie方案
用cookie作为媒介存放用户凭证。
用户登录系统之后,会返回一个加密的cookie,当用户访问子应用的时候会带上这个cookie,授权以解密cookie并进行校验,校验通过后即可登录当前用户。
缺点:
Cookie不安全,Cookie是存到客户端的,攻击者可以伪造Cookie伪造成特定用户。
2.传统Session方案
最经典的一种
因为HTTP协议是一种无状态的协议,这就意味着,某个用户登录之后,下一次他再请求时,用户还得登录,因为客户端和服务器是多对一的关系,所以我们不知道那些用户登录了,哪些没登录。
传统解决方式:
1. 用户第一次发送登录请求时,用Session存下来用户的信息,将SessionID用Cookie传给客户端。
2. 之后这个浏览器每次访问服务器的时候,看它的Cookie里面有没有SessionID,用SessionID找到对应的Session,找到了那就说明登录了,没找到就是没登录。
简单示例
SpringBoot项目中写一个简单的接口测试一下:
访问接口
可以在浏览器的Cookie中看到有JSESSIONID,就是SessionID
缺点
- 用户多了之后,Session全部存到服务器中,服务器开销大。
- 分布式的应用中,有多台服务器,那么Session是存在单个服务器上的,不共享Session,如果用户在服务器1上登录之后,下次的请求跳转到服务器2上了,就需要再次登录。
3.分布式Session方案
为了解决分布式系统中,多服务器是不共享session,传统的单机session不适用于分布式系统中,所以这里使用分布式session。
实现分布式session有四种方案:
- 数据库统一存储
- session复制
- 客户端存储
- HASH一致性
具体请看:分布式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如果被截获,用户就会很容易受到跨站请求伪造的攻击。
ThreadLocal + 拦截器 + Redis 实现登录鉴权
就是上面所说的 统一在数据库存储的方法。
最终实现的效果
这种方式实现了:允许在分布式环境中实现线程隔离的单点登录,确保用户在不同的请求之间共享登录状态,并使用Redis作为分布式会话存储来保持会话一致性。
其实就是解决了两个问题
- 分布式环境中Session不共享的问题。
- 多线程环境下并发导致不安全的问题。
解释
在分布式的环境下,因为Session是存到服务器上面的,使用session会出现Session不共享数据的问题。那么可以将共享数据存入数据库中,然后应用服务器就可以去数据库获取共享数据。
对于每一次请求,可以在一开始从数据库里取到数据,然后将其临时存放在本地的内存里。
考虑到线程安全的问题,所以使用threadlocal进行线程隔离,这样在本次请求的过程中,就可以随时获取到这份共享数据了。
所以,session的替代方案是数据库,ThreadLocal在这里起辅助的作用。
数据库不建议用mysql,访问慢,用Redis的话访问快。
具体实现流程:
- 在登录业务代码中,当用户登录成功时,生成一个登录凭证存储到redis中,将凭证中的字符串保存在cookie中返回给客户端。
- 以后每次的请求,使用一个拦截器拦截请求,从cookie中获取凭证字符串。
- 将字符串与redis中的凭证进行匹配,获取用户信息,将用户信息存储到ThreadLocal中,在本次请求中持有用户信息,即可在后续操作中使用到用户信息。
登录凭证类:
//登录凭证表
@Data
@ApiModel("登录凭证类")
public class LoginTicket {
private int id;
private int userId;
//登录凭证字符串
private String ticket;
private int status;
private Date expired;
}
拦截器
- 拦截每一次请求,从request中获取cookies。
- 从cookies里面找到 凭证字符串与redis中的凭证进行匹配,获取用户信息。
- 将用户信息存储到ThreadLocal中,在本次请求中持有用户信息,即可在后续操作中使用到用户信息。
SpringMVC拦截器使用介绍:
1.创建拦截器类
创建一个DemoInterceptor类实现HandlerInterceptor接口。
重写preHandle(),postHandle(),afterCompletion() 三个方法,如下代码,我们就创建了一个Spring的拦截器。
- preHandle():在controller执行请求之前执行
- postHandle():在controller执行请求之后执行,在模板引擎(例如Thymeleaf)之前执行
- afterCompletion() :在模板引擎之后执行
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
他是什么?
- 可以理解成它是一个特殊的map,它的key就是线程本身,value就是你想存储的数据。
- ThreadLocal本质是以线程为key存储元素
- ThreadLocal可以把用户信息保存在线程中,用户的每一次请求,就是一个线程,保存了用户信息,方便我们在后续操作获取用户登录信息。
- 当请求结束,也就是用户点击了退出登录之后,我们会把保存的用户信息清除掉,防止内存泄漏。
为什么用这个ThreadLocal?
解决并发带来的问题,有助于确保每个用户的登录状态在不同的线程中得到正确维护和隔离,防止了混淆、安全问题、并发问题和会话管理问题,从而提高了系统的可靠性和安全性。
为了防止多进程对用户信息修改造成的数据不一致,因此需要保证每个请求(线程)访问自己的资源(用户信息)。隔离起来,就不会发送资源错误的问题了。
所以在后续请求中,这个线程一直是存活的,ThreadLocal里面的数据也是一直在的。
当请求处理完,服务器向浏览器做出响应之后,这个线程就被销毁了,我们会把保存的用户信息清除掉,防止内存泄漏。
/**
*持有用户信息,用于代替session对象
*/
@Component
public class HostHolder {
//ThreadLocal本质是以线程为key存储元素
private ThreadLocal<User> users = new ThreadLocal<>();
public void setUser(User user){
users.set(user);
}
public User getUser(){
return users.get();
}
public void clear(){
users.remove();
}
}
还有一种常用的:JWT认证
登录功能的实现可以有多种方式,具体取决于开发人员的需求和技术选择。有传统的Session会话机制的,也有JWT的。
JSON Web Token 是个令牌,就是通过Json形式作为web应用中的令牌,用于在各方之间安全的将信息作为JSON对象传输。
JSON Web Token是在各方之间安全地传输信息的好方法。
因为可以对JWT进行签名(例如,使用公钥/私钥对),所以您可以确保发件人是他们所说的人。此外,由于签名是使用标头和有效负载计算的,因此您还可以验证内容是否遭到篡改。
JWT是存储在客户端的。
流程
- 首先,前端通过Web表单将自己的用户名和密码发送到后端的接口。这一过程一般是一个HTTP POST请求。建议的方式是通过SSL加密的传输(https协议),从而避免敏感信息被嗅探。
- 后端核对用户名和密码成功后,将用户的id等其他信息作为JWT Payload(负载),将其与头部分别进行Base64编码拼接后签名
- 形成一个JWT(Token)。形成的JWT就是一个形同111.zzz.xxx的字符串。token head.payload.singurater
- 后端将JWT字符串作为登录成功的返回结果返回给前端。前端可以将返回的结果保存在localStorage或sessionStorage上,退出登录时前端删除保存的JWT即可。
- 前端在每次请求时将JWT放入HTTP Header中的Authorization位。(解决XSS和XSRF问题)HEADEF。
- 后端检查是否存在,如存在验证JWT的有效性。例如,检查签名是否正确;检查Token是否过期;检查Token的接收方是否是自己(可选)。
- 验证通过后后端使用JWT中包含的用户信息进行其他逻辑操作,返回相应结果。
优势
- 传输速度快:可以通过URL,POST参数或者在HTTP header发送,因为数据量小,传输速度快。
- 自包含:负载中包含了所有用户所需要的信息,避免了多次查询数据库。
- 跨语言:因为Token是以JSON加密的形式保存在客户端的,所以JWT是跨语言的,原则上任何web形式都支持。
- 支持分布式:因为是存储到客户端的,所以不需要在服务端保存会话信息。
结构
token string ====> header.payload.singnature token
JWT令牌组成
- 1.标头(Header)
- 2.有效负载(Payload)
- 3.签名(Signature)
- 因此,JWT通常如下所示:xxxxx.yyyyy.zzzzz Header.Payload.Signature
1.Header(标头)
- 标头通常由两部分组成:令牌的类型(即JWT)和所使用的签名算法,例如HMAC SHA256或RSA。它会使用 Base64 编码组成 JWT 结构的第一部分。
- 注意:Base64是一种编码,也就是说,它是可以被翻译回原来的样子来的。它并不是一种加密过程。
{
"alg": "HS256",
"typ": "JWT"
}
2.Payload(有效负载)
- 令牌的第二部分是有效负载,其中包含声明。声明是有关实体(通常是用户)和其他数据的声明。
- 同样的,它会使用Base64 编码组成 JWT 结构的第二部分
- 官方不建议放用户的敏感信息,放些用户ID、用户名什么的没事,不要放密码。不安全。
{
"sub": "1234567890",
"name": "John Doe",
"admin": true
}
3.Signature(签名)
前面两部分都是使用 Base64 进行编码的,即前端可以解开知道里面的信息。
Signature 需要使用编码后的 header 和 payload以及我们提供的一个密钥
然后使用 header 中指定的签名算法(HS256)进行签名。
也就是:3.Signature = 编码后的1.Header + 编码后的2.Payload + 密钥
签名的作用是保证 JWT 没有被篡改过
例如
HMACSHA256(base64UrlEncode(header) + "." + base64UrlEncode(payload),secret);
最后一步签名的过程,实际上是对头部以及负载内容进行签名,防止内容被窜改。
如果有人对头部以及负载的内容解码之后进行修改,再进行编码,最后加上之前的签名组合形成新的JWT的话,那么服务器端会判断出新的头部和负载形成的签名和JWT附带上的签名是不一样的。
如果要对新的头部和负载进行签名,在不知道服务器加密时用的密钥的话,得出来的签名也是不一样的。
关于密钥
通常,密钥存储在服务器的配置文件或环境变量中,以确保只有授权的人员可以访问它。
密钥的安全性非常重要,因为如果密钥泄漏,攻击者可能会伪造有效的JWT令牌来访问受保护的资源。
不同的请求,服务器的密钥通常是相同的。
密钥管理的方式:
- 储在安全的配置文件或环境变量中:密钥应该存储在服务器上的安全位置,而不是硬编码在代码中,以避免泄漏。
- 定期轮换密钥:定期更改密钥以减小泄漏的风险。如果密钥不再安全,及时进行轮换。
- 限制访问:确保只有授权的人员可以访问密钥存储。使用访问控制和权限管理来限制对密钥的访问。