2024. 2. 25. 19:08ㆍSpring Security
이 장에서는 다음을 다룹니다.
- UserDetails 인터페이스를 사용하여 사용자를 설명하기
- 인증 흐름에서 UserDetailsService 사용하기
- UserDetailsService의 커스텀 구현 생성하기
- UserDetailsManager의 커스텀 구현 생성하기
- 인증 흐름에서 JdbcUserDetailsManager 사용하기
대학 동료 중 한 명이 요리를 꽤 잘합니다. 그는 화려한 식당의 셰프는 아니지만 요리에 대한 열정이 대단해요. 어느 날, 토론 중에 생각을 공유하면서 그에게 어떻게 그렇게 많은 레시피를 기억하는지 물었습니다. 그는 그게 쉽다고 말했습니다. "전체 레시피를 기억할 필요는 없어요, 기본 재료들이 서로 어떻게 맞는지만 알면 돼요. 마치 현실 세계의 계약서처럼, 무엇을 섞어야 하고 무엇을 섞지 말아야 하는지 알려주죠. 그리고 각 레시피마다 몇 가지 요령만 기억하면 돼요." 이 비유는 아키텍처가 작동하는 방식과 유사합니다. 어떤 견고한 프레임워크에서든, 우리는 계약을 사용하여 프레임워크의 구현을 그 위에 구축된 애플리케이션으로부터 분리합니다. 자바에서는 인터페이스를 사용하여 계약을 정의합니다. 프로그래머는 셰프와 비슷하게, 재료가 어떻게 "함께 작동하는지" 알고 있어서 딱 맞는 "구현"을 선택합니다. 프로그래머는 프레임워크의 추상화를 알고 그것을 통합하는 데 사용합니다.
이 장에서는 2장에서 처음 접한 핵심 역할 중 하나인 UserDetailsService에 대해 자세히 이해하는 것에 대해 다룹니다. UserDetailsService와 함께 다음 인터페이스(계약)들에 대해서도 논의할 것입니다:
- UserDetails는 Spring Security에서 사용자를 설명합니다.
- GrantedAuthority는 사용자가 실행할 수 있는 동작을 정의할 수 있게 합니다.
- UserDetailsManager는 UserDetailsService 계약을 확장합니다. 상속된 행동 외에도 사용자 생성, 사용자 비밀번호 수정 또는 삭제와 같은 동작을 설명합니다.
2장에서 이미 UserDetailsService와 PasswordEncoder가 인증 프로세스에서 어떤 역할을 하는지 알고 있습니다. 하지만 우리는 Spring Boot에 의해 설정된 디폴트 인스턴스 대신 당신이 정의한 인스턴스를 플러그인하는 방법만 논의했습니다. 더 자세히 논의할 내용이 있습니다:
- Spring Security가 제공하는 구현체는 무엇이며 어떻게 사용하는지
- 계약에 대한 커스텀 구현을 정의하는 방법과 그 시기
- 실제 애플리케이션에서 찾을 수 있는 인터페이스 구현 방법
- 이러한 인터페이스 사용 시의 모범 사례
플랜은 Spring Security가 사용자 정의를 어떻게 이해하는지로 시작합니다. 이를 위해 UserDetails와 GrantedAuthority 계약에 대해 논의할 것입니다. 그런 다음 UserDetailsService와 UserDetailsManager가 이 계약을 어떻게 확장하는지 상세히 설명할 것입니다. 여러분은 이 인터페이스들(예: InMemoryUserDetailsManager, JdbcUserDetailsManager, LdapUserDetailsManager)에 대한 구현을 적용할 것입니다. 이 구현체들이 시스템에 적합하지 않은 경우, 커스텀 구현을 작성할 것입니다.
3.1 Implementing authentication in Spring Security
이전 장에서 우리는 Spring Security를 시작했습니다. 첫 번째 예제에서는 Spring Boot가 새 애플리케이션의 초기 작동 방식을 정의하는 일부 디폴트 값을 어떻게 정의하는지에 대해 논의했습니다. 또한, 우리는 앱에서 자주 찾을 수 있는 다양한 대안을 사용하여 이러한 디폴트 설정을 어떻게 재정의하는지 배웠습니다. 하지만 우리는 당신이 우리가 무엇을 할 것인지에 대한 아이디어를 가질 수 있도록 이러한 것들의 표면만을 고려했습니다. 이 장, 그리고 4장과 5장에서, 우리는 이러한 인터페이스들을 더 자세히 논의할 것이며, 다양한 구현과 실제 애플리케이션에서 그것들을 어디에서 찾을 수 있는지 함께 논의할 것입니다.
그림 3.1은 Spring Security에서의 인증 흐름을 보여줍니다. 이 아키텍처는 Spring Security에 의해 구현된 인증 프로세스의 근간입니다. 이것을 이해하는 것은 정말 중요합니다. 왜냐하면 여러분은 Spring Security 구현에서 어떤 것이든 이것에 의존할 것이기 때문입니다. 이 아키텍처의 일부를 이 책의 거의 모든 장에서 논의하는 것을 관찰할 수 있을 것입니다. 여러분은 이것을 너무 자주 보게 될 것이므로, 아마도 마음속으로 배울 수 있을 것이고, 이것은 좋은 일입니다. 이 아키텍처를 알고 있다면, 재료를 아는 셰프처럼 어떤 레시피든지 함께 만들 수 있습니다.
그림 3.1에서 음영 처리된 상자는 우리가 시작하는 컴포넌트들인 UserDetailsService와 PasswordEncoder를 나타냅니다. 이 두 컴포넌트는 제가 종종 "사용자 관리 부분"이라고 언급하는 흐름의 일부에 초점을 맞춥니다. 이 장에서는 UserDetailsService와 PasswordEncoder가 바로 사용자 세부 정보와 그들의 자격 증명을 직접 다루는 컴포넌트입니다. PasswordEncoder에 대해서는 4장에서 자세히 논의할 예정입니다.
그림 3.1 Spring Security의 인증 흐름입니다. AuthenticationFilter가 요청을 가로채고 인증 책임을 AuthenticationManager에 위임합니다. 인증 로직을 구현하기 위해, AuthenticationManager는 인증 제공자를 사용합니다. 사용자 이름과 비밀번호를 확인하기 위해, AuthenticationProvider는 UserDetailsService와 PasswordEncoder를 사용합니다.
사용자 관리의 일부로, 우리는 UserDetailsService와 UserDetailsManager 인터페이스를 사용합니다. UserDetailsService는 사용자 이름으로 사용자를 검색하는 책임만을 가집니다. 이 행동은 프레임워크가 인증을 완료하는 데 필요한 유일한 것입니다. UserDetailsManager는 사용자 추가, 수정 또는 삭제와 같은 행동을 추가하는데, 이는 대부분의 애플리케이션에서 필요한 기능입니다. 두 계약 간의 분리는 "인터페이스 분리 원칙"의 훌륭한 예입니다. 인터페이스를 분리함으로써 더 나은 유연성을 제공하는데, 이는 프레임워크가 앱이 필요로 하지 않는 행동의 구현을 강제하지 않기 때문입니다. 앱이 사용자를 인증하는 것만 필요로 한다면, UserDetailsService 계약을 구현하는 것만으로 원하는 기능을 충분히 커버할 수 있습니다. 사용자를 관리하기 위해, UserDetailsService와 UserDetailsManager 컴포넌트는 사용자를 표현할 방법이 필요합니다.
Spring Security는 프레임워크가 이해하는 방식으로 사용자를 설명하기 위해 구현해야 하는 UserDetails 계약을 제공합니다. 이 장에서 배울 것처럼, Spring Security에서 사용자는 허용되는 행동의 집합인 권한을 가집니다. 우리는 7장부터 12장까지 권한 부여에 대해 논의할 때 이 권한들을 많이 다룰 것입니다. 하지만 지금으로서는, Spring Security는 사용자가 할 수 있는 행동을 GrantedAuthority 인터페이스로 표현합니다. 우리는 이것들을 종종 "권한"이라고 부르며, 사용자는 하나 이상의 권한을 가집니다. 그림 3.2에서는 인증 흐름의 사용자 관리 부분의 컴포넌트 간의 관계를 나타내는 표현을 찾을 수 있습니다.
그림 3.2 사용자 관리에 관련된 컴포넌트 간의 의존성을 보여줍니다. UserDetailsService는 사용자의 이름으로 사용자를 찾아 사용자의 세부 정보를 반환합니다. UserDetails 계약은 사용자를 설명합니다. 사용자는 GrantedAuthority 인터페이스에 의해 표현되는 하나 이상의 권한을 가집니다. 사용자에게 생성, 삭제 또는 비밀번호 변경과 같은 작업을 추가하기 위해, UserDetailsManager 계약은 작업을 추가하기 위해 UserDetailsService를 확장합니다.
Spring Security 아키텍처에서 이러한 객체들 간의 연결과 그것들을 구현하는 방법을 이해하는 것은 애플리케이션 작업 시 선택할 수 있는 다양한 옵션을 제공합니다. 이 옵션들 중 어느 것이든 당신이 작업 중인 앱에서 올바른 퍼즐 조각이 될 수 있으며, 현명하게 선택해야 합니다. 하지만 선택을 하기 위해서는 먼저 무엇을 선택할 수 있는지 알아야 합니다.
3.2 Describing the user
이 섹션에서는 Spring Security가 이해할 수 있도록 애플리케이션의 사용자를 어떻게 설명하는지 배울 것입니다. 사용자를 표현하는 방법을 배우고 프레임워크가 그들을 인식하게 하는 것은 인증 흐름을 구축하는 데 필수적인 단계입니다. 사용자에 기반하여, 애플리케이션은 결정을 내립니다 - 특정 기능에 대한 호출이 허용되거나 허용되지 않습니다. 사용자와 작업하기 위해서는 먼저 애플리케이션에서 사용자의 프로토타입을 어떻게 정의하는지 이해해야 합니다. 이 섹션에서는 예를 들어 Spring Security 애플리케이션에서 사용자의 청사진을 어떻게 설정하는지 설명합니다.
Spring Security에서 사용자 정의는 UserDetails 계약을 준수해야 합니다. UserDetails 계약은 Spring Security에 의해 이해되는 사용자를 나타냅니다. 사용자를 설명하는 애플리케이션의 클래스는 이 인터페이스를 구현해야 하며, 이런 식으로 프레임워크가 이를 이해합니다.
3.2.1 Describing users with the UserDetails contract
이 섹션에서는 애플리케이션의 사용자를 설명하기 위해 UserDetails 인터페이스를 어떻게 구현하는지 배울 것입니다. 우리는 UserDetails 계약에 의해 선언된 메소드들을 논의하며, 각각을 어떻게 그리고 왜 구현하는지 이해할 것입니다. 먼저 다음 목록에서 제시된 인터페이스를 살펴보며 시작해 보겠습니다.
Listing 3.1 The UserDetails interface
public interface UserDetails extends Serializable {
String getUsername(); #A
String getPassword();
Collection<? extends GrantedAuthority>
getAuthorities(); #B
boolean isAccountNonExpired(); #C
boolean isAccountNonLocked();
boolean isCredentialsNonExpired();
boolean isEnabled();
}
#A These methods return the user credentials.
#B Returns the actions that the app allows the user to do as a collection of GrantedAuthority instances
#C These four methods enable or disable the account for different reasons.
getUsername()과 getPassword() 메소드는 예상대로 사용자 이름과 비밀번호를 리턴합니다. 앱은 이러한 값을 인증 과정에서 사용하며, 이 계약과 관련된 인증 관련 세부 정보는 이것들이 전부입니다. 다른 다섯 가지 메소드는 모두 사용자가 애플리케이션의 자원에 접근하는 것을 승인하는 데 관련되어 있습니다. 일반적으로 앱은 사용자가 애플리케이션의 맥락에서 의미 있는 일부 행동을 할 수 있게 해야 합니다. 예를 들어, 사용자는 데이터를 읽거나, 쓰거나, 삭제할 수 있어야 합니다. 우리는 사용자가 행동을 수행할 권한이 있거나 없다고 말하며, 권한은 사용자가 가진 특권을 나타냅니다. `getAuthorities()` 메소드를 구현하여 사용자에게 부여된 권한 그룹을 반환합니다.
참고로 여러분이 6장에서 배우게 될 것처럼, Spring Security는 권한을 세밀한 특권이나 권한의 그룹인 Role[Guest, User, Admin]을 지칭하는 데 사용합니다. 여러분의 읽기를 더 수월하게 하기 위해, 이 책에서는 세밀한 특권을 권한이라고 지칭합니다.
또한 UserDetails 계약에서 볼 수 있듯이, 사용자는 다음과 같은 조치를 취할 수 있습니다:
- 계정[account]을 만료시키다
- 계정을 잠그다[lock]
- 자격 증명[credential]을 만료시키다
- 계정을 비활성화[Disable]하다
애플리케이션의 로직에서 이러한 사용자 제한을 구현하기로 결정했다면, isAccountNonExpired(), isAccountNonLocked(), isCredentialsNonExpired(), isEnabled()와 같은 메서드들을 오버라이드해야 하며, 활성화가 필요한 항목들은 true를 반환하도록 해야 합니다. 모든 애플리케이션에서 계정이 만료되거나 특정 조건으로 잠기는 것은 아닙니다. 만약 이러한 기능들을 애플리케이션에 구현할 필요가 없다면, 이 네 가지 메서드가 단순히 true를 반환하도록 만들 수 있습니다.
참고로 UserDetails 인터페이스의 마지막 네 개의 메서드 이름은 이상하게 들릴 수 있습니다. 이 이름들은 클린 코딩과 유지 보수 측면에서 현명하게 선택되지 않았다고 주장할 수도 있습니다. 예를 들어, isAccountNonExpired()라는 이름은 이중 부정처럼 보이며 처음 보았을 때 혼란을 일으킬 수 있습니다. 하지만 주의 깊게 모든 네 가지 메서드 이름을 분석해 보세요. 이 이름들은 인증이 실패해야 하는 경우 모두 false를 반환하고 그렇지 않은 경우는 true를 반환하도록 지어졌습니다. 이 접근 방식이 올바른 이유는 인간의 마음이 false라는 단어를 부정적인 상황과 연관시키고 true라는 단어를 긍정적인 시나리오와 연관시키는 경향이 있기 때문입니다.
3.2.2 Detailing on the GrantedAuthority contract
3.2.1절에서 UserDetails 인터페이스의 정의를 볼 때, 사용자에게 부여된 행동들을 권한이라고 부릅니다. 7장부터 12장까지, 우리는 이러한 사용자 권한을 기반으로 한 인증 구성을 작성할 것입니다. 그러므로 권한을 정의하는 방법을 아는 것이 중요합니다.
권한은 사용자가 애플리케이션에서 할 수 있는 것을 나타냅니다. 권한 없이는 모든 사용자가 동등할 것입니다. 사용자가 동등한 간단한 애플리케이션이 있긴 하지만, 대부분의 실용적인 시나리오에서는 애플리케이션이 여러 종류의 사용자를 정의합니다. 어떤 애플리케이션은 특정 정보만 읽을 수 있는 사용자를 가질 수 있으며, 다른 사용자는 데이터를 수정할 수도 있습니다. 그리고 애플리케이션은 애플리케이션의 기능 요구 사항에 따라 그들 사이를 구별할 수 있어야 합니다. 이것이 사용자에게 필요한 권한입니다. Spring Security에서 권한을 설명하기 위해 GrantedAuthority 인터페이스를 사용합니다.
UserDetails를 구현하는 것을 논의하기 전에, GrantedAuthority 인터페이스를 이해합시다. 우리는 사용자 세부 정보의 정의에서 이 인터페이스를 사용합니다. 이것은 사용자에게 부여된 특권을 나타냅니다. 사용자는 적어도 하나의 권한을 가져야 합니다. 다음은 GrantedAuthority 정의의 구현입니다:
public interface GrantedAuthority extends Serializable {
String getAuthority();
}
권한을 생성하기 위해서는, 그 특권에 대한 이름을 찾아서 추후에 인증 규칙을 작성할 때 그것을 참조할 수 있어야 합니다. 예를 들어, 사용자는 애플리케이션이 관리하는 기록을 읽거나 삭제할 수 있습니다. 이러한 행동에 부여한 이름을 기반으로 인증 규칙을 작성합니다.
이 장에서는 getAuthority() 메소드를 구현하여 권한의 이름을 문자열로 반환할 것입니다. GrantedAuthority 인터페이스는 단 하나의 추상 메소드를 가지고 있으며, 이 책에서는 그 구현을 위해 람다 표현식을 사용하는 예제를 자주 찾아볼 수 있습니다. 또 다른 가능성은 SimpleGrantedAuthority 클래스를 사용하여 권한 인스턴스를 생성하는 것입니다. SimpleGrantedAuthority 클래스는 GrantedAuthority 타입의 불변 인스턴스를 생성하는 방법을 제공합니다. 인스턴스를 구축할 때 권한 이름을 제공합니다. 다음 코드 스니펫에서는 GrantedAuthority를 구현하는 두 가지 예제를 찾을 수 있습니다. 여기서는 람다 표현식을 사용한 다음, SimpleGrantedAuthority 클래스를 사용합니다:
GrantedAuthority g1 = () -> "READ";
GrantedAuthority g2 = new SimpleGrantedAuthority("READ");
3.2.3 Writing a minimal implementation of UserDetails
이 섹션에서는 UserDetails 계약의 첫 번째 구현을 작성할 것입니다. 우리는 각 메소드가 정적 값을 반환하는 기본 구현으로 시작합니다. 그런 다음 실제 시나리오에서 더 자주 찾을 수 있는 버전으로 변경하고, 여러분이 다양하고 서로 다른 사용자 인스턴스를 가질 수 있게 하는 버전으로 변경합니다. 이제 UserDetails와 GrantedAuthority 인터페이스를 구현하는 방법을 알았으니, 애플리케이션을 위한 사용자의 가장 간단한 정의를 작성할 수 있습니다.
DummyUser라는 클래스로, 사용자의 최소한의 설명을 3.2 리스팅에서와 같이 구현해 봅시다. 이 클래스는 주로 UserDetails 계약에 대한 메소드를 구현하는 것을 시연하기 위해 사용됩니다. 이 클래스의 인스턴스는 항상 "bill"이라는 한 명의 사용자만을 참조하며, 이 사용자는 "12345"라는 비밀번호와 "READ"라는 이름의 권한을 가지고 있습니다.
Listing 3.2 The DummyUser class
public class DummyUser implements UserDetails {
@Override
public String getUsername() {
return "bill";
}
@Override
public String getPassword() {
return "12345";
}
// Omitted code
}
리스팅 3.2의 클래스는 UserDetails 인터페이스를 구현하며 그 모든 메서드를 구현해야 합니다. 여기에서는 getUsername()과 getPassword()의 구현을 찾을 수 있습니다. 이 예제에서 이러한 메서드들은 각 속성에 대해 고정된 값을 반환하기만 합니다.
다음으로, 권한 목록에 대한 정의를 추가합니다. 리스팅 3.3은 getAuthorities() 메소드의 구현을 보여줍니다. 이 메소드는 GrantedAuthority 인터페이스의 구현체가 하나만 포함된 컬렉션을 반환합니다.
Listing 3.3 Implementation of the getAuthorities() method
public class DummyUser implements UserDetails {
// Omitted code
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return List.of(() -> "READ");
}
// Omitted code
}
마지막으로, UserDetails 인터페이스의 마지막 네 가지 메소드에 대한 구현을 추가해야 합니다. DummyUser 클래스의 경우, 이들은 항상 true를 반환하는데, 이는 사용자가 항상 활성화되어 있고 사용 가능하다는 것을 의미합니다. 다음 리스팅에서 예제를 찾을 수 있습니다.
Listing 3.4 Implementation of the last four UserDetails interface methods
public class DummyUser implements UserDetails {
// Omitted code
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
// Omitted code
}
물론, 이 최소 구현은 클래스의 모든 인스턴스가 동일한 사용자를 대표한다는 것을 의미합니다. 이것은 계약을 이해하기 시작하기에 좋은 출발점이지만, 실제 애플리케이션에서는 이렇게 하지 않을 것입니다. 실제 애플리케이션의 경우, 서로 다른 사용자를 대표할 수 있는 인스턴스를 생성할 수 있는 클래스를 만들어야 합니다. 이 경우, 다음 목록에 표시된 것처럼, 클래스에서 최소한 사용자 이름과 비밀번호를 속성으로 가져야 합니다.
Listing 3.5 A more practical implementation of the UserDetails interface
public class SimpleUser implements UserDetails {
private final String username;
private final String password;
public SimpleUser(String username, String password) {
this.username = username;
this.password = password;
}
@Override
public String getUsername() {
return this.username;
}
@Override
public String getPassword() {
return this.password;
}
// Omitted code
}
3.2.4 Using a builder to create instances of the UserDetails type
일부 애플리케이션은 간단해서 UserDetails 인터페이스의 커스텀 구현이 필요하지 않습니다. 이 섹션에서는 Spring Security가 제공하는 빌더 클래스를 사용하여 간단한 사용자 인스턴스를 생성하는 방법을 살펴봅니다. 애플리케이션에 또 하나의 클래스를 선언하는 대신, User 빌더 클래스를 사용하여 사용자를 대표하는 인스턴스를 빠르게 얻을 수 있습니다.
org.springframework.security.core.userdetails 패키지의 User 클래스는 UserDetails 타입의 인스턴스를 구축하는 간단한 방법입니다. 이 클래스를 사용하여 UserDetails의 불변 인스턴스를 생성할 수 있습니다. 적어도 사용자 이름과 비밀번호를 제공해야 하며, 사용자 이름은 빈 문자열이어서는 안 됩니다. 리스팅 3.6은 이 빌더를 사용하는 방법을 보여줍니다. 이런 식으로 사용자를 구축하면, UserDetails 계약의 커스텀 구현이 필요하지 않습니다.
Listing 3.6 Constructing a user with the User builder class
UserDetails u = User.withUsername("bill")
.password("12345")
.authorities("read", "write")
.accountExpired(false)
.disabled(true)
.build();
앞선 리스팅을 예로 들어, User 빌더 클래스의 구조를 더 자세히 살펴보겠습니다. User.withUsername(String username) 메소드는 User 클래스에 중첩된 빌더 클래스 UserBuilder의 인스턴스를 반환합니다. 빌더를 생성하는 또 다른 방법은 UserDetails의 다른 인스턴스에서 시작하는 것입니다. 리스팅 3.7에서 첫 번째 줄은 문자열로 주어진 사용자 이름으로 시작하여 UserBuilder를 구성합니다. 이후에는 이미 존재하는 UserDetails 인스턴스를 시작으로 빌더를 생성하는 방법을 보여줍니다.
Listing 3.7 Creating the User.UserBuilder instance
User.UserBuilder builder1 =
User.withUsername("bill"); #A
UserDetails u1 = builder1
.password("12345")
.authorities("read", "write")
.passwordEncoder(p -> encode(p)) #B
.accountExpired(false)
.disabled(true)
.build(); #C
User.UserBuilder builder2 =
User.withUserDetails(u); #D
UserDetails u2 = builder2.build();
#A Builds a user with their username
#B The password encoder is only a function that does an encoding.
#C At the end of the build pipeline, calls the build() method
#D You can also build a user from an existing UserDetails instance.
리스팅 3.7에 정의된 빌더 중 어느 것이든 사용하여 UserDetails 계약에 의해 표현되는 사용자를 얻을 수 있습니다. 빌드 파이프라인의 끝에서, build() 메소드를 호출합니다. 이 메소드는 비밀번호를 제공한 경우 비밀번호를 인코딩하는 함수를 적용하고, UserDetails의 인스턴스를 구성한 후 리턴합니다.
참고로 여기서 비밀번호 인코더는 Spring Security에서 제공하는 PasswordEncoder 인터페이스의 형태가 아니라 Function<String, String>으로 제공됩니다. 이 함수의 유일한 책임은 주어진 인코딩으로 비밀번호를 변환하는 것입니다. 다음 섹션에서는 2장에서 사용한 Spring Security의 PasswordEncoder 계약에 대해 자세히 논의할 것입니다. PasswordEncoder 계약에 대해서는 4장에서 더 자세히 논의할 예정입니다.
3.2.5 Combining multiple responsibilities related to the user
이전 섹션에서는 UserDetails 인터페이스를 구현하는 방법을 배웠습니다. 실제 시나리오에서는 종종 더 복잡합니다. 대부분의 경우, 사용자와 관련된 여러 책임을 찾을 수 있습니다. 그리고 사용자를 데이터베이스에 저장한 다음 애플리케이션에서는 영속성 엔티티를 나타내는 클래스도 필요할 것입니다. 또는 다른 시스템에서 웹 서비스를 통해 사용자를 검색하는 경우, 사용자 인스턴스를 나타내는 데이터 전송 객체가 필요할 수도 있습니다. 첫 번째 경우를 가정하여, 간단하지만 전형적인 사례로, 우리는 SQL 데이터베이스에 사용자를 저장하는 테이블이 있다고 가정합시다. 예를 간단하게 하기 위해, 각 사용자에게 단 하나의 권한만을 부여합니다. 다음 리스팅은 테이블을 매핑하는 엔티티 클래스를 보여줍니다.
Listing 3.8 Defining the JPA User entity class
@Entity
public class User {
@Id
private Long id;
private String username;
private String password;
private String authority;
// Omitted getters and setters
}
동일한 클래스가 Spring Security의 사용자 세부 정보에 대한 계약도 구현하게 되면, 클래스는 더 복잡해집니다. 다음 목록에 있는 코드가 어떻게 보이는지 어떻게 생각하십니까? 제 입장에서는 이것이 혼란스럽습니다. 저는 이 코드에서 길을 잃을 것 같습니다.
Listing 3.9 The User class has two responsibilities
@Entity
public class User implements UserDetails {
@Id
private int id;
private String username;
private String password;
private String authority;
@Override
public String getUsername() {
return this.username;
}
@Override
public String getPassword() {
return this.password;
}
public String getAuthority() {
return this.authority;
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return List.of(() -> authority);
}
// Omitted code
}
이 클래스에는 JPA 어노테이션, 게터와 세터가 포함되어 있으며, 이 중 getUsername()과 getPassword()는 UserDetails 계약의 메서드를 오버라이드합니다. 이 클래스는 문자열을 반환하는 getAuthority() 메서드와 컬렉션을 반환하는 getAuthorities() 메서드를 가지고 있습니다. getAuthority() 메서드는 클래스 내의 단순한 게터이며, getAuthorities()는 UserDetails 인터페이스의 메서드를 구현합니다. 그리고 다른 엔티티와의 관계를 추가할 때 사태는 더 복잡해집니다. 다시 말하지만, 이 코드는 전혀 친절하지 않습니다!
이 코드를 더 깔끔하게 작성할 수 있는 방법은 무엇일까요? 이전 코드 예제의 혼란스러운 원인은 두 가지 책임의 혼합입니다. 애플리케이션에서 두 가지가 모두 필요하다는 것은 사실이지만, 이 경우에 같은 클래스에 넣어야 한다는 규칙은 없습니다. SecurityUser라고 하는 별도의 클래스를 정의하여 이들을 분리해 보겠습니다. SecurityUser 클래스는 UserDetails 계약을 구현하고 이를 사용하여 우리의 사용자를 Spring Security 아키텍처에 통합합니다. User 클래스는 JPA 엔티티 책임만 남게 됩니다.
Listing 3.10 Implementing the User class only as a JPA entity
@Entity
public class User {
@Id
private int id;
private String username;
private String password;
private String authority;
// Omitted getters and setters
}
리스팅 3.10의 User 클래스는 JPA 엔티티 책임만 남게 되어 더 읽기 쉬워집니다. 이 코드를 읽으면 이제 Spring Security 관점에서 중요하지 않은 영속성과 관련된 세부 사항에만 집중할 수 있습니다. 다음 목록에서는 User 엔티티를 감싸는 SecurityUser 클래스를 구현합니다.
Listing 3.11 The SecurityUser class implements the UserDetails contract
public class SecurityUser implements UserDetails {
private final User user;
public SecurityUser(User user) {
this.user = user;
}
@Override
public String getUsername() {
return user.getUsername();
}
@Override
public String getPassword() {
return user.getPassword();
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return List.of(() -> user.getAuthority());
}
// Omitted code
}
보시다시피, 우리는 SecurityUser 클래스를 시스템 내의 사용자 세부 정보를 Spring Security가 이해하는 UserDetails 계약에 매핑하기 위해서만 사용합니다. SecurityUser가 User 엔티티 없이는 의미가 없다는 사실을 표시하기 위해, 해당 필드를 final로 만듭니다. 사용자는 생성자를 통해 제공해야 합니다. SecurityUser 클래스는 User 엔티티 클래스를 꾸며주고 JPA 엔티티에 코드를 혼합하지 않으면서도 Spring Security 계약과 관련된 필요한 코드를 추가함으로써, 여러 다른 작업을 구현합니다.
참고로 두 가지 책임을 분리하는 다양한 접근 방법이 있습니다. 이 섹션에서 제시한 접근 방법이 최고이거나 유일한 방법이라고 말하고 싶지는 않습니다. 일반적으로 클래스 설계를 구현하는 방식은 사례마다 크게 다릅니다. 하지만 주요 아이디어는 동일합니다: 책임을 혼합하는 것을 피하고 코드를 가능한 한 분리하여 앱의 유지보수성을 높이려고 시도합니다.
3.3 Instructing Spring Security on how to manage users
이전 섹션에서는 UserDetails 계약을 구현하여 Spring Security가 이해할 수 있는 방식으로 사용자를 설명했습니다. 그러나 Spring Security는 사용자를 어떻게 관리하나요? 자격 증명을 비교할 때 사용자는 어디에서 가져오며, 새 사용자를 어떻게 추가하거나 기존 사용자를 어떻게 변경하나요? 2장에서, 프레임워크가 인증 과정에서 사용자 관리를 위임하는 특정 컴포넌트를 정의한다는 것을 배웠습니다: UserDetailsService 인스턴스입니다. 우리는 심지어 Spring Boot에 의해 제공된 기본 구현을 오버라이드하기 위해 UserDetailsService를 정의했습니다.
이 섹션에서는 UserDetailsService 클래스를 구현하는 다양한 방법을 실험합니다. UserDetailsService 계약에 의해 설명된 책임을 우리 예제에서 구현함으로써 사용자 관리가 어떻게 작동하는지 이해할 수 있을 것입니다. 그 후, UserDetailsManager 인터페이스가 UserDetailsService에 의해 정의된 계약에 더 많은 행동을 추가하는 방법을 알게 될 것입니다. 이 섹션의 마지막에서는 Spring Security가 제공하는 UserDetailsManager 인터페이스의 구현을 사용할 것입니다. 우리는 Spring Security가 제공하는 가장 잘 알려진 구현 중 하나인 JdbcUserDetailsManager 클래스를 사용하는 예제 프로젝트를 작성할 것입니다. 이를 통해 배우면, 인증 흐름에서 Spring Security에 사용자를 어디에서 찾을지 알려주는 방법을 알게 될 것입니다.
3.3.1 Understanding the UserDetailsService contract
이 섹션에서는 UserDetailsService 인터페이스 정의에 대해 배울 것입니다. 이를 구현하는 방법과 이유를 이해하기 전에, 먼저 계약을 이해해야 합니다. 이제 UserDetailsService에 대해 더 자세히 알아보고, 이 컴포넌트의 구현과 함께 작업하는 방법에 대해 설명할 시간입니다. UserDetailsService 인터페이스는 다음과 같이 단 하나의 메소드만을 포함합니다:
public interface UserDetailsService {
UserDetails loadUserByUsername(String username)
throws UsernameNotFoundException;
}
인증 구현은 주어진 사용자 이름을 가진 사용자의 세부 정보를 얻기 위해 loadUserByUsername(String username) 메소드를 호출합니다(그림 3.3 참조). 물론, 사용자 이름은 고유하다고 간주됩니다. 이 메소드에 의해 반환된 사용자는 UserDetails 계약의 구현체입니다. 만약 사용자 이름이 존재하지 않으면, 이 메소드는 UsernameNotFoundException을 던집니다.
그림 3.3 AuthenticationProvider는 인증 로직을 구현하는 컴포넌트로, 사용자에 대한 세부 정보를 로드하기 위해 UserDetailsService를 사용합니다. 사용자 이름으로 사용자를 찾기 위해, loadUserByUsername(String username) 메소드를 호출합니다.
참고로 UsernameNotFoundException은 RuntimeException입니다. UserDetailsService 인터페이스의 throws 절은 문서화 목적으로만 사용됩니다. UsernameNotFoundException은 AuthenticationException 타입에서 직접 상속받으며, 이는 인증 과정과 관련된 모든 예외의 부모입니다. AuthenticationException은 더 나아가 RuntimeException 클래스를 상속받습니다.
3.3.2 Implementing the UserDetailsService contract
이 섹션에서는 UserDetailsService의 구현을 시연하기 위해 실제 예제를 다룰 것입니다. 여러분의 애플리케이션은 자격 증명 및 기타 사용자 관련 세부 정보를 관리합니다. 이러한 정보는 데이터베이스에 저장되어 있거나, 웹 서비스나 기타 방법을 통해 접근하는 다른 시스템에 의해 처리될 수 있습니다(그림 3.3 참조). 여러분의 시스템에서 이러한 일이 어떻게 일어나든, Spring Security가 여러분에게 요구하는 유일한 것은 사용자 이름으로 사용자를 검색하는 구현입니다.
다음 예제에서는 메모리 내에 사용자 목록을 가지고 있는 UserDetailsService를 작성합니다. 2장에서는 동일한 작업을 수행하는 제공된 구현인 InMemoryUserDetailsManager를 사용했습니다. 이 구현이 어떻게 작동하는지 이미 잘 알고 있기 때문에, 비슷한 기능을 선택했지만 이번에는 우리 스스로 구현할 것입니다. 우리의 UserDetailsService 클래스의 인스턴스를 생성할 때 사용자 목록을 제공합니다. 이 예제는 ssia-ch3-ex1 프로젝트에서 찾을 수 있습니다. model이라는 패키지에서는 다음 목록에 제시된 것처럼 UserDetails를 정의합니다.
Listing 3.12 The implementation of the UserDetails interface
public class User implements UserDetails {
private final String username; #A
private final String password;
private final String authority; #B
public User(String username, String password, String authority) {
this.username = username;
this.password = password;
this.authority = authority;
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return List.of(() -> authority); #C
}
@Override
public String getPassword() {
return password;
}
@Override
public String getUsername() {
return username;
}
@Override
public boolean isAccountNonExpired() { #D
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
}
#A The User class is immutable. You give the values for the three attributes when you build the instance, and these values cannot be changed afterward.
#B To make the example simple, a user has only one authority.
#C Returns a list containing only the GrantedAuthority object with the name provided when you built the instance
#D The account does not expire or get locked.
services 패키지에 InMemoryUserDetailsService라는 클래스를 생성합니다. 다음 리스팅은 이 클래스를 구현하는 방법을 보여줍니다.
Listing 3.13 The implementation of the UserDetailsService interface
public class InMemoryUserDetailsService implements UserDetailsService {
private final List<UserDetails> users; #A
public InMemoryUserDetailsService(List<UserDetails> users) {
this.users = users;
}
@Override
public UserDetails loadUserByUsername(String username)
throws UsernameNotFoundException {
return users.stream()
.filter( #B
u -> u.getUsername().equals(username)
)
.findFirst() #C
.orElseThrow( #D
() -> new UsernameNotFoundException("User not found")
);
}
}
#A UserDetailsService manages the list of users in-memory.
#B From the list of users, filters the one that has the requested username
#C If there is such a user, returns it
#D If a user with this username does not exist, throws an exception
loadUserByUsername(String username) 메서드는 주어진 사용자 이름으로 사용자 리스트를 검색하고 원하는 UserDetails 인스턴스를 반환합니다. 해당 사용자 이름을 가진 인스턴스가 없는 경우 UsernameNotFoundException을 throw합니다. 이제 이 구현체를 UserDetailsService로 사용할 수 있습니다. 다음 목록에서는 이를 구성 클래스에 빈으로 추가하고 그 안에 사용자를 등록하는 방법을 보여줍니다.
Listing 3.14 UserDetailsService registered as a bean in the configuration class
@Configuration
public class ProjectConfig {
@Bean
public UserDetailsService userDetailsService() {
UserDetails u = new User("john", "12345", "read");
List<UserDetails> users = List.of(u);
return new InMemoryUserDetailsService(users);
}
@Bean
public PasswordEncoder passwordEncoder() {
return NoOpPasswordEncoder.getInstance();
}
}
마지막으로 간단한 엔드포인트를 생성하고 구현을 테스트합니다. 다음 목록은 엔드포인트를 정의합니다.
Listing 3.15 The definition of the endpoint used for testing the implementation
@RestController
public class HelloController {
@GetMapping("/hello")
public String hello() {
return "Hello!";
}
}
cURL을 사용하여 엔드포인트를 호출하면, 비밀번호가 12345인 사용자 John에 대해 HTTP 200 OK를 반환하는 것을 관찰할 수 있습니다. 다른 것을 사용하면, 애플리케이션은 401 Unauthorized를 반환합니다.
curl -u john:12345 http://localhost:8080/hello
The response body is
Hello!
3.3.3 Implementing the UserDetailsManager contract
이 섹션에서는 UserDetailsManager 인터페이스를 사용하고 구현하는 방법에 대해 논의합니다. 이 인터페이스는 UserDetailsService 계약을 확장하고 더 많은 메서드를 추가합니다. Spring Security는 인증을 수행하기 위해 UserDetailsService 계약이 필요합니다. 그러나 일반적으로 응용 프로그램에서는 사용자를 관리해야하는 경우도 있습니다. 대부분의 경우 응용 프로그램은 새 사용자를 추가하거나 기존 사용자를 삭제할 수 있어야 합니다. 이 경우, 우리는 Spring Security에서 정의한 더 구체적인 인터페이스인 UserDetailsManager를 구현합니다. 이 인터페이스는 UserDetailsService를 확장하고 구현해야 하는 더 많은 작업을 추가합니다.
public interface UserDetailsManager extends UserDetailsService {
void createUser(UserDetails user);
void updateUser(UserDetails user);
void deleteUser(String username);
void changePassword(String oldPassword, String newPassword);
boolean userExists(String username);
}
우리가 2장에서 사용한 InMemoryUserDetailsManager 객체는 실제로 UserDetailsManager입니다. 그 때는 단순히 그것의 UserDetailsService 특성만을 고려했습니다. 이 섹션의 예제와 함께 ssia-ch3-ex2 프로젝트가 제공됩니다.
USING A JDBCUSERDETAILSMANAGER FOR USER MANAGEMENT
InMemoryUserDetailsManager 외에도 JdbcUserDetailsManager라는 또 다른 UserDetailManager 구현을 종종 사용합니다. JdbcUserDetailsManager 클래스는 SQL 데이터베이스에서 사용자를 관리합니다. 이는 JDBC를 통해 데이터베이스에 직접 연결됩니다. 이렇게 함으로써 JdbcUserDetailsManager는 데이터베이스 연결과 관련된 다른 프레임워크나 사양과 독립적입니다. JdbcUserDetailsManager가 어떻게 작동하는지 이해하려면 예제를 통해 실제로 사용해 보는 것이 가장 좋습니다. 다음 예제에서는 MySQL 데이터베이스에서 JdbcUserDetailsManager를 사용하여 사용자를 관리하는 응용 프로그램을 구현합니다. Figure 3.4는 JdbcUserDetailsManager 구현이 인증 플로우에서 차지하는 위치에 대한 개요를 제공합니다.
Figure 3.4 스프링 시큐리티의 인증 플로우. 여기서는 JdbcUserDetailsManager를 UserDetailsService 구성 요소로 사용합니다. JdbcUserDetailsManager는 데이터베이스를 사용하여 사용자를 관리합니다.
JdbcUserDetailsManager를 사용하는 방법에 대한 데모 응용 프로그램을 작성하기 위해 데이터베이스와 두 개의 테이블을 생성하는 것으로 시작하겠습니다. 우리의 경우, 데이터베이스 이름을 spring으로 지정하고 하나는 users 테이블, 다른 하나는 authorities로 지정합니다. 이러한 이름은 JdbcUserDetailsManager가 알고 있는 기본 테이블 이름입니다. 이 섹션의 끝에서 배울 것처럼, JdbcUserDetailsManager 구현은 유연하며 필요한 경우 기본 이름을 재정의할 수 있습니다. users 테이블의 목적은 사용자 레코드를 유지하는 것입니다. JdbcUserDetailsManager 구현에서는 users 테이블에 세 개의 열이 필요합니다: 사용자 이름, 암호 및 사용자를 비활성화하는 데 사용할 수 있는 enabled입니다.
데이터베이스 및 구조를 직접 생성할 수 있습니다. 데이터베이스 관리 시스템(DBMS)의 명령줄 도구 또는 클라이언트 응용 프로그램을 사용하여 생성할 수 있습니다. 예를 들어 MySQL의 경우, MySQL Workbench를 사용할 수 있습니다. 그러나 가장 간단한 방법은 Spring Boot 자체가 스크립트를 실행하도록 하는 것입니다. 이를 위해 프로젝트의 리소스 폴더에 두 개의 파일을 추가하면 됩니다: schema.sql 및 data.sql. schema.sql 파일에는 데이터베이스 구조와 관련된 쿼리를 추가합니다(테이블 생성, 변경 또는 삭제). data.sql 파일에는 테이블 내 데이터와 작동하는 쿼리를 추가합니다(INSERT, UPDATE, DELETE). Spring Boot는 애플리케이션을 시작할 때 이러한 파일을 자동으로 실행합니다. 데이터베이스가 필요한 예제를 작성하는 간단한 해결책은 H2 인메모리 데이터베이스를 사용하는 것입니다. 이렇게 하면 별도의 DBMS 솔루션을 설치할 필요가 없습니다.
참고로 본 교재의 예제 애플리케이션을 개발할 때 H2를 선택해도 좋습니다(저는 ssia-ch3-ex2 프로젝트에서 그렇게 했습니다). 그러나 이 책의 대부분의 예제에서는 외부 DBMS를 사용하여 예제를 구현하고 외부 컴포넌트임을 명확히 하고 혼란을 피하기 위해 이 방법을 선택했습니다.
다음 리스팅의 코드를 사용하여 MySQL 서버에 users 테이블을 만듭니다. 이 스크립트를 Spring Boot 프로젝트의 schema.sql 파일에 추가할 수 있습니다.
Listing 3.16 The SQL query for creating the users table
CREATE TABLE IF NOT EXISTS `spring`.`users` (
`id` INT NOT NULL AUTO_INCREMENT,
`username` VARCHAR(45) NOT NULL,
`password` VARCHAR(45) NOT NULL,
`enabled` INT NOT NULL,
PRIMARY KEY (`id`));
authorities 테이블은 사용자당 권한을 저장합니다. 각 레코드는 해당 사용자의 사용자 이름과 부여된 권한을 저장합니다.
Listing 3.17 The SQL query for creating the authorities table
CREATE TABLE IF NOT EXISTS `spring`.`authorities` (
`id` INT NOT NULL AUTO_INCREMENT,
`username` VARCHAR(45) NOT NULL,
`authority` VARCHAR(45) NOT NULL,
PRIMARY KEY (`id`));
참고로 간단하게 유지하고 이 책에서 논의하는 Spring Security 구성에 집중할 수 있도록, 이 책에서 제공하는 예제에서는 인덱스나 외래 키의 정의를 생략합니다.
테스트를 위한 사용자가 있는지 확인하려면 각 테이블에 레코드를 삽입하세요. 이 쿼리들은 Spring Boot 프로젝트의 resources 폴더에 있는 data.sql 파일에 추가할 수 있습니다.
INSERT INTO `spring`.`authorities`
(username, authority)
VALUES
('john', 'write');
INSERT INTO `spring`.`users`
(username, password, enabled)
VALUES
('john', '12345', '1');
프로젝트에는 다음 리스팅에 나열된 종속성을 추가해야 합니다. pom.xml 파일을 확인하여 이러한 종속성이 추가되었는지 확인하십시오.
Listing 3.18 Dependencies needed to develop the example project
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
</dependency>
기억하세요. 사용하는 데이터베이스 기술에 따라 JDBC 드라이버를 추가해야 합니다. 예를 들어 MySQL을 사용한다면 다음 스니펫에 표시된대로 MySQL 드라이버 종속성을 추가해야 합니다.
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<scope>runtime</scope>
</dependency>
프로젝트의 application.properties 파일이나 별도의 빈으로 데이터 소스를 구성할 수 있습니다. application.properties 파일을 사용하려는 경우 해당 파일에 다음 줄을 추가해야 합니다.
spring.datasource.url=jdbc:h2:mem:ssia
spring.datasource.username=sa
spring.datasource.password=
spring.sql.init.mode=always
프로젝트의 구성 클래스에서 UserDetailsService 및 PasswordEncoder를 정의합니다. JdbcUserDetailsManager는 데이터베이스에 연결하기 위해 DataSource가 필요합니다. 데이터 소스는 메서드의 매개변수로 자동으로 주입할 수 있습니다(다음 리스팅에서 설명한대로), 또는 클래스의 속성으로 주입할 수도 있습니다.
Listing 3.19 Registering the JdbcUserDetailsManager in the configuration class
@Configuration
public class ProjectConfig {
@Bean
public UserDetailsService userDetailsService(DataSource dataSource) {
return new JdbcUserDetailsManager(dataSource);
}
@Bean
public PasswordEncoder passwordEncoder() {
return NoOpPasswordEncoder.getInstance();
}
}
이제 애플리케이션의 모든 엔드포인트에 액세스하려면 데이터베이스에 저장된 사용자 중 하나를 사용하여 HTTP Basic 인증을 사용해야 합니다. 이를 증명하기 위해 다음 리스팅에 나와 있는대로 새로운 엔드포인트를 생성한 다음 cURL을 사용하여 호출합니다.
curl -u john:12345 http://localhost:8080/hello
The response to the call is
Hello!
JdbcUserDetailsManager를 사용하면, 사용할 쿼리를 구성할 수도 있습니다. 이전 예제에서는 JdbcUserDetailsManager 구현이 예상하는 것과 정확한 테이블 및 열 이름을 사용하도록 했습니다. 그러나 당신의 애플리케이션에서는 이러한 이름이 최상의 선택이 아닐 수 있습니다. 다음 목록에서는 JdbcUserDetailsManager의 쿼리를 재정의하는 방법을 보여줍니다.
Listing 3.21 Changing JdbcUserDetailsManager’s queries to find the user
@Bean
public UserDetailsService userDetailsService(DataSource dataSource) {
String usersByUsernameQuery =
"select username, password, enabled
from users where username = ?";
String authsByUserQuery =
"select username, authority
from spring.authorities where username = ?";
var userDetailsManager = new JdbcUserDetailsManager(dataSource);
userDetailsManager.setUsersByUsernameQuery(usersByUsernameQuery);
userDetailsManager.setAuthoritiesByUsernameQuery(authsByUserQuery);
return userDetailsManager;
}
동일한 방식으로 JdbcUserDetailsManager 구현에서 사용되는 모든 쿼리를 변경할 수 있습니다.
USING AN LDAPUSERDETAILSMANAGER FOR USER MANAGEMENT
스프링 보안은 LDAP를 위한 UserDetailsManager의 구현도 제공합니다. JdbcUserDetailsManager보다는 인기가 적지만, 사용자 관리를 위해 LDAP 시스템과 통합해야 할 경우에 이를 활용할 수 있습니다. ssia-ch3-ex3 프로젝트에서는 LdapUserDetailsManager를 사용한 간단한 데모를 찾을 수 있습니다. 이 데모에서 실제 LDAP 서버를 사용할 수 없기 때문에 내장형 LDAP 서버를 Spring Boot 애플리케이션에 설정했습니다. 내장형 LDAP 서버를 설정하기 위해 간단한 LDAP 데이터 교환 형식 (LDIF) 파일을 정의했습니다. 다음은 LDIF 파일의 내용을 보여줍니다.
'Spring Security' 카테고리의 다른 글
ch05 A web app's security begins with filters (0) | 2024.02.25 |
---|---|
ch04 Managing passwords (0) | 2024.02.25 |
Ch02 Hello Spring Security (0) | 2024.02.25 |
CORS : Non-Simple Request / Simple Request (0) | 2023.06.20 |
Options (0) | 2023.06.10 |