IT정리/java

로그인 시 ID/비밀번호 안전하게 암호화하는 방법 (feat. 일회용 키 & AES 암호화)

wireless mouse 2025. 3. 24. 10:21

1. 로그인 파라미터 암호화 (싱글톤 + Web Crypto API)

- 목적

  • 아이디/비밀번호가 평문으로 전송되는 것을 방지
  • JavaScript의 Web Crypto API + 서버 복호화 구조로 보안 강화
  • 세션이 아닌 싱글톤 기반 키 관리로 인증 처리

2. 클라이언트 흐름 (JavaScript)

  1. 로그인 전, 서버에서 1회용 세션 키 발급
  2. 클라이언트는 해당 키로 user_id, user_password를 AES-GCM 방식으로 암호화
  3. IV (초기화 벡터)도 Base64로 인코딩해 함께 전송
  4. 암호화 완료 후 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");
}