스프링 시큐리티 6

Shiba·2024년 8월 4일
0

📝정리 노트

목록 보기
4/4

앞선 프로젝트 중간점검 글에서 스프링 시큐리티를 적용해보았다. 하지만, 내용이 많고, 어려우며 최근에는 버전 이슈때문에 적용하는데에 힘이 들었다. 그래서 이 글을 작성하면서 스프링 시큐리티에 대해 제대로 알아보고, 스프링 시큐리티를 어떻게 적용하는지에 대해 정리해보려고 한다.

스프링 시큐리티 6.3.1.2 버전을 사용했다.

🔐 스프링 시큐리티가 뭘까?

스프링 시큐리티는 간단하게 말하면 회원가입,로그인,로그아웃,권한 관리와 같이 사용자들에게 적절한 권한을 주고, 보안 기능을 제공해야하는데 이러한 기능들을 만드는 것은 어렵기도하고 아주 많은 시간이 들 것이다. 이를 상당히 편리하게 구현하여 사용할 수 있도록 스프링은 스프링 시큐리티라고 하는 이미 해당 기능들을 구현해놓은 프레임워크를 지원해주는 것이다.
정리하자면 다음과 같겠다.

스프링 시큐리티는 개발자들이 시간을 많이 들이지 않고도 사용자들에게 권한,보안 기능을 적절히 제공해줄 수 있도록 스프링이 지원하는 프레임워크


❓ 스프링 시큐리티를 사용하는 이유

물론 위에서 말했듯이 스프링 시큐리티가 개발 작업 효율을 높일 수 있기 때문이기도 하지만, 스프링 시큐리티는 스프링의 생태계에서 보안에 필요한 기능들을 제공하기 때문이다. 또한, 직접 만들게 된다면 Spring이 추구하는 IoC/DI 패턴을 만드는게 쉽지 않을 것이다.

스프링 시큐리티의 구조

이 사진은 스프링 시큐리티의 아키텍처 사진이고 스프링 시큐리티의 흐름은 아래와 같다.

  1. Http Request 수신
    - 사용자가 로그인 정보와 함께 인증 요청을 한다.

  2. 유저 자격을 기반으로 인증토큰 생성
    -> AuthenticationFilter가 요청을 가로채고, 가로챈 정보를 통해 UsernamePasswordAuthenticationToken이라는 인증용 객체를 생성한다.

  3. FIlter를 통해 Token을 AuthenticationManager로 위임
    -> AuthenticationManager의 구현체인 ProviderManager에게 생성한 UsernamePasswordToken 객체를 전달한다.

  4. AuthenticationProvider의 목록으로 인증을 시도
    -> AutenticationManger는 등록된 AuthenticationProvider들을 조회하며 인증을 요구한다.

  5. UserDetailsService의 요구
    -> 실제 데이터베이스에서 사용자 인증정보를 가져오는 UserDetailsService에 사용자 정보를 넘겨준다.

  6. UserDetails를 이용해 User객체에 대한 정보 탐색
    -> 넘겨받은 사용자 정보를 통해 데이터베이스에서 찾아낸 사용자 정보인 UserDetails 객체를 만든다.

  7. User 객체의 정보들을 UserDetails가 UserDetailsService(LoginService)로 전달
    -> AuthenticaitonProvider들은 UserDetails를 넘겨받고 사용자 정보를 비교한다.

  8. 인증 객체 or AuthenticationException
    -> 인증이 완료가되면 권한 등의 사용자 정보를 담은 Authentication 객체를 반환한다.

  9. 인증 끝
    -> 다시 최초의 AuthenticationFilter에 Authentication 객체가 반환된다.

  10. SecurityContext에 인증 객체를 설정
    -> Authentication 객체를 Security Context에 저장한다.


    최종적으로는 SecurityContextHolder는 세션 영역에 있는 SecurityContext에 Authentication 객체를 저장한다. 사용자 정보를 저장한다는 것은 스프링 시큐리티가 전통적인 세선-쿠키 기반의 인증 방식을 사용한다는 것을 의미한다.

간단 요약

  1. 로그인 요청이 들어옴
  2. 필터가 해당 요청정보를 이용해 사용자 토큰을 만듬
  3. 만든 토큰을 매니저한테 전달
  4. 매니저가 제공자에게 정보를 주며 인증 요구
  5. 제공자는 서비스한테 데이터베이스 검색을 요청
  6. 서비스가 해당 사용자와 일치하는 정보를 찾아냄
  7. 인증이 완료되어서 그 결과를 다시 필터까지 전달
  8. 필터가 해당 사용자 정보를 세션에 인증객체로 만들어 저장
  9. 로그인 완료

📗 사용법

의존성 추가

implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity6'

//사용하는 데이터베이스 의존성도 추가해야 함 (MySql사용)
implementation 'mysql:mysql-connector-java:8.0.33'

Config파일 구성

@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class SpringConfig {
	@Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http.csrf((csrf) -> csrf.disable())
                .cors(AbstractHttpConfigurer::disable)
                .authorizeHttpRequests((requests)->requests
                        .requestMatchers("/css/**", "/js/**", "/images/**", "/json/**").permitAll() // CSS 파일에 대한 접근을 허용
                        .requestMatchers("/user/status", "/products/add", "/cart/**").authenticated()
                        .anyRequest().permitAll())
                .exceptionHandling(ex -> ex
                .accessDeniedPage("/cart/add") // 접근 거부 페이지 설정
                )
                .formLogin(formLogin -> formLogin
                        .loginPage("/login")
                        .loginProcessingUrl("/login-process")
                        .usernameParameter("loginId")	// [C] submit할 아이디
                        .passwordParameter("password")
                        .permitAll()
                        .defaultSuccessUrl("/") // 성공 시 리다이렉트 URL
                        .failureUrl("/login?error") // 실패 시 리다이렉트 URL
                )
                .sessionManagement(session -> session
                .sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)
                .sessionFixation().newSession() // 세션 고정 보호
                )
                .httpBasic(Customizer.withDefaults())
                .logout((logout) -> logout
                        .logoutUrl("/logout") // 로그아웃
                        .logoutSuccessUrl("/")
                        .invalidateHttpSession(true));// 세션 무효화

        return http.build();
    }

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

여기서 가장 적용할 때 이슈가 많았다. 일단 스프링 시큐리티 6 이상 버전 부터는 무조건 PasswordEncoder 빈 객체가 있어야 서버가 실행이 되었다.
또한 userDetailsService를 직접 만들어 빈으로 선언하는 경우가 많았는데 오히려 지우고 하니까 제대로 실행이 된다. (이건 아마 애노테이션 아니면 내가 UserDetailsService를 제대로 안 만들었어서 그런거같다)

컨트롤러 구현

import com.shoppingmall.domain.Users;
import com.shoppingmall.service.UserService;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.servlet.http.HttpSession;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestParam;

import java.util.Map;

@Slf4j
@Controller
public class LoginController {

    @Autowired
    private UserService userService;

    @Autowired
    private PasswordEncoder passwordEncoder;

    @GetMapping("/login")
    public String login() {
        return "/user/login";
    }

    @GetMapping("/user/logout")
    public String logout(HttpServletResponse response) {
        // JWT 토큰을 저장하는 쿠키의 값을 삭제
        Cookie jwtCookie = new Cookie("jwt", null);
        jwtCookie.setMaxAge(0);  // 쿠키의 유효기간을 0으로 설정하여 즉시 삭제
        jwtCookie.setPath("/");
        response.addCookie(jwtCookie);

        return "redirect:/login";  // 로그인 페이지로 리다이렉트
    }

    @PostMapping("/user/new")
    public ResponseEntity<String> registerUser(@RequestBody Users users) {
        Users savedUser = null;
        ResponseEntity response = null;
        try {
            String hashPwd = passwordEncoder.encode(users.getPassword());
            users.setPassword(hashPwd);
            if(userService.findById(users.getId()) == null) {
                savedUser = userService.join(users);
                if (savedUser.getId() != null) {
                    response = ResponseEntity
                            .status(HttpStatus.CREATED)
                            .body(Map.of("message", "success"));
                }
            }
            else{
                response = ResponseEntity
                        .status(HttpStatus.CREATED)
                        .body("중복된 ID입니다");
            }
        } catch (Exception ex) {
            response = ResponseEntity
                    .status(HttpStatus.INTERNAL_SERVER_ERROR)
                    .body("An exception occured due to " + ex.getMessage());
        }
        return response;
    }
}

로그인을 위한 컨트롤러는 GetMapping만 있으면 된다. 스프링 시큐리티가 알아서 정보를 가로채서 해당 기능들을 모두 수행하기 때문이다.
회원가입을 할 때에는 PostMapping이 필요하며, 여기서 중요한 것은 PasswordEncoder를 통해 인코딩된 비밀번호를 저장하는 것이다.
로그인 요청을 받을 때, 받은 패스워드를 PasswordEncoder로 인코딩한 후에 비교하기 때문이다. 그래서 이전에 인코딩 되지않은 raw한 패스워드가 있다면 로그인이 되지 않는 현상을 확인했다.


이렇게 아주 간단하게 스프링 시큐리티를 정리해보았다. 물론 스프링 시큐리티를 더 확실히 알기 위해서는 filter,manager,provider,userDetailsService등 스프링 시큐리티에서 사용하는 객체들의 구조나 원리까지 정확하게 알아두어야 할 것이다.
(이 부분은 프로젝트를 끝내고 스프링을 정리하면서 다시 보는 것으로 하자)


참고자료

https://www.elancer.co.kr/blog/view?seq=235

https://velog.io/@dh1010a/Spring-Spring-Security%EB%A5%BC-%EC%9D%B4%EC%9A%A9%ED%95%9C-%EB%A1%9C%EA%B7%B8%EC%9D%B8-%EA%B5%AC%ED%98%84-%EC%8A%A4%ED%94%84%EB%A7%81%EB%B6%80%ED%8A%B8-3.X-%EB%B2%84%EC%A0%84-1

https://velog.io/@pizza_1/Spring-Security-6-%ED%8A%9C%ED%86%A0%EB%A6%AC%EC%96%BC-1

https://velog.io/@hope0206/Spring-Security-%EA%B5%AC%EC%A1%B0-%ED%9D%90%EB%A6%84-%EA%B7%B8%EB%A6%AC%EA%B3%A0-%EC%97%AD%ED%95%A0-%EC%95%8C%EC%95%84%EB%B3%B4%EA%B8%B0

profile
모르는 것 정리하기

0개의 댓글