🍉Spring Authorization Server (5) 授权服务器【用户、客户端信息】扩展

1,075 阅读6分钟

架构版本
Spring Boot 3.1
Spring Authorization Server 1.1.1
spring-cloud 2022.0.3
spring-cloud-alibaba 2022.0.0.0
完整代码👉watermelon-cloud

一切要从授权服务的配置说起

DefaultSecurityConfig

  @Bean
  public UserDetailsService users() {
  	UserDetails user = User.withDefaultPasswordEncoder()
  			.username("user1")
  			.password("password")
  			.roles("USER")
  			.build();
  	return new InMemoryUserDetailsManager(user);
  }

AuthorizationServerConfig

  @Bean
  public RegisteredClientRepository registeredClientRepository(JdbcTemplate jdbcTemplate) {
  	RegisteredClient registeredClient = RegisteredClient.withId(UUID.randomUUID().toString())
  			.clientId("messaging-client")
  			.clientSecret("{noop}secret")
  			.clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
  			.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
  			.authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN)
  			.authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS)
  			.redirectUri("http://127.0.0.1:8080/login/oauth2/code/messaging-client-oidc")
  			.redirectUri("http://127.0.0.1:8080/authorized")
  			.postLogoutRedirectUri("http://127.0.0.1:8080/logged-out")
  			.scope(OidcScopes.OPENID)
  			.scope(OidcScopes.PROFILE)
  			.scope("message.read")
  			.scope("message.write")
  			.clientSettings(ClientSettings.builder().requireAuthorizationConsent(true).build())//requireAuthorizationConsent(true) 授权页是有的 如果是false是没有的
  			.build();

  	RegisteredClient deviceClient = RegisteredClient.withId(UUID.randomUUID().toString())
  			.clientId("device-messaging-client")
  			.clientAuthenticationMethod(ClientAuthenticationMethod.NONE)
  			.authorizationGrantType(AuthorizationGrantType.DEVICE_CODE)
  			.authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN)
  			.scope("message.read")
  			.scope("message.write")
  			.build();

  	// Save registered client's in db as if in-memory
  	JdbcRegisteredClientRepository registeredClientRepository = new JdbcRegisteredClientRepository(jdbcTemplate);
  	registeredClientRepository.save(registeredClient);
  	registeredClientRepository.save(deviceClient);

  	return registeredClientRepository;
  }

用户信息、客户端配置肯定不能是基于 Memory 存储是吧,特别是用户信息,客户端数据也不多,存内存影响不大,不过我们还是都存数据库。

最近公司都不用Mysql了,再是因为这次搭建的Spring Cloud 架构 整体都会用 PostgreSQL 去做持久化存储,用PostgreSQL 的原因很简单 优势比Mysql 更多 ,存储和查询、数据结构上也有更多的支持。

扩展篇就看新的工程了 watermelon-cloud👉 https://github.com/WatermelonPlanet/watermelon-cloud 基于Spring Boot 3.1.0 、Spring Authorization Server 1.1.1、Spring Cloud Alibaba 2022.0.0.0、Spring Cloud 2022.0.3 搭建的 oauth2 微服务架构。

PostgreSQL 此次涉及到的sql脚本

sys_registered_client客户端信息表

DROP TABLE IF EXISTS sys_registered_client;
CREATE TABLE sys_registered_client (
 id varchar(64)  NOT NULL,
 client_id varchar(100)  NOT NULL,
 client_id_issued_at timestamp(6),
 client_secret varchar(200) ,
 client_secret_expires_at timestamp(6),
 client_name varchar(200)  NOT NULL,
 client_authentication_methods jsonb,
 authorization_grant_types jsonb,
 redirect_uris jsonb,
 post_logout_redirect_uris jsonb,
 scopes jsonb,
 client_settings json,
 token_settings json
)
;

-- ----------------------------
-- Records of sys_registered_client
-- ----------------------------
INSERT INTO sys_registered_client VALUES ('1702591381795115010', 'device-messaging-client', NULL, NULL, NULL, 'a8513cd1-ad98-4817-9c60-58e7712af873', '[none]', '[refresh_token, urn:ietf:params:oauth:grant-type:device_code]', '[]', '[]', '[message.read, message.write]', '{settings.client.require-proof-key: false, settings.client.require-authorization-consent: false}', '{settings.token.access-token-format: {value: self-contained}, settings.token.reuse-refresh-tokens: true, settings.token.device-code-time-to-live: PT5M, settings.token.access-token-time-to-live: PT5M, settings.token.refresh-token-time-to-live: PT1H, settings.token.id-token-signature-algorithm: RS256, settings.token.authorization-code-time-to-live: PT5M}');
INSERT INTO sys_registered_client VALUES ('1703682313609162754', 'messaging-client', NULL, '{bcrypt}$2a$10$U6e5ChyXSWL7Js7hs0GWlew5BMdjzhlB6Kwx7gK2fXgU9skSe8kEq', NULL, '9069244b-ca7c-4788-93f1-64f23e0b2250', '[client_secret_basic]', '[refresh_token, client_credentials, password, authorization_code]', '[http://127.0.0.1:8080/authorized, http://127.0.0.1:8080/login/oauth2/code/messaging-client-oidc]', '[http://127.0.0.1:8080/logged-out]', '[openid, profile, message.read, message.write]', '{settings.client.require-proof-key: false, settings.client.require-authorization-consent: true}', '{settings.token.access-token-format: {value: self-contained}, settings.token.reuse-refresh-tokens: true, settings.token.device-code-time-to-live: PT5M, settings.token.access-token-time-to-live: PT5M, settings.token.refresh-token-time-to-live: PT1H, settings.token.id-token-signature-algorithm: RS256, settings.token.authorization-code-time-to-live: PT5M}');

-- ----------------------------
-- Indexes structure for table sys_registered_client
-- ----------------------------
CREATE UNIQUE INDEX sys_registered_client_unique_index ON sys_registered_client USING btree (
 client_id  pg_catalog.text_ops ASC NULLS LAST
);
COMMENT ON INDEX sys_registered_client_unique_index IS 'sys_registered_client 唯一索引';

-- ----------------------------
-- Primary Key structure for table sys_registered_client
-- ----------------------------
ALTER TABLE sys_registered_client ADD CONSTRAINT sys_registered_client_pkey PRIMARY KEY (id);

sys_user用户表

DROP TABLE IF EXISTS sys_user;
CREATE TABLE sys_user (
 create_time timestamp(6) NOT NULL DEFAULT timezone('UTC-8'::text, (now())::timestamp(0) without time zone),
 modified_time timestamp(6) DEFAULT timezone('UTC-8'::text, (now())::timestamp(0) without time zone),
 id int8 NOT NULL DEFAULT nextval('sys_user_id_seq'::regclass),
 name varchar(64)  NOT NULL,
 password varchar(255) ,
 phone varchar(11)  NOT NULL,
 mobile varchar(255)  NOT NULL,
 avatar varchar(255) ,
 status int2 NOT NULL DEFAULT 1
)
;
COMMENT ON COLUMN sys_user.create_time IS '创建时间';
COMMENT ON COLUMN sys_user.modified_time IS '修改时间';
COMMENT ON COLUMN sys_user.id IS 'id';
COMMENT ON COLUMN sys_user.name IS '用户名称';
COMMENT ON COLUMN sys_user.password IS '密码';
COMMENT ON COLUMN sys_user.phone IS '手机号(未加密)';
COMMENT ON COLUMN sys_user.mobile IS '手机号(加密)';
COMMENT ON COLUMN sys_user.avatar IS '头像';
COMMENT ON COLUMN sys_user.status IS '账号状态(0:无效;1:有效)';
COMMENT ON TABLE sys_user IS '用户表';

-- ----------------------------
-- Primary Key structure for table sys_user
-- ----------------------------
ALTER TABLE sys_user ADD CONSTRAINT sys_user_pkey PRIMARY KEY (id);

mysql8.0 +版本的sql脚本👉 https://github.com/WatermelonPlanet/watermelon-cloud/tree/master/watermelon-authorization/watermelon-authorization-user-core/doc/sql/mysql

用户存储扩展

UserDetailsService

public interface UserDetailsService {


  UserDetails loadUserByUsername(String username) throws UsernameNotFoundException;

}

UserDetailsService 原来是一个接口,自定义一个接口实现,so easy 🤔
开干 🤓

@Component
@RequiredArgsConstructor
public class UserDetailsServiceImpl implements UserDetailsService {

   private final SysUserService sysUserService;

   private final PasswordEncoder passwordEncoder;

   @Override
   public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
       //如今这个世界 我们肯定都用手机号登录的了
       SysUserDetailDto sysUser = sysUserService.findOneByPhone(username);
       if (sysUser == null) {
           throw new UsernameNotFoundException("手机号:" + username + "未注册!");
       }
       //todo 后续可自行修改和完善
       List<GrantedAuthority> authorityList = AuthorityUtils.createAuthorityList("/oauth2/token", "/oauth2/authorize","/authorized");
       SysUserDto sysUserDto = new SysUserDto();
       sysUserDto.setUsername(username);
       sysUserDto.setAuthorities(authorityList);
       sysUserDto.setId(sysUser.getId());
       sysUserDto.setAvatar(sysUser.getAvatar());
       sysUserDto.setPassword(passwordEncoder.encode(sysUser.getPassword()));
       sysUserDto.setStatus(sysUser.getStatus());
       sysUserDto.setPhone(sysUser.getPhone());
       return sysUserDto;
   }
}

这个扩展没啥技术含量,是的吧,SysUserService 是 基于 mybatis-plus 定义的service ,这就ok了,是的,以上这个扩展都很简单的。

UserDetails 扩展

@Data
@JsonSerialize
@JsonIgnoreProperties(ignoreUnknown = true)
public class SysUserDto implements UserDetails, Serializable {

   private static final long serialVersionUID = SpringSecurityCoreVersion.SERIAL_VERSION_UID;
   //id
   private  Long id;
   //手机号(未加密)
   private  String phone;
   //用户名
   private  String username;
   //用户名
   private  String password;
   //头像
   private  String avatar;
   //账号状态(0:无效;1:有效)
   private  Integer status;
   //权限
   private Collection<GrantedAuthority> authorities;


   @Override
   public Collection<? extends GrantedAuthority> getAuthorities() {
       return this.authorities;
   }

   @Override
   public String getPassword() {
       return this.password;
   }

   @Override
   public String getUsername() {
       return this.username;
   }

   @Override
   public boolean isAccountNonExpired() {
       return true;
   }

   @Override
   public boolean isAccountNonLocked() {
       return true;
   }

   @Override
   public boolean isCredentialsNonExpired() {
       return true;
   }

   @Override
   public boolean isEnabled() {
       return true;
   }
}

需要用 @JsonSerialize、 @JsonIgnoreProperties(ignoreUnknown = true)处理JSON序列化和反序列化问题。否则 security 会抛异常。

PasswordEncoder 需要注入了,在DefaultSecurityConfig 注入。

@Bean
public PasswordEncoder passwordEncoder() {
  return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}    

为什么要注入 PasswordEncoderFactories.createDelegatingPasswordEncoder() 是因为 /login 登录时有密码验证是在DaoAuthenticationProvider 中 进行密码匹配验证的,所以UserDetailsServiceImpl loadUser()地 要和DaoAuthenticationProvider 中保存一致,否则密码匹配时 两边的passwordEncoder都不一样 密码就一定是匹配错误了。

看看 PasswordEncoder createDelegatingPasswordEncoder 的内部

public static PasswordEncoder createDelegatingPasswordEncoder() {
  	String encodingId = "bcrypt";
  	Map<String, PasswordEncoder> encoders = new HashMap<>();
  	encoders.put(encodingId, new BCryptPasswordEncoder());
  	encoders.put("ldap", new org.springframework.security.crypto.password.LdapShaPasswordEncoder());
  	encoders.put("MD4", new org.springframework.security.crypto.password.Md4PasswordEncoder());
  	encoders.put("MD5", new org.springframework.security.crypto.password.MessageDigestPasswordEncoder("MD5"));
  	encoders.put("noop", org.springframework.security.crypto.password.NoOpPasswordEncoder.getInstance());
  	encoders.put("pbkdf2", Pbkdf2PasswordEncoder.defaultsForSpringSecurity_v5_5());
  	encoders.put("pbkdf2@SpringSecurity_v5_8", Pbkdf2PasswordEncoder.defaultsForSpringSecurity_v5_8());
  	encoders.put("scrypt", SCryptPasswordEncoder.defaultsForSpringSecurity_v4_1());
  	encoders.put("scrypt@SpringSecurity_v5_8", SCryptPasswordEncoder.defaultsForSpringSecurity_v5_8());
  	encoders.put("SHA-1", new org.springframework.security.crypto.password.MessageDigestPasswordEncoder("SHA-1"));
  	encoders.put("SHA-256",
  			new org.springframework.security.crypto.password.MessageDigestPasswordEncoder("SHA-256"));
  	encoders.put("sha256", new org.springframework.security.crypto.password.StandardPasswordEncoder());
  	encoders.put("argon2", Argon2PasswordEncoder.defaultsForSpringSecurity_v5_2());
  	encoders.put("argon2@SpringSecurity_v5_8", Argon2PasswordEncoder.defaultsForSpringSecurity_v5_8());
  	return new DelegatingPasswordEncoder(encodingId, encoders);
  }

PasswordEncoder 默认是 bcrypt 对应的就是 BCryptPasswordEncoder,如果要替换 /login中的 PasswordEncoder,有如下两种解决方案
①:再创建一个Filter 去做后续的验证流程,代码流程不走 DaoAuthenticationProvider
②:继承 DaoAuthenticationProvider 重写 additionalAuthenticationChecks() 方法,目的是重现注入 passwordEncoder后再进行密码匹配。

客户端存储扩展

RegisteredClientRepository

public interface RegisteredClientRepository {


  void save(RegisteredClient registeredClient);

  @Nullable
  RegisteredClient findById(String id);

  @Nullable
  RegisteredClient findByClientId(String clientId);

} 

RegisteredClientRepository 也是一个接口,里面3个方法,spring 很喜欢用接口呢,我们撸一个实现就ok 😎

@Component
@RequiredArgsConstructor
public class MybatisRegisteredClientRepository implements RegisteredClientRepository {


   private static final String CLIENT_ID_NOT_EXIST_ERROR_CODE = "client not exist";

   private static final String ZONED_DATETIME_ZONE_ID = "Asia/Shanghai";

   private final SysRegisteredClientService sysRegisteredClientService;


   @Override
   public void save(RegisteredClient registeredClient) {
       SysRegisteredClientDto sysRegisteredClientDto = new SysRegisteredClientDto();
       sysRegisteredClientDto.setClientId(registeredClient.getClientId());
       sysRegisteredClientDto.setClientName(registeredClient.getClientName());
       sysRegisteredClientDto.setClientSecret(registeredClient.getClientSecret());
       if (registeredClient.getClientIdIssuedAt() != null) {
           sysRegisteredClientDto.setClientIdIssuedAt(registeredClient.getClientIdIssuedAt().atZone(ZoneId.of("Asia/Shanghai")).toLocalDateTime());
       }
       if (registeredClient.getClientSecretExpiresAt() != null) {
           sysRegisteredClientDto.setClientSecretExpiresAt(registeredClient.getClientSecretExpiresAt().atZone(ZoneId.of("Asia/Shanghai")).toLocalDateTime());
       }
       sysRegisteredClientDto.setClientAuthenticationMethods(registeredClient.getClientAuthenticationMethods().stream().map(ClientAuthenticationMethod::getValue).collect(Collectors.toSet()));
       sysRegisteredClientDto.setAuthorizationGrantTypes(registeredClient.getAuthorizationGrantTypes().stream().map(AuthorizationGrantType::getValue).collect(Collectors.toSet()));
       sysRegisteredClientDto.setRedirectUris(registeredClient.getRedirectUris());
       sysRegisteredClientDto.setPostLogoutRedirectUris(registeredClient.getPostLogoutRedirectUris());
       sysRegisteredClientDto.setScopes(registeredClient.getScopes());
       sysRegisteredClientDto.setTokenSettings(registeredClient.getTokenSettings().getSettings());
       sysRegisteredClientDto.setClientSettings(registeredClient.getClientSettings().getSettings());
       sysRegisteredClientService.saveClient(sysRegisteredClientDto);
   }

   @Override
   public RegisteredClient findById(String id) {
       SysRegisteredClientDto sysRegisteredClientDetailVo = sysRegisteredClientService.getOneById(id);
       if (sysRegisteredClientDetailVo == null) {
           throw new ClientAuthorizationException(new OAuth2Error(CLIENT_ID_NOT_EXIST_ERROR_CODE,
                   "Authorization client table data id not exist: " + id, null),
                   id);
       }
       return sysRegisteredClientDetailConvert(sysRegisteredClientDetailVo);
   }

   @Override
   public RegisteredClient findByClientId(String clientId) {
       SysRegisteredClientDto sysRegisteredClientDto = sysRegisteredClientService.getOneByClientId(clientId);
       if (sysRegisteredClientDto == null) {
           throw new ClientAuthorizationException(new OAuth2Error(CLIENT_ID_NOT_EXIST_ERROR_CODE,
                   "Authorization client id not exist: " + clientId, null),
                   clientId);
       }
       return sysRegisteredClientDetailConvert(sysRegisteredClientDto);
   }

   /**
    * sysRegisteredClientDetailVo 转换为 RegisteredClient
    *
    * @param sysRegisteredClientDto
    * @return
    */
   private RegisteredClient sysRegisteredClientDetailConvert(SysRegisteredClientDto sysRegisteredClientDto) {
       RegisteredClient.Builder builder = RegisteredClient
               .withId(sysRegisteredClientDto.getId())
               .clientId(sysRegisteredClientDto.getClientId())
               .clientSecret(sysRegisteredClientDto.getClientSecret())
               .clientIdIssuedAt(Optional.ofNullable(sysRegisteredClientDto.getClientIdIssuedAt())
                       .map(d -> d.atZone(ZoneId.of(ZONED_DATETIME_ZONE_ID)).toInstant())
                       .orElse(null))
               .clientSecretExpiresAt(Optional.ofNullable(sysRegisteredClientDto.getClientSecretExpiresAt())
                       .map(d -> d.atZone(ZoneId.of(ZONED_DATETIME_ZONE_ID)).toInstant())
                       .orElse(null))
               .clientName(sysRegisteredClientDto.getClientName())
               .clientAuthenticationMethods(c ->
                       c.addAll(sysRegisteredClientDto.getClientAuthenticationMethods()
                               .stream().map(ClientAuthenticationMethod::new).collect(Collectors.toSet()))
               ).authorizationGrantTypes(a ->
                       a.addAll(sysRegisteredClientDto.getAuthorizationGrantTypes()
                               .stream().map(AuthorizationGrantType::new).collect(Collectors.toSet()))
               ).redirectUris(r -> r.addAll(sysRegisteredClientDto.getRedirectUris()))
               .postLogoutRedirectUris(p -> p.addAll(sysRegisteredClientDto.getPostLogoutRedirectUris()))
               .scopes(s -> s.addAll(sysRegisteredClientDto.getScopes()))
               .clientSettings(ClientSettings.builder().requireAuthorizationConsent(true).build());// requireAuthorizationConsent(true) 不设置 授权页不会显示
//                .tokenSettings(TokenSettings.builder().build());
       //todo clientSettings和 tokenSettings 根据需要后续自行修改
//                .clientSettings(ClientSettings.withSettings(sysRegisteredClientDetailVo.getClientSettings()).build());
       return builder.build();

   }
}

以上用户、客户端基于PostgreSQL扩展都搞定了,so easy😁 然后注释或删除掉 DefaultSecurityConfigAuthorizationServerConfig 先前 @Bean 方式注入的 UserDetailsService、RegisteredClientRepository。

最后聊聊 watermelon-cloud中的模块的设计

watermelon-authorization 授权服务模块
-watermelon-authorization-server 【授权服务】
-watermelon-authorization-user-core 【用户、客户端相关】

为什么要模块化去做呢?
原因时因为:关于持久层的代码写在watermelon-authorization-server 授权服务中,从责任划分来说,用户信息、客户端相关不属于授权服务,授权服务肯定是只干授权的事情,所以将用户、客户端相关单独分一个模块,watermelon-authorization-server 授权服务依赖用户、客户端相关时,引入依赖即可。