diff --git a/src/main/java/com/moplus/moplus_server/domain/member/controller/MemberController.java b/src/main/java/com/moplus/moplus_server/domain/member/controller/MemberController.java new file mode 100644 index 0000000..c23ddc7 --- /dev/null +++ b/src/main/java/com/moplus/moplus_server/domain/member/controller/MemberController.java @@ -0,0 +1,26 @@ +package com.moplus.moplus_server.domain.member.controller; + +import com.moplus.moplus_server.domain.member.domain.Member; +import com.moplus.moplus_server.domain.member.dto.response.MemberGetResponse; +import com.moplus.moplus_server.global.annotation.AuthUser; +import io.swagger.v3.oas.annotations.Operation; +import lombok.RequiredArgsConstructor; +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; + +@RestController +@RequestMapping("/api/v1/member") +@RequiredArgsConstructor +public class MemberController { + + @GetMapping("me") + @Operation(summary = "내 정보 조회", description = "jwt accessToken을 통해 내 정보를 조회합니다.") + public ResponseEntity getMyInfo( + @AuthUser Member member + ) { + return ResponseEntity.ok(MemberGetResponse.of(member)); + } +} + diff --git a/src/main/java/com/moplus/moplus_server/domain/member/domain/Member.java b/src/main/java/com/moplus/moplus_server/domain/member/domain/Member.java index 289a78d..fb000bd 100644 --- a/src/main/java/com/moplus/moplus_server/domain/member/domain/Member.java +++ b/src/main/java/com/moplus/moplus_server/domain/member/domain/Member.java @@ -23,6 +23,7 @@ public class Member extends BaseEntity { @Column(name = "member_id") private Long id; + private String name; private String email; private String password; @@ -30,7 +31,8 @@ public class Member extends BaseEntity { private MemberRole role; @Builder - public Member(String nickname, String email, String password, MemberRole role) { + public Member(String name, String email, String password, MemberRole role) { + this.name = name; this.email = email; this.password = password; this.role = role; diff --git a/src/main/java/com/moplus/moplus_server/domain/member/dto/response/MemberGetResponse.java b/src/main/java/com/moplus/moplus_server/domain/member/dto/response/MemberGetResponse.java new file mode 100644 index 0000000..0585dac --- /dev/null +++ b/src/main/java/com/moplus/moplus_server/domain/member/dto/response/MemberGetResponse.java @@ -0,0 +1,18 @@ +package com.moplus.moplus_server.domain.member.dto.response; + + +import com.moplus.moplus_server.domain.member.domain.Member; + +public record MemberGetResponse( + Long id, + String name, + String email +) { + public static MemberGetResponse of(Member member) { + return new MemberGetResponse( + member.getId(), + member.getName(), + member.getEmail() + ); + } +} diff --git a/src/main/java/com/moplus/moplus_server/global/annotation/AuthUser.java b/src/main/java/com/moplus/moplus_server/global/annotation/AuthUser.java new file mode 100644 index 0000000..5dd8a36 --- /dev/null +++ b/src/main/java/com/moplus/moplus_server/global/annotation/AuthUser.java @@ -0,0 +1,14 @@ +package com.moplus.moplus_server.global.annotation; + +import io.swagger.v3.oas.annotations.Parameter; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.PARAMETER) +@Retention(RetentionPolicy.RUNTIME) +@Parameter(hidden = true) +public @interface AuthUser { + +} diff --git a/src/main/java/com/moplus/moplus_server/global/annotation/AuthenticationArgumentResolver.java b/src/main/java/com/moplus/moplus_server/global/annotation/AuthenticationArgumentResolver.java new file mode 100644 index 0000000..3952759 --- /dev/null +++ b/src/main/java/com/moplus/moplus_server/global/annotation/AuthenticationArgumentResolver.java @@ -0,0 +1,49 @@ +package com.moplus.moplus_server.global.annotation; + +import com.moplus.moplus_server.domain.member.domain.Member; +import com.moplus.moplus_server.domain.member.repository.MemberRepository; +import com.moplus.moplus_server.global.error.exception.BusinessException; +import com.moplus.moplus_server.global.error.exception.ErrorCode; +import lombok.RequiredArgsConstructor; +import org.springframework.core.MethodParameter; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.web.bind.support.WebDataBinderFactory; +import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.method.support.ModelAndViewContainer; + +@Component +@RequiredArgsConstructor +public class AuthenticationArgumentResolver implements HandlerMethodArgumentResolver { + + private final MemberRepository memberRepository; + + @Override + public boolean supportsParameter(MethodParameter parameter) { + final boolean isUserAuthAnnotation = parameter.getParameterAnnotation(AuthUser.class) != null; + final boolean isMemberClass = parameter.getParameterType().equals(Member.class); + return isUserAuthAnnotation && isMemberClass; + } + + @Override + public Member resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, + NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception { + return getCurrentMember(); + } + + private Member getCurrentMember() { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + + if (authentication == null || !authentication.isAuthenticated()) { + throw new BusinessException(ErrorCode.AUTH_NOT_FOUND); + } + Object principal = authentication.getPrincipal(); + + if (!(principal instanceof Member)) { + throw new BusinessException(ErrorCode.INVALID_PRINCIPAL); + } + return (Member) principal; + } +} diff --git a/src/main/java/com/moplus/moplus_server/global/config/WebConfig.java b/src/main/java/com/moplus/moplus_server/global/config/WebConfig.java index 51117e6..1663f45 100644 --- a/src/main/java/com/moplus/moplus_server/global/config/WebConfig.java +++ b/src/main/java/com/moplus/moplus_server/global/config/WebConfig.java @@ -1,9 +1,12 @@ package com.moplus.moplus_server.global.config; +import com.moplus.moplus_server.global.annotation.AuthenticationArgumentResolver; +import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Configuration; import org.springframework.scheduling.annotation.EnableScheduling; import org.springframework.transaction.annotation.EnableTransactionManagement; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; import org.springframework.web.servlet.config.annotation.CorsRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; @@ -13,15 +16,21 @@ @EnableTransactionManagement public class WebConfig implements WebMvcConfigurer { + private final AuthenticationArgumentResolver authenticationArgumentResolver; @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/**") - .allowedOrigins("https://dev.mopl.kr","http://dev.mopl.kr", "http://localhost:8080", "https://www.mopl.kr", "http" - + "://localhost:3000", "https://dev-web.mopl.kr", "http://dev-web.mopl.kr") - .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") - .allowedHeaders("*") - .allowCredentials(true); + .allowedOrigins("https://dev.mopl.kr", "http://dev.mopl.kr", "http://localhost:8080", + "https://www.mopl.kr", "http" + + "://localhost:3000", "https://dev-web.mopl.kr", "http://dev-web.mopl.kr") + .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") + .allowedHeaders("*") + .allowCredentials(true); } + @Override + public void addArgumentResolvers(List resolvers) { + resolvers.add(authenticationArgumentResolver); + } } diff --git a/src/main/java/com/moplus/moplus_server/global/config/swagger/SwaggerConfig.java b/src/main/java/com/moplus/moplus_server/global/config/swagger/SwaggerConfig.java new file mode 100644 index 0000000..f532118 --- /dev/null +++ b/src/main/java/com/moplus/moplus_server/global/config/swagger/SwaggerConfig.java @@ -0,0 +1,28 @@ +package com.moplus.moplus_server.global.config.swagger; + +import io.swagger.v3.oas.models.Components; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.info.Info; +import io.swagger.v3.oas.models.security.SecurityRequirement; +import io.swagger.v3.oas.models.security.SecurityScheme; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class SwaggerConfig { + + private SecurityScheme createAPIKeyScheme() { + return new SecurityScheme().type(SecurityScheme.Type.HTTP) + .bearerFormat("JWT") + .scheme("Bearer"); + } + + @Bean + public OpenAPI openAPI() { + return new OpenAPI().addSecurityItem(new SecurityRequirement().addList("JWT")) + .components(new Components().addSecuritySchemes("JWT", createAPIKeyScheme())) + .info(new Info().title("모플 API 명세서") + .description("모플 API 명세서 입니다") + .version("v0.0.1")); + } +} diff --git a/src/main/java/com/moplus/moplus_server/global/error/exception/ErrorCode.java b/src/main/java/com/moplus/moplus_server/global/error/exception/ErrorCode.java index 31735e8..b8c892f 100644 --- a/src/main/java/com/moplus/moplus_server/global/error/exception/ErrorCode.java +++ b/src/main/java/com/moplus/moplus_server/global/error/exception/ErrorCode.java @@ -21,6 +21,7 @@ public enum ErrorCode { TOKEN_SUBJECT_FORMAT_ERROR(HttpStatus.UNAUTHORIZED, "Subject 값에 Long 타입이 아닌 다른 타입이 들어있습니다."), AT_EXPIRED_AND_RT_NOT_FOUND(HttpStatus.UNAUTHORIZED, "AT는 만료되었고 RT는 비어있습니다."), RT_NOT_FOUND(HttpStatus.UNAUTHORIZED, "RT가 비어있습니다"), + INVALID_PRINCIPAL(HttpStatus.UNAUTHORIZED, "잘못된 Principal입니다"), //모의고사 PRACTICE_TEST_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 모의고사를 찾을 수 없습니다"), diff --git a/src/test/java/com/moplus/moplus_server/domain/member/controller/MemberControllerTest.java b/src/test/java/com/moplus/moplus_server/domain/member/controller/MemberControllerTest.java new file mode 100644 index 0000000..9f0defd --- /dev/null +++ b/src/test/java/com/moplus/moplus_server/domain/member/controller/MemberControllerTest.java @@ -0,0 +1,78 @@ +package com.moplus.moplus_server.domain.member.controller; + +import static org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.moplus.moplus_server.global.properties.jwt.JwtProperties; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Keys; +import java.security.Key; +import java.util.Date; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +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.test.context.ActiveProfiles; +import org.springframework.test.context.jdbc.Sql; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.context.WebApplicationContext; + +@Transactional +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("h2test") +@Sql({"/auth-test-data.sql"}) +class MemberControllerTest { + + @Nested + class 어드민_로그인 { + + @Autowired + private JwtProperties jwtProperties; + + @Autowired + private MockMvc mockMvc; + + @Autowired + private WebApplicationContext context; + + private String validToken; + private Key key; + + @BeforeEach + public void setMockMvc() throws Exception { + this.mockMvc = MockMvcBuilders.webAppContextSetup(context) + .apply(springSecurity()).build(); + + key = Keys.hmacShaKeyFor(jwtProperties.accessTokenSecret().getBytes()); + Date issuedAt = new Date(); // 3 hour ago + Date expiredAt = new Date(issuedAt.getTime() + jwtProperties.accessTokenExpirationMilliTime()); + validToken = Jwts.builder() + .setIssuer(jwtProperties.issuer()) + .setSubject("1") + .claim("role", "ROLE_USER") + .setIssuedAt(issuedAt) + .setExpiration(expiredAt) + .signWith(key) + .compact(); + } + + @Test + void 성공() throws Exception { + mockMvc.perform(MockMvcRequestBuilders.get("/api/v1/member/me") + .contentType("application/json") + .header(HttpHeaders.AUTHORIZATION, "Bearer " + validToken)) + .andExpect(status().isOk()) // 200 응답 확인 + .andExpect(jsonPath("$.id").exists()) // MemberGetResponse의 필드 확인 + .andExpect(jsonPath("$.name").exists()) + .andExpect(jsonPath("$.email").exists()); + } + } +} \ No newline at end of file diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-mysqltest.yml similarity index 100% rename from src/test/resources/application-test.yml rename to src/test/resources/application-mysqltest.yml diff --git a/src/test/resources/auth-test-data.sql b/src/test/resources/auth-test-data.sql index f757a22..595162a 100644 --- a/src/test/resources/auth-test-data.sql +++ b/src/test/resources/auth-test-data.sql @@ -1,4 +1,4 @@ -INSERT INTO member (deleted, created_at, update_at, member_id, email, password, role) +INSERT INTO member (deleted, created_at, update_at, member_id, email, password, name, role) VALUES (false, '2024-07-24 21:27:20.000000', '2024-07-24 21:27:21.000000', 1, 'admin@example.com', - 'password123', 'ADMIN'); + 'password123', '홍길동', 'ADMIN');