diff --git a/build.gradle b/build.gradle
index 413cdb5..13ecd79 100644
--- a/build.gradle
+++ b/build.gradle
@@ -70,13 +70,15 @@ dependencies {
// Apache Commons Lang3
implementation 'org.apache.commons:commons-lang3:3.12.0'
+
+ // WebClient
+ implementation 'org.springframework.boot:spring-boot-starter-webflux'
}
tasks.named('test') {
useJUnitPlatform()
}
-
tasks.withType(Checkstyle){
reports {
xml.required = true
diff --git a/checkstyle/config/rules.xml b/checkstyle/config/rules.xml
index 4f64efa..3e9622a 100644
--- a/checkstyle/config/rules.xml
+++ b/checkstyle/config/rules.xml
@@ -59,7 +59,7 @@ The following rules in the Naver coding convention cannot be checked by this con
-
+
@@ -97,7 +97,7 @@ The following rules in the Naver coding convention cannot be checked by this con
-
+
diff --git a/src/main/java/com/rabbitmqprac/application/controller/OauthController.java b/src/main/java/com/rabbitmqprac/application/controller/OauthController.java
new file mode 100644
index 0000000..794a047
--- /dev/null
+++ b/src/main/java/com/rabbitmqprac/application/controller/OauthController.java
@@ -0,0 +1,53 @@
+package com.rabbitmqprac.application.controller;
+
+import com.rabbitmqprac.application.dto.oauth.req.OauthSignInReq;
+import com.rabbitmqprac.application.dto.oauth.req.OauthSignUpReq;
+import com.rabbitmqprac.domain.context.oauth.service.OauthService;
+import com.rabbitmqprac.domain.persistence.oauth.constant.OauthProvider;
+import com.rabbitmqprac.global.util.CookieUtil;
+import com.rabbitmqprac.infra.security.jwt.Jwts;
+import lombok.RequiredArgsConstructor;
+import org.apache.commons.lang3.tuple.Pair;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.ResponseCookie;
+import org.springframework.http.ResponseEntity;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.validation.annotation.Validated;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.time.Duration;
+import java.util.Map;
+
+@RequiredArgsConstructor
+@RestController
+public class OauthController {
+ private final OauthService oauthService;
+
+ @PostMapping("/oauth/sign-in")
+ @PreAuthorize("isAnonymous()")
+ public ResponseEntity> signIn(@RequestParam OauthProvider oauthProvider, @RequestBody @Validated OauthSignInReq req) {
+ return createAuthenticatedResponse(oauthService.signIn(oauthProvider, req));
+ }
+
+ @PostMapping("/oauth/sign-up")
+ @PreAuthorize("isAnonymous()")
+ public ResponseEntity> signUp(@RequestParam OauthProvider oauthProvider, @RequestBody @Validated OauthSignUpReq req) {
+ return createAuthenticatedResponse(oauthService.signUp(oauthProvider, req));
+ }
+
+ private ResponseEntity> createAuthenticatedResponse(Pair userInfo) {
+ ResponseCookie cookie = CookieUtil.createCookie(
+ "refreshToken", userInfo.getValue().refreshToken(), Duration.ofDays(7).toSeconds()
+ );
+
+ return ResponseEntity.ok()
+ .header(HttpHeaders.SET_COOKIE, cookie.toString())
+ .header(HttpHeaders.AUTHORIZATION, userInfo.getValue().accessToken())
+ .body(
+ Map.of("userId", userInfo.getKey())
+ );
+ }
+}
diff --git a/src/main/java/com/rabbitmqprac/application/dto/auth/req/AuthSignUpReq.java b/src/main/java/com/rabbitmqprac/application/dto/auth/req/AuthSignUpReq.java
index fca1145..8ae96b3 100644
--- a/src/main/java/com/rabbitmqprac/application/dto/auth/req/AuthSignUpReq.java
+++ b/src/main/java/com/rabbitmqprac/application/dto/auth/req/AuthSignUpReq.java
@@ -1,13 +1,17 @@
package com.rabbitmqprac.application.dto.auth.req;
+import com.rabbitmqprac.global.annotation.Nickname;
import com.rabbitmqprac.global.annotation.Password;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import org.springframework.security.crypto.password.PasswordEncoder;
public record AuthSignUpReq(
+ @NotBlank(message = "닉네임을 입력해주세요")
+ @Nickname
+ String nickname,
@NotBlank(message = "아이디를 입력해주세요")
- @Pattern(regexp = "^[a-z0-9-_.]{5,20}$", message = "영문 소문자, 숫자, 특수기호 (-), (_), (.) 만 사용하여, 5~20자의 아이디를 입력해 주세요")
+ @Pattern(regexp = "^[a-z0-9-_.]{5,20}$", message = "영문 소문자, 숫자만 사용하여, 5~20자의 아이디를 입력해 주세요")
String username,
@NotBlank(message = "비밀번호를 입력해주세요")
@Password(message = "8~16자의 영문 대/소문자, 숫자, 특수문자를 사용해주세요. (적어도 하나의 영문 소문자, 숫자 포함)")
@@ -20,4 +24,3 @@ public String getEncodedPassword(PasswordEncoder bCryptPasswordEncoder) {
return bCryptPasswordEncoder.encode(password);
}
}
-
diff --git a/src/main/java/com/rabbitmqprac/application/dto/oauth/req/OauthSignInReq.java b/src/main/java/com/rabbitmqprac/application/dto/oauth/req/OauthSignInReq.java
new file mode 100644
index 0000000..c53dee6
--- /dev/null
+++ b/src/main/java/com/rabbitmqprac/application/dto/oauth/req/OauthSignInReq.java
@@ -0,0 +1,9 @@
+package com.rabbitmqprac.application.dto.oauth.req;
+
+import jakarta.validation.constraints.NotBlank;
+
+public record OauthSignInReq(
+ @NotBlank(message = "OIDC CODE 필수 입력값입니다.")
+ String code
+) {
+}
diff --git a/src/main/java/com/rabbitmqprac/application/dto/oauth/req/OauthSignUpReq.java b/src/main/java/com/rabbitmqprac/application/dto/oauth/req/OauthSignUpReq.java
new file mode 100644
index 0000000..ce91d43
--- /dev/null
+++ b/src/main/java/com/rabbitmqprac/application/dto/oauth/req/OauthSignUpReq.java
@@ -0,0 +1,14 @@
+package com.rabbitmqprac.application.dto.oauth.req;
+
+import com.rabbitmqprac.global.annotation.Nickname;
+import jakarta.validation.constraints.NotBlank;
+
+public record OauthSignUpReq(
+ @NotBlank(message = "OIDC CODE는 필수 입력값입니다.")
+ String code,
+ @NotBlank(message = "닉네임을 입력해주세요")
+ @Nickname
+ String nickname
+) {
+
+}
diff --git a/src/main/java/com/rabbitmqprac/application/dto/user/req/NicknameUpdateReq.java b/src/main/java/com/rabbitmqprac/application/dto/user/req/NicknameUpdateReq.java
index ee55f6a..d032594 100644
--- a/src/main/java/com/rabbitmqprac/application/dto/user/req/NicknameUpdateReq.java
+++ b/src/main/java/com/rabbitmqprac/application/dto/user/req/NicknameUpdateReq.java
@@ -1,11 +1,11 @@
package com.rabbitmqprac.application.dto.user.req;
+import com.rabbitmqprac.global.annotation.Nickname;
import jakarta.validation.constraints.NotBlank;
-import jakarta.validation.constraints.Pattern;
public record NicknameUpdateReq(
@NotBlank(message = "닉네임을 입력해주세요")
- @Pattern(regexp = "^[가-힣a-zA-Z]{2,8}$", message = "한글과 영문 대, 소문자만 가능해요")
+ @Nickname
String nickname
) {
}
diff --git a/src/main/java/com/rabbitmqprac/config/OauthConfig.java b/src/main/java/com/rabbitmqprac/config/OauthConfig.java
new file mode 100644
index 0000000..8daf416
--- /dev/null
+++ b/src/main/java/com/rabbitmqprac/config/OauthConfig.java
@@ -0,0 +1,16 @@
+package com.rabbitmqprac.config;
+
+import com.rabbitmqprac.infra.oauth.properties.GoogleOidcProperties;
+import com.rabbitmqprac.infra.oauth.properties.KakaoOidcProperties;
+import org.springframework.boot.autoconfigure.web.ServerProperties;
+import org.springframework.boot.context.properties.EnableConfigurationProperties;
+import org.springframework.context.annotation.Configuration;
+
+@Configuration
+@EnableConfigurationProperties({
+ ServerProperties.class,
+ GoogleOidcProperties.class,
+ KakaoOidcProperties.class
+})
+public class OauthConfig {
+}
diff --git a/src/main/java/com/rabbitmqprac/config/WebClientConfig.java b/src/main/java/com/rabbitmqprac/config/WebClientConfig.java
new file mode 100644
index 0000000..ecceaa3
--- /dev/null
+++ b/src/main/java/com/rabbitmqprac/config/WebClientConfig.java
@@ -0,0 +1,72 @@
+package com.rabbitmqprac.config;
+
+import io.netty.channel.ChannelOption;
+import io.netty.handler.timeout.ReadTimeoutHandler;
+import io.netty.handler.timeout.WriteTimeoutHandler;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.http.HttpStatusCode;
+import org.springframework.http.client.reactive.ReactorClientHttpConnector;
+import org.springframework.http.codec.LoggingCodecSupport;
+import org.springframework.web.reactive.function.client.ExchangeFilterFunction;
+import org.springframework.web.reactive.function.client.ExchangeStrategies;
+import org.springframework.web.reactive.function.client.WebClient;
+import reactor.core.publisher.Mono;
+import reactor.netty.http.client.HttpClient;
+
+@Slf4j
+@Configuration
+public class WebClientConfig {
+
+ @Bean
+ public WebClient webClient() {
+ ExchangeStrategies exchangeStrategies = ExchangeStrategies.builder()
+ .codecs(configurer -> configurer.defaultCodecs().maxInMemorySize(1024 * 1024 * 50))
+ .build();
+ exchangeStrategies
+ .messageWriters().stream()
+ .filter(LoggingCodecSupport.class::isInstance)
+ .forEach(writer -> ((LoggingCodecSupport) writer).setEnableLoggingRequestDetails(true));
+
+ return WebClient.builder()
+ .clientConnector(
+ new ReactorClientHttpConnector(
+ HttpClient
+ .create()
+ .tcpConfiguration(client ->
+ client.option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 120_000)
+ .doOnConnected(conn -> conn.addHandlerLast(new ReadTimeoutHandler(180))
+ .addHandlerLast(new WriteTimeoutHandler(180))
+ )
+ )
+ )
+ )
+ .exchangeStrategies(exchangeStrategies)
+ .filter(ExchangeFilterFunction.ofRequestProcessor(
+ clientRequest -> {
+ log.debug("Request: {} {}", clientRequest.method(), clientRequest.url());
+ clientRequest.headers().forEach((name, values) -> values.forEach(value -> log.debug("{} : {}", name, value)));
+ if (clientRequest.body() != null) {
+ log.debug("Request Body: {}", clientRequest.body());
+ }
+ return Mono.just(clientRequest);
+ }
+ ))
+ .filter(ExchangeFilterFunction.ofResponseProcessor(
+ clientResponse -> {
+ clientResponse.headers().asHttpHeaders().forEach((name, values) -> values.forEach(value -> log.debug("{} : {}", name, value)));
+ return Mono.just(clientResponse);
+ }
+ ))
+ .defaultStatusHandler(
+ HttpStatusCode::isError,
+ clientResponse -> clientResponse.bodyToMono(String.class)
+ .flatMap(body -> {
+ log.error("WebClient error: status={}, body={}", clientResponse.statusCode(), body);
+ return Mono.error(new RuntimeException("WebClient error: " + body));
+ })
+ )
+ .build();
+ }
+}
diff --git a/src/main/java/com/rabbitmqprac/domain/context/auth/service/AuthService.java b/src/main/java/com/rabbitmqprac/domain/context/auth/service/AuthService.java
index 5c69a58..241b800 100644
--- a/src/main/java/com/rabbitmqprac/domain/context/auth/service/AuthService.java
+++ b/src/main/java/com/rabbitmqprac/domain/context/auth/service/AuthService.java
@@ -29,7 +29,7 @@ public Pair signUp(AuthSignUpReq req) {
throw new AuthErrorException(AuthErrorCode.PASSWORD_CONFIRM_MISMATCH);
User user = userService.saveUserWithEncryptedPassword(
- UserCreateReq.of(req.username(), req.getEncodedPassword(bCryptPasswordEncoder))
+ UserCreateReq.of(req.nickname(), req.username(), req.getEncodedPassword(bCryptPasswordEncoder))
);
return Pair.of(user.getId(), jwtHelper.createToken(user));
diff --git a/src/main/java/com/rabbitmqprac/domain/context/oauth/exception/OauthErrorCode.java b/src/main/java/com/rabbitmqprac/domain/context/oauth/exception/OauthErrorCode.java
new file mode 100644
index 0000000..124a152
--- /dev/null
+++ b/src/main/java/com/rabbitmqprac/domain/context/oauth/exception/OauthErrorCode.java
@@ -0,0 +1,38 @@
+package com.rabbitmqprac.domain.context.oauth.exception;
+
+import com.rabbitmqprac.global.exception.payload.BaseErrorCode;
+import com.rabbitmqprac.global.exception.payload.CausedBy;
+import com.rabbitmqprac.global.exception.payload.DomainCode;
+import com.rabbitmqprac.global.exception.payload.ReasonCode;
+import com.rabbitmqprac.global.exception.payload.StatusCode;
+import lombok.RequiredArgsConstructor;
+
+@RequiredArgsConstructor
+public enum OauthErrorCode implements BaseErrorCode {
+ /* 400 BAD_REQUEST */
+ MISSING_ISS(StatusCode.BAD_REQUEST, ReasonCode.MISSING_REQUIRED_PARAMETER, "iss 값이 존재하지 않습니다."),
+ MISSING_AUD(StatusCode.BAD_REQUEST, ReasonCode.MISSING_REQUIRED_PARAMETER, "aud 값이 존재하지 않습니다."),
+ MISSING_NONCE(StatusCode.BAD_REQUEST, ReasonCode.MISSING_REQUIRED_PARAMETER, "nonce 값이 존재하지 않습니다."),
+
+ INVALID_ISS(StatusCode.BAD_REQUEST, ReasonCode.MALFORMED_PARAMETER, "iss 값이 유효하지 않습니다."),
+ INVALID_AUD(StatusCode.BAD_REQUEST, ReasonCode.MALFORMED_PARAMETER, "aud 값이 유효하지 않습니다."),
+ INVALID_NONCE(StatusCode.BAD_REQUEST, ReasonCode.MALFORMED_PARAMETER, "nonce 값이 유효하지 않습니다."),
+
+ /* 409 CONFLICT */
+ CONFLICT(StatusCode.CONFLICT, ReasonCode.RESOURCE_ALREADY_EXISTS, "이미 회원가입된 유저입니다.");
+
+ private final StatusCode statusCode;
+ private final ReasonCode reasonCode;
+ private final String message;
+ private final DomainCode domainCode = DomainCode.OAUTH;
+
+ @Override
+ public CausedBy causedBy() {
+ return CausedBy.of(statusCode, reasonCode, domainCode);
+ }
+
+ @Override
+ public String getExplainError() throws NoSuchFieldError {
+ return message;
+ }
+}
diff --git a/src/main/java/com/rabbitmqprac/domain/context/oauth/exception/OauthErrorException.java b/src/main/java/com/rabbitmqprac/domain/context/oauth/exception/OauthErrorException.java
new file mode 100644
index 0000000..1680dfb
--- /dev/null
+++ b/src/main/java/com/rabbitmqprac/domain/context/oauth/exception/OauthErrorException.java
@@ -0,0 +1,14 @@
+package com.rabbitmqprac.domain.context.oauth.exception;
+
+import com.rabbitmqprac.global.exception.GlobalErrorException;
+import lombok.Getter;
+
+@Getter
+public class OauthErrorException extends GlobalErrorException {
+ private final OauthErrorCode errorCode;
+
+ public OauthErrorException(OauthErrorCode errorCode) {
+ super(errorCode);
+ this.errorCode = errorCode;
+ }
+}
diff --git a/src/main/java/com/rabbitmqprac/domain/context/oauth/service/OauthService.java b/src/main/java/com/rabbitmqprac/domain/context/oauth/service/OauthService.java
new file mode 100644
index 0000000..c4f569d
--- /dev/null
+++ b/src/main/java/com/rabbitmqprac/domain/context/oauth/service/OauthService.java
@@ -0,0 +1,68 @@
+package com.rabbitmqprac.domain.context.oauth.service;
+
+import com.rabbitmqprac.application.dto.oauth.req.OauthSignInReq;
+import com.rabbitmqprac.application.dto.oauth.req.OauthSignUpReq;
+import com.rabbitmqprac.domain.context.oauth.exception.OauthErrorCode;
+import com.rabbitmqprac.domain.context.oauth.exception.OauthErrorException;
+import com.rabbitmqprac.domain.context.user.service.UserService;
+import com.rabbitmqprac.domain.persistence.oauth.constant.OauthProvider;
+import com.rabbitmqprac.domain.persistence.oauth.entity.Oauth;
+import com.rabbitmqprac.domain.persistence.oauth.repository.OauthRepository;
+import com.rabbitmqprac.domain.persistence.user.entity.User;
+import com.rabbitmqprac.global.helper.JwtHelper;
+import com.rabbitmqprac.global.helper.OauthHelper;
+import com.rabbitmqprac.infra.oauth.dto.OauthTokenRes;
+import com.rabbitmqprac.infra.oauth.dto.OidcDecodePayload;
+import com.rabbitmqprac.infra.security.jwt.Jwts;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.commons.lang3.tuple.Pair;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.net.URLDecoder;
+import java.nio.charset.StandardCharsets;
+
+@Slf4j
+@RequiredArgsConstructor
+@Service
+public class OauthService {
+ private final OauthHelper oauthHelper;
+ private final OauthRepository oauthRepository;
+ private final UserService userService;
+ private final JwtHelper jwtHelper;
+
+ @Transactional(readOnly = true)
+ public Pair signIn(OauthProvider oauthProvider, OauthSignInReq req) {
+ String code = URLDecoder.decode(req.code(), StandardCharsets.UTF_8);
+ OauthTokenRes tokenRes = oauthHelper.getIdToken(oauthProvider, code);
+ OidcDecodePayload payload = oauthHelper.getOidcDecodedPayload(oauthProvider, tokenRes.idToken());
+ log.debug("payload : {}", payload);
+
+ User user = oauthRepository.findBySubAndOauthProvider(payload.sub(), oauthProvider)
+ .map(oauth -> oauth.getUser())
+ .orElse(null);
+
+ return (user != null)
+ ? Pair.of(user.getId(), jwtHelper.createToken(user))
+ : Pair.of(-1L, null);
+ }
+
+ @Transactional
+ public Pair signUp(OauthProvider oauthProvider, OauthSignUpReq req) {
+ String code = URLDecoder.decode(req.code(), StandardCharsets.UTF_8);
+ OauthTokenRes tokenRes = oauthHelper.getIdToken(oauthProvider, code);
+ OidcDecodePayload payload = oauthHelper.getOidcDecodedPayload(oauthProvider, tokenRes.idToken());
+
+ if (oauthRepository.existsBySubAndOauthProvider(payload.sub(), oauthProvider)) {
+ throw new OauthErrorException(OauthErrorCode.CONFLICT);
+ }
+
+ userService.validateNicknameDuplication(req.nickname());
+
+ User user = userService.create(req.nickname());
+ oauthRepository.save(Oauth.of(oauthProvider, payload.sub(), user));
+
+ return Pair.of(user.getId(), jwtHelper.createToken(user));
+ }
+}
diff --git a/src/main/java/com/rabbitmqprac/domain/context/user/dto/req/UserCreateReq.java b/src/main/java/com/rabbitmqprac/domain/context/user/dto/req/UserCreateReq.java
index 6334196..d1b769b 100644
--- a/src/main/java/com/rabbitmqprac/domain/context/user/dto/req/UserCreateReq.java
+++ b/src/main/java/com/rabbitmqprac/domain/context/user/dto/req/UserCreateReq.java
@@ -4,15 +4,17 @@
import com.rabbitmqprac.domain.persistence.user.entity.User;
public record UserCreateReq(
+ String nickname,
String username,
String password
) {
- public static UserCreateReq of(String username, String password) {
- return new UserCreateReq(username, password);
+ public static UserCreateReq of(String nickname, String username, String password) {
+ return new UserCreateReq(nickname, username, password);
}
public User toEntity() {
return User.of(
+ nickname,
username,
password,
Role.USER
diff --git a/src/main/java/com/rabbitmqprac/domain/context/user/service/UserService.java b/src/main/java/com/rabbitmqprac/domain/context/user/service/UserService.java
index 2e84251..5e0beb9 100644
--- a/src/main/java/com/rabbitmqprac/domain/context/user/service/UserService.java
+++ b/src/main/java/com/rabbitmqprac/domain/context/user/service/UserService.java
@@ -7,9 +7,9 @@
import com.rabbitmqprac.domain.context.user.dto.req.UserCreateReq;
import com.rabbitmqprac.domain.context.user.exception.UserErrorCode;
import com.rabbitmqprac.domain.context.user.exception.UserErrorException;
+import com.rabbitmqprac.domain.persistence.user.entity.Role;
import com.rabbitmqprac.domain.persistence.user.entity.User;
import com.rabbitmqprac.domain.persistence.user.repository.UserRepository;
-import com.rabbitmqprac.global.exception.GlobalErrorException;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -70,14 +70,25 @@ public void updateNickname(Long userId, NicknameUpdateReq req) {
User user = userRepository.findById(userId)
.orElseThrow(() -> new UserErrorException(UserErrorCode.NOT_FOUND));
- if (userRepository.existsByNickname(req.nickname()))
- throw new UserErrorException(UserErrorCode.CONFLICT_USERNAME);
+ validateNicknameDuplication(req.nickname());
user.updateNickname(req.nickname());
}
+ @Transactional(readOnly = true)
+ public void validateNicknameDuplication(String nickname) {
+ if (userRepository.existsByNickname(nickname))
+ throw new UserErrorException(UserErrorCode.CONFLICT_USERNAME);
+ }
+
@Transactional(readOnly = true)
public Boolean isDuplicatedUsername(String username) {
return userRepository.existsByUsername(username);
}
+
+ @Transactional
+ public User create(String nickname) {
+ User user = User.of(nickname, Role.USER);
+ return userRepository.save(user);
+ }
}
diff --git a/src/main/java/com/rabbitmqprac/domain/persistence/oauth/constant/OauthProvider.java b/src/main/java/com/rabbitmqprac/domain/persistence/oauth/constant/OauthProvider.java
new file mode 100644
index 0000000..3cdd1f3
--- /dev/null
+++ b/src/main/java/com/rabbitmqprac/domain/persistence/oauth/constant/OauthProvider.java
@@ -0,0 +1,23 @@
+package com.rabbitmqprac.domain.persistence.oauth.constant;
+
+import com.fasterxml.jackson.annotation.JsonCreator;
+import lombok.Getter;
+import lombok.RequiredArgsConstructor;
+
+@Getter
+@RequiredArgsConstructor
+public enum OauthProvider {
+ KAKAO,
+ GOOGLE,
+ APPLE;
+
+ @JsonCreator
+ public OauthProvider fromString(String type) {
+ return valueOf(type.toUpperCase());
+ }
+
+ @Override
+ public String toString() {
+ return name().toLowerCase();
+ }
+}
diff --git a/src/main/java/com/rabbitmqprac/domain/persistence/oauth/entity/Oauth.java b/src/main/java/com/rabbitmqprac/domain/persistence/oauth/entity/Oauth.java
new file mode 100644
index 0000000..7452dea
--- /dev/null
+++ b/src/main/java/com/rabbitmqprac/domain/persistence/oauth/entity/Oauth.java
@@ -0,0 +1,50 @@
+package com.rabbitmqprac.domain.persistence.oauth.entity;
+
+import com.rabbitmqprac.domain.persistence.common.model.DateAuditable;
+import com.rabbitmqprac.domain.persistence.oauth.constant.OauthProvider;
+import com.rabbitmqprac.domain.persistence.user.entity.User;
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.EntityListeners;
+import jakarta.persistence.EnumType;
+import jakarta.persistence.Enumerated;
+import jakarta.persistence.FetchType;
+import jakarta.persistence.GeneratedValue;
+import jakarta.persistence.GenerationType;
+import jakarta.persistence.Id;
+import jakarta.persistence.JoinColumn;
+import jakarta.persistence.ManyToOne;
+import jakarta.persistence.Table;
+import lombok.AccessLevel;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import org.springframework.data.jpa.domain.support.AuditingEntityListener;
+
+@Entity
+@Getter
+@Table(name = "oauth")
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+@EntityListeners(AuditingEntityListener.class)
+public class Oauth extends DateAuditable {
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private Long id;
+
+ @Column(nullable = false)
+ @Enumerated(EnumType.STRING)
+ private OauthProvider oauthProvider;
+ @Column(nullable = false)
+ private String sub;
+
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "user_id")
+ private User user;
+
+ public static Oauth of(OauthProvider provider, String oauthId, User user) {
+ Oauth oauth = new Oauth();
+ oauth.oauthProvider = provider;
+ oauth.sub = oauthId;
+ oauth.user = user;
+ return oauth;
+ }
+}
diff --git a/src/main/java/com/rabbitmqprac/domain/persistence/oauth/repository/OauthRepository.java b/src/main/java/com/rabbitmqprac/domain/persistence/oauth/repository/OauthRepository.java
new file mode 100644
index 0000000..5929253
--- /dev/null
+++ b/src/main/java/com/rabbitmqprac/domain/persistence/oauth/repository/OauthRepository.java
@@ -0,0 +1,13 @@
+package com.rabbitmqprac.domain.persistence.oauth.repository;
+
+import com.rabbitmqprac.domain.persistence.oauth.constant.OauthProvider;
+import com.rabbitmqprac.domain.persistence.oauth.entity.Oauth;
+import org.springframework.data.jpa.repository.JpaRepository;
+
+import java.util.Optional;
+
+public interface OauthRepository extends JpaRepository {
+ Optional findBySubAndOauthProvider(String sub, OauthProvider oauthProvider);
+
+ boolean existsBySubAndOauthProvider(String sub, OauthProvider oauthProvider);
+}
diff --git a/src/main/java/com/rabbitmqprac/domain/persistence/user/entity/User.java b/src/main/java/com/rabbitmqprac/domain/persistence/user/entity/User.java
index f8cc354..dbcdb01 100644
--- a/src/main/java/com/rabbitmqprac/domain/persistence/user/entity/User.java
+++ b/src/main/java/com/rabbitmqprac/domain/persistence/user/entity/User.java
@@ -11,6 +11,7 @@
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
+import org.hibernate.annotations.ColumnDefault;
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@@ -21,20 +22,27 @@ public class User extends DateAuditable {
@Column(name = "user_id")
private Long id;
- @Column(nullable = false, unique = true)
+ @Column(unique = true)
private String username;
- @Column(nullable = false)
+ @ColumnDefault("NULL")
private String password;
@Column(nullable = false)
private String nickname;
@Enumerated(EnumType.STRING)
private Role role;
- public static User of(String username, String password, Role role) {
+ public static User of(String nickname, String username, String password, Role role) {
User user = new User();
+ user.nickname = nickname;
user.username = username;
user.password = password;
- user.nickname = username;
+ user.role = role;
+ return user;
+ }
+
+ public static User of(String nickname, Role role) {
+ User user = new User();
+ user.nickname = nickname;
user.role = role;
return user;
}
diff --git a/src/main/java/com/rabbitmqprac/global/annotation/Nickname.java b/src/main/java/com/rabbitmqprac/global/annotation/Nickname.java
new file mode 100644
index 0000000..9e8f435
--- /dev/null
+++ b/src/main/java/com/rabbitmqprac/global/annotation/Nickname.java
@@ -0,0 +1,24 @@
+package com.rabbitmqprac.global.annotation;
+
+import com.rabbitmqprac.global.validator.NicknameValidator;
+import jakarta.validation.Constraint;
+import jakarta.validation.Payload;
+
+import java.lang.annotation.Documented;
+import java.lang.annotation.Retention;
+import java.lang.annotation.Target;
+
+import static java.lang.annotation.ElementType.FIELD;
+import static java.lang.annotation.RetentionPolicy.RUNTIME;
+
+@Documented
+@Constraint(validatedBy = {NicknameValidator.class})
+@Target({FIELD})
+@Retention(RUNTIME)
+public @interface Nickname {
+ String message() default "한글, 영문, 숫자만 사용하여, 2~10자의 닉네임을 입력해 주세요";
+
+ Class>[] groups() default {};
+
+ Class extends Payload>[] payload() default {};
+}
diff --git a/src/main/java/com/rabbitmqprac/global/exception/payload/DomainCode.java b/src/main/java/com/rabbitmqprac/global/exception/payload/DomainCode.java
index b252472..ead7400 100644
--- a/src/main/java/com/rabbitmqprac/global/exception/payload/DomainCode.java
+++ b/src/main/java/com/rabbitmqprac/global/exception/payload/DomainCode.java
@@ -8,7 +8,8 @@ public enum DomainCode implements BaseCode {
JWT(1),
USER(2),
USER_SESSION(3),
- CHAT_ROOM(4);
+ CHAT_ROOM(4),
+ OAUTH(5);
private final int code;
diff --git a/src/main/java/com/rabbitmqprac/global/helper/OauthHelper.java b/src/main/java/com/rabbitmqprac/global/helper/OauthHelper.java
new file mode 100644
index 0000000..2200f92
--- /dev/null
+++ b/src/main/java/com/rabbitmqprac/global/helper/OauthHelper.java
@@ -0,0 +1,93 @@
+package com.rabbitmqprac.global.helper;
+
+import com.rabbitmqprac.domain.persistence.oauth.constant.OauthProvider;
+import com.rabbitmqprac.global.annotation.Helper;
+import com.rabbitmqprac.global.util.OauthRequestBodyUtil;
+import com.rabbitmqprac.infra.oauth.client.GoogleOidcClient;
+import com.rabbitmqprac.infra.oauth.client.KakaoOidcClient;
+import com.rabbitmqprac.infra.oauth.client.OauthOidcClient;
+import com.rabbitmqprac.infra.oauth.dto.OauthTokenRes;
+import com.rabbitmqprac.infra.oauth.dto.OidcDecodePayload;
+import com.rabbitmqprac.infra.oauth.dto.OidcPublicKey;
+import com.rabbitmqprac.infra.oauth.dto.OidcPublicKeyRes;
+import com.rabbitmqprac.infra.oauth.properties.GoogleOidcProperties;
+import com.rabbitmqprac.infra.oauth.properties.KakaoOidcProperties;
+import com.rabbitmqprac.infra.oauth.properties.OauthOidcClientProperties;
+import com.rabbitmqprac.infra.oauth.provider.OauthOidcProvider;
+import org.springframework.util.MultiValueMap;
+
+import java.util.Map;
+
+@Helper
+public class OauthHelper {
+ private final OauthOidcProvider oauthOidcProvider;
+ private final Map> oauthOidcClients;
+
+ public OauthHelper(
+ OauthOidcProvider oauthOidcProvider,
+ KakaoOidcClient kakaoOauthClient,
+ GoogleOidcClient googleOauthClient,
+ KakaoOidcProperties kakaoOauthClientProperties,
+ GoogleOidcProperties googleOauthClientProperties
+ ) {
+ this.oauthOidcProvider = oauthOidcProvider;
+ oauthOidcClients = Map.of(
+ OauthProvider.KAKAO, Map.of(kakaoOauthClient, kakaoOauthClientProperties),
+ OauthProvider.GOOGLE, Map.of(googleOauthClient, googleOauthClientProperties)
+ );
+ }
+
+ /**
+ * Provider에 따라 Client와 Properties를 선택하고 Odic public key 정보를 가져와서 ID Token의 payload를 추출하는 메서드
+ *
+ * @param oauthProvider : {@link OauthProvider}
+ * @param idToken : code
+ * @return OIDCDecodePayload : ID Token의 payload
+ */
+ public OidcDecodePayload getOidcDecodedPayload(OauthProvider oauthProvider, String idToken) {
+ OauthOidcClient client = oauthOidcClients.get(oauthProvider).keySet().iterator().next();
+ OauthOidcClientProperties properties = oauthOidcClients.get(oauthProvider).values().iterator().next();
+ OidcPublicKeyRes response = client.getOidcPublicKey();
+
+ return getPayloadFromIdToken(idToken, properties.getIssuer(), properties.getClientId(), properties.getNonce(), response);
+ }
+
+ /**
+ * ID Token의 payload를 추출하는 메서드
+ * OAuth 2.0 spec에 따라 ID Token의 유효성 검사 수행
+ *
+ * @param idToken : code
+ * @param iss : ID Token을 발급한 provider의 URL
+ * @param aud : ID Token이 발급된 앱의 앱 키
+ * @param nonce : 인증 서버 로그인 요청 시 전달한 임의의 문자열 (Optional, 현재는 사용하지 않음)
+ * @param response : 공개키 목록
+ * @return OIDCDecodePayload : ID Token의 payload
+ */
+ private OidcDecodePayload getPayloadFromIdToken(String idToken, String iss, String aud, String nonce, OidcPublicKeyRes response) {
+ String kid = getKidFromUnsignedIdToken(idToken, iss, aud, nonce);
+
+ OidcPublicKey key = response.getKeys().stream()
+ .filter(k -> k.kid().equals(kid))
+ .findFirst()
+ .orElseThrow(() -> new IllegalArgumentException("No matching key found"));
+ return oauthOidcProvider.getOIDCTokenBody(idToken, key.n(), key.e());
+ }
+
+ private String getKidFromUnsignedIdToken(String token, String iss, String aud, String nonce) {
+ return oauthOidcProvider.getKidFromUnsignedTokenHeader(token, iss, aud, nonce);
+ }
+
+ public OauthTokenRes getIdToken(OauthProvider oauthProvider, String code) {
+ OauthOidcClient client = oauthOidcClients.get(oauthProvider).keySet().iterator().next();
+ OauthOidcClientProperties properties = oauthOidcClients.get(oauthProvider).values().iterator().next();
+
+ MultiValueMap body = OauthRequestBodyUtil.createIdTokenRequestBody(
+ code,
+ properties.getClientId(),
+ properties.getClientSecret(),
+ properties.getRedirectUri()
+ );
+
+ return client.getIdToken(body);
+ }
+}
diff --git a/src/main/java/com/rabbitmqprac/global/util/OauthRequestBodyUtil.java b/src/main/java/com/rabbitmqprac/global/util/OauthRequestBodyUtil.java
new file mode 100644
index 0000000..0f56fbb
--- /dev/null
+++ b/src/main/java/com/rabbitmqprac/global/util/OauthRequestBodyUtil.java
@@ -0,0 +1,26 @@
+package com.rabbitmqprac.global.util;
+
+
+import com.rabbitmqprac.global.annotation.Util;
+import org.springframework.util.LinkedMultiValueMap;
+import org.springframework.util.MultiValueMap;
+
+@Util
+public final class OauthRequestBodyUtil {
+ private static final String GRANT_TYPE = "authorization_code";
+
+ public static MultiValueMap createIdTokenRequestBody(
+ String code,
+ String clientId,
+ String clientSecret,
+ String redirectUri
+ ) {
+ MultiValueMap formData = new LinkedMultiValueMap<>();
+ formData.add("grant_type", GRANT_TYPE);
+ formData.add("code", code);
+ formData.add("client_id", clientId);
+ formData.add("client_secret", clientSecret);
+ formData.add("redirect_uri", redirectUri);
+ return formData;
+ }
+}
diff --git a/src/main/java/com/rabbitmqprac/global/validator/NicknameValidator.java b/src/main/java/com/rabbitmqprac/global/validator/NicknameValidator.java
new file mode 100644
index 0000000..727baf6
--- /dev/null
+++ b/src/main/java/com/rabbitmqprac/global/validator/NicknameValidator.java
@@ -0,0 +1,16 @@
+package com.rabbitmqprac.global.validator;
+
+import com.rabbitmqprac.global.annotation.Nickname;
+import jakarta.validation.ConstraintValidator;
+import jakarta.validation.ConstraintValidatorContext;
+
+import java.util.regex.Pattern;
+
+public class NicknameValidator implements ConstraintValidator {
+ private static final Pattern PASSWORD_PATTERN = Pattern.compile("^[가-힣a-zA-Z0-9]{2,10}$");
+
+ @Override
+ public boolean isValid(String value, ConstraintValidatorContext context) {
+ return value != null && PASSWORD_PATTERN.matcher(value).matches();
+ }
+}
diff --git a/src/main/java/com/rabbitmqprac/infra/oauth/client/GoogleOidcClient.java b/src/main/java/com/rabbitmqprac/infra/oauth/client/GoogleOidcClient.java
new file mode 100644
index 0000000..e5e9597
--- /dev/null
+++ b/src/main/java/com/rabbitmqprac/infra/oauth/client/GoogleOidcClient.java
@@ -0,0 +1,41 @@
+package com.rabbitmqprac.infra.oauth.client;
+
+import com.rabbitmqprac.infra.oauth.dto.OauthTokenRes;
+import com.rabbitmqprac.infra.oauth.dto.OidcPublicKeyRes;
+import lombok.RequiredArgsConstructor;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.http.MediaType;
+import org.springframework.stereotype.Component;
+import org.springframework.util.MultiValueMap;
+import org.springframework.web.reactive.function.client.WebClient;
+
+@RequiredArgsConstructor
+@Component
+public class GoogleOidcClient implements OauthOidcClient {
+ private final WebClient webClient;
+
+ @Value("${oauth2.client.provider.google.jwks-uri}")
+ private String jwksUri;
+ @Value("${oauth2.client.provider.google.token-uri}")
+ private String tokenUri;
+
+ @Override
+ public OidcPublicKeyRes getOidcPublicKey() {
+ return webClient.get()
+ .uri(jwksUri)
+ .retrieve()
+ .bodyToMono(OidcPublicKeyRes.class)
+ .block();
+ }
+
+ @Override
+ public OauthTokenRes getIdToken(MultiValueMap body) {
+ return webClient.post()
+ .uri(tokenUri)
+ .contentType(MediaType.APPLICATION_FORM_URLENCODED)
+ .bodyValue(body)
+ .retrieve()
+ .bodyToMono(OauthTokenRes.class)
+ .block();
+ }
+}
diff --git a/src/main/java/com/rabbitmqprac/infra/oauth/client/KakaoOidcClient.java b/src/main/java/com/rabbitmqprac/infra/oauth/client/KakaoOidcClient.java
new file mode 100644
index 0000000..45b5bc1
--- /dev/null
+++ b/src/main/java/com/rabbitmqprac/infra/oauth/client/KakaoOidcClient.java
@@ -0,0 +1,43 @@
+package com.rabbitmqprac.infra.oauth.client;
+
+import com.rabbitmqprac.infra.oauth.dto.OauthTokenRes;
+import com.rabbitmqprac.infra.oauth.dto.OidcPublicKeyRes;
+import lombok.RequiredArgsConstructor;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.http.MediaType;
+import org.springframework.stereotype.Component;
+import org.springframework.util.MultiValueMap;
+import org.springframework.web.reactive.function.client.WebClient;
+
+
+@RequiredArgsConstructor
+@Component
+public class KakaoOidcClient implements OauthOidcClient {
+ private final WebClient webClient;
+
+ @Value("${oauth2.client.provider.kakao.jwks-uri}")
+ private String jwksUri;
+ @Value("${oauth2.client.provider.kakao.token-uri}")
+ private String tokenUri;
+
+ @Override
+ public OidcPublicKeyRes getOidcPublicKey() {
+ return webClient.get()
+ .uri(jwksUri)
+ .retrieve()
+ .bodyToMono(OidcPublicKeyRes.class)
+ .block();
+ }
+
+ @Override
+ public OauthTokenRes getIdToken(MultiValueMap body) {
+ return webClient.post()
+ .uri(tokenUri)
+ .contentType(MediaType.APPLICATION_FORM_URLENCODED)
+ .bodyValue(body)
+ .retrieve()
+ .bodyToMono(OauthTokenRes.class)
+ .block();
+ }
+
+}
diff --git a/src/main/java/com/rabbitmqprac/infra/oauth/client/OauthClient.java b/src/main/java/com/rabbitmqprac/infra/oauth/client/OauthClient.java
new file mode 100644
index 0000000..3582564
--- /dev/null
+++ b/src/main/java/com/rabbitmqprac/infra/oauth/client/OauthClient.java
@@ -0,0 +1,8 @@
+package com.rabbitmqprac.infra.oauth.client;
+
+import com.rabbitmqprac.infra.oauth.dto.OauthTokenRes;
+import org.springframework.util.MultiValueMap;
+
+public interface OauthClient {
+ OauthTokenRes getIdToken(MultiValueMap body);
+}
diff --git a/src/main/java/com/rabbitmqprac/infra/oauth/client/OauthOidcClient.java b/src/main/java/com/rabbitmqprac/infra/oauth/client/OauthOidcClient.java
new file mode 100644
index 0000000..bb30011
--- /dev/null
+++ b/src/main/java/com/rabbitmqprac/infra/oauth/client/OauthOidcClient.java
@@ -0,0 +1,7 @@
+package com.rabbitmqprac.infra.oauth.client;
+
+import com.rabbitmqprac.infra.oauth.dto.OidcPublicKeyRes;
+
+public interface OauthOidcClient extends OauthClient {
+ OidcPublicKeyRes getOidcPublicKey();
+}
diff --git a/src/main/java/com/rabbitmqprac/infra/oauth/dto/OauthTokenRes.java b/src/main/java/com/rabbitmqprac/infra/oauth/dto/OauthTokenRes.java
new file mode 100644
index 0000000..4648236
--- /dev/null
+++ b/src/main/java/com/rabbitmqprac/infra/oauth/dto/OauthTokenRes.java
@@ -0,0 +1,21 @@
+package com.rabbitmqprac.infra.oauth.dto;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+
+public record OauthTokenRes(
+ @JsonProperty("access_token")
+ String accessToken,
+ @JsonProperty("token_type")
+ String tokenType,
+ @JsonProperty("refresh_token")
+ String refreshToken,
+ @JsonProperty("id_token")
+ String idToken,
+ @JsonProperty("expires_in")
+ int expiresIn,
+ @JsonProperty("scope")
+ String scope,
+ @JsonProperty("refresh_token_expires_in")
+ int refreshTokenExpiresIn
+) {
+}
diff --git a/src/main/java/com/rabbitmqprac/infra/oauth/dto/OidcDecodePayload.java b/src/main/java/com/rabbitmqprac/infra/oauth/dto/OidcDecodePayload.java
new file mode 100644
index 0000000..69e1801
--- /dev/null
+++ b/src/main/java/com/rabbitmqprac/infra/oauth/dto/OidcDecodePayload.java
@@ -0,0 +1,12 @@
+package com.rabbitmqprac.infra.oauth.dto;
+
+public record OidcDecodePayload(
+ /* issuer */
+ String iss,
+ /* client id */
+ String aud,
+ /* aouth provider account unique id */
+ String sub,
+ String email
+) {
+}
diff --git a/src/main/java/com/rabbitmqprac/infra/oauth/dto/OidcPublicKey.java b/src/main/java/com/rabbitmqprac/infra/oauth/dto/OidcPublicKey.java
new file mode 100644
index 0000000..d0deff0
--- /dev/null
+++ b/src/main/java/com/rabbitmqprac/infra/oauth/dto/OidcPublicKey.java
@@ -0,0 +1,11 @@
+package com.rabbitmqprac.infra.oauth.dto;
+
+public record OidcPublicKey(
+ String kid,
+ String kty,
+ String alg,
+ String use,
+ String n,
+ String e
+) {
+}
diff --git a/src/main/java/com/rabbitmqprac/infra/oauth/dto/OidcPublicKeyRes.java b/src/main/java/com/rabbitmqprac/infra/oauth/dto/OidcPublicKeyRes.java
new file mode 100644
index 0000000..330d972
--- /dev/null
+++ b/src/main/java/com/rabbitmqprac/infra/oauth/dto/OidcPublicKeyRes.java
@@ -0,0 +1,12 @@
+package com.rabbitmqprac.infra.oauth.dto;
+
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+import java.util.List;
+
+@Getter
+@NoArgsConstructor
+public class OidcPublicKeyRes {
+ List keys;
+}
diff --git a/src/main/java/com/rabbitmqprac/infra/oauth/properties/GoogleOidcProperties.java b/src/main/java/com/rabbitmqprac/infra/oauth/properties/GoogleOidcProperties.java
new file mode 100644
index 0000000..7a88fba
--- /dev/null
+++ b/src/main/java/com/rabbitmqprac/infra/oauth/properties/GoogleOidcProperties.java
@@ -0,0 +1,17 @@
+package com.rabbitmqprac.infra.oauth.properties;
+
+import lombok.Getter;
+import lombok.RequiredArgsConstructor;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+
+@Getter
+@RequiredArgsConstructor
+@ConfigurationProperties(prefix = "oauth2.client.provider.google")
+public class GoogleOidcProperties implements OauthOidcClientProperties {
+ private final String clientId;
+ private final String clientSecret;
+ private final String issuer;
+ private final String jwksUri;
+ private final String nonce;
+ private final String redirectUri;
+}
diff --git a/src/main/java/com/rabbitmqprac/infra/oauth/properties/KakaoOidcProperties.java b/src/main/java/com/rabbitmqprac/infra/oauth/properties/KakaoOidcProperties.java
new file mode 100644
index 0000000..6a3225c
--- /dev/null
+++ b/src/main/java/com/rabbitmqprac/infra/oauth/properties/KakaoOidcProperties.java
@@ -0,0 +1,17 @@
+package com.rabbitmqprac.infra.oauth.properties;
+
+import lombok.Getter;
+import lombok.RequiredArgsConstructor;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+
+@Getter
+@RequiredArgsConstructor
+@ConfigurationProperties(prefix = "oauth2.client.provider.kakao")
+public class KakaoOidcProperties implements OauthOidcClientProperties {
+ private final String clientId;
+ private final String clientSecret;
+ private final String issuer;
+ private final String jwksUri;
+ private final String nonce;
+ private final String redirectUri;
+}
diff --git a/src/main/java/com/rabbitmqprac/infra/oauth/properties/OauthOidcClientProperties.java b/src/main/java/com/rabbitmqprac/infra/oauth/properties/OauthOidcClientProperties.java
new file mode 100644
index 0000000..fbd9068
--- /dev/null
+++ b/src/main/java/com/rabbitmqprac/infra/oauth/properties/OauthOidcClientProperties.java
@@ -0,0 +1,15 @@
+package com.rabbitmqprac.infra.oauth.properties;
+
+public interface OauthOidcClientProperties {
+ String getJwksUri();
+
+ String getClientId();
+
+ String getIssuer();
+
+ String getNonce();
+
+ String getClientSecret();
+
+ String getRedirectUri();
+}
diff --git a/src/main/java/com/rabbitmqprac/infra/oauth/provider/OauthOidcProvider.java b/src/main/java/com/rabbitmqprac/infra/oauth/provider/OauthOidcProvider.java
new file mode 100644
index 0000000..ae0aa90
--- /dev/null
+++ b/src/main/java/com/rabbitmqprac/infra/oauth/provider/OauthOidcProvider.java
@@ -0,0 +1,26 @@
+package com.rabbitmqprac.infra.oauth.provider;
+
+import com.rabbitmqprac.infra.oauth.dto.OidcDecodePayload;
+
+public interface OauthOidcProvider {
+ /**
+ * ID Token의 header에서 kid를 추출하는 메서드
+ *
+ * @param token : code
+ * @param iss : ID Token을 발급한 OAuth 2.0 제공자의 URL
+ * @param aud : ID Token이 발급된 앱의 앱 키
+ * @param nonce : 인증 서버 로그인 요청 시 전달한 임의의 문자열
+ * @return kid : ID Token의 서명에 사용된 공개키의 ID
+ */
+ String getKidFromUnsignedTokenHeader(String token, String iss, String aud, String nonce);
+
+ /**
+ * ID Token의 payload를 추출하는 메서드
+ *
+ * @param token : code
+ * @param modulus : 공개키 모듈(n)
+ * @param exponent : 공개키 지수(e)
+ * @return OIDCDecodePayload : ID Token의 payload
+ */
+ OidcDecodePayload getOIDCTokenBody(String token, String modulus, String exponent);
+}
diff --git a/src/main/java/com/rabbitmqprac/infra/oauth/provider/OauthOidcProviderImpl.java b/src/main/java/com/rabbitmqprac/infra/oauth/provider/OauthOidcProviderImpl.java
new file mode 100644
index 0000000..e7bacb6
--- /dev/null
+++ b/src/main/java/com/rabbitmqprac/infra/oauth/provider/OauthOidcProviderImpl.java
@@ -0,0 +1,144 @@
+package com.rabbitmqprac.infra.oauth.provider;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.rabbitmqprac.domain.context.oauth.exception.OauthErrorCode;
+import com.rabbitmqprac.domain.context.oauth.exception.OauthErrorException;
+import com.rabbitmqprac.global.util.JwtErrorCodeUtil;
+import com.rabbitmqprac.infra.oauth.dto.OidcDecodePayload;
+import com.rabbitmqprac.infra.security.exception.JwtErrorCode;
+import com.rabbitmqprac.infra.security.exception.JwtErrorException;
+import io.jsonwebtoken.Claims;
+import io.jsonwebtoken.Jws;
+import io.jsonwebtoken.JwtException;
+import io.jsonwebtoken.Jwts;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Component;
+
+import java.math.BigInteger;
+import java.security.KeyFactory;
+import java.security.NoSuchAlgorithmException;
+import java.security.PublicKey;
+import java.security.spec.InvalidKeySpecException;
+import java.security.spec.RSAPublicKeySpec;
+import java.util.Base64;
+import java.util.Map;
+
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class OauthOidcProviderImpl implements OauthOidcProvider {
+ private static final String KID = "kid";
+ private static final String RSA = "RSA";
+ private final ObjectMapper objectMapper;
+
+ @Override
+ public String getKidFromUnsignedTokenHeader(String token, String iss, String aud, String nonce) {
+ return getUnsignedTokenClaims(token, iss, aud, nonce).get("header").get(KID);
+ }
+
+ @Override
+ public OidcDecodePayload getOIDCTokenBody(String token, String modulus, String exponent) {
+ Claims body = getOIDCTokenJws(token, modulus, exponent).getPayload();
+ String aud = body.getAudience().iterator().next(); // aud가 여러개일 경우 첫 번째 aud를 사용
+
+ return new OidcDecodePayload(
+ body.getIssuer(),
+ aud,
+ body.getSubject(),
+ body.get("email", String.class)
+ );
+ }
+
+ /**
+ * ID Token의 header와 body를 Base64 방식으로 디코딩하는 메서드
+ */
+ @SuppressWarnings("unchecked")
+ private Map> getUnsignedTokenClaims(String token, String iss, String aud, String nonce) {
+ try {
+ Base64.Decoder decoder = Base64.getUrlDecoder();
+
+ String unsignedToken = getUnsignedToken(token);
+ String headerJson = new String(decoder.decode(unsignedToken.split("\\.")[0]));
+ String payloadJson = new String(decoder.decode(unsignedToken.split("\\.")[1]));
+
+ Map header = objectMapper.readValue(headerJson, Map.class);
+ Map payload = objectMapper.readValue(payloadJson, Map.class);
+
+ validatePayload(payload, iss, aud, nonce);
+
+ return Map.of("header", header, "payload", payload);
+ } catch (JsonProcessingException e) {
+ log.warn("getUnsignedTokenClaims : Error - {}, {}", e.getClass(), e.getMessage());
+ throw new RuntimeException(e);
+ }
+ }
+
+ /**
+ * ID Token의 payload를 검증하는 메서드
+ */
+ private void validatePayload(Map payload, String iss, String aud, String nonce) {
+ if (!payload.containsKey("iss")) {
+ throw new OauthErrorException(OauthErrorCode.MISSING_ISS);
+ }
+ if (!payload.containsKey("aud")) {
+ throw new OauthErrorException(OauthErrorCode.MISSING_AUD);
+ }
+ if (!payload.containsKey("nonce")) {
+ throw new OauthErrorException(OauthErrorCode.MISSING_NONCE);
+ }
+ if (!payload.get("iss").equals(iss)) {
+ throw new OauthErrorException(OauthErrorCode.INVALID_ISS);
+ }
+ if (!payload.get("aud").equals(aud)) {
+ throw new OauthErrorException(OauthErrorCode.INVALID_AUD);
+ }
+ if (!payload.get("nonce").equals(nonce)) {
+ throw new OauthErrorException(OauthErrorCode.INVALID_NONCE);
+ }
+ }
+
+ /**
+ * Token의 signature를 제거하는 메서드
+ */
+ private String getUnsignedToken(String token) {
+ String[] splitToken = token.split("\\.");
+ if (splitToken.length != 3) throw new JwtErrorException(JwtErrorCode.MALFORMED_TOKEN);
+ return splitToken[0] + "." + splitToken[1];
+ }
+
+ /**
+ * 공개키로 서명을 검증하는 메서드
+ */
+ private Jws getOIDCTokenJws(String token, String modulus, String exponent) {
+ try {
+ return Jwts.parser()
+ .verifyWith(getRSAPublicKey(modulus, exponent))
+ .build()
+ .parseSignedClaims(token);
+ } catch (JwtException e) {
+ final JwtErrorCode errorCode = JwtErrorCodeUtil.determineErrorCode(e, JwtErrorCode.FAILED_AUTHENTICATION);
+
+ log.warn("getOIDCTokenJws : Error code : {}, Error - {}, {}", errorCode, e.getClass(), e.getMessage());
+ throw new JwtErrorException(errorCode);
+ } catch (NoSuchAlgorithmException | InvalidKeySpecException e) {
+ log.warn("getOIDCTokenJws : Error - {}, {}", e.getClass(), e.getMessage());
+ throw new JwtErrorException(JwtErrorCode.MALFORMED_TOKEN);
+ }
+ }
+
+ /**
+ * n, e 조합으로 공개키를 생성하는 메서드
+ */
+ private PublicKey getRSAPublicKey(String modulus, String exponent) throws NoSuchAlgorithmException, InvalidKeySpecException {
+ KeyFactory keyFactory = KeyFactory.getInstance(RSA);
+ byte[] decodeN = Base64.getUrlDecoder().decode(modulus);
+ byte[] decodeE = Base64.getUrlDecoder().decode(exponent);
+ BigInteger n = new BigInteger(1, decodeN);
+ BigInteger e = new BigInteger(1, decodeE);
+
+ RSAPublicKeySpec publicKeySpec = new RSAPublicKeySpec(n, e);
+ return keyFactory.generatePublic(publicKeySpec);
+ }
+}
diff --git a/src/main/java/com/rabbitmqprac/infra/security/constant/WebSecurityUrls.java b/src/main/java/com/rabbitmqprac/infra/security/constant/WebSecurityUrls.java
index 05c4f76..725c72b 100644
--- a/src/main/java/com/rabbitmqprac/infra/security/constant/WebSecurityUrls.java
+++ b/src/main/java/com/rabbitmqprac/infra/security/constant/WebSecurityUrls.java
@@ -1,9 +1,9 @@
package com.rabbitmqprac.infra.security.constant;
public final class WebSecurityUrls {
- public static final String[] READ_ONLY_PUBLIC_ENDPOINTS = {"/favicon.ico", "/chat-rooms", "/users/username", "/test"};
+ public static final String[] READ_ONLY_PUBLIC_ENDPOINTS = {"/favicon.ico", "/chat-rooms", "/users/nickname"};
public static final String[] PUBLIC_ENDPOINTS = {"/"};
- public static final String[] ANONYMOUS_ENDPOINTS = {"/auth/sign-in", "/auth/sign-up"};
+ public static final String[] ANONYMOUS_ENDPOINTS = {"/auth/sign-in", "/auth/sign-up", "/oauth/sign-up", "/oauth/sign-in"};
public static final String[] AUTHENTICATED_ENDPOINTS = {"/"};
public static final String[] PUBLIC_STOMP_ENDPOINTS = {"/chat/inbox"};
public static final String[] SWAGGER_ENDPOINTS = {"/api-docs/**", "/v3/api-docs/**", "/swagger-ui/**", "/swagger"};
diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml
index adce61d..9a26064 100644
--- a/src/main/resources/application.yml
+++ b/src/main/resources/application.yml
@@ -1,4 +1,6 @@
spring:
+ config:
+ import: optional:file:.env[.properties]
sql:
init:
mode: always
@@ -31,6 +33,26 @@ spring:
host: localhost
port: 6379
+oauth2:
+ client:
+ provider:
+ kakao:
+ client-id: ${KAKAO_CLIENT_ID:57e328d97dc0c7b53898e2e79082f23c}
+ client-secret: ${KAKAO_CLIENT_SECRET:exampleSecretKeyForRabbit}
+ jwks-uri: ${KAKAO_JWKS_URI:https://kauth.kakao.com/.well-known/jwks.json}
+ token-uri: ${KAKAO_TOKEN_URI:https://kauth.kakao.com/oauth/token}
+ issuer: ${KAKAO_ISS:https://kauth.kakao.com}
+ nonce: ${KAKAO_NONCE:example-nonce}
+ redirect-uri: ${KAKAO_REDIRECT_URI:http://localhost:8080}
+ google:
+ client-id: ${GOOGLE_CLIENT_ID:248388975343-0oo0f79rrsqpf1k63ahpivkhd2rfu1jp.apps.googleusercontent.com}
+ client-secret: ${GOOGLE_CLIENT_SECRET:exampleSecretKeyForRabbit}
+ jwks-uri: ${GOOGLE_JWKS_URI:https://www.googleapis.com/oauth2/v3/certs}
+ token-uri: ${GOOGLE_TOKEN_URI:https://oauth2.googleapis.com/token}
+ issuer: ${GOOGLE_ISS:https://accounts.google.com}
+ nonce: ${GOOGLE_NONCE:example-nonce}
+ redirect-uri: ${GOOGLE_REDIRECT_URI:http://localhost:8080}
+
jwt:
secret-key:
access-token: ${JWT_ACCESS_SECRET_KEY:exampleSecretKeyForPennywaySystemAccessSecretKeyTestForPadding}
diff --git a/src/main/resources/data.sql b/src/main/resources/data.sql
index a194ad0..4742b22 100644
--- a/src/main/resources/data.sql
+++ b/src/main/resources/data.sql
@@ -2,7 +2,7 @@
SET
FOREIGN_KEY_CHECKS = 0;
--- 기존 데이터 삭제 (이미지에 있는 테이블만 해당)
+-- 기존 데이터 삭제
DELETE
FROM `user`;
DELETE
@@ -11,8 +11,14 @@ DELETE
FROM `chat_room_member`;
DELETE
FROM `chat_message`;
--- AUTO_INCREMENT 값 초기화 (이미지에 있는 테이블만 해당)
+DELETE
+FROM `oauth`;
+-- AUTO_INCREMENT 값 초기화
ALTER TABLE `user` AUTO_INCREMENT = 1;
+ALTER TABLE `chat_room` AUTO_INCREMENT = 1;
+ALTER TABLE `chat_room_member` AUTO_INCREMENT = 1;
+ALTER TABLE `chat_message` AUTO_INCREMENT = 1;
+ALTER TABLE `oauth` AUTO_INCREMENT = 1;
-- 빠른 삽입을 위해 검사 비활성화
/*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */;
@@ -115,4 +121,4 @@ TABLES;
/*!40111 SET SQL_NOTES=@OLD_SQL_NOTES */;
SET
-FOREIGN_KEY_CHECKS = 1;
\ No newline at end of file
+FOREIGN_KEY_CHECKS = 1;
diff --git a/src/test/java/com/rabbitmqprac/common/fixture/UserFixture.java b/src/test/java/com/rabbitmqprac/common/fixture/UserFixture.java
index 77adabc..2b3fff3 100644
--- a/src/test/java/com/rabbitmqprac/common/fixture/UserFixture.java
+++ b/src/test/java/com/rabbitmqprac/common/fixture/UserFixture.java
@@ -8,15 +8,16 @@
@RequiredArgsConstructor
@Getter
public enum UserFixture {
- FIRST_USER(1L, "user_1", "password_1", Role.USER),
- SECOND_USER(2L, "user_2", "password_2", Role.USER);
+ FIRST_USER(1L, "user_1", "user_1", "password_1", Role.USER),
+ SECOND_USER(2L, "user_2", "user_2", "password_2", Role.USER);
private final Long id;
+ private final String nickname;
private final String username;
private final String password;
private final Role role;
public User toEntity() {
- return User.of(username, password, role);
+ return User.of(nickname, username, password, role);
}
}
diff --git a/src/test/java/com/rabbitmqprac/service/AuthServiceTest.java b/src/test/java/com/rabbitmqprac/service/AuthServiceTest.java
index 2b949b8..07e2585 100644
--- a/src/test/java/com/rabbitmqprac/service/AuthServiceTest.java
+++ b/src/test/java/com/rabbitmqprac/service/AuthServiceTest.java
@@ -53,10 +53,11 @@ class SignUpSuccessScenarios {
@DisplayName("회원가입 성공")
void signUp() {
// given
- final String username = "username";
+ final String nickname = "nickname";
+ final String username = "nickname";
final String password = "password_123";
final String confirmPassword = "password_123";
- AuthSignUpReq req = new AuthSignUpReq(username, password, confirmPassword);
+ AuthSignUpReq req = new AuthSignUpReq(nickname, username, password, confirmPassword);
Jwts jwts = mock(Jwts.class);
given(userService.saveUserWithEncryptedPassword(any(UserCreateReq.class)))
@@ -81,10 +82,11 @@ class SignUpFailScenarios {
@DisplayName("password와 confirmPassword가 다른 경우 회원 가입에 실패")
void signUpWhenInvalidPassword() {
// given
- final String username = "username";
+ final String nickname = "nickname";
+ final String username = "nickname";
final String password = "password";
final String confirmPassword = "invalid_password";
- AuthSignUpReq req = new AuthSignUpReq(username, password, confirmPassword);
+ AuthSignUpReq req = new AuthSignUpReq(nickname, username, password, confirmPassword);
// when
AuthErrorException errorException = assertThrows(AuthErrorException.class, () -> authService.signUp(req));
@@ -101,7 +103,7 @@ class SignInSuccessScenarios {
@DisplayName("로그인 성공")
void signIn() {
// given
- final String username = "username";
+ final String username = "nickname";
final String password = "password";
AuthSignInReq req = new AuthSignInReq(username, password);
Jwts jwts = mock(Jwts.class);
@@ -127,7 +129,7 @@ class SignInFailScenarios {
@DisplayName("로그인 유저의 패스워드가 올바르지 않다면 로그인 실패")
void signInWhenInvalidPassword() {
// given
- final String username = "username";
+ final String username = "nickname";
final String password = "password";
AuthSignInReq req = new AuthSignInReq(username, password);
given(userService.readUserByUsername(username)).willReturn(user);
diff --git a/src/test/java/com/rabbitmqprac/service/OauthServiceTest.java b/src/test/java/com/rabbitmqprac/service/OauthServiceTest.java
new file mode 100644
index 0000000..03f82d5
--- /dev/null
+++ b/src/test/java/com/rabbitmqprac/service/OauthServiceTest.java
@@ -0,0 +1,177 @@
+package com.rabbitmqprac.service;
+
+import com.rabbitmqprac.application.dto.oauth.req.OauthSignInReq;
+import com.rabbitmqprac.application.dto.oauth.req.OauthSignUpReq;
+import com.rabbitmqprac.common.fixture.UserFixture;
+import com.rabbitmqprac.domain.context.oauth.exception.OauthErrorCode;
+import com.rabbitmqprac.domain.context.oauth.exception.OauthErrorException;
+import com.rabbitmqprac.domain.context.oauth.service.OauthService;
+import com.rabbitmqprac.domain.context.user.service.UserService;
+import com.rabbitmqprac.domain.persistence.oauth.constant.OauthProvider;
+import com.rabbitmqprac.domain.persistence.oauth.entity.Oauth;
+import com.rabbitmqprac.domain.persistence.oauth.repository.OauthRepository;
+import com.rabbitmqprac.domain.persistence.user.entity.User;
+import com.rabbitmqprac.global.helper.JwtHelper;
+import com.rabbitmqprac.global.helper.OauthHelper;
+import com.rabbitmqprac.infra.oauth.dto.OauthTokenRes;
+import com.rabbitmqprac.infra.oauth.dto.OidcDecodePayload;
+import com.rabbitmqprac.infra.security.jwt.Jwts;
+import org.apache.commons.lang3.tuple.Pair;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+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 java.util.Optional;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.doNothing;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+@ExtendWith(MockitoExtension.class)
+@DisplayName("OauthService 테스트")
+public class OauthServiceTest {
+ @Mock
+ private OauthHelper oauthHelper;
+ @Mock
+ private OauthRepository oauthRepository;
+ @Mock
+ private UserService userService;
+ @Mock
+ private JwtHelper jwtHelper;
+
+ @InjectMocks
+ private OauthService oauthService;
+
+ private static final User user = mock(User.class);
+ private static final Oauth oauth = mock(Oauth.class);
+
+ @BeforeEach
+ void setUp() {
+ when(user.getId()).thenReturn(UserFixture.FIRST_USER.toEntity().getId());
+ when(oauth.getUser()).thenReturn(user);
+ }
+
+ @Nested
+ @DisplayName("signIn 시나리오")
+ class SignInScenario {
+ private final OauthSignInReq req = mock(OauthSignInReq.class);
+ private final OauthProvider provider = mock(OauthProvider.class);
+ private final String code = "test-code";
+ private final String idToken = "test-id-token";
+ private final OidcDecodePayload payload = mock(OidcDecodePayload.class);
+ private final Jwts jwts = mock(Jwts.class);
+ private final OauthTokenRes oauthTokenRes = mock(OauthTokenRes.class);
+
+ @BeforeEach
+ void setUp() {
+ when(req.code()).thenReturn(code);
+ when(oauthHelper.getIdToken(provider, code)).thenReturn(oauthTokenRes);
+ when(oauthTokenRes.idToken()).thenReturn(idToken);
+ when(oauthHelper.getOidcDecodedPayload(provider, idToken)).thenReturn(payload);
+ }
+
+ @Nested
+ @DisplayName("성공 시나리오")
+ class SuccessScenario {
+ @Test
+ @DisplayName("존재하는 유저일 때 정상적으로 토큰 반환")
+ void signInSuccessWithExistingUser() {
+ // given
+ when(oauthRepository.findBySubAndOauthProvider(payload.sub(), provider)).thenReturn(Optional.of(oauth));
+ when(jwtHelper.createToken(user)).thenReturn(jwts);
+
+ // when
+ Pair result = oauthService.signIn(provider, req);
+
+ // then
+ assertThat(result.getLeft()).isEqualTo(user.getId());
+ assertThat(result.getRight()).isEqualTo(jwts);
+ }
+
+ @Test
+ @DisplayName("존재하지 않는 유저일 때 -1L, null 반환")
+ void signInSuccessWittNotExistingUser() {
+ // given
+ when(oauthRepository.findBySubAndOauthProvider(payload.sub(), provider)).thenReturn(Optional.empty());
+
+ // when
+ Pair result = oauthService.signIn(provider, req);
+
+ // then
+ assertThat(result.getLeft()).isEqualTo(-1L);
+ assertThat(result.getRight()).isNull();
+ }
+ }
+ }
+
+ @Nested
+ @DisplayName("signUp 시나리오")
+ class SignUpScenario {
+ private final OauthProvider provider = mock(OauthProvider.class);
+ private final String code = "test-code";
+ private final String nickname = "test-nickname";
+ private final String idToken = "test-id-token";
+ private final OauthSignUpReq req = mock(OauthSignUpReq.class);
+ private final OauthTokenRes tokenRes = mock(OauthTokenRes.class);
+ private final OidcDecodePayload payload = mock(OidcDecodePayload.class);
+ private final Jwts jwts = mock(Jwts.class);
+
+ @BeforeEach
+ void setUp() {
+ when(req.code()).thenReturn(code);
+ when(oauthHelper.getIdToken(provider, code)).thenReturn(tokenRes);
+ when(tokenRes.idToken()).thenReturn(idToken);
+ when(oauthHelper.getOidcDecodedPayload(provider, idToken)).thenReturn(payload);
+ when(req.nickname()).thenReturn(nickname);
+ }
+
+ @Nested
+ @DisplayName("성공 시나리오")
+ class SuccessScenario {
+ @Test
+ @DisplayName("정상적으로 회원가입 및 토큰 반환")
+ void signUpSuccess() {
+ // given
+ when(oauthRepository.existsBySubAndOauthProvider(payload.sub(), provider)).thenReturn(false);
+ doNothing().when(userService).validateNicknameDuplication(nickname);
+ when(userService.create(nickname)).thenReturn(user);
+ when(jwtHelper.createToken(user)).thenReturn(jwts);
+
+ // when
+ Pair result = oauthService.signUp(provider, req);
+
+ // then
+ verify(userService).create(nickname);
+ verify(oauthRepository).save(any(Oauth.class));
+ assertThat(result.getLeft()).isEqualTo(user.getId());
+ assertThat(result.getRight()).isEqualTo(jwts);
+ }
+ }
+
+ @Nested
+ @DisplayName("실패 시나리오")
+ class FailScenario {
+ @Test
+ @DisplayName("이미 가입된 sub일 때 예외 발생")
+ void signUpFail_conflict() {
+ // given
+ when(oauthRepository.existsBySubAndOauthProvider(payload.sub(), provider)).thenReturn(true);
+
+ // when
+ OauthErrorException ex = assertThrows(OauthErrorException.class, () -> oauthService.signUp(provider, req));
+
+ // then
+ assertThat(ex.getErrorCode()).isEqualTo(OauthErrorCode.CONFLICT);
+ }
+ }
+ }
+}
diff --git a/src/test/java/com/rabbitmqprac/service/UserServiceTest.java b/src/test/java/com/rabbitmqprac/service/UserServiceTest.java
index 9db3168..b0d72f8 100644
--- a/src/test/java/com/rabbitmqprac/service/UserServiceTest.java
+++ b/src/test/java/com/rabbitmqprac/service/UserServiceTest.java
@@ -44,7 +44,7 @@ class UserSaveSuccessScenarios {
@DisplayName("유저 저장 성공")
void saveUser() {
// given
- UserCreateReq req = new UserCreateReq(user.getUsername(), user.getPassword());
+ UserCreateReq req = new UserCreateReq(user.getNickname(), user.getUsername(), user.getPassword());
given(userRepository.save(any(User.class))).willReturn(user);
given(userRepository.existsByUsername(user.getUsername())).willReturn(Boolean.FALSE);
@@ -61,10 +61,10 @@ void saveUser() {
@DisplayName("유저 저장 실패 시나리오")
class UserSaveFailScenarios {
@Test
- @DisplayName("username 중복")
+ @DisplayName("nickname 중복")
void saveUserWhenExistingUserByUsername() {
// given
- UserCreateReq req = new UserCreateReq(user.getUsername(), user.getPassword());
+ UserCreateReq req = new UserCreateReq(user.getNickname(), user.getUsername(), user.getPassword());
given(userRepository.existsByUsername(user.getUsername())).willReturn(Boolean.TRUE);
// when
@@ -184,7 +184,7 @@ void readUserByUsername() {
@DisplayName("username으로 유저 조회 실패 시나리오")
class UserReadByUsernameFailScenarios {
@Test
- @DisplayName("존재하지 않는 username")
+ @DisplayName("존재하지 않는 nickname")
void readUserByUsernameWhenNotFoundedUser() {
// given
given(userRepository.findByUsername(user.getUsername())).willReturn(Optional.empty());
@@ -250,10 +250,10 @@ void updateNicknameFailByDuplicate() {
}
@Nested
- @DisplayName("username 중복 체크 성공 시나리오")
+ @DisplayName("nickname 중복 체크 성공 시나리오")
class UsernameDuplicateCheckSuccessScenarios {
@Test
- @DisplayName("username 중복 체크 - 중복")
+ @DisplayName("nickname 중복 체크 - 중복")
void isDuplicatedUsernameTrue() {
// given
given(userRepository.existsByUsername(user.getUsername())).willReturn(Boolean.TRUE);
@@ -266,7 +266,7 @@ void isDuplicatedUsernameTrue() {
}
@Test
- @DisplayName("username 중복 체크 - 중복 아님")
+ @DisplayName("nickname 중복 체크 - 중복 아님")
void isDuplicatedUsernameFalse() {
// given
given(userRepository.existsByUsername(user.getUsername())).willReturn(Boolean.FALSE);