스프링 시큐리티 이용하여 패스워드 암호화 적용하기

이 포스팅은 2년전 처음에 작성했던 글을 수정하는 글이다.

스프링 시큐리티를 공부하고 적용한게 아니라 프로젝트중에 빠르게 개발에 적용해본 경험을 기반으로 간단한 튜토리얼을 위한 글임을 서두에 밝힌다.

ToC


왜 암호화가 필요한가

패스워드를 평문(plain text)로 저장했다가 서버가 해커에게 털려서 비밀번호가 유출된다면 어떤 일이 벌어질까?

사실 비밀번호를 노출하는것 자체는 큰 문제는 아니라고 생각한다. 서버에 크리티컬한 개인정보가 없다면 비밀번호가 유출된다한들 문제될건 없을수도 있을것 같다.

그러나 하나의 웹 사이트만 가입된 사람은 없다. 대부분 여러 웹 사이트를 이용하는데 이 때 동일한 패스워드를 사용하다가 어느 한 웹 사이트에서 비밀번호가 유출되면 이 사람이 가입된 모든 웹 사이트가 해킹된 일이 벌어질수 있다.

따라서 암호화를 해두면 설사 서버가 털려서 패스워드가 유출된다 하더라도 해당 사용자의 이메일로 가입된 다른 웹사이트에 접근하지 못할 것이다.

이런 가정이 없더라도 서버측에서 굳이 사용자의 비밀번호를 저장하거나 알아둬야할 일은 사실 없다. 그래서 인증 과정에서 암호화 알고리즘을 이용하면 서버 입장에선 어느정도의 책임을 덜 수 있지 않나 싶다.

이번 포스팅에선 스프링 시큐리티의 PasswordEncoder를 이용하여 패스워드를 암호화하는 방법을 알아볼것인데, 예를 들면 'password' 라는 평문의 비밀번호를 $2a$10$kZ.aZODm7JAR7AHkuGlIr.6/6cAzZAN//kVrOy1aTsdkkP4kehoA.와 같은 암호로 바꿔서 서버에 저장하는 작업이다.


Spring Security 의존성 주입

이제부터 개발을 해보자.

우선 비밀번호 암호화에 사용되는 PasswordEncoder를 사용하기 위해서는 Spring Security 의존성을 주입해줘야 한다.

Spring-Boot-Starter-Security 의존성을 주입하면, Spring-Security-Web, Spring-Security-Config 의존성까지 가져올수 있으므로, 의존성으로 Spring-Boot-Starter-Security를 주입하겠다.

maven

1
2
3
4
5
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
<version>2.4.5</version>
</dependency>

Gradle

1
implementation group: 'org.springframework.boot', name: 'spring-boot-starter-security', version: '2.4.5'

이 포스팅을 작성하는 시점에서 Spring-Boot-Starter-Security의 최신버전은 2.4.5이다. 최신 버전을 사용하고 싶다면, 버전을 명시하지 않고 주입하면 IDE에서 자동으로 최신 버전을 가져올 이다.

2021.06.24
세진님께서 피드백주셔서 내용을 수정!! 감사합니다 세진님 😍

parent pom.xml에 명시한 버전을 가져오는 것이지, IDE와는 아무런 관련이 없어요.

빌드툴에 대한 이해도가 낮다보니 저렇게 작성했던것 같다. 지금 다시 생각하니 IDE가 버전을 다시 찾아준다는 황당한 생각을 어떻게 한건지.. 부끄럽다..

세진님의 피드백을 인용하기 앞서 메이븐 pom.xml 파일의 가장 앞에있는 부분을 예제로 옮겨와봤다.

1
2
3
4
5
6
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.0.5.RELEASE</version>
<relativePath/>
</parent>

위의 spring-boot-starter-parent의 부모인 spring-boot-dependencies에 dependencies가 명시되어 있다고 한다.

이에 대해서는 아래 링크가 좀 더 자세한 설명이 될 것 같다.


Config 설정

PasswordEncoder는 스프링 시큐리티의 인터페이스 객체이다.

PasswordEncoder가 하는 역할은 이름에서 알수있듯 비밀번호를 암호화하는 역할이다. 구현체들이 하는 역할은 바로 이 암호화를 어떻게 할지, 암호화 알고리즘에 해당한다.

그래서 PasswordEncoder의 구현체를 대입해주고 이를 스프링 빈으로 등록하는 과정이 필요하다.

이와 함께 스프링 시큐리티 의존성을 주입하고, 바로 톰캣 서버로 실행하면 브라우저에서 로그인 프롬프트가 출력된다. 이런 기본적인 설정들을 disable하는 Config 객체를 생성해야 한다.

Config 객체는 WebSecurityConfigurerAdapter를 상속받아서 configure()를 구현한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;

@Configuration
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
@Bean
public PasswordEncoder getPasswordEncoder() {
return new BCryptPasswordEncoder();
}

@Override
protected void configure(HttpSecurity http) throws Exception {
http.cors().disable()
.csrf().disable()
.formLogin().disable()
.headers().frameOptions().disable();
}
}

패스워드 암호화 방식으로 BCryptPasswordEncoder를 적용했다. BcryptPasswordEncoderBCrypt라는 해시 함수를 이용하여 패스워드를 암호화하는 구현체이다.

Config 객체 내부에서 PasswordEncoder의 구현체로 BCryptPasswordEncoder를 지정해주었으니 이를 스프링 프레임워크에서 사용하도록 스프링 빈(Bean)으로 등록해주어야 한다.

아직 깊게 공부하지 못해서 추론만 할뿐이지만, 어노테이션으로 @Configuration을 클래스 이름 위에 선언하면 스프링 프레임워크가 빈을 주입하는 클래스로 인식하는것 같다.

어떤걸 빈으로 주입하는지는 메소드 위에 @Bean을 명시해주면 스프링 프레임워크에서 해당 객체를 Bean으로 주입해준다.

@Autowired를 이용해서 쉽게 스프링 프레임워크에서 DI(Dependency Injection)를 사용할 수 있다는 이야기이다. 여기까지 하면 이제 외부에서 PasswordEncoder@AutoWired 어노테이션으로 쉽게 DI 할 수 있게된다.


테스트 코드 작성

의존성도 주입했고, Config도 설정했으니 이제 정말 비밀번호가 암호화되는지 테스트해볼 예정이다.

JUnit5을 사용하여 테스트를 진행했다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.security.crypto.password.PasswordEncoder;

import static org.junit.jupiter.api.Assertions.*;

@SpringBootTest
public class PasswordEncoderTest {

@Autowired
private UserService userService;

@Autowired
private PasswordEncoder passwordEncoder;

@Test
@DisplayName("패스워드 암호화 테스트")
void passwordEncode() {
// given
String rawPassword = "12345678";

// when
String encodedPassword = passwordEncoder.encode(rawPassword);

// then
assertAll(
() -> assertNotEquals(rawPassword, encodedPassword),
() -> assertTrue(passwordEncoder.matches(rawPassword, encodedPassword))
);
}
}

평소에 테스트 코드를 작성할때, 마틴 파울러의 given-when-then 으로 테스트를 작성하는데, 이렇게 하면 무엇을 테스트할지를 좀 더 명확하게 표현하는것 같아 항상 이렇게 작성하고 있다.

암호화는 BCryptPasswordEncoder로 구현된 encoder()를 이용한다. 파라미터에 평문 패스워드를 주입하면, 암호화된 패스워드를 반환해준다.

그리고 평문 패스워드와 암호화된 패스워드가 서로 같다라는 사실이 증명되어야 로그인을 구현할 수 있을 것이다. 이 때 이 두 문자열을 비교해주는 메소드가 matches()이다.

matches()는 내부에서 평문 패스워드와 암호화된 패스워드가 서로 대칭되는지에 대한 알고리즘을 구현하고 있기 때문에 가능하다.

실제 테스트는 // then 아래의 코드인데 두가지를 테스트하기 위해서 assertAll을 사용했다.

  • 평문 패스워드와 암호화 패스워드가 서로 다른게 맞는지 -> assertNotEquals()
  • BCryptPasswordEncodermatches()를 이용해서 평문 패스워드와 암호화 패스워드를 비교했을때, 같은 패스워드라는 결과를 반환받는지 -> assertTrue()

테스트에 통과했으니 이제 실제 회원가입과 로그인에 구현해도 될 것 같다.

  • String encode(String raw) : 패스워드 암호화

  • boolean matches(String raw, String encoded) : 평문 패스워드와 암호화 패스워드가 같은 패스워드인지 비교


회원가입/로그인 구현

UserService 인터페이스 구현체인 UserServiceImpl 클래스에서 작업을 했다.

패스워드 암호화 로직이 필요한 기능은 두가지이다. 회원가입과 로그인.

먼저 회원가입이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;

@Service
public class UserServiceImpl implements UserService {

@Autowired
private UserRepository userRepository;

@Autowired
private PasswordEncoder passwordEncoder;

@Override
public void createUser(User user) {
String encodedPassword = passwordEncoder.encode(user.getPassword());
user.setPassword(encodedPassword);
userRepository.save(user);
}
}

사용자가 입력폼에 입력한 데이터가 도메인 객체(User)로 컨트롤러를 거쳐서 서비스까지 올 것이다.

그럼 서비스에서 이 패스워드를 암호화 패스워드로 바꿔서 DAO 또는 Repository에 넘기면 된다.

본 포스팅은 JPA를 사용하고 있는 개발환경이기 때문에 JpaRepository<>를 상속받는 인터페이스 UserRepository를 통해서 DB(h2)에 저장했다.

이제 비밀번호를 비교하는 로직을 갖는 로그인 구현이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Override
public boolean validationLogin(String email, String password) {
User loginUser = userRepository.findByEmail(email);

if(loginUser==null) {
System.out.println("해당 이메일의 유저가 존재하지 않습니다.");
return false;
}

if(!passwordEncoder.matches(password, loginUser.getPassword())) {
System.out.println("비밀번호가 일치하지 않습니다.");
return false;
}

return true;
}

이메일을 체크하고, 비밀번호를 체크하는 메소드이다. 정리하면서 보니 이메일을 체크하는 것과 비밀번호를 체크하는 것을 다른 책임으로 본다면, 이를 2개의 메소드로 분리하는게 나은것 같다는 생각이든다.. 하지만 이 포스팅은 패스워드 암호화가 주제므로 일단 패스하겠다..!

이렇게 하면 성공적으로 구현이 끝난다.

클라이언트에서 회원가입을 할 때 비밀번호를 12345678로 가입을 했다.

h2 콘솔에서 확인해보니 정상적으로 저장된걸 알 수 있다.

위의 과정은 아래의 테스트로 대체할 수 있다. 실제 서비스 로직을 태워서 가입을 하고, DB에서 해당 이메일의 사용자 도메인 객체를 가져와서 비교를 하는 테스트 코드이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@Test
@DisplayName("패스워드 암호화 테스트")
void passwordEncode() {
// given
String email = "test@gmail.com";
String rawPassword = "12345678";

User user = new User();
user.setEmail(email);
user.setNickname("test");
user.setPassword(rawPassword);

// when
userService.createUser(user);
String encodedPassword = userRepository.findByEmail(email).getPassword();

// then
assertAll(
() -> assertNotEquals(rawPassword, encodedPassword),
() -> assertTrue(passwordEncoder.matches(rawPassword, encodedPassword))
);
}