Post

06 Spring Security

06 Spring Security

1. Note

  • 오 간단한 듯 복잡한듯 어렵
  • 기본적인 흐름만 먼저 파악해두고
    • 보안과 관련된 공부가 먼저 필요할듯함.
    • “무엇인가를 막는다?” 또는 “예방한다” 하는데 그게 뭔지 모르니까
    • 점점 어려워짐

2. Spring Security

1. Spring Security

  • 스프링 기반 애플리케이션에서 인증(Authentication)과 권한(Authorization) 관리를 담당하는 프레임워크
  • 웹, REST API, 메서드 수준 보안까지 포괄적 보안 기능 제공

2. 핵심기능

  • 인증(Authentication)
    • 사용자가 누구인지 확인
    • 로그인 폼, JWT, OAuth2, SSO 등 다양한 방식 지원
  • 권한(Authorization)
    • 인증된 사용자가 어떤 리소스에 접근할 수 있는지 결정
    • URL 단위, 메서드 단위(@PreAuthorize, @Secured) 등
  • 보안 필터 체인(Filter Chain)
    • 요청(Request)이 들어오면 Filter들이 순서대로 처리
    • 인증, 권한 체크, 세션 관리, CSRF, CORS 등
  • SecurityContext / ThreadLocal
    • 인증 정보(Authentication)를 요청 동안 공유
    • Controller, Service, AOP, Filter 등 어디서든 접근 가능
  • 세션/토큰 관리
    • 로그인 상태 유지, 다음 요청에서 인증 정보 사용 가능
  • 패스워드 암호화
    • PasswordEncoder를 통한 안전한 비밀번호 저장 및 검증

3. 전반적인 흐름

1
2
3
4
5
6
- 사용자가 로그인 요청
- Spring Security → UserDetailsService 호출
- CustomUserDetailsService.loadUserByUsername() 실행
- DB에서 사용자 조회
- CustomUserDetails로 감싸서 반환
- Security가 비밀번호 비교 및 인증 수행

3. Spring Security 사전 셋팅

1. 의존성

1
2
3
4
5
6
7
8
9
10
11
12
dependencies {
    // Spring Security 프레임워크를 통합하기 위한 의존성
    implementation 'org.springframework.boot:spring-boot-starter-security'
    
    // Spring Session과 Redis를 연동하기 위한 의존성
    // 이 의존성을 추가하면 Redis를 세션 저장소로 사용할 수 있도록 Spring Session이 자동 구성
    implementation 'org.springframework.session:spring-session-data-redis'
    
    // Spring Data Redis: Redis 클라이언트를 추상화하고 Spring의 데이터 접근 계층과 통합합
    // 내부적으로 Lettuce(기본) 또는 Jedis 클라이언트를 사용합니다.
    implementation 'org.springframework.boot:spring-boot-starter-data-redis'
}

2. SecurityFilterChain

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
  
  // 필터에서 사용할 화이트리스트
  public static final String[] SECURITY_EXCLUDE_PATHS = {
      "/public/**", "/api/swagger-ui/**", "/swagger-ui/**", "/swagger-ui.html",
      "/api/v3/api-docs/**", "/v3/api-docs/**", "/favicon.ico", "/actuator/**",
      "/swagger-resources/**", "/external/**", "/api/auth/**"
  };
  
  @Bean
  public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    http
          // CSRF 공격 방어 기능을 끄는 설정
          // 보통 Spring은 Nginx를 거쳐서 오기떄문에 csrf를 nginx에서 잡는 편
          .csrf(AbstractHttpConfigurer::disable) 
          
          // 인증 정보(SecurityContext)를 어디에 저장하고, 어디서 꺼낼지 결정하는 설정
          // 부가 bean
          .securityContext(context -> context
              .securityContextRepository(securityContextRepository())
          )
          
          // 세션을 어떻게 만들고, 몇 개까지 허용할지 제어하는 설정
          .sessionManagement(session -> session
              .sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED) // 필요시 생성해서 사용(총 4종)
              .maximumSessions(1) // 한 계정당 동시 로그인 가능한 세션 수 제한
              .maxSessionsPreventsLogin(false) 
              // 동시 로그인 발생 시 어떻게 처리할지 결정 (false -> 신규 로그인 허용, 기존은 강제 로그아웃)
          )
          
          // 인증이 필요한 url과 아닌 url 구분
          .authorizeHttpRequests(auth -> auth
              .requestMatchers(SECURITY_EXCLUDE_PATHS).permitAll() // 인증 필요 X
              .requestMatchers("/api/**").hasRole("USER") // 인증 필요 X
              .anyRequest().authenticated() // 그외에는 인증 필요 O
          )
          
           // 기본 로그인 페이지 + 폼 기반 로그인 기능
          .formLogin(AbstractHttpConfigurer::disable)
          
           // HTTP Basic 인증 방식
          .httpBasic(AbstractHttpConfigurer::disable)
          
          // 인증 실패(로그인 안 된 상태)일 때 어떤 응답을 줄지 직접 정의하는 설정
          .exceptionHandling(ex -> ex
              .authenticationEntryPoint((request, response, authException) -> {
                response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); // 상태 설정 
                response.setContentType("application/json;charset=UTF-8"); // 컨텐츠 설정
                
                // 기존에 사용하던 ApiResponse 객체 
                ApiResponse<Void> errorResponse = ApiResponse.<Void>builder()
                    .error(ApiResponse.Error.of("UNAUTHORIZED", "Authentication required"))
                    .build();
                
                // 리스폰 처리
                response.getWriter().write(objectMapper.writeValueAsString(errorResponse));
              })
          );
      
      return http.build();
  }
  ~~~~~~~~~~~~
  @Bean
  public SecurityContextRepository securityContextRepository() {
  // httpSession에 저장함
  // httpsession이 redis로 잡혀있으면 redis로 감
  return new HttpSessionSecurityContextRepository();
  }
  ~~~~~~~~
}

3. passwordEncoder

1. passwordEncoder

  • 스프링 시큐리티가 패스워드를 해석하는 방식
  • Bean으로 등록해두면 Spring Security가 자동으로 사용

2. 종류

이름특징보안 수준사용 여부
BCryptPasswordEncodersalt 자동, 느린 해싱높음기본
Pbkdf2PasswordEncoder반복 해싱, 설정 가능높음선택
SCryptPasswordEncoder메모리 기반, 강력매우 높음제한적
DelegatingPasswordEncoder여러 알고리즘 혼용상황별확장용
NoOpPasswordEncoder평문 저장없음금지
StandardPasswordEncoderSHA-256 기반낮음deprecated

3. 소스

1
2
3
4
@Bean
public PasswordEncoder passwordEncoder() {
  return new BCryptPasswordEncoder();
}

4. authenticationManager

1. authenticationManager

  • 서비스 단에서 인증정보를 가져가서 사용할 수 있도록 하는 곳

2. 소스

1
2
3
4
5
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration config)
  throws Exception {
  return config.getAuthenticationManager();
}

4. 사용자 조회 & 인증 정보 객체

1. 사용자 조회

1. CustomUserDetailsService (사용자 조회)

  • 로그인 시 사용자 정보를 DB에서 조회
    • 특정한 사용자를 조회하는 방식
    • DB를 쓴다면 이 패턴으로 구현해서 “우리 서비스 방식으로 사용자 조회”
  • 조회한 데이터를 UserDetails 형태로 변환해서 반환
    • 스프링 시큐리티는 내부적으로 UserDetailsService를 통해 사용자 정보를 가져옴

2. 소스

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Service
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {

  private final UserRepository userRepository;

  @Override
  public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
    User user = userRepository.findByEmail(email)
        .orElseThrow(() -> new UsernameNotFoundException("User not found with email: " + email));

    return CustomUserDetails.from(user); //CustomUSerDtails로 반환
  }
}

2. 인증 정보 객체 (Security용 Wrapper)

1. 소스

  • DB에서 가져온 사용자 정보를 스프링 시큐리티가 이해할 수 있는 형태로 감싸는 객체
  • User 엔티티 → Security 전용 객체로 변환
  • 스프링 시큐리티는 내부적으로 UserDetails 인터페이스를 기준으로 동작

2. 소스

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
@Getter
public class CustomUserDetails implements UserDetails, Serializable {

  private static final long serialVersionUID = 1L;

  private final Long userId;
  private final String email;
  private final String password;

  public CustomUserDetails(Long userId, String email, String password) {
    this.userId = userId;
    this.email = email;
    this.password = password;
  }
 
  public static CustomUserDetails from(User user) {
    return new CustomUserDetails(user.getId(), user.getEmail(), user.getPassword());
  }

  @Override
  public Collection<? extends GrantedAuthority> getAuthorities() {
    // 이 유저가 가진 권한(Role) 정보를 시큐리티에 알려줌
    // 예시 1: 단일 권한만 부여
    return Collections.singletonList(new SimpleGrantedAuthority("ROLE_USER"));
    
    // 예시 2: 여러 권한 부여 가능
    // return Arrays.asList(
    //     new SimpleGrantedAuthority("ROLE_USER"),
    //     new SimpleGrantedAuthority("ROLE_ADMIN")
    // );
  }

  @Override
  public String getPassword() {
    // 비밀번호 검증에 사용됨
    return password;
  }

  @Override
  public String getUsername() {
    // email = username 역할
    // 시큐리티는 email을 “아이디”처럼 사용
    // 이미 한번 필터된거라 별도 작업 X
    return email; 
  }

  @Override
  public boolean isAccountNonExpired() {
    // 계정 만료 여부 체크
    // 예시: DB에서 accountExpiryDate를 가져와서 현재 날짜와 비교
    // return accountExpiryDate.isAfter(LocalDate.now());
    return true; // 현재는 만료 없음으로 처리
  }

  @Override
  public boolean isAccountNonLocked() {
    // 계정 잠금 여부 체크
    // 예시: DB에서 failedLoginAttempts 확인
    // return failedLoginAttempts < 5;
    return true; // 현재는 잠금 없음으로 처리
  }

  @Override
  public boolean isCredentialsNonExpired() {
    // 비밀번호 만료 여부 체크
    // 예시: DB에서 passwordLastChangedDate 확인
    // return passwordLastChangedDate.plusMonths(3).isAfter(LocalDate.now());
    return true; // 현재는 비밀번호 만료 없음으로 처리
  }

  @Override
  public boolean isEnabled() {
    // 계정 활성화 여부 체크
    // 예시: DB에서 active 컬럼 확인
    // return active == true;
    return true; // 현재는 항상 활성으로 처리
  }
}

5. AuthService

1. 유저등록

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
  private final UserRepository userRepository;
  private final PasswordEncoder passwordEncoder;
  private final AuthenticationManager authenticationManager;
  private final SecurityContextRepository securityContextRepository;


  @Transactional
  public void registration(RegistrationRequest request) {
    userRepository.save(User.builder()
        .name(request.getName())
        .phone(request.getPhone())
        .email(request.getEmail())
        .password(passwordEncoder.encode(request.getPassword())) //암호화처리
        .gender(request.getGender())
        .build());
  }

2. 로그인 / 인증정보 저장

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
public LoginResponse login(LoginRequest loginRequest, HttpServletRequest request,
      HttpServletResponse response) {

    // 입력받은 이메일과 비밀번호로 AuthenticationToken 생성 후 인증 시도
    // 인증 실패 시 예외로 튕겨나오고, 로그인 성공 로직은 실행되지 않음
    Authentication authentication = authenticationManager.authenticate(
        new UsernamePasswordAuthenticationToken(
            loginRequest.getEmail(),      // 로그인 폼에서 입력받은 이메일
            loginRequest.getPassword()    // 로그인 폼에서 입력받은 비밀번호
        )
    );

    // 비어있는 컨테이너(SecurityContext) 생성
    SecurityContext context = SecurityContextHolder.createEmptyContext();

    // 인증 정보를 저장함.
    context.setAuthentication(authentication);

    // 현재 쓰레드에 SecurityContext 세팅
    // 해당 쓰레드에서는 어디에서도 사용 가능 해짐.(필수X)
    SecurityContextHolder.setContext(context);

    // 외부 저장소에 저장함.
    // 저장해놔야 다른 스레드나 요청에서 시큐리티가 내부 검토하고 바로 통과 / 검증필요 작업을 함. 
    securityContextRepository.saveContext(context, request, response);

    // 인증된 유저 정보를 CustomUserDetails로 가져오기(Response용)
    CustomUserDetails userDetails = (CustomUserDetails) authentication.getPrincipal();

    // 로그인 성공 응답 생성
    return LoginResponse.builder()
        .userId(userDetails.getUserId())   // DB User id
        .email(userDetails.getEmail())     // 로그인 이메일
        .build();
}
This post is licensed under CC BY 4.0 by the author.