Spring Security整合OAuth2实现统一认证

前言

公司有多个系统,每个系统都要单独登录,用户体验不太好

所以决定用OAuth2实现统一认证,一次登录,多个系统都能用

其实SSO(单点登录)是个挺常见的需求,OAuth2是实现SSO的一种方式

OAuth2简介

OAuth2是一个授权框架,定义了四种角色:

  • Resource Owner:资源所有者(用户)
  • Resource Server:资源服务器(提供API的服务)
  • Client:客户端(第三方应用)
  • Authorization Server:授权服务器(认证服务器)

四种授权模式:

  1. 授权码模式(最安全,推荐)
  2. 简化模式
  3. 密码模式
  4. 客户端模式

架构设计

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
┌─────────────┐       ┌──────────────┐       ┌─────────────┐
│ 浏览器 │ <---> │ 授权服务器 │ <---> │ 用户数据库 │
└─────────────┘ └──────────────┘ └─────────────┘


├─> ┌─────────────┐
│ │ 客户端A │
│ └─────────────┘

├─> ┌─────────────┐
│ │ 客户端B │
│ └─────────────┘

└─> ┌─────────────┐
│ 客户端C │
└─────────────┘

授权服务器配置

添加依赖

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-oauth2-authorization-server</artifactId>
<version>1.1.1</version>
</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>

配置授权服务器

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
@Configuration
@EnableWebSecurity
public class SecurityConfig {

@Bean
public SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.anyRequest().authenticated()
)
.formLogin(withDefaults());

return http.build();
}

@Bean
public UserDetailsService userDetailsService(UserRepository userRepository) {
return username -> userRepository.findByUsername(username)
.map(user -> User.withUsername(user.getUsername())
.password(user.getPassword())
.roles(user.getRoles().toArray(new String[0]))
.build())
.orElseThrow(() -> new UsernameNotFoundException("用户不存在"));
}

@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}

OAuth2配置

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
@Configuration
@Import(OAuth2AuthorizationServerConfiguration.class)
public class AuthorizationServerConfig {

@Bean
public RegisteredClientRepository registeredClientRepository(JdbcTemplate jdbcTemplate) {
return new JdbcRegisteredClientRepository(jdbcTemplate);
}

@Bean
public OAuth2AuthorizationService authorizationService(
JdbcTemplate jdbcTemplate, RegisteredClientRepository registeredClientRepository) {
return new JdbcOAuth2AuthorizationService(jdbcTemplate, registeredClientRepository);
}

@Bean
public OAuth2AuthorizationConsentService authorizationConsentService(
JdbcTemplate jdbcTemplate, RegisteredClientRepository registeredClientRepository) {
return new JdbcOAuth2AuthorizationConsentService(jdbcTemplate, registeredClientRepository);
}

@Bean
public JWKSource<SecurityContext> jwkSource() {
KeyPair keyPair = generateRsaKey();
RSAPublicKey publicKey = (RSAPublicKey) keyPair.getPublic();
RSAPrivateKey privateKey = (RSAPrivateKey) keyPair.getPrivate();

RSAKey rsaKey = new RSAKey.Builder(publicKey)
.privateKey(privateKey)
.keyID(UUID.randomUUID().toString())
.build();

JWKSet jwkSet = new JWKSet(rsaKey);
return new ImmutableJWKSet<>(jwkSet);
}

private static KeyPair generateRsaKey() {
KeyPair keyPair;
try {
KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA");
keyPairGenerator.initialize(2048);
keyPair = keyPairGenerator.generateKeyPair();
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException(e);
}
return keyPair;
}

@Bean
public ProviderSettings providerSettings() {
return ProviderSettings.builder()
.issuer("http://auth-server:9000")
.build();
}
}

初始化客户端数据

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
@Component
public class ClientDataInitializer implements ApplicationRunner {

@Autowired
private RegisteredClientRepository registeredClientRepository;

@Override
public void run(ApplicationArguments args) {
RegisteredClient clientA = RegisteredClient.withId(UUID.randomUUID().toString())
.clientId("client-a")
.clientSecret(passwordEncoder().encode("secret-a"))
.clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
.authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN)
.redirectUri("http://client-a:8081/login/oauth2/code/auth-server")
.scope("read")
.scope("write")
.build();

RegisteredClient clientB = RegisteredClient.withId(UUID.randomUUID().toString())
.clientId("client-b")
.clientSecret(passwordEncoder().encode("secret-b"))
.clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
.authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN)
.redirectUri("http://client-b:8082/login/oauth2/code/auth-server")
.scope("read")
.scope("write")
.build();

registeredClientRepository.save(clientA);
registeredClientRepository.save(clientB);
}

private PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}

客户端配置

客户端A配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
spring:
security:
oauth2:
client:
registration:
client-a:
provider: auth-server
client-id: client-a
client-secret: secret-a
authorization-grant-type: authorization_code
redirect-uri: http://client-a:8081/login/oauth2/code/auth-server
scope: read,write
provider:
auth-server:
issuer-uri: http://auth-server:9000

客户端Security配置

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
@Configuration
@EnableWebSecurity
public class ClientSecurityConfig {

@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.anyRequest().authenticated()
)
.oauth2Login(withDefaults());

return http.build();
}

@Bean
public OAuth2AuthorizedClientService authorizedClientService(
ClientRegistrationRepository clientRegistrationRepository,
OAuth2AuthorizedClientRepository authorizedClientRepository) {
return new JdbcOAuth2AuthorizedClientService(
clientRegistrationRepository,
authorizedClientRepository
);
}
}

获取用户信息

1
2
3
4
5
6
7
8
9
@RestController
public class UserController {

@GetMapping("/user")
public OAuth2AuthenticatedPrincipal getUser(@RegisteredOAuth2AuthorizedClient("client-a") OAuth2AuthorizedClient authorizedClient,
OAuth2AuthenticationToken authentication) {
return (OAuth2AuthenticatedPrincipal) authentication.getPrincipal();
}
}

资源服务器配置

添加依赖

1
2
3
4
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>

配置

1
2
3
4
5
6
spring:
security:
oauth2:
resourceserver:
jwt:
issuer-uri: http://auth-server:9000

Security配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@Configuration
@EnableWebSecurity
public class ResourceServerConfig {

@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/public/**").permitAll()
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.anyRequest().authenticated()
)
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(withDefaults())
);

return http.build();
}
}

API接口

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@RestController
@RequestMapping("/api")
public class ApiController {

@GetMapping("/public/info")
public String publicInfo() {
return "公开信息";
}

@GetMapping("/user/info")
public String userInfo(JwtAuthenticationToken authentication) {
Jwt jwt = authentication.getToken();
return "用户信息: " + jwt.getSubject();
}

@GetMapping("/admin/info")
@PreAuthorize("hasRole('ADMIN')")
public String adminInfo() {
return "管理员信息";
}
}

单点登录流程

  1. 用户访问客户端A,被重定向到授权服务器
  2. 用户在授权服务器登录
  3. 授权服务器生成授权码,重定向回客户端A
  4. 客户端A用授权码换取access token
  5. 用户访问客户端B,如果token有效,直接登录
  6. 如果token过期,客户端B用refresh token换取新的access token

登出

1
2
3
4
5
6
7
8
9
10
11
12
13
@Configuration
public class LogoutConfig {

@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.logout(logout -> logout
.logoutSuccessUrl("http://auth-server:9000/logout")
);

return http.build();
}
}

总结

OAuth2实现SSO虽然复杂一点,但是用起来挺方便的

主要优点:

  • 一次登录,多个系统访问
  • Token统一管理
  • 安全性高

注意事项:

  • 确保HTTPS通信
  • Token要设置合理的过期时间
  • Refresh Token要做好安全防护
  • 客户端Secret要保管好

暂时就先记录这么多