JWT 实现登录

我们首先回顾下过去登录的方式,用户页面中输入账号和密码,点击提交发送到 controller 类中,controller 类接收账号和密码,并且调用业务和数据层去判断账号和密码是否正确,如果错误,返回到登录页面,如果正确,将用户信息保存到 session 中并跳转到主页面。

这里会发现,我们是通过 session 来记录用户登录状态,跟踪用户信息,那么就要在这里提一提 session 登录的缺陷:

  1. session 是将客户端数据储存在服务器的内存,当客户端的数据过多,连接较多,服务器的内存开销大。
  2. session 的数据储存在某台服务器,在分布式的项目中无法做到共享。
  3. 前后端分离的项目中共享 session 比较困难。
  4. jwt 不需要在服务端去保留用户的认证信息或者会话信息。

什么是 JWT

jwt 全称是 json web token。是由用户以用户名、密码登录,服务端验证后,会生成一个 token,返回给客户端,客户端在下次访问的过程中携带这个 token,服务端责每次验证这个token。 

JWT 的构成

jwt 由三部分组成,每一部分之间用符号"."进行分割,整体可以看做是一个长字符串。一个经典的jwt的样子:xxx.xxx.xxx。

Header 头部

头部由两部分组成:第一部分是声明类型,在 jwt 中声明类型就 jwt,第二部分是声明加密的算法,加密算法通常使用 HMAC|SHA256。一个经典的头部:

{
  'typ': 'JWT',      //  'typ':'声明类型'
  'alg': 'HS256'	//	'alg':'声明的加密算法'
}

Payload 载体、载荷

这一部分是jwt的主体部分,这一部分也是json对象,可以包含需要传递的数据,其中jwt指定了七个默认的字段选择,这七个字段是推荐但是不强制使用的:

  • iss:发行人
  • exp:到期时间
  • sub:主题
  • aud:用户
  • nbf:在此之前不可用
  • iat:发布时间
  • jti:JWT ID 用于识别该 JWT

除了上述的七个默认字段之外,还可以自定义字段,通常我们说 JWT 用于用户登陆,就可以在这个地方放置用户的id和用户名。下面这个json对象是一个 jwt 的 Payload 部分:

{
"sub": "一个演示",
"nickname": "dailyblue",
"id": "001"
}

这里注意虽然可以放自定的信息,但是不要存放一些敏感信息,除非是加密过的,因为这里的信息可能会被截获。 

signature  签证

这部分是对前两部分进行base64编码在进行加密,这个加密的方式使用的是jwt的头部声明中的加密方式,在加上一个密码(secret)组成的,secret 通常是一个随机的字符串,这个 secret是服务器特有的,不能够让其他人知道。这部分的组成公式是:

HMACSHA256(base64UrlEncode(header) + "." + base64UrlEncode(payload),secret)

JWT 的优点

  1. json形式,而json非常通用性可以让它在很多地方使用
  2. jwt所占字节很小,便于传输信息
  3. 需要服务器保存信息,易于扩展 

使用 JWT 登录流程

  1. 第一次登录的时候,前端调后端的登陆接口,发送帐号和密码。
  2. 后端收到请求,验证帐号和密码,验证成功,就给前端返回一个 jwt。
  3. 前端拿到 jwt,将 jwt 存储到 localStroage 或 header 中,并跳转到页面。
  4. 前端每次跳转页面,就判断 localStroage 中有无 jwt ,没有就跳转到登录页面,有则跳转到对应页面。
  5. 每次调后端接口,都要在请求头中加 jwt。
  6. 后端判断请求头中有无 jwt,有 jwt,就拿到 jwt 并验证 jwt,验证成功就返回数据,验证失败(例如:jwt 过期)就返回401,请求头中没有 jwt 也返回401。
  7. 如果前端拿到状态码为401,就清除 jwt 信息并跳转到登录页面。

所需依赖

<dependency>
   <groupId>org.springframework.boot</groupId>
   <artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
   <groupId>org.projectlombok</groupId>
   <artifactId>lombok</artifactId>
</dependency>
<dependency>
   <groupId>io.jsonwebtoken</groupId>
   <artifactId>jjwt</artifactId>
   <version>0.9.1</version>
</dependency>
<!-- jaxb依赖包 -->
<dependency>
   <groupId>javax.xml.bind</groupId>
   <artifactId>jaxb-api</artifactId>
   <version>2.3.0</version>
</dependency>
<dependency>
   <groupId>com.sun.xml.bind</groupId>
   <artifactId>jaxb-impl</artifactId>
   <version>2.3.0</version>
</dependency>
<dependency>
   <groupId>com.sun.xml.bind</groupId>
   <artifactId>jaxb-core</artifactId>
   <version>2.3.0</version>
</dependency>
<dependency>
   <groupId>javax.activation</groupId>
   <artifactId>activation</artifactId>
   <version>1.1.1</version>
</dependency>

JWT 工具类

package com.dailyblue.java.spring.config;

import com.dailyblue.java.spring.bean.User;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jws;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import org.springframework.util.StringUtils;

import javax.servlet.http.HttpServletRequest;
import java.util.Date;

/**
 * @author dailyblue
 * @since 2022/6/23
 */
public class JwtConfig {

    //常量
    public static final long EXPIRE = 1000 * 60 * 60 * 24; //token过期时间
    public static final String APP_SECRET = "1234"; //秘钥,加盐

    //	@param id 当前用户ID
    //	@param issuer 该JWT的签发者,是否使用是可选的
    //	@param subject 该JWT所面向的用户,是否使用是可选的
    //	@param ttlMillis 什么时候过期,这里是一个Unix时间戳,是否使用是可选的
    //	@param audience 接收该JWT的一方,是否使用是可选的
    //生成token字符串的方法
    public static String getJwtToken(User user) {

        String JwtToken = Jwts.builder()
                .setHeaderParam("typ", "JWT")    //头部信息
                .setHeaderParam("alg", "HS256")    //头部信息
                //下面这部分是payload部分
                // 设置默认标签
                .setSubject("dailyblue")    //设置jwt所面向的用户
                .setIssuedAt(new Date())    //设置签证生效的时间
                .setExpiration(new Date(System.currentTimeMillis() + EXPIRE))    //设置签证失效的时间
                //自定义的信息,这里存储id和姓名信息
                .claim("id", user.getId())  //设置token主体部分 ,存储用户信息
                .claim("name", user.getUserName())
                //下面是第三部分
                .signWith(SignatureAlgorithm.HS256, APP_SECRET)
                .compact();
        // 生成的字符串就是jwt信息,这个通常要返回出去
        return JwtToken;
    }

    /**
     * 判断token是否存在与有效
     * 直接判断字符串形式的jwt字符串
     *
     * @param jwtToken
     * @return
     */
    public static boolean checkToken(String jwtToken) {
        if (StringUtils.isEmpty(jwtToken)) return false;
        try {
            Jwts.parser().setSigningKey(APP_SECRET).parseClaimsJws(jwtToken);
        } catch (Exception e) {
            e.printStackTrace();
            return false;
        }
        return true;
    }

    /**
     * 判断token是否存在与有效
     * 因为通常jwt都是在请求头中携带,此方法传入的参数是请求
     *
     * @param request
     * @return
     */
    public static boolean checkToken(HttpServletRequest request) {
        try {
            String jwtToken = request.getHeader("token");//注意名字必须为token才能获取到jwt
            if (StringUtils.isEmpty(jwtToken)) return false;
            Jwts.parser().setSigningKey(APP_SECRET).parseClaimsJws(jwtToken);
        } catch (Exception e) {
            e.printStackTrace();
            return false;
        }
        return true;
    }

    /**
     * 根据token字符串获取会员id
     * 这个方法也直接从http的请求中获取id的
     *
     * @param request
     * @return
     */
    public static String getMemberIdByJwtToken(HttpServletRequest request) {
        String jwtToken = request.getHeader("token");
        if (StringUtils.isEmpty(jwtToken)) return "";
        Jws<Claims> claimsJws = Jwts.parser().setSigningKey(APP_SECRET).parseClaimsJws(jwtToken);
        Claims claims = claimsJws.getBody();
        return claims.get("id").toString();
    }

    /**
     * 解析JWT
     * @param jwt
     * @return
     */
    public static Claims parseJWT(String jwt) {
        Claims claims = Jwts.parser().setSigningKey(APP_SECRET).parseClaimsJws(jwt).getBody();
        return claims;
    }
}

相关工具类在网上有很多,有兴趣的童鞋也可以相互借鉴。 

返回结果工具类

状态码返回

package com.dailyblue.java.spring.util;

/**
 * @Author: Dailyblue
 * @Description: 返回码定义
 * 规定:
 * #1表示成功
 * #1001~1999 区间表示参数错误
 * #2001~2999 区间表示用户错误
 * #3001~3999 区间表示接口异常
 * @Date Create in 2022/06/21 19:28
 */
public enum ResultCode {
    /* 成功 */
    SUCCESS(200, "成功"),

    /* 默认失败 */
    COMMON_FAIL(999, "失败"),

    /* 参数错误:1000~1999 */
    PARAM_NOT_VALID(1001, "参数无效"),
    PARAM_IS_BLANK(1002, "参数为空"),
    PARAM_TYPE_ERROR(1003, "参数类型错误"),
    PARAM_NOT_COMPLETE(1004, "参数缺失"),

    /* 用户错误 */
    USER_NOT_LOGIN(2001, "用户未登录"),
    USER_ACCOUNT_EXPIRED(2002, "账号已过期"),
    USER_CREDENTIALS_ERROR(2003, "密码错误"),
    USER_CREDENTIALS_EXPIRED(2004, "密码过期"),
    USER_ACCOUNT_DISABLE(2005, "账号不可用"),
    USER_ACCOUNT_LOCKED(2006, "账号被锁定"),
    USER_ACCOUNT_NOT_EXIST(2007, "账号不存在"),
    USER_ACCOUNT_ALREADY_EXIST(2008, "账号已存在"),
    USER_ACCOUNT_USE_BY_OTHERS(2009, "账号下线"),

    /* 业务错误 */
    NO_PERMISSION(3001, "没有权限");
    private Integer code;
    private String message;

    ResultCode(Integer code, String message) {
        this.code = code;
        this.message = message;
    }

    public Integer getCode() {
        return code;
    }

    public void setCode(Integer code) {
        this.code = code;
    }

    public String getMessage() {
        return message;
    }

    public void setMessage(String message) {
        this.message = message;
    }

    /**
     * 根据code获取message
     *
     * @param code
     * @return
     */
    public static String getMessageByCode(Integer code) {
        for (ResultCode ele : values()) {
            if (ele.getCode().equals(code)) {
                return ele.getMessage();
            }
        }
        return null;
    }
}

统一返回实体

package com.dailyblue.java.spring.util;

import lombok.Data;

import java.io.Serializable;

/**
 * @Author: Dailyblue
 * @Description: 统一返回实体
 * @Date Create in 2022/06/21 19:28
 */
@Data
public class JsonResult<T> implements Serializable {
    private Boolean success;
    private Integer errorCode;
    private String errorMsg;
    private T data;

    public JsonResult() {
    }

    public JsonResult(boolean success) {
        this.success = success;
        this.errorCode = success ? ResultCode.SUCCESS.getCode() : ResultCode.COMMON_FAIL.getCode();
        this.errorMsg = success ? ResultCode.SUCCESS.getMessage() : ResultCode.COMMON_FAIL.getMessage();
    }

    public JsonResult(boolean success, ResultCode resultEnum) {
        this.success = success;
        this.errorCode = success ? ResultCode.SUCCESS.getCode() : (resultEnum == null ? ResultCode.COMMON_FAIL.getCode() : resultEnum.getCode());
        this.errorMsg = success ? ResultCode.SUCCESS.getMessage() : (resultEnum == null ? ResultCode.COMMON_FAIL.getMessage() : resultEnum.getMessage());
    }

    public JsonResult(boolean success, T data) {
        this.success = success;
        this.errorCode = success ? ResultCode.SUCCESS.getCode() : ResultCode.COMMON_FAIL.getCode();
        this.errorMsg = success ? ResultCode.SUCCESS.getMessage() : ResultCode.COMMON_FAIL.getMessage();
        this.data = data;
    }

    public JsonResult(boolean success, ResultCode resultEnum, T data) {
        this.success = success;
        this.errorCode = success ? ResultCode.SUCCESS.getCode() : (resultEnum == null ? ResultCode.COMMON_FAIL.getCode() : resultEnum.getCode());
        this.errorMsg = success ? ResultCode.SUCCESS.getMessage() : (resultEnum == null ? ResultCode.COMMON_FAIL.getMessage() : resultEnum.getMessage());
        this.data = data;
    }

}

返回体构造器

package com.dailyblue.java.spring.util;

/**
 * @Author: Dailyblue
 * @Description: 返回体构造工具
 * @Date Create in 2022/06/21 19:28
 */
public class ResultTool {
    public static JsonResult success() {
        return new JsonResult(true);
    }

    public static <T> JsonResult<T> success(T data) {
        return new JsonResult(true, data);
    }

    public static JsonResult fail() {
        return new JsonResult(false);
    }

    public static JsonResult fail(ResultCode resultEnum) {
        return new JsonResult(false, resultEnum);
    }
}

前端发送时携带 jwt

axios.get('url',{
  headers: 
  {
    'key': value
  }
})

拦截器的跨域问题 

起初解决 Springboot 跨域问题的方法是直接在 Controller 上添加 @CrossOrigin 注解,实现了前后端分离中的跨域请求。但随着业务代码的编写,做了 token 会话保持的检验,添加了拦截器后,再次出现了跨域问题。

很纳闷,按理说后台已经允许了跨域请求,之前的测试也证明了这一点,那为什么又突然出现了跨域拦截问题呢?

首先,在登录拦截器中作了校验,对于需要登录后才能访问的接口,如果请求头中没有携带 token,则是非法请求,直接返回404码。然后由于一直有对拦截到的请求中的请求头中的 token 做打印,所以出现问题的时候,控制台打印的 token 值为null,打开浏览器的开发者工具查看请求头也发现没有携带成功。而在没有添加拦截器之前上述问题都是不存在的,都是正常的,所以都不用考虑是前端问题,问题肯定出在后端,准确的说是拦截器。

那么为什么浏览器不能成功发送 token 呢?根据线索在更详细的查看了 CROS 的介绍后发现,原来 CROS 复杂请求时会首先发送一个 OPTIONS 请求做嗅探,来测试服务器是否支持本次请求,请求成功后才会发送真实的请求;而 OPTIONS 请求不会携带任何数据,导致这个请求不符合我们拦截器的校验规则被拦截了,直接返回了状态码,响应头中也没携带解决跨域需要的头部信息,进而出现了跨域问题。所以在浏览器调试工具中会发现该次请求没有携带 token,后端控制台打印 token 也为 null。

其次,就算这样,为什么会发生在添加跨域相关头部信息前就提前结束请求的这种情况呢?难道自定义的拦截器优先于 @CrossOrigin 注解执行?

通过查阅资料,解析 @CrossOrigin 注解的源码得知,如果 Controller 在类上标了 @CrossOrigin 或在方法上标了 @CrossOrigin 注解,则 Spring 在记录 mapper 映射时会记录对应跨域请求映射,将结果返回到 AbstractHandlerMethodMapping,当一个跨域请求过来时,Spring 在获取 handler 时会判断这个请求是否是一个跨域请求,如果是,则会返回一个可以处理跨域的 handler。总结起来 @CrossOrigin 的原理相当于和 Handler 进行强绑定。

于是现在的问题又到了:Handler 和拦截器的执行顺序?

DispatchServlet.doDispatch() 方法是 SpringMVC 的核心入口方法,经过分析发现所有的拦截器的 preHandle() 方法的执行都在实际 handler 的方法之前,其中任意拦截器返回 false 都会跳过后续所有处理过程。而 SpringMVC 对预检请求的处理则在 PreFlightHandler.handleRequest() 中处理,在整个处理链条中出于后置位。由于预检请求中不带数据,因此先被权限拦截器拦截了。

所以每次获取不到 token 的请求都是 OPTIONS 请求,那么解决的方法就很明了了:把所有的OPTIONS请求统统放行。

//拦截器取到请求先进行判断,如果是OPTIONS请求,则放行
if("OPTIONS".equals(httpServletRequest.getMethod().toUpperCase())) {
    System.out.println("Method:OPTIONS");
	return true;
 }

还有另一种方式,就是通过过滤器来处理跨域请求,众所周知,过滤器在拦截器之前执行,能够有效避免拦截处理的情况发生。

@Slf4j
@WebFilter("/*")
// 跨域过滤器
public class CrosFilter implements Filter {
    @Override
    public void init(FilterConfig filterConfig) throws ServletException {
        log.info("过滤处理过滤器启动了...");
    }

    @Override
    public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
            throws IOException, ServletException {
        log.info("跨域处理过滤器");
        HttpServletResponse response = (HttpServletResponse) res;
        HttpServletRequest request = (HttpServletRequest) req;
        if (StringUtils.isEmpty(request.getHeader("Origin"))) {
            response.setHeader("Access-Control-Allow-Origin", "*");
        } else {
            response.setHeader("Access-Control-Allow-Origin", request.getHeader("Origin"));
        }
        response.setHeader("Access-Control-Allow-Headers", "*");
        response.setHeader("Access-Control-Allow-Methods", "*");
        response.setHeader("Access-Control-Max-Age", "3600");
        if ("OPTIONS".equals(request.getMethod())) {
            response.setStatus(HttpServletResponse.SC_OK);
            return;
        }
        chain.doFilter(request, response);
    }

    @Override
    public void destroy() {
    }
}

详细的代码会在课程中涉及到,这里只罗列了流程和概念性问题。

<think>好的,我现在需要帮助用户了解如何使用JWT实现用户登录和权限控制。首先,我需要回忆一下JWT的基本概念和工作流程。JWT是JSON Web Token的缩写,通常用于身份验证和授权。用户登录后,服务器生成一个JWT,客户端之后在请求中携带这个Token,服务器验证Token的合法性并据此授权。 根据用户提供的引用内容,他们提到了Shiro和JWT的整合,以及权限管理的实现。可能用户对如何在Spring Boot项目中结合这两个技术感兴趣。不过用户的问题更偏向于JWT本身如何实现登录和权限管理,而不仅仅是整合框架。 首先,我应该分步骤解释JWT登录流程。用户登录时提交凭证,服务器验证凭证,生成JWT并返回。客户端存储JWT,通常在localStorage或cookie中。之后的每个请求都需要携带JWT,服务器验证JWT的有效性,并从中提取用户信息进行权限判断。 接下来是权限控制部分。JWT的Payload部分可以包含用户角色和权限信息。服务器在验证Token后,需要根据这些信息决定用户是否有权访问特定资源。例如,使用中间件或拦截器来检查权限,或者在API网关处理。 需要注意JWT的安全性,比如使用HTTPS传输,设置合理的过期时间,以及处理Token的刷新机制。此外,JWT是无状态的,所以服务器不需要保存会话信息,但这也意味着一旦签发,无法中途撤销,除非等到过期。 用户可能还会关心如何实际编码实现,比如生成Token的库,如何解析和验证签名,以及如何处理常见的权限控制场景,比如基于角色的访问控制(RBAC)。可能需要提到一些代码示例,比如使用Java的jjwt库,或者在Node.js中使用jsonwebtoken。 还要注意引用的内容中提到了Shiro的整合,所以可能需要比较JWT与传统会话管理的区别,以及为什么选择JWT,比如分布式系统的适用性。 另外,用户可能对JWT的结构(Header, Payload, Signature)不太清楚,需要简要解释。例如,Header包含算法类型,Payload包含声明(claims),如用户ID、角色,Signature用于验证消息未被篡改。 可能的问题点包括如何安全存储JWT,避免XSS和CSRF攻击,以及如何处理Token过期后的刷新。需要给出最佳实践建议,比如使用HttpOnly的cookie存储,而不是localStorage。 最后,生成相关问题时,要考虑用户可能想了解JWT的安全隐患、具体实现步骤、与其他认证方式的对比,或者在微服务架构中的应用。</think>### 如何使用JWT实现用户登录与权限控制 #### 一、JWT登录流程实现 1. **用户提交凭证** 用户通过登录接口发送用户名和密码(或其他凭证),例如: ```bash POST /login { "username": "admin", "password": "******" } ``` 2. **服务器验证并生成Token** 服务器验证凭证合法性,成功后生成JWTJWT包含三部分: - **Header**:指定算法类型(如HMAC SHA256)和Token类型 ```json { "alg": "HS256", "typ": "JWT" } ``` - **Payload**:存放用户标识、角色、权限和有效期 ```json { "sub": "user123", "roles": ["admin"], "permissions": ["create:user", "delete:post"], "exp": 1629999999 } ``` - **Signature**:使用密钥对前两部分签名,防止篡改 ```python signature = HMACSHA256(base64UrlEncode(header) + "." + base64UrlEncode(payload), secret_key) ``` 3. **返回Token给客户端** 服务器通过响应体返回Token: ```json { "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyMTIzIn0.7d5b7j8g..." } ``` 客户端需将Token存储在安全位置(如`localStorage`或`HttpOnly Cookie`)[^2]。 --- #### 二、权限控制实现 1. **Token校验中间件** 在请求到达业务逻辑前,通过拦截器验证JWT: ```java public boolean preHandle(HttpServletRequest request, HttpServletResponse response) { String token = request.getHeader("Authorization"); try { Claims claims = Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token).getBody(); request.setAttribute("userRoles", claims.get("roles")); // 传递权限信息 return true; } catch (Exception e) { response.setStatus(401); // 无效Token返回401 return false; } } ``` 2. **基于角色的访问控制(RBAC)** - 使用注解标注接口所需权限: ```java @GetMapping("/admin") @RequiresRoles("admin") // Shiro注解示例 public ResponseEntity<?> adminResource() { ... } ``` - 自定义权限验证逻辑: ```python def check_permission(user_roles, required_role): return required_role in user_roles ``` --- #### 三、安全增强措施 | 措施 | 说明 | |---------------------|----------------------------------------------------------------------| | **HTTPS传输** | 防止Token在传输中被窃听 | | **短期有效期** | 设置`exp`字段(如30分钟),配合Refresh Token机制延长会话[^1] | | **黑名单管理** | 通过Redis记录主动注销的Token,在拦截器中二次校验 | | **密钥轮换** | 定期更换签名密钥,降低泄露风险 | ---
评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值