Skip to content

Commit

Permalink
feat: JWT & OAuth2 카카오 로그인 로직 구현 #8
Browse files Browse the repository at this point in the history
  • Loading branch information
PgmJun committed Jan 18, 2024
1 parent 68463f6 commit 8adce85
Show file tree
Hide file tree
Showing 21 changed files with 532 additions and 10 deletions.
3 changes: 3 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@ dependencies {
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'

// spring webflux
implementation "org.springframework.boot:spring-boot-starter-webflux"

// QueryDSL
implementation "com.querydsl:querydsl-jpa:5.0.0:jakarta"
annotationProcessor "com.querydsl:querydsl-apt:5.0.0:jakarta"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
package com.nice.petudio.api.controller.auth;

import com.nice.petudio.api.controller.auth.dto.request.LoginRequest;
import com.nice.petudio.api.controller.auth.dto.request.SignUpRequest;
import com.nice.petudio.api.controller.auth.vo.TokenVO;
import com.nice.petudio.api.controller.auth.service.AuthService;
import com.nice.petudio.api.controller.auth.service.AuthServiceProvider;
import com.nice.petudio.api.controller.auth.service.CommonAuthService;
import com.nice.petudio.api.controller.auth.service.CreateTokenService;
import com.nice.petudio.api.dto.ApiResponse;
import com.nice.petudio.global.auth.auth.Auth;
import com.nice.petudio.global.auth.resolver.MemberId;
import io.swagger.v3.oas.annotations.Operation;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.CookieValue;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;

@RequiredArgsConstructor
@RequestMapping("/api/v1")
@RestController
public class AuthController {
private final AuthServiceProvider authServiceProvider;
private final CreateTokenService createTokenService;
private final CommonAuthService commonAuthService;


@Operation(summary = "OAuth2 소셜 회원가입")
@ResponseStatus(HttpStatus.OK)
@PostMapping("/auth/signup")
public ApiResponse<?> signUp(@Valid @RequestBody SignUpRequest request, HttpServletResponse response) {
AuthService authService = authServiceProvider.getAuthService(request.getSocialType());
Long memberId = authService.signUp(request);

addTokensToCookie(createTokenService.createTokenInfo(memberId), response);

return ApiResponse.success();
}

@Operation(summary = "OAuth2 소셜 로그인")
@ResponseStatus(HttpStatus.OK)
@PostMapping("/auth/login")
public ApiResponse<?> login(@Valid @RequestBody LoginRequest request, HttpServletResponse response) {
AuthService authService = authServiceProvider.getAuthService(request.getSocialType());
Long memberId = authService.login(request);

addTokensToCookie(createTokenService.createTokenInfo(memberId), response);

return ApiResponse.success();
}

@Operation(summary = "[인증] 로그아웃")
@Auth
@ResponseStatus(HttpStatus.OK)
@PostMapping("/auth/logout")
public ApiResponse<String> logout(@MemberId Long memberId) {
commonAuthService.logout(memberId);

return ApiResponse.success();
}

@Operation(summary = "JWT 토큰 갱신")
@ResponseStatus(HttpStatus.OK)
@PostMapping("/auth/reissue")
public ApiResponse<?> reissue(@CookieValue String accessToken, @CookieValue String refreshToken, HttpServletResponse response) {
TokenVO tokenVO = TokenVO.of(accessToken, refreshToken);
addTokensToCookie(createTokenService.reissueToken(tokenVO), response);

return ApiResponse.success();
}

private void addTokensToCookie(TokenVO tokenVO, HttpServletResponse response) {
addTokenToCookie("accessToken", tokenVO.getAccessToken(), response);
addTokenToCookie("refreshToken", tokenVO.getRefreshToken(), response);
}

private void addTokenToCookie(String cookieName, String token, HttpServletResponse response) {
Cookie cookie = new Cookie(cookieName, token);
cookie.setSecure(true);
cookie.setHttpOnly(true);

response.addCookie(cookie);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package com.nice.petudio.api.controller.auth.dto.request;


import com.nice.petudio.domain.member.SocialType;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.ToString;

@ToString
@Getter
@AllArgsConstructor(access = AccessLevel.PRIVATE)
@NoArgsConstructor(access = AccessLevel.PRIVATE)
public class LoginRequest {

@Schema(description = "소셜 로그인 타입", example = "KAKAO")
@NotNull(message = "{auth.socialType.notNull}")
private SocialType socialType;

@Schema(description = "소셜 토큰", example = "eyJhbGciOiJIUzUxdfadfadsMiJ9.udnKnDSK08EuX56E5k-")
@NotBlank(message = "{auth.token.notBlank}")
private String token;

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package com.nice.petudio.api.controller.auth.dto.request;

import com.nice.petudio.api.controller.member.dto.CreateMemberRequest;
import com.nice.petudio.domain.member.SocialType;
import com.nice.petudio.external.client.auth.kakao.dto.response.KakaoProfileResponse;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.ToString;

@ToString
@Getter
@AllArgsConstructor(access = AccessLevel.PRIVATE)
@NoArgsConstructor(access = AccessLevel.PRIVATE)
public class SignUpRequest {

@Schema(description = "소셜 로그인 타입", example = "KAKAO")
@NotNull(message = "{auth.socialType.notNull}")
private SocialType socialType;

@Schema(description = "소셜 토큰", example = "eyJhbGciOiJIUzUxdfadfadsMiJ9.udnKnDSK08EuX56E5k-")
@NotBlank(message = "{auth.token.notBlank}")
private String token;


public CreateMemberRequest toCreateMemberDto(KakaoProfileResponse response) {
return CreateMemberRequest.of(response.getId(), socialType, response.getNickname(), response.getThumbnailImage());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package com.nice.petudio.api.controller.auth.service;


import com.nice.petudio.api.controller.auth.dto.request.LoginRequest;
import com.nice.petudio.api.controller.auth.dto.request.SignUpRequest;

public interface AuthService {

Long signUp(SignUpRequest request);

Long login(LoginRequest request);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package com.nice.petudio.api.controller.auth.service;

import com.nice.petudio.api.controller.auth.service.impl.KakaoAuthService;
import com.nice.petudio.domain.member.SocialType;
import jakarta.annotation.PostConstruct;
import java.util.HashMap;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;

@RequiredArgsConstructor
@Component
public class AuthServiceProvider {

private static final Map<SocialType, AuthService> authServiceMap = new HashMap<>();

private final KakaoAuthService kakaoAuthService;

@PostConstruct
void initializeAuthServicesMap() {
authServiceMap.put(SocialType.KAKAO, kakaoAuthService);
}

public AuthService getAuthService(SocialType socialType) {
return authServiceMap.get(socialType);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package com.nice.petudio.api.controller.auth.service;

import com.nice.petudio.api.controller.member.service.MemberServiceUtils;
import com.nice.petudio.domain.member.Member;
import com.nice.petudio.domain.member.repository.MemberRepository;
import com.nice.petudio.global.auth.jwt.JwtTokenService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@RequiredArgsConstructor
@Service
@Transactional
public class CommonAuthService {

private final MemberRepository memberRepository;

private final JwtTokenService jwtTokenService;

public void logout(Long memberId) {
Member member = MemberServiceUtils.findMemberById(memberRepository, memberId);
jwtTokenService.expireRefreshToken(member.getId());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package com.nice.petudio.api.controller.auth.service;


import com.nice.petudio.api.controller.auth.vo.TokenVO;
import com.nice.petudio.api.controller.member.service.MemberServiceUtils;
import com.nice.petudio.domain.member.Member;
import com.nice.petudio.domain.member.repository.MemberRepository;
import com.nice.petudio.global.auth.jwt.JwtTokenService;
import com.nice.petudio.global.config.redis.constant.RedisKey;
import com.nice.petudio.global.exception.UnAuthorizedException;
import com.nice.petudio.global.exception.error.ErrorCode;
import java.util.List;
import java.util.Objects;
import lombok.RequiredArgsConstructor;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@RequiredArgsConstructor
@Service
@Transactional
public class CreateTokenService {

private final MemberRepository memberRepository;

private final RedisTemplate redisTemplate;

private final JwtTokenService jwtTokenService;

public TokenVO createTokenInfo(Long memberId) {
List<String> tokens = jwtTokenService.createTokenInfo(memberId);
return TokenVO.of(tokens.get(0), tokens.get(1));
}

public TokenVO reissueToken(TokenVO tokenVO) {
Long memberId = jwtTokenService.parseMemberId(tokenVO.getAccessToken())
.orElseThrow();
Member member = MemberServiceUtils.findMemberById(memberRepository, memberId);

if (!jwtTokenService.validateToken(tokenVO.getRefreshToken())) {
throw new UnAuthorizedException(ErrorCode.UNAUTHORIZED_JWT_EXCEPTION,
String.format("MemberId(%d)의 토큰 갱신 요청에 포함된 Refresh Token이 유효하지 않아, Token Refresh가 수행되지 않았습니다.", memberId));
}
String refreshToken = (String) redisTemplate.opsForValue().get(RedisKey.REFRESH_TOKEN + memberId.toString());
if (Objects.isNull(refreshToken)) {
throw new UnAuthorizedException(ErrorCode.UNAUTHORIZED_JWT_EXCEPTION,
String.format("보관 중인 MemberId(%d)의 Refresh Token이 존재하지 않아, Token Refresh가 수행되지 않았습니다.", memberId));
}
if (!refreshToken.equals(tokenVO.getRefreshToken())) {
jwtTokenService.expireRefreshToken(member.getId());
throw new UnAuthorizedException(ErrorCode.UNAUTHORIZED_JWT_EXCEPTION,
String.format("보관 중인 MemberId(%d)의 Refresh Token이 유효하지 않아, Token Refresh가 수행되지 않았습니다.", memberId));
}
List<String> tokens = jwtTokenService.createTokenInfo(memberId);
return TokenVO.of(tokens.get(0), tokens.get(1));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package com.nice.petudio.api.controller.auth.service.impl;

import com.nice.petudio.api.controller.auth.dto.request.LoginRequest;
import com.nice.petudio.api.controller.auth.dto.request.SignUpRequest;
import com.nice.petudio.api.controller.auth.service.AuthService;
import com.nice.petudio.api.controller.member.service.MemberService;
import com.nice.petudio.api.controller.member.service.MemberServiceUtils;
import com.nice.petudio.domain.member.Member;
import com.nice.petudio.domain.member.SocialType;
import com.nice.petudio.domain.member.repository.MemberRepository;
import com.nice.petudio.external.client.auth.kakao.KakaoApiCaller;
import com.nice.petudio.external.client.auth.kakao.dto.response.KakaoProfileResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@RequiredArgsConstructor
@Service
@Transactional
public class KakaoAuthService implements AuthService {

private final KakaoApiCaller kakaoApiCaller;

private final MemberRepository memberRepository;

private final MemberService memberService;

@Override
public Long signUp(SignUpRequest request) {
KakaoProfileResponse response = kakaoApiCaller.getProfileInfo(request.getToken());
return memberService.registerMember(request.toCreateMemberDto(response));
}

@Override
public Long login(LoginRequest request) {
KakaoProfileResponse response = kakaoApiCaller.getProfileInfo(request.getToken());
Member member = MemberServiceUtils.findMemberBySocialIdAndSocialType(memberRepository, response.getId(),
SocialType.KAKAO);
return member.getId();
}
}
24 changes: 24 additions & 0 deletions src/main/java/com/nice/petudio/api/controller/auth/vo/TokenVO.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package com.nice.petudio.api.controller.auth.vo;

import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Getter
@NoArgsConstructor(access = AccessLevel.PRIVATE)
@AllArgsConstructor(access = AccessLevel.PRIVATE)
@Builder(access = AccessLevel.PRIVATE)
public class TokenVO {

private String accessToken;
private String refreshToken;

public static TokenVO of(String accessToken, String refreshToken) {
return TokenVO.builder()
.accessToken(accessToken)
.refreshToken(refreshToken)
.build();
}
}
33 changes: 33 additions & 0 deletions src/main/java/com/nice/petudio/api/dto/ApiResponse.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package com.nice.petudio.api.dto;

import com.nice.petudio.global.exception.error.ErrorCode;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.RequiredArgsConstructor;

@Getter
@AllArgsConstructor(access = AccessLevel.PRIVATE)
@RequiredArgsConstructor(access = AccessLevel.PRIVATE)
public class ApiResponse<T> {

private final boolean isSuccess;
private final String code;
private final String message;
private T data;

private static final String SUCCESS_MESSAGE = "요청이 성공적으로 수행되었습니다.";
public static final String SUCCESS_CODE = "S001";

public static <T> ApiResponse<T> success(T data) {
return new ApiResponse<>(true, SUCCESS_CODE, SUCCESS_MESSAGE, data);
}

public static <T> ApiResponse<T> success() {
return new ApiResponse<>(true, SUCCESS_CODE, SUCCESS_MESSAGE);
}

public static <T> ApiResponse<T> error(ErrorCode errorCode) {
return new ApiResponse<>(false, errorCode.getCode(), errorCode.getMessage());
}
}
Loading

0 comments on commit 8adce85

Please sign in to comment.