Spring Security + JWT的应用

Spring Security + JWT 技术总结


一、Spring Security + JWT 是什么?

1. Spring Security

Spring Security 是 Spring 生态中的安全框架,主要解决:

  • 用户认证(Authentication)
  • 权限控制(Authorization)
  • 请求安全过滤
  • 防止非法访问
    简单来说:

Spring Security负责判断“你是谁”和“你能干什么”。
例如:
用户登录:

用户名 + 密码 | ↓ Spring Security认证 | ↓ 认证成功 | ↓ 生成用户身份信息

2. JWT

JWT(JSON Web Token)是一种基于JSON的身份认证协议。
它解决的是:

用户登录之后,如何在后续请求中证明自己的身份。
传统Session:

第一次登录 浏览器 | | 用户名密码 ↓ 服务器 生成Session 服务器保存: sessionId -> 用户信息 后续请求: 浏览器携带sessionId 服务器查询Session

缺点:

  • 服务器需要保存Session
  • 集群环境需要Session共享
  • 扩展困难

JWT方式:

用户登录 用户名密码 ↓ 服务器生成JWT { "mno":10001, "nickname":"张三", "expire":xxx } ↓ 返回Token 客户端保存 后续请求: Authorization: Bearer eyJxxx ↓ 服务器解析JWT 得到用户信息

特点:

  • 无状态
  • 不需要服务器保存用户信息
  • 适合微服务架构

二、为什么项目中需要JWT?

我的项目是:

snacknet | | +-- gateway网关 | +-- users 用户服务(包括管理员,用户以及地址) | +-- productions 商品服务(包括商品,类型以及购物车) | +-- orders 订单服务(包括订单以及详情) | +-- idGenerator id生成器服务 | +-- fileUploda 图片上传服务

微服务环境下:
用户登录发生在:

user-service

但是:
购物车请求:

cart-service

订单请求:

order-service

如果使用Session:
问题:

用户登录 user服务保存session 访问购物车 cart服务 cart不知道user服务的session

需要:

  • Redis共享Session
  • Session复制
  • 服务间同步
    复杂。
    所以改成JWT:
登录 user-service 生成JWT 返回客户端 客户端以后访问任何服务: Authorization: Bearer token gateway解析JWT 获取用户信息 传递给后端服务

使用Spring security + jwt的其他原因:

  1. 无状态,天生适合微服务扩展:服务器不再需要存储 Session(无需共享缓存),服务实例可以随意弹性伸缩,真正做到水平扩展无阻碍。
  2. 多端适配,摆脱 Cookie 束缚:JWT 通过请求头传递,原生支持 Web、App、小程序等所有客户端,完美解决跨域和移动端兼容问题。
  3. 架构解耦,网关统一鉴权(即你现在的做法):网关层统一解析 JWT 并将用户信息(如mno)透传给下游微服务,业务服务完全不需要引入 Security 依赖,大幅消除重复代码,让认证层与业务层彻底分离。

三、微服务中的JWT整体流程

项目最终架构:

用户 | | Authorization Bearer JWT | ↓ Gateway网关 | JwtGlobalFilter | 解析JWT 获取: mno nickname | ↓ 添加请求头: X-User-Mno:10001 X-User-Nickname:张三 | ↓ --------------------------------- users (这是我的服务名) orders productions ---------------------------------

四、项目中的配置

1.配置依赖

<!--引入spring security依赖--><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-security</artifactId></dependency><!--JWTAPI--><dependency><groupId>io.jsonwebtoken</groupId><artifactId>jjwt-api</artifactId><version>0.11.5</version></dependency><!--JWT实现--><dependency><groupId>io.jsonwebtoken</groupId><artifactId>jjwt-impl</artifactId><version>0.11.5</version></dependency><!--JSON序列化--><dependency><groupId>io.jsonwebtoken</groupId><artifactId>jjwt-jackson</artifactId><version>0.11.5</version></dependency>

Spring Security的依赖没有版本是因为我的父项目已经有版本了,是直接使用的父项目的版本。
在网关gateway这个微服务中我导入的是Spring Security依赖,而JWT的依赖我是放在了公共模块,这导致了后面导入了公共模块的微服务都需要导入jwt.yml的配置。
原因如下:

products | 引入common | 扫描到JwtUtil | 创建Bean | 需要jwt.secret

而我的公共模块包含了Entity、DTO等,其他模块也是需要的,所以可能的解决方案应该是拆公共模块,将公共模块的JWT相关的拆分出去,形成一个微服务,给网关以及用户服务使用。

2.公共模块加入JWT相关

2.1 加入UserAuthentication

用户认证信息DTO(把JWT里面解析出来的用户身份信息返回给前端,或者在服务之间传递)

@DatapublicclassUserAuthentication{privateLongmno;privateStringnickname;publicUserAuthentication(Longmno,Stringnickname){this.mno=mno;this.nickname=nickname;}}

2.2 加入JwtProperties

JwtProperties 是一个JWT配置属性类,作用是:把 application.yml 里面关于 JWT 的配置映射成 Java 对象,方便 JwtUtil 使用。

@Data@ConfigurationProperties(prefix="jwt")publicclassJwtProperties{/** * JWT密钥 */privateStringsecret;/** * JWT过期时间 * 单位:毫秒 */privateLongexpire;}

2.3 加入JwtConfig

负责Spring注册:把 JwtProperties 这个配置类交给 Spring 管理,并且绑定 yml 中的配置。
这个也可以说是一个小型starter配置入口

@Configuration@EnableConfigurationProperties(JwtProperties.class)publicclassJwtConfig{}

那为什么要分开些JwtConfig和JwtProperties呢?
其实是因为职责分离,并且方便做Spring Boot自动配置。
当然也可以合并,合并的写法:

@Data@Component//创建Bean、加入Spring IOC@ConfigurationProperties(prefix="jwt")publicclassJwtProperties{privateStringsecret;privateLongexpire;}

2.4. 公共模块加入JwtUtil

放在:

common模块

原因:
我的网关服务和用户服务需要用到。

@ComponentpublicclassJwtUtil{privatefinalSecretKeykey;privatefinalJwtPropertiesjwtProperties;//相当于@AutowriedpublicJwtUtil(JwtPropertiesjwtProperties){this.jwtProperties=jwtProperties;if(jwtProperties.getSecret()==null){thrownewRuntimeException("JWT secret 未配置");}this.key=Keys.hmacShaKeyFor(jwtProperties.getSecret().getBytes(StandardCharsets.UTF_8));}/** * 创建JWT */publicStringcreateToken(Longmno,Stringnickname){returnJwts.builder().claim("mno",mno).claim("nickname",nickname)//签发时间.setIssuedAt(newDate())//过期时间.setExpiration(newDate(System.currentTimeMillis()+jwtProperties.getExpire())).signWith(key,SignatureAlgorithm.HS256).compact();}/** * 解析JWT */publicClaimsparseToken(Stringtoken){try{returnJwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token).getBody();}catch(Exceptione){returnnull;}}/** * 获取昵称 */publicStringgetNickname(Stringtoken){Claimsclaims=parseToken(token);if(claims==null){returnnull;}returnclaims.get("nickname",String.class);}/** * 获取会员编号 */publicLonggetMno(Stringtoken){Claimsclaims=parseToken(token);if(claims==null){returnnull;}returnLong.valueOf(claims.get("mno").toString());}}

2. 网关加入Security相关

2.1 网关加入SecurityConfig

SecurityConfig 是在 Gateway 中开启 Spring Security,并配置 Spring Security 不拦截任何请求,把真正的 JWT 登录认证交给你自己的 JwtGlobalFilter 处理。不负责登录认证、不负责权限判断。

@Configuration//Gateway 是基于WebFlux的,所以使用Spring Security WebFlux版本@EnableWebFluxSecuritypublicclassSecurityConfig{@BeanpublicSecurityWebFilterChainsecurityFilterChain(ServerHttpSecurityhttp){returnhttp.csrf(ServerHttpSecurity.CsrfSpec::disable)//关闭跨站请求伪造保护//所有请求放行.authorizeExchange(exchange->exchange.anyExchange().permitAll()).build();}}

2.2 加入JwtGlobalFilter

它运行在 Gateway 网关层,负责检查请求有没有 JWT,有就解析用户身份,没有或者无效就拒绝请求,然后把用户信息传给后面的微服务。

@ComponentpublicclassJwtGlobalFilterimplementsGlobalFilter,Ordered{privatefinalJwtUtiljwtUtil;/** * 不需要登录的接口 */privatestaticfinalList<String>WHITE_LIST=List.of(//用户注册登录"/user/member/login","/user/member/reg","/user/member/sendCode",//验证码"/user/code",//swagger"/swagger-ui","/v3/api-docs");publicJwtGlobalFilter(JwtUtiljwtUtil){this.jwtUtil=jwtUtil;}@OverridepublicMono<Void>filter(ServerWebExchangeexchange,GatewayFilterChainchain){Stringpath=exchange.getRequest().getURI().getPath();// 白名单直接放行if(isWhitePath(path)){returnchain.filter(exchange);}// 获取tokenStringauthorization=exchange.getRequest().getHeaders().getFirst("Authorization");// 没有tokenif(authorization==null||!authorization.startsWith("Bearer ")){exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED);returnexchange.getResponse().setComplete();}// 截取tokenStringtoken=authorization.substring(7);// 解析tokenLongmno=jwtUtil.getMno(token);Stringnickname=jwtUtil.getNickname(token);// token无效if(mno==null||nickname==null){exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED);returnexchange.getResponse().setComplete();}// 注入用户信息ServerHttpRequestrequest=exchange.getRequest().mutate().headers(headers->{// 防止客户端伪造headers.remove("X-User-Mno");headers.remove("X-User-Nickname");headers.add("X-User-Mno",String.valueOf(mno));headers.add("X-User-Nickname",nickname);}).build();returnchain.filter(exchange.mutate().request(request).build());}/** * 判断白名单 */privatebooleanisWhitePath(Stringpath){returnWHITE_LIST.stream().anyMatch(path::startsWith);}@OverridepublicintgetOrder(){// 优先执行return-100;}}

3.修改原来用session的地方

3.1 修改登录生成token

原来的:

session.setAttribute(CURRENT_LOGIN_MEMBER,member);

改成:

Stringtoken=jwtUtil.createToken(member.getMno(),member.getNickname());returnResponseResult.ok().setData(token);

登录成功后:
返回:

eyJhbGciOiJIUzI1Ni...

3.2Controller如何修改?

以前:
依赖Session:

HttpSessionsession

例如:

Objectobj=session.getAttribute(CURRENT_LOGIN_MEMBER);

改成:
读取Gateway传递的信息:

@RequestHeader("X-User-Mno")Longmno

例如购物车:
以前:

Longmno=getMno(session);

现在:

@GetMapping("/list")publicResponseResultlist(@RequestHeader("X-User-Mno")Longmno){}

五、我遇到的问题以及解决方案

问题1:JWT未启动

错误:

org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'jwtUtil' defined in file [D:\ideaIC\ideaIC_project\cloud156\snacknet156-common\target\classes\com\zy\utils\JwtUtil.class]: Failed to instantiate [com.zy.utils.JwtUtil]: Constructor threw exception at org.springframework.beans.factory.support.ConstructorResolver.instantiate(ConstructorResolver.java:318) at org.springframework.beans.factory.support.ConstructorResolver.autowireConstructor(ConstructorResolver.java:306) at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.autowireConstructor(AbstractAutowireCapableBeanFactory.java:1375) at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.createBeanInstance(AbstractAutowireCapableBeanFactory.java:1212) at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.doCreateBean(AbstractAutowireCapableBeanFactory.java:562) at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.createBean(AbstractAutowireCapableBeanFactory.java:522) at org.springframework.beans.factory.support.AbstractBeanFactory.lambda$doGetBean$0(AbstractBeanFactory.java:337) at org.springframework.beans.factory.support.DefaultSingletonBeanRegistry.getSingleton(DefaultSingletonBeanRegistry.java:234) at org.springframework.beans.factory.support.AbstractBeanFactory.doGetBean(AbstractBeanFactory.java:335) at org.springframework.beans.factory.support.AbstractBeanFactory.getBean(AbstractBeanFactory.java:200) at org.springframework.beans.factory.support.DefaultListableBeanFactory.preInstantiateSingletons(DefaultListableBeanFactory.java:975) at org.springframework.context.support.AbstractApplicationContext.finishBeanFactoryInitialization(AbstractApplicationContext.java:971) at org.springframework.context.support.AbstractApplicationContext.refresh(AbstractApplicationContext.java:625) at org.springframework.boot.web.servlet.context.ServletWebServerApplicationContext.refresh(ServletWebServerApplicationContext.java:146) at org.springframework.boot.SpringApplication.refresh(SpringApplication.java:754) at org.springframework.boot.SpringApplication.refreshContext(SpringApplication.java:456) at org.springframework.boot.SpringApplication.run(SpringApplication.java:335) at org.springframework.boot.SpringApplication.run(SpringApplication.java:1363) at org.springframework.boot.SpringApplication.run(SpringApplication.java:1352) at com.zy.ordersApp.main(ordersApp.java:15) Caused by: org.springframework.beans.BeanInstantiationException: Failed to instantiate [com.zy.utils.JwtUtil]: Constructor threw exception at org.springframework.beans.BeanUtils.instantiateClass(BeanUtils.java:221) at org.springframework.beans.factory.support.SimpleInstantiationStrategy.instantiate(SimpleInstantiationStrategy.java:117) at org.springframework.beans.factory.support.ConstructorResolver.instantiate(ConstructorResolver.java:315) ... 19 common frames omitted Caused by: java.lang.RuntimeException: JWT secret 未配置 at com.zy.utils.JwtUtil.<init>(JwtUtil.java:22) at java.base/jdk.internal.reflect.DirectConstructorHandleAccessor.newInstance(DirectConstructorHandleAccessor.java:62) at java.base/java.lang.reflect.Constructor.newInstanceWithCaller(Constructor.java:502) at java.base/java.lang.reflect.Constructor.newInstance(Constructor.java:486) at org.springframework.beans.BeanUtils.instantiateClass(BeanUtils.java:208) ... 21 common frames omitted

原因:
我将gateway的application.yml拆分放到nacos上面去了或者其他服务忘记加jwt.yml,造成jwt的配置读不到。
解决:
1.针对把gateway配置到了nacos上去了,最好就别配置上去了,因为gateway要求性能好,而且与其他的公共配置的牵扯较少。如果你实在想配置,要去找spring-boot-configuration-processor等相关的。
2.针对忘记加jwt.yml,你加上即可。


问题2:请求被拦截,但是地址好像是对的

原因:我使用postman的时候,再添加参数时会打一个空格,比如:
http://localhost:11004/user/test?uid =123456,这里uid的后面就多了一个空格,在postman发送请求的时候空格也会带上,然后到网关就找不到路径可以匹配。
解决:用postman的时候请注意你的参数后面是否携带空格,删掉即可。

问题3:Redis数据混乱

现象:
Redis 0号库有旧数据
现在配置:

database:0

原因:在做单体项目转变成微服务的拆分中,保存了之前单体项目的数据,还没有删除,而且我的包名还发生了改变导致503。
解决方案:清空之前redis里面的数据或者换一个库,比如2号等。


八、最终项目安全架构

用户 | | JWT Token | ↓ Gateway Spring Security JwtGlobalFilter | 验证JWT | 添加用户信息 | ↓ -------------------------------- user-service cart-service order-service product-service -------------------------------- Controller: @RequestHeader("X-User-Mno") 获取用户身份

六、项目收获总结

在项目中,我使用Spring Security + JWT实现了微服务环境下的统一身份认证。用户登录成功后由用户服务生成JWT Token,客户端后续请求携带Token,通过Gateway中的GlobalFilter统一解析JWT,并将用户信息通过请求头传递给各业务服务。相比传统Session方式,JWT解决了微服务环境下Session共享困难的问题,实现了无状态认证。同时通过Spring Security配置接口放行规则,保证登录注册等公共接口可以访问,其他请求经过JWT校验,提高了系统安全性。