본문으로 바로가기
반응형

소프트웨어 품질인증 이라고 불리는 GS 인증을 받게 됐다...

모든 개인정보 데이터와 비밀번호 관련해서 암호화 작업을 할 필요가 생겨서 관련 내용을 정리하는 글이다.

개인정보 및 비밀번호 저장 시, 암호화하여 저장해야 합니다.

(비밀번호는 단방향으로 암호화 되어야 하고, salt 값 등을 이용하여 동일한 비밀번호를 반복하여 생성 시, 매번 다른 암호 값이 생성되어야 함)

비밀번호 입력 5회 실패 시 잠금 장치 필요

위의 내용은 실제로 TTA 측 담당자에게 전달받은 답변이다.
개인정보 범위가 생각보다 많지는 않아서 다행이였으나, 비밀번호의 경우는 기존에 Spring Security 에서 제공하는 PasswordEncoder 를 활용하여 암호화 하고 있었기에 이 부분은 SHA-256 으로 Digest 를 생성하여 처리하였다.


비밀번호 암호화 알고리즘 수정

기존에 없던 salt 추가

DB의 회원 테이블 Userssalt 필드를 VARCHAR(30) NULL 형식으로 추가하였다.

SHA256Util

다른 프로젝트에서도 사용중이던 SHA256 유틸클래스를 생성한다.

import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.Random;

public class SHA256Util {

    public static String getEncrypt(String source, String salt) {
        return getEncrypt(source, salt.getBytes());
    }

    public static String getEncrypt(String source, byte[] salt) {

        String result = "";

        byte[] a = source.getBytes();
        byte[] bytes = new byte[a.length + salt.length];

        System.arraycopy(a, 0, bytes, 0, a.length);
        System.arraycopy(salt, 0, bytes, a.length, salt.length);

        try {
            MessageDigest md = MessageDigest.getInstance("SHA-256");
            md.update(bytes);

            byte[] byteData = md.digest();

            StringBuffer sb = new StringBuffer();
            for (int i = 0; i < byteData.length; i++) {
                sb.append(Integer.toString((byteData[i] & 0xFF) + 256, 16).substring(1));
            }

            result = sb.toString();
        } catch (NoSuchAlgorithmException e) {
            e.printStackTrace();
        }

        return result;
    }

    public static String generateSalt() {
        Random random = new Random();

        byte[] salt = new byte[8];
        random.nextBytes(salt);

        StringBuffer sb = new StringBuffer();
        for (int i = 0; i < salt.length; i++) {
            // byte 값을 Hex 값으로 바꾸기.
            sb.append(String.format("%02x",salt[i]));
        }

        return sb.toString();
    }

}

SecurityUtil

이번엔 SHA256Util 을 호출해서 salt 생성과 hash 처리를 담당할 유틸클래스를 생성한다.

import org.springframework.stereotype.Component;

@Component
public class SecurityUtil {

    /**
     * Salt 발급
     */
    public static String generateSalt() {
        return SHA256Util.generateSalt();
    }

    /**
     * 비밀번호 암호화
     */
    public static String passwordHash(String password, String salt) {
        return SHA256Util.getEncrypt(password, salt);
    }

}

비밀번호 확인 로직 수정

기존에 처리하던 로직을 if ~ else 로 변형한다.

AS-IS
if (passwordEncoder.matches(password, user.getPassword()))
    return true;
else
    throw new FailedToAuthenticatePasswordException();
TO-BE
long passwordFailedCount = passwordFailedRepository.countByUserId(user.getId());
if (passwordFailedCount == 5) throw new AuthenticateLockedException();

if (user.getSalt() == null || user.getSalt().isEmpty()) {
  if (passwordEncoder.matches(inputPassword, user.getPassword())) {
    user.resetPassword(defaultPassword); // 비밀번호 초기화
    passwordFailedRepository.deleteAllByUserId(user.getId());
    return true;
  }
  else {
    passwordFailedRepository.saveAndFlush(PasswordFailed.create(user.getId()));
    throw new FailedToAuthenticatePasswordException();
  }
} else {
  if (user.passwordVerify(inputPassword)) {
    passwordFailedRepository.deleteAllByUserId(user.getId());
    return true;
  }
  else {
    passwordFailedRepository.saveAndFlush(PasswordFailed.create(user.getId()));
    throw new FailedToAuthenticatePasswordException();
  }
}

AS-IS 에서는 Spring Security 에서 제공하던 PasswordEncoder 인터페이스로 비밀번호를 체크하고 있었으나
TO-BE 에서는 기존의 것은 그대로 두고 회원 Entity 인 user 에서 passwordVerify 메서드를 통해 비밀번호를 체크한다.
그리고 기존의 비밀번호 체크 방식으로 진행될 경우 비밀번호를 한번 초기화 한다. 로그인 직후 비밀번호 초기 설정을 진행시켜 salt 값을 저장하기 위함이며 이 후에는 else 부분의 로직으로만 이루어 지게하려고 한다.


비밀번호 검사 및 수정, 초기화 로직 수정

회원 Entity 인 user 에 다음과 같은 메서드를 각각 생성한다.

public Boolean passwordVerify(String inputPassword) {
    return SecurityUtil.passwordHash(inputPassword.trim(), getSalt()).equals(getPassword());
}
public void modifyPassword(String password) {
    String salt = SecurityUtil.generateSalt();
    String hashPassword = SecurityUtil.passwordHash(password.trim(), salt);
    this.salt = salt;
    this.password = hashPassword;
    this.isPasswordModified = true;
}
public void resetPassword(String password) {
    String salt = SecurityUtil.generateSalt();
    String hashPassword = SecurityUtil.passwordHash(password.trim(), salt);
    this.salt = salt;
    this.password = hashPassword;
    this.isPasswordModified = false;
}

참고로 isPasswordModified 는 비밀번호 초기 설정 여부를 확인하기 위한 필드이며 값은 아래와 같다.

@Column(name = "is_password_modified", columnDefinition = "BIT(1) DEFAULT 0", nullable = false)
private boolean isPasswordModified;

해당 값을 로그인 Response 에 내보내서 초기 비밀번호 설정 여부를 판단하면 된다.


비밀번호 초기화 Service 수정

@Value("${default-password}")
private String defaultPassword;
private final PasswordEncoder passwordEncoder;
private final UserRepository userRepository;
private final PasswordFailedRepository passwordFailedRepository;

/**
 * 비밀번호 초기화
 */
@Transactional
public void resetPassword(UUID userId) {
    userRepository.findById(userId)
        .ifPresentOrElse(
            user -> {
                passwordFailedRepository.deleteAllByUserId(user.getId()); // 비밀번호 입력 오류 카운트 관리 데이터 초기화
                user.resetPassword(defaultPassword); // 비밀번호 초기화
            },
            () -> { throw new NotFoundDataException(); }
        );
}

/**
 * 초기 비밀번호 수정
 */
@Transactional
public void modifyDefaultPassword(UUID userId, String password) {
    userRepository.findById(userId)
        .filter(user -> !user.isPasswordModified())
        .ifPresentOrElse(
            user -> {
                if (user.getSalt() == null || user.getSalt().isEmpty()) { // 기존 암화 처리 방식
                    if (passwordEncoder.matches(password, user.getPassword())) throw new CannotUseSamePasswordAsBeforeException();
                } else {
                    if (user.passwordVerify(password)) throw new CannotUseSamePasswordAsBeforeException(); // 정책상 비밀번호 이전 것과 동일할 수 없음
                }
                user.modifyPassword(password); // 비밀번호 수정
            },
            () -> { throw new NotFoundDataException(); }
        );
}

비밀번호 암호화 정리

다시 정리하자면 기존 암호화 처리 방식인 PasswordEncoder 인터페이스를 사용한 데이터들도 대응하면서 새로 바꾼 암호화 방식까지 동시 대응하기 위함이며, 사용자는 로그인 직후 비밀번호를 초기화 당하여 다시 재설정을 해야하며 재설정하고 나면 salt 값과 SHA256 알고리즘 암호화 처리로 새롭게 저장될 것이다.


개인정보 암호화

개인정보 데이터들을 암호화 하기위해 AES+Base64 조합을 선택했다. (SEED 를 사용하기도 한다는데 간단한 방식으로 우선 정함)
그리고 구글링해보니 이걸 어느 시점에 암·복호화 할 것인가를 많이들 고민하는 것 같았다.

  1. JPA Convert
  2. Filter
  3. Service Layer

여기서 1번을 선택했다. 나머지 2,3 번은 로직의 변경에 따라 유지보수를 해야하는 번거로움이 있을 것 같아서 피했다.

EncryptionUtils

암·복호화 처리를 담당할 유틸클래스를 생성한다.

import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;

import javax.crypto.Cipher;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.Key;
import java.util.Base64;

@Component
public class EncryptionUtils {

    private static String encryptKey;
    private static String encryptAlgorithm;

    @Value("${encrypt.key}")
    public void setKey(String key) {
        EncryptionUtils.encrypt = key;
    }

    @Value("${encrypt.algorithm}")
    public void setAlgorithm(String algorithm) {
        EncryptionUtils.encryptAlgorithm = algorithm;
    }
    
    public static String getKey() {
        return encryptionKey;
    }

    public static String encrypt(String data) {
        try {
            Cipher cipher = Cipher.getInstance(encryptionAlgorithm);
            Key key = new SecretKeySpec(encryptionKey.getBytes(StandardCharsets.UTF_8), encryptionAlgorithm);
            cipher.init(Cipher.ENCRYPT_MODE, key);
            boolean containsFlag = StringUtils.isNotBlank(data) && data.startsWith("%") && data.endsWith("%");
            if (containsFlag) data = data.replace("%", "");
            byte[] encryptedData = cipher.doFinal(data.getBytes(StandardCharsets.UTF_8));
            String encodedData = Base64.getEncoder().encodeToString(encryptedData);
            if (containsFlag) encodedData = "%" + encodedData + "%";
            return encodedData;
        } catch (Exception e) {
            throw new EncryptFailedException(); // 커스텀 Exception
        }
    }

    public static String decrypt(String encryptedData) {
        try {
            Cipher cipher = Cipher.getInstance(encryptAlgorithm);
            Key key = new SecretKeySpec(encryptKey.getBytes(StandardCharsets.UTF_8), encryptAlgorithm);
            cipher.init(Cipher.DECRYPT_MODE, key);
            byte[] decodedData = Base64.getDecoder().decode(encryptedData);
            byte[] decryptedData = cipher.doFinal(decodedData);
            return new String(decryptedData, StandardCharsets.UTF_8);
        } catch (Exception e) {
            throw new DecryptFailedException(); // 커스텀 Exception
        }
    }

}

encrypt.key 는 적당한 키값으로 지정한다. AES-128 알고리즘은 16자리 이다.
encrypt.algorithmaes 라고 지정한다.


Custom Converter 생성하기

String <-> String 인 경우
import javax.persistence.AttributeConverter;
import javax.persistence.Converter;

@Converter
public class CryptoStringConverter implements AttributeConverter<String, String> {
    @Override
    public String convertToDatabaseColumn(String attribute) {
        if (attribute == null) return null;
        return EncryptionUtils.encrypt(attribute);
    }

    @Override
    public String convertToEntityAttribute(String dbData) {
        if (dbData == null) return null;
        return EncryptionUtils.decrypt(dbData);
    }
}

Enum <-> String 인 경우
import com.jeffrey.api.enum.user.Gender;

import javax.persistence.AttributeConverter;
import javax.persistence.Converter;

@Converter
public class CryptoGenderConverter implements AttributeConverter<Gender, String> {
    @Override
    public String convertToDatabaseColumn(Gender attribute) {
        if (attribute == null) return null;
        return EncryptionUtils.encrypt(attribute.toString());
    }

    @Override
    public Gender convertToEntityAttribute(String dbData) {
        if (dbData == null) return null;
        return Gender.valueOf(EncryptionUtils.decrypt(dbData));
    }
}

LocalDate <-> String 인 경우
import javax.persistence.AttributeConverter;
import javax.persistence.Converter;
import java.time.LocalDate;

@Converter
public class CryptoLocalDateConverter implements AttributeConverter<LocalDate, String> {
    @Override
    public String convertToDatabaseColumn(LocalDate attribute) {
        if (attribute == null) return null;
        return EncryptionUtils.encrypt(attribute.toString());
    }

    @Override
    public LocalDate convertToEntityAttribute(String dbData) {
        if (dbData == null) return null;
        return LocalDate.parse(EncryptionUtils.decrypt(dbData));
    }
}

대충 이쯤이면 감잡았을 거라 믿는다. (나머지 데이터 타입도 이런식으로 생성)


Entity Column 에 Convert 지정하기
@Convert(converter = CryptoStringConverter.class)
@Column(name = "login_id", length = 100, nullable = false)
protected String loginId;
@Convert(converter = CryptoGenderConverter.class)
@Column(name = "gender", length = 100, nullable = false)
private Gender gender;
@Convert(converter = CryptoLocalDateConverter.class)
@Column(name = "birthday", length = 100, nullable = false)
private LocalDate birthday;

DB 데이터 타입은 VARCHAR (100) 혹은 VARCHAR (200) 으로 모두 수정하였다. 200은 email 같은 데이터가 워낙 길어서 길게함.
기존에 30 또는 50 이래서 2배의 100으로 지정하였음.


Error 대응

  • 기존에 Enum 타입의 컬럼의 경우 DB에 String 으로 저장하기 위해 @Enumerated(EnumType.STRING) 을 선언했는데 이는 @Convert 와 같이 작동하지 않기 때문에 @Enumerated(EnumType.STRING) 를 삭제해야한다.

  • querydsl 에서 컬럼을 조회 할 때 case 문법을 사용하는 경우 해당하는 컬럼 모두에게 @Convert 를 지정해야 한다.

  • querydsl 에서 contains 처리 시에는 미리 복호화를 한 데이터에 대해서 like 처리가 되어야하므로 복호화 함수를 설정한 후 다음과 같이 대응해야한다.
application.yml
spring:
  jpa:
    database-platform: com.test.CustomMySQL8Dialect
Dialect Custom
public class CustomMySQL8Dialect extends MySQL8Dialect {
    public CustomMySQL8Dialect() {
        registerFunction("decrypt", new SQLFunctionTemplate(StandardBasicTypes.STRING, "AES_DECRYPT(FROM_BASE64(?1), '?2')"));
    }
}
Querydsl AS-IS
private BooleanExpression likeName(String name) {
    final StringPath encryptionKey = Expressions.stringPath(EncryptionUtils.getKey());
    return Optional.ofNullable(name)
        .filter(StringUtils::isNotBlank)
        .map(student.name::contains)
        .orElse(null);
}
Querydsl TO-BE
private BooleanExpression likeName(String name) {
    final StringPath encryptionKey = Expressions.stringPath(EncryptionUtils.getKey());
    return Optional.ofNullable(name)
        .filter(StringUtils::isNotBlank)
        .map(v -> Expressions.stringTemplate("DECRYPT({0}, {1})", student.name, encryptionKey).contains(v))
        .orElse(null);
}
반응형