[JPA] Spring Security를 이용한 사용자 인증 및 조회

이재민·2024년 10월 30일
0

JPA 

목록 보기
16/21

1. 개요

최근 사이드 프로젝트를 진행하면서 세션을 어떻게 해야할지 고민을 했었다. 처음 개인 프로젝트를 할 때는 sessoin을 수동으로 작업을 했었는데, 이번 사이드 프로젝트를 하면서 아닌 시간이 좀 걸리더라도 Spring Security를 통해서 세션을 자동으로 관리하고 싶은 마음이 컸다.

그래서 여러 곳을 찾아보면서 공부를 해보았다.

먼저 Spring Security를 사용하여 세션을 자동으로 관리하면 어떤 점이 좋을까?

2. Spring Security 세션 자동 관리 장점

Spring Security를 통해 세션을 자동으로 관리하는 데는 여러 가지 장점이 있습니다. 아래는 수동 세션 관리와 비교했을 때의 주요 이점입니다.

1. 간편한 세션 관리

자동 세션 관리는 Spring Security가 세션을 생성하고 관리하기 때문에 개발자가 세션을 수동으로 관리할 필요가 없습니다. 이를 통해 코드의 복잡성을 줄이고, 세션 관리 관련 오류를 줄일 수 있습니다.

2. 일관된 보안 컨텍스트 유지

Spring Security는 인증된 사용자의 정보를 SecurityContextHolder에 저장합니다. 이를 통해 애플리케이션 전체에서 일관된 보안 컨텍스트를 유지할 수 있으며, 필요한 경우 쉽게 접근할 수 있습니다.

3. 세션 만료 관리

자동 세션 관리는 세션 만료를 쉽게 처리할 수 있습니다. Spring Security는 세션 타임아웃을 설정하여 일정 시간 동안 활동이 없을 경우 세션을 자동으로 만료시키고, 사용자가 재로그인하도록 유도합니다.

4. 다중 요청 처리

자동 세션 관리는 여러 요청 간에 인증 정보를 자동으로 공유합니다. 사용자가 로그인한 후, 모든 요청에서 인증 정보를 자동으로 사용할 수 있어 매 요청마다 인증 정보를 수동으로 처리할 필요가 없습니다.

5. 세션 클러스터링 지원

Spring Security는 클러스터 환경에서 세션을 관리하는 데 유리합니다. 여러 서버에 걸쳐 세션 정보를 공유할 수 있어, 로드 밸런싱 환경에서도 일관된 사용자 경험을 제공합니다.

6. 보안 강화

Spring Security의 자동 세션 관리는 보안 관련 기능(예: CSRF 방어, XSS 방어 등)을 함께 제공하므로, 개발자가 이러한 보안 기능을 수동으로 구현할 필요가 없습니다. 이는 보안을 강화하는 데 도움이 됩니다.

7. 유지 관리 용이성

자동 세션 관리는 코드의 유지 관리가 용이합니다. 세션 관리와 관련된 로직이 Spring Security에 통합되어 있기 때문에, 개발자는 비즈니스 로직에 더 집중할 수 있습니다.

결론

Spring Security를 통한 자동 세션 관리는 개발자의 수고를 덜어주고, 코드의 안전성과 유지 관리성을 높이는 데 기여합니다. 이와 같은 장점 덕분에 기업의 웹 애플리케이션에서 Spring Security가 널리 사용되고 있습니다.

(오픈 AI한테 물어보았다) 확실히 세션을 사용하면 보안과 유지 관리의 용이함, 복잡성 감소 등 많은 점에서 장점이 있다는 점을 알 수 있다.

3. 진행 과정

1. build.gradle

먼저 build.gradle에


    // Spring security
    implementation 'org.springframework.boot:spring-boot-starter-security'

의존성 주입을 해준다.

2. CORS 설정

    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/**") // 모든 경로에 대해 CORS 설정
                .allowedOrigins("http://localhost:8080") // 허용할 오리진
                .allowedMethods("GET", "POST", "PUT", "DELETE", "PATCH") // 허용할 HTTP 메서드
                .allowCredentials(true); // 쿠키 전송 허용
    }

클라이언트와 서버가 다른 도메인에서 실행될 경우가 있기 때문에 CORS 설정 해준다.

3. 사용자 정보 SimpleUserDetails 설정

@Getter
public class SimpleUserDetails implements UserDetails {

    private final Member member;

    public SimpleUserDetails(Member member) {
        this.member = member;
    }

    @Override
    public String getUsername() {
        return member.getLoginId(); // 로그인 ID로 설정
    }

    @Override
    public String getPassword() {
        return member.getPassword(); // 암호화된 비밀번호 반환
    }

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return List.of(); // 권한 정보 추가 필요 시 설정
    }

    @Override
    public boolean isAccountNonExpired() {
        return true; // 계정 만료 여부
    }

    @Override
    public boolean isAccountNonLocked() {
        return true; // 계정 잠금 여부
    }

    @Override
    public boolean isCredentialsNonExpired() {
        return true; // 자격 증명 만료 여부
    }

    @Override
    public boolean isEnabled() {
        return true; // 계정 활성화 여부
    }

    // 추가 메서드
    public Long getMemberId() {
        return member.getId();
    }
}

사용자 정보를 담고 있는 SimpleUserDetails 클래스를 생성한다. 이 클래스는UserDetails 인터페이스를 구현하고, 이 클래스를 통해 Spring Security가 사용자 정보를 쉽게 사용할 수 있도록 한다.

4. 사용자 세부 정보 서비스 구현

그리고 사용자 정보를 데이터베이스에서 로드하는 CustomUserDetailsService 클래스를 생성한다.

@Service
@Slf4j
public class CustomUserDetailsService implements UserDetailsService {

    private final MemberRepository memberRepository;

    public CustomUserDetailsService(MemberRepository memberRepository) {
        this.memberRepository = memberRepository;
    }

    @Override
    public UserDetails loadUserByUsername(String loginId) throws UsernameNotFoundException {
        // 로그인 ID를 기반으로 사용자 조회
        Member member = memberRepository.findByLoginId(loginId)
                .orElseThrow(() -> new UsernameNotFoundException("User not found with loginId: " + loginId));

        log.info("User found: {}", member.getLoginId());

        return new SimpleUserDetails(member); // 인증된 사용자 정보 반환
    }
}

5. SecurityConfig

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {

    private final CustomUserDetailsService userDetailsService;

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
                .csrf(csrf -> csrf.disable())
                .authorizeHttpRequests(authorize -> authorize
                        .requestMatchers("/api/members/signup", "/api/members/login").permitAll()
                        .anyRequest().authenticated()
                )
                .sessionManagement(session -> session
                        .sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)
                )
                .addFilterBefore(new LoggingFilter(), UsernamePasswordAuthenticationFilter.class); // 인스턴스 직접 생성

        return http.build();
    }

    @Bean
    public AuthenticationManager authenticationManager(HttpSecurity http) throws Exception {
        // AuthenticationManagerBuilder를 통해 사용자 세부 정보 서비스와 비밀번호 인코더 설정
        AuthenticationManagerBuilder authenticationManagerBuilder =
                http.getSharedObject(AuthenticationManagerBuilder.class);

        authenticationManagerBuilder
                .userDetailsService(userDetailsService)
                .passwordEncoder(passwordEncoder());

        return authenticationManagerBuilder.build();
    }

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

그리고 SecurityConfig를 작성해준다.
회원 조회를 하면서 나는 총 3개의 메서드를 작성하였다.

1. securityFilterChain으로 애플리케이션 보안 정책을 정의 하였다.

여기서 간단한 테스트용이므로 csrf(csrf -> csrf.disable()) 해주었고, authorizeHttpRequests를 통해 어떤 URL이 인증이 필요하지 않는지 확인 해주고,
sessionManagement를 통해 세션을 관리하도록 하였다.

2. 사용자 인증 처리하는 AuthenticationManager 설정

주석에도 달려있다시피 AuthenticationManager를 통해 사용자 로그인할 때 제공 정보 검증 및 공유해서 사용한다.

3. passwordEncoder

passwordEncoder를 통해 비밀번호를 암호화하였다.

6. 세션 타임아웃 설정

server.servlet.session.timeout=30m

4. 로그인 컨트롤러, 서비스

MemberController 중 로그인 부분

    @PostMapping("/login")
    public ResponseEntity<Long> login(@RequestBody @Valid LoginRequest loginRequest, HttpServletRequest httpRequest) {
            Long memberId = memberService.login(loginRequest, httpRequest);
            return ResponseEntity.ok(memberId);
        }

LoginRequest

@Getter
@NoArgsConstructor
@AllArgsConstructor

public class LoginRequest {
    @NotBlank(message = "아이디를 입력해주세요.")
    private String loginId;

    @NotBlank(message = "비밀번호를 입력해주세요.")
    private String password;
}

Login을 위한 DTO이다.

    public Long login(LoginRequest loginRequest, HttpServletRequest httpRequest) {

        // 사용자 정보 조회
        Member member = memberRepository.findByLoginId(loginRequest.getLoginId())
                .orElseThrow(() -> new MemberNotFoundException("잘못된 로그인 ID 또는 비밀번호입니다."));

        // UserDetails 객체 생성
        SimpleUserDetails userDetails = new SimpleUserDetails(member);

        // 인증 전 토큰 생성: 사용자 ID와 입력된 비밀번호 사용
        UsernamePasswordAuthenticationToken authenticationToken =
                new UsernamePasswordAuthenticationToken(userDetails.getUsername(), loginRequest.getPassword());

        // AuthenticationManager를 사용하여 인증
        Authentication authentication = authenticationManager.authenticate(authenticationToken);

        // 인증 성공 여부 확인
        if (authentication == null || !authentication.isAuthenticated()) {
            throw new RuntimeException("Authentication failed");
        }

        // 인증 성공 시 SecurityContext에 저장
        SecurityContextHolder.getContext().setAuthentication(authentication);

        // 현재 세션에 SecurityContext 저장
        HttpSession session = httpRequest.getSession();
        session.setAttribute("SPRING_SECURITY_CONTEXT", SecurityContextHolder.getContext());

        // 인증된 사용자 ID 반환
        return member.getId();
    }

그리고 주석과 같이 이러한 과정을 순서대로 진행하면 된다. 나같은 경우
1. 사용자 정보 조회
2. UserDetails 객체 생성
3. 인증 전 토큰 생성: 사용자 ID와 입력된 비밀번호 사용
4. AuthenticationManager를 사용하여 인증
5. 인증 성공 여부 확인
6. 인증 성공 시 SecurityContext에 저장
7. 현재 세션에 SecurityContext 저장
이러한 과정으로 진행하였다.

특히, 3번에서 인증 전 토큰 생성을 할 때 입력된 비밀번호는 평문 상태로 저장하지 않고, 인증 과정에서 해시된 비밀번호와 비교된다. (미리 1번에서 회원 정보 조회시 사용자의 비밀번호는 해시된 상태로 저장되어 있어야 한다.)

그리고 5번처럼 인증된 사용자 정보 저장하고, 인증 성공 후 현재 사용자 상태를 유지하는 SecurityContext에 authentication을 저장한다.
마지막으로 현재 세션을 SecurityContext에 저장하여, 사용자가 애플리케이션을 사용하는 동안 세션을 자동으로 관리하도록 한다.

추가로 이 부분에서 계속

2024-10-30T11:08:00.164+09:00 DEBUG 70799 --- [nio-8080-exec-4] o.s.s.w.a.AnonymousAuthenticationFilter  : Set SecurityContextHolder to anonymous SecurityContext

이런 오류가 떴었다.
근데
https://stackoverflow.com/questions/78977123/successfull-login-with-authenticationmanager-but-still-403-at-any-request
여기에 다행히 매우 친절하게 나와있어서 추가 후 수정하니 해결이 되었다.

이렇게 일단 컨트롤러와 서비스를 설정해주고
회원 가입 -> 로그인까지 해준 뒤 회원 조회를 한다.

5. 회원 조회 컨트롤러, 서비스

    // 회원 정보 조회
    @GetMapping("/findMember")
    public ResponseEntity<MemberResponseDTO> getMemberInfo(@AuthenticationPrincipal SimpleUserDetails userDetails) {
        Long memberId = userDetails.getMemberId();
        MemberResponseDTO memberInfo = memberService.getMemberInfo(memberId);
        return ResponseEntity.ok(memberInfo);
    }

그리고 회원 정보를 조회해준다.

    // 로그인
    // ID로 회원 찾기
    // 회원 정보 조회 후 DTO로 변환
    public MemberResponseDTO getMemberInfo(Long memberId) {
        Member member = findById(memberId);
        return new MemberResponseDTO(member.getId(), member.getName(), member.getEmail(), member.getAddress());
    }


무사히 200 ok가 떴다.

6. 회고

세션 관련해서 나한테는 좀 큰 벽이라 느껴졌지만, 많이 찾아보고 하니 다행히 조금씩 진전되어 해결할 수 있었다. 이렇듯 개발하면서 모르는 걸 계속 배우는게 재밌는 것 같다. 다음에도 모르는 게 있으면 해결 과정을 작성할 예정이다!

profile
복학생의 개발 일기

0개의 댓글

관련 채용 정보