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