본문 바로가기
개발/게시판 만들기

[Vue] Vue.js 게시판 만들기 12 - DB 변경과 시큐리티 설정

by onethejay 2022. 6. 22.
728x90

H2 DB에서 MariaDB로 변경

이전의 백엔드 프로젝트는 H2 Database를 사용했습니다.
로그인 이후 데이터 관리 등을 위해 MariaDB를 세팅하고 사용하도록 합니다.
도커 혹은 로컬에 MariaDB나 MySQL을 설치해주세요. (도커에 MariaDB 설치하기)

vue-backboard의 application.yml 내용을 변경합니다. (jpa와 datasource 내용이 변경되었습니다.)
datasource의 url과 username, password는 각자의 설정에 맞게 변경해주세요.

server:
  port: 8081

spring:
  jackson:
    property-naming-strategy: SNAKE_CASE

  h2:
    console:
      enabled: true
      settings:
        web-allow-others: true
      path: /h2-console

  jpa:
    show-sql: true

  datasource:
    driver-class-name: com.mysql.cj.jdbc.Driver
    url: jdbc:mysql://127.0.0.1:3306/mydb?useUnicode=true&characterEncoding=UTF-8&autoReconnect=true&serverTimezone=Asia/Seoul
    username: root
    password: password

이어서 build.gradle의 dependencies에 아래 내용을 추가합니다.

implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'javax.validation:validation-api:2.0.1.Final'
implementation 'com.auth0:java-jwt:3.14.0'
runtimeOnly  'mysql:mysql-connector-java'

gradle refresh와 build를 진행하고 서버를 시작합니다.
DB와 연결할 수 없으면 에러 메세지가 나타나면서 서버가 정상적으로 시작되지 않습니다.

DB와 정상적으로 연결되었다면 서버는 이상없이 시작됩니다.

이전에 사용했던 BOARD 테이블을 추가하고 기본 데이터를 입력해주도록 합니다.

CREATE TABLE TB_BOARD (
    IDX BIGINT(20) NOT NULL AUTO_INCREMENT,
    TITLE VARCHAR(50) NULL DEFAULT NULL COLLATE 'utf8mb4_general_ci',
    CONTENTS TEXT NULL DEFAULT NULL COLLATE 'utf8mb4_general_ci',
    AUTHOR VARCHAR(50) NULL DEFAULT NULL COLLATE 'utf8mb4_general_ci',
    CREATED_AT DATETIME NULL DEFAULT NULL,
    PRIMARY KEY (IDX) USING BTREE
)
    COMMENT='게시판 테이블'
    COLLATE='utf8mb4_general_ci'
    ENGINE=InnoDB
;
INSERT INTO TB_BOARD (IDX, TITLE, CONTENTS, AUTHOR, CREATED_AT) VALUES (1, '게시글 제목1', '게시글 내용1', '작성자1', '2022-02-18 23:24:00');
INSERT INTO TB_BOARD (IDX, TITLE, CONTENTS, AUTHOR, CREATED_AT) VALUES (2, '게시글 제목2', '게시글 내용2', '작성자2', '2022-02-18 23:24:00');
INSERT INTO TB_BOARD (IDX, TITLE, CONTENTS, AUTHOR, CREATED_AT) VALUES (3, '게시글 제목3', '게시글 내용3', '작성자3', '2022-02-18 23:24:00');
INSERT INTO TB_BOARD (IDX, TITLE, CONTENTS, AUTHOR, CREATED_AT) VALUES (4, '게시글 제목4', '게시글 내용4', '작성자4', '2022-02-18 23:24:00');
INSERT INTO TB_BOARD (IDX, TITLE, CONTENTS, AUTHOR, CREATED_AT) VALUES (5, '게시글 제목5', '게시글 내용5', '작성자5', '2022-02-18 23:24:00');
INSERT INTO TB_BOARD (IDX, TITLE, CONTENTS, AUTHOR, CREATED_AT) VALUES (6, '게시글 제목6', '게시글 내용6', '작성자6', '2022-02-18 23:24:00');
INSERT INTO TB_BOARD (IDX, TITLE, CONTENTS, AUTHOR, CREATED_AT) VALUES (7, '게시글 제목7', '게시글 내용7', '작성자7', '2022-02-18 23:24:00');
INSERT INTO TB_BOARD (IDX, TITLE, CONTENTS, AUTHOR, CREATED_AT) VALUES (8, '게시글 제목8', '게시글 내용8', '작성자8', '2022-02-18 23:24:00');
INSERT INTO TB_BOARD (IDX, TITLE, CONTENTS, AUTHOR, CREATED_AT) VALUES (9, '게시글 제목9', '게시글 내용9', '작성자9', '2022-02-18 23:24:00');
INSERT INTO TB_BOARD (IDX, TITLE, CONTENTS, AUTHOR, CREATED_AT) VALUES (10, '게시글 제목10', '게시글 내용10', '작성자10', '2022-02-18 23:24:00');
INSERT INTO TB_BOARD (IDX, TITLE, CONTENTS, AUTHOR, CREATED_AT) VALUES (11, '게시글 제목11', '게시글 내용11', '작성자11', '2022-02-18 23:24:00');
INSERT INTO TB_BOARD (IDX, TITLE, CONTENTS, AUTHOR, CREATED_AT) VALUES (12, '게시글 제목12', '게시글 내용12', '작성자12', '2022-02-18 23:24:00');
INSERT INTO TB_BOARD (IDX, TITLE, CONTENTS, AUTHOR, CREATED_AT) VALUES (13, '게시글 제목13', '게시글 내용13', '작성자13', '2022-02-18 23:24:00');
INSERT INTO TB_BOARD (IDX, TITLE, CONTENTS, AUTHOR, CREATED_AT) VALUES (14, '게시글 제목14', '게시글 내용14', '작성자14', '2022-02-18 23:24:00');
INSERT INTO TB_BOARD (IDX, TITLE, CONTENTS, AUTHOR, CREATED_AT) VALUES (15, '게시글 제목15', '게시글 내용15', '작성자15', '2022-02-18 23:24:00');
INSERT INTO TB_BOARD (IDX, TITLE, CONTENTS, AUTHOR, CREATED_AT) VALUES (16, '게시글 제목16', '게시글 내용16', '작성자16', '2022-02-18 23:24:00');
INSERT INTO TB_BOARD (IDX, TITLE, CONTENTS, AUTHOR, CREATED_AT) VALUES (17, '게시글 제목17', '게시글 내용17', '작성자17', '2022-02-18 23:24:00');
INSERT INTO TB_BOARD (IDX, TITLE, CONTENTS, AUTHOR, CREATED_AT) VALUES (18, '게시글 제목18', '게시글 내용18', '작성자18', '2022-02-18 23:24:00');
INSERT INTO TB_BOARD (IDX, TITLE, CONTENTS, AUTHOR, CREATED_AT) VALUES (19, '게시글 제목19', '게시글 내용19', '작성자19', '2022-02-18 23:24:00');
INSERT INTO TB_BOARD (IDX, TITLE, CONTENTS, AUTHOR, CREATED_AT) VALUES (20, '게시글 제목20', '게시글 내용20', '작성자20', '2022-02-18 23:24:00');

이어서 사용자 관리를 위한 USER 테이블을 생성합니다.

CREATE TABLE TB_USER (
                         IDX BIGINT(20) NOT NULL AUTO_INCREMENT,
                         USER_ID VARCHAR(50) NULL DEFAULT NULL COLLATE 'utf8mb4_general_ci',
                         USER_PW VARCHAR(100) NULL DEFAULT NULL COLLATE 'utf8mb4_general_ci',
                         USER_NAME VARCHAR(50) NULL DEFAULT NULL COLLATE 'utf8mb4_general_ci',
                         PRIMARY KEY (IDX) USING BTREE,
                         UNIQUE INDEX USER_ID (USER_ID) USING BTREE
)
    COMMENT='유저 테이블'
    COLLATE='utf8mb4_general_ci'
    ENGINE=InnoDB
;

스프링 시큐리티와 UserEntity 생성 및 테스트

스프링에서 제공하는 시큐리티 라이브러리를 통해 강력한 보안 기능을 쉽게 적용할 수 있습니다.

먼저 config 패키지에 WebSecurityConfig.java 파일을 생성합니다.
비밀번호 암호화에 사용할 BCryptPasswordEncoder를 Bean으로 등록하고 기본 http 설정을 진행합니다. 주석처리 된 부분은 추후에 해제하여 사용합니다.

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
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.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;

//@RequiredArgsConstructor
@EnableWebSecurity
@Configuration
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
//    private final UserService userService;

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

//    @Bean
//    public AuthenticationManager authenticationManagerBean() throws Exception {
//        return super.authenticationManagerBean();
//    }

//    public void configure(AuthenticationManagerBuilder auth) throws Exception {
//        auth.userDetailsService(userService).passwordEncoder(passwordEncoder());
//    }

    public void configure(HttpSecurity http) throws Exception {
        http.csrf().disable().authorizeRequests() // 토큰을 활용하는 경우 모든 요청에 대해 접근이 가능하도록 함
                .anyRequest().permitAll()
                .and() // 토큰을 활용하면 세션이 필요 없으므로 STATELESS로 설정하여 Session을 사용하지 않는다.
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and() // form 기반의 로그인에 대해 비활성화 한다.
                .formLogin()
                .disable();
                //.addFilterBefore(tokenRequestFilter, UsernamePasswordAuthenticationFilter.class)

        http.cors();
    }

}

이어서 USER 테이블과 연결될 UserEntity를 entity 패키지 안에 생성합니다.
(BoardEntity의 Table 어노테이션의 name도 TB_BOARD로 변경해주세요.)

/* UserEntity.java */

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;

import javax.persistence.*;

@Data
@AllArgsConstructor
@NoArgsConstructor
@Builder
@Table(name="TB_USER")
@Entity
public class UserEntity {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long idx;
    private String userId;
    private String userPw;
    private String userName;
}

UserRepository도 같이 생성합니다.

/* UserRepository.java */

import org.springframework.data.jpa.repository.JpaRepository;

public interface UserRepository extends JpaRepository<UserEntity, Long> {

}

현재까지의 구성입니다.

이제 User를 테이블에 저장하는 테스트 코드를 작성하고 데이터를 확인해보겠습니다.

UserRepository.java에서 Ctrl + Shift + T 또는 마우스 오른쪽 클릭 -> Go To -> Test 를 선택하여 테스트 파일을 생성합니다.

import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.junit.jupiter.SpringExtension;

import javax.transaction.Transactional;

import static org.assertj.core.api.Assertions.assertThat;

@ExtendWith(SpringExtension.class)
@SpringBootTest
class UserRepositoryTest {
    @Autowired
    UserRepository userRepository;

    BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();

    @DisplayName("1. 유저 데이터 생성하기")
    @Test
    void test_1(){
        String encPassword = passwordEncoder.encode("test_password");
        UserEntity userEntity = UserEntity.builder()
                .userId("test_user")
                .userPw(encPassword)
                .userName("테스트유저")
                .build();

        UserEntity savedUser = userRepository.save(userEntity);
        assertThat(userEntity.getUserId()).isEqualTo(savedUser.getUserId());
    }
}

테스트 메서드를 수행하여 성공하는지 확인합니다.

만약, 테스트에 실패한 이유가 테이블을 찾지 못한 것이라면 DB의 대소문자 구분 설정이 켜져있기 때문입니다.

show variables like 'lower_case_table_names';

대소문자 구분은 Value가 0, 대소문자 구분없음은 Value가 1입니다.

DB의 설정(my.cnf)을 변경하거나 변경이 어려운 경우라면 대소문자를 구분하도록 JPA의 설정 값을 추가하면 됩니다.

jpa:
    hibernate:
      naming:
        physical-strategy: org.hibernate.boot.model.naming.PhysicalNamingStrategyStandardImpl

이제 위에서 생성한 유저 정보를 검색 하여 비밀번호를 비교하는 테스트 코드를 작성합니다.

@ExtendWith(SpringExtension.class)
@SpringBootTest
class UserRepositoryTest {
    @Autowired
    UserRepository userRepository;

    BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();

    @DisplayName("2. 유저정보 검색 후 비밀번호 비교")
    @Test    
    void test_2(){
        String encPassword = passwordEncoder.encode("test_password");

        UserEntity user = userRepository.findByUserId("test_user")
                .orElseThrow(() -> new UsernameNotFoundException("사용자를 찾을 수 없습니다."));

        assertThat(user.getUserPw()).isEqualTo(encPassword);
    }

}

UserRepository에 findByUserId 쿼리메서드를 구현합니다.

import org.springframework.data.jpa.repository.JpaRepository;

import java.util.Optional;

public interface UserRepository extends JpaRepository<UserEntity, Long> {
    Optional<UserEntity> findByUserId(String userId);
}

성공을 예상했으나 비밀번호가 맞지 않아 테스트에 실패하였습니다.

PasswordEncoder를 통해 비밀번호를 암호화하여 단순하게 비교하는 방식은 허용되지 않기 때문입니다.

시큐리티 UserDetailsService 구현

스프링 시큐리티에서 필수로 구현해야 하는 UserDetailsService의 loadUserByUsername 메서드를 통해 비밀번호 비교를 진행하도록 하겠습니다.

services에 UserService를 생성하고 UserDetailsService를 상속받아 loadUserByUsername 메서드를 오버라이드하고 소스를 추가합니다.

import com.example.vuebackboard.entity.UserEntity;
import com.example.vuebackboard.entity.UserRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;

import java.util.ArrayList;
import java.util.List;

@RequiredArgsConstructor
@Service
public class UserService implements UserDetailsService {

    private final UserRepository userRepository;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        List<GrantedAuthority> authorities = new ArrayList<>();

        UserEntity userEntity = userRepository.findByUserId(username)
                .orElseThrow(() -> new UsernameNotFoundException("사용자를 찾을 수 없습니다."));

        if (userEntity.getUserId().equals(username)) {
            authorities.add(new SimpleGrantedAuthority("ROLE_USER"));
        }

        return new User(userEntity.getUserId(), userEntity.getUserPw(), authorities);
    }
}

위에서 생성한 메서드가 잘 작동하는지 테스트코드를 통해 확인하겠습니다.
테스트 클래스에 UserService와 AuthenticationManager를 Autowired로 추가합니다.

class UserRepositoryTest {
    @Autowired
    UserRepository userRepository;

    @Autowired
    UserService userService;

    @Autowired
    AuthenticationManager authenticationManager;

    BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
}


AuthenticationManager Bean 등록 및 PasswordEncoder 설정을 위해 WebSecurityConfig의 주석 처리한 부분 중 일부를 해제합니다.

import com.example.vuebackboard.services.UserService;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
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.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;

@RequiredArgsConstructor
@EnableWebSecurity
@Configuration
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
    private final UserService userService;

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

    @Bean
    public AuthenticationManager authenticationManagerBean() throws Exception {
        return super.authenticationManagerBean();
    }

    public void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.userDetailsService(userService).passwordEncoder(passwordEncoder());
    }

    public void configure(HttpSecurity http) throws Exception {
        http.csrf().disable().authorizeRequests() // 토큰을 활용하는 경우 모든 요청에 대해 접근이 가능하도록 함
                .anyRequest().permitAll()
                .and() // 토큰을 활용하면 세션이 필요 없으므로 STATELESS로 설정하여 Session을 사용하지 않는다.
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and() // form 기반의 로그인에 대해 비활성화 한다.
                .formLogin()
                .disable();
                //.addFilterBefore(tokenRequestFilter, UsernamePasswordAuthenticationFilter.class)

        http.cors();
    }

}

유저정보 검색 후 비밀번호 비교하는 테스트 메서드의 내용을 수정합니다.

@ExtendWith(SpringExtension.class)
@SpringBootTest
class UserRepositoryTest {
    @Autowired
    UserRepository userRepository;

    @Autowired
    UserService userService;

    @Autowired
    AuthenticationManager authenticationManager;

    BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();

    /* 1. 유저 데이터 생성하기 .............. */

    @DisplayName("2. 유저정보 검색 후 비밀번호 비교")
    @Test
    void test_2(){
        /*
        String encPassword = passwordEncoder.encode("test_password");
        UserEntity user = userRepository.findByUserId("test_user")
                .orElseThrow(() -> new UsernameNotFoundException("사용자를 찾을 수 없습니다."));
        assertThat(user.getUserPw()).isEqualTo(encPassword);
        */

        String userId = "test_user";
        String userPw = "test_password";
        UserDetails user = userService.loadUserByUsername(userId);

        UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(user, userPw);
        authenticationManager.authenticate(authenticationToken);

        assertThat(authenticationToken.getCredentials()).isEqualTo(userPw);

        System.out.println("getCredentials: " + authenticationToken.getCredentials());
        System.out.println("userPw: " + userPw);
    }
}

변수 authenticationToken의 getCredentials을 호출한 값과 비밀번호 userPw의 값이 동일한 것을 확인할 수 있습니다.

만약, 비밀번호를 잘못 입력했다면 BadCredentialsException이 발생하고 테스트에 실패합니다.

다음 포스팅에서는 로그인 컨트롤러를 생성하고 JWT를 통해 발급된 토큰을 화면으로 전달하여 로그인 완료 여부를 판단하는 작업을 진행하겠습니다.
(현재까지의 소스는 vue-backboard > chap12 브랜치에 있습니다.)

728x90

댓글