이메일 생성 및 발송 프로세스
1. RSA 링크 생성
RSA(Rivest-Shamir-Adleman) 암호화를 사용하여 보안 링크를 생성합니다.
String link = RsaUtils.generateVerificationLink(to, privateKey);
to: 수신자의 이메일 주소privateKey: RSA 개인 키
generateVerificationLink 메서드는 다음과 같은 과정을 거칩니다:
- 이메일 주소와 만료 시간을 포함한 페이로드 생성
- RSA 개인 키를 사용하여 페이로드 암호화
- 암호화된 데이터를 Base64로 인코딩
- 인코딩된 데이터를 URL에 추가하여 완전한 링크 생성
2. Thymeleaf Context 설정
Context context = new Context();
context.setVariable("key", value);
Context: Thymeleaf의 변수 컨테이너setVariable(String key, Object value): 컨텍스트에 변수 추가
사용된 변수들:
verificationCode: 이메일 인증 코드email: 사용자 이메일 주소propLink: 이메일 찾기 링크resetLink: 비밀번호 재설정 링크
3. Thymeleaf Template 처리
String htmlBody = templateEngine.process("template-name", context);
templateEngine: Thymeleaf 템플릿 엔진 인스턴스process(String template, Context context): 템플릿 처리 메서드
처리 과정:
- 지정된 이름의 템플릿 파일 로드
- 컨텍스트의 변수들을 템플릿의 플레이스홀더에 삽입
- 최종 HTML 문자열 생성
사용된 템플릿들:
verification-email: 인증 코드 이메일 템플릿email-find-link: 이메일 찾기 링크 템플릿password-reset-link: 비밀번호 재설정 링크 템플릿
4. Amazon SES 이메일 요청 객체 생성
SendEmailRequest request = new SendEmailRequest()
.withDestination(new Destination().withToAddresses(to))
.withMessage(new Message()
.withBody(new Body().withHtml(new Content().withCharset("UTF-8").withData(htmlBody)))
.withSubject(new Content().withCharset("UTF-8").withData(subject)))
.withSource(sourceEmail);
SendEmailRequest 객체 구성:
Destination: 수신자 이메일 주소 설정Message: 이메일 본문과 제목 설정Body: 이메일 본문 (HTML 형식)Subject: 이메일 제목
Source: 발신자 이메일 주소
5. 이메일 발송
sesClient.sendEmail(request);
sesClient: Amazon Simple Email Service (SES) 클라이언트sendEmail(SendEmailRequest request): 실제 이메일 발송 메서드
발송 과정:
- SES 클라이언트가 AWS 서버에 연결
- 요청 객체를 AWS SES로 전송
- AWS SES가 이메일을 처리하고 지정된 수신자에게 발송
- 발송 성공 시 정상 반환, 실패 시 예외 발생
6. 예외 처리
try {
sesClient.sendEmail(request);
} catch (AmazonSimpleEmailServiceException e) {
throw new EmailSendException(e);
}
AmazonSimpleEmailServiceException: AWS SES 관련 예외EmailSendException: 애플리케이션의 커스텀 예외
7. 비동기 처리
@Async
protected void sendEmail(String to, String subject, String htmlBody) {
// 이메일 발송 로직
}
8. 시퀀스
sequenceDiagram
participant C as 클라이언트
participant MA as MailAdapter
participant RSA as RsaUtils
participant TE as TemplateEngine
participant SES as Amazon SES
C->>MA: 이메일 발송 요청
alt 인증 코드 이메일
MA->>TE: 인증 코드 템플릿 처리
else 이메일 찾기/비밀번호 재설정
MA->>RSA: 보안 링크 생성
RSA-->>MA: 생성된 링크
MA->>TE: 링크 포함 템플릿 처리
end
TE-->>MA: 처리된 HTML
MA->>MA: SendEmailRequest 객체 생성
MA->>SES: 이메일 발송 요청
alt 발송 성공
SES-->>MA: 발송 완료
MA-->>C: 성공 응답
else 발송 실패
SES-->>MA: 예외 발생
MA->>MA: EmailSendException 생성
MA-->>C: 실패 응답
end
찾기 요청을 할 때 유저의 상태 업데이트
메일 형식 및 링크 확인
인증번호 발송 메일
- 인증번호 입력 시 검증
- 재인증 요청 시 메일 재발송 및 기존 코드 삭제 후 재삽입
- 인증번호 입력 시 검증
인증정보 익셉션 추가
요청이 끝나면 인증 삭제
인증 이메일 요청 시 이전에 있던 인증 정보 삭제
인증 시간 내 제한 - 질문
RSA (Rivest-Shamir-Adleman) 암호화 방식:
RSA는 공개키 암호화 시스템의 하나로, 두 개의 키를 사용합니다:
- 공개키: 누구나 알 수 있는 키
- 개인키: 오직 소유자만 알고 있는 비밀 키
주요 특징:
- 큰 소수의 곱셈을 기반으로 한 알고리즘
- 공개키로 암호화된 메시지는 개인키로만 복호화 가능
- 개인키로 서명한 메시지는 공개키로 검증 가능
구현 방법:
키 생성:
- 서버 측에서 RSA 키 쌍(공개키/개인키) 생성
- 공개키는 클라이언트에 제공, 개인키는 서버에 안전하게 보관
링크 생성 (서버):
- 이메일 주소와 만료 시간을 포함한 페이로드 생성
- 페이로드를 JSON으로 변환 후 Base64 인코딩
- 인코딩된 데이터를 개인키로 서명
- 인코딩된 데이터와 서명을 조합하여 링크 생성
링크 검증 (클라이언트):
- 링크에서 인코딩된 데이터와 서명 추출
- 서버의 공개키를 사용하여 서명 검증
- 검증 성공 시 Base64 디코딩 및 JSON 파싱
- 만료 시간 확인
API 구현:
- 공개키 제공 API 구현 (클라이언트가 서버의 공개키를 받을 수 있도록)
- 링크 검증 결과를 서버에 전송하는 API 구현 (선택적)
// 키 생성
KeyPairGenerator keyGen = KeyPairGenerator.getInstance("RSA");
keyGen.initialize(2048);
KeyPair keyPair = keyGen.generateKeyPair();
PublicKey publicKey = keyPair.getPublic();
PrivateKey privateKey = keyPair.getPrivate();
// 링크 생성
String payload = "{\"email\":\"user@example.com\",\"expiry\":1234567890}";
byte[] encodedPayload = Base64.getEncoder().encode(payload.getBytes());
Signature signature = Signature.getInstance("SHA256withRSA");
signature.initSign(privateKey);
signature.update(encodedPayload);
byte[] signatureBytes = signature.sign();
String signatureBase64 = Base64.getEncoder().encodeToString(signatureBytes);
String link = "https://example.com/verify?data=" + new String(encodedPayload) + "&signature=" + signatureBase64;
// 링크 검증 (클라이언트 측)
String[] parts = link.split("&signature=");
byte[] receivedPayload = parts[0].split("=")[1].getBytes();
byte[] receivedSignature = Base64.getDecoder().decode(parts[1]);
Signature verifier = Signature.getInstance("SHA256withRSA");
verifier.initVerify(publicKey);
verifier.update(receivedPayload);
boolean isValid = verifier.verify(receivedSignature);
if (isValid) {
String decodedPayload = new String(Base64.getDecoder().decode(receivedPayload));
// JSON 파싱 및 만료 시간 확인
}
sequenceDiagram
participant Server
participant Client
Note over Server: Generate RSA key pair on startup
Client->>Server: Request public key
Server->>Client: Send public key
Note over Client: Store public key
Note over Server: User requests password reset
Server->>Server: Create payload (email, expiry)
Server->>Server: Encrypt payload with private key
Server->>Client: Send encrypted link via email
Note over Client: User clicks link
Client->>Client: Extract encrypted data from link
Client->>Client: Decrypt data using stored public key
Client->>Client: Verify payload (expiry, etc.)
alt Link is valid
Client->>Server: Proceed with password reset
else Link is invalid
Client->>Client: Show error message
end
- RSA 키 페어 생성 (CLI 사용):
OpenSSL을 사용하여 키 페어를 생성할 수 있습니다. 터미널에서 다음 명령어를 실행합니다:
# 프라이빗 키 생성
openssl genpkey -algorithm RSA -out <deploy-key>.pem -pkeyopt rsa_keygen_bits:2048
# 프라이빗 키에서 퍼블릭 키 추출
openssl rsa -pubout -in <deploy-key>.pem -out <deploy-key>.pem
# 프라이빗 키를 Base64로 인코딩 (환경 변수용)
base64 -w 0 < <deploy-key>.pem > private_key_base64.txt
# 퍼블릭 키를 Base64로 인코딩 (환경 변수용)
base64 -w 0 < <deploy-key>.pem > public_key_base64.txt
- 키 설정:
백엔드 (Java):
private_key_base64.txt의 내용을 환경 변수로 설정RSA_PRIVATE_KEY=<private_key_base64.txt의 내용>프론트엔드 (React):
public_key_base64.txt의 내용을 환경 변수로 설정REACT_APP_RSA_PUBLIC_KEY=<public_key_base64.txt의 내용>
- Java 코드 수정:
RsaUtils 클래스를 다음과 같이 수정합니다:
package kr.co.visibleray.prop.util;
import javax.crypto.Cipher;
import java.security.*;
import java.security.spec.PKCS8EncodedKeySpec;
import java.security.spec.X509EncodedKeySpec;
import java.util.Base64;
public class RsaUtils {
private static final String INSTANCE_TYPE = "RSA";
public static String encryptWithPrivateKey(String plainText, String privateKeyString) throws Exception {
PrivateKey privateKey = convertPrivateKey(privateKeyString);
Cipher cipher = Cipher.getInstance(INSTANCE_TYPE);
cipher.init(Cipher.ENCRYPT_MODE, privateKey);
byte[] encryptedBytes = cipher.doFinal(plainText.getBytes());
return Base64.getEncoder().encodeToString(encryptedBytes);
}
private static PrivateKey convertPrivateKey(String privateKeyString) throws Exception {
byte[] privateBytes = Base64.getDecoder().decode(privateKeyString);
PKCS8EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(privateBytes);
KeyFactory keyFactory = KeyFactory.getInstance(INSTANCE_TYPE);
return keyFactory.generatePrivate(keySpec);
}
}
- 백엔드에서 사용:
@Service
public class EmailService {
@Value("${RSA_PRIVATE_KEY}")
private String privateKeyString;
@Autowired
private MailAdapter mailAdapter;
public void sendEmailFindLink(String email) throws Exception {
long expiryTime = System.currentTimeMillis() + 3600000; // 1 hour
String payload = String.format("{\"email\":\"%s\",\"expiry\":%d}", email, expiryTime);
String encryptedPayload = RsaUtils.encryptWithPrivateKey(payload, privateKeyString);
String link = "https://example.com/verify?data=" + encryptedPayload;
mailAdapter.sendEmailFindLink(email, link);
}
}
- 프론트엔드 (React) 에서 사용:
import { Buffer } from 'buffer';
import { publicDecrypt } from 'crypto-browserify';
function verifyLink(link) {
const publicKey = process.env.REACT_APP_RSA_PUBLIC_KEY;
try {
const encryptedData = link.split('data=')[1];
const buffer = Buffer.from(encryptedData, 'base64');
const decrypted = publicDecrypt(
{ key: publicKey, padding: crypto.constants.RSA_PKCS1_PADDING },
buffer
);
const payload = JSON.parse(decrypted.toString());
if (Date.now() > payload.expiry) {
return false; // 링크 만료
}
// 추가 검증 로직...
return true;
} catch (error) {
console.error('Link verification failed:', error);
return false;
}
}
import React, { useEffect, useState } from 'react';
import { useLocation } from 'react-router-dom';
import CryptoJS from 'crypto-js';
import JSEncrypt from 'jsencrypt';
const ResetPassword = () => {
const [email, setEmail] = useState('');
const [isExpired, setIsExpired] = useState(false);
const location = useLocation();
useEffect(() => {
const decryptData = () => {
const searchParams = new URLSearchParams(location.search);
const encryptedData = searchParams.get('data');
if (encryptedData) {
const publicKey = `-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAuqCptryI0CGnCDOyN4ro
yd7s9MUvamfeRuYopBznRgGyd6jxnQPXClujGdkhz0dKp445bc9W06OZcxGTCZyu
HuQrlUDgnzAjfFQDMVYcY5B5GjWezU69VOsYB5V5/0aPebXWUQhoJMc0M6B+NlJJ
6bLrR2a0Y5TqB9iiZoYsF4AxKgNeyYQ5kGMs7FakN2T2153pBcjJSVr/mQd+ee2M
psgw8KYQQjHn6+yFiuIaP/cnt0KiYqWrE72+C/lKH7rXIpufOoEAICMMd9hmbojL
V7dZS0JnA7Lt487KAKvfnFhA/QsRn9ShsoWk7OOfQ7psFBkNrja4a8VcrmtcogjfJQ
IDAQAB
-----END PUBLIC KEY-----`;
const decrypt = new JSEncrypt();
decrypt.setPublicKey(publicKey);
const decryptedData = decrypt.decrypt(encryptedData);
if (decryptedData) {
const { email, expiry } = JSON.parse(decryptedData);
setEmail(email);
setIsExpired(Date.now() > expiry);
}
}
};
decryptData();
}, [location]);
if (isExpired) {
return <div>링크가 만료되었습니다. 새로운 비밀번호 재설정 링크를 요청해주세요.</div>;
}
return (
<div>
<h1>비밀번호 재설정</h1>
<p>{email} 계정의 비밀번호를 재설정합니다.</p>
{/* 비밀번호 재설정 폼 구현 */}
</div>
);
};
export default ResetPassword;
sequenceDiagram
participant U as 사용자
participant F as 프론트엔드
participant B as 백엔드
participant E as 이메일 서버
U->>B: 비밀번호 재설정 요청
B->>B: RSA 키쌍 생성
B->>B: 이메일과 만료시간으로 페이로드 생성
B->>B: 개인키로 페이로드 암호화
B->>E: 암호화된 링크가 포함된 이메일 전송
E->>U: 비밀번호 재설정 이메일 수신
U->>F: 이메일의 링크 클릭
F->>F: URL에서 암호화된 데이터 추출
F->>F: 공개키로 데이터 복호화
F->>F: 이메일과 만료시간 확인
alt 링크가 유효한 경우
F->>U: 비밀번호 재설정 폼 표시
else 링크가 만료된 경우
F->>U: 오류 메시지 표시
end
Public Key: MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAnQru4l5JTgTo4PCQjlP2OgkrH70RblLpkUCy/0ZN6F+ESE9PXgIosqwEjVLRe60Ua3Ywus6L2FOWgJrZXOxS0sapNvRJGoeR+d/hTuqriQY4WhKhxvT7B2wYL3otwP5DjFnewhEZ9u4N42n01AsUzXFK+EXRyyF9yMNfKCs702IpyQvOuZmthDu9kgquc+6H2D4taH70tpZcCqtaL2o0C9etCWYJ7S7wOZlu07FIQ92UZR5Ceph+nDK1m/TPYTNLXDLM12YvtXGNptxhAy0ISkz7DINM+k/7/5+eoN+J4N5TqSymf2o1DbZVmMMY5RHSBfAmaNnwc6tmBtOYIoefQQIDAQAB
Private Key: