diff --git a/.gitignore b/.gitignore index 72989815..1e3c0c70 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,13 @@ # auth layer-api/src/main/resources/application-auth.properties layer-api/src/main/resources/application.* +layer-external/src/main/resources/application-oauth.properties +layer-external/src/main/resources/application-oauth.yaml +layer-external/src/main/resources/application.yaml +layer-api/src/main/resources/application.yaml +layer-external/src/main/resources/application.yml + + HELP.md .gradle build/ diff --git a/build.gradle b/build.gradle index fa52532a..044a1d04 100644 --- a/build.gradle +++ b/build.gradle @@ -46,6 +46,9 @@ subprojects { testImplementation platform('org.junit:junit-bom:5.9.1') testImplementation 'org.junit.jupiter:junit-jupiter' + implementation 'org.springframework.boot:spring-boot-starter' + implementation 'org.springframework.boot:spring-boot-starter-web' + } test { @@ -60,8 +63,7 @@ project(":layer-api") { implementation project(path: ':layer-domain') implementation project(path: ':layer-external') - implementation 'org.springframework.boot:spring-boot-starter' - implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-actuator' //== jwt ==// @@ -74,8 +76,15 @@ project(":layer-api") { // redis implementation 'org.springframework.boot:spring-boot-starter-data-redis' - testImplementation 'org.springframework.boot:spring-boot-starter-test' + + // jpa + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + + //Swagger + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.0.2' + + runtimeOnly 'com.mysql:mysql-connector-j' } jar.enabled = false @@ -88,6 +97,7 @@ project(":layer-common") { dependencies { implementation 'org.springframework.boot:spring-boot-starter-web' +// implementation 'org.springframework.boot:spring-boot-starter-data-jpa' } } diff --git a/layer-api/build.gradle b/layer-api/build.gradle deleted file mode 100644 index e69de29b..00000000 diff --git a/layer-api/src/main/java/org/layer/api/controller/HelloController.java b/layer-api/src/main/java/org/layer/api/controller/HelloController.java deleted file mode 100644 index 18c23116..00000000 --- a/layer-api/src/main/java/org/layer/api/controller/HelloController.java +++ /dev/null @@ -1,18 +0,0 @@ -package org.layer.api.controller; - -import lombok.RequiredArgsConstructor; - -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - -@RestController -@RequiredArgsConstructor -@RequestMapping("/api/test") -public class HelloController { - - @GetMapping("") - public String test(){ - return "Hello"; - } -} diff --git a/layer-api/src/main/java/org/layer/auth/api/AuthController.java b/layer-api/src/main/java/org/layer/auth/api/AuthController.java deleted file mode 100644 index 10457594..00000000 --- a/layer-api/src/main/java/org/layer/auth/api/AuthController.java +++ /dev/null @@ -1,28 +0,0 @@ -package org.layer.auth.api; - -import lombok.RequiredArgsConstructor; -import org.layer.auth.jwt.JwtToken; -import org.layer.auth.service.JwtService; -import org.layer.domain.member.entity.MemberRole; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RestController; - -@RequiredArgsConstructor -@RestController -public class AuthController { - private final JwtService jwtService; - - // 테스트용 임시 컨트롤러입니다. (토큰 없이 접속 가능) - // "/create-token?id=멤버아이디" uri로 get 요청을 보내면 토큰이 발급됩니다. - @GetMapping("/create-token") - public JwtToken authTest(@RequestParam("id") Long memberId) { - return jwtService.issueToken(memberId, MemberRole.USER); - } - - // header에 액세스 토큰을 넣어 요청을 보내면 인증됩니다. - @GetMapping("/authentication-test") - public String authTest() { - return "인증 성공"; - } -} diff --git a/layer-api/src/main/java/org/layer/auth/exception/TokenException.java b/layer-api/src/main/java/org/layer/auth/exception/TokenException.java deleted file mode 100644 index 62e1e27d..00000000 --- a/layer-api/src/main/java/org/layer/auth/exception/TokenException.java +++ /dev/null @@ -1,5 +0,0 @@ -package org.layer.auth.exception; - -// 임시로 만들어놓은 Exception입니다 -public class TokenException extends RuntimeException { -} diff --git a/layer-api/src/main/java/org/layer/auth/jwt/JwtToken.java b/layer-api/src/main/java/org/layer/auth/jwt/JwtToken.java deleted file mode 100644 index f9888165..00000000 --- a/layer-api/src/main/java/org/layer/auth/jwt/JwtToken.java +++ /dev/null @@ -1,16 +0,0 @@ -package org.layer.auth.jwt; - -import lombok.Builder; -import lombok.Getter; - -@Getter -public class JwtToken { - private final String accessToken; - private final String refreshToken; - - @Builder - public JwtToken(String accessToken, String refreshToken) { - this.accessToken = accessToken; - this.refreshToken = refreshToken; - } -} \ No newline at end of file diff --git a/layer-api/src/main/java/org/layer/common/annotation/MemberId.java b/layer-api/src/main/java/org/layer/common/annotation/MemberId.java new file mode 100644 index 00000000..6bcda6df --- /dev/null +++ b/layer-api/src/main/java/org/layer/common/annotation/MemberId.java @@ -0,0 +1,11 @@ +package org.layer.common.annotation; + +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 MemberId { +} diff --git a/layer-api/src/main/java/org/layer/common/exception/GlobalExceptionHandler.java b/layer-api/src/main/java/org/layer/common/exception/GlobalExceptionHandler.java index b4eb496d..d94ec47e 100644 --- a/layer-api/src/main/java/org/layer/common/exception/GlobalExceptionHandler.java +++ b/layer-api/src/main/java/org/layer/common/exception/GlobalExceptionHandler.java @@ -1,8 +1,6 @@ package org.layer.common.exception; import lombok.extern.slf4j.Slf4j; -import org.layer.common.exception.BaseCustomException; -import org.layer.common.exception.ExceptionType; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.ExceptionHandler; diff --git a/layer-api/src/main/java/org/layer/config/AuthValueConfig.java b/layer-api/src/main/java/org/layer/config/AuthValueConfig.java index befa2319..e9711e62 100644 --- a/layer-api/src/main/java/org/layer/config/AuthValueConfig.java +++ b/layer-api/src/main/java/org/layer/config/AuthValueConfig.java @@ -14,8 +14,12 @@ public class AuthValueConfig { @Value("${jwt.secret}") private String JWT_SECRET; + public static final Long ACCESS_TOKEN_EXPIRATION_TIME = 1000 * 60 * 30L; // 30분 public static final Long REFRESH_TOKEN_EXPIRATION_TIME = 1000 * 60 * 60 * 24 * 14L; // 2주 + public static final String AUTHORIZATION = "Authorization"; + public static final String KAKAO_URI = "https://kapi.kakao.com/v2/user/me"; + @PostConstruct diff --git a/layer-api/src/main/java/org/layer/config/SecurityConfig.java b/layer-api/src/main/java/org/layer/config/SecurityConfig.java index 7912aa45..88e1f5cf 100644 --- a/layer-api/src/main/java/org/layer/config/SecurityConfig.java +++ b/layer-api/src/main/java/org/layer/config/SecurityConfig.java @@ -2,7 +2,7 @@ import lombok.RequiredArgsConstructor; -import org.layer.auth.jwt.JwtAuthenticationFilter; +import org.layer.domain.jwt.JwtAuthenticationFilter; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.config.annotation.web.builders.HttpSecurity; @@ -35,7 +35,12 @@ private void setHttp(HttpSecurity http) throws Exception { session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS) ).authorizeHttpRequests(authorizeRequest -> authorizeRequest - .requestMatchers(new AntPathRequestMatcher("/create-token")).permitAll() + .requestMatchers(new AntPathRequestMatcher("/api/auth/sign-in")).permitAll() + .requestMatchers(new AntPathRequestMatcher("/api/auth/reissue-token")).permitAll() + .requestMatchers(new AntPathRequestMatcher("/api/auth/sign-up")).permitAll() + .requestMatchers(new AntPathRequestMatcher("/api/auth/oauth2/google")).permitAll() + .requestMatchers(new AntPathRequestMatcher("/api/test")).permitAll() + .requestMatchers(new AntPathRequestMatcher("/api/auth/oauth2/kakao")).permitAll() .anyRequest().authenticated() ); } @@ -47,7 +52,4 @@ private void permitSwaggerUri(HttpSecurity http) throws Exception { .requestMatchers(new AntPathRequestMatcher("/swagger-ui/**")).permitAll() .requestMatchers(new AntPathRequestMatcher("/docs/**")).permitAll()); } - - - } diff --git a/layer-api/src/main/java/org/layer/config/SwaggerConfig.java b/layer-api/src/main/java/org/layer/config/SwaggerConfig.java new file mode 100644 index 00000000..ce007325 --- /dev/null +++ b/layer-api/src/main/java/org/layer/config/SwaggerConfig.java @@ -0,0 +1,60 @@ +package org.layer.config; + +import io.swagger.v3.oas.models.Components; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.info.Contact; +import io.swagger.v3.oas.models.info.Info; +import io.swagger.v3.oas.models.info.License; +import io.swagger.v3.oas.models.security.SecurityRequirement; +import io.swagger.v3.oas.models.security.SecurityScheme; +import org.layer.common.annotation.MemberId; +import org.springdoc.core.customizers.OperationCustomizer; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.method.HandlerMethod; + +import java.util.Arrays; + +@Configuration +public class SwaggerConfig { + + SecurityScheme apiAuth = new SecurityScheme() + .type(SecurityScheme.Type.APIKEY) + .in(SecurityScheme.In.HEADER) + .name("authorization-token"); + + SecurityRequirement addSecurityItem = new SecurityRequirement() + .addList("authorization-token"); + + @Bean + public OpenAPI openAPI(){ + var info = new Info(); + info.title("Layer API"); + info.description("Layer API 문서에요."); + info.contact( + new Contact() + .email("teamkb.dpm@gmail.com") + .name("떡잎마을방범대") + ); + info.license(new License().name("MIT")); + return new OpenAPI() + .components(new Components() + .addSecuritySchemes("authorization-token", apiAuth) + ) + .addSecurityItem(addSecurityItem) + .info(info); + } + + @Bean + public OperationCustomizer customizeOperation() { + return (operation, handlerMethod) -> { + HandlerMethod method = (HandlerMethod) handlerMethod; + method.getMethodParameters(); + method.getMethodParameters(); + if (Arrays.stream(method.getMethodParameters()).anyMatch(param -> param.hasParameterAnnotation(MemberId.class))) { + operation.getParameters().removeIf(param -> "memberId".equals(param.getName())); + } + return operation; + }; + } +} diff --git a/layer-api/src/main/java/org/layer/config/WebConfig.java b/layer-api/src/main/java/org/layer/config/WebConfig.java new file mode 100644 index 00000000..e573e066 --- /dev/null +++ b/layer-api/src/main/java/org/layer/config/WebConfig.java @@ -0,0 +1,41 @@ +package org.layer.config; + +import lombok.RequiredArgsConstructor; +import org.layer.resolver.MemberIdResolver; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.servlet.config.annotation.CorsRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +import java.util.List; + +@Configuration +@RequiredArgsConstructor +public class WebConfig implements WebMvcConfigurer { + + @Value("${webmvc.cors.allowedOrigins}") + private String allowedOrigins; + + private final MemberIdResolver memberIdResolver; + + + @Bean + public WebMvcConfigurer corsConfigurer(){ + return new WebMvcConfigurer() { + @Override + public void addCorsMappings(CorsRegistry registry) { + registry.addMapping("/**") + .allowedOrigins(allowedOrigins.split(",")) + .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") + .allowedHeaders("*") + .allowCredentials(true); + } + }; + } + @Override + public void addArgumentResolvers(List resolvers) { + resolvers.add(memberIdResolver); + } +} diff --git a/layer-api/src/main/java/org/layer/domain/auth/controller/AuthController.java b/layer-api/src/main/java/org/layer/domain/auth/controller/AuthController.java new file mode 100644 index 00000000..30279580 --- /dev/null +++ b/layer-api/src/main/java/org/layer/domain/auth/controller/AuthController.java @@ -0,0 +1,74 @@ +package org.layer.domain.auth.controller; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.layer.domain.auth.controller.dto.*; +import org.layer.domain.auth.service.dto.SignInServiceResponse; +import org.layer.domain.auth.service.dto.SignUpServiceResponse; +import org.layer.oauth.service.GoogleService; +import org.layer.oauth.service.KakaoService; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.layer.domain.auth.service.AuthService; +@Slf4j +@RequiredArgsConstructor +@RequestMapping("/api/auth") +@RestController +public class AuthController { + private final AuthService authService; + private final GoogleService googleService; + private final KakaoService kakaoService; + + // 로그인 + @PostMapping("/sign-in") + public ResponseEntity signIn(@RequestHeader("Authorization") final String socialAccessToken, @RequestBody final SignInRequest signInRequest) { + SignInServiceResponse signInServiceResponse = authService.signIn(socialAccessToken, signInRequest.socialType()); + return new ResponseEntity<>(SignInResponse.of(signInServiceResponse), HttpStatus.OK); + } + + // 회원가입 => 소셜로그인 했는데 유효한 유저가 없을 때 이름 입력하고 회원가입하는 과정 + @PostMapping("/sign-up") + public ResponseEntity signUp(@RequestHeader("Authorization") final String socialAccessToken, @RequestBody final SignUpRequest signUpRequest) { + SignUpServiceResponse signUpServiceResponse = authService.signUp(socialAccessToken, signUpRequest); + return new ResponseEntity<>(SignUpResponse.of(signUpServiceResponse), HttpStatus.CREATED); + } + + + // 로그아웃 + @PostMapping("/sign-out") + public ResponseEntity signOut(@RequestBody Long memberId) { + authService.signOut(memberId); + return new ResponseEntity<>(HttpStatus.OK); + } + + // 회원 탈퇴 + @PostMapping("/withdraw") + public ResponseEntity withdraw(@RequestBody Long memberId) { + authService.withdraw(memberId); + return new ResponseEntity<>(HttpStatus.OK); // TODO: 리턴 객체 수정 필요 + } + + // 토큰 재발급 + @PostMapping("/reissue-token") + public ResponseEntity reissueToken(@RequestBody Long memberId) { + return new ResponseEntity<>( + ReissueTokenResponse.of(authService.reissueToken(memberId)), + HttpStatus.CREATED); + } + + //== google OAuth2 test용 API 액세스 토큰 발급 ==// + @GetMapping("oauth2/google") + public String googleTest(@RequestParam("code") String code) { + return googleService.getToken(code); + } + + //== kakao OAuth2 test용 API 액세스 토큰 발급 ==// + @GetMapping("oauth2/kakao") + public Object kakaoLogin(@RequestParam(value = "code", required = false) String code) { + return kakaoService.getToken(code); + } +} diff --git a/layer-api/src/main/java/org/layer/domain/auth/controller/dto/ReissueTokenResponse.java b/layer-api/src/main/java/org/layer/domain/auth/controller/dto/ReissueTokenResponse.java new file mode 100644 index 00000000..220a593b --- /dev/null +++ b/layer-api/src/main/java/org/layer/domain/auth/controller/dto/ReissueTokenResponse.java @@ -0,0 +1,11 @@ +package org.layer.domain.auth.controller.dto; + +import org.layer.domain.auth.service.dto.ReissueTokenServiceResponse; + +public record ReissueTokenResponse(Long memberId, String accessToken, String refreshToken){ + public static ReissueTokenResponse of(ReissueTokenServiceResponse rtsr) { + return new ReissueTokenResponse(rtsr.memberId(), + rtsr.jwtToken().getAccessToken(), + rtsr.jwtToken().getRefreshToken()); + } +} diff --git a/layer-api/src/main/java/org/layer/domain/auth/controller/dto/SignInRequest.java b/layer-api/src/main/java/org/layer/domain/auth/controller/dto/SignInRequest.java new file mode 100644 index 00000000..2f6f149d --- /dev/null +++ b/layer-api/src/main/java/org/layer/domain/auth/controller/dto/SignInRequest.java @@ -0,0 +1,7 @@ +package org.layer.domain.auth.controller.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import org.layer.domain.member.entity.SocialType; + +public record SignInRequest(@JsonProperty("social_type") SocialType socialType) { +} diff --git a/layer-api/src/main/java/org/layer/domain/auth/controller/dto/SignInResponse.java b/layer-api/src/main/java/org/layer/domain/auth/controller/dto/SignInResponse.java new file mode 100644 index 00000000..157fe559 --- /dev/null +++ b/layer-api/src/main/java/org/layer/domain/auth/controller/dto/SignInResponse.java @@ -0,0 +1,15 @@ +package org.layer.domain.auth.controller.dto; + +import org.layer.domain.auth.service.dto.SignInServiceResponse; +import org.layer.domain.member.entity.MemberRole; + + +public record SignInResponse(Long memberId, String accessToken, String refreshToken, MemberRole memberRole) { + public static SignInResponse of(SignInServiceResponse signInServiceResponse) { + return new SignInResponse(signInServiceResponse.memberId(), + signInServiceResponse.accessToken(), + signInServiceResponse.refreshToken(), + signInServiceResponse.memberRole() + ); + } +} diff --git a/layer-api/src/main/java/org/layer/domain/auth/controller/dto/SignUpRequest.java b/layer-api/src/main/java/org/layer/domain/auth/controller/dto/SignUpRequest.java new file mode 100644 index 00000000..dabb82b6 --- /dev/null +++ b/layer-api/src/main/java/org/layer/domain/auth/controller/dto/SignUpRequest.java @@ -0,0 +1,9 @@ +package org.layer.domain.auth.controller.dto; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import org.layer.domain.member.entity.SocialType; + +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) +public record SignUpRequest(SocialType socialType, String name) { +} diff --git a/layer-api/src/main/java/org/layer/domain/auth/controller/dto/SignUpResponse.java b/layer-api/src/main/java/org/layer/domain/auth/controller/dto/SignUpResponse.java new file mode 100644 index 00000000..abba1bf2 --- /dev/null +++ b/layer-api/src/main/java/org/layer/domain/auth/controller/dto/SignUpResponse.java @@ -0,0 +1,21 @@ +package org.layer.domain.auth.controller.dto; + +import org.layer.domain.auth.service.dto.SignUpServiceResponse; +import org.layer.domain.member.entity.MemberRole; +import org.layer.domain.member.entity.SocialType; + +public record SignUpResponse(Long memberId, + String name, + String email, + MemberRole memberRole, + String SocialId, + SocialType socialType) { + public static SignUpResponse of(SignUpServiceResponse signUpServiceResponse) { + return new SignUpResponse(signUpServiceResponse.memberId(), + signUpServiceResponse.name(), + signUpServiceResponse.email(), + signUpServiceResponse.memberRole(), + signUpServiceResponse.socialId(), + signUpServiceResponse.socialType()); + } +} diff --git a/layer-api/src/main/java/org/layer/domain/auth/exception/AuthExceptionType.java b/layer-api/src/main/java/org/layer/domain/auth/exception/AuthExceptionType.java new file mode 100644 index 00000000..fc23481d --- /dev/null +++ b/layer-api/src/main/java/org/layer/domain/auth/exception/AuthExceptionType.java @@ -0,0 +1,29 @@ +package org.layer.domain.auth.exception; + +import lombok.RequiredArgsConstructor; +import org.layer.common.exception.ExceptionType; +import org.springframework.http.HttpStatus; + +@RequiredArgsConstructor +public enum AuthExceptionType implements ExceptionType { + + /** + * 400 + */ + INVALID_SOCIAL_TYPE(HttpStatus.BAD_REQUEST, "지원하지 않는 방식의 로그인입니다."), + FORBIDDEN(HttpStatus.FORBIDDEN, "권한이 없습니다."); + + + private final HttpStatus status; + private final String message; + + @Override + public HttpStatus httpStatus() { + return status; + } + + @Override + public String message() { + return message; + } +} \ No newline at end of file diff --git a/layer-api/src/main/java/org/layer/domain/auth/exception/TokenExceptionType.java b/layer-api/src/main/java/org/layer/domain/auth/exception/TokenExceptionType.java new file mode 100644 index 00000000..1d01762a --- /dev/null +++ b/layer-api/src/main/java/org/layer/domain/auth/exception/TokenExceptionType.java @@ -0,0 +1,30 @@ +package org.layer.domain.auth.exception; + +import lombok.RequiredArgsConstructor; +import org.layer.common.exception.ExceptionType; +import org.springframework.http.HttpStatus; + +@RequiredArgsConstructor +public enum TokenExceptionType implements ExceptionType { + + /** + * 400 + */ + INVALID_TOKEN(HttpStatus.BAD_REQUEST, "token이 유효하지 않습니다."), + INVALID_REFRESH_TOKEN(HttpStatus.BAD_REQUEST, "refresh token이 유효하지 않습니다."), + INVALID_ACCESS_TOKEN(HttpStatus.BAD_REQUEST, "access token이 유효하지 않습니다."); + + + private final HttpStatus status; + private final String message; + + @Override + public HttpStatus httpStatus() { + return status; + } + + @Override + public String message() { + return message; + } +} \ No newline at end of file diff --git a/layer-api/src/main/java/org/layer/domain/auth/service/AuthService.java b/layer-api/src/main/java/org/layer/domain/auth/service/AuthService.java new file mode 100644 index 00000000..3ee52021 --- /dev/null +++ b/layer-api/src/main/java/org/layer/domain/auth/service/AuthService.java @@ -0,0 +1,113 @@ +package org.layer.domain.auth.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.layer.common.exception.BaseCustomException; +import org.layer.domain.auth.controller.dto.SignUpRequest; +import org.layer.domain.auth.service.dto.ReissueTokenServiceResponse; +import org.layer.domain.auth.service.dto.SignInServiceResponse; +import org.layer.domain.auth.service.dto.SignUpServiceResponse; +import org.layer.domain.jwt.JwtToken; +import org.layer.domain.jwt.exception.AuthExceptionType; +import org.layer.domain.jwt.service.JwtService; +import org.layer.domain.member.entity.Member; +import org.layer.domain.member.entity.SocialType; +import org.layer.domain.member.service.MemberService; +import org.layer.domain.member.service.MemberUtil; +import org.layer.oauth.dto.service.MemberInfoServiceResponse; +import org.layer.oauth.service.GoogleService; +import org.layer.oauth.service.KakaoService; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + + +@Slf4j +@RequiredArgsConstructor +@Service +public class AuthService { + private final KakaoService kakaoService; + private final GoogleService googleService; + private final JwtService jwtService; + private final MemberService memberService; + private final MemberUtil memberUtil; + + //== 로그인 ==// + @Transactional + public SignInServiceResponse signIn(final String socialAccessToken, final SocialType socialType) { + MemberInfoServiceResponse signedMember = getMemberInfo(socialType, socialAccessToken); + + // DB에서 회원 찾기. 없다면 Exception 발생 => 이름 입력 창으로 + Member member = memberService.findMemberBySocialIdAndSocialType(signedMember.socialId(), socialType); + JwtToken jwtToken = jwtService.issueToken(member.getId(), member.getMemberRole()); + return SignInServiceResponse.of(member, jwtToken); + } + + //== 회원가입(이름을 입력 받기) ==// + @Transactional + public SignUpServiceResponse signUp(final String socialAccessToken, final SignUpRequest signUpRequest) { + MemberInfoServiceResponse memberInfo = getMemberInfo(signUpRequest.socialType(), socialAccessToken); + + // 이미 있는 회원인지 확인 + isNewMember(memberInfo.socialType(), memberInfo.socialId()); + + // DB에 회원 저장 + Member member = memberService.saveMember(signUpRequest, memberInfo); + return SignUpServiceResponse.of(member); + } + + //== 로그아웃 ==// + @Transactional + public void signOut(final Long memberId) { + // 현재 로그인된 사용자와 memberId가 일치하는지 확인 => 일치하지 않으면 Exception + isValidMember(memberId); + jwtService.deleteRefreshToken(memberId); + } + + + //== 회원 탈퇴 ==// + @Transactional + public void withdraw(final Long memberId) { + // TODO: member 도메인에서 del_yn 바꾸기 => Member entitiy에 추가,,? + + } + + //== (리프레시 토큰을 받았을 때) 토큰 재발급 ==// + @Transactional + public ReissueTokenServiceResponse reissueToken(final Long memberId) { + // 현재 로그인된 사용자와 memberId가 일치하는지 확인 + isValidMember(memberId); + + // 시큐리티 컨텍스트에서 member 찾아오기 + Member member = memberUtil.getCurrentMember(); + return ReissueTokenServiceResponse.of(member, + jwtService.issueToken(member.getId(), member.getMemberRole())); + } + + + //== private methods ==// + + // MemberInfoServiceResponse: socialId, socialType, email + private MemberInfoServiceResponse getMemberInfo(SocialType socialType, String socialAccessToken) { + return switch (socialType) { + case KAKAO -> kakaoService.getMemberInfo(socialAccessToken); + case GOOGLE -> googleService.getMemberInfo(socialAccessToken); + default -> throw new BaseCustomException(AuthExceptionType.INVALID_SOCIAL_TYPE); + }; + } + + + // 현재 로그인 된 사용자와 해당 멤버 아이디가 일치하는지 확인 + private void isValidMember(Long memberId) { + Member currentMember = memberUtil.getCurrentMember(); + if(!currentMember.getId().equals(memberId)) { + throw new BaseCustomException(AuthExceptionType.FORBIDDEN); + } + } + + // 이미 있는 회원인지 확인하기 + private void isNewMember(SocialType socialType, String socialId) { + memberService.checkIsNewMember(socialId, socialType); + } + + +} diff --git a/layer-api/src/main/java/org/layer/domain/auth/service/dto/ReissueTokenServiceResponse.java b/layer-api/src/main/java/org/layer/domain/auth/service/dto/ReissueTokenServiceResponse.java new file mode 100644 index 00000000..7c4f26c1 --- /dev/null +++ b/layer-api/src/main/java/org/layer/domain/auth/service/dto/ReissueTokenServiceResponse.java @@ -0,0 +1,10 @@ +package org.layer.domain.auth.service.dto; + +import org.layer.domain.jwt.JwtToken; +import org.layer.domain.member.entity.Member; + +public record ReissueTokenServiceResponse(Long memberId, JwtToken jwtToken) { + public static ReissueTokenServiceResponse of(Member member, JwtToken jwtToken) { + return new ReissueTokenServiceResponse(member.getId(), jwtToken); + } +} diff --git a/layer-api/src/main/java/org/layer/domain/auth/service/dto/SignInServiceResponse.java b/layer-api/src/main/java/org/layer/domain/auth/service/dto/SignInServiceResponse.java new file mode 100644 index 00000000..37b5da9f --- /dev/null +++ b/layer-api/src/main/java/org/layer/domain/auth/service/dto/SignInServiceResponse.java @@ -0,0 +1,14 @@ +package org.layer.domain.auth.service.dto; + +import org.layer.domain.jwt.JwtToken; +import org.layer.domain.member.entity.Member; +import org.layer.domain.member.entity.MemberRole; + +public record SignInServiceResponse(Long memberId, String accessToken, String refreshToken, MemberRole memberRole) { + public static SignInServiceResponse of(Member member, JwtToken jwtToken) { + return new SignInServiceResponse(member.getId(), + jwtToken.getAccessToken(), + jwtToken.getRefreshToken(), + member.getMemberRole()); + } +} diff --git a/layer-api/src/main/java/org/layer/domain/auth/service/dto/SignUpServiceResponse.java b/layer-api/src/main/java/org/layer/domain/auth/service/dto/SignUpServiceResponse.java new file mode 100644 index 00000000..d32a3d39 --- /dev/null +++ b/layer-api/src/main/java/org/layer/domain/auth/service/dto/SignUpServiceResponse.java @@ -0,0 +1,21 @@ +package org.layer.domain.auth.service.dto; + +import org.layer.domain.member.entity.Member; +import org.layer.domain.member.entity.MemberRole; +import org.layer.domain.member.entity.SocialType; + +public record SignUpServiceResponse(Long memberId, + String name, + String email, + MemberRole memberRole, + String socialId, + SocialType socialType) { + public static SignUpServiceResponse of(Member member) { + return new SignUpServiceResponse(member.getId(), + member.getName(), + member.getEmail(), + member.getMemberRole(), + member.getSocialId(), + member.getSocialType()); + } +} diff --git a/layer-api/src/main/java/org/layer/auth/jwt/JwtAuthenticationFilter.java b/layer-api/src/main/java/org/layer/domain/jwt/JwtAuthenticationFilter.java similarity index 98% rename from layer-api/src/main/java/org/layer/auth/jwt/JwtAuthenticationFilter.java rename to layer-api/src/main/java/org/layer/domain/jwt/JwtAuthenticationFilter.java index 8f510632..7ad7c959 100644 --- a/layer-api/src/main/java/org/layer/auth/jwt/JwtAuthenticationFilter.java +++ b/layer-api/src/main/java/org/layer/domain/jwt/JwtAuthenticationFilter.java @@ -1,4 +1,4 @@ -package org.layer.auth.jwt; +package org.layer.domain.jwt; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; diff --git a/layer-api/src/main/java/org/layer/auth/jwt/JwtProvider.java b/layer-api/src/main/java/org/layer/domain/jwt/JwtProvider.java similarity index 96% rename from layer-api/src/main/java/org/layer/auth/jwt/JwtProvider.java rename to layer-api/src/main/java/org/layer/domain/jwt/JwtProvider.java index f8283da1..5f1848e8 100644 --- a/layer-api/src/main/java/org/layer/auth/jwt/JwtProvider.java +++ b/layer-api/src/main/java/org/layer/domain/jwt/JwtProvider.java @@ -1,4 +1,4 @@ -package org.layer.auth.jwt; +package org.layer.domain.jwt; import io.jsonwebtoken.Jwts; import lombok.RequiredArgsConstructor; diff --git a/layer-api/src/main/java/org/layer/domain/jwt/JwtToken.java b/layer-api/src/main/java/org/layer/domain/jwt/JwtToken.java new file mode 100644 index 00000000..33554177 --- /dev/null +++ b/layer-api/src/main/java/org/layer/domain/jwt/JwtToken.java @@ -0,0 +1,13 @@ +package org.layer.domain.jwt; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +@AllArgsConstructor +public class JwtToken { + private final String accessToken; + private final String refreshToken; +} \ No newline at end of file diff --git a/layer-api/src/main/java/org/layer/auth/jwt/JwtValidationType.java b/layer-api/src/main/java/org/layer/domain/jwt/JwtValidationType.java similarity index 52% rename from layer-api/src/main/java/org/layer/auth/jwt/JwtValidationType.java rename to layer-api/src/main/java/org/layer/domain/jwt/JwtValidationType.java index 55369baa..8307defb 100644 --- a/layer-api/src/main/java/org/layer/auth/jwt/JwtValidationType.java +++ b/layer-api/src/main/java/org/layer/domain/jwt/JwtValidationType.java @@ -1,7 +1,7 @@ -package org.layer.auth.jwt; +package org.layer.domain.jwt; public enum JwtValidationType { VALID_JWT, - INVALID_JWT; + INVALID_JWT } diff --git a/layer-api/src/main/java/org/layer/auth/jwt/JwtValidator.java b/layer-api/src/main/java/org/layer/domain/jwt/JwtValidator.java similarity index 56% rename from layer-api/src/main/java/org/layer/auth/jwt/JwtValidator.java rename to layer-api/src/main/java/org/layer/domain/jwt/JwtValidator.java index 099c8e23..1b0eaca0 100644 --- a/layer-api/src/main/java/org/layer/auth/jwt/JwtValidator.java +++ b/layer-api/src/main/java/org/layer/domain/jwt/JwtValidator.java @@ -1,16 +1,17 @@ -package org.layer.auth.jwt; +package org.layer.domain.jwt; import io.jsonwebtoken.Claims; import io.jsonwebtoken.Jwts; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.layer.auth.exception.TokenException; +import org.layer.common.exception.BaseCustomException; import org.springframework.stereotype.Component; -import java.util.LinkedHashMap; import java.util.List; -import static org.layer.auth.jwt.JwtValidationType.*; +import static org.layer.domain.auth.exception.TokenExceptionType.INVALID_TOKEN; +import static org.layer.domain.jwt.JwtValidationType.INVALID_JWT; +import static org.layer.domain.jwt.JwtValidationType.VALID_JWT; @Slf4j @RequiredArgsConstructor @@ -22,18 +23,28 @@ public JwtValidationType validateToken(String token) { try { getClaims(token); return VALID_JWT; - } catch(TokenException e) { + } catch(Exception e) { return INVALID_JWT; } } public long getMemberIdFromToken(String token) { - Claims claims = getClaims(token); + Claims claims; + try { + claims = getClaims(token); + } catch(Exception e) { + throw new BaseCustomException(INVALID_TOKEN); + } return Long.parseLong(claims.get("memberId").toString()); } - public List getRoleFromToken(String token) throws TokenException { - Claims claims = getClaims(token); + public List getRoleFromToken(String token) { + Claims claims; + try { + claims = getClaims(token); + } catch(Exception e) { + throw new BaseCustomException(INVALID_TOKEN); + } return (List) (claims.get("role")); } diff --git a/layer-api/src/main/java/org/layer/auth/jwt/MemberAuthentication.java b/layer-api/src/main/java/org/layer/domain/jwt/MemberAuthentication.java similarity index 96% rename from layer-api/src/main/java/org/layer/auth/jwt/MemberAuthentication.java rename to layer-api/src/main/java/org/layer/domain/jwt/MemberAuthentication.java index 8d07eda4..4d8fb4c0 100644 --- a/layer-api/src/main/java/org/layer/auth/jwt/MemberAuthentication.java +++ b/layer-api/src/main/java/org/layer/domain/jwt/MemberAuthentication.java @@ -1,4 +1,4 @@ -package org.layer.auth.jwt; +package org.layer.domain.jwt; import lombok.Builder; import org.layer.domain.member.entity.MemberRole; diff --git a/layer-api/src/main/java/org/layer/auth/jwt/RefreshToken.java b/layer-api/src/main/java/org/layer/domain/jwt/RefreshToken.java similarity index 92% rename from layer-api/src/main/java/org/layer/auth/jwt/RefreshToken.java rename to layer-api/src/main/java/org/layer/domain/jwt/RefreshToken.java index d66e9749..073d3f29 100644 --- a/layer-api/src/main/java/org/layer/auth/jwt/RefreshToken.java +++ b/layer-api/src/main/java/org/layer/domain/jwt/RefreshToken.java @@ -1,4 +1,4 @@ -package org.layer.auth.jwt; +package org.layer.domain.jwt; import lombok.Builder; import lombok.Getter; diff --git a/layer-api/src/main/java/org/layer/auth/jwt/SecretKeyFactory.java b/layer-api/src/main/java/org/layer/domain/jwt/SecretKeyFactory.java similarity index 76% rename from layer-api/src/main/java/org/layer/auth/jwt/SecretKeyFactory.java rename to layer-api/src/main/java/org/layer/domain/jwt/SecretKeyFactory.java index e6483470..56f4627d 100644 --- a/layer-api/src/main/java/org/layer/auth/jwt/SecretKeyFactory.java +++ b/layer-api/src/main/java/org/layer/domain/jwt/SecretKeyFactory.java @@ -1,15 +1,12 @@ -package org.layer.auth.jwt; +package org.layer.domain.jwt; import io.jsonwebtoken.security.Keys; -import java.util.Base64; -import javax.crypto.SecretKey; - import lombok.RequiredArgsConstructor; import org.layer.config.AuthValueConfig; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.context.annotation.PropertySource; import org.springframework.stereotype.Component; +import javax.crypto.SecretKey; + import static java.util.Base64.getEncoder; @RequiredArgsConstructor diff --git a/layer-api/src/main/java/org/layer/domain/jwt/SecurityUtil.java b/layer-api/src/main/java/org/layer/domain/jwt/SecurityUtil.java new file mode 100644 index 00000000..2b92e1e7 --- /dev/null +++ b/layer-api/src/main/java/org/layer/domain/jwt/SecurityUtil.java @@ -0,0 +1,21 @@ +package org.layer.domain.jwt; + +import org.layer.common.exception.BaseCustomException; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; + +import static org.layer.common.exception.MemberExceptionType.UNAUTHORIZED_USER; + +@Component +public class SecurityUtil { + public Long getCurrentMemberId() { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + + try { + return Long.parseLong(authentication.getName()); + } catch(Exception e) { + throw new BaseCustomException(UNAUTHORIZED_USER); + } + } +} diff --git a/layer-api/src/main/java/org/layer/domain/jwt/exception/AuthExceptionType.java b/layer-api/src/main/java/org/layer/domain/jwt/exception/AuthExceptionType.java new file mode 100644 index 00000000..7aba9b70 --- /dev/null +++ b/layer-api/src/main/java/org/layer/domain/jwt/exception/AuthExceptionType.java @@ -0,0 +1,29 @@ +package org.layer.domain.jwt.exception; + +import lombok.RequiredArgsConstructor; +import org.layer.common.exception.ExceptionType; +import org.springframework.http.HttpStatus; + +@RequiredArgsConstructor +public enum AuthExceptionType implements ExceptionType { + + /** + * 400 + */ + INVALID_SOCIAL_TYPE(HttpStatus.BAD_REQUEST, "지원하지 않는 방식의 로그인입니다."), + FORBIDDEN(HttpStatus.FORBIDDEN, "권한이 없습니다."); + + + private final HttpStatus status; + private final String message; + + @Override + public HttpStatus httpStatus() { + return status; + } + + @Override + public String message() { + return message; + } +} diff --git a/layer-api/src/main/java/org/layer/auth/service/JwtService.java b/layer-api/src/main/java/org/layer/domain/jwt/service/JwtService.java similarity index 58% rename from layer-api/src/main/java/org/layer/auth/service/JwtService.java rename to layer-api/src/main/java/org/layer/domain/jwt/service/JwtService.java index 2467a4ae..0524b35b 100644 --- a/layer-api/src/main/java/org/layer/auth/service/JwtService.java +++ b/layer-api/src/main/java/org/layer/domain/jwt/service/JwtService.java @@ -1,8 +1,10 @@ -package org.layer.auth.service; +package org.layer.domain.jwt.service; import lombok.RequiredArgsConstructor; -import org.layer.auth.exception.TokenException; -import org.layer.auth.jwt.*; +import org.layer.common.exception.BaseCustomException; +import org.layer.domain.jwt.JwtProvider; +import org.layer.domain.jwt.JwtToken; +import org.layer.domain.jwt.MemberAuthentication; import org.layer.domain.member.entity.MemberRole; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.stereotype.Service; @@ -10,7 +12,9 @@ import java.time.Duration; import java.util.Objects; -import static org.layer.config.AuthValueConfig.*; +import static org.layer.domain.auth.exception.TokenExceptionType.INVALID_REFRESH_TOKEN; +import static org.layer.config.AuthValueConfig.ACCESS_TOKEN_EXPIRATION_TIME; +import static org.layer.config.AuthValueConfig.REFRESH_TOKEN_EXPIRATION_TIME; @RequiredArgsConstructor @Service @@ -22,7 +26,7 @@ public JwtToken issueToken(Long memberId, MemberRole memberRole) { String accessToken = jwtProvider.createToken(MemberAuthentication.create(memberId, memberRole), ACCESS_TOKEN_EXPIRATION_TIME); String refreshToken = jwtProvider.createToken(MemberAuthentication.create(memberId, memberRole), REFRESH_TOKEN_EXPIRATION_TIME); - saveRefreshTokenToRedis(memberId, memberRole, refreshToken); + saveRefreshTokenToRedis(memberId, refreshToken); return JwtToken.builder() .accessToken(accessToken) @@ -30,22 +34,22 @@ public JwtToken issueToken(Long memberId, MemberRole memberRole) { .build(); } - private void saveRefreshTokenToRedis(Long memberId, MemberRole memberRole, String refreshToken) { - redisTemplate.opsForValue().set(refreshToken, memberId, Duration.ofDays(14)); + private void saveRefreshTokenToRedis(Long memberId, String refreshToken) { + redisTemplate.opsForValue().set(memberId.toString(), refreshToken, Duration.ofDays(14)); } - private Long getMemberIdFromRefreshToken(String refreshToken) throws TokenException { + private Long getMemberIdFromRefreshToken(String refreshToken) { Long memberId = null; try { memberId = Long.parseLong((String) Objects.requireNonNull(redisTemplate.opsForValue().get(refreshToken))); } catch(Exception e) { - throw new TokenException(); + throw new BaseCustomException(INVALID_REFRESH_TOKEN); } return memberId; } - public void deleteRefreshToken(String refreshToken) { - redisTemplate.delete(refreshToken); + public void deleteRefreshToken(Long memberId) { + redisTemplate.delete(memberId.toString()); } } diff --git a/layer-api/src/main/java/org/layer/domain/member/service/MemberService.java b/layer-api/src/main/java/org/layer/domain/member/service/MemberService.java new file mode 100644 index 00000000..f29f9005 --- /dev/null +++ b/layer-api/src/main/java/org/layer/domain/member/service/MemberService.java @@ -0,0 +1,54 @@ +package org.layer.domain.member.service; + +import lombok.RequiredArgsConstructor; +import org.layer.common.exception.BaseCustomException; +import org.layer.common.exception.MemberExceptionType; +import org.layer.domain.auth.controller.dto.SignUpRequest; +import org.layer.domain.member.entity.Member; +import org.layer.domain.member.entity.SocialType; +import org.layer.domain.member.repository.MemberRepository; +import org.layer.oauth.dto.service.MemberInfoServiceResponse; +import org.springframework.stereotype.Service; + +import java.util.Optional; + +import static org.layer.common.exception.MemberExceptionType.NOT_A_NEW_MEMBER; +import static org.layer.domain.member.entity.MemberRole.USER; + +@RequiredArgsConstructor +@Service +public class MemberService { + private final MemberRepository memberRepository; + public Member findMemberById(Long memberId) { + return memberRepository.findById(memberId).orElse(null); + } + + // 소셜 아이디와 소셜 타입으로 멤버 찾기. 멤버가 없으면 Exception + public Member findMemberBySocialIdAndSocialType(String socialId, SocialType socialType) { + return memberRepository.findBySocialIdAndSocialType(socialId, socialType) + .orElseThrow(() -> new BaseCustomException(MemberExceptionType.NOT_FOUND_USER)); + } + + public void checkIsNewMember(String socialId, SocialType socialType) { + Optional memberOpt = memberRepository.findBySocialIdAndSocialType(socialId, socialType); + + if(memberOpt.isPresent()) { + throw new BaseCustomException(NOT_A_NEW_MEMBER); + } + } + + public Member saveMember(SignUpRequest signUpRequest, MemberInfoServiceResponse memberInfo) { + Member member = Member.builder() + .name(signUpRequest.name()) + .memberRole(USER) + .email(memberInfo.email()) + .socialId(memberInfo.socialId()) + .socialType(memberInfo.socialType()) + .build(); + + memberRepository.save(member); + + return member; + } + +} diff --git a/layer-api/src/main/java/org/layer/domain/member/service/MemberUtil.java b/layer-api/src/main/java/org/layer/domain/member/service/MemberUtil.java new file mode 100644 index 00000000..00569d31 --- /dev/null +++ b/layer-api/src/main/java/org/layer/domain/member/service/MemberUtil.java @@ -0,0 +1,27 @@ +package org.layer.domain.member.service; + +import lombok.RequiredArgsConstructor; +import org.layer.common.exception.BaseCustomException; +import org.layer.domain.jwt.SecurityUtil; +import org.layer.domain.member.entity.Member; +import org.layer.domain.member.repository.MemberRepository; +import org.springframework.stereotype.Component; + +import static org.layer.common.exception.MemberExceptionType.NOT_FOUND_USER; +@RequiredArgsConstructor +@Component +public class MemberUtil { + private final SecurityUtil securityUtil; + private final MemberRepository memberRepository; + public Member getCurrentMember() { + return memberRepository + .findById(securityUtil.getCurrentMemberId()) + .orElseThrow(() -> new BaseCustomException(NOT_FOUND_USER)); + } + + public Member getMemberByMemberId(Long memberId) { + return memberRepository. + findById(memberId) + .orElseThrow(() -> new BaseCustomException(NOT_FOUND_USER)); + } +} diff --git a/layer-api/src/main/java/org/layer/resolver/MemberIdResolver.java b/layer-api/src/main/java/org/layer/resolver/MemberIdResolver.java new file mode 100644 index 00000000..10fb2b3c --- /dev/null +++ b/layer-api/src/main/java/org/layer/resolver/MemberIdResolver.java @@ -0,0 +1,45 @@ +package org.layer.resolver; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.layer.common.annotation.MemberId; +import org.layer.common.exception.BaseCustomException; +import org.springframework.core.MethodParameter; +import org.springframework.security.core.context.SecurityContextHolder; +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 java.util.Optional; + +import static org.layer.common.exception.MemberExceptionType.UNAUTHORIZED_USER; + +@Component +@RequiredArgsConstructor +@Slf4j +public class MemberIdResolver implements HandlerMethodArgumentResolver { + @Override + public boolean supportsParameter(MethodParameter parameter) { + // 1. 어노테이션 체크 + var annotation = parameter.hasParameterAnnotation(MemberId.class); + + // 2. 파라미터의 타입 체크 + var parameterType = parameter.getParameterType().equals(Long.class); + + return (annotation && parameterType); + } + + @Override + public Long resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception { + try{ + var securityContext = SecurityContextHolder.getContext(); + var memberId = Optional.ofNullable(securityContext).map(it -> it.getAuthentication().getName()).orElseThrow(() -> new BaseCustomException(UNAUTHORIZED_USER)); + + return Long.parseLong(memberId); + } catch(Exception e) { + throw new BaseCustomException(UNAUTHORIZED_USER); + } + } +} diff --git a/layer-common/src/main/java/org/layer/common/exception/MemberExceptionType.java b/layer-common/src/main/java/org/layer/common/exception/MemberExceptionType.java index 1536d6ee..29b67706 100644 --- a/layer-common/src/main/java/org/layer/common/exception/MemberExceptionType.java +++ b/layer-common/src/main/java/org/layer/common/exception/MemberExceptionType.java @@ -9,7 +9,10 @@ public enum MemberExceptionType implements ExceptionType { /** * 400 */ - NOT_FOUND_USER(HttpStatus.NOT_FOUND, "유효한 유저를 찾지 못했습니다."); + NOT_FOUND_USER(HttpStatus.NOT_FOUND, "유효한 유저를 찾지 못했습니다."), + UNAUTHORIZED_USER(HttpStatus.UNAUTHORIZED, "로그인되지 않은 사용자입니다"), + FAIL_TO_AUTH(HttpStatus.BAD_REQUEST, "인증에 실패했습니다."), + NOT_A_NEW_MEMBER(HttpStatus.BAD_REQUEST, "이미 가입된 회원입니다."); private final HttpStatus status; diff --git a/layer-domain/src/main/java/org/layer/domain/member/entity/Member.java b/layer-domain/src/main/java/org/layer/domain/member/entity/Member.java index 34c082a7..f19c135f 100644 --- a/layer-domain/src/main/java/org/layer/domain/member/entity/Member.java +++ b/layer-domain/src/main/java/org/layer/domain/member/entity/Member.java @@ -8,9 +8,12 @@ import jakarta.persistence.Id; import jakarta.validation.constraints.NotNull; import lombok.AccessLevel; +import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; +import java.time.LocalDateTime; + @Getter @Entity @NoArgsConstructor(access = AccessLevel.PROTECTED) @@ -35,4 +38,15 @@ public class Member { @NotNull private String socialId; + + + @Builder(access = AccessLevel.PUBLIC) + private Member(String name,String email, MemberRole memberRole, + SocialType socialType, String socialId) { + this.name = name; + this.email = email; + this.memberRole = memberRole; + this.socialType = socialType; + this.socialId = socialId; + } } diff --git a/layer-domain/src/main/java/org/layer/domain/member/repository/MemberRepository.java b/layer-domain/src/main/java/org/layer/domain/member/repository/MemberRepository.java index a733e798..d3648db8 100644 --- a/layer-domain/src/main/java/org/layer/domain/member/repository/MemberRepository.java +++ b/layer-domain/src/main/java/org/layer/domain/member/repository/MemberRepository.java @@ -1,8 +1,13 @@ package org.layer.domain.member.repository; +import jakarta.validation.constraints.NotNull; import org.layer.domain.member.entity.Member; +import org.layer.domain.member.entity.SocialType; import org.springframework.data.jpa.repository.JpaRepository; +import java.util.Optional; + public interface MemberRepository extends JpaRepository { + Optional findBySocialIdAndSocialType(@NotNull String socialId, @NotNull SocialType socialType); } diff --git a/layer-domain/src/main/java/org/layer/domain/space/entity/Space.java b/layer-domain/src/main/java/org/layer/domain/space/entity/Space.java index d911f252..3c10bd58 100644 --- a/layer-domain/src/main/java/org/layer/domain/space/entity/Space.java +++ b/layer-domain/src/main/java/org/layer/domain/space/entity/Space.java @@ -31,5 +31,8 @@ public class Space { @NotNull private Long leaderId; - private Long defaultFormId; + /** + * Form Relationid + */ + private Long formId; } diff --git a/layer-external/build.gradle b/layer-external/build.gradle index e69de29b..17ff2c7c 100644 --- a/layer-external/build.gradle +++ b/layer-external/build.gradle @@ -0,0 +1,3 @@ +dependencies { + implementation project(path: ':layer-domain') +} \ No newline at end of file diff --git a/layer-external/src/main/java/org/layer/oauth/config/GoogleOAuthConfig.java b/layer-external/src/main/java/org/layer/oauth/config/GoogleOAuthConfig.java new file mode 100644 index 00000000..7cbc4f3e --- /dev/null +++ b/layer-external/src/main/java/org/layer/oauth/config/GoogleOAuthConfig.java @@ -0,0 +1,25 @@ +package org.layer.oauth.config; + +import lombok.Getter; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Configuration; + +@Getter +@Configuration +public class GoogleOAuthConfig { + public static final String AUTHORIZATION = "Authorization"; + public static final String TOKEN_PREFIX = "Bearer "; + public static final String GOOGLE_CODE_URI = "https://accounts.google.com/o/oauth2/v2/auth"; + public static final String GOOGLE_TOKEN_URI = "https://oauth2.googleapis.com/token"; + public static final String GOOGLE_USER_INFO_URI = "https://www.googleapis.com/userinfo/v2/me"; + + + @Value("${google.login.client_id}") + private String googleClientId; + + @Value("${google.login.client_secret}") + private String googleClientSecret; + + @Value("${google.login.redirect_uri}") + private String googleRedirectUri; +} diff --git a/layer-external/src/main/java/org/layer/oauth/config/KakaoOAuthConfig.java b/layer-external/src/main/java/org/layer/oauth/config/KakaoOAuthConfig.java new file mode 100644 index 00000000..e39fe55c --- /dev/null +++ b/layer-external/src/main/java/org/layer/oauth/config/KakaoOAuthConfig.java @@ -0,0 +1,35 @@ +package org.layer.oauth.config; + +import lombok.Getter; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Configuration; + +@Getter +@Configuration +public class KakaoOAuthConfig { + public static final String AUTHORIZATION = "Authorization"; + public static final String KAKAO_URI = "https://kapi.kakao.com/v2/user/me"; + public static final String TOKEN_PREFIX = "Bearer "; + + @Value("${kakao.login.api_key}") + private String kakaoLoginApiKey; + + @Value("${kakao.login.redirect_uri}") + private String redirectUri; + + @Value("${kakao.login.uri.code}") + private String codeReqeustUri; + + @Value("${kakao.login.uri.base}") + private String kakaoAuthBaseUri; + + @Value("${kakao.login.uri.token}") + private String tokenRequestUri; + + @Value("${kakao.api.uri.base}") + private String kakaoApiBaseUri; + + @Value("${kakao.api.uri.user}") + private String kakaoApiUserInfoRequestUri; + +} diff --git a/layer-external/src/main/java/org/layer/oauth/dto/service/MemberInfoServiceResponse.java b/layer-external/src/main/java/org/layer/oauth/dto/service/MemberInfoServiceResponse.java new file mode 100644 index 00000000..d8318687 --- /dev/null +++ b/layer-external/src/main/java/org/layer/oauth/dto/service/MemberInfoServiceResponse.java @@ -0,0 +1,6 @@ +package org.layer.oauth.dto.service; + +import org.layer.domain.member.entity.SocialType; + +public record MemberInfoServiceResponse(String socialId, SocialType socialType, String email) { +} diff --git a/layer-external/src/main/java/org/layer/oauth/dto/service/google/GoogleGetMemberInfoServiceResponse.java b/layer-external/src/main/java/org/layer/oauth/dto/service/google/GoogleGetMemberInfoServiceResponse.java new file mode 100644 index 00000000..eb09b963 --- /dev/null +++ b/layer-external/src/main/java/org/layer/oauth/dto/service/google/GoogleGetMemberInfoServiceResponse.java @@ -0,0 +1,7 @@ +package org.layer.oauth.dto.service.google; + +import org.layer.oauth.dto.service.kakao.KakaoAccountServiceResponse; +import org.layer.oauth.dto.service.kakao.KakaoGetMemberInfoServiceResponse; + +public record GoogleGetMemberInfoServiceResponse(String id, String email) { +} diff --git a/layer-external/src/main/java/org/layer/oauth/dto/service/google/GoogleTokenServiceResponse.java b/layer-external/src/main/java/org/layer/oauth/dto/service/google/GoogleTokenServiceResponse.java new file mode 100644 index 00000000..937a25e2 --- /dev/null +++ b/layer-external/src/main/java/org/layer/oauth/dto/service/google/GoogleTokenServiceResponse.java @@ -0,0 +1,6 @@ +package org.layer.oauth.dto.service.google; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public record GoogleTokenServiceResponse(@JsonProperty("access_token") String accessToken) { +} diff --git a/layer-external/src/main/java/org/layer/oauth/dto/service/kakao/KakaoAccountServiceResponse.java b/layer-external/src/main/java/org/layer/oauth/dto/service/kakao/KakaoAccountServiceResponse.java new file mode 100644 index 00000000..d09c6970 --- /dev/null +++ b/layer-external/src/main/java/org/layer/oauth/dto/service/kakao/KakaoAccountServiceResponse.java @@ -0,0 +1,4 @@ +package org.layer.oauth.dto.service.kakao; + +public record KakaoAccountServiceResponse(String email) { +} diff --git a/layer-external/src/main/java/org/layer/oauth/dto/service/kakao/KakaoGetMemberInfoServiceResponse.java b/layer-external/src/main/java/org/layer/oauth/dto/service/kakao/KakaoGetMemberInfoServiceResponse.java new file mode 100644 index 00000000..77b9de42 --- /dev/null +++ b/layer-external/src/main/java/org/layer/oauth/dto/service/kakao/KakaoGetMemberInfoServiceResponse.java @@ -0,0 +1,8 @@ +package org.layer.oauth.dto.service.kakao; + +public record KakaoGetMemberInfoServiceResponse(String id, KakaoAccountServiceResponse kakao_account) { + public static KakaoGetMemberInfoServiceResponse of(String id, KakaoAccountServiceResponse kakao_account) { + return new KakaoGetMemberInfoServiceResponse(id, kakao_account); + } + +} diff --git a/layer-external/src/main/java/org/layer/oauth/dto/service/kakao/KakaoTokenServiceResponse.java b/layer-external/src/main/java/org/layer/oauth/dto/service/kakao/KakaoTokenServiceResponse.java new file mode 100644 index 00000000..c77e4b6f --- /dev/null +++ b/layer-external/src/main/java/org/layer/oauth/dto/service/kakao/KakaoTokenServiceResponse.java @@ -0,0 +1,33 @@ +package org.layer.oauth.dto.service.kakao; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Getter; + +@Getter +public class KakaoTokenServiceResponse { + + /** + * tokenType: bearer로 고정 + */ + @JsonProperty("token_type") + private String tokenType; + @JsonProperty("access_token") + private String accessToken; + /** + * 액세스 토큰과 ID 토큰의 만료 시간(초) + */ + @JsonProperty("expires_in") + private Integer expiresIn; + @JsonProperty("refresh_token") + private String refreshToken; + /** + * 리프레시 토큰 만료 시간(초) + */ + @JsonProperty("refresh_token_expires_in") + private Integer refreshTokenExpiresIn; + + @JsonProperty("id_token") + private String idToken; + @JsonProperty("scope") + private String scope; +} \ No newline at end of file diff --git a/layer-external/src/main/java/org/layer/oauth/service/GoogleService.java b/layer-external/src/main/java/org/layer/oauth/service/GoogleService.java new file mode 100644 index 00000000..15d3619f --- /dev/null +++ b/layer-external/src/main/java/org/layer/oauth/service/GoogleService.java @@ -0,0 +1,76 @@ +package org.layer.oauth.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.layer.common.exception.BaseCustomException; +import org.layer.oauth.config.GoogleOAuthConfig; +import org.layer.oauth.dto.service.google.GoogleTokenServiceResponse; +import org.layer.oauth.dto.service.MemberInfoServiceResponse; +import org.layer.oauth.dto.service.google.GoogleGetMemberInfoServiceResponse; +import org.springframework.http.HttpStatusCode; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestClient; +import org.springframework.web.util.UriComponentsBuilder; + +import java.util.LinkedHashMap; +import java.util.Map; + +import static org.layer.common.exception.MemberExceptionType.FAIL_TO_AUTH; +import static org.layer.domain.member.entity.SocialType.GOOGLE; +import static org.layer.oauth.config.GoogleOAuthConfig.*; +import static org.springframework.http.MediaType.APPLICATION_JSON; + +@Slf4j +@RequiredArgsConstructor +@Service +public class GoogleService { + private final GoogleOAuthConfig googleOAuthConfig; + + //== 액세스 토큰으로 사용자 정보 가져오기 ==// + public MemberInfoServiceResponse getMemberInfo(final String accessToken) { + GoogleGetMemberInfoServiceResponse response = null; + try { + RestClient restClient = RestClient.create(); + response = restClient.get() + .uri(GOOGLE_USER_INFO_URI) + .header(AUTHORIZATION, TOKEN_PREFIX + accessToken) + .header("Content-type", "application/x-www-form-urlencoded;charset=utf-8") + .retrieve() + .onStatus(HttpStatusCode::is4xxClientError, + (googleRequest, googleResponse) -> { + throw new BaseCustomException(FAIL_TO_AUTH); + }) + .body(GoogleGetMemberInfoServiceResponse.class); + } catch(Exception e) { + throw new BaseCustomException(FAIL_TO_AUTH); + } + + assert response != null; + return new MemberInfoServiceResponse(response.id(), GOOGLE, response.email()); + } + + public String getToken(String code) { + log.info("redirect uri: {}", googleOAuthConfig.getGoogleRedirectUri()); + // 토큰 요청 데이터 + String uri = UriComponentsBuilder.fromOriginHeader(GOOGLE_TOKEN_URI) + .toUriString(); + + Map params = new LinkedHashMap<>(); + params.put("client_id", googleOAuthConfig.getGoogleClientId()); + params.put("client_secret", googleOAuthConfig.getGoogleClientSecret()); + params.put("code", code); + params.put("grant_type", "authorization_code"); + params.put("redirect_uri", googleOAuthConfig.getGoogleRedirectUri()); + + + GoogleTokenServiceResponse response = RestClient.create(GOOGLE_TOKEN_URI) + .post() + .contentType(APPLICATION_JSON) + .body(params) + .retrieve() + .body(GoogleTokenServiceResponse.class); + + assert response != null; + return response.accessToken(); + } +} diff --git a/layer-external/src/main/java/org/layer/oauth/service/KakaoService.java b/layer-external/src/main/java/org/layer/oauth/service/KakaoService.java new file mode 100644 index 00000000..02ee66be --- /dev/null +++ b/layer-external/src/main/java/org/layer/oauth/service/KakaoService.java @@ -0,0 +1,71 @@ +package org.layer.oauth.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.layer.common.exception.BaseCustomException; +import org.layer.oauth.config.KakaoOAuthConfig; +import org.layer.oauth.dto.service.kakao.KakaoGetMemberInfoServiceResponse; +import org.layer.oauth.dto.service.MemberInfoServiceResponse; +import org.springframework.http.HttpStatusCode; +import org.springframework.stereotype.Service; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.client.RestClient; + +import java.util.Map; + +import static org.layer.common.exception.MemberExceptionType.FAIL_TO_AUTH; +import static org.layer.domain.member.entity.SocialType.KAKAO; +import static org.layer.oauth.config.KakaoOAuthConfig.*; + + +@Slf4j +@RequiredArgsConstructor +@Service +public class KakaoService { + private final KakaoOAuthConfig kakaoOAuthConfig; + + public MemberInfoServiceResponse getMemberInfo(final String accessToken) { + KakaoGetMemberInfoServiceResponse response = null; + try { + RestClient restClient = RestClient.create(); + response = restClient.get() + .uri(KAKAO_URI) + .header(AUTHORIZATION, TOKEN_PREFIX + accessToken) + .header("Content-type", "application/x-www-form-urlencoded;charset=utf-8") + .retrieve() + .onStatus(HttpStatusCode::is4xxClientError, + (kakaoRequest, kakaoResponse) -> { + throw new BaseCustomException(FAIL_TO_AUTH); + }) + .body(KakaoGetMemberInfoServiceResponse.class); + } catch(Exception e) { + throw new BaseCustomException(FAIL_TO_AUTH); + } + + assert response != null; + return new MemberInfoServiceResponse(response.id(), KAKAO, response.kakao_account().email()); + } + + //== 프론트에서 처리해주는 부분 TODO: 추후 삭제하기 ==// + public String getToken(String code) { + // 토큰 요청 데이터 + MultiValueMap params = new LinkedMultiValueMap<>(); + params.add("grant_type", "authorization_code"); + params.add("client_id", kakaoOAuthConfig.getKakaoLoginApiKey()); + params.add("redirect_uri", kakaoOAuthConfig.getRedirectUri()); + params.add("code", code); + + + Map response = RestClient.create(kakaoOAuthConfig.getKakaoAuthBaseUri()) + .post() + .uri(kakaoOAuthConfig.getTokenRequestUri()) + .body(params) + .header("Content-type", "application/x-www-form-urlencoded;charset=utf-8") //요청 헤더 + .retrieve() + .body(Map.class); + + assert response != null; + return (String) response.get("access_token"); + } +}