IT정리/java
로그인 시 ID/비밀번호 안전하게 암호화하는 방법 (feat. 일회용 키 & AES 암호화)
wireless mouse
2025. 3. 24. 10:21
1. 로그인 파라미터 암호화 (싱글톤 + Web Crypto API)
- 목적
- 아이디/비밀번호가 평문으로 전송되는 것을 방지
- JavaScript의 Web Crypto API + 서버 복호화 구조로 보안 강화
- 세션이 아닌 싱글톤 기반 키 관리로 인증 처리
2. 클라이언트 흐름 (JavaScript)
- 로그인 전, 서버에서 1회용 세션 키 발급
- 클라이언트는 해당 키로 user_id, user_password를 AES-GCM 방식으로 암호화
- IV (초기화 벡터)도 Base64로 인코딩해 함께 전송
- 암호화 완료 후 form 제출 (onsubmit 유지 가능)
2.1 암호화 함수
function before_login() {
const idField = $('#user_id');
const pwField = $('#user_password');
const encryptionField = $('#encryption');
const encrypt_data_id = $('#encrypt_data_id');
const encrypt_data_pw = $('#encrypt_data_pw');
const originalId = idField.val();
const originalPw = pwField.val();
// Web Crypto API 지원 여부 체크
if (!window.crypto || !window.crypto.subtle) {
console.warn("브라우저에서 보안 암호화 기능을 지원하지 않습니다.");
encryptionField.val("false");
return true; // 평문 그대로 전송
}
// 세션 키 요청
$.ajax({
url: '/login_encode',
method: 'GET',
dataType: 'json',
async: false, // 세션 키는 동기로 받는다
success: function (data) {
const sessionKey = data.sessionKey;
encryptValue(originalId, sessionKey, function (encId, ivId) {
idField.val(encId);
encrypt_data_id.val(ivId);
encryptValue(originalPw, sessionKey, function (encPw, ivPw) {
pwField.val(encPw);
encrypt_data_pw.val(ivPw);
encryptionField.val("true");
// 모든 암호화 완료 후 수동 제출
$('#loginForm')[0].submit();
}, function (err) {
console.warn("비밀번호 암호화 실패:", err);
encryptionField.val("false");
$('#loginForm')[0].submit();
});
}, function (err) {
console.warn("아이디 암호화 실패:", err);
encryptionField.val("false");
$('#loginForm')[0].submit();
});
},
error: function () {
console.warn("세션 키 요청 실패");
encryptionField.val("false");
$('#loginForm')[0].submit();
}
});
return false; // 기본 제출 막고 수동 제출(비동기로 인한 암호화 전 submit 방지)
}
// 암호화 함수
function encryptValue(value, sessionKey, onSuccess, onError) {
try {
const encoder = new TextEncoder();
const keyData = encoder.encode(sessionKey);
const iv = window.crypto.getRandomValues(new Uint8Array(12));
const algorithm = { name: "AES-GCM", iv };
window.crypto.subtle.importKey("raw", keyData, algorithm, false, ["encrypt"])
.then(key => window.crypto.subtle.encrypt(algorithm, key, encoder.encode(value)))
.then(encrypted => {
const encryptedBase64 = btoa(String.fromCharCode(...new Uint8Array(encrypted)));
const ivBase64 = btoa(String.fromCharCode(...iv));
onSuccess(encryptedBase64, ivBase64);
})
.catch(onError);
} catch (error) {
onError(error);
}
}
2.2 로그인 폼
<form id="loginForm" action="/login_post" method="POST" onsubmit="return before_login();">
<input type="text" id="user_id" name="user_id" required />
<input type="password" id="user_password" name="user_password" required />
<!-- 암호화 여부 -->
<input type="hidden" id="encryption" name="encryption" value="false" />
<!-- 암호화에 사용된 IV -->
<input type="hidden" id="encrypt_data_id" name="iv_user_id" />
<input type="hidden" id="encrypt_data_pw" name="iv_user_password" />
<button type="submit">로그인</button>
</form>
보안 보완 팁 :
- IV는 랜덤으로 생성해 필드별로 별도 전송(하나의 IV는 무조건 하나의 필드값만 암호화에 사용해야 함)
- 키는 서버에서 생성 후 일정 시간 후 자동 삭제
- 키 요청 시 무조건 새로 발급 (동시 로그인 충돌 방지)
3. 서버 흐름 (Spring + 싱글톤)
3.1 키 저장소(싱글톤)
public class LoginKeyStore {
private static final LoginKeyStore INSTANCE = new LoginKeyStore();
private final Map<String, String> keyStore = new ConcurrentHashMap<>();
private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor();
private LoginKeyStore() {}
public static LoginKeyStore getInstance() {
return INSTANCE;
}
public void putKey(String sessionId, String key) {
keyStore.put(sessionId, key);
scheduler.schedule(() -> keyStore.remove(sessionId), 1, TimeUnit.MINUTES); // 자동 삭제
}
public String getKey(String sessionId) {
return keyStore.get(sessionId);
}
public void removeKey(String sessionId) {
keyStore.remove(sessionId);
}
}
3.2 파라미터 암호화 AJAX
@ResponseBody
@RequestMapping(value = "/login_encode")
public String login_encode_session_key(
Model model
, HttpServletRequest request
, HttpServletResponse response
) throws Exception
{
String sessionKey = UUID.randomUUID().toString().substring(0, 16);
String sessionId = request.getSession().getId();
LoginKeyStore.getInstance().putKey(sessionId, sessionKey);
Map<String, String> result = new HashMap<String, String>();
result.put("sessionKey", sessionKey);
ResponseUtil.PrintJson(response, request, result);
return null;
}
3.3 로그인 제출 폼
@PostMapping("/login_post")
public ResponseEntity<String> login(
@RequestParam("action") String action,
@RequestParam("user_id") String encryptedId,
@RequestParam("user_password") String encryptedPw,
@RequestParam("encryption") String encryption,
HttpServletRequest request) {
if (!"login".equals(action)) {
return ResponseEntity.badRequest().body("Invalid action");
}
String sessionId = request.getSession().getId();
String sessionKey = LoginKeyStore.getInstance().getKey(sessionId);
String userId = encryptedId;
String password = encryptedPw;
try {
if ("true".equals(encryption) && sessionKey != null) {
userId = decrypt(encryptedId, sessionKey);
password = decrypt(encryptedPw, sessionKey);
LoginKeyStore.getInstance().removeKey(sessionId); // 1회용 키 삭제
}
} catch (Exception e) {
return ResponseEntity.badRequest().body("복호화 실패: " + e.getMessage());
}
// ✅ 아이디 / 비밀번호 검증 로직 (예시)
if ("admin".equals(userId) && "1234".equals(password)) {
return ResponseEntity.ok("로그인 성공");
} else {
return ResponseEntity.status(401).body("로그인 실패: 아이디 또는 비밀번호가 틀립니다.");
}
}
3.4 복호화 폼
public String decrypt(String encryptedBase64, String ivBase64, String sessionKey) throws Exception {
byte[] encryptedBytes = Base64.getDecoder().decode(encryptedBase64);
byte[] iv = Base64.getDecoder().decode(ivBase64);
SecretKeySpec keySpec = new SecretKeySpec(sessionKey.getBytes("UTF-8"), "AES");
GCMParameterSpec gcmSpec = new GCMParameterSpec(128, iv);
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
cipher.init(Cipher.DECRYPT_MODE, keySpec, gcmSpec);
return new String(cipher.doFinal(encryptedBytes), "UTF-8");
}