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[] 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);