Web 开发中的登录校验解决方案及原理

前言

通常一个完整的系统,需要安全性的保证。如登录校验,登录成功后,才可以访问服务资源。在服务端渲染项目中,我们通常使用 session来进行登录校验。在前后端分离的场景中,很多时候会采用 token(凭证)的方案来进行登录校验。 本篇文章主要介绍 sessiontoken 两种登录校验的原理和实现,希望观众老爷们多多支持,请在评论区批评指正!

1. 为什么要进行登录校验?

首先看一下不做登录校验的后果:

Web 开发中的登录校验解决方案及原理

不进行登录校验的话,用户登录的账户只是用户的一个虚拟身份,用于记录信息。而无法判断用户的登录态,万一有人非法获取用户登录信息,那么他可以在另一台主机以该身份访问网站,对真正的用户产生不良影响。如果我们记住用户的登录态,或者保持一段时间内用户的登录,这就很好了。但是服务器无法掌握用户是否时刻在访问网站(不是监控)。由于 HTTP 协议的限制,服务器和浏览器端实际是在交换信息,如果想要时刻掌握用户的登录状态,那就需要保持永久的连接,相当于每刻都在登录,这是不可能的。

虽然我们使用一些方式可以保持一段时间的登录态,当超过这个时间用户就需要重新登录,以刷新凭证。这样做算是目前最好的方式了,依据这段时间的登录态,可以防止用户重复登录,当用户超过登录态时间还在登录使用网站,那么也可以刷新登录态时间。

下面介绍两种登录校验方案。

2. 两种登录校验的原理

2.1. Session 认证原理

「什么是会话?」

  • 用户登录认证通过后,为了避免用户的每次操作都进行认证,可将用户的信息保存到会话中。会话就是系统为了保持当前用户的登录状态所提供的机制。

「session认证流程」

Web 开发中的登录校验解决方案及原理

用户登录通过后,服务端将用户相关的数据保存到 session(当前会话)中,并将对应的 session_id 发给浏览器,浏览器将 session_id 保存到 cookie中;然后用户每次发送请求,浏览器会自动将session_idcookie中取出放到请求头中;服务器收到请求后,通过session_id 找到对应的 session 会话,然后获取 session 中的用户信息,来完成用户的合法校验。当用户退出系统或 session 过期销毁时,客户端的 session_id 也就无效了。

「注意」

  1. 浏览器保存 session_idcookie 中;浏览器从 cookie 中取出 session_id 放到请求头中,有浏览器来完成。
  2. 服务器获取 session_id 找到 session 会话对象,由服务器自己来完成,不需要我们写代码。

2.2. JWT + token 认证原理

Web 开发中的登录校验解决方案及原理

用户发起登录请求后,服务器进行验证。如果验证失败,则提示重新登录;如果验证成功,则结合用户信息并通过 JWT生成 token,并将 token 封装到响应体中,返回给前端;然后前端需要保存这个 token。每次发送其他请求时,将 token放到请求头中,服务器获取请求头中的 token,解析 token,验证是否正确,如果正确则允许访问资源,否则返回错误信息给前端。

2.3. Session 登录和 JWT 登录的异同

1、Session 登录方式,session_id 是自动保存到 Cookie 中的,且发送请求时,浏览器是自动附加上 cookie 信息的,但是服务器和前端必须处在必须同源,否则Cookie信息是不会自动提交给后端的。

2、Session 登录方式,服务器端缓存了登录用户的信息,而 JWT 方式,服务器端可以不存储用户信息,只需要一套验证 token 的策略即可。实际上 JWTtoken 本身存储了一些关键数据(比如用户名,过期时间),token 是根据密码学算法生成的,无法更改token 里面的内容。

3、Session 登录方式,缓存的过期时间是由服务器端设定的,而 JWT 方式token 信息中自带过期时间(服务器端生成 token 的时候就设定好了过期时间),过期之后 token 验证失败。

4、Session 登录方式,优点是不存在CORS 跨域问题,且服务器端处理登录过期很简单。但不支持跨域访问以及(比如 APP 登录)。

5、Session 登录方式,session_id是自动保存和发送的,而JWT 方案,前端需要写代码实现 token的保存,并需要在每次请求时将 token 放到请求头中,发送给服务器。

3. JWT 生成 token 配合拦截器-推荐

3.1. 创建登录接口,并生成 token

  1. pom.xml文件中引入 JWT 依赖。
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.1</version>
</dependency>

然后在项目工具包下创建 JwtUtil 工具类:

JwtUtil 工具类的主要作用是生成 token 字符串,解析 token ,生成 token 时可以指定 token 中存储的信息和过期时间;当你解析 token 时,如果是非法 token 则提醒对应的错误,如果 token 超时,也会提醒对应的错误,只有解析正确且未超时才能获取解析出来的信息。

public class JwtUtil {

//有效期为
public static final Long JWT_TTL = 60 * 60 *1000L;/* 60 * 60 *1000 一个小时 */
//设置秘钥明文
public static final String JWT_KEY = "yanghi";

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("yanghi") // 签发者
.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 {
Claims claims = parseJWT("eyJhbGciOiJIUzI1NiJ9.eyJqdGkiOiJjM2JlNzBhZWJkYzM0YmQ2OTQzMTc5MTY3MmEzYTUyNiIsInN1YiI6IjEyMzQiLCJpc3MiOiJ5YW5naGkiLCJpYXQiOjE2NTgxNDM5NzUsImV4cCI6MTY1ODE0NzU3NX0.Jop6qv_FEiD96ZI1EAHToMG_ttJ7p1sbqhpSkk5PkMM");
System.out.println(claims.toString());
}

/**
* 生成加密后的秘钥 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) throws Exception {
SecretKey secretKey = generalKey();
return Jwts.parser()
.setSigningKey(secretKey)
.parseClaimsJws(jwt)
.getBody();
}

}
  1. 创建实体类 User
@Data
@NoArgsConstructor
@AllArgsConstructor
public class User{

private int id;

private String name;

private String password;

private int age;

private char sex;

}
  1. Mapper 层创建 UserMapper 并创建 getUserOneByUsernameAndPassword 方法。
@Mapper
public interface UserMapper {

/**
* 通过用户名和密码查询User表中是否存在该用户
* @param username 用户名
* @param password 密码
* @return User
*/

User getUserOneByUsernameAndPassword(String username, String password);

}
  1. 创建 UserMapper 对应的映射 xml 文件,并书写 SQL 语句,根据用户名和密码查询用户信息。
<select id="getUserOneByUsernameAndPassword" resultType="User">
select id, name, password, age, sex from user where name=#{username} and password=#{password};
</select>
  1. 创建 Service 层,然后创建 UserService 接口,并在接口书写抽象方法 User login(User user) 用于登录的逻辑处理,再创建 UserServiceImpl 类实现 UserService 接口。
@Service
public class UserServiceImpl implements UserService {

@Autowired
private UserMapper userMapper;

@Override
public User login(User user) {
return userMapper.getUserOneByUsernameAndPassword(user.getName(), user.getPassword());
}

}
  1. UserController 类中,创建 login 方法,接收 Post 请求的参数。
@RestController
@RequestMapping("/sys_user")
public class UserController {

@Resource
private UserService userService;

@PostMapping("/login")
public ResponseResult login(User user){
if(Objects.isNull(user.getName()) || Objects.isNull(user.getPassword())){
return ResponseResult.setCommonStatusNoData(ResultCode.PARAM_IS_BLANK);
}
User login = userService.login(user);
if(!Objects.isNull(login)){
// token 根据用户id生成Token,最后一个参数为null,使用默认有效期
String token = JwtUtil.createJWT(UUID.randomUUID().toString(), String.valueOf(login.getId()), null);
Map<String, Object> objectObjectHashMap = new HashMap<>();
objectObjectHashMap.put("token", token);
return ResponseResult.setCommonStatusAndData(ResultCode.SUCCESS, objectObjectHashMap);
}
return ResponseResult.setCommonStatusNoData(ResultCode.USER_LOGIN_ERROR);
}


}
  1. 测试登录接口,发送 POST 请求,并在响应体中设置表单参数,用户名密码正确,则返回成功后的 token
Web 开发中的登录校验解决方案及原理

3.2. 通过拦截器统一拦截请求校验 token

我们在上一步生成了 token ,难道就为了生成 token 就完了吗?当然不是,当前端请求我们的其他接口的时候,我们需要验证 token 是否正确且不超时;如果满足我们就让其他接口进行处理,如果不满足则让用户重新登录。但是为什么要使用拦截器统一拦截请求进行处理呢?因为如果我们在每一个接口方法中,都去验证 token,这是非常无聊的。而拦截器会拦截请求,也就是在请求之前进行一些处理,用来做 token 校验最为合适。

「注意:」 前端发送请求时,需要将获取的 token 封装到请求头中。

「那么 SpringBoot 如何创建并配置拦截器呢?」

  1. 创建类实现 HandlerInterceptor 接口
public class TokenCheckingInterceptor implements HandlerInterceptor {

// 在目标方法执行之前进行拦截处理 返回值为true 放行请求,返回false 拦截请求
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
return HandlerInterceptor.super.preHandle(request, response, handler);
}

// 在目标方法执行之后进行处理
@Override
public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
HandlerInterceptor.super.postHandle(request, response, handler, modelAndView);
}

// 在目标方法完成之后进行处理
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
HandlerInterceptor.super.afterCompletion(request, response, handler, ex);
}

}
  1. 配置拦截器:创建一个类继承 WebMvcConfigurer,并在类上标注 @Configuration 注解,并实现 addInterceptors 方法,用于向 spring 容器中注入我们的拦截器即可。
@Configuration
public class MyMvcConfiguration implements WebMvcConfigurer{

@Autowired
TokenCheckingInterceptor tokenCheckingInterceptor;

@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(tokenCheckingInterceptor)
.addPathPatterns("/**")
.excludePathPatterns("/sys_user/login");
}
}

「我们需要做什么?」

由于是在请求方法之前对 token 进行校验,所以我们只实现拦截器的 preHandle 的方法即可;在该方法中书写校验 token 的代码,如果校验失败,拦截请求,否则放行,允许请求。

	@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 1.获取请求头中的 token
String token = request.getHeader("token");
// 2. 判断token是否为空
if(!StringUtils.hasText(token)){
response.sendError(HttpServletResponse.SC_UNAUTHORIZED); // 401 未认证
return false;
}
// 3. 校验 token
try{

Claims claims = JwtUtil.parseJWT(token);
}catch(Exception e){
// 4. 解析失败,或者超时,则出现异常,向前端返回401 未认证,需重新登录
response.sendError(HttpServletResponse.SC_UNAUTHORIZED); // 401 未认证
return false;
}

return true;
}

然后配置拦截器

@Configuration
public class MyMvcConfiguration implements WebMvcConfigurer{

@Autowired
TokenCheckingInterceptor tokenCheckingInterceptor;

@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(tokenCheckingInterceptor) //注入我们的自定义拦截器
.addPathPatterns("/**") // 配置拦截路径 /** 所有路径 多参数
.excludePathPatterns("/sys_user/login"); // 配置放行路径 如登录接口,静态资源 多参数
}

}

「测试」

重启项目,首先进行登录后,拿到 token

Web 开发中的登录校验解决方案及原理

然后试着去访问其他接口,不把 token 放入请求头时,就返回了 401。

Web 开发中的登录校验解决方案及原理

将我们登录后的 token 放入到请求头中时:就返回了正确的结果

Web 开发中的登录校验解决方案及原理当我们胡乱填写一个 token,就又返回 401 了。

Web 开发中的登录校验解决方案及原理

只有当 token 是正确的才能进行请求,也就是访问服务器的资源。

使用 token 作为前后端分离项目中浏览器与服务器的凭证,使服务器资源的安全得到了保证。以上的操作与服务端渲染项目中,通过 session 校验用户是否登录,然后允许用户是否可以访问其他接口的思路是一致的。

不是说前后端项目必须使用 token 验证的方式,而是需要在适当的场景进行选用恰当的方式

4. Session +拦截器实现登录校验

SessionJWT + session 的流程类似,我们只需要修改上述代码中的登录接口,和拦截器即可。

  1. 修改登录接口
Web 开发中的登录校验解决方案及原理
@RestController
@RequestMapping("/sys_user")
public class UserController {

@Resource
private UserService userService;

@PostMapping("/login")
public ResponseResult login(User user, HttpSession httpSession){
if(Objects.isNull(user.getName()) || Objects.isNull(user.getPassword())){
return ResponseResult.setCommonStatusNoData(ResultCode.PARAM_IS_BLANK);
}
User login = userService.login(user);
if(!Objects.isNull(login)){
// 设置过期时间为 3600s
httpSession.setMaxInactiveInterval(3600);
httpSession.setAttribute("loginUser", login);
return ResponseResult.setCommonStatusNoData(ResultCode.SUCCESS);
}
return ResponseResult.setCommonStatusNoData(ResultCode.USER_LOGIN_ERROR);
}

}
  1. 修改拦截器验证代码
Web 开发中的登录校验解决方案及原理
@Component
public class LoginCheckingInterceptor implements HandlerInterceptor {

@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 1. 获取session对象中的 loginUser
HttpSession session = request.getSession();
User loginUser = (User) session.getAttribute("loginUser");
// 2. 判断loginUser是否为空,为空抛出异常,否则放行
if(Objects.isNull(loginUser)){
throw new RuntimeException("未登录,请重新登录");
}
return true;
}

}

5. 一个问题

那么问题来了:我们为什么不使用 session 而大费周章的使用 jwt+token 的方案呢?

使用 session 可以达到与 jwt+token一样的效果。为什么不用呢。

因为我们上述的演练中建议在只有一个服务器下,而在现实中,一个庞大的应用,往往不是一个服务器。当与用户访问服务器时,虽然会生成 sesion_id ,当用户再次访问服务器时,可以通过 sesion_id 进行校验,但是如果用户访问其他服务器时,没有这个 sesion_id就访问不了服务器资源。为此我们可以通过 session复制 session粘连 session共享 来解决这个问题,但是这些操作,通常会造成性能消耗。

no sesion 的方式,也就是 jwt+token 的方式。这种方式 token 保存在浏览器端,服务器只需要校验 token 是否正确即可。多台服务器的话,只需要有一套 token 的校验机制即可。

并且在移动端 Session 验证登录的方式是往往失效的,而 jwt+token 的解决方案也适用于移动端。


原文始发于微信公众号(yanghi):Web 开发中的登录校验解决方案及原理

版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 举报,一经查实,本站将立刻删除。

文章由极客之家整理,本文链接:https://www.bmabk.com/index.php/post/226729.html

(0)
小半的头像小半

相关推荐

发表回复

登录后才能评论
极客之家——专业性很强的中文编程技术网站,欢迎收藏到浏览器,订阅我们!