SpringSecurity系列学习(四):基于JWT的认证
系列导航
SpringSecurity系列
- SpringSecurity系列学习(一):初识SpringSecurity
- SpringSecurity系列学习(二):密码验证
- SpringSecurity系列学习(三):认证流程和源码解析
- SpringSecurity系列学习(四):基于JWT的认证
- SpringSecurity系列学习(四-番外):多因子验证和TOTP
- SpringSecurity系列学习(五):授权流程和源码分析
- SpringSecurity系列学习(六):基于RBAC的授权
SpringSecurityOauth2系列
- SpringSecurityOauth2系列学习(一):初认Oauth2
- SpringSecurityOauth2系列学习(二):授权服务
- SpringSecurityOauth2系列学习(三):资源服务
- SpringSecurityOauth2系列学习(四):自定义登陆登出接口
- SpringSecurityOauth2系列学习(五):授权服务自定义异常处理
基于JWT的认证
憋嗦话,上号!
咳咳,在上号之前,再聊两句。。。
分布式认证
分布式认证,即我们常说的单点登录,简称SSO,指的是在多应用系统的项目中,用户只需要登录一次,就可以访问所有互相信任的应用系统。
但是首先,我们要明确,在分布式项目中,每台服务器都有各自独立的session,而这些session之间是无法直接共享资源的,所以,session通常不能被作为单点登录的技术方案。
最合理的单点登录方案流程如下图所示:
总结一下,单点登录的实现分两大环节:
- 用户认证:这一环节主要是客户端向认证服务器发起认证请求,认证服务器给客户端返回一个成功的令牌token,主要在认证服务器中完成,认证服务器只能有一个。
- 身份校验:这一环节是客户端携带token去访问其他服务器时,在其他服务器中要对token的真伪进行检验,主要在资源服务器中完成,资源服务器可以有很多个。
JWT
从分布式认证流程中,我们不难发现,这中间起最关键作用的就是token,token的安全与否,直接关系到系统的健壮性,这里我们选择使用JWT来实现token的生成和校验。
JWT,全称JSON Web Token,官网地址https://jwt.io 是一款出色的分布式身份校验方案。可以生成token,也可以解析检验token。
JWT生成的token由三部分组成:
- 头部(header):主要设置一些规范信息,签名部分的编码格式就在头部中声明(用的什么加密算法)。
- 载荷(payload):token中存放有效信息的部分,比如用户名,用户角色,过期时间等,但是不要放密码,会泄露!
- 签名(sign):将头部与载荷分别采用base64编码后,用“.”相连,再加入盐,最后使用头部声明的编码类型进行编码,就得到了签名。
其中JWT在荷载中已经声明的字段:
lss
:签发者exb
:过期时间sub
:主题aud
:目标受众- ...
还可以使用 Jwts.claim(key,value)
添加字段
从JWT生成的token组成上来看,要想避免token被伪造,主要就得看签名部分了,而签名部分又有三部分组成,其中头部和载荷的base64编码,几乎是透明的,毫无安全性可言,那么最终守护token安全的重担就落在了加入的盐上面了!
试想:如果生成token所用的盐与解析token时加入的盐是一样的。岂不是类似于中国人民银行把人民币防伪技术公开了?大家可以用这个盐来解析token,就能用来伪造token。
这时,我们就需要对盐采用非对称加密的方式进行加密,以达到生成token与校验token方所用的盐不一致的安全效果!
非对称加密
基本原理:同时生成两把密钥:私钥和公钥,私钥隐秘保存,公钥可以下发给信任客户端
- 私钥加密,持有公钥才可以解密
- 公钥加密,持有私钥才可解密
优点:安全,难以破解
缺点:算法比较耗时,为了安全,可以接受
缩写:RSA。
对称加密算法不能实现签名,因此签名只能非对称算法。
实战
具体的代码可以看githubspring-security-demo
引入依赖
我们首先开始实现JWT的创建,新建一个项目,引入依赖
Springboot:2.4.3
SpringSecurity:5.4.5
Mybatis-plus:3.4.3
mysql-connector:8.0.25
<?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">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.4.3</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>com.cupricnitrate</groupId>
<artifactId>spring-security-demo</artifactId>
<version>1.0.0</version>
<properties>
<maven.compiler.source>8</maven.compiler.source>
<maven.compiler.target>8</maven.compiler.target>
<jjwt.version>0.11.2</jjwt.version>
<mybatis.plus.version>3.4.3</mybatis.plus.version>
<mysql.version>8.0.25</mysql.version>
</properties>
<dependencies>
<!--SpringSecurity-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<!--SpringBootWeb-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!--lombok-->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<!--参数验证-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<!--ORM-->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>${mybatis.plus.version}</version>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>${mysql.version}</version>
</dependency>
<!--jwt相关依赖-->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>${jjwt.version}</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>${jjwt.version}</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>${jjwt.version}</version>
<scope>runtime</scope>
</dependency>
<!--自定义配置属性自动补全-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-configuration-processor</artifactId>
<optional>true</optional>
</dependency>
</dependencies>
</project>
创建JWT
上文说到,JWT中有一个荷载(payload)部分,其包含了有效信息,比如用户名,用户角色,过期时间等。这里定义一个荷载实体类
/**
* 荷载类
* 为了方便后期获取token中的用户信息,将token中载荷部分单独封装成一个对象
* @author 硝酸铜
* @date 2021/9/22
*/
@Data
public class Payload<T> {
private String id;
private T userInfo;
private Date expiration;
private Date issuedAt;
}
/**
* 荷载中的数据
* @author 硝酸铜
* @date 2021/9/22
*/
@Data
@AllArgsConstructor
@NoArgsConstructor
@Builder
public class ClaimInfo {
/**
* 用户名
*/
private String username;
/**
* 权限
*/
private List<ClaimAuthority> authorities;
@Data
public static class ClaimAuthority implements GrantedAuthority{
private String authority;
@Override
public String getAuthority() {
return this.authority;
}
}
}
这里的ClaimAuthority
类就是权限,其需要实现SpringSecurity的接口GrantedAuthority
,SpringSecurity就是通过getAuthority()
来读取权限的
JWT的签名部分,推荐使用非对称加密的形式,非对称加密的形式更为安全,当然在安全性要求没有这么高的情况下,不使用非对称加密也是可以的。
这里实现一下非对称加密的工具类,便于生成JWT
package com.cupricnitrate.util;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.security.*;
import java.security.spec.InvalidKeySpecException;
import java.security.spec.PKCS8EncodedKeySpec;
import java.security.spec.X509EncodedKeySpec;
import java.util.Base64;
/**
* RSA工具类
*
* @author 硝酸铜
* @date 2021/9/22
*/
public class RsaUtils {
private static final int DEFAULT_KEY_SIZE = 2048;
/**
* 从文件中读取公钥
*
* @param filename 公钥保存路径
* @return 公钥对象
* @throws Exception
*/
public static PublicKey getPublicKey(String filename) throws Exception {
byte[] bytes = readFile(filename);
return getPublicKey(bytes);
}
/**
* 从文件中读取密钥
*
* @param filename 私钥保存路径
* @return 私钥对象
* @throws Exception
*/
public static PrivateKey getPrivateKey(String filename) throws Exception {
byte[] bytes = readFile(filename);
return getPrivateKey(bytes);
}
/**
* 获取公钥
*
* @param bytes 公钥的字节形式
* @return
* @throws Exception
*/
private static PublicKey getPublicKey(byte[] bytes) throws Exception {
bytes = Base64.getDecoder().decode(bytes);
X509EncodedKeySpec spec = new X509EncodedKeySpec(bytes);
KeyFactory factory = KeyFactory.getInstance("RSA");
return factory.generatePublic(spec);
}
/**
* 获取密钥
*
* @param bytes 私钥的字节形式
* @return
* @throws Exception
*/
private static PrivateKey getPrivateKey(byte[] bytes) throws NoSuchAlgorithmException,
InvalidKeySpecException {
bytes = Base64.getDecoder().decode(bytes);
PKCS8EncodedKeySpec spec = new PKCS8EncodedKeySpec(bytes);
KeyFactory factory = KeyFactory.getInstance("RSA");
return factory.generatePrivate(spec);
}
/**
* 根据密文,生成rsa公钥和私钥,并写入指定文件
*
* @param publicKeyFilename 公钥文件绝对路径,比如:xxx/xxx/rsa_key.pub
* @param privateKeyFilename 私钥文件绝对路径,比如:xxx/xxx/rsa_key
* @param secret 生成密钥的密文
*/
public static void generateKey(String publicKeyFilename, String privateKeyFilename, String
secret, int keySize) throws Exception {
KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA");
SecureRandom secureRandom = new SecureRandom(secret.getBytes());
keyPairGenerator.initialize(Math.max(keySize, DEFAULT_KEY_SIZE), secureRandom);
KeyPair keyPair = keyPairGenerator.genKeyPair();
// 获取公钥并写出
byte[] publicKeyBytes = keyPair.getPublic().getEncoded();
publicKeyBytes = Base64.getEncoder().encode(publicKeyBytes);
writeFile(publicKeyFilename, publicKeyBytes);
// 获取私钥并写出
byte[] privateKeyBytes = keyPair.getPrivate().getEncoded();
privateKeyBytes = Base64.getEncoder().encode(privateKeyBytes);
writeFile(privateKeyFilename, privateKeyBytes);
}
public static void generateKey(String publicKeyFilename, String privateKeyFilename, String
secret) throws Exception {
generateKey(publicKeyFilename,privateKeyFilename,secret,DEFAULT_KEY_SIZE);
}
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);
File dir = dest.getParentFile();
//判断目录是否存在,不在则新建
if(!dir.exists()){
dir.mkdirs();
}
//判断文件是否存在,不在则新建
if (!dest.exists()) {
dest.createNewFile();
}
Files.write(dest.toPath(), bytes);
}
}
有了这个工具类,就可以获取公钥,私钥了
接下来,实现一个生成JWT的工具类
package com.cupricnitrate.util;
import com.cupricnitrate.model.Authority;
import com.cupricnitrate.model.ClaimInfo;
import com.cupricnitrate.model.Payload;
import com.cupricnitrate.model.User;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.jsonwebtoken.*;
import io.jsonwebtoken.security.Keys;
import io.jsonwebtoken.security.SignatureException;
import java.security.Key;
import java.security.PrivateKey;
import java.security.PublicKey;
import java.util.*;
/**
* 生成token以及校验token相关方法
*
* @author 硝酸铜
* @date 2021/9/22
*/
public class JwtUtils {
private static final String JWT_PAYLOAD_USER_KEY = "user";
// 用于HS512加密 签名的key
public static final Key key = Keys.secretKeyFor(SignatureAlgorithm.HS512);
/**
* 私钥加密token
*
* @param claimInfo 载荷中的数据
* @param key key
* @param expire 过期时间,单位ms
* @return JWT
*/
public static String generateTokenExpire(Object claimInfo,
Key key,
long expire,
String id) {
long now = System.currentTimeMillis();
return Jwts.builder()
.claim(JWT_PAYLOAD_USER_KEY, claimInfo)
.setId(id)
.setExpiration(new Date(now + expire))
.setIssuedAt(new Date(now))
//RS256加密
.signWith(key, SignatureAlgorithm.RS256)
//如果使用HS512加密则使用这个
//.signWith(key, SignatureAlgorithm.HS512).compact();
.compact();
}
/**
* 解析token
*
* @param token 用户请求中的token
* @param key key
* @return Jws<Claims>
*/
public static Jws<Claims> parserToken(String token, Key key) {
return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
}
public static String createJTI() {
return new String(Base64.getEncoder().encode(UUID.randomUUID().toString().getBytes()));
}
/**
* 获取token中的用户信息
*
* @param token 用户请求中的令牌
* @param key key
* @return 用户信息
*/
public static <T> Payload<T> getInfoFromToken(String token, Key key, Class<T> userType) {
Jws<Claims> claimsJws = parserToken(token, key);
Claims body = claimsJws.getBody();
Payload<T> claims = new Payload<>();
claims.setId(body.getId());
ObjectMapper objectMapper = new ObjectMapper();
claims.setUserInfo(objectMapper.convertValue(body.get(JWT_PAYLOAD_USER_KEY),userType));
claims.setExpiration(body.getExpiration());
claims.setIssuedAt(body.getIssuedAt());
return claims;
}
/**
* 获取token中的载荷信息
*
* @param token 用户请求中的令牌
* @param key key
* @return 用户信息
*/
public static <T> Payload<T> getInfoFromToken(String token, Key key) {
Jws<Claims> claimsJws = parserToken(token, key);
Claims body = claimsJws.getBody();
Payload<T> claims = new Payload<>();
claims.setId(body.getId());
claims.setExpiration(body.getExpiration());
claims.setIssuedAt(body.getIssuedAt());
return claims;
}
/**
* 验证 token,忽略过期
*
* @param jwtToken token
* @param key key
* @return boolean
*/
public static boolean validateWithoutExpiration(String jwtToken, Key key) {
try {
Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(jwtToken);
return true;
} catch (ExpiredJwtException | SignatureException | MalformedJwtException | UnsupportedJwtException | IllegalArgumentException e) {
if (e instanceof ExpiredJwtException) {
return true;
}
}
return false;
}
/**
* 验证token
*
* @param jwtToken token
* @param key key
* @return boolean
*/
public static boolean validateToken(String jwtToken, Key key) {
try {
Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(jwtToken);
return true;
} catch (ExpiredJwtException | SignatureException | MalformedJwtException | UnsupportedJwtException | IllegalArgumentException e) {
return false;
}
}
}
写一个main
方法测试一下
public static void main(String[] args) throws Exception {
//生成访问令牌公钥和私钥文件
String keyPublicFilePath = "/Users/xiaoshengpeng/auth_key/key/rsa_key.pub";
String keyPrivateFilePath = "/Users/xiaoshengpeng/auth_key/key/rsa_key";
//RsaUtils.generateKey(keyPublicFilePath, keyPrivateFilePath, "CupricNitrate Key Token");
//生成刷新令牌公钥和私钥文件
String refreshPublicFilePath = "/Users/xiaoshengpeng/auth_key/refresh/rsa_key.pub";
String refreshPrivateFilePath = "/Users/xiaoshengpeng/auth_key/refresh/rsa_key";
//RsaUtils.generateKey(refreshPublicFilePath, refreshPrivateFilePath, "CupricNitrate Refresh Token");
//模拟加密生成token
PublicKey publicKey = RsaUtils.getPublicKey(keyPublicFilePath);
PrivateKey privateKey = RsaUtils.getPrivateKey(keyPrivateFilePath);
//权限设置
List<ClaimInfo.ClaimAuthority> authorities = new ArrayList<>();
ClaimInfo.ClaimAuthority authority = new ClaimInfo.ClaimAuthority();
authority.setAuthority("ROLE_USER");
authorities.add(authority);
//荷载数据
ClaimInfo claimInfo = ClaimInfo.builder()
.username("user")
.authorities(authorities)
.build();
//生成token
String token = JwtUtils.generateTokenExpire(claimInfo, privateKey, 24 * 60 * 60 * 1000, createJTI());
System.out.println("token: " + token);
//模拟解密从token中获取用户信息
ObjectMapper objectMapper = new ObjectMapper();
//序列化时忽略值为null的属性
objectMapper.setSerializationInclusion(JsonInclude.Include.NON_NULL);
Payload<User> payload = JwtUtils.getInfoFromToken(token,
publicKey, User.class);
User user1 = payload.getUserInfo();
System.out.println("user: " + objectMapper.writeValueAsString(user1));
}
控制台输出:
token: eyJhbGciOiJSUzI1NiJ9.eyJ1c2VyIjp7InVzZXJuYW1lIjoidXNlciIsImF1dGhvcml0aWVzIjpbeyJhdXRob3JpdHkiOiJST0xFX1VTRVIifV19LCJqdGkiOiJPV1F6Tnpoa01HWXRNbVpoWWkwME9HUTRMV0ptTWpVdFl6TTVPRGczT1dVMk1XRTMiLCJleHAiOjE2MzI0NDk3NDgsImlhdCI6MTYzMjM2MzM0OH0.Isga_gn8JcskbFsIrzYWuzB-oLusSD7kUM6_gEr8TP5y7RrB0eS2l7YoNCox0xsdVBkf2ANn-zwqYvlqy3bDFCVgbNiNjUiZ3YiD0MJliR2J1Ci2sg0Tjt98EnxkzaS07rg9kfhrGhZ0vdb_EwMVmms1o6-a-gj1baOFsF3_ZmBL_rR4lDDPY5R86SUTJdVksZTBsnxv4bmqV15asnaJg8_f6mYuCH-dMeJ836G_EFgGu4XsqC_n5Fkm9fxkwzpzLdTlAXawgdiPeNhxwn_8bHcWhnP0m62L1pQj39mj15ghISAUs0EWlFP6DKNddCAQf3gDjfVPt5f-CSTv0JD04Q
user: {"username":"user","authorities":[{"authority":"ROLE_USER"}],"accountNonExpired":true,"accountNonLocked":true,"credentialsNonExpired":true,"enabled":true}
使用这个token在jwt.io上解析:
这里可以看到JWT是可以通过被解析出来的,其非对称加密只是将签名进行加密,在验签的时候起到安全防护的作用。所以不要在JWT中存放敏感信息!
访问令牌和刷新令牌
上文的main
方法中,我生成了两套公私钥,访问令牌公私钥和刷新令牌公私钥,为什么需要两个令牌呢?我使用一个令牌不就可以了吗?当然可以,但是使用两个令牌更为安全!
令牌一旦签发出去之后,就默认已经暴露出去了,因为不论我们保存在客户端还是服务器中,别人想要获取这个令牌,是很容易的。
所以,对于公开的令牌,我们采用的策略就是减少它的生命周期,比如说5分钟有效,这样别人拿到令牌之后,这个令牌可能已经过期了,令牌泄漏了也不会造成太大的影响。
还有就是在服务端做一些异常检查,因为一个安全系统,是不能单纯的依赖一个单一的手段的,比如还需要在服务端中检查高频访问的IP这些东西,做一些限制。综合来判断是否有人盗取了用户的信息,防止恶意用户的攻击。
- 访问令牌(直接开门的钥匙):声明周期要短,一般在几分钟到几小时之间,防止令牌暴露之后,黑客为所欲为
- 刷新令牌(不能直接开门,是用来生成新的访问令牌用的):生命周期会长,应该在几周到不超过一年
- 上面两种令牌使用不同的key签发
- 当访问令牌过期之后,使用刷新令牌去生成新的访问令牌。由于生成新的访问令牌需要同时验证刷新令牌和过期的访问令牌,所以就降低了令牌暴露的危险。
创建JWT过滤器
SpringSecurityOauth2有内建的JwtFilter
,并且支持得很好
但是SpringSecurity里面没有单体应用的JwtFilter
,需要我们自己实现
- 认证成功就是把
Authentication
对象setAuthenticated(true)
,然后存到SecurityContext
中。 - 认证失败就是清空
SecurityContext
然后交给下一个Filter处理
首先自定义一个属性,便于token相关的配置
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
/**
* token配置属性类
* @author 硝酸铜
* @date 2021/9/22
*/
@Data
@Component
@ConfigurationProperties(prefix = TokenPropertities.PREFIX)
public class TokenProperties {
public static final String PREFIX = "token";
/**
* Http报头中令牌自定义标识,默认:Authorization
*/
private String header = "Authorization";
/**
* Http报头中令牌自定义标识中的开头,默认:Bearer
*/
private String prefix = "Bearer";
/**
* 访问令牌相关属性
*/
private AccessToken access;
/**
* 刷新令牌相关属性
*/
private RefreshToken refresh;
@Data
public static class AccessToken{
/**
* 访问令牌过期时间,单位ms,默认60s
*/
private Long expireTime = 60 * 1000L;
/**
* 访问令牌私钥文件访问路径,比如/user/auth_key/rsa_key
*/
private String privateKey;
/**
* 访问令牌公钥文件访问路径,比如/user/auth_key/rsa_key.pub
*/
private String publicKey;
}
@Data
public static class RefreshToken{
/**
* 刷新令牌过期时间,单位ms,默认30天
*/
private Long expireTime = 30 * 24 * 60 * 60 * 1000L;
/**
* 访问令牌私钥文件访问路径,比如/user/auth_key/rsa_key
*/
private String privateKey;
/**
* 访问令牌公钥文件访问路径,比如/user/auth_key/rsa_key.pub
*/
private String publicKey;
}
}
然后在application.yaml
中将其配置好
spring:
datasource:
type: com.zaxxer.hikari.HikariDataSource
#mysql驱动8.x版本使用com.mysql.cj.jdbc.Driver
#5.x使用com.mysql.jdbc.Driver
driver-class-name: com.mysql.cj.jdbc.Driver
#数据库地址
url: jdbc:mysql://localhost:3306/demo?useUnicode=true&characterEncoding=utf-8&useSSL=true&serverTimezone=UTC
#数据库账号
username: root
#数据库密码
password: root
#hikari连接池
hikari:
#2*cpu
maximum-pool-size: 16
#cpu
minimum-idle: 8
data-source-properties:
cachePrepStmts: true
prepStmtCacheSize: 250
prepStmtCacheSqlLimit: 2048
useServerPrepStmts: true
token:
access:
public-key: /Users/xiaoshengpeng/auth_key/key/rsa_key.pub
private-key: /Users/xiaoshengpeng/auth_key/key/rsa_key
refresh:
public-key: /Users/xiaoshengpeng/auth_key/refresh/rsa_key.pub
private-key: /Users/xiaoshengpeng/auth_key/refresh/rsa_key
接下来我们来实现签发token的步骤。
一般我们我们将签发token的步骤写在登录中
还记得我们怎么自定义认证逻辑的吗?
创建一个Jwt认证过滤器,使其继承UsernamePasswordAuthenticationFilter
import com.cupricnitrate.config.property.TokenProperties;
import com.cupricnitrate.http.req.LoginReqDto;
import com.cupricnitrate.http.resp.LoginRespDto;
import com.cupricnitrate.model.Authority;
import com.cupricnitrate.model.ClaimInfo;
import com.cupricnitrate.util.JwtUtils;
import com.cupricnitrate.util.RsaUtils;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.io.InputStream;
import java.io.PrintWriter;
import java.security.PrivateKey;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
/**
* Jwt认证过滤器
* @author 硝酸铜
* @date 2021/9/22
*/
public class JwtAuthenticationFilter extends UsernamePasswordAuthenticationFilter {
private final AuthenticationManager authenticationManager;
private final TokenProperties tokenProperties;
public JwtAuthenticationFilter(AuthenticationManager authenticationManager,TokenProperties tokenProperties) {
this.authenticationManager = authenticationManager;
this.tokenProperties = tokenProperties;
// 浏览器访问 /authorize/login 会通过 JWTAuthenticationFilter
setFilterProcessesUrl("/authorize/login");
}
/**
* json格式:
*
* {
* "username": "user",
* "password": "12345678"
* }
*
* @param request 请求体
* @param response 返回体
* @return Authentication
* @throws AuthenticationException 认证异常
*/
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
InputStream is = null;
LoginReqDto req = null;
try {
//从Body中读取参数
is = request.getInputStream();
//使用jackson解析json
ObjectMapper objectMapper = new ObjectMapper();
req = objectMapper.readValue(is,LoginReqDto.class);
} catch (IOException e) {
e.printStackTrace();
throw new BadCredentialsException("json格式错误,没有找到用户名或密码");
}
//认证,同父类,生成一个没有被完全初始化的Authentication
UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(req.getUsername(), req.getPassword());
this.setDetails(request, authRequest);
return authenticationManager.authenticate(authRequest);
}
/**
* 认证成功逻辑
*/
@Override
protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException {
//令牌私钥
PrivateKey accessPrivateKey = null;
PrivateKey refreshPrivateKey = null;
try {
accessPrivateKey = RsaUtils.getPrivateKey(tokenProperties.getAccess().getPrivateKey());
refreshPrivateKey = RsaUtils.getPrivateKey(tokenProperties.getRefresh().getPrivateKey());
} catch (Exception e) {
e.printStackTrace();
}
//创建荷载信息
List<ClaimInfo.ClaimAuthority> authorities = authResult.getAuthorities().stream().map(a -> {
ClaimInfo.ClaimAuthority claimAuthority = new ClaimInfo.ClaimAuthority();
claimAuthority.setAuthority(a.getAuthority());
return claimAuthority;
}).collect(Collectors.toList());
ClaimInfo claim = ClaimInfo.builder().username(authResult.getName()).authorities(authorities).build();
//签发token,使用私钥进行签发
LoginRespDto respDto = new LoginRespDto(
JwtUtils.generateTokenExpire(claim, accessPrivateKey,tokenProperties.getAccess().getExpireTime(), JwtUtils.createJTI()),
JwtUtils.generateTokenExpire(claim, refreshPrivateKey,tokenProperties.getRefresh().getExpireTime(),JwtUtils.createJTI()));
try {
//登录成功時,返回json格式进行提示
response.setContentType("application/json;charset=utf-8");
response.setStatus(HttpServletResponse.SC_OK);
PrintWriter out = response.getWriter();
Map<String, Object> map = new HashMap<>();
map.put("code", HttpServletResponse.SC_OK);
map.put("message", "登陆成功!");
map.put("token",respDto);
out.write(new ObjectMapper().writeValueAsString(map));
out.flush();
out.close();
} catch (Exception e1) {
e1.printStackTrace();
}
}
/**
* 登录失败逻辑
*/
@Override
protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, AuthenticationException failed) throws IOException, ServletException {
try {
//登录成功時,返回json格式进行提示
response.setContentType("application/json;charset=utf-8");
response.setStatus(HttpServletResponse.SC_OK);
PrintWriter out = response.getWriter();
Map<String, Object> map = new HashMap<>();
map.put("code", HttpServletResponse.SC_FORBIDDEN);
map.put("message", "登陆失败!");
map.put("reason",failed.getMessage());
out.write(new ObjectMapper().writeValueAsString(map));
out.flush();
out.close();
} catch (Exception e1) {
e1.printStackTrace();
}
}
}
具体逻辑可以看注释,很清晰了
完成了认证逻辑,还需要实现一个验证token的过滤器,客户端在登陆后,请求接口的时候,在Http Header中带上Authorization:Bearer XXX
(xxx是JWT)即可通过认证
import com.cupricnitrate.config.property.TokenProperties;
import com.cupricnitrate.model.ClaimInfo;
import com.cupricnitrate.model.Payload;
import com.cupricnitrate.util.JwtUtils;
import com.cupricnitrate.util.RsaUtils;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.SneakyThrows;
import org.springframework.security.authentication.AnonymousAuthenticationToken;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.authentication.www.BasicAuthenticationFilter;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.io.PrintWriter;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* @author 硝酸铜
* @date 2021/9/22
*/
public class JwtVerifyFilter extends BasicAuthenticationFilter {
private TokenProperties tokenProperties;
public JwtVerifyFilter(AuthenticationManager authenticationManager, TokenProperties tokenProperties) {
super(authenticationManager);
this.tokenProperties = tokenProperties;
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException {
if (checkJwtToken(request)) {
try {
//获取权限失败,会抛出异常
UsernamePasswordAuthenticationToken authentication = getAuthentication(request);
//获取后,将Authentication写入SecurityContextHolder中供后序使用
SecurityContextHolder.getContext().setAuthentication(authentication);
chain.doFilter(request, response);
} catch (Exception e) {
responseJson(response);
e.printStackTrace();
}
} else {
//token不在请求头中,则说明是匿名用户访问
List<GrantedAuthority> list = new ArrayList<>();
GrantedAuthority grantedAuthority = () -> "ROLE_ANONYMOUS";
list.add(grantedAuthority);
AnonymousAuthenticationToken authentication = new AnonymousAuthenticationToken("token-anonymousUser","anonymousUser", list);
SecurityContextHolder.getContext().setAuthentication(authentication);
chain.doFilter(request, response);
}
}
/**
* 检查JWT Token 是否在HTTP 报头中
*
* @param request HTTP请求
* @return boolean
*/
private boolean checkJwtToken(HttpServletRequest request) {
String header = request.getHeader(tokenProperties.getHeader());
return header != null && header.startsWith(tokenProperties.getPrefix());
}
/**
* 未登录提示
*
* @param response
*/
private void responseJson(HttpServletResponse response) {
try {
//未登录提示
response.setContentType("application/json;charset=utf-8");
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
PrintWriter out = response.getWriter();
Map<String, Object> map = new HashMap<String, Object>();
map.put("code", HttpServletResponse.SC_FORBIDDEN);
map.put("message", "未登录或登录过期,请进行登录!");
out.write(new ObjectMapper().writeValueAsString(map));
out.flush();
out.close();
} catch (Exception e1) {
e1.printStackTrace();
}
}
/**
* 通过token,获取用户信息
*
* @param request
* @return
*/
@SneakyThrows
private UsernamePasswordAuthenticationToken getAuthentication(HttpServletRequest request) {
//读取请求头中的Authorization的值
String token = request.getHeader("Authorization");
if (token != null) {
//Authorization 中JWT传参,默认格式 Authorization:Bearer XXX
token = token.replaceFirst(tokenProperties.getPrefix(), "");
//通过token解析出载荷信息,使用公钥进行解析
Payload<ClaimInfo> payload = JwtUtils.getInfoFromToken(token, RsaUtils.getPublicKey(tokenProperties.getAccess().getPublicKey()), ClaimInfo.class);
ClaimInfo claimInfo = payload.getUserInfo();
//不为null,返回一个完全初始化的Authentication
if (claimInfo != null) {
return new UsernamePasswordAuthenticationToken(claimInfo.getUsername(), null, claimInfo.getAuthorities());
}
return null;
}
return null;
}
}
最后,将这两个安全过滤器设置到SpringSecurity的安全配置中即可
/**
* @author 硝酸铜
* @date 2021/9/22
*/
@EnableWebSecurity(debug = true)
@Slf4j
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Resource
private TokenProperties tokenProperties;
...
@Override
protected void configure(HttpSecurity http) throws Exception {
http
//禁用生成默认的登陆页面
.formLogin(AbstractHttpConfigurer::disable)
//关闭httpBasic,采用自定义过滤器
.httpBasic(AbstractHttpConfigurer::disable)
//前后端分离架构不需要csrf保护,这里关闭
.csrf(AbstractHttpConfigurer::disable)
//禁用生成默认的注销页面
.logout(AbstractHttpConfigurer::disable)
.authorizeRequests(req -> req
//允许访问authorize url下的所有接口
.antMatchers("/authorize/**").permitAll()
.anyRequest().authenticated()
)
//添加我们自定义的过滤器,替代UsernamePasswordAuthenticationFilter
.addFilterAt(new JwtAuthenticationFilter(authenticationManager(),tokenProperties), UsernamePasswordAuthenticationFilter.class)
//添加token检验过滤器
.addFilter(new JwtVerifyFilter(authenticationManager(),tokenProperties))
//前后端分离是无状态的,不用session了,直接禁用。
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);
}
...
}
刷新令牌接口
登陆是获取令牌的接口,那么访问令牌过期了,需要使用刷新令牌去刷新令牌
我们接下来实现一下刷新令牌的接口
/**
* @author 硝酸铜
* @date 2021/9/22
*/
@RestController
@RequestMapping("/authorize")
public class AuthorizeController {
...
/**
* 刷新访问令牌
* @param req 请求体
* @param authorization 访问令牌
* @return LoginRespDto
*/
@PostMapping(value = "/refreshToken")
public LoginRespDto refreshToken(@Validated @RequestBody RefreshTokenReqDto req, @RequestHeader(name = "Authorization") String authorization){
return tokenService.refreshToken(authorization.replaceFirst("Bearer ", ""),req.getRefreshToken());
}
...
}
/**
* @author 硝酸铜
* @date 2021/9/22
*/
@Data
public class RefreshTokenReqDto implements Serializable {
private static final long serialVersionUID = 8410311036049755024L;
/**
* 刷新令牌
*/
@NotBlank
private String refreshToken;
}
import org.springframework.security.access.AccessDeniedException;
import org.springframework.stereotype.Service;
import javax.annotation.Resource;
import java.security.PrivateKey;
import java.security.PublicKey;
import java.util.Optional;
/**
* @author 硝酸铜
* @date 2021/9/22
*/
@Service
public class TokenService {
@Resource
private TokenProperties tokenProperties;
/**
* 使用刷新token创建访问token
* @param token 访问token
* @param refreshToken 刷新token
* @return 访问token
*/
public LoginRespDto refreshToken(String token, String refreshToken){
LoginRespDto resp = new LoginRespDto();
//获取公钥和私钥
PublicKey accessPublicKey = null;
PrivateKey accessPrivateKey = null;
PublicKey refreshPublicKey = null;
PrivateKey refreshPrivateKey = null;
try {
//访问令牌公钥
accessPublicKey = RsaUtils.getPublicKey(tokenProperties.getAccess().getPublicKey());
//访问令牌私钥
accessPrivateKey = RsaUtils.getPrivateKey(tokenProperties.getAccess().getPrivateKey());
//刷新令牌公钥
refreshPublicKey = RsaUtils.getPublicKey(tokenProperties.getRefresh().getPublicKey());
} catch (Exception e) {
e.printStackTrace();
}
//解析刷新令牌并生成新的访问令牌
if(JwtUtils.validateWithoutExpiration(token,accessPublicKey) &&
JwtUtils.validateToken(refreshToken,refreshPublicKey)){
PrivateKey key = accessPrivateKey;
//生成新的访问令牌
String accessToken = Optional.ofNullable(JwtUtils.parserToken(refreshToken, refreshPublicKey))
.map(claims ->
JwtUtils.generateTokenExpire(claims.getBody(),
key,
tokenProperties.getAccess().getExpireTime(),
JwtUtils.createJTI()))
.orElseThrow(() -> new AccessDeniedException("访问被拒绝"));
resp.setAccessToken(accessToken);
}
return resp;
}
}
调用接口
我们来验证一下,先进行登陆:
这个时候访问接口是可以通过认证的
等待一定时间,访问令牌过期
这个时候,调用刷新令牌接口,生成新的访问令牌
使用新的访问令牌,又能通过认证了
到此为止,关于认证的事情我们就做完了
接下来我们来学习一下授权的事情