스프링 보안 새 인증 서버(0.3.1) - 3부

31726 단어 oauth2javasecurity
이 부분에서는 서버 구축을 계속할 것이며 특히 인 메모리 클라이언트를 데이터베이스, 영구 솔루션으로 변경할 것입니다.

먼저 엔터티부터 시작하겠습니다. RegisteredClient에서 사용하는 라이브러리에서 제공하는 RegisteredClientRepository 클래스를 검사하여 엔터티를 모델링하는 방법을 알 수 있습니다.

다음은 RegisteredClient 클래스의 필드입니다.
  • id(문자열)
  • clientId(문자열)가 id와 동일한 필드일 수 있음
  • clientIdIssuedAt(Instant) - 엔터티에 포함되지 않음
  • clientSecret(문자열)
  • clientSecretExpiresAt(Instant) - 우리 엔티티에 포함되지 않음
  • clientName(문자열) - 엔터티에 포함되지 않음
  • clientAuthenticationMethods(세트)
  • authorizationGrantTypes(세트)
  • redirectUris(세트)
  • 스코프(세트)
  • clientSettings(ClientSettings) - 당사 엔티티에 포함되지 않음
  • tokenSettings(TokenSettings) - 엔터티에 포함되지 않음

  • 이를 기반으로 클라이언트 엔터티를 원하는 대로 모델링할 수 있지만 결국에는 클라이언트 엔터티에서 RegisteredClient를 구성할 수 있어야 합니다. 우리는 UserDetails에 대한 인터페이스가 없기 때문에 단순히 인터페이스를 구현하고 이 엔터티가 RegisteredClient라고 말할 수 없지만 대신 내가 한 것은 RegisteredClient를 빌드하는 팩토리 클래스를 제공하는 것입니다. 엔티티에서.

    클라이언트 엔터티:

    @Entity
    @Getter
    @Setter
    @NoArgsConstructor
    public class Client {
    
      @Id
      private String id;
      private String secret;
      @ElementCollection(fetch = FetchType.EAGER)
      private Set<AuthorizationGrantType> grantTypes;
      private ClientAuthenticationMethod authenticationMethod;
      @OneToMany(mappedBy = "client", fetch = FetchType.EAGER)
      private Set<ClientRedirectUrl> redirectUris;
      @ManyToMany(cascade = CascadeType.PERSIST, fetch = FetchType.EAGER)
      @JoinTable(name = "client_scope_mapping",
        joinColumns = @JoinColumn(name = "client_id", referencedColumnName = "id"),
        inverseJoinColumns = @JoinColumn(name = "scope_id", referencedColumnName = "id")
      )
      private Collection<ClientScope> scopes;
    
      public Client(CreateClientRequest request) {
        this.id = request.getId();
        this.secret = request.getSecret();
        this.grantTypes = request.getGrantTypes();
        this.authenticationMethod = request.getAuthenticationMethod();
      }
    
      public static RegisteredClient toRegisteredClient(Client client) {
        RegisteredClient.Builder builder = RegisteredClient.withId(client.getId())
          .clientId(client.getId())
          .clientSecret(client.getSecret())
          .clientAuthenticationMethod(client.getAuthenticationMethod())
          .authorizationGrantTypes(
              authorizationGrantTypes -> authorizationGrantTypes.addAll(client.getGrantTypes()))
          .redirectUris(
              redirectUris -> redirectUris.addAll(client.getRedirectUris()
                  .stream()
                  .map(ClientRedirectUrl::getUrl)
                  .collect(Collectors.toSet())))
          .scopes(scopes -> scopes.addAll(client.getScopes()
              .stream()
              .map(ClientScope::getScope)
              .collect(Collectors.toSet())));
        return builder.build();
      }
    
    }
    


    각 클라이언트에 대한 보조금 유형을 데이터베이스에 @ElementCollection로 직렬화된 AuthorizationGrantType 세트를 저장하는 tinyblob로 저장하기로 결정했습니다. 사용 가능한 모든 보조금 유형을 보관할 별도의 테이블을 갖도록 선택할 수 있습니다. 그런 다음 각 클라이언트에 필요한 것을 조인하면 조인 테이블도 필요할 것입니다.

    그런 다음 redirectUris(ClientRedirectUrl)가 있는 @OneToMany와 범위(ClientScope)가 있는 @ManyToMany가 있습니다.

    ClientRedirectUrl 엔티티:

    @Entity
    @Getter
    @NoArgsConstructor
    @Table(name = "client_redirect_uri")
    public class ClientRedirectUrl {
      @Id
      private String id;
      private String url;
      @Setter
      @ManyToOne
      @JoinColumn(name = "client_id", referencedColumnName = "id")
      private Client client;
    
      public ClientRedirectUrl(String url, Client client) {
        this.id = RandomStringUtils.randomAlphanumeric(10);
        this.url = url;
        this.client = client;
      }
    }
    


    ClientScope 엔터티:

    @Entity
    @Getter
    @NoArgsConstructor
    @Setter
    @Table(name = "client_scope")
    public class ClientScope {
    
      @Id
      private String id;
      private String scope;
    
      public ClientScope(String scope) {
        this.id = RandomStringUtils.randomAlphanumeric(10);
        this.scope = scope;
      }
    }
    


    이것은 최종 스키마 다이어그램입니다.



    다음으로, 첫 번째 파트에서 생성한 Oauth2Config 클래스에서 메모리 내 클라이언트 리포지토리를 제공하는 빈을 제거해야 합니다. 대신 RegisteredClientRepository 를 구현할 @Service 를 제공할 것입니다.

    고객 서비스:

    @Service
    @RequiredArgsConstructor
    public class ClientService implements RegisteredClientRepository {
    
      private final ClientRepository clientRepository;
      private final ClientRedirectUrlRepository clientRedirectUrlRepository;
      private final ClientScopeRepository clientScopeRepository;
    
      @Override
      public void save(RegisteredClient registeredClient) {
      }
    
      @Override
      public RegisteredClient findById(String id) {
        var client = this.clientRepository.findById(id).orElseThrow();
        return Client.toRegisteredClient(client);
      }
    
      @Override
      public RegisteredClient findByClientId(String clientId) {
        var client = this.clientRepository.findById(clientId).orElseThrow();
        return Client.toRegisteredClient(client);
      }
    
      public ClientResponse createClient(CreateClientRequest request) {
        var scopes = request.getScopes().stream().map(ClientScope::new).collect(Collectors.toSet());
        scopes.forEach(this.clientScopeRepository::save);
    
        var client = new Client(request);
        client.setScopes(scopes);
        this.clientRepository.save(client);
    
        client.setRedirectUris(request.getRedirectUris().stream()
            .map(url -> new ClientRedirectUrl(url, client))
            .collect(Collectors.toSet()));
        client.getRedirectUris().forEach(this.clientRedirectUrlRepository::save);
    
        return new ClientResponse(client);
      }
    }
    


    이제 Oauth2Config 클래스는 다음과 같습니다.

    @Configuration
    @RequiredArgsConstructor
    public class Oauth2Config {
    
      private final ClientService clientService;
    
      @Bean
      @Order(HIGHEST_PRECEDENCE)
      public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http)
          throws Exception {
        OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http);
    
        http
            // Redirect to the login page when not authenticated from the
            // authorization endpoint
            .exceptionHandling((exceptions) -> exceptions
                .authenticationEntryPoint(
                    new LoginUrlAuthenticationEntryPoint("/login"))
            );
    
    
        return http.build();
      }
    
      @Bean
      public JWKSource<SecurityContext> jwkSource() {
        KeyPair keyPair = generateRsaKey();
        RSAPublicKey publicKey = (RSAPublicKey) keyPair.getPublic();
        RSAPrivateKey privateKey = (RSAPrivateKey) keyPair.getPrivate();
        RSAKey rsaKey = new RSAKey.Builder(publicKey)
            .privateKey(privateKey)
            .keyID(UUID.randomUUID().toString())
            .build();
        JWKSet jwkSet = new JWKSet(rsaKey);
        return new ImmutableJWKSet<>(jwkSet);
      }
    
      private static KeyPair generateRsaKey() {
        KeyPair keyPair;
        try {
          KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA");
          keyPairGenerator.initialize(2048);
          keyPair = keyPairGenerator.generateKeyPair();
        }
        catch (Exception ex) {
          throw new IllegalStateException(ex);
        }
        return keyPair;
      }
    
      @Bean
      public ProviderSettings providerSettings() {
        return ProviderSettings.builder().build();
      }
    
    }
    


    또한 새 클라이언트를 생성하기 위한 엔드포인트를 생성하고 기본 구성에서 이에 대한 요청도 허용해야 했기 때문에 이제 다음과 같이 보입니다.

    @Configuration
    @RequiredArgsConstructor
    public class SecurityConfig {
    
      private final UserService userService;
    
      @Bean
      public SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http)
          throws Exception {
    
        http
            .authorizeRequests()
            .antMatchers("/users/**").permitAll()
            .antMatchers("/clients/**").permitAll()
            .anyRequest().authenticated()
            .and()
            .csrf().ignoringAntMatchers("/users/**", "/clients/**")
            .and()
            .formLogin(Customizer.withDefaults());
    
        return http.build();
      }
    
      @Bean
      public AuthenticationManager authenticationManagerBean() throws Exception {
        var provider = new DaoAuthenticationProvider();
        provider.setUserDetailsService(userService);
        provider.setPasswordEncoder(NoOpPasswordEncoder.getInstance()); //temporary
        return new ProviderManager(provider);
      }
    
      @Bean
      public UserDetailsService userDetailsService() {
        return this.userService;
      }
    
    }
    


    필자의 경우처럼 와일드카드에 대해 permitAll을 허용하는 것은 좋은 생각이 아닐 수 있지만 다시 필요에 따라 수정할 수 있습니다.

    인증을 다시 테스트해 보겠습니다. 먼저 클라이언트를 생성합니다.



    그런 다음 사용자를 만듭니다.



    그런 다음 권한을 부여합니다.
    http://localhost:8080/oauth2/authorize?response_type=code&client_id=client&scope=openid&redirect_uri=http://spring.io&code_challenge=YHRCg0i58poWtvPg_xiSHFBqCahjxCqTyUbhYTAk5ME&code_challenge_method=S256

    자신의 code_challenge를 사용하려면 검증자가 필요합니다.

    토큰 받기:



    작동합니다. 인증 매처 구성, 비밀번호 인코더 또는 인코더 부족과 같이 변경해야 할 몇 가지 분명한 사항이 있지만 요점을 알 수 있습니다.

    당신은 전체 코드here를 찾을 수 있지만, 오픈 소스 버전을 만들려고 할 것이기 때문에 나중에 변경될 것이라는 공정한 경고가 있습니다.

    좋은 웹페이지 즐겨찾기