안녕하세요! delay100입니다! e-commerce 프로젝트의 2번째 게시글이네요.
이번에는 e-commerce를 이용할 때 중요한 3요소 중 하나인 "회원"의 회원가입 기능을 다룹니다.
오늘은 회원가입 시 이메일 인증 로직을 왜, 어떻게 개발했는지에 대해 다뤄보려 합니다!
1. 이메일 인증
1. 왜 이메일 인증 방식을 도입했냐면요..
- 사용자 신뢰도 향상: 이메일 인증 절차를 통해 사용자가 자신의 이메일을 확인하고 인증함으로써 서비스에 대한 신뢰감을 높일 수 있습니다.
- 유효한 연락 수단 확보: 주문 확인, 배송 정보, 비밀번호 재설정 등 중요한 정보를 전달할 수 있는 유효한 이메일 주소를 확보합니다.
- 계정 보호: 이메일 인증을 통해 인증된 사용자만 회원가입을 완료할 수 있게 하여, 악의적인 사용자의 접근을 차단합니다
2. 네이버 이메일 인증 구현
Github 주소
1. 이메일 인증 코드 전송
1. 사용자가 이메일을 입력하고 인증 요청을 합니다.
2. 랜덤 인증 코드를 생성합니다.
3. 이메일 발송 요청을 보냅니다.
4. Naver 측에서 인증 코드를 발송해줍니다.
5. 인증 코드를 Redis에 저장합니다.
Redis에 저장하는 시간은 10분으로 설정(TTL을 10분으로 설정)
// MailController
/**
* 회원가입 이메일 전송
* @param signupVerificationEmailDto 가입할 이메일 정보
* @return 이메일 전송 성공 여부(T/F)
*/
@PostMapping(BASE_AUTH + "/send-signup-code")
public ApiResponse<Boolean> postSignupVerificationEmail(
@RequestBody SignupVerificationEmailDto signupVerificationEmailDto
) throws MessagingException, UnsupportedEncodingException {
return ApiResponse.createSuccess(mailService.postSignupVerificationEmail(signupVerificationEmailDto.getEmail()));
}
// MailService
public boolean postSignupVerificationEmail(String email) throws MessagingException, UnsupportedEncodingException {
Optional<Member> member = memberRepository.findByEmail(aes256Encoder.encodeString(email));
if (member.isPresent()) {
throw new IllegalArgumentException("이미 가입된 이메일입니다.");
}
String code = createCode();
MimeMessage message = createSignupMessage(email, code);
try {
javaMailSender.send(message);
} catch (MailException e) {
throw new IllegalArgumentException("이메일 전송에 실패했습니다.");
}
String key = SIGNUP_CODE_KEY_PREFIX + email;
redisTemplate.opsForValue().set(key, code, 10, TimeUnit.MINUTES);
return true;
}
// 인증 코드 만들기
public String createCode() {
Random random = new Random();
StringBuffer key = new StringBuffer();
for(int i=0; i<8; i++) {
int index = random.nextInt(3);
switch(index) {
case 0 -> key.append((char)((int) random.nextInt(26)+97));
case 1 -> key.append((char)((int) random.nextInt(26)+65));
case 2 -> key.append(random.nextInt(9));
}
}
return key.toString();
}
public MimeMessage createSignupMessage(String to, String code) throws MessagingException, UnsupportedEncodingException {
MimeMessage message = javaMailSender.createMimeMessage();
message.addRecipients(Message.RecipientType.TO, to);
message.setSubject("회원가입 이메일 인증");
String msgText =
"<div style='margin:100px;'>"
+ "<br>"
+ "<p>아래 코드를 회원가입 창으로 돌아가 입력해주세요<p>"
+ "<strong>"
+ code
+ "</strong></div>";
message.setText(msgText, "utf-8", "html");
message.setFrom(new InternetAddress(mailUsername, "ProductShop"));
return message;
}
2. 인증 코드 확인
1. 사용자가 이메일로 받은 인증 코드를 입력합니다.
2, 3. Redis에 저장된 인증 코드와 비교하여 일치하는 경우에만 "_checked" 값을 추가합니다. 이 값은 인증이 완료되었음을 표시합니다.
4. 존재하면 true 반환/존재하지 않으면 false반환합니다.
(만약 프론트가 존재한다면, 이 요청(버튼) 클릭 시 return값에 따라 이후 폼 입력창으로 이동하거나 실패 표시를 띄웁니다)
// MailController
/**
* 회원가입 이메일 코드 확인
* @param signupVerifyCodeDto 인증할 정보
* @return 이메일 코드 확인 여부(T/F)
*/
@PostMapping(BASE_AUTH + "/check-signup-code")
public ApiResponse<Boolean> checkSignupEmailCode(
@RequestBody SignupVerifyCodeDto signupVerifyCodeDto
){
return ApiResponse.createSuccess(mailService.checkSignupEmailCode(signupVerifyCodeDto.getEmail(), signupVerifyCodeDto.getEmailCode()));
}
// MailService
public boolean checkSignupEmailCode(String email, String verificationCode) {
String key = SIGNUP_CODE_KEY_PREFIX + email;
String savedCode = redisTemplate.opsForValue().get(key);
if (savedCode != null && savedCode.equals(verificationCode)) {
redisTemplate.opsForValue().set(key, verificationCode + SIGNUP_CODE_KEY_CHECK, 10, TimeUnit.MINUTES);
return true;
}
return false;
}
*주의!
만약, Docker를 이용해서 Redis를 사용한다면 application.yml에 아래와 같이 설정해주어야합니다.
왜냐하면 Docker 공식문서에 따르면 도커 컨테이너는 연결된 네트워크마다 고유한 IP 주소를 받습니다. 라고 적혀져있습니다,
// application.yml에 아래와 같이 설정해줘야합니다.
data:
redis:
host: redis
port: 6379
3. 회원가입 처리
Github 주소
1. 사용자가 회원가입 폼을 제출
2, 3. 백엔드는 먼저 Redis에서 해당 이메일에 "_checked" 값이 있는지 확인합니다.
4. "_checked" 값이 있는 경우에만 입력된 폼 데이터를 저장하여 회원가입을 완료합니다. "_checked" 값이 없으면 인증되지 않은 사용자로 간주하여 회원가입을 거부합니다.
// AuthController
/**
* POST
* 회원가입
* @param signupRequestDto 회원가입 요청 DTO
* @return 회원가입 성공 여부(T/F)
*/
@PostMapping(BASE_AUTH + "/signup")
public ApiResponse<Boolean> signup(
@RequestBody SignupRequestDto signupRequestDto
) {
return ApiResponse.createSuccess(authService.signup(signupRequestDto));
}
// AuthService
@Transactional
public Boolean signup(SignupRequestDto signupRequestDto) {
if (!mailService.isSignupEmailVerified(signupRequestDto.getEmail())) {
throw new IllegalArgumentException("이메일 인증을 완료해주세요.");
}
if (memberRepository.existsByMemberId(signupRequestDto.getMemberId())) {
throw new IllegalArgumentException("중복된 아이디입니다.");
}
memberRepository.save(Member.from(signupRequestDto, passwordEncoder, aes256Encoder));
return true;
}
3. 이후 추가하고 싶은 기능
개발하면서 OAuth2도 도입하고 싶어졌습니다! 이러면 로그인 시 이메일 인증이 되지 않은 사용자에 대해 어떻게 처리할 것인지에 대한 고민이 추가적으로 필요합니다.
스터디로 진행했던 OAuth2의 동작 방식도 함께 첨부합니다.
2. 개인정보 암호화
1. 왜 AES256 알고리즘을 선택했냐면요..
- 강력한 보안: AES256은 현재 가장 강력하고 안전한 암호화 알고리즘 중 하나로, 256비트 키를 사용하여 높은 수준의 보안을 제공합니다. 민감한 데이터를 보호하는 데 적합합니다.
- 성능 효율성: AES256은 하드웨어 가속을 지원하여, 암호화 및 복호화 속도가 빠르고 효율적입니다. 이는 대규모 데이터 처리에도 유리합니다.
- 광범위한 표준화: AES는 미국 국가 표준 기술 연구소(NIST)에서 표준으로 채택한 암호화 알고리즘으로, 전 세계적으로 널리 사용되고 있습니다. 이는 상호 운용성과 신뢰성을 보장합니다.
- 법적 요구 사항 준수: 많은 산업 및 국가에서 데이터 보호를 위해 AES 암호화를 권장하거나 요구합니다. 이를 통해 법적 요구 사항을 준수할 수 있습니다.
2. AES256 알고리즘 구현
Github 주소
회원가입 이메일 전송 API에 aes256Encoder가 있었습니다. DB에 이메일을 암호화해서 저장하고 있는겁니다!
// 이메일 인증 API의 MailService 中
public boolean postSignupVerificationEmail(String email) throws MessagingException, UnsupportedEncodingException {
Optional<Member> member = memberRepository.findByEmail(aes256Encoder.encodeString(email));
..
}
AES256 encoder의 내부는 아래와 같이 구현했습니다.
구성 요소
- 필드
- ALG: AES 알고리즘을 지정하는 문자열로, 애플리케이션 설정 파일(application.properties 등)에서 주입받습니다.
- KEY: AES 키를 지정하는 문자열로, 애플리케이션 설정 파일에서 주입받습니다.
- 메서드
- getIv(): AES 알고리즘에 사용될 초기화 벡터(IV)를 생성합니다. AES 키의 처음 16바이트를 사용하여 IV를 만듭니다.
- getCipher(int mode): 주어진 모드(암호화 또는 복호화)에 따라 Cipher 객체를 생성하고 초기화합니다.
- encodeString(String text): 주어진 텍스트를 AES-256으로 암호화하고, Base64로 인코딩하여 반환합니다.
- decodeString(String cipherText): 주어진 암호문을 Base64로 디코딩한 후, AES-256으로 복호화하여 원래의 텍스트를 반환합니다.
동작 원리
- 암호화 (encodeString)
- 주어진 텍스트를 AES-256 알고리즘으로 암호화합니다.
- 암호화된 바이트 배열을 Base64로 인코딩하여 문자열로 반환합니다.
- 복호화 (decodeString)
- 주어진 암호문(텍스트)을 Base64로 디코딩하여 바이트 배열로 변환합니다.
- AES-256 알고리즘을 사용하여 암호문을 복호화하고 원래의 텍스트로 변환합니다.
// AES256Encoder
package com.whitedelay.productshop.util;
import org.springframework.beans.factory.annotation.Value;
import javax.crypto.BadPaddingException;
import javax.crypto.Cipher;
import javax.crypto.IllegalBlockSizeException;
import javax.crypto.NoSuchPaddingException;
import javax.crypto.spec.IvParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import java.security.InvalidAlgorithmParameterException;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import java.util.Base64;
import static java.nio.charset.StandardCharsets.UTF_8;
public class AES256Encoder {
@Value("${AES_ALG}")
private String ALG;
@Value("${AES_KEY}")
private String KEY;
private String getIv() {
return KEY.substring(0, 16);
}
private Cipher getCipher(int mode) throws NoSuchPaddingException, NoSuchAlgorithmException, InvalidAlgorithmParameterException, InvalidKeyException {
Cipher cipher = Cipher.getInstance(ALG);
SecretKeySpec keySpec = new SecretKeySpec(KEY.getBytes(), "AES");
IvParameterSpec ivSpec = new IvParameterSpec(getIv().getBytes());
cipher.init(mode, keySpec, ivSpec);
return cipher;
}
public String encodeString(String text) {
try {
Cipher cipher = getCipher(Cipher.ENCRYPT_MODE);
byte[] encrypted = cipher.doFinal(text.getBytes(UTF_8));
return Base64.getEncoder().encodeToString(encrypted);
} catch (NoSuchAlgorithmException | NoSuchPaddingException | InvalidKeyException |
InvalidAlgorithmParameterException | IllegalBlockSizeException | BadPaddingException e) {
throw new IllegalArgumentException(e);
}
}
public String decodeString(String cipherText) {
try {
Cipher cipher = getCipher(Cipher.DECRYPT_MODE);
byte[] decodedBytes = Base64.getDecoder().decode(cipherText);
byte[] decrypted = cipher.doFinal(decodedBytes);
return new String(decrypted, UTF_8);
} catch (NoSuchAlgorithmException | NoSuchPaddingException | InvalidKeyException |
InvalidAlgorithmParameterException | IllegalBlockSizeException | BadPaddingException e) {
throw new IllegalArgumentException(e);
}
}
}
3. AES256 알고리즘 인코딩 결과
참고한 블로그
1. AES256 알고리즘
https://velog.io/@meteor_control0/%EC%95%94%ED%98%B8%ED%99%94-%EB%B3%B5%ED%98%B8%ED%99%94
3. 비밀번호 암호화
1. 왜 BcryptPasswordEncoder을 선택했냐면요..
- 강력한 보안: Bcrypt는 비밀번호를 강력하게 암호화하여 해킹을 어렵게 만듭니다.
- 적응형 기능: 시간이 지남에 따라 해시 비용을 조정할 수 있어, 미래의 컴퓨팅 파워 증가에도 대비할 수 있습니다.
- 솔트 적용: 각 비밀번호에 고유한 솔트를 적용하여 무차별 대입 공격을 방지합니다.
- 검증된 알고리즘: Bcrypt는 여러 보안 전문가와 커뮤니티에 의해 검증된 안정적인 알고리즘입니다.
- 사용 편의성: Spring Security와 통합되어 쉽게 사용할 수 있습니다.
2. BcryptPasswordEncoder 이용
Github 주소
PasswordEncoder 방식을 BcryptPasswordEncoder을 이용한 것입니다.
Bean에 passwordEncoder를 등록할 때 SpringSecurity에서 제공하는 BcryptPasswordEncoder를 인스턴스로 만들어 넣어주면 됩니다.
// EncryptConfig
package com.whitedelay.productshop.security.config;
import com.whitedelay.productshop.util.AES256Encoder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
@Configuration
public class EncryptConfig {
@Bean
public AES256Encoder aes256Encoder() {
return new AES256Encoder();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
passwordEncoder는 아래와 같이 사용하고 있습니다.
// Member 中 passwordEncoder 이용
public static Member from(SignupRequestDto member, PasswordEncoder passwordEncoder, AES256Encoder aesEncoder) {
return Member.builder()
.memberId(member.getMemberId())
.password(passwordEncoder.encode(member.getPassword()))
.address(aesEncoder.encodeString(member.getAddress()))
.zipCode(member.getZipCode())
.email(aesEncoder.encodeString(member.getEmail()))
.memberName(aesEncoder.encodeString(member.getMemberName()))
.phone(aesEncoder.encodeString(member.getPhone()))
.role(member.getRole())
.build();
}
4. 결과 및 예제(API)
아래의 링크에서 더 자세히 보실 수 있습니다.
API(POSTMAN) : https://documenter.getpostman.com/view/23481846/2sA3kSo3ZJ
긴 글 읽어주셔서 감사합니다! 코드나 동작 방식에 대해 궁금한 점이 있으시면 언제든지 질문해 주세요. 피드백도 환영합니다.
'Study > SpringBoot' 카테고리의 다른 글
[e-commerce 프로젝트] 4. 마이페이지 정보 업데이트 (4) | 2024.07.25 |
---|---|
[e-commerce 프로젝트] 3. 로그인(SpringSecurity + JWT 로그인 및 인가) (7) | 2024.07.24 |
[e-commerce 프로젝트] 1. 프로젝트 간단 소개 및 ERD 설계 (1) | 2024.07.17 |
[SpringBoot] 따라하면 만들어지는 메모장 + AWS 배포까지 (1) (0) | 2024.05.22 |
[SpringBoot] Spring프로젝트 Postman에서 확인하기 (0) | 2024.05.13 |