이 장에서는 Spring Security에서 중요한 역할을 하는 UserDetailsService와 관련된 여러 가지 개념과 구성 요소를 깊이 있게 다룹니다. 이 내용을 이해하는 것은 Spring Security를 잘 활용하기 위한 핵심적인 부분이므로, 각 개념을 체계적으로 살펴보겠습니다.
핵심 개념 및 인터페이스
- UserDetails 인터페이스:
- UserDetails는 Spring Security에서 사용자를 설명하는 계약입니다. 사용자의 정보(예: 사용자 이름, 비밀번호, 권한 등)를 정의하고, 이를 인증 및 권한 부여 과정에서 사용합니다.
- 사용자 객체는 UserDetails를 구현하여 애플리케이션에 적합한 사용자 정보를 제공합니다.
- GrantedAuthority 인터페이스:
- GrantedAuthority는 사용자가 수행할 수 있는 동작(즉, 권한)을 정의하는 계약입니다. 이 인터페이스를 구현하는 객체는 사용자가 특정 자원에 접근할 수 있는지 확인하는 데 사용됩니다.
- 예를 들어, "ROLE_USER", "ROLE_ADMIN" 같은 권한을 GrantedAuthority로 구현할 수 있습니다.
- UserDetailsService 인터페이스:
- UserDetailsService는 Spring Security에서 사용자 정보를 로드하는 데 사용되는 서비스 인터페이스입니다. 이 서비스는 인증을 위한 사용자 정보를 반환하는 주요 메커니즘으로, loadUserByUsername() 메서드를 통해 사용자를 검색하고 인증에 필요한 정보를 제공합니다.
- Spring Security는 기본적으로 InMemoryUserDetailsManager, JdbcUserDetailsManager, LdapUserDetailsManager와 같은 구현체를 제공하지만, 필요에 따라 커스터마이징할 수도 있습니다.
- UserDetailsManager 인터페이스:
- UserDetailsManager는 UserDetailsService를 확장한 인터페이스로, 사용자 관리에 관련된 추가적인 기능을 제공합니다. 이 인터페이스는 사용자 생성, 사용자 삭제, 비밀번호 수정 등과 같은 동작을 지원합니다.
- UserDetailsService가 사용자 정보 조회에 중점을 둔다면, UserDetailsManager는 사용자 정보의 생성, 수정, 삭제 등 관리 기능까지 포함합니다.
계약과 구현
이 장에서는 계약과 구현의 개념을 비유를 통해 설명합니다. 예를 들어, 셰프가 다양한 레시피를 기억하지 않고 기본적인 재료들이 어떻게 조화를 이루는지 아는 것처럼, 프로그래머도 프레임워크에서 제공하는 계약을 이해하고 그 계약에 맞는 구현체를 선택하여 사용할 수 있어야 합니다.
Spring Security에서는 UserDetailsService와 같은 인터페이스를 통해 계약을 정의하고, 다양한 구현체(InMemoryUserDetailsManager, JdbcUserDetailsManager, 등)를 통해 이를 실제 애플리케이션에 맞게 구현할 수 있습니다.
사용자 정의 구현 만들기
이 장에서는 기본 제공되는 구현체가 애플리케이션의 요구 사항에 맞지 않을 때, 어떻게 커스텀 구현을 작성할 수 있는지에 대해서도 다룹니다. 예를 들어, UserDetailsService를 구현하여 DB에서 사용자 정보를 로드하거나, UserDetailsManager를 구현하여 특정한 사용자 관리 로직을 추가할 수 있습니다.
모범 사례
이 인터페이스들을 사용하는 데 있어 몇 가지 모범 사례를 따르는 것이 중요합니다. 그 예로는:
- SecurityContext를 적절히 활용하여 인증 정보를 안전하게 저장하고 사용하기.
- 암호화된 비밀번호를 사용하고, PasswordEncoder를 올바르게 설정하여 보안성을 높이기.
- InMemory 저장소는 테스트나 간단한 애플리케이션에 적합하지만, 프로덕션 환경에서는 JdbcUserDetailsManager 또는 LdapUserDetailsManager와 같은 더 강력한 사용자 관리 솔루션을 사용하는 것이 좋습니다.
전체 흐름
이 장에서는 UserDetailsService와 UserDetailsManager 인터페이스가 인증 흐름에서 어떻게 활용되는지에 대한 흐름을 설명합니다. UserDetailsService는 주로 사용자 정보 로드에 사용되며, UserDetailsManager는 사용자 정보를 관리하고 수정하는 데 사용됩니다. 이 흐름을 통해 인증과 권한 부여의 과정을 구체적으로 이해하고, 애플리케이션에서 이를 어떻게 구현할 수 있을지 고민해 볼 수 있습니다.
결론
이 장에서 다룬 핵심 개념을 통해 Spring Security에서 사용자 정보 관리와 인증 흐름을 보다 세밀하게 이해할 수 있습니다. 이를 통해 애플리케이션에서 Spring Security를 적용할 때 커스터마이징된 인증 시스템을 구축할 수 있으며, 보안을 강화하고 유연한 인증 관리가 가능해집니다.
3.1 Implementing authentication in Spring Security
이 장에서는 Spring Security에서의 인증 프로세스를 깊이 있게 다룹니다. 인증 흐름과 관련된 중요한 컴포넌트들에 대해 설명하고, 이들을 어떻게 구현하고 연결할 수 있는지 살펴봅니다. 아래는 주요 개념을 정리한 내용입니다.
Spring Security 인증 흐름
Spring Security에서 인증 흐름은 프레임워크의 핵심적인 부분으로, 사용자의 자격 증명을 처리하는 방식의 근본적인 구조를 이해하는 것이 중요합니다.
- AuthenticationFilter가 요청을 가로채어 인증 책임을 AuthenticationManager에 위임합니다.
- AuthenticationManager는 AuthenticationProvider를 사용하여 인증 로직을 처리합니다.
- AuthenticationProvider는 UserDetailsService와 PasswordEncoder를 사용하여 사용자 이름과 비밀번호를 확인합니다.
이 흐름에서 중요한 역할을 하는 두 가지 컴포넌트는 UserDetailsService와 PasswordEncoder입니다. 이들은 사용자 세부 정보와 자격 증명을 처리하는 중요한 요소로, 후속 장에서 다룰 PasswordEncoder에 대해서도 더욱 깊이 논의할 예정입니다.
사용자 관리
Spring Security에서는 UserDetailsService와 UserDetailsManager 인터페이스를 통해 사용자 정보를 처리합니다:
- UserDetailsService는 사용자 이름을 기반으로 사용자를 찾는 역할만 합니다. 이 기능은 인증을 완료하는 데 필수적인 요소로, 인증 프로세스에서 유일한 요구사항입니다.
- UserDetailsManager는 사용자 추가, 수정, 삭제와 같은 작업을 수행할 수 있는 기능을 추가합니다. 이는 대부분의 애플리케이션에서 필요한 기능입니다.
이 두 계약(인터페이스)을 분리한 것은 인터페이스 분리 원칙을 잘 따른 예입니다. 이렇게 분리함으로써 애플리케이션이 필요로 하지 않는 기능을 강제하지 않고, 필요한 기능만을 제공할 수 있습니다. 예를 들어, 애플리케이션이 인증만 필요하다면 UserDetailsService만 구현해도 충분히 기능을 구현할 수 있습니다.
UserDetails 계약
Spring Security는 UserDetails라는 계약을 통해 사용자를 정의합니다. 사용자는 GrantedAuthority 인터페이스를 통해 할 수 있는 행동(권한)을 가집니다. 이 권한은 사용자가 수행할 수 있는 작업들을 정의하며, 이후 권한 부여를 다루는 7장에서 12장에 걸쳐 상세히 다룰 예정입니다.
- UserDetailsService는 사용자 이름을 통해 사용자를 찾고, 사용자의 세부 정보를 반환합니다.
- UserDetails는 사용자를 설명하며, 사용자는 GrantedAuthority로 표현되는 하나 이상의 권한을 가집니다.
- UserDetailsManager는 사용자 관리 작업(생성, 삭제, 비밀번호 변경 등)을 처리하는 데 UserDetailsService를 확장합니다.
요약
Spring Security에서 이러한 객체들 간의 관계와 이를 구현하는 방법을 이해하는 것은 애플리케이션에서 사용할 다양한 옵션을 선택하는 데 중요한 정보를 제공합니다. 이러한 옵션을 잘 선택하면 애플리케이션에서 필요한 인증 기능을 보다 효율적으로 구현할 수 있습니다.
3.2 사용자를 설명하기
이 섹션에서는 Spring Security가 애플리케이션에서 사용자를 어떻게 설명하는지 배우게 됩니다. 사용자를 표현하는 방법을 이해하고 이를 Spring Security가 인식하도록 만드는 것은 인증 흐름을 구축하는 데 필수적인 단계입니다. 애플리케이션이 사용자에 대한 결정을 내릴 때, 예를 들어 특정 기능의 접근을 허용하거나 거부할 때, 사용자가 어떻게 정의되느냐가 중요합니다.
Spring Security에서 사용자를 정의하는 첫 번째 단계는 UserDetails 인터페이스를 구현하는 것입니다. 이 인터페이스는 Spring Security가 이해할 수 있는 사용자 모델을 제공합니다. 이를 통해 애플리케이션은 사용자의 정보와 권한을 정의할 수 있습니다.
UserDetails 계약
UserDetails 인터페이스는 Spring Security에서 사용자를 나타내는 계약입니다. 이 인터페이스를 구현하는 클래스는 Spring Security가 인식하고 처리할 수 있는 사용자 모델을 정의합니다. 이 계약을 통해 사용자의 이름, 비밀번호, 권한 등을 포함한 기본적인 세부 사항을 제공할 수 있습니다. UserDetails를 구현한 클래스는 애플리케이션 내에서 사용자 정보를 어떻게 표현할지를 결정하게 되며, Spring Security는 이 정보를 바탕으로 인증 및 권한 부여를 처리합니다.
사용자 정의 방법
사용자를 정의하려면, 애플리케이션에서 UserDetails 계약을 준수하는 클래스를 만들어야 합니다. 이 클래스는 기본적으로 사용자의 정보를 포함하고 있으며, 권한(Authorities)와 계정의 활성화 여부 등의 추가 정보를 제공할 수 있습니다. UserDetails의 주요 메서드는 사용자의 비밀번호, 권한 목록, 계정이 활성화되었는지 등을 반환하는 역할을 합니다.
Spring Security는 이 사용자 정의 클래스에 의해 제공되는 정보를 바탕으로 사용자의 인증을 처리하며, 다양한 보안 작업을 수행할 수 있습니다. UserDetails를 통해 Spring Security는 애플리케이션에서 사용자가 수행할 수 있는 행동을 정의하고 이를 기반으로 권한 부여를 실행합니다.
따라서 사용자를 정의하는 것은 인증과 권한 부여 흐름의 핵심이 되는 작업입니다.
3.2.1 UserDetails 계약으로 사용자 설명하기
이 섹션에서는 UserDetails 인터페이스를 어떻게 구현하는지 배우게 됩니다. 이 인터페이스는 Spring Security에서 사용자를 표현하는 핵심 계약입니다. UserDetails 인터페이스에 선언된 메소드들을 이해하고 구현하는 방법을 배우는 것은 인증 흐름을 제대로 구축하는 데 중요한 단계입니다. 다음은 UserDetails 인터페이스의 예시입니다.
Listing 3.1: UserDetails 인터페이스
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: **getUsername()**과 getPassword()
이 메소드들은 사용자의 자격 증명인 사용자 이름과 비밀번호를 반환합니다. 인증 과정에서 애플리케이션은 이 값들을 사용하여 사용자가 제공한 정보가 올바른지 확인합니다. 이 두 메소드는 기본적인 사용자 인증 정보만을 제공합니다.
B: getAuthorities()
이 메소드는 애플리케이션에서 사용자가 수행할 수 있는 작업들을 나타내는 GrantedAuthority 객체의 컬렉션을 반환합니다. 즉, 사용자가 가진 권한을 반환하는 메소드입니다. Spring Security에서는 권한을 GrantedAuthority로 표현하며, 일반적으로 "Role" 또는 "Permission"과 같은 특권을 포함합니다.
권한은 사용자가 애플리케이션에서 어떤 행동을 할 수 있는지, 예를 들어 데이터를 읽거나 수정하는 등의 권리를 결정합니다. 이 권한은 애플리케이션에서 사용자가 무엇을 할 수 있는지 정의하는 중요한 부분입니다.
C: 계정과 자격 증명의 상태 관리
이 네 가지 메소드 (isAccountNonExpired(), isAccountNonLocked(), isCredentialsNonExpired(), isEnabled())는 사용자의 계정과 자격 증명 상태를 관리하는 데 사용됩니다. 이 메소드들은 계정이 만료되었는지, 잠겼는지, 자격 증명이 만료되었는지, 계정이 활성화되었는지 등을 확인하는 데 도움을 줍니다.
- isAccountNonExpired(): 계정이 만료되지 않았는지 확인합니다.
- isAccountNonLocked(): 계정이 잠기지 않았는지 확인합니다.
- isCredentialsNonExpired(): 자격 증명이 만료되지 않았는지 확인합니다.
- isEnabled(): 계정이 활성화되었는지 확인합니다.
이 메소드들은 true 또는 false를 반환하며, true는 해당 조건이 정상적임을, false는 해당 조건이 실패했음을 나타냅니다. 예를 들어, 계정이 만료되었으면 isAccountNonExpired()는 false를 반환해야 하며, 계정이 활성화되어 있으면 isEnabled()는 true를 반환해야 합니다.
메소드 이름의 의미
이 메소드들의 이름이 직관적이지 않게 느껴질 수 있습니다. 예를 들어, **isAccountNonExpired()**는 일종의 이중 부정처럼 보이며 처음에는 혼란스러울 수 있습니다. 그러나 이러한 명명 방식은 인증 실패 시 false를 반환하고, 정상적인 상태일 때 true를 반환하도록 설계되었습니다. 이는 false라는 단어가 부정적인 상황을, true는 긍정적인 상황을 연상시키기 때문에, 코드가 더 직관적으로 이해되도록 돕습니다.
구현 시 고려사항
모든 애플리케이션에서 사용자가 계정 만료나 잠금, 자격 증명 만료 등의 제한을 구현할 필요는 없습니다. 만약 이러한 제한을 구현하지 않으려면, 이 네 가지 메소드에서 true를 반환하도록 하면 됩니다. 하지만 애플리케이션의 요구 사항에 따라, 이 메소드들을 적절히 오버라이드하여 사용자의 계정과 자격 증명 상태를 세밀하게 제어할 수 있습니다.
이렇게 UserDetails 인터페이스를 구현하면, Spring Security는 사용자 정보를 바탕으로 인증 및 권한 부여를 처리할 수 있습니다.
3.2.2 GrantedAuthority 계약 상세화
3.2.1절에서는 UserDetails 인터페이스를 통해 사용자의 세부 정보를 정의했으며, 이 세부 정보에서 사용자의 권한을 GrantedAuthority 인터페이스로 설명한다고 언급했습니다. 이 섹션에서는 GrantedAuthority 인터페이스의 역할과 그 구현 방법을 다룹니다. 권한은 애플리케이션 내에서 사용자가 수행할 수 있는 작업을 나타냅니다. 권한을 정의하는 방법을 이해하는 것은 인증 및 권한 부여 구성을 작성하는 데 필수적입니다.
권한의 정의
권한은 사용자가 애플리케이션 내에서 수행할 수 있는 특정 작업을 나타냅니다. 예를 들어, 사용자가 애플리케이션에서 데이터를 읽을 수 있다면 "READ"라는 권한을 가질 수 있습니다. 사용자가 데이터를 수정할 수 있다면 "WRITE" 권한을 가질 수 있습니다. 권한을 부여함으로써 애플리케이션은 다양한 사용자에게 각기 다른 기능을 제공할 수 있습니다.
권한이 없다면 모든 사용자가 동등한 위치에 있게 되며, 이는 실용적인 애플리케이션에서는 거의 발생하지 않습니다. 대부분의 애플리케이션에서는 각기 다른 권한을 가진 여러 종류의 사용자가 필요합니다. 예를 들어, 관리자, 일반 사용자, 게스트 등 다양한 사용자 역할을 정의하고 각 역할에 권한을 부여합니다.
GrantedAuthority 인터페이스
Spring Security에서는 사용자가 가진 권한을 GrantedAuthority 인터페이스로 정의합니다. GrantedAuthority는 사용자가 수행할 수 있는 작업을 나타내는 특권을 표현하는 인터페이스입니다. 모든 사용자는 적어도 하나의 권한을 가져야 하며, 이를 통해 애플리케이션은 사용자의 접근 권한을 관리할 수 있습니다.
GrantedAuthority 인터페이스의 정의는 다음과 같습니다:
public interface GrantedAuthority extends Serializable {
String getAuthority();
}
getAuthority() 메소드는 사용자가 가진 권한의 이름을 문자열로 반환합니다. 이 메소드를 통해 사용자가 가진 권한을 문자열 형식으로 정의하고, 이를 기반으로 인증 및 권한 부여 규칙을 작성할 수 있습니다.
GrantedAuthority 구현 예시
GrantedAuthority 인터페이스는 단 하나의 추상 메소드인 **getAuthority()**를 가지고 있습니다. 이 메소드를 구현하여 사용자가 가진 권한을 문자열로 반환합니다. 권한을 구현하는 방법에는 여러 가지가 있으며, 자주 사용되는 방법은 람다 표현식이나 SimpleGrantedAuthority 클래스를 사용하는 것입니다.
- 람다 표현식을 사용한 예시:
- GrantedAuthority g1 = () -> "READ";
- SimpleGrantedAuthority 클래스를 사용한 예시:
- GrantedAuthority g2 = new SimpleGrantedAuthority("READ");
이 예시들에서 "READ"는 사용자가 가진 권한의 이름입니다. SimpleGrantedAuthority는 GrantedAuthority 인터페이스를 구현하는 간단한 클래스이며, 불변의 권한 인스턴스를 생성할 수 있습니다. getAuthority() 메소드가 자동으로 "READ"를 반환합니다.
권한의 활용
애플리케이션에서 권한을 설정하고 활용하는 방식은 다양합니다. 예를 들어, 특정 사용자가 "READ" 권한을 가진 경우, 그 사용자는 애플리케이션 내에서 데이터를 읽을 수 있지만 수정하거나 삭제하는 권한은 없을 수 있습니다. 이처럼 권한을 정의하고 부여함으로써 애플리케이션은 사용자의 접근 권한을 세밀하게 제어할 수 있습니다.
권한을 관리하는 방식은 인증과 밀접하게 연관되어 있으며, 이후 장에서 다룰 인증 흐름과 결합되어 애플리케이션의 보안을 강화하는 중요한 역할을 합니다.
3.2.3 UserDetails의 최소 구현 작성
이 섹션에서는 UserDetails 인터페이스를 최소화한 구현을 작성해보겠습니다. 여기서 우리는 각 메소드가 고정된 값을 반환하는 간단한 구현으로 시작하며, 이후 실제 시나리오에서 더 유용하게 사용할 수 있는 방법으로 수정해갈 것입니다.
간단한 구현: DummyUser 클래스
먼저, DummyUser 클래스를 사용하여 사용자에 대한 가장 기본적인 설명을 작성하겠습니다. 이 클래스는 하나의 사용자를 대표하며, 해당 사용자는 사용자 이름 "bill", 비밀번호 "12345", 권한 "READ"를 가집니다.
Listing 3.2 DummyUser 클래스:
public class DummyUser implements UserDetails {
@Override
public String getUsername() {
return "bill";
}
@Override
public String getPassword() {
return "12345";
}
// 생략된 코드
}
이 클래스는 UserDetails 인터페이스를 구현하고 있으며, 두 개의 메소드인 **getUsername()**과 **getPassword()**를 구현하고 있습니다. 이 구현은 각각 사용자 이름과 비밀번호를 고정된 값으로 반환합니다.
권한 정의 추가
다음으로, getAuthorities() 메소드를 구현하여 사용자의 권한을 설정합니다. 이 메소드는 GrantedAuthority 인터페이스를 구현하는 객체가 포함된 Collection을 반환해야 합니다. 여기서는 "READ"라는 권한을 부여합니다.
Listing 3.3 getAuthorities() 메소드 구현:
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return List.of(() -> "READ");
}
위 구현에서 getAuthorities() 메소드는 "READ" 권한을 가진 GrantedAuthority 객체를 반환합니다.
마지막 네 가지 메소드 구현
마지막으로, UserDetails 인터페이스의 나머지 네 가지 메소드인 isAccountNonExpired(), isAccountNonLocked(), isCredentialsNonExpired(), **isEnabled()**에 대한 구현을 추가합니다. 이 예제에서는 사용자가 항상 활성화되어 있고 잠금되지 않으며 자격 증명이 만료되지 않았다고 가정하여 모든 메소드가 true를 반환하도록 설정합니다.
Listing 3.4 마지막 네 가지 메소드 구현:
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
이 구현은 DummyUser 클래스가 항상 활성화된 계정으로 처리되며, 사용자의 자격 증명이 만료되지 않았고 계정이 잠기지 않았음을 의미합니다.
실제 사용을 위한 개선된 구현: SimpleUser 클래스
위의 구현은 모든 인스턴스가 동일한 사용자를 나타내므로 실용적인 애플리케이션에서는 사용자의 속성(예: 사용자 이름과 비밀번호)을 동적으로 설정할 수 있어야 합니다. 이를 위해 SimpleUser 클래스를 작성하고, 사용자의 속성을 인스턴스화할 때 동적으로 지정할 수 있도록 합니다.
Listing 3.5 더 실용적인 UserDetails 구현:
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;
}
// 생략된 코드
}
SimpleUser 클래스는 username과 password를 인스턴스 변수로 설정하여 다양한 사용자 정보를 동적으로 처리할 수 있습니다. 이 구현은 실용적인 애플리케이션에서 더 자주 사용될 것입니다. DummyUser 클래스는 단일 사용자를 테스트하는 데 유용했지만, 실제 애플리케이션에서는 사용자의 정보가 동적으로 처리되는 방식이 더 적합합니다.
결론
이 섹션에서 우리는 UserDetails 인터페이스를 최소화한 간단한 구현과, 이를 실용적인 애플리케이션에 적용할 수 있는 방식으로 개선한 예제를 살펴보았습니다. 실용적인 구현에서는 사용자 이름과 비밀번호를 동적으로 설정할 수 있으며, 이를 바탕으로 다양한 사용자 인스턴스를 생성할 수 있습니다.
3.2.4 UserDetails 타입의 인스턴스를 생성하는 빌더 사용
어떤 애플리케이션은 UserDetails 인터페이스를 구현하는 사용자 클래스를 직접 만들지 않고도, Spring Security에서 제공하는 빌더 클래스를 활용하여 사용자 인스턴스를 간단히 생성할 수 있습니다. 이 섹션에서는 User 클래스의 빌더를 사용하여 UserDetails 인스턴스를 빠르게 만드는 방법을 다룹니다.
Spring Security에서 제공하는 User 클래스는 UserDetails 타입의 객체를 생성할 수 있는 간단한 방법을 제공합니다. 이 클래스는 사용자의 이름과 비밀번호를 제공하는 것 외에도 추가적인 설정을 지원합니다. 이 방법을 사용하면 UserDetails 인터페이스를 직접 구현할 필요 없이 사용자를 정의할 수 있습니다.
User 빌더 클래스를 사용한 사용자 생성
User 클래스는 withUsername() 메소드로 사용자의 이름을 받고, 다양한 설정 메소드들을 체이닝 방식으로 호출하여 UserDetails 객체를 생성합니다. 리스팅 3.6에서는 User 빌더를 사용한 예시를 보여줍니다.
Listing 3.6 User 빌더 클래스를 사용하여 사용자 생성:
UserDetails u = User.withUsername("bill")
.password("12345")
.authorities("read", "write")
.accountExpired(false)
.disabled(true)
.build();
이 코드에서 User.withUsername("bill")은 User 클래스의 빌더를 생성하며, 사용자의 이름을 설정합니다. 이후, password()와 authorities() 메소드로 비밀번호와 권한을 설정하고, accountExpired()와 disabled() 메소드를 사용하여 계정의 상태를 설정합니다. 마지막으로, build() 메소드를 호출하여 최종 UserDetails 인스턴스를 생성합니다.
빌더 인스턴스 생성 및 사용
Listing 3.7에서는 User.UserBuilder 인스턴스를 어떻게 생성하는지 보여줍니다. 두 가지 방법이 있습니다. 첫 번째 방법은 **withUsername()**을 사용하여 새로운 빌더를 생성하는 것이고, 두 번째 방법은 이미 존재하는 UserDetails 인스턴스를 사용하여 빌더를 시작하는 방식입니다.
Listing 3.7 User.UserBuilder 인스턴스 생성:
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: 사용자의 이름("bill")으로 빌더를 생성합니다.
- #B: 비밀번호 인코딩을 위한 함수(encode(p))를 설정합니다. 이 함수는 비밀번호를 주어진 방식으로 인코딩합니다.
- #C: 빌더의 설정을 완료한 후, build() 메소드를 호출하여 UserDetails 인스턴스를 생성합니다.
- #D: 기존의 UserDetails 인스턴스를 사용하여 새로운 빌더를 생성할 수 있습니다.
이러한 빌더 패턴을 사용하면 사용자 정의 클래스를 작성할 필요 없이 UserDetails 객체를 빠르고 쉽게 생성할 수 있습니다.
비밀번호 인코딩
위 예제에서 비밀번호를 인코딩하는 부분이 언급되었는데, User 클래스 빌더에서 비밀번호를 인코딩할 수 있도록 passwordEncoder() 메소드가 제공됩니다. 이 메소드는 Function<String, String> 타입의 함수를 받으며, 이 함수는 주어진 비밀번호를 원하는 방식으로 변환합니다.
비밀번호 인코딩에 대한 더 자세한 내용은 2장과 4장에서 다룰 예정입니다.
결론
Spring Security의 User 빌더 클래스를 사용하면 UserDetails 타입의 객체를 간단하게 생성할 수 있습니다. 이 방식은 커스텀 구현을 필요로 하지 않으며, 다양한 메소드 체이닝을 통해 사용자의 이름, 비밀번호, 권한 등을 설정할 수 있습니다. 이 접근법은 애플리케이션에서 사용자가 필요한 기본 정보를 빠르게 설정하고 관리할 수 있게 해줍니다.
3.2.5 사용자와 관련된 여러 책임 결합하기
이전 섹션에서 UserDetails 인터페이스를 구현하는 방법을 배웠지만, 실제 애플리케이션에서는 종종 더 복잡한 시나리오를 처리해야 합니다. 사용자는 다양한 시스템에서 영속성 엔티티로 저장되거나, 다른 웹 서비스와의 상호작용을 통해 검색되는 경우가 많습니다. 이러한 복잡한 요구 사항을 처리하기 위해서는 여러 책임을 분리하는 것이 중요합니다. 이 섹션에서는 JPA 엔티티와 UserDetails 계약을 분리하여 사용하는 방법을 다룹니다.
1. JPA 엔티티와 UserDetails 계약 혼합
일반적으로 애플리케이션에서는 사용자 정보를 JPA 엔티티로 데이터베이스에 저장하고, 이를 Spring Security에서 사용할 수 있도록 UserDetails 인터페이스를 구현해야 합니다. 그러나 두 책임을 하나의 클래스에 결합하면 코드가 복잡해지고, 책임이 뒤섞여 가독성이 떨어집니다.
Listing 3.8에서는 간단한 JPA 엔티티를 정의하여 사용자 정보를 저장하는 테이블을 매핑합니다:
@Entity
public class User {
@Id
private Long id;
private String username;
private String password;
private String authority;
// 게터와 세터 생략
}
이 클래스는 단순히 사용자 정보를 저장하는 데이터베이스 엔티티로, UserDetails 계약을 구현하려면, 추가적인 메서드가 필요합니다. Listing 3.9는 User 클래스가 두 가지 책임을 모두 수행하는 예시입니다:
@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);
}
// 게터와 세터 생략
}
이 코드에서는 JPA 엔티티로서의 역할과 UserDetails 인터페이스 구현을 동시에 처리하고 있습니다. 그러나 두 가지 책임을 하나의 클래스에서 처리하는 것은 혼란을 일으킬 수 있습니다. 예를 들어, UserDetails 메서드를 구현하면서 JPA와 관련된 필드와 로직을 다루는 것은 코드의 복잡성을 높이고, 유지보수성에 불리할 수 있습니다.
2. 책임 분리: SecurityUser 클래스 사용
이 문제를 해결하기 위한 방법은 UserDetails 인터페이스와 JPA 엔티티를 분리하여 처리하는 것입니다. 이를 위해 SecurityUser라는 별도의 클래스를 정의하여 UserDetails 계약을 구현하고, User 클래스는 오직 JPA 엔티티로만 사용합니다.
Listing 3.10에서 User 클래스는 이제 JPA 엔티티로서의 역할만 수행합니다:
@Entity
public class User {
@Id
private int id;
private String username;
private String password;
private String authority;
// 게터와 세터 생략
}
이제 User 클래스는 JPA 엔티티로서 사용자 정보를 저장하는 역할만 하며, SecurityUser 클래스를 통해 UserDetails 계약을 구현합니다. Listing 3.11은 SecurityUser 클래스를 정의한 예시입니다:
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());
}
// 기타 메서드 생략
}
SecurityUser 클래스는 UserDetails 계약을 구현하는 역할을 하며, User 클래스를 감싸는 형태로 사용됩니다. 이 구조에서 User 클래스는 JPA 엔티티로만 사용되므로, Spring Security 관련 코드와 영속성 관련 코드가 분리되어 더 깔끔하고 이해하기 쉬운 구조가 됩니다.
3. 책임 분리의 장점
두 책임을 분리하면 코드의 복잡성을 줄이고, 각 클래스가 맡은 역할을 명확히 할 수 있습니다. JPA 엔티티와 UserDetails 계약을 분리함으로써, 두 개의 다른 책임을 가진 클래스가 서로의 역할을 침범하지 않게 됩니다. 또한, 애플리케이션의 유지보수성이 향상되며, 각각의 책임에 대한 테스트나 수정이 더 수월해집니다.
결론
이 섹션에서 제시한 방법은 두 가지 책임을 분리하여 코드의 가독성을 높이고, 애플리케이션을 보다 명확하고 유지보수하기 쉽게 만드는 접근법입니다. 여러 가지 방식으로 책임을 분리할 수 있지만, 핵심은 하나의 클래스에 너무 많은 책임을 부여하지 않는 것입니다. 책임을 명확히 분리하여 각 클래스가 맡은 역할을 집중할 수 있도록 하는 것이 중요합니다.
알겠습니다! 3.3 섹션에 대해 주어진 설명을 바탕으로 3.1과 같은 형태로 번호를 달아서 다시 작성해 보겠습니다.
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) 메서드를 호출합니다. 사용자 이름은 고유하다고 간주됩니다. 이 메서드가 반환하는 사용자는 UserDetails 계약의 구현체이어야 합니다. 만약 사용자 이름이 존재하지 않으면, 이 메서드는 UsernameNotFoundException을 던집니다.
참고로, UsernameNotFoundException은 RuntimeException을 확장한 예외입니다. UserDetailsService 인터페이스의 throws 절은 문서화 목적으로만 사용됩니다. UsernameNotFoundException은 AuthenticationException에서 직접 상속받으며, 이는 인증 과정과 관련된 모든 예외의 부모 클래스입니다. 또한, AuthenticationException은 RuntimeException을 상속받습니다.
3.3.2 Implementing the UserDetailsService contract
이 섹션에서는 UserDetailsService의 구현을 시연하기 위해 실제 예제를 다룹니다. 여러분의 애플리케이션은 자격 증명 및 기타 사용자 관련 세부 정보를 관리해야 합니다. 이러한 정보는 데이터베이스에 저장되어 있거나, 웹 서비스 또는 다른 시스템을 통해 접근될 수 있습니다. 중요한 점은, 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 User 클래스는 불변(immutable)입니다. 세 개의 속성 값을 인스턴스를 만들 때 제공하며, 이후에는 변경할 수 없습니다.
- #B 예제를 단순하게 하기 위해, 사용자는 하나의 권한만 가집니다.
- #C 권한은 생성 시 제공된 권한 이름을 가진 GrantedAuthority 객체를 포함하는 리스트로 반환됩니다.
- #D 계정은 만료되지 않으며 잠금되지 않습니다.
이제 services 패키지에 InMemoryUserDetailsService라는 클래스를 생성하여 UserDetailsService를 구현합니다. 다음 리스팅은 이 클래스를 구현하는 방법을 보여줍니다.
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는 메모리 내에서 사용자 목록을 관리합니다.
- #B 사용자 목록에서 요청된 사용자 이름을 가진 사용자를 필터링합니다.
- #C 해당 사용자가 있으면 반환합니다.
- #D 주어진 사용자 이름으로 사용자를 찾을 수 없다면, UsernameNotFoundException을 던집니다.
loadUserByUsername(String username) 메서드는 주어진 사용자 이름으로 사용자 리스트를 검색하고 원하는 UserDetails 인스턴스를 반환합니다. 만약 해당 사용자 이름에 해당하는 인스턴스가 없다면 UsernameNotFoundException을 던집니다. 이제 이 구현체를 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
응답 본문은 다음과 같습니다:
Hello!
3.3.3 UserDetailsManager 계약 구현
이 섹션에서는 UserDetailsManager 인터페이스를 사용하고 구현하는 방법에 대해 설명합니다. 이 인터페이스는 UserDetailsService를 확장하며, 사용자 관리에 필요한 추가적인 메서드를 제공합니다. Spring Security는 인증을 수행할 때 UserDetailsService 계약을 필요로 하지만, 일반적으로 애플리케이션에서는 사용자를 관리해야 할 필요가 있습니다. 예를 들어, 새 사용자를 추가하거나 기존 사용자를 삭제하는 작업을 수행할 수 있어야 합니다. 이 경우, 우리는 UserDetailsManager 인터페이스를 구현하여 이러한 기능을 제공합니다.
UserDetailsManager 인터페이스는 다음과 같은 메서드를 정의합니다:
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의 구현체였습니다. 이번 섹션에서는 UserDetailsManager를 이용해 사용자를 관리하는 방법을 설명합니다. ssia-ch3-ex2 프로젝트에서 이와 관련된 예제를 제공합니다.
JdbcUserDetailsManager를 이용한 사용자 관리
InMemoryUserDetailsManager 외에도 JdbcUserDetailsManager라는 또 다른 UserDetailsManager 구현체가 있습니다. JdbcUserDetailsManager는 데이터베이스에서 사용자를 관리하며, JDBC를 통해 데이터베이스와 연결됩니다. 이를 사용하면 Spring Security가 데이터베이스에서 사용자 정보를 조회하고 관리할 수 있습니다.
JdbcUserDetailsManager 개요
JdbcUserDetailsManager는 SQL 데이터베이스를 사용하여 사용자 정보를 관리합니다. 기본적으로 사용자는 users 테이블과 authorities 테이블을 통해 관리됩니다. users 테이블에는 사용자의 레코드가 저장되고, authorities 테이블에는 사용자의 권한 정보가 저장됩니다. 기본적으로 JdbcUserDetailsManager는 이 두 테이블을 사용하지만, 필요에 따라 테이블 이름을 변경할 수 있습니다.
다음은 MySQL 데이터베이스에서 사용할 users와 authorities 테이블의 예시입니다.
users 테이블 스키마 (SQL 쿼리):
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 테이블 스키마 (SQL 쿼리):
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`)
);
이 두 테이블을 사용하여 사용자의 기본 정보를 저장하고, 각 사용자에게 권한을 부여합니다.
데이터 삽입
테스트용으로 사용자를 삽입하려면 users와 authorities 테이블에 데이터를 추가해야 합니다. 이 쿼리는 data.sql 파일에 추가할 수 있습니다.
INSERT INTO `spring`.`authorities` (username, authority)
VALUES ('john', 'write');
INSERT INTO `spring`.`users` (username, password, enabled)
VALUES ('john', '12345', '1');
의존성 추가
JdbcUserDetailsManager를 사용하려면 spring-boot-starter-security, spring-boot-starter-web, spring-boot-starter-jdbc 등의 의존성을 pom.xml 파일에 추가해야 합니다.
pom.xml 의존성 예시:
<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>
MySQL을 사용하는 경우, MySQL 드라이버 의존성을 추가해야 합니다:
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<scope>runtime</scope>
</dependency>
데이터 소스 구성
JdbcUserDetailsManager를 사용하려면 데이터 소스가 필요합니다. 이 데이터 소스는 자동으로 주입되며, application.properties 또는 별도의 빈으로 설정할 수 있습니다. 예를 들어 application.properties에서 H2 데이터베이스를 사용하려면 다음과 같은 설정을 추가합니다:
spring.datasource.url=jdbc:h2:mem:ssia
spring.datasource.username=sa
spring.datasource.password=
spring.sql.init.mode=always
JdbcUserDetailsManager 등록
구성 클래스에서 JdbcUserDetailsManager를 빈으로 등록하려면 다음과 같이 설정할 수 있습니다.
ProjectConfig 클래스 예시:
@Configuration
public class ProjectConfig {
@Bean
public UserDetailsService userDetailsService(DataSource dataSource) {
return new JdbcUserDetailsManager(dataSource);
}
@Bean
public PasswordEncoder passwordEncoder() {
return NoOpPasswordEncoder.getInstance();
}
}
HTTP Basic 인증을 사용하여 테스트
이제 데이터베이스에 저장된 사용자로 HTTP Basic 인증을 사용하여 엔드포인트에 접근할 수 있습니다. 예를 들어, curl을 사용하여 /hello 엔드포인트를 호출하면 다음과 같이 응답을 받을 수 있습니다.
curl -u john:12345 http://localhost:8080/hello
응답은 Hello!가 됩니다.
쿼리 재정의
기본적으로 JdbcUserDetailsManager는 특정 쿼리를 사용하여 사용자 정보를 조회합니다. 예를 들어, 사용자를 찾는 쿼리나 권한을 찾는 쿼리 등을 재정의할 수 있습니다. 예를 들어 users와 authorities 테이블의 쿼리를 변경하려면 다음과 같이 할 수 있습니다.
@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에서 사용하는 기본 쿼리를 커스터마이즈할 수 있습니다.
'Spring Security in Action' 카테고리의 다른 글
8장 Configuring endpoint-level authorization: Applying restrictions (0) | 2024.12.01 |
---|---|
7장 Configuring endpoint-level authorization: Restricting access (0) | 2024.12.01 |
5장 A web app's security begins with filters (0) | 2024.12.01 |
4장 비밀번호 인코더 구현 및 작업 (0) | 2024.12.01 |
2장 Hello Spring Security (0) | 2024.12.01 |