시큐리티를 이용한 로그인
- 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
댓글