登录校验
问题分析
基础的登录功能:
接受前端请求传递的用户名和密码,然后再根据用户名和密码查询用户信息,如果用户信息存在,则说明用户输入的用户名和密码正确。如果查询的用户不存在,则说明输入的用户名和密码错误。
但是当在页面上输入网络地址时,没有登录仍然可以进入到后端管理系统页面
真正的登录功能:
登录之后才能访问后端系统页面,不登陆则跳转登录页面进行登录。
为什么出现这个问题?
原因很简单,就是因为针对当前开发的相关接口来说,我们在服务器端并没有进行任何的判断,没有去判断用户是否登录了。所以无论用户是否登录,都可以访问后端的相关数据。如果想要解决这个问题,需要完成一部非常重要的操作:登录校验
介绍
所谓的登录校验:指的是在服务器端接收到浏览器发送过来的请求之后,首先要对请求进行校验,先检验一下用户登录了没有,如果用户登录了,就直接执行对应的业务操作;如果用户没有登录,此时就不允许他执行相关的业务操作,直接给前端响应一个错误的结果,最终跳转到登录页面,要求他登录成功之后,再来访问对应的数据。
思路
无状态:指的是每一次请求都是独立的,下一次请求并不会携带上一次请求的数据。而浏览器与服务器之间进行交互,基于HTTP协议也就意味着通过浏览器来访问登录接口,实现登陆的操作后,我们在执行其他业务操作时,服务器并不知道这个员工登录了没有。因为HTTP协议是无状态的,两次请求之间是独立的,所以无法判断这个员工到底登录了没有。
具体实现思路:
- 在员工登录成功后,需要将用户登录成功的信息存起来,记录用户已经登录成功的标记。
- 在浏览器发起请求时,需要在服务端进行统一拦截,拦截后进行登录校验。
想要判断员工是否已经登录,需要在员工登录成功之后,存储一个登录成功的标记,接下来在每一个接口方法执行之前,先做一个条件判断,判断这个员工到底登录了没有。如果是登录了,就可以执行正常的业务操作,如果没有登录,会直接给前端返回一个错误信息,前端拿着这个错误信息之后会自动的跳转到登录页面。
为了简化这块操作,可以使用一种技术:统一拦截技术
通过同意拦截技术,可以拦截浏览器发送过来的所有请求,拦截到这个请求之后,就可以通过请求来获取之前所存入的登陆标记,在获取到登录标记且标记为登录成功,就说明员工已经登录了,如果已经登录,就直接放行(访问正常的业务接口)。
要完成以上操作,会会涉及到web开发中的两个技术:
- 会话技术
- 统一拦截技术
统一拦截技术实现方案也有两种:
- Servlet规范中的Filter过滤器
- Spring提供的interception拦截器
会话技术
介绍
什么是会话
- 在日常生活中,会话就是谈话,交谈
- 在web开发当中,会话指的是浏览器与服务器之间的一次连接
在用户打开浏览器第一次访问服务器时,这个会话就建立了,直到任何一方断开连接,此时会话就结束了。在一次会话中,是可以包含多次请求和响应的。
如:打开了浏览器来访问web服务器上的资源(浏览器不能关闭、服务器不能断开)
- 第一次:访问的是登录的接口,完成登录的操作
- 第二次:访问的是部门管理接口,查询所有部门数据
- 第三次:访问的是员工管理接口,查询员工数据
只要浏览器和服务器都没有关闭,以上3次请求都属于一次会话中完成的。
注意:会话是和浏览器关联的,当有三个浏览器客户和服务器建立了连接时,就会有三个会话。同一个浏览器在未关闭之前请求了多次服务器,这多次请求是属于同一个会话。比如:1,2,3这三个请求都是属于同于个会话。当我们关闭浏览器之后,这次会话就结束了,而如果直接把web服务器关了,那么所有的会话就都结束了。
会话跟踪
会话跟踪:一种维护浏览器状态的方法,服务器需要识别多次请求是否来自于同一个浏览器,以便同一次会话的多次请求间共享数据。
服务器会接收很多的请求,但是服务器是需要识别出这些请求不是同一个浏览器发出来的。比如:1和2这两个请求是同一个浏览器发出来的,3和5这两个请求不是同一个浏览器发出来的。如果是同一个浏览器发出的,就说明是同一个会话。如果是不同的浏览器发出来的,就说明是不同的会话。而识别多次请求是否来自同一浏览器的过程,就称为会话跟踪。
使用会话技术就是要完成在一个会话中,多个请求之间进行共享数据。
为什么要共享数据?
由于HTTP是无状态协议,在后面的请求中怎么拿到前一次请求生成的数据呢?此时就需要在一次会话的多次请求之间进行数据共享
会话跟踪技术有:
- Cookie(客户端会话跟踪技术)
- 数据存储在客户端浏览器当中
- Session(服务端会话跟踪技术)
- 数据存储在服务端
- 令牌技术
会话跟踪方案
方案一:Cookie
cookie是客户端会话跟踪技术,它是存储在客户端浏览器的,如果使用cookie来跟踪会话,可以在浏览器第一次发起请求来请求服务器的时候,在服务端设置一个cookie。
服务器端在给客户端响应数据的时候,会自动的将cookie响应给浏览器,浏览器接收到响应回来的cookie之后,会自动的将cookie的值存储在浏览器本地。接下来每一次请求当中,都会将浏览器本地所存储的cookie自动的携带到服务端。
接下来在服务端可以获取到cookie的值。然后判断这个cookie的值是否存在,如果不存在,就说明客户端之前是没有访问登录接口的;如果存在,就说明客户端之前已经登录完成了。这样就可以基于cookie在同一次会话的不同请求之间共享数据。
为什么这一切都是自动化进行的?
因为cookie是HTTP协议当中所支持的技术,而且各大浏览器厂都支持了这一标准。HTTP协议官方提供了一个响应头和请求头:
- 响应头Set-Cookie:设置Cookie数据的
- 请求头Cookie:携带Cookie数据的。
代码测试:
@Slf4j
@RestController
public class SessionController {//设置Cookie@GetMapping("/c1")public Result cookie1(HttpServletResponse response){response.addCookie(new Cookie("login_username","itheima"));//设置Cookie/响应Cookiereturn Result.success();}//获取Cookie@GetMapping("/c2")public Result cookie2(HttpServletRequest request){Cookie[] cookies = request.getCookies();for (Cookie cookie : cookies) {if(cookie.getName().equals("login_username")){System.out.println("login_username: "+cookie.getValue()); //输出name为login_username的cookie}}return Result.success();}
}
结果展示:
优缺点:
- 优点:HTTP协议中支持的技术(像Set-Cookie响应头的解析以及Cookie请求头数据的携带,都是浏览器自动进行的,是无需手动操作的)
- 缺点:
- 移动端APP(Android、IOS)中无法使用Cookie
- 不安全,用户可以自己禁用Cookie
- Cookie不能跨域
区分跨域的维度:
- 协议
- IP/协议
- 端口
只要上述的三个维度有任何一个维度不同,那就是跨域操作
方案二:Session
Session是服务端会话跟踪技术,所以他存储在服务器端。而Seesion的底层其实就是基于Cookie来实现的。
-
获取Session
如果现在要基于Session来进行会话跟踪,浏览器在第一次请求服务器的时候,可以直接在服务器当中获取会话对象Session。如果是第一次请求Session,会话对象是不存在的,这个时候服务器会自动创建一个会话对象Session,而每一个会话对象Session,都有一个ID(图中Session后面的括号中的1,就表示ID),称之为Session的ID。
-
响应Cookie(JSESSIONID)
接下来,服务器端在给浏览器响应数据的时候,会将Session的ID通过Cookie响应给浏览器。其实在响应头当中增加了一个Set-Cookie响应头。cookie的名字是固定的,JSESSIONID代表的服务器端对话对象Session的ID。浏览器会自动识别这个响应头,然后自动将Cookie存储在浏览器本地。
-
查找Sesion
接下来,在后续的每一次请求当中,都会将Cookie的数据获取出来,并且携带到服务器端。然后服务器拿到JSESSIONID这个cookie的值,也就是Session的ID。拿到ID之后,就会从众多的Session中找到当前请求对应的会话对象Session。
代码测试:
@Slf4j
@RestController
public class SessionController {@GetMapping("/s1")public Result session1(HttpSession session){log.info("HttpSession-s1: {}", session.hashCode());session.setAttribute("loginUser", "tom"); //往session中存储数据return Result.success();}@GetMapping("/s2")public Result session2(HttpServletRequest request){HttpSession session = request.getSession();log.info("HttpSession-s2: {}", session.hashCode());Object loginUser = session.getAttribute("loginUser"); //从session中获取数据log.info("loginUser: {}", loginUser);return Result.success(loginUser);}
}
访问s1接口:
请求完成之后,在响应头中,就会看到有个Set-Cookie的响应头,里面响应回来一个Cookie,就是JSESSIONID,这个就是服务器端会话对象Session的ID。
访问s2接口:
接下来,在后续的每次请求时,都会Cookie的值,携带到服务端,服务端在接收到Cookie之后,会自动地根据JSESSIONID地值,找到对应地会话对象Session。
两次请求中,获取到的Session会话对象的hashCode是一样的,就说明是同一个会话对象。而且,第一次请求时,往Session会话对象中存储的值,在第二次请求时也获取到了。这样就可以通过Session会话对象,在同一个会话的多次请求之间来进行数据共享了。
优缺点:
- 优点:Session是存储在服务端的,安全。
- 缺点:
- 服务器集群环境下无法直接使用Session
- 移动端APP(Android、IOS)中无法使用Cookie
- 用户可以禁用Cookie
- Cookie不能跨域
注意:Session底层是基于Cookie实现的会话跟踪,如果Cookie不可用,在该方案,也就失效了。
服务器集群环境为何无法使用Session?
首先第一,我们现在开发的项目,一般不会只部署在一台服务器上,因为一台服务器会存在一个很大的问题,就是单点故障。所谓单点故障,指的就是一旦这台服务器挂了,整个应用都没法访问了。
所以在现在的企业项目开发当中,最终部署的时候是以集群的形式部署,也就是同一个项目它会部署多份。
而用户在访问的时候,到底访问这三台其中的那一台?其实用户在访问的时候,他会访问一台前置的服务器,我们叫负载均衡服务器。作用:将前端发起的请求均匀的分发给后面的这三台服务器。
此时假如通过Session来进行会话跟踪,可能会存在这样一个问题。用户打开浏览器要进行登录操作,此时会发起登录请求。登录请求到达负载均衡服务器,将这个请求转发给了第一台Tomcat服务器。
而Tomcat服务器收到请求之后,要获取到会话对象session。获取到会话对象session之后,要给浏览器响应数据,最终在给浏览器响应数据的时候,就会携带这么一个cookie的名字,就是JSESSIONID。
此时假如有执行了一次查询操作。这次请求到达负载均衡服务器之后,负载均衡服务器将这次请求转发给了第二台Tomcat服务器,此时它就要到第二台服务器当中。根据JSESSIONID找到对应的session会话对象。
在第二台服务器当中是没有这个ID的会话对象的。也就是同一个服务器发送了2次请求,结果获取到的不是同一个会话对象。这就是session会话跟踪方案的缺点,在服务器集群环境下无法直接使用Session。
方案三:令牌技术
这里提到的令牌本质上就是一个字符串。
如果通过令牌技术来跟踪会话,我们就可以在浏览器发起请求。在请求登录接口的时候,如果登录成功,就可以生成一个令牌,令牌就是用户的合法身份凭证。在响应数据的时候,可以直接将令牌响应给前端。
在前端程序当中接收到令牌之后,需要将这个令牌存储起来。这个存储可以存储在cookie当中,也可以在其他的存储空间(如:localStorage)当中。
在后续的每一次请求中,都需要将令牌携带到服务端之后,就需要检验令牌的有效性。如果令牌是有效的就说明用户已经执行了登录操作,如果令牌是无效的,就说明用户之前并未执行登录操作。
此时,如果在同一次会话的多次请求之间,想要共享数据,把需要功效的数据存储在令牌当中就可以了。
优缺点:
- 优点:
- 支持PC端、移动端
- 解决集群环境下的认证问题
- 减轻服务器的存储压力(无需在服务器端存储)、
- 缺点:需要自己实现(包括令牌的生成、令牌的传递、令牌的校验)
JWT令牌
介绍
JWT全称:JSON Web Token(官网:https://jwt.io/)
-
定义了一种简洁的、自包含的格式,用于在通信双方以json数据格式安全的传输信息。由于数字签名的存在,这些信息是可靠的。
简洁:是指jwt就是一个简单的字符串。可以在请求参数或者是请求头当中直接存储。
自包含:指的是jwt令牌,看似是一个随机的字符串,但是可以根据自身的需求在jwt令牌中存储自定义的数据内容。如:可以直接在jwt令牌中存储用户的相关信息。
简单来说:jwt就是将原始的json数据格式进行了安全的封装,这样可以直接基于jwt在通信双方安全的进行信息传输。
jwt的组成:(JWT令牌由三个部分组成,三个部分之间使用英文的点来分割)
- 第一部分:Header(头),记录令牌类型、签名算法等。如:{“alg”:“HS256”,“type”:“JWT”}
- 第二部分:Payload(有效载荷),携带一些自定义信息、默认信息等。如:{“id”:“1”,“username”:“Tom”}
- 第三部分:Signature(签名),防止Token被篡改、确保安全性。将header、payload,并加入指定私钥,通过指定签名算法进行计算而来。
签名的目的就是为了防止jwt令牌被篡改,而正是因为jwt令牌最后一个部分数字签名的存在,所以整个jwt令牌是非常安全可靠的。一旦jwt令牌当中任何以部分、任何一个字符被篡改了,整个令牌在校验的时候都会失败,所以它是非常安全可靠的。
JWT是如何将原始的JSON格式数据,转变为字符串的呢?
Base64:是一种基于64个可打印的字符来表示二进制数据的编码方式。既然能编码,那也就意味着也能解码。所使用的64个字符分别是A到Z、a到z、0-9,一个+,一个/,加起来就是64个字符。任何数据经过base64。任何数据经过base64编码之后,最终就会通过64个字符来表示。当然还有一个符号,那就是等号。等号是一个补位的符号。
注意:是base64是编码方式,不是加密方式。
JWT令牌最典型的应用场景就是登录认证:
- 在浏览器发起请求来执行登录操作,此时会访问登录的接口,如果登录成功之后,需要生成一个jwt令牌,将生成的jwt令牌返回给前端。
- 前端拿到jwt令牌之后,会将jwt令牌存储起来。在后续的每一次请求中都会将jwt令牌携带到服务器端。
- 服务端统一拦截请求之后,先来判断以下这个请求有没有把令牌带过来,如果没有带过来,直接拒绝访问,如果带过来了,还要检验一下令牌是否是有效。如果有效,就直接放行进行请求得处理。
在JWT登录认证的场景中发现,整个流程当中涉及到两步操作:
- 在登录成功之后,要生成令牌
- 每一次请求当中,要接收令牌并对令牌进行校验
生成和校验
首先需要引入依赖:
<!-- JWT依赖-->
<dependency><groupId>io.jsonwebtoken</groupId><artifactId>jjwt</artifactId><version>0.9.1</version>
</dependency>
生成JWT代码实现:
@Test
public void genJwt(){Map<String,Object> claims = new HashMap<>();claims.put("id",1);claims.put("username","Tom");String jwt = Jwts.builder().setClaims(claims) //自定义内容(载荷) .signWith(SignatureAlgorithm.HS256, "itheima") //签名算法.setExpiration(new Date(System.currentTimeMillis() +
24*3600*1000)) //有效期.compact();System.out.println(jwt);
}
运行测试方法:
eyJhbGciOiJIUzI1NiJ9.eyJpZCI6MSwiZXhwIjoxNjcyNzI5NzMwfQ.fHi0Ub8npbyt71UqLXDdLyipptLgxBUg_mSuGJtXtBk
输出的结果就是生成的JWT令牌,通过英文的点分割对三个部分进行分割,可以将生成的令牌在JWT官网进行解析。
校验JWT令牌(解析生成的令牌)
@Test
public void parseJwt(){Claims claims = Jwts.parser().setSigningKey("itheima")//指定签名密钥(必须保证和生成令牌时使用相同的签名密钥)
.parseClaimsJws("eyJhbGciOiJIUzI1NiJ9.eyJpZCI6MSwiZXhwIjoxNjcyNzI5NzMwfQ.fHi0Ub8npbyt71UqLXDdLyipptLgxBUg_mSuGJtXtBk").getBody();System.out.println(claims);
}
运行测试方法得到:
{id=1, exp=1672729730}
令牌解析后,可以看到id和过期时间,如果在解析的过程中没有报错,就说明解析成功。
在使用JWT令牌时需要注意:
- JWT校验时使用的签名私钥,必须和生成JWT令牌时使用的私钥是配套的。
- 如果JWT令牌解析校验时报错,则说明JWT令牌被篡改或失效了,令牌非法。
登录下发令牌
具体思路主要为:
- 生成令牌
- 在登录成功之后来生成一个JWT令牌,并且把这个令牌直接返回给前端。
- 校验令牌
- 拦截前端请求,从请求中获取到令牌,对令牌进行解析校验
JWT工具类
public class JwtUtils {private static String signKey = itheima";//签名密钥private static Long expire = 43200000L; //有效时间/*** 生成JWT令牌* @param claims JWT第二部分负载 payload 中存储的内容* @return*/public static String generateJwt(Map<String, Object> claims){String jwt = Jwts.builder().addClaims(claims)//自定义信息(有效载荷).signWith(SignatureAlgorithm.HS256, signKey)//签名算法(头部).setExpiration(new ate(System.currentTimeMillis() + expire))//过期时间.compact();return jwt;}/*** 解析JWT令牌* @param jwt JWT令牌* @return JWT第二部分负载 payload 中存储的内容*/public static Claims parseJWT(String jwt){Claims claims = Jwts.parser().setSigningKey(signKey)//指定签名密钥.parseClaimsJws(jwt)//指定令牌Token.getBody();return claims;}
}
登录成功,生成JWT令牌并返回
@RestController
@Slf4j
public class LoginController {//依赖业务层对象@Autowiredprivate EmpService empService;@PostMapping("/login")public Result login(@RequestBody Emp emp) {//调用业务层:登录功能Emp loginEmp = empService.login(emp);//判断:登录用户是否存在if(loginEmp !=null ){//自定义信息Map<String , Object> claims = new HashMap<>();claims.put("id", loginEmp.getId());claims.put("username",loginEmp.getUsername());claims.put("name",loginEmp.getName());//使用JWT工具类,生成身份令牌String token = JwtUtils.generateJwt(claims);return Result.success(token);}return Result.error("用户名或密码错误");}
}
服务器响应的JWT令牌存储在本地浏览器哪里?
-
在当前这个案例中,JWT令牌存储在浏览器的本地存储空间local storage中。local storage是浏览器的本地存储,在移动端也是支持的。
过滤器Filter
实现统一拦截到所有的请求检验令牌的有效性的方案:
- Filter过滤器
- Interceptor拦截器
快速入门
什么是Filter?
-
Filter表示过滤器,是JavaWeb三大组件(Servlet、Filter、Listener)之一
-
过滤器可以把对资源的请求拦截下来,从而实现一些特殊的功能
-
使用了过滤器之后,要想访问web服务器上的资源,必须先经过滤器,过滤器处理完毕之后,才可以访问对应的资源
-
过滤器一般完成一些通用的操作,比如:登录校验,统一编码处理、敏感字符处理等。
-
下面通过Filtert快速入门程序掌握过滤器的基本使用操作:
- 第一步,定义过滤器:定义一个类,实现Filter接口,并重写其所有方法
- 第二步,配置过滤器:Filter类上加@WebFilter注解,配置拦截资源的路径。引导类上加@ServletComponentScan开启Servlet组件支持
定义过滤器:
//定义一个类,实现一个标准的Filter过滤器的接口
public class DemoFilter implements Filter {@Override //初始化方法, 只调用一次public void init(FilterConfig filterConfig) throws ServletException {System.out.println("init 初始化方法执行了");}@Override //拦截到请求之后调用, 调用多次public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {System.out.println("Demo 拦截到了请求...放行前逻辑");//放行chain.doFilter(request,response);}@Override //销毁方法, 只调用一次public void destroy() {System.out.println("destroy 销毁方法执行了");}
}
- init方法:过滤器的初始方法。在web服务器启动时会自动地创建Filter过滤器对象,在创建过滤器对象地时候会自动调用init初始化方法,这个方法只会被调用一次
- doFilter方法:这个方法是在每一次拦截到请求之后都会被调用,所以这个方法是会被调用多次的,每拦截到一次请求就会调用一次doFilter()方法
- destory方法:是销毁的方法。当我们关闭服务器的时候,会自动调用销毁方法destory,而这个销毁方法也只会被调用一次
在调用完Filter之后,Filter其实并不会生效,还需要完成Filter的配置,Filter的配置非常简单,只需要在Filter类上添加一个注解:@WebFilter,并指定属性urlPatterns,通过这个属性指定过滤器要拦截哪些请求
@WebFilter(urlPatterns = "/*") //配置过滤器要拦截的请求路径( /* 表示拦截浏览器的所有请求 )
public class DemoFilter implements Filter {@Override //初始化方法, 只调用一次public void init(FilterConfig filterConfig) throws ServletException {System.out.println("init 初始化方法执行了");}@Override //拦截到请求之后调用, 调用多次public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {System.out.println("Demo 拦截到了请求...放行前逻辑");//放行chain.doFilter(request,response);}@Override //销毁方法, 只调用一次public void destroy() {System.out.println("destroy 销毁方法执行了");}
}
当在Filter类上加了@WebFilter注解之后,接下来还需要在启动类上加上一个注解,@ServletCompontScan,通过这个@ServletComponentScan注解开启SpringBoot项目对于Servlet组件的支持。
@ServletComponentScan
@SpringBootApplication
public class TliasWebManagementApplication {public static void main(String[] args) {SpringApplication.run(TliasWebManagementApplication.class, args);}
}
重新启动服务,打开浏览器,执行部门管理的请求,可以看到控制台输出了过滤器中的内容: