개발놀이터

스프링 시큐리티 + JWT 인증 레이어 추가하기 (3) : 주요클래스 (JwtService와 Controller) 본문

카테고리 없음

스프링 시큐리티 + JWT 인증 레이어 추가하기 (3) : 주요클래스 (JwtService와 Controller)

마늘냄새폴폴 2023. 5. 20. 05:08

https://coding-review.tistory.com/383

 

스프링 시큐리티 + JWT 인증 레이어 추가하기 (2) : 주요 클래스

https://coding-review.tistory.com/382 스프링 시큐리티 + JWT 인증 레이어 추가하기 (1) : 개요 이번 포스팅에선 스프링 시큐리티에 JWT 인증 레이어를 추가하는 방법에 대해서 소개해드릴까 합니다. 제가 이

coding-review.tistory.com

이전 포스팅과 이어집니다. 

 

다음은 JwtService입니다. 이 곳에서 본격적으로 로그인이 일어날 때 로직을 구현했습니다. 

 

package com.hello.capston.jwt.service;

import com.hello.capston.jwt.JwtTokenProvider;
import com.hello.capston.jwt.dto.Response;
import com.hello.capston.jwt.dto.UserRequestDto;
import com.hello.capston.jwt.dto.UserResponseDto;
import com.hello.capston.repository.MemberRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.util.ObjectUtils;

import java.util.concurrent.TimeUnit;

@Slf4j
@RequiredArgsConstructor
@Service
public class JwtService {

    private final MemberRepository memberRepository;
    private final Response response;
    private final JwtTokenProvider jwtTokenProvider;
    private final AuthenticationManager authenticationManager;
    private final RedisTemplate redisTemplate;

    public UserResponseDto login(UserRequestDto.Login login) {
        memberRepository.findByLoginId(login.getUsername()).orElseThrow(
                () -> new RuntimeException("Not Found Member")
        );

        // 1. Login ID/PW 를 기반으로 Authentication 객체 생성
        // 이 때 authentication 은 인증 여부를 확인하는 isAuthenticated 값이 false
        UsernamePasswordAuthenticationToken authenticationToken = login.toAuthentication();

        // 2. 실제 검증이 이루어지는 부분
        // authenticate 메서드가 실행될 때 PrincipalDetailService 에서 만든 loadUserByUsername 메서드가 실행
        Authentication authentication = authenticationManager.authenticate(authenticationToken);

        // 3. 인증 정보를 기반으로 JWT 토큰 생성
        UserResponseDto tokenInfo = jwtTokenProvider.generateToken(authentication);

        // 4. RefreshToken Redis 저장
        redisTemplate.opsForValue().set("RT:" + authentication.getName(), tokenInfo, tokenInfo.getRefreshTokenExpirationTime(), TimeUnit.MILLISECONDS);

        // Customize 5. Spring Security Context Holder 에 Authentication 객체 삽입
        SecurityContextHolder.getContext().setAuthentication(authentication);

        return tokenInfo;
    }
}

여기서 눈여겨 보셔야할 점은 스프링 시큐리티의 인증을 위해서라면 반드시 만들어야할 메서드인 loadUserByUsername을 이용해 Authenticaion 객체를 가져오는 부분인데요. 

 

스프링 시큐리티의 전반적인 동작 원리를 알고 계신다면 아시겠지만 

 

  1. Filter로 ID / PW를 인터셉트
  2. UsernamePasswordAuthenticaionToken 생성
  3. Token을 처리할 AuthenticationManager (실질적인 구현체는 ProviderManager) 로 authenticate() 메서드를 통해 Authentication 객체 생성
  4. AuthenticationProviderAuthentication 객체를 검증할 Provider를 선택
  5. SecurityContextHolderSecurityContext 안에 Authentication 객체를 저장하여 인증을 완료

이 3번에 해당하는 AuthenticaionManager인데 무작정 AuthenticationManagerauthenticate() 메서드를 부르면 "빈이 만들어지지 않았습니다." 라는 에러메시지를 받습니다. 

 

이는 스프링 컨테이너가 AuthenticationManager를 주입하기에 아직 빈이 만들어지지 않은 것입니다. 

 

때문에 AuthenticationManager를 빈으로 등록해줘야 합니다. 

 

    @Bean
    @Override
    public AuthenticationManager authenticationManagerBean() throws Exception {
        return super.authenticationManagerBean();
    }

Configuration 클래스에 이렇게 빈으로 등록해주시면 됩니다. 참고로 스프링 5.0에선 다음과 같이 작성하시면 됩니다. 

 

    @Bean
    public AuthenticationManager authenticationManager() throws Exception {
        return authenticationManager();
    }

 

이렇게 JwtService까지 만들었다면 다음은 컨트롤러에서 써먹어야겠죠? 

 

    
    @PostMapping("/custom/login")
    public ResponseEntity<?> login_post(@Validated @RequestBody LoginForm form, BindingResult bindingResult, HttpServletResponse response,
                                        HttpServletRequest request) {
        UserRequestDto.Login login = new UserRequestDto.Login();
        login.setUsername(form.getLoginId());
        login.setPassword(form.getLoginPw());

        if (bindingResult.hasErrors()) {
            return ResponseEntity.status(HttpStatus.BAD_REQUEST).body("아이디와 비밀번호는 빈칸일 수 없습니다.");
        }

        UserResponseDto tokenInfo = jwtService.login(login);

        Cookie authCookie = new Cookie("AUTH-TOKEN", tokenInfo.getAccessToken());

        authCookie.setHttpOnly(true);

        response.addCookie(authCookie);

        ResponseCookie cookie =
                ResponseCookie.from("AUTH-TOKEN", tokenInfo.getAccessToken())
                        .sameSite("Lax")
                        .httpOnly(true)
                        .secure(false)
                        .path("/")
                        .maxAge(Duration.ofMinutes(30))
                        .build();

        response.addHeader("Set-Cookie", cookie.toString());

        // NEW!
        String sessionId = request.getSession(true).getId();
        Cookie sessionCookie = new Cookie("SESSION-TOKEN", sessionId);
        sessionCookie.setHttpOnly(true);
        response.addCookie(sessionCookie);
        ResponseCookie responseSessionCookie =
                ResponseCookie.from("SESSION-TOKEN", sessionId)
                        .sameSite("Lax")
                        .httpOnly(true)
                        .secure(false)
                        .path("/")
                        .maxAge(Duration.ofDays(7))
                        .build();
        response.addHeader("Set-Cookie", responseSessionCookie.toString());
        RedisAndSession redisAndSession = RedisAndSession.builder().sessionId(sessionId).refreshToken(tokenInfo.getRefreshToken()).build();
        redisTokenRepository.save(redisAndSession);
        Member member = memberRepository.findByLoginId(form.getLoginId()).map(entity -> entity.update(sessionId)).orElse(null);
        memberRepository.save(member);
        //NEW!

        return "main";
    }

 

ResposneCookie 객체에 대해서 설명을 잠깐 해야겠군요. 

 

Cookie는 탈취당하기 매우 쉽기 때문에 반드시 HttpOnly 플래그를 true로 만들어주셔야 합니다. 하지만 무작정 쿠키만 세팅하고 끝나면 쿠키가 안보이는 상황이 생깁니다. 

 

이는 Same-Site 속성이 Secure이기 때문일 수 있습니다. 또한 쿠키가 만들어진 경로가(path) 루트 주소가 아닌 다른 곳에서 만들어지면 쿠키가 안보입니다. 

 

때문에 ResponseCookie 속성을 이용해 쿠키를 세팅해주고 response.setHeader()를 이용해 헤더를 적어주셔야 쿠키가 잘 작동하는 모습을 볼 수 있습니다. 

 

너무 길어져서 다음에 이어서 진행하도록 하겠습니다. 

 

https://coding-review.tistory.com/385

 

스프링 시큐리티 + JWT 인증 레이어 추가하기 (4) : 주요 클래스 (SecurityConfig와 OAuth2 Handler)

https://coding-review.tistory.com/384 스프링 시큐리티 + JWT 인증 레이어 추가하기 (3) : 주요클래스 (JwtService와 Controller) https://coding-review.tistory.com/383 스프링 시큐리티 + JWT 인증 레이어 추가하기 (2) : 주요

coding-review.tistory.com