본문 바로가기
공부 기록

[스프링 부트로 게시판 만들기] 07. 스프링 시큐리티를 이용한 로그인

by 매트(Mat) 2021. 7. 21.



시큐리티를 이용한 로그인

  • Spring Security를 이용한 로그인 방법에 대해 알아보겠습니다.


  • user-login.html 수정을 하겠습니다.

user-login.html

  • button의 id 수정, button을 form 태그 안에 넣어서 form에 action을 주고, method는 post
  • username과 password에 name="" 값을 각각 줍니다.
  • th:if를 이용해 로그인 성공과 실패에 대한 정보를 넣습니다.
<main class="form-signin">
    <div class="container border rounded flex-md-row mb-4 shadow-sm h-md-250">
        <form action="/auth/user/login" method="post">
            <h1 class="h3 m-3 fw-normal">로그인</h1>
            <div th:if="${param.error}" class="alert alert-danger" role="alert">
                아이디 혹은 비밀번호가 잘못 입력되었습니다.
            </div>
            <div th:if="${param.logout}" class="alert alert-primary" role="alert">
                로그아웃이 완료되었습니다.
            </div>
            <div class="form-floating m-3">
                <input type="text" name="username" class="form-control" id="username" placeholder="아이디를 입력하세요." required="required">
                <label for="username">아이디</label>
            </div>
            <div class="form-floating m-3">
                <input type="password" name="password" class="form-control" id="password" placeholder="패스워드를 입력하세요." required="required">
                <label for="password">패스워드</label>
            </div>
            <div class="checkbox mb-3">
                <label>
                    <input type="checkbox" value="remember-me"> Remember me
                </label>
            </div>
            <button class="w-100 btn btn-lg btn-success" id="btn-login">로그인</button>
        </form>

    </div>
</main>



의존성 추가

dependencies {
    ...
    implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity5'

}



SecurityConfig

  • loginProcessingUrl에 form의 action url을 여기다 적어줍니다.
  • /auth/user/login이 URL의 API Controller를 작성하지 않는 이유는 스프링 시큐리티가 얘를 가로채서 대신 작업을 수행해줍니다.


SecurityConfig 클래스

@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Bean
    public BCryptPasswordEncoder bCryptPasswordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                .csrf().disable()
                    .authorizeRequests()
                    .antMatchers("/", "/auth/**", "/js/**", "/css/**", "/image/**").permitAll()
                    .anyRequest().authenticated()
                .and()
                    .formLogin()
                    .loginPage("/auth/user/login")
                    .loginProcessingUrl("/auth/user/login")
                    .defaultSuccessUrl("/");
    }
}




UserDetails 상속

  • UserDatails 객체를 상속받으면 스프링 시큐리티의 고유한 세션저장소에 저장을 할 수 있게 됩니다.
  • 그래서 PrincipalDetail 클래스를 생성하여 UserDatails 인터페이스를 상속받도록 하겠습니다.
  • 폴더 구조
캡처

PrincipalDetail 클래스

package com.azurealstn.blogproject.config.auth;

import com.azurealstn.blogproject.domain.user.User;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;

import java.util.ArrayList;
import java.util.Collection;

@RequiredArgsConstructor
public class PrincipalDetail implements UserDetails {

    private final User user;

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        Collection<GrantedAuthority> collection = new ArrayList<>();
        collection.add(() -> user.getRoleKey());

        return collection;
    }

    //사용자 패스워드
    @Override
    public String getPassword() {
        return user.getPassword();
    }

    //사용자 아이디
    @Override
    public String getUsername() {
        return user.getUsername();
    }

    //계정이 만료되었는지 (true: 만료되지 않음)
    @Override
    public boolean isAccountNonExpired() {
        return true;
    }

    //계정이 잠겨있는지 (true: 잠겨있지 않음)
    @Override
    public boolean isAccountNonLocked() {
        return true;
    }

    //패스워드가 만료되지 않았는지 (true: 만료되지 않음)
    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }

    //계정이 활성화되어 있는지 (true: 활성화)
    @Override
    public boolean isEnabled() {
        return true;
    }
}


  • 스프링 시큐리티가 대신 로그인을 해주는데 패스워드를 가로채기 합니다.
  • 하지만 저희가 패스워드를 암호화해서 회원가입을 하였는데 뭘로 암호화를 했는지 알아야 DB에 있는 패스워드와 비교할 수 있기 때문에 이를 설정해야 합니다.


SecurityConfig 클래스

@RequiredArgsConstructor
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    private final PrincipalDetailService principalDetailService;

    @Bean
    public BCryptPasswordEncoder bCryptPasswordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.userDetailsService(principalDetailService).passwordEncoder(bCryptPasswordEncoder());
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        ...
    }
}
  • configure(AuthenticationManagerBuilder auth) 이 메소드를 오버라이딩을 해줍니다.


  • PrincipalDetailService 클래스 생성

PrincipalDetailService 클래스

package com.azurealstn.blogproject.config.auth;

import com.azurealstn.blogproject.domain.user.User;
import com.azurealstn.blogproject.domain.user.UserRepository;
import lombok.RequiredArgsConstructor;
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;

@RequiredArgsConstructor
@Service
public class PrincipalDetailService implements UserDetailsService {

    private final UserRepository userRepository;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        User principal =  userRepository.findByUsername(username).orElseThrow(() -> new UsernameNotFoundException("해당 사용자를 찾을 수 없습니다. " + username));
        return new PrincipalDetail(principal);
    }
}
  • @Service로 Bean으로 등록합니다.
  • UserDetailsService를 상속받게 되면 오버라이딩을 해야하는데 이 메소드는 DB에 username이 있는지 확인하는 메소드입니다.
  • PrincipalDetail(principal)을 리턴을 하게 되면 시큐리티의 세션에 유저 정보가 저장됩니다.


UserRepository 클래스

package com.azurealstn.blogproject.domain.user;

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

import java.util.Optional;

public interface UserRepository extends JpaRepository<User, Long> {

    Optional<User> findByUsername(String username);
}


  • 마지막으로 header.html 에서 로그인이 성공하면 분기처리를 해주겠습니다.

header.html

<header th:fragment="header">
    <!-- Fixed navbar -->
    <nav class="navbar navbar-expand-md navbar-dark fixed-top bg-dark">
        <div class="container-fluid">
            <a class="navbar-brand" href="/">내가 만든 블로그</a>
            <button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarCollapse" aria-controls="navbarCollapse" aria-expanded="false" aria-label="Toggle navigation">
                <span class="navbar-toggler-icon"></span>
            </button>
            <div class="collapse navbar-collapse" id="navbarCollapse">
                <ul class="navbar-nav me-auto mb-2 mb-md-0">
                    <li class="nav-item">
                        <a sec:authorize="isAnonymous()" class="nav-link" href="/auth/user/login">로그인</a>
                    </li>
                    <li class="nav-item">
                        <a sec:authorize="isAnonymous()" class="nav-link" href="/auth/user/save">회원가입</a>
                    </li>
                    <li class="nav-item">
                        <a sec:authorize="isAuthenticated()" class="nav-link" href="/board/save">글쓰기</a>
                    </li>
                    <li class="nav-item">
                        <a sec:authorize="isAuthenticated()" class="nav-link" href="/user/update">회원정보</a>
                    </li>
                    <li class="nav-item">
                        <a sec:authorize="isAuthenticated()" class="nav-link" href="/logout">로그아웃</a>
                    </li>
                </ul>
                <form class="d-flex">
                    <input class="form-control me-2" type="search" placeholder="Search" aria-label="Search">
                    <button class="btn btn-outline-success" type="submit">Search</button>
                </form>
            </div>
        </div>
    </nav>
</header>






다음으로

  • 여기까지 로그인을 마무리 하겠습니다. (실세로 되는지 확인해보세용!)
  • 다음으로 회원수정에 대해 알아보도록 하겠습니다.
  • 이제 전체 테스트 코드를 돌리면 HelloControllerTest 클래스에서 hello_Test(), helloDto_Test() 이 두 개의 테스트 코드가 에러가 납니다.


테스트 코드 에러 원인

  • @WebMvcTest에서는 @Controller를 읽을 순 있지만, @Repository, @Service 등은 스캔 대상이 아닙니다. 따라서 SecurityConfig의 PrincipalDetailService를 읽을 수 없어서 에러가 발생했습니다.
  • 그래서 SecurityConfig를 스캔 대상에서 제거합니다.
  • 그리고, 가짜로 인증된 사용자를 생성하기 위해 @WithMockUser를 추가합니다.


HelloControllerTest 클래스

package com.azurealstn.blogproject.controller;

import com.azurealstn.blogproject.config.SecurityConfig;
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.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.FilterType;
import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.test.context.junit.jupiter.SpringExtension;
import org.springframework.test.web.servlet.MockMvc;

import static org.hamcrest.Matchers.is;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;

@ExtendWith(SpringExtension.class)
@WebMvcTest(controllers = HelloController.class,
    excludeFilters = {
        @ComponentScan.Filter(type = FilterType.ASSIGNABLE_TYPE, classes = SecurityConfig.class)
    }
)
public class HelloControllerTest {

    @Autowired
    private MockMvc mvc;

    @Test
    @WithMockUser
    public void hello_Test() throws Exception {
        String hello = "hello Spring Boot!";

        mvc.perform(get("/hello"))
                .andExpect(status().isOk())
                .andExpect(content().string(hello));
    }

    @Test
    @WithMockUser
    public void helloDto_Test() throws Exception {
        String name = "minsu";
        String nickname = "babo";

        mvc.perform(
                get("/hello/dto")
                        .param("name", name)
                        .param("nickname", nickname))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.name", is(name)))
                .andExpect(jsonPath("$.nickname", is(nickname)));
    }
}






References

댓글