Token 방식을 사용한다는 것은 Service 계층에서 Token을 처리하는 로직이 존재해야 가능합니다. MemberService
등 여러 서비스 및 컨트롤러 계층에서 사용하게 될 Token을 처리하는 로직부터 먼저 생성하겠습니다.
@Repository
public interface RefreshTokenRepository extends JpaRepository<RefreshToken, String> {
Optional<RefreshToken> findByMember(Member member);
}
@Service
@RequiredArgsConstructor
public class TokenService {
private final JwtTokenProvider tokenProvider;
private final RefreshTokenRepository refreshTokenRepository;
private final MemberRepository memberRepository;
public TokenDTO createToken(MemberDTO memberDTO) {
TokenDTO tokenDTO = tokenProvider.createTokenDTO(memberDTO.getUserId(), memberDTO.getRoles());
Member member = memberRepository.findByUserId(memberDTO.getUserId()).orElseThrow(() -> new RuntimeException("Wrong Access (member does not exist)"));
RefreshToken refreshToken = RefreshToken.builder()
.member(member)
.token(tokenDTO.getRefreshToken())
.build();
refreshTokenRepository.save(refreshToken);
return tokenDTO;
}
public TokenDTO createToken(Member member) {
TokenDTO tokenDTO = tokenProvider.createTokenDTO(member.getUserId(), member.getRoles());
RefreshToken refreshToken = RefreshToken.builder()
.member(member)
.token(tokenDTO.getRefreshToken())
.build();
refreshTokenRepository.save(refreshToken);
return tokenDTO;
}
public TokenDTO refresh(TokenDTO tokenDTO) {
if(!tokenProvider.validateToken(tokenDTO.getRefreshToken())) {
throw new RuntimeException("Refresh Token이 유효하지 않습니다.");
}
Authentication authentication = tokenProvider.getAuthentication(tokenDTO.getAccessToken());
RefreshToken refreshToken = refreshTokenRepository.findByMember(memberRepository.findByUserId(authentication.getName()).get())
.orElseThrow(() -> new RuntimeException("로그아웃 된 사용자입니다."));
if (!refreshToken.getToken().equals(tokenDTO.getRefreshToken())) {
throw new RuntimeException("Refresh Token이 일치하지 않습니다.");
}
Member member = memberRepository.findByUserId(refreshToken.getMember().getUserId()).orElseThrow(() -> new RuntimeException("존재하지 않는 계정입니다."));
TokenDTO tokenDto = tokenProvider.createTokenDTO(member.getUserId(), member.getRoles());
RefreshToken newRefreshToken = refreshToken.updateValue(tokenDto.getRefreshToken());
refreshTokenRepository.save(newRefreshToken);
return tokenDto;
}
}
MemberDTO
를 받아와서 토큰을 생성합니다.Member
를 받아와서 토큰을 생성합니다.다음으로 서비스 계층에서 인증과 관련한 작업을 처리해줄 서비스도 생성해야 합니다. 현재에는 UsernamePasswordAuthenticationToken을 이용한 인증만 하지만, 이후 어플리케이션이 커지면서 더 많은 부분에서 인증이 필요하게 될 것입니다.
@Service
@RequiredArgsConstructor
public class AuthService {
private final AuthenticationManagerBuilder authenticationManagerBuilder;
public void authenticateLogin(LoginRequestDTO requestDTO) {
UsernamePasswordAuthenticationToken authenticationToken = requestDTO.toAuthentication();
authenticationManagerBuilder.getObject().authenticate(authenticationToken);
}
}
본격적으로 로그인/회원가입과 관련된 서비스를 생성하겠습니다. `MemberService를 생성합니다.
@Service
@Log4j2
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class MemberService implements UserDetailsService {
private final MemberRepository memberRepository;
private final MemberImageRepository imageRepository;
private final TokenService tokenService;
private final FileService fileService;
private final AuthService authService;
@Value("${spring.servlet.multipart.location}")
private String uploadPath;
@Override
public UserDetails loadUserByUsername(String userId) throws UsernameNotFoundException {
return memberRepository.findByUserId(userId)
.map(this::createUserDetails)
.orElseThrow(() -> new UsernameNotFoundException("userId: " + userId + "를 데이터베이스에서 찾을 수 없습니다."));
}
private UserDetails createUserDetails(Member member) {
GrantedAuthority grantedAuthority = new SimpleGrantedAuthority(member.getRoles().stream().map(Role::getType).collect(Collectors.joining(",")));
return new User(
member.getUserId(),
member.getPassword(),
Collections.singleton(grantedAuthority)
);
}
//이미지를 eager로 불러옴
public Member findMemberByUserId(String userId) {
return memberRepository.findByUserIdEagerLoadImage(userId)
.orElseThrow(() -> new RuntimeException("해당 ID를 가진 사용자가 존재하지 않습니다."));
}
public MemberDTO findMemberByEmail(String email) {
Member member = memberRepository.findByEmail(email).orElseThrow(() -> new RuntimeException("해당 email을 가진 사용자가 존재하지 않습니다."));
return MemberMapper.INSTANCE.memberToMemberDTO(member);
}
public MemberDTO getMember(String userId) {
return MemberMapper.INSTANCE.memberToMemberDTO(findMemberByUserId(userId));
}
@Transactional
public void saveMember(MemberDTO memberDTO) {
memberRepository.save(MemberMapper.INSTANCE.memberDTOToMember(memberDTO));
}
/**
* UsernamePasswordAuthenticationToken을 통한 Spring Security인증 진행
* 이후 tokenService에 userId값을 전달하여 토큰 생성
* @param requestDTO
* @return TokenDTO
*/
@Transactional
public TokenDTO login(LoginRequestDTO requestDTO) {
authService.authenticateLogin(requestDTO);
Member member = memberRepository.findByUserId(requestDTO.getUserId()).get();
return tokenService.createToken(member);
}
@Transactional(readOnly = false)
public void signup(MemberRequestDTO requestDTO) {
if(memberRepository.existsByUserId(requestDTO.getUserId())) {
throw new RuntimeException("이미 존재하는 아이디입니다.");
}
Member member = MemberMapper.INSTANCE.memberRequestDTOToMember(requestDTO);
member.updateRole(Role.ROLE_USER);
if(!(requestDTO.getMemberImage() == null)) {
MemberImage memberImage = saveMemberImage(requestDTO.getMemberImage());
member.updateMemberImage(memberImage);
}
memberRepository.save(member);
}
@Transactional(readOnly = false)
private MemberImage saveMemberImage(MultipartFile file) {
String originalName = file.getOriginalFilename();
Path root = Paths.get(uploadPath, "member");
try {
ImageDTO imageDTO = fileService.createImageDTO(originalName, root);
MemberImage memberImage = MemberImage.builder()
.uuid(imageDTO.getUuid())
.fileName(imageDTO.getFileName())
.fileUrl(imageDTO.getFileUrl())
.build();
file.transferTo(Paths.get(imageDTO.getFileUrl()));
return imageRepository.save(memberImage);
} catch (IOException e) {
log.warn("업로드 폴더 생성 실패: " + e.getMessage());
}
return null;
}
}
MemberService
는 UserDetailsService
를 상속받고 있습니다. 이를 상속받아야 Security Chain의 과정에서(바로 다음 글에서 설명할) loadUserByUsername을 호출할 수 있기 때문입니다.
login : login에서 인증 후 토큰을 발급하여 Controller로 넘겨줄 예정입니다.
signup : 매핑 후 DB에 저장합니다.
Spring강의를 보면 Service를 인터페이스화하고, 이를 구현한 구현체를 Spring Bean에 등록하는 방식을 대부분 적용합니다. 하지만 현재 진행하는 프로젝트는 큰 기업 프로젝트가 아니기에 어짜피 Interface : Impl = 1 : 1의 관계로 만들어질 것이라고 예상되어 불필요한 인터페이스화를 없앴습니다.
현재 사진을 처리하는 Service는 구현되어 있지 않는데, 이는 이후에 작성할 Image처리에서 추가할 예정입니다.
@RestController
@Log4j2
@RequestMapping("/member")
@RequiredArgsConstructor
public class MemberController {
private final MemberService memberService;
private final PasswordEncoder passwordEncoder;
@PostMapping("/signup")
public ResponseEntity<String> memberSignup(@ModelAttribute MemberRequestDTO memberRequestDTO) {
log.info(memberRequestDTO);
memberRequestDTO.setPassword(passwordEncoder.encode(memberRequestDTO.getPassword()));
memberService.signup(memberRequestDTO);
return new ResponseEntity<>("", HttpStatus.OK);
}
@PostMapping("/login")
public ResponseEntity<TokenResponseDTO> memberLogin(@ModelAttribute LoginRequestDTO loginRequestDTO) {
log.info(loginRequestDTO);
TokenDTO tokenDTO = memberService.login(loginRequestDTO);
ResponseCookie responseCookie = ResponseCookie
.from("refresh_token", tokenDTO.getRefreshToken())
.httpOnly(true)
.secure(true)
.sameSite("None")
.maxAge(tokenDTO.getDuration())
.path("/")
.build();
TokenResponseDTO tokenResponseDTO = TokenResponseDTO.builder()
.isNewMember(false)
.accessToken(tokenDTO.getAccessToken())
.build();
return ResponseEntity.ok().header("Set-Cookie", responseCookie.toString()).body(tokenResponseDTO);
}
@GetMapping("/getMemberData")
public ResponseEntity<MemberDTO> loadMemberData() {
return ResponseEntity.ok(memberService.getMember(SecurityUtil.getCurrentUsername()));
}
}
passwordEncoder : SecurityConfig
에서 Spring Bean으로 등록했던 인코더입니다. 회원가입 시 패스워드를 인코딩하고 DB에 저장합니다.
loadMemberData : 로그인 후 발급받은 accessToken을 헤더에 담아 보내면 회원정보를 반환해줍니다.
왜 로그인 시에는 패스워드 인코딩의 과정이 없을까?
AuthService에서 토큰 생성 전에 아래의 코드를 실행합니다.
authenticationManagerBuilder.getObject().authenticate(authenticationToken);
authenticate()메서드는 AuthenticationManager 인터페이스 아래에 있는 메서드로, 이는 Spring Security가 ProviderManager로 구현하고 있습니다.
ProviderManager의 authenticate()메서드 내부에 아래의 코드를 확인할 수 있습니다.try { result = provider.authenticate(authentication); if (result != null) { copyDetails(authentication, result); break; } }
여기서 provider는 AuthenticationManager가 생성될 때 자동으로 주입받는 인증 Provider로, 이중에서 UsernamePasswordAuthentication은 이전에는 DaoAuthenticationProvider가 처리합니다.
DaoAuthenticationProvider는 UserDetailsService를 주입하는데, 이는 현재 MemberService가 구현하고 있습니다. loadUserByUsername() 메서드에 접근하여 Provider는 Member객체를 가져오고, additionalAuthenticationChecks() 메서드에서 로그인 시 입력된 비밀번호를 인코딩하여 비교합니다.if (!this.passwordEncoder.matches(presentedPassword, userDetails.getPassword())) { this.logger.debug("Failed to authenticate since password does not match stored value"); throw new BadCredentialsException(this.messages.getMessage( "AbstractUserDetailsAuthenticationProvider.badCredentials", "Bad credentials")); }
토큰 만료 시 갱신할 컨트롤러도 생성합니다. 토큰 이외에도 API URI로 접근할 기능들이 많을 예정이므로 ApiController
에 생성합니다.
@RestController
@Log4j2
@RequestMapping("/api")
@RequiredArgsConstructor
public class ApiController {
private final TokenService tokenService;
@Operation(summary = "토큰 갱신")
@PostMapping("/refreshToken")
public ResponseEntity<TokenDTO> refreshToken(@RequestBody TokenDTO tokenDTO) {
return ResponseEntity.ok(tokenService.refresh(tokenDTO));
}
}
현재 Refresh Token은 MySQL DB에 저장되는데, 이것의 문제는 토큰 시간이 만료되도 지워지지 않아 중간중간 스케줄링을 하여 직접 처리해야 한다는 점입니다. 때문에 좀 더 효율적인 Redis DB를 추후 적용할 예정입니다.
이제 다음 게시글에서 Redis DB를 적용하는 방법을 확인하실 수 있습니다!
다음 글에서는 로그인 로직에 대해 세부적으로 분석해보는 과정을 보여드리겠습니다.