데블 아니고 데블리

운동,햄버거, 개발 좋아요

🐷💻📝

프로그래밍/웹기술

[로그인] 토큰 방식 로그인(Spring boot, Spring Security, JWT)

데블아니고데블리 2024. 3. 23. 15:59

jwt토큰을 사용하여 로그인 구현합니다.

(2024.03.23. 수정 중... 완료 예정 26일(화))

 

기능에 대한 간단한 설명

토큰 기반 로그인입니다.

 

1 ) 클라이언트 서버로 ID/PW 로그인 요청(실제 로그인 하는 행위)

2 ) 서버에 해당 유저가 있다면 Access Token 과 Refresh Token을 발급

3 ) 클라이언트측(브라우저)에서 AccessToken을 포함하여 API 요청합니다.(axios)

4 ) 토큰 유효성 검사 후 토큰 유효 하면 그대로 진행, 유효하지 않다면 토큰 새로 발급받습니다.

*RT 만료는 7일이라 여유롭습니다… 로그아웃 하고 다시 재로그인 시 1번 로직 타는거라고 생각하시면 이해하기 편할 것 같습니다

 

해당 기능을 구현하기 전 공통으로 jwt(json 형식의 토큰)을 발급받을 설정들이 필요하나, 현 기능에서 설명하면 길어지니 언급만 하도록 하겠습니다. build.gradle implementation 'com.nimbusds:nimbus-jose-jwt:9.37.3' 을 추가하고 공통코드 생성, 복호화 한 인증정보 생성, 유효성 검사 로직이 필요합니다. + 시큐리티도 인증 필요한 요청 따로 설정해두어야 합니다.

 

여기서 고민했던 jwt의 보안성입니다. https://jwt.io/ 여기 사이트만 들어가도 해석이 가능하단 말이죠.. 

만약 거기에 비밀번호가 있다? 핸드폰 번호가 있다? 혹시.. 내 개인정보가 들어가 있다? 그렇다면 당신의 개인정보는.....(이하 생략)

 

그래서! 어떻게 하면 해결이 될까 고민을 하다가.. 

2) 서버에 해당 유저가 있다면 Access Token 과 Refresh Token을 발급에 대해 자세하게 풀어봅시다!

 

컨트롤러! 

/**
     * 회원 로그인
     * @param requestDto 회원 로그인 요청 DTO
     * @return 회원 토큰 DTO
     */
    @PostMapping("/api/v1/member/login")
    public ApiResponse<MemberTokenDto> memberLoginV1(@RequestBody MemberLoginDto requestDto) {
        return ApiResponse.ok(memberService.memberLoginProc(requestDto));
    }

 

 

컨트롤러에서 호출한 서비스! 

로그인 성공 시 마지막 로그인 시점(LastLoginAt, DB칼럼의 값)을 업데이트 합니다.

Access Token은 jwt로 만들고, Refresh Token은 해석 불가능한 

이 때 새로운 Access Token 과 Refresh Token 을 발급 한 후 Refresh Token으로 uuid를 만듭니다. 

데이터베이스에는 Refresh Token 과 만료 시간만 저장합니다.

 

그럼 프론트에서는 uuid로 요청이 가겠죠 ? 이걸 해석할 방법은 없으니 개인정보는 이제 안전합니다.

@Transactional
    public MemberTokenDto memberLoginProc(MemberLoginDto requestDto) {
        if (!hasText(requestDto.getLoginId()) || !hasText(requestDto.getPassword()))
            throw new ApiBadRequestException("로그인 정보를 입력해주세요.");

        try {
            // 로그인 요청 정보로 인증 처리
            Authentication authentication = memberAuthenticationProvider.authenticate(
                    new UsernamePasswordAuthenticationToken(requestDto.getLoginId(), requestDto.getPassword()));

            Member member = (Member) authentication.getPrincipal();
            member.setLastLoginAt(LocalDateTime.now()); // 마지막 로그인 시간 업데이트

            // 토큰 생성
            String accessToken = jwtUtils.builder()
                    .claim("memberId", member.getId())
                    .claim("loginId", member.getLoginId())
                    .claim("role", member.getRole().getRole())
                    .expirationTime(Duration.ofHours(1).toSeconds())
                    .build();
            String refreshToken = UUID.randomUUID().toString();

            // 토큰 정보 DB에 저장
            MemberToken memberToken = MemberToken.builder()
                    .member(member)
                    .refreshToken(refreshToken)
                    .expiredAt(LocalDateTime.now().plusMonths(1))
                    .build();
            memberTokenRepository.save(memberToken);

            // 반환할 회원 정보와 토큰 설정
            MemberTokenDto memberTokenDto = new MemberTokenDto();
            memberTokenDto.setGrantType("Bearer");
            memberTokenDto.setAccessToken(accessToken);
            memberTokenDto.setRefreshToken(refreshToken);
            memberTokenDto.setMemberId(member.getId());
            memberTokenDto.setLoginId(member.getLoginId());
            memberTokenDto.setRole(member.getRole());

            return memberTokenDto;
        } catch (AuthenticationException e) {
            throw new ApiUnauthorizedException(e.getMessage());
        }
    }

 

 

그리고 이 이후에 클라이언트 측에서 API를 요청하게 되죠.. 

토큰 유효성 검사 후 로직이 반복됩니다.

 

전체 코드는 다음과 같습니다.

 

1. 공통 코드 구현

private class JwtAuthenticationFilter extends OncePerRequestFilter {
        @Override
        protected void doFilterInternal(HttpServletRequest request,
                                        @NonNull HttpServletResponse response,
                                        @NonNull FilterChain filterChain) throws ServletException, IOException {

            // HTTP 헤더에서 토큰 정보가 없으면 다음 필터로
            String authorization = request.getHeader("authorization");
            if (authorization == null || !authorization.startsWith("Bearer ")) {
                filterChain.doFilter(request, response);
                return;
            }

            // HTTP 헤더에서 토큰 정보를 추출하고 토큰을 검증
            String token = authorization.substring(7);
            Map<String, Object> payload = jwtUtils.parser(token).verify().getPayload();
            Long memberId = (Long) payload.get("memberId");
            String loginId = (String) payload.get("loginId");
            MemberRole role = MemberRole.ofRole((String) payload.get("role"));

            // DB 조회하지 않기 위해 Member Entity 생성 후 인증 정보 설정
            Member member = Member.builder().id(memberId).loginId(loginId).role(role).build();
            UsernamePasswordAuthenticationToken authentication =
                    new UsernamePasswordAuthenticationToken(member, member.getPassword(), member.getAuthorities());
            authentication.setDetails(member);

            // SecurityContext 에 인증 정보 저장
            SecurityContextHolder.getContext().setAuthentication(authentication);
            filterChain.doFilter(request, response);
        }
    }

 

 

 

2.회원 Controller

@RestController
@RequiredArgsConstructor
@Tag(name = "회원", description = "회원 API")
public class MemberController {

    private final MemberService memberService;

    /**
     * 회원 로그인
     * @param requestDto 회원 로그인 요청 DTO
     * @return 회원 토큰 DTO
     */
    @PostMapping("/api/v1/member/login")
    public ApiResponse<MemberTokenDto> memberLoginV1(@RequestBody MemberLoginDto requestDto) {
        return ApiResponse.ok(memberService.memberLoginProc(requestDto));
    }

    /**
     * 로그인 한 회원 정보 조회
     * @param member 회원 정보 (Security Context)
     * @return 회원 정보 DTO
     */
    @GetMapping("/api/v1/member/me")
    @PreAuthorize("hasRole('NORMAL')")
    @SecurityRequirement(name = HttpHeaders.AUTHORIZATION)
    public ApiResponse<MemberDto> memberMeV1(@AuthenticationPrincipal Member member) {
        return ApiResponse.ok(memberService.memberMe(member.getId()));
    }

    /**
     * 회원 토큰 갱신
     * @param requestDto 회원 토큰 갱신 요청 DTO
     * @return 회원 토큰 DTO
     */
    @PostMapping("/api/v1/member/refresh")
    public ApiResponse<MemberTokenDto> memberRefreshV1(@RequestBody MemberRefreshDto requestDto) {
        return ApiResponse.ok(memberService.memberTokenRefresh(requestDto));
    }
}

3. 회원 서비스

/**
     * 회원 로그인 후 회원 정보와 토큰 반환
     * @param requestDto 로그인 요청 정보
     * @return 회원 정보와 토큰
     */
    @Transactional
    public MemberTokenDto memberLoginProc(MemberLoginDto requestDto) {
        if (!hasText(requestDto.getLoginId()) || !hasText(requestDto.getPassword()))
            throw new ApiBadRequestException("로그인 정보를 입력해주세요.");

        try {
            // 로그인 요청 정보로 인증 처리
            Authentication authentication = memberAuthenticationProvider.authenticate(
                    new UsernamePasswordAuthenticationToken(requestDto.getLoginId(), requestDto.getPassword()));

            Member member = (Member) authentication.getPrincipal();
            member.setLastLoginAt(LocalDateTime.now()); // 마지막 로그인 시간 업데이트

            // 토큰 생성
            String accessToken = jwtUtils.builder()
                    .claim("memberId", member.getId())
                    .claim("loginId", member.getLoginId())
                    .claim("role", member.getRole().getRole())
                    .expirationTime(Duration.ofHours(1).toSeconds())
                    .build();
            String refreshToken = UUID.randomUUID().toString();

            // 토큰 정보 DB에 저장
            MemberToken memberToken = MemberToken.builder()
                    .member(member)
                    .refreshToken(refreshToken)
                    .expiredAt(LocalDateTime.now().plusMonths(1))
                    .build();
            memberTokenRepository.save(memberToken);

            // 반환할 회원 정보와 토큰 설정
            MemberTokenDto memberTokenDto = new MemberTokenDto();
            memberTokenDto.setGrantType("Bearer");
            memberTokenDto.setAccessToken(accessToken);
            memberTokenDto.setRefreshToken(refreshToken);
            memberTokenDto.setMemberId(member.getId());
            memberTokenDto.setLoginId(member.getLoginId());
            memberTokenDto.setRole(member.getRole());

            return memberTokenDto;
        } catch (AuthenticationException e) {
            throw new ApiUnauthorizedException(e.getMessage());
        }
    }
    
    
    /**
     * 회원 정보 조회
     * @param memberId 회원 시퀀스
     * @return 회원 정보 DTO
     */
    @Transactional(readOnly = true)
    public MemberDto memberMe(Long memberId) {
        MemberDto memberDto = memberRepository.searchMemberBy(memberId);

        if (memberDto == null)
            throw new ApiNotFoundException("회원 정보를 찾을 수 없습니다.");
        return memberDto;
    }
    
    
     /**
     * 토큰 재발급 후 회원 정보와 토큰 반환
     * refreshToken 유효 시간이 7일 이내일때만 새로 연장
     * @param requestDto 토큰 정보
     * @return 토큰 정보 DTO
     */
    @Transactional
    public MemberTokenDto memberTokenRefresh(MemberRefreshDto requestDto) {
        if (requestDto.getMemberId() == null)
            throw new ApiBadRequestException("토큰을 재발급 받을 회원 정보가 없습니다.");
        if (!hasText(requestDto.getRefreshToken()))
            throw new ApiBadRequestException("토큰 정보가 없습니다.");

        // 회원에 대해 사용 가능한 Refresh Token 조회
        MemberToken memberToken =
                memberTokenRepository.findByRefreshTokenValid(requestDto.getMemberId(), requestDto.getRefreshToken());

        if (memberToken == null)
            throw new ApiUnauthorizedException("재발급 받을 수 없는 토큰입니다. 다시 로그인 해 주세요.");

        Member member = memberToken.getMember();
        LocalDateTime expiredAt = memberToken.getExpiredAt();

        // 재발급 시 회원 인증 여부 검증
        if (member.getDelYn() == YN.Y || member.getStatus() != MemberStatus.NORMAL)
            throw new ApiUnauthorizedException("회원 검증에 실패했습니다. 관리자에게 문의하세요.");

        // Refresh Token 유효 시간이 7일 이내일 때만 새로 연장
        if (ChronoUnit.DAYS.between(LocalDateTime.now(), expiredAt) <= 7)
            expiredAt = LocalDateTime.now().plusMonths(1);

        // 기존 Refresh Token 무효화
        memberToken.setUseYn(YN.Y);

        // 토큰 생성
        String accessToken = jwtUtils.builder()
                .claim("memberId", member.getId())
                .claim("loginId", member.getLoginId())
                .claim("role", member.getRole().getRole())
                .expirationTime(Duration.ofHours(1).toSeconds())
                .build();
        String refreshToken = UUID.randomUUID().toString();

        // 토큰 정보 DB에 저장
        MemberToken newMemberToken = MemberToken.builder()
                .member(memberToken.getMember())
                .refreshToken(refreshToken)
                .expiredAt(expiredAt)
                .build();
        memberTokenRepository.save(newMemberToken);

        // 반환할 회원 정보와 토큰 설정
        MemberTokenDto memberTokenDto = new MemberTokenDto();
        memberTokenDto.setGrantType("Bearer");
        memberTokenDto.setAccessToken(accessToken);
        memberTokenDto.setRefreshToken(refreshToken);
        memberTokenDto.setMemberId(member.getId());
        memberTokenDto.setLoginId(member.getLoginId());
        memberTokenDto.setRole(member.getRole());

        return memberTokenDto;
    }

 

'프로그래밍 > 웹기술' 카테고리의 다른 글

API 방식에 따른 반환 클래스 구현  (1) 2024.03.23