본문 바로가기

더 나은 엔지니어가 되기 위해/지금은 안쓰는 자바

[스프링 부트 개념과 활용] 스프링 시큐리티

인프런에서 백기선님의 스프링부트 개념과 활용 강의를 듣고, 개인적으로 공부하며 핵심만 정리한 글입니다.

Spring-Security

먼저 Spring Security 를 적용하면 어떻게 되는지 살펴보자.

1) dependency 추가

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

2) 테스트를 위한 파일 작성

Thymeleaf 를 이용해 간단한 테스트 페이지를 만들어보자.

<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
<!-- reosurces/templates/hello.html -->

<!doctype html>
<html lang="en">
  <head>
    <title>Document</title>
  </head>
  <body>
    <h1>hello</h1>
  </body>
</html>
@Controller
public class HomeController {

  @GetMapping("/hello")
  public String hello() {
    return "hello";
  }
}

이제 앱을 구동하여 localhost:8080/hello 로 접근하면 다음과 같은 페이지가 보여진다.

Spring security 가 적용되었기 때문에, 인증되지 않은 사용자가 접근하면 http://localhost:8080/login 로 리다이렉트 된다.

이는 다음 테스트 코드에서도 마찬가지다.

@RunWith(SpringRunner.class)
@WebMvcTest(HomeController.class)
public class HomeControllerTest {

  @Autowired
  MockMvc mockMvc;

  @Test
  public void hello() throws Exception {
    mockMvc.perform(get("/hello")
      .andDo(print())
      .andExpect(status().isOk())
      .andExpect(view().name("hello"));
  }

마찬가지로 테스트 통과에 실패한다.

3) 테스트에 인증된 유저정보 주기

테스트 코드에 인증된 유저정보를 주는 방법은 다음과 같다.
먼저 다음 dependency 를 추가한다.

<dependency>
  <groupId>org.springframework.security</groupId>
  <artifactId>spring-security-test</artifactId>
  <version>${spring-security.version}</version>
  <scope>test</scope>
</dependency>

그리고 테스트 코드에 @WithMockUser 를 추가한다.

@RunWith(SpringRunner.class)
@WebMvcTest(HomeController.class)
public class HomeControllerTest {

  @Autowired
  MockMvc mockMvc;

  @Test
  @WithMockUser // 인증된 Mock 유저 주입
  public void hello() throws Exception {
    mockMvc.perform(get("/hello")
                    .accept(MediaType.TEXT_HTML))
      .andDo(print())
      .andExpect(status().isOk())
      .andExpect(view().name("hello"));
  }

이제 테스트 코드를 실행하면 테스트에 통과한다.

커스터마이징

커스터마이징에 앞서, 테스트 페이지 몇 개를 더 만들어 놓자.

<!-- reosurces/templates/my.html -->

<!doctype html>
<html lang="en">
  <head>
    <title>Document</title>
  </head>
  <body>
    <h1>my</h1>
  </body>
</html>
<!-- reosurces/templates/index.html -->

<!doctype html>
<html lang="en">
  <head>
    <title>Document</title>
  </head>
  <body>
    <h1>welcome</h1>
    <a href="/hello">hello</a>
    <a href="/my">my</a>
  </body>
</html>

위 페이지를 서빙하기 위해 Controller 도 수정해준다.

@Controller
public class HelloController {

  @GetMapping("/hello")
  public String hello() {
    return "hello";
  }

  @GetMapping("/my")
  public String my() {
    return "my";
  }
}

이제 index.html 에 접근하게 되면 hello.htmlmy.html 로 가는 두 개의 링크가 보일 것이다.
현재는 시큐리티 커스터마이징을 하지 않은 상태라, 모든 페이지 접근에 인증된 사용자 정보가 필요하다.

앞으로 커스터마이징을 통해 index.htmlhello.html 에는 인증되지 않은 사용자도 접근가능하게 하고, my.html 에는 인증된 사용자만 접근할 수 있도록 해보자.

1) Entity 와 DB 세팅

먼저 사용자 인증 정보를 담는, 즉 사용자 계정을 정의하는 Entity Class 를 정의하자.

// account/Account.class

@Entity
public class Account {

  @Id @GeneratedValue
  private Long id;
  private String username;
  private String password;

  ... getter and setter ...
}

이제 이 Entity 를 담을 DB 설정을 하자.
여기서는 간단하게 작동시키기 위해, h2 DB와 JPA 를 사용한다.

<dependency>
  <groupId>com.h2database</groupId>
  <artifactId>h2</artifactId>
</dependency>
<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
// account/AccountRepository.java

public interface AccountRepository extends JpaRepository<Account, Long> {
    Optional<Account> findByUsername(String username);
}

2) SecurityConfig 설정

WebSecurityConfigurerAdapter 를 상속받는 SecurityConfig 클래스를 정의하자.
protected void configure(HttpSecurity http) 를 오버라이딩하여 권한 설정을 커스텀하게 줄 수 있다.

// config/SecurityConfig.java

@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {

  @Override
  protected void configure(HttpSecurity http) throws Exception {
    http
      .authorizeRequests() // 인증이 필요한 모든 요청에 대해
        .antMatchers("/", "/hello").permitAll() // "/", "/hello" 는 인증정보 필요 없음.
        .anyRequest().authenticated() // 그 외 모든 요청은 인증정보 필요함.
        .and()
      .formLogin() // formLogin 사용할 거임.
        .and()
      .httpBasic(); // httpBasic 도 사용할 거임.
  }
}

위 코드는 기존의 시큐리티 자동설정 파일에 있는 WebSecurityConfigurerAdapter 를 오버라이딩한 것이다.
자동설정의 WebSecurityConfigurerAdapter 의 오버라이딩된 메쏘드를 찾아보면 다음과 같이 생겼다.

protected void configure(HttpSecurity http) throws Exception {
  http
    .authorizeRequests()
      .anyRequest().authenticated()
      .and()
    .formLogin()
      .and()
    .httpBasic();
}

또, WebSecurityConfigurerAdapter Bean 을 새로 정의하여 등록한 것이기 때문에, 기존의 자동설정은 이제 적용되지 않는다.

3) PasswordEncoder 설정

인증 계정 정보에 들어가는 패스워드는 모두 인코딩이 된 형태로 DB 에 들어가야한다.
예를 들어 비밀번호 123 그대로 DB 에 넣을 수는 없는 셈. 이를 해쉬화된 형태로 만들어줘야한다.
이 역할을 해주는 passwordEncoder 클래스를 Bean 으로 등록하자.
여기서는 SecurityConfig 클래스 내부에 정의한다.

@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {

  ...

  @Bean
  public PasswordEncoder passwordEncoder() {
    return PasswordEncoderFactories.createDelegatingPasswordEncoder();
  }
}

PasswordEncoderFactories 를 이용하여 DelegatingPasswordEncoder 를 만들어 내보냈다.
이제 DB 에 넣기 전에 이 인코더를 활용하면 된다.

4) Service 작성

Repository 를 가져다 사용하는 Service 컴포넌트를 만들자.

// account/AccountService.java

@Service
public class AccountService implements UserDetailsService {

  @Autowired
  private AccountRepository accountRepository;

  @Autowired
  private PasswordEncoder passwordEncoder;

  public Account createAccount(String username, String password) {
    Account account = new Account();
    account.setUsername(username);
    account.setPassword(passwordEncoder.encode(password)); // encoder 사용
    return accountRepository.save(account);
  }

  // UserDetailsService 로 인해 구현해야하는 부분
  @Override
  public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
    Optional<Account> byUsername = accountRepository.findByUsername(username);
    Account account = byUsername.orElseThrow(() -> new UsernameNotFoundException(username));
    return new User(account.getUsername(), account.getPassword(), authorities());
  }

  private Collection<? extends GrantedAuthority> authorities() {
    return Arrays.asList(new SimpleGrantedAuthority("ROLE_USER"));
  }
}

여기서 좀 특이한 부분은 클래스 하단 부의 public UserDetails loadUserByUsername(String username) 부분인데, 이는 UserDetailsService 를 인터페이스 구현하면서 반드시 구현해야하는 부분이다.

UserDetailsService 를 구현하게 되면, 스프링 인증 계정과 내 DB 의 Account 객체가 연동된다.
loadUserByUsername 를 이렇게 연동할 수 있게끔 코딩해야 하는데, 그냥 위같이 해주면 된다.
(자세한 건 따로 더 공부해야할 듯 싶다.)

아무튼 여기서는 간단하게 정리하면 해야할건 두 가지다.

  • UserDetailsServiceimplement 해야 함.
  • public UserDetails loadUserByUsername(String username) 구현으로 사용자 계정 DB 와 연동해야 함.

5) 테스트

여기서는 테스트 코드를 이용하지 않고, ApplicationRunner 를 사용해서 사용자 계정을 만들어 직접 접속해본다.

// AccountRunner.java

@Component
public class AccountRunner implements ApplicationRunner {

  @Autowired
  AccountService accountService;

  @Override
  public void run(ApplicationArguments args) throws Exception {
    Account heumsi = accountService.createAccount("heumsi", "1234");
    System.out.println(heumsi);
  }
}

이제 앱을 실행하자. 위의 AccountRunner 도 동시에 실행될 것이다.

index.html 에 접근해도 인증 정보가 요구되지 않는다. (이전처럼 login 페이지가 로드되지 않음)
my.html 로 접근을 시도하면,

인증 정보를 요구하는 로그인 페이지가 등장한다.
위 코드로 heumsi / 1234 인 계정을 만들었으므로, 이를 통해 접속하면

다음과 같이 잘 접속되는 것을 알 수 있다.

한편, 인코딩된 비밀번호는 로그에 다음과 같이 찍힌 것을 볼 수 있다.

Account{
  id=1, 
  username='heumsi',
  password='{bcrypt}$2a$10$uAcjw8tUwJ2aXVjaUWh1VOcJDz2BoaTIQqz5NIAUun/ajP2gnFpIa'
}