From 316dafdb7e9c99ae36488ec78272279c64282c66 Mon Sep 17 00:00:00 2001 From: Park Seyeon Date: Tue, 7 Nov 2023 23:46:26 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=ED=9A=8C=EC=9B=90=20=EC=83=9D=EC=84=B1?= =?UTF-8?q?=20=EB=B0=8F=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EC=9D=91=EB=8B=B5?= =?UTF-8?q?=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84=20(#47)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 회원 엔티티 생성 및 테스트코드 추가 * feat: 카카오 OAuth 환경변수 추가 및 클래스 바인딩 * feat: authorization code를 받기 위한 queryString generator 추가 * feat: Authorization code의 parameter 만드는 로직 분리 및 테스트 코드 추가 * feat: 회원 가입/로그인 요청 api 및 소셜 로그인 페이지 반환 * refactor: member관련 클래스 네이밍과 폴더 위치 변경 * refactor: 로그인 페이지 요청 방식 Resttemplate -> response (redirect)하도록 변경 * style: 코드 포맷 재적용 및 사용하지 않는 클래스 삭제 * chore: config 파일 업데이트 * refactor: 테스트 코드 추가 및 코드 포맷 재적용 * refactor: 사용하지 않는 코드 제거 * refactor: CRLF -> LF로 변경 * fix: config 커밋, config 최근 커밋으로 변경 * feat: 테스트 코드 추가 및 패키지 구조 변경 * refactor: revert merge * fix: merge confilt해결 및 예외처리 추가 * test: oauth properties가 없을 때의 테스트코드 추가 * feat: 코드리뷰에 따른 기능 분리 및 테스트 코드 변경 * fix: 테스트코드 관련 code smell 제거 * feat: Authorization grant 받기 예외 코드 및 테스트 코드 추가 * feat: Authorization Token 요청 및 반환 코드, 에러 반환 테스트 코드 추가 * refactor: AuthenticationService에서 서버에 요청보내는 로직 OAuth2AuthorizationServerRequestService로 분리 * test: 로그인 요청 테스트 코드 추가 * feat: 토큰 발급 요청 기능 테스트 코드 추가 및 RestTemplate 필드변수로 변경 * test: restTemplate 및 서비스 테스트 추가 * refactor: 에러 메세지 이름 변경 * refacotr: 변수명 및 entity default 명 변경 * feat: 토큰 정보 조회 기능 및 테스트 추가 * feat: 사용자 토큰 정보 조회 및 테스트 코드 & Resttemplate 테크트 코드 변경 * fix: encoding, formatting, tab 문제로 인한 파일 삭제 후 다시 작성 * feat: JWT 토큰 제공 서비스 및 테스트 코드 추가 * feat: 토큰 인증 코드 및 테스트 코드 작성 * feat: 로그인 및 회원가입 기능 추가 - 회원의 socialId string -> long으로 변경 * feat: 회원 로그인 테스트 코드 추가 * chore: 코드 포메팅 재 설정 * feat: config 파일 업데이트 * feat: Window용 포트 redis 포트 변경 추가 * refacotr: develop 업데이트 사항 merge * refactor: develop 업데이트 부분 merge * fix: TimeConfig 삭제 및 코드 스멜 변경 * refactor: 코르리뷰 반영 --- build.gradle | 8 + .../application/AuthenticationService.java | 54 ++++-- .../application/JwtAuthenticationService.java | 43 +++++ .../api/application/JwtProviderService.java | 41 +++++ .../moabam/api/application/MemberService.java | 28 +++ ...uth2AuthorizationServerRequestService.java | 3 +- .../com/moabam/api/domain/entity/Member.java | 8 +- .../domain/repository/MemberRepository.java | 3 + .../repository/NotificationRepository.java | 2 +- .../com/moabam/api/dto/LoginResponse.java | 11 ++ .../java/com/moabam/api/dto/MemberMapper.java | 32 ++++ .../api/presentation/MemberController.java | 13 +- .../common/constant/GlobalConstant.java | 2 - .../global/common/constant/RedisConstant.java | 14 -- .../global/common/util/CookieUtils.java | 18 ++ .../common/util/OAuthParameterNames.java | 16 -- .../global/common/util/TokenConstant.java | 13 -- .../global/config/EmbeddedRedisConfig.java | 160 ++++++++++++++---- .../com/moabam/global/config/RedisConfig.java | 2 + .../com/moabam/global/config/TokenConfig.java | 28 +++ .../global/error/model/ErrorMessage.java | 1 + src/main/resources/config | 2 +- src/main/resources/static/docs/index.html | 2 +- .../resources/static/docs/notification.html | 4 +- .../AuthenticationServiceTest.java | 58 ++++++- .../JwtAuthenticationServiceTest.java | 108 ++++++++++++ .../application/JwtProviderServiceTest.java | 125 ++++++++++++++ .../api/application/MemberServiceTest.java | 70 ++++++++ .../moabam/api/domain/entity/MemberTest.java | 4 +- .../presentation/MemberControllerTest.java | 88 +++++----- .../NotificationControllerTest.java | 2 +- .../api/presentation/RoomControllerTest.java | 9 +- .../moabam/support/fixture/MemberFixture.java | 7 +- src/test/resources/application.yml | 7 +- 34 files changed, 808 insertions(+), 178 deletions(-) create mode 100644 src/main/java/com/moabam/api/application/JwtAuthenticationService.java create mode 100644 src/main/java/com/moabam/api/application/JwtProviderService.java create mode 100644 src/main/java/com/moabam/api/dto/LoginResponse.java create mode 100644 src/main/java/com/moabam/api/dto/MemberMapper.java delete mode 100644 src/main/java/com/moabam/global/common/constant/RedisConstant.java create mode 100644 src/main/java/com/moabam/global/common/util/CookieUtils.java delete mode 100644 src/main/java/com/moabam/global/common/util/OAuthParameterNames.java delete mode 100644 src/main/java/com/moabam/global/common/util/TokenConstant.java create mode 100644 src/main/java/com/moabam/global/config/TokenConfig.java create mode 100644 src/test/java/com/moabam/api/application/JwtAuthenticationServiceTest.java create mode 100644 src/test/java/com/moabam/api/application/JwtProviderServiceTest.java create mode 100644 src/test/java/com/moabam/api/application/MemberServiceTest.java diff --git a/build.gradle b/build.gradle index e98fa317..9175fd97 100644 --- a/build.gradle +++ b/build.gradle @@ -75,6 +75,14 @@ dependencies { // Firebase Admin implementation 'com.google.firebase:firebase-admin:9.2.0' + // JWT + implementation 'io.jsonwebtoken:jjwt-api:0.11.5' + runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5' + runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5' + + // JSON parser + implementation 'org.json:json:20230618' + // Asciidoctor asciidoctorExtensions 'org.springframework.restdocs:spring-restdocs-asciidoctor' diff --git a/src/main/java/com/moabam/api/application/AuthenticationService.java b/src/main/java/com/moabam/api/application/AuthenticationService.java index 02d93d78..c53b10f1 100644 --- a/src/main/java/com/moabam/api/application/AuthenticationService.java +++ b/src/main/java/com/moabam/api/application/AuthenticationService.java @@ -1,9 +1,8 @@ package com.moabam.api.application; -import static com.moabam.global.common.util.OAuthParameterNames.*; - import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import org.springframework.util.LinkedMultiValueMap; import org.springframework.util.MultiValueMap; import org.springframework.web.util.UriComponentsBuilder; @@ -13,9 +12,10 @@ import com.moabam.api.dto.AuthorizationTokenInfoResponse; import com.moabam.api.dto.AuthorizationTokenRequest; import com.moabam.api.dto.AuthorizationTokenResponse; +import com.moabam.api.dto.LoginResponse; import com.moabam.api.dto.OAuthMapper; import com.moabam.global.common.constant.GlobalConstant; -import com.moabam.global.common.util.TokenConstant; +import com.moabam.global.common.util.CookieUtils; import com.moabam.global.config.OAuthConfig; import com.moabam.global.error.exception.BadRequestException; import com.moabam.global.error.model.ErrorMessage; @@ -29,6 +29,8 @@ public class AuthenticationService { private final OAuthConfig oAuthConfig; private final OAuth2AuthorizationServerRequestService oauth2AuthorizationServerRequestService; + private final MemberService memberService; + private final JwtProviderService jwtProviderService; public void redirectToLoginPage(HttpServletResponse httpServletResponse) { String authorizationCodeUri = getAuthorizationCodeUri(); @@ -37,36 +39,46 @@ public void redirectToLoginPage(HttpServletResponse httpServletResponse) { public AuthorizationTokenResponse requestToken(AuthorizationCodeResponse authorizationCodeResponse) { validAuthorizationGrant(authorizationCodeResponse.code()); + return issueTokenToAuthorizationServer(authorizationCodeResponse.code()); } public AuthorizationTokenInfoResponse requestTokenInfo(AuthorizationTokenResponse authorizationTokenResponse) { String tokenValue = generateTokenValue(authorizationTokenResponse.accessToken()); - ResponseEntity authorizationTokenInfoResponse - = oauth2AuthorizationServerRequestService.tokenInfoRequest(oAuthConfig.provider().tokenInfo(), tokenValue); + ResponseEntity authorizationTokenInfoResponse = + oauth2AuthorizationServerRequestService.tokenInfoRequest(oAuthConfig.provider().tokenInfo(), tokenValue); return authorizationTokenInfoResponse.getBody(); } + @Transactional + public LoginResponse signUpOrLogin(HttpServletResponse httpServletResponse, + AuthorizationTokenInfoResponse authorizationTokenInfoResponse) { + LoginResponse loginResponse = memberService.login(authorizationTokenInfoResponse); + issueServiceToken(httpServletResponse, loginResponse.id()); + + return loginResponse; + } + private String getAuthorizationCodeUri() { AuthorizationCodeRequest authorizationCodeRequest = OAuthMapper.toAuthorizationCodeRequest(oAuthConfig); return generateQueryParamsWith(authorizationCodeRequest); } private String generateTokenValue(String token) { - return TokenConstant.TOKEN_TYPE + GlobalConstant.SPACE + token; + return "Bearer" + GlobalConstant.SPACE + token; } private String generateQueryParamsWith(AuthorizationCodeRequest authorizationCodeRequest) { - UriComponentsBuilder authorizationCodeUri = UriComponentsBuilder - .fromUriString(oAuthConfig.provider().authorizationUri()) - .queryParam(RESPONSE_TYPE, CODE) - .queryParam(CLIENT_ID, authorizationCodeRequest.clientId()) - .queryParam(REDIRECT_URI, authorizationCodeRequest.redirectUri()); + UriComponentsBuilder authorizationCodeUri = UriComponentsBuilder.fromUriString( + oAuthConfig.provider().authorizationUri()) + .queryParam("response_type", "code") + .queryParam("client_id", authorizationCodeRequest.clientId()) + .queryParam("redirect_uri", authorizationCodeRequest.redirectUri()); if (!authorizationCodeRequest.scope().isEmpty()) { - String scopes = String.join(GlobalConstant.COMMA, authorizationCodeRequest.scope()); - authorizationCodeUri.queryParam(SCOPE, scopes); + String scopes = String.join(",", authorizationCodeRequest.scope()); + authorizationCodeUri.queryParam("scope", scopes); } return authorizationCodeUri.toUriString(); @@ -91,15 +103,21 @@ private AuthorizationTokenResponse issueTokenToAuthorizationServer(String code) private MultiValueMap generateTokenRequest(AuthorizationTokenRequest authorizationTokenRequest) { MultiValueMap contents = new LinkedMultiValueMap<>(); - contents.add(GRANT_TYPE, authorizationTokenRequest.grantType()); - contents.add(CLIENT_ID, authorizationTokenRequest.clientId()); - contents.add(REDIRECT_URI, authorizationTokenRequest.redirectUri()); - contents.add(CODE, authorizationTokenRequest.code()); + contents.add("grant_type", authorizationTokenRequest.grantType()); + contents.add("client_id", authorizationTokenRequest.clientId()); + contents.add("redirect_uri", authorizationTokenRequest.redirectUri()); + contents.add("code", authorizationTokenRequest.code()); if (authorizationTokenRequest.clientSecret() != null) { - contents.add(CLIENT_SECRET, authorizationTokenRequest.clientSecret()); + contents.add("client_secret", authorizationTokenRequest.clientSecret()); } return contents; } + + private void issueServiceToken(HttpServletResponse response, Long id) { + response.addHeader("token_type", "Bearer"); + response.addCookie(CookieUtils.tokenCookie("access_token", jwtProviderService.provideAccessToken(id))); + response.addCookie(CookieUtils.tokenCookie("refresh_token", jwtProviderService.provideRefreshToken(id))); + } } diff --git a/src/main/java/com/moabam/api/application/JwtAuthenticationService.java b/src/main/java/com/moabam/api/application/JwtAuthenticationService.java new file mode 100644 index 00000000..14247dab --- /dev/null +++ b/src/main/java/com/moabam/api/application/JwtAuthenticationService.java @@ -0,0 +1,43 @@ +package com.moabam.api.application; + +import java.util.Base64; + +import org.json.JSONObject; +import org.springframework.stereotype.Service; + +import com.moabam.global.config.TokenConfig; +import com.moabam.global.error.exception.UnauthorizedException; +import com.moabam.global.error.model.ErrorMessage; + +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.Jwts; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class JwtAuthenticationService { + + private final TokenConfig tokenConfig; + + public boolean isTokenValid(String token) { + try { + Jwts.parserBuilder() + .setSigningKey(tokenConfig.getKey()) + .build() + .parseClaimsJwt(token); + return true; + } catch (ExpiredJwtException expiredJwtException) { + return false; + } catch (Exception exception) { + throw new UnauthorizedException(ErrorMessage.AUTHENTICATIE_FAIL); + } + } + + public String parseEmail(String token) { + String claims = token.split("\\.")[1]; + String decodeClaims = new String(Base64.getDecoder().decode(claims)); + JSONObject jsonObject = new JSONObject(decodeClaims); + + return (String)jsonObject.get("id"); + } +} diff --git a/src/main/java/com/moabam/api/application/JwtProviderService.java b/src/main/java/com/moabam/api/application/JwtProviderService.java new file mode 100644 index 00000000..50466841 --- /dev/null +++ b/src/main/java/com/moabam/api/application/JwtProviderService.java @@ -0,0 +1,41 @@ +package com.moabam.api.application; + +import java.util.Date; + +import org.springframework.stereotype.Service; + +import com.moabam.global.config.TokenConfig; + +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class JwtProviderService { + + private final TokenConfig tokenConfig; + + public String provideAccessToken(long id) { + return generateToken(id, tokenConfig.getAccessExpire()); + } + + public String provideRefreshToken(long id) { + return generateToken(id, tokenConfig.getRefreshExpire()); + } + + private String generateToken(long id, long expireTime) { + Date issueDate = new Date(); + Date expireDate = new Date(issueDate.getTime() + expireTime); + + return Jwts.builder() + .setHeaderParam("alg", "HS256") + .setHeaderParam("typ", "JWT") + .setIssuer(tokenConfig.getIss()) + .setIssuedAt(issueDate) + .setExpiration(expireDate) + .claim("id", id) + .signWith(tokenConfig.getKey(), SignatureAlgorithm.HS256) + .compact(); + } +} diff --git a/src/main/java/com/moabam/api/application/MemberService.java b/src/main/java/com/moabam/api/application/MemberService.java index f2d32c7d..f3609251 100644 --- a/src/main/java/com/moabam/api/application/MemberService.java +++ b/src/main/java/com/moabam/api/application/MemberService.java @@ -2,14 +2,21 @@ import static com.moabam.global.error.model.ErrorMessage.*; +import java.security.SecureRandom; import java.util.List; +import java.util.Optional; +import org.apache.commons.lang3.RandomStringUtils; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import com.moabam.api.domain.entity.Member; import com.moabam.api.domain.repository.MemberRepository; import com.moabam.api.domain.repository.MemberSearchRepository; +import com.moabam.api.domain.repository.NotificationRepository; +import com.moabam.api.dto.AuthorizationTokenInfoResponse; +import com.moabam.api.dto.LoginResponse; +import com.moabam.api.dto.MemberMapper; import com.moabam.global.error.exception.NotFoundException; import lombok.RequiredArgsConstructor; @@ -21,12 +28,33 @@ public class MemberService { private final MemberRepository memberRepository; private final MemberSearchRepository memberSearchRepository; + private final NotificationRepository notificationRepository; public Member getById(Long memberId) { return memberRepository.findById(memberId) .orElseThrow(() -> new NotFoundException(MEMBER_NOT_FOUND)); } + @Transactional + public LoginResponse login(AuthorizationTokenInfoResponse authorizationTokenInfoResponse) { + Optional member = memberRepository.findBySocialId(authorizationTokenInfoResponse.id()); + Member loginMember = member.orElse(signUp(authorizationTokenInfoResponse.id())); + + return MemberMapper.toLoginResponse(loginMember.getId(), member.isEmpty()); + } + + private Member signUp(Long socialId) { + String randomNickName = createRandomNickName(); + Member member = MemberMapper.toMember(socialId, randomNickName); + + return memberRepository.save(member); + } + + private String createRandomNickName() { + return RandomStringUtils.random(6, 0, 0, true, true, null, + new SecureRandom()); + } + public Member getManager(Long roomId) { return memberSearchRepository.findManager(roomId) .orElseThrow(() -> new NotFoundException(MEMBER_NOT_FOUND)); diff --git a/src/main/java/com/moabam/api/application/OAuth2AuthorizationServerRequestService.java b/src/main/java/com/moabam/api/application/OAuth2AuthorizationServerRequestService.java index c054add0..66076649 100644 --- a/src/main/java/com/moabam/api/application/OAuth2AuthorizationServerRequestService.java +++ b/src/main/java/com/moabam/api/application/OAuth2AuthorizationServerRequestService.java @@ -15,7 +15,6 @@ import com.moabam.api.dto.AuthorizationTokenInfoResponse; import com.moabam.api.dto.AuthorizationTokenResponse; import com.moabam.global.common.constant.GlobalConstant; -import com.moabam.global.common.util.TokenConstant; import com.moabam.global.error.exception.BadRequestException; import com.moabam.global.error.handler.RestTemplateResponseHandler; import com.moabam.global.error.model.ErrorMessage; @@ -54,7 +53,7 @@ public ResponseEntity requestAuthorizationServer(Str public ResponseEntity tokenInfoRequest(String tokenInfoUri, String tokenValue) { HttpHeaders headers = new HttpHeaders(); - headers.add(TokenConstant.AUTHORIZATION, tokenValue); + headers.add("Authorization", tokenValue); HttpEntity httpEntity = new HttpEntity<>(headers); return restTemplate.exchange(tokenInfoUri, HttpMethod.GET, httpEntity, AuthorizationTokenInfoResponse.class); diff --git a/src/main/java/com/moabam/api/domain/entity/Member.java b/src/main/java/com/moabam/api/domain/entity/Member.java index a5c535c3..43427908 100644 --- a/src/main/java/com/moabam/api/domain/entity/Member.java +++ b/src/main/java/com/moabam/api/domain/entity/Member.java @@ -40,7 +40,7 @@ public class Member extends BaseTimeEntity { private Long id; @Column(name = "social_id", nullable = false, unique = true) - private String socialId; + private Long socialId; @Column(name = "nickname", nullable = false, unique = true) private String nickname; @@ -79,11 +79,11 @@ public class Member extends BaseTimeEntity { private LocalDateTime deletedAt; @Builder - private Member(Long id, String socialId, String nickname, String profileImage, Bug bug) { + private Member(Long id, Long socialId, String nickname, Bug bug) { this.id = id; - this.socialId = requireNonNull(socialId); + this.socialId = socialId; this.nickname = requireNonNull(nickname); - this.profileImage = requireNonNullElse(profileImage, BaseImageUrl.PROFILE_URL); + this.profileImage = BaseImageUrl.PROFILE_URL; this.bug = requireNonNull(bug); this.role = Role.USER; } diff --git a/src/main/java/com/moabam/api/domain/repository/MemberRepository.java b/src/main/java/com/moabam/api/domain/repository/MemberRepository.java index 095c71b4..94bd696b 100644 --- a/src/main/java/com/moabam/api/domain/repository/MemberRepository.java +++ b/src/main/java/com/moabam/api/domain/repository/MemberRepository.java @@ -1,9 +1,12 @@ package com.moabam.api.domain.repository; +import java.util.Optional; + import org.springframework.data.jpa.repository.JpaRepository; import com.moabam.api.domain.entity.Member; public interface MemberRepository extends JpaRepository { + Optional findBySocialId(Long id); } diff --git a/src/main/java/com/moabam/api/domain/repository/NotificationRepository.java b/src/main/java/com/moabam/api/domain/repository/NotificationRepository.java index 130a6d7b..b280c8be 100644 --- a/src/main/java/com/moabam/api/domain/repository/NotificationRepository.java +++ b/src/main/java/com/moabam/api/domain/repository/NotificationRepository.java @@ -18,7 +18,7 @@ public class NotificationRepository { private final StringRedisRepository stringRedisRepository; - // TODO : 세연님 로그인 시, 해당 메서드 사용해서 해당 유저의 FCM TOKEN 저장하면 됩니다. + // TODO : 세연님 로그인 시, 해당 메서드 사용해서 해당 유저의 FCM TOKEN 저장하면 됩니다. Front와 상의 후 삭제예정 public void saveFcmToken(Long key, String value) { stringRedisRepository.save( String.valueOf(requireNonNull(key)), diff --git a/src/main/java/com/moabam/api/dto/LoginResponse.java b/src/main/java/com/moabam/api/dto/LoginResponse.java new file mode 100644 index 00000000..49e13cc9 --- /dev/null +++ b/src/main/java/com/moabam/api/dto/LoginResponse.java @@ -0,0 +1,11 @@ +package com.moabam.api.dto; + +import lombok.Builder; + +@Builder +public record LoginResponse( + Long id, + boolean isSignUp +) { + +} diff --git a/src/main/java/com/moabam/api/dto/MemberMapper.java b/src/main/java/com/moabam/api/dto/MemberMapper.java new file mode 100644 index 00000000..2802b2ba --- /dev/null +++ b/src/main/java/com/moabam/api/dto/MemberMapper.java @@ -0,0 +1,32 @@ +package com.moabam.api.dto; + +import com.moabam.api.domain.entity.Bug; +import com.moabam.api.domain.entity.Member; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public final class MemberMapper { + + public static Member toMember(Long socialId, String nickName) { + return Member.builder() + .socialId(socialId) + .nickname(nickName) + .bug(Bug.builder().build()) + .build(); + } + + public static LoginResponse toLoginResponse(Long memberId) { + return LoginResponse.builder() + .id(memberId) + .build(); + } + + public static LoginResponse toLoginResponse(Long memberId, boolean isSignUp) { + return LoginResponse.builder() + .id(memberId) + .isSignUp(isSignUp) + .build(); + } +} diff --git a/src/main/java/com/moabam/api/presentation/MemberController.java b/src/main/java/com/moabam/api/presentation/MemberController.java index 0554173e..20ec8018 100644 --- a/src/main/java/com/moabam/api/presentation/MemberController.java +++ b/src/main/java/com/moabam/api/presentation/MemberController.java @@ -1,13 +1,17 @@ package com.moabam.api.presentation; +import org.springframework.http.HttpStatus; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.ModelAttribute; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.RestController; import com.moabam.api.application.AuthenticationService; import com.moabam.api.dto.AuthorizationCodeResponse; +import com.moabam.api.dto.AuthorizationTokenInfoResponse; import com.moabam.api.dto.AuthorizationTokenResponse; +import com.moabam.api.dto.LoginResponse; import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; @@ -25,8 +29,13 @@ public void socialLogin(HttpServletResponse httpServletResponse) { } @GetMapping("/login/kakao/oauth") - public void authorizationTokenIssue(@ModelAttribute AuthorizationCodeResponse authorizationCodeResponse) { + @ResponseStatus(HttpStatus.OK) + public LoginResponse authorizationTokenIssue(@ModelAttribute AuthorizationCodeResponse authorizationCodeResponse, + HttpServletResponse httpServletResponse) { AuthorizationTokenResponse tokenResponse = authenticationService.requestToken(authorizationCodeResponse); - authenticationService.requestTokenInfo(tokenResponse); + AuthorizationTokenInfoResponse authorizationTokenInfoResponse = + authenticationService.requestTokenInfo(tokenResponse); + + return authenticationService.signUpOrLogin(httpServletResponse, authorizationTokenInfoResponse); } } diff --git a/src/main/java/com/moabam/global/common/constant/GlobalConstant.java b/src/main/java/com/moabam/global/common/constant/GlobalConstant.java index 8a78c94f..436df263 100644 --- a/src/main/java/com/moabam/global/common/constant/GlobalConstant.java +++ b/src/main/java/com/moabam/global/common/constant/GlobalConstant.java @@ -7,8 +7,6 @@ public class GlobalConstant { public static final String BLANK = ""; - public static final String COMMA = ","; - public static final String UNDER_BAR = "_"; public static final String CHARSET_UTF_8 = ";charset=UTF-8"; public static final String SPACE = " "; } diff --git a/src/main/java/com/moabam/global/common/constant/RedisConstant.java b/src/main/java/com/moabam/global/common/constant/RedisConstant.java deleted file mode 100644 index fe06b7dd..00000000 --- a/src/main/java/com/moabam/global/common/constant/RedisConstant.java +++ /dev/null @@ -1,14 +0,0 @@ -package com.moabam.global.common.constant; - -public class RedisConstant { - - public static final String REDIS_SERVER_MAX_MEMORY = "maxmemory 128M"; - public static final String REDIS_BINARY_PATH = "binary/redis/redis-server-arm64"; - public static final String FIND_LISTEN_PROCESS_COMMAND = "netstat -nat | grep LISTEN | grep %d"; - public static final String SHELL_PATH = "/bin/sh"; - public static final String SHELL_COMMAND_OPTION = "-c"; - public static final String OS_ARCHITECTURE = "os.arch"; - public static final String OS_NAME = "os.name"; - public static final String ARM_ARCHITECTURE = "aarch64"; - public static final String MAC_OS_NAME = "Mac OS X"; -} diff --git a/src/main/java/com/moabam/global/common/util/CookieUtils.java b/src/main/java/com/moabam/global/common/util/CookieUtils.java new file mode 100644 index 00000000..b7ece395 --- /dev/null +++ b/src/main/java/com/moabam/global/common/util/CookieUtils.java @@ -0,0 +1,18 @@ +package com.moabam.global.common.util; + +import jakarta.servlet.http.Cookie; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class CookieUtils { + + public static Cookie tokenCookie(String name, String value) { + Cookie cookie = new Cookie(name, value); + cookie.setSecure(true); + cookie.setHttpOnly(true); + cookie.setPath("/"); + + return cookie; + } +} diff --git a/src/main/java/com/moabam/global/common/util/OAuthParameterNames.java b/src/main/java/com/moabam/global/common/util/OAuthParameterNames.java deleted file mode 100644 index 05e26dea..00000000 --- a/src/main/java/com/moabam/global/common/util/OAuthParameterNames.java +++ /dev/null @@ -1,16 +0,0 @@ -package com.moabam.global.common.util; - -import lombok.AccessLevel; -import lombok.NoArgsConstructor; - -@NoArgsConstructor(access = AccessLevel.PRIVATE) -public class OAuthParameterNames { - - public static final String RESPONSE_TYPE = "response_type"; - public static final String CODE = "code"; - public static final String CLIENT_ID = "client_id"; - public static final String REDIRECT_URI = "redirect_uri"; - public static final String SCOPE = "scope"; - public static final String GRANT_TYPE = "grant_type"; - public static final String CLIENT_SECRET = "client_secret"; -} diff --git a/src/main/java/com/moabam/global/common/util/TokenConstant.java b/src/main/java/com/moabam/global/common/util/TokenConstant.java deleted file mode 100644 index 0da12b30..00000000 --- a/src/main/java/com/moabam/global/common/util/TokenConstant.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.moabam.global.common.util; - -import lombok.AccessLevel; -import lombok.NoArgsConstructor; - -@NoArgsConstructor(access = AccessLevel.PRIVATE) -public class TokenConstant { - - public static final String TOKEN_TYPE = "Bearer"; - public static final String ACCESS_TOKEN = "access_token"; - public static final String REFRESH_TOKEN = "refresh_token"; - public static final String AUTHORIZATION = "Authorization"; -} diff --git a/src/main/java/com/moabam/global/config/EmbeddedRedisConfig.java b/src/main/java/com/moabam/global/config/EmbeddedRedisConfig.java index 16587cac..77a7fcbd 100644 --- a/src/main/java/com/moabam/global/config/EmbeddedRedisConfig.java +++ b/src/main/java/com/moabam/global/config/EmbeddedRedisConfig.java @@ -1,24 +1,26 @@ package com.moabam.global.config; -import static com.moabam.global.common.constant.RedisConstant.*; - import java.io.BufferedReader; import java.io.File; import java.io.IOException; import java.io.InputStreamReader; -import java.util.Objects; import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Profile; import org.springframework.core.io.ClassPathResource; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.serializer.StringRedisSerializer; import org.springframework.util.StringUtils; import com.moabam.global.error.exception.MoabamException; import com.moabam.global.error.model.ErrorMessage; -import jakarta.annotation.PostConstruct; import jakarta.annotation.PreDestroy; +import lombok.Builder; import lombok.extern.slf4j.Slf4j; import redis.embedded.RedisServer; @@ -27,21 +29,45 @@ @Profile("test") public class EmbeddedRedisConfig { - @Value("${spring.data.redis.port}") - private int redisPort; + private final int redisPort; + private final String redisHost; + private int availablePort; private RedisServer redisServer; - @PostConstruct + public EmbeddedRedisConfig(@Value("${spring.data.redis.port}") int redisPort, + @Value("${spring.data.redis.host}") String redisHost) { + this.redisPort = redisPort; + this.redisHost = redisHost; + + startRedis(); + } + + @Bean + public RedisConnectionFactory redisConnectionFactory(EmbeddedRedisConfig embeddedRedisConfig) { + return new LettuceConnectionFactory(redisHost, embeddedRedisConfig.getAvailablePort()); + } + + @Bean + public StringRedisTemplate stringRedisTemplate(RedisConnectionFactory redisConnectionFactory) { + StringRedisTemplate stringRedisTemplate = new StringRedisTemplate(); + stringRedisTemplate.setKeySerializer(new StringRedisSerializer()); + stringRedisTemplate.setValueSerializer(new StringRedisSerializer()); + stringRedisTemplate.setConnectionFactory(redisConnectionFactory); + + return stringRedisTemplate; + } + public void startRedis() { - int port = isRedisRunning() ? findAvailablePort() : redisPort; + Os os = Os.createOs(); + availablePort = findPort(os); - if (isArmMac()) { - redisServer = new RedisServer(getRedisFileForArcMac(), port); + if (os.isMac()) { + redisServer = new RedisServer(getRedisFileForArcMac(), availablePort); } else { redisServer = RedisServer.builder() - .port(port) - .setting(REDIS_SERVER_MAX_MEMORY) + .port(availablePort) + .setting("maxmemory 128M") .build(); } @@ -64,9 +90,21 @@ public void stopRedis() { } } - public int findAvailablePort() { + public int getAvailablePort() { + return availablePort; + } + + private int findPort(Os os) { + if (!isRunning(os.executeCommand(redisPort))) { + return redisPort; + } + + return findAvailablePort(os); + } + + private int findAvailablePort(Os os) { for (int port = 10000; port <= 65535; port++) { - Process process = executeGrepProcessCommand(port); + Process process = os.executeCommand(port); if (!isRunning(process)) { return port; @@ -76,21 +114,6 @@ public int findAvailablePort() { throw new MoabamException(ErrorMessage.NOT_FOUND_AVAILABLE_PORT); } - private boolean isRedisRunning() { - return isRunning(executeGrepProcessCommand(redisPort)); - } - - private Process executeGrepProcessCommand(int redisPort) { - String command = String.format(FIND_LISTEN_PROCESS_COMMAND, redisPort); - String[] shell = {SHELL_PATH, SHELL_COMMAND_OPTION, command}; - - try { - return Runtime.getRuntime().exec(shell); - } catch (IOException e) { - throw new MoabamException(e.getMessage()); - } - } - private boolean isRunning(Process process) { String line; StringBuilder pidInfo = new StringBuilder(); @@ -106,16 +129,83 @@ private boolean isRunning(Process process) { return StringUtils.hasText(pidInfo.toString()); } - private boolean isArmMac() { - return Objects.equals(System.getProperty(OS_ARCHITECTURE), ARM_ARCHITECTURE) - && Objects.equals(System.getProperty(OS_NAME), MAC_OS_NAME); - } - private File getRedisFileForArcMac() { try { - return new ClassPathResource(REDIS_BINARY_PATH).getFile(); + return new ClassPathResource("binary/redis/redis-server-arm64").getFile(); } catch (Exception e) { throw new MoabamException(e.getMessage()); } } + + private static final class Os { + + enum Type { + MAC, + WIN, + LINUX + } + + private final String shellPath; + private final String optionOperator; + private final String command; + private final Type type; + + @Builder + private Os(String shellPath, String optionOperator, String command, Type type) { + this.shellPath = shellPath; + this.optionOperator = optionOperator; + this.command = command; + this.type = type; + } + + public Process executeCommand(int port) { + String osCommand = String.format(this.command, port); + String[] script = {shellPath, optionOperator, osCommand}; + + try { + return Runtime.getRuntime().exec(script); + } catch (IOException e) { + throw new MoabamException(e.getMessage()); + } + } + + public boolean isMac() { + return type == Type.MAC; + } + + public static Os createOs() { + String osArchitecture = System.getProperty("os.arch"); + String osName = System.getProperty("os.name"); + + if (osArchitecture.equals("aarch64") && osName.equals("Mac OS X")) { + return linuxOs(Type.MAC); + } + + if (osArchitecture.equals("amd64") && osName.contains("Windows")) { + return windowOs(); + } + + return linuxOs(Type.LINUX); + } + + // 변경 전 + private static Os linuxOs(Type type) { + return Os.builder() + .shellPath("/bin/sh") + .optionOperator("-c") + .command("netstat -nat | grep LISTEN | grep %d") + .type(type) + .build(); + } + + // 변경 후 + private static Os windowOs() { + return Os.builder() + .shellPath("cmd.exe") + .optionOperator("/c") + .command("netstat -ano | findstr LISTEN | findstr %d") + .type(Type.WIN) + .build(); + } + } } diff --git a/src/main/java/com/moabam/global/config/RedisConfig.java b/src/main/java/com/moabam/global/config/RedisConfig.java index d4d484c2..79c0e85b 100644 --- a/src/main/java/com/moabam/global/config/RedisConfig.java +++ b/src/main/java/com/moabam/global/config/RedisConfig.java @@ -3,12 +3,14 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; import org.springframework.data.redis.connection.RedisConnectionFactory; import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.data.redis.serializer.StringRedisSerializer; @Configuration +@Profile("!test") public class RedisConfig { @Value("${spring.data.redis.host}") diff --git a/src/main/java/com/moabam/global/config/TokenConfig.java b/src/main/java/com/moabam/global/config/TokenConfig.java new file mode 100644 index 00000000..fe6bdc91 --- /dev/null +++ b/src/main/java/com/moabam/global/config/TokenConfig.java @@ -0,0 +1,28 @@ +package com.moabam.global.config; + +import java.nio.charset.StandardCharsets; +import java.security.Key; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +import io.jsonwebtoken.security.Keys; +import lombok.Getter; + +@Getter +@ConfigurationProperties(prefix = "token") +public class TokenConfig { + + private final String iss; + private final long accessExpire; + private final long refreshExpire; + private final String secretKey; + private final Key key; + + public TokenConfig(String iss, long accessExpire, long refreshExpire, String secretKey) { + this.iss = iss; + this.accessExpire = accessExpire; + this.refreshExpire = refreshExpire; + this.secretKey = secretKey; + this.key = Keys.hmacShaKeyFor(secretKey.getBytes(StandardCharsets.UTF_8)); + } +} diff --git a/src/main/java/com/moabam/global/error/model/ErrorMessage.java b/src/main/java/com/moabam/global/error/model/ErrorMessage.java index 6205b4ae..a5ee589a 100644 --- a/src/main/java/com/moabam/global/error/model/ErrorMessage.java +++ b/src/main/java/com/moabam/global/error/model/ErrorMessage.java @@ -26,6 +26,7 @@ public enum ErrorMessage { LOGIN_FAILED("로그인에 실패했습니다."), REQUEST_FAILED("네트워크 접근 실패입니다."), GRANT_FAILED("인가 코드 실패"), + AUTHENTICATIE_FAIL("인증 실패"), MEMBER_NOT_FOUND("존재하지 않는 회원입니다."), MEMBER_ROOM_EXCEED("참여할 수 있는 방의 개수가 모두 찼습니다."), diff --git a/src/main/resources/config b/src/main/resources/config index 7026a658..8ba1e5fb 160000 --- a/src/main/resources/config +++ b/src/main/resources/config @@ -1 +1 @@ -Subproject commit 7026a65853d700a4f25a700fd327e926b562eabf +Subproject commit 8ba1e5fbd724fc621b1f082c98356754244ad355 diff --git a/src/main/resources/static/docs/index.html b/src/main/resources/static/docs/index.html index c1cb84c5..06e3fd55 100644 --- a/src/main/resources/static/docs/index.html +++ b/src/main/resources/static/docs/index.html @@ -616,7 +616,7 @@

diff --git a/src/main/resources/static/docs/notification.html b/src/main/resources/static/docs/notification.html index e28c38d8..1c3d4e18 100644 --- a/src/main/resources/static/docs/notification.html +++ b/src/main/resources/static/docs/notification.html @@ -473,7 +473,7 @@

응답

Vary: Access-Control-Request-Method Vary: Access-Control-Request-Headers Content-Type: application/json -Content-Length: 64 +Content-Length: 66 { "message" : "이미 콕 알림을 보낸 대상입니다." @@ -487,7 +487,7 @@

응답

diff --git a/src/test/java/com/moabam/api/application/AuthenticationServiceTest.java b/src/test/java/com/moabam/api/application/AuthenticationServiceTest.java index deb900f6..57afb71a 100644 --- a/src/test/java/com/moabam/api/application/AuthenticationServiceTest.java +++ b/src/test/java/com/moabam/api/application/AuthenticationServiceTest.java @@ -12,6 +12,8 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; @@ -25,12 +27,15 @@ import com.moabam.api.dto.AuthorizationTokenInfoResponse; import com.moabam.api.dto.AuthorizationTokenRequest; import com.moabam.api.dto.AuthorizationTokenResponse; +import com.moabam.api.dto.LoginResponse; import com.moabam.api.dto.OAuthMapper; import com.moabam.global.config.OAuthConfig; import com.moabam.global.error.exception.BadRequestException; import com.moabam.global.error.model.ErrorMessage; import com.moabam.support.fixture.AuthorizationResponseFixture; +import jakarta.servlet.http.Cookie; + @ExtendWith(MockitoExtension.class) class AuthenticationServiceTest { @@ -40,6 +45,12 @@ class AuthenticationServiceTest { @Mock OAuth2AuthorizationServerRequestService oAuth2AuthorizationServerRequestService; + @Mock + MemberService memberService; + + @Mock + JwtProviderService jwtProviderService; + OAuthConfig oauthConfig; AuthenticationService noPropertyService; OAuthConfig noOAuthConfig; @@ -58,8 +69,8 @@ public void initParams() { new OAuthConfig.Provider(null, null, null, null), new OAuthConfig.Client(null, null, null, null, null) ); - noPropertyService = new AuthenticationService(noOAuthConfig, oAuth2AuthorizationServerRequestService); - + noPropertyService = new AuthenticationService(noOAuthConfig, oAuth2AuthorizationServerRequestService, + memberService, jwtProviderService); } @DisplayName("인가코드 URI 생성 매퍼 실패") @@ -169,11 +180,52 @@ void generate_token() { = AuthorizationResponseFixture.authorizationTokenInfoResponse(); // When - when(oAuth2AuthorizationServerRequestService.tokenInfoRequest(eq(oauthConfig.provider().tokenInfo()), + when(oAuth2AuthorizationServerRequestService.tokenInfoRequest( + any(String.class), eq("Bearer " + tokenResponse.accessToken()))) .thenReturn(new ResponseEntity<>(tokenInfoResponse, HttpStatus.OK)); // Then assertThatNoException().isThrownBy(() -> authenticationService.requestTokenInfo(tokenResponse)); } + + @DisplayName("회원 가입 및 로그인 성공 테스트") + @ParameterizedTest + @ValueSource(booleans = {true, false}) + void signUp_success(boolean isSignUp) { + // given + MockHttpServletResponse httpServletResponse = new MockHttpServletResponse(); + AuthorizationTokenInfoResponse authorizationTokenInfoResponse = + AuthorizationResponseFixture.authorizationTokenInfoResponse(); + LoginResponse loginResponse = LoginResponse.builder() + .id(1L) + .isSignUp(isSignUp) + .build(); + + willReturn(loginResponse).given(memberService).login(authorizationTokenInfoResponse); + + // when + LoginResponse result = + authenticationService.signUpOrLogin(httpServletResponse, authorizationTokenInfoResponse); + + // then + assertThat(loginResponse).isEqualTo(result); + assertThat(httpServletResponse.getHeader("token_type")).isEqualTo("Bearer"); + + Cookie accessCookie = httpServletResponse.getCookie("access_token"); + assertThat(accessCookie).isNotNull(); + assertAll( + () -> assertThat(accessCookie.getSecure()).isTrue(), + () -> assertThat(accessCookie.isHttpOnly()).isTrue(), + () -> assertThat(accessCookie.getPath()).isEqualTo("/") + ); + + Cookie refreshCookie = httpServletResponse.getCookie("refresh_token"); + assertThat(refreshCookie).isNotNull(); + assertAll( + () -> assertThat(refreshCookie.getSecure()).isTrue(), + () -> assertThat(refreshCookie.isHttpOnly()).isTrue(), + () -> assertThat(refreshCookie.getPath()).isEqualTo("/") + ); + } } diff --git a/src/test/java/com/moabam/api/application/JwtAuthenticationServiceTest.java b/src/test/java/com/moabam/api/application/JwtAuthenticationServiceTest.java new file mode 100644 index 00000000..1c4df207 --- /dev/null +++ b/src/test/java/com/moabam/api/application/JwtAuthenticationServiceTest.java @@ -0,0 +1,108 @@ +package com.moabam.api.application; + +import static org.assertj.core.api.Assertions.*; + +import java.nio.charset.StandardCharsets; +import java.security.Key; +import java.util.Base64; +import java.util.Date; + +import org.assertj.core.api.Assertions; +import org.json.JSONObject; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.moabam.global.config.TokenConfig; +import com.moabam.global.error.exception.UnauthorizedException; + +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; +import io.jsonwebtoken.security.Keys; + +@ExtendWith(MockitoExtension.class) +class JwtAuthenticationServiceTest { + + String originIss = "PARK"; + String originSecretKey = "testestestestestestestestestesttestestestestestestestestestest"; + long originId = 1L; + long originAccessExpire = 100000; + long originRefreshExpire = 150000; + + TokenConfig tokenConfig; + JwtAuthenticationService jwtAuthenticationService; + JwtProviderService jwtProviderService; + + @BeforeEach + void initConfig() { + tokenConfig = new TokenConfig(originIss, originAccessExpire, originRefreshExpire, originSecretKey); + jwtProviderService = new JwtProviderService(tokenConfig); + jwtAuthenticationService = new JwtAuthenticationService(tokenConfig); + } + + @DisplayName("토큰 인증 시간 만료 테스트") + @Test + void token_authentication_time_expire() { + // Given + TokenConfig tokenConfig = new TokenConfig(originIss, 0, 0, originSecretKey); + JwtAuthenticationService jwtAuthenticationService = new JwtAuthenticationService(tokenConfig); + JwtProviderService jwtProviderService = new JwtProviderService(tokenConfig); + String token = jwtProviderService.provideAccessToken(originId); + + // When + assertThatNoException().isThrownBy(() -> { + boolean result = jwtAuthenticationService.isTokenValid(token); + + // Then + assertThat(result).isFalse(); + }); + } + + @DisplayName("토큰의 payload 변조되어 인증 실패") + @Test + void token_authenticate_failBy_payload() { + // Given + String token = jwtProviderService.provideAccessToken(originId); + String[] parts = token.split("\\."); + String claims = new String(Base64.getDecoder().decode(parts[1])); + + JSONObject tokenJson = new JSONObject(claims); + + // When + tokenJson.put("id", "2"); + + claims = tokenJson.toString(); + String newToken = String.join(".", parts[0], + Base64.getEncoder().encodeToString(claims.getBytes()), + parts[2]); + + // Then + Assertions.assertThatThrownBy(() -> jwtAuthenticationService.isTokenValid(newToken)) + .isInstanceOf(UnauthorizedException.class); + } + + @DisplayName("토큰 위조 값 검증 테스트") + @Test + void token_authenticate_failBy_key() { + // Givne + String fakeKey = "fakefakefakefakefakefakefakefakefakefakefakefake"; + Key key = Keys.hmacShaKeyFor(fakeKey.getBytes(StandardCharsets.UTF_8)); + + Date now = new Date(); + String token = Jwts.builder() + .setHeaderParam("alg", "HS256") + .setHeaderParam("typ", "JWT") + .setIssuer(originIss) + .setIssuedAt(now) + .setExpiration(new Date(now.getTime() + originAccessExpire)) + .claim("id", 5L) + .signWith(key, SignatureAlgorithm.HS256) + .compact(); + + // When + Then + assertThatThrownBy(() -> jwtAuthenticationService.isTokenValid(token)) + .isExactlyInstanceOf(UnauthorizedException.class); + } +} diff --git a/src/test/java/com/moabam/api/application/JwtProviderServiceTest.java b/src/test/java/com/moabam/api/application/JwtProviderServiceTest.java new file mode 100644 index 00000000..e1d84262 --- /dev/null +++ b/src/test/java/com/moabam/api/application/JwtProviderServiceTest.java @@ -0,0 +1,125 @@ +package com.moabam.api.application; + +import static org.assertj.core.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.*; + +import java.util.Base64; + +import org.json.JSONException; +import org.json.JSONObject; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import com.moabam.global.config.TokenConfig; + +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.Jwts; + +class JwtProviderServiceTest { + + String iss = "PARK"; + String secretKey = "testestestestestestestestestesttestestestestestestestestestest"; + long id = 1L; + + @DisplayName("access 토큰 생성 성공") + @Test + void create_access_token_success() throws JSONException { + // given + long accessExpire = 10000L; + + TokenConfig tokenConfig = new TokenConfig("PARK", accessExpire, 0L, secretKey); + JwtProviderService jwtProviderService = new JwtProviderService(tokenConfig); + + // when + String accessToken = jwtProviderService.provideAccessToken(id); + + String[] parts = accessToken.split("\\."); + String headers = new String(Base64.getDecoder().decode(parts[0])); + String claims = new String(Base64.getDecoder().decode(parts[1])); + + JSONObject headersJson = new JSONObject(headers); + JSONObject claimsJson = new JSONObject(claims); + + // then + assertAll( + () -> assertThat(headersJson.get("alg")).isEqualTo("HS256"), + () -> assertThat(headersJson.get("typ")).isEqualTo("JWT"), + () -> assertThat(claimsJson.get("iss")).isEqualTo(iss) + ); + + Long iat = Long.valueOf(claimsJson.get("iat").toString()); + Long exp = Long.valueOf(claimsJson.get("exp").toString()); + assertThat(iat).isLessThan(exp); + } + + @DisplayName("refresh 토큰 생성 성공") + @Test + void create_refresh_token_success() throws JSONException { + // given + long refreshExpire = 15000L; + + TokenConfig tokenConfig = new TokenConfig("PARK", 0L, refreshExpire, secretKey); + JwtProviderService jwtProviderService = new JwtProviderService(tokenConfig); + + // when + String refreshToken = jwtProviderService.provideRefreshToken(id); + + String[] parts = refreshToken.split("\\."); + String headers = new String(Base64.getDecoder().decode(parts[0])); + String claims = new String(Base64.getDecoder().decode(parts[1])); + + JSONObject headersJson = new JSONObject(headers); + JSONObject claimsJson = new JSONObject(claims); + + // then + assertAll( + () -> assertThat(headersJson.get("alg")).isEqualTo("HS256"), + () -> assertThat(headersJson.get("typ")).isEqualTo("JWT"), + () -> assertThat(claimsJson.get("iss")).isEqualTo(iss) + ); + + Long iat = Long.valueOf(claimsJson.get("iat").toString()); + Long exp = Long.valueOf(claimsJson.get("exp").toString()); + assertThat(iat).isLessThan(exp); + } + + @DisplayName("access 토큰 만료시간에 따른 생성 실패") + @Test + void create_access_token_fail() { + // given + long accessExpire = -1L; + + TokenConfig tokenConfig = new TokenConfig("PARK", accessExpire, 0L, secretKey); + JwtProviderService jwtProviderService = new JwtProviderService(tokenConfig); + + // when + String accessToken = jwtProviderService.provideAccessToken(id); + + // then + assertThatThrownBy(() -> Jwts.parserBuilder() + .setSigningKey(tokenConfig.getKey()) + .build() + .parseClaimsJwt(accessToken) + ).isInstanceOf(ExpiredJwtException.class); + } + + @DisplayName("refresh 토큰 만료시간에 따른 생성 실패") + @Test + void create_token_fail() { + // given + long refreshExpire = -1L; + + TokenConfig tokenConfig = new TokenConfig("PARK", 0L, refreshExpire, secretKey); + JwtProviderService jwtProviderService = new JwtProviderService(tokenConfig); + + // when + String accessToken = jwtProviderService.provideAccessToken(id); + + // then + assertThatThrownBy(() -> Jwts.parserBuilder() + .setSigningKey(tokenConfig.getKey()) + .build() + .parseClaimsJwt(accessToken) + ).isExactlyInstanceOf(ExpiredJwtException.class); + } +} diff --git a/src/test/java/com/moabam/api/application/MemberServiceTest.java b/src/test/java/com/moabam/api/application/MemberServiceTest.java new file mode 100644 index 00000000..f5ce03a0 --- /dev/null +++ b/src/test/java/com/moabam/api/application/MemberServiceTest.java @@ -0,0 +1,70 @@ +package com.moabam.api.application; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.BDDMockito.*; + +import java.util.Optional; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.moabam.api.domain.entity.Member; +import com.moabam.api.domain.repository.MemberRepository; +import com.moabam.api.dto.AuthorizationTokenInfoResponse; +import com.moabam.api.dto.LoginResponse; +import com.moabam.support.fixture.AuthorizationResponseFixture; +import com.moabam.support.fixture.MemberFixture; + +@ExtendWith(MockitoExtension.class) +class MemberServiceTest { + + @InjectMocks + MemberService memberService; + + @Mock + MemberRepository memberRepository; + + @DisplayName("회원 존재하고 로그인 성공") + @Test + void member_exist_and_login_success() { + // given + AuthorizationTokenInfoResponse authorizationTokenInfoResponse = + AuthorizationResponseFixture.authorizationTokenInfoResponse(); + Member member = MemberFixture.member(); + willReturn(Optional.of(member)) + .given(memberRepository).findBySocialId(authorizationTokenInfoResponse.id()); + + // when + LoginResponse result = memberService.login(authorizationTokenInfoResponse); + + // then + assertThat(result.id()).isEqualTo(member.getId()); + assertThat(result.isSignUp()).isFalse(); + } + + @DisplayName("회원가입 성공") + @Test + void signUp_success() { + // given + AuthorizationTokenInfoResponse authorizationTokenInfoResponse = + AuthorizationResponseFixture.authorizationTokenInfoResponse(); + willReturn(Optional.empty()) + .given(memberRepository).findBySocialId(authorizationTokenInfoResponse.id()); + + Member member = spy(MemberFixture.member()); + given(member.getId()).willReturn(1L); + willReturn(member) + .given(memberRepository).save(any(Member.class)); + + // when + LoginResponse result = memberService.login(authorizationTokenInfoResponse); + + // then + assertThat(authorizationTokenInfoResponse.id()).isEqualTo(result.id()); + assertThat(result.isSignUp()).isTrue(); + } +} diff --git a/src/test/java/com/moabam/api/domain/entity/MemberTest.java b/src/test/java/com/moabam/api/domain/entity/MemberTest.java index ab0df828..01c89306 100644 --- a/src/test/java/com/moabam/api/domain/entity/MemberTest.java +++ b/src/test/java/com/moabam/api/domain/entity/MemberTest.java @@ -13,7 +13,7 @@ class MemberTest { - String socialId = "1"; + Long socialId = 1L; String nickname = "밥세공기"; String profileImage = "kakao/profile/url"; @@ -24,7 +24,6 @@ void create_member_success() { assertThatNoException().isThrownBy(() -> Member.builder() .socialId(socialId) .nickname(nickname) - .profileImage(profileImage) .bug(Bug.builder().build()) .build()); } @@ -37,7 +36,6 @@ void create_member_noImage_success() { Member member = Member.builder() .socialId(socialId) .nickname(nickname) - .profileImage(null) .bug(Bug.builder().build()) .build(); diff --git a/src/test/java/com/moabam/api/presentation/MemberControllerTest.java b/src/test/java/com/moabam/api/presentation/MemberControllerTest.java index 2ecf1883..32cef2d0 100644 --- a/src/test/java/com/moabam/api/presentation/MemberControllerTest.java +++ b/src/test/java/com/moabam/api/presentation/MemberControllerTest.java @@ -1,11 +1,9 @@ package com.moabam.api.presentation; -import static com.moabam.global.common.util.OAuthParameterNames.*; import static org.mockito.BDDMockito.*; import static org.springframework.test.web.client.match.MockRestRequestMatchers.*; import static org.springframework.test.web.client.response.MockRestResponseCreators.*; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; -import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.*; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; import org.junit.jupiter.api.BeforeAll; @@ -37,6 +35,7 @@ import com.moabam.api.application.AuthenticationService; import com.moabam.api.application.OAuth2AuthorizationServerRequestService; import com.moabam.api.dto.AuthorizationCodeResponse; +import com.moabam.api.dto.AuthorizationTokenInfoResponse; import com.moabam.api.dto.AuthorizationTokenResponse; import com.moabam.global.common.constant.GlobalConstant; import com.moabam.global.config.OAuthConfig; @@ -86,10 +85,10 @@ void authorization_code_request_success() throws Exception { // given String uri = UriComponentsBuilder .fromUriString(oAuthConfig.provider().authorizationUri()) - .queryParam(RESPONSE_TYPE, "code") - .queryParam(CLIENT_ID, oAuthConfig.client().clientId()) - .queryParam(REDIRECT_URI, oAuthConfig.provider().redirectUri()) - .queryParam(SCOPE, String.join(",", oAuthConfig.client().scope())) + .queryParam("response_type", "code") + .queryParam("client_id", oAuthConfig.client().clientId()) + .queryParam("redirect_uri", oAuthConfig.provider().redirectUri()) + .queryParam("scope", String.join(",", oAuthConfig.client().scope())) .toUriString(); // expected @@ -101,26 +100,25 @@ void authorization_code_request_success() throws Exception { .andExpect(MockMvcResultMatchers.redirectedUrl(uri)); } - @DisplayName("Authorization Server에 토큰 발급 요청") + @DisplayName("소셜 로그인 및 회원가입 요청 성공") @Test - void authorization_token_request_success() throws Exception { + void social_login_signUp_request_success() throws Exception { // given MultiValueMap contentParams = new LinkedMultiValueMap<>(); - contentParams.add(GRANT_TYPE, oAuthConfig.client().authorizationGrantType()); - contentParams.add(CLIENT_ID, oAuthConfig.client().clientId()); - contentParams.add(REDIRECT_URI, oAuthConfig.provider().redirectUri()); - contentParams.add(CODE, "test"); - contentParams.add(CLIENT_SECRET, oAuthConfig.client().clientSecret()); + contentParams.add("grant_type", oAuthConfig.client().authorizationGrantType()); + contentParams.add("client_id", oAuthConfig.client().clientId()); + contentParams.add("redirect_uri", oAuthConfig.provider().redirectUri()); + contentParams.add("code", "test"); + contentParams.add("client_secret", oAuthConfig.client().clientSecret()); AuthorizationCodeResponse authorizationCodeResponse = AuthorizationResponseFixture.successCodeResponse(); AuthorizationTokenResponse authorizationTokenResponse = AuthorizationResponseFixture.authorizationTokenResponse(); - String response = objectMapper.writeValueAsString(authorizationTokenResponse); - // When - doReturn(AuthorizationResponseFixture.authorizationTokenInfoResponse()) - .when(authenticationService).requestTokenInfo(authorizationTokenResponse); + AuthorizationTokenInfoResponse authorizationTokenInfoResponse = + AuthorizationResponseFixture.authorizationTokenInfoResponse(); + String tokenInfoResponse = objectMapper.writeValueAsString(authorizationTokenInfoResponse); // expected mockRestServiceServer.expect(requestTo(oAuthConfig.provider().tokenUri())) @@ -129,10 +127,25 @@ void authorization_token_request_success() throws Exception { .andExpect(method(HttpMethod.POST)) .andRespond(withSuccess(response, MediaType.APPLICATION_JSON)); - ResultActions result = mockMvc.perform(get("/members/login/kakao/oauth") + mockRestServiceServer.expect(requestTo(oAuthConfig.provider().tokenInfo())) + .andExpect(MockRestRequestMatchers.method(HttpMethod.GET)) + .andExpect(MockRestRequestMatchers.header("Authorization", "Bearer accessToken")) + .andRespond(withSuccess(tokenInfoResponse, MediaType.APPLICATION_JSON)); + + mockMvc.perform(get("/members/login/kakao/oauth") .flashAttr("authorizationCodeResponse", authorizationCodeResponse)) - .andExpect(status().isOk()) - .andDo(print()); + .andExpectAll( + status().isOk(), + MockMvcResultMatchers.content().contentType(MediaType.APPLICATION_JSON), + MockMvcResultMatchers.header().string("token_type", "Bearer"), + cookie().exists("access_token"), + cookie().httpOnly("access_token", true), + cookie().secure("access_token", true), + cookie().exists("refresh_token"), + cookie().httpOnly("refresh_token", true), + cookie().secure("refresh_token", true) + ) + .andExpect(MockMvcResultMatchers.jsonPath("$.isSignUp").value(true)); } @DisplayName("Authorization Token 발급 실패") @@ -141,11 +154,11 @@ void authorization_token_request_success() throws Exception { void authorization_token_request_fail(int code) throws Exception { // given MultiValueMap contentParams = new LinkedMultiValueMap<>(); - contentParams.add(GRANT_TYPE, oAuthConfig.client().authorizationGrantType()); - contentParams.add(CLIENT_ID, oAuthConfig.client().clientId()); - contentParams.add(REDIRECT_URI, oAuthConfig.provider().redirectUri()); - contentParams.add(CODE, "test"); - contentParams.add(CLIENT_SECRET, oAuthConfig.client().clientSecret()); + contentParams.add("grant_type", oAuthConfig.client().authorizationGrantType()); + contentParams.add("client_id", oAuthConfig.client().clientId()); + contentParams.add("redirect_uri", oAuthConfig.provider().redirectUri()); + contentParams.add("code", "test"); + contentParams.add("client_secret", oAuthConfig.client().clientSecret()); AuthorizationCodeResponse authorizationCodeResponse = AuthorizationResponseFixture.successCodeResponse(); @@ -156,32 +169,11 @@ void authorization_token_request_fail(int code) throws Exception { .andExpect(method(HttpMethod.POST)) .andRespond(withStatus(HttpStatusCode.valueOf(code))); - ResultActions result = mockMvc.perform(get("/members/login/kakao/oauth") + mockMvc.perform(get("/members/login/kakao/oauth") .flashAttr("authorizationCodeResponse", authorizationCodeResponse)) .andExpect(status().isBadRequest()); } - @DisplayName("토큰 정보 조회 요청") - @Test - void token_info_request_success() throws Exception { - // given - AuthorizationCodeResponse authorizationCodeResponse = AuthorizationResponseFixture.successCodeResponse(); - - // When - doReturn(AuthorizationResponseFixture.authorizationTokenResponse()) - .when(authenticationService).requestToken(authorizationCodeResponse); - - // expected - mockRestServiceServer.expect(requestTo(oAuthConfig.provider().tokenInfo())) - .andExpect(MockRestRequestMatchers.method(HttpMethod.GET)) - .andExpect(MockRestRequestMatchers.header("Authorization", "Bearer accessToken")) - .andRespond(withStatus(HttpStatusCode.valueOf(200))); - - ResultActions result = mockMvc.perform(get("/members/login/kakao/oauth") - .flashAttr("authorizationCodeResponse", authorizationCodeResponse)) - .andExpect(status().isOk()); - } - @DisplayName("토큰 정보 요청 실패") @ParameterizedTest @ValueSource(ints = {400, 401}) @@ -199,7 +191,7 @@ void token_info_response_fail(int code) throws Exception { .andExpect(MockRestRequestMatchers.header("Authorization", "Bearer accessToken")) .andRespond(withStatus(HttpStatusCode.valueOf(code))); - ResultActions result = mockMvc.perform(get("/members/login/kakao/oauth") + mockMvc.perform(get("/members/login/kakao/oauth") .flashAttr("authorizationCodeResponse", authorizationCodeResponse)) .andExpect(status().isBadRequest()); } diff --git a/src/test/java/com/moabam/api/presentation/NotificationControllerTest.java b/src/test/java/com/moabam/api/presentation/NotificationControllerTest.java index 7914b949..7083f1c2 100644 --- a/src/test/java/com/moabam/api/presentation/NotificationControllerTest.java +++ b/src/test/java/com/moabam/api/presentation/NotificationControllerTest.java @@ -61,7 +61,7 @@ class NotificationControllerTest { @BeforeEach void setUp() { - target = memberRepository.save(MemberFixture.member("target123", "targetName")); + target = memberRepository.save(MemberFixture.member(123L, "targetName")); room = roomRepository.save(RoomFixture.room()); knockKey = String.format(KNOCK_KEY, room.getId(), 1, target.getId()); diff --git a/src/test/java/com/moabam/api/presentation/RoomControllerTest.java b/src/test/java/com/moabam/api/presentation/RoomControllerTest.java index 8857073e..e8c2113d 100644 --- a/src/test/java/com/moabam/api/presentation/RoomControllerTest.java +++ b/src/test/java/com/moabam/api/presentation/RoomControllerTest.java @@ -525,9 +525,8 @@ void enter_room_wrong_password_fail() throws Exception { Member member = Member.builder() .id(1L) - .socialId("test123") + .socialId(1L) .nickname("nick") - .profileImage("testtests") .bug(BugFixture.bug()) .build(); @@ -771,16 +770,14 @@ void get_room_details_test() throws Exception { participant1.enableManager(); Member member2 = Member.builder() - .socialId("SOCIAL_2") + .socialId(2L) .nickname("NICKNAME_2") - .profileImage("PROFILE_IMAGE_2") .bug(BugFixture.bug()) .build(); Member member3 = Member.builder() - .socialId("SOCIAL_3") + .socialId(3L) .nickname("NICKNAME_3") - .profileImage("PROFILE_IMAGE_3") .bug(BugFixture.bug()) .build(); diff --git a/src/test/java/com/moabam/support/fixture/MemberFixture.java b/src/test/java/com/moabam/support/fixture/MemberFixture.java index 80bb1b03..e1b4d000 100644 --- a/src/test/java/com/moabam/support/fixture/MemberFixture.java +++ b/src/test/java/com/moabam/support/fixture/MemberFixture.java @@ -4,24 +4,21 @@ public final class MemberFixture { - public static final String SOCIAL_ID = "test123"; + public static final long SOCIAL_ID = 1L; public static final String NICKNAME = "모아밤"; - public static final String PROFILE_IMAGE = "/profile/moabam.png"; public static Member member() { return Member.builder() .socialId(SOCIAL_ID) .nickname(NICKNAME) - .profileImage(PROFILE_IMAGE) .bug(BugFixture.bug()) .build(); } - public static Member member(String socialId, String nickname) { + public static Member member(Long socialId, String nickname) { return Member.builder() .socialId(socialId) .nickname(nickname) - .profileImage(PROFILE_IMAGE) .bug(BugFixture.bug()) .build(); } diff --git a/src/test/resources/application.yml b/src/test/resources/application.yml index 3347b41f..723cc57c 100644 --- a/src/test/resources/application.yml +++ b/src/test/resources/application.yml @@ -33,5 +33,10 @@ oauth2: authorization_uri: https://authorization.com/test/test redirect_uri: http://redirect:8080/test token_uri: https://token.com/test/test - token-info: https://api.token.com/test + token_info: https://api.token.com/test +token: + iss: "PARK" + access-expire: 100000 + refresh-expire: 150000 + secret-key: testestestestestestestestestesttestestestestestestestestestest