045 用户登录功能01----JWT和后台代码
例如登录:用户登录后,我们把登录者的信息保存在服务端session中,并且给用户一个cookie值,记录对应的session。然后下次请求,用户携带cookie值来,我们就能识别到对应session,从而找到用户的信息。
缺点是什么?
-
服务端保存大量数据,增加服务端压力
-
服务端保存用户状态,无法进行水平扩展
-
-
服务端不保存任何客户端请求者信息
-
客户端的每次请求必须具备自描述信息,通过这些信息识别客户端身份
带来的好处是什么呢?
-
客户端请求不依赖服务端的信息,任何多次请求不需要必须访问到同一台服务
-
服务端的集群和状态对客户端透明
-
服务端可以任意的迁移和伸缩
-
减小服务端存储压力
(3)
-
当客户端第一次请求服务时,服务端对用户进行信息认证(登录)
-
认证通过,将用户信息进行加密形成token,返回给客户端,作为登录凭证
-
以后每次请求,客户端都携带认证的token
-
token的安全性
token是识别客户端身份的唯一标示,如果加密不够严密,被人伪造那就完蛋了。
采用何种方式加密才是安全可靠的呢?
我们将采用
<2>
-
Header:头部,通常头部有两部分信息:
-
声明类型,这里是JWT
我们会对头部进行base64编码,得到第一部分数据
-
-
Payload:载荷,就是有效数据,一般包含下面信息:
-
用户身份信息(注意,这里因为采用base64编码,可解码,因此不要存放敏感信息)
-
注册声明:如token的签发时间,过期时间,签发人等
这部分也会采用base64编码,得到第二部分数据
-
-
Signature:签名,是整个数据的认证信息。一般根据前两步的数据,再加上服务的的密钥(secret)(不要泄漏,最好周期性更换),通过加密算法生成。用于验证整个数据完整和可靠性
生成的数据格式:token==个人证件 jwt=个人身份证
可以看到分为3段,每段就是上面的一部分数据
<3>
-
1、用户登录
-
2、服务的认证,通过后根据secret生成token
-
3、将生成的token返回给浏览器
-
4、用户每次请求携带token
-
5、服务端利用公钥解读jwt签名,判断签名有效后,从Payload中获取用户信息
-
6、处理请求,返回响应结果
因为JWT签发的token中已经包含了用户的身份信息,并且每次请求都会携带,这样服务的就无需保存用户信息,甚至无需去数据库查询,完全符合了Rest的无状态规范。
<4>
-
对称加密,如AES
-
基本原理:将明文分成N个组,然后使用密钥对各个组进行加密,形成各自的密文,最后把所有的分组密文进行合并,形成最终的密文。
-
优势:算法公开、计算量小、加密速度快、加密效率高
-
缺陷:双方都使用同样密钥,安全性得不到保证
-
-
非对称加密,如RSA
-
基本原理:同时生成两把密钥:私钥和公钥,私钥隐秘保存,公钥可以下发给信任客户端
-
私钥加密,持有私钥或公钥才可以解密
-
公钥加密,持有私钥才可解密
-
-
优点:安全,难以破解
-
缺点:算法比较耗时
-
-
不可逆加密,如MD5,SHA
-
基本原理:加密过程中不需要使用密钥,输入明文后由系统直接经过加密算法处理成密文,这种加密后的数据是无法被解密的,无法根据密文推算出明文。
-
RSA算法历史:
-
-
用户请求登录
-
授权中心校验,通过后用私钥对JWT进行签名加密
-
返回jwt给用户
-
用户携带JWT访问
-
Zuul直接通过公钥解密JWT,进行验证,验证通过则放行
-
(1)
-
用户鉴权:
-
接收用户的登录请求,通过用户中心的接口进行校验,通过后生成JWT
-
使用私钥生成JWT并返回
-
-
服务鉴权:微服务间的调用不经过Zuul,会有风险,需要鉴权中心进行认证
-
原理与用户鉴权类似,但逻辑稍微复杂一些(此处我们不做实现)
-
我们先创建父module(maven模块),名称为:leyou-auth
pom文件:
<?xml version="1.0" encoding="UTF-8"?> <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> <parent> <artifactId>leyou</artifactId> <groupId>lucky.leyou.parent</groupId> <version>1.0-SNAPSHOT</version> </parent> <modelVersion>4.0.0</modelVersion> <packaging>pom</packaging> <groupId>lucky.leyou.auth</groupId> <artifactId>leyou-auth</artifactId> </project>
注意:将pom打包方式改为pom
<2>
pom文件:
<?xml version="1.0" encoding="UTF-8"?> <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> <parent> <artifactId>leyou-auth</artifactId> <groupId>lucky.leyou.auth</groupId> <version>1.0-SNAPSHOT</version> </parent> <modelVersion>4.0.0</modelVersion> <groupId>lucky.leyou.auth</groupId> <artifactId>leyou-auth-common</artifactId> </project>
结构:
<3>
pom.xml:
<?xml version="1.0" encoding="UTF-8"?> <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> <parent> <artifactId>leyou-auth</artifactId> <groupId>lucky.leyou.auth</groupId> <version>1.0-SNAPSHOT</version> </parent> <modelVersion>4.0.0</modelVersion> <groupId>lucky.leyou.auth</groupId> <artifactId>leyou-auth-service</artifactId> <dependencies> <dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-starter-netflix-eureka-client</artifactId> </dependency> <dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-starter-openfeign</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>lucky.leyou.auth</groupId> <artifactId>leyou-auth-common</artifactId> <version>1.0-SNAPSHOT</version> </dependency> <dependency> <groupId>lucky.leyou.common</groupId> <artifactId>leyou-common</artifactId> <version>1.0-SNAPSHOT</version> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> </dependency> </dependencies> </project>
引导类:
package lucky.leyou; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.cloud.client.discovery.EnableDiscoveryClient; import org.springframework.cloud.openfeign.EnableFeignClients; @SpringBootApplication @EnableDiscoveryClient @EnableFeignClients public class LeyouAuthApplication { public static void main(String[] args) { SpringApplication.run(LeyouAuthApplication.class, args); } }
application.yml:
server: port: 8090 spring: application: name: auth-service eureka: client: service-url: defaultZone: http://127.0.0.1:10086/eureka registry-fetch-interval-seconds: 10 instance: lease-renewal-interval-in-seconds: 5 # 每隔5秒发送一次心跳 lease-expiration-duration-in-seconds: 10 # 10秒不发送就过期
结构:
在leyou-gateway工程的application.yml中,修改路由:
zuul: prefix: /api # 路由路径前缀 routes: item-service: /item/** # 商品微服务的映射路径 search-service: /search/** # 搜索微服务 user-service: /user/** # 用户微服务 auth-service: /auth/** # 授权中心微服务
(2)
<?xml version="1.0" encoding="UTF-8"?> <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> <parent> <artifactId>leyou-auth</artifactId> <groupId>lucky.leyou.auth</groupId> <version>1.0-SNAPSHOT</version> </parent> <modelVersion>4.0.0</modelVersion> <groupId>lucky.leyou.auth</groupId> <artifactId>leyou-auth-common</artifactId> <dependencies> <dependency> <groupId>org.apache.commons</groupId> <artifactId>commons-lang3</artifactId> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt</artifactId> <version>0.9.0</version> </dependency> <dependency> <groupId>joda-time</groupId> <artifactId>joda-time</artifactId> </dependency> </dependencies> </project>
(3)
package lucky.leyou.auth.test; import lucky.leyou.auth.entity.UserInfo; import lucky.leyou.auth.utils.JwtUtils; import lucky.leyou.auth.utils.RsaUtils; import org.junit.Before; import org.junit.Test; import java.security.PrivateKey; import java.security.PublicKey; public class JwtTest { private static final String pubKeyPath = "C:\\tmp\\rsa\\rsa.pub"; private static final String priKeyPath = "C:\\tmp\\rsa\\rsa.pri"; private PublicKey publicKey; private PrivateKey privateKey; @Test public void testRsa() throws Exception { RsaUtils.generateKey(pubKeyPath, priKeyPath, "234"); } @Before public void testGetRsa() throws Exception { this.publicKey = RsaUtils.getPublicKey(pubKeyPath); this.privateKey = RsaUtils.getPrivateKey(priKeyPath); } @Test public void testGenerateToken() throws Exception { // 生成token String token = JwtUtils.generateToken(new UserInfo(20L, "jack"), privateKey, 5); System.out.println("token = " + token); } @Test public void testParseToken() throws Exception { String token = "eyJhbGciOiJSUzI1NiJ9.eyJpZCI6MjAsInVzZXJuYW1lIjoiamFjayIsImV4cCI6MTUzMzI4MjQ3N30.EPo35Vyg1IwZAtXvAx2TCWuOPnRwPclRNAM4ody5CHk8RF55wdfKKJxjeGh4H3zgruRed9mEOQzWy79iF1nGAnvbkraGlD6iM-9zDW8M1G9if4MX579Mv1x57lFewzEo-zKnPdFJgGlAPtNWDPv4iKvbKOk1-U7NUtRmMsF1Wcg"; // 解析token UserInfo user = JwtUtils.getInfoFromToken(token, publicKey); System.out.println("id: " + user.getId()); System.out.println("userName: " + user.getUsername()); } }
运行之后,查看目标目录:
控制台输出:
<3>测试解析token:
正常情况:
任意改动token,发现报错了:
3.
-
客户端携带用户名和密码请求登录
-
授权中心调用用户中心接口,根据用户名和密码查询用户信息
-
如果用户名密码正确,能获取用户,否则为空,则登录失败
-
如果校验成功,则生成JWT并返回
逻辑分析图:
(1)
leyou: jwt: secret: leyou@Login(Auth}*^31)&heiMa% # 登录校验的密钥 pubKeyPath: D:\temp\rsa\\rsa.pub # 公钥地址 priKeyPath: D:\temp\rsa\\rsa.pri # 私钥地址 expire: 30 # 过期时间,单位分钟
然后编写属性类,加载这些数据:
package lucky.leyou.auth.config; import lucky.leyou.auth.utils.RsaUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.boot.context.properties.ConfigurationProperties; import javax.annotation.PostConstruct; import java.io.File; import java.security.PrivateKey; import java.security.PublicKey; @ConfigurationProperties(prefix = "leyou.jwt") public class JwtProperties { private String secret; // 密钥 private String pubKeyPath;// 公钥路径 private String priKeyPath;// 私钥路径 private int expire;// token过期时间 private PublicKey publicKey; // 公钥 private PrivateKey privateKey; // 私钥 private static final Logger logger = LoggerFactory.getLogger(JwtProperties.class); /** * @PostContruct:在构造方法执行之后执行该方法 */ @PostConstruct public void init(){ try { File pubKey = new File(pubKeyPath); File priKey = new File(priKeyPath); if (!pubKey.exists() || !priKey.exists()) { // 生成公钥和私钥 RsaUtils.generateKey(pubKeyPath, priKeyPath, secret); } // 获取公钥和私钥 this.publicKey = RsaUtils.getPublicKey(pubKeyPath); this.privateKey = RsaUtils.getPrivateKey(priKeyPath); } catch (Exception e) { logger.error("初始化公钥和私钥失败!", e); throw new RuntimeException(); } } // getter setter ... public String getSecret() { return secret; } public void setSecret(String secret) { this.secret = secret; } public String getPubKeyPath() { return pubKeyPath; } public void setPubKeyPath(String pubKeyPath) { this.pubKeyPath = pubKeyPath; } public String getPriKeyPath() { return priKeyPath; } public void setPriKeyPath(String priKeyPath) { this.priKeyPath = priKeyPath; } public int getExpire() { return expire; } public void setExpire(int expire) { this.expire = expire; } public PublicKey getPublicKey() { return publicKey; } public void setPublicKey(PublicKey publicKey) { this.publicKey = publicKey; } public PrivateKey getPrivateKey() { return privateKey; } public void setPrivateKey(PrivateKey privateKey) { this.privateKey = privateKey; } }
(2)controller
-
请求方式:post
-
请求路径:/accredit
-
请求参数:username和password
-
返回结果:无
leyou: jwt: secret: leyou@Login(Auth}*^31)&heiMa% # 登录校验的密钥 pubKeyPath: D:\temp\rsa\\rsa.pub # 公钥地址 priKeyPath: D:\temp\rsa\\rsa.pri # 私钥地址 expire: 30 # 过期时间,单位分钟 cookieName: LY_TOKEN #cookie的名称 cookieMaxAge: 30
代码:
package lucky.leyou.auth.controller; import lucky.leyou.auth.config.JwtProperties; import lucky.leyou.common.utils.CookieUtils; import org.apache.commons.lang3.StringUtils; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestParam; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; @Controller @EnableConfigurationProperties(JwtProperties.class) public class AuthController { @Autowired private AuthService authService; @Autowired private JwtProperties prop; /** * 登录授权 * * @param username * @param password * @return */ @PostMapping("accredit") public ResponseEntity<Void> authentication( @RequestParam("username") String username, @RequestParam("password") String password, HttpServletRequest request, HttpServletResponse response) { // 登录校验 String token = this.authService.authentication(username, password); if (StringUtils.isBlank(token)) { return new ResponseEntity<>(HttpStatus.UNAUTHORIZED); } // 将token写入cookie,并指定httpOnly为true,防止通过JS获取和修改 CookieUtils.setCookie(request, response, prop.getCookieName(), token, prop.getCookieMaxAge(), null, true); return ResponseEntity.ok().build(); } }
注意:
这里我们使用了一个工具类,CookieUtils
(3)
package lucky.leyou.user.api; import lucky.leyou.user.domain.User; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestParam; public interface UserApi { /** * 根据用户名和密码查询用户 * @param username * @param password * @return */ @GetMapping("query") public User queryUser( @RequestParam("username") String username, @RequestParam("password") String password ); }
<2>在leyou-auth-service中编写FeignClient
在leyou-auth中引入user-service-interface依赖:
<dependency> <groupId>lucky.leyou.user</groupId> <artifactId>leyou-user-interface</artifactId> <version>1.0-SNAPSHOT</version> </dependency>
参照leyou-search或者leyou-goods-web
package lucky.leyou.auth.client; import lucky.leyou.user.api.UserApi; import org.springframework.cloud.openfeign.FeignClient; @FeignClient(value = "user-service") public interface UserClient extends UserApi { }
(4)
package lucky.leyou.auth.service; import lucky.leyou.auth.client.UserClient; import lucky.leyou.auth.config.JwtProperties; import lucky.leyou.auth.entity.UserInfo; import lucky.leyou.auth.utils.JwtUtils; import lucky.leyou.user.domain.User; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; @Service public class AuthService { @Autowired private UserClient userClient; @Autowired private JwtProperties properties; public String authentication(String username, String password) { try { // 调用微服务,执行查询 User user = this.userClient.queryUser(username, password); // 如果查询结果为null,则直接返回null if (user == null) { return null; } // 如果有查询结果,则生成token String token = JwtUtils.generateToken(new UserInfo(user.getId(), user.getUsername()), properties.getPrivateKey(), properties.getExpire()); return token; } catch (Exception e) { e.printStackTrace(); } return null; } }
(5)
(6)测试
打开postman进行测试:http://localhost:8090/accredit
4.
查看控制台:
发现请求的路径不对,我们的认证接口是:
/api/auth/accredit
页面ajax请求:
然后再次测试,成功跳转到了首页:
5.
我们发现内部有一个方法,用来获取Domain:
它获取domain是通过服务器的host来计算的,然而我们的地址竟然是:127.0.0.1:8087,因此后续的运算,最终得到的domain就变成了:
问题找到了:我们请求时的serverName明明是:api.leyou.com,现在却被变成了:127.0.0.1,因此计算domain是错误的,从而导致cookie设置失败!
(2)
-
我们使用了nginx反向代理,当监听到api.leyou.com的时候,会自动将请求转发至127.0.0.1:10010,即Zuul。
-
而后请求到达我们的网关Zuul,Zuul就会根据路径匹配,我们的请求是/api/auth,根据规则被转发到了 127.0.0.1:8087 ,即我们的授权中心。
我们首先去更改nginx配置,让它不要修改我们的host:
把nginx进行reload:
nginx -s reload
这样就解决了nginx这里的问题。但是Zuul还会有一次转发,所以要去修改网关的配置(leyou-gateway工程):
zuul: prefix: /api # 路由路径前缀 routes: item-service: /item/** # 商品微服务的映射路径 search-service: /search/** #搜索微服务 user-service: /user/** #用户微服务 auth-service: /auth/** # 授权中心微服务 add-host-header: true #携带请求本身的头信息
重启后,我们再次测试。
最终得到的domainName:
(3)
Zuul内部有默认的过滤器,会对请求和响应头信息进行重组,过滤掉敏感的头信息:
而这个SensitiveHeaders
的默认值就包含了set-cookie
解决方案:
把敏感头设置为null
zuul:
prefix: /api # 路由路径前缀
routes:
item-service: /item/** # 商品微服务的映射路径
search-service: /search/** #搜索微服务
user-service: /user/** #用户微服务
auth-service: /auth/** # 授权中心微服务
add-host-header: true #携带请求本身的头信息
sensitive-headers: #覆盖默认敏感头信息
(4)测试
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 记一次.NET内存居高不下排查解决与启示
· 探究高空视频全景AR技术的实现原理
· 理解Rust引用及其生命周期标识(上)
· 浏览器原生「磁吸」效果!Anchor Positioning 锚点定位神器解析
· 没有源码,如何修改代码逻辑?
· 分享4款.NET开源、免费、实用的商城系统
· 全程不用写代码,我用AI程序员写了一个飞机大战
· MongoDB 8.0这个新功能碉堡了,比商业数据库还牛
· 白话解读 Dapr 1.15:你的「微服务管家」又秀新绝活了
· 上周热点回顾(2.24-3.2)