Spring Authorization Server(AS)從 Mysql 中讀取客戶端、用戶

Spring AS 持久化

jdk version: 17
spring boot version: 2.7.0
spring authorization server:0.3.0
mysql version: 8.x

在 [[spring authorization server 實現授權中心]] 中實現了基礎的演示功能。本文包含的內容有:

  1. 在 mysql 中保存客戶端資訊
  2. 在 mysql 中保存用戶資訊

創建數據表

查看 [[spring authorization server 實現授權中心#AuthorizationServerConfig]] 可以看到以下配置,這裡定義了一個嵌入數據 Bean,包含 3 條資料庫腳本。分別用於創建

  • oauth2_registered_client
  • oauth2_authorization_consent
  • oauth2_authorization
@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();  
}

oauth2_registered_client

CREATE TABLE oauth2_registered_client (

id varchar(100) NOT NULL,

client_id varchar(100) NOT NULL,

client_id_issued_at timestamp DEFAULT CURRENT_TIMESTAMP NOT NULL,

client_secret varchar(200) DEFAULT NULL,

client_secret_expires_at timestamp DEFAULT NULL,

client_name varchar(200) NOT NULL,

client_authentication_methods varchar(1000) NOT NULL,

authorization_grant_types varchar(1000) NOT NULL,

redirect_uris varchar(1000) DEFAULT NULL,

scopes varchar(1000) NOT NULL,

client_settings varchar(2000) NOT NULL,

token_settings varchar(2000) NOT NULL,

PRIMARY KEY (id)

);

打開 mysql,創建 auth-center 資料庫,執行 [[#oauth2_registered_client]] 腳本。

oauth2_authorization

用戶認證時需要此表。

/*

IMPORTANT:

If using PostgreSQL, update ALL columns defined with 'blob' to 'text',

as PostgreSQL does not support the 'blob' data type.

*/

CREATE TABLE oauth2_authorization (

id varchar(100) NOT NULL,

registered_client_id varchar(100) NOT NULL,

principal_name varchar(200) NOT NULL,

authorization_grant_type varchar(100) NOT NULL,

attributes blob DEFAULT NULL,

state varchar(500) DEFAULT NULL,

authorization_code_value blob DEFAULT NULL,

authorization_code_issued_at timestamp DEFAULT NULL,

authorization_code_expires_at timestamp DEFAULT NULL,

authorization_code_metadata blob DEFAULT NULL,

access_token_value blob DEFAULT NULL,

access_token_issued_at timestamp DEFAULT NULL,

access_token_expires_at timestamp DEFAULT NULL,

access_token_metadata blob DEFAULT NULL,

access_token_type varchar(100) DEFAULT NULL,

access_token_scopes varchar(1000) DEFAULT NULL,

oidc_id_token_value blob DEFAULT NULL,

oidc_id_token_issued_at timestamp DEFAULT NULL,

oidc_id_token_expires_at timestamp DEFAULT NULL,

oidc_id_token_metadata blob DEFAULT NULL,

refresh_token_value blob DEFAULT NULL,

refresh_token_issued_at timestamp DEFAULT NULL,

refresh_token_expires_at timestamp DEFAULT NULL,

refresh_token_metadata blob DEFAULT NULL,

PRIMARY KEY (id)

);

配置 application.yml

  1. build.gradle 中依賴更改如下所示

    • 添加 mysql 驅動
    • 去掉 H2 相關依賴
    
    ...
    
    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'  
    	  
    	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'
    }
    
    ...
    
    
  2. 更改 application.yml 如下

[server:  
  port: 9000  
  
logging:  
  level:  
    root: INFO  
    org.springframework.web: INFO  
    org.springframework.security: INFO  
    org.springframework.security.oauth2: INFO  
  
spring:  
  datasource:  
    driver-class-name: com.mysql.cj.jdbc.Driver  
    url: jdbc:mysql://localhost:3306/auth-center?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai  
    username: root  
    password: 123456](<server:
  port: 9000

logging:
  level:
    root: INFO
    org.springframework.web: INFO
    org.springframework.security: INFO
    org.springframework.security.oauth2: INFO

spring:
  datasource:
    driver-class-name: com.mysql.cj.jdbc.Driver
    url: jdbc:mysql://localhost:3306/auth-center?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai
    username: root
    password: 123456

client:
  registers:
    - client-id: mobile-gateway-client
      client-secret: "{noop}123456"
      authentication-method: client_secret_basic
      grant-types:
        - authorization_code
        - refresh_token
        - client_credentials
      scopes:
        - openid
        - message.read
        - message.write
      redirect-uris:
        - //127.0.0.1:9100/login/oauth2/code/mobile-gateway-client-oidc
        - //127.0.0.1:9100/authorized>)

讀取配置 ConfigurationProperties

...
@ConfigurationProperties(prefix = "client")  
@ConstructorBinding  
public record RegisterClientConfig(List<Register> registers) {  
      
    public record Register(String clientId, String clientSecret, String authenticationMethod, List<String> grantTypes,  
                           List<String> scopes, List<String> redirectUris) {  
    }  
}

添加 Member 對象

@Getter  
@Setter  
@ToString  
@AllArgsConstructor  
@RequiredArgsConstructor  
public class Member implements UserDetails {  
  
    private Long id;  
  
    private String loginAccount;  
  
    private String password;  
  
    @Transient  
    private List<GrantedAuthority> authorities;  
  
  
    @Override  
    public Collection<? extends GrantedAuthority> getAuthorities() {  
        return AuthorityUtils.createAuthorityList("read", "write");  
    }  
  
    @Override  
    public String getPassword() {  
        return password;  
    }  
  
    @Override  
    public String getUsername() {  
        return loginAccount;  
    }  
  
    @Override  
    public boolean isAccountNonExpired() {  
        return true;  
    }  
  
    @Override  
    public boolean isAccountNonLocked() {  
        return true;  
    }  
  
    @Override  
    public boolean isCredentialsNonExpired() {  
        return true;  
    }  
  
    @Override  
    public boolean isEnabled() {  
        return true;  
    }  
}

添加 MbrRepository

@Repository  
public interface MbrRepository extends CrudRepository<Member, Long> {  
  
    Optional<Member> findByLoginAccount(String loginAccount);  
}

MbrService

public interface MbrService extends UserDetailsService {  
  
}

UserDetailsServiceImp

@Service  
@RequiredArgsConstructor  
public class UserDetailsServiceImp implements MbrService {  
  
    private final MbrRepository mbrRepository;  
  
    @Override  
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {  
        return mbrRepository.findByLoginAccount(username).orElseThrow(() -> new UsernameNotFoundException("用戶不存在"));  
    }  
  
}

AuthorizationServerConfig

...
[@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) {  
        return new JdbcRegisteredClientRepository(jdbcTemplate);  
    }  
  
    @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();  
    }  
  
}](<@EnableWebSecurity
@Configuration(proxyBeanMethods = false)
@RequiredArgsConstructor
public class AuthorizationServerConfig {

    private final JdbcTemplate jdbcTemplate;
    private final RegisterClientConfig clientConfig;
    private final MbrService mbrService;

    @Bean
    @Order(1)
    public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http) throws Exception {
        OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http);
        http.oauth2ResourceServer(OAuth2ResourceServerConfigurer::jwt)
                .exceptionHandling((exceptions) -%3E exceptions
                        .authenticationEntryPoint(new LoginUrlAuthenticationEntryPoint("/login"))
                );

        return http.build();
    }

    @Bean
    @Order(2)
    public SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {
        http.authorizeRequests(authorizeRequests -> authorizeRequests.anyRequest().authenticated())
                .userDetailsService(mbrService)
                .formLogin(withDefaults());
        return http.build();
    }

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

    @Bean
    public OAuth2AuthorizationService authorizationService(RegisteredClientRepository registeredClientRepository, PasswordEncoder passwordEncoder) {
        clientConfig.registers().forEach(cfg -> {
            RegisteredClient registeredClientFromDb = registeredClientRepository.findByClientId(cfg.clientId());
            if (registeredClientFromDb != null) {
                return;
            }
            RegisteredClient.Builder registerBuilder = RegisteredClient.withId(UUID.randomUUID().toString())
                    .clientId(cfg.clientId())
                    .clientSecret(passwordEncoder.encode(cfg.clientSecret()))
                    .clientAuthenticationMethod(new ClientAuthenticationMethod(cfg.authenticationMethod()));
            cfg.grantTypes().forEach(grantType -> registerBuilder.authorizationGrantType(new AuthorizationGrantType(grantType)));
            cfg.redirectUris().forEach(registerBuilder::redirectUri);
            cfg.scopes().forEach(registerBuilder::scope);
            registeredClientRepository.save(registerBuilder.build());
        });
        JdbcOAuth2AuthorizationService jdbcOAuth2AuthorizationService = new JdbcOAuth2AuthorizationService(jdbcTemplate, registeredClientRepository);
        jdbcOAuth2AuthorizationService.setAuthorizationRowMapper(new RowMapper(registeredClientRepository));
        return jdbcOAuth2AuthorizationService;
    }

    @Bean
    public JWKSource%3CSecurityContext> 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 JwtDecoder jwtDecoder(JWKSource<SecurityContext> jwkSource) {
        return OAuth2AuthorizationServerConfiguration.jwtDecoder(jwkSource);
    }

    static class RowMapper extends JdbcOAuth2AuthorizationService.OAuth2AuthorizationRowMapper {
        RowMapper(RegisteredClientRepository registeredClientRepository) {
            super(registeredClientRepository);
            getObjectMapper().addMixIn(Member.class, MemberMixin.class);
        }
    }

    @JsonTypeInfo(use = JsonTypeInfo.Id.CLASS)
    @JsonAutoDetect(fieldVisibility = JsonAutoDetect.Visibility.ANY, getterVisibility = JsonAutoDetect.Visibility.NONE,
            isGetterVisibility = JsonAutoDetect.Visibility.NONE)
    @JsonIgnoreProperties(ignoreUnknown = true)
    @JsonDeserialize(using = MemberDeserializer.class)
    static class MemberMixin {
    }

}>)

EncoderConfig

@Configuration  
public class EncoderConfig {  
  
    @Bean  
    @ConditionalOnMissingBean(PasswordEncoder.class)  
    public PasswordEncoder passwordEncoder() {  
        return new BCryptPasswordEncoder();  
    }  
}

MemberDeserializer

public class MemberDeserializer extends JsonDeserializer<Member> {  
  
    @Override  
    public Member deserialize(JsonParser jsonParser, DeserializationContext deserializationContext) throws IOException {  
        ObjectMapper mapper = (ObjectMapper) jsonParser.getCodec();  
        JsonNode jsonNode = mapper.readTree(jsonParser);  
        Long id = readJsonNode(jsonNode, "id").asLong();  
        String loginAccount = readJsonNode(jsonNode, "loginAccount").asText();  
        String password = readJsonNode(jsonNode, "password").asText();  
        List<GrantedAuthority> authorities = mapper.readerForListOf(GrantedAuthority.class).readValue(jsonNode.get("authorities"));  
        return new Member(id, loginAccount, password, authorities);  
    }  
  
    private JsonNode readJsonNode(JsonNode jsonNode, String field) {  
        return jsonNode.has(field) ? jsonNode.get(field) : MissingNode.getInstance();  
    }  
}

啟動服務

@SpringBootApplication  
@ConfigurationPropertiesScan  
public class AuthCenterApplication {  
  
    public static void main(String[] args) {  
        SpringApplication.run(AuthCenterApplication.class, args);  
    }  
}

總結

  1. 目前 spring authorization server 版本是 0.3.0 ,在我看來仍然有諸多不完善的地方,但官方總不至於又實現一套 keycloak。
  2. 0.3.0 版本發布之際,官方文檔 也放出來了。