diff --git a/build.gradle b/build.gradle index 4256dab1..62d1265c 100644 --- a/build.gradle +++ b/build.gradle @@ -53,11 +53,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') { diff --git a/src/main/java/com/dnd/gongmuin/auth/cotroller/AuthController.java b/src/main/java/com/dnd/gongmuin/auth/cotroller/AuthController.java new file mode 100644 index 00000000..014ac7e5 --- /dev/null +++ b/src/main/java/com/dnd/gongmuin/auth/cotroller/AuthController.java @@ -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); + } +} + diff --git a/src/main/java/com/dnd/gongmuin/auth/domain/Auth.java b/src/main/java/com/dnd/gongmuin/auth/domain/Auth.java new file mode 100644 index 00000000..25687f7e --- /dev/null +++ b/src/main/java/com/dnd/gongmuin/auth/domain/Auth.java @@ -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; + } +} diff --git a/src/main/java/com/dnd/gongmuin/auth/domain/AuthStatus.java b/src/main/java/com/dnd/gongmuin/auth/domain/AuthStatus.java new file mode 100644 index 00000000..31124376 --- /dev/null +++ b/src/main/java/com/dnd/gongmuin/auth/domain/AuthStatus.java @@ -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; +} diff --git a/src/main/java/com/dnd/gongmuin/auth/domain/Provider.java b/src/main/java/com/dnd/gongmuin/auth/domain/Provider.java new file mode 100644 index 00000000..97072568 --- /dev/null +++ b/src/main/java/com/dnd/gongmuin/auth/domain/Provider.java @@ -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)); + } +} diff --git a/src/main/java/com/dnd/gongmuin/auth/exception/AuthErrorCode.java b/src/main/java/com/dnd/gongmuin/auth/exception/AuthErrorCode.java new file mode 100644 index 00000000..60e9a45b --- /dev/null +++ b/src/main/java/com/dnd/gongmuin/auth/exception/AuthErrorCode.java @@ -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; +} diff --git a/src/main/java/com/dnd/gongmuin/auth/repository/AuthRepository.java b/src/main/java/com/dnd/gongmuin/auth/repository/AuthRepository.java new file mode 100644 index 00000000..71348b84 --- /dev/null +++ b/src/main/java/com/dnd/gongmuin/auth/repository/AuthRepository.java @@ -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 { + + Optional findByMember(Member member); +} diff --git a/src/main/java/com/dnd/gongmuin/auth/service/AuthService.java b/src/main/java/com/dnd/gongmuin/auth/service/AuthService.java new file mode 100644 index 00000000..64326c73 --- /dev/null +++ b/src/main/java/com/dnd/gongmuin/auth/service/AuthService.java @@ -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); + } + +} diff --git a/src/main/java/com/dnd/gongmuin/common/exception/runtime/CustomJwtException.java b/src/main/java/com/dnd/gongmuin/common/exception/runtime/CustomJwtException.java new file mode 100644 index 00000000..953704cd --- /dev/null +++ b/src/main/java/com/dnd/gongmuin/common/exception/runtime/CustomJwtException.java @@ -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(); + } +} diff --git a/src/main/java/com/dnd/gongmuin/member/controller/MemberController.java b/src/main/java/com/dnd/gongmuin/member/controller/MemberController.java new file mode 100644 index 00000000..ddc7edce --- /dev/null +++ b/src/main/java/com/dnd/gongmuin/member/controller/MemberController.java @@ -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 checkNickName( + @RequestBody ValidateNickNameRequest validateNickNameRequest) { + return ResponseEntity.ok(memberService.isDuplicatedNickname(validateNickNameRequest)); + } + + @PostMapping("/member") + public ResponseEntity signUp(@RequestBody AdditionalInfoRequest request, + @AuthenticationPrincipal CustomOauth2User loginMember) { + SignUpResponse response = memberService.signUp(request, loginMember.getEmail()); + + return ResponseEntity.ok(response); + } + +} diff --git a/src/main/java/com/dnd/gongmuin/member/domain/Member.java b/src/main/java/com/dnd/gongmuin/member/domain/Member.java index ac19f582..3ae5700d 100644 --- a/src/main/java/com/dnd/gongmuin/member/domain/Member.java +++ b/src/main/java/com/dnd/gongmuin/member/domain/Member.java @@ -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; @@ -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; + } + } diff --git a/src/main/java/com/dnd/gongmuin/member/dto/request/AdditionalInfoRequest.java b/src/main/java/com/dnd/gongmuin/member/dto/request/AdditionalInfoRequest.java new file mode 100644 index 00000000..21161865 --- /dev/null +++ b/src/main/java/com/dnd/gongmuin/member/dto/request/AdditionalInfoRequest.java @@ -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 +) { +} diff --git a/src/main/java/com/dnd/gongmuin/member/dto/request/ValidateNickNameRequest.java b/src/main/java/com/dnd/gongmuin/member/dto/request/ValidateNickNameRequest.java new file mode 100644 index 00000000..2ab27df8 --- /dev/null +++ b/src/main/java/com/dnd/gongmuin/member/dto/request/ValidateNickNameRequest.java @@ -0,0 +1,12 @@ +package com.dnd.gongmuin.member.dto.request; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record ValidateNickNameRequest( + @NotBlank(message = "닉네임은 필수 입력 항목입니다.") + @Size(min = 2, max = 12, message = "닉네임은 최소 2자리 이상 최대 12자 이하입니다.") + String nickname +) { + +} diff --git a/src/main/java/com/dnd/gongmuin/member/dto/response/SignUpResponse.java b/src/main/java/com/dnd/gongmuin/member/dto/response/SignUpResponse.java new file mode 100644 index 00000000..3761056c --- /dev/null +++ b/src/main/java/com/dnd/gongmuin/member/dto/response/SignUpResponse.java @@ -0,0 +1,6 @@ +package com.dnd.gongmuin.member.dto.response; + +public record SignUpResponse( + String nickName +) { +} diff --git a/src/main/java/com/dnd/gongmuin/member/dto/response/ValidateNickNameResponse.java b/src/main/java/com/dnd/gongmuin/member/dto/response/ValidateNickNameResponse.java new file mode 100644 index 00000000..25bb7ccf --- /dev/null +++ b/src/main/java/com/dnd/gongmuin/member/dto/response/ValidateNickNameResponse.java @@ -0,0 +1,6 @@ +package com.dnd.gongmuin.member.dto.response; + +public record ValidateNickNameResponse( + boolean isDuplicated +) { +} diff --git a/src/main/java/com/dnd/gongmuin/member/exception/MemberErrorCode.java b/src/main/java/com/dnd/gongmuin/member/exception/MemberErrorCode.java new file mode 100644 index 00000000..19ffd702 --- /dev/null +++ b/src/main/java/com/dnd/gongmuin/member/exception/MemberErrorCode.java @@ -0,0 +1,17 @@ +package com.dnd.gongmuin.member.exception; + +import com.dnd.gongmuin.common.exception.ErrorCode; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum MemberErrorCode implements ErrorCode { + + NOT_FOUND_MEMBER("특정 회원을 찾을 수 없습니다.", "MEMBER_001"), + NOT_FOUND_NEW_MEMBER("신규 회원이 아닙니다.", "MEMBER_002"); + + private final String message; + private final String code; +} diff --git a/src/main/java/com/dnd/gongmuin/member/service/MemberService.java b/src/main/java/com/dnd/gongmuin/member/service/MemberService.java new file mode 100644 index 00000000..c01c1a83 --- /dev/null +++ b/src/main/java/com/dnd/gongmuin/member/service/MemberService.java @@ -0,0 +1,94 @@ +package com.dnd.gongmuin.member.service; + +import java.util.Objects; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.dnd.gongmuin.auth.exception.AuthErrorCode; +import com.dnd.gongmuin.common.exception.runtime.NotFoundException; +import com.dnd.gongmuin.member.domain.JobCategory; +import com.dnd.gongmuin.member.domain.JobGroup; +import com.dnd.gongmuin.member.domain.Member; +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.exception.MemberErrorCode; +import com.dnd.gongmuin.member.repository.MemberRepository; +import com.dnd.gongmuin.security.oauth2.Oauth2Response; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class MemberService { + + private final MemberRepository memberRepository; + + public Member saveOrUpdate(Oauth2Response oauth2Response) { + Member member = memberRepository.findBySocialEmail(oauth2Response.createSocialEmail()) + .map(m -> { + m.updateSocialEmail(oauth2Response.createSocialEmail()); + return m; + }) + .orElse(createMemberFromOauth2Response(oauth2Response)); + + return memberRepository.save(member); + } + + public String parseProviderFromSocialEmail(Member member) { + String socialEmail = member.getSocialEmail().toUpperCase(); + if (socialEmail.contains("KAKAO")) { + return "KAKAO"; + } else if (socialEmail.contains("NAVER")) { + return "NAVER"; + } + throw new NotFoundException(AuthErrorCode.NOT_FOUND_PROVIDER); + } + + private Member createMemberFromOauth2Response(Oauth2Response oauth2Response) { + return Member.of(oauth2Response.getName(), oauth2Response.createSocialEmail(), 10000); + } + + public boolean isOfficialEmail(Member member) { + return Objects.isNull(member.getOfficialEmail()); + } + + @Transactional(readOnly = true) + public ValidateNickNameResponse isDuplicatedNickname(ValidateNickNameRequest request) { + boolean isDuplicate = memberRepository.existsByNickname(request.nickname()); + + return new ValidateNickNameResponse(isDuplicate); + } + + @Transactional + public SignUpResponse signUp(AdditionalInfoRequest request, String email) { + Member findMember = memberRepository.findBySocialEmail(email) + .orElseThrow(() -> new NotFoundException(MemberErrorCode.NOT_FOUND_MEMBER)); + + if (!isOfficialEmail(findMember)) { + new NotFoundException(MemberErrorCode.NOT_FOUND_NEW_MEMBER); + } + + Member signUpMember = updateAdditionalInfo(request, findMember); + + return new SignUpResponse(signUpMember.getNickname()); + } + + public Member getMemberBySocialEmail(String socialEmail) { + return memberRepository.findBySocialEmail(socialEmail) + .orElseThrow(() -> new NotFoundException(MemberErrorCode.NOT_FOUND_MEMBER)); + } + + private Member updateAdditionalInfo(AdditionalInfoRequest request, Member findMember) { + findMember.updateAdditionalInfo( + request.nickname(), + request.officialEmail(), + JobGroup.of(request.jobGroup()), + JobCategory.of(request.jobCategory()) + ); + + return memberRepository.save(findMember); + } +} \ No newline at end of file diff --git a/src/main/java/com/dnd/gongmuin/security/config/SecurityConfig.java b/src/main/java/com/dnd/gongmuin/security/config/SecurityConfig.java new file mode 100644 index 00000000..029e45ae --- /dev/null +++ b/src/main/java/com/dnd/gongmuin/security/config/SecurityConfig.java @@ -0,0 +1,72 @@ +package com.dnd.gongmuin.security.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.WebSecurityCustomizer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; + +import com.dnd.gongmuin.security.handler.CustomAuthenticationEntryPoint; +import com.dnd.gongmuin.security.handler.CustomOauth2FailureHandler; +import com.dnd.gongmuin.security.handler.CustomOauth2SuccessHandler; +import com.dnd.gongmuin.security.jwt.util.TokenAuthenticationFilter; +import com.dnd.gongmuin.security.jwt.util.TokenExceptionFilter; +import com.dnd.gongmuin.security.service.CustomOauth2UserService; + +import lombok.RequiredArgsConstructor; + +@Configuration +@RequiredArgsConstructor +public class SecurityConfig { + + private final CustomOauth2UserService customOauth2UserService; + private final CustomOauth2SuccessHandler customOauth2SuccessHandler; + private final CustomOauth2FailureHandler customOauth2FailureHandler; + private final TokenAuthenticationFilter tokenAuthenticationFilter; + + @Bean + public WebSecurityCustomizer webSecurityCustomizer() { + return web -> web.ignoring() + .requestMatchers("/error", "/favicon.ico"); + } + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + http + .cors((auth) -> auth.disable()) + .csrf((auth) -> auth.disable()) + .formLogin((auth) -> auth.disable()) + .httpBasic((auth) -> auth.disable()) + .sessionManagement( + (session) -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS) + ); + + http + .authorizeHttpRequests( + (auth) -> auth + .requestMatchers("/").permitAll() + .requestMatchers("/api/auth/signin/kakao").permitAll() + .requestMatchers("/api/auth/member").permitAll() + .requestMatchers("/api/auth/check-email").permitAll() + .requestMatchers("/additional-info").permitAll() + .anyRequest().authenticated() + ) + .oauth2Login((oauth2) -> oauth2 + .userInfoEndpoint( + (userInfoEndpointConfig -> userInfoEndpointConfig.userService(customOauth2UserService)) + ) + .successHandler(customOauth2SuccessHandler) + .failureHandler(customOauth2FailureHandler) + ) + + .addFilterBefore(tokenAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) + .addFilterBefore(new TokenExceptionFilter(), tokenAuthenticationFilter.getClass()) + + .exceptionHandling((exception) -> exception + .authenticationEntryPoint(new CustomAuthenticationEntryPoint())); + + return http.build(); + } +} diff --git a/src/main/java/com/dnd/gongmuin/security/handler/CustomAuthenticationEntryPoint.java b/src/main/java/com/dnd/gongmuin/security/handler/CustomAuthenticationEntryPoint.java new file mode 100644 index 00000000..a0e75ea5 --- /dev/null +++ b/src/main/java/com/dnd/gongmuin/security/handler/CustomAuthenticationEntryPoint.java @@ -0,0 +1,21 @@ +package com.dnd.gongmuin.security.handler; + +import java.io.IOException; + +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; + +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint { + @Override + public void commence(HttpServletRequest request, HttpServletResponse response, + AuthenticationException authException) throws IOException, ServletException { + log.error("비인가 사용자 요청으로 인가예외 발생", authException); + response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "인증에 실패하였습니다."); + } +} diff --git a/src/main/java/com/dnd/gongmuin/security/handler/CustomOauth2FailureHandler.java b/src/main/java/com/dnd/gongmuin/security/handler/CustomOauth2FailureHandler.java new file mode 100644 index 00000000..08b3d31c --- /dev/null +++ b/src/main/java/com/dnd/gongmuin/security/handler/CustomOauth2FailureHandler.java @@ -0,0 +1,24 @@ +package com.dnd.gongmuin.security.handler; + +import java.io.IOException; + +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.authentication.AuthenticationFailureHandler; +import org.springframework.stereotype.Component; + +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.extern.slf4j.Slf4j; + +@Component +@Slf4j +public class CustomOauth2FailureHandler implements AuthenticationFailureHandler { + + @Override + public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, + AuthenticationException exception) throws IOException, ServletException { + log.error("소셜 로그인 실패", exception); + response.sendError(HttpServletResponse.SC_BAD_REQUEST, "소셜 로그인에 실패하였습니다."); + } +} diff --git a/src/main/java/com/dnd/gongmuin/security/handler/CustomOauth2SuccessHandler.java b/src/main/java/com/dnd/gongmuin/security/handler/CustomOauth2SuccessHandler.java new file mode 100644 index 00000000..dbafd2e9 --- /dev/null +++ b/src/main/java/com/dnd/gongmuin/security/handler/CustomOauth2SuccessHandler.java @@ -0,0 +1,55 @@ +package com.dnd.gongmuin.security.handler; + +import java.io.IOException; +import java.util.Date; + +import org.springframework.security.core.Authentication; +import org.springframework.security.web.authentication.AuthenticationSuccessHandler; +import org.springframework.stereotype.Component; + +import com.dnd.gongmuin.auth.service.AuthService; +import com.dnd.gongmuin.common.exception.runtime.NotFoundException; +import com.dnd.gongmuin.member.domain.Member; +import com.dnd.gongmuin.member.exception.MemberErrorCode; +import com.dnd.gongmuin.member.repository.MemberRepository; +import com.dnd.gongmuin.security.jwt.util.TokenProvider; +import com.dnd.gongmuin.security.oauth2.CustomOauth2User; + +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class CustomOauth2SuccessHandler implements AuthenticationSuccessHandler { + + private final AuthService authService; + private final MemberRepository memberRepository; + private final TokenProvider tokenProvider; + + @Override + public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, + Authentication authentication) throws IOException, ServletException { + + CustomOauth2User customOauth2User = (CustomOauth2User)authentication.getPrincipal(); + + String socialEmail = customOauth2User.getEmail(); + Member findmember = memberRepository.findBySocialEmail(socialEmail) + .orElseThrow(() -> new NotFoundException(MemberErrorCode.NOT_FOUND_MEMBER)); + + String token = tokenProvider.generateAccessToken(customOauth2User, new Date()); + response.setHeader("Authorization", token); + + if (!isAuthStatusOld(findmember)) { + response.sendRedirect("/additional-info"); + } else { + response.sendRedirect("/"); + } + } + + private boolean isAuthStatusOld(Member member) { + return authService.isAuthStatusOld(member); + } + +} diff --git a/src/main/java/com/dnd/gongmuin/security/jwt/exception/JwtErrorCode.java b/src/main/java/com/dnd/gongmuin/security/jwt/exception/JwtErrorCode.java new file mode 100644 index 00000000..2fac6af4 --- /dev/null +++ b/src/main/java/com/dnd/gongmuin/security/jwt/exception/JwtErrorCode.java @@ -0,0 +1,18 @@ +package com.dnd.gongmuin.security.jwt.exception; + +import com.dnd.gongmuin.common.exception.ErrorCode; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum JwtErrorCode implements ErrorCode { + + MALFORMED_TOKEN("알맞지 않은 형식의 토큰입니다..", "JWT_001"), + INVALID_TOKEN("유효하지 않은 토큰입니다.", "JWT_002"); + + private final String message; + private final String code; + +} \ No newline at end of file diff --git a/src/main/java/com/dnd/gongmuin/security/jwt/util/TokenAuthenticationFilter.java b/src/main/java/com/dnd/gongmuin/security/jwt/util/TokenAuthenticationFilter.java new file mode 100644 index 00000000..edc6dc66 --- /dev/null +++ b/src/main/java/com/dnd/gongmuin/security/jwt/util/TokenAuthenticationFilter.java @@ -0,0 +1,54 @@ +package com.dnd.gongmuin.security.jwt.util; + +import static org.springframework.http.HttpHeaders.*; + +import java.io.IOException; +import java.util.Date; + +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.util.ObjectUtils; +import org.springframework.web.filter.OncePerRequestFilter; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +@Component +public class TokenAuthenticationFilter extends OncePerRequestFilter { + + private final TokenProvider tokenProvider; + private static final String TOKEN_PREFIX = "Bearer "; + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + + String accessToken = resolveToken(request); + + if (tokenProvider.validateToken(accessToken, new Date())) { + saveAuthentication(accessToken); + } else { + // TODO : 만료시 accessToken 재발급 + } + + filterChain.doFilter(request, response); + } + + private void saveAuthentication(String accessToken) { + Authentication authentication = tokenProvider.getAuthentication(accessToken); + SecurityContextHolder.getContext().setAuthentication(authentication); + } + + private String resolveToken(HttpServletRequest request) { + String token = request.getHeader(AUTHORIZATION); + if (ObjectUtils.isEmpty(token) || !token.startsWith(TOKEN_PREFIX)) { + return null; + } + return token.substring(TOKEN_PREFIX.length()); + } +} diff --git a/src/main/java/com/dnd/gongmuin/security/jwt/util/TokenExceptionFilter.java b/src/main/java/com/dnd/gongmuin/security/jwt/util/TokenExceptionFilter.java new file mode 100644 index 00000000..d2a40016 --- /dev/null +++ b/src/main/java/com/dnd/gongmuin/security/jwt/util/TokenExceptionFilter.java @@ -0,0 +1,27 @@ +package com.dnd.gongmuin.security.jwt.util; + +import static org.springframework.http.HttpStatus.*; + +import java.io.IOException; + +import org.springframework.web.filter.OncePerRequestFilter; + +import com.dnd.gongmuin.common.exception.runtime.CustomJwtException; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +public class TokenExceptionFilter extends OncePerRequestFilter { + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + try { + filterChain.doFilter(request, response); + } catch (CustomJwtException e) { + response.sendError(NOT_FOUND.value(), e.getMessage()); + } + } +} diff --git a/src/main/java/com/dnd/gongmuin/security/jwt/util/TokenProvider.java b/src/main/java/com/dnd/gongmuin/security/jwt/util/TokenProvider.java new file mode 100644 index 00000000..71366c7a --- /dev/null +++ b/src/main/java/com/dnd/gongmuin/security/jwt/util/TokenProvider.java @@ -0,0 +1,125 @@ +package com.dnd.gongmuin.security.jwt.util; + +import java.util.Collections; +import java.util.Date; +import java.util.List; +import java.util.stream.Collectors; + +import javax.crypto.SecretKey; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.oauth2.jwt.JwtException; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; + +import com.dnd.gongmuin.common.exception.runtime.CustomJwtException; +import com.dnd.gongmuin.member.domain.Member; +import com.dnd.gongmuin.member.service.MemberService; +import com.dnd.gongmuin.security.jwt.exception.JwtErrorCode; +import com.dnd.gongmuin.security.oauth2.CustomOauth2User; +import com.dnd.gongmuin.security.service.TokenService; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.MalformedJwtException; +import io.jsonwebtoken.security.Keys; +import jakarta.annotation.PostConstruct; +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class TokenProvider { + + @Value("${spring.jwt.key}") + private String key; + private SecretKey secretKey; + private static final String ROLE_KEY = "ROLE"; + private static final long ACCESS_TOKEN_EXPIRE_TIME = 1000 * 60 * 30L; + private static final long REFRESH_TOKEN_EXPIRE_TIME = 1000 * 60 * 60 * 24L; + private final TokenService tokenService; + private final MemberService memberService; + + @PostConstruct + private void initSecretKey() { + this.secretKey = Keys.hmacShaKeyFor(key.getBytes()); + } + + public String generateAccessToken(CustomOauth2User authentication, Date now) { + return generateToken(authentication, ACCESS_TOKEN_EXPIRE_TIME, now); + } + + public String generateRefreshToken(CustomOauth2User authentication, Date now) { + String refreshToken = generateToken(authentication, REFRESH_TOKEN_EXPIRE_TIME, now); + // TODO : RedisRepo refreshToken SAVE + return refreshToken; + } + + private String generateToken(CustomOauth2User authentication, long tokenExpireTime, Date now) { + Date expiredTime = createExpiredDateWithTokenType(now, tokenExpireTime); + String authorities = getAuthorities(authentication); + + return Jwts.builder() + .subject(authentication.getEmail()) + .claim(ROLE_KEY, authorities) + .issuedAt(now) + .expiration(expiredTime) + .signWith(secretKey, Jwts.SIG.HS512) + .compact(); + } + + private String getAuthorities(CustomOauth2User authentication) { + return authentication.getAuthorities().stream() + .map(GrantedAuthority::getAuthority) + .collect(Collectors.joining()); + } + + private Date createExpiredDateWithTokenType(Date date, long tokenExpireTime) { + return new Date(date.getTime() + tokenExpireTime); + } + + public Authentication getAuthentication(String token) { + Claims claims = parseToken(token); + List authorities = getAuthorities(claims); + + String socialEmail = claims.getSubject(); + Member principal = memberService.getMemberBySocialEmail(socialEmail); + + return new UsernamePasswordAuthenticationToken(principal, token, authorities); + } + + // TODO : AccessToken 재발급 구현 + + public boolean validateToken(String token, Date date) { + if (!StringUtils.hasText(token)) { + return false; + } + + Claims claims = parseToken(token); + return claims.getExpiration().after(date); + } + + private Claims parseToken(String token) { + try { + return Jwts.parser().verifyWith(secretKey).build() + .parseSignedClaims(token).getPayload(); + } catch (ExpiredJwtException e) { + return e.getClaims(); + } catch (MalformedJwtException e) { + throw new CustomJwtException(JwtErrorCode.MALFORMED_TOKEN); + } catch (JwtException e) { + throw new CustomJwtException(JwtErrorCode.INVALID_TOKEN); + } + } + + private List getAuthorities(Claims claims) { + return Collections.singletonList(new SimpleGrantedAuthority( + claims.get(ROLE_KEY).toString() + )); + } + +} diff --git a/src/main/java/com/dnd/gongmuin/security/oauth2/AuthInfo.java b/src/main/java/com/dnd/gongmuin/security/oauth2/AuthInfo.java new file mode 100644 index 00000000..3118303e --- /dev/null +++ b/src/main/java/com/dnd/gongmuin/security/oauth2/AuthInfo.java @@ -0,0 +1,33 @@ +package com.dnd.gongmuin.security.oauth2; + +import lombok.Builder; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public class AuthInfo { + + private String socialName; + private String socialEmail; + + @Builder + private AuthInfo(String socialName, String socialEmail) { + this.socialName = socialName; + this.socialEmail = socialEmail; + } + + public static AuthInfo fromSocialEmail(String socialEmail) { + return AuthInfo.builder() + .socialEmail(socialEmail) + .build(); + } + + public static AuthInfo of(String socialName, String socialEmail) { + return AuthInfo.builder() + .socialName(socialName) + .socialEmail(socialEmail) + .build(); + } + +} diff --git a/src/main/java/com/dnd/gongmuin/security/oauth2/CustomOauth2User.java b/src/main/java/com/dnd/gongmuin/security/oauth2/CustomOauth2User.java new file mode 100644 index 00000000..ecb65ffb --- /dev/null +++ b/src/main/java/com/dnd/gongmuin/security/oauth2/CustomOauth2User.java @@ -0,0 +1,39 @@ +package com.dnd.gongmuin.security.oauth2; + +import java.util.Collection; +import java.util.Collections; +import java.util.Map; + +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.oauth2.core.user.OAuth2User; + +public class CustomOauth2User implements OAuth2User { + + private final AuthInfo authInfo; + private Map attributes; + + public CustomOauth2User(AuthInfo authInfo) { + this.authInfo = authInfo; + } + + @Override + public Map getAttributes() { + return attributes; + } + + @Override + public Collection getAuthorities() { + String role = "ROLE_USER"; + return Collections.singletonList(new SimpleGrantedAuthority(role)); + } + + @Override + public String getName() { + return authInfo.getSocialName(); + } + + public String getEmail() { + return authInfo.getSocialEmail(); + } +} diff --git a/src/main/java/com/dnd/gongmuin/security/oauth2/KakaoResponse.java b/src/main/java/com/dnd/gongmuin/security/oauth2/KakaoResponse.java new file mode 100644 index 00000000..2821f627 --- /dev/null +++ b/src/main/java/com/dnd/gongmuin/security/oauth2/KakaoResponse.java @@ -0,0 +1,44 @@ +package com.dnd.gongmuin.security.oauth2; + +import java.util.Map; + +public class KakaoResponse implements Oauth2Response { + + private final Map attribute; + private final Long id; + + public KakaoResponse(Map attribute) { + this.attribute = (Map)attribute.get("kakao_account"); + this.id = (Long)attribute.get("id"); + } + + @Override + public String getProvider() { + return "kakao"; + } + + @Override + public String getProviderId() { + return this.id.toString(); + } + + @Override + public String getEmail() { + return attribute.get("email").toString(); + } + + @Override + public String getName() { + return ((Map)attribute.get("profile")).get("nickname").toString(); + } + + @Override + public String createSocialEmail() { + return String.format("%s%s/%s", + this.getProvider(), + this.getProviderId(), + this.getEmail() + ); + } + +} diff --git a/src/main/java/com/dnd/gongmuin/security/oauth2/Oauth2Response.java b/src/main/java/com/dnd/gongmuin/security/oauth2/Oauth2Response.java new file mode 100644 index 00000000..745828d5 --- /dev/null +++ b/src/main/java/com/dnd/gongmuin/security/oauth2/Oauth2Response.java @@ -0,0 +1,14 @@ +package com.dnd.gongmuin.security.oauth2; + +public interface Oauth2Response { + String getProvider(); + + String getProviderId(); + + String getEmail(); + + String getName(); + + String createSocialEmail(); + +} diff --git a/src/main/java/com/dnd/gongmuin/security/service/CustomOauth2UserService.java b/src/main/java/com/dnd/gongmuin/security/service/CustomOauth2UserService.java new file mode 100644 index 00000000..2b2104c6 --- /dev/null +++ b/src/main/java/com/dnd/gongmuin/security/service/CustomOauth2UserService.java @@ -0,0 +1,56 @@ +package com.dnd.gongmuin.security.service; + +import static com.dnd.gongmuin.auth.exception.AuthErrorCode.*; + +import java.util.Objects; + +import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService; +import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.OAuth2Error; +import org.springframework.security.oauth2.core.user.OAuth2User; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.dnd.gongmuin.auth.service.AuthService; +import com.dnd.gongmuin.member.domain.Member; +import com.dnd.gongmuin.member.service.MemberService; +import com.dnd.gongmuin.security.oauth2.AuthInfo; +import com.dnd.gongmuin.security.oauth2.CustomOauth2User; +import com.dnd.gongmuin.security.oauth2.KakaoResponse; +import com.dnd.gongmuin.security.oauth2.Oauth2Response; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class CustomOauth2UserService extends DefaultOAuth2UserService { + + private final MemberService memberService; + private final AuthService authService; + + @Transactional + @Override + public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException { + OAuth2User oAuth2User = super.loadUser(userRequest); + + String registrationId = userRequest.getClientRegistration().getRegistrationId(); + Oauth2Response oauth2Response = null; + + if (Objects.equals(registrationId, "kakao")) { + oauth2Response = new KakaoResponse(oAuth2User.getAttributes()); + } else { + throw new OAuth2AuthenticationException( + new OAuth2Error(UNSUPPORTED_SOCIAL_LOGIN.getCode()), + UNSUPPORTED_SOCIAL_LOGIN.getMessage() + ); + } + + Member savedMember = memberService.saveOrUpdate(oauth2Response); + authService.saveOrUpdate(savedMember); + + AuthInfo authInfo = AuthInfo.of(savedMember.getSocialName(), savedMember.getSocialEmail()); + return new CustomOauth2User(authInfo); + } +} + diff --git a/src/main/java/com/dnd/gongmuin/security/service/TokenService.java b/src/main/java/com/dnd/gongmuin/security/service/TokenService.java new file mode 100644 index 00000000..4f73227d --- /dev/null +++ b/src/main/java/com/dnd/gongmuin/security/service/TokenService.java @@ -0,0 +1,12 @@ +package com.dnd.gongmuin.security.service; + +import org.springframework.stereotype.Service; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class TokenService { + + // TODO : RefreshToken CRUD 구현 +} diff --git a/src/test/java/com/dnd/gongmuin/auth/service/AuthServiceTest.java b/src/test/java/com/dnd/gongmuin/auth/service/AuthServiceTest.java new file mode 100644 index 00000000..fbe30116 --- /dev/null +++ b/src/test/java/com/dnd/gongmuin/auth/service/AuthServiceTest.java @@ -0,0 +1,91 @@ +package com.dnd.gongmuin.auth.service; + +import static org.assertj.core.api.Assertions.*; + +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +import com.dnd.gongmuin.auth.domain.Auth; +import com.dnd.gongmuin.auth.domain.AuthStatus; +import com.dnd.gongmuin.auth.repository.AuthRepository; +import com.dnd.gongmuin.common.support.ApiTestSupport; +import com.dnd.gongmuin.member.domain.JobCategory; +import com.dnd.gongmuin.member.domain.JobGroup; +import com.dnd.gongmuin.member.domain.Member; +import com.dnd.gongmuin.member.repository.MemberRepository; + +@Disabled +class AuthServiceTest extends ApiTestSupport { + + @Autowired + AuthService authService; + + @Autowired + MemberRepository memberRepository; + + @Autowired + AuthRepository authRepository; + + @DisplayName("신규 회원의 상태는 Old가 아니다.") + @Test + void isAuthStatusOld() { + // given + Member member = createMember(); + Member savedMember = memberRepository.save(member); + authService.saveOrUpdate(savedMember); + + // when + boolean result = authService.isAuthStatusOld(savedMember); + + // then + assertThat(result).isFalse(); + } + + @DisplayName("신규 회원의 공무원 이메일 값이 있다면 Auth 상태는 OLD로 업데이트된다.") + @Test + void updateStatusWithOfficialEmail() { + // given + Member member = createMember(); + Member savedMember = memberRepository.save(member); + authService.saveOrUpdate(savedMember); + authService.saveOrUpdate(savedMember); + + // when + Auth findAuth = authRepository.findByMember(savedMember).get(); + + // then + assertThat(findAuth.getStatus()).isEqualTo(AuthStatus.OLD); + } + + @DisplayName("신규 회원의 공무원 이메일 값이 없다면 Auth 상태는 NEW로 유지된다.") + @Test + void maintainStatusWithOfficialEmail() { + // given + Member member = Member.of("김신규", "KAKAO123/newMember@member.com", 1000); + Member savedMember = memberRepository.save(member); + authService.saveOrUpdate(savedMember); + authService.saveOrUpdate(savedMember); + + // when + Auth findAuth = authRepository.findByMember(savedMember).get(); + + // then + assertThat(findAuth.getStatus()).isEqualTo(AuthStatus.NEW); + } + + private Member createMember() { + return Member.builder() + .nickname("김철수") + .socialName("철수") + .socialEmail("KAKAO123/abc@naver.com") + .officialEmail("abc123@korea.com") + .jobCategory(JobCategory.GAS) + .jobGroup(JobGroup.ENGINEERING) + .credit(10000) + .build(); + + } + +} \ No newline at end of file diff --git a/src/test/java/com/dnd/gongmuin/common/fixture/MemberFixture.java b/src/test/java/com/dnd/gongmuin/common/fixture/MemberFixture.java index a674d4af..90f2ab95 100644 --- a/src/test/java/com/dnd/gongmuin/common/fixture/MemberFixture.java +++ b/src/test/java/com/dnd/gongmuin/common/fixture/MemberFixture.java @@ -1,24 +1,24 @@ package com.dnd.gongmuin.common.fixture; -import static lombok.AccessLevel.*; - import com.dnd.gongmuin.member.domain.JobCategory; import com.dnd.gongmuin.member.domain.JobGroup; import com.dnd.gongmuin.member.domain.Member; +import lombok.AccessLevel; import lombok.NoArgsConstructor; -@NoArgsConstructor(access = PRIVATE) +@NoArgsConstructor(access = AccessLevel.PRIVATE) public class MemberFixture { - public static Member member() { - return Member.builder() - .nickname("test") - .socialName("test") - .officialEmail("test@official.com") - .socialEmail("test@naver.com") - .jobCategory(JobCategory.of("가스")) - .jobGroup(JobGroup.of("공업")) - .build(); + public static Member getMemberFixture() { + return Member.of( + "김회원", + "회원123", + JobGroup.ENGINEERING, + JobCategory.GAS, + "KAKAO123/gongmuin@daum.net", + "gongmuin@korea.kr", + 10000 + ); } } diff --git a/src/test/java/com/dnd/gongmuin/common/support/ApiTestSupport.java b/src/test/java/com/dnd/gongmuin/common/support/ApiTestSupport.java index 108cb5e7..b144a528 100644 --- a/src/test/java/com/dnd/gongmuin/common/support/ApiTestSupport.java +++ b/src/test/java/com/dnd/gongmuin/common/support/ApiTestSupport.java @@ -1,18 +1,34 @@ package com.dnd.gongmuin.common.support; +import java.util.Date; + import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.test.web.servlet.MockMvc; +import com.dnd.gongmuin.common.fixture.MemberFixture; +import com.dnd.gongmuin.member.domain.Member; +import com.dnd.gongmuin.member.repository.MemberRepository; +import com.dnd.gongmuin.security.jwt.util.TokenProvider; +import com.dnd.gongmuin.security.oauth2.AuthInfo; +import com.dnd.gongmuin.security.oauth2.CustomOauth2User; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.annotation.PostConstruct; + // 컨트롤러 단 통합테스트용 @SpringBootTest @AutoConfigureMockMvc public abstract class ApiTestSupport extends TestContainerSupport { + protected Member loginMember; + protected String accessToken; + @Autowired + private TokenProvider tokenProvider; + @Autowired + private MemberRepository memberRepository; @Autowired protected MockMvc mockMvc; @@ -23,4 +39,17 @@ protected String toJson(Object object) throws JsonProcessingException { return objectMapper.writeValueAsString(object); } + // API 테스트할 때마다 Member를 저장하고 토큰정보를 가져오지 않기 위해서 하나의 유저와 토큰정보 구성 + @PostConstruct + public void setUpMember() { + if (loginMember != null) { + return; + } + Member savedMember = memberRepository.save(MemberFixture.getMemberFixture()); + AuthInfo authInfo = AuthInfo.of(savedMember.getSocialName(), savedMember.getSocialEmail()); + String token = tokenProvider.generateAccessToken(new CustomOauth2User(authInfo), new Date()); + + this.loginMember = savedMember; + this.accessToken = "Bearer " + token; + } } diff --git a/src/test/java/com/dnd/gongmuin/member/domain/MemberTest.java b/src/test/java/com/dnd/gongmuin/member/domain/MemberTest.java new file mode 100644 index 00000000..07b52b3d --- /dev/null +++ b/src/test/java/com/dnd/gongmuin/member/domain/MemberTest.java @@ -0,0 +1,60 @@ +package com.dnd.gongmuin.member.domain; + +import static com.dnd.gongmuin.member.domain.JobCategory.*; +import static com.dnd.gongmuin.member.domain.JobGroup.*; +import static org.assertj.core.api.Assertions.*; + +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +@Disabled +class MemberTest { + + @DisplayName("소셜 이메일을 변경할 수 있다.") + @Test + void updateSocialEmail() { + // given + Member 공무인1 = createMember("공무인1", "kakao1234/영태", "gongmuin@nate.com", "gongmuin@korea.kr"); + + // when + 공무인1.updateSocialEmail("gongmuin2@daum.net"); + + // then + assertThat(공무인1.getSocialEmail()).isEqualTo("gongmuin2@daum.net"); + + } + + @DisplayName("추가 정보를 업데이트 할 수 있다.") + @Test + void updateAdditionalInfo() { + // given + Member member = createMember("김신규", "kakao1234/영태", "gongmuin@nate.com", "gongmuin@korea.kr"); + + // when + member.updateAdditionalInfo("김회원", "abcd@korea.kr", ENGINEERING, GAS); + + // then + assertThat(member).extracting("nickname", "officialEmail") + .containsExactlyInAnyOrder( + "김회원", + "abcd@korea.kr" + ); + + } + + private Member createMember(String nickname, String socialName, String socialEmail, String officialEmail) { + return Member.builder() + .nickname(nickname) + .socialName(socialName) + .socialEmail(socialEmail) + .officialEmail(officialEmail) + .jobCategory(GAS) + .jobGroup(ENGINEERING) + .credit(10000) + .build(); + + } +} \ No newline at end of file diff --git a/src/test/java/com/dnd/gongmuin/member/repository/MemberRepositoryTest.java b/src/test/java/com/dnd/gongmuin/member/repository/MemberRepositoryTest.java new file mode 100644 index 00000000..3b377ccd --- /dev/null +++ b/src/test/java/com/dnd/gongmuin/member/repository/MemberRepositoryTest.java @@ -0,0 +1,48 @@ +package com.dnd.gongmuin.member.repository; + +import static com.dnd.gongmuin.member.domain.JobCategory.*; +import static com.dnd.gongmuin.member.domain.JobGroup.*; +import static org.assertj.core.api.Assertions.*; + +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +import com.dnd.gongmuin.common.support.DataJpaTestSupport; +import com.dnd.gongmuin.member.domain.Member; + +@Disabled +class MemberRepositoryTest extends DataJpaTestSupport { + + @Autowired + MemberRepository memberRepository; + + @DisplayName("소셜이메일로 특정 회원을 조회한다.") + @Test + void test() { + // given + Member 공무인1 = createMember("공무인1", "영태", "kakao1234/gongmuin@nate.com", "gongumin@korea.kr"); + Member savedMember = memberRepository.save(공무인1); + + // when + Member findMember = memberRepository.findBySocialEmail("kakao1234/gongmuin@nate.com").get(); + + // then + assertThat(findMember.getNickname()).isEqualTo("공무인1"); + } + + private Member createMember(String nickname, String socialName, String socialEmail, String officialEmail) { + return Member.builder() + .nickname(nickname) + .socialName(socialName) + .socialEmail(socialEmail) + .officialEmail(officialEmail) + .jobCategory(GAS) + .jobGroup(ENGINEERING) + .credit(10000) + .build(); + + } + +} \ No newline at end of file diff --git a/src/test/java/com/dnd/gongmuin/member/service/MemberServiceTest.java b/src/test/java/com/dnd/gongmuin/member/service/MemberServiceTest.java new file mode 100644 index 00000000..43a6354b --- /dev/null +++ b/src/test/java/com/dnd/gongmuin/member/service/MemberServiceTest.java @@ -0,0 +1,111 @@ +package com.dnd.gongmuin.member.service; + +import static com.dnd.gongmuin.member.domain.JobCategory.*; +import static com.dnd.gongmuin.member.domain.JobGroup.*; +import static org.assertj.core.api.Assertions.*; + +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.transaction.annotation.Transactional; + +import com.dnd.gongmuin.common.support.ApiTestSupport; +import com.dnd.gongmuin.member.domain.Member; +import com.dnd.gongmuin.member.dto.request.AdditionalInfoRequest; +import com.dnd.gongmuin.member.dto.request.ValidateNickNameRequest; +import com.dnd.gongmuin.member.dto.response.ValidateNickNameResponse; +import com.dnd.gongmuin.member.repository.MemberRepository; + +@Transactional +@Disabled +class MemberServiceTest extends ApiTestSupport { + + @Autowired + MemberService memberService; + + @Autowired + MemberRepository memberRepository; + + @DisplayName("조합된 소셜 이메일 부분 중 공급자 부분을 얻을 수 있다.") + @Test + void parseProviderFromSocialEmail() { + // given + Member kakaoMember = createMember("김철수", "철수", "kakao123/kakao123@daum.net", "abc123@korea.com"); + Member naverMember = createMember("김철수", "철수", "naver123/naver123@naver.com", "abc321@korea.com"); + + // when + String kakaoProvider = memberService.parseProviderFromSocialEmail(kakaoMember); + String naverProvider = memberService.parseProviderFromSocialEmail(naverMember); + + // then + assertThat(kakaoProvider).isEqualToIgnoringCase("kakao"); + assertThat(naverProvider).isEqualToIgnoringCase("naver"); + } + + @DisplayName("공무원 이메일이 존재하는지 체크한다.") + @Test + void isOfficialEmail() { + // given + Member kakaoMember = createMember("김철수", "철수", "kakao123/kakao123@daum.net", "abc123@korea.com"); + + // when + boolean result = memberService.isOfficialEmail(kakaoMember); + + // then + assertThat(result).isFalse(); + + } + + @DisplayName("중복 닉네임이 존재하는지 체크한다.") + @Test + void isDuplicatedNickname() { + // given + Member member1 = createMember("김철수", "철수", "kakao123/kakao123@daum.net", "abc123@korea.com"); + memberRepository.save(member1); + + ValidateNickNameRequest request = new ValidateNickNameRequest("김철수"); + + // when + ValidateNickNameResponse duplicatedNickname = memberService.isDuplicatedNickname(request); + + // then + assertThat(duplicatedNickname.isDuplicated()).isTrue(); + } + + @DisplayName("신규 회원은 추가 정보가 업데이트 된다.") + @Test + void signUp() { + // given + Member member1 = createMember(null, "철수", "kakao123/kakao123@daum.net", null); + memberRepository.save(member1); + + AdditionalInfoRequest request = new AdditionalInfoRequest("abc123@korea.com", "김신규", "공업", "가스"); + + // when + memberService.signUp(request, "kakao123/kakao123@daum.net"); + + // then + assertThat(member1).extracting("officialEmail", "nickname", "jobGroup", "jobCategory") + .containsExactlyInAnyOrder( + "abc123@korea.com", + "김신규", + GAS, + ENGINEERING + ); + + } + + private Member createMember(String nickname, String socialName, String socialEmail, String officialEmail) { + return Member.builder() + .nickname(nickname) + .socialName(socialName) + .socialEmail(socialEmail) + .officialEmail(officialEmail) + .jobCategory(GAS) + .jobGroup(ENGINEERING) + .credit(10000) + .build(); + + } +} \ No newline at end of file diff --git a/src/test/java/com/dnd/gongmuin/security/jwt/TokenProviderTest.java b/src/test/java/com/dnd/gongmuin/security/jwt/TokenProviderTest.java new file mode 100644 index 00000000..9b5e2613 --- /dev/null +++ b/src/test/java/com/dnd/gongmuin/security/jwt/TokenProviderTest.java @@ -0,0 +1,140 @@ +package com.dnd.gongmuin.security.jwt; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; +import static org.mockito.MockitoAnnotations.*; + +import java.util.Date; + +import javax.crypto.SecretKey; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.core.Authentication; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.transaction.annotation.Transactional; + +import com.dnd.gongmuin.security.jwt.util.TokenProvider; +import com.dnd.gongmuin.security.oauth2.AuthInfo; +import com.dnd.gongmuin.security.oauth2.CustomOauth2User; +import com.dnd.gongmuin.security.service.TokenService; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Keys; + +@Transactional +@ExtendWith(MockitoExtension.class) +@Disabled +class TokenProviderTest { + + @Value("${spring.jwt.key}") + private String key; + + @InjectMocks + private TokenProvider tokenProvider; + + @Mock + private TokenService tokenService; + + private SecretKey secretKey; + + @Mock + private AuthInfo authInfo; + + @BeforeEach + void setUp() { + openMocks(this); + + secretKey = Keys.hmacShaKeyFor(key.getBytes()); + + ReflectionTestUtils.setField(tokenProvider, "secretKey", secretKey); + } + + @DisplayName("만료일이 30분인 토큰이 생성된다.") + @Test + void generateAccessToken() { + // given + Date now = new Date(); + long expectedExpirationTime = now.getTime() + 30 * 60 * 1000; + + when(authInfo.getSocialEmail()).thenReturn("kakao123/kimMember@daum.net"); + when(authInfo.getSocialName()).thenReturn("김회원"); + CustomOauth2User authentication = new CustomOauth2User(authInfo); + + // when + String accessToken = tokenProvider.generateAccessToken(authentication, now); + Claims claims = Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(accessToken).getPayload(); + + Date expiration = claims.getExpiration(); + + // then + assertThat(expiration.getTime()).isCloseTo(expectedExpirationTime, within(1000L)); + } + + @DisplayName("만료일이 1일인 토큰이 생성된다.") + @Test + void generateRefreshToken() { + // given + Date now = new Date(); + long expectedExpirationTime = now.getTime() + 1000 * 60 * 60 * 24; + + when(authInfo.getSocialEmail()).thenReturn("kakao123/kimMember@daum.net"); + when(authInfo.getSocialName()).thenReturn("김회원"); + CustomOauth2User authentication = new CustomOauth2User(authInfo); + + // when + String accessToken = tokenProvider.generateRefreshToken(authentication, now); + Claims claims = Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(accessToken).getPayload(); + + Date expiration = claims.getExpiration(); + + // then + assertThat(expiration.getTime()).isCloseTo(expectedExpirationTime, within(1000L)); + } + + @DisplayName("토큰 파싱을 통해 만들어진 인증 객체의 이메일은 토큰 정보의 이메일 값과 동일하다.") + @Test + void getAuthentication() { + // given + Date now = new Date(); + + when(authInfo.getSocialEmail()).thenReturn("kakao123/kimMember@daum.net"); + when(authInfo.getSocialName()).thenReturn("김회원"); + CustomOauth2User customOauth2User = new CustomOauth2User(authInfo); + String accessToken = tokenProvider.generateRefreshToken(customOauth2User, now); + + // when + Authentication authentication = tokenProvider.getAuthentication(accessToken); + CustomOauth2User getPrincipal = (CustomOauth2User)authentication.getPrincipal(); + + // then + assertThat(authentication.isAuthenticated()).isTrue(); + assertThat(getPrincipal.getEmail()).isEqualTo("kakao123/kimMember@daum.net"); + } + + @DisplayName("토큰의 만료일이 현재 시간보다 전이면 만료된 토큰이다.") + @Test + void validateToken() { + // given + Date past = new Date(124, 6, 30, 16, 0, 0); + + when(authInfo.getSocialEmail()).thenReturn("kakao123/kimMember@daum.net"); + when(authInfo.getSocialName()).thenReturn("김회원"); + CustomOauth2User customOauth2User = new CustomOauth2User(authInfo); + String accessToken = tokenProvider.generateRefreshToken(customOauth2User, past); + + // when + boolean result = tokenProvider.validateToken(accessToken, new Date()); + + // then + assertThat(result).isFalse(); + } +}