댓글 저장과 삭제 까지능 구현 했지만, 아무 사용자가 내 댓글을 삭제해서는 안된다. 따라서 인증 기능을 추가해서 해당 사용자가 작성한 댓글은 다른 사람이 삭제할수 없도록 조치해줘야 한다.
이전에 로그인 화면을 구현할 때, 스프링 서큐리티를 통해 제작하는 과정에서 무작정 로그인 페이지가 나타나는 것을 막기 위해 securityConfig
를 작성해서 로그인화면을 지나가게 했다.
어떤 request 건 통과를 시켜서 로그인 창을 스킵한 것인데, 여기서 수정을 해준다.
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.mvcMatchers(
HttpMethod.GET,
"/",
"articles",
"/articles/search-hashtag"
).permitAll()
.anyRequest().authenticated()
)
.formLogin();
return http.build();
}
해당 페이지들은 인증과 권한 체크를 진행하게 해준다. 인증을 진행할 필요가 없는 페이지들은 따로 설정해 줘야하는데, 이 것을 WEbSecurityCustomizer로 설정할 수 있다.
굳이 인증기능을 추가할 필요가 없는 정적 리소스들을 대상을 선택해서 설정하는 방식이다.
@Bean
public WebSecurityCustomizer webSecurityCustomizer() {
return (web) -> web.ignoring().requestMatchers(PathRequest.toStaticResources().atCommonLocations());
}
userAccountRepository에서 정보를 가져와야 보안쪽에서 얘가 사용자 권한을 줘도 되는 애인지 아닌지를 판단 할 것이다. findById에서 username을가져온뒤 UserAccountDto::from으로 mapping 받아온 것을 BoardPrinciapal로 받아온다.
package com.jycproject.bulletinboard.dto.security;
import com.jycproject.bulletinboard.dto.UserAccountDto;
import lombok.Getter;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import java.util.Collection;
import java.util.Set;
import java.util.stream.Collectors;
public record BoardPrincipal(
String username,
String password,
Collection<? extends GrantedAuthority> authorities,
String email,
String nickname,
String memo
) implements UserDetails {
public static BoardPrincipal of(String username, String password, String email, String nickname, String memo) {
Set<RoleType> roleTypes = Set.of(RoleType.USER);
return new BoardPrincipal(
username,
password,
roleTypes.stream()
.map(RoleType::getName)
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toUnmodifiableSet()),
email,
nickname,
memo
);
}
public static BoardPrincipal from(UserAccountDto dto) {
return BoardPrincipal.of(
dto.userId(),
dto.userPassword(),
dto.email(),
dto.nickname(),
dto.memo()
);
}
public UserAccountDto toDto() {
return UserAccountDto.of(
username,
password,
email,
nickname,
memo
);
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() { return authorities; }
@Override
public String getUsername() { return username; }
@Override
public String getPassword() { return password; }
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
public enum RoleType{
USER("ROLE_USER");
@Getter
private final String name;
RoleType(String name) {
this.name = name;
}
}
}
유저의 구체적인 정보와 권한을 작성했으며 UserAccountDto에서 받아온 데이터로 BoardPrincipal로 만드는 팩토리 메소드와 BoardPrincipal을 UserAccountDto로 만드는 메소드도 구현했다.
이제 이 Optional에서 가져온 유저의 디테일한 정보까지 인증 구현에 사용할 수 있게 된다.
@Bean
public UserDetailsService userDetailsService(UserAccountRepository userAccountRepository){
return username -> userAccountRepository
.findById(username)
.map(UserAccountDto::from)
.map(BoardPrincipal::from)
.orElseThrow(() -> new UsernameNotFoundException("유저를 찾을 수 없습니다 - username: " + username));
}
여기서 끝은 아니고, passwordEncoder 도 존재해야한다.
@Bean
public PasswordEncoder passwordEncoder() {
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
이미 제공된 메소드를 사용하면 된다. 패스워드 인코더 설정을 팩토리메소드에 있는 createDelegatingPasswordEncoder()로 위임해서 가져오겠다는 뜻이다.
이제 이상태에서 테스트를 한번 돌려보면 컨트롤러 관련 테스트들이 모조리 실패한 모습이 나타날 것이다.
어플리케이션 실행에는 문제가 없지만 실행을 해보면 로그에 이런 warning이 나타난다.
[ restartedMain] o.s.s.c.a.web.builders.WebSecurity : You are asking Spring Security to ignore org.springframework.boot.autoconfigure.security.servlet.StaticResourceRequest$StaticResourceRequestMatcher@ad685b3. This is not recommended -- please use permitAll via HttpSecurity#authorizeHttpRequests instead.
마지막 줄에 보면 제발 HttpSecurity 에서 permitAll을 사용하라는 것인데, 처음에 WebSecurityCustomizer에서 ignoring을 사용해서 정적 요소를 전부 서큐리티가 무시를 하게 설정했었다.
이렇게 해버리면 서큐리티에 관한 모든 서비스를 이용하지 않게 되는데, 물론 이렇게 해도 실행은 되지만, 만일 외부에서 각종 보안 위험이 닥쳐온다고 해도, 서큐리티가 무시를 하게 되기 때문에 보안 위험에 노출된다. 이것을 방지하기 위해 WebSecurityCustomizer에 ignore를 따로 작성하지 말고, securityFilterChain에 permitAll 을 작성해서 그런 위험을 미연에 방지해줘라 라는 뜻이다.
authorizeHttpRequests안에 작성하면 csrf 관리하에 들어가고 스프링 서큐리티가 관리하고 있는 여러가지 보안 설정이 적용된다. 따라서 WebSecurityCustomizer
는 제거 하고 SecurityFilterChain
의 내용을 이렇게 변경해주면 된다.
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers(PathRequest.toStaticResources().atCommonLocations()).permitAll()
.mvcMatchers(
HttpMethod.GET,
"/",
"articles",
"/articles/search-hashtag"
).permitAll()
.anyRequest().authenticated()
)
.formLogin();
return http.build();
}
요런 식으로 맨 윗줄에 requestMatchers를 통해 작성한 PathREquest에다가 permitAll을 부여해주면 끝난다.
여기서 더 추가하면 만약에 로그인 중에서 사용자가 로그아웃을 할경우 루트페이지로 이동하게 하는 설정을 만들고 싶으면 이렇게 .logout()을 추가해주면 된다. 거기에 .logoutSuccesUrl을 루트 페이지로 지정해준다
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
return http
.authorizeHttpRequests(auth -> auth
.requestMatchers(PathRequest.toStaticResources().atCommonLocations()).permitAll()
.mvcMatchers(
HttpMethod.GET,
"/",
"/articles",
"/articles/search-hashtag"
).permitAll()
.anyRequest().authenticated()
)
.formLogin().and()
.logout()
.logoutSuccessUrl("/")
.and()
.build();
}
SecurityConfig에서 사용자의 정보를 가져와서 인증 검사를 진행하게 되면서 원래 작성되었던 프로젝트의 내용을 수정해줄 필요가 있다.
초반에 auditorAware설정을 통해서 사용자 데이터를 임의로 설정했었지만, 이제 사용자의 데이터를 userAccountRepository를 통해서 받아오기 때문에 이를 수정해주려고 한다.
public class JpaConfig {
@Bean
public AuditorAware<String> auditorAware() {
return () -> Optional.ofNullable(SecurityContextHolder.getContext())
.map(SecurityContext::getAuthentication)
.filter(Authentication::isAuthenticated)
.map(Authentication::getPrincipal)
.map(BoardPrincipal.class::cast)
.map(BoardPrincipal::getUsername);
}
}
스프링 서큐리티를 사용하면 서큐리티와 관련된 정보를 가지고 있는 SecurityContextHolder가 있는데, 이를 getContext를 사용해서 SecurityContext를 불러온다.
SecurityConetxt에서 Authentication 정보를 가져오면서 로그인이 되었는지를 확인하는 과정을 거친 뒤에 로그인 정보인 Principal을 가져오게 된다. 이전에 만든 BoardPrincipal이 바로 그것이고, 여기에서 작성한 UserDetails에 있떤 Username을 가져옴으로써 유저의 정보를 리턴한다.
JpaRepositoryTest를 돌려봤는데 insert테스트에 오류가 발생했다.
오류 내용은 createdBy가 들어가지 않았다고 나타나는데, securityconfig의 userDetailService에서 이제 userAccountRepository를 건드리게 되는데 이 녀석이 제대로 빈으로 등록 되어있지 않거나, 인증 정보 혹은 사용자의 정보가 들어있지 않으면 테스트가 실패하게 되는것이다. 따라서 서큐리티 전용 설정을 작성해서 추가해주는 것이 좋다.
JpaRepositoryTest의 테스트를 진행하면서 서큐리티에 문제가 있었지만, 더 앞에는 auditorAware, 즉 auditing을 자동으로 넣는 코드에서 문제가 생기는 것이기 때문에, 그 부분을 테스트 때만 무시하게 하면 된다.
@EnableJpaAuditing
@TestConfiguration
public static class TestJpaConfig {
@Bean public AuditorAware<String> auditorAware() {
return () -> Optional.of("jyc");
}
}
테스트를 진행할 때만, 서큐리티 설정을 넣기 전에 임의로 데이터를 설정한 상태로 변경시켜주는 것이다.
@TestConfiguration을 사용하면, configuration으로 등록하되 테스트 할때만 등록이 된다.
따라서 일반적인 서비스를 실행할 대는 BeanScan에 포함되지 않는다. 이제 TEST파일 맨위에서 imnport 한 jpaconfig를 대신해서 이 내용을 import 해주면 된다.
jpaRepository 테스트가 모두 통과되었다.
현재 테스트 내용을 보면 @Import 에서 SecurityConfig를 사용하는데, 여기에는 사용자관련 정보가 들어가지 않은 상태이며, 이는 곧 인증 기능관련 여부를 테스트 할수 없는 상태라는 뜻이다 .
따라서
package com.jycproject.bulletinboard.config;
import com.jycproject.bulletinboard.domain.UserAccount;
import com.jycproject.bulletinboard.repository.UserAccountRepository;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.context.annotation.Import;
import org.springframework.test.context.event.annotation.BeforeTestMethod;
import java.util.Optional;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.BDDMockito.given;
@Import(SecurityConfig.class)
public class TestSecurityConfig {
@MockBean private UserAccountRepository userAccountRepository;
@BeforeTestMethod
public void securitySetUp(){
given(userAccountRepository.findById(anyString())).willReturn(Optional.of(UserAccount.of(
"jycTest",
"pw",
"jyc-test@email.com",
"jyc-test",
"test memo"
)));
}
}
이렇게 SecurityConfig 뿐만 아니라 사용자 관련 데이터 또한 리턴해주는 설정을 추가해주는 것이다.
이제 ArticleControllerTest의 @Import의 내용을
@Import({TestSecurityConfig.class, FormDataEncoder.class})
이렇게 변경해준다. 물론 이렇게만 하는것이 다가 아니다. 이상태에서 테스트 돌려보면 아마 거의 다 실패로 나타날 것이다.
테스트 내용에서 '인증 없을 때는 로그인 페이지로 이동' 테스트를 추가했다.
인증의 정보가 없으면 리다이렉션이 일어나서 login페이지로 이동하게 된다.
@DisplayName("[view][GET] 게시글 페이지 -인증 업을 땐 로그인 페이지로 이동")
@Test
public void givenNothing_whenRequestingArticlePage_thenRedirectsToLoginPage() throws Exception {
// Given
Long articleId = 1L;
// When & Then
mvc.perform(get("/articles/" + articleId))
.andExpect(status().is3xxRedirection())
.andExpect(redirectedUrlPattern("**/login"));
then(articleService).shouldHaveNoInteractions();
then(articleService).shouldHaveNoInteractions();
}
그럼 이제 전에 실패한 테스트들에 인증 정보를 넣어줘야하는데, 여러가지가 있지만 가장 쉬운 방법은 바로 @WithMockUser이다. 단, BoardPrincipal에서 인증 정보를 받아와서 추가적인 작업을 진행하지 않는 경우에만 이렇게 해주는 것이다. 그저 "와! 인증이 통과는 되네! " 느낌으로만 인증 정보를 넣어주는 것이다.
이런 방식으로 진행하면 securityConfig에서 작성한 userDetialService를 호출하지 않았기 때문에 실제로 있는 사용자 정보를 이용할수 없다는 단점이 존재한다. 그렇기 때문에, 그냥 아무 지나가는 데이터를 잡아다가 넣을게 아니라. BoardPrincipal을 사용하는 인증정보를 보내줘야한다.
예를들면 "새로운 게시글 등록" 같은 경우는 실제 사용자의 정보가 있어야한다. 실제로 존재하는 사용자가 로그인해서 게시글을 등록하는 과정이 제대로 돌아가는지를 테스트해야하기 때문이다.
여기서 등장하는 어노테이션이 @WithUserDetails인데
@WithUserDetails(value="jycTest",userDetailsServiceBeanName = "userDetailsService",setupBefore = TestExecutionEvent.TEST_EXECUTION)
"userDetailsSerivice를 통해서 유저 정보를 가져오는 작업을 테스트 실행하기 직전해 해주세요" 라는 뜻이다. 여기서 jycTest는 TestSecurityConfig에서 정의한 값이다.
수정후에 테스트를 돌려보면 테스트가 통과한다.
게시글 수정 페이지의 경우도 인증이 없을 땐 로그인 페이지로 이동하는지 테스트를 작성하고
@DisplayName("[view][GET] 게시글 수정 페이지 - 인증 없을 땐 로그인 페이지로 이동")
@Test
public void givenNothing_whenRequesting_thenRedirectsToLoginPage() throws Exception {
// Given
Long articleId = 1L;
// When & Then
mvc.perform(get("/articles/" + articleId + "/form"))
.andExpect(status().is3xxRedirection())
.andExpect(redirectedUrlPattern("**/login"));
then(articleService).shouldHaveNoInteractions();
}
바로 아래에 있는 게시글 수정페이지 정상호출 테스트에서는 @WithMockUser를 사용한다.
@WithMockUser
@DisplayName("[view][GET] 게시글 수정 페이지 - 정상 호출, 인증된 사용자")
하지만 게시글 수정과 삭제 테스트에서는 실제 사용자 정보가 필요하므로 @WithUserDetails를 추가한다.
@WithUserDetails(value="jycTest",userDetailsServiceBeanName = "userDetailsService",setupBefore = TestExecutionEvent.TEST_EXECUTION)
@DisplayName("[view][POST] 게시글 수정 - 정상 호출")
...
@WithUserDetails(value="jycTest",userDetailsServiceBeanName = "userDetailsService",setupBefore = TestExecutionEvent.TEST_EXECUTION)
@DisplayName("[view][POST] 게시글 삭제 - 정상 호출")
삭제 테스트의 경우 유저의 id를 추가해준다.
String userId = "jycTest";
willDoNothing().given(articleService).deleteArticle(articleId, userId);
현재 deleteArticle은 articleId만 파라미터로 가지고 있지만, 유저의 id값을 가지고 있는 사람만 삭제를 할수 있게 해야 무단으로 게시글이 지워지는 현상을 막을수 있을 것이다.
따라서 deleteArticle에 파라미터를 추가한다. 이렇게 하면 deleteById말고 추가적인 파라미터를 받아서 처리하는 메소드를 따로 제작한다. ArticleRepository
로 가서
void deleteByIdAndUserAccount_UserId(Long article, String userId);
이렇게 파라미터를 userId 까지 받아오는 메소드를 정의하고
public void deleteArticle(long articleId, String userId) {
articleRepository.deleteByIdAndUserAccount_UserId(articleId,userId);
}
deleteArticle의 내용을 변경해준다.
파라미터를 변경했으니 아마 다른 곳에서 오류가 나타날 것이다. 이를 찾아서 내용을 수정한다.
given에 userId 추가,
deleteById -> deleteByIdandUserAccount_UserId
deleteArticle(1L) -> deleteArticle(1L,userId);
ArticleServiceTest
@DisplayName("게시글 ID를 입력하면 게시글을 삭제한다.")
@Test
void givenArticleId_whenDeletingArticle_thenDeletesArticle(){
// Given
Long articleId = 1L;
String userId = "jyc"
willDoNothing().given(articleRepository).deleteByIdAndUserAccount_UserId(articleId,userId);
// When
sut.deleteArticle(1L,userId);
// Then
then(articleRepository).should().deleteById(articleId); // delete 메소드가 호출되었는지 여부를 확인
}
ArticleController
deleteArticle에 유저 Id값을 파라미터로 추가하기 위해서 @AuthenticationPrincipal 어노테이션을 사용해서 이전에 작성한 BoardPrincipal을 불러와서 getUsername 메소드를 호출하여 정보를 가져온다.
@PostMapping("/{articleId}/delete")
public String deleteArticle(@PathVariable Long articleId,
@AuthenticationPrincipal BoardPrincipal boardPrincipal
){
articleService.deleteArticle(articleId, boardPrincipal.getUsername());
return "redirect:/articles";
}
이제 테스트를 돌려보면
전부 통과했다.
컨트롤러에서 updateArticle에 내용을 추가했다. 유저 정보를 UserAccountDto.of를 통해서 임의로 만들어진 데이터를 사용하지만, 이제 사용정보를 받아올수 있으니 boardPrincipal을 이용해서 정의할수 있다.
@PostMapping("/{articleId}/form")
public String updateArticle(@PathVariable Long articleId, ArticleRequest articleRequest,
@AuthenticationPrincipal BoardPrincipal boardPrincipal
){
articleService.updateArticle(articleId,articleRequest.toDto(boardPrincipal.toDto()));
return "redirect:/articles/" + articleId;
}
물론 PostMapping 인 PostNewArticle에서도 수정을 해줘야한다.
@PostMapping("/form")
public String postNewArticle(ArticleRequest articleRequest,
@AuthenticationPrincipal BoardPrincipal boardPrincipal
){
articleService.saveArticle(articleRequest.toDto(boardPrincipal.toDto()));
return "redirect:/articles";
}
updateArticle을 보면 파라미터가 articleId, ArticleDto만 있지 유저의 정보는 추가되어있지 않은 모습이다. 따라서 작성된 게시글 작성자의 id와 인증된 사용자의 id가 같은 경우에만 수정을 할수 있어야 한다. 따라서
public void updateArticle(Long articleId,ArticleDto dto) {
try {
Article article = articleRepository.getReferenceById(articleId);
UserAccount userAccount = userAccountRepository.getReferenceById(dto.userAccountDto().userId());
if(article.getUserAccount().equals(userAccount)) {
if (dto.title() != null) {
article.setTitle(dto.title());
}
if (dto.content() != null) {
article.setContent(dto.content());
}
article.setHashtag(dto.hashtag());
}
} catch (EntityNotFoundException e) {
log.warn("게시글 업데이트 실패. 게시글을 수정하는데 필요한 정보를 찾을수 없습니다 - {}", e.getLocalizedMessage());
}
}
userAccountDto에서 가져온 Id값과 비교하는 조건문을 추가했다.
테스트는 아직 수정해주지 않았기 때문에 아마 수정관련 테스트에서는 통과되지 못할 것이다.
@DisplayName("게시글의 수정정보를 입력하면 게시글을 수정한다.")
@Test
void givenAndModifiedInfo_whenUpdatingArticle_thenUpdatesArticle(){
// Given
Article article = createArticle();
ArticleDto dto = createArticleDto("새 타이틀","새 내용","#springboot");
given(articleRepository.getReferenceById(dto.id())).willReturn(article);
given(userAccountRepository.getReferenceById(dto.userAccountDto().userId())).willReturn(dto.userAccountDto().toEntity());
// When
sut.updateArticle(dto.id(), dto);
// Then
assertThat(article)
.hasFieldOrPropertyWithValue("title",dto.title())
.hasFieldOrPropertyWithValue("content",dto.content())
.hasFieldOrPropertyWithValue("hashtag",dto.hashtag());
then(articleRepository).should().getReferenceById(dto.id());
then(userAccountRepository).should().getReferenceById(dto.userAccountDto().userId());
}
userAccountRepository에서 getReferenceById를 통해 사용자의 Id값을 가져오고 userAccountDto의 toEntity로 데이터를 전송할 것이다.
이제 다시 테스트를 돌려보면
오케이 이제 전부 통과했다.
게시글의 경우와 거의 동일하다. 수정과 삭제 관련 내용을 수정해야한다.
@DisplayName("댓글 ID를 입력하면, 댓글을 삭제한다.")
@Test
void giveArticleCommentId_whenDeletingArticleComment_thenDeletesArticleComment(){
// Given
Long articleCommentId = 1L;
String userID = "jyc";
willDoNothing().given(articleCommentRepository).deleteByIdandUserAccount_UserId(articleCommentId);
// When
sut.deleteArticleComment(articleCommentId);
// Then
then(articleCommentRepository).should().deleteByIdandUserAccount_UserId(articleCommentId);
}
테스트 대상 메소드 변경
public void deleteArticleComment(Long articleCommentId, String userId){
articleCommentRepository.deleteByIdAndUserAccount_UserId(articleCommentId,userId);
}
import 를 testsecurityconfig로 변경
댓글 삭제 테스트에서 deleteArticleComment 파라미터 추가
댓글 삭제, 등록 테스트에 @withUserDetails 추가
@WithUserDetails(value="jycTest",userDetailsServiceBeanName = "userDetailsService",setupBefore = TestExecutionEvent.TEST_EXECUTION)
@DisplayName("[view][POST] 댓글 삭제 - 정상 호출")
@Test
void givenArticleCommentIdToDelete_whenRequesting_thenDeletesArticleComment() throws Exception {
// Given
long articleId = 1L;
long articleCommentId = 1L;
String userId = "jycTest";
willDoNothing().given(articleCommentService).deleteArticleComment(articleCommentId,userId);
// When & Then
mvc.perform(
post("/comments/" + articleCommentId + "/delete")
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
.content(formDataEncoder.encode(Map.of("articleId",articleId)))
.with(csrf())
)
.andExpect(status().is3xxRedirection())
.andExpect(view().name("redirect:/articles/" + articleId))
.andExpect(redirectedUrl("/articles/" + articleId));
then(articleCommentService).should().deleteArticleComment(articleId,userId);
}
각 매핑마다 실제 유저의 정보를 추가한다.
@PostMapping("/new")
public String postNewArticleComment(ArticleCommentRequest articleCommentRequest,
Long articleId,
@AuthenticationPrincipal BoardPrincipal boardPrincipal
) {
articleCommentService.saveArticleComment(articleCommentRequest.toDto(boardPrincipal.toDto()));
return "redirect:/articles/" + articleCommentRequest.articleId();
}
@PostMapping("/{commentId}/delete")
public String deleteArticleComment(@PathVariable Long commentId,
Long articleId,
@AuthenticationPrincipal BoardPrincipal boardPrincipal
){
articleCommentService.deleteArticleComment(commentId, boardPrincipal.username());
return "redirect:/articles/" + articleId ;
}
컨트롤러 테스트 실행 결과