TIL(Today I Learned)

TIL - 비밀번호 수정 요청 시 기존 비밀번호를 사용하지 못하게 하기

Happy._. 2024. 6. 13. 23:09

백오피스 프로젝트에서 1차 기능 구현에서 회원 관리를 담당하게 되었다.

요구사항 중 최근 3번 안에 사용한 비밀번호는 사용하지 못하도록 하는 조건이 있었다.

3번이라는 확정된 조건이 있어서 DB 테이블에 변경이력 1, 2, 3처럼 만드는 방법도 있지만 한 컬럼 안에서 해결할 수 있는 방법이 있어서 기록으로 남겨본다.

 

회원정보 수정에 대한 전체 코드는 다음과 같고 수정할 수 있는 회원정보는 이름, 전화번호, 비밀번호이다.

    @Transactional
    fun updateMember(
        updateMemberRequest: UpdateMemberRequest
    ): MemberResponse {
        val memberId = getMemberIdFromToken()
        val member = memberRepository.findByIdOrNull(memberId) ?: throw ModelNotFoundException("Member", memberId)

        if (updateMemberRequest.name != null) {
            member.name = updateMemberRequest.name
        }

        if (updateMemberRequest.phoneNumber != null) {
            val isExistPhoneNumber = memberRepository.existsByPhoneNumber(updateMemberRequest.phoneNumber)

            if (isExistPhoneNumber) { // DB에 전화번호가 있다면
                throw AlreadyExistsException(updateMemberRequest.phoneNumber, "전화번호")
            }

            member.phoneNumber = updateMemberRequest.phoneNumber
        }

        if (updateMemberRequest.password != null) {
            // 이전에 사용했던 비밀번호 이력을 가져옴
            val pwHistory = member.pwHistory.split(",").toMutableList() // [hh23,  dddd,  1234]

            // 수정 요청한 비밀번호가 이전에 사용했던 적이 있는지 확인
            val isExistPassword = pwHistory.filter { passwordEncoder.matches(updateMemberRequest.password, it) }.size

            if (isExistPassword > 0) {
                throw ReusedPasswordException("이전에 사용했던 비밀번호는 사용할 수 없습니다.")
            }

            // 일치하는 비밀번호가 없으면 기존 변경 이력횟수를 확인
            if (pwHistory.size >= 3) { // 3번 이상이면
                // 첫 번째 요소(가장 오래 전에 사용했던 비밀번호)를 제거
                pwHistory.removeFirst()
            }

            pwHistory.add(passwordEncoder.encode(updateMemberRequest.password))

            // 변경된 비밀번호 List를 비밀번호 이력에 추가
            member.pwHistory = pwHistory.joinToString(",")
            member.password = pwHistory.last()
        }

        return member.toResponse()
    }

 

회원정보 수정에 사용되는 DTO는 다음과 같고 비밀번호 입력 값에 대한 요구사항을 반영하여 작성하였다.

DTO의 타입들이 nullable인 이유는 사용자가 특정 값만 수정할 수 있기 때문이다.

data class UpdateMemberRequest(
    val name: String?,
    val phoneNumber: String?,
    @field:Pattern(
        regexp = "^(?=.*[a-zA-Z])(?=.*[0-9])(?=.*[\\^\$*.\\[\\]{}\\(\\)\\?\\-\\\"!@#%&/\\\\,><':;|_~`]).{8,15}\$",
        message = "최소 8자 이상, 15자 이하이며 알파벳 대소문자(a~z, A~Z), 숫자(0~9), 특수문자를 포함해야 합니다."
    )
    val password: String?
)

 

 

다음으로 이어지는 내용들은 위에 작성된 회원정보를 수정하는 코드를 나누어 설명한다.

 

 

첫 번째 메서드는

principal에 담겨있는 회원 ID를 반환하는 메서드이고 두 번째 메서드는 principal에서 추출한 회원 ID와 일치하는 회원 정보를 가져오는 메서드이다.

val memberId = getMemberIdFromToken()
val member = memberRepository.findByIdOrNull(memberId) ?: throw ModelNotFoundException("Member", memberId)

 

회원정보 수정은 모든 파라미터를 한 번에 수정하지 않는 경우도 있기 때문에 파라미터마다 null값인지 체크해야 한다.

이름의 경우 별도 조건이 없기 때문에 null값만 아니면 수정을 한다.

전화번호는 다른 사람이 사용하고 있는 전화번호를 사용할 수 없도록 DB에서 수정 요청한 전화번호와 일치하는 값이 있는지 확인 후 있다면 예외 처리를 하고 없다면 수정을 한다.

if (updateMemberRequest.name != null) {
    member.name = updateMemberRequest.name
}

if (updateMemberRequest.phoneNumber != null) {
    val isExistPhoneNumber = memberRepository.existsByPhoneNumber(updateMemberRequest.phoneNumber)

    if (isExistPhoneNumber) { // DB에 전화번호가 있다면
        throw AlreadyExistsException(updateMemberRequest.phoneNumber, "전화번호")
    }

    member.phoneNumber = updateMemberRequest.phoneNumber
}

 

사용자가 비밀번호 변경을 요청하면 한 번 더 비밀번호를 검증해야 한다는 요구사항이 있었다.

클라이언트 측에서 회원정보 수정 요청 전 비밀번호 체크 요청을 보내고 회원정보 수정 요청을 보내도록 하는 방식으로 구현했다.

별도 password api를 만들어서 access token과 함께 비밀번호를 서버로 요청하면 해당 비밀번호가 맞는지 체크 후 status code 200 or 400을 반환하도록 했다.

 

변경할 비밀번호를 입력한 경우 이전에 사용했던 비밀번호 이력을 DB에서 가져온다.

DB에는 passwordEncoder를 통해 인코딩된 값과 구분자로 쉼표(,)를 사용해 3개의 값을 저장하도록 구현했고 가져올 때도 split() 함수를 사용해 구분자(,)를 기준으로 List로 가져오도록 구현했다.

 예) $2a$10$X85FFGw.Tv4rtjxrdreg..ERrq9RGNr3KhjbQ47wfLnD1kLI0F.JW,

       $2a$10$gGFbFDMx0LXtzs.W5ISQDuI3Gsim7yow/iW1fIAPYgrFR9dMC/0Gi,

       $2a$10$CWdsG2ZyNOqJBUuIM.Pc7uMzn3PWbVYj3yq.9gB7blFBpLK4HWplu

val pwHistory = member.pwHistory.split(",").toMutableList() // [hh23,  dddd,  1234]

 

위에서 만들어진 List를 filter 함수를 사용해 각 비밀번호가 수정 요청한 비밀번호와 일치하는 값이 있는지 확인한다.

하나라도 일치하는 값이 있다면 size가 0보다 큰 값이 반환되는데 그럴 경우 예외 처리를 통해 메시지를 반환한다.

 val isExistPassword = pwHistory.filter { passwordEncoder.matches(updateMemberRequest.password, it) }.size
 
 if (isExistPassword > 0) {
    throw ReusedPasswordException("이전에 사용했던 비밀번호는 사용할 수 없습니다.")
}

 

수정 요청한 비밀번호가 예외 조건에 부합하지 않는다면 다음 코드를 수행하게 된다.

비밀번호 이력은 최근 3번에 대해 저장하기 때문에 저장된 비밀번호의 개수가 3개라면 수정된 비밀번호를 List에 저장하기 전에 먼저 첫 번째 요소를 제거한다.

그 후 새로운 비밀번호를 인코딩하여 List에 먼저 저장, DB의 비밀번호 변경이력 컬럼에 List의 값을 구분자(,)를 포함한 문자열로 변환하여 저장한다.

마지막으로 List의 마지막 값을 회원의 비밀번호로 저장하고 수정된 회원정보를 비밀번호를 제외하고 반환한다.

// 일치하는 비밀번호가 없으면 기존 변경 이력횟수를 확인
if (pwHistory.size >= 3) { // 3번 이상이면
    // 첫 번째 요소(가장 오래 전에 사용했던 비밀번호)를 제거
    pwHistory.removeFirst()


    pwHistory.add(passwordEncoder.encode(updateMemberRequest.password))

    // 변경된 비밀번호 List를 비밀번호 이력에 추가
    member.pwHistory = pwHistory.joinToString(",")
    member.password = pwHistory.last()
}