thinkingofnickname 2025. 4. 23. 06:20
반응형

✅ 12일차 목표 제안

기능설명
✅ 회원가입 DB에 사용자 저장 (비밀번호 암호화 포함)
✅ 로그인 Spring Security로 로그인 세션 관리
✅ 인증된 사용자만 글쓰기 비회원은 글쓰기 막기 (403 or 로그인 페이지로 리디렉트)
✅ 현재 로그인된 사용자 정보 가져오기 작성자 자동 입력, 수정/삭제 권한 제어까지 가능하게 준비!

 

 

- 로그인 

: 로그인하는데 mapping 에러가 난다. 다 알맞게 되어있는데...

🔥 Spring Security의 로그인은 /login으로 POST를 날려야 하고, 그 요청은 Security가 처리한다!

내 login.html

<form class="login-form" th:action="@{/users/login}" method="post">

 

그런데 Spring Security는 기본적으로:

.formLogin(form -> form
    .loginPage("/login") // 이건 GET 요청만 처리해
    .defaultSuccessUrl("/") // 로그인 성공 시 이동
)
  • POST /login 은 Spring Security가 가로채서 처리해!
  • 그런데 나 /api/users/login으로 보내고, 그건 JSON 처리 기대하고 있음 → 매핑 에러

 1. login.html 수정

<form class="login-form" th:action="@{/login}" method="post">

 

🔧 그리고 Spring Security는 이 필드명만 인식해:

  • name="username"
  • name="password"

그래서 이렇게 유지해야 해:

<input type="text" name="username" ...>
<input type="password" name="password" ...>

 

2. UserController 수정 

// 로그인 직접 처리하지 마셈!! ↓ 지워!
@PostMapping("/login")
public String login(@RequestBody UserLoginRequestDto dto) {
    return userService.login(dto);
}

 

3.SecurityConfig : formLogin 설정하기 

.formLogin(form -> form
    .loginPage("/login")            // 로그인 페이지 URL (GET)
    .defaultSuccessUrl("/", true)   // 로그인 성공 시 이동할 페이지
    .permitAll()                    // 로그인 페이지 접근 허용
)

 

 

- 회원가입 후 자동로그인 하기 

: Spring Security에서는 SecurityContextHolder + UsernamePasswordAuthenticationToken을 이용하면 회원가입 직후 바로 로그인된 상태로 만들어줄 수 있다!!

 

✅ 1. UserService에 자동 로그인 기능 추가

public class UserService {

    private final UserRepository userRepository;
    private final PasswordEncoder passwordEncoder;

    public void register(UserRegisterDto dto) {
        User user = new User();
        user.setUsername(dto.getUsername());
        user.setPassword(passwordEncoder.encode(dto.getPassword()));
        user.setEmail(dto.getEmail());
        user.setNickname(dto.getNickname());
        user.setRole("USER");

        userRepository.save(user);

        // ✅ 자동 로그인 처리
        UsernamePasswordAuthenticationToken authToken =
                new UsernamePasswordAuthenticationToken(
                        user.getUsername(),
                        null,
                        Collections.singletonList(new SimpleGrantedAuthority("ROLE_" + user.getRole()))
                );

        SecurityContextHolder.getContext().setAuthentication(authToken);
    }
}

 

✅ 주의사항

: 자동 로그인을 하려면 반드시 아래 설정이 SecurityConfig에 포함되어 있어야 한다!

@Bean
public PasswordEncoder passwordEncoder() {
    return new BCryptPasswordEncoder();
}

 



- 로그인 에러

: 로그인 시도하면 주소창에 http://localhost:8080/login?error 이렇게 뜨고 콘솔에는 아무 에러 메시지가 안나옴.

1. Spring Security가 자동으로 /login Post 요청을 처리하므로 Controller에 명시할 필요가 없음. 

2. Spring Security가 로그인 처리를 위해 필요한 핵심 메서드 loadUserByUsername()을 찾을 수가 없음.

 

✅ 왜 꼭 필요한가?

Spring Security 로그인 과정 요약:

  1. 사용자가 로그인 폼에서 username과 password 입력
  2. Spring Security는 내부적으로 이걸로 인증 시도
  3. UserDetailsService.loadUserByUsername() 호출함
  4. DB에서 사용자 정보를 가져오고
  5. 비밀번호 비교하고 로그인 성공/실패 판단

✅ 없으면 어떻게 되나?

  • Spring Security가 로그인 시도할 때,
  • UserDetailsService 구현이 없거나 loadUserByUsername()이 구현 안돼 있으면
  • "No UserDetailsService configured" 라는 오류가 발생하거나
  • 로그인 시도 자체가 안 됨 (계속 실패하거나 에러로 빠짐)

✅ 꼭 이 메서드 이름이어야 해?

✔️ 네. 반드시 loadUserByUsername() 이름 그대로 있어야 하고, 오버라이딩해야 해요.
이건 Spring Security가 정해놓은 인터페이스 메서드이기 때문이야:

public interface UserDetailsService {
    UserDetails loadUserByUsername(String username) throws UsernameNotFoundException;
}

 

 

- 로그인 에러

: pdf 파일 iframe 열기 시도시 연결안됨.

홈페이지는 잘 뜨는데 iframe태그 안의 pdf 부분이 이렇게 뜸.

 

 

✅ 문제 원인

크롬 에러창

Refused to display 'http://localhost:8080/' in a frame because it set 'X-Frame-Options' to 'DENY'.

=> Spring Security는 기본적으로 보안 강화를 위해 모든 응답에 X-Frame-Options: DENY 함.

iframe으로 절대 다른 페이지 못 띄움! (심지어 자기 자신도 안 됨!)

 

 

@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
    http
     
        .headers(headers -> headers
            .frameOptions(frame -> frame.sameOrigin()) // ✅ 여기!
        );

    return http.build();
}

 

- 로그인 사용자만 감정등록과 게시판 글쓰기 가능하게

1: SecurityConfig 설정으로 URL 제한

2: Thymeleaf 템플릿에서 로그인 사용자만 버튼 보이게

<div th:if="${#authorization.expression('isAuthenticated()')}">
    <a th:href="@{/posts/create}" class="btn">글쓰기</a>
</div>

3. Controller에서도 Double Check

 @GetMapping("/posts/write")
    public String showCreateForm(Principal principal) {
        if (principal == null) {
            return "redirect:/login";
        }
        return "create"; // templates/create.html
    }

: Principal은 Spring Security가 로그인한 사용자 정보를 담아서 컨트롤러에 넘겨주는 객체.

Principal은 자바 표준 인터페이스인데, Spring Security에서는 Authentication.getPrincipal()을 꺼내서 자동 주입해줌.

 

 

- 댓글이 있을 시 삭제가 안되므로 경고문(alert) 띄우기 

1. Controller

@PostMapping("/{id}/delete")
public String deletePost(@PathVariable Long id, RedirectAttributes redirectAttributes) {
    try {
        postService.deletePost(id);
        return "redirect:/community";
    } catch (IllegalStateException e) {
        redirectAttributes.addFlashAttribute("errorMessage", e.getMessage());
        return "redirect:/posts/" + id;
    }
}

2. service

@Transactional
public void deletePost(Long postId) {
    // commentRepository.deleteByPostId(postId); // 댓글 먼저 삭제
    boolean hasComment = commentRepository.existsByPostId(postId);
    if (hasComment) {
        throw new IllegalStateException("댓글이 있는 게시글은 삭제할 수 없습니다.");
    }
    postRepository.deleteById(postId);        // 그 다음 게시글 삭제
}

3. 상세페이지 .html에서 alert 띄우기

<script src="https://cdn.jsdelivr.net/npm/sweetalert2@11"></script>
<script th:if="${errorMessage}" th:inline="javascript">
    Swal.fire({
        icon: 'error',
        title: '삭제 실패',
        text: [[${errorMessage}]]
    });
</script>

 

- 댓글 작성 / 수정 / 삭제 버튼도 로그인 한 사람만 보게 하기

1: isAuthenticated를 안전하게 넘기기

<script th:inline="javascript">
    const isAuthenticated = /*[[${#authorization.expression('isAuthenticated()')}]]*/ false;
</script>

2. renderComment() 함수에서 buttonHtml만 조건으로

function renderComment(comment, container, depth = 0) {
  const div = document.createElement('div');
  div.style.marginLeft = `${depth * 20}px`;

  const ipDisplay = userIsAdmin
    ? comment.maskedIp || 'unknown'
    : (comment.maskedIp ? comment.maskedIp.replace(/\.\d+$/, '.xxx') : 'unknown');

  let buttonHtml = '';
  if (isAuthenticated) {
    buttonHtml = `
      <button onclick="showReplyForm(${comment.id})">답글</button>
      <button onclick="showEditForm(${comment.id}, '${comment.content.replace(/'/g, "\\'")}')">수정</button>
      <button onclick="deleteComment(${comment.id})">삭제</button>
      <div id="replyForm-${comment.id}"></div>
      <div id="editForm-${comment.id}"></div>
    `;
  }

  div.innerHTML = `
    <div style="border-bottom:1px solid #ccc; padding:5px 0;">
      <strong>${comment.writer}</strong> (${ipDisplay}) - ${new Date(comment.createdAt).toLocaleString()}<br>
      <span id="comment-content-${comment.id}">${comment.content}</span><br>
      ${buttonHtml}
    </div>
  `;

  container.appendChild(div);

  comment.children.forEach(child => renderComment(child, container, depth + 1));
}
반응형