diff --git a/README.md b/README.md index a6d18c3e1..b5fbe76da 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,59 @@ ![123](https://github.com/user-attachments/assets/a6256292-33d9-4801-9b9d-78f11d9dea13) +> Bottle note 프로젝트의 API 서버입니다. -## Bottle Note Api Server +Image 1 +Image 4 +Image 3 +Image 2 +
-### 개요(Overview) +## *bottle note* -> Bottle note 프로젝트의 API 서버입니다. +--- + +**📄*내 입맛에 딱 맞는 한병을 찾아가는 여정노트*** + +보틀노트는 위스키 애호가들이 모여 서로의 의견을 공유하고, 더욱 즐겁고 유익한 시음 경험을 할 수 있도록 도와줍니다.
+위스키에 대한 열정을 함께 나눠보세요! 쉽고 편리하게 위스키 평가와 리뷰를 공유할 수 있어요! + +--- + +**🥃 위스키 별점 및 리뷰 작성** + +위스키를 시음한 후 별점을 부여하고, 상세한 리뷰를 작성해 보세요!
+맛, 향, 느낌 등 다양한 측면을 기록하고 공유할 수 있습니다.
+가격을 입력하고, 시음한 장소를 태그하여 좋은 경험한 장소를 기록해 보는건 어때요?
+
+ +**🥃 위스키 정보 및 리뷰 보기** + +위스키의 상세 정보와 다른 사용자의 리뷰를 쉽게 확인할 수 있습니다.
+다양한 의견을 통해 다른 사람들이 위스키를 어떻게 평가했는지 알 수 있고
+새로운 위스키를 시도해볼 수 있어요!
+
+ +**🥃 찜하기 기능으로 위스키 목록 관리** + +관심 있는 위스키를 찜하기 기능을 통해 목록에 추가하고
+찜한 위스키 목록을 통해 나만의 위스키 컬렉션을 만들 수 있어요!
+
+**🥃 다른 유저와 팔로우 및 별점 보기** + +다른 사용자를 팔로우하여 그들의 별점과 리뷰를 볼 수 있어요!
+나의 팔로워들은 어떤 평가를 했을까요?
+팔로워가 나와 취향이 비슷하다면, 내 입맛에 맞는 새로운 위스키를 알 수 있을지도?
+
+ +**🥃 나의 평가 및 별점 기록 관리** + +내가 평가한 위스키와 부여한 별점을 한눈에 볼 수 있어요!
+나만의 위스키 평가 기록을 통해 나의 취향을 분석하고 다음 시음을 계획해보세요!
+
+ +--- +보틀노트는 위스키 애호가들이 모여 서로의 의견을 공유하고
+더욱 즐겁고 유익한 시음 경험을 할 수 있도록 도와줍니다. -![boton](https://github.com/user-attachments/assets/50bf121a-fb97-46d6-9d1f-6e56858363c1) -![CodeRabbit Pull Request Reviews](https://img.shields.io/coderabbit/prs/github/bottle-note/bottle-note-api-server?utm_source=oss&utm_medium=github&utm_campaign=bottle-note%2Fbottle-note-api-server&labelColor=171717&color=FF570A&link=https%3A%2F%2Fcoderabbit.ai&label=CodeRabbit+Reviews) +![boton](https://github.com/user-attachments/assets/d8750770-1e6a-4133-86a4-298f795420a6) diff --git a/build.gradle b/build.gradle index 55fb2b284..99fac61f6 100644 --- a/build.gradle +++ b/build.gradle @@ -216,7 +216,7 @@ tasks.register('unitTest', Test) { includeTags 'unit' } } -tasks.register('check rule', Test) { +tasks.register('check_rule', Test) { useJUnitPlatform { includeTags 'rule' } diff --git a/git.environment-variables b/git.environment-variables index cd96d787a..2bf643353 160000 --- a/git.environment-variables +++ b/git.environment-variables @@ -1 +1 @@ -Subproject commit cd96d787a9bb9e9554ae209da068ab3060344bdd +Subproject commit 2bf6433534696b910c69c8237689ce9753e52f9a diff --git "a/http/\354\234\204\354\212\244\355\202\244/\354\234\204\354\212\244\355\202\244_\352\262\200\354\203\211.http" "b/http/\354\234\204\354\212\244\355\202\244/\354\234\204\354\212\244\355\202\244_\352\262\200\354\203\211.http" index a2c9e4b8d..78959e659 100644 --- "a/http/\354\234\204\354\212\244\355\202\244/\354\234\204\354\212\244\355\202\244_\352\262\200\354\203\211.http" +++ "b/http/\354\234\204\354\212\244\355\202\244/\354\234\204\354\212\244\355\202\244_\352\262\200\354\203\211.http" @@ -18,13 +18,16 @@ Content-Type: application/json %} ### 위스키 목록 조회 - -GET {{host}}/api/v1/alcohols/search?category=SINGLE_MALT&cursor=1&pageSize=1&keyword=초콜릿 +# @no-cookie-jar +GET {{host}}/api/v1/alcohols/search?category=SINGLE_MALT&cursor=1&pageSize=10&keyword=비 오늘 날 추천 위스키 Authorization: Bearer {{accessToken}} + ### 위스키 조회 상세 조회 +# @no-cookie-jar GET {{host}}/api/v1/alcohols/4 Authorization: Bearer {{accessToken}} ### 카테고리 조회 +# @no-cookie-jar GET {{host}}/api/v1/alcohols/categories diff --git a/src/docs/asciidoc/api/auth/auth.adoc b/src/docs/asciidoc/api/auth/auth.adoc new file mode 100644 index 000000000..fcb8c81cf --- /dev/null +++ b/src/docs/asciidoc/api/auth/auth.adoc @@ -0,0 +1,23 @@ +=== 루트 관리자 권한 검증 === + +- 현재 로그인한 사용자가 루트 관리자 권한을 가지고 있는지 검증합니다. +- 개발 / 운영 환경이 변경 가능한지 검증하는데 활용 할 수 있습니다. + +[source] +---- +api/v2/auth/admin/permissions +---- + +[discrete] +==== 요청 파라미터 ==== + +[discrete] +include::{snippets}/auth/admin/root-permissions/curl-request.adoc[] +include::{snippets}/auth/admin/root-permissions/http-request.adoc[] + +[discrete] +==== 응답 파라미터 ==== + +[discrete] +include::{snippets}/auth/admin/root-permissions/response-fields.adoc[] +include::{snippets}/auth/admin/root-permissions/http-response.adoc[] diff --git a/src/docs/asciidoc/index.adoc b/src/docs/asciidoc/index.adoc index 48b8b30bc..b206b06c9 100644 --- a/src/docs/asciidoc/index.adoc +++ b/src/docs/asciidoc/index.adoc @@ -84,6 +84,11 @@ include::api/overview/global-auth.adoc[] ''' include::api/file/image/upload/presign-url.adoc[] +== 권한 (auth) 관련 API + +''' +include::api/auth/auth.adoc[] + == 회원 (user) 관련 API ''' diff --git a/src/main/java/app/bottlenote/global/security/jwt/JwtAuthenticationFilter.java b/src/main/java/app/bottlenote/global/security/jwt/JwtAuthenticationFilter.java index 9370be491..d063db410 100644 --- a/src/main/java/app/bottlenote/global/security/jwt/JwtAuthenticationFilter.java +++ b/src/main/java/app/bottlenote/global/security/jwt/JwtAuthenticationFilter.java @@ -154,6 +154,10 @@ protected boolean shouldNotFilter(HttpServletRequest request) { * 다만 내가 픽했는지 여부등을 파악하기 위해 선택적으로 제공 *

* 수정 : 2024-11-10 + * + * TODO: 현재 하드코딩된 경로 관리 방식을 애노테이션 기반으로 개선 필요 + * TODO: @AccessPolicy 또는 @OptionalAuth 애노테이션을 활용하여 컨트롤러에서 선언적으로 관리 + * TODO: 경로 추가 시마다 두 곳(SecurityConfig + 여기)을 수정해야 하는 문제 해결 필요 */ private boolean skipFilter(String method, String url) { final String targetPath = method + ":" + url; diff --git a/src/main/java/app/bottlenote/global/security/jwt/JwtProperties.java b/src/main/java/app/bottlenote/global/security/jwt/JwtProperties.java new file mode 100644 index 000000000..66f8f8294 --- /dev/null +++ b/src/main/java/app/bottlenote/global/security/jwt/JwtProperties.java @@ -0,0 +1,23 @@ +package app.bottlenote.global.security.jwt; + +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.ConfigurationPropertiesScan; +import org.springframework.context.annotation.Configuration; + +@Setter +@Getter +@Configuration +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@ConfigurationProperties(prefix = "security.jwt") +@ConfigurationPropertiesScan +public class JwtProperties { + private String secretKey; + private long accessTokenExpiration; + private long refreshTokenExpiration; +} diff --git a/src/main/java/app/bottlenote/global/security/jwt/JwtTokenProvider.java b/src/main/java/app/bottlenote/global/security/jwt/JwtTokenProvider.java index add3c8fe9..d208cc767 100644 --- a/src/main/java/app/bottlenote/global/security/jwt/JwtTokenProvider.java +++ b/src/main/java/app/bottlenote/global/security/jwt/JwtTokenProvider.java @@ -19,8 +19,8 @@ @Component public class JwtTokenProvider { - public static final int ACCESS_TOKEN_EXPIRE_TIME = 1000 * 60 * 60 * 24; // 24시간 - public static final int REFRESH_TOKEN_EXPIRE_TIME = 1000 * 60 * 60 * 24 * 14; // 14일 + public static final int ACCESS_TOKEN_EXPIRE_TIME = 1000 * 60 * 60 * 24 * 2; + public static final int REFRESH_TOKEN_EXPIRE_TIME = 1000 * 60 * 60 * 24 * 14; public static final String KEY_ROLES = "roles"; private final Key secretKey; @@ -30,7 +30,7 @@ public class JwtTokenProvider { * @param secret 토큰 생성용 시크릿 키 */ public JwtTokenProvider( - @Value("${security.jwt.secret-key}") String secret + @Value("${security.jwt.secret-key}") String secret ) { byte[] keyBytes = Decoders.BASE64.decode(secret); this.secretKey = Keys.hmacShaKeyFor(keyBytes); @@ -48,9 +48,9 @@ public TokenItem generateToken(String userEmail, UserType role, Long userId) { String accessToken = createAccessToken(userEmail, role, userId); String refreshToken = createRefreshToken(userEmail, role, userId); return TokenItem.builder() - .accessToken(accessToken) - .refreshToken(refreshToken) - .build(); + .accessToken(accessToken) + .refreshToken(refreshToken) + .build(); } /** @@ -65,11 +65,11 @@ public String createAccessToken(String userEmail, UserType role, Long userId) { Claims claims = createClaims(userEmail, role, userId); Date now = new Date(); return Jwts.builder() - .setClaims(claims) - .setIssuedAt(now) - .setExpiration(new Date(now.getTime() + ACCESS_TOKEN_EXPIRE_TIME)) - .signWith(secretKey, SignatureAlgorithm.HS512) - .compact(); + .setClaims(claims) + .setIssuedAt(now) + .setExpiration(new Date(now.getTime() + ACCESS_TOKEN_EXPIRE_TIME)) + .signWith(secretKey, SignatureAlgorithm.HS512) + .compact(); } public String createGuestToken(Long userId, int expireTime) { @@ -78,12 +78,12 @@ public String createGuestToken(Long userId, int expireTime) { claims.put(KEY_ROLES, UserType.ROLE_GUEST.name()); Date now = new Date(); return Jwts.builder() - .setClaims(claims) - .setSubject("guest") - .setIssuedAt(now) - .setExpiration(new Date(now.getTime() + expireTime)) - .signWith(secretKey, SignatureAlgorithm.HS512) - .compact(); + .setClaims(claims) + .setSubject("guest") + .setIssuedAt(now) + .setExpiration(new Date(now.getTime() + expireTime)) + .signWith(secretKey, SignatureAlgorithm.HS512) + .compact(); } /** @@ -98,11 +98,11 @@ public String createRefreshToken(String userEmail, UserType role, Long userId) { Claims claims = createClaims(userEmail, role, userId); Date now = new Date(); return Jwts.builder() - .setClaims(claims) - .setIssuedAt(now) - .setExpiration(new Date(now.getTime() + REFRESH_TOKEN_EXPIRE_TIME)) - .signWith(secretKey, SignatureAlgorithm.HS512) - .compact(); + .setClaims(claims) + .setIssuedAt(now) + .setExpiration(new Date(now.getTime() + REFRESH_TOKEN_EXPIRE_TIME)) + .signWith(secretKey, SignatureAlgorithm.HS512) + .compact(); } /** diff --git a/src/main/java/app/bottlenote/user/controller/AuthV2Controller.java b/src/main/java/app/bottlenote/user/controller/AuthV2Controller.java new file mode 100644 index 000000000..cf20e64f4 --- /dev/null +++ b/src/main/java/app/bottlenote/user/controller/AuthV2Controller.java @@ -0,0 +1,32 @@ +package app.bottlenote.user.controller; + +import app.bottlenote.global.data.response.GlobalResponse; +import app.bottlenote.global.security.SecurityContextUtil; +import app.bottlenote.user.exception.UserException; +import app.bottlenote.user.service.AuthService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +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 static app.bottlenote.user.exception.UserExceptionCode.REQUIRED_USER_ID; + + +@Slf4j +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v2/auth") +public class AuthV2Controller { + private final AuthService authService; + + @GetMapping("/admin/permissions") + public ResponseEntity checkAdminStatus() { + Long currentUserId = SecurityContextUtil.getUserIdByContext(). + orElseThrow(() -> new UserException(REQUIRED_USER_ID)); + + boolean is = authService.checkAdminStatus(currentUserId); + return GlobalResponse.ok(is); + } +} diff --git a/src/main/java/app/bottlenote/user/domain/RootAdmin.java b/src/main/java/app/bottlenote/user/domain/RootAdmin.java new file mode 100644 index 000000000..413201702 --- /dev/null +++ b/src/main/java/app/bottlenote/user/domain/RootAdmin.java @@ -0,0 +1,29 @@ +package app.bottlenote.user.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.MapsId; +import jakarta.persistence.OneToOne; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity(name = "root_admins") +@Table(name = "root_admins") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class RootAdmin { + + @Id + @Column(name = "user_id") + private Long userId; + + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id") + @MapsId + private User user; +} diff --git a/src/main/java/app/bottlenote/user/repository/RootAdminRepository.java b/src/main/java/app/bottlenote/user/repository/RootAdminRepository.java new file mode 100644 index 000000000..7e3a5d6c1 --- /dev/null +++ b/src/main/java/app/bottlenote/user/repository/RootAdminRepository.java @@ -0,0 +1,8 @@ +package app.bottlenote.user.repository; + +import app.bottlenote.user.domain.RootAdmin; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface RootAdminRepository extends JpaRepository { + boolean existsByUserId(Long userId); +} diff --git a/src/main/java/app/bottlenote/user/service/AuthService.java b/src/main/java/app/bottlenote/user/service/AuthService.java new file mode 100644 index 000000000..6c7260d93 --- /dev/null +++ b/src/main/java/app/bottlenote/user/service/AuthService.java @@ -0,0 +1,20 @@ +package app.bottlenote.user.service; + +import app.bottlenote.user.repository.RootAdminRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Service +@RequiredArgsConstructor +public class AuthService { + + private final RootAdminRepository rootAdminRepository; + + @Transactional(readOnly = true) + public boolean checkAdminStatus(Long userId) { + return rootAdminRepository.existsByUserId(userId); + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index e8d5b08ef..ac206f6f8 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -60,6 +60,9 @@ spring: security: jwt: secret-key: ${JWT_SECRET_KEY:c2VjdXJlU2VjcmV0S2V5MTIzNDU2Nzg5MGFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6QWJDRGVGR2hJSktMTU5PUFFSU1RVVldYWVphYmNkZWZnaGlrSg==} + access-token-expiration: 86400 # 하루 (24시간) + refresh-token-expiration: 2592000 # 한 달 (30일) + # Actuator 설정 management: diff --git a/src/test/java/app/docs/user/RestAuthV2ControllerTest.java b/src/test/java/app/docs/user/RestAuthV2ControllerTest.java new file mode 100644 index 000000000..0124a9918 --- /dev/null +++ b/src/test/java/app/docs/user/RestAuthV2ControllerTest.java @@ -0,0 +1,80 @@ +package app.docs.user; + +import app.bottlenote.global.security.SecurityContextUtil; +import app.bottlenote.user.controller.AuthV2Controller; +import app.bottlenote.user.service.AuthService; +import app.docs.AbstractRestDocs; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; +import org.springframework.http.MediaType; + +import java.util.Optional; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.when; +import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document; +import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; +import static org.springframework.restdocs.payload.PayloadDocumentation.responseFields; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@Tag("document") +@DisplayName("유저 Auth 컨트롤러 V2x RestDocs 테스트") +class RestAuthV2ControllerTest extends AbstractRestDocs { + private final AuthService authService = mock(AuthService.class); + private MockedStatic mockedSecurityUtil; + + @Override + protected Object initController() { + return new AuthV2Controller(authService); + } + + @BeforeEach + void setup() { + mockedSecurityUtil = mockStatic(SecurityContextUtil.class); + } + + + @AfterEach + void tearDown() { + mockedSecurityUtil.close(); + } + + @Test + @DisplayName("루트 어드민 검증을 수행합니다.") + void login_test() throws Exception { + + //given + final long userId = 1L; + when(SecurityContextUtil.getUserIdByContext()).thenReturn(Optional.of(userId)); + + //when + when(authService.checkAdminStatus(userId)).thenReturn(true); + + //then + mockMvc.perform(get("/api/v2/auth/admin/permissions") + .contentType(MediaType.APPLICATION_JSON) + .with(csrf())) + .andExpect(status().isOk()) + .andDo( + document("auth/admin/root-permissions", + responseFields( + fieldWithPath("success").description("응답 성공 여부"), + fieldWithPath("code").description("응답 코드(http status code)"), + fieldWithPath("data").description("검증 결과"), + fieldWithPath("errors").description("응답 성공 여부가 false일 경우 에러 메시지(없을 경우 null)"), + fieldWithPath("meta.serverEncoding").description("서버 인코딩 정도"), + fieldWithPath("meta.serverVersion").description("서버 버전"), + fieldWithPath("meta.serverPathVersion").description("서버 경로 버전"), + fieldWithPath("meta.serverResponseTime").description("서버 응답 시간") + ) + ) + ); + } +} diff --git a/src/test/java/app/rule/api/ControllerLayerRules.java b/src/test/java/app/rule/api/ControllerLayerRules.java index 90944632f..c04677741 100644 --- a/src/test/java/app/rule/api/ControllerLayerRules.java +++ b/src/test/java/app/rule/api/ControllerLayerRules.java @@ -341,6 +341,7 @@ public void check(JavaMethod method, ConditionEvents events) { allowedPrefixesMap.put("restore", "복원하다"); allowedPrefixesMap.put("reissue", "재발급하다"); allowedPrefixesMap.put("signup", "가입하다"); + allowedPrefixesMap.put("check", "확인하다"); boolean startsWithVerb = allowedPrefixesMap.keySet().stream() .anyMatch(methodName::startsWith);