diff --git a/build.gradle b/build.gradle index 1e1dec121..54431c586 100644 --- a/build.gradle +++ b/build.gradle @@ -34,6 +34,10 @@ dependencies { annotationProcessor 'org.projectlombok:lombok' testImplementation 'org.springframework.boot:spring-boot-starter-test' implementation 'org.springframework.boot:spring-boot-starter-actuator' + + // Spring Security + implementation 'org.springframework.boot:spring-boot-starter-security' + implementation 'org.springframework.security:spring-security-test' } tasks.named('bootBuildImage') { diff --git a/src/main/java/com/depromeet/ExampleController.java b/src/main/java/com/depromeet/ExampleController.java index 989f3fa57..8e6773768 100644 --- a/src/main/java/com/depromeet/ExampleController.java +++ b/src/main/java/com/depromeet/ExampleController.java @@ -1,7 +1,10 @@ package com.depromeet; +import com.depromeet.global.error.ErrorResponse; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RestController; @@ -10,8 +13,8 @@ public class ExampleController { @Operation(summary = "게시글 생성", description = "API health check") - @GetMapping("/health-check") - public String hello() { - return "hello"; + @GetMapping("/v1/health-check") + public ResponseEntity hello() { + return ResponseEntity.ok(ErrorResponse.of(HttpStatus.OK, "Hello World!")); } } diff --git a/src/main/java/com/depromeet/domain/user/api/.gitkeep b/src/main/java/com/depromeet/domain/member/api/.gitkeep similarity index 100% rename from src/main/java/com/depromeet/domain/user/api/.gitkeep rename to src/main/java/com/depromeet/domain/member/api/.gitkeep diff --git a/src/main/java/com/depromeet/domain/user/application/.gitkeep b/src/main/java/com/depromeet/domain/member/application/.gitkeep similarity index 100% rename from src/main/java/com/depromeet/domain/user/application/.gitkeep rename to src/main/java/com/depromeet/domain/member/application/.gitkeep diff --git a/src/main/java/com/depromeet/domain/member/dao/MemberRepository.java b/src/main/java/com/depromeet/domain/member/dao/MemberRepository.java new file mode 100644 index 000000000..cd73c7fa2 --- /dev/null +++ b/src/main/java/com/depromeet/domain/member/dao/MemberRepository.java @@ -0,0 +1,6 @@ +package com.depromeet.domain.member.dao; + +import com.depromeet.domain.member.domain.Member; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface MemberRepository extends JpaRepository {} diff --git a/src/main/java/com/depromeet/domain/member/domain/Member.java b/src/main/java/com/depromeet/domain/member/domain/Member.java new file mode 100644 index 000000000..0cbdca5f6 --- /dev/null +++ b/src/main/java/com/depromeet/domain/member/domain/Member.java @@ -0,0 +1,57 @@ +package com.depromeet.domain.member.domain; + +import com.depromeet.domain.common.model.BaseTimeEntity; +import jakarta.persistence.*; +import java.time.LocalDateTime; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Member extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "member_id") + private Long id; + + @Embedded private Profile profile; + + @Enumerated(EnumType.STRING) + private MemberStatus status; + + @Enumerated(EnumType.STRING) + private MemberRole role; + + @Enumerated(EnumType.STRING) + private MemberVisibility visibility; + + private LocalDateTime lastLoginAt; + + @Builder(access = AccessLevel.PRIVATE) + private Member( + Profile profile, + MemberStatus status, + MemberRole role, + MemberVisibility visibility, + LocalDateTime lastLoginAt) { + this.profile = profile; + this.status = status; + this.role = role; + this.visibility = visibility; + this.lastLoginAt = lastLoginAt; + } + + public static Member createNormalMember(Profile profile) { + return Member.builder() + .profile(profile) + .status(MemberStatus.NORMAL) + .role(MemberRole.USER) + .visibility(MemberVisibility.PUBLIC) + .lastLoginAt(LocalDateTime.now()) + .build(); + } +} diff --git a/src/main/java/com/depromeet/domain/member/domain/MemberRole.java b/src/main/java/com/depromeet/domain/member/domain/MemberRole.java new file mode 100644 index 000000000..9fa28faf4 --- /dev/null +++ b/src/main/java/com/depromeet/domain/member/domain/MemberRole.java @@ -0,0 +1,13 @@ +package com.depromeet.domain.member.domain; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum MemberRole { + USER("USER"), + ADMIN("ADMIN"); + + private final String value; +} diff --git a/src/main/java/com/depromeet/domain/member/domain/MemberStatus.java b/src/main/java/com/depromeet/domain/member/domain/MemberStatus.java new file mode 100644 index 000000000..c0e148f31 --- /dev/null +++ b/src/main/java/com/depromeet/domain/member/domain/MemberStatus.java @@ -0,0 +1,14 @@ +package com.depromeet.domain.member.domain; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum MemberStatus { + NORMAL("NORMAL"), + DELETED("DELETED"), + FORBIDDEN("FORBIDDEN"); + + private final String value; +} diff --git a/src/main/java/com/depromeet/domain/member/domain/MemberVisibility.java b/src/main/java/com/depromeet/domain/member/domain/MemberVisibility.java new file mode 100644 index 000000000..f9e48cbe6 --- /dev/null +++ b/src/main/java/com/depromeet/domain/member/domain/MemberVisibility.java @@ -0,0 +1,13 @@ +package com.depromeet.domain.member.domain; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum MemberVisibility { + PUBLIC("PUBLIC"), + PRIVATE("PRIVATE"); + + private final String value; +} diff --git a/src/main/java/com/depromeet/domain/member/domain/Profile.java b/src/main/java/com/depromeet/domain/member/domain/Profile.java new file mode 100644 index 000000000..966e14873 --- /dev/null +++ b/src/main/java/com/depromeet/domain/member/domain/Profile.java @@ -0,0 +1,3 @@ +package com.depromeet.domain.member.domain; + +public record Profile(String nickname, String profileImageUrl) {} diff --git a/src/main/java/com/depromeet/domain/user/dao/.gitkeep b/src/main/java/com/depromeet/domain/member/dto/.gitkeep similarity index 100% rename from src/main/java/com/depromeet/domain/user/dao/.gitkeep rename to src/main/java/com/depromeet/domain/member/dto/.gitkeep diff --git a/src/main/java/com/depromeet/domain/user/domain/.gitkeep b/src/main/java/com/depromeet/domain/member/exception/.gitkeep similarity index 100% rename from src/main/java/com/depromeet/domain/user/domain/.gitkeep rename to src/main/java/com/depromeet/domain/member/exception/.gitkeep diff --git a/src/main/java/com/depromeet/domain/user/dto/.gitkeep b/src/main/java/com/depromeet/domain/user/dto/.gitkeep deleted file mode 100644 index e69de29bb..000000000 diff --git a/src/main/java/com/depromeet/domain/user/exception/.gitkeep b/src/main/java/com/depromeet/domain/user/exception/.gitkeep deleted file mode 100644 index e69de29bb..000000000 diff --git a/src/main/java/com/depromeet/global/config/security/PrincipalDetails.java b/src/main/java/com/depromeet/global/config/security/PrincipalDetails.java new file mode 100644 index 000000000..0b2daaf29 --- /dev/null +++ b/src/main/java/com/depromeet/global/config/security/PrincipalDetails.java @@ -0,0 +1,50 @@ +package com.depromeet.global.config.security; + +import java.util.Collection; +import java.util.Collections; +import lombok.AllArgsConstructor; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; + +@AllArgsConstructor +public class PrincipalDetails implements UserDetails { + + private final Long memberId; + private final String role; + + @Override + public Collection getAuthorities() { + return Collections.singleton(new SimpleGrantedAuthority("ROLE_" + role)); + } + + @Override + public String getPassword() { + return null; + } + + @Override + public String getUsername() { + return memberId.toString(); + } + + @Override + public boolean isAccountNonExpired() { + return true; + } + + @Override + public boolean isAccountNonLocked() { + return true; + } + + @Override + public boolean isCredentialsNonExpired() { + return true; + } + + @Override + public boolean isEnabled() { + return true; + } +} diff --git a/src/main/java/com/depromeet/global/config/security/WebSecurityConfig.java b/src/main/java/com/depromeet/global/config/security/WebSecurityConfig.java new file mode 100644 index 000000000..abd434c46 --- /dev/null +++ b/src/main/java/com/depromeet/global/config/security/WebSecurityConfig.java @@ -0,0 +1,51 @@ +package com.depromeet.global.config.security; + +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.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; + +@Configuration +@EnableWebSecurity +public class WebSecurityConfig { + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + http.csrf(AbstractHttpConfigurer::disable); + + http.authorizeHttpRequests( + authorize -> + authorize + .requestMatchers("/10mm-actuator/**") + .permitAll() // 액추에이터 + .requestMatchers("/v1/**") + .permitAll() // 임시로 모든 요청 허용 + .anyRequest() + .authenticated()); + + return http.build(); + } + + @Bean + public CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration configuration = new CorsConfiguration(); + + configuration.addAllowedOriginPattern("https://10mm.today"); + + // TODO: 운영환경에 따라 허용되는 도메인이 달라지도록 개선 + configuration.addAllowedOriginPattern("http://localhost:3000"); + + configuration.addAllowedHeader("*"); + configuration.addAllowedMethod("*"); + configuration.setAllowCredentials(true); + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", configuration); + return source; + } +} diff --git a/src/main/java/com/depromeet/global/error/exception/CustomException.java b/src/main/java/com/depromeet/global/error/exception/CustomException.java index 36a13f708..831c5baea 100644 --- a/src/main/java/com/depromeet/global/error/exception/CustomException.java +++ b/src/main/java/com/depromeet/global/error/exception/CustomException.java @@ -1,11 +1,14 @@ package com.depromeet.global.error.exception; -import lombok.AllArgsConstructor; import lombok.Getter; @Getter -@AllArgsConstructor public class CustomException extends RuntimeException { private final ErrorCode errorCode; + + public CustomException(ErrorCode errorCode) { + super(errorCode.getMessage()); + this.errorCode = errorCode; + } } diff --git a/src/main/java/com/depromeet/global/error/exception/ErrorCode.java b/src/main/java/com/depromeet/global/error/exception/ErrorCode.java index 073254d8e..13dfbf30c 100644 --- a/src/main/java/com/depromeet/global/error/exception/ErrorCode.java +++ b/src/main/java/com/depromeet/global/error/exception/ErrorCode.java @@ -8,6 +8,12 @@ @AllArgsConstructor public enum ErrorCode { SAMPLE_ERROR(HttpStatus.BAD_REQUEST, "Sample Error Message"), + + // Member + MEMBER_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 회원을 찾을 수 없습니다."), + + // Security + AUTH_NOT_FOUND(HttpStatus.INTERNAL_SERVER_ERROR, "시큐리티 인증 정보를 찾을 수 없습니다."), ; private final HttpStatus status; diff --git a/src/main/java/com/depromeet/global/util/.gitkeep b/src/main/java/com/depromeet/global/util/.gitkeep deleted file mode 100644 index e69de29bb..000000000 diff --git a/src/main/java/com/depromeet/global/util/MemberUtil.java b/src/main/java/com/depromeet/global/util/MemberUtil.java new file mode 100644 index 000000000..feead7b31 --- /dev/null +++ b/src/main/java/com/depromeet/global/util/MemberUtil.java @@ -0,0 +1,36 @@ +package com.depromeet.global.util; + +import com.depromeet.domain.member.dao.MemberRepository; +import com.depromeet.domain.member.domain.Member; +import com.depromeet.domain.member.domain.Profile; +import com.depromeet.global.error.exception.CustomException; +import com.depromeet.global.error.exception.ErrorCode; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class MemberUtil { + + private final SecurityUtil securityUtil; + private final MemberRepository memberRepository; + + // TODO: 데이터베이스 연동 및 픽스쳐 데이터 삽입 이후 삭제 + private void insertMockMemberIfNotExist() { + if (memberRepository.count() != 0) { + return; + } + + Member memeber = Member.createNormalMember(new Profile("testNickname", "testImageUrl")); + + memberRepository.save(memeber); + } + + public Member getCurrentMember() { + insertMockMemberIfNotExist(); + + return memberRepository + .findById(securityUtil.getCurrentMemberId()) + .orElseThrow(() -> new CustomException(ErrorCode.MEMBER_NOT_FOUND)); + } +} diff --git a/src/main/java/com/depromeet/global/util/SecurityUtil.java b/src/main/java/com/depromeet/global/util/SecurityUtil.java new file mode 100644 index 000000000..965e194f5 --- /dev/null +++ b/src/main/java/com/depromeet/global/util/SecurityUtil.java @@ -0,0 +1,27 @@ +package com.depromeet.global.util; + +import com.depromeet.global.config.security.PrincipalDetails; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; + +@Component +public class SecurityUtil { + + private void setMockAuthentication() { + PrincipalDetails principal = new PrincipalDetails(1L, "USER"); + Authentication authentication = + new UsernamePasswordAuthenticationToken( + principal, "password", principal.getAuthorities()); + SecurityContextHolder.getContext().setAuthentication(authentication); + } + + public Long getCurrentMemberId() { + setMockAuthentication(); + PrincipalDetails principal = + (PrincipalDetails) + SecurityContextHolder.getContext().getAuthentication().getPrincipal(); + return Long.parseLong(principal.getUsername()); + } +} diff --git a/src/test/java/com/depromeet/domain/member/domain/MemberTest.java b/src/test/java/com/depromeet/domain/member/domain/MemberTest.java new file mode 100644 index 000000000..eaf877959 --- /dev/null +++ b/src/test/java/com/depromeet/domain/member/domain/MemberTest.java @@ -0,0 +1,53 @@ +package com.depromeet.domain.member.domain; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class MemberTest { + + // Fixture + Profile profile; + + @BeforeEach + void setUp() { + profile = new Profile("testNickname", "testProfileImageUrl"); + } + + @Test + void 회원가입시_초기_상태는_NORMAL이다() { + // given + Member member = Member.createNormalMember(profile); + + // when + MemberStatus status = member.getStatus(); + + // then + assertEquals(MemberStatus.NORMAL, status); + } + + @Test + void 회원가입시_초기_역할은_USER이다() { + // given + Member member = Member.createNormalMember(profile); + + // when + MemberRole role = member.getRole(); + + // then + assertEquals(MemberRole.USER, role); + } + + @Test + void 회원가입시_초기_공개여부는_PUBLIC이다() { + // given + Member member = Member.createNormalMember(profile); + + // when + MemberVisibility visibility = member.getVisibility(); + + // then + assertEquals(MemberVisibility.PUBLIC, visibility); + } +} diff --git a/src/test/java/com/depromeet/global/util/MemberUtilTest.java b/src/test/java/com/depromeet/global/util/MemberUtilTest.java new file mode 100644 index 000000000..efcf17eed --- /dev/null +++ b/src/test/java/com/depromeet/global/util/MemberUtilTest.java @@ -0,0 +1,39 @@ +package com.depromeet.global.util; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import com.depromeet.domain.member.dao.MemberRepository; +import com.depromeet.domain.member.domain.Member; +import com.depromeet.domain.member.domain.Profile; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +class MemberUtilTest { + + @Autowired private MemberUtil memberUtil; + @Autowired private MemberRepository memberRepository; + + @Test + void 이미_회원이_존재하면_임시_회원을_삽입하지_않는다() { + // given + Member member = Member.createNormalMember(new Profile("testNickname", "testImageUrl")); + memberRepository.save(member); + + // when + memberUtil.getCurrentMember(); + + // then + assertEquals(1, memberRepository.count()); + } + + @Test + void 현재_로그인한_회원ID는_1이다() { + // given & when + Member currentMember = memberUtil.getCurrentMember(); + + // then + assertEquals(1L, currentMember.getId()); + } +}