From 50dcd964332989c93511e2c074210689e45ef169 Mon Sep 17 00:00:00 2001 From: junwon <67488973+wjdwnsdnjs13@users.noreply.github.com> Date: Thu, 18 Jul 2024 21:56:55 +0900 Subject: [PATCH] =?UTF-8?q?[BSVR-71]=20=EB=A1=9C=EA=B7=B8=EC=9D=B8/?= =?UTF-8?q?=ED=9A=8C=EC=9B=90=EA=B0=80=EC=9E=85,=20JWT=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20(#34)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * build : security 의존성 추가 * feat : security config 추가 * docs : jwt 시크릿 키 추가 * build : jwt 사용을 위한 라이브러리 추가 * feat : jwtFilter 등록 * feat : jwt 구현 * feat : jwtFilter 구현 * docs : jwt 구현 * docs : 카카오 Http 요청을 위한 webflux 추가 * feat : member 도메인 구현 * feat : memberEntity 구현 * feat : 카카오 엑세스 토큰, 유저 정보 가져오기 구현 * feat : 멤버 Role 관리를 위한 enum 구현 * remove : 초기 세팅 테스트용으로 작성된 코드 제거 * feat : 닉네임 중복 확인 구현 * feat : 닉네임 중복 확인 구현 * feat : 닉네임 중복 에러 구현 * refactor : 카카오 계정 정보 가져오는 로직 변경 * feat : 회원가입, 로그인 구현 * feat : 회원가입, 로그인 구현 * feat : id 토큰을 이용한 로그인 구현 * refactor : email과 이름, 전화번호, 레벨 권한 문제로 인해 nullable 처리 * refactor : url과 clientId 키 값 분리 * feat : Authentication를 상속받은 jwt 토큰 구현 * refactor : 권한 문제로 인해 idToken값 비교로 변경 * docs : oauth를 위한 환경 변수 추가 * fix : SecretKey 타입 차이로 인한 공백 오류 해결 * refactor : 로그인, 회원가입 JWT 사용해서 구현 * feat : 빌더 패턴 추가 * refactor : Member 변경에 따른 get 메소드 변경 * fix : spotless 포맷팅 오류 해결 * fix : 닉네임 중복 Exception 수정 * fix : swagger jwtFilter 오류 해결 * fix : 기존 회원 정보가 없을 경우 발생하는 에러 수정 * refactor : JwtFilter 개발 환경에서 모든 api 허용 * fix : my_team nullable로 인한 오류 해결(추 후 해당 필드와 컬럼 삭제 예정) * feat: jwt 파일 분리 * feat: gitignore update * feat: update gitignore * Delete application/src/main/resources/application-jwt.yml * feat: add kakao yml to gitignore * fix: properties mapping 이슈 해결 --------- Co-authored-by: EunjiShin Co-authored-by: 우디 <38103085+EunjiShin@users.noreply.github.com> --- .gitignore | 6 +- application/.gitignore | 4 +- application/build.gradle.kts | 10 ++ .../common/config/SecurityConfig.java | 43 +++++++ .../common/jwt/JwtAuthenticationFilter.java | 69 ++++++++++ .../spot/application/common/jwt/JwtToken.java | 52 ++++++++ .../application/common/jwt/JwtTokenUtil.java | 118 +++++++++++++++++ .../member/controller/MemberController.java | 57 ++++++--- .../member/dto/request/RegisterReq.java | 30 +++++ .../src/main/resources/application.yaml | 2 + .../exception/member/MemberErrorCode.java | 1 + .../exception/member/MemberException.java | 6 + infrastructure/jpa/build.gradle.kts | 3 + .../spot/jpa/member/entity/MemberEntity.java | 9 +- .../repository/MemberCustomRepository.java | 31 ----- .../repository/MemberJpaRepository.java | 8 +- .../repository/MemberRepositoryImpl.java | 18 +-- .../spot/jpa/oauth/OauthRepositoryImpl.java | 114 +++++++++++++++++ .../jpa/oauth/entity/KakaoTokenEntity.java | 36 ++++++ .../jpa/oauth/entity/KakaoUserInfoEntity.java | 119 ++++++++++++++++++ .../src/main/resources/application-jpa.yaml | 6 +- .../usecase/port/in/member/MemberUsecase.java | 8 +- .../usecase/port/out/MemberRepository.java | 12 -- .../port/out/member/MemberRepository.java | 14 +++ .../port/out/oauth/OauthRepository.java | 12 ++ .../usecase/service/member/MemberService.java | 45 +++++-- versions.properties | 2 + 27 files changed, 743 insertions(+), 92 deletions(-) create mode 100644 application/src/main/java/org/depromeet/spot/application/common/config/SecurityConfig.java create mode 100644 application/src/main/java/org/depromeet/spot/application/common/jwt/JwtAuthenticationFilter.java create mode 100644 application/src/main/java/org/depromeet/spot/application/common/jwt/JwtToken.java create mode 100644 application/src/main/java/org/depromeet/spot/application/common/jwt/JwtTokenUtil.java create mode 100644 application/src/main/java/org/depromeet/spot/application/member/dto/request/RegisterReq.java delete mode 100644 infrastructure/jpa/src/main/java/org/depromeet/spot/jpa/member/repository/MemberCustomRepository.java create mode 100644 infrastructure/jpa/src/main/java/org/depromeet/spot/jpa/oauth/OauthRepositoryImpl.java create mode 100644 infrastructure/jpa/src/main/java/org/depromeet/spot/jpa/oauth/entity/KakaoTokenEntity.java create mode 100644 infrastructure/jpa/src/main/java/org/depromeet/spot/jpa/oauth/entity/KakaoUserInfoEntity.java delete mode 100644 usecase/src/main/java/org/depromeet/spot/usecase/port/out/MemberRepository.java create mode 100644 usecase/src/main/java/org/depromeet/spot/usecase/port/out/member/MemberRepository.java create mode 100644 usecase/src/main/java/org/depromeet/spot/usecase/port/out/oauth/OauthRepository.java diff --git a/.gitignore b/.gitignore index 754acf39..a14d1e9d 100644 --- a/.gitignore +++ b/.gitignore @@ -382,4 +382,8 @@ gradle-app.setting # End of https://www.toptal.com/developers/gitignore/api/macos,windows,intellij,intellij+iml,intellij+all,visualstudiocode,java,gradle,kotlin /db/ -.env \ No newline at end of file +.env + +*.application-jwt.yml +application-jwt.yml +application-kakao.yml diff --git a/application/.gitignore b/application/.gitignore index b63da455..59c70a62 100644 --- a/application/.gitignore +++ b/application/.gitignore @@ -39,4 +39,6 @@ bin/ .vscode/ ### Mac OS ### -.DS_Store \ No newline at end of file +.DS_Store + +*.application-jwt.yml \ No newline at end of file diff --git a/application/build.gradle.kts b/application/build.gradle.kts index ff13140a..2d5e753d 100644 --- a/application/build.gradle.kts +++ b/application/build.gradle.kts @@ -11,8 +11,18 @@ dependencies { implementation("org.springframework.boot:spring-boot-starter-validation") implementation("org.springframework:spring-aspects") + // security + implementation("org.springframework.boot:spring-boot-starter-security") + // swagger implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:_") + + // jwt +// implementation("io.jsonwebtoken:jjwt:_") + implementation("io.jsonwebtoken:jjwt-api:0.11.5") + implementation("io.jsonwebtoken:jjwt-impl:0.11.5") + implementation("io.jsonwebtoken:jjwt-jackson:0.11.5") + } // spring boot main application이므로 실행 가능한 jar를 생성한다. diff --git a/application/src/main/java/org/depromeet/spot/application/common/config/SecurityConfig.java b/application/src/main/java/org/depromeet/spot/application/common/config/SecurityConfig.java new file mode 100644 index 00000000..14f73f13 --- /dev/null +++ b/application/src/main/java/org/depromeet/spot/application/common/config/SecurityConfig.java @@ -0,0 +1,43 @@ +package org.depromeet.spot.application.common.config; + +import org.depromeet.spot.application.common.jwt.JwtAuthenticationFilter; +import org.depromeet.spot.application.common.jwt.JwtTokenUtil; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; + +import lombok.RequiredArgsConstructor; + +@Configuration +@EnableWebSecurity +@RequiredArgsConstructor +public class SecurityConfig { + + private final JwtTokenUtil jwtTokenUtil; + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + http + // cross-site -> stateless라서 필요 없음. + .csrf(AbstractHttpConfigurer::disable) + // 초기 로그인 화면 필요 없음. + .formLogin(AbstractHttpConfigurer::disable) + // 토큰 방식을 사용하므로 httpBasic도 제거. + .httpBasic(AbstractHttpConfigurer::disable) + .authorizeHttpRequests( + authorize -> + authorize + // 테스트, 개발 중엔 모든 경로 오픈. + .requestMatchers("/**") + .permitAll()) + // UsernamePasswordAuthenticationFilter 필터 전에 jwt 필터가 먼저 동작하도록함. + .addFilterBefore( + new JwtAuthenticationFilter(jwtTokenUtil), + UsernamePasswordAuthenticationFilter.class); + return http.build(); + } +} diff --git a/application/src/main/java/org/depromeet/spot/application/common/jwt/JwtAuthenticationFilter.java b/application/src/main/java/org/depromeet/spot/application/common/jwt/JwtAuthenticationFilter.java new file mode 100644 index 00000000..89c743b2 --- /dev/null +++ b/application/src/main/java/org/depromeet/spot/application/common/jwt/JwtAuthenticationFilter.java @@ -0,0 +1,69 @@ +package org.depromeet.spot.application.common.jwt; + +import java.io.IOException; +import java.util.List; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +import org.depromeet.spot.domain.member.enums.MemberRole; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; +import org.springframework.web.server.ResponseStatusException; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@RequiredArgsConstructor +@Slf4j +@Component +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + private final JwtTokenUtil jwtTokenUtil; + + @Override + protected void doFilterInternal( + HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + + List list = + List.of( + // swagger-ui와 v3/api-docs는 스웨거를 제외하기 위해 등록. + // 혹시나 스웨거 자원 사용 에러 발생 시 아래 두 가지 추가 필요함. + // Swagger UI에서 사용하는 외부 라ㅇ이브러리 제공 엔드포인트 : "/webjars/**" + // Swagger UI에서 사용하는 리소스 제공 엔드포인트 : "/swagger-resources/**" + // 로그인, 회원가입은 제외 + "/swagger-ui", "/v3/api-docs", "/api/v1/members", "/kakao/", "/api/v1/"); + + // 현재 URL 이 LIST 안에 포함되있는걸로 시작하는가? + boolean flag = list.stream().anyMatch(url -> request.getRequestURI().startsWith(url)); + + if (flag) { + filterChain.doFilter(request, response); + return; + } + + String header = request.getHeader(HttpHeaders.AUTHORIZATION); + log.info("JwtAuthenticationFilter header : {}", header); + + // header가 null이거나 빈 문자열이면 안됨. + if (header != null && !header.equalsIgnoreCase("")) { + if (header.startsWith("Bearer")) { + String access_token = header.split(" ")[1]; + if (jwtTokenUtil.isValidateToken(access_token)) { + String memberId = jwtTokenUtil.getIdFromJWT(access_token); + MemberRole role = MemberRole.valueOf(jwtTokenUtil.getRoleFromJWT(access_token)); + JwtToken jwtToken = new JwtToken(memberId, role); + SecurityContextHolder.getContext().setAuthentication(jwtToken); + filterChain.doFilter(request, response); + } + } + // 토큰 검증 실패 -> Exception + } else throw new ResponseStatusException(HttpStatus.UNAUTHORIZED); + } +} diff --git a/application/src/main/java/org/depromeet/spot/application/common/jwt/JwtToken.java b/application/src/main/java/org/depromeet/spot/application/common/jwt/JwtToken.java new file mode 100644 index 00000000..11d7a2dc --- /dev/null +++ b/application/src/main/java/org/depromeet/spot/application/common/jwt/JwtToken.java @@ -0,0 +1,52 @@ +package org.depromeet.spot.application.common.jwt; + +import java.util.Collection; +import java.util.List; + +import org.depromeet.spot.domain.member.enums.MemberRole; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class JwtToken implements Authentication { + // TODO : Authentication을 상속받고 UserDetail을 상속받은 커스텀 유저 정보 객체 생성해줘야함. + private String memberId; + private MemberRole memberRole; + + @Override + public Collection getAuthorities() { + return List.of(); + } + + @Override + public Object getCredentials() { + return null; + } + + @Override + public Object getDetails() { + return null; + } + + @Override + public Object getPrincipal() { + return null; + } + + @Override + public boolean isAuthenticated() { + return false; + } + + @Override + public void setAuthenticated(boolean isAuthenticated) throws IllegalArgumentException {} + + @Override + public String getName() { + return ""; + } +} diff --git a/application/src/main/java/org/depromeet/spot/application/common/jwt/JwtTokenUtil.java b/application/src/main/java/org/depromeet/spot/application/common/jwt/JwtTokenUtil.java new file mode 100644 index 00000000..da785949 --- /dev/null +++ b/application/src/main/java/org/depromeet/spot/application/common/jwt/JwtTokenUtil.java @@ -0,0 +1,118 @@ +package org.depromeet.spot.application.common.jwt; + +import java.security.Key; +import java.util.Date; +import java.util.HashMap; +import java.util.Map; + +import javax.crypto.spec.SecretKeySpec; + +import org.depromeet.spot.domain.member.Member; +import org.depromeet.spot.domain.member.enums.MemberRole; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpHeaders; +import org.springframework.stereotype.Component; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.Jws; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.MalformedJwtException; +import io.jsonwebtoken.SignatureAlgorithm; +import io.jsonwebtoken.UnsupportedJwtException; +import io.jsonwebtoken.security.WeakKeyException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Component +@RequiredArgsConstructor +public class JwtTokenUtil { + // JWT를 생성하고 관리하는 클래스 + + // 토큰에 사용되는 시크릿 키 + @Value("${spring.jwt.secret}") + private String SECRETKEY; + + public HttpHeaders getJWTToken(Member member) { + // TODO 토큰 구현하기. + + // jwt 토큰 생성 + String token = generateToken(member.getId(), member.getRole()); + + HttpHeaders headers = new HttpHeaders(); + headers.set(HttpHeaders.AUTHORIZATION, "Bearer " + token); + return headers; + } + + public String generateToken(Long memberId, MemberRole memberRole) { + return Jwts.builder() + .setHeader(createHeader()) + .setClaims(createClaims(memberRole)) + .setSubject(memberId.toString()) + .setIssuedAt(new Date(System.currentTimeMillis())) + .setExpiration( + new Date( + System.currentTimeMillis() + 1000 * 60 * 60 * 24 * 30L)) // 토큰 만료 시간 + .signWith(SignatureAlgorithm.HS256, SECRETKEY.getBytes()) + .compact(); + } + + public String getIdFromJWT(String token) { + return Jwts.parser() + .setSigningKey(SECRETKEY.getBytes()) + .parseClaimsJws(token) + .getBody() + .get("id", String.class); + } + + public String getRoleFromJWT(String token) { + return Jwts.parser() + .setSigningKey(SECRETKEY.getBytes()) + .parseClaimsJws(token) + .getBody() + .get("role", String.class); + } + + public Jws getClaims(String token) { + return Jwts.parserBuilder().setSigningKey(createSignature()).build().parseClaimsJws(token); + } + + public boolean isValidateToken(String token) { + try { + Jws claims = getClaims(token); + return true; + } catch (ExpiredJwtException exception) { + log.error("Token Expired"); + throw new ExpiredJwtException(exception.getHeader(), exception.getClaims(), token); + } catch (UnsupportedJwtException | WeakKeyException exception) { + log.error("Unsupported Token"); + throw new UnsupportedJwtException("지원되지 않는 토큰입니다."); + } catch (MalformedJwtException | IllegalArgumentException exception) { + throw new MalformedJwtException("잘못된 형식의 토큰입니다."); + } + } + + private Map createHeader() { + // 헤더 생성 + Map headers = new HashMap<>(); + + headers.put("typ", "JWT"); + headers.put("alg", "HS256"); // 서명? 생성에 사용될 알고리즘 + + return headers; + } + + // Claim -> 정보를 key-value 형태로 저장함. + private Map createClaims(MemberRole role) { + Map claims = new HashMap<>(); + + claims.put("role", role); + return claims; + } + + private Key createSignature() { + byte[] apiKeySecretBytes = SECRETKEY.getBytes(); + return new SecretKeySpec(apiKeySecretBytes, SignatureAlgorithm.HS256.getJcaName()); + } +} diff --git a/application/src/main/java/org/depromeet/spot/application/member/controller/MemberController.java b/application/src/main/java/org/depromeet/spot/application/member/controller/MemberController.java index bcf9ebed..1e22cec7 100644 --- a/application/src/main/java/org/depromeet/spot/application/member/controller/MemberController.java +++ b/application/src/main/java/org/depromeet/spot/application/member/controller/MemberController.java @@ -1,16 +1,18 @@ package org.depromeet.spot.application.member.controller; -import java.util.List; +import jakarta.validation.Valid; -import org.depromeet.spot.application.member.dto.request.MemberRequest; -import org.depromeet.spot.application.member.dto.response.MemberResponse; +import org.depromeet.spot.application.common.jwt.JwtTokenUtil; +import org.depromeet.spot.application.member.dto.request.RegisterReq; +import org.depromeet.spot.domain.member.Member; import org.depromeet.spot.usecase.port.in.member.MemberUsecase; +import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; 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.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.RestController; @@ -18,32 +20,51 @@ import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; -import lombok.val; +import lombok.extern.slf4j.Slf4j; -// FIXME: JPA 확인용 샘플 컨트롤러 입니다. 이후 실제 작업 시작할 때 삭제 예정이에요! @RestController @RequiredArgsConstructor +@Slf4j @Tag(name = "멤버") -@RequestMapping("/api/members") +@RequestMapping("/api/v1/members") public class MemberController { private final MemberUsecase memberUsecase; + private final JwtTokenUtil jwtTokenUtil; + @PostMapping @ResponseStatus(HttpStatus.CREATED) - @Operation(summary = "Member 생성 API") - public MemberResponse create(@RequestBody MemberRequest request) { - val member = memberUsecase.create(request.name()); - return MemberResponse.from(member); + @Operation(summary = "Member 회원가입 API") + public HttpHeaders create(@RequestBody @Valid RegisterReq request) { + + Member member = request.toDomain(); + Member memberResult = memberUsecase.create(member); + + return jwtTokenUtil.getJWTToken(memberResult); + } + + @GetMapping("/{idCode}") + @ResponseStatus(HttpStatus.OK) + @Operation(summary = "Member 로그인 API") + public HttpHeaders login( + @PathVariable("idCode") + @Parameter(name = "idCode", description = "sns idCode", required = true) + String idCode) { + + Member member = memberUsecase.login(idCode); + + return jwtTokenUtil.getJWTToken(member); } - @GetMapping + @GetMapping("/duplicatedNickname/{nickname}") @ResponseStatus(HttpStatus.OK) - @Operation(summary = "이름으로 Member 조회하는 API") - public List findByName( - @RequestParam("name") @Parameter(name = "name", description = "사용자 이름", required = true) - final String name) { - val memberList = memberUsecase.findByName(name); - return memberList.stream().map(MemberResponse::from).toList(); + @Operation(summary = "닉네임 중복확인 API") + public Boolean duplicatedNickname( + @PathVariable("nickname") + @Parameter(name = "nickname", description = "닉네임", required = true) + String nickname) { + Boolean result = memberUsecase.duplicatedNickname(nickname); + return result; } } diff --git a/application/src/main/java/org/depromeet/spot/application/member/dto/request/RegisterReq.java b/application/src/main/java/org/depromeet/spot/application/member/dto/request/RegisterReq.java new file mode 100644 index 00000000..bd3bf0d1 --- /dev/null +++ b/application/src/main/java/org/depromeet/spot/application/member/dto/request/RegisterReq.java @@ -0,0 +1,30 @@ +package org.depromeet.spot.application.member.dto.request; + +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; + +import org.depromeet.spot.domain.member.Member; +import org.hibernate.validator.constraints.Length; +import org.hibernate.validator.constraints.Range; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record RegisterReq( + @NotNull(message = "인가 id code는 필수 값입니다.") @Schema(description = "인가 id code") + String idCode, + @NotNull(message = "닉네임 값은 필수입니다.") + @Schema(description = "설정하려는 닉네임") + @Length(min = 2, max = 10, message = "닉네임은 2글자에서 10글자 사이여야합니다.") + @Pattern( + regexp = "^[a-zA-Z0-9가-힣]*$", + message = "닉네임은 알파벳 대소문자, 숫자, 한글만 허용하며, 공백은 불가능합니다.") + String nickname, + @NotNull(message = "응원 팀 선택은 필수입니다.") + @Schema(description = "응원 팀 pk") + @Range(min = 1, max = 11, message = "응원 팀은 1번(두산 베어스)부터 11번(없음)까지 입니다.") + Long teamId) { + + public Member toDomain() { + return Member.builder().idToken(idCode).nickname(nickname).teamId(teamId).build(); + } +} diff --git a/application/src/main/resources/application.yaml b/application/src/main/resources/application.yaml index 8e9bc0b4..f6a123f8 100644 --- a/application/src/main/resources/application.yaml +++ b/application/src/main/resources/application.yaml @@ -8,6 +8,8 @@ spring: include: - jpa - ncp + - jwt + - kakao # swagger를 이용해 API 명세서 생성 doc: swagger-ui: diff --git a/common/src/main/java/org/depromeet/spot/common/exception/member/MemberErrorCode.java b/common/src/main/java/org/depromeet/spot/common/exception/member/MemberErrorCode.java index be953a57..6f1ba273 100644 --- a/common/src/main/java/org/depromeet/spot/common/exception/member/MemberErrorCode.java +++ b/common/src/main/java/org/depromeet/spot/common/exception/member/MemberErrorCode.java @@ -8,6 +8,7 @@ @Getter public enum MemberErrorCode implements ErrorCode { MEMBER_NOT_FOUND(HttpStatus.NOT_FOUND, "M001", "요청 유저가 존재하지 않습니다."), + MEMBER_NICKNAME_CONFLICT(HttpStatus.CONFLICT, "M002", "닉네임이 중복됩니다."), ; private final HttpStatus status; diff --git a/common/src/main/java/org/depromeet/spot/common/exception/member/MemberException.java b/common/src/main/java/org/depromeet/spot/common/exception/member/MemberException.java index adf73795..fe5440e4 100644 --- a/common/src/main/java/org/depromeet/spot/common/exception/member/MemberException.java +++ b/common/src/main/java/org/depromeet/spot/common/exception/member/MemberException.java @@ -17,4 +17,10 @@ public MemberNotFoundException(Object o) { super(MemberErrorCode.MEMBER_NOT_FOUND.appended(o)); } } + + public static class MemberNicknameConflictException extends MemberException { + public MemberNicknameConflictException() { + super(MemberErrorCode.MEMBER_NICKNAME_CONFLICT); + } + } } diff --git a/infrastructure/jpa/build.gradle.kts b/infrastructure/jpa/build.gradle.kts index 6643e45c..8f15af2a 100644 --- a/infrastructure/jpa/build.gradle.kts +++ b/infrastructure/jpa/build.gradle.kts @@ -17,6 +17,9 @@ dependencies { // p6spy implementation("com.github.gavlyukovskiy:p6spy-spring-boot-starter:_") + + // webflux (HTTP 요청에 사용) + implementation("org.springframework.boot:spring-boot-starter-webflux") } tasks.bootJar { enabled = false } diff --git a/infrastructure/jpa/src/main/java/org/depromeet/spot/jpa/member/entity/MemberEntity.java b/infrastructure/jpa/src/main/java/org/depromeet/spot/jpa/member/entity/MemberEntity.java index af3d4f6a..395f7e75 100644 --- a/infrastructure/jpa/src/main/java/org/depromeet/spot/jpa/member/entity/MemberEntity.java +++ b/infrastructure/jpa/src/main/java/org/depromeet/spot/jpa/member/entity/MemberEntity.java @@ -51,6 +51,10 @@ public class MemberEntity extends BaseEntity { @Column(name = "team_id", nullable = false, length = 10) private Long teamId; + // TODO : 얘는 테이블 차이로 인한 임시 필드이므로 추후 수정해서 삭제해야함. + @Column(name = "my_team", nullable = false, length = 10) + private Long myTeam; + @Column(name = "role", nullable = false) private String role; @@ -60,11 +64,14 @@ public static MemberEntity from(Member member) { member.getName(), member.getNickname(), member.getPhoneNumber(), - member.getLevel(), + // TODO : 레벨 - 추 후 PrePersist, DynamicInsert 등을 통해 기본값 넣기. + 1, member.getProfileImage(), member.getSnsProvider().getValue(), member.getIdToken(), member.getTeamId(), + // TODO : 얘는 테이블 차이로 인한 임시 필드이므로 추후 수정해서 삭제해야함. + member.getTeamId(), member.getRole().getValue()); } diff --git a/infrastructure/jpa/src/main/java/org/depromeet/spot/jpa/member/repository/MemberCustomRepository.java b/infrastructure/jpa/src/main/java/org/depromeet/spot/jpa/member/repository/MemberCustomRepository.java deleted file mode 100644 index f0d490a7..00000000 --- a/infrastructure/jpa/src/main/java/org/depromeet/spot/jpa/member/repository/MemberCustomRepository.java +++ /dev/null @@ -1,31 +0,0 @@ -package org.depromeet.spot.jpa.member.repository; - -import static org.depromeet.spot.jpa.member.entity.QMemberEntity.memberEntity; - -import java.util.List; - -import org.depromeet.spot.jpa.member.entity.MemberEntity; -import org.springframework.stereotype.Repository; - -import com.querydsl.core.types.dsl.BooleanExpression; -import com.querydsl.jpa.impl.JPAQueryFactory; - -import lombok.RequiredArgsConstructor; - -@Repository -@RequiredArgsConstructor -public class MemberCustomRepository { - - private final JPAQueryFactory queryFactory; - - public List findByName(final String name) { - return queryFactory.selectFrom(memberEntity).where(eqMemberName(name)).fetch(); - } - - private BooleanExpression eqMemberName(final String name) { - if (name == null) { - return null; - } - return memberEntity.name.eq(name); - } -} diff --git a/infrastructure/jpa/src/main/java/org/depromeet/spot/jpa/member/repository/MemberJpaRepository.java b/infrastructure/jpa/src/main/java/org/depromeet/spot/jpa/member/repository/MemberJpaRepository.java index 502030df..48a2b393 100644 --- a/infrastructure/jpa/src/main/java/org/depromeet/spot/jpa/member/repository/MemberJpaRepository.java +++ b/infrastructure/jpa/src/main/java/org/depromeet/spot/jpa/member/repository/MemberJpaRepository.java @@ -1,6 +1,12 @@ package org.depromeet.spot.jpa.member.repository; +import java.util.Optional; + import org.depromeet.spot.jpa.member.entity.MemberEntity; import org.springframework.data.jpa.repository.JpaRepository; -public interface MemberJpaRepository extends JpaRepository {} +public interface MemberJpaRepository extends JpaRepository { + Optional findByIdToken(String idToken); + + Boolean existsByNickname(String nickname); +} diff --git a/infrastructure/jpa/src/main/java/org/depromeet/spot/jpa/member/repository/MemberRepositoryImpl.java b/infrastructure/jpa/src/main/java/org/depromeet/spot/jpa/member/repository/MemberRepositoryImpl.java index 8c4ebba9..ccbeab7f 100644 --- a/infrastructure/jpa/src/main/java/org/depromeet/spot/jpa/member/repository/MemberRepositoryImpl.java +++ b/infrastructure/jpa/src/main/java/org/depromeet/spot/jpa/member/repository/MemberRepositoryImpl.java @@ -1,31 +1,33 @@ package org.depromeet.spot.jpa.member.repository; -import java.util.List; +import java.util.Optional; import org.depromeet.spot.domain.member.Member; import org.depromeet.spot.jpa.member.entity.MemberEntity; -import org.depromeet.spot.usecase.port.out.MemberRepository; +import org.depromeet.spot.usecase.port.out.member.MemberRepository; import org.springframework.stereotype.Repository; import lombok.RequiredArgsConstructor; -import lombok.val; @Repository @RequiredArgsConstructor public class MemberRepositoryImpl implements MemberRepository { private final MemberJpaRepository memberJpaRepository; - private final MemberCustomRepository memberCustomRepository; @Override public Member save(Member member) { - val memberEntity = memberJpaRepository.save(MemberEntity.from(member)); + MemberEntity memberEntity = memberJpaRepository.save(MemberEntity.from(member)); return memberEntity.toDomain(); } @Override - public List findByName(String name) { - val memberEntities = memberCustomRepository.findByName(name); - return memberEntities.stream().map(MemberEntity::toDomain).toList(); + public Optional findByIdToken(String idToken) { + return memberJpaRepository.findByIdToken(idToken).map(MemberEntity::toDomain); + } + + @Override + public Boolean existsByNickname(String nickname) { + return memberJpaRepository.existsByNickname(nickname); } } diff --git a/infrastructure/jpa/src/main/java/org/depromeet/spot/jpa/oauth/OauthRepositoryImpl.java b/infrastructure/jpa/src/main/java/org/depromeet/spot/jpa/oauth/OauthRepositoryImpl.java new file mode 100644 index 00000000..2497b322 --- /dev/null +++ b/infrastructure/jpa/src/main/java/org/depromeet/spot/jpa/oauth/OauthRepositoryImpl.java @@ -0,0 +1,114 @@ +package org.depromeet.spot.jpa.oauth; + +import org.depromeet.spot.domain.member.Member; +import org.depromeet.spot.jpa.oauth.entity.KakaoTokenEntity; +import org.depromeet.spot.jpa.oauth.entity.KakaoUserInfoEntity; +import org.depromeet.spot.usecase.port.out.oauth.OauthRepository; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatusCode; +import org.springframework.stereotype.Repository; +import org.springframework.web.reactive.function.client.WebClient; + +import io.netty.handler.codec.http.HttpHeaderValues; +import lombok.extern.slf4j.Slf4j; +import reactor.core.publisher.Mono; + +@Slf4j +@Repository +public class OauthRepositoryImpl implements OauthRepository { + + // kakao에서 발급 받은 clientID + @Value("${oauth.clientId}") + private String CLIENT_ID; + + @Value("${oauth.kauthTokenUrlHost}") + private String KAUTH_TOKEN_URL_HOST; + + // 엑세스 토큰으로 카카오에서 유저 정보 받아오기 + @Value("${oauth.kauthUserUrlHost}") + private String KAUTH_USER_URL_HOST; + + @Override + public String getKakaoAccessToken(String idCode) { + // Webflux의 WebClient + KakaoTokenEntity kakaoTokenEntity = + WebClient.create(KAUTH_TOKEN_URL_HOST) + .post() + .uri( + uriBuilder -> + uriBuilder + .scheme("https") + .path("/oauth/token") + .queryParam("grant_type", "authorization_code") + .queryParam("client_id", CLIENT_ID) + .queryParam("code", idCode) + .build(true)) + .header( + HttpHeaders.CONTENT_TYPE, + HttpHeaderValues.APPLICATION_X_WWW_FORM_URLENCODED.toString()) + .retrieve() + // TODO : Custom Exception + .onStatus( + HttpStatusCode::is4xxClientError, + clientResponse -> + Mono.error(new RuntimeException("Invalid Parameter"))) + .onStatus( + HttpStatusCode::is5xxServerError, + clientResponse -> + Mono.error(new RuntimeException("Internal Server Error"))) + .bodyToMono(KakaoTokenEntity.class) + .block(); + + if (kakaoTokenEntity == null) { + // TODO + } + return kakaoTokenEntity.getAccessToken(); + } + + @Override + public Member getRegisterUserInfo(String accessToken, Member member) { + KakaoUserInfoEntity userInfo = getUserInfo(accessToken); + + // 회원가입 시 받은 정보를 바탕으로 member로 변환해서 리턴. + return userInfo.toKakaoDomain(member); + } + + @Override + public Member getLoginUserInfo(String accesstoken) { + KakaoUserInfoEntity userInfo = getUserInfo(accesstoken); + + // TODO : idToken이 변경 될 수 있음. 등록된 email도 변경될 수 있기에 추 후 논의가 필요. + // 기존 유저와 비교를 위해선 idToken만 필요함. + return userInfo.toLoginDomain(); + } + + public KakaoUserInfoEntity getUserInfo(String accessToken) { + KakaoUserInfoEntity userInfo = + WebClient.create(KAUTH_USER_URL_HOST) + .get() + .uri( + uriBuilder -> + uriBuilder.scheme("https").path("/v2/user/me").build(true)) + .header( + HttpHeaders.AUTHORIZATION, + "Bearer " + accessToken) // access token 인가 + .header( + HttpHeaders.CONTENT_TYPE, + HttpHeaderValues.APPLICATION_X_WWW_FORM_URLENCODED.toString()) + .retrieve() + // TODO : Custom Exception + .onStatus( + HttpStatusCode::is4xxClientError, + clientResponse -> + Mono.error(new RuntimeException("Invalid Parameter"))) + .onStatus( + HttpStatusCode::is5xxServerError, + clientResponse -> + Mono.error(new RuntimeException("Internal Server Error"))) + .bodyToMono(KakaoUserInfoEntity.class) + .block(); + + return userInfo; + } +} diff --git a/infrastructure/jpa/src/main/java/org/depromeet/spot/jpa/oauth/entity/KakaoTokenEntity.java b/infrastructure/jpa/src/main/java/org/depromeet/spot/jpa/oauth/entity/KakaoTokenEntity.java new file mode 100644 index 00000000..ea7fd7b5 --- /dev/null +++ b/infrastructure/jpa/src/main/java/org/depromeet/spot/jpa/oauth/entity/KakaoTokenEntity.java @@ -0,0 +1,36 @@ +package org.depromeet.spot.jpa.oauth.entity; + +import org.depromeet.spot.jpa.common.entity.BaseEntity; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor // 역직렬화를 위한 기본 생성자 +@JsonIgnoreProperties(ignoreUnknown = true) +public class KakaoTokenEntity extends BaseEntity { + + @JsonProperty("token_type") + public String tokenType; + + @JsonProperty("access_token") + public String accessToken; + + @JsonProperty("id_token") + public String idToken; + + @JsonProperty("expires_in") + public Integer expiresIn; + + @JsonProperty("refresh_token") + public String refreshToken; + + @JsonProperty("refresh_token_expires_in") + public Integer refreshTokenExpiresIn; + + @JsonProperty("scope") + public String scope; +} diff --git a/infrastructure/jpa/src/main/java/org/depromeet/spot/jpa/oauth/entity/KakaoUserInfoEntity.java b/infrastructure/jpa/src/main/java/org/depromeet/spot/jpa/oauth/entity/KakaoUserInfoEntity.java new file mode 100644 index 00000000..92b21188 --- /dev/null +++ b/infrastructure/jpa/src/main/java/org/depromeet/spot/jpa/oauth/entity/KakaoUserInfoEntity.java @@ -0,0 +1,119 @@ +package org.depromeet.spot.jpa.oauth.entity; + +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.Date; + +import org.depromeet.spot.domain.member.Member; +import org.depromeet.spot.domain.member.enums.MemberRole; +import org.depromeet.spot.domain.member.enums.SnsProvider; +import org.depromeet.spot.jpa.common.entity.BaseEntity; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.ToString; + +@Getter +@NoArgsConstructor // 역직렬화를 위한 기본 생성자 +@JsonIgnoreProperties(ignoreUnknown = true) +public class KakaoUserInfoEntity extends BaseEntity { + + // 서비스에 연결 완료된 시각. UTC + @JsonProperty("connected_at") + public Date connectedAt; + + // 카카오 계정 정보 + @JsonProperty("kakao_account") + public KakaoAccount kakaoAccount; + + @Getter + @NoArgsConstructor + @ToString + @JsonIgnoreProperties(ignoreUnknown = true) + public class KakaoAccount { + + // 사용자 프로필 정보 + @JsonProperty("profile") + public Profile profile; + + // 이름 제공 동의 여부 + @JsonProperty("name_needs_agreement") + public Boolean isNameAgree; + + // 카카오계정 이름 + @JsonProperty("name") + public String name; + + // 이메일 제공 동의 여부 + @JsonProperty("email_needs_agreement") + public Boolean isEmailAgree; + + // 이메일이 유효 여부 + // true : 유효한 이메일, false : 이메일이 다른 카카오 계정에 사용돼 만료 + @JsonProperty("is_email_valid") + public Boolean isEmailValid; + + // 이메일이 인증 여부 + // true : 인증된 이메일, false : 인증되지 않은 이메일 + @JsonProperty("is_email_verified") + public Boolean isEmailVerified; + + // 카카오계정 대표 이메일 + @JsonProperty("email") + public String email; + + // 성별 + @JsonProperty("gender") + public String gender; + + // 전화번호 + // +82 00-0000-0000 형식 + @JsonProperty("phone_number") + public String phoneNumber; + + @Getter + @NoArgsConstructor + @ToString + @JsonIgnoreProperties(ignoreUnknown = true) + public class Profile { + + // 닉네임 + @JsonProperty("nickname") + public String nickName; + + // 프로필 미리보기 이미지 URL + @JsonProperty("thumbnail_image_url") + public String thumbnailImageUrl; + + // 프로필 사진 URL + @JsonProperty("profile_image_url") + public String profileImageUrl; + } + } + + public Member toKakaoDomain(Member member) { + return Member.builder() + .email(kakaoAccount.email) + .name(kakaoAccount.name) + .nickname(member.getNickname()) + .phoneNumber(kakaoAccount.phoneNumber) + .profileImage(kakaoAccount.profile.profileImageUrl) + .snsProvider(SnsProvider.KAKAO) + .idToken(getId().toString()) + .role(MemberRole.ROLE_USER) + .teamId(member.getTeamId()) + .createdAt(toLocalDateTime(connectedAt)) + .build(); + } + + public Member toLoginDomain() { + return Member.builder().idToken(getId().toString()).build(); + } + + public LocalDateTime toLocalDateTime(Date date) { + return date.toInstant().atZone(ZoneId.of("Asia/Seoul")).toLocalDateTime(); + } +} diff --git a/infrastructure/jpa/src/main/resources/application-jpa.yaml b/infrastructure/jpa/src/main/resources/application-jpa.yaml index a4939dbd..9fd702ee 100644 --- a/infrastructure/jpa/src/main/resources/application-jpa.yaml +++ b/infrastructure/jpa/src/main/resources/application-jpa.yaml @@ -1,8 +1,8 @@ spring: datasource: - url: jdbc:mysql://mysql:3306/spot + url: jdbc:mysql://localhost:3306/spot username: test1234 - password: test1234 + password: DPM15thspot! driver-class-name: com.mysql.cj.jdbc.Driver jpa: @@ -31,3 +31,5 @@ decorator: datasource: p6spy: enable-logging: true + +# 필요한 경우 추가 설정 \ No newline at end of file diff --git a/usecase/src/main/java/org/depromeet/spot/usecase/port/in/member/MemberUsecase.java b/usecase/src/main/java/org/depromeet/spot/usecase/port/in/member/MemberUsecase.java index cfda7bf0..89097f21 100644 --- a/usecase/src/main/java/org/depromeet/spot/usecase/port/in/member/MemberUsecase.java +++ b/usecase/src/main/java/org/depromeet/spot/usecase/port/in/member/MemberUsecase.java @@ -1,12 +1,12 @@ package org.depromeet.spot.usecase.port.in.member; -import java.util.List; - import org.depromeet.spot.domain.member.Member; public interface MemberUsecase { - Member create(String name); + Member create(Member member); + + Member login(String idCode); - List findByName(String name); + Boolean duplicatedNickname(String nickname); } diff --git a/usecase/src/main/java/org/depromeet/spot/usecase/port/out/MemberRepository.java b/usecase/src/main/java/org/depromeet/spot/usecase/port/out/MemberRepository.java deleted file mode 100644 index 58da7024..00000000 --- a/usecase/src/main/java/org/depromeet/spot/usecase/port/out/MemberRepository.java +++ /dev/null @@ -1,12 +0,0 @@ -package org.depromeet.spot.usecase.port.out; - -import java.util.List; - -import org.depromeet.spot.domain.member.Member; - -public interface MemberRepository { - - Member save(Member member); - - List findByName(String name); -} diff --git a/usecase/src/main/java/org/depromeet/spot/usecase/port/out/member/MemberRepository.java b/usecase/src/main/java/org/depromeet/spot/usecase/port/out/member/MemberRepository.java new file mode 100644 index 00000000..3ea7f85d --- /dev/null +++ b/usecase/src/main/java/org/depromeet/spot/usecase/port/out/member/MemberRepository.java @@ -0,0 +1,14 @@ +package org.depromeet.spot.usecase.port.out.member; + +import java.util.Optional; + +import org.depromeet.spot.domain.member.Member; + +public interface MemberRepository { + + Member save(Member member); + + Optional findByIdToken(String idToken); + + Boolean existsByNickname(String nickname); +} diff --git a/usecase/src/main/java/org/depromeet/spot/usecase/port/out/oauth/OauthRepository.java b/usecase/src/main/java/org/depromeet/spot/usecase/port/out/oauth/OauthRepository.java new file mode 100644 index 00000000..95543e08 --- /dev/null +++ b/usecase/src/main/java/org/depromeet/spot/usecase/port/out/oauth/OauthRepository.java @@ -0,0 +1,12 @@ +package org.depromeet.spot.usecase.port.out.oauth; + +import org.depromeet.spot.domain.member.Member; + +public interface OauthRepository { + + String getKakaoAccessToken(String idCode); + + Member getRegisterUserInfo(String accesstoken, Member member); + + Member getLoginUserInfo(String accesstoken); +} diff --git a/usecase/src/main/java/org/depromeet/spot/usecase/service/member/MemberService.java b/usecase/src/main/java/org/depromeet/spot/usecase/service/member/MemberService.java index 5d5cf60a..88c1a085 100644 --- a/usecase/src/main/java/org/depromeet/spot/usecase/service/member/MemberService.java +++ b/usecase/src/main/java/org/depromeet/spot/usecase/service/member/MemberService.java @@ -1,11 +1,13 @@ package org.depromeet.spot.usecase.service.member; -import java.util.List; +import java.util.Optional; +import org.depromeet.spot.common.exception.member.MemberException.MemberNicknameConflictException; import org.depromeet.spot.common.exception.member.MemberException.MemberNotFoundException; import org.depromeet.spot.domain.member.Member; import org.depromeet.spot.usecase.port.in.member.MemberUsecase; -import org.depromeet.spot.usecase.port.out.MemberRepository; +import org.depromeet.spot.usecase.port.out.member.MemberRepository; +import org.depromeet.spot.usecase.port.out.oauth.OauthRepository; import org.springframework.stereotype.Service; import lombok.RequiredArgsConstructor; @@ -14,23 +16,40 @@ @RequiredArgsConstructor public class MemberService implements MemberUsecase { + private final OauthRepository oauthRepository; + private final MemberRepository memberRepository; @Override - public Member create(final String name) { - var member = - new Member( - null, null, name, null, null, null, null, null, null, null, null, null, - null); - return memberRepository.save(member); + public Member create(Member member) { + if (memberRepository.existsByNickname(member.getNickname())) { + throw new MemberNicknameConflictException(); + } + String accessToken = oauthRepository.getKakaoAccessToken(member.getIdToken()); + Member memberResult = oauthRepository.getRegisterUserInfo(accessToken, member); + Optional existedMember = memberRepository.findByIdToken(memberResult.getIdToken()); + if (existedMember.isPresent()) { + return existedMember.get(); + } + + return memberRepository.save(memberResult); } @Override - public List findByName(final String name) { - var members = memberRepository.findByName(name); - if (members.isEmpty()) { - throw new MemberNotFoundException("name : " + name); + public Member login(String idCode) { + String accessToken = oauthRepository.getKakaoAccessToken(idCode); + Member memberResult = oauthRepository.getLoginUserInfo(accessToken); + Optional existedMember = memberRepository.findByIdToken(memberResult.getIdToken()); + if (existedMember.isEmpty()) { + throw new MemberNotFoundException(); } - return members; + return existedMember.get(); + } + + @Override + public Boolean duplicatedNickname(String nickname) { + if (memberRepository.existsByNickname(nickname)) + throw new MemberNicknameConflictException(); + return Boolean.FALSE; } } diff --git a/versions.properties b/versions.properties index be599584..e87b4cf8 100644 --- a/versions.properties +++ b/versions.properties @@ -13,6 +13,8 @@ plugin.io.spring.dependency-management=1.0.11.RELEASE plugin.com.diffplug.spotless=6.21.0 +version.io.jsonwebtoken..jjwt=0.12.6 + version.junit=5.9.1 version.org.projectlombok..lombok=1.18.30