Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Feature] - 자체 회원 가입과 로그인 구현 #234

Merged
merged 23 commits into from
Aug 7, 2024
Merged
Show file tree
Hide file tree
Changes from 20 commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
7241333
feat: 투룻 서비스 자체 로그인 API 작성
hangillee Aug 5, 2024
92b8216
feat: 회원 가입 및 로그인 기능 구현
hangillee Aug 5, 2024
e4bc915
feat: 회원의 로그인 타입에 따른 구분을 위한 열거형 추가
hangillee Aug 5, 2024
55a8b0b
feat: 회원 가입 기능 구현
hangillee Aug 5, 2024
7cba989
feat: 회원 가입 및 로그인 기능 토큰 필터에서 제외
hangillee Aug 5, 2024
5f21841
feat: 이미지 URL 검증 추가에 따른 Swagger example 수정
hangillee Aug 5, 2024
931a226
refactor: URL 검증 예외 메시지 수정
hangillee Aug 5, 2024
6f800d5
feat: 로그인 요청 DTO 구현
hangillee Aug 5, 2024
96b6efb
refactor: 회원 가입 및 로그인 기능 구현에 따른 테스트 코드 수정
hangillee Aug 5, 2024
d3b252f
chore: 반환 URI 수정
hangillee Aug 6, 2024
b538714
refactor: 이메일과 비밀번호 null 검증 추가
hangillee Aug 6, 2024
b4df94f
refactor: 사용자 도메인 테스트 추가 작성
hangillee Aug 6, 2024
1371e7d
refactor: 사용자 test fixture 추가
hangillee Aug 6, 2024
f9e1a2d
refactor: Test fixture 이름 변경에 따른 리팩토링
hangillee Aug 6, 2024
fa9b228
test: 사용자 회원 가입 로직 컨트롤러 계층과 서비스 계층 통합 테스트 작성
hangillee Aug 6, 2024
4504162
Merge branch 'develop/be' into feature/be/#215
hangillee Aug 6, 2024
94373b4
fix: Conflict 해결 과정에서 누락된 수정 사항 반영
hangillee Aug 6, 2024
668c4ff
Merge branch 'develop/be' into feature/be/#215
hangillee Aug 6, 2024
fbefa2c
fix: Conflict 해결 과정에서 누락된 수정 사항 반영
hangillee Aug 6, 2024
8cf1d35
refactor: 불필요한 요청 화이트리스트 제거
hangillee Aug 6, 2024
0501f4b
refactor: test fixture lombok을 활용한 enum fixture로 개선
hangillee Aug 7, 2024
6d4a10a
Merge branch 'develop/be' into feature/be/#215
hangillee Aug 7, 2024
729795a
fix: 변경된 메소드 이름이 반영되지 않은 코드 수정
hangillee Aug 7, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,15 @@
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import kr.touroot.authentication.dto.request.LoginRequest;
import kr.touroot.authentication.dto.response.LoginResponse;
import kr.touroot.authentication.service.LoginService;
import kr.touroot.global.exception.dto.ExceptionResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
Expand Down Expand Up @@ -44,4 +47,22 @@ public ResponseEntity<LoginResponse> login(
return ResponseEntity.ok()
.body(loginService.login(authorizationCode, encodedRedirectUri));
}

@Operation(summary = "투룻 서비스 자체 로그인")
@ApiResponses(value = {
@ApiResponse(
responseCode = "200",
description = "요청이 정상적으로 처리되었을 때"
),
@ApiResponse(
responseCode = "400",
description = "요청 Body에 올바르지 않은 이메일 또는 비밀번호가 전달되었을 때",
content = @Content(schema = @Schema(implementation = ExceptionResponse.class))
)
})
@PostMapping
public ResponseEntity<LoginResponse> defaultLogin(@Valid @RequestBody LoginRequest request) {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

그냥 제안) 위를 kakaoLogin()으로 하고 일반 로그인을 login()으로 하면 어떨까요? 뭔가 디폴트가 뭐지? 싶을 수도 있을 것 같은

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

동의합니다! 모호한 메소드 네이밍이었네요.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

저도 같은 생각..!

return ResponseEntity.ok()
.body(loginService.login(request));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package kr.touroot.authentication.dto.request;

import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;

public record LoginRequest(
@Schema(description = "사용자 이메일", example = "email@gmail.com")
@NotBlank(message = "이메일은 비어있을 수 없습니다.")
@Email
String email,
@Schema(description = "사용자 비밀번호", example = "@testpassword1234")
@NotBlank(message = "비밀번호는 비어있을 수 없습니다.")
String password
) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import com.fasterxml.jackson.annotation.JsonProperty;
import kr.touroot.authentication.dto.response.kakao.KakaoAccount;
import kr.touroot.member.domain.LoginType;
import kr.touroot.member.domain.Member;

public record OauthUserInformationResponse(
Expand All @@ -12,7 +13,7 @@ public record OauthUserInformationResponse(
) {

public Member toMember() {
return new Member(socialLoginId, nickname(), profileImage());
return new Member(socialLoginId, nickname(), profileImage(), LoginType.KAKAO);
}

public String nickname() {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package kr.touroot.authentication.infrastructure;

import java.math.BigInteger;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import org.springframework.stereotype.Component;

@Component
public class PasswordEncryptor {

public static final int HEXADECIMAL = 16;

public String encrypt(String password) {
try {
MessageDigest md = MessageDigest.getInstance("SHA-512");

byte[] message = md.digest(password.getBytes());
BigInteger number = new BigInteger(1, message);

return number.toString(HEXADECIMAL);
} catch (NoSuchAlgorithmException exception) {
throw new RuntimeException();
}
}
}
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
package kr.touroot.authentication.service;

import kr.touroot.authentication.dto.request.LoginRequest;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import kr.touroot.authentication.dto.response.LoginResponse;
import kr.touroot.authentication.dto.response.OauthUserInformationResponse;
import kr.touroot.authentication.infrastructure.JwtTokenProvider;
import kr.touroot.authentication.infrastructure.KakaoOauthProvider;
import kr.touroot.authentication.infrastructure.PasswordEncryptor;
import kr.touroot.global.exception.BadRequestException;
import kr.touroot.member.domain.Member;
import kr.touroot.member.repository.MemberRepository;
import lombok.RequiredArgsConstructor;
Expand All @@ -18,6 +21,7 @@ public class LoginService {
private final MemberRepository memberRepository;
private final KakaoOauthProvider oauthProvider;
private final JwtTokenProvider tokenProvider;
private final PasswordEncryptor passwordEncryptor;

public LoginResponse login(String code, String encodedRedirectUri) {
String redirectUri = URLDecoder.decode(encodedRedirectUri, StandardCharsets.UTF_8);
Expand All @@ -31,4 +35,12 @@ public LoginResponse login(String code, String encodedRedirectUri) {
private Member signUp(OauthUserInformationResponse userInformation) {
return memberRepository.save(userInformation.toMember());
}

public LoginResponse login(LoginRequest request) {
String encryptPassword = passwordEncryptor.encrypt(request.password());
Member member = memberRepository.findByEmailAndPassword(request.email(), encryptPassword)
.orElseThrow(() -> new BadRequestException("잘못된 이메일 또는 비밀번호입니다."));

return LoginResponse.of(member, tokenProvider.createToken(member.getId()));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ public class JwtAuthFilter extends OncePerRequestFilter {
new HttpRequestInfo(HttpMethod.GET, "/api/v1/travelogues/**"),
new HttpRequestInfo(HttpMethod.POST, "/api/v1/login/**"),
new HttpRequestInfo(HttpMethod.GET, "/api/v1/travel-plans/shared/**"),
new HttpRequestInfo(HttpMethod.POST, "/api/v1/members"),
new HttpRequestInfo(HttpMethod.OPTIONS, "/**")
);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ public String copyImageToPermanentStorage(String imageUrl) {

private void validateS3Path(String imageKey) {
if (!imageKey.startsWith(imageBaseUri + temporaryStoragePath)) {
throw new BadRequestException("이미지 url 형식이 잘못되었습니다.");
throw new BadRequestException("S3 이미지 url 형식이 잘못되었습니다.");
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

굳굳

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

훨씬 좋아졌네요! 감사합니다!!

}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package kr.touroot.member.controller;

import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import java.net.URI;
import kr.touroot.global.exception.dto.ExceptionResponse;
import kr.touroot.member.dto.request.MemberRequest;
import kr.touroot.member.service.MemberService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@Tag(name = "사용자")
@RequiredArgsConstructor
@RestController
@RequestMapping("/api/v1/members")
public class MemberController {

private final MemberService memberService;

@Operation(summary = "회원 가입")
@ApiResponses(value = {
@ApiResponse(
responseCode = "201",
description = "요청이 정상적으로 처리되었을 때"
),
@ApiResponse(
responseCode = "400",
description = "요청 Body에 올바르지 않은 값이 전달되었을 때",
content = @Content(schema = @Schema(implementation = ExceptionResponse.class))
),
})
@PostMapping
public ResponseEntity<Void> createMember(@Valid @RequestBody MemberRequest request) {
Long id = memberService.createMember(request);

return ResponseEntity.created(URI.create("/api/v1/members/" + id))
.build();
Comment on lines +45 to +46

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

그냥 질문) 반환한 members uri는 어떻게 쓰이나용

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

프론트엔드 분들이 끝의 id를 활용하거나 단순히 몇 번째 멤버가 잘 생성되었는가(어디에 자원이 위치하는가)에 대한 메타데이터 용도이기도 합니다!

}
}
5 changes: 5 additions & 0 deletions backend/src/main/java/kr/touroot/member/domain/LoginType.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package kr.touroot.member.domain;

public enum LoginType {
KAKAO, DEFAULT
}
57 changes: 46 additions & 11 deletions backend/src/main/java/kr/touroot/member/domain/Member.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
Expand All @@ -24,37 +26,70 @@ public class Member extends BaseEntity {
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

@Column(nullable = false)
private Long kakaoId;

private String email;

private String password;

@Column(nullable = false)
private String nickname;

@Column(nullable = false)
private String profileImageUrl;

public Member(Long id, Long kakaoId, String nickname, String profileImageUrl) {
validate(kakaoId, nickname, profileImageUrl);
@Column(nullable = false)
@Enumerated(EnumType.STRING)
private LoginType loginType;

public Member(
Long id, Long kakaoId, String email, String password, String nickname, String url, LoginType loginType
) {
validate(kakaoId, email, password, nickname, url, loginType);
this.id = id;
this.kakaoId = kakaoId;
this.email = email;
this.password = password;
this.nickname = nickname;
this.profileImageUrl = profileImageUrl;
this.profileImageUrl = url;
this.loginType = loginType;
}

public Member(Long kakaoId, String nickname, String profileImageUrl, LoginType loginType) {
this(null, kakaoId, null, null, nickname, profileImageUrl, loginType);
}

public Member(Long kakaoId, String nickname, String profileImageUrl) {
this(null, kakaoId, nickname, profileImageUrl);
public Member(String email, String password, String nickname, String profileImageUrl, LoginType loginType) {
this(null, null, email, password, nickname, profileImageUrl, loginType);
}

private void validate(Long kakaoId, String nickname, String profileImageUrl) {
validateNotNull(kakaoId, nickname, profileImageUrl);
private void validate(
Long kakaoId, String email, String password, String nickname, String profileImageUrl, LoginType loginType
) {
validateByLoginType(kakaoId, email, password, loginType);
validateNotNull(nickname, profileImageUrl);
validateNotBlank(nickname, profileImageUrl);
validateNicknameLength(nickname);
validateProfileImageUrl(profileImageUrl);
}

private void validateNotNull(Long kakaoId, String nickname, String profileImageUrl) {
if (kakaoId == null || nickname == null || profileImageUrl == null) {
throw new BadRequestException("카카오 아이디, 닉네임, 프로필 이미지는 비어 있을 수 없습니다");
private void validateByLoginType(Long kakaoId, String email, String password, LoginType loginType) {
if (loginType.equals(LoginType.KAKAO) && kakaoId == null) {
throw new BadRequestException("카카오 ID는 비어 있을 수 없습니다");
}

if (loginType.equals(LoginType.DEFAULT) && (email == null || password == null)) {
throw new BadRequestException("이메일과 비밀번호는 비어 있을 수 없습니다.");
}

if (loginType.equals(LoginType.DEFAULT) && (email.isBlank() || password.isBlank())) {
throw new BadRequestException("이메일과 비밀번호는 비어 있을 수 없습니다.");
}
}

private void validateNotNull(String nickname, String profileImageUrl) {
if (nickname == null || profileImageUrl == null) {
throw new BadRequestException("닉네임, 프로필 이미지는 비어 있을 수 없습니다");
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package kr.touroot.member.dto.request;

import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import kr.touroot.member.domain.LoginType;
import kr.touroot.member.domain.Member;

public record MemberRequest(
@Schema(description = "사용자 이메일", example = "email@gmail.com")
@NotBlank(message = "이메일은 비어있을 수 없습니다.")
@Email
String email,
@Schema(description = "사용자 비밀번호", example = "@testpassword1234")
@NotBlank(message = "비밀번호는 비어있을 수 없습니다.")
String password,
@Schema(description = "사용자 닉네임", example = "뚜리")
@NotBlank(message = "닉네임은 비어있을 수 없습니다.")
String nickname,
@Schema(description = "사용자 프로필 사진 URL", example = "S3 이미지 URL")
@NotBlank(message = "프로필 사진 URL은 비어있을 수 없습니다.")
String profileImageUrl
Comment on lines +20 to +22

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

프사를 설정하지 않는 상남자일 경우 default url을 넣는건 백에서 따로 처리 안해도 되는 건가욤

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

요것도 고민해볼 부분인데, 저희 default 이미지 url이 있다면 그걸로 지정해줘도 좋을 것 같아요.
아직 저희 기본 프로필 사진이 없어서 지금은 비워뒀습니다!

) {

public Member toMember(String password) {
return new Member(email, password, nickname, profileImageUrl, LoginType.DEFAULT);
}
}
Original file line number Diff line number Diff line change
@@ -1,10 +1,16 @@
package kr.touroot.member.repository;

import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import kr.touroot.member.domain.Member;
import org.springframework.data.jpa.repository.JpaRepository;

public interface MemberRepository extends JpaRepository<Member, Long> {

Optional<Member> findByKakaoId(Long kakaoId);

Optional<Member> findByEmailAndPassword(String email, String password);

Optional<Member> findByEmail(String email);

Optional<Object> findByNickname(String nickname);
}
28 changes: 28 additions & 0 deletions backend/src/main/java/kr/touroot/member/service/MemberService.java
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
package kr.touroot.member.service;

import kr.touroot.authentication.infrastructure.PasswordEncryptor;
import kr.touroot.global.exception.BadRequestException;
import kr.touroot.member.domain.Member;
import kr.touroot.member.dto.request.MemberRequest;
import kr.touroot.member.repository.MemberRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
Expand All @@ -11,9 +13,35 @@
public class MemberService {

private final MemberRepository memberRepository;
private final PasswordEncryptor passwordEncryptor;

public Member getById(Long memberId) {
return memberRepository.findById(memberId)
.orElseThrow(() -> new BadRequestException("존재하지 않는 사용자입니다."));
}

public Long createMember(MemberRequest request) {
validateRequest(request);
String encryptedPassword = passwordEncryptor.encrypt(request.password());
Member member = request.toMember(encryptedPassword);

return memberRepository.save(member).getId();
}

private void validateRequest(MemberRequest request) {
validateEmailDuplication(request.email());
validateNicknameDuplicationr(request.nickname());
}

private void validateEmailDuplication(String email) {
if (memberRepository.findByEmail(email).isPresent()) {
throw new BadRequestException("이미 회원 가입되어 있는 이메일입니다.");
}
}

private void validateNicknameDuplicationr(String nickname) {
if (memberRepository.findByNickname(nickname).isPresent()) {
throw new BadRequestException("이미 사용 중인 닉네임입니다.");
}
}
}
Loading
Loading