diff --git a/build.gradle b/build.gradle index 8939082..facd766 100644 --- a/build.gradle +++ b/build.gradle @@ -37,6 +37,7 @@ dependencies { // mail implementation 'org.springframework.boot:spring-boot-starter-mail:3.0.5' implementation 'org.springframework.boot:spring-boot-starter-thymeleaf' + implementation 'nz.net.ultraq.thymeleaf:thymeleaf-layout-dialect' // JWT implementation 'io.jsonwebtoken:jjwt-api:0.11.5' @@ -53,10 +54,17 @@ dependencies { implementation 'com.fasterxml.jackson.core:jackson-core:2.16.1' implementation 'org.springframework.boot:spring-boot-starter-webflux' + if (isAppleSilicon()) { runtimeOnly("io.netty:netty-resolver-dns-native-macos:4.1.94.Final:osx-aarch_64") } + //Apple Login + implementation 'com.nimbusds:nimbus-jose-jwt:3.10' + + //Json + implementation 'com.googlecode.json-simple:json-simple:1.1.1' + developmentOnly 'org.springframework.boot:spring-boot-devtools' compileOnly 'org.projectlombok:lombok' runtimeOnly 'com.mysql:mysql-connector-j' diff --git a/src/main/java/meltingpot/server/auth/controller/AuthController.java b/src/main/java/meltingpot/server/auth/controller/AuthController.java index b2361df..5f216b2 100644 --- a/src/main/java/meltingpot/server/auth/controller/AuthController.java +++ b/src/main/java/meltingpot/server/auth/controller/AuthController.java @@ -6,6 +6,8 @@ import jakarta.validation.Valid; import jakarta.validation.constraints.NotBlank; import meltingpot.server.auth.controller.dto.*; +import meltingpot.server.auth.service.OAuthService; +import meltingpot.server.auth.service.dto.OAuthSignInResponseDto; import meltingpot.server.exception.AuthException; import meltingpot.server.exception.DuplicateException; import meltingpot.server.exception.IllegalArgumentException; @@ -31,9 +33,10 @@ public class AuthController { private final AuthService authService; + private final OAuthService oAuthService; private final Logger logger = LoggerFactory.getLogger(this.getClass()); - // 회원 가입 + // 일반 회원 가입 @PostMapping("signup") @Operation(summary="회원가입", description="회원가입 API 입니다.\n 회원가입 성공시 자동 로그인되어 AccessToken이 반환됩니다. " ) @ApiResponses(value = { @@ -56,6 +59,28 @@ public ResponseEntity> signup( } } + // SNS 회원 가입 + @PostMapping("/signup/oauth") + @Operation(summary="SNS 회원가입", description="SNS 회원가입 API 입니다.\n" ) + @ApiResponses(value = { + @ApiResponse(responseCode = "CREATED", description = "회원가입 성공"), + @ApiResponse(responseCode = "BAD_REQUEST", description = "회원가입 실패") + }) + public ResponseEntity> oauthSignup( + @RequestBody @Valid OAuthSignupRequestDto request + ){ + try{ + return ResponseData.toResponseEntity(ResponseCode.OAUTH_SIGNUP_SUCCESS, oAuthService.oauthSignup(request)); + + }catch ( AuthException e ){ + return ResponseData.toResponseEntity( e.getResponseCode(), null); + }catch ( IllegalArgumentException e ){ + return ResponseData.toResponseEntity( e.getResponseCode(), null); + } + } + + + // 프로필 이미지 URL 생성 @GetMapping("/image-url") @Operation(summary = "회원가입 프로필 이미지 URL 생성", description = "프로필 이미지 업로드를 위한 URL을 생성합니다. 생성된 URL에 PUT으로 이미지를 업로드 한 뒤 key를 회원가입에 첨부해주세요.") @@ -86,6 +111,25 @@ public ResponseEntity> signin( } } + // SNS 로그인 + @PostMapping("signin/oauth") + @Operation(summary="SNS 로그인", description="SNS 로그인 API 입니다" ) + public ResponseEntity> SNSLogin( + @RequestBody @Valid OAuthSignInRequestDto request + ){ + try{ + OAuthSignInResponseDto data = oAuthService.SNSLogin(request); + return ResponseData.toResponseEntity(ResponseCode.OAUTH_SIGNIN_SUCCESS, data); + + }catch( ResourceNotFoundException e ){ + return ResponseData.toResponseEntity(ResponseCode.ACCOUNT_NOT_FOUND, null); + }catch ( InvalidTokenException e ){ + return ResponseData.toResponseEntity(ResponseCode.REFRESH_TOKEN_NOT_FOUND, null); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + // 로그아웃 @GetMapping("signout") @@ -111,11 +155,4 @@ public ResponseEntity> reissueToken( return ResponseData.toResponseEntity(ResponseCode.INVALID_REFRESH_TOKEN, null); } } - - - // 비밀번호 재설정 - - // 탈퇴 - - } diff --git a/src/main/java/meltingpot/server/auth/controller/dto/OAuthSignInRequestDto.java b/src/main/java/meltingpot/server/auth/controller/dto/OAuthSignInRequestDto.java new file mode 100644 index 0000000..c5cd0d6 --- /dev/null +++ b/src/main/java/meltingpot/server/auth/controller/dto/OAuthSignInRequestDto.java @@ -0,0 +1,10 @@ +package meltingpot.server.auth.controller.dto; + +import meltingpot.server.domain.entity.enums.OAuthType; + +public record OAuthSignInRequestDto( + OAuthType type, + String code, + String push_token +) { +} diff --git a/src/main/java/meltingpot/server/auth/controller/dto/OAuthSignupRequestDto.java b/src/main/java/meltingpot/server/auth/controller/dto/OAuthSignupRequestDto.java new file mode 100644 index 0000000..4b47153 --- /dev/null +++ b/src/main/java/meltingpot/server/auth/controller/dto/OAuthSignupRequestDto.java @@ -0,0 +1,21 @@ +package meltingpot.server.auth.controller.dto; + +import lombok.Builder; +import meltingpot.server.domain.entity.enums.OAuthType; + +import java.time.LocalDate; +import java.util.List; + +@Builder +public record OAuthSignupRequestDto( + OAuthType OauthType, + String email, + String name, + String gender, + LocalDate birth, + String nationality, + List languages, + List profileImages, + String pushToken +) { +} diff --git a/src/main/java/meltingpot/server/auth/oauth/OAuthUserDetails.java b/src/main/java/meltingpot/server/auth/oauth/OAuthUserDetails.java new file mode 100644 index 0000000..8365b87 --- /dev/null +++ b/src/main/java/meltingpot/server/auth/oauth/OAuthUserDetails.java @@ -0,0 +1,58 @@ +package meltingpot.server.auth.oauth; + +import meltingpot.server.domain.entity.Account; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.Optional; + +public class OAuthUserDetails implements UserDetails { + + private final Account account; + + public OAuthUserDetails(Account account){ + this.account = account; + } + + @Override + public Collection getAuthorities() { + List grantedAuthorities = new ArrayList<>(); + grantedAuthorities.add(new SimpleGrantedAuthority("ROLE_USER")); + return grantedAuthorities; + } + + @Override + public String getPassword() { + return this.account.getPassword(); + } + + @Override + public String getUsername() { + return this.account.getName(); + } + + //계정 만료 여부 + @Override + public boolean isAccountNonExpired() { + return true; + } + //계정 잠김 여부 + @Override + public boolean isAccountNonLocked() { + return true; + } + //계정 정보 변경 필요 여부 + @Override + public boolean isCredentialsNonExpired() { + return true; + } + //계정 활성화 여부 + @Override + public boolean isEnabled() { + return true; + } +} diff --git a/src/main/java/meltingpot/server/auth/oauth/kakao/KaKaoTokenDto.java b/src/main/java/meltingpot/server/auth/oauth/kakao/KaKaoTokenDto.java new file mode 100644 index 0000000..1538024 --- /dev/null +++ b/src/main/java/meltingpot/server/auth/oauth/kakao/KaKaoTokenDto.java @@ -0,0 +1,10 @@ +package meltingpot.server.auth.oauth.kakao; + +import lombok.Builder; + +@Builder +public record KaKaoTokenDto ( + String accessToken, + String refreshToken +){ +} diff --git a/src/main/java/meltingpot/server/auth/oauth/kakao/KakaoDto.java b/src/main/java/meltingpot/server/auth/oauth/kakao/KakaoDto.java new file mode 100644 index 0000000..5c0780c --- /dev/null +++ b/src/main/java/meltingpot/server/auth/oauth/kakao/KakaoDto.java @@ -0,0 +1,13 @@ +package meltingpot.server.auth.oauth.kakao; + +import lombok.Builder; +import lombok.Data; +@Builder +@Data +public class KakaoDto { + + private long id; + private String email; + private String nickname; + +} \ No newline at end of file diff --git a/src/main/java/meltingpot/server/auth/oauth/kakao/KakaoService.java b/src/main/java/meltingpot/server/auth/oauth/kakao/KakaoService.java new file mode 100644 index 0000000..08e9822 --- /dev/null +++ b/src/main/java/meltingpot/server/auth/oauth/kakao/KakaoService.java @@ -0,0 +1,110 @@ +package meltingpot.server.auth.oauth.kakao; + +import org.json.simple.JSONObject; +import org.json.simple.parser.JSONParser; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Service; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.client.RestTemplate; + +@Service +public class KakaoService { + + @Value("${spring.security.oauth2.client.registration.kakao.client-id}") + private String KAKAO_CLIENT_ID; + + @Value("${spring.security.oauth2.client.registration.kakao.client-secret}") + private String KAKAO_CLIENT_SECRET; + + @Value("${spring.security.oauth2.client.registration.kakao.redirect-uri}") + private String redirect_uri; + + private final static String KAKAO_AUTH_URI = "https://kauth.kakao.com"; + private final static String KAKAO_API_URI = "https://kapi.kakao.com"; + + public String getKakaoLogin(String redirect_uri) { // 프론트 구현부 + return KAKAO_AUTH_URI + "/oauth/authorize" + + "?client_id=" + KAKAO_CLIENT_ID + + "&redirect_uri=" + redirect_uri + + "&response_type=code"; + } + + public KaKaoTokenDto getKakaoInfo(String code) throws Exception { + if (code == null) throw new Exception("Failed get authorization code"); + + String accessToken = ""; + String refreshToken = ""; + + try { + HttpHeaders headers = new HttpHeaders(); + headers.add("Content-type", "application/x-www-form-urlencoded"); + + MultiValueMap params = new LinkedMultiValueMap<>(); + params.add("grant_type" , "authorization_code"); + params.add("client_id" , KAKAO_CLIENT_ID); + params.add("client_secret", KAKAO_CLIENT_SECRET); + params.add("code" , code); + params.add("redirect_uri" , redirect_uri); + + RestTemplate restTemplate = new RestTemplate(); + HttpEntity> httpEntity = new HttpEntity<>(params, headers); + + ResponseEntity response = restTemplate.exchange( + KAKAO_AUTH_URI + "/oauth/token", + HttpMethod.POST, + httpEntity, + String.class + ); + + JSONParser jsonParser = new JSONParser(); + JSONObject jsonObj = (JSONObject) jsonParser.parse(response.getBody()); + + accessToken = (String) jsonObj.get("access_token"); + refreshToken = (String) jsonObj.get("refresh_token"); + } catch (Exception e) { + throw new Exception("KAKAO API call failed"); + } + + return KaKaoTokenDto.builder() + .accessToken(accessToken) + .refreshToken(refreshToken) + .build(); + } + + public KakaoDto getUserInfoWithToken(String accessToken) throws Exception { + //HttpHeader 생성 + HttpHeaders headers = new HttpHeaders(); + headers.add("Authorization", "Bearer " + accessToken); + headers.add("Content-type", "application/x-www-form-urlencoded;charset=utf-8"); + + //HttpHeader 담기 + RestTemplate rt = new RestTemplate(); + HttpEntity> httpEntity = new HttpEntity<>(headers); + ResponseEntity response = rt.exchange( + KAKAO_API_URI + "/v2/user/me", + HttpMethod.POST, + httpEntity, + String.class + ); + + //Response 데이터 파싱 + JSONParser jsonParser = new JSONParser(); + JSONObject jsonObj = (JSONObject) jsonParser.parse(response.getBody()); + JSONObject account = (JSONObject) jsonObj.get("kakao_account"); + JSONObject profile = (JSONObject) account.get("profile"); + + long id = (long) jsonObj.get("id"); + String email = String.valueOf(account.get("email")); + String nickname = String.valueOf(profile.get("nickname")); + + return KakaoDto.builder() + .id(id) + .email(email) + .nickname(nickname).build(); + } +} \ No newline at end of file diff --git a/src/main/java/meltingpot/server/auth/service/AuthService.java b/src/main/java/meltingpot/server/auth/service/AuthService.java index 3732892..94f56af 100644 --- a/src/main/java/meltingpot/server/auth/service/AuthService.java +++ b/src/main/java/meltingpot/server/auth/service/AuthService.java @@ -2,18 +2,16 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import meltingpot.server.auth.controller.dto.ProfileImageRequestDto; -import meltingpot.server.auth.controller.dto.ReissueTokenResponseDto; -import meltingpot.server.auth.controller.dto.SignupRequestDto; +import meltingpot.server.auth.controller.dto.*; import meltingpot.server.domain.entity.*; import meltingpot.server.domain.entity.enums.Gender; +import meltingpot.server.domain.entity.enums.OAuthType; import meltingpot.server.domain.repository.AccountPushTokenRepository; import meltingpot.server.domain.repository.MailVerificationRepository; import meltingpot.server.exception.*; import meltingpot.server.config.TokenProvider; import meltingpot.server.domain.repository.RefreshTokenRepository; import meltingpot.server.domain.repository.AccountRepository; -import meltingpot.server.auth.controller.dto.AccountResponseDto; import meltingpot.server.auth.service.dto.SigninServiceDto; import meltingpot.server.exception.IllegalArgumentException; import meltingpot.server.util.AccountUser; @@ -107,6 +105,8 @@ public AccountResponseDto signup(SignupRequestDto signupRequest) { .gender(Gender.valueOf(signupRequest.gender())) .birth(signupRequest.birth()) .nationality(signupRequest.nationality()) + .isQuit(false) + .oAuthType(OAuthType.NONE) .build(); account.setProfileImages(signupRequest.profileImages().stream().map( @@ -152,7 +152,7 @@ public AccountResponseDto signin(SigninServiceDto serviceDto){ TokenDto tokenDto = tokenProvider.generateTokenDto(authentication); // 4. RefreshToken 저장 - Account account = accountRepository.findByUsername(authentication.getName()) + Account account = accountRepository.findByUsernameAndIsQuitIsFalse(authentication.getName()) .orElseThrow(() -> new ResourceNotFoundException(ResponseCode.ACCOUNT_NOT_FOUND)); RefreshToken refreshToken = RefreshToken.builder() .account(account) @@ -201,7 +201,7 @@ public FileUploadResponse generateImageUploadUrl() { // 로그인 유저 정보 반환 to @CurrentUser @Transactional(readOnly = true) public Account getUserInfo(){ - return accountRepository.findByUsernameAndDeletedAtIsNull(SecurityUtil.getCurrentUserName()) + return accountRepository.findByUsernameAndIsQuitIsFalse(SecurityUtil.getCurrentUserName()) .orElseThrow(() -> new ResourceNotFoundException(ResponseCode.ACCOUNT_NOT_FOUND)); } @@ -209,7 +209,7 @@ public Account getUserInfo(){ @Override @Transactional(readOnly = true) public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { - Account account = accountRepository.findByUsername(username) + Account account = accountRepository.findByUsernameAndIsQuitIsFalse(username) .orElseThrow(() -> new UsernameNotFoundException(username)); return new AccountUser(account); } diff --git a/src/main/java/meltingpot/server/auth/service/OAuthService.java b/src/main/java/meltingpot/server/auth/service/OAuthService.java new file mode 100644 index 0000000..59a39a4 --- /dev/null +++ b/src/main/java/meltingpot/server/auth/service/OAuthService.java @@ -0,0 +1,210 @@ +package meltingpot.server.auth.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import meltingpot.server.auth.controller.dto.AccountResponseDto; +import meltingpot.server.auth.controller.dto.OAuthSignInRequestDto; +import meltingpot.server.auth.controller.dto.OAuthSignupRequestDto; +import meltingpot.server.auth.controller.dto.ProfileImageRequestDto; +import meltingpot.server.auth.oauth.OAuthUserDetails; +import meltingpot.server.auth.oauth.kakao.KaKaoTokenDto; +import meltingpot.server.auth.oauth.kakao.KakaoDto; +import meltingpot.server.auth.oauth.kakao.KakaoService; +import meltingpot.server.auth.service.dto.OAuthSignInResponseDto; +import meltingpot.server.config.TokenProvider; +import meltingpot.server.domain.entity.*; +import meltingpot.server.domain.entity.enums.Gender; +import meltingpot.server.domain.entity.enums.OAuthType; +import meltingpot.server.domain.repository.AccountPushTokenRepository; +import meltingpot.server.domain.repository.AccountRepository; +import meltingpot.server.domain.repository.RefreshTokenRepository; +import meltingpot.server.exception.AuthException; +import meltingpot.server.exception.IllegalArgumentException; +import meltingpot.server.exception.ResourceNotFoundException; +import meltingpot.server.util.ResponseCode; +import meltingpot.server.util.TokenDto; +import meltingpot.server.util.r2.FileService; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.HashSet; +import java.util.NoSuchElementException; +import java.util.Optional; +import java.util.Set; + +@Slf4j +@RequiredArgsConstructor +@Service +@EnableWebSecurity +public class OAuthService { + private final AccountRepository accountRepository; + private final KakaoService kakaoService; + private final TokenProvider tokenProvider; + private final RefreshTokenRepository refreshTokenRepository; + private final AccountPushTokenRepository accountPushTokenRepository; + + + // SNS 회원 가입 + @Transactional + public OAuthSignInResponseDto oauthSignup(OAuthSignupRequestDto signupRequest) { + + // 프로필 사진 개수 확인 + if(signupRequest.profileImages().isEmpty()){ + throw new AuthException(ResponseCode.PROFILE_IMAGE_LESS_THAN_ONE); + } + if(signupRequest.profileImages().size()>4){ + throw new AuthException(ResponseCode.PROFILE_IMAGE_MORE_THAN_FOUR); + } + + // 프로필 이미지 썸네일 지정 여부 확인 + boolean thumbnail_check = false; + for(ProfileImageRequestDto image : signupRequest.profileImages()){ + if (image.isThumbnail()) { + if(thumbnail_check) throw new IllegalArgumentException(ResponseCode.THUMBNAIL_IS_DUPLICATED); + thumbnail_check = true; + } + } + if(!thumbnail_check) throw new AuthException(ResponseCode.THUMBNAIL_NOT_FOUND); + + // 시퀀스 모두 다른지 확인 + Set sequences = new HashSet<>(); + for (ProfileImageRequestDto profileImage : signupRequest.profileImages()) { + if (!sequences.add(profileImage.getSequence())) { + throw new IllegalArgumentException(ResponseCode.PROFILE_IMAGE_SEQUENCE_IS_DUPLICATED); + } + } + + // 성별 유효성 확인 + boolean gender_check = false; + for( Gender gender : Gender.values()){ + if(gender.toString().equals(signupRequest.gender())) gender_check = true; + } + if(!gender_check) throw new IllegalArgumentException(ResponseCode.INVALID_GENDER_IS_PROVIDED); + + + Account account = Account.builder() + .username(signupRequest.email()) + .name(signupRequest.name()) + .password("") + .gender(Gender.valueOf(signupRequest.gender())) + .birth(signupRequest.birth()) + .nationality(signupRequest.nationality()) + .isQuit(false) + .oAuthType(signupRequest.OauthType()) + .build(); + + account.setProfileImages(signupRequest.profileImages().stream().map( + (image) -> AccountProfileImage.builder() + .account(account) + .imageKey(image.getImageKey()) + .isThumbnail(image.isThumbnail()) + .sequence(image.getSequence()) + .imageOriginalName("") + .build()).toList() + ); + + account.setLanguages(signupRequest.languages().stream().map( + (language) -> AccountLanguage.builder() + .account(account) + .language(language) + .build()).toList() + ); + + accountRepository.save(account); + + + return OAuthSignInResponseDto.builder(). + register_required(false) + .nickName(account.getName()) + .email(account.getUsername()) + .tokenDto(setSecurityContext(account, signupRequest.pushToken())) + .build(); + + } + + @Transactional + public OAuthSignInResponseDto SNSLogin(OAuthSignInRequestDto request) throws Exception { + + if(request.type() == OAuthType.KAKAO) { + // 카카오 토큰 가져오기 + KaKaoTokenDto tokenDto = kakaoService.getKakaoInfo(request.code()); + + // 카카오 유저 정보 가져오기 + KakaoDto kakaoDto = kakaoService.getUserInfoWithToken(tokenDto.accessToken()); + + // 이미 가입한 회원인지 확인 + Optional account = accountRepository.findByUsernameAndIsQuitIsFalse(kakaoDto.getEmail()); + if (account.isEmpty()) { + + // 회원 가입이 필요한 경우 + return OAuthSignInResponseDto.builder() + .register_required(true) + .nickName(kakaoDto.getNickname()) + .email(kakaoDto.getEmail()) + .tokenDto(null) + .build(); + + } else { + + return OAuthSignInResponseDto.builder(). + register_required(false) + .nickName(kakaoDto.getNickname()) + .email(kakaoDto.getEmail()) + .tokenDto(setSecurityContext(account.get(), request.push_token())) + .build(); + } + } +// else if(request.type() == OAuthType.APPLE) { +// +// } +// else if(request.type() == OAuthType.GOOGLE) { +// +// } + else { + throw new NoSuchElementException(); + } + + } + + @Transactional + public TokenDto setSecurityContext(Account account, String pushToken ){ + + OAuthUserDetails oAuthUserDetails= new OAuthUserDetails(account); + Authentication authentication = new UsernamePasswordAuthenticationToken(oAuthUserDetails, null, oAuthUserDetails.getAuthorities()); + SecurityContextHolder.getContext().setAuthentication(authentication); + + // 인증 정보를 기반으로 JWT 토큰 생성 + TokenDto jwtTokenDto = tokenProvider.generateTokenDto(authentication); + + // RefreshToken 저장 + RefreshToken refreshToken = RefreshToken.builder() + .account(account) + .tokenValue(jwtTokenDto.getRefreshToken()) + .build(); + + refreshTokenRepository.save(refreshToken); + + // PushToken 저장 + if (!accountPushTokenRepository.existsAccountPushByAccountAndToken(account, pushToken)) { + AccountPushToken accountPushToken = AccountPushToken.builder() + .account(account) + .token(pushToken) + .build(); + + accountPushTokenRepository.save(accountPushToken); + } + + //인증된 Authentication를 SecurityContext에 저장 + SecurityContextHolder.getContext().setAuthentication(authentication); + + return jwtTokenDto; + + } + +} diff --git a/src/main/java/meltingpot/server/auth/service/dto/OAuthSignInResponseDto.java b/src/main/java/meltingpot/server/auth/service/dto/OAuthSignInResponseDto.java new file mode 100644 index 0000000..f11eb80 --- /dev/null +++ b/src/main/java/meltingpot/server/auth/service/dto/OAuthSignInResponseDto.java @@ -0,0 +1,13 @@ +package meltingpot.server.auth.service.dto; + +import lombok.Builder; +import meltingpot.server.util.TokenDto; + +@Builder +public record OAuthSignInResponseDto( + boolean register_required, + String email, + String nickName, + TokenDto tokenDto +) { +} diff --git a/src/main/java/meltingpot/server/config/TokenProvider.java b/src/main/java/meltingpot/server/config/TokenProvider.java index 91a84a6..4d13c65 100644 --- a/src/main/java/meltingpot/server/config/TokenProvider.java +++ b/src/main/java/meltingpot/server/config/TokenProvider.java @@ -90,7 +90,7 @@ public TokenDto generateReissuedTokenDto(String accessToken) { // accessToken에서 username 추출 String username = parseClaims(accessToken).getSubject(); // username으로 account 조회 - Account account = accountRepository.findByUsername(username) + Account account = accountRepository.findByUsernameAndIsQuitIsFalse(username) .orElseThrow(() -> new ResourceNotFoundException(ResponseCode.ACCOUNT_NOT_FOUND)); // account에서 account_roles -> authorities로 변환 String authorities = account.toAuthStringList().stream().collect(Collectors.joining(",")); @@ -126,7 +126,7 @@ public TokenDto generateReissuedTokenDto(String accessToken) { // 저장되어있는 RefreshToken의 account와 접속한 계정이 동일한지 확인 public Boolean validRefreshToken(String refreshToken, String accessToken) { String username = parseClaims(accessToken).getSubject(); - Account account = accountRepository.findByUsername(username) + Account account = accountRepository.findByUsernameAndIsQuitIsFalse(username) .orElseThrow(() -> new ResourceNotFoundException(ResponseCode.ACCOUNT_NOT_FOUND)); RefreshToken matchRefreshToken = refreshTokenRepository.findByTokenValue(refreshToken) @@ -141,7 +141,7 @@ public Boolean validRefreshToken(String refreshToken, String accessToken) { // 재발급한 RefreshToken 저장 public void updateRefreshToken(String accessToken, String newRefreshToken) { String username = parseClaims(accessToken).getSubject(); - Account account = accountRepository.findByUsername(username) + Account account = accountRepository.findByUsernameAndIsQuitIsFalse(username) .orElseThrow(() -> new ResourceNotFoundException(ResponseCode.ACCOUNT_NOT_FOUND)); // 재발급한 refresh token 저장 diff --git a/src/main/java/meltingpot/server/domain/entity/Account.java b/src/main/java/meltingpot/server/domain/entity/Account.java index 989d1d4..6521063 100644 --- a/src/main/java/meltingpot/server/domain/entity/Account.java +++ b/src/main/java/meltingpot/server/domain/entity/Account.java @@ -8,6 +8,7 @@ import meltingpot.server.domain.entity.comment.CommentImage; import meltingpot.server.domain.entity.common.BaseEntity; import meltingpot.server.domain.entity.enums.Gender; +import meltingpot.server.domain.entity.enums.OAuthType; import meltingpot.server.domain.entity.post.Post; import org.hibernate.annotations.DynamicInsert; import org.hibernate.annotations.DynamicUpdate; @@ -42,7 +43,6 @@ public class Account extends BaseEntity { @NotNull private String name; - @NotNull private String password; @NotNull @@ -59,6 +59,10 @@ public class Account extends BaseEntity { private LocalDateTime deletedAt; + private Boolean isQuit; // 탈퇴 여부 + + private OAuthType oAuthType; + @OneToMany(mappedBy = "account", cascade = CascadeType.ALL, fetch = FetchType.EAGER) @Builder.Default private List profileImages = new ArrayList<>(); diff --git a/src/main/java/meltingpot/server/domain/entity/MsgEntity.java b/src/main/java/meltingpot/server/domain/entity/MsgEntity.java new file mode 100644 index 0000000..2fb131d --- /dev/null +++ b/src/main/java/meltingpot/server/domain/entity/MsgEntity.java @@ -0,0 +1,17 @@ +package meltingpot.server.domain.entity; + +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.Data; + +@Data +@JsonInclude(JsonInclude.Include.NON_NULL) +public class MsgEntity { + + private String msg; + private Object result; + + public MsgEntity(String msg, Object result) { + this.msg = msg; + this.result = result; + } +} \ No newline at end of file diff --git a/src/main/java/meltingpot/server/domain/entity/enums/OAuthType.java b/src/main/java/meltingpot/server/domain/entity/enums/OAuthType.java new file mode 100644 index 0000000..b132801 --- /dev/null +++ b/src/main/java/meltingpot/server/domain/entity/enums/OAuthType.java @@ -0,0 +1,5 @@ +package meltingpot.server.domain.entity.enums; + +public enum OAuthType { + NONE, KAKAO, GOOGLE, APPLE +} diff --git a/src/main/java/meltingpot/server/domain/repository/AccountRepository.java b/src/main/java/meltingpot/server/domain/repository/AccountRepository.java index 87e16d7..a3230c6 100644 --- a/src/main/java/meltingpot/server/domain/repository/AccountRepository.java +++ b/src/main/java/meltingpot/server/domain/repository/AccountRepository.java @@ -4,16 +4,13 @@ import org.springframework.data.jpa.repository.EntityGraph; import org.springframework.data.jpa.repository.JpaRepository; -import javax.swing.text.html.Option; import java.util.Optional; public interface AccountRepository extends JpaRepository { - @EntityGraph(attributePaths = {"accountRoles"}) + Optional findByUsernameAndIsQuitIsFalse(String username); Optional findByUsername(String name); - Optional findByUsernameAndDeletedAtIsNull(String currentUserName); - boolean existsByUsername(String username); - Account findByIdAndDeletedAtIsNull(Long id); + Account findByIdAndIsQuitIsFalse(Long id); } diff --git a/src/main/java/meltingpot/server/domain/repository/party/PartyParticipantRepository.java b/src/main/java/meltingpot/server/domain/repository/party/PartyParticipantRepository.java index 079246a..8aec54f 100644 --- a/src/main/java/meltingpot/server/domain/repository/party/PartyParticipantRepository.java +++ b/src/main/java/meltingpot/server/domain/repository/party/PartyParticipantRepository.java @@ -1,15 +1,20 @@ package meltingpot.server.domain.repository.party; import meltingpot.server.domain.entity.Account; +import meltingpot.server.domain.entity.party.Party; import meltingpot.server.domain.entity.party.PartyParticipant; import meltingpot.server.domain.entity.party.enums.ParticipantStatus; import meltingpot.server.domain.entity.party.enums.PartyStatus; import org.springframework.data.jpa.repository.JpaRepository; +import java.util.EnumSet; +import java.util.List; import java.util.Optional; public interface PartyParticipantRepository extends JpaRepository { int countByParty_PartyStatusAndParticipantStatusAndAccount(PartyStatus partyStatus, ParticipantStatus participantStatus, Account account); + List findAllByAccount(Account account); + Optional findAllByAccountId(Long userId); } diff --git a/src/main/java/meltingpot/server/domain/repository/party/PartyRepository.java b/src/main/java/meltingpot/server/domain/repository/party/PartyRepository.java index 2a82a8e..2692d72 100644 --- a/src/main/java/meltingpot/server/domain/repository/party/PartyRepository.java +++ b/src/main/java/meltingpot/server/domain/repository/party/PartyRepository.java @@ -10,6 +10,7 @@ import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; +import java.util.EnumSet; import java.util.Optional; public interface PartyRepository extends JpaRepository, JpaSpecificationExecutor { @@ -17,6 +18,8 @@ public interface PartyRepository extends JpaRepository, JpaSpeci Party findByAccountAndPartyStatus(Account account, PartyStatus status); + boolean existsByAccountAndPartyStatusIn(Account account, EnumSet statuses); + int countByAccountAndPartyStatus(Account account, PartyStatus status); @Query("SELECT DISTINCT p FROM Party p LEFT JOIN p.partyParticipants pp WHERE (p.account = :account OR pp.account = :account) AND p.deletedAt IS NULL ORDER BY p.createdAt DESC") diff --git a/src/main/java/meltingpot/server/exception/ExceptionAdvice.java b/src/main/java/meltingpot/server/exception/ExceptionAdvice.java new file mode 100644 index 0000000..d8c55da --- /dev/null +++ b/src/main/java/meltingpot/server/exception/ExceptionAdvice.java @@ -0,0 +1,15 @@ +package meltingpot.server.exception; + +import meltingpot.server.domain.entity.MsgEntity; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +@RestControllerAdvice +public class ExceptionAdvice { + @ExceptionHandler(Exception.class) + protected ResponseEntity globalException(Exception e) { + return ResponseEntity.badRequest() + .body(new MsgEntity(e.getMessage(), "")); + } +} \ No newline at end of file diff --git a/src/main/java/meltingpot/server/user/controller/UserController.java b/src/main/java/meltingpot/server/user/controller/UserController.java index 0b41a70..ea10723 100644 --- a/src/main/java/meltingpot/server/user/controller/UserController.java +++ b/src/main/java/meltingpot/server/user/controller/UserController.java @@ -176,4 +176,21 @@ public ResponseEntity>> readUsersParti } } + //탈퇴 + @DeleteMapping("") + @Operation(summary="마이페이지 회원 탈퇴", description="회원 삭제가 아닌 소프트 마킹으로 구현했습니다." ) + @ApiResponses(value = { + @ApiResponse(responseCode = "OK", description = "회원 탈퇴 성공"), + @ApiResponse(responseCode = "BAD_REQUEST", description = "예정된 파티가 있는 경우") + }) + public ResponseEntity deleteAccount( + @CurrentUser Account account + ){ + try{ + return ResponseData.toResponseEntity(userService.deleteAccount(account)); + } + catch (NoSuchElementException e){ + return ResponseData.toResponseEntity(ResponseCode.ACCOUNT_DELETE_FAIL); + } + } } diff --git a/src/main/java/meltingpot/server/user/service/UserService.java b/src/main/java/meltingpot/server/user/service/UserService.java index 98fbfe8..61ffc6f 100644 --- a/src/main/java/meltingpot/server/user/service/UserService.java +++ b/src/main/java/meltingpot/server/user/service/UserService.java @@ -4,7 +4,9 @@ import lombok.RequiredArgsConstructor; import meltingpot.server.domain.entity.Account; import meltingpot.server.domain.entity.AccountProfileImage; +import meltingpot.server.domain.entity.enums.Gender; import meltingpot.server.domain.entity.party.Party; +import meltingpot.server.domain.entity.party.PartyParticipant; import meltingpot.server.util.Constants; import meltingpot.server.domain.entity.comment.Comment; import meltingpot.server.domain.entity.party.enums.ParticipantStatus; @@ -88,7 +90,7 @@ public UserResponseDto updateProfileBio(Account account, UpdateBioRequestDto ser @Transactional public List readProfileImages( long accountId ) { - Account account = accountRepository.findByIdAndDeletedAtIsNull(accountId); + Account account = accountRepository.findByIdAndIsQuitIsFalse(accountId); if(account == null) throw new NoSuchElementException(); List accountProfileImages = accountProfileImageRepository.findAllByAccountAndDeletedAtIsNull(account); @@ -228,6 +230,8 @@ public SliceResponse readUsersComments(Long userId, Integer pa return new SliceResponse<>(postSlice); } + // 마이페이지 사용자 파티 참여/주최 내역 + @Transactional public SliceResponse readUsersParties(Long userId, Integer page) { Account account = accountRepository.findById(userId).orElseThrow(() -> new NoSuchElementException("계정을 찾을 수 없습니다")); PageRequest pageRequest = PageRequest.of(page, Constants.PAGE_DEFAULT_SIZE, Sort.by("createdAt").descending()); @@ -237,4 +241,42 @@ public SliceResponse readUsersParties(Long userId, Integer page) .map(party -> PartyResponse.of(party))); } + + // 회원 탈퇴 + @Transactional + public ResponseCode deleteAccount(Account account) { + + EnumSet plannedPartyStatus = EnumSet.of(PartyStatus.RECRUIT_SCHEDULED, PartyStatus.RECRUIT_OPEN, PartyStatus.RECRUIT_CLOSED, PartyStatus.RUNNING); + + // 주최 중인 파티 있는지 확인 + if(partyRepository.existsByAccountAndPartyStatusIn(account, plannedPartyStatus)){ + return ResponseCode.PARTY_HOST_ACCOUNT_DELETE_DENIED; + } + + // 참여 중인 파티 있는지 확인 + boolean hasActiveParty = partyParticipantRepository.findAllByAccount(account).stream() + .map(participant -> participant.getParty().getPartyStatus()) + .anyMatch(plannedPartyStatus::contains); + + if (hasActiveParty) { + return ResponseCode.PARTY_PARTICIPANT_ACCOUNT_DELETE_DENIED; + } + + account.setUsername(""); + account.setLanguages(new ArrayList<>()); + account.setName("UNKNOWN"); + account.setPassword(""); + account.setGender(Gender.UNKNOWN); + account.setBirth(null); + account.setBio("This account is deleted"); + account.setNationality(""); + account.setIsQuit(true); + + // 프로필 이미지 삭제 + for(AccountProfileImage image : account.getProfileImages()){ + deleteProfileImage(account, image.getId()); + } + + return ResponseCode.ACCOUNT_DELETE_SUCCESS; + } } diff --git a/src/main/java/meltingpot/server/util/ErrorCode.java b/src/main/java/meltingpot/server/util/ErrorCode.java index 9247f2e..a77d232 100644 --- a/src/main/java/meltingpot/server/util/ErrorCode.java +++ b/src/main/java/meltingpot/server/util/ErrorCode.java @@ -2,7 +2,7 @@ public enum ErrorCode { INVALID_MESSAGE(400, "Invalid message format."), - INVALID_TOKEN(401, "Invalid token."); + INVALID_TOKEN(401, "Invalid code."); private final int status; private final String message; diff --git a/src/main/java/meltingpot/server/util/ResponseCode.java b/src/main/java/meltingpot/server/util/ResponseCode.java index 1b5ea0f..bfc6cf0 100644 --- a/src/main/java/meltingpot/server/util/ResponseCode.java +++ b/src/main/java/meltingpot/server/util/ResponseCode.java @@ -12,11 +12,13 @@ public enum ResponseCode { /* 200 OK : 요청 성공 */ SIGNIN_SUCCESS(OK, "로그인 성공"), + OAUTH_SIGNIN_SUCCESS(OK, "SNS 로그인 성공"), SIGNOUT_SUCCESS(OK, "로그아웃 성공"), REISSUE_TOKEN_SUCCESS(OK, "토큰 재발급 성공"), MAIL_VERIFICATION_SEND_SUCCESS(OK, "이메일 인증번호 전송 성공"), MAIL_VERIFICATION_CHECK_SUCCESS(OK, "인증번호가 일치합니다"), MAIL_AVAILABLE(OK, "사용 가능한 이메일입니다"), + ACCOUNT_DELETE_SUCCESS(OK,"회원 탈퇴 성공"), PARTY_FETCH_SUCCESS(OK, "파티 정보 불러오기 성공"), PARTY_SEARCH_SUCCESS(OK, "파티 검색 성공"), @@ -59,6 +61,7 @@ public enum ResponseCode { PARTY_REPORT_SUCCESS(CREATED, "파티 신고 성공"), PARTY_CREATE_SUCCESS(CREATED, "파티 생성 성공"), IMAGE_URL_GENERATE_SUCCESS(CREATED, "이미지 URL 생성 성공"), + OAUTH_SIGNUP_SUCCESS(CREATED, "SNS 회원가입 성공"), /* 400 BAD_REQUEST : 잘못된 요청 */ @@ -99,6 +102,11 @@ public enum ResponseCode { READ_USERS_COMMENTS_FAIL(BAD_REQUEST, "사용자가 댓글을 작성한 게시글 불러오기 실패"), READ_USERS_PARTIES_FAIL(BAD_REQUEST, "사용자가 참여한 파티 불러오기 실패"), + ACCOUNT_DELETE_FAIL(BAD_REQUEST,"회원 탈퇴 실패"), + PARTY_HOST_ACCOUNT_DELETE_DENIED(BAD_REQUEST,"파티가 예정된 주최자는 탈퇴할 수 없습니다."), + PARTY_PARTICIPANT_ACCOUNT_DELETE_DENIED(BAD_REQUEST,"파티가 예정된 참여자는 탈퇴할 수 없습니다."), + + /* 401 UNAUTHORIZED : 인증되지 않은 사용자 */ INVALID_AUTH_TOKEN(UNAUTHORIZED, "권한 정보가 없는 토큰입니다"), INVALID_ACCOUNT(UNAUTHORIZED, "계정이 비활성화 되었습니다"), diff --git a/src/main/java/meltingpot/server/util/SecurityUtil.java b/src/main/java/meltingpot/server/util/SecurityUtil.java index 4754649..a21d590 100644 --- a/src/main/java/meltingpot/server/util/SecurityUtil.java +++ b/src/main/java/meltingpot/server/util/SecurityUtil.java @@ -6,8 +6,6 @@ @Slf4j public class SecurityUtil { - // SecurityContext 에 유저 정보가 저장되는 시점 - // Request 가 들어올 때 JwtFilter 의 doFilter 에서 저장 public static String getCurrentUserName() { final Authentication authentication = SecurityContextHolder.getContext() .getAuthentication(); diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 5469230..100bf05 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -21,6 +21,28 @@ spring: mail.smtp.auth: true mail.smtp.starttls.enable: true default-encoding: UTF-8 + security: + oauth2: + client: + registration: + kakao: + client-id: ${KAKAO_CLIENT_ID} + client-secret: ${KAKAO_CLIENT_SECRET} + scope: + - account_email + - profile_nickname + authorization-grant-type: authorization_code + redirect-uri: ${KAKAO_REDIRECT_URI} + client-name: Kakao + client-authentication-method: client_secret_post + + 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 + springdoc: swagger-ui: