ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [Spring Boot] 스프링 부트에서 비밀번호 암호화하기
    Server/Spring REST API 2020. 8. 27. 17:59
    728x90
    반응형

     

    이번 글에서는 비밀번호 암호화 개념에 대해 정리하고 스프링부트를 이용해서 비밀번호 암호화를 하여 DB에 저장하는 방법에 대해 정리해보려 한다.

     

    단방향 해시 함수

    보통 비밀번호를 저장하는 방법은 2가지 정도가 있다.

    • 단순 텍스트(plain text)
    • 단방향 해시 함수(one-way hash function)의 다이제스트(digest)

     

    단순텍스트로 패스워드를 저장하는 것은 범죄를 저지르는 것이나 다름없다. DB가 해킹당하는 순간, 비밀번호를 그대로 해커들에게 넘겨주게 되기 때문이다. 그러면 DB를 해킹당하지 않으면 되지 않나? 라고 할 수 있다. 물론 애초에 DB를 해킹당하지 않도록 보안에 신경써야 겠지만 해야겠지만,  다양한 변수의 상황들이 존재하기 때문에 언제든 해킹 당할 가능성이 존재한다. 따라서 해시함수를 사용하여야 한다.

     

    단방향 해시 함수는 수학적인 연산을 통해 원본 메세지를 변환하여 암호화된 메세지인 다이제스트를 생성한다. 원본 메세지를 알면 암호화된 메세지를 구하기는 쉽지만 암호화된 원본 메세지를 구할 수 없어야 하며 이를 '단방향성'이라고 한다. 단방향 암호화는 복호화할 수 없는 암호화 방법이다. 복호화란 문자열을 다시 원래 문자열로 돌려놓는 것을 의미한다.

     

    단방향 암호화를 사용하는 이유에 대해 생각해보면, 홈페이지 비밀번호같은 경우는 복호화할 이유가 없고, 운영자들이 사용자들의 비밀번호를 알아야할 이유가 없다. 그래서 대부분의 사이트에서 비밀번호를 잃어버렸을 때 찾으려면 비밀번호를 알려주는 것이 아니라 다시 설정해야 할 것이다. 따라서 DB에 암호화된 비밀번호를 저장해놓고, 나중에 사용자가 로그인할 때 다시 입력받은 비밀번호를 같은 알고리즘으로 암호화해서 DB에 저장된 문자열과 비교하여 인증절차를 거치게 된다.

     

     

    이제 해시함수에 대해 예시를 보면서 좀 더 자세히 알아보자. 만약 사용자의 비밀번호가 "hunter" 라면 문자열을 암호화할 때 흔히 사용하는 해시 알고리즘인 SHA-256으로 인코딩하면 아래와 같은 값을 얻을 수 있다.

    f52fbd32b2b3b86ff88ef6c490628285f482af15ddcb29541f94bcf526a3f6c7

     이렇게 저장하면 사용자의 패스워드를 직접 저장하는 위험을 피할 수 있다. 이번에는 "hunter3"라는 값을 SHA-256 알고리즘을 이용하여 인코딩해보자.

    fb8c2e2b85ca81eb4350199faddd983cb26af3064614e737ea9f479621cfa57a  

    그러면 위와 같이 완전히 달라진 값이 나온 것을 알 수 있다. 이 특징을 avalanche 효과라고 하며, 사용자의 원본 패스워드를 추론하기 어렵게 만드는 중요한 요소이다. 그러나 이것만으로는 패스워드 보안이 충분히 안전하다고 말할 수 없다. 왜냐하면 원본 메세지에 같은 알고리즘을 사용하면 매번 같은 결과가 나오기 때문이다.

     

    동일한 메시지가 언제나 동일한 다이제스트를 갖는다면, 공격자가 전처리(pre-computing)된 다이제스트를 가능한 한 많이 확보한 다음 이를 탈취한 다이제스트와 비교해 원본 메시지를 찾아내거나 동일한 효과의 메시지를 찾을 수 있다. 이와 같은 다이제스트 목록을 레인보우 테이블(rainbow table)이라 하고, 이와 같은 공격 방식을 레인보우 공격(rainbow attack)이라 한다. 게다가 다른 사용자의 패스워드가 같으면 다이제스트도 같으므로 한꺼번에 모두 정보가 탈취될 수 있다.

     

     

    단방향 해시 함수 보완하기

    솔트(salt)는 단방향 해시 함수에서 다이제스트를 생성할 때 추가되는 바이트 단위의 랜덤의 문자열이다. 그리고 이 원본 메시지에 문자열을 추가하여 다이제스를 생성하는 것을 솔팅(salting)이라 한다. 예를들어 다음과 같이 "redfl0wer"에 솔트인 "8zff4fgflgfd93fgdl4fgdgf4mlf45p1"를 추가해 다이제스트를 생성할 수 있다. 

    출처 : https://d2.naver.com/helloworld/318732

     

    이 방법을 사용하면, 공격자가 "redfl0wer"의 다이제스트를 알아내더라도 솔팅된 다이제스트를 대상으로 패스워드 일치 여부를 확인하기 어렵다. 또한 사용자별로 다른 솔트를 사용한다면 동일한 패스워드를 사용하는 사용자의 다이제스트가 다르게 생성되어 인식 가능성 문제가 크게 개선된다.

     

    입력한 패스워드의 다이제스트를 생성하고, 생성된 다이제스트를 입력 값으로 하여 또 다른 다이제스트를 생성할 수 있다. 

    출처 : https://d2.naver.com/helloworld/318732

    이렇게 N번에 걸쳐서 하는 이유는 억지 기법 공격(brute-force-attack)으로 패스워드를 추측하는데 많은 시간이 소요되도록 하기 위함이다.

     

     

     

    이제 스프링부트에서 비밀번호 암호화 하는 예제를 진행해보자.

    pom.xml

    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-security</artifactId>
    </dependency>

    Spring에서 인증/인가 등의 처리가 필요할 때 사용하는 security 의존성을 pom.xml에 추가하자.

    스프링에서 비밀번호를 암호화하기 할 때 사용하는 가장 대표적인 PasswordEncoder 인터페이스를 이용하여 비밀번호 암호화를 진행해보자. 

     

     

    PasswordEncoder란?

    Spring Security에서는 비밀번호를 안전하게 저장할 수 있도록 비밀번호의 단방향 암호화를 지원하는 PasswordEncoder 인터페이스와 구현체들을 제공한다. 그리고 PasswordEncoder 인터페이스 내부는 아래와 같다.

    public interface PasswordEncoder {
    
    	// 비밀번호 단방향 암호화
    	String encode(CharSequence rawPassword);
        
            // 암호화되지 않은 비밀번호(raw)와 암호화된 비밀번호(encode)가 일치하는지 비교
    	boolean matches(CharSequence rawPassword, String encodedPassword);
    
    	// 기본적으로 false를 return, Custom하게 구현할 경우 이를 기반으로 더 강력한 암호화 구현
    	default boolean upgradeEncoding(String encodedPassword) {
    		return false;
    	}
    }

     

     

    Spring Security 5.3.3에서 공식 지원하는 PasswordEncoder 구현 클래스들은 아래와 같다.

    • BcryptPasswordEncoder : Bcrypt 해시 함수를 사용하여 비밀번호 암호화
    • Argon2PasswordEncoder : Argon2 해시 함수를 사용하여 비밀번호 암호화
    • Pbkdf2PasswordEncoder : Pbkdf2 해시 함수를 사용하여 비밀번호 암호화
    • SCryptPasswordEncoder : SCrypt 해시 함수를  사용하여 비밀번호 암호화

     

    이번에는 BcryptPasswordEncoder 클래스를 사용하여 암호화 예제를 진행할 것이고, 먼저 내부코드를 살펴보면 아래와 같다. 

    package org.springframework.security.crypto.bcrypt;
    
    import org.apache.commons.logging.Log;
    import org.apache.commons.logging.LogFactory;
    import org.springframework.security.crypto.password.PasswordEncoder;
    
    import java.security.SecureRandom;
    import java.util.regex.Matcher;
    import java.util.regex.Pattern;
    
    
    public class BCryptPasswordEncoder implements PasswordEncoder {
    
    	public String encode(CharSequence rawPassword) {
    		if (rawPassword == null) {
    			throw new IllegalArgumentException("rawPassword cannot be null");
    		}
    
    		String salt;
    		if (random != null) {
    			salt = BCrypt.gensalt(version.getVersion(), strength, random);
    		} else {
    			salt = BCrypt.gensalt(version.getVersion(), strength);
    		}
    		return BCrypt.hashpw(rawPassword.toString(), salt);
    	}
    
    	public boolean matches(CharSequence rawPassword, String encodedPassword) {
    		if (rawPassword == null) {
    			throw new IllegalArgumentException("rawPassword cannot be null");
    		}
    
    		if (encodedPassword == null || encodedPassword.length() == 0) {
    			logger.warn("Empty encoded password");
    			return false;
    		}
    
    		if (!BCRYPT_PATTERN.matcher(encodedPassword).matches()) {
    			logger.warn("Encoded password does not look like BCrypt");
    			return false;
    		}
    
    		return BCrypt.checkpw(rawPassword.toString(), encodedPassword);
    	}
    
    	@Override
    	public boolean upgradeEncoding(String encodedPassword) {
    		if (encodedPassword == null || encodedPassword.length() == 0) {
    			logger.warn("Empty encoded password");
    			return false;
    		}
    
    		Matcher matcher = BCRYPT_PATTERN.matcher(encodedPassword);
    		if (!matcher.matches()) {
    			throw new IllegalArgumentException("Encoded password does not look like BCrypt: " + encodedPassword);
    		}
    		else {
    			int strength = Integer.parseInt(matcher.group(2));
    			return strength < this.strength;
    		}
    	}
    }
    

    encode 메소드 내부코드를 보면 BCrypt.gensalt() 메소드를 통해서 salt를 Random으로 만드는 것을 알 수 있다. 그리고 BCrypt.hashpw() 메소드는 유저가 입력한 비밀번호와 랜덤 값 salt를 이용하여 비밀번호 암호화를 진행하게 된다.

     

    public class BCrypt {
    
    	public static String gensalt(String prefix, int log_rounds, SecureRandom random)
    			throws IllegalArgumentException {
    		StringBuilder rs = new StringBuilder();
    		byte rnd[] = new byte[BCRYPT_SALT_LEN];
    
    		if (!prefix.startsWith("$2") ||
    				(prefix.charAt(2) != 'a' && prefix.charAt(2) != 'y' &&
    						prefix.charAt(2) != 'b')) {
    			throw new IllegalArgumentException ("Invalid prefix");
    		}
    		if (log_rounds < 4 || log_rounds > 31) {
    			throw new IllegalArgumentException ("Invalid log_rounds");
    		}
    
    		random.nextBytes(rnd);
    
    		rs.append("$2");
    		rs.append(prefix.charAt(2));
    		rs.append("$");
    		if (log_rounds < 10)
    			rs.append("0");
    		rs.append(log_rounds);
    		rs.append("$");
    		encode_base64(rnd, rnd.length, rs);
    		return rs.toString();
    	}
    }
    

    gensalt() 메소드 내부는 위와 같이 되어 있고 랜덤으로 salt 값을 만들어주는 메소드이고 내부코드는 가볍게 보면 될 것 같다.

     

    이제 포스트맨을 이용하여 사용자 로그인 정보가 서버로 전달되었을 때 비밀번호를 암호화 하여 DB에 저장하는 것을 해보려 한다. 

    스프링부트에서 MySQL, MyBatis 사용법은 아래의 블로그에서 설정하고 예제를 따라하면 될 것 같다.

     

     

    1. Config 클래스  설정

    import org.springframework.context.annotation.Bean;
    import org.springframework.context.annotation.Configuration;
    import org.springframework.security.config.annotation.web.builders.HttpSecurity;
    import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
    import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
    import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
    import org.springframework.security.crypto.password.PasswordEncoder;
    
    @Configuration
    @EnableWebSecurity
    public class JavaConfig extends WebSecurityConfigurerAdapter {
    
        @Bean
        public PasswordEncoder getPasswordEncoder() {
            return new BCryptPasswordEncoder();
        }
    
        @Override
        protected void configure(HttpSecurity http) throws Exception {
            http
                    .cors().disable()      // cors 비활성화
                    .csrf().disable()      // csrf 비활성화
                    .formLogin().disable() //기본 로그인 페이지 없애기
                    .headers().frameOptions().disable();
        }
    }

    PasswordEncoder 인터페이스는 Bean으로 등록되어 있지 않기 때문에 따로 Bean으로 등록해야 한다. 방법은 위와 같다.

     

    • @Configuration : 설정파일이라는 것을 알려주는 어노테이션
    • @Bean : 빈으로 등록하는 어노테이션 (return 타입이 주입됨)

     

    Config 클래스에서 WebSecurityConfigurerAdapter 클래스를 상속받아 configure를 오버라이딩 하고, 파라미터인 HttpSecurity 클래스를 이용하여 설정한다. 

     

    먼저 클라이언트에게 REST API 응답을 보내는 방법이 궁금하다면 여기의 글을 먼저 읽고 다시 읽는 것을 추천한다.

     

    Member 클래스

    import lombok.Data;
    
    @Data
    public class Member {
        private int memberIdx;
        private String id;
        private String password;
    }

    MySQL에서 Member 테이블을 위와 같이 만든 후에, Java 코드로 Member 클래스를 만들었다.

     

     

    Controller

    import com.example.demo.dto.Member;
    import com.example.demo.model.DefaultRes;
    import com.example.demo.service.MemberService;
    import lombok.extern.slf4j.Slf4j;
    import org.springframework.http.HttpStatus;
    import org.springframework.http.ResponseEntity;
    import org.springframework.web.bind.annotation.PostMapping;
    import org.springframework.web.bind.annotation.RequestBody;
    import org.springframework.web.bind.annotation.RequestMapping;
    import org.springframework.web.bind.annotation.RestController;
    
    @RestController
    @Slf4j
    @RequestMapping("member")
    public class MemberController {
    
        MemberService memberService;
    
        // Service 생성자 의존성 주입
        public MemberController(MemberService memberService) {
            this.memberService = memberService;
        }
    
        // 회원가입
        @PostMapping("signUp")
        public ResponseEntity signUp(@RequestBody Member member) {
            try {
                return new ResponseEntity(memberService.signUp(member), HttpStatus.OK);
            } catch (Exception e) {
                log.info(e.getMessage());
                return new ResponseEntity(DefaultRes.FAIL_DEFAULT_RES, HttpStatus.INTERNAL_SERVER_ERROR);
            }
        }
    
        // 로그인
        @PostMapping("signIn")
        public ResponseEntity siginIn(@RequestBody Member member) {
            try {
                return new ResponseEntity(memberService.signIn(member), HttpStatus.OK);
            } catch (Exception e) {
                log.error(e.getMessage());
                return new ResponseEntity(DefaultRes.FAIL_DEFAULT_RES, HttpStatus.INTERNAL_SERVER_ERROR);
            }
        }
    }

    Controller 계층과 Service 계층을 분리하였고 Controller에서는 위와 같이 로그인, 회원가입 URL 맵핑을 하였다.

     

     

    Service

    import com.example.demo.dto.Member;
    import com.example.demo.mapper.MemberMapper;
    import com.example.demo.model.DefaultRes;
    import com.example.demo.utils.ResponseMessage;
    import com.example.demo.utils.StatusCode;
    import lombok.extern.slf4j.Slf4j;
    import org.springframework.security.crypto.password.PasswordEncoder;
    import org.springframework.stereotype.Service;
    import org.springframework.transaction.annotation.Transactional;
    import org.springframework.transaction.interceptor.TransactionAspectSupport;
    
    @Service
    @Slf4j
    public class MemberService {
    
        private final MemberMapper memberMapper;
        private final PasswordEncoder passwordEncoder;
    
        /**
         * UserMapper 생성자 의존성 주입
         *
         * @param userMapper, passwordEncoder
         */
        public MemberService(MemberMapper memberMapper, PasswordEncoder passwordEncoder) {
            this.memberMapper = memberMapper;
            this.passwordEncoder = passwordEncoder;
        }
    
        /**
         * 로그인
         *
         * @return DefaultRes
         */
    
        public DefaultRes signIn(final Member member) {
            try {
                // 회원아이디 체크
                Member m = memberMapper.checkById(member.getId());
                
                // 아이디가 틀렸을 때
                if (m == null) {
                    return new DefaultRes(StatusCode.BAD_REQUEST, ResponseMessage.LOGIN_FAIL);
                }
                
                // parameter1 : rawPassword, parameter2 : encodePassword
                boolean check = passwordEncoder.matches(member.getPassword(), m.getPassword());
    
                // 로그인 성공
                if (check) {
                    return new DefaultRes(StatusCode.OK, ResponseMessage.LOGIN_SUCCESS);
                }
    
                return new DefaultRes(StatusCode.BAD_REQUEST, ResponseMessage.LOGIN_FAIL);
    
            } catch (Exception e) {
                log.error(e.getMessage());
                return new DefaultRes(StatusCode.DB_ERROR, ResponseMessage.DB_ERROR);
            }
        }
    
    
        /**
         * 회원가입
         *
         * @return DefaultRes
         */
        @Transactional
        public DefaultRes signUp(final Member member) {
            try {
                // 아이디 중복 체크
                final Member m = memberMapper.checkById(member.getId());
    
                // 이미 유저가 존재할 때
                if (m != null) {
                    return DefaultRes.res(StatusCode.BAD_REQUEST, ResponseMessage.ALREADY_USER);
                }
                // 비밀번호 암호화
                String encodePassword = passwordEncoder.encode(member.getPassword());
                member.setPassword(encodePassword);
                memberMapper.insertMember(member);
                return DefaultRes.res(StatusCode.OK, ResponseMessage.CREATED_USER);
            } catch (Exception e) {
                //Rollback
                TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();
                log.error(e.getMessage());
                return DefaultRes.res(StatusCode.DB_ERROR, ResponseMessage.DB_ERROR);
            }
        }
    }

    Service 계층에서는 비밀번호를 암호화하기 위해 Bean으로 등록했던 PasswordEncoder 인터페이스와 데이터베이스를 연결하기 위한 Mapper 인터페이스를 주입을 받는다.

     

    위의 코드에서 DB에 접근하여 회원가입, 로그인 로직이 진행된다. 먼저 회원가입을 보면서 비밀번호 암호화 예제를 이해해보자.

     

    회원가입

    /**
     * 회원가입
     *
     * @return DefaultRes
     */
    @Transactional
    public DefaultRes signUp(final Member member) {
        try {
            // 아이디 중복 체크
            final Member m = memberMapper.checkById(member.getId());
    
            // 이미 유저가 존재할 때
            if (m != null) {
                return DefaultRes.res(StatusCode.BAD_REQUEST, ResponseMessage.ALREADY_USER);
            }
            // 비밀번호 암호화
            String encodePassword = passwordEncoder.encode(member.getPassword());
            member.setPassword(encodePassword);
            memberMapper.insertMember(member);
            return DefaultRes.res(StatusCode.OK, ResponseMessage.CREATED_USER);
        } catch (Exception e) {
            //Rollback
            TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();
            log.error(e.getMessage());
            return DefaultRes.res(StatusCode.DB_ERROR, ResponseMessage.DB_ERROR);
        }
    }

    먼저 파라미터로 사용자가 입력한 ID, password를 Member 클래스를 통해서 받아온다. 그리고 주입받은 PasswordEncoder 인터페이스의 encode 메소드를 사용하여 암호화를 진행하면 된다.

    public String encode(CharSequence rawPassword) {
    	if (rawPassword == null) {
    		throw new IllegalArgumentException("rawPassword cannot be null");
    	}
    
    	String salt;
    	if (random != null) {
    		salt = BCrypt.gensalt(version.getVersion(), strength, random);
    	} else {
    		salt = BCrypt.gensalt(version.getVersion(), strength);
    	}
    	return BCrypt.hashpw(rawPassword.toString(), salt);
    }

    위의 코드를 보면 알 수 있듯이, encode 메소드는 내부적으로 BCrypt.gensalt 메소드를 통해서 salt를 랜덤으로 만들어 암호화를 해준다

     

     

    이제 PostMan을 이용하여 암호화가 제대로 되어 DB에 저장되는지 확인해보자.

    [POST]
    http://localhost:8080/member/signUp
    {
        "id" : "wjdrbs",
        "password" : "1234"
    }

    POST방식으로 Request Body에 위의 JSON 형식을 담아 요청을 보내보자.

    memberIdx id password
    1 wjdrbs $2a$10$TuKGiVuLJl3xhaVPDNj3EOcjDyKrMcFcc7m.d.PsFX7UjbTgrl1Ju

    그러면 위와 같이 암호화되어 테이블에 저장이 된 것을 확인할 수 있다.

     

    이번에는 암호화된 비밀번호를 이용하여 어떻게 로그인 요청을 처리할 지에 대해서 알아보자.

     

    로그인

    /**
     * 로그인
     *
     * @return DefaultRes
     */
    
    public DefaultRes signIn(final Member member) {
        try {
            // 회원아이디 체크
            Member m = memberMapper.checkById(member.getId());
                
            // 아이디가 틀렸을 때
            if (m == null) {
                return new DefaultRes(StatusCode.BAD_REQUEST, ResponseMessage.LOGIN_FAIL);
            }
                
            // parameter1 : rawPassword, parameter2 : encodePassword
            boolean check = passwordEncoder.matches(member.getPassword(), m.getPassword());
    
            // 로그인 성공
            if (check) {
                return new DefaultRes(StatusCode.OK, ResponseMessage.LOGIN_SUCCESS);
            }
    
            return new DefaultRes(StatusCode.BAD_REQUEST, ResponseMessage.LOGIN_FAIL);
    
        } catch (Exception e) {
            log.error(e.getMessage());
            return new DefaultRes(StatusCode.DB_ERROR, ResponseMessage.DB_ERROR);
        }
    }

    이번에는 PasswordEncoder 인터페이스의 메소드인 matches 메소드를 사용하면 된다. 첫 파라미터에는 사용자의 원본 password를 넣어주고 두 번째 파라미터에는 DB에 저장되어 있던 암호화된 비밀번호를 넣어준다. (그런데 내부로직이 정확히 어떻게 되어 있어서 원본패스워드와 암호화된 패스워드를 비교해 true, false를 반환하는지 모르겠다. 원본패스워드 + salt = 암호화된 패스워드인데 salt를 저장하지 않았는데 말이다)

     

     

    Reference

    https://www.zerocho.com/category/NodeJS/post/593a487c2ed1da0018cff95d

    https://velog.io/@corgi/Spring-Security-PasswordEncoder%EB%9E%80-4kkyw8gi

    https://d2.naver.com/helloworld/318732

    반응형

    댓글

Designed by Tistory.