架构版本
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😁
然后注释或删除掉 DefaultSecurityConfig
、AuthorizationServerConfig
先前 @Bean 方式注入的 UserDetailsService、RegisteredClientRepository。
最后聊聊 watermelon-cloud中的模块的设计
watermelon-authorization 授权服务模块
-watermelon-authorization-server 【授权服务】
-watermelon-authorization-user-core 【用户、客户端相关】为什么要模块化去做呢?
原因时因为:关于持久层的代码写在watermelon-authorization-server
授权服务中,从责任划分来说,用户信息、客户端相关不属于授权服务,授权服务肯定是只干授权的事情,所以将用户、客户端相关单独分一个模块,watermelon-authorization-server
授权服务依赖用户、客户端相关时,引入依赖即可。