什么是JWT
JJSON Web Token (JWT)是一个开放标准(RFC 7519),它定义了一种紧凑的、自包含的方式,用于作为JSON对象在各方之间安全地传输信息。该信息可以被验证和信任,因为它是数字签名的。
构成
构成: 头部(header).载荷(payload).签证(signature)
头部:
{
'typ': 'JWT', 声明类型
'alg': 'HS256' 声明加密算法
}
==》然后进行Base64编码
载荷:
{
"sub": "1234567890", 标准中注册的声明
"name": "John Doe", 公共的声明:公共的声明可以添加任何的信息,一般添加用户的相关信息或其他业 务需要的必要信息
"admin": true 私有的声明:私有声明是提供者和消费者所共同定义的声明
}
==》然后进行base64编码
注意,不要在JWT的payload或header中放置敏感信息,除非它们是加密的。
签证:
header (base64后的)
payload (base64后的)
secret
==》这个部分需要base64base64编码后的header和base64base64编码后的payload使用.连接组成的字符串,然后通过header中声明的加密方式进行加盐secret组合进行签名即可。
签名是用于验证消息在传递过程中有没有被更改,并且,对于使用私钥签名的token,它还可以验证JWT的发送方是否为它所称的发送方。
用法
客户端接收服务器返回的JWT,将其存储在Cookie或localStorage中。
此后,客户端将在与服务器交互中都会带JWT。如果将它存储在Cookie中,就可以自动发送,但是不会跨域,因此一般是将它放入HTTP请求的Header Authorization字段中。
Authorization: Bearer
当跨域时,也可以将JWT被放置于POST请求的数据主体中。
与基于服务器(session)身份认证的区别
基于sessionID的身份认证:
HTTP协议是无状态的,也就是说,如果我们已经认证了一个用户,那么他下一次请求的时候,服务器不知道我是谁,我们必须再次认证
传统的做法是将已经认证过的用户信息存储在服务器上,比如Session。用户下次请求的时候带着Session ID,然后服务器以此检查用户是否认证过。
这种基于服务器的身份认证方式存在一些问题:
Sessions : 每次用户认证通过以后,服务器需要创建一条记录保存用户信息,通常是在内存中,随着认证通过的用户越来越多,服务器的在这里的开销就会越来越大。
Scalability : 由于Session是在内存中的,这就带来一些扩展性的问题。
CORS : 当我们想要扩展我们的应用,让我们的数据被多个移动设备使用时,我们必须考虑跨资源共享问题。当使用AJAX调用从另一个域名下获取资源时,我们可能会遇到禁止请求的问题。
CSRF : 用户很容易受到CSRF攻击。
基于Token的身份认证:
基于Token的身份认证是无状态的,服务器或者Session中不会存储任何用户信息。
没有会话信息意味着应用程序可以根据需要扩展和添加更多的机器,而不必担心用户登录的位置。
虽然这一实现可能会有所不同,但其主要流程如下:
-用户携带用户名和密码请求访问 -服务器校验用户凭据 -应用提供一个token给客户端 -客户端存储token,并且在随后的每一次请求中都带着它 -服务器校验token并返回数据
注意
:
-每一次请求都需要token -Token应该放在请求header中 -我们还需要将服务器设置为接受来自所有域的请求,用Access-Control-Allow-Origin: *
JWT与Session的差异
相同点是,它们都是存储用户信息;然而,Session是在服务器端的,而JWT是在客户端的。
Session方式存储用户信息的最大问题在于要占用大量服务器内存,增加服务器的开销。
而JWT方式将用户状态分散到了客户端中,可以明显减轻服务端的内存压力。
Session的状态是存储在服务器端,客户端只有session id;而Token的状态是存储在客户端。
用Token的好处
- 无状态和可扩展性:Tokens存储在客户端。完全无状态,可扩展。我们的负载均衡器可以将用户传递到任意服务器,因为在任何地方都没有状态或会话信息。
- 安全:Token不是Cookie。(The token, not a cookie.)每次请求的时候Token都会被发送。而且,由于没有Cookie被发送,还有助于防止CSRF攻击。即使在你的实现中将token存储到客户端的Cookie中,这个Cookie也只是一种存储机制,而非身份认证机制。没有基于会话的信息可以操作,因为我们没有会话!
- token在一段时间以后会过期,这个时候用户需要重新登录。这有助于我们保持安全。还有一个概念叫token撤销,它允许我们根据相同的授权许可使特定的token甚至一组token无效。
JWT的问题与趋势:
1、JWT默认不加密,但可以加密。生成原始令牌后,可以使用改令牌再次对其进行加密。
2、当JWT未加密方法是,一些私密数据无法通过JWT传输。
3、JWT不仅可用于认证,还可用于信息交换。善用JWT有助于减少服务器请求数据库的次数。
4、JWT的最大缺点是服务器不保存会话状态,所以在使用期间不可能取消令牌或更改令牌的权限。也就是说,一旦JWT签发,在有效期内将会一直有效。
5、JWT本身包含认证信息,因此一旦信息泄露,任何人都可以获得令牌的所有权限。为了减少盗用,JWT的有效期不宜设置太长。对于某些重要操作,用户在使用时应该每次都进行进行身份验证。
6、为了减少盗用和窃取,JWT不建议使用HTTP协议来传输代码,而是使用加密的HTTPS协议进行传输。
结合RSA的鉴权流程
- 我们首先利用RSA生成公钥和私钥。私钥保存在授权中心,公钥保存在Zuul和各个微服务
- 用户请求登录
- 授权中心校验,通过后用私钥对JWT进行签名加密
- 返回jwt给用户
- 用户携带JWT访问
- Zuul直接通过公钥解密JWT,进行验证,验证通过则放行
- 请求到达微服务,微服务直接用公钥解析JWT,获取用户信息,无需访问授权中心
整合进项目中
1.创建一个授权中心的module
2. 在utils里导入 工具类
//jwt字段定义
public abstract class JwtConstans {
public static final String JWT_KEY_ID = "id";
public static final String JWT_KEY_USER_NAME = "username";
}
import com.luo.entity.UserInfo;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jws;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import org.joda.time.DateTime;
import java.security.PrivateKey;
import java.security.PublicKey;
/*jwt工具类 产生jwt字符*/
public class JwtUtils {
/**
* 私钥加密token
*
* @param userInfo 载荷中的数据
* @param privateKey 私钥
* @param expireMinutes 过期时间,单位秒
* @return
* @throws Exception
*/
public static String generateToken(UserInfo userInfo, PrivateKey privateKey, int expireMinutes) throws Exception {
return Jwts.builder()
.claim(JwtConstans.JWT_KEY_ID, userInfo.getId())
.claim(JwtConstans.JWT_KEY_USER_NAME, userInfo.getUsername())
.setExpiration(DateTime.now().plusMinutes(expireMinutes).toDate())
.signWith(SignatureAlgorithm.RS256, privateKey)
.compact();
}
/**
* 私钥加密token
*
* @param userInfo 载荷中的数据
* @param privateKey 私钥字节数组
* @param expireMinutes 过期时间,单位秒
* @return
* @throws Exception
*/
public static String generateToken(UserInfo userInfo, byte[] privateKey, int expireMinutes) throws Exception {
return Jwts.builder()
.claim(JwtConstans.JWT_KEY_ID, userInfo.getId())
.claim(JwtConstans.JWT_KEY_USER_NAME, userInfo.getUsername())
.setExpiration(DateTime.now().plusMinutes(expireMinutes).toDate())
.signWith(SignatureAlgorithm.RS256, RsaUtils.getPrivateKey(privateKey))
.compact();
}
/**
* 公钥解析token
*
* @param token 用户请求中的token
* @param publicKey 公钥
* @return
* @throws Exception
*/
private static Jws<Claims> parserToken(String token, PublicKey publicKey) {
return Jwts.parser().setSigningKey(publicKey).parseClaimsJws(token);
}
/**
* 公钥解析token
*
* @param token 用户请求中的token
* @param publicKey 公钥字节数组
* @return
* @throws Exception
*/
private static Jws<Claims> parserToken(String token, byte[] publicKey) throws Exception {
return Jwts.parser().setSigningKey(RsaUtils.getPublicKey(publicKey))
.parseClaimsJws(token);
}
/**
* 获取token中的用户信息
*
* @param token 用户请求中的令牌
* @param publicKey 公钥
* @return 用户信息
* @throws Exception
*/
public static UserInfo getInfoFromToken(String token, PublicKey publicKey) throws Exception {
Jws<Claims> claimsJws = parserToken(token, publicKey);
Claims body = claimsJws.getBody();
return new UserInfo(
ObjectUtils.toLong(body.get(JwtConstans.JWT_KEY_ID)),
ObjectUtils.toString(body.get(JwtConstans.JWT_KEY_USER_NAME))
);
}
/**
* 获取token中的用户信息
*
* @param token 用户请求中的令牌
* @param publicKey 公钥
* @return 用户信息
* @throws Exception
*/
public static UserInfo getInfoFromToken(String token, byte[] publicKey) throws Exception {
Jws<Claims> claimsJws = parserToken(token, publicKey);
Claims body = claimsJws.getBody();
return new UserInfo(
ObjectUtils.toLong(body.get(JwtConstans.JWT_KEY_ID)),
ObjectUtils.toString(body.get(JwtConstans.JWT_KEY_USER_NAME))
);
}
}
package com.luo.utils;
import org.apache.commons.lang3.StringUtils;
/*类型转换类*/
public class ObjectUtils {
public static String toString(Object obj) {
if (obj == null) {
return null;
}
return obj.toString();
}
public static Long toLong(Object obj) {
if (obj == null) {
return 0L;
}
if (obj instanceof Double || obj instanceof Float) {
return Long.valueOf(StringUtils.substringBefore(obj.toString(), "."));
}
if (obj instanceof Number) {
return Long.valueOf(obj.toString());
}
if (obj instanceof String) {
return Long.valueOf(obj.toString());
} else {
return 0L;
}
}
public static Integer toInt(Object obj) {
return toLong(obj).intValue();
}
}
package com.luo.utils;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.security.*;
import java.security.spec.PKCS8EncodedKeySpec;
import java.security.spec.X509EncodedKeySpec;
/*加密工具类*/
public class RsaUtils {
/**
* 从文件中读取公钥
*
* @param filename 公钥保存路径,相对于classpath
* @return 公钥对象
* @throws Exception
*/
public static PublicKey getPublicKey(String filename) throws Exception {
byte[] bytes = readFile(filename);
return getPublicKey(bytes);
}
/**
* 从文件中读取密钥
*
* @param filename 私钥保存路径,相对于classpath
* @return 私钥对象
* @throws Exception
*/
public static PrivateKey getPrivateKey(String filename) throws Exception {
byte[] bytes = readFile(filename);
return getPrivateKey(bytes);
}
/**
* 获取公钥
*
* @param bytes 公钥的字节形式
* @return
* @throws Exception
*/
public static PublicKey getPublicKey(byte[] bytes) throws Exception {
X509EncodedKeySpec spec = new X509EncodedKeySpec(bytes);
KeyFactory factory = KeyFactory.getInstance("RSA");
return factory.generatePublic(spec);
}
/**
* 获取密钥
*
* @param bytes 私钥的字节形式
* @return
* @throws Exception
*/
public static PrivateKey getPrivateKey(byte[] bytes) throws Exception {
PKCS8EncodedKeySpec spec = new PKCS8EncodedKeySpec(bytes);
KeyFactory factory = KeyFactory.getInstance("RSA");
return factory.generatePrivate(spec);
}
/**
* 根据密文,生存rsa公钥和私钥,并写入指定文件
*
* @param publicKeyFilename 公钥文件路径
* @param privateKeyFilename 私钥文件路径
* @param secret 生成密钥的密文
* @throws IOException
* @throws NoSuchAlgorithmException
*/
public static void generateKey(String publicKeyFilename, String privateKeyFilename, String secret) throws Exception {
KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA");
SecureRandom secureRandom = new SecureRandom(secret.getBytes());
keyPairGenerator.initialize(1024, secureRandom);
KeyPair keyPair = keyPairGenerator.genKeyPair();
// 获取公钥并写出
byte[] publicKeyBytes = keyPair.getPublic().getEncoded();
writeFile(publicKeyFilename, publicKeyBytes);
// 获取私钥并写出
byte[] privateKeyBytes = keyPair.getPrivate().getEncoded();
writeFile(privateKeyFilename, privateKeyBytes);
}
private static byte[] readFile(String fileName) throws Exception {
return Files.readAllBytes(new File(fileName).toPath());
}
private static void writeFile(String destPath, byte[] bytes) throws IOException {
File dest = new File(destPath);
if (!dest.exists()) {
dest.createNewFile();
}
Files.write(dest.toPath(), bytes);
}
}
3.创建载荷类
public class UserInfo {
private Long id;
private String username;
}
4.在maven中引入JWT工具类
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.0</version>
</dependency>
<dependency>
<groupId>joda-time</groupId>
<artifactId>joda-time</artifactId>
</dependency>
5. 登录逻辑
- 客户端携带用户名和密码请求登录
- 授权中心调用客户中心接口,根据用户名和密码查询用户信息
- 如果用户名密码正确,能获取用户,否则为空,则登录失败
- 如果校验成功,则生成JWT并返回
在配置文件application.yml中配置jwt的信息
jwt:
secret: ly@Login(Auth}*^31)&hei% # 登录校验的密钥
pubKeyPath: D:/rsa/rsa.pub # 公钥地址
priKeyPath: D:/rsa/rsa.pri # 私钥地址
expire: 30 # token的过期时间,单位分钟
cookieName: LY_TOKEN #cookie名称
cookieMaxAge: 1800 #cookie过期时间
在编写配置类读取这些配置 并将公钥与私钥获取到
//构造函数之后调用,构造方法——>@Autowired——>@PostConstruct——>静态方法
@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();
}
}
创建controller层 接收登录请求
/**
* 登录的参数为用户名和密码
* 没有返回结果,登录成功后会以用户的信息为基础
* 生成token,token经过cookie来返回
*
* @param username
* @param password
* @return
*/
@PostMapping("accredit")
public ResponseEntity<Void> accredit(
@RequestParam("username") String username,
@RequestParam("password") String password,
HttpServletRequest request,
HttpServletResponse response){
//在数据库判断账号密码是否匹配 在根据账号信息生成jwt的token
String token = this.authService.accredit(username, password);
//生成token的逻辑
/*
//账号信息 只包含id和账号名 私钥(进行加密操作) token的过期时间
String token = JwtUtils.generateToken(new UserInfo(user.getId(), user.getUsername()),
jwtProps.getPrivateKey(), jwtProps.getExpire());
*/
// 登录校验
if (StringUtils.isBlank(token)) { //没找到token就返回401 登录失败
//401没有权限
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}
//将token写入cookie
//httpOnly, 由于js可以操作cookie,所以为了安全,拒绝被js操作,
// cookie的name jwt字符串 cookie的过期时间 只读
CookieUtils.setCookie(request, response, jwtProps.getCookieName(), token, jwtProps.getCookieMaxAge(), null, true);
return ResponseEntity.ok().build();
}
网关转发问题
直接访问登录逻辑可以,但是经过网关无法访问?
发现cookie的domain
属性似乎不太对
cookie也是有域
的限制,一个网页,只能操作当前域名下的cookie,但是现在我们看到的地址是0.0.1,而页面是www.leyou.com,域名不匹配,cookie设置肯定失败了!
解决办法
-
更改nginx配置,让它不要修改我们的host
-
修改application配置:
zuul:
add-host-header: true #携带请求本身的host头信息
sensitive-headers: #敏感头过滤将失效
- 修改网关的pom文件
<!--解决zuul中bug,使用老版本-->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-netflix-zuul</artifactId>
<version>2.0.0.RELEASE</version>
</dependency>
登录逻辑完成
6.登录成功后 每次跳转页面都会对当前用户进行校验
通过cookie获取token,然后校验通过返回用户信息
controller层
/**
* 使用注解直接获取对应的cookie的值
*
* 由于每次用户在前端操作都会执行verify方法,所以,我们可以在verify中重新生成token和cookie
*
* @return
*/
@GetMapping("verify")
public ResponseEntity<UserInfo> verify(@CookieValue("LY_TOKEN") String token,
HttpServletRequest request,HttpServletResponse response) {
try {
UserInfo userInfo = JwtUtils.getInfoFromToken(token,jwtProps.getPublicKey());
return ResponseEntity.ok(userInfo);
} catch (Exception e) {
e.printStackTrace();
}
// 抛出异常,证明token无效,直接返回401
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(null);
}
时间过期问题
每当用户在页面进行新的操作,都应该刷新token的过期时间,否则30分钟后用户的登录信息就无效了。而刷新其实就是重新生成一份token,然后写入cookie即可。
解决方法:
每当用户来查询其个人信息,就证明他正在浏览网页,此时刷新cookie是比较合适的时机。因此我们可以对刚刚的校验用户登录状态的接口进行改进,加入刷新token的逻辑。
//生成新的token
String newToken = JwtUtils.generateToken(userInfo, jwtProps.getPrivateKey(), jwtProps.getExpire());
//把token再一次保存到cookie中
CookieUtils.setCookie(request,response,jwtProps.getCookieName(),newToken,
jwtProps.getCookieMaxAge(),null,true);
网关的登录拦截
在项目中 有的页面在未登录状态无法访问,所以需要做网关的拦截
1. 将jwt等工具类引入到网关项目中
2. 配置application.yml文件
jwt:
pubKeyPath: D:/rsa/rsa.pub # 公钥地址
cookieName: LY_TOKEN # cookie的名称
3. 照样写一个工具类将公钥获取到
4. 编写过滤器逻辑
@Component
@EnableConfigurationProperties({JwtProperties.class})
public class LoginFilter extends ZuulFilter {
@Autowired
private JwtProperties jwtProps;
@Override
public String filterType() {
return FilterConstants.PRE_TYPE;
}
@Override
public int filterOrder() {
//请求头之前,查看请求参数
return FilterConstants.PRE_DECORATION_FILTER_ORDER-1;
}
/**
* 返回false过滤器不生效,返回true,生效
*
* 对于某些请求,应该放行,白名单
*
* @return
*/
@Override
public boolean shouldFilter() {
return true;
}
/**
* 没有酒驾,一切正常,放行,
* 拦截要处理,拦截就是不响应
*
* @return
* @throws ZuulException
*/
@Override
public Object run() throws ZuulException {
//获取当前请求的上下文对象
RequestContext currentContext = RequestContext.getCurrentContext();
HttpServletRequest request = currentContext.getRequest();
try {
//获取token值,看能不能正常获取
String token = CookieUtils.getCookieValue(request, jwtProps.getCookieName());
JwtUtils.getInfoFromToken(token,jwtProps.getPublicKey());
} catch (Exception e) {
e.printStackTrace();
//不能解析,说明没有登录,回状态码401
currentContext.setResponseStatusCode(401);
//说没有登录不响应
currentContext.setSendZuulResponse(false);
}
return null;
}
}
5. 设置白名单
- 在
application.yml
中添加规则:
ly:
filter:
allowPaths:
- /api/auth
- /api/search
- /api/user/register
- /api/user/check
- /api/user/code
- /api/item
- 写一个类去获取白名单信息
@ConfigurationProperties(prefix = "ly.filter")
public class FilterProperties {
private List<String> allowPaths;
public List<String> getAllowPaths() {
return allowPaths;
}
public void setAllowPaths(List<String> allowPaths) {
this.allowPaths = allowPaths;
}
}
- 在过滤器的shouldFilter() 方法编写对应逻辑
public boolean shouldFilter() {
//获取当前请求的上下文对象
RequestContext currentContext = RequestContext.getCurrentContext();
HttpServletRequest request = currentContext.getRequest();
///api/auth/accredit
String requestURI = request.getRequestURI();
//获取白名单集合
List<String> allowPaths = filterProps.getAllowPaths();
//迭代白名单数组,如果请求的uri以白名单中某一项开头,则过滤器不生效
for (String allowPath : allowPaths) {
if (requestURI.startsWith(allowPath)){
return false;
}
}
return true;
}
注
文章内容多是转载~~~