스프링 시큐리티 1. 암호화(PasswordEncoder를 통한 회원 가입 구현)

2024. 3. 16. 00:23Spring/Spring Security

[ 암호화가 필요한 이유 ]

 스프링 시큐리티를 통해 자체적으로 계정을 만든다면, 유저의 아이디와 비밀번호를 DB에 저장을 할 것이다. 많은 스프링 유튜브 강의에서 로그인을 할 때 비밀번호를 암호화해서 DB에 저장하는 걸 알려주지 않는다. 그래서 처음 로그인을 구현했을 때 이를 지키지 않았던 기억이 난다. 그래서 보안으로 반드시 기억해야 할 내용은 유저의 패스워드는 반드시 암호화를 해제할 수 없도록 단방향 암호화를 해서 저장해야 한다는 것이다.

 

 그 이유는 DB 속의 내용은 안전하지 않다. SQL injection과 같은 방식으로 DB 속의 데이터가 갈취 당할 수 있으며 그 내용은 유저의 아이디와 비밀번호 테이블에서 가져올 내용일 수도 있다. 이 때 공격자에게 아이디와 비밀번호를  갈취당하더라도 서비스 상에선 아무런 문제가 없어야 한다. 그러기 위해선 유저의 패스워드를 단방향으로 암호화해야 한다. Encryption을 통한 양방향을 고려해볼 수도 있지만 양방향의 경우엔 암호화 키가 갈취 당하거나 컴퓨터 리소스의 증가로 무차별 대입을 통해 원래 비밀번호를 알아낼 수 있다. 이런 이유로 갈취당해도 알지 못하는 단방향 해싱을 비밀번호 암호화로 스프링 시큐리티는 권장하고 있다.

 

 스프링 시큐리티에서는 password를 암호화 할 수 있게 PasswordEncoder를 제공한다. bcypt, PBKDF2, scrypt, argon2 같은 인코더를 제공하지만 현재 널리 쓰이는 건 BCryptPassword 인코더이다. BCryptPassword 인코더는 생성시 해싱 반복횟수를 정함으로써 암호화 강도를 높일 수 있다. 암호화 강도를 높일수록 컴퓨터 내의 자원을 사용한다. 기본 값이 10이지만 Spring 공식 문서에 따르면 컴퓨터 환경에 따라 로그인 과정이 1초가 걸리도록 테스트하고 사용하라고 한다.

 

 이렇게 시간을 임의로 늘리는 이유는 해시는 무차별 대입에는 뚫릴 수 있기 때문이다. 해시로 암호화를 하더라도 무차별 공격을 공격자가 생각할 수 없게 유저에게 암호를 복잡하게 만들도록 강제해야 한다. 또한 무차별 공격 당 걸리는 시간을 지연시켜 비밀번호를 해킹하는데 걸리는 시간을 최대한 지연시켜주어야 한다.

 

 해싱 알고리즘을 사용해서 암호화를 하면 그럼 나중에 검증은 어떻게 하는 지도 궁금할 것이다. 해싱 알고리즘 같은 패스워드 "12345"로 해싱을 하더라도 결과값이 다를 수 있다. 하지만 해싱 알고리즘은 비가역적이라 해석은 못하지만 둘이 같은 값인지 비교는 가능하다. 

 

 아래의 사이트에서 암호화를 진행하고 비교해볼 수 있는데 Encrypt에서는 해싱된 결과를 확인하고 Decrypt를 통해 비교가 가능하다는 걸 확인해보자.

https://bcrypt-generator.com/

 

Bcrypt-Generator.com - Online Bcrypt Hash Generator and Checker

Bcrypt-Generator.com is a online tool to check Bcrypt hashes. You can also use it to generate new Bcrypt hashes for your other applications that require a Bcrypt encrypted string or password

bcrypt-generator.com


[ 회원가입 구현 ]

 스프링 시큐리티를 사용해서 회원가입을 구현하고 해당 내용을 DB로 넣기 위해서는 PasswordEncoder와 SecurityFilterChain을 스프링 빈에 등록해주어야 한다. 이 외에는 스프링 JPA와 관련된 내용들이 대부분이다. 이와 MySQL 관련 내용은 알 것이라 가정하고 코드를 작성하도록 하겠다.

 

 가장 먼저 확인해야 할 부분은 스프링 의존성이다. 처음 스프링 프로젝트를 만들 때 다음처럼 spring web, mysql, jpa, spring security 의존성이 아래처럼 추가되었는지를 확인하자.

 

build.gradle

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    implementation 'org.springframework.boot:spring-boot-starter-security'
    implementation 'org.springframework.boot:spring-boot-starter-web'
    runtimeOnly 'com.mysql:mysql-connector-j'
    compileOnly 'org.projectlombok:lombok'
    annotationProcessor 'org.projectlombok:lombok'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
    testImplementation 'org.springframework.security:spring-security-test'
}

 

  그 다음 할 일은 회원 객체를 만들어야 한다. user라는 객체는 스프링 시큐리티에서 기본적으로 제공해주지만 여러 비즈니스 로직와 함께 이용하는 편의를 위해선 커스텀 객체를 만드는 게 좋다. 따라서 Member라는 이름으로 객체를 만들었다.

 

Member.java

@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
public class Member {

    @Id @GeneratedValue
    @Column(name = "member_id")
    private Long id;

    private String userEmail;
    private String password;

    private String username;

    @OneToMany(mappedBy = "member")
    private List<Authority> authorities = new ArrayList<>();

    public Member(String userEmail, String password, String username) {
        this.userEmail = userEmail;
        this.password = password;
        this.username = username;
    }

    public void addAuthorities(List<Authority> authorities){
        authorities.stream().forEach(authority ->{
            authority.setMember(this);
            this.authorities.add(authority);
        });
    }

    public void setEncodedPassword(String password){
        this.password = password;
    }

}

 

 또한 이후에 역할도 만들어 볼 것이기 때문에 Authority라는 이름으로 객체를 만들었다. 객체 연관관계는 Member 객체와 Authority 객체를 보면 다대일로 양방향 연관관계로 설정해놓았음을 기억하자.

 

Authority.java

@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter @Setter
public class Authority {

    @Id @GeneratedValue
    @Column(name = "authority_id")
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "member_id") @JsonIgnore
    private Member member;
    private String role;

    public Authority(String role) {
        this.role = role;
    }
}

 

 이렇게 엔티티 설계가 끝났다면 리포지토리는 다음과 같이 스프링 데이터 JPA로 편하게 썼다. 딱 하나 MemberRepository 부분만 페치 조인으로 한꺼번에 member를 조회하면 권한도 함께 다 조회할 수 있게 설정해놓았다. 또한 findByUserEmail로 유저가 존재하는지 확인하는 메서드도 추가해두었다.

 

MemberRepository.java

public interface MemberRepository extends JpaRepository<Member, Long> {

    @Query("select m from Member m join fetch m.authorities where m.userEmail = :userEmail")
    Optional<Member> findByUserEmailWithAuthorities(@Param("userEmail") String userEmail);

    Member findByUserEmail(String userEmail);
}

 

AuthorityRepository.java

public interface AuthorityRepository extends JpaRepository<Authority, Long> {
}

 

 서비스 계층은 dto 내부의 password를 암호화한 뒤 Member를 dto를 통해 생성하고 암화한 비밀번호로 바꾼다. 그리고 회원가입된 유저는 모두 일반 유저라 생각하고 권한을 부여해서 DB에 저장된다. LoginDto에서 createMemberByLoginDto에서 혹시나 값이 없는 상황을 대비해 언체크 예외인 RuntimeException을 던져주었다. 이러면 LoginService에서 @Transactional 부분에서 언체크 예외가 터지는 순간 롤백된다.

 

LoginDto.java

@Data
public class LoginDto {
    private String email;
    private String password;
    private String username;

    public Member createMemberByLoginDto(){
        if(email == null || password == null || username == null){
            throw new RuntimeException("Login is failed");
        }
        return new Member(email, password, username);
    }
}

 

LoginService.java

@Service
@RequiredArgsConstructor
public class LoginService {

    private final MemberRepository memberRepository;
    private final AuthorityRepository authorityRepository;
    private final PasswordEncoder passwordEncoder;

    @Transactional
    public Member saveMember(LoginDto dto){
        String password = passwordEncoder.encode(dto.getPassword());
        Member member = dto.createMemberByLoginDto();
        member.setEncodedPassword(password);

        List<Authority> authorities = List.of(new Authority("ROLE_USER"));
        member.addAuthorities(authorities);
        authorityRepository.save(authorities.get(0));
        return memberRepository.save(member);
    }
}

 

 이제 컨트롤러를 작성해보자. 컨트롤러에선 dto 값을 받아서 잘 저장됐으면 ok를 보낸다.언체크 예외로 RuntimeException을 던진 건 서버 내부의 문제가 아니고 외부에서 유저가 잘못 보낸 요청이니 400번대 에러를 보냈다. 또한 비밀번호 검증로직을 붙여 비밀번호가 반드시 대문자, 소문자, 특수문자를 포함하고 8자리에서 15자리가 아니면 400번대 에러를 반환고 이미 있는 회원인 경우에도 400번대 에러를 반환하게 작성했다. 이 외의 DB 관련 예외는 Exception에 잡혀 500번대 에러를 내보냈다.

 

LoginController.java

@RestController
@RequiredArgsConstructor
public class LoginController {

    private final LoginService loginService;

    @PostMapping("/register")
    public ResponseEntity<String> registerUser(@RequestBody LoginDto dto){
        Member savedMember = null;
        ResponseEntity response = null;
        try{
            if(dto.getPassword() == null || !checkPassword(dto.getPassword()) || loginService.memberExists(dto.getEmail())){
                throw new RuntimeException("Password has Problem");
            }
            savedMember = loginService.saveMember(dto);
            if(savedMember.getId() > 0){
                response = ResponseEntity
                        .status(HttpStatus.CREATED)
                        .body("Member is successfully registered");
            }
        } catch(RuntimeException ex){
            response = ResponseEntity.status(HttpStatus.BAD_REQUEST)
                    .body("An exception occured due to " + ex.getMessage());
        } catch(Exception ex){
            response = ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
                    .body("An exception occured due to " + ex.getMessage());
        }
        return response;
    }

    public static boolean checkPassword(String password) {
        // 비밀번호 길이가 8자에서 15자 사이인지 확인
        if (password.length() < 8 || password.length() > 15) {
            return false;
        }

        // 대문자, 소문자, 특수문자가 모두 포함되었는지 확인
        boolean hasUpperCase = false;
        boolean hasLowerCase = false;
        boolean hasSpecialChar = false;
        for (char c : password.toCharArray()) {
            if (Character.isUpperCase(c)) {
                hasUpperCase = true;
            } else if (Character.isLowerCase(c)) {
                hasLowerCase = true;
            } else if (!Character.isLetterOrDigit(c)) {
                hasSpecialChar = true;
            }
        }

        return hasUpperCase && hasLowerCase && hasSpecialChar;
    }
}

 

 위까지의 작업을 그대로 실행을 하면 작동이 안될 것이다. 그 이유는 스프링 시큐리티에서 인증되지 않은 클라이언트가 계속 접근했다고 판단하기 때문이다. 따라서 이를 회원가입 부분에서는 인증되지 않은 클라이언트도 접근 가능하게 해주어야 한다. 스프링 부트 3.2 버전이라 내부에서 request를 다음처럼 람다식으로 작성해주어야 한다. 그리고 아직 csrf 설정과 Login 같은 설정은 하지 않을 것이므로 끄거나 기본 설정으로 해놓았다.

 

SecurityConfig.java

@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception{
    http.csrf((csrf) -> csrf.disable())
            .authorizeHttpRequests((requests) -> requests
                    .requestMatchers("/register").permitAll())
                .formLogin(Customizer.withDefaults())
                .httpBasic(Customizer.withDefaults());

    return http.build();
}

 

또한 스프링에서 기본적으로 제공하는 PasswordEncoder는 DelegatingPasswordEncoder인데 BCryptPasswordEncoder를 통해 암호화를 하고 싶다면 스프링 빈에 PasswordEncoder를 BCryptPassword Encoder로 다음처럼 등록해주면 된다. 참고로 생성자에 숫자 값을 주면 라운드 수를 정해서 암호화 시간을 변경할 수 있다.

 

SecurityConfig.java

@Bean
public PasswordEncoder passwordEncoder(){
    return new BCryptPasswordEncoder();
}

 

 이제 PostMan을 키고 회원가입을 해보면 다음처럼 값이 뜰 것이다. 그러면 성공이다! 

 


[ 참고 자료 ]

https://docs.spring.io/spring-security/reference/features/authentication/password-storage.html#authentication-password-storage-dpe

Spring Security 6 초보에서 마스터되기 최신 강의! - Eazy Bytes