개발놀이터
스프링 시큐리티 + JWT 인증 레이어 추가하기 (3) : 주요클래스 (JwtService와 Controller) 본문
https://coding-review.tistory.com/383
이전 포스팅과 이어집니다.
다음은 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 객체를 가져오는 부분인데요.
스프링 시큐리티의 전반적인 동작 원리를 알고 계신다면 아시겠지만
- Filter로 ID / PW를 인터셉트
- UsernamePasswordAuthenticaionToken 생성
- Token을 처리할 AuthenticationManager (실질적인 구현체는 ProviderManager) 로 authenticate() 메서드를 통해 Authentication 객체 생성
- AuthenticationProvider가 Authentication 객체를 검증할 Provider를 선택
- SecurityContextHolder에 SecurityContext 안에 Authentication 객체를 저장하여 인증을 완료
이 3번에 해당하는 AuthenticaionManager인데 무작정 AuthenticationManager로 authenticate() 메서드를 부르면 "빈이 만들어지지 않았습니다." 라는 에러메시지를 받습니다.
이는 스프링 컨테이너가 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