diff --git a/build.gradle b/build.gradle index ee7f52aa..9bb755b8 100644 --- a/build.gradle +++ b/build.gradle @@ -55,6 +55,15 @@ dependencies { // Security implementation 'org.springframework.boot:spring-boot-starter-security' + + //Webflux + implementation 'org.springframework.boot:spring-boot-starter-webflux' + + // redis + implementation 'org.springframework.boot:spring-boot-starter-data-redis' + + // SMTP + implementation 'org.springframework.boot:spring-boot-starter-mail' } tasks.named('test') { diff --git a/src/main/java/com/project/likelion13thbe/LikeLion13thBeApplication.java b/src/main/java/com/project/likelion13thbe/LikeLion13thBeApplication.java index 69d35449..c30421bc 100644 --- a/src/main/java/com/project/likelion13thbe/LikeLion13thBeApplication.java +++ b/src/main/java/com/project/likelion13thbe/LikeLion13thBeApplication.java @@ -7,7 +7,6 @@ @EnableJpaAuditing @SpringBootApplication -//@EntityScan(basePackages = "com.project.likelion13thbe") public class LikeLion13thBeApplication { public static void main(String[] args) { diff --git a/src/main/java/com/project/likelion13thbe/domain/member/controller/MemberController.java b/src/main/java/com/project/likelion13thbe/domain/member/controller/MemberController.java index 3d3c34b9..11471b43 100644 --- a/src/main/java/com/project/likelion13thbe/domain/member/controller/MemberController.java +++ b/src/main/java/com/project/likelion13thbe/domain/member/controller/MemberController.java @@ -1,12 +1,17 @@ package com.project.likelion13thbe.domain.member.controller; import com.project.likelion13thbe.domain.member.dto.request.MemberRequestDTO; +import com.project.likelion13thbe.domain.member.dto.response.KakaoUserInfoResponseDTO; import com.project.likelion13thbe.domain.member.dto.response.MemberResponseDTO; +import com.project.likelion13thbe.domain.member.entity.Member; +import com.project.likelion13thbe.domain.member.repository.MemberRepository; +import com.project.likelion13thbe.domain.member.service.KakaoService; import com.project.likelion13thbe.domain.member.service.command.MemberCommandService; import com.project.likelion13thbe.domain.member.service.query.MemberQueryService; import com.project.likelion13thbe.global.Security.AuthErrorCode; import com.project.likelion13thbe.global.Security.AuthException; -import com.project.likelion13thbe.global.Security.DTO.JwtDTO; +import com.project.likelion13thbe.global.Security.AuthService; +import com.project.likelion13thbe.global.Security.DTO.JwtDto; import com.project.likelion13thbe.global.apiPayload.CustomResponse; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.media.Content; @@ -20,6 +25,8 @@ import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.web.bind.annotation.*; +import java.util.Optional; +import java.util.UUID; @RestController @RequiredArgsConstructor @@ -29,27 +36,44 @@ public class MemberController { private final MemberCommandService memberCommandService; private final MemberQueryService memberQueryService; + private final KakaoService kakaoService; + private final AuthService authService; + private final MemberRepository memberRepository; - @Operation(summary = "카카오 로그인", description = "카카오 로그인을 수행합니다.") + @Operation(summary = "카카오 로그인", description = "카카오 로그인을 수행") @ApiResponses({ @ApiResponse(responseCode = "200", description = "카카오 로그인 성공") }) - @PostMapping("/login/kakao") - public ResponseEntity kakaoLogin() { - // 로그인 로직 - return ResponseEntity.ok().build(); + @GetMapping("/callback/kakao") + public CustomResponse callbackKakaoLogin(@RequestParam("code") String code){ + // 1. 카카오 인증서버에서 토큰을 발급받는다. + // 인가code와 Redirect URL을 파라미터로 전달하여 카카오 인증서버에 요청. + // String accessToken = kakaoService.getAccessTokenFromKakao(code); + + // 2. 1번에서 받은 토큰으로 카카오 리소스 서버에 사용자의 정보 요청. + // KakaoUserInfoResponseDTO userInfo = kakaoService.getUserInfo(accessToken); + + // 3. 회원가입 & 로그인 처리 + // 여기에 서버 사용자 로그인(인증) 또는 회원가입 로직 추가 + // 이메일이 있으면 로그인 없으면 회원가입 시키기 + + // 서비스로 넘겨서 처리하기 + JwtDto jwtDto = kakaoService.handleKakaoLogin(code); + return CustomResponse.onSuccess(jwtDto); } @Operation(summary = "일반 로그인") @ApiResponses({ @ApiResponse(responseCode = "200", description = "OK", content = @Content(mediaType = "application/json", - schema = @Schema(implementation = JwtDTO.class))) }) + schema = @Schema(implementation = JwtDto.class))) }) @PostMapping("/login") - public ResponseEntity localLogin(@RequestBody MemberRequestDTO.LoginRequestDTO loginRequestDTO) { - return null; + public ResponseEntity localLogin(@RequestBody MemberRequestDTO.LoginRequestDTO loginRequestDTO) { + JwtDto jwtDto = authService.login(loginRequestDTO); + return ResponseEntity.ok(jwtDto); } + @Operation(summary = "비밀번호 수정", description = "비밀번호를 수정") @ApiResponses({ @ApiResponse(responseCode = "200", description = "비밀번호 수정 성공") diff --git a/src/main/java/com/project/likelion13thbe/domain/member/dto/request/MemberRequestDTO.java b/src/main/java/com/project/likelion13thbe/domain/member/dto/request/MemberRequestDTO.java index 66b769e4..028f8650 100644 --- a/src/main/java/com/project/likelion13thbe/domain/member/dto/request/MemberRequestDTO.java +++ b/src/main/java/com/project/likelion13thbe/domain/member/dto/request/MemberRequestDTO.java @@ -25,6 +25,7 @@ public static class MemberReqDTO { private String note; } // 사용자 회원가입 + @Builder public record MemberCreateRequestDTO ( String email, String name, diff --git a/src/main/java/com/project/likelion13thbe/domain/member/dto/response/KakaoTokenResponseDTO.java b/src/main/java/com/project/likelion13thbe/domain/member/dto/response/KakaoTokenResponseDTO.java new file mode 100644 index 00000000..bd79e3bc --- /dev/null +++ b/src/main/java/com/project/likelion13thbe/domain/member/dto/response/KakaoTokenResponseDTO.java @@ -0,0 +1,28 @@ +package com.project.likelion13thbe.domain.member.dto.response; + +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 KakaoTokenResponseDTO { + + @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; + +} \ No newline at end of file diff --git a/src/main/java/com/project/likelion13thbe/domain/member/dto/response/KakaoUserInfoResponseDTO.java b/src/main/java/com/project/likelion13thbe/domain/member/dto/response/KakaoUserInfoResponseDTO.java new file mode 100644 index 00000000..0faec980 --- /dev/null +++ b/src/main/java/com/project/likelion13thbe/domain/member/dto/response/KakaoUserInfoResponseDTO.java @@ -0,0 +1,189 @@ +package com.project.likelion13thbe.domain.member.dto.response; + + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Getter; +import lombok.NoArgsConstructor; +import java.util.Date; +import java.util.HashMap; + +@Getter +@NoArgsConstructor //역직렬화를 위한 기본 생성자 +@JsonIgnoreProperties(ignoreUnknown = true) +public class KakaoUserInfoResponseDTO { + + //회원 번호 + @JsonProperty("id") + public Long id; + + //자동 연결 설정을 비활성화한 경우만 존재. + //true : 연결 상태, false : 연결 대기 상태 + @JsonProperty("has_signed_up") + public Boolean hasSignedUp; + + //서비스에 연결 완료된 시각. UTC + @JsonProperty("connected_at") + public Date connectedAt; + + //카카오싱크 간편가입을 통해 로그인한 시각. UTC + @JsonProperty("synched_at") + public Date synchedAt; + + //사용자 프로퍼티 + @JsonProperty("properties") + public HashMap properties; + + //카카오 계정 정보 + @JsonProperty("kakao_account") + public KakaoAccount kakaoAccount; + + //uuid 등 추가 정보 + @JsonProperty("for_partner") + public Partner partner; + + @Getter + @NoArgsConstructor + @JsonIgnoreProperties(ignoreUnknown = true) + public static class KakaoAccount { + + //프로필 정보 제공 동의 여부 + @JsonProperty("profile_needs_agreement") + public Boolean isProfileAgree; + + //닉네임 제공 동의 여부 + @JsonProperty("profile_nickname_needs_agreement") + public Boolean isNickNameAgree; + + //프로필 사진 제공 동의 여부 + @JsonProperty("profile_image_needs_agreement") + public Boolean isProfileImageAgree; + + //사용자 프로필 정보 + @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("age_range_needs_agreement") + public Boolean isAgeAgree; + + //연령대 + //참고 https://developers.kakao.com/docs/latest/ko/kakaologin/rest-api#req-user-info + @JsonProperty("age_range") + public String ageRange; + + //출생 연도 제공 동의 여부 + @JsonProperty("birthyear_needs_agreement") + public Boolean isBirthYearAgree; + + //출생 연도 (YYYY 형식) + @JsonProperty("birthyear") + public String birthYear; + + //생일 제공 동의 여부 + @JsonProperty("birthday_needs_agreement") + public Boolean isBirthDayAgree; + + //생일 (MMDD 형식) + @JsonProperty("birthday") + public String birthDay; + + //생일 타입 + // SOLAR(양력) 혹은 LUNAR(음력) + @JsonProperty("birthday_type") + public String birthDayType; + + //성별 제공 동의 여부 + @JsonProperty("gender_needs_agreement") + public Boolean isGenderAgree; + + //성별 + @JsonProperty("gender") + public String gender; + + //전화번호 제공 동의 여부 + @JsonProperty("phone_number_needs_agreement") + public Boolean isPhoneNumberAgree; + + //전화번호 + //국내 번호인 경우 +82 00-0000-0000 형식 + @JsonProperty("phone_number") + public String phoneNumber; + + //CI 동의 여부 + @JsonProperty("ci_needs_agreement") + public Boolean isCIAgree; + + //CI, 연계 정보 + @JsonProperty("ci") + public String ci; + + //CI 발급 시각, UTC + @JsonProperty("ci_authenticated_at") + public Date ciCreatedAt; + + @Getter + @NoArgsConstructor + @JsonIgnoreProperties(ignoreUnknown = true) + public static class Profile { + + //닉네임 + @JsonProperty("nickname") + public String nickName; + + //프로필 미리보기 이미지 URL + @JsonProperty("thumbnail_image_url") + public String thumbnailImageUrl; + + //프로필 사진 URL + @JsonProperty("profile_image_url") + public String profileImageUrl; + + //프로필 사진 URL 기본 프로필인지 여부 + //true : 기본 프로필, false : 사용자 등록 + @JsonProperty("is_default_image") + public String isDefaultImage; + + //닉네임이 기본 닉네임인지 여부 + //true : 기본 닉네임, false : 사용자 등록 + @JsonProperty("is_default_nickname") + public Boolean isDefaultNickName; + + } + } + + @Getter + @NoArgsConstructor + @JsonIgnoreProperties(ignoreUnknown = true) + public static class Partner { + //고유 ID + @JsonProperty("uuid") + public String uuid; + } +} diff --git a/src/main/java/com/project/likelion13thbe/domain/member/repository/MemberRepository.java b/src/main/java/com/project/likelion13thbe/domain/member/repository/MemberRepository.java index a6787ebd..952c259c 100644 --- a/src/main/java/com/project/likelion13thbe/domain/member/repository/MemberRepository.java +++ b/src/main/java/com/project/likelion13thbe/domain/member/repository/MemberRepository.java @@ -21,5 +21,7 @@ public interface MemberRepository extends JpaRepository { // 소프트 delete @Query("SELECT m FROM Member m WHERE m.id = :id AND m.deletedAt IS null ") Optional findByIdNotDeleted(@Param("id") Long id); + + Optional findByEmail(String email); } diff --git a/src/main/java/com/project/likelion13thbe/domain/member/service/KakaoService.java b/src/main/java/com/project/likelion13thbe/domain/member/service/KakaoService.java new file mode 100644 index 00000000..25a50e62 --- /dev/null +++ b/src/main/java/com/project/likelion13thbe/domain/member/service/KakaoService.java @@ -0,0 +1,123 @@ +package com.project.likelion13thbe.domain.member.service; + + +import com.project.likelion13thbe.domain.member.dto.response.KakaoTokenResponseDTO; +import com.project.likelion13thbe.domain.member.dto.response.KakaoUserInfoResponseDTO; +import com.project.likelion13thbe.domain.member.entity.Member; +import com.project.likelion13thbe.domain.member.repository.MemberRepository; +import com.project.likelion13thbe.global.RedisService; +import com.project.likelion13thbe.global.Security.AuthService; +import com.project.likelion13thbe.global.Security.DTO.JwtDto; +import com.project.likelion13thbe.global.Security.JwtUtil; +import io.netty.handler.codec.http.HttpHeaderValues; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatusCode; +import org.springframework.stereotype.Service; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.core.publisher.Mono; + +@Slf4j +@Service +public class KakaoService { + + + private final String clientId; // API Key + private final String tokenURI; // 카카오 인증 서버 + private final String userInfoURI; // 카카오 리소스 서버 + private final String redirectURI; // redirect URI + private final MemberRepository memberRepository; + private final JwtUtil jwtUtil; + private final AuthService authService; + private final RedisService redisService; + + @Autowired + public KakaoService(@Value("${spring.security.oauth2.client.registration.kakao.client-id}") String clientId, + @Value("${spring.security.oauth2.client.registration.kakao.redirect-uri}") String redirectURI, + @Value("${spring.security.oauth2.client.provider.kakao.user-info-uri}") String userInfoURI, + @Value("${spring.security.oauth2.client.provider.kakao.token-uri}") String tokenURI, MemberRepository memberRepository, JwtUtil jwtUtil, AuthService authService, RedisService redisService) { + this.clientId = clientId; + this.tokenURI = tokenURI; + this.userInfoURI = userInfoURI; + this.redirectURI = redirectURI; + this.memberRepository = memberRepository; + this.jwtUtil = jwtUtil; + this.authService = authService; + this.redisService = redisService; + } + + public String getAccessTokenFromKakao(String code) { + + KakaoTokenResponseDTO kakaoTokenResponseDto = WebClient.create(tokenURI) + .post() + .uri(uriBuilder -> uriBuilder + .scheme("https") + .queryParam("grant_type", "authorization_code") + .queryParam("client_id", clientId) + .queryParam("redirect_uri", redirectURI) + .queryParam("code", code) + .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("Client Error: " + clientResponse.releaseBody()))) + .onStatus(HttpStatusCode::is5xxServerError, clientResponse -> Mono.error(new RuntimeException("Internal Server Error"))) + .bodyToMono(KakaoTokenResponseDTO.class) + .block(); + + + log.info(" [Kakao Service] Access Token ------> {}", kakaoTokenResponseDto.getAccessToken()); + log.info(" [Kakao Service] Refresh Token ------> {}", kakaoTokenResponseDto.getRefreshToken()); + //제공 조건: OpenID Connect가 활성화 된 앱의 토큰 발급 요청인 경우 또는 scope에 openid를 포함한 추가 항목 동의 받기 요청을 거친 토큰 발급 요청인 경우 + log.info(" [Kakao Service] Id Token ------> {}", kakaoTokenResponseDto.getIdToken()); + log.info(" [Kakao Service] Scope ------> {}", kakaoTokenResponseDto.getScope()); + + return kakaoTokenResponseDto.getAccessToken(); + } + + public KakaoUserInfoResponseDTO getUserInfo(String accessToken) { + + KakaoUserInfoResponseDTO userInfo = WebClient.create(userInfoURI) + .get() + .uri(uriBuilder -> uriBuilder + .scheme("https") + .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(KakaoUserInfoResponseDTO.class) + .block(); + + log.info("[ Kakao Service ] Auth ID ---> {} ", userInfo.getId()); + log.info("[ Kakao Service ] NickName ---> {} ", userInfo.getKakaoAccount().getProfile().getNickName()); + log.info("[ Kakao Service ] ProfileImageUrl ---> {} ", userInfo.getKakaoAccount().getProfile().getProfileImageUrl()); + + return userInfo; + } + + public JwtDto handleKakaoLogin(String code) { + String accessToken = getAccessTokenFromKakao(code); + + // 받은 토큰으로 카카오 리소스 서버에 사용자의 정보 요청. + KakaoUserInfoResponseDTO userInfo = getUserInfo(accessToken); + String email = userInfo.getKakaoAccount().getEmail(); + + // 있으면 로그인, 없으면 회원가입하도록 설정 + Member member = memberRepository.findByEmail(email) + .orElseGet(() -> memberRepository.save(Member.builder() + .email(email) + .build())); + + // jwt 발급 + JwtDto jwtDto = authService.createJwt(member); + + redisService.setRefreshToken(email, jwtDto.getRefreshToken(), 1000L * 60 * 60 * 24 * 7); // 7일 설정하기 + + return jwtDto; + } +} \ No newline at end of file diff --git a/src/main/java/com/project/likelion13thbe/domain/member/service/command/MemberCommandService.java b/src/main/java/com/project/likelion13thbe/domain/member/service/command/MemberCommandService.java index 2d1d3979..0f8b4050 100644 --- a/src/main/java/com/project/likelion13thbe/domain/member/service/command/MemberCommandService.java +++ b/src/main/java/com/project/likelion13thbe/domain/member/service/command/MemberCommandService.java @@ -9,4 +9,4 @@ public interface MemberCommandService { MemberResponseDTO.MemberCreateResDTO createMember(MemberRequestDTO.MemberCreateRequestDTO memberCreateRequestDTO); void deleteMember(Long id); -} +} \ No newline at end of file diff --git a/src/main/java/com/project/likelion13thbe/domain/member/service/command/MemberCommandServiceImpl.java b/src/main/java/com/project/likelion13thbe/domain/member/service/command/MemberCommandServiceImpl.java index 3f66ba5e..020afc4c 100644 --- a/src/main/java/com/project/likelion13thbe/domain/member/service/command/MemberCommandServiceImpl.java +++ b/src/main/java/com/project/likelion13thbe/domain/member/service/command/MemberCommandServiceImpl.java @@ -48,4 +48,4 @@ public void deleteMember(Long id) { Member member = memberRepository.findByIdNotDeleted(id) .orElseThrow(() -> new MemberException(MemberErrorCode.MEMBER_NOT_FOUND)); } -} +} \ No newline at end of file diff --git a/src/main/java/com/project/likelion13thbe/domain/member/service/query/MemberQueryService.java b/src/main/java/com/project/likelion13thbe/domain/member/service/query/MemberQueryService.java index 2e76244b..262c3c1e 100644 --- a/src/main/java/com/project/likelion13thbe/domain/member/service/query/MemberQueryService.java +++ b/src/main/java/com/project/likelion13thbe/domain/member/service/query/MemberQueryService.java @@ -1,10 +1,13 @@ package com.project.likelion13thbe.domain.member.service.query; import com.project.likelion13thbe.domain.member.dto.response.MemberResponseDTO; +import com.project.likelion13thbe.domain.member.entity.Member; +import java.util.Optional; public interface MemberQueryService { MemberResponseDTO.MemberPreviewResDTO getMember(Long userId); MemberResponseDTO.MemberOffsetResDTO getMemberOffset(Integer offset,Integer size); MemberResponseDTO.MemberCursorResDTO getMemberCursor(Long cursor,Integer size); + Optional findByEmail(String emailInfo); } diff --git a/src/main/java/com/project/likelion13thbe/domain/member/service/query/MemberQueryServiceImpl.java b/src/main/java/com/project/likelion13thbe/domain/member/service/query/MemberQueryServiceImpl.java index ea24cd81..83d8ba4b 100644 --- a/src/main/java/com/project/likelion13thbe/domain/member/service/query/MemberQueryServiceImpl.java +++ b/src/main/java/com/project/likelion13thbe/domain/member/service/query/MemberQueryServiceImpl.java @@ -5,7 +5,6 @@ import com.project.likelion13thbe.domain.member.entity.Member; import com.project.likelion13thbe.domain.member.exception.MemberErrorCode; import com.project.likelion13thbe.domain.member.repository.MemberRepository; -import com.project.likelion13thbe.domain.member.service.command.MemberCommandService; import com.project.likelion13thbe.global.apiPayload.exception.CustomException; import jakarta.transaction.Transactional; import lombok.RequiredArgsConstructor; @@ -14,6 +13,7 @@ import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Slice; import org.springframework.stereotype.Service; +import java.util.Optional; @Service @RequiredArgsConstructor @@ -53,4 +53,9 @@ public MemberResponseDTO.MemberCursorResDTO getMemberCursor(Long cursor,Integer return MemberConverter.toMemberCursorResDTO(members); } + + @Override + public Optional findByEmail(String emailInfo){ + return memberRepository.findByEmail(emailInfo); + } } diff --git a/src/main/java/com/project/likelion13thbe/global/RedisService.java b/src/main/java/com/project/likelion13thbe/global/RedisService.java new file mode 100644 index 00000000..7b1c8352 --- /dev/null +++ b/src/main/java/com/project/likelion13thbe/global/RedisService.java @@ -0,0 +1,22 @@ +package com.project.likelion13thbe.global; + +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Service; + +import java.util.concurrent.TimeUnit; + +@Service +@RequiredArgsConstructor +public class RedisService { + private final RedisTemplate redisTemplate; + + + public void setRefreshToken(String key, String token, long expirationMillis) { + redisTemplate.opsForValue().set(key, token, expirationMillis, TimeUnit.MILLISECONDS); + } + + public String getRefreshToken(String key) { + return redisTemplate.opsForValue().get(key); + } +} \ No newline at end of file diff --git a/src/main/java/com/project/likelion13thbe/global/Security/AuthController.java b/src/main/java/com/project/likelion13thbe/global/Security/AuthController.java index 3cfb7cf5..8dc1edf1 100644 --- a/src/main/java/com/project/likelion13thbe/global/Security/AuthController.java +++ b/src/main/java/com/project/likelion13thbe/global/Security/AuthController.java @@ -1,6 +1,6 @@ package com.project.likelion13thbe.global.Security; -import com.project.likelion13thbe.global.Security.DTO.JwtDTO; +import com.project.likelion13thbe.global.Security.DTO.JwtDto; import com.project.likelion13thbe.global.apiPayload.CustomResponse; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; @@ -24,7 +24,7 @@ public class AuthController { //토큰 재발급 API @Operation(method = "POST", summary = "토큰 재발급", description = "토큰 재발급. accessToken과 refreshToken을 body에 담아서 전송합니다.") @PostMapping("/reissue") - public CustomResponse reissue(@RequestBody JwtDTO jwtDto) throws SignatureException { + public CustomResponse reissue(@RequestBody JwtDto jwtDto) throws SignatureException { log.info("[ Auth Controller ] 토큰을 재발급합니다. "); diff --git a/src/main/java/com/project/likelion13thbe/global/Security/AuthService.java b/src/main/java/com/project/likelion13thbe/global/Security/AuthService.java index a273bf1c..05759abb 100644 --- a/src/main/java/com/project/likelion13thbe/global/Security/AuthService.java +++ b/src/main/java/com/project/likelion13thbe/global/Security/AuthService.java @@ -1,8 +1,10 @@ package com.project.likelion13thbe.global.Security; -import com.project.likelion13thbe.global.Security.AuthErrorCode; -import com.project.likelion13thbe.global.Security.AuthException; -import com.project.likelion13thbe.global.Security.DTO.JwtDTO; +import com.project.likelion13thbe.domain.member.dto.request.MemberRequestDTO; +import com.project.likelion13thbe.domain.member.entity.Member; +import com.project.likelion13thbe.global.RedisService; +import com.project.likelion13thbe.global.Security.CustomUserDetail.CustomUserDetails; +import com.project.likelion13thbe.global.Security.DTO.JwtDto; import com.project.likelion13thbe.global.Security.Entity.Token; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -16,8 +18,21 @@ public class AuthService { private final JwtUtil jwtUtil; private final TokenRepository tokenRepository; + private final RedisService redisService; - public JwtDTO reissueToken(JwtDTO jwtDto) throws SignatureException { + public JwtDto createJwt(Member member) { + CustomUserDetails customUserDetails = new CustomUserDetails(member.getEmail(), member.getPassword(), member.getRole().toString()); + + String accessToken = jwtUtil.createJwtAccessToken(customUserDetails); + String refreshToken = jwtUtil.createJwtRefreshToken(customUserDetails); + + // RefreshToken 저장 (DB에 갱신) + tokenRepository.save(new Token(member.getEmail(), refreshToken)); + + return new JwtDto(accessToken, refreshToken); + } + + public JwtDto reissueToken(JwtDto jwtDto) throws SignatureException { log.info("[ Auth Service ] 토큰 재발급을 시작합니다."); String accessToken = jwtDto.getAccessToken(); @@ -45,4 +60,23 @@ public JwtDTO reissueToken(JwtDTO jwtDto) throws SignatureException { throw new AuthException(AuthErrorCode.INVALID_TOKEN); } } + + // Redis 활용하기 + public JwtDto login(MemberRequestDTO.LoginRequestDTO loginRequestDTO) { + // 이메일을 통한 회원 조회 + Member member = Member.builder().email(loginRequestDTO.getEmail()).build(); + + // 비밀번호 일치 확인 + if (!member.getPassword().equals(loginRequestDTO.getPassword())) { + throw new AuthException(AuthErrorCode._NOT_FOUND); + } + + // 토큰 생성 + JwtDto jwtDto = createJwt(member); + + // Redis에 저장하기 + redisService.setRefreshToken("RT:" + member.getEmail(), jwtDto.getRefreshToken(), 1000 * 60 * 60 * 24 * 7); + + return jwtDto; + } } \ No newline at end of file diff --git a/src/main/java/com/project/likelion13thbe/global/Security/CustomLoginFilter.java b/src/main/java/com/project/likelion13thbe/global/Security/CustomLoginFilter.java index 960e1225..f5064da5 100644 --- a/src/main/java/com/project/likelion13thbe/global/Security/CustomLoginFilter.java +++ b/src/main/java/com/project/likelion13thbe/global/Security/CustomLoginFilter.java @@ -3,7 +3,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.project.likelion13thbe.domain.member.dto.request.MemberRequestDTO; import com.project.likelion13thbe.global.Security.CustomUserDetail.CustomUserDetails; -import com.project.likelion13thbe.global.Security.DTO.JwtDTO; +import com.project.likelion13thbe.global.Security.DTO.JwtDto; import com.project.likelion13thbe.global.apiPayload.CustomResponse; import jakarta.servlet.FilterChain; import jakarta.servlet.http.HttpServletRequest; @@ -76,13 +76,13 @@ protected void successfulAuthentication( //Client 에게 줄 Response 를 Build - JwtDTO jwtDto = JwtDTO.builder() + JwtDto jwtDto = JwtDto.builder() .accessToken(jwtUtil.createJwtAccessToken(customUserDetails)) //access token 생성 .refreshToken(jwtUtil.createJwtRefreshToken(customUserDetails)) //refresh token 생성 .build(); // CustomResponse 사용하여 응답 통일 - CustomResponse responseBody = CustomResponse.onSuccess(jwtDto); + CustomResponse responseBody = CustomResponse.onSuccess(jwtDto); //JSON 변환 ObjectMapper objectMapper = new ObjectMapper(); @@ -127,7 +127,7 @@ protected void unsuccessfulAuthentication( } // CustomResponse 사용하여 응답 통일 - CustomResponse responseBody = CustomResponse.onFailure(errorCode, errorMessage); + CustomResponse responseBody = CustomResponse.onFailure(errorCode, errorMessage); ObjectMapper objectMapper = new ObjectMapper(); response.setStatus(Integer.parseInt(errorCode)); // HTTP 상태 코드 설정 diff --git a/src/main/java/com/project/likelion13thbe/global/Security/DTO/JwtDTO.java b/src/main/java/com/project/likelion13thbe/global/Security/DTO/JwtDto.java similarity index 94% rename from src/main/java/com/project/likelion13thbe/global/Security/DTO/JwtDTO.java rename to src/main/java/com/project/likelion13thbe/global/Security/DTO/JwtDto.java index 0584955f..4c48180c 100644 --- a/src/main/java/com/project/likelion13thbe/global/Security/DTO/JwtDTO.java +++ b/src/main/java/com/project/likelion13thbe/global/Security/DTO/JwtDto.java @@ -9,7 +9,7 @@ @NoArgsConstructor @AllArgsConstructor @Builder -public class JwtDTO { +public class JwtDto { // 로그인 성공 -> 토큰 응답 public String accessToken; // 엑세스 토큰 public String refreshToken; // 리프레쉬 토큰 diff --git a/src/main/java/com/project/likelion13thbe/global/Security/JwtUtil.java b/src/main/java/com/project/likelion13thbe/global/Security/JwtUtil.java index b3696bb3..b817afe0 100644 --- a/src/main/java/com/project/likelion13thbe/global/Security/JwtUtil.java +++ b/src/main/java/com/project/likelion13thbe/global/Security/JwtUtil.java @@ -1,7 +1,7 @@ package com.project.likelion13thbe.global.Security; import com.project.likelion13thbe.global.Security.CustomUserDetail.CustomUserDetails; -import com.project.likelion13thbe.global.Security.DTO.JwtDTO; +import com.project.likelion13thbe.global.Security.DTO.JwtDto; import com.project.likelion13thbe.global.Security.Entity.Token; import io.jsonwebtoken.ExpiredJwtException; import io.jsonwebtoken.Jwts; @@ -112,7 +112,7 @@ public String createJwtRefreshToken(CustomUserDetails customUserDetails) { } // 제공된 리프레시 토큰을 기반으로 JwtDto 쌍을 다시 발급 - public JwtDTO reissueToken(String refreshToken) throws SignatureException { + public JwtDto reissueToken(String refreshToken) throws SignatureException { // 강의에서 학습한 것 처럼 기존 토큰 만료 시 재발급의 경우에 사용 // refreshToken 에서 user 정보를 가져와서 새로운 토큰을 발급 (발급 시간, 유효 시간(reset)만 새로 적용) CustomUserDetails userDetails = new CustomUserDetails( @@ -123,7 +123,7 @@ public JwtDTO reissueToken(String refreshToken) throws SignatureException { log.info("[ JwtUtil ] 새로운 토큰을 재발급 합니다."); // 재발급 - return new JwtDTO( + return new JwtDto( createJwtAccessToken(userDetails), createJwtRefreshToken(userDetails) ); diff --git a/src/main/java/com/project/likelion13thbe/global/config/MailConfig.java b/src/main/java/com/project/likelion13thbe/global/config/MailConfig.java new file mode 100644 index 00000000..def087d6 --- /dev/null +++ b/src/main/java/com/project/likelion13thbe/global/config/MailConfig.java @@ -0,0 +1,40 @@ +package com.project.likelion13thbe.global.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.mail.javamail.JavaMailSenderImpl; +import java.util.Properties; + +@Configuration +public class MailConfig { + + private final String username; + private final String pw; + + public MailConfig( + @Value("${spring.mail.username}") String username, + @Value("${spring.mail.password}") String password ){ + this.username = username; + this.pw = password; + } + + @Bean + public JavaMailSender javaMailSender() { + JavaMailSenderImpl mailSender = new JavaMailSenderImpl(); + mailSender.setHost("smtp.gmail.com"); + mailSender.setPort(587); + mailSender.setUsername(username); + mailSender.setPassword(pw); + + Properties props = mailSender.getJavaMailProperties(); + props.put("mail.transport.protocol", "smtp"); + props.put("mail.smtp.auth", "true"); + props.put("mail.smtp.starttls.enable", "true"); + props.put("mail.smtp.starttls.required", "true"); + props.put("mail.debug", "true"); + + return mailSender; + } +} diff --git a/src/main/java/com/project/likelion13thbe/global/config/RedisConfig.java b/src/main/java/com/project/likelion13thbe/global/config/RedisConfig.java new file mode 100644 index 00000000..56ad8be1 --- /dev/null +++ b/src/main/java/com/project/likelion13thbe/global/config/RedisConfig.java @@ -0,0 +1,29 @@ +package com.project.likelion13thbe.global.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.serializer.StringRedisSerializer; + +@Configuration +public class RedisConfig { + + @Bean + public RedisConnectionFactory redisConnectionFactory() { + return new LettuceConnectionFactory(); // application.yml의 spring.redis.* 값 자동 연결 + } + + @Bean + public RedisTemplate redisTemplate(RedisConnectionFactory redisConnectionFactory) { + RedisTemplate redisTemplate = new RedisTemplate<>(); + redisTemplate.setConnectionFactory(redisConnectionFactory); + + // 문자열로 key/value 직렬화 + redisTemplate.setKeySerializer(new StringRedisSerializer()); + redisTemplate.setValueSerializer(new StringRedisSerializer()); + + return redisTemplate; + } +} diff --git a/src/main/java/com/project/likelion13thbe/global/Security/SecurityConfig.java b/src/main/java/com/project/likelion13thbe/global/config/SecurityConfig.java similarity index 95% rename from src/main/java/com/project/likelion13thbe/global/Security/SecurityConfig.java rename to src/main/java/com/project/likelion13thbe/global/config/SecurityConfig.java index 9bc6df87..8f55e3d7 100644 --- a/src/main/java/com/project/likelion13thbe/global/Security/SecurityConfig.java +++ b/src/main/java/com/project/likelion13thbe/global/config/SecurityConfig.java @@ -1,5 +1,6 @@ -package com.project.likelion13thbe.global.Security; +package com.project.likelion13thbe.global.config; +import com.project.likelion13thbe.global.Security.*; import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -36,6 +37,8 @@ public class SecurityConfig { "api/usage", "/swagger-ui/**", // swagger 관련 URL "/v3/api-docs/**", + "api/v1/callback/kakao", // kakao login + "/api/v1/email/**" // SMTP 설정 }; @Bean diff --git a/src/main/java/com/project/likelion13thbe/global/mail/controller/MailController.java b/src/main/java/com/project/likelion13thbe/global/mail/controller/MailController.java new file mode 100644 index 00000000..c2fcc3d5 --- /dev/null +++ b/src/main/java/com/project/likelion13thbe/global/mail/controller/MailController.java @@ -0,0 +1,31 @@ +package com.project.likelion13thbe.global.mail.controller; + +import com.project.likelion13thbe.global.apiPayload.CustomResponse; +import com.project.likelion13thbe.global.mail.dto.MailDTO; +import com.project.likelion13thbe.global.mail.service.MailService; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.mail.MessagingException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.slf4j.Logger; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.*; + +import java.io.UnsupportedEncodingException; + +@Slf4j +@RestController +@Tag(name = "Mail", description = "메일 관련 API") +@RequestMapping("/api/v1/email") +@RequiredArgsConstructor +public class MailController { + + private final MailService mailService; + + @ResponseBody + @PostMapping("/emailCheck") + public String emailCheck(@RequestBody MailDTO mailDTO) throws MessagingException, UnsupportedEncodingException { + String authCode = mailService.sendSimpleMessage(mailDTO.getEmail()); + return authCode; + } +} \ No newline at end of file diff --git a/src/main/java/com/project/likelion13thbe/global/mail/dto/MailDTO.java b/src/main/java/com/project/likelion13thbe/global/mail/dto/MailDTO.java new file mode 100644 index 00000000..6345bae4 --- /dev/null +++ b/src/main/java/com/project/likelion13thbe/global/mail/dto/MailDTO.java @@ -0,0 +1,12 @@ +package com.project.likelion13thbe.global.mail.dto; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@AllArgsConstructor +@NoArgsConstructor +public class MailDTO { + private String email; +} diff --git a/src/main/java/com/project/likelion13thbe/global/mail/exception/MailErrorCode.java b/src/main/java/com/project/likelion13thbe/global/mail/exception/MailErrorCode.java new file mode 100644 index 00000000..a30336ed --- /dev/null +++ b/src/main/java/com/project/likelion13thbe/global/mail/exception/MailErrorCode.java @@ -0,0 +1,20 @@ +package com.project.likelion13thbe.global.mail.exception; + +import com.project.likelion13thbe.global.apiPayload.code.BaseErrorCode; +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + + +@AllArgsConstructor +@Getter +public enum MailErrorCode implements BaseErrorCode { + + AUTHENTICATION_FAILED(HttpStatus.UNAUTHORIZED, "SMTP401_1", "SMTP 인증 실패"), + CONNECTION_FAILED(HttpStatus.SERVICE_UNAVAILABLE, "SMTP503_1", "SMTP 서버 연결 오류"), + INVALID_RECIPIENT(HttpStatus.BAD_REQUEST, "SMTP400_1", "수신자 이메일 오류"); + + private final HttpStatus status; + private final String code; + private final String message; +} \ No newline at end of file diff --git a/src/main/java/com/project/likelion13thbe/global/mail/exception/MailException.java b/src/main/java/com/project/likelion13thbe/global/mail/exception/MailException.java new file mode 100644 index 00000000..04447914 --- /dev/null +++ b/src/main/java/com/project/likelion13thbe/global/mail/exception/MailException.java @@ -0,0 +1,9 @@ +package com.project.likelion13thbe.global.mail.exception; + +import com.project.likelion13thbe.global.apiPayload.exception.CustomException; + +public class MailException extends CustomException { + public MailException(MailErrorCode errorCode) { + super(errorCode); + } +} \ No newline at end of file diff --git a/src/main/java/com/project/likelion13thbe/global/mail/service/MailService.java b/src/main/java/com/project/likelion13thbe/global/mail/service/MailService.java new file mode 100644 index 00000000..403ba81f --- /dev/null +++ b/src/main/java/com/project/likelion13thbe/global/mail/service/MailService.java @@ -0,0 +1,65 @@ +package com.project.likelion13thbe.global.mail.service; + +import jakarta.mail.MessagingException; +import jakarta.mail.internet.MimeMessage; +import lombok.RequiredArgsConstructor; +import org.springframework.mail.MailException; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.stereotype.Service; + +import java.util.Random; + +@Service +@RequiredArgsConstructor +public class MailService { + + private final JavaMailSender javaMailSender; + private static final String senderEmail = "choihw031230@gmail.com"; + + // 랜덤으로 숫자 생성 + public String createNumber() { + Random random = new Random(); + StringBuilder key = new StringBuilder(); + + for (int i = 0; i < 8; i++) { // 인증 코드 8자리 + int index = random.nextInt(3); // 0~2까지 랜덤, 랜덤값으로 switch문 실행 + + switch (index) { + case 0 -> key.append((char) (random.nextInt(26) + 97)); // 소문자 + case 1 -> key.append((char) (random.nextInt(26) + 65)); // 대문자 + case 2 -> key.append(random.nextInt(10)); // 숫자 + } + } + return key.toString(); + } + + public MimeMessage createMail(String mail, String number) throws MessagingException { + MimeMessage message = javaMailSender.createMimeMessage(); + + message.setFrom(senderEmail); + message.setRecipients(MimeMessage.RecipientType.TO, mail); + message.setSubject("이메일 인증"); + String body = ""; + body += "

요청하신 인증 번호입니다.

"; + body += "

" + number + "

"; + body += "

감사합니다.

"; + message.setText(body, "UTF-8", "html"); + + return message; + } + + // 메일 발송 + public String sendSimpleMessage(String sendEmail) throws MessagingException { + String number = createNumber(); // 랜덤 인증번호 생성 + + MimeMessage message = createMail(sendEmail, number); // 메일 생성 + try { + javaMailSender.send(message); // 메일 발송 + } catch (MailException e) { + e.printStackTrace(); + throw new IllegalArgumentException("메일 발송 중 오류가 발생했습니다."); + } + + return number; // 생성된 인증번호 반환 + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 6865cc77..46d7b647 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -1,4 +1,7 @@ spring: + config: + import: optional:file:.env[.properties] + application: name: LikeLion13th-BE @@ -24,3 +27,51 @@ spring: token: access-expiration-time: ${JWT_ACCESS_EXPIRATION} # Access Token 만료 시간 refresh-expiration-time: ${JWT_ACCESS_EXPIRATION} # Refresh Token 만료 시간 + + security: + oauth2: + client: + registration: + kakao: + authorization-grant-type: authorization_code + client-id: ${CLIENT_ID} + redirect-uri: ${REDIRECT_URL} + client-authentication-method: client_secret_post + scope: + - profile_nickname + - profile_image + - profile_email + provider: + kakao: + authorization-uri: https://kauth.kakao.com/oauth/authorize + token-uri: https://kauth.kakao.com/oauth/token + user-info-uri: https://kapi.kakao.com/v2/user/me + user-name-attribute: id + + + mail: + host: smtp.gmail.com + port: 587 + username: ${email} + password: ${pw} + properties: + mail: + smtp: + auth: true + starttls: + enable: true + required: true + + + + redis: + lettuce: + pool: + max-size: 10 + min-active: 1 + data: + redis: + password: ${REDIS_PW} + timeout: 2000 + port: 6379 + host: 127.0.0.1 \ No newline at end of file