이번 시간에는, 지난 번에 작성했던 엔티티를 이용하여 로그인과 회원가입을 수행하는 서비스 로직을 작성하려고 하였지만, 해당 로직까지 모두 포함하면 지면이 길어지는 단계로 다음 시간으로 미루도록 하겠습니다.
따라서, 이번에는 로그인 및 회원가입 서비스 로직을 작성하기 위한 비밀번호 암호화와 토큰 발급 및 검증 기능을 작성해보도록 하겠습니다.
사용자는 입력한 정보로 회원가입을 하면, 이메일과 닉네임의 유효성(우리의 서비스 로직에서는 중복)을 검사하고,
변형된(암호화 또는 해시) 비밀번호로 저장해야합니다.
사용자는 로그인을 하면, 가입된 사용자인지 확인한 뒤에, 토큰을 발급받게 됩니다.
토큰은 액세스 토큰과 리프레쉬 토큰 두 가지를 발급합니다.
액세스 토큰은, 사용자 인증에 사용될 것이기 때문에 유효 기간이 짧고,
리프레쉬 토큰은, 액세스 토큰을 재발급하는 용도로 사용될 것이기 때문에 유효 기간이 길도록 하겠습니다.
먼저, 비밀번호를 날 것 그대로 저장하면 안되므로, spring security에서 제공해주는 PasswordEncoder를 사용해보도록 하겠습니다.
PasswordEncoder의 구현체를 빈으로 등록해주면서, spring security의 기초적인 설정도 간단히 해두도록 하겠습니다.
config.security 패키지에 SecurityConfig 클래스를 작성해줍니다.
@EnableWebSecurity를 클래스 레벨에 선언하여 security 관련 설정과 빈들을 활성화시켜주고,
WebSecurityConfigurerAdapter를 상속받아서 설정 작업을 수행하겠습니다.
package kukekyakya.kukemarket.config.security;
import org.springframework.context.annotation.Bean;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.builders.WebSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.factory.PasswordEncoderFactories;
import org.springframework.security.crypto.password.PasswordEncoder;
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Override
public void configure(WebSecurity web) throws Exception {
super.configure(web);
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.httpBasic().disable() // 1
.formLogin().disable() // 2
.csrf().disable() // 3
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) // 4
.and()
.authorizeRequests()
.antMatchers("/**").permitAll(); // 5
}
@Bean
public PasswordEncoder passwordEncoder() {
return PasswordEncoderFactories.createDelegatingPasswordEncoder(); // 6
}
}
현재 작성하고 있는 애플리케이션은 API 서버로 구동될 것입니다. 따라서 위와 같은 설정을 해줍니다.
1. http basic 인증 방법을 비활성화합니다.
2. form login을 비활성화합니다.
3. csrf 관련 설정을 비활성화합니다.
4. 세션 관리 정책을 설정합니다. 세션을 유지하지 않도록 설정해줍니다. (처음에는 disable()로 하면, 세션이 관리되지 않는 줄 알았는데, 테스트를 하다보니 disable로 설정을 해도 JSESSIONID가 쿠키로 발급되고 있었습니다. 따라서, 위와 같이 설정해주었습니다. 자세한 설정 사항은 문서를 참고해보시길 바랍니다.)
5. 일단은 모든 url에 대해서 접근할 수 있도록 허용해줍니다. (나중에 권한 설정을 하면서 개선되어야할 사항입니다.)
6. PasswordEncoder의 구현체로 DelegatingPasswordEncoder를 사용해줍니다. PasswordEncoderFactories 클래스에 팩토리 메소드를 이용하면 인스턴스를 생성할 수 있습니다. 이것을 구현체로 선택한 이유는, 비밀번호를 암호화하기 위한 다양한 알고리즘(bcrypt, md5, sha 계열 등)이 있는데, 이 구현체를 이용하면 여러 알고리즘들을 선택적으로 편리하게 사용할 수 있습니다. 자세한 내용은 문서를 참고해보시길 바랍니다.
우리는 DelegatingPasswordEncoder를 사용하기로 결정햐였습니다.
PasswordEncoderFactories.createDelegatingPasswordEncoder() 가 어떤 식으로 구현체를 생성하는지 확인하기 위해 코드를 살짝 들여다보겠습니다.
public static PasswordEncoder createDelegatingPasswordEncoder() {
String encodingId = "bcrypt";
Map<String, PasswordEncoder> encoders = new HashMap<>();
encoders.put(encodingId, new BCryptPasswordEncoder());
encoders.put("ldap", new org.springframework.security.crypto.password.LdapShaPasswordEncoder());
encoders.put("MD4", new org.springframework.security.crypto.password.Md4PasswordEncoder());
encoders.put("MD5", new org.springframework.security.crypto.password.MessageDigestPasswordEncoder("MD5"));
encoders.put("noop", org.springframework.security.crypto.password.NoOpPasswordEncoder.getInstance());
encoders.put("pbkdf2", new Pbkdf2PasswordEncoder());
encoders.put("scrypt", new SCryptPasswordEncoder());
encoders.put("SHA-1", new org.springframework.security.crypto.password.MessageDigestPasswordEncoder("SHA-1"));
encoders.put("SHA-256",
new org.springframework.security.crypto.password.MessageDigestPasswordEncoder("SHA-256"));
encoders.put("sha256", new org.springframework.security.crypto.password.StandardPasswordEncoder());
encoders.put("argon2", new Argon2PasswordEncoder());
return new DelegatingPasswordEncoder(encodingId, encoders);
}
다음과 같이 여러 개의 PasswordEncoder 구현체를 생성하고, DelegatingPasswordEncoder의 인자로 넘겨줍니다.
DelegatingPasswordEncoder의 생성자는 다음과 같습니다.
public DelegatingPasswordEncoder(String idForEncode, Map<String, PasswordEncoder> idToPasswordEncoder) {
if (idForEncode == null) {
throw new IllegalArgumentException("idForEncode cannot be null");
}
if (!idToPasswordEncoder.containsKey(idForEncode)) {
throw new IllegalArgumentException(
"idForEncode " + idForEncode + "is not found in idToPasswordEncoder " + idToPasswordEncoder);
}
for (String id : idToPasswordEncoder.keySet()) {
if (id == null) {
continue;
}
if (id.contains(PREFIX)) {
throw new IllegalArgumentException("id " + id + " cannot contain " + PREFIX);
}
if (id.contains(SUFFIX)) {
throw new IllegalArgumentException("id " + id + " cannot contain " + SUFFIX);
}
}
this.idForEncode = idForEncode; // 1
this.passwordEncoderForEncode = idToPasswordEncoder.get(idForEncode);
this.idToPasswordEncoder = new HashMap<>(idToPasswordEncoder);
}
1. 생성자로 넘겨준 idForEncode를 이용하여 기본적으로 사용될 암호화 알고리즘 구현체를 지정하는 것 같습니다.
즉, 여기에선 bcrypt 알고리즘이 사용됩니다.
어떻게 암호화하는지 DelegatingPasswordEncoder.encode 메소드도 살펴보도록 하겠습니다.
@Override
public String encode(CharSequence rawPassword) {
return PREFIX + this.idForEncode + SUFFIX + this.passwordEncoderForEncode.encode(rawPassword);
}
encode를 수행할 때, PREFIX와 SUFFIX 사이에 암호화 알고리즘을 기록해놓고, 알고리즘이 적용된 문자열과 함께 반환됩니다. (PREFIX와 SUFFIX는 디폴트로 '{'와 '}'으로 설정되어 있습니다.)
그렇다면, 실제 encode는 어떻게 적용되는지 BcryptPasswordEncoder.encode를 간단히 살펴보겠습니다.
@Override
public String encode(CharSequence rawPassword) {
if (rawPassword == null) {
throw new IllegalArgumentException("rawPassword cannot be null");
}
String salt = getSalt();
return BCrypt.hashpw(rawPassword.toString(), salt);
}
랜덤 문자열(salt)과 함께 알고리즘을 적용하여 반환해줍니다.
(* 어떤 자주 쓰이는 문자열들을 해시(또는 암호화)해둔 룩업 테이블이 있을 때, 이를 이용하면 특정한 문자열이 해시된 값이 무엇인지 빠르게 알아낼 수 있기 때문에 랜덤한 문자열 salt를 기존 문자열에 추가하여 알고리즘을 수행해줍니다.)
그렇다면, PasswordEncoderFactories.createDelegatingPasswordEncoder()로 생성된 구현체로 encode를 수행하면,
"{bcrypt}<알고리즘이 적용된 문자열>"와 같은 형태의 문자열이 생성될 것입니다.
이해한 내용이 맞는지, 별도의 학습 테스트를 작성하여 검증해보겠습니다.
* 작성하고 있는 애플리케이션 로직과 관계 없이, 어떠한 라이브러리나 외부 기능을 검증해보기 위해 사용되는 테스트를 학습 테스트라고 합니다. 이를 이용하여 기능에 대한 사용법을 익혀볼 수 있습니다.
test 디렉토리에 learning 패키지를 만들고, PasswordEncoderTest 클래스를 작성해줍니다.
package learning;
import org.junit.jupiter.api.Test;
import org.springframework.security.crypto.factory.PasswordEncoderFactories;
import org.springframework.security.crypto.password.PasswordEncoder;
import static org.assertj.core.api.Assertions.*;
public class PasswordEncoderTest {
// 1
PasswordEncoder passwordEncoder = PasswordEncoderFactories.createDelegatingPasswordEncoder();
@Test
void encodeWithBcryptTest() { // 2
// given
String password = "password";
// when
String encodedPassword = passwordEncoder.encode(password);
// then
assertThat(encodedPassword).contains("bcrypt");
}
@Test
void matchTest() { // 3
// given
String password = "password";
String encodedPassword = passwordEncoder.encode(password);
// when
boolean isMatch = passwordEncoder.matches(password, encodedPassword);
// then
assertThat(isMatch).isTrue();
}
}
1. PasswordEncoderFactories.createDelegatingPasswordEncoder 팩토리 메소드로 PasswordEncoder의 구현체를 만들어줍니다.
2. encode를 수행해봅니다. 기본적으로 bcrypt가 설정되어있으니 반환된 문자열에는 bcrypt가 포함되어 있어야합니다.
3. encode된 문자열을 어떻게 검증하는지 테스트해봅니다. PasswordEncoder.matches 메소드를 이용하여 rawPassword와 encodedPassword의 일치 여부를 검사할 수 있습니다.
학습 테스트를 이용하여, PasswordEncoder의 기본 사용법을 익히게 되었습니다.
이를 이용하여 회원가입을 수행할 때, 암호화된 비밀번호를 저장하고,
로그인을 수행할 때, 비밀번호 일치 여부를 검증하도록 하겠습니다.
이제 토큰을 발급받기 위한 기능을 정의해보겠습니다.
토큰의 종류로는 Json Web Token을 편리하게 사용하기 위해서 다음과 같은 dependency를 추가해주어야합니다.
// https://mvnrepository.com/artifact/io.jsonwebtoken/jjwt
implementation 'io.jsonwebtoken:jjwt:0.9.1'
이를 이용하여 토큰을 발급하고 검증할 수 있습니다.
토큰을 다루기 위해 간단한 핸들러를 작성해보겠습니다.
토큰 발급 및 검증을 위한 최소한의 기능만을 만들어낼 것입니다.
handler 패키지에 JwtHandler를 작성해줍니다.
* 지금 단계에서는, 별도의 방식으로 토큰을 만들어낼 계획이 없으므로 인터페이스를 정의하지않고 하나의 클래스만 선언해줍니다.
package kukekyakya.kukemarket.handler;
import io.jsonwebtoken.*;
import org.springframework.stereotype.Component;
import java.util.Date;
@Component
public class JwtHandler {
private String type = "Bearer ";
public String createToken(String encodedKey, String subject, long maxAgeSeconds) {
Date now = new Date();
return type + Jwts.builder()
.setSubject(subject)
.setIssuedAt(now)
.setExpiration(new Date(now.getTime() + maxAgeSeconds * 1000L))
.signWith(SignatureAlgorithm.HS256, encodedKey)
.compact();
}
public String extractSubject(String encodedKey, String token) {
return parse(encodedKey, token).getBody().getSubject();
}
public boolean validate(String encodedKey, String token) {
try {
parse(encodedKey, token);
return true;
} catch (JwtException e) {
return false;
}
}
private Jws<Claims> parse(String key, String token) {
return Jwts.parser()
.setSigningKey(key)
.parseClaimsJws(untype(token));
}
private String untype(String token) {
return token.substring(type.length());
}
}
복잡해보이지만, 실제로는 그렇게 복잡하지 않습니다.
단순히, https://github.com/jwtk/jjwt
위 링크에 서술된 방식대로 토큰을 생성하고, 검증할 뿐입니다.
각각의 메소드에 대해서 자세히 살펴보도록 하겠습니다.
public String createToken(String encodedKey, String subject, long maxAgeSeconds) {
Date now = new Date();
return type + Jwts.builder() // 1
.setSubject(subject) // 2
.setIssuedAt(now) // 3
.setExpiration(new Date(now.getTime() + maxAgeSeconds * 1000L)) // 4
.signWith(SignatureAlgorithm.HS256, encodedKey) // 5
.compact(); // 6
}
JwtHandler.createToken은 Base64로 인코딩된 key 값을 받고, 토큰에 저장될 데이터 subject, 만료 기간 maxAgeSeconds를 초단위로 받아서 토큰을 만들어주는 작업을 수행합니다.
1. jwt를 빌드하기 시작합니다.
2. 토큰에 저장될 데이터를 지정해줍니다. (우리의 서비스에서는 Member의 id 값을 넣어주겠습니다.)
3. 토큰 발급일을 지정해줍니다. 현재 시간에서 입력된 시간을 더해주었습니다. createToken의 파라미터는 초 단위로 입력받지만, Date는 ms 단위로 입력받기 때문에 1000을 곱해줍니다.
4. 토큰 만료 일자를 지정해줍니다.
5. 파라미터로 받은 key로 SHA-256 알고리즘을 사용하여 서명해줍니다.
6. 주어진 정보로 토큰을 생성해냅니다.
type으로 지정한 "Bearer"는, 생성해낸 토큰이 어떤 타입인지(여기서는 jwt)를 나타냅니다.
public String extractSubject(String encodedKey, String token) {
return parse(encodedKey, token).getBody().getSubject();
}
토큰에서 subject를 추출해냅니다. 토큰을 파싱하고, 바디에서 subject를 꺼내올 수 있습니다.
우리의 서비스에서는, 토큰의 subject로 Member의 id가 저장되기 때문에, 이를 이용하여 사용자를 인증할 수 있을 것이라 예상됩니다.
public boolean validate(String encodedKey, String token) {
try {
parse(encodedKey, token);
return true;
} catch (JwtException e) {
return false;
}
}
토큰의 유효성을 검증합니다. 토큰을 파싱하면서 jwt 관련 예외가 발생했다면, 유효하지않은 토큰으로 판단합니다.
private Jws<Claims> parse(String key, String token) {
return Jwts.parser()
.setSigningKey(key)
.parseClaimsJws(untype(token));
}
private String untype(String token) {
return token.substring(type.length());
}
토큰을 파싱하는 과정은 위와 같습니다.
parser를 이용하여 사용된 key를 지정해주고, 파싱을 수행해줍니다.
이 때, 토큰 문자열에는 토큰의 타입도 포함되어있으므로, 이를 untype 메소드를 이용하여 제거해줍니다.
JwtHandler는 사용할 때, 기본적으로 Base64로 인코딩된 키를 파라미터로 받게 됩니다.
이는, jwt dependency를 이용할 때 인코딩된 키를 인자로 넘겨주어야하기 때문입니다.
JwtHandler에서 인코딩되지않은 키를 입력받아서 직접 인코딩한 뒤에 사용해도 되지만, Base64 인코딩은 손쉽게(인코딩사이트 또는 심지어 눈으로든) 할 수 있기 때문에, 이를 불필요한 작업이라 보고, 처음부터 인코딩된 키를 넘겨받도록 명시하였습니다.
이렇게 작성된 JwtHandler를 테스트해보겠습니다.
test 디렉토리에, 위와 동일한 경로에 JwtHandlerTest 클래스를 작성해줍니다.
전체 소스코드는 다음과 같습니다.
class JwtHandlerTest {
JwtHandler jwtHandler = new JwtHandler();
@Test
void createTokenTest() {
// given, when
String encodedKey = Base64.getEncoder().encodeToString("myKey".getBytes());
String token = createToken(encodedKey, "subject", 60L);
// then
assertThat(token).contains("Bearer ");
}
@Test
void extractSubjectTest() {
// given
String encodedKey = Base64.getEncoder().encodeToString("myKey".getBytes());
String subject = "subject";
String token = createToken(encodedKey, subject, 60L);
// when
String extractedSubject = jwtHandler.extractSubject(encodedKey, token);
// then
assertThat(extractedSubject).isEqualTo(subject);
}
@Test
void validateTest() {
// given
String encodedKey = Base64.getEncoder().encodeToString("myKey".getBytes());
String token = createToken(encodedKey, "subject", 60L);
// when
boolean isValid = jwtHandler.validate(encodedKey, token);
// then
assertThat(isValid).isTrue();
}
@Test
void invalidateByInvalidKeyTest() {
// given
String encodedKey = Base64.getEncoder().encodeToString("myKey".getBytes());
String token = createToken(encodedKey, "subject", 60L);
// when
boolean isValid = jwtHandler.validate("invalid", token);
// then
assertThat(isValid).isFalse();
}
@Test
void invalidateByExpiredTokenTest() {
// given
String encodedKey = Base64.getEncoder().encodeToString("myKey".getBytes());
String token = createToken(encodedKey, "subject", 0L);
// when
boolean isValid = jwtHandler.validate(encodedKey, token);
// then
assertThat(isValid).isFalse();
}
private String createToken(String encodedKey, String subject, long maxAgeSeconds) {
return jwtHandler.createToken(
encodedKey,
subject,
maxAgeSeconds);
}
}
각 테스트가 무엇을 의미하는지 자세히 살펴보도록 하겠습니다.
class JwtHandlerTest {
JwtHandler jwtHandler = new JwtHandler();
...
먼저, 필요한 의존성입니다. JwtHandler의 기능에 대해서 테스트할 예정이므로, 인스턴스를 직접 생성해주었습니다.
@Test
void createTokenTest() {
// given, when
String encodedKey = Base64.getEncoder().encodeToString("myKey".getBytes());
String token = createToken(encodedKey, "subject", 60L);
// then
assertThat(token).contains("Bearer ");
}
토큰을 생성하는 테스트입니다.
정상적으로 토큰이 생성되었다면, Bearer 타입이 덧붙여진 토큰 문자열이 반환될 것입니다.
@Test
void extractSubjectTest() {
// given
String encodedKey = Base64.getEncoder().encodeToString("myKey".getBytes());
String subject = "subject";
String token = createToken(encodedKey, subject, 60L);
// when
String extractedSubject = jwtHandler.extractSubject(encodedKey, token);
// then
assertThat(extractedSubject).isEqualTo(subject);
}
토큰에서 subject를 추출하기 위한 테스트입니다.
주어진 subject로 토큰을 생성하고, 생성된 토큰에서 다시 subject를 추출하여 검증합니다.
@Test
void validateTest() {
// given
String encodedKey = Base64.getEncoder().encodeToString("myKey".getBytes());
String token = createToken(encodedKey, "subject", 60L);
// when
boolean isValid = jwtHandler.validate(encodedKey, token);
// then
assertThat(isValid).isTrue();
}
토큰을 정상적으로 생성하고 검증했을 때, 올바른 토큰인지 테스트합니다.
@Test
void invalidateByInvalidKeyTest() {
// given
String encodedKey = Base64.getEncoder().encodeToString("myKey".getBytes());
String token = createToken(encodedKey, "subject", 60L);
// when
boolean isValid = jwtHandler.validate("invalid", token);
// then
assertThat(isValid).isFalse();
}
토큰 생성에 사용했던 key 외에, 다른 key를 사용하여 토큰을 검증할 경우, 토큰은 유효하지 않습니다.
@Test
void invalidateByExpiredTokenTest() {
// given
String encodedKey = Base64.getEncoder().encodeToString("myKey".getBytes());
String token = createToken(encodedKey, "subject", 0L);
// when
boolean isValid = jwtHandler.validate(encodedKey, token);
// then
assertThat(isValid).isFalse();
}
토큰의 유효 기간을 0초로 설정하여 생성하면, 토큰 발급과 동시에 토큰은 만료됩니다.
따라서 토큰의 유효성 검사는 실패합니다.
이번 시간에는, 비밀번호 암호화 기능과 토큰 발급 및 검증 기능을 작성하고 테스트하였습니다.
다음 시간에는, 이를 이용하여 회원가입 및 로그인 로직을 작성해보도록 하겠습니다.
* 질문 및 피드백은 환영입니다.
* 전체 소스코드에서는 여기에서 확인해볼 수 있습니다.
https://github.com/SongHeeJae/kuke-market
'Spring > 게시판 만들기' 카테고리의 다른 글
스프링부트 게시판 API 서버 만들기 (6) - @RestControllerAdvice로 예외 다루기 (5) | 2021.11.30 |
---|---|
스프링부트 게시판 API 서버 만들기 (5) - 로그인 - 4 - 웹 계층 구현 (0) | 2021.11.29 |
스프링부트 게시판 API 서버 만들기 (4) - 로그인 - 3 - 서비스 로직 (2) | 2021.11.29 |
스프링부트 게시판 API 서버 만들기 (2) - 로그인 - 1 - 엔티티 설계 (4) | 2021.11.29 |
스프링부트 게시판 API 서버 만들기 (1) - 프로젝트 생성 (2) | 2021.11.27 |