前言
公司有多个系统,每个系统都要单独登录,用户体验不太好
所以决定用OAuth2实现统一认证,一次登录,多个系统都能用
其实SSO(单点登录)是个挺常见的需求,OAuth2是实现SSO的一种方式
OAuth2简介
OAuth2是一个授权框架,定义了四种角色:
- Resource Owner:资源所有者(用户)
- Resource Server:资源服务器(提供API的服务)
- Client:客户端(第三方应用)
- Authorization Server:授权服务器(认证服务器)
四种授权模式:
- 授权码模式(最安全,推荐)
- 简化模式
- 密码模式
- 客户端模式
架构设计
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 "管理员信息"; } }
|
单点登录流程
- 用户访问客户端A,被重定向到授权服务器
- 用户在授权服务器登录
- 授权服务器生成授权码,重定向回客户端A
- 客户端A用授权码换取access token
- 用户访问客户端B,如果token有效,直接登录
- 如果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要保管好
暂时就先记录这么多