一、常用术语
1.1 SSO
SSO 术语介绍:
单点登录 (SingleSignOn,SSO),就是通过用户的一次性鉴别登录。当用户在身份认证服务器上登录一次以后,即可获得访问单点登录系统中其他关联系统和应用软件的权限,同时这种实现是不需要管理员对用户的登录状态或其他信息进行修改的,这意味着在多个应用系统中,用户只需一次登录就可以访问所有相互信任的应用系统。这种方式减少了由登录产生的时间消耗,辅助了用户管理,是目前比较流行的一种分布式登录方式。
SSO 实现流程:
首先,我们要明确,在分布式项目中,每台服务器都有各自独立的 session,而这些 session 之间是无法直接共享资源的,所以,session 通常不能被作为单点登录的技术方案。最合理的单点登录方案流程如下图所示:
单点登录的实现分两大环节:
用户认证:这一环节主要是用户向认证服务器发起认证请求,认证服务器给用户返回一个成功的令牌 token, 主要在认证服务器中完成,即图中的 A 系统,注意 A 系统只能有一个。
身份校验:这一环节是用户携带 token 去访问其他服务器时,在其他服务器中要对 token 的真伪进行检验,主要在资源服务器中完成,即图中的 B 系统,这里 B 系统可以有很多个。
1.2 JWT
JWT 是 JSON WEB TOKEN 的缩写,它是基于 RFC 7519 标准定义的一种可以安全传输的的JSON对象,由于使用了数字签名,所以是可信任和安全的。
从分布式认证流程中,我们不难发现,这中间起最关键作用的就是 token,token 的安全与否,直接关系到系统的健壮性,这里我们选择使用 JWT 来实现 token 的生成和校验。JWT,全称 JSON Web Token,官网地址:https://jwt.io,是一款出色的分布式身份校验方案,可以生成 token,也可以解析检验 token。
JWT 生成的 token 由三部分组成(JWT token的格式:header.payload.signature):
头部:主要设置一些规范信息,签名部分的编码格式就在头部中声明。
载荷:token 中存放有效信息的部分,比如用户名,用户角色,过期时间等,但是不要放密码,会泄露密码。
签名:将头部与载荷分别采用 base64 编码后,用“.”相连,再加入盐,最后使用头部声明的编码类型进行编码,就得到了签名。
那么,一个完整的 jwt 字符串到底长成什么样呢?
JWT 生成的 token 的安全性分析:
从 JWT 生成的 token 组成上来看,要想避免 token 被伪造,主要就得看签名部分了,而签名部分又有三部分组成,其中头部和载荷的 base64 编码,几乎是透明的,毫无安全性可言,那么最终守护 token 安全的重担就落在了加入的盐上面了,试想,如果生成 token 所用的盐与解析 token 时加入的盐是一样的。岂不是类似于中国人民银行把人民币防伪技术公开了?大家可以用这个盐来解析 token,就能用来伪造 token。 这时,我们就需要对盐采用非对称加密的方式进行加密,以达到生成 token 与校验 token 方所用的盐不一致的安全效果!
注意:加盐的意思就是让味道改变,也就是让通过加盐来提高 token 的复杂度,让 token 更加安全,这个盐你可以任意指定,全凭自己和项目需求。
JWT实现认证和授权的原理
用户调用登录接口,登录成功后获取到 JWT 的 token;
之后用户每次调用接口都在 http 的 header 中添加一个叫 Authorization 的头,值为 JWT 的 token;
后台程序通过对 Authorization 头中信息的解码及数字签名校验来获取其中的用户信息,从而实现认证和授权。
1.3 RSA
RSA术语介绍:
1976 年,两位美国计算机学家 Whitfield Diffie 和 Martin Hellman,提出了一种崭新构思,可以在不直接传递密钥的情况下,完成解密。这被称为 "Diffie-Hellman 密钥交换算法"。这个算法启发了其他科学家。人们认识到,加密和解密可以使用不同的规则,只要这两种规则之间存在某种对应关系即可,这样就避免了直接传递密钥。这种新的加密模式被称为"非对称加密算法"。
(1)乙方生成两把密钥(公钥和私钥)。公钥是公开的,任何人都可以获得,私钥则是保密的。
(2)甲方获取乙方的公钥,然后用它对信息加密。
(3)乙方得到加密后的信息,用私钥解密。
RSA 是1977年由罗纳德·李维斯特(Ron Rivest)、阿迪·萨莫尔(Adi Shamir)和伦纳德·阿德曼(Leonard Adleman)一起提出的,当时他们三人都在麻省理工学院工作,RSA 就是他们三人姓氏开头字母拼在一起组成的 。从那时直到现在,RSA 算法一直是最广为使用的"非对称加密算法"。毫不夸张地说,只要有计算机网络的地方,就有 RSA 算法。这种算法非常可靠,密钥越长,它就越难破解。根据已经披露的文献,目前被破解的最长 RSA 密钥是 768 个二进制位。也就是说,长度超过 768 位的密钥,还无法破解(至少没人公开宣布)。因此可以认为,1024 位的 RSA 密钥基本安全,2048 位的密钥极其安全。
RSA使用流程:
基本使用流程,同时生成两把密钥:私钥和公钥,私钥保存起来,公钥可以下发给信任客户端
私钥加密,持有私钥或公钥才可以解密
公钥加密,持有私钥才可解密
因此,我们认证服务一般存放私钥和公钥,而资源服务一般存放公钥。私钥负责加密,公钥负责解密。
二、认证思路
2.1 分析集中式认证流程
用户认证:使用 UsernamePasswordAuthenticationFilter 过滤器中 attemptAuthentication 方法实现认证功能,该过滤器父类中 successfulAuthentication 方法实现认证成功后的操作。
身份校验:使用 BasicAuthenticationFilter 过滤器中 doFilterInternal 方法验证是否登录,以决定能否进入后续过滤器。
2.2 分析分布式认证流程
用户认证:分布式项目多数是前后端分离的架构设计,我们要满足可以接受异步 post 的认证请求参数,需要修改 UsernamePasswordAuthenticationFilter 过滤器中 attemptAuthentication 方法,让其能够接收请求体。另外,默认 successfulAuthentication 方法在认证通过后,是把用户信息直接放入session就完事了,现在我们需要修改这个方法,在认证通过后生成 token 并返回给用户。
身份校验: 原来 BasicAuthenticationFilter 过滤器中 doFilterInternal 方法校验用户是否登录,就是看 session 中是否有用户信息,我们要修改为,验证用户携带的 token 是否合法,并解析出用户信息,交给 SpringSecurity,以便于后续的授权功能可以正常使用。
三、 工程介绍
3.1 介绍父工程
为了方便大家能够快速进行学习,我已经提前搭建好了一个基本工程,工程代码在配套资料中,名称叫单点登录基础代码,这只是一个普通的 Spring Boot 工程,该工程由四个子模块组成,一个认证服务模块,一个通用工具模块,一个订单资源模块,一个产品资源模块,我已经帮大家创建好了基本的包结构,并在父工程中对 Spring Boot 的版本进行了管理,在接下来的代码展示环节中,我并不会展示全部代码,我只展示核心代码,完整代码我会给出。
3.2 导入数据库
四、通用模块
注意:本章节所有操作均在
sso-common
中进行。
4.1 导入依赖
<dependencies>
<!--JWT-->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.11.2</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.11.2</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.11.2</version>
<scope>runtime</scope>
</dependency>
<!--Jackson-->
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-core</artifactId>
<version>2.11.4</version>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>2.11.4</version>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-annotations</artifactId>
<version>2.11.4</version>
</dependency>
<!--JodaTime-->
<dependency>
<groupId>joda-time</groupId>
<artifactId>joda-time</artifactId>
<version>2.10.9</version>
</dependency>
<!--Lombok-->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.16</version>
</dependency>
<!--日志包-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-logging</artifactId>
</dependency>
<!--测试包-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
</dependency>
</dependencies>
4.2 统一格式
4.2.1 统一载荷对象
com.caochenlei.domain.Payload
/**
* 为了方便后期获取token中的用户信息,将token中载荷部分单独封装成一个对象
*
* @author CaoChenLei
*/
@Data
public class Payload<T> implements Serializable {
private String id;
private T userInfo;
private Date expiration;
}
4.2.2 统一返回结果
com.caochenlei.domain.Result
/**
* 统一处理返回结果
*
* @author CaoChenLei
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class Result implements Serializable {
private Integer code;
private String msg;
private Object data;
}
4.3 常用工具
4.3.1 JSON 工具类
com.caochenlei.utils.JsonUtils
/**
* 对Jackson中的方法进行了简单封装
*
* @author CaoChenLei
*/
public class JsonUtils {
private static final Logger logger = LoggerFactory.getLogger(JsonUtils.class);
private static final ObjectMapper mapper = new ObjectMapper();
/**
* 将指定对象序列化为一个json字符串
*
* @param obj 指定对象
* @return 返回一个json字符串
*/
public static String toString(Object obj) {
if (obj == null) {
return null;
}
if (obj.getClass() == String.class) {
return (String) obj;
}
try {
return mapper.writeValueAsString(obj);
} catch (JsonProcessingException e) {
logger.error("json序列化出错:" + obj, e);
return null;
}
}
/**
* 将指定json字符串解析为指定类型对象
*
* @param json json字符串
* @param tClass 指定类型
* @return 返回一个指定类型对象
*/
public static <T> T toBean(String json, Class<T> tClass) {
try {
return mapper.readValue(json, tClass);
} catch (IOException e) {
logger.error("json解析出错:" + json, e);
return null;
}
}
/**
* 将指定输入流解析为指定类型对象
*
* @param inputStream 输入流对象
* @param tClass 指定类型
* @return 返回一个指定类型对象
*/
public static <T> T toBean(InputStream inputStream, Class<T> tClass) {
try {
return mapper.readValue(inputStream, tClass);
} catch (IOException e) {
logger.error("json解析出错:" + inputStream, e);
return null;
}
}
/**
* 将指定json字符串解析为指定类型集合
*
* @param json json字符串
* @param eClass 指定元素类型
* @return 返回一个指定类型集合
*/
public static <E> List<E> toList(String json, Class<E> eClass) {
try {
return mapper.readValue(json, mapper.getTypeFactory().constructCollectionType(List.class, eClass));
} catch (IOException e) {
logger.error("json解析出错:" + json, e);
return null;
}
}
/**
* 将指定json字符串解析为指定键值对类型集合
*
* @param json json字符串
* @param kClass 指定键类型
* @param vClass 指定值类型
* @return 返回一个指定键值对类型集合
*/
public static <K, V> Map<K, V> toMap(String json, Class<K> kClass, Class<V> vClass) {
try {
return mapper.readValue(json, mapper.getTypeFactory().constructMapType(Map.class, kClass, vClass));
} catch (IOException e) {
logger.error("json解析出错:" + json, e);
return null;
}
}
/**
* 将指定json字符串解析为一个复杂类型对象
*
* @param json json字符串
* @param type 复杂类型
* @return 返回一个复杂类型对象
*/
public static <T> T nativeRead(String json, TypeReference<T> type) {
try {
return mapper.readValue(json, type);
} catch (IOException e) {
logger.error("json解析出错:" + json, e);
return null;
}
}
}
4.3.2 Jwt 工具类
com.caochenlei.utils.JwtUtils
/**
* 生成token以及校验token相关方法
*
* @author CaoChenLei
*/
public class JwtUtils {
private static final String JWT_PAYLOAD_USER_KEY = "user";
private static String createJTI() {
return new String(Base64.getEncoder().encode(UUID.randomUUID().toString().getBytes()));
}
/**
* 私钥加密token
*
* @param userInfo 载荷中的数据
* @param privateKey 私钥
* @param expire 过期时间,单位分钟
* @return JWT
*/
public static String generateTokenExpireInMinutes(Object userInfo, PrivateKey privateKey, int expire) {
return Jwts.builder()
.claim(JWT_PAYLOAD_USER_KEY, JsonUtils.toString(userInfo))
.setId(createJTI())
.setExpiration(DateTime.now().plusMinutes(expire).toDate())
.signWith(privateKey, SignatureAlgorithm.RS256)
.compact();
}
/**
* 私钥加密token
*
* @param userInfo 载荷中的数据
* @param privateKey 私钥
* @param expire 过期时间,单位秒
* @return JWT
*/
public static String generateTokenExpireInSeconds(Object userInfo, PrivateKey privateKey, int expire) {
return Jwts.builder()
.claim(JWT_PAYLOAD_USER_KEY, JsonUtils.toString(userInfo))
.setId(createJTI())
.setExpiration(DateTime.now().plusSeconds(expire).toDate())
.signWith(privateKey, SignatureAlgorithm.RS256)
.compact();
}
/**
* 公钥解析token
*
* @param token 用户请求中的token
* @param publicKey 公钥
* @return Jws<Claims>
*/
private static Jws<Claims> parserToken(String token, PublicKey publicKey) {
return Jwts.parserBuilder()
.setSigningKey(publicKey)
.build()
.parseClaimsJws(token);
}
/**
* 获取token中的用户信息
*
* @param token 用户请求中的令牌
* @param publicKey 公钥
* @return 用户信息
*/
public static <T> Payload<T> getInfoFromToken(String token, PublicKey publicKey, Class<T> userType) {
Jws<Claims> claimsJws = parserToken(token, publicKey);
Claims body = claimsJws.getBody();
Payload<T> claims = new Payload<>();
claims.setId(body.getId());
claims.setUserInfo(JsonUtils.toBean(body.get(JWT_PAYLOAD_USER_KEY).toString(), userType));
claims.setExpiration(body.getExpiration());
return claims;
}
/**
* 获取token中的载荷信息
*
* @param token 用户请求中的令牌
* @param publicKey 公钥
* @return 用户信息
*/
public static <T> Payload<T> getInfoFromToken(String token, PublicKey publicKey) {
Jws<Claims> claimsJws = parserToken(token, publicKey);
Claims body = claimsJws.getBody();
Payload<T> claims = new Payload<>();
claims.setId(body.getId());
claims.setExpiration(body.getExpiration());
return claims;
}
}
4.3.3 RSA 工具类
com.caochenlei.utils.RsaUtils
/**
* 对Rsa操作进行了简单封装
*
* @author CaoChenLei
*/
public class RsaUtils {
private static final int DEFAULT_KEY_SIZE = 2048;
/**
* 从文件中读取公钥
*
* @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
*/
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 公钥文件路径
* @param privateKeyFilename 私钥文件路径
* @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);
}
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 parentFile = dest.getParentFile();
if (!parentFile.exists()) {
parentFile.mkdirs();
}
if (!dest.exists()) {
dest.createNewFile();
}
Files.write(dest.toPath(), bytes);
}
}
4.4 生成密钥
com.caochenlei.utils.RsaUtilsTest
public class RsaUtilsTest {
private String publicFile = "D:\\auth_key\\rsa_key.pub";
private String privateFile = "D:\\auth_key\\rsa_key";
private String secret = "CaoChenLeiSecret";
@Test
public void generateKey() throws Exception {
RsaUtils.generateKey(publicFile, privateFile, secret, 2048);
}
}
五、认证服务
注意:本章节所有操作均在
sso-auth-server
中进行。
5.1 导入依赖
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
<version>2.1.4</version>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>5.1.49</version>
</dependency>
<!--引入通用子模块-->
<dependency>
<groupId>com.caochenlei</groupId>
<artifactId>sso-common</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>
</dependencies>
5.2 编写配置文件
server:
port: 9001
servlet:
application-display-name: sso-auth-server
spring:
datasource:
driver-class-name: com.mysql.jdbc.Driver
url: jdbc:mysql://localhost:3309/test
username: root
password: root
mybatis:
type-aliases-package: com.caochenlei.domain
configuration:
map-underscore-to-camel-case: true
logging:
level:
com.caochenlei: debug
#自定义属性,配置私钥路径
rsa:
key:
privateKeyPath: D:\auth_key\rsa_key
5.3 编写属性类
com.caochenlei.prop.RsaKeyProperties
@Data
@ConfigurationProperties(prefix = "rsa.key", ignoreInvalidFields = true)
public class RsaKeyProperties {
private String publicKeyPath;
private String privateKeyPath;
private PublicKey publicKey;
private PrivateKey privateKey;
/**
* 该方法用于初始化公钥和私钥的内容
*/
@PostConstruct
public void loadRsaKey() throws Exception {
if (publicKeyPath != null) {
publicKey = RsaUtils.getPublicKey(publicKeyPath);
}
if (privateKeyPath != null) {
privateKey = RsaUtils.getPrivateKey(privateKeyPath);
}
}
}
com.caochenlei.AuthServerApplication
@SpringBootApplication
@EnableConfigurationProperties(RsaKeyProperties.class)
public class AuthServerApplication {
public static void main(String[] args) {
SpringApplication.run(AuthServerApplication.class, args);
}
}
5.4 编写工具类
com.caochenlei.utils.Request