Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions checkstyle/config/rules.xml
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ The following rules in the Naver coding convention cannot be checked by this con
<property name="allowedAbbreviationLength" value="1"/>
<message key="abbreviation.as.word"
value="[list-uppercase-abbr] Abbreviation in name ''{0}'' must contain no more than {1}"/>
<property name="allowedAbbreviations" value="DAO,BO,MySQL,SQL"/>
<property name="allowedAbbreviations" value="DAO,BO,MySQL,SQL,OIDC,RSA"/>
</module>

<!-- [package-lowercase] -->
Expand Down Expand Up @@ -97,7 +97,7 @@ The following rules in the Naver coding convention cannot be checked by this con
<!-- [var-lower-camelcase], [avoid-1-char-var] -->
<module name="LocalVariableName">
<property name="tokens" value="VARIABLE_DEF"/>
<property name="format" value="^[a-z][a-zA-Z0-9][a-zA-Z0-9]*$"/>
<property name="format" value="^[a-zA-Z]$|^[a-z][a-zA-Z0-9]*$"/>
<property name="allowOneCharVarInForLoop" value="true"/>
<message key="name.invalidPattern"
value="[var-lower-camelcase][avoid-1-char-var] Local variable name ''{0}'' must match pattern ''{1}''."/>
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Long, Jwts> 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())
);
}
}
Original file line number Diff line number Diff line change
@@ -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자의 영문 λŒ€/μ†Œλ¬Έμž, 숫자, 특수문자λ₯Ό μ‚¬μš©ν•΄μ£Όμ„Έμš”. (적어도 ν•˜λ‚˜μ˜ 영문 μ†Œλ¬Έμž, 숫자 포함)")
Expand All @@ -20,4 +24,3 @@ public String getEncodedPassword(PasswordEncoder bCryptPasswordEncoder) {
return bCryptPasswordEncoder.encode(password);
}
}

Original file line number Diff line number Diff line change
@@ -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
) {
}
Original file line number Diff line number Diff line change
@@ -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
) {

}
Original file line number Diff line number Diff line change
@@ -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
) {
}
16 changes: 16 additions & 0 deletions src/main/java/com/rabbitmqprac/config/OauthConfig.java
Original file line number Diff line number Diff line change
@@ -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 {
}
72 changes: 72 additions & 0 deletions src/main/java/com/rabbitmqprac/config/WebClientConfig.java
Original file line number Diff line number Diff line change
@@ -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();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ public Pair<Long, Jwts> 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));
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
}
}
Original file line number Diff line number Diff line change
@@ -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;
}
}
Original file line number Diff line number Diff line change
@@ -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<Long, Jwts> 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<Long, Jwts> 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));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading