Spring Authorization Server 實現授權中心

Spring Authorization Server 實現授權中心

源碼地址

當前,Spring Security 對 OAuth 2.0 框架提供了全面的支援。Spring Authorization Server 出現的含義在於替換 Spring Security OAuth,交付 OAuth 2.1 授權框架。 Spring 官方已棄用 Spring Security OAuth

本文涉及的組件版本如下:

組件 版本
JDK 17
org.springframework.boot 2.6.7
Gradle 7.4.1
spring-security-oauth2-authorization-server 0.2.3
spring-security-oauth2-authorization-server 項目由 Spring Security 團隊領導,**社區驅動**。

本文的目的:

  1. 搭建授權中心示例
  2. fork 當前項目從而免去一些工作

本 demo 的結構

  • root
    • [[#auth-center|授權中心]]
    • [[#user-service|用戶服務]]
    • [[#client-gateway|移動端網關]]

OAuth 2.1 支援三種許可類型,[[OAuth 2.1 授權框架#授權碼許可]]、[[OAuth 2.1 授權框架#客戶端證書許可]]、[[OAuth 2.1 授權框架#刷新令牌許可]]。

auth-center

build.gradle

plugins {  
    id 'org.springframework.boot' version '2.6.7'  
    id 'io.spring.dependency-management' version '1.0.11.RELEASE'  
    id 'java'  
}  
  
group = 'com.insight.into.life'  
version = '0.0.1-SNAPSHOT'  
sourceCompatibility = '17'  
  
configurations {  
    compileOnly {  
        extendsFrom annotationProcessor  
    }  
}  
  
repositories {  
    mavenCentral()  
}  
  
dependencies {  
    implementation 'org.springframework.boot:spring-boot-starter-web'  
    implementation 'org.springframework.boot:spring-boot-starter-security'  
    implementation 'org.springframework.boot:spring-boot-starter-data-jdbc'  
    implementation 'org.springframework.security:spring-security-oauth2-authorization-server:0.2.3'  
    implementation 'org.springframework.boot:spring-boot-starter-actuator'  
  
    compileOnly 'org.projectlombok:lombok'  
    developmentOnly 'org.springframework.boot:spring-boot-devtools'  
//    runtimeOnly 'mysql:mysql-connector-java'  
    runtimeOnly "com.h2database:h2"  
  
    annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor'  
    annotationProcessor 'org.projectlombok:lombok'  
  
    testImplementation 'org.springframework.boot:spring-boot-starter-test'  
    testImplementation 'org.springframework.security:spring-security-test'  
}  
  
tasks.named('test') {  
    useJUnitPlatform()  
}

config

...

@EnableWebSecurity  
@Slf4j  
public class DefaultSecurityConfig {  
  
    @Bean  
    public SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {  
        http.authorizeRequests(authorizeRequests -> authorizeRequests.anyRequest().authenticated())  
                .formLogin(withDefaults());  
        return http.build();  
    }  
  
  
    @Bean  
    public UserDetailsService users() {  
        UserDetails user = User.withDefaultPasswordEncoder()  
                .username("user1")  
                .password("password")  
                .roles("USER")  
                .build();  
        return new InMemoryUserDetailsManager(user);  
    }  
}
...
@Configuration(proxyBeanMethods = false)  
public class AuthorizationServerConfig {  
  
    @Bean  
    @Order(Ordered.HIGHEST_PRECEDENCE)  
    public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http) throws Exception {  
        OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http);  
        return http.formLogin(withDefaults()).build();  
    }  
  
    @Bean  
    public RegisteredClientRepository registeredClientRepository(JdbcTemplate jdbcTemplate) {  
        RegisteredClient registeredClient = RegisteredClient.withId(UUID.randomUUID().toString())  
                .clientId("mobile-gateway-client")  
                .clientSecret("{noop}123456")  
                .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)  
                .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)  
                .authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN)  
                .authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS)  
                .redirectUri("//127.0.0.1:9100/login/oauth2/code/mobile-gateway-client-oidc")  
                .redirectUri("//127.0.0.1:9100/authorized")  
                .scope(OidcScopes.OPENID)  
                .scope("message.read")  
                .scope("message.write")  
                .build();  
  
        JdbcRegisteredClientRepository registeredClientRepository = new JdbcRegisteredClientRepository(jdbcTemplate);  
        registeredClientRepository.save(registeredClient);  
  
        return registeredClientRepository;  
    }  
  
    @Bean  
    public OAuth2AuthorizationService authorizationService(JdbcTemplate jdbcTemplate, RegisteredClientRepository registeredClientRepository) {  
        return new JdbcOAuth2AuthorizationService(jdbcTemplate, registeredClientRepository);  
    }  
  
    @Bean  
    public JWKSource<SecurityContext> jwkSource() {  
        RSAKey rsaKey = Jwks.generateRsa();  
        JWKSet jwkSet = new JWKSet(rsaKey);  
        return (jwkSelector, securityContext) -> jwkSelector.select(jwkSet);  
    }  
  
    @Bean  
    public ProviderSettings providerSettings() {  
        return ProviderSettings.builder().issuer("//localhost:9000").build();  
    }  
  
    @Bean  
    public EmbeddedDatabase embeddedDatabase() {  
        return new EmbeddedDatabaseBuilder()  
                .generateUniqueName(true)  
                .setType(EmbeddedDatabaseType.H2)  
                .setScriptEncoding("UTF-8")  
                .addScript("org/springframework/security/oauth2/server/authorization/oauth2-authorization-schema.sql")  
                .addScript("org/springframework/security/oauth2/server/authorization/oauth2-authorization-consent-schema.sql")  
                .addScript("org/springframework/security/oauth2/server/authorization/client/oauth2-registered-client-schema.sql")  
                .build();  
    }
  1. 這裡的兩個 config 中有兩個 SecurityFilterChain 類。調用順序是 authorizationServerSecurityFilterChain、defaultSecurityFilterChain。
  2. registeredClientRepository 用於註冊 client。這裡的兩個 redirectUri 中地址來自於[[#mobile-gateway|移動端網關]]。

application.yml

server:  
  port: 9000  
  
logging:  
  level:  
    root: INFO  
    org.springframework.web: INFO  
    org.springframework.security: INFO  
    org.springframework.security.oauth2: INFO

啟動服務

在瀏覽器中輸入://localhost:9000/.well-known/openid-configuration,得到以下內容。

// 20220510135753
// //localhost:9000/.well-known/openid-configuration

{
  "issuer": "//localhost:9000",
  "authorization_endpoint": "//localhost:9000/oauth2/authorize",
  "token_endpoint": "//localhost:9000/oauth2/token",
  "token_endpoint_auth_methods_supported": [
    "client_secret_basic",
    "client_secret_post",
    "client_secret_jwt",
    "private_key_jwt"
  ],
  "jwks_uri": "//localhost:9000/oauth2/jwks",
  "userinfo_endpoint": "//localhost:9000/userinfo",
  "response_types_supported": [
    "code"
  ],
  "grant_types_supported": [
    "authorization_code",
    "client_credentials",
    "refresh_token"
  ],
  "subject_types_supported": [
    "public"
  ],
  "id_token_signing_alg_values_supported": [
    "RS256"
  ],
  "scopes_supported": [
    "openid"
  ]
}

user-service

用戶服務在 demo 中的角色是資源伺服器。

build.gradle

plugins {  
   id 'org.springframework.boot' version '2.6.7'  
   id 'io.spring.dependency-management' version '1.0.11.RELEASE'  
   id 'java'  
}  
  
group = 'com.insight.into.life'  
version = '0.0.1-SNAPSHOT'  
sourceCompatibility = '17'  
  
configurations {  
   compileOnly {  
      extendsFrom annotationProcessor  
   }  
}  
  
repositories {  
   mavenCentral()  
}  
  
dependencies {  
   implementation 'org.springframework.boot:spring-boot-starter-oauth2-resource-server'  
   implementation 'org.springframework.boot:spring-boot-starter-web'  
   compileOnly 'org.projectlombok:lombok'  
   annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor'  
   annotationProcessor 'org.projectlombok:lombok'  
  
   testImplementation 'org.springframework.boot:spring-boot-starter-test'  
}  
  
tasks.named('test') {  
   useJUnitPlatform()  
}

config

...
@EnableWebSecurity  
public class ResourceServerConfig {  
  
    @Bean  
    SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {  
        http.mvcMatcher("/menu/**")  
                .authorizeRequests()  
                .mvcMatchers("/menu/**").access("hasAuthority('SCOPE_message.read')")  
                .and()  
                .oauth2ResourceServer()  
                .jwt();  
        return http.build();  
    }  
}

定義 menu 路徑下的訪問許可權。

@RestController  
@RequestMapping("/menu")  
public class MenuController {  
  
    @GetMapping("/list")  
    public List<String> list() {  
        return List.of("menu1", "menu2", "menu3");  
    }  
}

application.yml

server:  
  port: 9001  
  
spring:  
  application:  
    name: user-service  
  security:  
    oauth2:  
      resourceserver:  
        jwt:  
          issuer-uri: //localhost:9000

啟動服務

資源伺服器目前不需要做額外配置,只需要啟動即可。

client-gateway

build.gradle

plugins {  
    id 'org.springframework.boot' version '2.6.7'  
    id 'io.spring.dependency-management' version '1.0.11.RELEASE'  
    id 'java'  
}  
  
group = 'com.insight.into.life'  
version = '0.0.1-SNAPSHOT'  
sourceCompatibility = '17'  
  
configurations {  
    compileOnly {  
        extendsFrom annotationProcessor  
    }  
}  
  
repositories {  
    mavenCentral()  
}  
  
dependencies {  
    implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'  
    implementation 'org.springframework.boot:spring-boot-starter-web'  
    implementation "org.springframework:spring-webflux"  
    implementation "io.projectreactor.netty:reactor-netty"  
    implementation 'org.springframework.cloud:spring-cloud-starter-openfeign:3.1.2'  
  
    compileOnly 'org.projectlombok:lombok'  
    annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor'  
    annotationProcessor 'org.projectlombok:lombok'  
}  
  
tasks.named('test') {  
    useJUnitPlatform()  
}

這裡引入 org.springframework:spring-webfluxio.projectreactor.netty:reactor-netty 的原因在於使用了 WebClient。

config

...
@Component  
@Order(Ordered.HIGHEST_PRECEDENCE)  
public class LoopbackIpRedirectFilter extends OncePerRequestFilter {  
  
    @Override  
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {  
        if (request.getServerName().equals("localhost") && request.getHeader("host") != null) {  
            UriComponents uri = UriComponentsBuilder.fromHttpRequest(new ServletServerHttpRequest(request))  
                    .host("127.0.0.1").build();  
            response.sendRedirect(uri.toUriString());  
            return;  
        }  
        filterChain.doFilter(request, response);  
    }  
  
}

該配置用於轉換地址。將 localhost 轉換為 127.0.0.1

...

@EnableWebSecurity  
@Slf4j  
public class SecurityConfig {  
  
    @Bean  
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {  
        http.authorizeRequests(authorizeRequests -> authorizeRequests.anyRequest().authenticated())  
                .oauth2Login(oauth2Login -> oauth2Login.loginPage("/oauth2/authorization/mobile-gateway-client-oidc"))  
                .oauth2Client(withDefaults());  
        return http.build();  
    }  
}
...
@Configuration  
public class WebClientConfig {  
  
    @Bean  
    WebClient webClient(OAuth2AuthorizedClientManager authorizedClientManager) {  
        ServletOAuth2AuthorizedClientExchangeFilterFunction oauth2Client = new ServletOAuth2AuthorizedClientExchangeFilterFunction(authorizedClientManager);  
        return WebClient.builder().apply(oauth2Client.oauth2Configuration()).build();  
    }  
  
    @Bean  
    OAuth2AuthorizedClientManager authorizedClientManager(ClientRegistrationRepository clientRegistrationRepository, OAuth2AuthorizedClientRepository authorizedClientRepository) {  
        OAuth2AuthorizedClientProvider authorizedClientProvider = OAuth2AuthorizedClientProviderBuilder.builder()  
                .authorizationCode()  
                .refreshToken()  
                .build();  
        DefaultOAuth2AuthorizedClientManager authorizedClientManager = new DefaultOAuth2AuthorizedClientManager(clientRegistrationRepository, authorizedClientRepository);  
        authorizedClientManager.setAuthorizedClientProvider(authorizedClientProvider);  
        return authorizedClientManager;  
    }  
}

AuthController

@RestController  
@Slf4j  
@RequiredArgsConstructor  
public class AuthController {  
  
    private final WebClient webClient;  
    @Value("${user-service.base-uri}")  
    private String userServiceBaseUri;  
  
    @GetMapping("/menus")  
    public String menus(@RegisteredOAuth2AuthorizedClient("client-gateway-authorization-code") OAuth2AuthorizedClient authorizedClient) {  
        return this.webClient  
                .get()  
                .uri(userServiceBaseUri)  
                .attributes(oauth2AuthorizedClient(authorizedClient))  
                .retrieve()  
                .bodyToMono(String.class)  
                .block();  
    }  
  
}

application.yml

server:  
  port: 9100  
  
spring:  
  application:  
    name: client-gateway  
  security:  
    oauth2:  
      client:  
        registration:  
          mobile-gateway-client-oidc:  
            provider: spring  
            client-id: mobile-gateway-client  
            client-secret: 123456  
            authorization-grant-type: authorization_code  
            redirect-uri: "//127.0.0.1:9100/login/oauth2/code/{registrationId}"  
            scope: openid  
          client-gateway-authorization-code:  
            provider: spring  
            client-id: mobile-gateway-client  
            client-secret: 123456  
            client-authentication-method: client_secret_basic  
            authorization-grant-type: authorization_code  
            redirect-uri: "//127.0.0.1:9100/authorized"  
            scope: message.read,message.write  
        provider:  
          spring:  
            issuer-uri: //localhost:9000  
  
user-service:  
  base-uri: //127.0.0.1:9001/menu/list

啟動服務

在瀏覽器中輸入://127.0.0.1:9100

輸入帳號密碼:user1/password,這裡的用戶在 [[#auth-center#config]] 中配置。得到以下內容:

總結

  1. spring-authorization-server 目前還沒有正式發布。文檔較少。
  2. 還有一些需要完善的點。比如用戶持久化、client 持久化。
  3. 此 demo 還要繼續更新,為了能和本文對應,所以對應的 git tag 為 primitive-man