Skip to content

Commit

Permalink
Merge branch 'develop' into feat/LS-13-2
Browse files Browse the repository at this point in the history
  • Loading branch information
raymondanythings authored Jul 12, 2024
2 parents 2d06d5f + 175492d commit e62b438
Show file tree
Hide file tree
Showing 20 changed files with 265 additions and 48 deletions.
5 changes: 3 additions & 2 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -131,8 +131,9 @@ project(":layer-external") {
bootJar.enabled = false
jar.enabled = true

dependencies {
implementation project(path: ':layer-common')
dependencies {
implementation project(path: ':layer-common')
implementation project(path: ':layer-domain')

testImplementation platform('org.junit:junit-bom:5.9.1')
testImplementation 'org.junit.jupiter:junit-jupiter'
Expand Down
2 changes: 1 addition & 1 deletion layer-api/src/main/java/org/layer/config/RedisConfig.java
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ RedisTemplate<String, Object> redisTemplate() {
RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
redisTemplate.setConnectionFactory(redisConnectionFactory());
redisTemplate.setKeySerializer(new StringRedisSerializer());
redisTemplate.setValueSerializer(new Jackson2JsonRedisSerializer<>(Member.class));
redisTemplate.setValueSerializer(new StringRedisSerializer());

return redisTemplate;
}
Expand Down
152 changes: 152 additions & 0 deletions layer-api/src/main/java/org/layer/domain/auth/controller/AuthApi.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
package org.layer.domain.auth.controller;

import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.headers.Header;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.ExampleObject;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.layer.domain.auth.controller.dto.*;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestParam;

@Tag(name = "인증", description = "인증 관련 API")
public interface AuthApi {
@Operation(summary = "[인증 불필요] 로그인", description = "소셜 로그인 API(구글, 카카오), 헤더에 소셜 액세스 토큰이 필요하며, 자체 jwt 필요없음")
@ApiResponses({
@ApiResponse(responseCode = "200", description = "로그인 성공",
headers = {
@Header(name = "Authorization", description = "소셜 액세스 토큰(Bearer 없이 토큰만)", schema = @Schema(type = "string", format = "jwt"), required = true)
},
content = @Content(mediaType = "application/json", examples = {
@ExampleObject(name="로그인 성공", value = """
{
"memberId": 1,
"accessToken": "eyJhbGciOiJIUzUxMiJ9.eyJpYXQiOjE3MjA2OTcyMDksImV4cCI6MTcyMDY5OTAwOSwicm9sZSI6WyJVU0VSIl0sIm1lbWJlcklkIjoxfQ.OV-RWbIPZIQlMsPMR0reFHMFq9MNBKQwf7Hw7Uo0QbJPrTEACu0MqSJlv-gMtag1PhBxo7KB5dxEDza6QI06Zw",
"refreshToken": "eyJhbGciOiJIUzUxMiJ9.eyJpYXQiOjE3MjA2OTcyMTAsImV4cCI6MTcyMTkwNjgxMCwicm9sZSI6WyJVU0VSIl0sIm1lbWJlcklkIjoxfQ.fIVauBlL3GHLrVFJ1YwWb89RFwxa84Cql2WqEu4L258ebPJ04TkAGbqrCt7i-oEKI6dbvv0KDRKXkgDQH18kTA",
"memberRole": "USER"
}
"""
)
})),
@ApiResponse(responseCode = "400", description = "로그인 실패 - 토큰이 유효하지 않음",
headers = {
@Header(name = "Authorization", description = "소셜 액세스 토큰(Bearer 없이 토큰만)", schema = @Schema(type = "string", format = "jwt"), required = true)
},
content = @Content(mediaType = "application/json", examples = {
@ExampleObject(name="토큰이 유효하지 않음", value = """
{
"name": "FAIL_TO_AUTH",
"message": "인증에 실패했습니다."
}
"""
)
})),
@ApiResponse(responseCode = "404", description = "로그인 실패 - 회원이 DB에 없음",
headers = {
@Header(name = "Authorization", description = "소셜 액세스 토큰(Bearer 없이 토큰만)", schema = @Schema(type = "string", format = "jwt"), required = true)
},
content = @Content(mediaType = "application/json", examples = {
@ExampleObject(name="회원이 DB에 없음", value = """
{
"name": "NOT_FOUND_USER",
"message": "유효한 유저를 찾지 못했습니다."
}
"""
)
}))
})
public ResponseEntity<SignInResponse> signIn(@RequestHeader("Authorization") final String socialAccessToken, @RequestBody final SignInRequest signInRequest);

@Operation(summary = "[인증 불필요] 회원가입", description = "처음 소셜 로그인 하는 유저가 이름을 입력하는 과정, social_type은 KAKAO, GOOGLE")
@ApiResponses({
@ApiResponse(responseCode = "201", description = "회원가입 성공",
headers = {
@Header(name = "Authorization", description = "소셜 액세스 토큰(Bearer 없이 토큰만)", schema = @Schema(type = "string", format = "jwt"), required = true)
},
content = @Content(mediaType = "application/json", examples = {
@ExampleObject(name="회원 가입 성공. 유저의 정보를 리턴", value = """
{
"memberId": 1,
"name": "김회고",
"email": "[email protected]",
"memberRole": "USER",
"SocialId": "1234567890",
"socialType": "KAKAO"
}
"""
)
})),
@ApiResponse(responseCode = "400", description = "회원가입 실패",
headers = {
@Header(name = "Authorization", description = "소셜 액세스 토큰", schema = @Schema(type = "string", format = "jwt"), required = true)
},
content = @Content(mediaType = "application/json", examples = {
@ExampleObject(name="이미 가입된 회원", value = """
{
"name": "NOT_A_NEW_MEMBER",
"message": "이미 가입된 회원입니다."
}
"""
),
@ExampleObject(name="토큰이 유효하지 않음", value = """
{
"name": "FAIL_TO_AUTH",
"message": "인증에 실패했습니다."
}
""")
}))
})
public ResponseEntity<SignUpResponse> signUp(@RequestHeader("Authorization") final String socialAccessToken, @RequestBody final SignUpRequest signUpRequest);

@Operation(summary = "로그아웃", description = "member_id를 전달하면 DB에서 리프레시 토큰을 지웁니다.")
public ResponseEntity<?> signOut(SignOutRequest signOutRequest);

@Operation(summary = "[인증 불필요] 토큰 재발급", description = "member_id를 전달하면 데이터베이스에 리프레시 토큰이 남아있지 확인하고 남아있다면 jwt(access + refresh)를 새로 발급합니다.")
@ApiResponses({
@ApiResponse(responseCode = "201", description = "토큰 재발급 성공",
content = @Content(mediaType = "application/json", examples = {
@ExampleObject(name="토큰 재발급 성공(리프레시 토큰이 DB에 남아있음, 기한 2주)", value = """
{
"memberId": 1,
"accessToken": "eyJhbGciOiJIUzUxMiJ9.eyJpYXQiOjE3MjA2OTMyOTMsImV4cCI6MTcyMDY5NTA5Mywicm9sZSI6WyJVU0VSIl0sIm1lbWJlcklkIjoxfQ.nt4Tj1jTihS-6U7j2wkzv4VbgzTkhSPWnjBC_yXe_GiOKn3eoJ0i9NuA7Dzw6e4w_B-ab_PHzdrhfzyeVoPJOg",
"refreshToken": "eyJhbGciOiJIUzUxMiJ9.eyJpYXQiOjE3MjA2OTMyOTMsImV4cCI6MTcyMTkwMjg5Mywicm9sZSI6WyJVU0VSIl0sIm1lbWJlcklkIjoxfQ.MROa3B266VcnQqGHpvu2Lh3JiwexOM4BTYQt_3Tbc7xMY1AwS5Z51oAyVVZdO7wTLDLiUNe73DwR-7HNejWEdA"
}
"""
)
})),
@ApiResponse(responseCode = "401", description = "토큰 재발급 실패",
content = @Content(mediaType = "application/json", examples = {
@ExampleObject(name="토큰 재발급 실패(리프레시 토큰이 DB에 없음)", value = """
{
"name": "NOT_FOUND_USER",
"message": "유효한 유저를 찾지 못했습니다."
}
"""
)
}))
})
public ResponseEntity<ReissueTokenResponse> reissueToken(@RequestBody ReissueTokenRequest reissueTokenRequest);


@Operation(summary = "회원 탈퇴", description = "header Authorization에 액세스 토큰과 memberId를 전달하여 회원 탈퇴를 할 수 있습니다.")
@ApiResponse(responseCode = "200", description = "탈퇴 성공",
headers = {
@Header(name = "Authorization", description = "자체 jwt 액세스 토큰", schema = @Schema(type = "string", format = "jwt"), required = true)
})
public ResponseEntity<?> withdraw(WithdrawMemberRequest withdrawMemberRequest);

// TODO: 토큰 확인용 임시 API 추후 삭제
@Operation(summary = "[실제 사용 X] 구글 액세스 토큰 받기", description = "서버 쪽에서 토큰을 확인하기 위한 API입니다! (실제 사용 X, 추후 삭제 예정)")
public String googleTest(@RequestParam("code") String code);

// TODO: 토큰 확인용 임시 API 추후 삭제
@Operation(summary = "[실제 사용 X] 카카오 액세스 토큰 받기", description = "서버 쪽에서 토큰을 확인하기 위한 API입니다! (실제 사용 X, 추후 삭제 예정)")
public Object kakaoLogin(@RequestParam(value = "code", required = false) String code);


}
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
@RequiredArgsConstructor
@RequestMapping("/api/auth")
@RestController
public class AuthController {
public class AuthController implements AuthApi {
private final AuthService authService;
private final GoogleService googleService;
private final KakaoService kakaoService;
Expand All @@ -45,23 +45,23 @@ public ResponseEntity<SignUpResponse> signUp(@RequestHeader(SOCIAL_TOKEN_NAME) f

// 로그아웃
@PostMapping("/sign-out")
public ResponseEntity<?> signOut(@RequestBody Long memberId) {
authService.signOut(memberId);
public ResponseEntity<?> signOut(@RequestBody SignOutRequest signOutRequest) {
authService.signOut(signOutRequest.memberId());
return new ResponseEntity<>(HttpStatus.OK);
}

// 회원 탈퇴
@PostMapping("/withdraw")
public ResponseEntity<?> withdraw(@RequestBody Long memberId) {
authService.withdraw(memberId);
return new ResponseEntity<>(HttpStatus.OK); // TODO: 리턴 객체 수정 필요
public ResponseEntity<?> withdraw(WithdrawMemberRequest withdrawMemberRequest) {
authService.withdraw(withdrawMemberRequest.memberId());
return new ResponseEntity<>(HttpStatus.OK);
}

// 토큰 재발급
@PostMapping("/reissue-token")
public ResponseEntity<ReissueTokenResponse> reissueToken(@RequestBody Long memberId) {
public ResponseEntity<ReissueTokenResponse> reissueToken(ReissueTokenRequest reissueTokenRequest) {
return new ResponseEntity<>(
ReissueTokenResponse.of(authService.reissueToken(memberId)),
ReissueTokenResponse.of(authService.reissueToken(reissueTokenRequest.memberId())),
HttpStatus.CREATED);
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package org.layer.domain.auth.controller.dto;

import com.fasterxml.jackson.annotation.JsonProperty;

public record ReissueTokenRequest(@JsonProperty("member_id") Long memberId) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
package org.layer.domain.auth.controller.dto;

public record SignOutRequest(Long memberId) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
package org.layer.domain.auth.controller.dto;

public record WithdrawMemberRequest(Long memberId) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,6 @@ public class AuthService {
private final GoogleService googleService;
private final JwtService jwtService;
private final MemberService memberService;
private final MemberUtil memberUtil;

//== 로그인 ==//
@Transactional
public SignInServiceResponse signIn(final String socialAccessToken, final SocialType socialType) {
Expand Down Expand Up @@ -67,20 +65,17 @@ public void signOut(final Long memberId) {
//== 회원 탈퇴 ==//
@Transactional
public void withdraw(final Long memberId) {
// TODO: member 도메인에서 del_yn 바꾸기 => Member entitiy에 추가,,?

// hard delete
memberService.withdrawMember(memberId);
}

//== (리프레시 토큰을 받았을 때) 토큰 재발급 ==//
//== 토큰 재발급. redis 확인 후 재발급 ==//
@Transactional
public ReissueTokenServiceResponse reissueToken(final Long memberId) {
// 현재 로그인된 사용자와 memberId가 일치하는지 확인
isValidMember(memberId);
Member member = memberService.getMemberByMemberId(memberId);
JwtToken jwtToken = jwtService.reissueToken(memberId);

// 시큐리티 컨텍스트에서 member 찾아오기
Member member = memberUtil.getCurrentMember();
return ReissueTokenServiceResponse.of(member,
jwtService.issueToken(member.getId(), member.getMemberRole()));
return ReissueTokenServiceResponse.of(member, jwtToken);
}


Expand All @@ -98,7 +93,7 @@ private MemberInfoServiceResponse getMemberInfo(SocialType socialType, String so

// 현재 로그인 된 사용자와 해당 멤버 아이디가 일치하는지 확인
private void isValidMember(Long memberId) {
Member currentMember = memberUtil.getCurrentMember();
Member currentMember = memberService.getCurrentMember();
if(!currentMember.getId().equals(memberId)) {
throw new BaseCustomException(AuthExceptionType.FORBIDDEN);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
String accessToken = getJwtFromRequest(request);

if(isValidToken(accessToken)) {
if(jwtValidator.isValidToken(accessToken)) {
Long memberId = jwtValidator.getMemberIdFromToken(accessToken);
List<String> role = jwtValidator.getRoleFromToken(accessToken);
setAuthenticationToContext(memberId, MemberRole.valueOf(role.get(0)));
Expand All @@ -44,7 +44,11 @@ private void setAuthenticationToContext(Long memberId, MemberRole memberRole) {
// 요청 헤더에서 액세스 토큰을 가져오는 메서드
private String getJwtFromRequest(HttpServletRequest request) {
String bearerToken = request.getHeader("Authorization");
return (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) ? bearerToken.replace("Bearer ", ""): null;
String accessToken = null;
if(StringUtils.hasText(bearerToken)) {
accessToken = bearerToken.replace("Bearer ", "");
}
return accessToken;
}

// 정상적인 토큰인지 판단하는 메서드
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import lombok.extern.slf4j.Slf4j;
import org.layer.common.exception.BaseCustomException;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;

import java.util.List;

Expand Down Expand Up @@ -46,7 +47,11 @@ public List<String> getRoleFromToken(String token) {
throw new BaseCustomException(INVALID_TOKEN);
}
return (List<String>) (claims.get("role"));
}

// 정상적인 토큰인지 판단하는 메서드
public boolean isValidToken(String token) {
return StringUtils.hasText(token) && validateToken(token) == JwtValidationType.VALID_JWT;
}

private Claims getClaims(String token) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,25 +1,29 @@
package org.layer.domain.jwt.service;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.layer.common.exception.BaseCustomException;
import org.layer.domain.jwt.JwtProvider;
import org.layer.domain.jwt.JwtToken;
import org.layer.domain.jwt.MemberAuthentication;
import org.layer.domain.auth.exception.TokenExceptionType;
import org.layer.domain.jwt.*;
import org.layer.domain.member.entity.MemberRole;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;

import java.time.Duration;
import java.util.List;
import java.util.Objects;

import static org.layer.domain.auth.exception.TokenExceptionType.INVALID_REFRESH_TOKEN;
import static org.layer.config.AuthValueConfig.ACCESS_TOKEN_EXPIRATION_TIME;
import static org.layer.config.AuthValueConfig.REFRESH_TOKEN_EXPIRATION_TIME;

@Slf4j
@RequiredArgsConstructor
@Service
public class JwtService {
private final JwtProvider jwtProvider;
private final JwtValidator jwtValidator;
private final RedisTemplate<String, Object> redisTemplate;

public JwtToken issueToken(Long memberId, MemberRole memberRole) {
Expand All @@ -34,10 +38,26 @@ public JwtToken issueToken(Long memberId, MemberRole memberRole) {
.build();
}

// Jwt 재발급
public JwtToken reissueToken(Long memberId) {
// 리프레시 토큰 검사
String refreshToken = getRefreshTokenFromRedis(memberId);
if(!jwtValidator.isValidToken(refreshToken)) {
throw new BaseCustomException(TokenExceptionType.INVALID_TOKEN); // FIXME: TokenException 등으로 변경 필요
}


return issueToken(memberId, getMemberRoleFromRefreshToken(refreshToken));
}

private void saveRefreshTokenToRedis(Long memberId, String refreshToken) {
redisTemplate.opsForValue().set(memberId.toString(), refreshToken, Duration.ofDays(14));
}

private String getRefreshTokenFromRedis(Long memberId) {
return (String) redisTemplate.opsForValue().get(memberId.toString());
}

private Long getMemberIdFromRefreshToken(String refreshToken) {
Long memberId = null;
try {
Expand All @@ -48,8 +68,20 @@ private Long getMemberIdFromRefreshToken(String refreshToken) {
return memberId;
}

private MemberRole getMemberRoleFromRefreshToken(String refreshToken) {
MemberRole memberRole = null;
try {
List<String> role = jwtValidator.getRoleFromToken(refreshToken);
memberRole = MemberRole.valueOf(role.get(0));
} catch(Exception e) {
throw new BaseCustomException(INVALID_REFRESH_TOKEN);
}

return memberRole;
}
public void deleteRefreshToken(Long memberId) {
redisTemplate.delete(memberId.toString());
}


}
Loading

0 comments on commit e62b438

Please sign in to comment.