들어가기 앞서
본 글은 스프링 시큐리티 서적인 Spring Security in Action을 읽고 책 속에 나와 있는 예제를 공부하며 얻은 지식을 바탕으로 적은 글입니다.
이전 글
[스프링 시큐리티 무작정 따라하기] 1. Hello Spring Security
[스프링 시큐리티 무작정 따라하기] 2. 회원 관리하기
오늘의 목표
- 스프링 시큐리티의 인증 메커니즘 중 인증 역할을 수행하는 AuthenticationProvider를 살펴본다
- 스프링 시큐리티의 인증 메커니즘 중 인증된 회원의 정보를 저장하는 SecurityContext를 살펴본다
- 스프링 시큐리티의 인증 메터니즘 중 회원의 비밀번호를 암호화하고 복호화하는 PasswordEncoder를 살펴본다.
- [실습] 회원 인증을 구현해본다.
1. AuthenticationProvider
이전 글에서 살펴본 스프링 시큐리티의 인증 아키텍처를 다시 한번 살펴보도록 하겠습니다. 인증과 관련된 아래의 아키텍처는 스프링 시큐리티의 인증 메커니즘을 이해하는데 매우 중요하기 때문에 가볍게 살펴본 후 오늘 살펴볼 AuthenticationProvider와 SecurityContext 그리고 PasswordEncoder를 살펴보도록 하죠. 만약, 아키텍처와 관련된 보다 자세한 사항이 궁금하시다면 제가 작성해 놓은 이전 글을 열람하시거나 아래 링크로 게시해 둔 스프링 공식문서 내에서의 인증 아키텍처를 살펴보시기 바랍니다!
https://docs.spring.io/spring-security/reference/servlet/authentication/architecture.html
스프링 시큐리티는 서블릿 필터의 형태로 작동합니다. 사용자의 요청을 필터단에서 가로채 시큐리티의 구체적인 요구사항에 수반하는 작업을 수행하는 것이죠. 인증과 관련해서는 Authentication Filter라고 불리는 인증 필터가 이러한 역할을 수행하고, 인증 필터에 의해 가로채진 사용자의 요청은 필터의 요청에 의해 실행되는 Authentication Manager와 Authentication Provider 리스트, UserDetailsService, PasswordEncoder에 의해 검증되고 처리됩니다. '인증' 처리를 위해 저렇게 많은 요소들이 사용되는 이유는 객체지향원칙 중 단일 책임 원칙과 관련이 깊은데, 각각의 요소들은 '인증'이라는 커다란 목표를 공동 수행하기 위해 각자의 명확한 역할을 가지고 있습니다. 예를 들어 UserDetailsServie는 회원을 시스템 상으로 불러오는 역할을, 오늘 이 장에서 살펴볼 AuthenticationProvider는 시스템 상으로 조회된 회원과 사용자의 요청에 적힌 회원의 정보가 일치하는지 검증하는 역할을 갖습니다.
예리하신 분들은 위 문단에서 제가 AuthenticationProvider를 AuthenticationProvider 리스트라 칭한 부분을 눈치 채셨을지도 모르겠습니다. 위의 캡처를 보시면 AuthenticationProvider는 다른 블록들과는 다르게 여러개가 중첩되어 표현된 듯한 부분을 발견하실 수 있습니다. 이는 실제로 AuthenticationProvider를 사용하는 AuthenticationManager가 단 하나의 AuthenticationProvider를 사용하는 것이 아니라 리스트 형태의 다양한 AuthenticationProvider를 사용할 수 있기 때문으로, 스프링 시큐리티는 한 애플리케이션 내부에 다수의 AuthenticationProvider를 배치하여 형태가 다른 다양한 형태의 인증을 유연하게 수행할 수 있도록 도와줍니다.
백문이 불여일견. 개발자에게는 백번 설명을 해주는 것보다는 한번 코드를 보여주는 것이 낫다, 라는 말이 있지요. AuthenticationProvider의 코드를 직접 살펴보며 위에서 설명한 부분, 즉
- AuthenticationProvider는 회원 정보와 사용자의 요청을 비교해 사용자의 요청을 검증한다.
- AuthenticationManager는 다수의 AuthenticationProvider를 가질 수 있다.
를 살펴보도록 하겠습니다.
public interface AuthenticationProvider {
Authentication authenticate(Authentication authentication) throws AuthenticationException;
boolean supports(Class<?> authentication);
}
인터페이스 AuthenticationProvider는 두 개의 메소드를 가지고 있습니다. 하나 하나 살펴보도록 하겠습니다.
authenticate 메소드
첫 번째 메소드는 authenticate 메소드입니다. 메소드의 이름을 통해 유추할 수 있듯, 이 메소드는 회원의 정보와 사용자의 요청값을 비교해 각각의 정보가 일치하는 지 검증하는 메소드입니다. 매개변수로 주어지는 Authentication 내부에 존재하는 사용자의 요청 값과 authenticate 메소드 내부에서 조회된 회원의 정보를 비교해 양 값이 일치하다면 Authentication에 회원 정보를 담아 리턴하고 일치하지 않는다면 AuthenticationException을 터트립니다. authenticate 메소드 내부에서는 빈으로 등록된 UserDetailsService와 PasswordEncoder가 사용되어 회원의 정보를 가져오고 비밀번호의 일치를 조회하는 역할을 수행합니다. Authentication과 PasswordEncoder는 잠시 후에 각각 조금씩 자세하게 살펴보도록 하겠습니다.
supports 메소드
두 번째 메소드는 supports 메소드로 이 메소드는 사용자의 요청이 자신이 수행하는 인증 검증과 일치하는지 조회합니다. AuthenticationManager는 AuthenticationProvider 내부에 존재하는 각각의 supports 메소드를 통해 특정 AuthenticationProvider의 인증 로직 수행 형태를 판단, 사용자의 요청에 수반하는 인증을 수행하는 AuthenticationProvider에게 인증 작업을 요청할 수 있습니다.
supports 메소드를 이해하기 위해 한 가지 예를 들어 보겠습니다. 여기 두 가지 형태의 사용자 요청과 username/password 형태의 인증을 수행하는 AuthenticationProvider가 있다고 가정해 보겠습니다. 각각의 사용자 요청은 JWT 기반의 인증 요청과 username/password 기반의 인증 요청입니다.
- username과 password 기반의 인증 검증을 지원하는 Authentication Provider
- JWT 기반의 인증 형태를 갖는 사용자 요청 1
- username과 password 기반의 인증 형태를 갖는 사용자 요청 2
username/password 기반의 인증 검증을 하는 AuthenticationProvider가 사용자 요청 1과 2에 각각 호출됩니다. 그리고 이에 대한 결과에 각각 false와 true를 반환합니다. (JWT 기반의 사용자 요청 1 - false / username과 password 기반의 사용자 요청 2 - true) AuthenticationManager는 사용자의 요청이 들어올 때마다 자신이 가지고 있는 AuthenticationProvider 리스트 내의 모든 supports 메소드를 호출하여 true를 내뱉는 AuthenticationProvider에게 사용자 요청의 인증을 수행케 합니다.
authenticate 메소드와 supports 메소드는 이후 실습을 진행하며 사용법을 보다 자세히 살펴보도록 하겠습니다.
2. SecurityContext
SecurityContext는 데이터를 담는 일종의 상자로 AuthenticationProvider와 AuthenticaionManager에 의해 인증된 회원 정보를 저장하는 보관소입니다. 내부에 실제 회원 정보를 담는 Authentication 인터페이스를 저장하며 SecurityContext 내부에 저장된 Authenticaion 즉, 인증된 회원 정보는 이후 애플리케이션의 전반에서 사용이 가능하게 됩니다. 이번 장의 이하에서는 아래에 위치한 캡쳐 자료를 토대로 캡쳐 내부에 표시된 요소들에 대해 간단하게 살펴보도록 하겠습니다.
2-1. SecurityContextHolder
SecurityContextHolder는 SecurityContext를 담는 또 하나의 상자입니다. Authentication은 SecurityContext에 담기고, SecurityContext는 다시 SecurityContextHolder에 담기는 구조로 최상위 폴더(SecurityContextHolder)에 하위 폴더(SecurityContext)가 담기고 바로 그 하위 폴더 내부에 Authentication이라고 불리는 인증 파일이 있는 것이라고 생각하셔도 무방할 것 같습니다.
스프링 공식문서는 SecurityContextHolder를 스프링 인증 메커니즘의 핵심이라 일컫는데, 이는 SecurityContextHolder가 스프링 시큐리티를 인증한 SecurityContext를 가지고 있으며, 현재 애플리케이션의 사용자 요청을 수행하는 쓰레드에 해당 SecurityContext를 연결해주는 역할을 하기 때문입니다. 쓰레드와 SecurityContext를 연결하는 SecurityContextHolder의 전략은 크게 세 가지가 존재하며, 각각의 전략은 아래와 같습니다. 기본 전략은 MODE_THREADLOCAL입니다.
- SecurityContextHolder.MODE_THREADLOCAL
- SecurityContextHolder.MODE_INHERITABLETHREADLOCAL
- SecurityContextHolder.MODE_GLOBAL
SecurityContextHolder는 클래스로 클래스의 내부에는 아래와 같은 메소드들이 존재해 SecurityContext에 접근하거나 제어할 수 있습니다.
public static void clearContext() {
strategy.clearContext();
}
public static SecurityContext getContext() {
return strategy.getContext();
}
public static int getInitializeCount() {
return initializeCount;
}
private static void initialize() {
...
}
public static void setContext(SecurityContext context) {
strategy.setContext(context);
}
2-2. SecurityContext
앞서 언급한 바와 같이 SecurityContext는 회원의 정보를 기술한 Authentication을 보관하는 일종의 상자입니다. SecurityContextHolder의 getContext 메소드를 통해 획득이 가능하고 내부의 getter와 setter 메소드로 Authentication을 획득, 조작할 수 있습니다.
public interface SecurityContext extends Serializable {
Authentication getAuthentication();
void setAuthentication(Authentication authentication);
}
2-3. Authentication
인증된 회원의 정보를 저장하는 인터페이스인 Authentication는 내부에 Principal, Credential, Authority 정보를 담습니다. 아래의 코드를 확인하시면 Authentication이 가지고 있는 정보들이 무엇이 있는지 조금 더 명확하게 확인이 가능합니다.
public interface Authentication extends Principal, Serializable {
Collection<? extends GrantedAuthority> getAuthorities();
Object getCredentials();
Object getDetails();
Object getPrincipal();
boolean isAuthenticated();
void setAuthenticated(boolean isAuthenticated) throws IllegalArgumentException;
}
Principal, Credential, Authority는 회원의 정보를 세분화한 개념으로 각각의 요소들은 다음과 같은 의미를 갖습니다.
- Principal: 정보의 주체입니다. 시스템에 접근하고 인증 허가된 '회원', '시스템' 등을 일컫습니다.
- Credential: 주체가 올바르다는 것을 증명할 수 있는 자격증명입니다. 일반적으로는 암호를 일컫지만 경우에 따라서는 인증서, 사용자 이름 등이 해당될 수 있습니다.
- Authority: 주체에게 부여된 권한입니다. 스프링 시큐리티의 경우 모든 권한은 ROLE_ 이라는 prefix로 시작됩니다.
3. PasswordEncoder
앞서 살펴본 글에서 스프링 시큐리티는 회원의 정보 중 암호에 해당하는 password를 암호화하여 저장한다는 부분을 살펴보았습니다. PasswordEncoder가 바로 이러한 역할을 하는데요, PasswordEncoder는 이름 그대로 비밀번호를 암호화하고 암호화된 비밀번호와 특정 문자열이 일치하는지 검증하는 역할을 갖습니다. PasswordEncoder의 암호화는 단방향 암호화이기 때문에 복호화가 불가능합니다. UserDetailsService와 마찬가지로 AuthenticationProvider에 의해 사용됩니다. 시프링 시큐리티는 다음과 같은 구현 클래스들을 제공합니다.
- BCryptPasswordEncoder
- Argon2PasswordEncoder
- Pbkdf2PasswordEncoder
- SCryptPasswordEncoder
그리고 PasswordEncoder의 내부 코드는 다음과 같습니다. 코드를 살펴본 후 각각의 메소드를 간단히 살펴보도록 하겠습니다.
public interface PasswordEncoder {
String encode(CharSequence rawPassword);
boolean matches(CharSequence rawPassword, String encodedPassword);
default boolean upgradeEncoding(String encodedPassword) {
return false;
}
}
PasswordEncoder는 encode, matches, upgradeEncoding의 세 메소드를 갖습니다.
encode 메소드
신규 가입하는 회원, 혹은 시스템 상으로 새로 들어온 회원의 비밀번호를 암호화하는 메소드입니다. 내부를 직접 작성해 구현할 수도, 스프링 시큐리티가 제공하는 강력한 PasswordEncoder를 사용할 수도 있습니다.
matches 메소드
인증하고자 하는 회원의 인증 정보가 시스템 내부로 들어오면, PasswordEncoder는 시스템 상에 기록되어 있는 암호화된 회원의 비밀번호와 회원이 입력한 텍스트를 비교해 일치 여부를 판단합니다. 이 때 암호화된 비밀번호는 복호화가 불가능하기 때문에, 회원의 입력 텍스트를 저장된 비밀번호화 같은 방법으로 암호화 해 암호화된 결과가 일치하는 지 판단합니다. 아래는 스프링 시큐리티의 BCryptPasswordEncoder의 matches 메소드입니다.
@Override
public boolean matches(CharSequence rawPassword, String encodedPassword) {
if (rawPassword == null) {
throw new IllegalArgumentException("rawPassword cannot be null");
}
if (encodedPassword == null || encodedPassword.length() == 0) {
this.logger.warn("Empty encoded password");
return false;
}
if (!this.BCRYPT_PATTERN.matcher(encodedPassword).matches()) {
this.logger.warn("Encoded password does not look like BCrypt");
return false;
}
return BCrypt.checkpw(rawPassword.toString(), encodedPassword);
}
4. [실습] 회원 인증 구현하기
오늘 실습할 내용입니다. 오늘은 지금까지 배운 인증 과정을 통틀어 실제 애플리케이션에 사용 가능한 수준의 인증을 구현해볼 예정입니다. 물론 실습을 위한 예제이기 때문에 인증 외적인 부분 다수가 생략되거나 간략화되어 있을 수 있습니다. 오늘 이 실습을 통해 인증에 관한 부분을 연습해 보시면 이후에는 오늘 배운 내용을 토대로 애플리케이션을 구체화, 고도화하여 실제 상용 가능한 애플리케이션을 구축하실 수 있을 것입니다.
오늘 실습 내용의 목표는 회원이 로그인 창에서 username/password 기반의 인증 요청을 서버 상으로 전송했을 때 AuthenticationProvider에서부터 시작되는 인증을 구현해보는 것입니다. 그리고 이후 인증을 통해 획득한 회원 정보, 즉 Authentication 객체를 컨트롤러 단에서 사용해 보는 작업까지 진행해 보겠습니다. 데이터베이스에 접근하는 과정에는 JPA를 사용할 예정이며 코드를 간략하고 가독성 있게 표현하기 위해 lombok을 함께 사용하여 실습을 진행해보도록 하겠습니다.
오늘 함께 할 실습의 순서는 다음과 같습니다.
- 데이터베이스와 호환될 수 있는 엔티티인 User와 Authority를 구현합니다.
- UserRepository와 AuthorityRepository를 구현합니다.
- JPA를 사용하여 회원 정보를 조회할 수 있는 JpaUserDetailsService를 구현합니다.
- AuthenticationProvider가 사용할 수 있는 UserDetails 구현체인 CustomUserDetails를 구현합니다.
- 회원의 정보와 사용자의 요청을 비교해 인증을 수행하는 AuthenticationProvider 구현체인 CustomAuthenticationProvider를 구현합니다.
- 스프링이 제공하는 PasswordEncoder인 BCryptPasswordEncoder와 SCryptPasswordEncoder를 스프링 컨테이너의 빈으로 등록합니다.
- 서버를 구동한 후 로그인 작업을 수행합니다.
- MainController로 접근했을 때 로그인 한 회원의 정보인 Authentication 객체가 제대로 로드되는지 확인합니다.
실습 준비
이전 글에서 사용한 프로젝트에 JPA와 lombok 의존성을 주입하거나 https://start.spring.io/ 에서 새로운 프로젝트를 생성합니다. 프로젝트에 필요한 의존성은 다음과 같습니다. https://start.spring.io/ 에서 프로젝트를 생성하는 방법이 익숙하지 않으시다면 본 시리즈의 첫 번째 글에서 방법을 확인해 주시기 바랍니다.
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'
// 추가
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.springframework.security:spring-security-test'
이후 프로젝트 application.properties 파일에 데이터베이스 정보를 기입합니다. 본 프로젝트에서는 JPA를 사용할 예정이기 때문에 jpa의 ddl-auto 정보를 추가적으로 기입해 주도록 하겠습니다.
spring.datasource.driver-class-name=org.mariadb.jdbc.Driver
spring.datasource.url=jdbc:mariadb://127.0.0.1:3306/spring_security
spring.datasource.username=[데이터베이스 권한이름]
spring.datasource.password=[데이터베이스 비밀번호]
spring.jpa.hibernate.ddl-auto=create
spring.jpa.hibernate.ddl-auto는 서버가 구동될 때 jpa가 자동으로 해당 데이터베이스에 ddl문을 날릴지 여부를 설정하는 정보입니다. create, create-drop, update, validate, none이 있으며 create로 설정 시 서버가 구동될 때마다 jpa가 관리하는 모든 테이블을 날린 후(drop) 테이블들을 신규 생성(create)합니다.
4-1. 엔티티 개발
프로젝트에 entity 패키지를 생성 후 User 클래스와 Authority 클래스, 그리고 열거형 클래스인 EncryptAlgorithm 클래스를 생성합니다. 각각의 클래스에 다음과 같이 코드를 작성합니다. 코드는 JPA로 작성되기 때문에 JPA에 관한 관련 지식이 다소 필요하기는 하지만 오늘 예제의 핵심은 시큐리티이지 JPA는 아니기 때문에 아래의 코드를 그대로 긁어 진행하셔도 무방합니다.
User 클래스
import lombok.Getter;
import lombok.Setter;
import javax.persistence.*;
import java.util.ArrayList;
import java.util.List;
@Entity
@Getter @Setter
public class User {
@Id @GeneratedValue(strategy = GenerationType.AUTO)
private Integer id;
private String username;
private String password;
@Enumerated(EnumType.STRING)
private EncryptionAlgorithm algorithm;
@OneToMany(mappedBy = "user", fetch = FetchType.EAGER)
private List<Authority> authorities = new ArrayList<>();
}
데이터베이스에 저장되는 회원 객체입니다. 인증에 필요한 최소 정보이자 필수 정보인 username, password를 가지고 있고 회원이 인증되었을 때 수행할 수 있는 역할을 정의하는 권한 정보 suthorities를 가지고 있습니다. algorithm이라는 이름의 필드는 '이 회원이 어떤 PasswordEncoder에 의해 비밀번호가 암호화 되었는지'를 나타내는 필드로 조금 후에 살펴볼 EncryptionAlgorithm enum 클래스에 의해 정의됩니다.
Authority 클래스
import lombok.Getter;
import lombok.Setter;
import javax.persistence.*;
@Entity
@Getter @Setter
public class Authority {
@Id @GeneratedValue(strategy = GenerationType.AUTO)
private Integer id;
private String name;
@ManyToOne
@JoinColumn(name = "user_id")
private User user;
}
회원의 역할을 정의하는 권한 정보입니다. 필드로 권한의 이름과 해당 권한을 소유한 회원의 정보를 갖습니다. 권한과 관련된 부분은 보안 상 매우 중요한 주제 중 하나이지만 오늘은 인증에 관한 실습을 진행하고 있기 때문에 마찬가지로 최대한 간략화하여 표현 하였습니다. 권한에 관련해서는 추후에 조금 더 자세히 살펴보도록 하겠습니다.
EncryptionAlgorithm enum 클래스
public enum EncryptionAlgorithm {
BCRYPT, SCRYPT
}
회원의 비밀번호가 암호화된 알고리즘을 구분지어줄 수 있는 enum 클래스입니다. 오늘 예제에서 저희는 BCryptPasswordEncoder와 SCryptPasswordEncoder를 사용할 예정이므로 BCRYPT와 SCTYPT라는 이름의 상수를 정의했습니다.
4-2. Repository 개발
4-1에서 개발한 엔티티에 대응하여 데이터베이스와 해당 엔티티를 연동시켜주는 레포지토리를 개발해보도록 하겠습니다. spring data jpa를 사용할 예정이지만 앞서 언급한 부분처럼 오늘의 주제는 JPA가 아니므로 최대한 간단한 예제를 사용할 예정입니다. 마찬가지로 JPA에 대해 잘 모르신다면 아래의 코드를 긁어 붙여넣고 실습을 진행하셔도 무방합니다.
프로젝트에 repository 패키지를 생성한 후, 아래 두 개의 인터페이스를 각각 생성한 후 코드를 작성합니다.
UserRepository 인터페이스
import org.springframework.data.jpa.repository.JpaRepository;
import security.study.entity.User;
import java.util.Optional;
public interface UserRepository extends JpaRepository<User, Integer> {
Optional<User> findUserByUsername(String username);
}
UserRepository는 JpaRepository 인터페이스를 상속받아 spring data jpa의 도움을 받습니다. spring data jpa는 persistence layer, 즉 영속성 계층에 필요한 기본적인, 혹은 자주 사용되는 메소드를 자동 생성해주는 데 도움을 주는 라이브러리입니다. 타겟이 되는 엔티티와 해당 엔티티의 PK에 해당하는 필드의 자료형을 JpaRepository의 제네릭 타입 변수로 입력하면 해당 엔티티를 데이터베이스에 연동할 수 있는 기본적인 메소드가 자동 생성됩니다. UserRepository의 경우 username 필드 이름으로 User 객체를 조회할 수 있는 별도의 메소드를 추가 해 주었습니다.
AuthorityRepository 인터페이스
import org.springframework.data.jpa.repository.JpaRepository;
import security.study.entity.Authority;
public interface AuthorityRepository extends JpaRepository<Authority, Long> {
}
Authority 엔티티에 대응하는 레포지토리입니다. 코드를 살펴보면 인터페이스 내부에 어떠한 코드도 작성되어 있지 않은 것을 알 수 있습니다. 하지만 spring data jpa의 도움을 받아 여러 메소드들을 사용할 수 있습니다.
4-3. JpaUserDetailsService 개발
앞선 글에서 살펴본 것처럼 UserDetailsService는 인터페이스로 주된 역할을 파일 시스템, 데이터베이스 시스템 그리고 메모리 상에 저장되어 있는 인증 정보를 서버 상으로 불러오는 것입니다. JpaUserDetailsService는 앞서 개발한 레포지토리들을 이용해 해당 정보를 서버로 조회하는 클래스입니다. 프로젝트에 security.service 패키지를 생성한 후 아래의 코드를 작성해 주십시오.
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
import security.study.entity.User;
import security.study.repository.UserRepository;
import security.study.security.user.CustomUserDetails;
@Service
@RequiredArgsConstructor
public class JpaUserDetailsService implements UserDetailsService {
private final UserRepository userRepository;
@Override
public CustomUserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
User user = userRepository.findUserByUsername(username)
.orElseThrow(() -> new UsernameNotFoundException("조회 실패"));
return new CustomUserDetails(user);
}
}
코드를 작성하시다보면 CustomUserDetails가 존재하지 않아 컴파일 에러가 날 것입니다. CustomUserDetails는 바로 다음에 작성할 예정입니다.
이곳에서 명심할 부분이 하나 있습니다. 그건 바로 UserDetailsService는 AuthenticationProvider에 의해 호출되고 시스템 상에 인증 정보가 존재한다면 이를 UserDetails에 담아 반환한다는 부분입니다. 저번 글에서 살펴본 내용이지만 중요한 부분이니 한번 더 언급 하였습니다.
4-4. CustomUserDetails 개발
import lombok.Getter;
import lombok.Setter;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import security.study.entity.User;
import java.util.Collection;
import java.util.stream.Collectors;
@Getter @Setter
public class CustomUserDetails implements UserDetails {
private final User user;
public CustomUserDetails(User user) {
this.user = user;
}
public User getUser() {
return user;
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return user.getAuthorities().stream()
.map(a -> new SimpleGrantedAuthority(a.getName()))
.collect(Collectors.toList());
}
@Override
public String getPassword() {
return user.getPassword();
}
@Override
public String getUsername() {
return user.getUsername();
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
}
UserDetails는 스프링 시큐리티가 사용할 수 있는 인증 인터페이스입니다. isAccountNonExpired등의 인증 관련 메소드를 직접 구현할 수 있지만 우리는 현재 실제 애플리케이션을 만드는 것은 아니기에 getUsername과 getPassword를 제외한 모든 메소드를 return true 처리 해 주었습니다.
이 쯤에서 한가지 언급할 부분이 있습니다. 이미 4-1에서 눈치채신 분들이 계실지 모르지만 이번 실습에서 작성된 모든 엔티티와 CustomUserDetails는 setter 메소드를 가지고 있습니다. setter 메소드는 일반적으로 엔티티 레벨에서 사용되지 않는 것이 권장됩니다. 이유는 생성된 객체의 필드값을 쉽게 예측하기 어렵기 때문인데 모든 엔티티의 모든 필드에 setter 메소드를 열어두게 되면 코드의 유지보수적인 면에서 다소간의 난항을 겪을 수 있습니다. 그래서 일반적으로는 생성자, builder, 정적 팩토리 메소드를 이용해 객체가 '생성될 때에만' 필드 값이 정의될 수 있도록 코딩을 하곤 합니다. 그러나 이번 실습은 실제 애플리케이션을 구축하는데 목적이 있지 않고 공부를 하는데 목적이 있기 때문에 편의상 모든 엔티티에 setter 메소드를 연 상태로 진행하게 되었습니다.
프로젝트 내부의 security.user 패키지 내부에 코드를 생성합니다.
4-5. CustomAuthenticationProvider 개발
JpaUserDetailsService로 인해 데이터베이스 상에서 조회된 UserDetails 구현체를 이용해 실제 인증 작업을 수행하는 CustomAuthenticationProvider 객체를 개발 해 보겠습니다. 프로젝트 내부 security.authentication 패키지 내부에 다음과 같은 코드를 작성합니다.
import lombok.RequiredArgsConstructor;
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.crypto.scrypt.SCryptPasswordEncoder;
import org.springframework.stereotype.Component;
import security.study.security.service.JpaUserDetailsService;
import security.study.security.user.CustomUserDetails;
@RequiredArgsConstructor
@Component
public class CustomAuthenticationProvider implements AuthenticationProvider {
private final JpaUserDetailsService jpaUserDetailsService;
private final BCryptPasswordEncoder bCryptPasswordEncoder;
private final SCryptPasswordEncoder sCryptPasswordEncoder;
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
String username = authentication.getName();
String password = authentication.getCredentials().toString();
CustomUserDetails customUserDetails = jpaUserDetailsService.loadUserByUsername(username);
switch (customUserDetails.getUser().getAlgorithm()) {
case BCRYPT:
return checkPassword(customUserDetails, password, bCryptPasswordEncoder);
case SCRYPT:
return checkPassword(customUserDetails, password, sCryptPasswordEncoder);
}
throw new BadCredentialsException("Bad credentials");
}
@Override
public boolean supports(Class<?> authentication) {
return UsernamePasswordAuthenticationToken.class
.isAssignableFrom(authentication);
}
private Authentication checkPassword(CustomUserDetails customUserDetails, String rawPassword, PasswordEncoder passwordEncoder) {
if (passwordEncoder.matches(rawPassword, customUserDetails.getPassword())) {
return new UsernamePasswordAuthenticationToken(
customUserDetails.getUsername(),
customUserDetails.getPassword(),
customUserDetails.getAuthorities());
}
throw new BadCredentialsException("Bad credentials");
}
}
CustomAuthenticationProvider 객체는 회원의 인증 처리를 검증하기 위해 JpaUserDetailsService, BCryptPasswordEncoder 그리고 SCryptPasswordEncoder를 주입 받습니다. JpaUserDetailsService는 저희가 조금 전 만든 객체, 두 개의 PasswordEncoder 구현체는 스프링 시큐리티가 제공하는 객체입니다. CustomAuthenticationProvider의 authenticate 메소드는 JpaUserDetailsService를 통해 조회된 회원 인증 정보를 checkPassword 메소드 내부에서 검증합니다. 이 때 User의 algorithm 필드 정보가 BCRYPT와 일치한다면 BCryptPasswordEncoder를, SCRYPT와 일치한다면 SCryptPasswordEncoder를 인증에 사용합니다.
4-6. PasswordEncoder 구현체 등록
4-5 즉, CustomAuthenticationProvider가 사용할 두 개의 PasswordEncoder를 시스템 상에 등록하는 코드를 작성해보겠습니다. 프로젝트에 configuration 패키지를 생성한 후 아래와 같이 클래스를 생성한 후 코드를 작성합니다.
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.scrypt.SCryptPasswordEncoder;
@Configuration
public class SecurityConfiguration {
@Bean
public BCryptPasswordEncoder bCryptPasswordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public SCryptPasswordEncoder sCryptPasswordEncoder() {
return new SCryptPasswordEncoder();
}
}
@Configuration 애노테이션은 해당 클래스가 설정 정보를 담고 있는 클래스라는 것을 시스템에 알려주고 시스템은 애플리케이션이 구동될 때 내부에 존재하는 설정 정보들을 읽어 필요한 설정 정보를 애플리케이션에 반영합니다.
@Bean 애노테이션은 해당 메소드가 시스템 상, 즉 스프링 컨테이너에 등록되어야 할 빈임을 알려줍니다. return 값으로 특정 객체를 작성하면 해당 객체가 스프링 시큐리티에 빈으로 등록됩니다.
4-7. 서버 구동 & 로그인 수행
자, 모든 인증 관련 구현이 완료되었습니다. 이제 해야 할 것은 서버를 구동한 후 인증이 제대로 이루어지는지 테스트하는 것입니다. 그러나 그 전에, 해야할 일이 하나 남았습니다.
현재 시스템에는 어떠한 회원도 존재하지 않습니다. 회원의 인증을 테스트 해야 하는데 회원이 존재하지 않는다면 테스트는 항상 실패할 것입니다. 프로젝트에 InitUserData라는 클래스를 생성한 후 아래의 코드를 작성해 주십시오.
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import security.study.entity.Authority;
import security.study.entity.EncryptionAlgorithm;
import security.study.entity.User;
import security.study.repository.AuthorityRepository;
import security.study.repository.UserRepository;
import javax.annotation.PostConstruct;
@Component
@RequiredArgsConstructor
public class InitUserData {
private final UserRepository userRepository;
private final AuthorityRepository authorityRepository;
@PostConstruct
public void setInitUserData() {
User user = new User();
user.setUsername("jinseok");
user.setPassword("$2a$10$8SltI0Jht3aZC.LNkfPZ1OeKh.iAAtVmnh.SkJpF1nqaH0RfIBOye"); // 1234
user.setAlgorithm(EncryptionAlgorithm.BCRYPT);
Authority authority = new Authority();
authority.setName("read");
authority.setUser(user);
userRepository.save(user);
authorityRepository.save(authority);
}
}
위의 코드는 애플리케이션이 최초 구동될 때, 스프링에 의해 해당 객체가 생성되면서 실행되는 코드입니다. @PostConstruct 애노테이션이 붙은 setInitUserData 메소드가 실행되는데, 해당 메소드는 회원을 생성해 데이터베이스에 밀어넣는 메소드입니다. 즉, 테스트를 위한 임의의 회원을 생성하는 것이죠.
user.setPassword("$2a$10$8SltI0Jht3aZC.LNkfPZ1OeKh.iAAtVmnh.SkJpF1nqaH0RfIBOye"); // 1234
위 코드에서 비밀번호로 들어가는 알 수 없는 난수는 1234라는 평문을 스프링 시큐리티의 BCryptPasswordEncoder가 암호화한 암호문입니다. 정상적인 회원가입 로직이 존재하지 않기 때문에 암호화된 데이터를 직접 비밀번호에 기입합니다. PasswordEncoder를 연습해보고 싶으시다면 BCryptPasswordEncoder 혹은 SCryptPassowrdEncoder를 주입 받아 encode 메소드를 이용해 어떤 평문이 어떤 암호문으로 바뀌는지 확인해 보셔도 좋을 것 같습니다.
자 이제, 마지막으로 application.properties의 ddl-auto가 create로 되어 있는지 확인한 후 프로젝트를 구동합니다. 성공적으로 프로젝트가 구동된 것이 확인되었을 때 데이터베이스에 들어가 아래와 같이 데이터가 생성되었다면 모두 성공입니다.
자, 이제 localhost:8080으로 들어간 후, 로그인을 수행합니다. 이 때, 비밀번호는 암호화된 암호문이 아닌 1234를 기입해야 합니다. 로그인 버튼을 눌렀을 때 아래와 같이 Whitelabel Error Page가 노출된다면 성공입니다.
4-8. MainController 개발 & Authentication 객체 확인
마지막으로 MainController를 개발해 인증이 성공한 후, 회원의 정보가 요청과 함께 계속해서 살아 있는지 확인해보도록 하겠습니다. 이전 글에서 살펴본 부분을 다시 한번 말씀 드리자면 스프링 시큐리티는 서블릿 필터, 즉 controller 이전에 작동합니다. 인증과 같은 시큐리티 과업이 성공적으로 수행되면 요청은 DispatcherServlet을 거쳐 컨트롤러 계층으로 들어갈 수 있습니다. 만약 컨트롤러에서 필터 상에서 사용된 인증 정보를 사용할 수 있다면 이는 곧 인증 작업 이후, 인증 정보가 시스템에 어떠한 방식으로든 기록된다는 것을 의미할 것입니다. 저희는 앞서 SecurityContext와 Authentication에 대해 공부하였기 때문에 '어떠한 방식'이라는 것이 무엇인지 알고 있지만 말입니다.
프로젝트에 controller 패키지를 생성한 후 다음과 같이 코드를 작성합니다.
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class MainController {
@GetMapping("/main/1")
public String getMain1() {
SecurityContext securityContext = SecurityContextHolder.getContext();
Authentication authentication = securityContext.getAuthentication();
String password = (authentication.getCredentials() == null) ?
"보안을 위한 eraseCredentialsAfterAuthentication 정책에 의해 성공적으로 null 처리 되었습니다." :
authentication.getCredentials().toString() + "입니다.";
return "안녕하세요, " + authentication.getName() + "님!<br>" +
"귀하의 비밀번호는 " + password;
}
@GetMapping("/main/2")
public String getMain2(Authentication authentication) {
String password = (authentication.getCredentials() == null) ?
"보안을 위한 eraseCredentialsAfterAuthentication 정책에 의해 성공적으로 null 처리 되었습니다." :
authentication.getCredentials().toString() + "입니다.";
return "안녕하세요, " + authentication.getName() + "님!<br>" +
"귀하의 비밀번호는 " + password;
}
}
컨트롤러 계층에서 Authentication 객체를 획득하는 방법은 다음과 같습니다.
- SecurityContextHolder 객체에서 SecurityContext를 획득한 후 다시 SecurityContext에서 Authentication 객체를 획득하는 방법
- 컨트롤러 메소드의 매개변수로 Authentication 객체를 직접 받는 방법
두 번째 방법이 훨씬 편하지만 첫 번째 방법도 알아야 할 필요가 있기 때문에 두 개의 코드를 모두 작성해 보았습니다. 자, 이제 다시 프로젝트를 구동한 후, 로그인을 수행합니다. 이후에
- localhost:8080/main/1
- localhost:8080/main/2
로 들어가 아래와 같은 메세지가 출력되는지 확인합니다. 아래처럼 메세지가 출력된다면 성공입니다.
4-9. 과제
성공적으로 실습을 마치셨다면, 조금 더 심화된 학습을 위해 다음 두 가지의 미션을 진행해 주십시오.
- BCryptPasswordEncoder가 아닌 SCryptPasswordEncoder를 이용해 로그인을 수행해 보십시오. PasswordEncoder의 동작 원리를 익히는데 많은 도움이 될 것입니다.
- localhost:8080/main/1 혹은 localhost:8080/main/2 에서 얻은 메세지를 보면 비밀번호가 null 처리 되었다고 나와 있습니다. 왜 이런 현상이 일어났는지, 비밀번호를 살릴 수 있는 방법은 있는지 조사해 주십시오. AuthenticationProvider 뒤편에 있는 AuthenticationManager에 대해 살펴볼 수 있는 기회가 될 것입니다.
다음 글
'Spring Framework > 스프링 시큐리티' 카테고리의 다른 글
[스프링 시큐리티 무작정 따라하기] 5. 필터(Filter) 이해하기 (0) | 2022.09.22 |
---|---|
[스프링 시큐리티 무작정 따라하기] 4. 권한 설정하고 인가(authorization) 처리 하기 (2) | 2022.09.11 |
[스프링 시큐리티 무작정 따라하기] 2. 회원 관리하기 (2) | 2022.07.31 |
[스프링 시큐리티 무작정 따라하기] 1. Hello Spring Security (0) | 2022.07.12 |
[스프링 시큐리티 - 인증] 공식문서 번역하며 공부하기 - 서블릿 인증 아키텍처 (0) | 2022.01.11 |