들어가기 앞서
본 글은 스프링 시큐리티 서적인 Spring Security in Action을 읽고 책 속에 나와 있는 예제를 공부하며 얻은 지식을 바탕으로 적은 글입니다.
이전 글
[스프링 시큐리티 무작정 따라하기] 1. Hello Spring Security
오늘의 목표
- 스프링 시큐리티의 핵심 인증 아키텍처에 대해 이해한다.
- 스프링 시큐리티의 회원 관리 인터페이스인 UserDetails, GrantedAuthority, UserDetailsService, UserDetailsManager에 대해 이해한다.
- 스프링 시큐리티의 인증 메커니즘에 따라 커스텀 회원을 생성하고, 이를 메모리와 데이터베이스 상에 저장 및 조회해 본다.
1. 스프링 시큐리티의 인증 아키텍처
스프링 시큐리티의 인증 메커니즘을 이해하기 위해서는 스프링 시큐리티가 설계된 인증에 관한 아키텍처에 대해 이해할 필요가 있습니다. 스프링 시큐리티는 매우 유연한 프레임워크로 반드시 이러한 핵심 아키텍처를 따라야 하는 것은 아니지만 애플리케이션의 유지보수성과 설계의 일관성을 유지하기 위해 프레임워크가 제시하는 인증 아키텍처를 숙지하고 사용하는 것이 권장됩니다.
언뜻 보기에는 쉽게 겁을 먹을 수 밖에 없는 복잡한 도식이 아래에 보일 것입니다. 걱정하지 마세요, 하나하나 따라가며 인증에 관한 아키텍처를 입문적인 수준에서 익혀보도록 하겠습니다. 오늘 우리는 아래 보이는 도식에 매겨진 단계 중 1번, 3번, 4번, 5번, 6번, 7번, 8번, 9번에 대해 살펴볼 것입니다. 앞서 말한 것처럼 아주 러프하게, 또 아주 입문적인 수준에서 가볍게 다루고 넘어갈 것이니 천천히 같이 가보시죠.
스프링 시큐리티의 가장 큰 특징 중 하나는 스프링 시큐리티 그 자체가 애플리케이션의 필터 단에서 동작한다는 것인데요, 스프링 시큐리티는 다양한 보안 관련 요구사항들을 각각의 필터 형태로 애플리케이션에 삽입하고, 사용자의 요청을 가로채(intercept) 필요한 보안적 역할을 수행하는 형식으로 동작합니다. 이러한 방식은 인증에 관한 처리에서 또한 마찬가지로, 스프링 시큐리티는 AuthenticationFilter라는 인증 필터를 이용해 사용자의 요청을 검증하고 현재 요청의 사용자가 시스템상에 접근 허가된 회원인지를 판별합니다. 그림에서 보이는 1번이 바로 이러한 과정을 나타내는 곳이죠.
- 1번 : 사용자의 요청이 스프링 시큐리티의 인증필터인 AuthenticationFilter에 의해 가로채(intercepted)진다.
AuthenticationFilter에 의해 가로채진 사용자의 요청은 AuthenticationFilter가 내부적으로 호출하는 AuthenticationManager에 의해 검증됩니다. AuthenticationManager는 AuthenticationFilter에 의해 사용자의 요청을 인증해야 하는 업무를 위임받습니다. 이러한 방식을 사용하는 이유는 객체 지향 설계 원칙 중 하나인 '단일 책임 원칙'과 관련이 깊은데, AuthenticationFilter는 이미 사용자의 요청을 필터 단에서 가로채는 역할을 수행하기 때문에 실제 회원에 대한 인증을 처리하는 별도의 장치를 마련한 것입니다. 이렇게 함으로써 AuthenticationFilter는 필터 상에서 회원의 요청을 가로채는 역할과 책임만을, AuthenticationManager는 AuthenticationFilter에 의해 획득된 회원의 요청을 인증하는 역할과 책임만을 부여받게 되는 것이죠.
- 3번 : 인증에 대한 책임이 AuthenticationManager에게 위임됩니다.
AuthenticationFilter에 의해 인증에 대한 책임을 위임받은 AuthenticationManager는 다수의 AuthenticationProvider를 주입 받아 인증에 대한 작업을 실행합니다. AuthenticationManager는 하나 이상의 AuthenticationProvider 목록을 순회하며 검증에 필요한 특정 유형의 인증을 수행합니다.
예를 들어 사용자명 / 비밀번호 기반의 인증 형태를 가진 애플리케이션의 경우 AuthenticationManager는 AuthenticationProvider 중 사용자명 / 비밀번호 형태의 인증을 수행하는 DaoAuthenticationProvider를, JWT 토큰 기반의 인증 형태를 가진 애플리케이션의 경우는 JWT 형태의 인증을 수행하는 JwtAuthenticationProvider를 사용할 수 있습니다.
- 4번 : 실질적이고 구체적인 형태의 특정 인증을 수행하기 위해 AuthenticationManager는 다양한 종류의 AuthenticationProvider 중 특정 AuthenticationProvider를 선택해 사용합니다.
특정 형태의 인증을 수행해야 하는 AuthenticationProvider가 가장 먼저 해야 하는 작업이 무엇일까요? 바로 인증하고자 하는 회원을 회원이 저장된 위치로부터 불러와 인증을 시작하는 것입니다. 인증을 할 회원을 가져오지 못한다면 인증을 하는 것은 아무런 의미가 없겠죠. 회원을 인증해야 하는데, 기존 회원의 정보를 열람할 수 없으면 어떻게 인증을 할 수 있겠습니까?!
따라서 시스템은 이러한 역할, 즉 시스템 상 혹은 시스템 외부에 저장된 회원을 AuthenticationProvider가 사용할 수 있게 불러오는 역할을 위해 UserDetailsService를 사용하는데요, 조금 후에 살펴볼 예정이지만 UserDetailsService에는 loadUserByUsername 라는 이름의 메소드가 있어 회원을 스프링 시큐리티가 사용할 수 있는 형태로 가져올 수 있습니다.
- 5번 : AuthenticationProvider는 인증을 하기 위한 회원 정보를 호출하기 위해 UserDetailsService를 사용합니다.
마지막으로 살펴볼 UserDetails는 일종의 데이터 저장 형태로, UserDetailsService에 의해 조회된 회원 정보를 스프링 시큐리티가 사용할 수 있는 형태로 변환한 형태를 의미합니다. UserDetails는 스프링 시큐리티가 사용하는 회원 객체(사실 UserDetails는 인터페이스이기 때문에 객체라는 표현은 무리가 있습니다. 편의상 표현하였습니다.)로 getUsername(), getPassword(), isEnabled()와 같은 회원의 인증에 도움이 되는 각종 메소드를 가지고 있습니다. UserServices를 구현한 대표적인 객체가 스프링 시큐리티의 User 객체입니다. UserDetails와 UserDetailsService는 아래의 장에서 조금 더 자세히 알아보도록 하겠습니다.
- 6번 : UserDetailsService는 불러온 회원 정보를 스프링 시큐리티가 사용할 수 있는 형태인 UserDetails에 저장합니다.
- 7번 : 저장된 회원정보를 담은 UserDetails를 AuthenticationProvider에 반환합니다.
- 8번, 9번, 10번 : 인증에 성공한다면 해당 정보를 SecurityContextHolder 내부에 저장합니다. 인증에 실패할 경우 Exception이 발생합니다.
2. 회원 관리 인터페이스 : UserDetails, GrantedAuthority, UserDetailsService, UserDetailsManager
다음으로 스프링 시큐리티에서 회원 관리를 담당하는 네 가지 인터페이스에 대해 살펴보겠습니다. 각각의 인터페이스는 역할에 따라 회원을 정의하거나, 회원의 권한을 정의하거나, 회원을 직접적으로 관리합니다. 소제목에 언급된 순서대로, UserDetails에서부터 UserDetailsManager까지 그 역할과 용도를 개략적으로 살펴보도록 하겠습니다.
스프링 시큐리티가 사용하는 회원의 형태를 기술한 UserDetails
UserDetails는 스프링 시큐리티가 사용 가능한 회원의 형태를 기술한 인터페이스입니다. UserDetails를 구현한 가장 대표적인 객체는 스프링 시큐리티의 User 객체로 User 객체를 통해 스프링 시큐리티를 사용하는 개발자들은 매순간 UserDetails를 구현하는 번거로움을 해소할 수 있습니다.
public interface UserDetails extends Serializable {
Collection<? extends GrantedAuthority> getAuthorities();
String getPassword();
String getUsername();
boolean isAccountNonExpired();
boolean isAccountNonLocked();
boolean isCredentialsNonExpired();
boolean isEnabled();
}
UserDetails의 형태는 다음과 같습니다. 내부에 getUsername(), getPassword()과 getter 메소드와 isAccountNonExpired(), isAccountNonLocked(), isCredentialNonExpired(), isEnabled()와 같이 인증과 관련된 진위여부를 판단할 수 있는 메소드들이 있음을 알 수 있습니다. UserDetails를 구현하기 위해서는 위에 언급된 메소드들을 반드시 구현해야 하며, 스프링 시큐리티는 이 UserDetails를 구현한 객체가 자신이 사용할 수 있는 회원 객체임을 짐작할 수 있습니다.
UserDetails를 구현한 대표적인 객체인 User의 형태는 어떨까요? User 객체는 구현의 양으로 인해 코드가 UserDetails에 비해 상당히 많기 때문에 눈여겨볼 대표적인 부분만 가져와 같이 논의해보도록 하겠습니다.
public class User implements UserDetails, CredentialsContainer {
...
private String password;
private final String username;
private final Set<GrantedAuthority> authorities;
private final boolean accountNonExpired;
private final boolean accountNonLocked;
private final boolean credentialsNonExpired;
private final boolean enabled;
...
@Override
public Collection<GrantedAuthority> getAuthorities() {
return this.authorities;
}
@Override
public String getPassword() {
return this.password;
}
@Override
public String getUsername() {
return this.username;
}
@Override
public boolean isEnabled() {
return this.enabled;
}
@Override
public boolean isAccountNonExpired() {
return this.accountNonExpired;
}
@Override
public boolean isAccountNonLocked() {
return this.accountNonLocked;
}
@Override
public boolean isCredentialsNonExpired() {
return this.credentialsNonExpired;
}
@Override
public void eraseCredentials() {
this.password = null;
}
...
}
위의 코드를 살펴보면 User 객체는 UserDetails를 구현하기 위해 오버라이드에 필요한 필드들을 생성한 것을 알 수 있습니다. 이들 필드들은 final 키워드로 정의됨에 따라 생성자로 인해 객체가 생성되는 시점에만 값이 할당되도록 세팅되어 있습니다. 아무래도 생성자로 인해 객체가 생성되는 시점에 필드의 값이 결정되고 이후에 시스템에서 해당 객체의 필드 정보를 변경할 수 없으니 혹여나 개발자들의 실수로 인해 객체의 정보가 오염되는 우려를 막을 수 있겠죠.
아래에는 UserDetails가 구현을 강제한 메소드들을 하나하나 구현한 모습입니다. 이를 통해 UserDetails를 구현한 User 객체는 UserDetails의 책임과 역할을 구현하여 이행한 것을 확인할 수 있습니다.
애플리케이션 내에서 회원에게 허용된 행동을 기술한 인터페이스 GrantedAuthority
GrantedAuthority 인터페이스는 UserDetails를 구현한 객체가 생성될 때 필드 정보로 삽입되는 정보에 대한 역할을 기술한 인터페이스입니다. 혹, crud라는 용어를 들어보셨는지요. crud는 각각 create, read, update, delete의 앞머리를 딴 말로 애플리케이션 내에서 행할 수 있는 네 가지의 대표적인 행동입니다. 애플리케이션에 따라 조금 더 세분화된 구분법이 존재할 수 있겠지만 일반적으로 모든 애플리케이션은 무언가를 생성하고(create), 조회/열람하고(read), 수정하고(update), 삭제하는(delete) 방식으로 동작합니다.
회원의 입장에서 "crud를 할 수 있는가,"는 일종의 권한의 입장에서 살펴볼 수 있는데, 예를 들어 '공지사항'과 같은 게시물의 경우 애플리케이션의 관리자(Admin, Administrator)는 공지사항을 작성하거나(create), 수정하거나(update), 삭제할 수 있는(delete) 권한이 부여되지만 일반 회원의 경우에는 crud 중 read, 오직 공지사항을 열람할 수 있는 권한만이 부여됩니다.
회원을 관리하기 위해서는 이러한 권한문제를 효율적으로 다뤄야 할 필요가 있는데, 이러한 권한 정보를 스프링 시큐리티에서는 바로 GrantedAuthority가 담당하는 것입니다. GrantedAuthority는 이후에 따로 자세히 살펴볼 수 있도록 하겠습니다.
스프링 시큐리티 아키텍처에서 회원을 관리하는 인터페이스 UserDetailsService, UserDetailsManager
위에서 우리는 UserDetails와 GrantedAuthority를 살펴보았습니다. 이 두 인터페이스는 스프링 시큐리티에서 각각 회원을 정의하는, 그리고 권한을 정의하는 일종의 VO(Value Object)입니다. 즉, 보안과 관련된, 특히 인증과 관련된 특정 로직을 담고 있는 요소가 아닌 데이터베이스 혹은 파일시스템으로부터 불려진 데이터의 정보를 보관하는 일종의 자료구조(자료구조에 비유하는 것이 옳은 비유인지는 모르겠습니다만..)에 가까운 것이죠.
반면 지금 살펴볼 UserDetailsService와 UserDetailsManager는 직접적으로 회원을 관리하는 요소입니다. UserDetailsService와 UserDetailsManager를 같은 챕터에 배치한 이유가 있는데, 그와 관련해서는 UserDetailsService 코드를 살펴보며 한번 알아보도록 하겠습니다.
public interface UserDetailsService {
UserDetails loadUserByUsername(String username) throws UsernameNotFoundException;
}
UserDetailsService입니다. 생각보다 많이 간단해 보이네요. 네, UserDetailsService는 단 하나의 메소드만 갖습니다. 바로 loadUserByUsername() 메소드이죠.
앞서 살펴본 장에서 UserDtailsService의 가장 중요한 역할이 무엇이었는지 기억하시나요? UserDetailsService의 가장 중요한 역할을 AuthenticationProvider가 회원 정보를 인증할 수 있도록 시스템 상으로 회원 정보를 불러오는 것입니다. UserDetailsService는 이러한 작업을 loadUserByUsername 메소드를 통해 처리합니다. 매개변수로 들어온 회원의 입력값(username)을 이용해 인증 정보와 일치하는 회원이 있다면 이를 UserDetails에 담아 반환하고(UserDetails는 스프링 시큐리티가 사용하는 회원 정보, 인 것을 기억하시지요?) 일치하는 회원이 없다면 UsernameNotFoundException을 자신이 호출된 AuthenticationProvider 상으로 던집니다.
그런데 조금 이상할 수 있습니다. 스프링 시큐리티에서 '회원'을 '관리'하는 '로직'을 담는 인터페이스인데 그 역할이 무언가 너무 부족하다고 느껴지시지 않나요? 맞습니다, 실제 애플리케이션을 구축한다면 '회원 관리'라 칭해지는 작업이 회원을 조회하는 작업에만 국한되지 않습니다. 많은 경우 회원을 관리하기 위해 애플리케이션은 회원을 생성하거나, 수정하거나, 삭제할 수 있어야 할 것입니다. 경우에 따라서는 회원의 비밀번호를 변경하거나 회원이 시스템 상에 존재하는지 판별할 수도 있어야 하겠죠.
이렇듯, 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번 장에서 언급한 부분을 한번 정리해보겠습니다. 우리는 2번 장에서 UserDetails, GrantedAuthority, UserDetailsService, UserDetailsManager를 살펴 보았습니다. 각각의 인터페이스는 아래의 역할을 갖습니다.
- UserDetails : 회원의 정보를 담습니다. 추가적으로 스프링 시큐리티가 활용할 수 있는 여러 판별 메소드를 담을 수 있습니다.
- GrantedAuthority : 특정 회원에게 인가할 수 권한 목록을 담습니다. 이러한 권한을 바탕으로 특정 회원에게 새로운 권한을 부여하거나 회수하여 회원의 행동을 확장하거나 제한할 수 있습니다.
- UserDetailsService : AuthenticationProvider가 사용할 수 있게 회원 정보를 시스템 상으로 불러오는 역할을 수행합니다.
- UserDetailsManager : UserDetailsService의 제한적인 역할을 보완하기 위해 회원 정보를 생성, 수정, 삭제하는 추가적인 메소드를 가진 UserDetailsService의 확장 인터페이스입니다.
3. 회원 인증 : InMemoryUserDetailsService와 JdbcUserDetailsManager를 사용한 메모리와 데이터베이스에서의 회원 인증
이번 장에서는 앞서 살펴본 아키텍처 중 5번, 6번에 해당하는 부분을 살펴보겠습니다. 구체적으로는 임의의 회원을 직접 생성한 후 이 회원을 한번은 메모리 상에 다른 한번에 데이터베이스 시스템 상에 저장한 후, 스프링 시큐리티의 메커니즘으로 이 회원 인증해 보는 부분을 살펴볼 것입니다.
InMemoryUserDetailsService를 사용한 메모리 상에서의 회원 인증 구현
자, 이번 챕터에서는 여러분들이 직접 생성한 회원을 메모리 상에 저장하고, 이를 꺼내 스프링 시큐리티로부터 인증을 받아 보는 작업을 진행해보겠습니다. 이번 챕터에서 해야 할 일은 크게 네 가지로,
- UserDetails를 구현해 스프링 시큐리티를 위한 회원 정보를 만들겠습니다.
- GrantedAuthority를 구현해 아주 간단한 권한 정보를 생성하겠습니다.
- InMemoryUserDetailsService를 구현해 메모리 상에 위치한 회원 정보를 시스템으로 불러 오겠습니다.
- @Configuration 애노테이션과 @Bean 애노테이션을 이용해 스프링 컨테이너에 InMemoryUserDetailsService 정보를 등록하겠습니다.
UserDetails 구현
지난 글에서 만든 프로젝트 패키지 구조 중, controller 패키지와 같은 위치에 security라는 이름의 패키지를 생성합니다. 이후 user라는 패키지를 security 패키지 아래에 만든 후, user 패키지 내부에 CustomUser라는 클래스를 생성합니다.
생성된 CustomUser 클래스에 다음과 같이 코드를 작성합니다.
public class CustomUser implements UserDetails {
private final String username;
private final String password;
private final GrantedAuthority authority;
public CustomUser(String username, String password, GrantedAuthority authority) {
this.username = username;
this.password = password;
this.authority = authority;
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
List<GrantedAuthority> authorities = new ArrayList<>();
authorities.add(authority);
return authorities;
}
@Override
public String getPassword() {
return password;
}
@Override
public String getUsername() {
return 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;
}
}
CustomUser는 UserDetails 인터페이스를 구현합니다. UserDetails 인터페이스를 구현하기 위해서는 UserDetails가 가진 모든 메소드를 구현해야 합니다. 실제 상용할 수 있는 애플리케이션을 구축한다면 CustomUser보다 훨씬 정교한 코드가 작성되어야 하겠지만 지금은 스프링 시큐리티의 회원 관리를 위한 공부를 하기 위함이니 위와 같이 간략하게 작성합니다. (일반적인 경우 UserDetails의 구현체는 CustomUser에서처럼 단 하나의 GrantedAuthority를 가지지는 않습니다. GrantedAuthority는 차후에 자세히 살펴볼 예정이므로, 예제의 단순성을 위해 편의상 하나의 GrantedAuthority만을 갖도록 하였습니다.)
만약 여러분들의 애플리케이션이 자격증명의 만료 여부를 판별해야 하거나, 계정의 만료 혹은 잠금을 판별해야 한다면 isCredentialsNonExpired() 등의 메소드를 구현하면 됩니다. 마찬가지로 이번 예에서는 예제의 단순성을 위해 모두 return true 처리 하였습니다.
GrantedAuthority 구현
user 패키지 내부에 CustomGrantedAuthority 클래스를 생성합니다. 그리고 아래와 같이 코드를 작성합니다.
public class CustomGrantedAuthority implements GrantedAuthority {
private final String authority;
public CustomGrantedAuthority(String authority) {
this.authority = authority;
}
@Override
public String getAuthority() {
return authority;
}
}
오늘의 예제에서는 GrantedAuthority가 자세히 다뤄지지 않습니다. 회원을 인증하기 위해 필요한 조건이기에 이렇게 간단하게만 코드를 작성합니다.
UserDetailsService 구현
다음으로 security 패키지 내부에 service 패키지를 생성한 후 InMemoryUserDetailsService 클래스를 생성합니다. 클래스의 내부에는 다음과 같이 작성합니다.
public class InMemoryUserDetailsService implements UserDetailsService {
private final List<UserDetails> users;
public InMemoryUserDetailsService(List<UserDetails> users) {
this.users = users;
}
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
return users.stream()
.filter(user -> user.getUsername().equals(username))
.findFirst()
.orElseThrow(() -> new UsernameNotFoundException("User not found Exception"));
}
}
InMemoryUserDetailsSerivce 클래스는 필드로 UserDetails의 리스트인 users를 가지고 있습니다. 그리고 loadUsersByUsername 메소드 내부에서는 자신이 가지고 있는 users 리스트와 사용자가 입력한 username을 비교해 같은 값이 있다면 그 값을 리턴하고, 없다면 UsernameNotFoundException을 던지고 있습니다. 만약 자바8의 문법인 stream이 익숙하지 않으시다면 아래와 같이 작성하여도 동일하게 동작할 것입니다.
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
for (UserDetails user : users) {
if (user.getUsername().equals(username)) return user;
}
throw new UsernameNotFoundException("User not found Exception");
}
Application context에 InMemoryUserDetailsService 등록
마지막으로 조금 전에 생성한 InMemoryUserDetailsService를 스프링 시큐리티가 사용할 수 있도록 Application Context에 등록하는 작업을 진행해보겠습니다. 우선 security 패키지에 configuration 패키지를 생성한 후 SecurityConfiguration 클래스를 생성합니다. 그리고 아래와 같이 코드를 작성합니다.
@Configuration
public class SecurityConfiguration {
@Bean
public UserDetailsService userDetailsService() {
CustomUser user1 = new CustomUser("jinseok", "1234", new CustomGrantedAuthority("read"));
CustomUser user2 = new CustomUser("suchan", "1234", new CustomGrantedAuthority("read"));
return new InMemoryUserDetailsService(List.of(user1, user2));
}
@Bean
public PasswordEncoder passwordEncoder() {
return NoOpPasswordEncoder.getInstance();
}
}
@Configuration 애노테이션은 스프링 컨테이너(Application Context)에 해당 클래스가 설정 파일임을 알려주는 애노테이션입니다. 스프링 컨테이너가 생성될 때 스프링 컨테이너는 해당 애노테이션이 붙은 클래스 내부의 모든 빈들을 컨테이너 내부에 등록합니다. @Bean 애노테이션은 아래 적힌 메소드의 return 값이 스프링 컨테이너에 등록되어야 하는 빈임을 알려주는 애노테이션입니다.
SecurityConfiguration 설정 클래스 내부에는 두 개의 빈이 기록되어 있습니다. 하나는 조금 전에 우리가 생성한 UserDetailsService인 InMemoryUserDetailsService이고, 다른 하나는 PasswordEncoder라고 하는 빈입니다. 스프링 시큐리티는 회원의 비밀번호 데이터를 암호화하여 저장하기 때문에 UserDetailsService를 사용할 때는 반드시 암호화를 담당할 PasswordEncoder를 지정해 주어야 합니다. NoOpPasswordEncoder는 회원의 비밀번호를 암호화하지 않는 형태로 실제 애플리케이션을 사용할 때는 사용하는 것이 권장되지 않지만, 오늘 실습에서 PasswordEncoder가 주가 되는 것이 아니기에 이 객체를 사용하겠습니다.
userDetailsService 메소드 내부에는 두 개의 CustomUser 객체가 생성되는 것을 확인할 수 있습니다. 생성자로 생성되는 각각의 객체들은 생성자 내부에 주어지는 인자값으로 회원이름, 비밀번호, 권한을 요구합니다. 저는 jinseok:1234, suchan:1234라는 두 명의 회원을 생성해주었습니다. (return 값으로 주어진 부분에 List.of(user1, user2)가 이해가 되지 않으신다면 조금 전에 생성한 InMemoryUserDetailsService의 생성자를 확인해보십시오!)
테스트
프로젝트를 구동한 후, 브라우저에서 localhost:8080 으로 접속합니다. 로그인 화면이 성공적으로 뜨면 앞서 작성한 회원 정보(회원이름, 비밀번호)를 입력합니다. 저의 경우 jinseok:1234, suchan:1234이지만 다르게 작성하신 분이 있으시다면 서버에 작성한 정보를 입력해주시면 됩니다.
이후 localhost:8080/hello 로 접근했을 때 아래와 같이 컨트롤러에 입력한 "Hello Security!!"가 노출되면 성공입니다.
JdbcUserDetailsManager를 사용한 데이터베이스에서의 회원 인증 구현
사전 준비 A) JDBC, MariaDB 의존성 추가
이번 챕터의 실습에 들어가기 앞서, 이전 글에서 만든 프로젝트에 jdbc 관련 의존성을 하나 추가해보겠습니다. 아래와 같이 보이는 프로젝트 구조에서,
build.gradle 파일을 더블클릭합니다. 이후 아래와 같이 2가지의 의존성을 추가하겠습니다. 우리는 이번 글에서 mariadb를 사용해 데이터베이스에 회원을 조회하는 실습을 진행할 것이므로 mariadb 클라이언트 의존성과 spring boot starter jdbc 의존성을 추가할 것입니다.
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-web'
// 추가
implementation 'org.springframework.boot:spring-boot-starter-jdbc'
implementation 'org.mariadb.jdbc:mariadb-java-client'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.springframework.security:spring-security-test'
}
의존성을 추가하신 후 아래 사진에 위치한 새로고침 버튼을 누르시거나,
Gradle 사이드 탭을 누르신 후 새로고침을 누르시면 관련 의존성이 프로젝트 내부로 다운로드 되어집니다.
이후 Dependencies 내부에서 아래와 같이 spring-boot-starter-jdbc와 madiadb-java-client를 찾으실 수 있으시다면 의존성 주입이 완료된 것입니다.
사전 준비 B) 데이터베이스 생성 및 데이터베이스 접속
우선 만약 이 글을 읽는 독자분들이 관계형 데이터베이스와 관련해 익숙하지 않은 상황이라면 아래의 링크를 타고 데이터베이스에 관해 먼저 학습하시고 오시는 것을 권장 드립니다. 본 글이 데이터베이스와 관련된 글은 아니지만 많은 경우 데이터베이스는 서버와 뗄 수 없는 불가결의 요소이므로 어느정도의 데이터베이스 관련 배경지식이 필요합니다.
https://opentutorials.org/course/3162
https://opentutorials.org/course/3161
(본 글에서는 실습 데이터베이스로 mariaDB를 사용하지만, mySQL과 같은 편한 rdbms를 사용하셔도 무방합니다.)
실습을 계속 진행하실 준비가 되셨다면 자신이 사용하는 데이터베이스 클라이언트 프로그램을 사용하거나, cli 환경에서 새로운 데이터베이스를 생성합니다. 데이터베이스명은 편의에 따라 사용하고 싶은 이름을 사용하시면 됩니다. 저는 spring_security라는 데이터베이스를 생성 해 주었습니다.
다음으로 다시 서버로 돌아와 아래의 캡처 자료를 참고해 application.properties 파일을 더블 클릭합니다. application.properties는 데이터베이스 관련 설정 등 애플리케이션 설정을 잡아줄 수 있는 설정파일입니다.
이곳에서 조금 전에 생성한 데이터베이스 정보를 등록해 서버가 데이터베이스 내부에 있는 데이터를 사용할 수 있도록 설정을 잡아주겠습니다.
# 데이터베이스 드라이버 설정 정보입니다.
spring.datasource.driver-class-name=org.mariadb.jdbc.Driver
# 데이터베이스 url과 타겟 데이터베이스 이름입니다.
# spring_security와 다른 데이터베이스 이름을 지정하셨다면 3306/ 이하의 위치에 해당 데이터베이스 이름을 적어주십시오.
spring.datasource.url=jdbc:mariadb://127.0.0.1:3306/spring_security
# 데이터베이스 유저 이름입니다.
spring.datasource.username=root
# 데이터베이스 유저 비밀번호입니다.
spring.datasource.password=[password]
이렇게 설정을 잡아 주시고 프로젝트를 실행했을 때, 정상적으로 애플리케이션이 구동되면 데이터베이스와 서버가 연결이 완료된 것입니다.
JdbcUserDetailsManager를 사용한 회원 인증 구현
본격적인 실습으로 들어가 데이터베이스 내부에 회원을 생성하고, 이를 조회하여 인증 절차를 밟아보도록 하겠습니다. 이번 챕터에서는 UserDetailsService가 아닌 이를 확장한 인터페이스 UserDetailsManager, 그리고 그 중에서 JdbcUserDetailsManager를 이용해볼 것입니다. 앞서 설명한 바와 같이 UserDetailsManager는 UserDetailsService를 확장한 인터페이스로 회원을 생성하거나, 수정하거나, 삭제하는 확장된 기능을 가지고 있습니다. 만약 오늘 사용할 JdbcUserDetailsManager의 구조가 궁금하시다면 아래의 링크에서 Field Summary와 Constructor Summary 그리고 Method Semmary를 열람해 주십시오.
실습으로 들어가기 앞서, 마지막으로 데이터베이스 테이블을 2개 생성하도록 하겠습니다. JdbcUserDetailsManager가 회원 관리를 하는데 필요한 users 테이블과 authorites 테이블입니다. 아래 두 개의 DDL을 이용하시거나 데이터베이스 클라이언트 프로그램을 이용해 각각의 테이블을 생성합니다.
# 회원 테이블 생성
CREATE TABLE users (
`id` INT NOT NULL AUTO_INCREMENT,
`username` VARCHAR(45) NOT NULL,
`password` VARCHAR(45) NOT NULL,
`enabled` INT NOT NULL,
PRIMARY KEY (`id`));
# 권한 테이블 생성
CREATE TABLE authorities (
`id` INT NOT NULL AUTO_INCREMENT,
`username` VARCHAR(45) NOT NULL,
`authority` VARCHAR(45) NOT NULL,
PRIMARY KEY (`id`));
JdbcUserDetailsManager는 데이터베이스 내부에 이 두 테이블을 조회해 회원을 관리합니다. 회원을 생성할 때도, 회원을 수정할 때도, 회원을 삭제할 때도 이 두 테이블을 조회해 작업을 수행합니다.
자, 데이터베이스 테이블까지 세팅이 완료되었다면 본격적으로 서버에 JdbcUserDetailsManager를 주입해보도록 하겠습니다. configuration 패키지 내부의 SecurityConfiguration 클래스에 다음과 같은 코드를 작성합니다.
@Configuration
public class SecurityConfiguration {
@Bean
public UserDetailsService userDetailsService(DataSource dataSource) {
JdbcUserDetailsManager userDetailsManager = new JdbcUserDetailsManager(dataSource);
CustomUser customUser = new CustomUser("jinseok", "1234", new CustomGrantedAuthority("read"));
userDetailsManager.createUser(customUser);
return userDetailsManager;
}
@Bean
public PasswordEncoder passwordEncoder() {
return NoOpPasswordEncoder.getInstance();
}
}
기본적인 맥락은 이전에 진행했던 InMemoryUserDetailsService와 동일합니다. 회원을 생성한 이후, 회원을 관리하는 UserDetailsService를 빈으로 등록해 스프링 시큐리티의 인증 시점에 회원을 관리하도록 하는 것이죠. 다만 이전과 다른 부분이 있다면 회원 생성을 UserDetailsManager의 구현 객체인 JdbcUserDetailsManager이 직접 담당하고 있다는 부분입니다. (다시 한번 말씀 드리지만 UserDetailsManager는 회원을 생성하거나, 수정, 삭제하는 기능이 포함되어 있습니다.)
이렇게 코드를 작성하고 애플리케이션을 구동했을 때, 데이터베이스 내부에 다음과 같이 회원 데이터가 삽입되었으면 성공입니다. 유의하실 점이 하나 있다면 위에 작성된 코드는 단순히 오늘 공부를 위한 예제 코드이기 때문에, 애플리케이션을 종료했다 다시 실행하면 같은 username, password, authority, enabled를 가진 중복된 회원이 생성됩니다. 애플리케이션을 종료했다 다시 실행하시는 분들은 우선 데이터베이스에 남아 있는 기존 데이터를 삭제한 후 실습을 진행해 주시기 바랍니다.
테스트
프로젝트를 구동한 후, 브라우저에서 localhost:8080 으로 접속합니다. 로그인 화면이 성공적으로 뜨면 데이터베이스 users 테이블에 작성된 username과 password를 입력합니다. 저의 경우 jinseok:1234이지만 다르게 작성하신 분이 있으시다면 데이터베이스를 기반으로 정보를 입력해주시면 됩니다.
이후 localhost:8080/hello 로 접근했을 때 아래와 같이 컨트롤러에 입력한 "Hello Security!!"가 노출되면 성공입니다.
글을 마치며
이번 글에서 우리는 스프링 시큐리티의 인증 아키텍처를 개략적으로 살펴보았습니다. 그리고 인증과 관련된 아키텍처 내부에서 회원을 관리하는 5가지의 인터페이스를 살펴보았습니다. 그리고 메모리와 데이터베이스 내부에 있는 회원을 직접 조회해 인증을 구현해 봄으로써 실제 스프링 시큐리티가 동작하는 메커니즘을 러프한 수준에서 확인해 보았습니다.
다음 글에서는 UserDetailsService에 의해 조회된 회원과 사용자의 요청을 비교하여 실제 인증 작업을 수행하는 AuthenticationProvider와 비밀번호를 암호화 복호화하는 PasswordEncoder 그리고 인증된 회원 객체를 관리하는 SecurityContext에 대해 살펴본 후 그 동안 학습한 내용들을 토대로 회원의 정보를 인증해 애플리케이션에서 사용하는 작은 애플리케이션을 개발해 보도록 하겠습니다.
다음 글
'Spring Framework > 스프링 시큐리티' 카테고리의 다른 글
[스프링 시큐리티 무작정 따라하기] 4. 권한 설정하고 인가(authorization) 처리 하기 (2) | 2022.09.11 |
---|---|
[스프링 시큐리티 무작정 따라하기] 3. 인증 구현하기 (2) | 2022.08.16 |
[스프링 시큐리티 무작정 따라하기] 1. Hello Spring Security (0) | 2022.07.12 |
[스프링 시큐리티 - 인증] 공식문서 번역하며 공부하기 - 서블릿 인증 아키텍처 (0) | 2022.01.11 |
[스프링 시큐리티 - 인증] 공식문서 번역하며 공부하기 - Authentication (0) | 2022.01.07 |