Spring

Spring Security를 이용한 암호화

날씨는 우중충 2021. 7. 18. 00:32

글의 목적

Spring Security를 다루면서 양이 너무 방대해 제대로 이해하고 사용하기 위해서, 하나하나씩 뜯어가며 이해하고, 배운 내용을 공유하기 위해 작성된 글입니다. 이번 글은 Spring Secyrity의 암호화에 대에서 알아보도록 하겠습니다.

 

아직 학생이라 설명이 틀릴 수도 있고 설명이 부족할 수도 있으니 해당 부분은 댓글로 작성 부탁드립니다.


Spring Security

Spring Security는 인증과 권한 그리고 일반적인 공격으로부터의 보호를 제공하는 프레임워크입니다. Spring Security는 Spring 기반의 Application의 보안을 위한 실질적인 표준으로 사용됩니다.

 

Spring Security 사용

Spring Boot에서 gradle파일에 Spring Security 의존성을 추가해 Spring Security를 사용할 수 있습니다.

dependencies {
    compile "org.springframework.boot:spring-boot-starter-security"
}

Authentication(인증)

Spring Security는 인증에 대한 다양한 기능을 제공합니다. 인증은 리소스에 접근하려는 사용자를 확인하는 방법입니다.

일반적으로 사용자의 ID와 암호를 입력받아 사용자를 인증할 수 있습니다.

(인증과 관련된 자세한 과정은 추후 학습을 통해 추가로 포스팅하도록 하겠습니다.)

 

Password Store History

암호를 왜 안전하게 저장해야 할까요? 과거에는 암호를 일반 텍스트로 저장을 했었습니다. 데이터가 저장된 곳에 접근하기 위해서는 자격을 증명해야 했기 때문에 안전하다 믿었기 때문이겠죠.

 

하지만 SQL injection 같은 공격을 이용해 많은 사용자 정보가 탈취당했습니다.

 

이후에는 암호를 SHA-256 같은 단방향 해시를 통해서 변환 후 저장하도록 권장되었습니다. 그러면 데이터가 탈취당해도 사용자의 암호가 노출되는 것이 아니라 암호에 해시 알고리즘을 이용해 변환된 값만 탈취당하기 때문에 안전하기 때문입니다.

 

이 또한 많은 사용자들이 사용되는 암호에 해시 알고리즘을 적용시킨 값과 매핑되도록 look-up테이블을 만들어 기존에 암호 크래킹에 들어가는 시간을 줄였습니다.

 

그래서 이를 막기 위해 암호만으로 해시를 수행하지 않고 각 암호에 대해 임의의 바이트를 생성해서 이를 이용해서 사용자의 암호와 임의의 바이트를 같이 해시 함수를 적용해 이 결과 값을 저장했습니다.(여기서 임의의 바이트를 salt라고 합니다)

 

하드웨어의 발전에 따라 개인 PC에서도 이제 초당 수많은 연산이 가능해졌기 때문에, 이젠 이 방법도 더 이상 안전하지 않다고 생각해 leverage adaptive 단방향 함수를 활용하도록 권장합니다. 이 방법은 암호 검증에 고의로 리소스를 많이 사용하도록 합니다.  adaptive 단방향 함수는 work factor를 조정해 암호 검증에 걸리는 시간을 조절할 수 있습니다.

 

Spring Security에서는 work factor의 기준을 정하려 했지만 시스템마다 성능이 크게 달라지기 때문에 사용자 스스로 work factor를 맞춤 제작할 것을 권장합니다.

 

adaptive 단방향 함수의 예로는 BCrypt, PBKDF2, scrypt, argon2 등이 있습니다.


PasswordEncoders

Spring Security는 PasswordEncoder라는 인터페이스를 이용해 암호가 안전하게 저장될 수 있도록 암호의 단방향 변환을 지원합니다. PasswordEncoder라는 인터페이스의 구현체로 BCryptPasswordEncoder, Argon2PasswordEncoder, Pbkdf2PasswordEncoder, SCryptPasswordEncoder 등 많은 PasswordEncoder가 있습니다. 이 외에도 기타 PasswordEncoder들이 있지만 이번 글에서는 BCryptPasswordEncoder 위주로 알아보도록 하겠습니다.

 

BCryptPasswordEncoder

Bcrypt는 1999년 USENIX에서 발표된 블로피시 암호에 기반을 둔 암호 해시 함수입니다. 위에서 언급했듯이 look-up(rainbow) table 공격을 방지하기 위해 salt를 이용하는 adaptive 단방향 함수의 하나입니다.

 

BCrypt 예제

BCryptPasswordEncoder encoder = new BCryptPasswordEncoder(16);
String result = encoder.encode("RAW-PASSWORD");
assertTrue(encoder.matches("RAW-PASSWORD", result));

 

BCryptPasswordEncoder의 Salt

BCrypt는 내부적으로 salt를 생성할 때 3가지 요소를 사용

  • BCrypt Version
  • Log Round
  • SecureRandom

 

BCrypt Version 

BCrypt의 버전으로, [ $2A | $2B | $2Y ] 3가지 버전이 존재(BCryptPasswordEncoder에 한해서. 추가로 버전에 관련된 내용은 해당 링크에 자세히 나와 있습니다.

링크 : https://stackoverflow.com/questions/15733196/where-2x-prefix-are-used-in-bcrypt

 

Where 2x prefix are used in BCrypt?

The question is the same title, Where $2x$ is used in BCrypt? The following scenario is right? We have a set of passwords that hashed with $2a$ prefix already, when the Server PHP version was ear...

stackoverflow.com

 

 

Log Round

다른 해시 함수에서는 strength라고도 부르는 값으로 계산에 걸리는 시간을 조절할 때 사용할 수 있습니다. 위에서 work factor를 조절해서 계산에 걸리는 시간을 1초 정도로 정하는 것을 좋다라고 말씀 드렸는데 이때 사용하면 될것 같습니다.

 

추가로 다른 PasswordEncoder에서는 CpuCost, memoryCost, 반복 횟수, Key 길이, 병렬화 등 다양한 값들을 조절 할 수 있습니다.

 

SecureRandom

일반적인 Random 함수와 다르게 예측할 수 없는 seed를 사용하여 결과값을 생성해내는 클래스입니다.

관련 문서 : http://cris.joongbu.ac.kr/course/java/api/java/security/SecureRandom.html

 

SecureRandom (Java 2 Platform SE 5.0)

이 생성자를 사용하면, 호출측은 구현된 SecureRandom를 가지고 있는 인스톨 끝난 그 중에서와 도 우선도의 높은 프로바이더로부터, SecureRandom 객체와 그 구현 코드를 취득할 수 있습니다. 이 생성자

cris.joongbu.ac.kr

 

BCrypt의 Hash Value의 구조

$2a$10$w47WGAfxIYm7RyIJcjmvduXnCPmvMoeoFdemGLVDdDy8VSujwQYI.

 

BCrypt의 결과 값을 보면 버전과 Loground 값, Salt값 Hash 값을 확인할 수 있습니다.

  • $2a : BCrypt의 버전을 의미
  • 10 : Log round 값
  • w47WGAfxIYm7RyIJcjmvdu : salt 값
  • XnCPmvMoeoFdemGLVDdDy8VSujwQYI. : hash 값

 

Log round 조절로 계산에 걸리는 시간 확인

// BCryptPasswordEncoder의 log round를 설정하여 인코딩에 걸리는 시간을 1초에 가깝도록 조절
long start = System.currentTimeMillis();
BCryptPasswordEncoder encoder = new BCryptPasswordEncoder(13);
String result = encoder.encode("RAW-PASSWORD");
System.out.println((System.currentTimeMillis() - start)/(double)1000+"s");
assertTrue(encoder.matches("RAW-PASSWORD", result));

0.688s // Lon Round : 13
1.213s // Log Round : 14

제 PC에서는 13으로 할 경우 0.7초 정도의 시간이 걸리고, 14로 할 경우 1.2초 정도의 시간이 걸리는 것을 확인할 수 있습니다.

 

Encoding에 사용할 Salt 생성

BCryptPasswordEncoder는 BCrypt 클래스를 이용해서 salt를 생성합니다. 이때 인자로 BCrypt의 버전, Log round, SecureRandom 인스턴스, 총 3개를 받습니다.

String salt = BCrypt.gensalt(
        String.valueOf(BCryptPasswordEncoder.BCryptVersion.$2B).toLowerCase(Locale.ROOT)
        , 15
        , new SecureRandom());
System.out.println(salt);

$2b$15$nfie/am1fpwrClC0.fVVjO

실제로 생성된 salt 값을 보면 버전이 $2b, Log round가 15, 뒤에 나머지 salt 문자열이 붙어 있는 것을 확인할 수 있습니다.

 

만들어진 Salt를 이용한 Encoding

String salt = BCrypt.gensalt(
        String.valueOf(BCryptPasswordEncoder.BCryptVersion.$2B).toLowerCase(Locale.ROOT)
        , 15
        , new SecureRandom());
String password = "RAW-PASSWORD";
byte[] passwordb = password.getBytes(StandardCharsets.UTF_8);
String encodedPassword = BCrypt.hashpw(passwordb, salt);
System.out.println(salt);
System.out.println(encodedPassword);

BCryptPasswordEncoder encoder = new BCryptPasswordEncoder();
assertTrue(encoder.matches(password, encodedPassword));

$2b$15$FXy9MRyFiYwHQ4XxOs7M.O // Salt
$2b$15$FXy9MRyFiYwHQ4XxOs7M.OgohHirzWNGjk.tJz07QV/puavHG7yhy // Encoded Password

gensalt 함수를 활용해서 Salt를 만들고 hashpw 함수를 이용해서 암호화를 할 수 있습니다. 실제로 BCrypt 방법으로 암호화된 결과를 보면 위에서 확인한 구조랑 동일한 구조를 가지는 것을 확인할 수 있습니다.

 

마지막에 BCryptPasswordEncoder에 내장된 matches 함수로 검증 결과 테스트에 통과된 것을 볼 수 있습니다.


DelegatingPasswordEncoder

기존에는 PasswordEncoder를 선택해서 한 가지 방법으로 구현해야 했습니다. 때문에 아래의 세 가지의 문제가 생깁니다.

  • 이전 방식에서 새로운 방식으로 쉽게 마이그레이션 할 수 없음
  • 암호 저장의 모범 사례가 계속 변경
  • Spring Security는 프레임워크이기 때문에 자주 변경될 수 없음

DelegationPasswordEncoder는 위에 3가지 문제를 아래와 같은 방법으로 해결합니다.

  • 현재 암호 저장소 권장 사항을 사용하여 암호가 인코딩 되는지 확인
  • 최신 및 레거시 형식으로 암호 검증 허용
  • 향후 인코딩 업그레이드 허용

 

DelegatingPasswordEncoder 생성

PasswordEncoderFactories를 사용하면 DelegatingPasswordEncoder를 쉽게 구현할 수 있습니다.

PasswordEncoder passwordEncoder = PasswordEncoderFactories.createDelegatingPasswordEncoder();
String result = passwordEncoder.encode("RAW-PASSWORD");
System.out.println(result);

{bcrypt}$2a$10$7HgywNqKE04TrQn5Uw1ryeAUxrh8sq..jBBDlbrd/8FLlHx7Q1i6W

 

Custom DelegatingPasswordEncoder 생성

String idForEncode = "bcrypt";
Map<String, PasswordEncoder> encoders = new HashMap<>();
encoders.put(idForEncode, new BCryptPasswordEncoder());
encoders.put("noop", NoOpPasswordEncoder.getInstance());
encoders.put("pbkdf2", new Pbkdf2PasswordEncoder());
encoders.put("scrypt", new SCryptPasswordEncoder());
encoders.put("sha256", new StandardPasswordEncoder());

PasswordEncoder passwordEncoderWithBcrypt = new DelegatingPasswordEncoder(idForEncode, encoders);
System.out.println(passwordEncoderWithBcrypt.encode("RAW-PASSWORD"));
// result : {bcrypt}$2a$10$69xh.rHzxPzQpy/B4nY.i.o5vM4oUK7Q3lumLTZ5upIxNn0Y1to.m

PasswordEncoder passwordEncoderWithNoop = new DelegatingPasswordEncoder("noop", encoders);
System.out.println(passwordEncoderWithNoop.encode("RAW-PASSWORD"));
// result : {noop}RAW-PASSWORD

PasswordEncoder passwordEncoderWithPbkdf2 = new DelegatingPasswordEncoder("pbkdf2", encoders);
System.out.println(passwordEncoderWithPbkdf2.encode("RAW-PASSWORD"));
// result : {pbkdf2}90bfbbcbcb8425eb3ebf4059f73ede6bc086a6f4b51f87ff053ba952df43a735fc98607e75034a88

PasswordEncoder passwordEncoderWithScrypt = new DelegatingPasswordEncoder("scrypt", encoders);
System.out.println(passwordEncoderWithScrypt.encode("RAW-PASSWORD"));
// result : {scrypt}$e0801$00D9CR1ttpebisj/nuZsC3TRTtXdGWLHYWw1X9EY9jpZI9GZ1gR2Kc8ITJ/AFtOKIWRjNwWEFch1OHf6OY8jng==$vIoz8W8OBmpx0ZIzREfj2k799i+cjmd/MBgFhKyVOuw=

PasswordEncoder passwordEncoderWithSha256 = new DelegatingPasswordEncoder("sha256", encoders);
System.out.println(passwordEncoderWithSha256.encode("RAW-PASSWORD"));
// result : {sha256}6c9741b719eade0b5d813f4fbd66b839f9f43f748ad82639af15f28f4e6034dbd0b8177eb5cf32ca

위 코드를 실행하면 SCryptPasswordEncoder에서 java.lang.NoClassDefFoundError: org/bouncycastle/crypto/generators/SCrypt 에러가 발생하는데 bcprov-jdk15on를 의존성에 추가해주면 해결할 수 있습니다.

링크 : https://mvnrepository.com/artifact/org.bouncycastle/bcprov-jdk15on

dependencies {
	implementation "org.bouncycastle:bcprov-jdk15on:1.64"
}

 

DelegatingPasswordEncoder Storage Format

위의 결과값을 확인해 보면 아래와 같은 형식으로 나온 다는 것을 알 수 있습니다.

{id}encodedPassword

 

  • {id}는 PasswordEncoder를 찾는 식별자로 사용
  • encodedPassword는 {id}에서 지정하고 있는 방식으로 암호화된 문자열

여기서 Storage Format이 드러나는 것을 우려할 수 있는데 알고리즘에 의존하지 않기 때문에 문제가 되지 않는다고 합니다.


DefaultPasswordEncoder

개발 환경이나 테스트 환경에서는 지금까지 알아본 방법으로는 암호화하는데 걸리는 시간 때문에 번거로울 수 있습니다. 이럴 때 DefaultPasswordEncoder를 이용할 수 있습니다.

User user = (User) User.withDefaultPasswordEncoder()
                .username("user")
                .password("password")
                .roles("user")
                .build();
System.out.println(user.getPassword());

 

Builder를 활용한 DefaultPasswordEncoder 재사용

User.UserBuilder users = User.withDefaultPasswordEncoder();
User user = (User) users
      .username("user")
      .password("password")
      .roles("USER")
      .build();
User admin = (User) users
      .username("admin")
      .password("password")
      .roles("USER","ADMIN")
      .build();

DefaultPasswordEncoder는 Deprecated 된 함수이기 때문에 개발환경에서만 사용하길 추천합니다.


마치며

Spring Security의 인증에서 사용자 암호를 저장하기 위한 암호화 방식에 대해서 오늘 학습해 보았습니다. 전체에 비하면 일부분이지만 직접 코드를 분석하고 이해하며 많은 것을 알게 되었다 생각합니다.

 

틀린 설명이나 추가로 보충 설명되어야 할 내용이 있다면 댓글 부탁드립니다. 긴글 읽어주셔서 감사합니다