From f71c43bcd5684146f508fc39063f16c6f6f92db8 Mon Sep 17 00:00:00 2001 From: SeokHwan An <70303795+seokhwan-an@users.noreply.github.com> Date: Wed, 9 Aug 2023 14:57:15 +0900 Subject: [PATCH] =?UTF-8?q?Feat/#130=20=EA=B5=AC=EA=B8=80=20=EC=86=8C?= =?UTF-8?q?=EC=85=9C=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EA=B5=AC=ED=98=84=20?= =?UTF-8?q?(#157)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 회원 엔티티 및 테이블 추가 * feat: 회원을 등록하고 조회하는 기능 추가 세부사항 - 회원 엔티티 필드를 객체로 포장한 것으로 변경 - email 객체와 nickName 객체 변수명 수정 * feat: jwt 토큰을 생성하고 검증하는 기능 추가 * feat: 구글 로그인 기능 추가 세부사항 - 구글 access token 받아오는 기능 추가 - 구글 member info 받아오는 기능 추가 * refactor: 어노테이션 수정 세부사항 - MemberService transactional 어노테이션 추가 - GoogleProvider componenet 어노테이션 service 어노테이션으로 수정 * style: 사용하지 않는 import문 제거 * config: 의존성 추가 주석 영어로 수정 * refactor: TokenProvider의 secretKey 재할당 받지 못하게 수정 * refactor: 구글 api로 받은 객체 필드 카멜케이스로 이용할 수 있도록 수정 * refactor: GoogleInfoProvider 전반적인 부분 수정 세부사항 - memberInfo 요청을 보낼 때 accessToken을 url의 param으로 요청하는 것에서 header로 넣어서 요청하는 방식으로 수정 - 예외처리 세분화 (4XX 예외와 5XX 예외 분리) - accessToken요청 시 요청 param을 객체로 추출 - 메소드 반환의 형태가 ResponseEntity가 아닌 body를 반환 * refactor: parameter 타입을 reference 타입에서 primitive 타입으로 수정 * refactor: 코드 컨벤션에 맞게 수정 및 가독성 향상 세부사항 - @Repository 붙이기 - 오타 수정 - 불필요한 공백제거 - final 키워드 붙이기 - 불필요한 @NoArgsConstructor 제거 - 예외 네이밍 수정 * refactor: token 예외 처리 세분화 세부사항 - 만료된 토큰 예외 추가 - 만료된 토큰에 대한 test 추가 * refactor: 코드 통일성 및 오타 수정 * chore: 파일 명 수정 (NickName -> Nickname) * refactor: 소셜 로그인 흐름 수정 세부사항 - 소셜 로그인 시 nickname을 입력받지 않고 email로 지정한 후 추후에 수정할 수 있도록 수정 * refactor: 코드 가독성 향상 및 오타 수정 --- backend/build.gradle | 4 + .../auth/jwt/application/TokenProvider.java | 71 +++++++++++ .../auth/jwt/exception/TokenException.java | 18 +++ .../oauth/application/GoogleInfoProvider.java | 89 ++++++++++++++ .../auth/oauth/application/OAuthService.java | 36 ++++++ .../dto/GoogleAccessTokenRequest.java | 18 +++ .../dto/GoogleAccessTokenResponse.java | 16 +++ .../dto/GoogleMemberInfoResponse.java | 19 +++ .../oauth/application/dto/LoginResponse.java | 12 ++ .../shook/auth/oauth/config/OAuthConfig.java | 15 +++ .../auth/oauth/exception/OAuthException.java | 32 +++++ .../shook/auth/oauth/ui/OauthController.java | 23 ++++ .../member/application/MemberService.java | 32 +++++ .../dto/MemberRegisterRequest.java | 25 ++++ .../java/shook/shook/member/domain/Email.java | 44 +++++++ .../shook/shook/member/domain/Member.java | 42 +++++++ .../shook/shook/member/domain/Nickname.java | 36 ++++++ .../domain/repository/MemberRepository.java | 13 +++ .../member/exception/MemberException.java | 46 ++++++++ backend/src/main/resources/schema.sql | 10 +- .../jwt/application/TokenProviderTest.java | 78 +++++++++++++ .../application/GoogleInfoProviderTest.java | 110 ++++++++++++++++++ .../oauth/application/OAuthServiceTest.java | 52 +++++++++ .../auth/oauth/ui/OauthControllerTest.java | 41 +++++++ .../member/application/MemberServiceTest.java | 71 +++++++++++ .../shook/shook/member/domain/EmailTest.java | 64 ++++++++++ .../shook/member/domain/NickNameTest.java | 47 ++++++++ backend/src/test/resources/application.yml | 13 +++ 28 files changed, 1075 insertions(+), 2 deletions(-) create mode 100644 backend/src/main/java/shook/shook/auth/jwt/application/TokenProvider.java create mode 100644 backend/src/main/java/shook/shook/auth/jwt/exception/TokenException.java create mode 100644 backend/src/main/java/shook/shook/auth/oauth/application/GoogleInfoProvider.java create mode 100644 backend/src/main/java/shook/shook/auth/oauth/application/OAuthService.java create mode 100644 backend/src/main/java/shook/shook/auth/oauth/application/dto/GoogleAccessTokenRequest.java create mode 100644 backend/src/main/java/shook/shook/auth/oauth/application/dto/GoogleAccessTokenResponse.java create mode 100644 backend/src/main/java/shook/shook/auth/oauth/application/dto/GoogleMemberInfoResponse.java create mode 100644 backend/src/main/java/shook/shook/auth/oauth/application/dto/LoginResponse.java create mode 100644 backend/src/main/java/shook/shook/auth/oauth/config/OAuthConfig.java create mode 100644 backend/src/main/java/shook/shook/auth/oauth/exception/OAuthException.java create mode 100644 backend/src/main/java/shook/shook/auth/oauth/ui/OauthController.java create mode 100644 backend/src/main/java/shook/shook/member/application/MemberService.java create mode 100644 backend/src/main/java/shook/shook/member/application/dto/MemberRegisterRequest.java create mode 100644 backend/src/main/java/shook/shook/member/domain/Email.java create mode 100644 backend/src/main/java/shook/shook/member/domain/Member.java create mode 100644 backend/src/main/java/shook/shook/member/domain/Nickname.java create mode 100644 backend/src/main/java/shook/shook/member/domain/repository/MemberRepository.java create mode 100644 backend/src/main/java/shook/shook/member/exception/MemberException.java create mode 100644 backend/src/test/java/shook/shook/auth/jwt/application/TokenProviderTest.java create mode 100644 backend/src/test/java/shook/shook/auth/oauth/application/GoogleInfoProviderTest.java create mode 100644 backend/src/test/java/shook/shook/auth/oauth/application/OAuthServiceTest.java create mode 100644 backend/src/test/java/shook/shook/auth/oauth/ui/OauthControllerTest.java create mode 100644 backend/src/test/java/shook/shook/member/application/MemberServiceTest.java create mode 100644 backend/src/test/java/shook/shook/member/domain/EmailTest.java create mode 100644 backend/src/test/java/shook/shook/member/domain/NickNameTest.java diff --git a/backend/build.gradle b/backend/build.gradle index ad1d1f899..357ba7075 100644 --- a/backend/build.gradle +++ b/backend/build.gradle @@ -26,6 +26,10 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.springframework.boot:spring-boot-starter-validation' + // JWT Dependency + implementation 'io.jsonwebtoken:jjwt-api:0.11.5' + implementation 'io.jsonwebtoken:jjwt-impl:0.11.5' + implementation 'io.jsonwebtoken:jjwt-jackson:0.11.5' //swagger implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.0.2' diff --git a/backend/src/main/java/shook/shook/auth/jwt/application/TokenProvider.java b/backend/src/main/java/shook/shook/auth/jwt/application/TokenProvider.java new file mode 100644 index 000000000..f56eacbe4 --- /dev/null +++ b/backend/src/main/java/shook/shook/auth/jwt/application/TokenProvider.java @@ -0,0 +1,71 @@ +package shook.shook.auth.jwt.application; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.MalformedJwtException; +import io.jsonwebtoken.SignatureAlgorithm; +import io.jsonwebtoken.security.Keys; +import java.security.Key; +import java.util.Base64; +import java.util.Date; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import shook.shook.auth.jwt.exception.TokenException; + +@Component +public class TokenProvider { + + private final long accessTokenValidTime; + private final long refreshTokenValidTime; + private final Key secretKey; + + public TokenProvider( + @Value("${jwt.access-token-valid-time}") final long accessTokenValidTime, + @Value("${jwt.refresh-token-valid-time}") final long refreshTokenValidTime, + @Value("${jwt.secret-code}") final String secretCode + ) { + this.accessTokenValidTime = accessTokenValidTime; + this.refreshTokenValidTime = refreshTokenValidTime; + this.secretKey = generateSecretKey(secretCode); + } + + private Key generateSecretKey(final String secretCode) { + final String encodedSecretCode = Base64.getEncoder().encodeToString(secretCode.getBytes()); + return Keys.hmacShaKeyFor(encodedSecretCode.getBytes()); + } + + public String createAccessToken(final long memberId) { + return createToken(memberId, accessTokenValidTime); + } + + public String createRefreshToken(final long memberId) { + return createToken(memberId, refreshTokenValidTime); + } + + private String createToken(final long memberId, final long validTime) { + final Claims claims = Jwts.claims().setSubject("user"); + claims.put("memberId", memberId); + Date now = new Date(); + return Jwts.builder() + .setClaims(claims) + .setIssuedAt(now) + .setExpiration(new Date(now.getTime() + validTime)) + .signWith(secretKey, SignatureAlgorithm.HS256) + .compact(); + } + + public Claims parseClaims(final String token) { + try { + return Jwts.parserBuilder() + .setSigningKey(secretKey) + .build() + .parseClaimsJws(token) + .getBody(); + } catch (MalformedJwtException e) { + throw new TokenException.NotIssuedTokenException(); + } catch (ExpiredJwtException e) { + throw new TokenException.ExpiredTokenException(); + } + } +} diff --git a/backend/src/main/java/shook/shook/auth/jwt/exception/TokenException.java b/backend/src/main/java/shook/shook/auth/jwt/exception/TokenException.java new file mode 100644 index 000000000..88745b6ab --- /dev/null +++ b/backend/src/main/java/shook/shook/auth/jwt/exception/TokenException.java @@ -0,0 +1,18 @@ +package shook.shook.auth.jwt.exception; + +public class TokenException extends RuntimeException { + + public static class NotIssuedTokenException extends TokenException { + + public NotIssuedTokenException() { + super(); + } + } + + public static class ExpiredTokenException extends TokenException { + + public ExpiredTokenException() { + super(); + } + } +} diff --git a/backend/src/main/java/shook/shook/auth/oauth/application/GoogleInfoProvider.java b/backend/src/main/java/shook/shook/auth/oauth/application/GoogleInfoProvider.java new file mode 100644 index 000000000..a3b73761f --- /dev/null +++ b/backend/src/main/java/shook/shook/auth/oauth/application/GoogleInfoProvider.java @@ -0,0 +1,89 @@ +package shook.shook.auth.oauth.application; + +import java.util.Objects; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.stereotype.Component; +import org.springframework.web.client.HttpClientErrorException; +import org.springframework.web.client.HttpServerErrorException; +import org.springframework.web.client.RestTemplate; +import shook.shook.auth.oauth.application.dto.GoogleAccessTokenRequest; +import shook.shook.auth.oauth.application.dto.GoogleAccessTokenResponse; +import shook.shook.auth.oauth.application.dto.GoogleMemberInfoResponse; +import shook.shook.auth.oauth.exception.OAuthException; + +@RequiredArgsConstructor +@Component +public class GoogleInfoProvider { + + private static final String TOKEN_PREFIX = "Bearer "; + private static final String GRANT_TYPE = "authorization_code"; + private static final String AUTHORIZATION_HEADER = "Authorization"; + + @Value("${oauth2.google.access-token-url}") + private String GOOGLE_ACCESS_TOKEN_URL; + + @Value("${oauth2.google.member-info-url}") + private String GOOGLE_MEMBER_INFO_URL; + + @Value("${oauth2.google.client-id}") + private String GOOGLE_CLIENT_ID; + + @Value("${oauth2.google.client-secret}") + private String GOOGLE_CLIENT_SECRET; + + @Value("${oauth2.google.redirect-uri}") + private String LOGIN_REDIRECT_URL; + + private final RestTemplate restTemplate; + + public GoogleMemberInfoResponse getMemberInfo(final String accessToken) { + try { + final HttpHeaders headers = new HttpHeaders(); + headers.set(AUTHORIZATION_HEADER, TOKEN_PREFIX + accessToken); + final HttpEntity request = new HttpEntity<>(headers); + + final GoogleMemberInfoResponse responseEntity = restTemplate.exchange( + GOOGLE_MEMBER_INFO_URL, + HttpMethod.GET, + request, + GoogleMemberInfoResponse.class).getBody(); + + if (!Objects.requireNonNull(responseEntity).isVerifiedEmail()) { + throw new OAuthException.InvalidEmailException(); + } + + return responseEntity; + } catch (HttpClientErrorException e) { + throw new OAuthException.InvalidAccessTokenException(); + } catch (HttpServerErrorException e) { + throw new OAuthException.GoogleServerException(); + } + } + + public GoogleAccessTokenResponse getAccessToken(final String authorizationCode) { + try { + final GoogleAccessTokenRequest googleAccessTokenRequest = new GoogleAccessTokenRequest( + authorizationCode, + GOOGLE_CLIENT_ID, + GOOGLE_CLIENT_SECRET, + LOGIN_REDIRECT_URL, + GRANT_TYPE); + final HttpEntity request = new HttpEntity<>( + googleAccessTokenRequest); + + return Objects.requireNonNull(restTemplate.postForEntity( + GOOGLE_ACCESS_TOKEN_URL, + request, + GoogleAccessTokenResponse.class).getBody()); + + } catch (HttpClientErrorException e) { + throw new OAuthException.InvalidAuthorizationCodeException(); + } catch (HttpServerErrorException e) { + throw new OAuthException.GoogleServerException(); + } + } +} diff --git a/backend/src/main/java/shook/shook/auth/oauth/application/OAuthService.java b/backend/src/main/java/shook/shook/auth/oauth/application/OAuthService.java new file mode 100644 index 000000000..60554366e --- /dev/null +++ b/backend/src/main/java/shook/shook/auth/oauth/application/OAuthService.java @@ -0,0 +1,36 @@ +package shook.shook.auth.oauth.application; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import shook.shook.auth.jwt.application.TokenProvider; +import shook.shook.auth.oauth.application.dto.GoogleAccessTokenResponse; +import shook.shook.auth.oauth.application.dto.GoogleMemberInfoResponse; +import shook.shook.auth.oauth.application.dto.LoginResponse; +import shook.shook.member.application.MemberService; +import shook.shook.member.domain.Email; +import shook.shook.member.domain.Member; + +@RequiredArgsConstructor +@Service +public class OAuthService { + + private final MemberService memberService; + private final GoogleInfoProvider googleInfoProvider; + private final TokenProvider tokenProvider; + + public LoginResponse login(final String accessCode) { + final GoogleAccessTokenResponse accessTokenResponse = + googleInfoProvider.getAccessToken(accessCode); + final GoogleMemberInfoResponse memberInfo = googleInfoProvider + .getMemberInfo(accessTokenResponse.getAccessToken()); + + final String userEmail = memberInfo.getEmail(); + final Member member = memberService.findByEmail(new Email(userEmail)) + .orElseGet(() -> memberService.register(userEmail)); + + final long memberId = member.getId(); + final String accessToken = tokenProvider.createAccessToken(memberId); + final String refreshToken = tokenProvider.createRefreshToken(memberId); + return new LoginResponse(accessToken, refreshToken); + } +} diff --git a/backend/src/main/java/shook/shook/auth/oauth/application/dto/GoogleAccessTokenRequest.java b/backend/src/main/java/shook/shook/auth/oauth/application/dto/GoogleAccessTokenRequest.java new file mode 100644 index 000000000..bd8bea15b --- /dev/null +++ b/backend/src/main/java/shook/shook/auth/oauth/application/dto/GoogleAccessTokenRequest.java @@ -0,0 +1,18 @@ +package shook.shook.auth.oauth.application.dto; + +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +@AllArgsConstructor +@Getter +public class GoogleAccessTokenRequest { + + private String code; + private String clientId; + private String clientSecret; + private String redirectUri; + private String grantType; +} diff --git a/backend/src/main/java/shook/shook/auth/oauth/application/dto/GoogleAccessTokenResponse.java b/backend/src/main/java/shook/shook/auth/oauth/application/dto/GoogleAccessTokenResponse.java new file mode 100644 index 000000000..a9c05e0c6 --- /dev/null +++ b/backend/src/main/java/shook/shook/auth/oauth/application/dto/GoogleAccessTokenResponse.java @@ -0,0 +1,16 @@ +package shook.shook.auth.oauth.application.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@AllArgsConstructor +@NoArgsConstructor(access = AccessLevel.PRIVATE) +@Getter +public class GoogleAccessTokenResponse { + + @JsonProperty("access_token") + private String accessToken; +} diff --git a/backend/src/main/java/shook/shook/auth/oauth/application/dto/GoogleMemberInfoResponse.java b/backend/src/main/java/shook/shook/auth/oauth/application/dto/GoogleMemberInfoResponse.java new file mode 100644 index 000000000..efd4c7be3 --- /dev/null +++ b/backend/src/main/java/shook/shook/auth/oauth/application/dto/GoogleMemberInfoResponse.java @@ -0,0 +1,19 @@ +package shook.shook.auth.oauth.application.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@AllArgsConstructor +@NoArgsConstructor(access = AccessLevel.PRIVATE) +@Getter +public class GoogleMemberInfoResponse { + + private String email; + + @JsonProperty("verified_email") + private boolean verifiedEmail; + +} diff --git a/backend/src/main/java/shook/shook/auth/oauth/application/dto/LoginResponse.java b/backend/src/main/java/shook/shook/auth/oauth/application/dto/LoginResponse.java new file mode 100644 index 000000000..c7f0f13c0 --- /dev/null +++ b/backend/src/main/java/shook/shook/auth/oauth/application/dto/LoginResponse.java @@ -0,0 +1,12 @@ +package shook.shook.auth.oauth.application.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@AllArgsConstructor +@Getter +public class LoginResponse { + + private String accessToken; + private String refreshToken; +} diff --git a/backend/src/main/java/shook/shook/auth/oauth/config/OAuthConfig.java b/backend/src/main/java/shook/shook/auth/oauth/config/OAuthConfig.java new file mode 100644 index 000000000..f494375c7 --- /dev/null +++ b/backend/src/main/java/shook/shook/auth/oauth/config/OAuthConfig.java @@ -0,0 +1,15 @@ +package shook.shook.auth.oauth.config; + +import org.springframework.boot.web.client.RestTemplateBuilder; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.client.RestTemplate; + +@Configuration +public class OAuthConfig { + + @Bean + public RestTemplate getRestTemplate() { + return new RestTemplateBuilder().build(); + } +} diff --git a/backend/src/main/java/shook/shook/auth/oauth/exception/OAuthException.java b/backend/src/main/java/shook/shook/auth/oauth/exception/OAuthException.java new file mode 100644 index 000000000..33f2b449b --- /dev/null +++ b/backend/src/main/java/shook/shook/auth/oauth/exception/OAuthException.java @@ -0,0 +1,32 @@ +package shook.shook.auth.oauth.exception; + +public class OAuthException extends RuntimeException { + + public static class InvalidEmailException extends OAuthException { + + public InvalidEmailException() { + super(); + } + } + + public static class InvalidAccessTokenException extends OAuthException { + + public InvalidAccessTokenException() { + super(); + } + } + + public static class InvalidAuthorizationCodeException extends OAuthException { + + public InvalidAuthorizationCodeException() { + super(); + } + } + + public static class GoogleServerException extends OAuthException { + + public GoogleServerException() { + super(); + } + } +} diff --git a/backend/src/main/java/shook/shook/auth/oauth/ui/OauthController.java b/backend/src/main/java/shook/shook/auth/oauth/ui/OauthController.java new file mode 100644 index 000000000..0e2e23f09 --- /dev/null +++ b/backend/src/main/java/shook/shook/auth/oauth/ui/OauthController.java @@ -0,0 +1,23 @@ +package shook.shook.auth.oauth.ui; + +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import shook.shook.auth.oauth.application.OAuthService; +import shook.shook.auth.oauth.application.dto.LoginResponse; + +@RequiredArgsConstructor +@RestController +public class OauthController { + + private final OAuthService oAuthService; + + @GetMapping("/login/google") + public ResponseEntity googleLogin( + @RequestParam("code") final String accessCode) { + final LoginResponse response = oAuthService.login(accessCode); + return ResponseEntity.ok(response); + } +} diff --git a/backend/src/main/java/shook/shook/member/application/MemberService.java b/backend/src/main/java/shook/shook/member/application/MemberService.java new file mode 100644 index 000000000..d2f1f9fa5 --- /dev/null +++ b/backend/src/main/java/shook/shook/member/application/MemberService.java @@ -0,0 +1,32 @@ +package shook.shook.member.application; + +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import shook.shook.member.domain.Email; +import shook.shook.member.domain.Member; +import shook.shook.member.domain.repository.MemberRepository; +import shook.shook.member.exception.MemberException; + +@RequiredArgsConstructor +@Transactional(readOnly = true) +@Service +public class MemberService { + + private final MemberRepository memberRepository; + + @Transactional + public Member register(final String email) { + findByEmail(new Email(email)) + .ifPresent(member -> { + throw new MemberException.ExistMemberException(); + }); + final Member newMember = new Member(email, email); + return memberRepository.save(newMember); + } + + public Optional findByEmail(final Email email) { + return memberRepository.findByEmail(email); + } +} diff --git a/backend/src/main/java/shook/shook/member/application/dto/MemberRegisterRequest.java b/backend/src/main/java/shook/shook/member/application/dto/MemberRegisterRequest.java new file mode 100644 index 000000000..d506d0077 --- /dev/null +++ b/backend/src/main/java/shook/shook/member/application/dto/MemberRegisterRequest.java @@ -0,0 +1,25 @@ +package shook.shook.member.application.dto; + +import jakarta.validation.constraints.NotBlank; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import shook.shook.member.domain.Member; + +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Getter +public class MemberRegisterRequest { + + @NotBlank + private String email; + + @NotBlank + private String nickname; + + public Member toMember() { + return new Member(email, nickname); + } + +} diff --git a/backend/src/main/java/shook/shook/member/domain/Email.java b/backend/src/main/java/shook/shook/member/domain/Email.java new file mode 100644 index 000000000..e3f014dcb --- /dev/null +++ b/backend/src/main/java/shook/shook/member/domain/Email.java @@ -0,0 +1,44 @@ +package shook.shook.member.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import lombok.AccessLevel; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; +import shook.shook.member.exception.MemberException; +import shook.shook.util.StringChecker; + +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Getter +@EqualsAndHashCode +@Embeddable +public class Email { + + private static final int EMAIL_MAXIMUM_LENGTH = 100; + private static final Pattern EMAIL_FORM = Pattern.compile( + "^[0-9a-zA-Z]([-_.]?[0-9a-zA-Z])*@[0-9a-zA-Z]([-_.]?[0-9a-zA-Z])*.[a-zA-Z]{2,3}$"); + + @Column(name = "email", length = 100, nullable = false) + private String value; + + public Email(final String value) { + validateEmail(value); + this.value = value; + } + + private void validateEmail(final String value) { + if (StringChecker.isNullOrBlank(value)) { + throw new MemberException.NullOrEmptyEmailException(); + } + if (value.length() > EMAIL_MAXIMUM_LENGTH) { + throw new MemberException.TooLongEmailException(); + } + final Matcher matcher = EMAIL_FORM.matcher(value); + if (!matcher.matches()) { + throw new MemberException.InValidEmailFormException(); + } + } +} diff --git a/backend/src/main/java/shook/shook/member/domain/Member.java b/backend/src/main/java/shook/shook/member/domain/Member.java new file mode 100644 index 000000000..4a5ae0d6f --- /dev/null +++ b/backend/src/main/java/shook/shook/member/domain/Member.java @@ -0,0 +1,42 @@ +package shook.shook.member.domain; + +import jakarta.persistence.Embedded; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Getter +@Table(name = "member") +@Entity +public class Member { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Embedded + private Email email; + + @Embedded + private Nickname nickname; + + public Member(final String email, final String nickname) { + this.id = null; + this.email = new Email(email); + this.nickname = new Nickname(nickname); + } + + public String getEmail() { + return email.getValue(); + } + + public String getNickname() { + return nickname.getValue(); + } +} diff --git a/backend/src/main/java/shook/shook/member/domain/Nickname.java b/backend/src/main/java/shook/shook/member/domain/Nickname.java new file mode 100644 index 000000000..efb005c14 --- /dev/null +++ b/backend/src/main/java/shook/shook/member/domain/Nickname.java @@ -0,0 +1,36 @@ +package shook.shook.member.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import lombok.AccessLevel; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; +import shook.shook.member.exception.MemberException; +import shook.shook.util.StringChecker; + +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Getter +@EqualsAndHashCode +@Embeddable +public class Nickname { + + private static final int NICKNAME_MAXIMUM_LENGTH = 100; + + @Column(name = "nickname", length = NICKNAME_MAXIMUM_LENGTH, nullable = false) + private String value; + + public Nickname(final String value) { + validateNickname(value); + this.value = value; + } + + private void validateNickname(final String value) { + if (StringChecker.isNullOrBlank(value)) { + throw new MemberException.NullOrEmptyNicknameException(); + } + if (value.length() > NICKNAME_MAXIMUM_LENGTH) { + throw new MemberException.TooLongNicknameException(); + } + } +} diff --git a/backend/src/main/java/shook/shook/member/domain/repository/MemberRepository.java b/backend/src/main/java/shook/shook/member/domain/repository/MemberRepository.java new file mode 100644 index 000000000..718e22af3 --- /dev/null +++ b/backend/src/main/java/shook/shook/member/domain/repository/MemberRepository.java @@ -0,0 +1,13 @@ +package shook.shook.member.domain.repository; + +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; +import shook.shook.member.domain.Email; +import shook.shook.member.domain.Member; + +@Repository +public interface MemberRepository extends JpaRepository { + + Optional findByEmail(final Email email); +} diff --git a/backend/src/main/java/shook/shook/member/exception/MemberException.java b/backend/src/main/java/shook/shook/member/exception/MemberException.java new file mode 100644 index 000000000..35e26c8af --- /dev/null +++ b/backend/src/main/java/shook/shook/member/exception/MemberException.java @@ -0,0 +1,46 @@ +package shook.shook.member.exception; + +public class MemberException extends RuntimeException { + + public static class NullOrEmptyEmailException extends MemberException { + + public NullOrEmptyEmailException() { + super(); + } + } + + public static class TooLongEmailException extends MemberException { + + public TooLongEmailException() { + super(); + } + } + + public static class InValidEmailFormException extends MemberException { + + public InValidEmailFormException() { + super(); + } + } + + public static class NullOrEmptyNicknameException extends MemberException { + + public NullOrEmptyNicknameException() { + super(); + } + } + + public static class TooLongNicknameException extends MemberException { + + public TooLongNicknameException() { + super(); + } + } + + public static class ExistMemberException extends MemberException { + + public ExistMemberException() { + super(); + } + } +} diff --git a/backend/src/main/resources/schema.sql b/backend/src/main/resources/schema.sql index be619a2d2..b3cc35c17 100644 --- a/backend/src/main/resources/schema.sql +++ b/backend/src/main/resources/schema.sql @@ -25,7 +25,13 @@ create table if not exists vote created_at timestamp(6) not null, primary key (id) ); - +create table if not exists member +( + id bigint auto_increment, + email varchar(100) not null, + nickname varchar(100) not null, + primary key (id) +); create table if not exists part_comment ( id bigint auto_increment, @@ -33,4 +39,4 @@ create table if not exists part_comment content varchar(200) not null, created_at timestamp(6) not null, primary key (id) -) +); diff --git a/backend/src/test/java/shook/shook/auth/jwt/application/TokenProviderTest.java b/backend/src/test/java/shook/shook/auth/jwt/application/TokenProviderTest.java new file mode 100644 index 000000000..a7e80ced9 --- /dev/null +++ b/backend/src/test/java/shook/shook/auth/jwt/application/TokenProviderTest.java @@ -0,0 +1,78 @@ +package shook.shook.auth.jwt.application; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import io.jsonwebtoken.Claims; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import shook.shook.auth.jwt.exception.TokenException; + +class TokenProviderTest { + + private static final long ACCESS_TOKEN_VALID_TIME = 12000L; + private static final long REFRESH_TOKEN_VALID_TIME = 6048000L; + private static final String SECRET_CODE = "2345asdfasdfsadfsdf243dfdsfsfs"; + private TokenProvider tokenProvider; + + @BeforeEach + public void setUp() { + tokenProvider = new TokenProvider(ACCESS_TOKEN_VALID_TIME, + REFRESH_TOKEN_VALID_TIME, + SECRET_CODE); + } + + @DisplayName("올바른 access token을 생성한다.") + @Test + void createAccessToken() { + //given + //when + final String accessToken = tokenProvider.createAccessToken(1L); + final Claims result = tokenProvider.parseClaims(accessToken); + + //then + assertThat(result.get("memberId")).isEqualTo(1); + assertThat(result.getExpiration().getTime() - result.getIssuedAt().getTime()) + .isEqualTo(ACCESS_TOKEN_VALID_TIME); + } + + @DisplayName("올바른 refresh token을 생성한다.") + @Test + void createRefreshToken() { + //given + // when + final String refreshToken = tokenProvider.createRefreshToken(1L); + final Claims result = tokenProvider.parseClaims(refreshToken); + + // then + assertThat(result.get("memberId")).isEqualTo(1); + assertThat(result.getExpiration().getTime() - result.getIssuedAt().getTime()) + .isEqualTo(REFRESH_TOKEN_VALID_TIME); + } + + @DisplayName("잘못 만들어진 token을 parsing하면 에러를 발생한다.") + @Test + void parsing_fail_malformed_token() { + // given + final String inValidToken = "asdfsev.asefsbd.23dfvs"; + + //when + //then + assertThatThrownBy(() -> tokenProvider.parseClaims(inValidToken)) + .isInstanceOf(TokenException.NotIssuedTokenException.class); + } + + @DisplayName("기한이 만료된 token을 parsing하면 에러를 발생한다.") + @Test + void parsing_fail_expired_token() { + // given + tokenProvider = new TokenProvider(0, 0, SECRET_CODE); + final String expiredToken = tokenProvider.createAccessToken(1L); + + //when + //then + assertThatThrownBy(() -> tokenProvider.parseClaims(expiredToken)) + .isInstanceOf(TokenException.ExpiredTokenException.class); + } +} diff --git a/backend/src/test/java/shook/shook/auth/oauth/application/GoogleInfoProviderTest.java b/backend/src/test/java/shook/shook/auth/oauth/application/GoogleInfoProviderTest.java new file mode 100644 index 000000000..3f4fd2256 --- /dev/null +++ b/backend/src/test/java/shook/shook/auth/oauth/application/GoogleInfoProviderTest.java @@ -0,0 +1,110 @@ +package shook.shook.auth.oauth.application; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo; +import static org.springframework.test.web.client.response.MockRestResponseCreators.withBadRequest; +import static org.springframework.test.web.client.response.MockRestResponseCreators.withServerError; +import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.test.autoconfigure.web.client.AutoConfigureWebClient; +import org.springframework.boot.test.autoconfigure.web.client.RestClientTest; +import org.springframework.http.MediaType; +import org.springframework.test.web.client.MockRestServiceServer; +import shook.shook.auth.oauth.exception.OAuthException; + +@AutoConfigureWebClient(registerRestTemplate = true) +@RestClientTest(value = {GoogleInfoProvider.class}) +class GoogleInfoProviderTest { + + @Value("${oauth2.google.access-token-url}") + private String ACCESS_TOKEN_URL; + + @Value("${oauth2.google.member-info-url}") + private String MEMBER_INFO_URL; + + @Autowired + private GoogleInfoProvider googleInfoProvider; + + @Autowired + private MockRestServiceServer mockServer; + + @DisplayName("올바르지 않은 authorizedCode를 통해 요청을 보내면 예외를 던진다.") + @Test + void fail_request_accessToken() { + //given + mockServer + .expect(requestTo(ACCESS_TOKEN_URL)) + .andRespond(withBadRequest()); + + //when + //then + assertThatThrownBy(() -> googleInfoProvider.getAccessToken("code")) + .isInstanceOf(OAuthException.InvalidAuthorizationCodeException.class); + } + + @DisplayName("올바르지 않은 accessToken으로 요청을 보내면 예외를 던진다.") + @Test + void fail_request_memberInfo_InvalidAccessToken() { + //given + mockServer + .expect(requestTo(MEMBER_INFO_URL)) + .andRespond(withBadRequest()); + + //when + //then + assertThatThrownBy(() -> googleInfoProvider.getMemberInfo("code")) + .isInstanceOf(OAuthException.InvalidAccessTokenException.class); + } + + @DisplayName("이메일이 유효하지 않으면 예외를 던진다.") + @Test + void fail_request_InvalidEmail() { + //given + final String response = """ + { + "email": "shook@wooteco.com", + "verified_email": "false" + } + """; + mockServer + .expect(requestTo(MEMBER_INFO_URL)) + .andRespond(withSuccess(response, MediaType.APPLICATION_JSON)); + + //when + //then + assertThatThrownBy(() -> googleInfoProvider.getMemberInfo("code")) + .isInstanceOf(OAuthException.InvalidEmailException.class); + } + + @DisplayName("accessToken을 요청할 때 구글 서버에러가 발생하면 예외를 던진다.") + @Test + void fail_access_token_request_google_server_error() { + //given + mockServer + .expect(requestTo(ACCESS_TOKEN_URL)) + .andRespond(withServerError()); + + //when + //then + assertThatThrownBy(() -> googleInfoProvider.getAccessToken("code")) + .isInstanceOf(OAuthException.GoogleServerException.class); + } + + @DisplayName("memberInfo를 요청할 때 구글 서버에러가 발생하면 예외를 던진다.") + @Test + void fail_memberInfo_request_google_server_error() { + //given + mockServer + .expect(requestTo(MEMBER_INFO_URL)) + .andRespond(withServerError()); + + //when + //then + assertThatThrownBy(() -> googleInfoProvider.getMemberInfo("code")) + .isInstanceOf(OAuthException.GoogleServerException.class); + } +} diff --git a/backend/src/test/java/shook/shook/auth/oauth/application/OAuthServiceTest.java b/backend/src/test/java/shook/shook/auth/oauth/application/OAuthServiceTest.java new file mode 100644 index 000000000..fb8a5bf03 --- /dev/null +++ b/backend/src/test/java/shook/shook/auth/oauth/application/OAuthServiceTest.java @@ -0,0 +1,52 @@ +package shook.shook.auth.oauth.application; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import shook.shook.auth.oauth.application.dto.GoogleAccessTokenResponse; +import shook.shook.auth.oauth.application.dto.GoogleMemberInfoResponse; +import shook.shook.auth.oauth.application.dto.LoginResponse; +import shook.shook.member.application.MemberService; + +@SpringBootTest +class OAuthServiceTest { + + @MockBean + private GoogleInfoProvider googleInfoProvider; + + @Autowired + private MemberService memberService; + + @Autowired + private OAuthService oAuthService; + + @DisplayName("회원인 경우 email, accessToken과 refreshToken을 반환한다.") + @Test + void success_login() { + //given + memberService.register("shook@wooteco.com"); + + final GoogleAccessTokenResponse accessTokenResponse = + new GoogleAccessTokenResponse("accessToken"); + when(googleInfoProvider.getAccessToken(any(String.class))) + .thenReturn(accessTokenResponse); + + final GoogleMemberInfoResponse memberInfoResponse = + new GoogleMemberInfoResponse("shook@wooteco.com", true); + when(googleInfoProvider.getMemberInfo(any(String.class))) + .thenReturn(memberInfoResponse); + + //when + final LoginResponse result = oAuthService.login("accessCode"); + + //then + assertThat(result.getAccessToken()).isNotNull(); + assertThat(result.getRefreshToken()).isNotNull(); + } +} diff --git a/backend/src/test/java/shook/shook/auth/oauth/ui/OauthControllerTest.java b/backend/src/test/java/shook/shook/auth/oauth/ui/OauthControllerTest.java new file mode 100644 index 000000000..538160e43 --- /dev/null +++ b/backend/src/test/java/shook/shook/auth/oauth/ui/OauthControllerTest.java @@ -0,0 +1,41 @@ +package shook.shook.auth.oauth.ui; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.web.servlet.MockMvc; +import shook.shook.auth.oauth.application.OAuthService; +import shook.shook.auth.oauth.application.dto.LoginResponse; + +@WebMvcTest(OauthController.class) +class OauthControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockBean + private OAuthService oAuthService; + + @DisplayName("회원이 로그인을 하면 요청결과 200을 반환한다.") + @Test + void login_success() throws Exception { + //given + final LoginResponse response = new LoginResponse( + "asdfafdv2", + "asdfsg5"); + + when(oAuthService.login(any(String.class))).thenReturn(response); + + //when + //then + mockMvc.perform(get("/login/google").param("code", "accessCode")) + .andExpect(status().isOk()); + } +} diff --git a/backend/src/test/java/shook/shook/member/application/MemberServiceTest.java b/backend/src/test/java/shook/shook/member/application/MemberServiceTest.java new file mode 100644 index 000000000..7e186132e --- /dev/null +++ b/backend/src/test/java/shook/shook/member/application/MemberServiceTest.java @@ -0,0 +1,71 @@ +package shook.shook.member.application; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; + +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import shook.shook.member.domain.Email; +import shook.shook.member.domain.Member; +import shook.shook.member.domain.repository.MemberRepository; +import shook.shook.member.exception.MemberException; +import shook.shook.support.UsingJpaTest; + +class MemberServiceTest extends UsingJpaTest { + + private static Member savedMember; + + @Autowired + private MemberRepository memberRepository; + + private MemberService memberService; + + @BeforeEach + void setUp() { + memberService = new MemberService(memberRepository); + savedMember = memberRepository.save(new Member("woowa@wooteco.com", "shook")); + } + + @DisplayName("회원을 등록한다.") + @Test + void register() { + //given + final String email = "shook@wooteco.com"; + + //when + final Member result = memberService.register(email); + + //then + assertThat(result.getEmail()).isEqualTo(email); + assertThat(result.getNickname()).isEqualTo(email); + } + + @DisplayName("중복된 이메일로 회원을 등록되는 경우 예외를 던진다.") + @Test + void register_fail_alreadyExistMember() { + // given + final String email = "woowa@wooteco.com"; + + // when + // then + assertThatThrownBy(() -> memberService.register(email)) + .isInstanceOf(MemberException.ExistMemberException.class); + } + + @DisplayName("회원을 이메일로 조회한다.") + @Test + void findByEmail() { + //given + //when + final Optional result = memberService.findByEmail( + new Email(savedMember.getEmail())); + + //then + assertThat(result.get().getId()).isEqualTo(savedMember.getId()); + assertThat(result.get().getEmail()).isEqualTo(savedMember.getEmail()); + assertThat(result.get().getNickname()).isEqualTo(savedMember.getNickname()); + } +} diff --git a/backend/src/test/java/shook/shook/member/domain/EmailTest.java b/backend/src/test/java/shook/shook/member/domain/EmailTest.java new file mode 100644 index 000000000..c0276a7a6 --- /dev/null +++ b/backend/src/test/java/shook/shook/member/domain/EmailTest.java @@ -0,0 +1,64 @@ +package shook.shook.member.domain; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullSource; +import org.junit.jupiter.params.provider.ValueSource; +import shook.shook.member.exception.MemberException; + +class EmailTest { + + @DisplayName("올바른 이메일을 생성한다.") + @Test + void create_success() { + //given + //when + //then + Assertions.assertDoesNotThrow(() -> new Email("shook@wooteco.com")); + } + + @DisplayName("이메일이 유효하지 않으면 예외를 던진다.") + @NullSource + @ParameterizedTest(name = "이메일이 \"{0}\" 일 때") + @ValueSource(strings = {"", " "}) + void create_fail_lessThanOne(final String email) { + //given + //when + //then + assertThatThrownBy(() -> new Email(email)) + .isInstanceOf(MemberException.NullOrEmptyEmailException.class); + } + + @DisplayName("이메일의 길이가 100자를 넘을 경우 예외를 던진다.") + @Test + void create_fail_lengthOver100() { + //given + final String email = ".".repeat(101); + + //when + //then + assertThatThrownBy(() -> new Email(email)) + .isInstanceOf(MemberException.TooLongEmailException.class); + } + + @DisplayName("이메일의 형태가 유효하지 않으면 예외를 던진다.") + @ParameterizedTest(name = "이메일이 \"{0}\" 일 때") + @ValueSource(strings = { + "shook", + "shook!wooteco.com", + "shook@wooteco.", + "@wooteco.com", + "shook@", + "shook.com"}) + void create_fail_wrongEmail(final String email) { + //given + //when + //then + assertThatThrownBy(() -> new Email(email)) + .isInstanceOf(MemberException.InValidEmailFormException.class); + } +} diff --git a/backend/src/test/java/shook/shook/member/domain/NickNameTest.java b/backend/src/test/java/shook/shook/member/domain/NickNameTest.java new file mode 100644 index 000000000..169573959 --- /dev/null +++ b/backend/src/test/java/shook/shook/member/domain/NickNameTest.java @@ -0,0 +1,47 @@ +package shook.shook.member.domain; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullSource; +import org.junit.jupiter.params.provider.ValueSource; +import shook.shook.member.exception.MemberException; + +class NicknameTest { + + @DisplayName("올바른 닉네임을 생성한다.") + @Test + void create_success() { + //given + //when + //then + Assertions.assertDoesNotThrow(() -> new Nickname("shook")); + } + + @DisplayName("닉네임이 유효하지 않으면 예외를 던진다.") + @NullSource + @ParameterizedTest(name = "닉네임이 \"{0}\" 일 때") + @ValueSource(strings = {"", " "}) + void create_fail_lessThanOne(final String nickname) { + //given + //when + //then + assertThatThrownBy(() -> new Nickname(nickname)) + .isInstanceOf(MemberException.NullOrEmptyNicknameException.class); + } + + @DisplayName("닉네임의 길이가 100자를 넘을 경우 예외를 던진다.") + @Test + void create_fail_lengthOver100() { + //given + final String nickname = ".".repeat(101); + + //when + //then + assertThatThrownBy(() -> new Nickname(nickname)) + .isInstanceOf(MemberException.TooLongNicknameException.class); + } +} diff --git a/backend/src/test/resources/application.yml b/backend/src/test/resources/application.yml index 03c30d374..1817f70a5 100644 --- a/backend/src/test/resources/application.yml +++ b/backend/src/test/resources/application.yml @@ -1,3 +1,16 @@ spring: profiles: active: test + +oauth2: + google: + access-token-url: http://localhost:8080/token + member-info-url: http://localhost:8080/userinfo + client-id: client + client-secret: secret + redirect-uri: redirect + +jwt: + secret-code: asdkfwofk23ksdfowsrk4sdkf + access-token-valid-time: 12000 + refresh-token-valid-time: 634000