diff --git a/user-service/build.gradle.kts b/user-service/build.gradle.kts index 60e0093..2778016 100644 --- a/user-service/build.gradle.kts +++ b/user-service/build.gradle.kts @@ -17,22 +17,39 @@ repositories { } dependencies { + annotationProcessor("org.springframework.boot:spring-boot-configuration-processor") implementation("org.springframework.boot:spring-boot-starter-actuator") implementation("org.springframework.boot:spring-boot-starter-data-r2dbc") implementation("org.springframework.boot:spring-boot-starter-security") implementation("org.springframework.boot:spring-boot-starter-webflux") implementation("org.flywaydb:flyway-core") implementation("org.flywaydb:flyway-mysql") +// https://mvnrepository.com/artifact/com.mysql/mysql-connector-j + implementation("com.mysql:mysql-connector-j:8.4.0") + implementation("org.springframework:spring-jdbc") implementation("org.springdoc:springdoc-openapi-starter-webflux-ui:2.5.0") - runtimeOnly("org.mariadb:r2dbc-mariadb:1.1.3") - runtimeOnly("org.mariadb.jdbc:mariadb-java-client") + implementation("io.asyncer:r2dbc-mysql:1.1.0") testImplementation("org.springframework.boot:spring-boot-starter-test") testImplementation("io.projectreactor:reactor-test") testImplementation("org.springframework.security:spring-security-test") testRuntimeOnly("org.junit.platform:junit-platform-launcher") implementation("org.projectlombok:lombok:1.18.32") + implementation("com.github.f4b6a3:ulid-creator:5.2.3") annotationProcessor("org.projectlombok:lombok:1.18.32") + + val jjwtVersion = "0.12.5" + implementation("io.jsonwebtoken:jjwt-api:$jjwtVersion") + runtimeOnly("io.jsonwebtoken:jjwt-impl:$jjwtVersion") + runtimeOnly("io.jsonwebtoken:jjwt-jackson:$jjwtVersion") + + implementation("io.projectreactor.tools:blockhound:1.0.9.RELEASE") +} + +tasks.withType().all { + if (JavaVersion.current().isCompatibleWith(JavaVersion.VERSION_13)) { + jvmArgs("-XX:+AllowRedefinitionToAddDeleteMethods") + } } tasks.withType { diff --git a/user-service/src/main/java/kr/mafoo/user/UserServiceApplication.java b/user-service/src/main/java/kr/mafoo/user/UserServiceApplication.java index f4f979d..1bee103 100644 --- a/user-service/src/main/java/kr/mafoo/user/UserServiceApplication.java +++ b/user-service/src/main/java/kr/mafoo/user/UserServiceApplication.java @@ -2,11 +2,15 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.context.properties.ConfigurationPropertiesScan; +import reactor.blockhound.BlockHound; +@ConfigurationPropertiesScan @SpringBootApplication public class UserServiceApplication { public static void main(String[] args) { + // BlockHound.install(); SpringApplication.run(UserServiceApplication.class, args); } diff --git a/user-service/src/main/java/kr/mafoo/user/config/JacksonSerializer.java b/user-service/src/main/java/kr/mafoo/user/config/JacksonSerializer.java new file mode 100644 index 0000000..16ce785 --- /dev/null +++ b/user-service/src/main/java/kr/mafoo/user/config/JacksonSerializer.java @@ -0,0 +1,72 @@ +package kr.mafoo.user.config; + +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.Module; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.ObjectWriter; +import com.fasterxml.jackson.databind.module.SimpleModule; +import io.jsonwebtoken.io.AbstractSerializer; +import io.jsonwebtoken.lang.Assert; + +import java.io.OutputStream; + +public class JacksonSerializer extends AbstractSerializer { + + static final String MODULE_ID = "jjwt-jackson"; + static final Module MODULE; + + static { + SimpleModule module = new SimpleModule(MODULE_ID); + // module.addSerializer(JacksonSupplierSerializer.INSTANCE); + MODULE = module; + } + + static final ObjectMapper DEFAULT_OBJECT_MAPPER = newObjectMapper(); + + /** + * Creates and returns a new ObjectMapper with the {@code jjwt-jackson} module registered and + * {@code JsonParser.Feature.STRICT_DUPLICATE_DETECTION} enabled (set to true) and + * {@code DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES} disabled (set to false). + * + * @return a new ObjectMapper with the {@code jjwt-jackson} module registered and + * {@code JsonParser.Feature.STRICT_DUPLICATE_DETECTION} enabled (set to true) and + * {@code DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES} disabled (set to false). + * + * @since 0.12.4 + */ + // package protected on purpose, do not expose to the public API + static ObjectMapper newObjectMapper() { + return new ObjectMapper() + .registerModule(MODULE) + .configure(JsonParser.Feature.STRICT_DUPLICATE_DETECTION, true) // https://github.com/jwtk/jjwt/issues/877 + .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); // https://github.com/jwtk/jjwt/issues/893 + } + + protected final ObjectMapper objectMapper; + + /** + * Constructor using JJWT's default {@link ObjectMapper} singleton for serialization. + */ + public JacksonSerializer() { + this(DEFAULT_OBJECT_MAPPER); + } + + /** + * Creates a new Jackson Serializer that uses the specified {@link ObjectMapper} for serialization. + * + * @param objectMapper the ObjectMapper to use for serialization. + */ + public JacksonSerializer(ObjectMapper objectMapper) { + Assert.notNull(objectMapper, "ObjectMapper cannot be null."); + this.objectMapper = objectMapper.registerModule(MODULE); + } + + @Override + protected void doSerialize(T t, OutputStream out) throws Exception { + Assert.notNull(out, "OutputStream cannot be null."); + ObjectWriter writer = this.objectMapper.writer().without(JsonGenerator.Feature.AUTO_CLOSE_TARGET); + writer.writeValue(out, t); + } +} diff --git a/user-service/src/main/java/kr/mafoo/user/config/WebFluxConfig.java b/user-service/src/main/java/kr/mafoo/user/config/WebFluxConfig.java index 449f4ca..8450301 100644 --- a/user-service/src/main/java/kr/mafoo/user/config/WebFluxConfig.java +++ b/user-service/src/main/java/kr/mafoo/user/config/WebFluxConfig.java @@ -1,8 +1,10 @@ package kr.mafoo.user.config; +import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.web.reactive.config.EnableWebFlux; import org.springframework.web.reactive.config.WebFluxConfigurer; +import org.springframework.web.reactive.function.client.WebClient; import org.springframework.web.reactive.result.method.annotation.ArgumentResolverConfigurer; @EnableWebFlux @@ -12,4 +14,15 @@ public class WebFluxConfig implements WebFluxConfigurer { public void configureArgumentResolvers(ArgumentResolverConfigurer configurer) { configurer.addCustomResolver(new MemberIdParameterResolver()); } + + @Bean("externalWebClient") + public WebClient externalServiceWebClient() { + return WebClient.builder() + .codecs(clientCodecConfigurer -> { + clientCodecConfigurer + .defaultCodecs() + .maxInMemorySize(16 * 1024 * 1024); // 16MB + }) + .build(); + } } diff --git a/user-service/src/main/java/kr/mafoo/user/config/properties/KakaoOAuthProperties.java b/user-service/src/main/java/kr/mafoo/user/config/properties/KakaoOAuthProperties.java new file mode 100644 index 0000000..fabc6db --- /dev/null +++ b/user-service/src/main/java/kr/mafoo/user/config/properties/KakaoOAuthProperties.java @@ -0,0 +1,13 @@ +package kr.mafoo.user.config.properties; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.ConfigurationPropertiesBinding; + +@ConfigurationProperties(prefix = "app.oauth.kakao") +@ConfigurationPropertiesBinding +public record KakaoOAuthProperties( + String clientId, + String redirectUri, + String clientSecret +) { +} diff --git a/user-service/src/main/java/kr/mafoo/user/controller/AuthController.java b/user-service/src/main/java/kr/mafoo/user/controller/AuthController.java index e5a898f..8e7649e 100644 --- a/user-service/src/main/java/kr/mafoo/user/controller/AuthController.java +++ b/user-service/src/main/java/kr/mafoo/user/controller/AuthController.java @@ -4,18 +4,27 @@ import kr.mafoo.user.controller.dto.request.KakaoLoginRequest; import kr.mafoo.user.controller.dto.request.TokenRefreshRequest; import kr.mafoo.user.controller.dto.response.LoginResponse; +import kr.mafoo.user.service.AuthService; +import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.RestController; import reactor.core.publisher.Mono; +@RequiredArgsConstructor @RestController public class AuthController implements AuthApi { + private final AuthService authService; + @Override public Mono loginWithKakao(KakaoLoginRequest request) { - return Mono.just(new LoginResponse("test_access_token", "test_refresh_token")); + return authService + .loginWithKakao(request.code()) + .map(authToken -> new LoginResponse(authToken.accessToken(), authToken.refreshToken())); } @Override public Mono loginWithRefreshToken(TokenRefreshRequest request) { - return Mono.just(new LoginResponse("test_access_token", request.refreshToken())); + return authService + .loginWithRefreshToken(request.refreshToken()) + .map(authToken -> new LoginResponse(authToken.accessToken(), authToken.refreshToken())); } } diff --git a/user-service/src/main/java/kr/mafoo/user/controller/dto/response/KakaoLoginInfo.java b/user-service/src/main/java/kr/mafoo/user/controller/dto/response/KakaoLoginInfo.java new file mode 100644 index 0000000..d6f6a1f --- /dev/null +++ b/user-service/src/main/java/kr/mafoo/user/controller/dto/response/KakaoLoginInfo.java @@ -0,0 +1,8 @@ +package kr.mafoo.user.controller.dto.response; + +public record KakaoLoginInfo( + String id, + String nickname, + String email +) { +} diff --git a/user-service/src/main/java/kr/mafoo/user/domain/AuthToken.java b/user-service/src/main/java/kr/mafoo/user/domain/AuthToken.java new file mode 100644 index 0000000..bb11c50 --- /dev/null +++ b/user-service/src/main/java/kr/mafoo/user/domain/AuthToken.java @@ -0,0 +1,8 @@ +package kr.mafoo.user.domain; + +public record AuthToken( + String accessToken, + String refreshToken +){ + +} diff --git a/user-service/src/main/java/kr/mafoo/user/domain/MemberEntity.java b/user-service/src/main/java/kr/mafoo/user/domain/MemberEntity.java index 048b10e..1ff8c2f 100644 --- a/user-service/src/main/java/kr/mafoo/user/domain/MemberEntity.java +++ b/user-service/src/main/java/kr/mafoo/user/domain/MemberEntity.java @@ -5,6 +5,8 @@ import lombok.Setter; import org.springframework.data.annotation.CreatedDate; import org.springframework.data.annotation.Id; +import org.springframework.data.annotation.Transient; +import org.springframework.data.domain.Persistable; import org.springframework.data.relational.core.mapping.Column; import org.springframework.data.relational.core.mapping.Table; @@ -14,7 +16,7 @@ @Setter @NoArgsConstructor @Table("member") -public class MemberEntity { +public class MemberEntity implements Persistable { @Id @Column("member_id") private String id; @@ -26,6 +28,9 @@ public class MemberEntity { @Column("created_at") private LocalDateTime createdAt; + @Transient + private boolean isNew = false; + @Override public boolean equals(Object obj) { if (this == obj) return true; @@ -40,4 +45,12 @@ public boolean equals(Object obj) { public int hashCode() { return id.hashCode(); } + + public static MemberEntity newMember(String id, String name) { + MemberEntity member = new MemberEntity(); + member.id = id; + member.name = name; + member.isNew = true; + return member; + } } diff --git a/user-service/src/main/java/kr/mafoo/user/domain/SocialMemberEntity.java b/user-service/src/main/java/kr/mafoo/user/domain/SocialMemberEntity.java new file mode 100644 index 0000000..05054f6 --- /dev/null +++ b/user-service/src/main/java/kr/mafoo/user/domain/SocialMemberEntity.java @@ -0,0 +1,65 @@ +package kr.mafoo.user.domain; + +import kr.mafoo.user.enums.IdentityProvider; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.Id; +import org.springframework.data.annotation.Transient; +import org.springframework.data.domain.Persistable; +import org.springframework.data.relational.core.mapping.Column; +import org.springframework.data.relational.core.mapping.Table; + +import java.time.LocalDateTime; +import java.util.Objects; + +@Getter +@Setter +@NoArgsConstructor +@Table("social_member") +public class SocialMemberEntity implements Persistable { + @Column("identity_provider") + private IdentityProvider identityProvider; + + @Column("identifier") + private String id; + + @CreatedDate + @Column("created_at") + private LocalDateTime createdAt; + + @Column("member_id") + private String memberId; + + @Transient + private boolean isNew = false; + + @Override + public boolean equals(Object obj) { + if (this == obj) return true; + if (obj == null || getClass() != obj.getClass()) return false; + + SocialMemberEntity that = (SocialMemberEntity) obj; + + return id.equals(that.id) && identityProvider.equals(that.identityProvider); + } + + public SocialMemberEntityKey getId() { + return new SocialMemberEntityKey(identityProvider, id); + } + + @Override + public int hashCode() { + return Objects.hash(id, identityProvider); + } + + public static SocialMemberEntity newSocialMember(IdentityProvider identityProvider, String id, String memberId) { + SocialMemberEntity socialMember = new SocialMemberEntity(); + socialMember.identityProvider = identityProvider; + socialMember.id = id; + socialMember.memberId = memberId; + socialMember.isNew = true; + return socialMember; + } +} diff --git a/user-service/src/main/java/kr/mafoo/user/domain/SocialMemberEntityKey.java b/user-service/src/main/java/kr/mafoo/user/domain/SocialMemberEntityKey.java new file mode 100644 index 0000000..4517edb --- /dev/null +++ b/user-service/src/main/java/kr/mafoo/user/domain/SocialMemberEntityKey.java @@ -0,0 +1,14 @@ +package kr.mafoo.user.domain; + +import kr.mafoo.user.enums.IdentityProvider; +import lombok.Data; +import lombok.RequiredArgsConstructor; + +import java.io.Serializable; + +@RequiredArgsConstructor +@Data +public class SocialMemberEntityKey implements Serializable { + private final IdentityProvider identityProvider; + private final String id; +} diff --git a/user-service/src/main/java/kr/mafoo/user/enums/IdentityProvider.java b/user-service/src/main/java/kr/mafoo/user/enums/IdentityProvider.java new file mode 100644 index 0000000..1e05e16 --- /dev/null +++ b/user-service/src/main/java/kr/mafoo/user/enums/IdentityProvider.java @@ -0,0 +1,5 @@ +package kr.mafoo.user.enums; + +public enum IdentityProvider { + KAKAO +} diff --git a/user-service/src/main/java/kr/mafoo/user/exception/ErrorCode.java b/user-service/src/main/java/kr/mafoo/user/exception/ErrorCode.java index 59f0902..5f05daa 100644 --- a/user-service/src/main/java/kr/mafoo/user/exception/ErrorCode.java +++ b/user-service/src/main/java/kr/mafoo/user/exception/ErrorCode.java @@ -6,7 +6,13 @@ @Getter @RequiredArgsConstructor public enum ErrorCode { - MEMBER_NOT_FOUND("ME0001", "사용자를 찾을 수 없습니다"); + MEMBER_NOT_FOUND("ME0001", "사용자를 찾을 수 없습니다"), + KAKAO_LOGIN_FAILED("EX0001", "카카오 로그인에 실패했습니다"), + + TOKEN_TYPE_MISMATCH("AU0001", "토큰 타입이 일치하지 않습니다. (아마 AccessToken?)"), + TOKEN_EXPIRED("AU0002", "토큰이 만료되었습니다"), + TOKEN_INVALID("AU0003", "토큰이 유효하지 않습니다"), + ; private final String code; private final String message; } diff --git a/user-service/src/main/java/kr/mafoo/user/exception/InvalidTokenException.java b/user-service/src/main/java/kr/mafoo/user/exception/InvalidTokenException.java new file mode 100644 index 0000000..2124ad0 --- /dev/null +++ b/user-service/src/main/java/kr/mafoo/user/exception/InvalidTokenException.java @@ -0,0 +1,7 @@ +package kr.mafoo.user.exception; + +public class InvalidTokenException extends DomainException { + public InvalidTokenException() { + super(ErrorCode.TOKEN_INVALID); + } +} diff --git a/user-service/src/main/java/kr/mafoo/user/exception/KakaoLoginFailedException.java b/user-service/src/main/java/kr/mafoo/user/exception/KakaoLoginFailedException.java new file mode 100644 index 0000000..74489c3 --- /dev/null +++ b/user-service/src/main/java/kr/mafoo/user/exception/KakaoLoginFailedException.java @@ -0,0 +1,7 @@ +package kr.mafoo.user.exception; + +public class KakaoLoginFailedException extends DomainException { + public KakaoLoginFailedException() { + super(ErrorCode.KAKAO_LOGIN_FAILED); + } +} diff --git a/user-service/src/main/java/kr/mafoo/user/exception/TokenExpiredException.java b/user-service/src/main/java/kr/mafoo/user/exception/TokenExpiredException.java new file mode 100644 index 0000000..5ab3d90 --- /dev/null +++ b/user-service/src/main/java/kr/mafoo/user/exception/TokenExpiredException.java @@ -0,0 +1,7 @@ +package kr.mafoo.user.exception; + +public class TokenExpiredException extends DomainException { + public TokenExpiredException() { + super(ErrorCode.TOKEN_EXPIRED); + } +} diff --git a/user-service/src/main/java/kr/mafoo/user/exception/TokenTypeMismatchException.java b/user-service/src/main/java/kr/mafoo/user/exception/TokenTypeMismatchException.java new file mode 100644 index 0000000..98831b5 --- /dev/null +++ b/user-service/src/main/java/kr/mafoo/user/exception/TokenTypeMismatchException.java @@ -0,0 +1,7 @@ +package kr.mafoo.user.exception; + +public class TokenTypeMismatchException extends DomainException { + public TokenTypeMismatchException() { + super(ErrorCode.TOKEN_TYPE_MISMATCH); + } +} diff --git a/user-service/src/main/java/kr/mafoo/user/repository/SocialMemberRepository.java b/user-service/src/main/java/kr/mafoo/user/repository/SocialMemberRepository.java new file mode 100644 index 0000000..1ba57ab --- /dev/null +++ b/user-service/src/main/java/kr/mafoo/user/repository/SocialMemberRepository.java @@ -0,0 +1,11 @@ +package kr.mafoo.user.repository; + +import kr.mafoo.user.domain.SocialMemberEntity; +import kr.mafoo.user.domain.SocialMemberEntityKey; +import kr.mafoo.user.enums.IdentityProvider; +import org.springframework.data.r2dbc.repository.R2dbcRepository; +import reactor.core.publisher.Mono; + +public interface SocialMemberRepository extends R2dbcRepository { + Mono findByIdentityProviderAndId(IdentityProvider identityProvider, String id); +} diff --git a/user-service/src/main/java/kr/mafoo/user/service/AuthService.java b/user-service/src/main/java/kr/mafoo/user/service/AuthService.java new file mode 100644 index 0000000..534c139 --- /dev/null +++ b/user-service/src/main/java/kr/mafoo/user/service/AuthService.java @@ -0,0 +1,101 @@ +package kr.mafoo.user.service; + +import kr.mafoo.user.config.properties.KakaoOAuthProperties; +import kr.mafoo.user.controller.dto.response.KakaoLoginInfo; +import kr.mafoo.user.domain.AuthToken; +import kr.mafoo.user.domain.SocialMemberEntity; +import kr.mafoo.user.enums.IdentityProvider; +import kr.mafoo.user.exception.KakaoLoginFailedException; +import kr.mafoo.user.repository.SocialMemberRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.core.publisher.Mono; + +import java.util.LinkedHashMap; + +@RequiredArgsConstructor +@Service +public class AuthService { + private final WebClient externalWebClient; + private final SocialMemberRepository socialMemberRepository; + private final MemberService memberService; + private final JWTTokenService jwtTokenService; + private final KakaoOAuthProperties kakaoOAuthProperties; + + + public Mono loginWithKakao(String code) { + return getKakaoTokenWithCode(code) + .flatMap(this::getUserInfoWithKakaoToken) + .flatMap(kakaoLoginInfo -> getOrCreateMember( + IdentityProvider.KAKAO, + kakaoLoginInfo.id(), + kakaoLoginInfo.nickname() + )); + } + + public Mono loginWithRefreshToken(String refreshToken){ + return Mono + .fromCallable(() -> jwtTokenService.extractUserIdFromRefreshToken(refreshToken)) + .map(memberId -> { + String accessToken = jwtTokenService.generateAccessToken(memberId); + String newRefreshToken = jwtTokenService.generateRefreshToken(memberId); + return new AuthToken(accessToken, newRefreshToken); + }); + } + + private Mono getOrCreateMember(IdentityProvider provider, String id, String username) { + return socialMemberRepository + .findByIdentityProviderAndId(provider, id) + .switchIfEmpty(createNewSocialMember(provider, id, username)) + .map(socialMember -> { + String accessToken = jwtTokenService.generateAccessToken(socialMember.getMemberId()); + String refreshToken = jwtTokenService.generateRefreshToken(socialMember.getMemberId()); + return new AuthToken(accessToken, refreshToken); + }); + } + + private Mono createNewSocialMember(IdentityProvider provider, String id, String username) { + return memberService + .createNewMember(username) + .flatMap(newMember -> socialMemberRepository.save( + SocialMemberEntity.newSocialMember(provider, id, newMember.getId()) + )); + } + + /** + * 카카오 로그인 관련 로직 + */ + private Mono getKakaoTokenWithCode(String code) { + return externalWebClient + .post() + .uri("https://kauth.kakao.com/oauth/token" + "?grant_type=authorization_code&client_id=" + + kakaoOAuthProperties.clientId() + + "&redirect_uri=" + + kakaoOAuthProperties.redirectUri() + +"&code=" + + code + + "&client_secret=" + + kakaoOAuthProperties.clientSecret() + ) + .retrieve() + .onStatus(status -> !status.is2xxSuccessful(), (res) -> Mono.error(new KakaoLoginFailedException())) + .bodyToMono(LinkedHashMap.class) + .map(map -> (String) map.get("access_token")); + } + + private Mono getUserInfoWithKakaoToken(String kakaoToken){ + return externalWebClient + .get() + .uri("https://kapi.kakao.com/v2/user/me") + .headers(headers -> headers.setBearerAuth(kakaoToken)) + .retrieve() + .onStatus(status -> !status.is2xxSuccessful(), (res) -> Mono.error(new KakaoLoginFailedException())) + .bodyToMono(LinkedHashMap.class) + .map(map -> new KakaoLoginInfo( + String.valueOf(map.get("id")), + (String) ((LinkedHashMap)map.get("properties")).get("nickname"), + (String) map.get("kakao_account.email") + )); + } +} diff --git a/user-service/src/main/java/kr/mafoo/user/service/JWTTokenService.java b/user-service/src/main/java/kr/mafoo/user/service/JWTTokenService.java new file mode 100644 index 0000000..e2dd56a --- /dev/null +++ b/user-service/src/main/java/kr/mafoo/user/service/JWTTokenService.java @@ -0,0 +1,102 @@ +package kr.mafoo.user.service; + +import io.jsonwebtoken.*; +import jakarta.annotation.PostConstruct; +import kr.mafoo.user.config.JacksonSerializer; +import kr.mafoo.user.exception.InvalidTokenException; +import kr.mafoo.user.exception.TokenExpiredException; +import kr.mafoo.user.exception.TokenTypeMismatchException; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import javax.crypto.SecretKey; +import javax.crypto.spec.SecretKeySpec; +import java.util.Date; + +@RequiredArgsConstructor +@Service +public class JWTTokenService { + @Value("${app.jwt.verify-key}") + private String verifyKey; + @Value("${app.jwt.expiration.access-token}") + private Long accessTokenExpiration; + @Value("${app.jwt.expiration.refresh-token}") + private Long refreshTokenExpiration; + + + + private SecretKey signKey = null; + private JwtParser parser = null; + + private JacksonSerializer jacksonSerializer = new JacksonSerializer(); + + @PostConstruct + public void initSignKey() { + signKey = new SecretKeySpec(verifyKey.getBytes(), "AES"); + parser = Jwts + .parser() + .decryptWith(signKey) + .build(); + } + + private final static String TOKEN_TYPE_HEADER_KEY = "tkn_typ"; + private final static String ACCESS_TOKEN_TYPE_VALUE = "access"; + private final static String REFRESH_TOKEN_TYPE_VALUE = "refresh"; + private final static String USER_ID_CLAIM_KEY = "user_id"; + + public String generateAccessToken(String memberId) { + return Jwts + .builder() + .header() + .add(TOKEN_TYPE_HEADER_KEY, ACCESS_TOKEN_TYPE_VALUE) + .and() + .claims().add(USER_ID_CLAIM_KEY, memberId) + .and() + .expiration(generateAccessTokenExpirationDate()) + .encryptWith(signKey, Jwts.ENC.A128CBC_HS256) + .json(jacksonSerializer) + .compact(); + } + + public String generateRefreshToken(String memberId) { + return Jwts + .builder() + .header() + .add(TOKEN_TYPE_HEADER_KEY, REFRESH_TOKEN_TYPE_VALUE) + .and() + .claims().add(USER_ID_CLAIM_KEY, memberId) + .and() + .expiration(generateRefreshTokenExpirationDate()) + .encryptWith(signKey, Jwts.ENC.A128CBC_HS256) + .json(jacksonSerializer) + .compact(); + } + + public String extractUserIdFromRefreshToken(String refreshToken){ + Jwe claims; + try { + claims = parser + .parseEncryptedClaims(refreshToken); + } catch(ExpiredJwtException e){ + throw new TokenExpiredException(); + } catch (Exception e){ + throw new InvalidTokenException(); + } + + String type = (String) claims.getHeader().get(TOKEN_TYPE_HEADER_KEY); + if (!type.equals(REFRESH_TOKEN_TYPE_VALUE)){ + throw new TokenTypeMismatchException(); + } + + return claims.getPayload().get(USER_ID_CLAIM_KEY, String.class); + } + + private Date generateAccessTokenExpirationDate() { + return new Date(System.currentTimeMillis() + 1000 * accessTokenExpiration); + } + + public Date generateRefreshTokenExpirationDate() { + return new Date(System.currentTimeMillis() + 1000 * refreshTokenExpiration); + } +} diff --git a/user-service/src/main/java/kr/mafoo/user/service/MemberService.java b/user-service/src/main/java/kr/mafoo/user/service/MemberService.java index 94deb7b..9df77f1 100644 --- a/user-service/src/main/java/kr/mafoo/user/service/MemberService.java +++ b/user-service/src/main/java/kr/mafoo/user/service/MemberService.java @@ -3,6 +3,7 @@ import kr.mafoo.user.domain.MemberEntity; import kr.mafoo.user.exception.MemberNotFoundException; import kr.mafoo.user.repository.MemberRepository; +import kr.mafoo.user.util.IdGenerator; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import reactor.core.publisher.Mono; @@ -22,4 +23,8 @@ public Mono getMemberByMemberId(String memberId) { .switchIfEmpty(Mono.error(new MemberNotFoundException())); } + public Mono createNewMember(String username) { + MemberEntity memberEntity = MemberEntity.newMember(IdGenerator.generate(), username); + return memberRepository.save(memberEntity); + } } diff --git a/user-service/src/main/java/kr/mafoo/user/util/IdGenerator.java b/user-service/src/main/java/kr/mafoo/user/util/IdGenerator.java new file mode 100644 index 0000000..18ac643 --- /dev/null +++ b/user-service/src/main/java/kr/mafoo/user/util/IdGenerator.java @@ -0,0 +1,9 @@ +package kr.mafoo.user.util; + +import com.github.f4b6a3.ulid.UlidCreator; + +public class IdGenerator { + public static String generate() { + return UlidCreator.getMonotonicUlid().toString(); + } +} diff --git a/user-service/src/main/resources/application.yaml b/user-service/src/main/resources/application.yaml index 273e526..beaf435 100644 --- a/user-service/src/main/resources/application.yaml +++ b/user-service/src/main/resources/application.yaml @@ -1,6 +1,12 @@ spring: application: name: mafoo-user-service + flyway: + url: ${FLYWAY_URL} + baseline-on-migrate: true + enabled: true + user: ${MYSQL_USERNAME} + password: ${MYSQL_PASSWORD} r2dbc: url: ${MYSQL_URL} username: ${MYSQL_USERNAME} @@ -8,3 +14,15 @@ spring: logging: level: org.springframework.data.r2dbc: DEBUG + +app: + oauth: + kakao: + client-id: ${KAKAO_CLIENT_ID} + redirect-uri: ${KAKAO_REDIRECT_URL} + client-secret: ${KAKAO_CLIENT_SECRET} + jwt: + verify-key: ${JWT_VERIFY_KEY} + expiration: + access-token: 86400 # 1 day + refresh-token: 2592000 # 30 days diff --git a/user-service/src/main/resources/db/migration/V1__init.sql b/user-service/src/main/resources/db/migration/V1__init.sql new file mode 100644 index 0000000..8f1f511 --- /dev/null +++ b/user-service/src/main/resources/db/migration/V1__init.sql @@ -0,0 +1,13 @@ +CREATE TABLE member( + `member_id` CHAR(26) PRIMARY KEY, + `name` VARCHAR(255) NOT NULL, + `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE social_member( + `identity_provider` VARCHAR(64) NOT NULL, + `identifier` VARCHAR(255) NOT NULL, + `member_id` CHAR(26) NOT NULL, + `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`identity_provider`, `identifier`) +);