package com.sparta.springsecurity.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.web.SecurityFilterChain;
@Configuration
@RequiredArgsConstructor
@EnableWebSecurity // 스프링 Security 지원을 가능하게 함
public class WebSecurityConfig {
private final UserDetailsServiceImpl userDetailsService;
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public WebSecurityCustomizer webSecurityCustomizer() {
// h2-console 사용 및 resources 접근 허용 설정
return (web) -> web.ignoring()
.requestMatchers(PathRequest.toH2Console())
.requestMatchers(PathRequest.toStaticResources().atCommonLocations());
}
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
// CSRF 설정
http.csrf().disable();
http.authorizeRequests().antMatchers("/api/user/**").permitAll()
.anyRequest().authenticated();
// Custom 로그인 페이지 사용
http.formLogin().loginPage("/api/user/login-page").permitAll();
// Custom Filter 등록하기
http.addFilterBefore(new CustomSecurityFilter(userDetailsService, passwordEncoder()), UsernamePasswordAuthenticationFilter.class);
return http.build();
}
}
지정된 필터 앞에 커스텀 필터를 추가 (UsernamePasswordAuthenticationFilter 보다 먼저 실행된다)
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
// Custom Filter 등록하기
http.addFilterBefore(new CustomSecurityFilter(userDetailsService, passwordEncoder()), UsernamePasswordAuthenticationFilter.class);
UsernamePasswordAuthenticationFilter에서 기본적으로는 username/password방식으로 인증처리가 되지만
CustomSecurityFilter를 사용할 예정으로
이제 우리가 적용할 프로젝트에는 JWT토큰을 사용할 것이기 때문에 JWT토큰을 검증할 필터도 만들어서 추가를 할 것이다.
UsernamePasswordAuthenticationFilter.class 보다 CustomSecurityFilter를 먼저 실행하게 한다.
일반적인 Spring Security의 인증 과정은 다음과 같습니다:
http.addFilterBefore(new CustomSecurityFilter(userDetailsService, passwordEncoder()), UsernamePasswordAuthenticationFilter.class);
📌 이렇게 설정을 하면 이 CustomSecurityFilter가 UsernamePasswordAuthenticationFilter를 돌기 전에 먼저 진행이 되어
📌 성공한 경우 Authentication 객체를 생성하여 SecurityContextHolder에 저장한다.
📌 따라서 이후 실행되는 UsernamePasswordAuthenticationFilter는 이미 인증이 완료된 상태이므로, 다음 필터로 계속 전달되면서 컨트롤러까지 요청이 지나갈 수 있다.
📌 즉, CustomSecurityFilter는 사용자 인증을 처리하여 SecurityContextHolder에 인증 정보를 저장하고, 이후의 필터 및 처리 과정에서 이 인증 정보를 활용할 수 있게 된다. 이는 인증이 완료된 상태에서 요청이 계속 처리될 수 있도록 하는 역할을 한다.
package com.sparta.springsecurity.controller;
import com.sparta.springsecurity.dto.SignupRequestDto;
import com.sparta.springsecurity.entity.User;
import com.sparta.springsecurity.entity.UserRoleEnum;
import com.sparta.springsecurity.repository.UserRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.ModelAndView;
import java.util.Optional;
@Controller
@RequiredArgsConstructor
@RequestMapping("/api/user")
public class UserController {
private final PasswordEncoder passwordEncoder;
private final UserRepository userRepository;
// ADMIN_TOKEN
private static final String ADMIN_TOKEN = "AAABnvxRVklrnYxKZ0aHgTBcXukeZygoC";
@GetMapping("/signup")
public ModelAndView signupPage() {
return new ModelAndView("signup");
}
@GetMapping("/login-page")
public ModelAndView loginPage() {
return new ModelAndView("login");
}
@PostMapping("/signup")
public String signup(SignupRequestDto signupRequestDto) {
String username = signupRequestDto.getUsername();
String password = passwordEncoder.encode(signupRequestDto.getPassword());
// 회원 중복 확인
Optional<User> found = userRepository.findByUsername(username);
if (found.isPresent()) {
throw new IllegalArgumentException("중복된 사용자가 존재합니다.");
}
// 사용자 ROLE 확인
UserRoleEnum role = UserRoleEnum.USER;
if (signupRequestDto.isAdmin()) {
if (!signupRequestDto.getAdminToken().equals(ADMIN_TOKEN)) {
throw new IllegalArgumentException("관리자 암호가 틀려 등록이 불가능합니다.");
}
role = UserRoleEnum.ADMIN;
}
User user = new User(username, password, role);
userRepository.save(user);
return "redirect:/api/user/login-page";
}
}
package com.sparta.springsecurity.dto;
import lombok.Getter;
import lombok.Setter;
@Setter
@Getter
public class LoginRequestDto {
private String username;
private String password;
}
package com.sparta.springsecurity.dto;
import lombok.Getter;
import lombok.Setter;
@Setter
@Getter
public class SignupRequestDto {
private String username;
private String password;
private boolean admin = false;
private String adminToken = "";
}
package com.sparta.springsecurity.security;
import lombok.RequiredArgsConstructor;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.web.filter.OncePerRequestFilter;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
@RequiredArgsConstructor
public class CustomSecurityFilter extends OncePerRequestFilter {
private final UserDetailsServiceImpl userDetailsService;
private final PasswordEncoder passwordEncoder;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
String username = request.getParameter("username");
String password = request.getParameter("password");
System.out.println("username = " + username);
System.out.println("password = " + password);
System.out.println("request.getRequestURI() = " + request.getRequestURI());
if(username != null && password != null && (request.getRequestURI().equals("/api/user/login") || request.getRequestURI().equals("/api/test-secured"))){
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
// 비밀번호 확인
if(!passwordEncoder.matches(password, userDetails.getPassword())) {
throw new IllegalAccessError("비밀번호가 일치하지 않습니다.");
}
// 인증 객체 생성 및 등록
SecurityContext context = SecurityContextHolder.createEmptyContext();
Authentication authentication = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
context.setAuthentication(authentication);
SecurityContextHolder.setContext(context);
}
filterChain.doFilter(request,response);
}
}
기존에 있는 필터를 상속받아서 필터를 만들면 doFilterInternal함수를 @Override재정의해서 사용하게 된다.
@RequiredArgsConstructor
public class CustomSecurityFilter extends OncePerRequestFilter {
private final UserDetailsServiceImpl userDetailsService;
private final PasswordEncoder passwordEncoder;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
🔔 파라미터 3개 : 🔑request, 🔒response, ⛓filterChain 있는데
주의 깊게 볼 점은
우리가 API요청이 오면 HTTP객체가 필터를 타고서 controller까지 들어온다라고 했으므로 파라미터로 🔑request를 받는 것이고
⛓filterChain 은 필터가 체인형식으로 연결되어 있는데 이것을 사용해서 필터끼리 이동을 한다.
이 필터가 끝나고 나면 다음 필터로 넘어가게 된다.
filterChain.doFilter를 사용해서 여기에 request, response 를 담아서 다음 필터로 이동 시켜준다.
🎈만약에 여기서 예외처리가 발생이 되면 이 doFilter를 타고 다음 filter가 아니라 그 이전에 filter로 예외가 넘어간다.
credential쪽에는 위에서 검증이 끝났기 때문에 넣어줄 필요가 없어 null값으로 넣어준다.
🔥 우리의 프로젝트에서는 토큰을 사용해서 인증/인가를 구현했습니다. 따라서 지금 실습환경과는 다르게 Filter 에서 ID, PWD가 아니라 토큰을 검증해야 하는데 로그인 요청에는 당연히 토큰이 없죠? 따라서 우리의 프로젝트에서는 로그인 검증은 서비스에서 진행이 되고 로그인 성공 후 반환된 토큰이 인증이 필요한 API 요청과 같이 들어왔을 때 토큰을 검증하여 사용자를 인증처리 해주는 JwtAuthFilter로 적용 할 예정입니다.
즉, 정리를 하자면 실습에서는 토큰방식이 적용되어 있지 않기 때문에 Filter 에서 사용자가 요청한 로그인을 검증하고 인증하고 있지만 토큰방식을 적용하게 되면 사용자의 로그인,회원가입과 같은 요청은 Filter에서 인증되지 않게 permitAll 처리하여 실제 검증 및 인증 처리는 service 에서 수행하고 그외의 인증이 필요한 요청 에서는 로그인을 통해 발급받은 토큰을 같이 보내 Filter에서 토큰을 검증하고 인증처리를 하게 됩니다.
-참고 : 스파르타코딩클럽 강의