Skip to content

Commit

Permalink
[feat #4] : 시큐리티 세팅 및 소셜 로그인 API 구현 (#13)
Browse files Browse the repository at this point in the history
* [feat] : Security Config 작성

* [feat] : member Repository 추가

* [feat] : Oauth2UserSerivce 구현
- Provider로부터 획득한 유저 정보 처리 Service 구현

* [feat] : AuthErrorCode 구현

* [feat] : 인증 객체 Dto 추가
- 인증 객체 Custom Dto 추가
- AuthDto.class, CustomOauth2User.class

* [feat] : Oauth2UserInfo Mapping 객체 구현
- 네이버 소셜로그인 도입을 고려한 Oauth2Response interface 구현
- 카카오 소셜로그인을 위한 Oauth2Response 구현체 KakaoResponse 구현

* [style] : AuthDto 네이밍 변경으로 인한 수정

* [feat] : socialEmail update method 추가

* [feat] : Member Repository 추가
- socialEmail로 회원을 찾는 쿼리 메서드 구현

* [test] : member socialEmail udpate method 테스트 구현

* [test] : memberRepository.findBySocialEmail() 테스트 구현

* [feat] : CustomOauth2User 구현
- Custom Authentication 객체 구현

* [refactor] : Provider로부터 획득한 유저 정보 처리 Service 리팩토링
- 신규 유저 또는 기존 유저 구분 후 저장 또는 업데이트 로직 메서드 추출
- 단일 책임 원칙(SRP)를 위한 책임 분배(createSocialName(), createMemberFromOauth2Response())
- MemberRepository.findBySocialEmail() return Type 변경(Member -> Optional<Member>)

* [feat] MemberService.class 추가

* [test] : 쿼리메서드 타입 변경으로 인한 테스트 코드 수정

* [feat] : 회원 구분 객체 Auth 엔티티 추가

* [feat] : AuthRepository 추가
- Member로 Auth 찾는 쿼리 메서드 추가

* [feat] AuthService 추가
- Auth 저장 또는 업데이트 메서드 구현
- 기존 회원 구분 메서드 구현

* [feat] : AuthErrorCode 에러코드 추가

* [feat] : Auth 객체 저장 또는 업데이트 로직 추가

* [feat] : SocialName 필드에서 Provider 추출 메서드 구현

* [feat] : OauthLogin 성공 핸들러 추가
- 회원 구분하여 신규 회원일시 추가 정보 기입 페이지로 리다이렉트 구현

* [refactor] : 단일 책임 원칙 준수하기 위한 메서드 이동
- saveOrUpdate(Oauth2Response oauth2Response) : CustomeOauth2userService.class -> MemberService.class

* [fix] : Auth 생성 시 연관된 회원 주입

* [fix] : 오탈자로 인한 오류 해결
- KAKKOA -> KAKAO 수정

* [feat] : MemberErrorCode 추가

* [feat] : AuthController 추가
- kakao login api 구현

* [fix] : 회원 검색 실패 시 예외 추가

* [fix] : provider명 변경

* [fix] : Kakao Login Api 명세와 일치되도록 수정

* [test] : AuthService UnitTest 구현

* [test] : MemberService UnitTest 구현

* [fix] : Enum 필드 수정
- 문자열 저장을 위한 어노테이션  @Enumerated(STRING) 추가
- 컬럼 설정 관련 어노테이션 @column() 추가

* [fix] : 소셜이름 메서드 로직 중 문자 포맷팅 오탈자 수정

* [fix] : 소셜 이름 변경으로 인한 문제 발생 차단을 위한 수정
- 기존 소셜이름(소셜 정보 + 소셜이름) -> default 소셜이름 변경
- default 소셜이메일 -> 소셜 정보 + 소셜이메일 변경
- createSocialName(), createAuth(), parseProviderFromSocialEmail(), etc.. socialName -> socialEmail 변경

* [test] : 소셜 이름 -> 소셜 이메일 로직으로 변경으로 인한 테스트 코드 수정

* [fix] 신규 회원 DB 저장 로직 변경
- Member nickname, job_group, job_category, official_email 제약조건 수정(nullable = false -> true)

- 신규 회원 DB저장 시 소셜 정보와 크레딧만 저장, 추가정보는 /additional-info를 통해 insert

* [feat] : Member 공무원 이메일 여부를 통한 Auth 상태 변경 추가

* [test] : 공무원 이메일 여부 통한 Auth 상태 변경 단위 테스트 추가
- 공무원 이메일 존재여부 체크 단위테스트 추가
- 공무원 이메일 존재여부에 따른 Auth.status 변경 단위테스트 추가

* [feat] : 닉네임 중복 Api 구현

* [test] : 중복 닉네임 검증 단위테스트 작성

* [feat] : 신규 회원 추가 정보 입력을 통한 회원가입 구현
- 추가 정보 입력을 통한 회원가입 api 구현
- 추가 정보 요청 dto, 회원가입 성공 응답 dto 추가
- 신규 회원 정보 업데이트 및 회원가입 로직 추가
- 신규 회원 관련 에러코드 추가

* [test] : MemberService 회원가입 UnitTest 작성

* [feat] : method 단위 읽기전용 트랜잭션 부여

* [test] : Member UnitTest 추가
- 추가 정보 업데이트 UnitTest 추가
- 소셜이메일 업데이트 UnitTest명 변경

* [rename] : security 관련 클래스 파일 경로 수정

* [style]: 코드 포맷팅

* [chore] jwt 라이브러리 추가

* [feat] : 소셜로그인 성공 이후 로직 구현
- 토큰 발급 후 헤더 설정 및 리다이렉션 구현
- 클래스 네이밍 변경(CustomOauth2AuthenticationSuccessHandler -> CustomOauth2SuccessHandler)

* [feat] 소셜 로그인 실패 핸들러 추가

* [feat] : 인증 오류 EntryPoint 추가

* [feat] : Jwt 관련 예외 클래스 추가

* [feat] : jwt Provider 구현
- 타입에 따른 토큰 생성 구현
- 토큰 파싱 구현
- 토큰 검증 구현

* [feat] : 토큰 서비스 추가

* [feat] : jwt 검증 필터 구현

* [feat] : 토큰 예외처리 필터 구현

* [feat] : SecuritConfig 설정 추가
- WebSecurityCustomizer 빈 등록으로 특정 경로 보안 필터 우회 설정
- permitAll 경로 추가
- oauth2Login 실패 핸들러 추가
- 토큰 필터 추가 및 예외 EntiryPoint 추가

* [test] : tokenProvider 단위 테스트 작성

* [chore] : jwt 관련 라이브러리 추가

* [fix] : 회원가입 임시토큰 관련 로직 삭제

* [test] : 회원가입 임시토큰 발급 테스트 코드 삭제

* [style] 코드 포맷팅

* [refactor] : 정적 팩토리 메서드 추가 리펙토링

* [rename] : 패키지 네이밍 변경

* [test] : 정적 팩토리 메서드, 클래스 네이밍 변경으로 인한 리팩토링

* [rename] : AuthDto -> AuthInfo 클래스 네이밍 변경
- AuthDto 클래스 네이밍 변경
- 패키지 구조 변경 auth.dto -> security.oauth2

* [style] Repository 코드 통일성을 위한 어노테이션 삭제

* [refactor] 변경 감지로 인한 불필요한 반환타입 제거

* [test] : 변경 감지에 따른 반환타입 제거로 인한 테스트 로직 수정

* [feat] : Member 외래키 제거

* [refactor] 변경 감지로 인한 불필요한 반환타입 제거

* [fix] : 회원가입 응답DTO 필드 변경

* [test] : 변경 감지에 따른 반환타입 제거로 인한 테스트 로직 수정

* [rename] : 클래스명 및 필드명 수정

* [test] : 레코드 필드명 수정에 따른 수정

* [style] : import 클래스명 변경

* [feat] : requestDto validation 추가

* [rename] : enum 필드명 통일을 위한 네이밍 수정

* [refactor] : 가독성을 위한 static import enum타입 제거

* [test] : 가독성을 위한 static import enum타입 제거

* [rename] : enum 에러코드 네이밍 수정

* [feat] : requestMapping 추가

* [feat] : 추가정보 api 인증/인가 허용

* [sytle] : 안쓰는 의존 코드 삭제

* [feat] : 소셜이메일로 회원 찾는 메서드 추가

* [fix] : CustomOauth2User 객체가 아닌 Member 객체를 인증 객채로 생성되도록 변경

* [test] : CI test 오류로 인한 테스트 disable 처리

* [test] : 주입 클래스, 변수 순서 변경

* [test] : mock 테스트 명시를 위한 어노테이션 수정

* [refactor] : AuthInfo of() Builder를 이용한 객체 생성 변경

* [feat] : 모든 필드 값을 주입하여 생성하는 팩터리 메서드 추가

* [test] : Member 관련 test를 위한 MemberFixture 추가

* [test] : API 테스트를 위한 인증 객체 setUp 추가

* [feat] Auth 도메인 로딩 전략 변경
- Auth - Member 로딩 전략 변경(Eager -> Lazy)
- 가독성을 위한 static import 제거

* [test] : 통합 테스트용 추상메서드 상속
  • Loading branch information
dudxo authored Aug 4, 2024
1 parent cdbb73c commit 5b1615f
Show file tree
Hide file tree
Showing 40 changed files with 1,580 additions and 17 deletions.
8 changes: 7 additions & 1 deletion build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -51,11 +51,17 @@ dependencies {
testImplementation 'org.springframework.security:spring-security-test'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'

// S3

/*JWT 관련 라이브러리*/
implementation 'io.jsonwebtoken:jjwt-api:0.12.3'
implementation 'io.jsonwebtoken:jjwt-impl:0.12.3'
implementation 'io.jsonwebtoken:jjwt-jackson:0.12.3'

implementation 'org.springframework.cloud:spring-cloud-starter-aws:2.2.6.RELEASE'

//spring bean validation
implementation 'org.springframework.boot:spring-boot-starter-validation'

}

tasks.named('test') {
Expand Down
27 changes: 27 additions & 0 deletions src/main/java/com/dnd/gongmuin/auth/cotroller/AuthController.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package com.dnd.gongmuin.auth.cotroller;

import java.net.URI;

import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import lombok.RequiredArgsConstructor;

@RestController
@RequiredArgsConstructor
@RequestMapping("/api/auth")
public class AuthController {

@GetMapping("signin/kakao")
public ResponseEntity<?> kakaoLoginRedirect() {
HttpHeaders httpHeaders = new HttpHeaders();
// 카카오 로그인 페이지로 리다이렉트
httpHeaders.setLocation(URI.create("/oauth2/authorization/kakao"));
return new ResponseEntity<>(httpHeaders, HttpStatus.MOVED_PERMANENTLY);
}
}

64 changes: 64 additions & 0 deletions src/main/java/com/dnd/gongmuin/auth/domain/Auth.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
package com.dnd.gongmuin.auth.domain;

import static jakarta.persistence.ConstraintMode.*;

import com.dnd.gongmuin.member.domain.Member;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.FetchType;
import jakarta.persistence.ForeignKey;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.OneToOne;
import lombok.AccessLevel;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
public class Auth {

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

@Enumerated(EnumType.STRING)
@Column(name = "provider", nullable = false)
private Provider provider;

@Enumerated(EnumType.STRING)
@Column(name = "status", nullable = false)
private AuthStatus status;

@OneToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "member_id",
nullable = false,
foreignKey = @ForeignKey(NO_CONSTRAINT))
private Member member;

@Builder
private Auth(Provider provider, AuthStatus status, Member member) {
this.provider = provider;
this.status = status;
this.member = member;
}

public static Auth of(Provider provider, AuthStatus status, Member member) {
return Auth.builder()
.provider(provider)
.status(status)
.member(member)
.build();
}

public void updateStatus() {
this.status = AuthStatus.OLD;
}
}
14 changes: 14 additions & 0 deletions src/main/java/com/dnd/gongmuin/auth/domain/AuthStatus.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package com.dnd.gongmuin.auth.domain;

import lombok.Getter;
import lombok.RequiredArgsConstructor;

@Getter
@RequiredArgsConstructor
public enum AuthStatus {

NEW("신규"),
OLD("기존");

private final String label;
}
26 changes: 26 additions & 0 deletions src/main/java/com/dnd/gongmuin/auth/domain/Provider.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package com.dnd.gongmuin.auth.domain;

import java.util.Arrays;

import com.dnd.gongmuin.auth.exception.AuthErrorCode;
import com.dnd.gongmuin.common.exception.runtime.NotFoundException;

import lombok.Getter;
import lombok.RequiredArgsConstructor;

@Getter
@RequiredArgsConstructor
public enum Provider {

KAKAO("kakao"),
NAVER("naver");

private final String provider;

public static Provider fromProviderName(String providerName) {
return Arrays.stream(values())
.filter(provider -> provider.getProvider().equalsIgnoreCase(providerName))
.findFirst()
.orElseThrow(() -> new NotFoundException(AuthErrorCode.NOT_FOUND_PROVIDER));
}
}
18 changes: 18 additions & 0 deletions src/main/java/com/dnd/gongmuin/auth/exception/AuthErrorCode.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package com.dnd.gongmuin.auth.exception;

import com.dnd.gongmuin.common.exception.ErrorCode;

import lombok.Getter;
import lombok.RequiredArgsConstructor;

@Getter
@RequiredArgsConstructor
public enum AuthErrorCode implements ErrorCode {

UNSUPPORTED_SOCIAL_LOGIN("해당 소셜 로그인은 지원되지 않습니다.", "AUHT_001"),
NOT_FOUND_PROVIDER("알맞은 Provider를 찾을 수 없습니다.", "AUTH_002"),
NOT_FOUND_AUTH("회원의 AUTH를 찾을 수 없습니다.", "AUTH_003");

private final String message;
private final String code;
}
13 changes: 13 additions & 0 deletions src/main/java/com/dnd/gongmuin/auth/repository/AuthRepository.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package com.dnd.gongmuin.auth.repository;

import java.util.Optional;

import org.springframework.data.jpa.repository.JpaRepository;

import com.dnd.gongmuin.auth.domain.Auth;
import com.dnd.gongmuin.member.domain.Member;

public interface AuthRepository extends JpaRepository<Auth, Long> {

Optional<Auth> findByMember(Member member);
}
52 changes: 52 additions & 0 deletions src/main/java/com/dnd/gongmuin/auth/service/AuthService.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package com.dnd.gongmuin.auth.service;

import java.util.Objects;

import org.springframework.stereotype.Service;

import com.dnd.gongmuin.auth.domain.Auth;
import com.dnd.gongmuin.auth.domain.AuthStatus;
import com.dnd.gongmuin.auth.domain.Provider;
import com.dnd.gongmuin.auth.exception.AuthErrorCode;
import com.dnd.gongmuin.auth.repository.AuthRepository;
import com.dnd.gongmuin.common.exception.runtime.NotFoundException;
import com.dnd.gongmuin.member.domain.Member;
import com.dnd.gongmuin.member.service.MemberService;

import lombok.RequiredArgsConstructor;

@Service
@RequiredArgsConstructor
public class AuthService {

private final AuthRepository authRepository;
private final MemberService memberService;

public void saveOrUpdate(Member savedMember) {
Auth findedOrCreatedAuth = authRepository.findByMember(savedMember)
.map(auth -> {
if (!memberService.isOfficialEmail(savedMember)) {
auth.updateStatus();
}
return auth;
})
.orElse(createAuth(savedMember));

authRepository.save(findedOrCreatedAuth);
}

public boolean isAuthStatusOld(Member member) {
Auth findAuth = authRepository.findByMember(member)
.orElseThrow(() -> new NotFoundException(AuthErrorCode.NOT_FOUND_AUTH));

return Objects.equals(findAuth.getStatus(), AuthStatus.OLD);
}

private Auth createAuth(Member savedMember) {
String providerName = memberService.parseProviderFromSocialEmail(savedMember);
Provider provider = Provider.fromProviderName(providerName);

return Auth.of(provider, AuthStatus.NEW, savedMember);
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package com.dnd.gongmuin.common.exception.runtime;

import com.dnd.gongmuin.common.exception.ErrorCode;

import lombok.Getter;

@Getter
public class CustomJwtException extends RuntimeException {

private final String code;

public CustomJwtException(ErrorCode errorCode) {
super(errorCode.getMessage());
this.code = errorCode.getCode();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package com.dnd.gongmuin.member.controller;

import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
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;

import com.dnd.gongmuin.member.dto.request.AdditionalInfoRequest;
import com.dnd.gongmuin.member.dto.request.ValidateNickNameRequest;
import com.dnd.gongmuin.member.dto.response.SignUpResponse;
import com.dnd.gongmuin.member.dto.response.ValidateNickNameResponse;
import com.dnd.gongmuin.member.service.MemberService;
import com.dnd.gongmuin.security.oauth2.CustomOauth2User;

import lombok.RequiredArgsConstructor;

@RestController
@RequiredArgsConstructor
@RequestMapping("/api/auth")
public class MemberController {

private final MemberService memberService;

@PostMapping("/check-nickname")
public ResponseEntity<ValidateNickNameResponse> checkNickName(
@RequestBody ValidateNickNameRequest validateNickNameRequest) {
return ResponseEntity.ok(memberService.isDuplicatedNickname(validateNickNameRequest));
}

@PostMapping("/member")
public ResponseEntity<SignUpResponse> signUp(@RequestBody AdditionalInfoRequest request,
@AuthenticationPrincipal CustomOauth2User loginMember) {
SignUpResponse response = memberService.signUp(request, loginMember.getEmail());

return ResponseEntity.ok(response);
}

}
44 changes: 39 additions & 5 deletions src/main/java/com/dnd/gongmuin/member/domain/Member.java
Original file line number Diff line number Diff line change
Expand Up @@ -25,31 +25,31 @@ public class Member extends TimeBaseEntity {
@Column(name = "member_id")
private Long id;

@Column(name = "nickname", nullable = false)
@Column(name = "nickname")
private String nickname;

@Column(name = "social_name", nullable = false)
private String socialName;

@Enumerated(STRING)
@Column(name = "job_group", nullable = false)
@Column(name = "job_group")
private JobGroup jobGroup;

@Enumerated(STRING)
@Column(name = "job_category", nullable = false)
@Column(name = "job_category")
private JobCategory jobCategory;

@Column(name = "social_email", nullable = false)
private String socialEmail;

@Column(name = "official_email", nullable = false)
@Column(name = "official_email")
private String officialEmail;

@Column(name = "credit", nullable = false)
private int credit;

@Builder
public Member(String nickname, String socialName, JobGroup jobGroup, JobCategory jobCategory, String socialEmail,
private Member(String nickname, String socialName, JobGroup jobGroup, JobCategory jobCategory, String socialEmail,
String officialEmail, int credit) {
this.nickname = nickname;
this.socialName = socialName;
Expand All @@ -59,4 +59,38 @@ public Member(String nickname, String socialName, JobGroup jobGroup, JobCategory
this.officialEmail = officialEmail;
this.credit = credit;
}

public static Member of(String socialName, String socialEmail, int credit) {
return Member.builder()
.socialName(socialName)
.socialEmail(socialEmail)
.credit(credit)
.build();
}

public static Member of(String nickname, String socialName, JobGroup jobGroup, JobCategory jobCategory,
String socialEmail, String officialEmail, int credit) {
return Member.builder()
.nickname(nickname)
.socialName(socialName)
.jobGroup(jobGroup)
.jobCategory(jobCategory)
.socialEmail(socialEmail)
.officialEmail(officialEmail)
.credit(credit)
.build();
}

public void updateSocialEmail(String socialEmail) {
this.socialEmail = socialEmail;
}

public void updateAdditionalInfo(String nickname, String officialEmail,
JobGroup jobGroup, JobCategory jobCategory) {
this.nickname = nickname;
this.officialEmail = officialEmail;
this.jobGroup = jobGroup;
this.jobCategory = jobCategory;
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package com.dnd.gongmuin.member.dto.request;

import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;

public record AdditionalInfoRequest(

@NotBlank(message = "공무원 이메일은 필수 입력 항목입니다.")
String officialEmail,
@NotBlank(message = "닉네임은 필수 입력 항목입니다.")
@Size(min = 2, max = 12, message = "닉네임은 최소 2자리 이상 최대 12자 이하입니다.")
String nickname,
@NotBlank(message = "직군은 필수 입력 항목입니다.")
String jobGroup,
@NotBlank(message = "직렬은 필수 입력 항목입니다.")
String jobCategory
) {
}
Loading

0 comments on commit 5b1615f

Please sign in to comment.