로그인, 회원가입 구현(2) - Service, Controller 구현, Token 처리

Jongwon·2023년 3월 3일
1

DMS

목록 보기
6/18

Token 기능 생성

Token 방식을 사용한다는 것은 Service 계층에서 Token을 처리하는 로직이 존재해야 가능합니다. MemberService등 여러 서비스 및 컨트롤러 계층에서 사용하게 될 Token을 처리하는 로직부터 먼저 생성하겠습니다.

TokenRepository

@Repository
public interface RefreshTokenRepository extends JpaRepository<RefreshToken, String> {

    Optional<RefreshToken> findByMember(Member member);
}

TokenService

@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;
    }
}
  • createToken(MemberDTO): 소셜 로그인 시 MemberDTO를 받아와서 토큰을 생성합니다.
  • createToken(Member): 일반 로그인 시 Member를 받아와서 토큰을 생성합니다.
  • refreshToken: 클라이언트가 보낸 accessToken이 만료되었을 때 refreshToken을 이용하여 갱신할 수 있도록 합니다.


인증 기능 생성

다음으로 서비스 계층에서 인증과 관련한 작업을 처리해줄 서비스도 생성해야 합니다. 현재에는 UsernamePasswordAuthenticationToken을 이용한 인증만 하지만, 이후 어플리케이션이 커지면서 더 많은 부분에서 인증이 필요하게 될 것입니다.

AuthService

@Service
@RequiredArgsConstructor
public class AuthService {

    private final AuthenticationManagerBuilder authenticationManagerBuilder;

    public void authenticateLogin(LoginRequestDTO requestDTO) {
        UsernamePasswordAuthenticationToken authenticationToken = requestDTO.toAuthentication();
        authenticationManagerBuilder.getObject().authenticate(authenticationToken);
    }
}
  • authenticateLogin : 로그인 시 인증을 도와줄 서비스입니다.


Member 관련 기능 생성

본격적으로 로그인/회원가입과 관련된 서비스를 생성하겠습니다. `MemberService를 생성합니다.

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;
    }
}

MemberServiceUserDetailsService를 상속받고 있습니다. 이를 상속받아야 Security Chain의 과정에서(바로 다음 글에서 설명할) loadUserByUsername을 호출할 수 있기 때문입니다.

  • login : login에서 인증 후 토큰을 발급하여 Controller로 넘겨줄 예정입니다.

  • signup : 매핑 후 DB에 저장합니다.

Spring강의를 보면 Service를 인터페이스화하고, 이를 구현한 구현체를 Spring Bean에 등록하는 방식을 대부분 적용합니다. 하지만 현재 진행하는 프로젝트는 큰 기업 프로젝트가 아니기에 어짜피 Interface : Impl = 1 : 1의 관계로 만들어질 것이라고 예상되어 불필요한 인터페이스화를 없앴습니다.


현재 사진을 처리하는 Service는 구현되어 있지 않는데, 이는 이후에 작성할 Image처리에서 추가할 예정입니다.



MemberController

@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"));
}


참고:
https://github.com/HomoEfficio/dev-tips/blob/master/Spring%20Security%EC%9D%98%20%EC%82%AC%EC%9A%A9%EC%9E%90%20%EB%B9%84%EB%B0%80%EB%B2%88%ED%98%B8%20%EA%B2%80%EC%82%AC.md



토큰 갱신 관련

토큰 만료 시 갱신할 컨트롤러도 생성합니다. 토큰 이외에도 API URI로 접근할 기능들이 많을 예정이므로 ApiController에 생성합니다.

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를 적용하는 방법을 확인하실 수 있습니다!


다음 글에서는 로그인 로직에 대해 세부적으로 분석해보는 과정을 보여드리겠습니다.

profile
Backend Engineer

0개의 댓글