授权中心的主要职责:
因为生成JWT,解析JWT这样的行为以后在其它微服务中也会用到,因此我们会抽取成工具。我们把鉴权中心进行聚合,一个工具module,一个提供服务的module
我们先创建父module,名称为:ly-auth-center,将pom打包方式改为pom
<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>leyouartifactId>
<groupId>com.leyou.parentgroupId>
<version>1.0.0-SNAPSHOTversion>
parent>
<modelVersion>4.0.0modelVersion>
<groupId>com.leyou.servicegroupId>
<artifactId>ly-auth-centerartifactId>
<version>1.0.0-SNAPSHOTversion>
<modules>
<module>ly-auth-commonmodule>
modules>
<packaging>pompackaging>
project>
<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>ly-auth-centerartifactId>
<groupId>com.leyou.servicegroupId>
<version>1.0.0-SNAPSHOTversion>
parent>
<modelVersion>4.0.0modelVersion>
<groupId>com.leyou.servicegroupId>
<artifactId>ly-auth-commonartifactId>
<version>1.0.0-SNAPSHOTversion>
project>
<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>ly-auth-centerartifactId>
<groupId>com.leyou.servicegroupId>
<version>1.0.0-SNAPSHOTversion>
parent>
<modelVersion>4.0.0modelVersion>
<groupId>com.leyou.servicegroupId>
<artifactId>ly-auth-serviceartifactId>
<version>1.0.0-SNAPSHOTversion>
<dependencies>
<dependency>
<groupId>org.springframework.cloudgroupId>
<artifactId>spring-cloud-starter-netflix-eureka-clientartifactId>
dependency>
<dependency>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-starter-webartifactId>
dependency>
<dependency>
<groupId>com.leyou.servicegroupId>
<artifactId>ly-auth-commonartifactId>
<version>${leyou.latest.version}version>
dependency>
<dependency>
<groupId>org.springframework.cloudgroupId>
<artifactId>spring-cloud-starter-openfeignartifactId>
dependency>
dependencies>
project>
server:
port: 8087
spring:
application:
name: auth-service
eureka:
client:
service-url:
defaultZone: http://127.0.0.1:10086/eureka
registry-fetch-interval-seconds: 10
instance:
prefer-ip-address: true
ip-address: 127.0.0.1
@SpringBootApplication
@EnableDiscoveryClient
@EnableFeignClients
public class LyAuthApplication {
public static void main(String[] args) {
SpringApplication.run(LyAuthApplication.class, args);
}
}
zuul:
prefix: /api # 添加路由前缀
retryable: true
routes:
item-service: /item/**
search-service: /search/**
user-service: /user/**
auth-service: /auth/**
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);
}
}
定义了jwt中的payload的常用key
public abstract class JwtConstans {
public static final String JWT_KEY_ID = "id";
public static final String JWT_KEY_USER_NAME = "username";
}
@Data
@NoArgsConstructor
@AllArgsConstructor
public class UserInfo {
private Long id;
private String username;
}
从jwt解析得到的数据是Object类型,转换为具体类型可能出现空指针,这个工具类进行了一些转换:
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();
}
}
我们已经在ly-auth-common
中引入JWT依赖:jjwt和开源的时间/日期库:joda-time
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))
);
}
}
接下来,我们需要在ly-auth-servcice
编写一个接口,对外提供登录授权服务。基本流程如下:
公钥和私钥要在项目一启动时就生成
我们需要在授权中心生成真正的公钥和私钥。我们必须有一个生成公钥和私钥的secret,这个可以配置到application.yml
中:
ly:
jwt:
secret: ly@Login(Auth}*^31)&yun6%f3q2 # 登录校验的密钥
pubKeyPath: H:/javacode/idea/rsa/rsa.pub # 公钥地址
priKeyPath: H:/javacode/idea/rsa/rsa.pri # 私钥地址
expire: 30 # 过期时间,单位分钟
cookieName: LY_TOKEN
然后编写属性类,加载这些数据:
@Data
@ConfigurationProperties(prefix = "ly.jwt")
public class JwtProperties {
private String secret; // 密钥
private String pubKeyPath;// 公钥
private String priKeyPath;// 私钥
private int expire;// token过期时间
private String cookieName;
private PublicKey publicKey; // 公钥
private PrivateKey privateKey; // 私钥
private static final Logger logger = LoggerFactory.getLogger(JwtProperties.class);
// 对象一旦实例化后,就应该读取公钥和私钥
@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();
}
}
}
@ConfigurationProperties
:@PostConstruct
:
@PostConstruct
修饰的方法会在服务器加载Servlet
的时候运行(对象一旦实例化后,就执行),并且只会被服务器调用一次,类似于Serclet
的inti()
方法。被@PostConstruc
t修饰的方法会在构造函数之后,init()方法之前运行。@PostConstruct
注解一个方法来完成初始化,@PostConstruct
注解的方法将会在依赖注入完成后被自动调用。参考资料:
注:理论上公钥私钥只生成一次,不会无限生成
编写授权接口,我们接收用户名和密码,校验成功后,写入cookie中,那如何写入cookie中呢?通过HttpServletResponse response
, HttpServletRequest request
,以及ly-common
中的CookieUtils
工具类(该工具类利用的工厂模式来创建)
为什么要用到response 和 request 呢?
@Value("${ly.jwt.cookieName}")
private String cookieName;
@PostMapping("login")
public ResponseEntity<Void> login(// 无需给前端浏览器返回token,token只有后端才需要使用,并且将token保存到cookie中
@RequestParam("username") String username,
@RequestParam("password") String password,
HttpServletResponse response,
HttpServletRequest request){
// 登录功能的实现
String token = authService.login(username, password);
// 将token写入cookie --- 工厂模式
CookieUtils.newBuilder(response).httpOnly().request(request).build(cookieName, token);
return ResponseEntity.status(HttpStatus.NO_CONTENT).build();
}
@value
:Spring 通过注解获取*.porperties 或 *.yml文件的内容,除了xml配置外,还可以通过@value方式来获取,这种注解方式比较麻烦,需要自己写全路径,@ConfigurationProperties
方式比较简单,直接全部注入进来接下来我们肯定要对用户密码进行校验,所以我们需要通过FeignClient去访问 user-service
微服务:
引入user-service依赖:
<dependency>
<groupId>com.leyou.servicegroupId>
<artifactId>ly-user-interfaceartifactId>
<version>${leyou.latest.version}version>
dependency>
编写FeignClient:
@FeignClient(value = "user-service")
public interface UserClient extends UserApi {
}
public String login(String username, String password) {
try {
// 校验用户名和密码
User user = userClient.queryUsernameAndPassword(username, password);
if (user == null) {
throw new LyException(ExceptionEnum.INVALID_USERNAME_PASSWORD);
}
// 生成token
String token = JwtUtils.generateToken(new UserInfo(user.getId(), username), prop.getPrivateKey(), prop.getExpire());
return token;
}catch (Exception e){
log.error("[授权中心] 用户名或者密码有误,用户名称:{}", username, e);
throw new LyException(ExceptionEnum.INVALID_USERNAME_PASSWORD);
}
}
接下来,我们看看登录页面,是否能够正确的发出请求。
成功跳转到了首页:
然后去看cookie:
什么都没有,为什么?——我们在下一篇博客中分析:解决cookie无法写入的问题
当cookie无法写入的问题得到解决以后,访问首页,发现首页的顶部,登录状态依然没能判断出用户信息:
这里需要向后台发起请求,获取根据cookie获取当前用户的信息。
因为token在cookie中,因此本次请求肯定会携带token信息在头中。
我们在ly-auth-service
中定义用户的校验接口,通过cookie获取token,然后校验通过返回用户信息。
代码:
@GetMapping("verify")
public ResponseEntity<UserInfo> verify(
@CookieValue("LY_TOKEN") String token,
HttpServletResponse response,
HttpServletRequest request
){
try {
// 解析token
UserInfo info = JwtUtils.getInfoFromToken(token, prop.getPublicKey());
// 刷新token并重新写入
String newToken = JwtUtils.generateToken(info, prop.getPrivateKey(), prop.getExpire());
CookieUtils.newBuilder(response).httpOnly().request(request).build(cookieName, newToken);
return ResponseEntity.ok(info);
} catch (Exception e){
throw new LyException(ExceptionEnum.NO_AUTHORIZED);
}
}
获取cookie并不非得从request中取出,可以使用注解@CookieValue
:
注:只要用户有操作就应该重新刷新token(重新生成token并写入),否则用户在浏览商品的时候不经意间过了30分钟有效期,准备下单时让用户重新登陆,影响用户体验。
到此为止,我们仅仅完成了对用户的授权,接下来,我们完成鉴权,见下一篇博客鉴权微服务——鉴权