✅ 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 로그인 과정 요약:
- 사용자가 로그인 폼에서 username과 password 입력
- Spring Security는 내부적으로 이걸로 인증 시도
- UserDetailsService.loadUserByUsername() 호출함
- DB에서 사용자 정보를 가져오고
- 비밀번호 비교하고 로그인 성공/실패 판단
✅ 없으면 어떻게 되나?
- Spring Security가 로그인 시도할 때,
- UserDetailsService 구현이 없거나 loadUserByUsername()이 구현 안돼 있으면
- "No UserDetailsService configured" 라는 오류가 발생하거나
- 로그인 시도 자체가 안 됨 (계속 실패하거나 에러로 빠짐)
✅ 꼭 이 메서드 이름이어야 해?
✔️ 네. 반드시 loadUserByUsername() 이름 그대로 있어야 하고, 오버라이딩해야 해요.
이건 Spring Security가 정해놓은 인터페이스 메서드이기 때문이야:
public interface UserDetailsService {
UserDetails loadUserByUsername(String username) throws UsernameNotFoundException;
}
- 로그인 에러
: pdf 파일 iframe 열기 시도시 연결안됨.
✅ 문제 원인
크롬 에러창
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));
}