Spring Cloud实战系列(十) - 单点登录JWT与Spring Security OAuth 2.0

前言

通过 JWT 配合 Spring Security OAuth2 使用的方式,可以避免 每次请求远程调度 认证授权服务。资源服务器 只需要从 授权服务器 验证一次,返回 JWT。返回的 JWT 包含了 用户 的所有信息,包括 权限信息

正文

1. 什么是JWT

JSON Web TokenJWT)是一种开放的标准(RFC 7519),JWT 定义了一种 紧凑自包含 的标准,旨在将各个主体的信息包装为 JSON 对象。主体信息 是通过 数字签名 进行 加密验证 的。经常使用 HMAC 算法或 RSA公钥/私钥非对称性加密)算法对 JWT 进行签名,安全性很高

  • 紧凑型数据体积小,可通过 POST 请求参数HTTP 请求头 发送。

  • 自包含JWT 包含了主体的所有信息,避免了 每个请求 都需要向 Uaa 服务验证身份,降低了 服务器的负载

2. JWT的结构

JWT 的结构由三部分组成:Header(头)、Payload(有效负荷)和 Signature(签名)。因此 JWT 通常的格式是 xxxxx.yyyyy.zzzzz

2.1. Header

Header 通常是由 两部分 组成:令牌的 类型(即 JWT)和使用的 算法类型,如 HMACSHA256RSA。例如:

1
2
3
4
{
"typ": "JWT",
"alg": "HS256"
}

HeaderBase64 编码作为 JWT第一部分,不建议在 JWTHeader 中放置 敏感信息

2.2. Payload

第二部分 PayloadJWT主体内容部分,它包含 声明 信息。声明是关于 用户其他数据 的声明。

声明有三种类型: registeredpublicprivate

  • Registered claimsJWT 提供了一组 预定义 的声明,它们不是 强制的,但是推荐使用。JWT 指定 七个默认 字段供选择:
注册声明 字段含义
iss 发行人
exp 到期时间
sub 主题
aud 用户
nbf 在此之前不可用
iat 发布时间
jti 用于标识JWT的ID
  • Public claims:可以随意定义。

  • Private claims:用于在 同意使用 它们的各方之间 共享信息,并且不是 注册的公开的 声明。

下面是 Payload 部分的一个示例:

1
2
3
4
5
{
"sub": "123456789",
"name": "John Doe",
"admin": true
}

PayloadBase64 编码作为 JWT第二部分,不建议在 JWTPayload 中放置 敏感信息

2.3. Signature

要创建签名部分,需要利用 秘钥Base64 编码后的 HeaderPayload 进行 加密,加密算法的公式如下:

1
2
3
4
5
HMACSHA256(
base64UrlEncode(header) + '.' +
base64UrlEncode(payload),
secret
)

签名 可以用于验证 消息传递过程 中有没有被更改。对于使用 私钥签名token,它还可以验证 JWT发送方 是否为它所称的 发送方

3. JWT的工作方式

客户端 获取 JWT 后,对于以后的 每次请求,都不需要再通过 授权服务 来判断该请求的 用户 以及该 用户的权限。在微服务系统中,可以利用 JWT 实现 单点登录。认证流程图如下:

4. 案例工程结构

  • eureka-server:作为 注册服务中心,端口号为 8761。这里不再演示搭建。

  • auth-service:作为 授权服务授权 需要用户提供 客户端client IdClient Secret,以及 授权用户usernamepassword。这些信息 准备无误 之后,auth-service 会返回 JWT,该 JWT 包含了用户的 基本信息权限点信息,并通过 RSA 私钥 进行加密。

  • user-service:作为 资源服务,它的 资源 被保护起来,需要相应的 权限 才能访问。user-service 服务得到 用户请求JWT 后,先通过 公钥 解密 JWT,得到 JWT 对应的 用户信息用户权限信息,再通过 Spring Security 判断该用户是否有 权限 访问该资源。

工程原理示意图如下:

5. 构建auth-service授权服务

  • 新建一个 auth-service 项目模块,完整的 pom.xml 文件配置如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
<?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>1.5.3.RELEASE</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>

<groupId>io.github.ostenant.springcloud</groupId>
<artifactId>auth-service</artifactId>
<version>0.0.1-SNAPSHOT</version>

<name>auth-service</name>
<description>Demo project for Spring Boot</description>

<properties>
<java.version>1.8</java.version>
<spring-cloud.version>Dalston.SR1</spring-cloud.version>
</properties>

<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-eureka</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-jwt</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.security.oauth</groupId>
<artifactId>spring-security-oauth2</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
</dependency>

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>

<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-dependencies</artifactId>
<version>${spring-cloud.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>

<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
<!--防止jks文件被mavne编译导致不可用-->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-resources-plugin</artifactId>
<configuration>
<nonFilteredFileExtensions>
<nonFilteredFileExtension>cert</nonFilteredFileExtension>
<nonFilteredFileExtension>jks</nonFilteredFileExtension>
</nonFilteredFileExtensions>
</configuration>
</plugin>
</plugins>
</build>
</project>
  • 修改 auth-service 的配置文件 application.yml 文件如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
spring:
application:
name: auth-service
datasource:
driver-class-name: com.mysql.jdbc.Driver
url: jdbc:mysql://localhost:3306/spring-cloud-auth?useUnicode=true&characterEncoding=utf8&characterSetResults=utf8
username: root
password: 123456
jpa:
hibernate:
ddl-auto: update
show-sql: true
server:
port: 9999
eureka:
client:
serviceUrl:
defaultZone: http://localhost:8761/eureka/
  • auth-service 配置 Spring Security 安全登录管理,用于保护 token 发放验证 的资源接口。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
@Configuration
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private UserServiceDetail userServiceDetail;

@Override
public @Bean AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}

@Override
protected void configure(HttpSecurity http) throws Exception {
http.csrf().disable() //关闭CSRF
.exceptionHandling()
.authenticationEntryPoint((request, response, authException) -> response.sendError(HttpServletResponse.SC_UNAUTHORIZED))
.and()
.authorizeRequests()
.antMatchers("/**").authenticated()
.and()
.httpBasic();
}

@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(userServiceDetail).passwordEncoder(new BCryptPasswordEncoder());
}
}

UserServiceDetail.java

1
2
3
4
5
6
7
8
9
10
@Service
public class UserServiceDetail implements UserDetailsService {
@Autowired
private UserDao userRepository;

@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
return userRepository.findByUsername(username);
}
}

UserRepository.java

1
2
3
4
@Repository
public interface UserRepository extends JpaRepository<User, Long> {
User findByUsername(String username);
}

实体类 User 和上一篇文章的内容一样,需要实现 UserDetails 接口,实体类 Role 需要实现 GrantedAuthority 接口。

User.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
@Entity
public class User implements UserDetails, Serializable {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

@Column(nullable = false, unique = true)
private String username;

@Column
private String password;

@ManyToMany(cascade = CascadeType.ALL, fetch = FetchType.EAGER)
@JoinTable(name = "user_role", joinColumns = @JoinColumn(name = "user_id", referencedColumnName = "id"),
inverseJoinColumns = @JoinColumn(name = "role_id", referencedColumnName = "id"))
private List<Role> authorities;

public Long getId() {
return id;
}

public void setId(Long id) {
this.id = id;
}

@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return authorities;
}

public void setAuthorities(List<Role> authorities) {
this.authorities = authorities;
}

@Override
public String getUsername() {
return username;
}

public void setUsername(String username) {
this.username = username;
}

@Override
public String getPassword() {
return password;
}

public void setPassword(String password) {
this.password = password;
}

@Override
public boolean isAccountNonExpired() {
return true;
}

@Override
public boolean isAccountNonLocked() {
return true;
}

@Override
public boolean isCredentialsNonExpired() {
return true;
}

@Override
public boolean isEnabled() {
return true;
}
}

Role.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
@Entity
public class Role implements GrantedAuthority {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

@Column(nullable = false)
private String name;

public Long getId() {
return id;
}

public void setId(Long id) {
this.id = id;
}

@Override
public String getAuthority() {
return name;
}

public void setName(String name) {
this.name = name;
}

@Override
public String toString() {
return name;
}
}
  • 新建一个配置类 OAuth2Config,为 auth-service 配置 认证服务,代码如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
@Configuration
@EnableAuthorizationServer
public class OAuth2Config extends AuthorizationServerConfigurerAdapter {
@Autowired
@Qualifier("authenticationManagerBean")
private AuthenticationManager authenticationManager;

@Override
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
// 将客户端的信息存储在内存中
clients.inMemory()
// 配置一个客户端
.withClient("user-service")
.secret("123456")
// 配置客户端的域
.scopes("service")
// 配置验证类型为refresh_token和password
.authorizedGrantTypes("refresh_token", "password")
// 配置token的过期时间为1h
.accessTokenValiditySeconds(3600 * 1000);
}

@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
// 配置token的存储方式为JwtTokenStore
endpoints.tokenStore(tokenStore())
// 配置用于JWT私钥加密的增强器
.tokenEnhancer(jwtTokenEnhancer())
// 配置安全认证管理
.authenticationManager(authenticationManager);
}

@Bean
public TokenStore tokenStore() {
return new JwtTokenStore(jwtTokenEnhancer());
}

@Bean
protected JwtAccessTokenConverter jwtTokenEnhancer() {
// 配置jks文件
KeyStoreKeyFactory keyStoreKeyFactory = new KeyStoreKeyFactory(new ClassPathResource("fzp-jwt.jks"), "fzp123".toCharArray());
JwtAccessTokenConverter converter = new JwtAccessTokenConverter();
converter.setKeyPair(keyStoreKeyFactory.getKeyPair("fzp-jwt"));
return converter;
}
}
  • 生成用于 Token 加密的 私钥文件 fzp-jwt.jks

jks 文件的生成需要使用 Java keytool 工具,保证 Java 环境变量没问题,输入命令如下:

1
2
3
4
5
6
7
$ keytool -genkeypair -alias fzp-jwt 
-validity 3650
-keyalg RSA
-dname "CN=jwt,OU=jtw,O=jwt,L=zurich,S=zurich, C=CH"
-keypass fzp123
-keystore fzp-jwt.jks
-storepass fzp123

其中,-alias 选项为 别名-keyalg加密算法-keypass-storepass密码选项-keystorejks文件名称-validity 为配置 jks 文件 过期时间(单位:天)。

生成的 jks 文件作为 私钥,只允许 授权服务 所持有,用作 加密生成 JWT。把生成的 jks 文件放到 auth-service 模块的 src/main/resource 目录下即可。

  • 生成用于 JWT 解密的 公钥

对于 user-service 这样的 资源服务,需要使用 jks公钥JWT 进行 解密。获取 jks 文件的 公钥 的命令如下:

1
2
3
4
$ keytool -list -rfc 
--keystore fzp-jwt.jks | openssl x509
-inform pem
-pubkey

这个命令要求安装 openSSL 下载地址,然后手动把安装的 openssl.exe 所在目录配置到 环境变量

输入密码 fzp123 后,显示的信息很多,只需要提取 PUBLIC KEY,即如下所示:

——-BEGIN PUBLIC KEY——-
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAlCFiWbZXIb5kwEaHjW+/
7J4b+KzXZffRl5RJ9rAMgfRXHqGG8RM2Dlf95JwTXzerY6igUq7FVgFjnPbexVt3
vKKyjdy2gBuOaXqaYJEZSfuKCNN/WbOF8e7ny4fLMFilbhpzoqkSHiR+nAHLkYct
OnOKMPK1SwmvkNMn3aTEJHhxGh1RlWbMAAQ+QLI2D7zCzQ7Uh3F+Kw0pd2gBYd8W
+DKTn1Tprugdykirr6u0p66yK5f1T9O+LEaJa8FjtLF66siBdGRaNYMExNi21lJk
i5dD3ViVBIVKi9ZaTsK9Sxa3dOX1aE5Zd5A9cPsBIZ12spYgemfj6DjOw6lk7jkG
9QIDAQAB
——-END PUBLIC KEY——-

新建一个 public.cert 文件,将上面的 公钥信息 复制到 public.cert 文件中并保存。并将文件放到 user-service资源服务src/main/resources 目录下。至此 auth-service 搭建完毕。

  • pom.xml 中配置 jks 文件后缀过滤器

maven 在项目编译时,可能会将 jks 文件 编译,导致 jks 文件 乱码,最后不可用。需要在 pom.xml 文件中添加以下内容:

1
2
3
4
5
6
7
8
9
10
11
<!-- 防止jks文件被maven编译导致不可用 -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-resources-plugin</artifactId>
<configuration>
<nonFilteredFileExtensions>
<nonFilteredFileExtension>cert</nonFilteredFileExtension>
<nonFilteredFileExtension>jks</nonFilteredFileExtension>
</nonFilteredFileExtensions>
</configuration>
</plugin>
  • 最后在启动类上配置 @EnableEurekaClient 注解开启服务注册功能。
1
2
3
4
5
6
7
@EnableEurekaClient
@SpringBootApplication
public class AuthServiceApplication {
public static void main(String[] args) {
SpringApplication.run(AuthServiceApplication.class, args);
}
}

6. 构建user-service资源服务

  • 新建一个 user-service 项目模块,完整的 pom.xml 文件配置如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
<?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>1.5.3.RELEASE</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>

<groupId>io.github.ostenant.springcloud</groupId>
<artifactId>user-service</artifactId>
<version>0.0.1-SNAPSHOT</version>

<name>user-service</name>
<description>Demo project for Spring Boot</description>

<properties>
<java.version>1.8</java.version>
<spring-cloud.version>Dalston.SR1</spring-cloud.version>
</properties>

<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-eureka</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.security.oauth</groupId>
<artifactId>spring-security-oauth2</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-hystrix</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-feign</artifactId>
</dependency>

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>

<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-dependencies</artifactId>
<version>${spring-cloud.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>

<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
  • 修改 user-service 的配置文件 application.yml,配置 应用名称user-service端口号9090。另外,需要配置 feign.hystrix.enabletrue,即开启 FeignHystrix 功能。完整的配置代码如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
server:
port: 9090
eureka:
client:
service-url:
defaultZone: http://localhost:8761/eureka/
spring:
application:
name: user-service
datasource:
driver-class-name: com.mysql.jdbc.Driver
url: jdbc:mysql://localhost:3306/spring-cloud-auth?useUnicode=true&characterEncoding=utf8&characterSetResults=utf8
username: root
password: 123456
jpa:
hibernate:
ddl-auto: update
show-sql: true
feign:
hystrix:
enabled: true
  • 配置 资源服务

注入 JwtTokenStore 类型的 Bean,同时初始化 JWT 转换器 JwtAccessTokenConverter,设置用于解密 JWT公钥

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
@Configuration
public class JwtConfig {
@Autowired
private JwtAccessTokenConverter jwtAccessTokenConverter;

@Bean
@Qualifier("tokenStore")
public TokenStore tokenStore() {
return new JwtTokenStore(jwtAccessTokenConverter);
}

@Bean
public JwtAccessTokenConverter jwtTokenEnhancer() {
// 用作JWT转换器
JwtAccessTokenConverter converter = new JwtAccessTokenConverter();
Resource resource = new ClassPathResource("public.cert");
String publicKey;
try {
publicKey = new String(FileCopyUtils.copyToByteArray(resource.getInputStream()));
} catch (IOException e) {
throw new RuntimeException(e);
}
//设置公钥
converter.setVerifierKey(publicKey);
return converter;
}
}

配置 资源服务 的认证管理,除了 注册登录 的接口之外,其他的接口都需要 认证

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@Configuration
@EnableResourceServer
public class ResourceServerConfig extends ResourceServerConfigurerAdapter{
@Autowired
private TokenStore tokenStore;

@Override
public void configure(HttpSecurity http) throws Exception {
http.csrf().disable()
.authorizeRequests()
.antMatchers("/user/login","/user/register").permitAll()
.antMatchers("/**").authenticated();
}

@Override
public void configure(ResourceServerSecurityConfigurer resources) throws Exception {
resources.tokenStore(tokenStore);
}
}

新建一个配置类 GlobalMethodSecurityConfig,通过 @EnableGlobalMethodSecurity 注解开启 方法级别安全验证

1
2
3
4
@Configuration
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class GlobalMethodSecurityConfig {
}
  • 实现用户注册接口

拷贝 auth-service 模块的 UserRoleUserRepository 三个类到本模块。在 Service 层的 UserService 编写一个 插入用户 的方法,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
@Service
public class UserServiceDetail {
@Autowired
private UserRepository userRepository;

public User insertUser(String username,String password){
User user=new User();
user.setUsername(username);
user.setPassword(BPwdEncoderUtil.BCryptPassword(password));
return userRepository.save(user);
}
}

配置用于用户密码 加密 的工具类 BPwdEncoderUtil

1
2
3
4
5
6
7
8
9
10
11
public class BPwdEncoderUtil {
private static final BCryptPasswordEncoder encoder = new BCryptPasswordEncoder();

public static String BCryptPassword(String password){
return encoder.encode(password);
}

public static boolean matches(CharSequence rawPassword, String encodedPassword){
return encoder.matches(rawPassword,encodedPassword);
}
}

实现一个 用户注册API 接口 /user/register,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
@RestController
@RequestMapping("/user")
public class UserController {
@Autowired
UserServiceDetail userServiceDetail;

@PostMapping("/register")
public User postUser(@RequestParam("username") String username,
@RequestParam("password") String password){
return userServiceDetail.insertUser(username, password);
}
}
  • 实现用户登录接口

Service 层的 UserServiceDetail 中添加一个 login() 方法,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
@Service
public class UserServiceDetail {

@Autowired
private AuthServiceClient client;

public UserLoginDTO login(String username, String password) {
// 查询数据库
User user = userRepository.findByUsername(username);
if (user == null) {
throw new UserLoginException("error username");
}

if(!BPwdEncoderUtil.matches(password,user.getPassword())){
throw new UserLoginException("error password");
}

// 从auth-service获取JWT
JWT jwt = client.getToken("Basic dXNlci1zZXJ2aWNlOjEyMzQ1Ng==", "password", username, password);
if(jwt == null){
throw new UserLoginException("error internal");
}

UserLoginDTO userLoginDTO=new UserLoginDTO();
userLoginDTO.setJwt(jwt);
userLoginDTO.setUser(user);
return userLoginDTO;
}
}

AuthServiceClient 作为 Feign Client,通过向 auth-service 服务接口 /oauth/token 远程调用获取 JWT。在请求 /oauth/tokenAPI 接口中,需要在 请求头 传入 Authorization 信息,认证类型 ( grant_type )、用户名 ( username ) 和 密码 ( password ),代码如下:

1
2
3
4
5
6
7
8
@FeignClient(value = "auth-service", fallback = AuthServiceHystrix.class)
public interface AuthServiceClient {
@PostMapping("/oauth/token")
JWT getToken(@RequestHeader("Authorization") String authorization,
@RequestParam("grant_type") String type,
@RequestParam("username") String username,
@RequestParam("password") String password);
}

其中,AuthServiceHystrixAuthServiceClient熔断器,代码如下:

1
2
3
4
5
6
7
8
9
10
@Component
public class AuthServiceHystrix implements AuthServiceClient {
private static final Logger LOGGER = LoggerFactory.getLogger(AuthServiceHystrix.class);

@Override
public JWT getToken(String authorization, String type, String username, String password) {
LOGGER.warn("Fallback of getToken is executed")
return null;
}
}

JWT 包含了 access_tokentoken_typerefresh_token 等信息,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
public class JWT {
private String access_token;
private String token_type;
private String refresh_token;
private int expires_in;
private String scope;
private String jti;

public String getAccess_token() {
return access_token;
}

public void setAccess_token(String access_token) {
this.access_token = access_token;
}

public String getToken_type() {
return token_type;
}

public void setToken_type(String token_type) {
this.token_type = token_type;
}

public String getRefresh_token() {
return refresh_token;
}

public void setRefresh_token(String refresh_token) {
this.refresh_token = refresh_token;
}

public int getExpires_in() {
return expires_in;
}

public void setExpires_in(int expires_in) {
this.expires_in = expires_in;
}

public String getScope() {
return scope;
}

public void setScope(String scope) {
this.scope = scope;
}

public String getJti() {
return jti;
}

public void setJti(String jti) {
this.jti = jti;
}
}

UserLoginDTO 包含了一个 User 和一个 JWT 成员属性,用于返回数据的实体:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class UserLoginDTO {
private JWT jwt;
private User user;

public JWT getJwt() {
return jwt;
}

public void setJwt(JWT jwt) {
this.jwt = jwt;
}

public User getUser() {
return user;
}

public void setUser(User user) {
this.user = user;
}
}

登录异常类 UserLoginException

1
2
3
4
5
public class UserLoginException extends RuntimeException {
public UserLoginException(String message) {
super(message);
}
}

全局异常处理 切面类 ExceptionHandle

1
2
3
4
5
6
7
8
@ControllerAdvice
@ResponseBody
public class ExceptionHandler {
@ExceptionHandler(UserLoginException.class)
public ResponseEntity<String> handleException(Exception e) {
return new ResponseEntity(e.getMessage(), HttpStatus.OK);
}
}

Web 层的 UserController 类中新增一个登录的 API 接口 /user/login 如下:

1
2
3
4
5
@PostMapping("/login")
public UserLoginDTO login(@RequestParam("username") String username,
@RequestParam("password") String password) {
return userServiceDetail.login(username,password);
}
  • 为了测试 用户权限,再新增一个 /foo 接口,该接口需要 ROLE_ADMIN 权限才能正常访问。
1
2
3
4
5
@RequestMapping(value = "/foo", method = RequestMethod.GET)
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
public String getFoo() {
return "i'm foo, " + UUID.randomUUID().toString();
}
  • 最后在应用的启动类上使用注解 @EnableFeignClients 开启 Feign 的功能即可。
1
2
3
4
5
6
7
8
@SpringBootApplication
@EnableFeignClients
@EnableEurekaClient
public class UserServiceApplication {
public static void main(String[] args) {
SpringApplication.run(UserServiceApplication.class, args);
}
}

依次启动 eureka-serviceauth-serviceuser-service 三个服务。

7. 使用Postman测试

  • 注册一个用户,返回注册成功信息

  • 使用用户名密码登录获取 JWT

  • 复制上面的 access_tokenheader 头部,请求需要 用户权限/user/foo 接口
1
"Authorization": "Bearer {access_token}"

因为没有权限,访问被拒绝。在数据库手动添加 ROLE_ADMIN 权限,并与该用户关联。重新登录并获取 JWT,再次请求 /user/foo 接口。

总结

在本案例中,用户通过 登录接口 来获取 授权服务 加密后的 JWT。用户成功获取 JWT 后,在以后每次访问 资源服务 的请求中,都需要携带上 JWT资源服务 通过 公钥解密 JWT解密成功 后可以获取 用户信息权限信息,从而判断该 JWT 所对应的 用户 是谁,具有什么 权限

  • 优点

获取一次 Token,多次使用,资源服务 不再每次访问 授权服务Token 所对应的 用户信息 和用户的 权限信息

  • 缺点

一旦 用户信息 或者 权限信息 发生了改变,Token 中存储的相关信息并 没有改变,需要 重新登录 获取新的 Token。就算重新获取了 Token,如果原来的 Token 没有过期,仍然是可以使用的。一种改进方式是在登录成功后,将获取的 Token 缓存网关上。如果用户的 权限更改,将 网关 上缓存的 Token 删除。当请求经过 网关,判断请求的 Token缓存 中是否存在,如果缓存中不存在该 Token,则提示用户 重新登录

参考

  • 方志朋《深入理解Spring Cloud与微服务构建》

欢迎关注技术公众号: 零壹技术栈

零壹技术栈

本帐号将持续分享后端技术干货,包括虚拟机基础,多线程编程,高性能框架,异步、缓存和消息中间件,分布式和微服务,架构学习和进阶等学习资料和文章。

文章作者: Chen Vainlgory
文章链接: https://geek.vainlgory.top/2019/02/01/Spring Cloud实战系列(十) - 单点登录JWT与Spring Security OAuth 2.0/
版权声明: 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来自 零壹技術棧 | VainlgoryのBlog
微信打赏
支付宝打赏