Skip to content

Commit

Permalink
Merge pull request #76 from EFUB4-Jukebox/feat/auth
Browse files Browse the repository at this point in the history
[Feat] 로그인/로그아웃/회원 탈퇴 기능 구현
  • Loading branch information
crHwang0822 authored Jul 23, 2024
2 parents db990f2 + 5880f95 commit eaeedf1
Show file tree
Hide file tree
Showing 17 changed files with 238 additions and 36 deletions.
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
package sws.songpin.domain.member.controller;

import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
Expand All @@ -13,8 +16,10 @@
import sws.songpin.domain.member.dto.request.LoginRequestDto;
import sws.songpin.domain.member.dto.request.SignUpRequestDto;
import sws.songpin.domain.member.dto.response.LoginResponseDto;
import sws.songpin.domain.member.dto.response.TokenDto;
import sws.songpin.domain.member.service.AuthService;

@Tag(name = "Auth", description = "인증 관련 API입니다.")
@RestController
@RequiredArgsConstructor
public class AuthController {
Expand All @@ -29,9 +34,25 @@ public ResponseEntity<Object> signUp(@Valid @RequestBody SignUpRequestDto reques

@Operation(summary = "로그인", description = "로그인 결과를 반환합니다.")
@PostMapping("/login")
public ResponseEntity<LoginResponseDto> login(@Valid @RequestBody LoginRequestDto requestDto){
LoginResponseDto responseDto = authService.login(requestDto);
return ResponseEntity.ok(responseDto);
public ResponseEntity<?> login(@Valid @RequestBody LoginRequestDto requestDto, HttpServletResponse response){
TokenDto tokenDto = authService.login(requestDto);

Cookie refreshTokenCookie = new Cookie("refreshToken", tokenDto.refreshToken());
refreshTokenCookie.setHttpOnly(true);
refreshTokenCookie.setSecure(true);
refreshTokenCookie.setPath("/");
refreshTokenCookie.setMaxAge(tokenDto.refreshTokenMaxAge());

response.addCookie(refreshTokenCookie);

return ResponseEntity.ok(new LoginResponseDto(tokenDto.accessToken()));
}

@Operation(summary = "로그아웃", description = "Redis와 쿠키에 저장되었던 회원의 Refresh Token을 삭제합니다.")
@PostMapping("/logout")
public ResponseEntity<?> logout(HttpServletResponse response){

return ResponseEntity.ok().build();
}

@Operation(summary = "토큰 검증 테스트")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,15 @@

import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import sws.songpin.domain.bookmark.service.BookmarkService;
import sws.songpin.domain.member.dto.request.ProfileDeactivateRequestDto;
import sws.songpin.domain.member.dto.request.ProfileUpdateRequestDto;
import sws.songpin.domain.member.service.MemberService;
import sws.songpin.domain.member.service.ProfileService;
import sws.songpin.domain.pin.service.PinService;
import sws.songpin.domain.playlist.service.PlaylistService;
Expand All @@ -21,7 +23,6 @@ public class MyPageController {
private final PlaylistService playlistService;
private final BookmarkService bookmarkService;
private final ProfileService profileService;
private final MemberService memberService;
private final PinService pinService;

@Operation(summary = "내 플레이리스트 목록 조회", description = "마이페이지에서 내 플레이리스트 목록 조회")
Expand All @@ -45,7 +46,7 @@ public ResponseEntity<?> getMyProfile(){
@Operation(summary = "프로필 편집", description = "프로필 이미지, 닉네임, 핸들 변경")
@PatchMapping
public ResponseEntity<?> updateProfile(@RequestBody @Valid ProfileUpdateRequestDto requestDto){
memberService.updateProfile(requestDto);
profileService.updateProfile(requestDto);
return ResponseEntity.ok().build();
}

Expand All @@ -61,4 +62,20 @@ public ResponseEntity<?> getMyFeedPinsByMonth(@RequestParam("year") int year, @R
return ResponseEntity.ok(pinService.getMyPinFeedForMonth(year, month));
}

@Operation(summary = "회원 탈퇴", description = "회원 상태를 '탈퇴'로 변경하고 닉네임을 '(알 수 없음)'으로 변경합니다. \t\n해당 회원의 handle을 랜덤 uuid 값으로 변경합니다. \t\nRedis와 쿠키에 저장되었던 회원의 Refresh Token을 삭제합니다. \t\n해당 회원이 등록했던 핀 등의 데이터는 남겨둡니다. \t\n해당 회원의 팔로우, 팔로잉 데이터는 삭제합니다.")
@PatchMapping("/status")
public ResponseEntity<?> deactivate(@Valid @RequestBody ProfileDeactivateRequestDto requestDto, HttpServletResponse response){
profileService.deactivateProfile(requestDto);

//쿠키 삭제
Cookie refreshTokenCookie = new Cookie("refreshToken",null);
refreshTokenCookie.setHttpOnly(true);
refreshTokenCookie.setPath("/");
refreshTokenCookie.setMaxAge(0);

response.addCookie(refreshTokenCookie);

return ResponseEntity.ok().build();
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import jakarta.validation.constraints.NotBlank;

public record LoginRequestDto(
@Email
@Email(message = "INVALID_INPUT_FORMAT-유효한 이메일 형식이 아닙니다.")
@NotBlank
String email,
@NotBlank
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package sws.songpin.domain.member.dto.request;

import jakarta.validation.constraints.NotBlank;

public record ProfileDeactivateRequestDto(
@NotBlank(message = "INVALID_INPUT_VALUE-비밀번호는 한 글자 이상 입력해야 합니다.")
String password
) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
public record ProfileUpdateRequestDto (
@NotBlank
String profileImg,
@Pattern(regexp = "^[가-힣a-zA-Z0-9]+$", message = "INVALID_INPUT_FORMAT-닉네임은 한글 문자, 영어 대소문자, 숫자 조합만 허용됩니다.")
@Pattern(regexp = "^(?!.*\\s$)(?!^\\s)[가-힣a-zA-Z0-9\\s]+$", message = "INVALID_INPUT_FORMAT-닉네임은 한글 문자, 영어 대소문자, 숫자, 공백 조합만 허용됩니다. 단, 공백만으로 구성되거나 공백이 맨 앞과 맨 뒤에 올 수 없습니다.")
@Size(max = 8, message = "INVALID_INPUT_LENGTH-닉네임은 8자 이내여야 합니다.")
@NotBlank
String nickname,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package sws.songpin.domain.member.dto.response;

public record LoginResponseDto(
String accessToken,
String refreshToken
) { }
public record LoginResponseDto (
String accessToken
){

}
13 changes: 13 additions & 0 deletions src/main/java/sws/songpin/domain/member/dto/response/TokenDto.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package sws.songpin.domain.member.dto.response;

import lombok.Builder;

public record TokenDto(
String accessToken,
String refreshToken,
int refreshTokenMaxAge
) {
public TokenDto(String accessToken, String refreshToken){
this(accessToken,refreshToken,7*24*60*60);
}
}
6 changes: 6 additions & 0 deletions src/main/java/sws/songpin/domain/member/entity/Member.java
Original file line number Diff line number Diff line change
Expand Up @@ -78,4 +78,10 @@ public void modifyProfile(ProfileImg profileImg, String nickname, String handle)
this.nickname = nickname;
this.handle = handle;
}

public void deactivate(String handle){
this.status = Status.DELETED;
this.nickname = "(알 수 없음)";
this.handle = handle;
}
}
24 changes: 20 additions & 4 deletions src/main/java/sws/songpin/domain/member/service/AuthService.java
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,16 @@
import org.springframework.transaction.annotation.Transactional;
import sws.songpin.domain.member.dto.request.LoginRequestDto;
import sws.songpin.domain.member.dto.request.SignUpRequestDto;
import sws.songpin.domain.member.dto.response.LoginResponseDto;
import sws.songpin.domain.member.dto.response.TokenDto;
import sws.songpin.domain.member.entity.Member;
import sws.songpin.domain.member.entity.Status;
import sws.songpin.domain.member.repository.MemberRepository;
import sws.songpin.global.auth.CustomUserDetailsService;
import sws.songpin.global.auth.JwtUtil;
import sws.songpin.global.exception.CustomException;
import sws.songpin.global.exception.ErrorCode;

import java.util.Optional;
import java.util.UUID;

@Service
Expand All @@ -27,11 +30,19 @@ public class AuthService {
private final PasswordEncoder passwordEncoder;
private final AuthenticationManager authenticationManager;
private final JwtUtil jwtUtil;
private final CustomUserDetailsService userDetailsService;

public void signUp(SignUpRequestDto requestDto) {

Optional<Member> memberOptional = memberRepository.findByEmail(requestDto.email());

//이메일 중복 검사
if (memberRepository.findByEmail(requestDto.email()).isPresent()) {
if (memberOptional.isPresent()) {

if(memberOptional.get().getStatus().equals(Status.DELETED)){
throw new CustomException(ErrorCode.ALREADY_DELETED_MEMBER);
}

throw new CustomException(ErrorCode.EMAIL_ALREADY_EXISTS);
}

Expand All @@ -48,7 +59,7 @@ public void signUp(SignUpRequestDto requestDto) {
memberRepository.save(member);
}

public LoginResponseDto login(LoginRequestDto requestDto){
public TokenDto login(LoginRequestDto requestDto){

try{
Authentication authentication = authenticationManager.authenticate(
Expand All @@ -57,12 +68,17 @@ public LoginResponseDto login(LoginRequestDto requestDto){
)
);

LoginResponseDto responseDto = new LoginResponseDto(jwtUtil.generateAccessToken(authentication), jwtUtil.generateRefreshToken(authentication) );
TokenDto responseDto = new TokenDto(jwtUtil.generateAccessToken(authentication), jwtUtil.generateRefreshToken(authentication) );

return responseDto;
} catch (BadCredentialsException e){
throw new CustomException(ErrorCode.LOGIN_FAIL);
}
}

@Transactional(readOnly = true)
public boolean checkPassword(Member member, String password){
return passwordEncoder.matches(password, member.getPassword());
}

}
16 changes: 3 additions & 13 deletions src/main/java/sws/songpin/domain/member/service/MemberService.java
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,10 @@
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import sws.songpin.domain.member.dto.request.ProfileUpdateRequestDto;
import sws.songpin.domain.member.dto.response.HomeResponseDto;
import sws.songpin.domain.member.dto.response.MemberSearchResponseDto;
import sws.songpin.domain.member.dto.response.MemberUnitDto;
import sws.songpin.domain.member.entity.Member;
import sws.songpin.domain.member.entity.ProfileImg;
import sws.songpin.domain.member.repository.MemberRepository;
import sws.songpin.domain.pin.dto.response.PinBasicUnitDto;
import sws.songpin.domain.pin.entity.Pin;
Expand All @@ -35,6 +33,7 @@ public class MemberService {
private final PlaceRepository placeRepository;

// 유저 검색
@Transactional(readOnly = true)
public MemberSearchResponseDto searchMembers(String keyword, Pageable pageable) {
Page<Member> memberPage = memberRepository.findAllByHandleContainingOrNicknameContaining(keyword, pageable);
Long currentMemberId = getCurrentMember().getMemberId();
Expand Down Expand Up @@ -65,17 +64,8 @@ public boolean checkMemberExistsByHandle(String handle){
return memberRepository.existsByHandle(handle);
}

public void updateProfile(ProfileUpdateRequestDto requestDto){
Member member = getCurrentMember();

//핸들 중복 검사
if(checkMemberExistsByHandle(requestDto.handle()) && !(member.getHandle().equals(requestDto.handle()))){
throw new CustomException(ErrorCode.HANDLE_ALREADY_EXISTS);
}

member.modifyProfile(ProfileImg.from(requestDto.profileImg()), requestDto.nickname(), requestDto.handle());

memberRepository.save(member);
public Member saveMember(Member member){
return memberRepository.save(member);
}

@Transactional(readOnly = true)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,30 @@

import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import sws.songpin.domain.follow.service.FollowService;
import sws.songpin.domain.member.dto.request.ProfileDeactivateRequestDto;
import sws.songpin.domain.member.dto.request.ProfileUpdateRequestDto;
import sws.songpin.domain.member.dto.response.MemberProfileResponseDto;
import sws.songpin.domain.member.dto.response.MyProfileResponseDto;
import sws.songpin.domain.member.entity.Member;
import sws.songpin.domain.member.entity.ProfileImg;
import sws.songpin.global.auth.RedisService;
import sws.songpin.global.exception.CustomException;
import sws.songpin.global.exception.ErrorCode;

import java.util.UUID;

@Service
@RequiredArgsConstructor
@Transactional
public class ProfileService {
private final MemberService memberService;
private final FollowService followService;
private final AuthService authService;
private final RedisService redisService;

@Transactional(readOnly = true)
public MemberProfileResponseDto getMemberProfile(Long memberId){
Member member = memberService.getMemberById(memberId);
Member currentMember = memberService.getCurrentMember();
Expand All @@ -39,6 +50,7 @@ public MemberProfileResponseDto getMemberProfile(Long memberId){

}

@Transactional(readOnly = true)
public MyProfileResponseDto getMyProfile(){
Member member = memberService.getCurrentMember();

Expand All @@ -50,4 +62,39 @@ public MyProfileResponseDto getMyProfile(){

return MyProfileResponseDto.from(member, followerCount, followingCount);
}

public void updateProfile(ProfileUpdateRequestDto requestDto){

Member member = memberService.getCurrentMember();

//핸들 중복 검사
if(memberService.checkMemberExistsByHandle(requestDto.handle()) && !(member.getHandle().equals(requestDto.handle()))){
throw new CustomException(ErrorCode.HANDLE_ALREADY_EXISTS);
}

member.modifyProfile(ProfileImg.from(requestDto.profileImg()), requestDto.nickname(), requestDto.handle());

memberService.saveMember(member);

}

public void deactivateProfile(ProfileDeactivateRequestDto requestDto){

Member member = memberService.getCurrentMember();

//패스워드 검사
if(!(authService.checkPassword(member, requestDto.password()))){
throw new CustomException(ErrorCode.PASSWORD_MISMATCH);
}

//handle 랜덤값 생성
String handle = UUID.randomUUID().toString().replaceAll("-", "").substring(0, 12);
//Status, Nickname, Handle 변경
member.deactivate(handle);
memberService.saveMember(member);

//Redis에서 Refresh Token 삭제
redisService.deleteValues(member.getEmail());
}

}
33 changes: 33 additions & 0 deletions src/main/java/sws/songpin/global/auth/CustomLogoutHandler.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package sws.songpin.global.auth;

import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.Authentication;
import org.springframework.security.web.authentication.logout.LogoutHandler;
import org.springframework.security.web.authentication.logout.SecurityContextLogoutHandler;
import org.springframework.stereotype.Component;
import sws.songpin.global.exception.CustomException;
import sws.songpin.global.exception.ErrorCode;

@Component
@RequiredArgsConstructor
public class CustomLogoutHandler implements LogoutHandler {

private final RedisService redisService;

@Override
public void logout(HttpServletRequest request, HttpServletResponse response, Authentication authentication) {

if(authentication!=null && authentication.getName() != null){
//Redis 에서 Refresh Token 삭제
redisService.deleteValues(authentication.getName());

// 기본 로그아웃 핸들러 기능 수행
SecurityContextLogoutHandler securityContextLogoutHandler = new SecurityContextLogoutHandler();
securityContextLogoutHandler.logout(request, response, authentication);
} else{
throw new CustomException(ErrorCode.NOT_AUTHENTICATED);
}
}
}
Loading

0 comments on commit eaeedf1

Please sign in to comment.