[Spring] Spring Security의 passwordEncoder와 MySQL db에서의 passwordEncoder 예제

민지·2024년 2월 22일
0
post-custom-banner

이건 Spring todo 프로젝트 코드에 없는 내용이다. 해당 프로젝트의 코드 작성 전에 배경지식을 먼저 쌓아보자.

1. 배경지식

1-1. encoding 과정

1) passwordEncoder 사용 방법

  • PasswordEncoder Bean 등록
@Configuration
public class CommonConfig {
    @Bean
    public PasswordEncoder passwordEncoder() {
        return PasswordEncoderFactories.createDelegatingPasswordEncoder();
    }
}
  • 비밀번호 encoding 하는 법 (passwordEncoder.encode)
import org.springframework.boot.test.context.SpringBootTest;
import org.junit.jupiter.api.Test;
import org.springframework.security.crypto.password.PasswordEncoder;

@SpringBootTest
class PasswordEncoderTest {

    @Autowired
    PasswordEncoder passwordEncoder; // DI
    
    @Test
    void pwdEncode() {
        String pwd = "minjiki123";
        String encodedPwd = passwordEncoder.encode(pwd); //암호화 하는부분
        System.out.println(encodedPwd);
    }
}

결과

{bcrypt}2a$10xO99cg0RupsQY4PNvdPJe.neRL7JSplM8t/NQUgBRGnOM19/FbstS

PasswordEncoder클래스로부터 의존성을 주입받아서

passwordEncoder.encode(pwd)를 해주면 알아서 암호화된다.
이렇게 암호화된 비밀번호를, 사용자 정보를 저장할 때 저장해주면 된다.

이때, 위 코드를 여러 번 할 때마다 결과값이 매번 달라진다는 사실을 알 수 있다.
그렇다면, 위와 같은 코드로 어떻게 사용자의 비밀번호가 맞는지 아닌지 인증할 수 있을까?

2) 사용자의 기존 비밀번호와 비교해 맞는지 확인하는 방법 (passwordEncoder.matches)

@SpringBootTest
class PwdEncodeTest {

    @Autowired
    PasswordEncoder passwordEncoder; // DI
    
    @Test
    void pwdEnc() {
        String pwd = "kedric123";
        String encodedPwd = passwordEncoder.encode(pwd); //암호화 하는부분
        System.out.println(encodedPwd);
    }
    
    @Test
    void pwdMatch(){
    	// 기존 저장해두었던 암호화된 비밀번호
    	String encodedPwd = "{bcrypt}$2a$10$xO99cg0RupsQY4PNvdPJe.neRL7JSplM8t/NQUgBRGnOM19/FbstS"; 
        // 검증할 비밀번호
        String newPwd = "kedric123"; 
        
    	if(passwordEncoder.matches(newPwd, encodedPwd){
        	System.out.println("true");
        }else{
        	System.out.println("false");
        }
    }
    
}

여기서 passwordEncoder.matches()함수는 첫번째 인자로 받은 평문 비밀번호가, 두 번째 인자로 받은 암호화된 비밀번호와 일치하는지를 검증한다.

사용자가 회원가입 후의 인증은 아래와 같은 과정을 거친다.

1. 비밀번호 저장

  • 사용자가 처음 회원가입을 할 때, 입력한 평문 비밀번호(pwd)를 passwordEncoder.encode(pwd) 함수 등을 사용해 암호화한다. 이 암호화된 비밀번호는 데이터베이스에 저장된다.
  • 이 과정에서 사용되는 암호화방식은 일방향 해시함수를 사용하는 것이 일반적이다. 일방향 해시함수는 원본데이터를 복원할 수 없는 암호화된 문자열을 생성한다. (뒤에서 한번 더 다룰 것)
  1. 로그인 인증
  • 사용자가 로그인을 시도할 때, 입력한 평문 비밀번호를 같은 암호화 방식(passwordEncoder.encode)으로 다시 암호화한다.
  • 그러나 해시 함수의 특성상 동일한 입력값에 대해 항상 동일한 출력값을 반환하기에, 단순히 이 방식으로는 보안측면에서 위험할 수 있다.
  • 이에 실제로는, passwordEncoder의 비교 메서드를 사용해 평문 비밀번호가 이전에 저장된 해시와 일치하는지 확인한다. 예를 들어 Spring Security에서는 passwordEncoder.matches(rawPassword, encodedPassword)함수를 사용해, 평문 비밀번호와 저장된 암호화된 비밀번호를 비교한다.

즉, 요약하자면 사용자가 로그인할 때 입력한 평문 비밀번호는 직접 데이터베이스에 저장된 암호화된 비밀번호와 비교되지 않는다. 대신, 입력한 평문 비밀번호를 암호화하고, 그 암호화된 버전을 데이터베이스에 저장된 값과 비교하는 과정을 통해 인증이 이뤄진다.
이런 방식은 데이터베이스가 설령 해킹당하더라도, 비밀번호가 직접 노출되지 않게 해준다.

1-2. PasswordEncoder 방식이 더 안전한 이유

(앞서 언급했었지만) 그럼 이제,

왜 이런 암호화를 쓰고, 어떤 식으로 암호화되는지를 다시 알아보자

우선 암호화는 단방향 암호화양방향 암호화가 있다.
단방향(MD5, SHA 등)은 암호화가 가능해도 복호화는 안된다. 왜냐하면, 단방향 암호화는 해시함수를 사용해 데이터를 암호화하는데, 이 과정은 복호화할 수 없어 원본 데이터를 다시 얻을 수 없다.
반면, 양방향(AES, RSA< DES 등)은 암호화와 복호화가 모두 가능하다. 원본 데이터를 다시 복구가능하다.

보안측면에서 단방향 암호화는 복호화가 불가능하기에 평문 비밀번호를 알아내는 것이 어려워보인다. 하지만, 암호를 알아내려는 공격자가 rainbow table을 이용해 rainbow attack을 하는 경우, 결국 비밀번호를 알아낼 수 있다.
왜냐면 단방향 암호화는 평문 비밀번호가 암호화될 때마다 동일한 암호화된 비밀번호를 생성하기 때문이다! 이때 단방향 암호화는 동일한 입력값에 대해 항상 동일한 출력값을 반환하는 해시함수**를 쓰기 때문에, 위와 같은 특징을 가진다.
쉽게 말하자면, 단방향 암호화는 minjiki1234를 암호화한 걸 asdf1234라고 한다면, 공격자는 평문 비밀번호인 minjiki1234는 모르지만 항상 동일한 암호화 버전을 생성하기에 asdf1234를 알 수 있다는 것이다.

이에, Spring Security에서는 '단방향' 암호화 방식에 '새로운' 방식을 추가한다.

기존 단방향 함수(해시 함수)는 동일한 입력에 대해 항상 동일한 출력을 생성하기에, 해시 충돌 공격에 보안상 취약할 수 있다. 이에, Spring Security의 PasswordEncoder 인터페이스에는 해시함수와 솔트(salt; 임의의 데이터)를 결합해 사용함으로써, 동일한 입력값(비밀번호)에 대해서도 매번 다른 암호화된 암호화된 값(해시)를 생성해낼 수 있다.

public interface PasswordEncoder {

	/**
	 * Encode the raw password. Generally, a good encoding algorithm applies a SHA-1 or
	 * greater hash combined with an 8-byte or greater randomly generated salt.
	 */
	String encode(CharSequence rawPassword);

	/**
	 * Verify the encoded password obtained from storage matches the submitted raw
	 * password after it too is encoded. Returns true if the passwords match, false if
	 * they do not. The stored password itself is never decoded.
	 * @param rawPassword the raw password to encode and match
	 * @param encodedPassword the encoded password from storage to compare with
	 * @return true if the raw password, after encoding, matches the encoded password from
	 * storage
	 */
	boolean matches(CharSequence rawPassword, String encodedPassword);

	/**
	 * Returns true if the encoded password should be encoded again for better security,
	 * else false. The default implementation always returns false.
	 * @param encodedPassword the encoded password to check
	 * @return true if the encoded password should be encoded again for better security,
	 * else false.
	 */
	default boolean upgradeEncoding(String encodedPassword) {
		return false;
	}

}

Spring Security에서 제공해주는 PasswordEncoder의 encode함수 부분을 보면, 단순히 해시 함수만이 아니라, salt(바이트 단위의 임의의 문자열)을 추가하기 때문에 입력값이 다르더라도 매번 다른 encode 값이 나오는 것을 알 수 있었다.
또한 matches메서드는 제출된 평문 비밀번호와 저장된 해시값을 비교할 때, 해당 해시가 생성될 당시의 솔트를 사용해 평문 비밀번호를 해시하고, 이를 저장된 해시값과 비교해 일치여부를 확인한다!

2. MySQL - PasswordEncoder Bean 설정 예제 코드

MySQL에서는 Spring DATA Jpa를 통해 UserRepository에 사용자 정보를 저장한다. 해당 내용은 Spring DATA JPA 포스팅 정리 후, 다시 읽고 와도 좋겠다.

UserRepository, UserService, User 예시

UserRepository사용자 정보를 데이터베이스에 저장하는 데 사용됩니다. Spring에서는 주로 Spring Data JPA를 사용하여 이러한 작업을 간단하게 처리할 수 있습니다. UserRepository는 인터페이스로 정의되며, Spring Data JPA의 JpaRepository 인터페이스를 확장하여 구현됩니다. 이를 통해 CRUD(Create, Read, Update, Delete) 작업을 손쉽게 수행할 수 있습니다.

UserRepository 예시

@Repository
public interface UserRepository extends JpaRepository<User, Long> {
    // 여기에 필요한 쿼리 메소드를 추가할 수 있습니다.
    User findByUsername(String username);
}

사용자 모델
데이터베이스에 저장될 사용자 정보를 나타내는 User 클래스는 다음과 같이 정의할 수 있습니다.

@Entity
public class User {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String username;
    private String password;

    // 기본 생성자, getters, setters 생략
    public User() {}

    public User(String username, String password) {
        this.username = username;
        this.password = password;
    }

    // getters and setters
}

PasswordEncoder Bean 설정
@Configuration
public class WebSecurityConfig {

@Bean
public BCryptPasswordEncoder passwordEncoder() {
    return new BCryptPasswordEncoder();
}

}
사용자 저장 로직
UserService에서 UserRepository를 사용하여 사용자를 데이터베이스에 저장하는 예는 다음과 같습니다.

@Autowired
private UserRepository userRepository;

@Autowired
private BCryptPasswordEncoder passwordEncoder;

public void createUser(String username, String rawPassword) {
    String encodedPassword = passwordEncoder.encode(rawPassword);
    User newUser = new User(username, encodedPassword);
    userRepository.save(newUser); // 사용자 정보를 데이터베이스에 저장
}

이와 같이 UserRepository를 사용하면, User 엔티티의 인스턴스를 생성하고, 비밀번호를 BCryptPasswordEncoder로 인코딩한 후, 이 인스턴스를 userRepository.save(newUser)를 호출하여 데이터베이스에 저장합니다. Spring Data JPA를 사용하면 이러한 과정이 매우 간단해지며, 복잡한 SQL 쿼리 없이도 데이터베이스 작업을 수행할 수 있습니다.

요약하자면,

  • User 엔티티 : 데이터베이스에 저장될 사용자 정보를 나타내며, @Entity 애노테이션을 사용하여 JPA 엔티티로 정의.

  • UserRepository 인터페이스 : 사용자 정보를 데이터베이스에 저장하기 위해 사용하며, Spring Data JPA의 JpaRepository 인터페이스를 확장한다. 이를 통해 CRUD 작업을 쉽게 수행할 수 있다. 추가로 필요한 쿼리 메서드(예 : findByUsername)를 정의할 수 있다.

  • PasswordEncoder Bean 설정: Spring Security의 BCryptPasswordEncoder를 사용하여 비밀번호 인코딩을 위한 빈을 설정

  • UserService : UserRepository를 사용해 사용자 엔디티 인스턴스를 생성하고, BCryptPasswordEncoder를 사용해 비밀번호를 인코딩한 후 데이터베이스에 저장한다. 이 과정은 createUser 메서드를 통해 수행된다.

createUser - 실제 유저 만들기 (개발단계 테스트용, 운영단계용)

CommandLineRunner - 초기 개발단계나 테스트 단계로 사용자 만들기

@Component
public class UserDataLoader implements CommandLineRunner {

  private final UserService userService;

  public UserDataLoader(UserService userService) {
      this.userService = userService;
  }

  @Override
  public void run(String... args) throws Exception {
      userService.createUser("minjiki2", "random");
  }
}

하지만, 실제 운영 환경에서 사용자를 생성하기 위해 매번 CommandLineRunner를 사용하는 것은 적절치 않다.

운영 환경에서는 사용자가 직접 회원가입을 하거나, 관리자가 사용자 관리 인터페이스를 통해 사용자를 추가하는 등의 방법을 사용한다. createUser()메서드는 이러한 사용자 생성 요청이 있을 때 호출되어야 한다!
예를 들어, 웹 어플리케이션에서는 사용자가 회원가입 폼을 제출할 때 createUser() 메서드를 호출해 새 사용자를 데이터베이스에 저장할 수 있다!

예제코드와 자동 바인딩 작동 원리

자동 바인딩 작동 원리
Spring MVC에서는 HTTP request parameter를 컨트롤러 메서드의 파라미터로 자동으로 바인딩할 수 있다. 이 기능은 앞서 배운 스프링의 @RequestParam을 통해 이뤄진다. 이때! Spring MVC에서는 메서드 파라미터의 이름이 HTTP 요청 파라미터의 이름과 일치할 경우, @RequestParam 어노테이션 생략 가능

예제코드
1. 회원가입 폼 제공
register 템플릿(HTML)에서는 사용자로부터 사용자 이름과 비밀번호를 입력받습니다. 이 폼은 /register 경로로 POST 요청을 보냅니다.

<!DOCTYPE html>
<html>
<head>
  <title>회원가입</title>
</head>
<body>
  <h2>회원가입</h2>
  <form action="/register" method="post">
      <div>
          <label for="username">사용자 이름:</label>
          <input type="text" id="username" name="username">
      </div>
      <div>
          <label for="password">비밀번호:</label>
          <input type="password" id="password" name="password">
      </div>
      <div>
          <button type="submit">회원가입</button>
      </div>
  </form>
</body>
</html>
  1. 회원가입 컨트롤러 생성
    먼저, 사용자가 웹 인터페이스를 통해 회원가입을 할 수 있도록 RegistrationController를 생성합니다. 이 컨트롤러는 회원가입 폼을 제공하고, 폼 제출을 처리하는 메서드를 포함합니다.
@Controller
public class RegistrationController {

    @Autowired
    private UserService userService;

    @GetMapping("/register")
    public ModelAndView registerForm() {
        return new ModelAndView("register");
    }

    @PostMapping("/register")
    public String registerUser(String username, String password) {
        userService.createUser(username, password);
        return "redirect:/login";
    }
}
**사용자가 회원가입 컨트롤러와 뷰를 통해 사용자 id와 password 입력받아 회원가입을 하면, createUser를 통해 사용자 id와 password가 암호화되어 mysql에 저장된다. 그러고 나서 다시 해당 사용자가 로그인하면, Spring Security 프레임워크가 자동으로 matches 함수를 통해 원본 pwd와 암호화된 pwd를 비교해서 인증에 성공하면 웹사이트 사용 가능해지거다!!!!!!!!**



참고 및 출처
이 시리즈는 Udemy 강의의 내용을 정리한 것입니다.
https://www.udemy.com/course/spring-boot-and-spring-framework-korean/
https://kedric-me.tistory.com/entry/Spring-Password-Encoder-%EB%B9%84%EB%B0%80%EB%B2%88%ED%98%B8-%EC%95%94%ED%98%B8%ED%99%94

profile
배운 내용을 바로바로 기록하자!
post-custom-banner

0개의 댓글