Jam's story
게시판 인증 기능 구현 본문
아무 사용자나 글이나 댓글을 삭제, 수정할 수 없도록
인증을 받은 사람만 사용할 수 있도록 만드는 기능
스프링 부트 2.7 과 가이드에 맞춰서
시큐리티 설정을 적용하고, 인증 정보를 db로부터 부르는 빈과
패스워드 인코더 빈까지 등록
영향 받는 부분을 서비스 코드와 컨트롤러에 반영
Principle
자바의 표준 시큐리티 기술이다.
로그인이 된 상태라면 , 계정 정보를 담고 있습니다.
현 상태에서는 principal에 아무 정보가 안담겨있기 때문에 두가지 문제점이 있습니다.
로그인 할 방법이 없습니다.
현재 사용자를 알 수 없습니다.
인증과 권한의 차이
인증 -> 로그인했냐 안했냐
권한 -> 로그인한 사용자가 어떤 권한을 가지고 있느냐
userAccountRepository
userAccountRepository에서 정보를 가져와야 보안쪽에서 권한을 줄 지 말 지 판단,
findById에서 username을 가져온뒤 UserAccountDto::from으로 mapping 받아온 것을 BoardPrincipal로 받아온다.
BoardPrincipal
✍️of생성자, 팩토리 메소드
객체 생성 처리를 서브 클래스로 분리해 처리하도록 캡슐화하는 패턴
즉, 객체의 생성 코드를 별도의 클래스나 메서드로 분리함으로써 객체 생성의 변화에 대비하는 데 유용하다.
package com.fastcampus.projectboard.dto.security;
import com.fastcampus.projectboard.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 {
//of로 바꿔서 팩토리 메소드로 전환
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,
// Collection<? extends GrantedAuthority> authorities 형식이어야 하는데, set을 사용했기 때문에 타입을 바꿔준다.
roleTypes.stream()
.map(RoleType::getName)
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toUnmodifiableSet())
,
email,
nickname,
memo
);
}
//dto로 부터 oardPrincipal조립
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 String getUsername() { return username; }
@Override public String getPassword() { return password; }
@Override public Collection<? extends GrantedAuthority> getAuthorities() { return authorities; }
@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
USER("ROLE_USER");
@Getter private final String name;
RoleType(String name) {
this.name = name;
}
}
}
SecurityConfig
- 스프링 웹 시큐리티를 설정 클래스를 만들려면 @Configuration, @EnableWebSecurity 애노테이션과 WebSecurityConfigurerAdapter 클래스를 상속 받아야 한다.
- http.authorizeRequests() 요청에 대한 권한을 지정
- formLogin() : 인증이 필요한 요청은 스프링시큐리티에서 사용하는 기본 Form Login Page 사용
- httpBasic() : http 기본인증 사용
- authorizeRequests()의 mvcMatchers() 메서드로 요청 URL별 인증 및 Role을 설정할 수 있습니다
- http.formLogin()으로 Form 로그인을 합니다.
- http.httpBasic()으로 HTTP 기본 인증을 활성화 합니다.
- .anyRequest().authenticated() : 인증이 되어야 한다는 이야기이다.
.mvcMatchers("/", "/info").permitAll()
.mvcMatchers("/admin").hasRole("ADMIN")
antMatchers
antMatchers("/login**", "/web-resources/**", "/actuator/**")
특정 리소스에 대해서 권한을 설정합니다.
permitAll
antMatchers("/login**", "/web-resources/**", "/actuator/**").permitAll()
antMatchers 설정한 리소스의 접근을 인증절차 없이 허용한다는 의미 입니다.
hasAnyRole
antMatchers("/admin/**").hasAnyRole("ADMIN")
리소스 admin으로 시작하는 모든 URL은 인증후 ADMIN 레벨의 권한을 가진 사용자만 접근을 허용한다는 의미입니다.
anyRequest
anyRequest().authenticated()
모든 리소스를 의미하며 접근허용 리소스 및 인증후 특정 레벨의 권한을 가진 사용자만 접근가능한 리소스를 설정하고 그외 나머지 리소스들은 무조건 인증을 완료해야 접근이 가능하다는 의미입니다.
※ antMatchers, mvcMatchers 차이
특정경로 지정해서 권한을 설정할때 antMatchers, mvcMatchers가 있는데
antMatchers는 URL 매핑 할때 개미패턴, mvcMatchers는 mvc패턴이다.
antMatchers(”/info”) 하면 /info URL과 매핑 되지만 mvcMatchers(”/info”)는 /info/, /info.html 이 매핑이 가능하다.
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.mvcMatchers("/", "/info").permitAll()
.mvcMatchers("/admin").hasRole("ADMIN")
.anyRequest().authenticated();
http.formLogin();
http.httpBasic();
}
}
encodePassword()
비밀번호가 그대로 DB에 보관되지 않도록 암호화
UserDetailsService
실제 인증데이터를 가져오는 서비스를 구현 DB에 저장하는 UserAccountRepository를 가져온다.
유저의 구체적인 정보와 권한을 작성했으며,
UserAccountRepository-> (UserAccountDto에서 받아온 데이터로--> BoardPrincipal로 만드는 팩토리 메소드)와
(BoardPrincipal을 --> UserAccountDto로 만드는 메소드)도 구현했다.
package com.fastcampus.projectboard.config;
import com.fastcampus.projectboard.dto.UserAccountDto;
import com.fastcampus.projectboard.dto.security.BoardPrincipal;
import com.fastcampus.projectboard.repository.UserAccountRepository;
import org.springframework.boot.autoconfigure.security.servlet.PathRequest;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.crypto.factory.PasswordEncoderFactories;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
@Configuration
public class SecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
return http
.authorizeHttpRequests(auth -> auth
.requestMatchers(PathRequest.toStaticResources().atCommonLocations()).permitAll()
//mvcMathcers() 더 자세한 스프링기반의 패턴매칭
.mvcMatchers(
HttpMethod.GET,
"/",
"/articles",
"/articles/search-hashtag"
).permitAll()
.anyRequest().authenticated()
)
.formLogin().and()
.logout()
.logoutSuccessUrl("/")
.and()
.build();
}
@Bean
//실제 인증데이터를 가져오는 서비스를 구현
//DB에 저장하는 UserAccountRepository를 가져온다.
public UserDetailsService userDetailsService(UserAccountRepository userAccountRepository) {
return username -> userAccountRepository
.findById(username)
.map(UserAccountDto::from)
.map(BoardPrincipal::from)
.orElseThrow(() -> new UsernameNotFoundException("유저를 찾을 수 없습니다 - username: " + username));
}
@Bean
public PasswordEncoder passwordEncoder() {
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
}
[ 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의 내용을 이렇게 변경해주면 된다.
시큐리티가 프로젝트 전반에 영향을 끼쳤기에 ,영향 받은 곳을 다 건드려야한다
JpaConfig
초반에 auditorAware설정을 통해서 사용자 데이터를 임의로 설정했었지만,
이제 사용자의 데이터를 userAccountRepository를 통해서 받아오기 때문에 이를 수정함
package com.fastcampus.projectboard.config;
import com.fastcampus.projectboard.dto.security.BoardPrincipal;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.domain.AuditorAware;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.core.context.SecurityContextHolder;
import java.util.Optional;
@EnableJpaAuditing
@Configuration
public class JpaConfig {
@Bean
public AuditorAware<String> auditorAware() {
//SecurityContext를 가져온다 .
return () -> Optional.ofNullable(SecurityContextHolder.getContext())
.map(SecurityContext::getAuthentication)
.filter(Authentication::isAuthenticated) //인증이 됐는지 확인이 되었으니
//getPrincipal 인증정보를 꺼내오겟다.
.map(Authentication::getPrincipal)
.map(BoardPrincipal.class::cast)//타입캐스팅 (람다식보다 클래스를 불러와서 그 안에 cast를 불러오는게 더 좋다)
.map(BoardPrincipal::getUsername); //UserName 이것이 실제 사용자 정보
}
}
JpaRepositoryTest
JpaRepositoryTest의 테스트를 진행하면서 서큐리티에 문제가 있었지만, 더 앞에는 auditorAware, 즉 auditing을 자동으로 넣는 코드에서 문제가 생기는 것이기 때문에, 그 부분을 테스트 때만 무시하게 하면 된다.
@EnableJpaAuditing
@TestConfiguration
public static class TestJpaConfig {
@Bean public AuditorAware<String> auditorAware() {
return () -> Optional.of("jyc");
}
}
테스트를 진행할 때만, 서큐리티 설정을 넣기 전에 임의로 데이터를 설정한 상태로 변경시켜주는 것이다.
package com.fastcampus.projectboard.config;
import com.fastcampus.projectboard.dto.UserAccountDto;
import com.fastcampus.projectboard.dto.security.BoardPrincipal;
import com.fastcampus.projectboard.repository.UserAccountRepository;
import org.springframework.boot.autoconfigure.security.servlet.PathRequest;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.crypto.factory.PasswordEncoderFactories;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
@Configuration
public class SecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
return http
.authorizeHttpRequests(auth -> auth
.requestMatchers(PathRequest.toStaticResources().atCommonLocations()).permitAll()
//mvcMathcers() 더 자세한 스프링기반의 패턴매칭
.mvcMatchers(
HttpMethod.GET,
"/",
"/articles",
"/articles/search-hashtag"
).permitAll()
.anyRequest().authenticated()
)
.formLogin().and()
.logout()
// 로그아웃이 성공하면 루트페이지로 보내기
.logoutSuccessUrl("/")
.and()
.build();
}
@Bean
//실제 인증데이터를 가져오는 서비스를 구현
//DB에 저장하는 UserAccountRepository를 가져온다.
public UserDetailsService userDetailsService(UserAccountRepository userAccountRepository) {
return username -> userAccountRepository
.findById(username)
.map(UserAccountDto::from)
.map(BoardPrincipal::from)
.orElseThrow(() -> new UsernameNotFoundException("유저를 찾을 수 없습니다 - username: " + username));
}
@Bean
public PasswordEncoder passwordEncoder() {
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
}
ArticleControllerTest.java
현재 테스트 내용을 보면 @Import 에서 SecurityConfig를 사용하는데,
여기에는 사용자관련 정보가 들어가지 않은 상태이며,
이는 곧 인증 기능관련 여부를 테스트 할수 없는 상태라는 뜻이다 .
따라서
@Import(SecurityConfig.class)
import를 바꿔준다.
@Import({TestSecurityConfig.class, FormDataEncoder.class})
테스트에서 인증이 없을때를 추가해준다.
@DisplayName("[view][GET] 게시글 페이지 - 인증 없을 땐 로그인 페이지로 이동")
@Test
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 - 게시글 수정페이지 정상호출 테스트
실패한 테스트들에 인증 정보를 넣어줘야하는데,
여러가지가 있지만 가장 쉬운 방법은 바로 @WithMockUser
인증됐다고 시그널을 보내는 것이다.
단점: 실제 사용자 정보를 사용할 수 없다는 것 (securityConfig에서 작성한 userDetialService를 호출하지 않았기 때문에 )
SecurityConfig.java
@Bean
public UserDetailsService userDetailsService(UserAccountRepository userAccountRepository) {
return username -> userAccountRepository
.findById(username)
.map(UserAccountDto::from)
.map(BoardPrincipal::from)
.orElseThrow(() -> new UsernameNotFoundException("유저를 찾을 수 없습니다 - username: " + username));
}
예를 들어, 게시물을 작성할때는 실제 사용자 정보가 필요하기 때문에
@WithUserDetails - 실제 사용자 정보가 필요할때
(수정, 삭제)
@WithUserDetails(value="jycTest",userDetailsServiceBeanName = "userDetailsService",setupBefore = TestExecutionEvent.TEST_EXECUTION)
userDetailsSerivice를 통해서 유저 정보를 가져오는 작업을 테스트 실행하기 직전에 실행
BeanName-> 메소드명으로 넣어주기
가상의 회원정보를 만들어서 테스트를 한다.
@Import(SecurityConfig.class)
public class TestSecurityConfig {
@MockBean private UserAccountRepository userAccountRepository;
@BeforeTestMethod
//인증과 관련된 테스트만, 특정한 주기에 맞춰서 특정한 코드가 실행되게끔
//계정정보를 가짜로 하나 더 만들어서 잘 보장된 테스트를 돌릴 수 있도록
public void securitySetUp() {
given(userAccountRepository.findById(anyString())).willReturn(Optional.of(UserAccount.of(
"unoTest",
"pw",
"uno-test@email.com",
"uno-test",
"test memo"
)));
}
}
ArticleService.java
public void deleteArticle(long articleId) {
articleRepository.deleteByIdAndUserAccount_UserId(articleId);
}
아무나 삭제할 수 있게 되어있기 때문에 고쳐야한다
public void deleteArticle(long articleId, String userId) {
articleRepository.deleteByIdAndUserAccount_UserId(articleId, userId);
}
해당하는 유저아이디가 아니라면 삭제가 되지 않게끔 구현하자
ArticleRepository.java
void deleteByIdAndUserAccount_UserId(Long articleId, String userid);
ArticleService.java
이렇게 파라미터를 userId 까지 받아오는 메소드를 정의하고
public void deleteArticle(long articleId, String userId) {
articleRepository.deleteByIdAndUserAccount_UserId(articleId,userId);
}
테스트가 통과되지 않기때문에, 테스트를 고쳐준다.
ArticleServiceTest
given에 userId 추가하기
deleteById -> deleteByIdandUserAccount_UserId
deleteArticle(1L) -> deleteArticle(1L,userId);
@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.java
deleteArticle에 유저 Id값을 파라미터로 추가하기 위해서
@AuthenticationPrincipal 어노테이션을 사용해서 이전에 작성한 BoardPrincipal을 불러와서 getUsername 메소드를 호출하여 정보를 가져온다.
예전에는 인증정보에 접근하기 어려울때는 SecurityContextHolder.getContext().getAuthentication()을 사용했었다.
바로 여러가지단계를 건너띄워서, 애노테이션 @AuthenticationPrincipal을 사용하면
인증정보를 설계해놓온 BoardPrinciple로 가져올 수 있다.
✔️@AuthenticationPrincipal
로그인한 사용자의 정보를 파라미터로 받고 싶을때, 기존에는 다음과 같이 Principal 객체로 받아서 사용한다.
.getUserName() 메소드 사용
이제 인증정보 넣어주는 것을 가짜로 하지 않아도 된다.
ArticleController.java
updateArticle()과 postArticle()의 파라미터도 이와같이 변경해줌
->이제 사용자정보를 불러올 수 있으니까
@PostMapping("/{articleId}/delete")
public String deleteArticle(@PathVariable Long articleId,
@AuthenticationPrincipal BoardPrincipal boardPrincipal
){
articleService.deleteArticle(articleId, boardPrincipal.getUsername());
return "redirect:/articles";
}
ArticleService.java
updateArticle을 보면 파라미터가 articleId, ArticleDto만 있지 유저의 정보는 추가되어있지 않은 모습이다.
따라서 작성된 게시글 작성자의 id와 인증된 사용자의 id가 같은 경우에만 수정을 할수 있어야 한다.
작성된 게시글의 사용자와 dto로 부터 받은 새로운 사용자가 같을 때, 업데이트 수용
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());
}
}
ArticleControllerTest.java
@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로 데이터를 전송할 것이다.
2️⃣댓글
게시글과 마찬가지로, 수정과 삭제 부분을 고쳐야한다.
ArticleCommnetServiceTest.java
@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);
}
ArticleCommentRepository.java
테스트 대상 메소드 변경
public void deleteArticleComment(Long articleCommentId, String userId){
articleCommentRepository.deleteByIdAndUserAccount_UserId(articleCommentId,userId);
}
서비스는 바꾸지 않았다. 댓글은 수정이 안되도록 구현하였기 때문에
ArticleCommentControllerTest.java
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);
}
ArticleCommentController.java
애노테이션을 통해 인증정보를 불러오게되고,
Boardprinciple객체로 각 매핑마다 실제 유저의 정보를 추가한다.
Useraccount부분을 BoardPrinciple로 치환
@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 ;
}