Spring AS 持久化
jdk version: 17 spring boot version: 2.7.0 spring authorization server:0.3.0 mysql version: 8.x
在 [[spring authorization server 实现授权中心]] 中实现了基础的演示功能。本文包含的内容有:
- 在 mysql 中保存客户端信息
- 在 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
-
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.3.0' 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' } ...
-
更改 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 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: "123456" authentication-method: client_secret_basic grant-types: - authorization_code - refresh_token - client_credentials scopes: - openid - message.read - message.write redirect-uris: - http://127.0.0.1:9100/login/oauth2/code/mobile-gateway-client-oidc - http://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("http://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("http://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); } }
总结
- 目前 spring authorization server 版本是 0.3.0 ,在我看来仍然有诸多不完善的地方,但官方总不至于又实现一套 keycloak。
- 0.3.0 版本发布之际,官方文档 也放出来了。