diff --git a/backend/baton/build.gradle b/backend/baton/build.gradle index cc3e58976..a0e7f2817 100644 --- a/backend/baton/build.gradle +++ b/backend/baton/build.gradle @@ -36,8 +36,12 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-thymeleaf' implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.springframework.boot:spring-boot-starter-validation' + implementation 'org.springframework.boot:spring-boot-starter-webflux' implementation 'org.flywaydb:flyway-core' implementation 'org.flywaydb:flyway-mysql' + implementation 'io.jsonwebtoken:jjwt-api:0.11.5' + runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5' + runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5' compileOnly 'org.projectlombok:lombok' runtimeOnly 'com.h2database:h2' runtimeOnly 'com.mysql:mysql-connector-j' diff --git a/backend/baton/src/main/java/touch/baton/BatonApplication.java b/backend/baton/src/main/java/touch/baton/BatonApplication.java index 5face043a..228971d79 100644 --- a/backend/baton/src/main/java/touch/baton/BatonApplication.java +++ b/backend/baton/src/main/java/touch/baton/BatonApplication.java @@ -2,7 +2,9 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.context.properties.ConfigurationPropertiesScan; +@ConfigurationPropertiesScan @SpringBootApplication public class BatonApplication { diff --git a/backend/baton/src/main/java/touch/baton/config/ArgumentResolverConfig.java b/backend/baton/src/main/java/touch/baton/config/ArgumentResolverConfig.java new file mode 100644 index 000000000..d2318e70f --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/config/ArgumentResolverConfig.java @@ -0,0 +1,23 @@ +package touch.baton.config; + +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import touch.baton.domain.oauth.controller.resolver.AuthRunnerPrincipalArgumentResolver; +import touch.baton.domain.oauth.controller.resolver.AuthSupporterPrincipalArgumentResolver; + +import java.util.List; + +@RequiredArgsConstructor +@Configuration +public class ArgumentResolverConfig extends WebMvcConfig { + + private final AuthRunnerPrincipalArgumentResolver authRunnerPrincipalArgumentResolver; + private final AuthSupporterPrincipalArgumentResolver authSupporterPrincipalArgumentResolver; + + @Override + public void addArgumentResolvers(final List resolvers) { + resolvers.add(authRunnerPrincipalArgumentResolver); + resolvers.add(authSupporterPrincipalArgumentResolver); + } +} diff --git a/backend/baton/src/main/java/touch/baton/config/OauthHttpInterfaceConfig.java b/backend/baton/src/main/java/touch/baton/config/OauthHttpInterfaceConfig.java new file mode 100644 index 000000000..724caa1fe --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/config/OauthHttpInterfaceConfig.java @@ -0,0 +1,25 @@ +package touch.baton.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.reactive.function.client.WebClient; +import org.springframework.web.service.invoker.HttpServiceProxyFactory; +import touch.baton.infra.auth.oauth.github.http.GithubHttpInterface; + +import static org.springframework.web.reactive.function.client.support.WebClientAdapter.forClient; + +@Configuration +public class OauthHttpInterfaceConfig { + + @Bean + public GithubHttpInterface githubHttpClient() { + return createHttpClient(GithubHttpInterface.class); + } + + private T createHttpClient(final Class clazz) { + return HttpServiceProxyFactory + .builder(forClient(WebClient.create())) + .build() + .createClient(clazz); + } +} diff --git a/backend/baton/src/main/java/touch/baton/config/WebMvcConfig.java b/backend/baton/src/main/java/touch/baton/config/WebMvcConfig.java index 7d5dbf341..527b3223b 100644 --- a/backend/baton/src/main/java/touch/baton/config/WebMvcConfig.java +++ b/backend/baton/src/main/java/touch/baton/config/WebMvcConfig.java @@ -5,8 +5,14 @@ import org.springframework.web.servlet.config.annotation.CorsRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; +import static org.springframework.http.HttpHeaders.AUTHORIZATION; import static org.springframework.http.HttpHeaders.LOCATION; -import static org.springframework.http.HttpMethod.*; +import static org.springframework.http.HttpMethod.DELETE; +import static org.springframework.http.HttpMethod.GET; +import static org.springframework.http.HttpMethod.OPTIONS; +import static org.springframework.http.HttpMethod.PATCH; +import static org.springframework.http.HttpMethod.POST; +import static org.springframework.http.HttpMethod.PUT; @Configuration public class WebMvcConfig implements WebMvcConfigurer { @@ -18,9 +24,9 @@ public class WebMvcConfig implements WebMvcConfigurer { public void addCorsMappings(final CorsRegistry registry) { registry.addMapping("/**") .allowedOrigins(allowedOrigin) - .allowCredentials(false) + .allowCredentials(true) .allowedMethods(GET.name(), POST.name(), PUT.name(), PATCH.name(), DELETE.name(), OPTIONS.name()) - .exposedHeaders(LOCATION) + .exposedHeaders(LOCATION, AUTHORIZATION) .maxAge(3600); } } diff --git a/backend/baton/src/main/java/touch/baton/config/converter/ConverterConfig.java b/backend/baton/src/main/java/touch/baton/config/converter/ConverterConfig.java index 7c0577823..161d12c52 100644 --- a/backend/baton/src/main/java/touch/baton/config/converter/ConverterConfig.java +++ b/backend/baton/src/main/java/touch/baton/config/converter/ConverterConfig.java @@ -20,6 +20,7 @@ public class ConverterConfig implements WebMvcConfigurer { @Override public void addFormatters(final FormatterRegistry registry) { registry.addConverter(new StringDateToLocalDateTimeConverter(DEFAULT_DATE_TIME_FORMAT, KOREA_TIME_ZONE)); + registry.addConverter(new OauthTypeConverter()); } @Bean diff --git a/backend/baton/src/main/java/touch/baton/config/converter/OauthTypeConverter.java b/backend/baton/src/main/java/touch/baton/config/converter/OauthTypeConverter.java new file mode 100644 index 000000000..be414a79f --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/config/converter/OauthTypeConverter.java @@ -0,0 +1,12 @@ +package touch.baton.config.converter; + +import org.springframework.core.convert.converter.Converter; +import touch.baton.domain.oauth.OauthType; + +public class OauthTypeConverter implements Converter { + + @Override + public OauthType convert(final String source) { + return OauthType.from(source); + } +} diff --git a/backend/baton/src/main/java/touch/baton/domain/common/exception/ClientErrorCode.java b/backend/baton/src/main/java/touch/baton/domain/common/exception/ClientErrorCode.java index 061b39c9f..38798abdb 100644 --- a/backend/baton/src/main/java/touch/baton/domain/common/exception/ClientErrorCode.java +++ b/backend/baton/src/main/java/touch/baton/domain/common/exception/ClientErrorCode.java @@ -11,7 +11,16 @@ public enum ClientErrorCode { CONTENTS_OVERFLOW(HttpStatus.BAD_REQUEST, "RP005", "내용은 1000자 까지 입력해주세요."), PAST_DEADLINE(HttpStatus.BAD_REQUEST, "RP006", "마감일은 오늘보다 과거일 수 없습니다."), CONTENTS_NOT_FOUND(HttpStatus.NOT_FOUND, "RP007", "존재하지 않는 게시물입니다."), - TAGS_ARE_NULL(HttpStatus.BAD_REQUEST, "RP008", "태그 목록을 빈 값이라도 입력해주세요."); + TAGS_ARE_NULL(HttpStatus.BAD_REQUEST, "RP008", "태그 목록을 빈 값이라도 입력해주세요."), + COMPANY_IS_NULL(HttpStatus.BAD_REQUEST, "OM001", "사용자의 회사 정보를 입력해주세요."), + OAUTH_REQUEST_URL_PROVIDER_IS_WRONG(HttpStatus.BAD_REQUEST, "OA001", "redirect 할 url 이 조회되지 않는 잘못된 소셜 타입입니다."), + OAUTH_INFORMATION_CLIENT_IS_WRONG(HttpStatus.BAD_REQUEST, "OA002", " 소셜 계정 정보를 조회할 수 없는 잘못된 소셜 타입입니다."), + OAUTH_AUTHORIZATION_VALUE_IS_NULL(HttpStatus.BAD_REQUEST, "OA003", "Authorization 값을 입력해주세요."), + OAUTH_AUTHORIZATION_BEARER_TYPE_NOT_FOUND(HttpStatus.BAD_REQUEST, "OA004", "Authorization 값을 Bearer 타입으로 입력해주세요."), + JWT_SIGNATURE_IS_WRONG(HttpStatus.BAD_REQUEST, "JW001", "시그니처가 다른 잘못된 JWT 입니다."), + JWT_FORM_IS_WRONG(HttpStatus.BAD_REQUEST, "JW002", "잘못 생성된 JWT 로 디코딩 할 수 없습니다."), + JWT_CLAIM_IS_WRONG(HttpStatus.BAD_REQUEST, "JW003", "JWT 에 기대한 정보를 모두 포함하고 있지 않습니다."), + JWT_CLAIM_EMAIL_IS_WRONG(HttpStatus.BAD_REQUEST, "JW004", "사용자의 잘못된 이메일 정보를 가진 JWT 입니다."); private final HttpStatus httpStatus; private final String errorCode; diff --git a/backend/baton/src/main/java/touch/baton/domain/oauth/OauthInformation.java b/backend/baton/src/main/java/touch/baton/domain/oauth/OauthInformation.java new file mode 100644 index 000000000..374d31b92 --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/domain/oauth/OauthInformation.java @@ -0,0 +1,48 @@ +package touch.baton.domain.oauth; + + +import lombok.Builder; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; +import touch.baton.domain.member.vo.Email; +import touch.baton.domain.member.vo.GithubUrl; +import touch.baton.domain.member.vo.ImageUrl; +import touch.baton.domain.member.vo.MemberName; +import touch.baton.domain.member.vo.OauthId; + +import static lombok.AccessLevel.PROTECTED; + +@EqualsAndHashCode +@Getter +@NoArgsConstructor(access = PROTECTED) +public class OauthInformation { + + private SocialToken socialToken; + + private OauthId oauthId; + + private MemberName memberName; + + private Email email; + + private GithubUrl githubUrl; + + private ImageUrl imageUrl; + + @Builder + private OauthInformation(final SocialToken socialToken, + final OauthId oauthId, + final MemberName memberName, + final Email email, + final GithubUrl githubUrl, + final ImageUrl imageUrl + ) { + this.socialToken = socialToken; + this.oauthId = oauthId; + this.memberName = memberName; + this.email = email; + this.githubUrl = githubUrl; + this.imageUrl = imageUrl; + } +} diff --git a/backend/baton/src/main/java/touch/baton/domain/oauth/OauthType.java b/backend/baton/src/main/java/touch/baton/domain/oauth/OauthType.java new file mode 100644 index 000000000..fb2de4143 --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/domain/oauth/OauthType.java @@ -0,0 +1,12 @@ +package touch.baton.domain.oauth; + +import static java.util.Locale.ENGLISH; + +public enum OauthType { + + GITHUB; + + public static OauthType from(final String name) { + return OauthType.valueOf(name.toUpperCase(ENGLISH)); + } +} diff --git a/backend/baton/src/main/java/touch/baton/domain/oauth/SocialToken.java b/backend/baton/src/main/java/touch/baton/domain/oauth/SocialToken.java new file mode 100644 index 000000000..2abaa9794 --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/domain/oauth/SocialToken.java @@ -0,0 +1,15 @@ +package touch.baton.domain.oauth; + +import lombok.EqualsAndHashCode; +import lombok.Getter; + +@EqualsAndHashCode +@Getter +public final class SocialToken { + + private final String value; + + public SocialToken(final String value) { + this.value = value; + } +} diff --git a/backend/baton/src/main/java/touch/baton/domain/oauth/authcode/AuthCodeRequestUrlProvider.java b/backend/baton/src/main/java/touch/baton/domain/oauth/authcode/AuthCodeRequestUrlProvider.java new file mode 100644 index 000000000..089b64cf5 --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/domain/oauth/authcode/AuthCodeRequestUrlProvider.java @@ -0,0 +1,10 @@ +package touch.baton.domain.oauth.authcode; + +import touch.baton.domain.oauth.OauthType; + +public interface AuthCodeRequestUrlProvider { + + OauthType oauthServer(); + + String getRequestUrl(); +} diff --git a/backend/baton/src/main/java/touch/baton/domain/oauth/authcode/AuthCodeRequestUrlProviderComposite.java b/backend/baton/src/main/java/touch/baton/domain/oauth/authcode/AuthCodeRequestUrlProviderComposite.java new file mode 100644 index 000000000..f25ff3ade --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/domain/oauth/authcode/AuthCodeRequestUrlProviderComposite.java @@ -0,0 +1,35 @@ +package touch.baton.domain.oauth.authcode; + +import org.springframework.stereotype.Component; +import touch.baton.domain.common.exception.ClientErrorCode; +import touch.baton.domain.oauth.OauthType; +import touch.baton.domain.oauth.exception.OauthRequestException; + +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; + +import static java.util.function.Function.identity; + +@Component +public class AuthCodeRequestUrlProviderComposite { + + private final Map authCodeProviders; + + public AuthCodeRequestUrlProviderComposite(final Set authCodeProviders) { + this.authCodeProviders = authCodeProviders.stream() + .collect(Collectors.toMap( + AuthCodeRequestUrlProvider::oauthServer, identity() + )); + } + + public String findRequestUrl(final OauthType oauthType) { + return findAuthCodeProvider(oauthType).getRequestUrl(); + } + + private AuthCodeRequestUrlProvider findAuthCodeProvider(final OauthType oauthType) { + return Optional.ofNullable(authCodeProviders.get(oauthType)) + .orElseThrow(() -> new OauthRequestException(ClientErrorCode.OAUTH_REQUEST_URL_PROVIDER_IS_WRONG)); + } +} diff --git a/backend/baton/src/main/java/touch/baton/domain/oauth/client/OauthInformationClient.java b/backend/baton/src/main/java/touch/baton/domain/oauth/client/OauthInformationClient.java new file mode 100644 index 000000000..21c9a00bb --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/domain/oauth/client/OauthInformationClient.java @@ -0,0 +1,11 @@ +package touch.baton.domain.oauth.client; + +import touch.baton.domain.oauth.OauthInformation; +import touch.baton.domain.oauth.OauthType; + +public interface OauthInformationClient { + + OauthType oauthType(); + + OauthInformation fetchInformation(final String authCode); +} diff --git a/backend/baton/src/main/java/touch/baton/domain/oauth/client/OauthInformationClientComposite.java b/backend/baton/src/main/java/touch/baton/domain/oauth/client/OauthInformationClientComposite.java new file mode 100644 index 000000000..c93c09f07 --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/domain/oauth/client/OauthInformationClientComposite.java @@ -0,0 +1,34 @@ +package touch.baton.domain.oauth.client; + +import org.springframework.stereotype.Component; +import touch.baton.domain.common.exception.ClientErrorCode; +import touch.baton.domain.oauth.OauthInformation; +import touch.baton.domain.oauth.OauthType; +import touch.baton.domain.oauth.exception.OauthRequestException; + +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; + +import static java.util.function.Function.identity; + +@Component +public class OauthInformationClientComposite { + + private final Map clients; + + public OauthInformationClientComposite(final Set clients) { + this.clients = clients.stream() + .collect(Collectors.toMap( + OauthInformationClient::oauthType, + identity() + )); + } + + public OauthInformation fetchInformation(final OauthType oauthType, final String authCode) { + return Optional.ofNullable(clients.get(oauthType)) + .orElseThrow(() -> new OauthRequestException(ClientErrorCode.OAUTH_INFORMATION_CLIENT_IS_WRONG)) + .fetchInformation(authCode); + } +} diff --git a/backend/baton/src/main/java/touch/baton/domain/oauth/controller/OauthController.java b/backend/baton/src/main/java/touch/baton/domain/oauth/controller/OauthController.java new file mode 100644 index 000000000..29bcf7b29 --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/domain/oauth/controller/OauthController.java @@ -0,0 +1,47 @@ +package touch.baton.domain.oauth.controller; + +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import touch.baton.domain.oauth.OauthType; +import touch.baton.domain.oauth.service.OauthService; + +import java.io.IOException; + +import static org.springframework.http.HttpHeaders.AUTHORIZATION; +import static org.springframework.http.HttpStatus.FOUND; + +@RequiredArgsConstructor +@RequestMapping("/oauth") +@RestController +public class OauthController { + + private final OauthService oauthService; + + @GetMapping("/{oauthType}") + public ResponseEntity redirectAuthCode(@PathVariable("oauthType") final OauthType oauthType, + final HttpServletResponse response + ) throws IOException { + final String redirectUrl = oauthService.readAuthCodeRedirect(oauthType); + response.sendRedirect(redirectUrl); + + return ResponseEntity.status(FOUND).build(); + } + + @PostMapping("/login/{oauthType}") + public ResponseEntity login(@PathVariable final OauthType oauthType, + @RequestParam final String code + ) { + final String jwtToken = oauthService.login(oauthType, code); + + return ResponseEntity.ok() + .header(AUTHORIZATION, jwtToken) + .build(); + } +} diff --git a/backend/baton/src/main/java/touch/baton/domain/oauth/controller/resolver/AuthRunnerPrincipal.java b/backend/baton/src/main/java/touch/baton/domain/oauth/controller/resolver/AuthRunnerPrincipal.java new file mode 100644 index 000000000..00acec92c --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/domain/oauth/controller/resolver/AuthRunnerPrincipal.java @@ -0,0 +1,11 @@ +package touch.baton.domain.oauth.controller.resolver; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.PARAMETER) +@Retention(RetentionPolicy.RUNTIME) +public @interface AuthRunnerPrincipal { +} diff --git a/backend/baton/src/main/java/touch/baton/domain/oauth/controller/resolver/AuthRunnerPrincipalArgumentResolver.java b/backend/baton/src/main/java/touch/baton/domain/oauth/controller/resolver/AuthRunnerPrincipalArgumentResolver.java new file mode 100644 index 000000000..181218803 --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/domain/oauth/controller/resolver/AuthRunnerPrincipalArgumentResolver.java @@ -0,0 +1,58 @@ +package touch.baton.domain.oauth.controller.resolver; + +import io.jsonwebtoken.Claims; +import lombok.RequiredArgsConstructor; +import org.springframework.core.MethodParameter; +import org.springframework.stereotype.Component; +import org.springframework.web.bind.support.WebDataBinderFactory; +import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.method.support.ModelAndViewContainer; +import touch.baton.domain.common.exception.ClientErrorCode; +import touch.baton.domain.oauth.exception.OauthRequestException; +import touch.baton.domain.oauth.repository.OauthRunnerRepository; +import touch.baton.domain.runner.Runner; +import touch.baton.infra.auth.jwt.JwtDecoder; + +import java.util.Objects; + +import static org.springframework.http.HttpHeaders.AUTHORIZATION; + +@RequiredArgsConstructor +@Component +public class AuthRunnerPrincipalArgumentResolver implements HandlerMethodArgumentResolver { + + private static final String BEARER = "Bearer "; + + private final JwtDecoder jwtDecoder; + private final OauthRunnerRepository oauthRunnerRepository; + + @Override + public boolean supportsParameter(final MethodParameter parameter) { + return parameter.hasParameterAnnotation(AuthRunnerPrincipal.class); + } + + @Override + public Object resolveArgument(final MethodParameter parameter, + final ModelAndViewContainer mavContainer, + final NativeWebRequest webRequest, + final WebDataBinderFactory binderFactory + ) throws Exception { + final String authHeader = webRequest.getHeader(AUTHORIZATION); + + if (Objects.isNull(authHeader)) { + throw new OauthRequestException(ClientErrorCode.OAUTH_AUTHORIZATION_VALUE_IS_NULL); + } + if (!authHeader.startsWith(BEARER)) { + throw new OauthRequestException(ClientErrorCode.OAUTH_AUTHORIZATION_BEARER_TYPE_NOT_FOUND); + } + + final String token = authHeader.substring(BEARER.length()); + final Claims claims = jwtDecoder.parseJwtToken(token); + final String email = claims.get("email", String.class); + final Runner foundRunner = oauthRunnerRepository.joinByMemberEmail(email) + .orElseThrow(() -> new OauthRequestException(ClientErrorCode.JWT_CLAIM_EMAIL_IS_WRONG)); + + return foundRunner; + } +} diff --git a/backend/baton/src/main/java/touch/baton/domain/oauth/controller/resolver/AuthSupporterPrincipal.java b/backend/baton/src/main/java/touch/baton/domain/oauth/controller/resolver/AuthSupporterPrincipal.java new file mode 100644 index 000000000..e8ea2fb35 --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/domain/oauth/controller/resolver/AuthSupporterPrincipal.java @@ -0,0 +1,11 @@ +package touch.baton.domain.oauth.controller.resolver; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.PARAMETER) +@Retention(RetentionPolicy.RUNTIME) +public @interface AuthSupporterPrincipal { +} diff --git a/backend/baton/src/main/java/touch/baton/domain/oauth/controller/resolver/AuthSupporterPrincipalArgumentResolver.java b/backend/baton/src/main/java/touch/baton/domain/oauth/controller/resolver/AuthSupporterPrincipalArgumentResolver.java new file mode 100644 index 000000000..f43e076ef --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/domain/oauth/controller/resolver/AuthSupporterPrincipalArgumentResolver.java @@ -0,0 +1,58 @@ +package touch.baton.domain.oauth.controller.resolver; + +import io.jsonwebtoken.Claims; +import lombok.RequiredArgsConstructor; +import org.springframework.core.MethodParameter; +import org.springframework.stereotype.Component; +import org.springframework.web.bind.support.WebDataBinderFactory; +import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.method.support.ModelAndViewContainer; +import touch.baton.domain.common.exception.ClientErrorCode; +import touch.baton.domain.oauth.exception.OauthRequestException; +import touch.baton.domain.oauth.repository.OauthSupporterRepository; +import touch.baton.domain.supporter.Supporter; +import touch.baton.infra.auth.jwt.JwtDecoder; + +import java.util.Objects; + +import static org.springframework.http.HttpHeaders.AUTHORIZATION; + +@RequiredArgsConstructor +@Component +public class AuthSupporterPrincipalArgumentResolver implements HandlerMethodArgumentResolver { + + private static final String BEARER = "Bearer "; + + private final JwtDecoder jwtDecoder; + private final OauthSupporterRepository oauthSupporterRepository; + + @Override + public boolean supportsParameter(final MethodParameter parameter) { + return parameter.hasParameterAnnotation(AuthSupporterPrincipal.class); + } + + @Override + public Object resolveArgument(final MethodParameter parameter, + final ModelAndViewContainer mavContainer, + final NativeWebRequest webRequest, + final WebDataBinderFactory binderFactory + ) throws Exception { + final String authHeader = webRequest.getHeader(AUTHORIZATION); + + if (Objects.isNull(authHeader)) { + throw new OauthRequestException(ClientErrorCode.OAUTH_AUTHORIZATION_VALUE_IS_NULL); + } + if (!authHeader.startsWith(BEARER)) { + throw new OauthRequestException(ClientErrorCode.OAUTH_AUTHORIZATION_BEARER_TYPE_NOT_FOUND); + } + + final String token = authHeader.substring(BEARER.length()); + final Claims claims = jwtDecoder.parseJwtToken(token); + final String email = claims.get("email", String.class); + final Supporter foundSupporter = oauthSupporterRepository.joinByMemberEmail(email) + .orElseThrow(() -> new OauthRequestException(ClientErrorCode.JWT_CLAIM_EMAIL_IS_WRONG)); + + return foundSupporter; + } +} diff --git a/backend/baton/src/main/java/touch/baton/domain/oauth/exception/OauthBusinessException.java b/backend/baton/src/main/java/touch/baton/domain/oauth/exception/OauthBusinessException.java new file mode 100644 index 000000000..e5a379890 --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/domain/oauth/exception/OauthBusinessException.java @@ -0,0 +1,10 @@ +package touch.baton.domain.oauth.exception; + +import touch.baton.domain.common.exception.BusinessException; + +public class OauthBusinessException extends BusinessException { + + public OauthBusinessException(final String message) { + super(message); + } +} diff --git a/backend/baton/src/main/java/touch/baton/domain/oauth/exception/OauthRequestException.java b/backend/baton/src/main/java/touch/baton/domain/oauth/exception/OauthRequestException.java new file mode 100644 index 000000000..6aa131f97 --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/domain/oauth/exception/OauthRequestException.java @@ -0,0 +1,11 @@ +package touch.baton.domain.oauth.exception; + +import touch.baton.domain.common.exception.ClientErrorCode; +import touch.baton.domain.common.exception.ClientRequestException; + +public class OauthRequestException extends ClientRequestException { + + public OauthRequestException(final ClientErrorCode errorCode) { + super(errorCode); + } +} diff --git a/backend/baton/src/main/java/touch/baton/domain/oauth/repository/OauthMemberRepository.java b/backend/baton/src/main/java/touch/baton/domain/oauth/repository/OauthMemberRepository.java new file mode 100644 index 000000000..fb8d7e2b5 --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/domain/oauth/repository/OauthMemberRepository.java @@ -0,0 +1,12 @@ +package touch.baton.domain.oauth.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import touch.baton.domain.member.Member; +import touch.baton.domain.member.vo.OauthId; + +import java.util.Optional; + +public interface OauthMemberRepository extends JpaRepository { + + Optional findMemberByOauthId(final OauthId oauthId); +} diff --git a/backend/baton/src/main/java/touch/baton/domain/oauth/repository/OauthRunnerRepository.java b/backend/baton/src/main/java/touch/baton/domain/oauth/repository/OauthRunnerRepository.java new file mode 100644 index 000000000..bc63b8c4e --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/domain/oauth/repository/OauthRunnerRepository.java @@ -0,0 +1,19 @@ +package touch.baton.domain.oauth.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import touch.baton.domain.runner.Runner; + +import java.util.Optional; + +public interface OauthRunnerRepository extends JpaRepository { + + @Query(""" + select r, r.member + from Runner r + join fetch Member m on m.id = r.member.id + where m.email.value = :email + """) + Optional joinByMemberEmail(@Param("email") final String email); +} diff --git a/backend/baton/src/main/java/touch/baton/domain/oauth/repository/OauthSupporterRepository.java b/backend/baton/src/main/java/touch/baton/domain/oauth/repository/OauthSupporterRepository.java new file mode 100644 index 000000000..7cdfe555c --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/domain/oauth/repository/OauthSupporterRepository.java @@ -0,0 +1,19 @@ +package touch.baton.domain.oauth.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import touch.baton.domain.supporter.Supporter; + +import java.util.Optional; + +public interface OauthSupporterRepository extends JpaRepository { + + @Query(""" + select s, s.member + from Supporter s + join fetch Member m on m.id = s.member.id + where m.email.value = :email + """) + Optional joinByMemberEmail(@Param("email") final String email); +} diff --git a/backend/baton/src/main/java/touch/baton/domain/oauth/service/OauthService.java b/backend/baton/src/main/java/touch/baton/domain/oauth/service/OauthService.java new file mode 100644 index 000000000..71cef1178 --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/domain/oauth/service/OauthService.java @@ -0,0 +1,95 @@ +package touch.baton.domain.oauth.service; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import touch.baton.domain.common.vo.Grade; +import touch.baton.domain.common.vo.Introduction; +import touch.baton.domain.common.vo.TotalRating; +import touch.baton.domain.member.Member; +import touch.baton.domain.member.vo.Company; +import touch.baton.domain.oauth.OauthInformation; +import touch.baton.domain.oauth.OauthType; +import touch.baton.domain.oauth.authcode.AuthCodeRequestUrlProviderComposite; +import touch.baton.domain.oauth.client.OauthInformationClientComposite; +import touch.baton.domain.oauth.repository.OauthMemberRepository; +import touch.baton.domain.oauth.repository.OauthRunnerRepository; +import touch.baton.domain.oauth.repository.OauthSupporterRepository; +import touch.baton.domain.runner.Runner; +import touch.baton.domain.supporter.Supporter; +import touch.baton.domain.supporter.vo.ReviewCount; +import touch.baton.domain.supporter.vo.StarCount; +import touch.baton.domain.technicaltag.SupporterTechnicalTags; +import touch.baton.infra.auth.jwt.JwtEncoder; + +import java.util.ArrayList; +import java.util.Map; +import java.util.Optional; + +@RequiredArgsConstructor +@Service +public class OauthService { + + private final AuthCodeRequestUrlProviderComposite authCodeRequestUrlProviderComposite; + private final OauthInformationClientComposite oauthInformationClientComposite; + private final OauthMemberRepository oauthMemberRepository; + private final OauthRunnerRepository oauthRunnerRepository; + private final OauthSupporterRepository oauthSupporterRepository; + private final JwtEncoder jwtEncoder; + + public String readAuthCodeRedirect(final OauthType oauthType) { + return authCodeRequestUrlProviderComposite.findRequestUrl(oauthType); + } + + public String login(final OauthType oauthType, final String code) { + final OauthInformation oauthInformation = oauthInformationClientComposite.fetchInformation(oauthType, code); + + final Optional maybeMember = oauthMemberRepository.findMemberByOauthId(oauthInformation.getOauthId()); + if (maybeMember.isEmpty()) { + final Member savedMember = signUpMember(oauthInformation); + saveNewRunner(savedMember); + saveNewSupporter(savedMember); + } + + return jwtEncoder.jwtToken(Map.of( + "email", oauthInformation.getEmail().getValue()) + ); + } + + private Member signUpMember(final OauthInformation oauthInformation) { + final Member newMember = Member.builder() + .memberName(oauthInformation.getMemberName()) + .email(oauthInformation.getEmail()) + .oauthId(oauthInformation.getOauthId()) + .githubUrl(oauthInformation.getGithubUrl()) + .company(new Company("")) + .imageUrl(oauthInformation.getImageUrl()) + .build(); + + return oauthMemberRepository.save(newMember); + } + + private Runner saveNewRunner(final Member member) { + final Runner newRunner = Runner.builder() + .totalRating(new TotalRating(0)) + .grade(Grade.BARE_FOOT) + .introduction(new Introduction("")) + .member(member) + .build(); + + return oauthRunnerRepository.save(newRunner); + } + + private Supporter saveNewSupporter(final Member member) { + final Supporter newSupporter = Supporter.builder() + .reviewCount(new ReviewCount(0)) + .starCount(new StarCount(0)) + .totalRating(new TotalRating(0)) + .grade(Grade.BARE_FOOT) + .introduction(new Introduction("")) + .member(member) + .supporterTechnicalTags(new SupporterTechnicalTags(new ArrayList<>())) + .build(); + + return oauthSupporterRepository.save(newSupporter); + } +} diff --git a/backend/baton/src/main/java/touch/baton/infra/auth/jwt/JwtConfig.java b/backend/baton/src/main/java/touch/baton/infra/auth/jwt/JwtConfig.java new file mode 100644 index 000000000..49bfcf2fc --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/infra/auth/jwt/JwtConfig.java @@ -0,0 +1,25 @@ +package touch.baton.infra.auth.jwt; + +import io.jsonwebtoken.security.Keys; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.context.properties.ConfigurationProperties; + +import java.security.Key; + +import static java.nio.charset.StandardCharsets.UTF_8; + +@RequiredArgsConstructor +@ConfigurationProperties("jwt.token") +public class JwtConfig { + + private final String secretKey; + private final String issuer; + + public Key getSecretKey() { + return Keys.hmacShaKeyFor(secretKey.getBytes(UTF_8)); + } + + public String getIssuer() { + return this.issuer; + } +} diff --git a/backend/baton/src/main/java/touch/baton/infra/auth/jwt/JwtDecoder.java b/backend/baton/src/main/java/touch/baton/infra/auth/jwt/JwtDecoder.java new file mode 100644 index 000000000..7d64c6c66 --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/infra/auth/jwt/JwtDecoder.java @@ -0,0 +1,37 @@ +package touch.baton.infra.auth.jwt; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.IncorrectClaimException; +import io.jsonwebtoken.JwtParser; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.MalformedJwtException; +import io.jsonwebtoken.MissingClaimException; +import io.jsonwebtoken.security.SignatureException; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import touch.baton.domain.common.exception.ClientErrorCode; +import touch.baton.domain.oauth.exception.OauthRequestException; + +@RequiredArgsConstructor +@Component +public class JwtDecoder { + + private final JwtConfig jwtConfig; + + public Claims parseJwtToken(final String token) { + try { + final JwtParser jwtParser = Jwts.parserBuilder() + .setSigningKey(jwtConfig.getSecretKey()) + .requireIssuer(jwtConfig.getIssuer()) + .build(); + + return jwtParser.parseClaimsJws(token).getBody(); + } catch (SignatureException e) { + throw new OauthRequestException(ClientErrorCode.JWT_SIGNATURE_IS_WRONG); + } catch (MalformedJwtException e) { + throw new OauthRequestException(ClientErrorCode.JWT_FORM_IS_WRONG); + } catch (MissingClaimException | IncorrectClaimException e) { + throw new OauthRequestException(ClientErrorCode.JWT_CLAIM_IS_WRONG); + } + } +} diff --git a/backend/baton/src/main/java/touch/baton/infra/auth/jwt/JwtEncoder.java b/backend/baton/src/main/java/touch/baton/infra/auth/jwt/JwtEncoder.java new file mode 100644 index 000000000..a351c2e88 --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/infra/auth/jwt/JwtEncoder.java @@ -0,0 +1,36 @@ +package touch.baton.infra.auth.jwt; + + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.JwtBuilder; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.time.Duration; +import java.util.Date; +import java.util.Map; + +@RequiredArgsConstructor +@Component +public class JwtEncoder { + + private final JwtConfig jwtConfig; + + public String jwtToken(final Map payload) { + final Date now = new Date(); + final Date expiration = new Date(now.getTime() + Duration.ofDays(30).toMillis()); + final Claims claims = Jwts.claims(); + + final JwtBuilder jwtBuilder = Jwts.builder() + .signWith(jwtConfig.getSecretKey(), SignatureAlgorithm.HS256) + .setIssuer(jwtConfig.getIssuer()) + .setIssuedAt(now) + .setExpiration(expiration) + .addClaims(claims) + .addClaims(payload); + + return jwtBuilder.compact(); + } +} diff --git a/backend/baton/src/main/java/touch/baton/infra/auth/oauth/github/GithubOauthConfig.java b/backend/baton/src/main/java/touch/baton/infra/auth/oauth/github/GithubOauthConfig.java new file mode 100644 index 000000000..a72db83e8 --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/infra/auth/oauth/github/GithubOauthConfig.java @@ -0,0 +1,11 @@ +package touch.baton.infra.auth.oauth.github; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "oauth.github") +public record GithubOauthConfig(String redirectUri, + String clientId, + String clientSecret, + String[] scope +) { +} diff --git a/backend/baton/src/main/java/touch/baton/infra/auth/oauth/github/authcode/GithubAuthCodeRequestUrlProvider.java b/backend/baton/src/main/java/touch/baton/infra/auth/oauth/github/authcode/GithubAuthCodeRequestUrlProvider.java new file mode 100644 index 000000000..1fed7b572 --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/infra/auth/oauth/github/authcode/GithubAuthCodeRequestUrlProvider.java @@ -0,0 +1,31 @@ +package touch.baton.infra.auth.oauth.github.authcode; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.web.util.UriComponentsBuilder; +import touch.baton.domain.oauth.OauthType; +import touch.baton.domain.oauth.authcode.AuthCodeRequestUrlProvider; +import touch.baton.infra.auth.oauth.github.GithubOauthConfig; + +@RequiredArgsConstructor +@Component +public class GithubAuthCodeRequestUrlProvider implements AuthCodeRequestUrlProvider { + + private final GithubOauthConfig githubOauthConfig; + + @Override + public OauthType oauthServer() { + return OauthType.GITHUB; + } + + @Override + public String getRequestUrl() { + return UriComponentsBuilder + .fromUriString("https://github.com/login/oauth/authorize") + .queryParam("response_type", "code") + .queryParam("client_id", githubOauthConfig.clientId()) + .queryParam("redirect_uri", githubOauthConfig.redirectUri()) + .queryParam("scope", String.join(",", githubOauthConfig.scope())) + .toUriString(); + } +} diff --git a/backend/baton/src/main/java/touch/baton/infra/auth/oauth/github/client/GithubInformationClient.java b/backend/baton/src/main/java/touch/baton/infra/auth/oauth/github/client/GithubInformationClient.java new file mode 100644 index 000000000..5a7bedaae --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/infra/auth/oauth/github/client/GithubInformationClient.java @@ -0,0 +1,41 @@ +package touch.baton.infra.auth.oauth.github.client; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import touch.baton.domain.oauth.OauthInformation; +import touch.baton.domain.oauth.OauthType; +import touch.baton.domain.oauth.client.OauthInformationClient; +import touch.baton.infra.auth.oauth.github.http.GithubHttpInterface; +import touch.baton.infra.auth.oauth.github.request.GithubTokenRequest; +import touch.baton.infra.auth.oauth.github.response.GithubMemberResponse; +import touch.baton.infra.auth.oauth.github.token.GithubToken; +import touch.baton.infra.auth.oauth.github.GithubOauthConfig; + +@RequiredArgsConstructor +@Component +public class GithubInformationClient implements OauthInformationClient { + + private final GithubHttpInterface githubHttpInterface; + private final GithubOauthConfig githubOauthConfig; + + @Override + public OauthType oauthType() { + return OauthType.GITHUB; + } + + @Override + public OauthInformation fetchInformation(final String authCode) { + final GithubToken githubToken = githubHttpInterface.fetchToken(tokenRequestBody(authCode)); + final GithubMemberResponse githubMemberResponse = githubHttpInterface.fetchMember("Bearer " + githubToken.accessToken()); + + return githubMemberResponse.toOauthInformation(githubToken.accessToken()); + } + + private GithubTokenRequest tokenRequestBody(final String authCode) { + return new GithubTokenRequest( + githubOauthConfig.clientId(), + githubOauthConfig.clientSecret(), + authCode, + githubOauthConfig.redirectUri()); + } +} diff --git a/backend/baton/src/main/java/touch/baton/infra/auth/oauth/github/http/GithubHttpInterface.java b/backend/baton/src/main/java/touch/baton/infra/auth/oauth/github/http/GithubHttpInterface.java new file mode 100644 index 000000000..ffeb62205 --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/infra/auth/oauth/github/http/GithubHttpInterface.java @@ -0,0 +1,21 @@ +package touch.baton.infra.auth.oauth.github.http; + +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.service.annotation.GetExchange; +import org.springframework.web.service.annotation.PostExchange; +import touch.baton.infra.auth.oauth.github.request.GithubTokenRequest; +import touch.baton.infra.auth.oauth.github.response.GithubMemberResponse; +import touch.baton.infra.auth.oauth.github.token.GithubToken; + +import static org.springframework.http.HttpHeaders.AUTHORIZATION; +import static org.springframework.http.MediaType.APPLICATION_JSON_VALUE; + +public interface GithubHttpInterface { + + @PostExchange(url = "https://github.com/login/oauth/access_token", accept = APPLICATION_JSON_VALUE) + GithubToken fetchToken(@RequestBody GithubTokenRequest request); + + @GetExchange("https://api.github.com/user") + GithubMemberResponse fetchMember(@RequestHeader(name = AUTHORIZATION) String bearerToken); +} diff --git a/backend/baton/src/main/java/touch/baton/infra/auth/oauth/github/request/GithubTokenRequest.java b/backend/baton/src/main/java/touch/baton/infra/auth/oauth/github/request/GithubTokenRequest.java new file mode 100644 index 000000000..d4953742d --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/infra/auth/oauth/github/request/GithubTokenRequest.java @@ -0,0 +1,13 @@ +package touch.baton.infra.auth.oauth.github.request; + +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import static com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; + +@JsonNaming(SnakeCaseStrategy.class) +public record GithubTokenRequest(String clientId, + String clientSecret, + String code, + String redirectUri +) { +} diff --git a/backend/baton/src/main/java/touch/baton/infra/auth/oauth/github/response/GithubMemberResponse.java b/backend/baton/src/main/java/touch/baton/infra/auth/oauth/github/response/GithubMemberResponse.java new file mode 100644 index 000000000..7d2de6ca1 --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/infra/auth/oauth/github/response/GithubMemberResponse.java @@ -0,0 +1,32 @@ +package touch.baton.infra.auth.oauth.github.response; + +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import touch.baton.domain.member.vo.Email; +import touch.baton.domain.member.vo.GithubUrl; +import touch.baton.domain.member.vo.ImageUrl; +import touch.baton.domain.member.vo.MemberName; +import touch.baton.domain.member.vo.OauthId; +import touch.baton.domain.oauth.SocialToken; +import touch.baton.domain.oauth.OauthInformation; + +import static com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; + +@JsonNaming(SnakeCaseStrategy.class) +public record GithubMemberResponse(String id, + String name, + String email, + String htmlUrl, + String avatarUrl +) { + + public OauthInformation toOauthInformation(final String accessToken) { + return OauthInformation.builder() + .socialToken(new SocialToken(accessToken)) + .oauthId(new OauthId(id)) + .memberName(new MemberName(name)) + .email(new Email(email)) + .githubUrl(new GithubUrl(htmlUrl)) + .imageUrl(new ImageUrl(avatarUrl)) + .build(); + } +} diff --git a/backend/baton/src/main/java/touch/baton/infra/auth/oauth/github/token/GithubToken.java b/backend/baton/src/main/java/touch/baton/infra/auth/oauth/github/token/GithubToken.java new file mode 100644 index 000000000..011523932 --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/infra/auth/oauth/github/token/GithubToken.java @@ -0,0 +1,11 @@ +package touch.baton.infra.auth.oauth.github.token; + +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import static com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; + +@JsonNaming(SnakeCaseStrategy.class) +public record GithubToken(String tokenType, + String accessToken +) { +} diff --git a/backend/baton/src/test/java/touch/baton/config/MockMvcTest.java b/backend/baton/src/test/java/touch/baton/config/MockMvcTest.java new file mode 100644 index 000000000..53e15d74d --- /dev/null +++ b/backend/baton/src/test/java/touch/baton/config/MockMvcTest.java @@ -0,0 +1,26 @@ +package touch.baton.config; + +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.annotation.FilterType; +import org.springframework.core.annotation.AliasFor; +import touch.baton.domain.oauth.controller.resolver.AuthRunnerPrincipalArgumentResolver; +import touch.baton.domain.oauth.controller.resolver.AuthSupporterPrincipalArgumentResolver; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +@WebMvcTest(excludeFilters = @ComponentScan.Filter(type = FilterType.ASSIGNABLE_TYPE, classes = { + ArgumentResolverConfig.class, + AuthRunnerPrincipalArgumentResolver.class, + AuthSupporterPrincipalArgumentResolver.class +})) +public @interface MockMvcTest { + + @AliasFor(annotation = WebMvcTest.class, attribute = "value") + Class[] value() default {}; +} diff --git a/backend/baton/src/test/java/touch/baton/config/RestdocsConfig.java b/backend/baton/src/test/java/touch/baton/config/RestdocsConfig.java index 105ae3770..ba5ef4918 100644 --- a/backend/baton/src/test/java/touch/baton/config/RestdocsConfig.java +++ b/backend/baton/src/test/java/touch/baton/config/RestdocsConfig.java @@ -3,7 +3,6 @@ import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.TestConfiguration; import org.springframework.context.annotation.Bean; @@ -21,9 +20,8 @@ import static org.springframework.restdocs.operation.preprocess.Preprocessors.preprocessResponse; import static org.springframework.restdocs.operation.preprocess.Preprocessors.prettyPrint; -@ExtendWith(MockitoExtension.class) -@Import(RestDocsResultConfig.class) @ExtendWith(RestDocumentationExtension.class) +@Import(RestDocsResultConfig.class) public abstract class RestdocsConfig { protected MockMvc mockMvc; @@ -40,6 +38,7 @@ void restdocsSetUp(final WebApplicationContext webApplicationContext, ) { this.mockMvc = MockMvcBuilders.webAppContextSetup(webApplicationContext) .apply(documentationConfiguration(restDocumentation)) + .alwaysDo(restDocs) .build(); } } diff --git a/backend/baton/src/test/java/touch/baton/config/converter/ConverterConfigTest.java b/backend/baton/src/test/java/touch/baton/config/converter/ConverterConfigTest.java index 8ac5df301..8b6893a02 100644 --- a/backend/baton/src/test/java/touch/baton/config/converter/ConverterConfigTest.java +++ b/backend/baton/src/test/java/touch/baton/config/converter/ConverterConfigTest.java @@ -8,7 +8,7 @@ import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.ValueSource; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import touch.baton.config.MockMvcTest; import java.time.LocalDateTime; @@ -16,7 +16,7 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.junit.jupiter.api.Assertions.assertAll; -@WebMvcTest(ConverterConfig.class) +@MockMvcTest(value = ConverterConfig.class) class ConverterConfigTest { @Autowired diff --git a/backend/baton/src/test/java/touch/baton/document/oauth/github/GithubOauthApiTest.java b/backend/baton/src/test/java/touch/baton/document/oauth/github/GithubOauthApiTest.java new file mode 100644 index 000000000..3fc2dd30c --- /dev/null +++ b/backend/baton/src/test/java/touch/baton/document/oauth/github/GithubOauthApiTest.java @@ -0,0 +1,87 @@ +package touch.baton.document.oauth.github; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.TestPropertySource; +import touch.baton.config.MockMvcTest; +import touch.baton.config.RestdocsConfig; +import touch.baton.domain.oauth.controller.OauthController; +import touch.baton.domain.oauth.service.OauthService; +import touch.baton.infra.auth.oauth.github.GithubOauthConfig; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.mockito.BDDMockito.when; +import static org.springframework.http.MediaType.APPLICATION_JSON; +import static org.springframework.restdocs.headers.HeaderDocumentation.headerWithName; +import static org.springframework.restdocs.headers.HeaderDocumentation.responseHeaders; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.get; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.post; +import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName; +import static org.springframework.restdocs.request.RequestDocumentation.pathParameters; +import static org.springframework.restdocs.request.RequestDocumentation.queryParameters; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.redirectedUrl; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static touch.baton.domain.oauth.OauthType.GITHUB; + +@EnableConfigurationProperties(GithubOauthConfig.class) +@TestPropertySource("classpath:application.yml") +@MockMvcTest(value = OauthController.class) +class GithubOauthApiTest extends RestdocsConfig { + + @MockBean + private OauthService oauthService; + + @Autowired + private GithubOauthConfig githubOauthConfig; + + @DisplayName("Github 소셜 로그인을 위한 AuthCode 를 받을 수 있도록 사용자를 redirect 한다.") + @Test + void github_redirect_auth_code() throws Exception { + // given & when + when(oauthService.readAuthCodeRedirect(GITHUB)) + .thenReturn(githubOauthConfig.redirectUri()); + + // then + mockMvc.perform(get("/oauth/{oauthType}", "github")) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl(githubOauthConfig.redirectUri())) + .andDo(restDocs.document( + pathParameters( + parameterWithName("oauthType").description("소셜 로그인 타입") + ) + )) + .andDo(print()); + } + + @DisplayName("Github 소셜 로그인을 위해 AuthCode 를 받아 SocialToken 으로 교환하여 Github 프로필 정보를 찾아오고 미가입 사용자일 경우 자동으로 회원가입을 진행하고 JWT 로 변환하여 클라이언트에게 넘겨준다.") + @Test + void github_login() throws Exception { + // given & when + when(oauthService.login(GITHUB, "authcode")) + .thenReturn("Bearer Jwt"); + + // then + mockMvc.perform(post("/oauth/login/{oauthType}", "github") + .queryParam("code", "authcode") + .contentType(APPLICATION_JSON) + .characterEncoding(UTF_8) + ) + .andExpect(status().isOk()) + .andDo(restDocs.document( + pathParameters( + parameterWithName("oauthType").description("소셜 로그인 타입") + ), + queryParameters( + parameterWithName("code").description("소셜로부터 redirect 하여 받은 AuthCode") + ), + responseHeaders( + headerWithName("Authorization").description("Json Web Token") + ) + )) + .andDo(print()); + } +} diff --git a/backend/baton/src/test/java/touch/baton/document/runnerpost/read/RunnerPostReadApiTest.java b/backend/baton/src/test/java/touch/baton/document/runnerpost/read/RunnerPostReadApiTest.java index 1a67455f7..6ce35f242 100644 --- a/backend/baton/src/test/java/touch/baton/document/runnerpost/read/RunnerPostReadApiTest.java +++ b/backend/baton/src/test/java/touch/baton/document/runnerpost/read/RunnerPostReadApiTest.java @@ -2,9 +2,9 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; -import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; import org.springframework.boot.test.mock.mockito.MockBean; import touch.baton.config.RestdocsConfig; +import touch.baton.config.MockMvcTest; import touch.baton.domain.runner.Runner; import touch.baton.domain.runner.service.RunnerService; import touch.baton.domain.runnerpost.RunnerPost; @@ -35,7 +35,7 @@ import static touch.baton.fixture.vo.TagCountFixture.tagCount; import static touch.baton.fixture.vo.TagNameFixture.tagName; -@WebMvcTest(RunnerPostController.class) +@MockMvcTest(value = RunnerPostController.class) class RunnerPostReadApiTest extends RestdocsConfig { @MockBean diff --git a/backend/baton/src/test/java/touch/baton/domain/oauth/controller/OauthTypeConverterTest.java b/backend/baton/src/test/java/touch/baton/domain/oauth/controller/OauthTypeConverterTest.java new file mode 100644 index 000000000..96a52b625 --- /dev/null +++ b/backend/baton/src/test/java/touch/baton/domain/oauth/controller/OauthTypeConverterTest.java @@ -0,0 +1,26 @@ +package touch.baton.domain.oauth.controller; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import touch.baton.config.converter.OauthTypeConverter; +import touch.baton.domain.oauth.OauthType; + +import static org.assertj.core.api.Assertions.assertThat; + +class OauthTypeConverterTest { + + @DisplayName("OauthType 이 github 으로 입력될 때 변환에 성공한다.") + @ParameterizedTest + @ValueSource(strings = {"github", "Github", "GitHub", "GITHUB"}) + void github(final String oauthTypeValue) { + // given + final OauthTypeConverter oauthTypeConverter = new OauthTypeConverter(); + + // when + final OauthType convertedOauthType = oauthTypeConverter.convert(oauthTypeValue); + + // then + assertThat(convertedOauthType).isEqualTo(OauthType.GITHUB); + } +} diff --git a/backend/baton/src/test/java/touch/baton/infra/auth/jwt/JwtEncoderAndDecoderTest.java b/backend/baton/src/test/java/touch/baton/infra/auth/jwt/JwtEncoderAndDecoderTest.java new file mode 100644 index 000000000..6368d72e2 --- /dev/null +++ b/backend/baton/src/test/java/touch/baton/infra/auth/jwt/JwtEncoderAndDecoderTest.java @@ -0,0 +1,61 @@ +package touch.baton.infra.auth.jwt; + +import io.jsonwebtoken.Claims; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import touch.baton.domain.oauth.exception.OauthRequestException; + +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class JwtEncoderAndDecoderTest { + + private final Logger log = LoggerFactory.getLogger(this.getClass()); + + private JwtDecoder jwtDecoder; + + private JwtEncoder jwtEncoder; + + private JwtConfig jwtConfig; + + @BeforeEach + void setUp() { + this.jwtConfig = new JwtConfig("hyenahyenahyenahyenahyenahyenahyenahyenahyenahyenahyenahyena", "hyena"); + this.jwtDecoder = new JwtDecoder(this.jwtConfig); + this.jwtEncoder = new JwtEncoder(this.jwtConfig); + } + + @DisplayName("Claim 으로 email 을 넣어 인코딩한 JWT 를 디코드했을 때 email 을 구할 수 있다.") + @Test + void encode_and_decode() { + // given + final String encodedJwt = jwtEncoder.jwtToken(Map.of("email", "test@test.com")); + + // when + final Claims claims = jwtDecoder.parseJwtToken(encodedJwt); + final String email = claims.get("email", String.class); + + // then + assertThat(email).isEqualTo("test@test.com"); + } + + @DisplayName("인코드할 때 사용한 secretKey 가 디코드할 때 사용할 secretKey 와 다를 경우 예외가 발생한다.") + @Test + void fail_decode_with_wrong_secretKey() { + // given + final String encodedJwt = jwtEncoder.jwtToken(Map.of("email", "test@test.com")); + + // when + final JwtConfig wrongJwtConfig = new JwtConfig("wrongSecretKeywrongSecretKeywrongSecretKey", "hyena"); + final JwtDecoder wrongJwtDecoder = new JwtDecoder(wrongJwtConfig); + + // then + assertThatThrownBy(() -> wrongJwtDecoder.parseJwtToken(encodedJwt)) + .isInstanceOf(OauthRequestException.class); + } +}