웹 개발 Spring Day9 비밀번호 암호화(SHA-512), 이메일 인증 (인증 유효 시간 설정), CryptoUtils

김지원·2022년 8월 12일
0

WebDevelop2

목록 보기
29/34

  • email_verified_flag 추가
  • password가 128자인 이유. 단방향 암호화를 위해 해싱을 할 것.

암호화

1. 단방향

CRC
MD2
MD5
SHA-1
SHA-224
SHA-256
SHA-384
SHA-512
SHA-512/224
SHA-512/256
  • MD5로 암호화를 하게 되면 엄호화되기전의 길이가 어떻든 간에 암호화후의 길이는 항상 같다. 손실이 발생하기 때문에 다시 돌릴 수 없다. MD5는 항상 같은 값을 준다.
    2. 양방향
    3. 비대칭

MessageDigest

회원가입을 할 때 UserEntity가 가진 password를 단방향 암호화된 비밀번호로 바꾸자.

→ UserService

public int putUser(UserEntity user) throws NoSuchAlgorithmException { // INSERT
        String password = user.getPassword();
        MessageDigest messageDigest = MessageDigest.getInstance("SHA-512");
        messageDigest.reset();
        messageDigest.update(password.getBytes(StandardCharsets.UTF_8));
        password = String.format("%0128x", new BigInteger(1, messageDigest.digest()));
        user.setPassword(password); // 위의 과정을 통해 만들어진 비밀번호를 다시 set해준다.
        return this.userMapper.insertUser(user);
}

  • 비밀번호가 암호화되어 회원가입 된 것을 확인 할 수 있다.

< 풀이 >

getInstance에 밑줄 : Unhandled exception: java.security.NoSuchAlgorithmException
  • MessageDigest.getInstanceNoSuchAlgorithmException 라는 예외를 던져줘야하기 때문에 오류가 뜨게 된다.
  • 문자열로 적어놓는 특성상 암호화 알고리즘 대신 다른 문자열을 적게 되면 그러한 알고리즘이 없다는 예외가 터질수 도 있기 때문에 예외를 던져준다.
    UserController도 책임전가 해주어 함께 던져주어야한다.

messageDigest.update(password.getBytes(StandardCharsets.UTF_8));

update는 byte배열 을 받기 때문에 password 문자열을 byte 배열로 바꿔줘야한다.

password = String.format("%0128x", new BigInteger(1, messageDigest.digest()));
						 ↑ %x : Hex : 16진법
  • 해싱된 비밀번호 문자열을 BigInterger로 받는다.( ← 얘네는 10진법) 10진법짜리 숫자를 String.format과 "%0128x" 통해 16진법으로 바꿔준다.

Enum<? extends IResult> result;

UserController에 있는 IResult result 변경한다.
IResult타입을 Enum<?>타입으로 한번 사용해보자.


Enum<?> ← 이렇게 만들어 놓으면 우리가 만들지 않은, 결과에 통용되지 않을 열거형도 들어갈 수 있게 된다.

CommonResult → Enum<CommonResult>

CommonResultEnum<CommonResult> 을 상속받기로 되어있고

RegisterResult → Enum<RegisterResult>

RegisterResultEnum<RegisterResult> 을 상속받기로 되어있다.

그런데 타입자리에 ? 라고 했다는 것은 모든 열거형이 들어와도 되며 누구를 상속받든 상관이 없다는 의미가 되버린다.

Enum<?> result; // Enum<HttpStatus>
result = HttpStatus.ACCEPTED;

우리가 정해놓지 않은 것들도 다 사용이 가능해져버린다.

타입을 IResult로 묶어내서 IResult를 상속받거나 구현하는 것만 사용할 수 있도록 하자.

Enum<? extends IResult> result;

IResult를 상속받자마자 우리가 구현하지 않은 것들은 사용하지 못하게 되는 것을 볼 수 있다.

Enum<T extends Enum<T>>

이런식으로 적게 되면.. (설명 편의를 위해 앞T : T1 | 뒤T : T1 라고 하겠음)
CommonResultEnum<CommonResult> 을 상속받고 있다.
Enum 클래스의 제네릭타입의 T가 될 수 있는 T1은
T2를 제네릭 타입으로 가지는 Enum에 대한 상속을 받는 것만 가능하다.
순환참조이다.
CommonResultEnum<CommonResult> 을 상속받기 떄문에
Enum<CommonResult extends Enum<CommonResult>> 가 가능하게 된다.

RegisterResult → Enum<RegisterResult>
  • RegisterResult은 RegisterResult 제네릭타입을 가지는 Enum을 상속받고 그리고 IReulst를 구현하고 있기 때문에 이렇게 적는 것이 가능하다.

T( )

: 어떠한 타입을 참고로 할 때 사용한다. 주로 열거형이나 어떤 클래스가 가지는 정적인 멤버에 접근하고자 할 때 사용한다.

  • T(java.lang.Math).PI 처럼 타입을 가져올 때 사용할 수 있다.

-> register.html

th:if="${result != null && result.name().equals(T(dev.jwkim.studymember.enums.CommonResult).SUCCESS.name())}"
: 결과가 null이 아니고 SUCCESS라면
  • null 처리를 꼭 해줘야하는 이유는 사이트 처음 들어왔을 때 null이기 때문에 처리를 해줘야한다. getRegister은 addObject를 해주지 않았기 때문에 null이 뜨게 된다.

window.location.href = '/'; // 회원가입 성공 후에는 첫 페이지로 이동.

window.history.back(); // 뒤로가기 

< 결과 >


이메일 인증

(org.springframework.boot) spring-boot-starter-mail

: Spring Boot를 통해 메일을 쉽게 보내기 위해 사용.

-> spring boot starter mail 의존성 추가

  • groupId가 springfamework.boot임으로 전역적으로 명시된 버전을 사용함으로 따로 버전을 명시해주지 않아도 괜찮다.

-> application.properties

service → JavaMailSender / SpringTemplateEngine

-> UserService

private final JavaMailSender javaMailSender;
private final SpringTemplateEngine springTemplateEngine;

JavaMailSender : 어디서, 어디로, 어떤 버퍼로, 어떤 방식으로 보내는지 설정을 해놓기 위해 사용한다.
SpringTemplateEngine : html파일을 컴파일 해주는데 응답으로 보내는 것이 아닌 메일로 보낼 것이기 때문에 사용한다. 이메일을 텍스트가 아닌 html내용으로 보낼 것이다. (이미지 넣고, css을 사용하기 위해)

-> 생성자 생성

  • 빨간줄이 뜨는데 실행은 됨으로 그냥 진행하자..

이메일 인증 개요

가입 → 중복 확인 (← 중복되면 빠져나간다) → INSERT (동시에 이메일 전송)
								 → 이메일 전송 /user/verify-email 맵핑으로 이메일 인증을 하게함

우리가 누구의 이메일을 인증하려고 하는지 모르기 때문에 key 를 사용한다.
key를 하나 주어서 aa@sample.com 사용자의 이메일 인증을 위한 요청이라고 했을 때 맵핑으로 보면 /user/verify-email/key=a가 되고 이 뒤에 오는 a를 난수화(암호화)해야한다.
이메일을 받은 사용자가 그 키를 가진 링크를 타고 들어오게 된다.
우리는 key라는 이름으로 값을 찾아줘야하는데 현재로는 비교할 대상이 없기 때문에 key를 만듦과 동시에 DB에 넣어줘야한다.
그럼으로 테이블을 먼저 생성하자.

→ DB
이메일인증을 위한 키를 모아놓은 테이블 email_verification_keys

user_email   : 사용자 이메일 
key          : 키 (해당 사용자 전용)  	
created_at   : 언제 만들어졌는지
expires_at   : 언제까지 유효한가 (언제 이후로는 이 키를 더 이상 사용하지 못하도록)
expired_flag : 이메일 인증 유효 여부 (사용자가 정상적으로 키를 사용한 이후에는 더 이상 사용하지 못하게 하기 위해서 True(1)로 돌려줘야함)

-> EmailVerificationKeyEntity

  • getter / setter

-> UserService

Date currentDate = new Date();
String key = String.format("%s%s%s%f%f",
                user.getEmail(),
                user.getPassword(),
                new SimpleDateFormat("yyyyMMddHHmmssSSS").format(currentDate),
                Math.random(),
                Math.random());
messageDigest.reset();
messageDigest.update(key.getBytes(StandardCharsets.UTF_8));
key = String.format("%0128x", new BigInteger(1, messageDigest.digest()));

  • 바로 .format(new Date()) 로 사용해도 되고 currentDate 변수로 뺴서 사용해도 된다.

.setExpiresAt(currentDate) : 만료일시가 현재일시보다 미래여야 함으로 60분의 시간을 준다.

org.apache.commons » commons-lang3

: 온갖 편의 기능을 포함하고 있다.

  • 자바의 원래의 기능을 사용해서 60분을 추가하기에는 상당히 긴 코드가 되기 때문에 apache.commons 의존성을 사용한다.

final int emailDueMinutes = 60; // 60분의 시간을 준다. 

.setExpiresAt(DateUtils.addMinutes(currentDate, emailDueMinutes))

addMinutes 메서드 사용.


putUser 라는 메서드의 반환타입은 int타입인데 회원가입 유저의 레코드의 갯수를 반환해주는데 지금까지는 user만 있었다면 추가가 되었는데 이제는 이메일 인증이 추가가 되었기 때문에 이메일까지 INSERT가 될 수 있으며 즉, putUser 가 2를 반환해야지만 회원가입에 성공한게 된다.

int affectedRecords = 0;
affectedRecords += this.userMapper.insertUser(user);

-> IUserMapper insertEmailVerificationKey

-> UserService

affectedRecords += this.userMapper.insertEmailVerificationKey(emailVerificationKey);

-> UserController

  • 컨트롤러에서도 확인하는 것이 ==1 이 아닌 ==2 가 되어야한다.

-> EmailVerificationKeyEntity

  • Entity에서 email로 만든거 userEmail로 수정 (fn + f6 + shift)

-> UserMapper

  • 회원가입한 시간보다 이메일 인증 유효시간이 1시간이 늘어나있으면 된다.

이메일인증 메일을 위한 html 작성

-> email-verification-email.html

  • css와 script는 link나 src를 걸어서 사용할 수 없다.

  • 하나의 html에서 태그안에 style까지 같이 쓰면 된다.
  • 외부의 이미지를 사용하지 않아도 base64로 바꾸어서 사용이 가능하다. 이 이미지는 이미지를 가지고 오는 주소가 없다.

-> UserService putUser

  • org.thymeleaf.context의 Context를 사용한다.

  • 회원가입진행 후 메일이 오는 것을 확인 할 수 있다

Login

-> LoginResult

성공 | 실패 | ← comminResult 사용 | 이메일인증x  ← LoginResult

-> UserService getUser

  • 사용자 입력값은 2개인데 email과 password임으로 매개변수는 UserEntity 사용하자.

-> UserEntity 멤버변수 추가

private boolean isEmailVerified;

-> UserMapper.xml

  • Mapper에도 isEmailVerified 추가해준다.

-> UserController postLogin

 @RequestMapping(value = "login", method = RequestMethod.POST)
    public ModelAndView postLogin(ModelAndView modelAndView, UserEntity user) {
        IResult result;
        user = this.userService.getUser(user);
        if (user == null) { // 돌아온 user 가 null 이면
            result = CommonResult.FAILURE;
        } else if (!user.isEmailVerified()) {
            result = LoginResult.FAILURE_EMAIL_NOT_VERIFIED;
        } else {
            result = CommonResult.SUCCESS;
        }
        modelAndView.addObject(IResult.ATTRIBUTE_NAME, result); // 편의성을 위해 사용 오타내지 않도록
        modelAndView.setViewName("user/login");
        return modelAndView;
    }

CryptoUtils

  • 로그인 할 때도 비밀번호를 해싱해줘야해서 회원가입과 마찬가지로 위의 과정을 거쳐야 하는데 똑같은 것을 또 적기 귀찮으니 묶어서 사용하자.

-> CryptoUtils 클래스 생성

  • SHA_512 원소가 생성자에 전달해줘야하는 첫번째 인자값은 항상 반듯이 "SHA_512" 이어야 한다!

    public static  String hashUnsafe(Hash hash, String input, Charset charset) throws NoSuchAlgorithmException {
        MessageDigest messageDigest = MessageDigest.getInstance(hash.algorithm);
        messageDigest.reset();
        messageDigest.update(input.getBytes(charset));
        byte[] hashBytes = messageDigest.digest(); // 128
        return String.format(String.format("%%0%dx", hashBytes.length), new BigInteger(1, hashBytes));
                             // ↑ 괄호 안 전체가 이렇게 된다. "%0128x"
    }

String.format("%%0%dx", hashBytes.length) => "%0128x" 이 되는 이유.

%% → %
d → 128
→ %0128x

SHA_512은 128자가 맞은데 다른 암호화방법은 (MD5..) 길이가 다 다르기 때문에 유동적으로 사용하기 위해서 %%0%dx 이렇게 작성한다.

String.format("%0128x", new BigInteger(1, messageDigest.digest()));  // 얘 처럼 된다.

유저가 가진 비밀번호를 암호화하는데 CryptoUtil의 hash라는 메서드를 오버로딩한다. hashUnsafe 메서드 자체는 NoSuchAlgorithmException 예외를 던진다는 시그니처 명시가 되어있고 일반 hash 메서드는 예외를 던져주고 있지 않다. 그말은 예외 상관없이 사용이 가능하다는 것인데 사용을 할 때 예외에 무관하려면 예외가 터졌을 때 대신해서 fallback 이라는 값을 사용해야되서 String fallback 이 명시가 되어있는 것이다. fallback은 명시가 되어있지 않으면 기본값으로 null을 사용하겠다는 의미이다.
Charset charset은 charset이 명시가 되지 않은 경우에는 기본값으로 여기에 있는 값을 사용한다는 것을 의미한다. 그래서 모든 경우에 수에 대해 오버로딩이 싹다 되어있다.

hashing!

-> UserService getUser

  • 첫번쨰로 전달하는 값은 어떤 알고리즘을 사용할것인지에 대한 값이다.
    유저의 getPassword를 SHA_512로 해싱해서 해싱된 비밀번호를 가진 유저를 return user해준다.

  • 이렇게 개발을 했을 때의 장점은 SHA_256를 사용하고자 했을 때 어떤 알고리즘을 사용할 것인지에 대한 명시만 바꿔주면 되기 때문에 매우 코드가 간결해진다.

byte배열의 길이로 처리를 하기 때문에 "%0128x" 처럼 각각의 암호화길이를 명시해줄 필요가 없기에 간편해지는 것이다.


이메일 링크 클릭하면 이메일 인증 절차를 진행해주자.

key라는 값이 들어왔을 때 email_verification_keys 테이블에 있는 key와 들어온 key를 비교하고 expires_at 가 현재보다 미래일 것이고 expired_flag 이 0인 것을 확인한 후 expired_flag 의 값을 1로 바꿔주자.

전송된 이메일 양식에서 사용자가 이메일 인증 버튼을 클릭하면 get요청이 들어오게 된다. 즉, get메서드가 필요한 것이다.
getVerifyEmail 메서드를 UserController에 만들어주자.


그전에 로그인부터 완성하자.
로그인하려고 했을 떄 이메일 인증이 되지 않았으면 alert를 띄워주기 위함이다.

UsesService에 있는 getUser 메서드 getUserByEmailAndPassword 로 이름 변경하였음.

-> IUserMapper selectUserByEmailAndPassword

  • 유저의 이메일과 비밀번호를 받아온다.

-> UserMapper.xml SELECT

  • 요청이 들어온 유저의 이메일과 패스워드가 일치하는 유저의 레코드를 뽑아온다.
  • @Param 어노테이션이 있다면 parameterType 을 적어주지 않는다.

-> UserService getUserByEmailAndPassword

  • user.getEmail(), user.getPassword() 을 Mapper를 통해 반환해준다.

서비스로 부터 받은 유저의 이메일과 비밀번호를 가지고 Result결과를 login.html의 script로 작성하자.

-> login.html

  • html에서 script를 추가해서 이메일 인증 여부에 대한 alert를 지정해준다.
  • 이메일 인증을 하지 않았으니 이런 alert가 뜬다.
  • 컨트롤러를 거쳐서 검사를 해온다.

-> CryptoUtils 오류 발생으로 수정
z012345678을 해싱해보면 0이 사라지고 127자로 해싱되는 오류를 발견했다.

  • 이전에 사용한 new BigInteger(1, hashBytes)BigInteger 가 정수임으로 앞에 0을 숫자로 취급을 하지 않아 날리게 되서 127자가 된다.
    String.format("%02x", hashByte) 이 방식을 사용하면 0이 날아가지 않고 16진수로 바꿔주고("%02x") Interger을 사용하지 않음으로 정수취급을 하지 않는다. 한원소 원소로 따지기 때문에 0을 취급해주게 된다.

-> VerifyEmailResult

-> IUserMapper

selectEmailVerificationKeyByKey
selectUserByEmail
updateEmailVerificationKey
updateUser

-> UserService verifyEmail

  • 이메일에 존재하는 Key값을 기준으로 비교해서 같은 레코드를 긁어와서 비교한다.
@Transactional
public Enum<? extends IResult> verifyEmail(EmailVerificationKeyEntity emailVerificationKey) {
    emailVerificationKey = this.userMapper.selectEmailVerificationKeyByKey(emailVerificationKey.getKey());
    if (emailVerificationKey == null) {
        return CommonResult.FAILURE;
    }
    if (emailVerificationKey.isExpired() || new Date().compareTo(emailVerificationKey.getExpiresAt()) > 0) { // 만료된 키
        return VerifyEmailResult.FAILURE_EXPIRED;
    }
    emailVerificationKey.setExpired(true); // 여기까지 내려온 거라면 이미 사용을 한것이니 update 시켜야함
    if (this.userMapper.updateEmailVerificationKey(emailVerificationKey) == 0) {
        return CommonResult.FAILURE;
    }
    UserEntity user = this.userMapper.selectUserByEmail(emailVerificationKey.getUserEmail()); // 이메일 기준으로 받아온다.
    user.setEmailVerified(true);
    if(this.userMapper.updateUser(user) == 0) {
        return CommonResult.FAILURE;
    }
    return CommonResult.SUCCESS;
}

Date 비교 방식

dt1.compareTo(dt2)
0 : dt1가 dt2보다 미래다.
< 0 : dt1가 dt2보다 과거다.
= 0 : dt1과 dt2가 같다.

@Transactional

  • 아래의 쿼리문중 하나라도 실패한다면 이때까지 진행한 전체 변경사항을 취소한다는 어노테이션이다.

-> UserController

  • 서비스에서 처리를 해주었기 떄문에 컨트롤러에서는 간단하게 작성이 된다.

-> UserMapper.xml SELECT → selectUserByEmail selectEmailVerificationKeyByKey

-> UserMapper.xml UPDATE updateEmailVerificationKey updateUser

-> verify-email.html

<script th:if="${result != null && result.equals(T(dev.jwkim.studymember.enums.CommonResult).FAILURE)}">
    alert('인증 링크가 잘못되었거나 일시적인 오류로 인증을 완료하지 못하였습니다.\n\n잠시 후 다시 시도하시거나 해당 문제가 지속된다면 관리자에게 문의해주세요.')
    window.close();
</script>
<script th:if="${result != null && result.equals(T(dev.jwkim.studymember.enums.CommonResult).SUCCESS)}">
    alert('이메일 인증이 완료되었습니다.\n\n확인을 클릭하면 로그인 페이지로 이동합니다.')
    window.location.href = '/';
</script>
<script th:if="${result != null && result.equals(T(dev.jwkim.studymember.enums.member.user.VerifyEmailResult).FAILURE_EXPIRED)}">
    alert('해당 인증 링크는 이미 만료되었습니다.')
    window.close();
</script>

profile
Software Developer : -)

0개의 댓글