From 344f96f87aee79f8a958ff646517f4ab152d06dc Mon Sep 17 00:00:00 2001 From: junhokim Date: Wed, 28 May 2025 22:16:57 +0900 Subject: [PATCH 1/3] Auth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Login 구현 --- .../application/config/SecurityConfig.java | 27 ++++++++++++ .../auth/application/in/MemberService.java | 26 ++++++++++++ .../application/in/request/LoginRequest.java | 9 ++++ .../in/usercase/ManageMemberUseCase.java | 8 ++++ .../out/repository/MemberRepository.java | 11 +++++ .../com/retrip/auth/domain/entity/Member.java | 36 ++++++++++++---- .../exception/MemberNotFoundException.java | 13 ++++++ .../exception/PasswordNotMatchException.java | 13 ++++++ .../domain/exception/common/ErrorCode.java | 5 ++- .../retrip/auth/domain/vo/MemberEmail.java | 32 ++++++++++++++ .../retrip/auth/domain/vo/MemberPassword.java | 40 ++++++++++++++++++ .../application/in/MemberServiceTest.java | 25 +++++++++++ .../in/base/BaseMemberServiceTest.java | 32 ++++++++++++++ .../auth/common/config/QuerydslConfig.java | 20 +++++++++ .../auth/common/fixture/MemberFixture.java | 9 ++++ .../retrip/auth/domain/entity/MemberTest.java | 42 +++++++++++++++++++ 16 files changed, 340 insertions(+), 8 deletions(-) create mode 100644 src/main/java/com/retrip/auth/application/config/SecurityConfig.java create mode 100644 src/main/java/com/retrip/auth/application/in/MemberService.java create mode 100644 src/main/java/com/retrip/auth/application/in/request/LoginRequest.java create mode 100644 src/main/java/com/retrip/auth/application/in/usercase/ManageMemberUseCase.java create mode 100644 src/main/java/com/retrip/auth/application/out/repository/MemberRepository.java create mode 100644 src/main/java/com/retrip/auth/domain/exception/MemberNotFoundException.java create mode 100644 src/main/java/com/retrip/auth/domain/exception/PasswordNotMatchException.java create mode 100644 src/main/java/com/retrip/auth/domain/vo/MemberEmail.java create mode 100644 src/main/java/com/retrip/auth/domain/vo/MemberPassword.java create mode 100644 src/test/java/com/retrip/auth/application/in/MemberServiceTest.java create mode 100644 src/test/java/com/retrip/auth/application/in/base/BaseMemberServiceTest.java create mode 100644 src/test/java/com/retrip/auth/common/config/QuerydslConfig.java create mode 100644 src/test/java/com/retrip/auth/common/fixture/MemberFixture.java create mode 100644 src/test/java/com/retrip/auth/domain/entity/MemberTest.java diff --git a/src/main/java/com/retrip/auth/application/config/SecurityConfig.java b/src/main/java/com/retrip/auth/application/config/SecurityConfig.java new file mode 100644 index 0000000..d1dc720 --- /dev/null +++ b/src/main/java/com/retrip/auth/application/config/SecurityConfig.java @@ -0,0 +1,27 @@ +package com.retrip.auth.application.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.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.SecurityFilterChain; + +@Configuration +public class SecurityConfig { + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); //암호 인코더 정의 + } + + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + http.authorizeHttpRequests( + auth -> auth.anyRequest().permitAll() + ); + + return http.build(); + } +} diff --git a/src/main/java/com/retrip/auth/application/in/MemberService.java b/src/main/java/com/retrip/auth/application/in/MemberService.java new file mode 100644 index 0000000..50fc9db --- /dev/null +++ b/src/main/java/com/retrip/auth/application/in/MemberService.java @@ -0,0 +1,26 @@ +package com.retrip.auth.application.in; + +import com.retrip.auth.application.in.request.LoginRequest; +import com.retrip.auth.application.in.usercase.ManageMemberUseCase; +import com.retrip.auth.application.out.repository.MemberRepository; +import com.retrip.auth.domain.entity.Member; +import com.retrip.auth.domain.exception.MemberNotFoundException; +import jakarta.transaction.Transactional; +import lombok.RequiredArgsConstructor; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +@Transactional +public class MemberService implements ManageMemberUseCase { + + private final MemberRepository memberRepository; + private final PasswordEncoder passwordEncoder; + + @Override + public void login(LoginRequest request) { + Member member = memberRepository.findByEmailValue(request.email()).orElseThrow(MemberNotFoundException::new); + member.matchPassword(passwordEncoder, request.password()); + } +} diff --git a/src/main/java/com/retrip/auth/application/in/request/LoginRequest.java b/src/main/java/com/retrip/auth/application/in/request/LoginRequest.java new file mode 100644 index 0000000..0b8f436 --- /dev/null +++ b/src/main/java/com/retrip/auth/application/in/request/LoginRequest.java @@ -0,0 +1,9 @@ +package com.retrip.auth.application.in.request; + +import org.springframework.security.crypto.password.PasswordEncoder; + +public record LoginRequest ( + String email, + String password +) { +} diff --git a/src/main/java/com/retrip/auth/application/in/usercase/ManageMemberUseCase.java b/src/main/java/com/retrip/auth/application/in/usercase/ManageMemberUseCase.java new file mode 100644 index 0000000..beb294c --- /dev/null +++ b/src/main/java/com/retrip/auth/application/in/usercase/ManageMemberUseCase.java @@ -0,0 +1,8 @@ +package com.retrip.auth.application.in.usercase; + + +import com.retrip.auth.application.in.request.LoginRequest; + +public interface ManageMemberUseCase { + void login(LoginRequest request); +} diff --git a/src/main/java/com/retrip/auth/application/out/repository/MemberRepository.java b/src/main/java/com/retrip/auth/application/out/repository/MemberRepository.java new file mode 100644 index 0000000..b51f1fc --- /dev/null +++ b/src/main/java/com/retrip/auth/application/out/repository/MemberRepository.java @@ -0,0 +1,11 @@ +package com.retrip.auth.application.out.repository; + +import com.retrip.auth.domain.entity.Member; + +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; + +public interface MemberRepository extends JpaRepository { + Optional findByEmailValue(String email); +} diff --git a/src/main/java/com/retrip/auth/domain/entity/Member.java b/src/main/java/com/retrip/auth/domain/entity/Member.java index b4f53dc..a9ce625 100644 --- a/src/main/java/com/retrip/auth/domain/entity/Member.java +++ b/src/main/java/com/retrip/auth/domain/entity/Member.java @@ -1,18 +1,19 @@ package com.retrip.auth.domain.entity; -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.Id; -import jakarta.persistence.Version; +import com.retrip.auth.domain.vo.MemberEmail; +import com.retrip.auth.domain.vo.MemberName; +import com.retrip.auth.domain.vo.MemberPassword; +import jakarta.persistence.*; import java.util.UUID; -import lombok.AccessLevel; -import lombok.Getter; -import lombok.NoArgsConstructor; +import lombok.*; +import org.springframework.security.crypto.password.PasswordEncoder; @Entity @NoArgsConstructor(access = AccessLevel.PROTECTED) +@Builder +@AllArgsConstructor @Getter public class Member { @Id @@ -22,6 +23,27 @@ public class Member { @Version private long version; + @Embedded + private MemberName name; + @Embedded + private MemberEmail email; + @Embedded + private MemberPassword password; + + + public void matchPassword(PasswordEncoder passwordEncoder, String password) { + this.password.matches(passwordEncoder, password); + } + + + public static Member create(String name, String email, String password) { + return Member.builder() + .id(UUID.randomUUID()) + .name(new MemberName(name)) + .email(new MemberEmail(email)) + .password(new MemberPassword(password)) + .build(); + } } diff --git a/src/main/java/com/retrip/auth/domain/exception/MemberNotFoundException.java b/src/main/java/com/retrip/auth/domain/exception/MemberNotFoundException.java new file mode 100644 index 0000000..4e5be2f --- /dev/null +++ b/src/main/java/com/retrip/auth/domain/exception/MemberNotFoundException.java @@ -0,0 +1,13 @@ +package com.retrip.auth.domain.exception; + +import com.retrip.auth.domain.exception.common.EntityNotFoundException; +import com.retrip.auth.domain.exception.common.ErrorCode; + +public class MemberNotFoundException extends EntityNotFoundException { + private static final ErrorCode errorCode = ErrorCode.MEMBER_NOT_FOUND; + + public MemberNotFoundException() { + super(errorCode); + } +} + diff --git a/src/main/java/com/retrip/auth/domain/exception/PasswordNotMatchException.java b/src/main/java/com/retrip/auth/domain/exception/PasswordNotMatchException.java new file mode 100644 index 0000000..4a7753a --- /dev/null +++ b/src/main/java/com/retrip/auth/domain/exception/PasswordNotMatchException.java @@ -0,0 +1,13 @@ +package com.retrip.auth.domain.exception; + +import com.retrip.auth.domain.exception.common.EntityNotFoundException; +import com.retrip.auth.domain.exception.common.ErrorCode; + +public class PasswordNotMatchException extends EntityNotFoundException { + private static final ErrorCode errorCode = ErrorCode.PASSWORD_NOT_MATCH; + + public PasswordNotMatchException() { + super(errorCode); + } +} + diff --git a/src/main/java/com/retrip/auth/domain/exception/common/ErrorCode.java b/src/main/java/com/retrip/auth/domain/exception/common/ErrorCode.java index 162b707..45a157f 100644 --- a/src/main/java/com/retrip/auth/domain/exception/common/ErrorCode.java +++ b/src/main/java/com/retrip/auth/domain/exception/common/ErrorCode.java @@ -12,7 +12,10 @@ public enum ErrorCode { HANDLE_ACCESS_DENIED(FORBIDDEN, "Common-003", "Access is denied"), ENTITY_NOT_FOUND(BAD_REQUEST, "Common-004", "Entity not found"), ILLEGAL_STATE(BAD_REQUEST, "Common-005", "Illegal state"), - INVALID_ACCESS(FORBIDDEN, "Common-006","접근 권한이 존재하지 않습니다.") + INVALID_ACCESS(FORBIDDEN, "Common-006","접근 권한이 존재하지 않습니다."), + + MEMBER_NOT_FOUND(HttpStatus.UNAUTHORIZED, "Member-001", "멤버 엔티티를 찾을 수 없습니다."), + PASSWORD_NOT_MATCH(HttpStatus.UNAUTHORIZED, "Member-001", "비밀 번호가 다릅니다."), ; private final HttpStatus status; diff --git a/src/main/java/com/retrip/auth/domain/vo/MemberEmail.java b/src/main/java/com/retrip/auth/domain/vo/MemberEmail.java new file mode 100644 index 0000000..eea7a45 --- /dev/null +++ b/src/main/java/com/retrip/auth/domain/vo/MemberEmail.java @@ -0,0 +1,32 @@ +package com.retrip.auth.domain.vo; + +import com.retrip.auth.domain.exception.common.InvalidValueException; +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import lombok.AccessLevel; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Embeddable +@EqualsAndHashCode +@NoArgsConstructor(access = AccessLevel.PROTECTED, force = true) +public class MemberEmail { + private static final int EMAIL_LENGTH_LIMIT = 50; + + @Column(name = "email", nullable = false, length = EMAIL_LENGTH_LIMIT) + private String value; + + public MemberEmail(String value) { + validate(value); + this.value = value; + } + + private void validate(String value) { + if (value.length() > EMAIL_LENGTH_LIMIT) { + throw new InvalidValueException("유저 ID는 " + EMAIL_LENGTH_LIMIT + "자를 넘을 수 없습니다."); + } + } + +} diff --git a/src/main/java/com/retrip/auth/domain/vo/MemberPassword.java b/src/main/java/com/retrip/auth/domain/vo/MemberPassword.java new file mode 100644 index 0000000..2dda642 --- /dev/null +++ b/src/main/java/com/retrip/auth/domain/vo/MemberPassword.java @@ -0,0 +1,40 @@ +package com.retrip.auth.domain.vo; + +import com.retrip.auth.domain.exception.PasswordNotMatchException; +import com.retrip.auth.domain.exception.common.InvalidValueException; +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import lombok.AccessLevel; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.crypto.password.PasswordEncoder; + +@Getter +@Embeddable +@EqualsAndHashCode +@NoArgsConstructor(access = AccessLevel.PROTECTED, force = true) +public class MemberPassword { + private static final int PASSWORD_LENGTH_LIMIT = 60; + + @Column(name = "password", nullable = false, length = PASSWORD_LENGTH_LIMIT) + private String value; + + public MemberPassword(String value) { + validate(value); + this.value = value; + } + + private void validate(String value) { + if (value.length() > PASSWORD_LENGTH_LIMIT) { + throw new InvalidValueException("유저 비밀번호는 " + PASSWORD_LENGTH_LIMIT + "자를 넘을 수 없습니다."); + } + } + + public void matches(PasswordEncoder passwordEncoder, String password) { + if (!passwordEncoder.matches(password, this.value)){ + throw new PasswordNotMatchException(); + } + } +} diff --git a/src/test/java/com/retrip/auth/application/in/MemberServiceTest.java b/src/test/java/com/retrip/auth/application/in/MemberServiceTest.java new file mode 100644 index 0000000..1eec890 --- /dev/null +++ b/src/test/java/com/retrip/auth/application/in/MemberServiceTest.java @@ -0,0 +1,25 @@ +package com.retrip.auth.application.in; + +import static org.assertj.core.api.Assertions.assertThatCode; + +import com.retrip.auth.application.in.base.BaseMemberServiceTest; +import com.retrip.auth.application.in.request.LoginRequest; +import com.retrip.auth.common.fixture.MemberFixture; + +import org.junit.jupiter.api.Test; + +class MemberServiceTest extends BaseMemberServiceTest { + + @Test + public void 로그인_테스트() { + //given + memberRepository.save(member); + LoginRequest request = MemberFixture.loginRequest("test@naver.com", "1234"); + + //when + + //then + assertThatCode(() -> memberService.login(request)).doesNotThrowAnyException(); + } + +} diff --git a/src/test/java/com/retrip/auth/application/in/base/BaseMemberServiceTest.java b/src/test/java/com/retrip/auth/application/in/base/BaseMemberServiceTest.java new file mode 100644 index 0000000..e571a78 --- /dev/null +++ b/src/test/java/com/retrip/auth/application/in/base/BaseMemberServiceTest.java @@ -0,0 +1,32 @@ +package com.retrip.auth.application.in.base; + +import com.retrip.auth.application.in.MemberService; +import com.retrip.auth.application.out.repository.MemberRepository; + +import com.retrip.auth.domain.entity.Member; + + +import org.junit.jupiter.api.BeforeEach; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.security.crypto.password.PasswordEncoder; + +@SpringBootTest +public abstract class BaseMemberServiceTest { + @Autowired + protected MemberRepository memberRepository; + + @Autowired + protected PasswordEncoder passwordEncoder; + + + protected MemberService memberService; + + protected Member member; + + @BeforeEach + void setUp() { + memberService = new MemberService(memberRepository, passwordEncoder); + member = Member.create( "테스트", "test@naver.com", passwordEncoder.encode("1234")); + } +} diff --git a/src/test/java/com/retrip/auth/common/config/QuerydslConfig.java b/src/test/java/com/retrip/auth/common/config/QuerydslConfig.java new file mode 100644 index 0000000..79e5387 --- /dev/null +++ b/src/test/java/com/retrip/auth/common/config/QuerydslConfig.java @@ -0,0 +1,20 @@ +package com.retrip.auth.common.config; + +import com.querydsl.jpa.impl.JPAQueryFactory; +import jakarta.persistence.EntityManager; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; + +@EnableJpaAuditing +@TestConfiguration +public class QuerydslConfig { + + @Autowired EntityManager entityManager; + + @Bean + public JPAQueryFactory jpaQueryFactory() { + return new JPAQueryFactory(entityManager); + } +} diff --git a/src/test/java/com/retrip/auth/common/fixture/MemberFixture.java b/src/test/java/com/retrip/auth/common/fixture/MemberFixture.java new file mode 100644 index 0000000..d81a00e --- /dev/null +++ b/src/test/java/com/retrip/auth/common/fixture/MemberFixture.java @@ -0,0 +1,9 @@ +package com.retrip.auth.common.fixture; + +import com.retrip.auth.application.in.request.LoginRequest; + +public abstract class MemberFixture { + public static LoginRequest loginRequest(String email, String password){ + return new LoginRequest(email,password); + } +} diff --git a/src/test/java/com/retrip/auth/domain/entity/MemberTest.java b/src/test/java/com/retrip/auth/domain/entity/MemberTest.java new file mode 100644 index 0000000..dcc6363 --- /dev/null +++ b/src/test/java/com/retrip/auth/domain/entity/MemberTest.java @@ -0,0 +1,42 @@ +package com.retrip.auth.domain.entity; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThatCode; +import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy; +import com.retrip.auth.domain.exception.PasswordNotMatchException; +import com.retrip.auth.domain.vo.MemberPassword; +import org.junit.jupiter.api.Test; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; + +class MemberTest { + + @Test + public void 패스워드_매처_성공_테스트(){ + //given + PasswordEncoder passwordEncoder = new BCryptPasswordEncoder(); + MemberPassword mp = new MemberPassword(passwordEncoder.encode( "TEST")); + + //when + String password = "TEST"; + + + + //then + assertThatCode(() -> mp.matches(passwordEncoder, password)) + .doesNotThrowAnyException(); + } + + @Test + public void 패스워드_매처_실패_테스트(){ + //given + PasswordEncoder passwordEncoder = new BCryptPasswordEncoder(); + MemberPassword mp = new MemberPassword(passwordEncoder.encode( "TEST")); + + //when + String password = "FAIL"; + + //then + assertThatThrownBy(() -> mp.matches(passwordEncoder, passwordEncoder.encode(password))) + .isExactlyInstanceOf(PasswordNotMatchException.class); + } +} From 4f4aa1ee160072e15eba3a22c9ea4afb34283ca7 Mon Sep 17 00:00:00 2001 From: junhokim Date: Sun, 1 Jun 2025 11:31:20 +0900 Subject: [PATCH 2/3] Auth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Login 구현 --- .../java/com/retrip/auth/AuthApplication.java | 3 + .../application/config/CustomUserDetails.java | 37 +++++++ .../auth/application/config/JwtConfig.java | 29 +++++ .../application/config/SecurityConfig.java | 35 +++++- .../UsernamePasswordAuthentication.java | 19 ++++ ...sernamePasswordAuthenticationProvider.java | 40 +++++++ .../auth/application/in/MemberService.java | 15 +-- .../in/response/LoginResponse.java | 9 ++ .../in/usercase/ManageMemberUseCase.java | 2 - .../auth/domain/entity/Authorities.java | 31 ++++++ .../retrip/auth/domain/entity/Authority.java | 43 ++++++++ .../retrip/auth/domain/entity/BaseEntity.java | 22 ++++ .../com/retrip/auth/domain/entity/Member.java | 15 +-- .../retrip/auth/domain/vo/AuthorityGrant.java | 27 +++++ .../retrip/auth/domain/vo/MemberPassword.java | 6 - .../rest/filter/JwtAuthenticationFilter.java | 91 +++++++++++++++ .../filter/LoginAuthenticationFilter.java | 104 ++++++++++++++++++ src/main/resources/application.yml | 9 ++ .../application/in/MemberServiceTest.java | 25 ----- .../retrip/auth/domain/entity/MemberTest.java | 42 ------- .../filter/LoginAuthenticationFilterTest.java | 47 ++++++++ .../base/BaseLoginAuthenticationTest.java} | 18 +-- 22 files changed, 568 insertions(+), 101 deletions(-) create mode 100644 src/main/java/com/retrip/auth/application/config/CustomUserDetails.java create mode 100644 src/main/java/com/retrip/auth/application/config/JwtConfig.java create mode 100644 src/main/java/com/retrip/auth/application/config/UsernamePasswordAuthentication.java create mode 100644 src/main/java/com/retrip/auth/application/config/UsernamePasswordAuthenticationProvider.java create mode 100644 src/main/java/com/retrip/auth/application/in/response/LoginResponse.java create mode 100644 src/main/java/com/retrip/auth/domain/entity/Authorities.java create mode 100644 src/main/java/com/retrip/auth/domain/entity/Authority.java create mode 100644 src/main/java/com/retrip/auth/domain/entity/BaseEntity.java create mode 100644 src/main/java/com/retrip/auth/domain/vo/AuthorityGrant.java create mode 100644 src/main/java/com/retrip/auth/infra/adapter/in/rest/filter/JwtAuthenticationFilter.java create mode 100644 src/main/java/com/retrip/auth/infra/adapter/in/rest/filter/LoginAuthenticationFilter.java delete mode 100644 src/test/java/com/retrip/auth/application/in/MemberServiceTest.java delete mode 100644 src/test/java/com/retrip/auth/domain/entity/MemberTest.java create mode 100644 src/test/java/com/retrip/auth/infra/adapter/in/rest/filter/LoginAuthenticationFilterTest.java rename src/test/java/com/retrip/auth/{application/in/base/BaseMemberServiceTest.java => infra/adapter/in/rest/filter/base/BaseLoginAuthenticationTest.java} (63%) diff --git a/src/main/java/com/retrip/auth/AuthApplication.java b/src/main/java/com/retrip/auth/AuthApplication.java index 4d9faa0..f4e8520 100644 --- a/src/main/java/com/retrip/auth/AuthApplication.java +++ b/src/main/java/com/retrip/auth/AuthApplication.java @@ -1,9 +1,12 @@ package com.retrip.auth; +import com.retrip.auth.application.config.JwtConfig; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.context.properties.EnableConfigurationProperties; @SpringBootApplication +@EnableConfigurationProperties({JwtConfig.class}) public class AuthApplication { public static void main(String[] args) { diff --git a/src/main/java/com/retrip/auth/application/config/CustomUserDetails.java b/src/main/java/com/retrip/auth/application/config/CustomUserDetails.java new file mode 100644 index 0000000..b026d5c --- /dev/null +++ b/src/main/java/com/retrip/auth/application/config/CustomUserDetails.java @@ -0,0 +1,37 @@ +package com.retrip.auth.application.config; + + +import com.retrip.auth.domain.entity.Member; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; + +import java.util.Collection; +import java.util.stream.Collectors; + + +public class CustomUserDetails implements UserDetails { + + private final Member member; + + public CustomUserDetails(Member member) { + this.member = member; + } + + @Override + public Collection getAuthorities() { + return member.getAuthorities().getValues().stream() + .map(authority -> new SimpleGrantedAuthority(authority.getGrant().getCode())) + .collect(Collectors.toList()); + } + + @Override + public String getPassword() { + return member.getPassword().getValue(); + } + + @Override + public String getUsername() { + return member.getEmail().getValue(); + } +} diff --git a/src/main/java/com/retrip/auth/application/config/JwtConfig.java b/src/main/java/com/retrip/auth/application/config/JwtConfig.java new file mode 100644 index 0000000..30cc89f --- /dev/null +++ b/src/main/java/com/retrip/auth/application/config/JwtConfig.java @@ -0,0 +1,29 @@ +package com.retrip.auth.application.config; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.context.properties.ConfigurationProperties; + + +@Getter +@RequiredArgsConstructor +@ConfigurationProperties("token.jwt") +public class JwtConfig { + private final String secret; + private final String header; + private final String prefix; + private final AccessConfig access; + private final RefreshConfig refresh; + + @Getter + @RequiredArgsConstructor + public static class AccessConfig { + private final int expireMin; + } + + @Getter + @RequiredArgsConstructor + public static class RefreshConfig { + private final int expireMin; + } +} diff --git a/src/main/java/com/retrip/auth/application/config/SecurityConfig.java b/src/main/java/com/retrip/auth/application/config/SecurityConfig.java index d1dc720..4923c6a 100644 --- a/src/main/java/com/retrip/auth/application/config/SecurityConfig.java +++ b/src/main/java/com/retrip/auth/application/config/SecurityConfig.java @@ -1,26 +1,51 @@ package com.retrip.auth.application.config; +import com.retrip.auth.infra.adapter.in.rest.filter.JwtAuthenticationFilter; +import com.retrip.auth.infra.adapter.in.rest.filter.LoginAuthenticationFilter; +import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder; import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.security.web.authentication.www.BasicAuthenticationFilter; @Configuration +@RequiredArgsConstructor public class SecurityConfig { - @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); //암호 인코더 정의 } + @Bean + public LoginAuthenticationFilter loginAuthenticationFilter(JwtConfig jwtConfig, AuthenticationManager authenticationManager) { + return new LoginAuthenticationFilter(jwtConfig, authenticationManager); + } + + @Bean + public JwtAuthenticationFilter jwtAuthenticationFilter(JwtConfig jwtConfig){ + return new JwtAuthenticationFilter(jwtConfig); + } + @Bean + public AuthenticationManager authenticationManager(HttpSecurity http, UsernamePasswordAuthenticationProvider usernamePasswordAuthenticationProvider) throws Exception { + return http.authenticationProvider(usernamePasswordAuthenticationProvider) + .getSharedObject(AuthenticationManagerBuilder.class) + .build(); + } + @Bean - public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { - http.authorizeHttpRequests( - auth -> auth.anyRequest().permitAll() - ); + public SecurityFilterChain securityFilterChain(HttpSecurity http, LoginAuthenticationFilter loginAuthenticationFilter, JwtAuthenticationFilter jwtAuthenticationFilter) throws Exception { + http.csrf(AbstractHttpConfigurer::disable) + .addFilterAt(loginAuthenticationFilter, BasicAuthenticationFilter.class) + .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); + http.authorizeHttpRequests(auth -> auth.anyRequest().authenticated()); return http.build(); } diff --git a/src/main/java/com/retrip/auth/application/config/UsernamePasswordAuthentication.java b/src/main/java/com/retrip/auth/application/config/UsernamePasswordAuthentication.java new file mode 100644 index 0000000..01e38b6 --- /dev/null +++ b/src/main/java/com/retrip/auth/application/config/UsernamePasswordAuthentication.java @@ -0,0 +1,19 @@ +package com.retrip.auth.application.config; + +import java.util.Collection; + +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.GrantedAuthority; + +public class UsernamePasswordAuthentication extends UsernamePasswordAuthenticationToken { + //인증 완료 + public UsernamePasswordAuthentication(Object principal, Object credentials, + Collection authorities) { + super(principal, credentials, authorities); + } + + //인증 X + public UsernamePasswordAuthentication(Object principal, Object credentials) { + super(principal, credentials); + } +} diff --git a/src/main/java/com/retrip/auth/application/config/UsernamePasswordAuthenticationProvider.java b/src/main/java/com/retrip/auth/application/config/UsernamePasswordAuthenticationProvider.java new file mode 100644 index 0000000..05a779f --- /dev/null +++ b/src/main/java/com/retrip/auth/application/config/UsernamePasswordAuthenticationProvider.java @@ -0,0 +1,40 @@ +package com.retrip.auth.application.config; + +import com.retrip.auth.application.in.MemberService; +import lombok.RequiredArgsConstructor; +import org.springframework.security.authentication.AuthenticationProvider; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class UsernamePasswordAuthenticationProvider implements AuthenticationProvider { + + private final MemberService memberService; + private final PasswordEncoder passwordEncoder; + + @Override + public Authentication authenticate(Authentication authentication) + throws AuthenticationException { + String username = authentication.getName(); + String password = String.valueOf(authentication.getCredentials()); + + UserDetails user = memberService.loadUserByUsername(username); + + if (!passwordEncoder.matches(password, user.getPassword())) { + throw new BadCredentialsException("Bad credentials"); + } + + return new UsernamePasswordAuthenticationToken(username, password); + } + + @Override + public boolean supports(Class authentication) { + return UsernamePasswordAuthentication.class.isAssignableFrom(authentication); + } +} diff --git a/src/main/java/com/retrip/auth/application/in/MemberService.java b/src/main/java/com/retrip/auth/application/in/MemberService.java index 50fc9db..8106abf 100644 --- a/src/main/java/com/retrip/auth/application/in/MemberService.java +++ b/src/main/java/com/retrip/auth/application/in/MemberService.java @@ -1,26 +1,27 @@ package com.retrip.auth.application.in; -import com.retrip.auth.application.in.request.LoginRequest; +import com.retrip.auth.application.config.CustomUserDetails; import com.retrip.auth.application.in.usercase.ManageMemberUseCase; import com.retrip.auth.application.out.repository.MemberRepository; import com.retrip.auth.domain.entity.Member; import com.retrip.auth.domain.exception.MemberNotFoundException; import jakarta.transaction.Transactional; import lombok.RequiredArgsConstructor; -import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; import org.springframework.stereotype.Service; @Service @RequiredArgsConstructor @Transactional -public class MemberService implements ManageMemberUseCase { +public class MemberService implements ManageMemberUseCase, UserDetailsService { private final MemberRepository memberRepository; - private final PasswordEncoder passwordEncoder; @Override - public void login(LoginRequest request) { - Member member = memberRepository.findByEmailValue(request.email()).orElseThrow(MemberNotFoundException::new); - member.matchPassword(passwordEncoder, request.password()); + public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { + Member member = memberRepository.findByEmailValue(username).orElseThrow(MemberNotFoundException::new); + return new CustomUserDetails(member); } } diff --git a/src/main/java/com/retrip/auth/application/in/response/LoginResponse.java b/src/main/java/com/retrip/auth/application/in/response/LoginResponse.java new file mode 100644 index 0000000..f3e514b --- /dev/null +++ b/src/main/java/com/retrip/auth/application/in/response/LoginResponse.java @@ -0,0 +1,9 @@ +package com.retrip.auth.application.in.response; + +public record LoginResponse( + TokenResponse token +) { + public record TokenResponse(String accessToken, String refreshToken) { + + } +} diff --git a/src/main/java/com/retrip/auth/application/in/usercase/ManageMemberUseCase.java b/src/main/java/com/retrip/auth/application/in/usercase/ManageMemberUseCase.java index beb294c..5c3c073 100644 --- a/src/main/java/com/retrip/auth/application/in/usercase/ManageMemberUseCase.java +++ b/src/main/java/com/retrip/auth/application/in/usercase/ManageMemberUseCase.java @@ -1,8 +1,6 @@ package com.retrip.auth.application.in.usercase; -import com.retrip.auth.application.in.request.LoginRequest; public interface ManageMemberUseCase { - void login(LoginRequest request); } diff --git a/src/main/java/com/retrip/auth/domain/entity/Authorities.java b/src/main/java/com/retrip/auth/domain/entity/Authorities.java new file mode 100644 index 0000000..5f845f2 --- /dev/null +++ b/src/main/java/com/retrip/auth/domain/entity/Authorities.java @@ -0,0 +1,31 @@ +package com.retrip.auth.domain.entity; + +import jakarta.persistence.CascadeType; +import jakarta.persistence.Embeddable; +import jakarta.persistence.OneToMany; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.ArrayList; +import java.util.List; + +import static lombok.AccessLevel.PROTECTED; + +@Getter +@Embeddable +@NoArgsConstructor(access = PROTECTED, force = true) +public class Authorities { + @OneToMany(mappedBy = "member", cascade = CascadeType.ALL, orphanRemoval = true) + private final List values = new ArrayList<>(); + + public Authorities(List authorities, Member member) { + validate(authorities); + authorities.forEach(authority -> this.values.add(Authority.create(authority, member))); + } + + private void validate(List authorities) { + if (authorities == null || authorities.isEmpty()) { + throw new IllegalArgumentException("권한이 없습니다."); + } + } +} diff --git a/src/main/java/com/retrip/auth/domain/entity/Authority.java b/src/main/java/com/retrip/auth/domain/entity/Authority.java new file mode 100644 index 0000000..51fe8c0 --- /dev/null +++ b/src/main/java/com/retrip/auth/domain/entity/Authority.java @@ -0,0 +1,43 @@ +package com.retrip.auth.domain.entity; + +import com.retrip.auth.domain.vo.AuthorityGrant; +import jakarta.persistence.*; + +import java.time.LocalDate; +import java.util.UUID; + +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.security.authentication.jaas.AuthorityGranter; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Authority extends BaseEntity { + @Id + @Column(columnDefinition = "varbinary(16)") + private UUID id; + + @Column(name = "grant", length = 50, nullable = false) + private AuthorityGrant grant; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn( + name = "member_id", + nullable = false, + columnDefinition = "varbinary(16)", + foreignKey = @ForeignKey(name = "fk_authority_to_member") + ) + private Member member; + + private Authority(String grant, Member member) { + this.id = UUID.randomUUID(); + this.grant = AuthorityGrant.codeOf(grant); + this.member = member; + } + + public static Authority create(String grant, Member member) { + return new Authority(grant, member); + } +} diff --git a/src/main/java/com/retrip/auth/domain/entity/BaseEntity.java b/src/main/java/com/retrip/auth/domain/entity/BaseEntity.java new file mode 100644 index 0000000..a4cb68d --- /dev/null +++ b/src/main/java/com/retrip/auth/domain/entity/BaseEntity.java @@ -0,0 +1,22 @@ +package com.retrip.auth.domain.entity; + +import jakarta.persistence.EntityListeners; +import jakarta.persistence.MappedSuperclass; + +import java.time.LocalDateTime; + +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +@EntityListeners(AuditingEntityListener.class) +@MappedSuperclass +public abstract class BaseEntity { + + @CreatedDate + private LocalDateTime createdAt; + + @LastModifiedDate + private LocalDateTime editedAt; +} + diff --git a/src/main/java/com/retrip/auth/domain/entity/Member.java b/src/main/java/com/retrip/auth/domain/entity/Member.java index a9ce625..010c883 100644 --- a/src/main/java/com/retrip/auth/domain/entity/Member.java +++ b/src/main/java/com/retrip/auth/domain/entity/Member.java @@ -5,6 +5,7 @@ import com.retrip.auth.domain.vo.MemberPassword; import jakarta.persistence.*; +import java.util.List; import java.util.UUID; import lombok.*; @@ -15,7 +16,7 @@ @Builder @AllArgsConstructor @Getter -public class Member { +public class Member extends BaseEntity { @Id @Column(columnDefinition = "varbinary(16)") private UUID id; @@ -32,18 +33,18 @@ public class Member { @Embedded private MemberPassword password; - - public void matchPassword(PasswordEncoder passwordEncoder, String password) { - this.password.matches(passwordEncoder, password); - } + @Embedded + private Authorities authorities; - public static Member create(String name, String email, String password) { - return Member.builder() + public static Member create(String name, String email, String password, List authorities) { + Member member = Member.builder() .id(UUID.randomUUID()) .name(new MemberName(name)) .email(new MemberEmail(email)) .password(new MemberPassword(password)) .build(); + member.authorities = new Authorities(authorities, member); + return member; } } diff --git a/src/main/java/com/retrip/auth/domain/vo/AuthorityGrant.java b/src/main/java/com/retrip/auth/domain/vo/AuthorityGrant.java new file mode 100644 index 0000000..2444608 --- /dev/null +++ b/src/main/java/com/retrip/auth/domain/vo/AuthorityGrant.java @@ -0,0 +1,27 @@ +package com.retrip.auth.domain.vo; + +import com.retrip.auth.domain.exception.common.InvalidValueException; +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; + +import java.util.Arrays; + +import lombok.*; + +@Getter +@AllArgsConstructor +public enum AuthorityGrant { + ADMIN("admin", "관리자"), + USER("user", "사용자"), + ; + + private final String code; + private final String viewName; + public static AuthorityGrant codeOf(String code) { + return Arrays.stream(AuthorityGrant.values()) + .filter(authorityGrant -> authorityGrant.getCode().equals(code)) + .findAny() + .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 코드입니다.")); + } + +} diff --git a/src/main/java/com/retrip/auth/domain/vo/MemberPassword.java b/src/main/java/com/retrip/auth/domain/vo/MemberPassword.java index 2dda642..190ddb6 100644 --- a/src/main/java/com/retrip/auth/domain/vo/MemberPassword.java +++ b/src/main/java/com/retrip/auth/domain/vo/MemberPassword.java @@ -31,10 +31,4 @@ private void validate(String value) { throw new InvalidValueException("유저 비밀번호는 " + PASSWORD_LENGTH_LIMIT + "자를 넘을 수 없습니다."); } } - - public void matches(PasswordEncoder passwordEncoder, String password) { - if (!passwordEncoder.matches(password, this.value)){ - throw new PasswordNotMatchException(); - } - } } diff --git a/src/main/java/com/retrip/auth/infra/adapter/in/rest/filter/JwtAuthenticationFilter.java b/src/main/java/com/retrip/auth/infra/adapter/in/rest/filter/JwtAuthenticationFilter.java new file mode 100644 index 0000000..7f1927a --- /dev/null +++ b/src/main/java/com/retrip/auth/infra/adapter/in/rest/filter/JwtAuthenticationFilter.java @@ -0,0 +1,91 @@ +package com.retrip.auth.infra.adapter.in.rest.filter; + +import com.retrip.auth.application.config.JwtConfig; +import com.retrip.auth.application.config.UsernamePasswordAuthentication; +import io.jsonwebtoken.*; +import io.jsonwebtoken.security.Keys; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; +import org.springframework.web.filter.OncePerRequestFilter; + +import javax.crypto.SecretKey; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.List; +import java.util.stream.Collectors; + +@Slf4j +@RequiredArgsConstructor +public class JwtAuthenticationFilter extends OncePerRequestFilter { + private final JwtConfig jwtConfig; + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + String token = getToken(request.getHeader("Authorization")); + SecretKey key = Keys.hmacShaKeyFor(jwtConfig.getSecret().getBytes(StandardCharsets.UTF_8)); + if (token == null || !validToken(token, key)) { + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + } + Claims payload = Jwts.parser() + .verifyWith(key) + .build() + .parseSignedClaims(token).getPayload(); + String username = String.valueOf(payload.get("username")); + List authorities = getAuthorities(String.valueOf(payload.get("authorities"))); + + UsernamePasswordAuthentication auth = new UsernamePasswordAuthentication(username, null, authorities); + SecurityContextHolder.getContext().setAuthentication(auth); + filterChain.doFilter(request, response); + } + + + private String getToken(String authorization) { + if (StringUtils.hasText(authorization) && authorization.startsWith("Bearer ")) { + return authorization.substring(7); + } + return null; + } + + private boolean validToken(String token, SecretKey key) { + try { + Jwts.parser() + .verifyWith(key) + .build() + .parseSignedClaims(token); + return true; // 검증 성공 + } catch (SecurityException | MalformedJwtException e) { + log.info("Invalid JWT Token", e); + } catch (ExpiredJwtException e) { + log.info("Expired JWT Token", e); + } catch (UnsupportedJwtException e) { + log.info("Unsupported JWT Token", e); + } catch (IllegalArgumentException e) { + log.info("JWT claims string is empty.", e); + } + return false; + } + + private List getAuthorities(String authorities) { + return Arrays.stream(authorities.split(",")) + .map(String::trim) + .map(SimpleGrantedAuthority::new) + .collect(Collectors.toList()); + } + + @Override + protected boolean shouldNotFilter(HttpServletRequest request) throws ServletException { + //로그인 제외 모든 필터 타기 + return request.getRequestURI().equals("/login"); + } +} diff --git a/src/main/java/com/retrip/auth/infra/adapter/in/rest/filter/LoginAuthenticationFilter.java b/src/main/java/com/retrip/auth/infra/adapter/in/rest/filter/LoginAuthenticationFilter.java new file mode 100644 index 0000000..6b41efb --- /dev/null +++ b/src/main/java/com/retrip/auth/infra/adapter/in/rest/filter/LoginAuthenticationFilter.java @@ -0,0 +1,104 @@ +package com.retrip.auth.infra.adapter.in.rest.filter; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.retrip.auth.application.config.JwtConfig; +import com.retrip.auth.application.config.UsernamePasswordAuthentication; +import com.retrip.auth.application.in.response.LoginResponse; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Keys; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; +import org.springframework.web.filter.OncePerRequestFilter; + +import javax.crypto.SecretKey; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.Date; +import java.util.Map; + +@Slf4j +@RequiredArgsConstructor +public class LoginAuthenticationFilter extends OncePerRequestFilter { + private final JwtConfig jwtConfig; + private final AuthenticationManager manager; + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + String id = request.getHeader("id"); + String password = request.getHeader("password"); + if (!StringUtils.hasText(id) || !StringUtils.hasText(password)) { + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + return; + } + Authentication authentication = new UsernamePasswordAuthentication(id, password); + Authentication auth = manager.authenticate(authentication); + LoginResponse.TokenResponse tokenResponse = generateToken(auth); + + // JSON 직렬화 + String json = getResponseBody(tokenResponse); + + // 응답 Header 설정 + response.setContentType("application/json"); + response.setCharacterEncoding("UTF-8"); + + // 응답에 JSON 데이터 작성 + response.setStatus(HttpServletResponse.SC_OK); + response.getWriter().write(json); + response.getWriter().flush(); + } + + + public LoginResponse.TokenResponse generateToken(Authentication authentication) { + String authorities = String.join(",", authentication.getAuthorities().stream().map(GrantedAuthority::getAuthority).toList()); + LocalDateTime issuedTime = LocalDateTime.now(); + LocalDateTime accessTokenExpireTime = LocalDateTime.now().plusMinutes(jwtConfig.getAccess().getExpireMin()); + LocalDateTime refreshTokenExpireTime = LocalDateTime.now().plusMinutes(jwtConfig.getRefresh().getExpireMin()); + + Date issuedDate = Date.from(issuedTime.atZone(ZoneId.of("Asia/Seoul")).toInstant()); + Date accessTokenExpireDate = Date.from(accessTokenExpireTime.atZone(ZoneId.of("Asia/Seoul")).toInstant()); + Date refreshTokenExpireDate = Date.from(refreshTokenExpireTime.atZone(ZoneId.of("Asia/Seoul")).toInstant()); + SecretKey key = Keys.hmacShaKeyFor(jwtConfig.getSecret().getBytes(StandardCharsets.UTF_8)); + + String accessToken = Jwts.builder() + .subject(authentication.getName()) + .claims(Map.of("username", authentication.getName(), "authorities", authorities)) + .expiration(accessTokenExpireDate) + .issuedAt(issuedDate) + .signWith(key) + .compact(); + + String refreshToken = Jwts.builder() + .subject(authentication.getName()) + .claims(Map.of("username", authentication.getName(), "authorities", authorities)) + .expiration(refreshTokenExpireDate) + .issuedAt(issuedDate) + .signWith(key) + .compact(); + return new LoginResponse.TokenResponse(accessToken, refreshToken); + } + + private static String getResponseBody(LoginResponse.TokenResponse tokenResponse) throws JsonProcessingException { + ObjectMapper objectMapper = new ObjectMapper(); + return objectMapper.writeValueAsString(tokenResponse); + } + + @Override + protected boolean shouldNotFilter(HttpServletRequest request) throws ServletException { + //로그인만 + return !request.getRequestURI().equals("/login"); + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index f3741b1..347f9bd 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -29,3 +29,12 @@ logging: web: client: RestTemplate: DEBUG +token: + jwt: + secret: 123 + header: Authorization + prefix: Bearer + access: + expire-min: 120 + refresh: + expire-min: 1051200 #2년 diff --git a/src/test/java/com/retrip/auth/application/in/MemberServiceTest.java b/src/test/java/com/retrip/auth/application/in/MemberServiceTest.java deleted file mode 100644 index 1eec890..0000000 --- a/src/test/java/com/retrip/auth/application/in/MemberServiceTest.java +++ /dev/null @@ -1,25 +0,0 @@ -package com.retrip.auth.application.in; - -import static org.assertj.core.api.Assertions.assertThatCode; - -import com.retrip.auth.application.in.base.BaseMemberServiceTest; -import com.retrip.auth.application.in.request.LoginRequest; -import com.retrip.auth.common.fixture.MemberFixture; - -import org.junit.jupiter.api.Test; - -class MemberServiceTest extends BaseMemberServiceTest { - - @Test - public void 로그인_테스트() { - //given - memberRepository.save(member); - LoginRequest request = MemberFixture.loginRequest("test@naver.com", "1234"); - - //when - - //then - assertThatCode(() -> memberService.login(request)).doesNotThrowAnyException(); - } - -} diff --git a/src/test/java/com/retrip/auth/domain/entity/MemberTest.java b/src/test/java/com/retrip/auth/domain/entity/MemberTest.java deleted file mode 100644 index dcc6363..0000000 --- a/src/test/java/com/retrip/auth/domain/entity/MemberTest.java +++ /dev/null @@ -1,42 +0,0 @@ -package com.retrip.auth.domain.entity; - -import static org.assertj.core.api.AssertionsForClassTypes.assertThatCode; -import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy; -import com.retrip.auth.domain.exception.PasswordNotMatchException; -import com.retrip.auth.domain.vo.MemberPassword; -import org.junit.jupiter.api.Test; -import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; -import org.springframework.security.crypto.password.PasswordEncoder; - -class MemberTest { - - @Test - public void 패스워드_매처_성공_테스트(){ - //given - PasswordEncoder passwordEncoder = new BCryptPasswordEncoder(); - MemberPassword mp = new MemberPassword(passwordEncoder.encode( "TEST")); - - //when - String password = "TEST"; - - - - //then - assertThatCode(() -> mp.matches(passwordEncoder, password)) - .doesNotThrowAnyException(); - } - - @Test - public void 패스워드_매처_실패_테스트(){ - //given - PasswordEncoder passwordEncoder = new BCryptPasswordEncoder(); - MemberPassword mp = new MemberPassword(passwordEncoder.encode( "TEST")); - - //when - String password = "FAIL"; - - //then - assertThatThrownBy(() -> mp.matches(passwordEncoder, passwordEncoder.encode(password))) - .isExactlyInstanceOf(PasswordNotMatchException.class); - } -} diff --git a/src/test/java/com/retrip/auth/infra/adapter/in/rest/filter/LoginAuthenticationFilterTest.java b/src/test/java/com/retrip/auth/infra/adapter/in/rest/filter/LoginAuthenticationFilterTest.java new file mode 100644 index 0000000..b705e19 --- /dev/null +++ b/src/test/java/com/retrip/auth/infra/adapter/in/rest/filter/LoginAuthenticationFilterTest.java @@ -0,0 +1,47 @@ +package com.retrip.auth.infra.adapter.in.rest.filter; + + +import com.retrip.auth.application.in.request.LoginRequest; +import com.retrip.auth.infra.adapter.in.rest.filter.base.BaseLoginAuthenticationTest; +import org.junit.jupiter.api.Test; +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.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@AutoConfigureMockMvc(addFilters = true) +class LoginAuthenticationFilterTest extends BaseLoginAuthenticationTest { + + @Autowired + private MockMvc mockMvc; + + @Test + void 로그인_성공() throws Exception { + memberRepository.save(member); + + // given + LoginRequest request = new LoginRequest("test@naver.com", "1234"); + + //when + // headers 생성 + HttpHeaders headers = new HttpHeaders(); + headers.add("id", request.email()); + headers.add("password", request.password()); + + // when & then + mockMvc.perform(get("/login") + .contentType(MediaType.APPLICATION_JSON) + .headers(headers)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.accessToken").value("access-token")) + .andExpect(jsonPath("$.refreshToken").value("refresh-token")); + } +} diff --git a/src/test/java/com/retrip/auth/application/in/base/BaseMemberServiceTest.java b/src/test/java/com/retrip/auth/infra/adapter/in/rest/filter/base/BaseLoginAuthenticationTest.java similarity index 63% rename from src/test/java/com/retrip/auth/application/in/base/BaseMemberServiceTest.java rename to src/test/java/com/retrip/auth/infra/adapter/in/rest/filter/base/BaseLoginAuthenticationTest.java index e571a78..3ab4236 100644 --- a/src/test/java/com/retrip/auth/application/in/base/BaseMemberServiceTest.java +++ b/src/test/java/com/retrip/auth/infra/adapter/in/rest/filter/base/BaseLoginAuthenticationTest.java @@ -1,18 +1,17 @@ -package com.retrip.auth.application.in.base; +package com.retrip.auth.infra.adapter.in.rest.filter.base; import com.retrip.auth.application.in.MemberService; import com.retrip.auth.application.out.repository.MemberRepository; - import com.retrip.auth.domain.entity.Member; - - import org.junit.jupiter.api.BeforeEach; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.security.crypto.password.PasswordEncoder; +import java.util.List; + @SpringBootTest -public abstract class BaseMemberServiceTest { +public abstract class BaseLoginAuthenticationTest { @Autowired protected MemberRepository memberRepository; @@ -26,7 +25,12 @@ public abstract class BaseMemberServiceTest { @BeforeEach void setUp() { - memberService = new MemberService(memberRepository, passwordEncoder); - member = Member.create( "테스트", "test@naver.com", passwordEncoder.encode("1234")); + memberService = new MemberService(memberRepository); + member = Member.create( + "테스트", + "test@naver.com", + passwordEncoder.encode("1234"), + List.of("admin") + ); } } From 3f5bd440671eaf6133afdf6b5a9abc191cc014f0 Mon Sep 17 00:00:00 2001 From: junhokim Date: Sun, 1 Jun 2025 15:11:06 +0900 Subject: [PATCH 3/3] Auth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Login 구현 - JWT 토큰 검증 구현 --- .../application/config/SecurityConfig.java | 7 ++- .../UsernamePasswordAuthentication.java | 2 + ...sernamePasswordAuthenticationProvider.java | 6 +- .../auth/application/in/MemberService.java | 5 +- .../out/repository/MemberQueryRepository.java | 10 +++ .../out/repository/MemberRepository.java | 1 - .../filter/LoginAuthenticationFilter.java | 9 ++- .../adapter/in/rest/in/TestController.java | 15 +++++ .../mysql/query/MemberQuerydslRepository.java | 30 +++++++++ src/main/resources/application.yml | 2 +- .../auth/common/fixture/MemberFixture.java | 9 --- .../filter/JwtAuthenticationFilterTest.java | 63 +++++++++++++++++++ .../filter/LoginAuthenticationFilterTest.java | 4 +- .../base/BaseLoginAuthenticationTest.java | 11 +++- 14 files changed, 153 insertions(+), 21 deletions(-) create mode 100644 src/main/java/com/retrip/auth/application/out/repository/MemberQueryRepository.java create mode 100644 src/main/java/com/retrip/auth/infra/adapter/in/rest/in/TestController.java create mode 100644 src/main/java/com/retrip/auth/infra/adapter/out/persistence/mysql/query/MemberQuerydslRepository.java delete mode 100644 src/test/java/com/retrip/auth/common/fixture/MemberFixture.java create mode 100644 src/test/java/com/retrip/auth/infra/adapter/in/rest/filter/JwtAuthenticationFilterTest.java diff --git a/src/main/java/com/retrip/auth/application/config/SecurityConfig.java b/src/main/java/com/retrip/auth/application/config/SecurityConfig.java index 4923c6a..357a3e3 100644 --- a/src/main/java/com/retrip/auth/application/config/SecurityConfig.java +++ b/src/main/java/com/retrip/auth/application/config/SecurityConfig.java @@ -1,5 +1,6 @@ package com.retrip.auth.application.config; +import com.retrip.auth.application.in.MemberService; import com.retrip.auth.infra.adapter.in.rest.filter.JwtAuthenticationFilter; import com.retrip.auth.infra.adapter.in.rest.filter.LoginAuthenticationFilter; import lombok.RequiredArgsConstructor; @@ -33,8 +34,12 @@ public JwtAuthenticationFilter jwtAuthenticationFilter(JwtConfig jwtConfig){ return new JwtAuthenticationFilter(jwtConfig); } @Bean - public AuthenticationManager authenticationManager(HttpSecurity http, UsernamePasswordAuthenticationProvider usernamePasswordAuthenticationProvider) throws Exception { + public AuthenticationManager authenticationManager( + HttpSecurity http, + UsernamePasswordAuthenticationProvider usernamePasswordAuthenticationProvider, + MemberService memberService) throws Exception { return http.authenticationProvider(usernamePasswordAuthenticationProvider) + .userDetailsService(memberService) .getSharedObject(AuthenticationManagerBuilder.class) .build(); } diff --git a/src/main/java/com/retrip/auth/application/config/UsernamePasswordAuthentication.java b/src/main/java/com/retrip/auth/application/config/UsernamePasswordAuthentication.java index 01e38b6..f8162a0 100644 --- a/src/main/java/com/retrip/auth/application/config/UsernamePasswordAuthentication.java +++ b/src/main/java/com/retrip/auth/application/config/UsernamePasswordAuthentication.java @@ -2,8 +2,10 @@ import java.util.Collection; +import org.springframework.context.annotation.Bean; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.GrantedAuthority; +import org.springframework.stereotype.Component; public class UsernamePasswordAuthentication extends UsernamePasswordAuthenticationToken { //인증 완료 diff --git a/src/main/java/com/retrip/auth/application/config/UsernamePasswordAuthenticationProvider.java b/src/main/java/com/retrip/auth/application/config/UsernamePasswordAuthenticationProvider.java index 05a779f..8efb427 100644 --- a/src/main/java/com/retrip/auth/application/config/UsernamePasswordAuthenticationProvider.java +++ b/src/main/java/com/retrip/auth/application/config/UsernamePasswordAuthenticationProvider.java @@ -30,7 +30,11 @@ public Authentication authenticate(Authentication authentication) throw new BadCredentialsException("Bad credentials"); } - return new UsernamePasswordAuthenticationToken(username, password); + return new UsernamePasswordAuthenticationToken( + username, + password, + user.getAuthorities().stream().toList() + ); } @Override diff --git a/src/main/java/com/retrip/auth/application/in/MemberService.java b/src/main/java/com/retrip/auth/application/in/MemberService.java index 8106abf..e5a7d97 100644 --- a/src/main/java/com/retrip/auth/application/in/MemberService.java +++ b/src/main/java/com/retrip/auth/application/in/MemberService.java @@ -2,6 +2,7 @@ import com.retrip.auth.application.config.CustomUserDetails; import com.retrip.auth.application.in.usercase.ManageMemberUseCase; +import com.retrip.auth.application.out.repository.MemberQueryRepository; import com.retrip.auth.application.out.repository.MemberRepository; import com.retrip.auth.domain.entity.Member; import com.retrip.auth.domain.exception.MemberNotFoundException; @@ -17,11 +18,11 @@ @Transactional public class MemberService implements ManageMemberUseCase, UserDetailsService { - private final MemberRepository memberRepository; + private final MemberQueryRepository memberQueryRepository; @Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { - Member member = memberRepository.findByEmailValue(username).orElseThrow(MemberNotFoundException::new); + Member member = memberQueryRepository.findByEmailWithAuthorities(username).orElseThrow(MemberNotFoundException::new); return new CustomUserDetails(member); } } diff --git a/src/main/java/com/retrip/auth/application/out/repository/MemberQueryRepository.java b/src/main/java/com/retrip/auth/application/out/repository/MemberQueryRepository.java new file mode 100644 index 0000000..77cb4c8 --- /dev/null +++ b/src/main/java/com/retrip/auth/application/out/repository/MemberQueryRepository.java @@ -0,0 +1,10 @@ +package com.retrip.auth.application.out.repository; + +import com.retrip.auth.domain.entity.Member; + +import java.util.Optional; +import java.util.UUID; + +public interface MemberQueryRepository { + Optional findByEmailWithAuthorities(String email); +} diff --git a/src/main/java/com/retrip/auth/application/out/repository/MemberRepository.java b/src/main/java/com/retrip/auth/application/out/repository/MemberRepository.java index b51f1fc..c5ff13b 100644 --- a/src/main/java/com/retrip/auth/application/out/repository/MemberRepository.java +++ b/src/main/java/com/retrip/auth/application/out/repository/MemberRepository.java @@ -7,5 +7,4 @@ import org.springframework.data.jpa.repository.JpaRepository; public interface MemberRepository extends JpaRepository { - Optional findByEmailValue(String email); } diff --git a/src/main/java/com/retrip/auth/infra/adapter/in/rest/filter/LoginAuthenticationFilter.java b/src/main/java/com/retrip/auth/infra/adapter/in/rest/filter/LoginAuthenticationFilter.java index 6b41efb..b390c11 100644 --- a/src/main/java/com/retrip/auth/infra/adapter/in/rest/filter/LoginAuthenticationFilter.java +++ b/src/main/java/com/retrip/auth/infra/adapter/in/rest/filter/LoginAuthenticationFilter.java @@ -75,7 +75,14 @@ public LoginResponse.TokenResponse generateToken(Authentication authentication) String accessToken = Jwts.builder() .subject(authentication.getName()) - .claims(Map.of("username", authentication.getName(), "authorities", authorities)) + .claims( + Map.of( + "username", + authentication.getName(), + "authorities", + authorities + ) + ) .expiration(accessTokenExpireDate) .issuedAt(issuedDate) .signWith(key) diff --git a/src/main/java/com/retrip/auth/infra/adapter/in/rest/in/TestController.java b/src/main/java/com/retrip/auth/infra/adapter/in/rest/in/TestController.java new file mode 100644 index 0000000..af14c19 --- /dev/null +++ b/src/main/java/com/retrip/auth/infra/adapter/in/rest/in/TestController.java @@ -0,0 +1,15 @@ +package com.retrip.auth.infra.adapter.in.rest.in; + +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/test") +public class TestController { + @GetMapping + public String test(Authentication authentication){ + return authentication.getName(); + } +} diff --git a/src/main/java/com/retrip/auth/infra/adapter/out/persistence/mysql/query/MemberQuerydslRepository.java b/src/main/java/com/retrip/auth/infra/adapter/out/persistence/mysql/query/MemberQuerydslRepository.java new file mode 100644 index 0000000..e1747e5 --- /dev/null +++ b/src/main/java/com/retrip/auth/infra/adapter/out/persistence/mysql/query/MemberQuerydslRepository.java @@ -0,0 +1,30 @@ +package com.retrip.auth.infra.adapter.out.persistence.mysql.query; + +import static com.retrip.auth.domain.entity.QAuthority.authority; +import static com.retrip.auth.domain.entity.QMember.member; + +import com.querydsl.jpa.impl.JPAQueryFactory; +import com.retrip.auth.application.out.repository.MemberQueryRepository; +import com.retrip.auth.domain.entity.Member; + +import com.retrip.auth.domain.entity.QAuthority; + +import java.util.Optional; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +@RequiredArgsConstructor +@Repository +public class MemberQuerydslRepository implements MemberQueryRepository { + private final JPAQueryFactory query; + + @Override + public Optional findByEmailWithAuthorities(String email) { + return Optional.ofNullable(query.selectFrom(member) + .leftJoin(member.authorities.values, authority) + .fetchJoin() + .where(member.email.value.eq(email)) + .fetchOne()); + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 347f9bd..79a9a59 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -31,7 +31,7 @@ logging: RestTemplate: DEBUG token: jwt: - secret: 123 + secret: Kf9uX!7zQa2$rB3cP#dLmV8@eYtNwZxGjHsTrC1o header: Authorization prefix: Bearer access: diff --git a/src/test/java/com/retrip/auth/common/fixture/MemberFixture.java b/src/test/java/com/retrip/auth/common/fixture/MemberFixture.java deleted file mode 100644 index d81a00e..0000000 --- a/src/test/java/com/retrip/auth/common/fixture/MemberFixture.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.retrip.auth.common.fixture; - -import com.retrip.auth.application.in.request.LoginRequest; - -public abstract class MemberFixture { - public static LoginRequest loginRequest(String email, String password){ - return new LoginRequest(email,password); - } -} diff --git a/src/test/java/com/retrip/auth/infra/adapter/in/rest/filter/JwtAuthenticationFilterTest.java b/src/test/java/com/retrip/auth/infra/adapter/in/rest/filter/JwtAuthenticationFilterTest.java new file mode 100644 index 0000000..c14c28a --- /dev/null +++ b/src/test/java/com/retrip/auth/infra/adapter/in/rest/filter/JwtAuthenticationFilterTest.java @@ -0,0 +1,63 @@ +package com.retrip.auth.infra.adapter.in.rest.filter; + + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.retrip.auth.application.in.request.LoginRequest; +import com.retrip.auth.infra.adapter.in.rest.filter.base.BaseLoginAuthenticationTest; +import org.junit.jupiter.api.Test; +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.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; + +@SpringBootTest +@AutoConfigureMockMvc(addFilters = true) +class JwtAuthenticationFilterTest extends BaseLoginAuthenticationTest { + + @Autowired + private MockMvc mockMvc; + + @Test + void JwtLogin() throws Exception { + memberRepository.save(member); + + // given + LoginRequest request = new LoginRequest("test@naver.com", "1234"); + + //when + // headers 생성 + HttpHeaders loginHeader = new HttpHeaders(); + loginHeader.add("id", request.email()); + loginHeader.add("password", request.password()); + MvcResult mvcResult = mockMvc.perform(get("/login") + .contentType(MediaType.APPLICATION_JSON) + .headers(loginHeader)) + .andExpect(status().isOk()) + .andReturn(); //로그인 결과 + // 응답 본문 추출 + String responseBody = mvcResult.getResponse().getContentAsString(); + + // JSON 파싱 + ObjectMapper objectMapper = new ObjectMapper(); + JsonNode jsonNode = objectMapper.readTree(responseBody); + String accessToken = jsonNode.get("accessToken").asText(); + String refreshToken = jsonNode.get("refreshToken").asText(); + + + // when & then + HttpHeaders jwtHeader = new HttpHeaders(); + jwtHeader.add("Authorization", "Bearer " + accessToken); + mockMvc.perform(get("/test") + .contentType(MediaType.APPLICATION_JSON) + .headers(jwtHeader)) + .andExpect(status().isOk()) + .andExpect(content().string("test@naver.com")); + } +} diff --git a/src/test/java/com/retrip/auth/infra/adapter/in/rest/filter/LoginAuthenticationFilterTest.java b/src/test/java/com/retrip/auth/infra/adapter/in/rest/filter/LoginAuthenticationFilterTest.java index b705e19..b687694 100644 --- a/src/test/java/com/retrip/auth/infra/adapter/in/rest/filter/LoginAuthenticationFilterTest.java +++ b/src/test/java/com/retrip/auth/infra/adapter/in/rest/filter/LoginAuthenticationFilterTest.java @@ -41,7 +41,7 @@ class LoginAuthenticationFilterTest extends BaseLoginAuthenticationTest { .contentType(MediaType.APPLICATION_JSON) .headers(headers)) .andExpect(status().isOk()) - .andExpect(jsonPath("$.accessToken").value("access-token")) - .andExpect(jsonPath("$.refreshToken").value("refresh-token")); + .andExpect(jsonPath("$.accessToken").isNotEmpty()) + .andExpect(jsonPath("$.refreshToken").isNotEmpty()); } } diff --git a/src/test/java/com/retrip/auth/infra/adapter/in/rest/filter/base/BaseLoginAuthenticationTest.java b/src/test/java/com/retrip/auth/infra/adapter/in/rest/filter/base/BaseLoginAuthenticationTest.java index 3ab4236..ff58e90 100644 --- a/src/test/java/com/retrip/auth/infra/adapter/in/rest/filter/base/BaseLoginAuthenticationTest.java +++ b/src/test/java/com/retrip/auth/infra/adapter/in/rest/filter/base/BaseLoginAuthenticationTest.java @@ -1,8 +1,11 @@ package com.retrip.auth.infra.adapter.in.rest.filter.base; +import com.querydsl.jpa.impl.JPAQueryFactory; import com.retrip.auth.application.in.MemberService; +import com.retrip.auth.application.out.repository.MemberQueryRepository; import com.retrip.auth.application.out.repository.MemberRepository; import com.retrip.auth.domain.entity.Member; +import com.retrip.auth.infra.adapter.out.persistence.mysql.query.MemberQuerydslRepository; import org.junit.jupiter.api.BeforeEach; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; @@ -17,15 +20,17 @@ public abstract class BaseLoginAuthenticationTest { @Autowired protected PasswordEncoder passwordEncoder; - - + @Autowired + protected JPAQueryFactory jpaQueryFactory; + protected MemberQueryRepository memberQueryRepository; protected MemberService memberService; protected Member member; @BeforeEach void setUp() { - memberService = new MemberService(memberRepository); + memberQueryRepository = new MemberQuerydslRepository(jpaQueryFactory); + memberService = new MemberService(memberQueryRepository); member = Member.create( "테스트", "test@naver.com",