diff --git a/Minsu/umc10th/build.gradle b/Minsu/umc10th/build.gradle index c7eeda4e..6a458e84 100644 --- a/Minsu/umc10th/build.gradle +++ b/Minsu/umc10th/build.gradle @@ -20,12 +20,16 @@ repositories { dependencies { implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + implementation 'org.springframework.boot:spring-boot-starter-jackson' + implementation 'org.springframework.boot:spring-boot-starter-security' + implementation 'org.springframework.boot:spring-boot-starter-validation' implementation 'org.springframework.boot:spring-boot-starter-webmvc' implementation 'org.springframework.boot:spring-boot-starter-validation' compileOnly 'org.projectlombok:lombok' runtimeOnly 'com.mysql:mysql-connector-j' annotationProcessor 'org.projectlombok:lombok' testImplementation 'org.springframework.boot:spring-boot-starter-data-jpa-test' + testImplementation 'org.springframework.security:spring-security-test' testImplementation 'org.springframework.boot:spring-boot-starter-webmvc-test' testRuntimeOnly 'com.h2database:h2' testCompileOnly 'org.projectlombok:lombok' diff --git a/Minsu/umc10th/keyword_summary/ch08.md b/Minsu/umc10th/keyword_summary/ch08.md new file mode 100644 index 00000000..f8fd88ed --- /dev/null +++ b/Minsu/umc10th/keyword_summary/ch08.md @@ -0,0 +1,21 @@ +## Spring Security가 무엇인가? + +Spring Security는 Spring 기반 애플리케이션에서 인증과 인가를 처리하는 보안 프레임워크다. +요청이 Controller에 도달하기 전에 Security Filter Chain을 먼저 통과하면서 로그인 여부와 권한을 검사한다. +개발자는 `SecurityFilterChain`, `UserDetailsService`, `PasswordEncoder` 같은 구성 요소를 조합해 보안 흐름을 설정한다. +폼 로그인에서는 사용자가 입력한 이메일과 비밀번호를 기반으로 인증하고, 성공한 인증 정보는 `SecurityContext`에 저장된다. + +## 인증(Authentication)vs 인가(Authorization) + +인증(Authentication)은 “이 사용자가 누구인가?”를 확인하는 과정이다. +예를 들어 이메일과 비밀번호로 로그인해서 DB에 저장된 회원인지 확인하는 흐름이 인증이다. +인가(Authorization)는 “인증된 사용자가 이 기능에 접근할 권한이 있는가?”를 확인하는 과정이다. +로그인하지 않은 사용자가 Private API에 접근하면 인증 실패로 401 응답이 나가고, 권한이 부족하면 인가 실패로 403 응답이 나간다. +Spring Security에서는 인증 후 생성된 `Authentication` 객체의 권한 정보를 바탕으로 인가 판단을 수행한다. + +## Stateful vs Stateless + +Stateful은 서버가 사용자의 로그인 상태를 세션 등에 저장하는 방식이다. +폼 로그인은 기본적으로 Stateful 방식이며, 로그인 성공 후 서버 세션을 통해 사용자를 계속 식별한다. +Stateless는 서버가 로그인 상태를 저장하지 않고, 클라이언트가 매 요청마다 토큰 같은 인증 정보를 보내는 방식이다. +JWT 인증은 대표적인 Stateless 방식으로, 서버 확장에는 유리하지만 토큰 관리와 만료 처리가 중요하다. diff --git a/Minsu/umc10th/src/main/java/com/example/umc10th/domain/auth/controller/AuthController.java b/Minsu/umc10th/src/main/java/com/example/umc10th/domain/auth/controller/AuthController.java index 814862e7..bfc036c0 100644 --- a/Minsu/umc10th/src/main/java/com/example/umc10th/domain/auth/controller/AuthController.java +++ b/Minsu/umc10th/src/main/java/com/example/umc10th/domain/auth/controller/AuthController.java @@ -7,6 +7,7 @@ import com.example.umc10th.global.apiPayload.ApiResponse; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.*; @@ -20,7 +21,7 @@ public class AuthController { @PostMapping("/signup") @Operation(summary = "회원가입") - public ApiResponse signUp(@RequestBody MemberReqDTO.SignUp dto) { + public ApiResponse signUp(@RequestBody @Valid MemberReqDTO.SignUp dto) { return ApiResponse.onSuccess(MemberSuccessCode.CREATED, memberService.signUp(dto)); } } diff --git a/Minsu/umc10th/src/main/java/com/example/umc10th/domain/member/dto/MemberReqDTO.java b/Minsu/umc10th/src/main/java/com/example/umc10th/domain/member/dto/MemberReqDTO.java index f5cc0357..b98e9401 100644 --- a/Minsu/umc10th/src/main/java/com/example/umc10th/domain/member/dto/MemberReqDTO.java +++ b/Minsu/umc10th/src/main/java/com/example/umc10th/domain/member/dto/MemberReqDTO.java @@ -1,6 +1,9 @@ package com.example.umc10th.domain.member.dto; import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; import java.util.List; @@ -13,6 +16,7 @@ public record GetInfo( public record SignUp( @Schema(description = "이름", example = "홍길동") + @NotBlank(message = "이름은 필수입니다.") String name, @Schema(description = "성별 (MALE/FEMALE)", example = "MALE") String gender, @@ -21,7 +25,13 @@ public record SignUp( @Schema(description = "상세주소", example = "서울시 성북구 안암동") String detailAddress, @Schema(description = "이메일", example = "user@example.com") + @NotBlank(message = "이메일은 필수입니다.") + @Email(message = "이메일 형식이 올바르지 않습니다.") String email, + @Schema(description = "비밀번호", example = "password1234") + @NotBlank(message = "비밀번호는 필수입니다.") + @Size(min = 8, message = "비밀번호는 8자 이상이어야 합니다.") + String password, @Schema(description = "전화번호", example = "010-1234-5678") String phoneNumber, @Schema(description = "선호 음식 ID 목록", example = "[1, 3]") diff --git a/Minsu/umc10th/src/main/java/com/example/umc10th/domain/member/entity/Member.java b/Minsu/umc10th/src/main/java/com/example/umc10th/domain/member/entity/Member.java index 40477642..a287be91 100644 --- a/Minsu/umc10th/src/main/java/com/example/umc10th/domain/member/entity/Member.java +++ b/Minsu/umc10th/src/main/java/com/example/umc10th/domain/member/entity/Member.java @@ -31,6 +31,9 @@ public class Member extends BaseEntity { @Column(name = "email", nullable = false) private String email; + @Column(name = "password", nullable = false) + private String password; + @Column(name = "profile_url") private String profileUrl; diff --git a/Minsu/umc10th/src/main/java/com/example/umc10th/domain/member/service/MemberService.java b/Minsu/umc10th/src/main/java/com/example/umc10th/domain/member/service/MemberService.java index fa157b8f..5bb87351 100644 --- a/Minsu/umc10th/src/main/java/com/example/umc10th/domain/member/service/MemberService.java +++ b/Minsu/umc10th/src/main/java/com/example/umc10th/domain/member/service/MemberService.java @@ -18,6 +18,7 @@ import com.example.umc10th.domain.member.repository.MemberTermRepository; import com.example.umc10th.domain.member.repository.TermRepository; import lombok.RequiredArgsConstructor; +import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -35,6 +36,7 @@ public class MemberService { private final TermRepository termRepository; private final MemberFoodRepository memberFoodRepository; private final MemberTermRepository memberTermRepository; + private final PasswordEncoder passwordEncoder; public MemberResDTO.GetInfo getInfo(MemberReqDTO.GetInfo dto) { Member member = memberRepository.findById(dto.id()) @@ -58,6 +60,7 @@ public MemberResDTO.SignUpInfo signUp(MemberReqDTO.SignUp dto) { Member member = Member.builder() .name(dto.name()) .email(dto.email()) + .password(passwordEncoder.encode(dto.password())) .phoneNumber(dto.phoneNumber()) .detailAddress(dto.detailAddress()) .gender(dto.gender() != null ? Gender.valueOf(dto.gender().toUpperCase()) : null) diff --git a/Minsu/umc10th/src/main/java/com/example/umc10th/global/config/SecurityConfig.java b/Minsu/umc10th/src/main/java/com/example/umc10th/global/config/SecurityConfig.java new file mode 100644 index 00000000..d40c6298 --- /dev/null +++ b/Minsu/umc10th/src/main/java/com/example/umc10th/global/config/SecurityConfig.java @@ -0,0 +1,62 @@ +package com.example.umc10th.global.config; + +import com.example.umc10th.global.security.CustomAccessDeniedHandler; +import com.example.umc10th.global.security.CustomAuthenticationEntryPoint; +import lombok.RequiredArgsConstructor; +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.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.SecurityFilterChain; + +@Configuration +@EnableWebSecurity +@RequiredArgsConstructor +public class SecurityConfig { + + private static final String[] PUBLIC_URIS = { + "/auth/**", + "/login", + "/logout", + "/swagger-ui/**", + "/swagger-resources/**", + "/v3/api-docs/**" + }; + + private final CustomAuthenticationEntryPoint customAuthenticationEntryPoint; + private final CustomAccessDeniedHandler customAccessDeniedHandler; + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + return http + .csrf(AbstractHttpConfigurer::disable) + .authorizeHttpRequests(requests -> requests + .requestMatchers(PUBLIC_URIS).permitAll() + .anyRequest().authenticated() + ) + .formLogin(form -> form + .usernameParameter("email") + .passwordParameter("password") + .defaultSuccessUrl("/swagger-ui/index.html", true) + .permitAll() + ) + .logout(logout -> logout + .logoutUrl("/logout") + .logoutSuccessUrl("/login?logout") + .permitAll() + ) + .exceptionHandling(exception -> exception + .authenticationEntryPoint(customAuthenticationEntryPoint) + .accessDeniedHandler(customAccessDeniedHandler) + ) + .build(); + } + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } +} diff --git a/Minsu/umc10th/src/main/java/com/example/umc10th/global/security/AuthMember.java b/Minsu/umc10th/src/main/java/com/example/umc10th/global/security/AuthMember.java new file mode 100644 index 00000000..07b7a814 --- /dev/null +++ b/Minsu/umc10th/src/main/java/com/example/umc10th/global/security/AuthMember.java @@ -0,0 +1,39 @@ +package com.example.umc10th.global.security; + +import com.example.umc10th.domain.member.entity.Member; +import lombok.Getter; +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.List; + +@Getter +public class AuthMember implements UserDetails { + + private final Long memberId; + private final String email; + private final String password; + + public AuthMember(Member member) { + this.memberId = member.getId(); + this.email = member.getEmail(); + this.password = member.getPassword(); + } + + @Override + public Collection getAuthorities() { + return List.of(new SimpleGrantedAuthority("ROLE_USER")); + } + + @Override + public String getPassword() { + return password; + } + + @Override + public String getUsername() { + return email; + } +} diff --git a/Minsu/umc10th/src/main/java/com/example/umc10th/global/security/CustomAccessDeniedHandler.java b/Minsu/umc10th/src/main/java/com/example/umc10th/global/security/CustomAccessDeniedHandler.java new file mode 100644 index 00000000..fe82466b --- /dev/null +++ b/Minsu/umc10th/src/main/java/com/example/umc10th/global/security/CustomAccessDeniedHandler.java @@ -0,0 +1,27 @@ +package com.example.umc10th.global.security; + +import com.example.umc10th.global.apiPayload.code.GeneralErrorCode; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.web.access.AccessDeniedHandler; +import org.springframework.stereotype.Component; + +import java.io.IOException; + +@Component +@RequiredArgsConstructor +public class CustomAccessDeniedHandler implements AccessDeniedHandler { + + private final SecurityResponseWriter securityResponseWriter; + + @Override + public void handle( + HttpServletRequest request, + HttpServletResponse response, + AccessDeniedException accessDeniedException + ) throws IOException { + securityResponseWriter.write(response, GeneralErrorCode.FORBIDDEN); + } +} diff --git a/Minsu/umc10th/src/main/java/com/example/umc10th/global/security/CustomAuthenticationEntryPoint.java b/Minsu/umc10th/src/main/java/com/example/umc10th/global/security/CustomAuthenticationEntryPoint.java new file mode 100644 index 00000000..1e7568a6 --- /dev/null +++ b/Minsu/umc10th/src/main/java/com/example/umc10th/global/security/CustomAuthenticationEntryPoint.java @@ -0,0 +1,27 @@ +package com.example.umc10th.global.security; + +import com.example.umc10th.global.apiPayload.code.GeneralErrorCode; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.stereotype.Component; + +import java.io.IOException; + +@Component +@RequiredArgsConstructor +public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint { + + private final SecurityResponseWriter securityResponseWriter; + + @Override + public void commence( + HttpServletRequest request, + HttpServletResponse response, + AuthenticationException authException + ) throws IOException { + securityResponseWriter.write(response, GeneralErrorCode.UNAUTHORIZED); + } +} diff --git a/Minsu/umc10th/src/main/java/com/example/umc10th/global/security/CustomUserDetailsService.java b/Minsu/umc10th/src/main/java/com/example/umc10th/global/security/CustomUserDetailsService.java new file mode 100644 index 00000000..035b0503 --- /dev/null +++ b/Minsu/umc10th/src/main/java/com/example/umc10th/global/security/CustomUserDetailsService.java @@ -0,0 +1,24 @@ +package com.example.umc10th.global.security; + +import com.example.umc10th.domain.member.entity.Member; +import com.example.umc10th.domain.member.repository.MemberRepository; +import lombok.RequiredArgsConstructor; +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 +public class CustomUserDetailsService implements UserDetailsService { + + private final MemberRepository memberRepository; + + @Override + public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { + Member member = memberRepository.findActiveByEmail(username) + .orElseThrow(() -> new UsernameNotFoundException("회원을 찾을 수 없습니다.")); + + return new AuthMember(member); + } +} diff --git a/Minsu/umc10th/src/main/java/com/example/umc10th/global/security/SecurityResponseWriter.java b/Minsu/umc10th/src/main/java/com/example/umc10th/global/security/SecurityResponseWriter.java new file mode 100644 index 00000000..10c3d29b --- /dev/null +++ b/Minsu/umc10th/src/main/java/com/example/umc10th/global/security/SecurityResponseWriter.java @@ -0,0 +1,26 @@ +package com.example.umc10th.global.security; + +import com.example.umc10th.global.apiPayload.ApiResponse; +import com.example.umc10th.global.apiPayload.code.BaseErrorCode; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import tools.jackson.databind.ObjectMapper; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; + +@Component +@RequiredArgsConstructor +public class SecurityResponseWriter { + + private final ObjectMapper objectMapper; + + public void write(HttpServletResponse response, BaseErrorCode errorCode) throws IOException { + response.setStatus(errorCode.getStatus().value()); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.setCharacterEncoding(StandardCharsets.UTF_8.name()); + objectMapper.writeValue(response.getWriter(), ApiResponse.onFailure(errorCode, null)); + } +} diff --git a/Minsu/umc10th/src/test/java/com/example/umc10th/SecurityIntegrationTest.java b/Minsu/umc10th/src/test/java/com/example/umc10th/SecurityIntegrationTest.java new file mode 100644 index 00000000..2812ba30 --- /dev/null +++ b/Minsu/umc10th/src/test/java/com/example/umc10th/SecurityIntegrationTest.java @@ -0,0 +1,97 @@ +package com.example.umc10th; + +import com.example.umc10th.domain.member.entity.Member; +import com.example.umc10th.domain.member.repository.MemberRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.http.MediaType; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.test.web.servlet.MockMvc; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestBuilders.formLogin; +import static org.springframework.security.test.web.servlet.response.SecurityMockMvcResultMatchers.authenticated; +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 +class SecurityIntegrationTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private MemberRepository memberRepository; + + @Autowired + private PasswordEncoder passwordEncoder; + + @BeforeEach + void setUp() { + memberRepository.deleteAll(); + } + + @Test + void signupEncryptsPassword() throws Exception { + String rawPassword = "password1234"; + + mockMvc.perform(post("/auth/signup") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "name": "홍길동", + "email": "user@example.com", + "password": "password1234" + } + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.isSuccess").value(true)); + + Member member = memberRepository.findActiveByEmail("user@example.com").orElseThrow(); + assertThat(member.getPassword()).isNotEqualTo(rawPassword); + assertThat(passwordEncoder.matches(rawPassword, member.getPassword())).isTrue(); + } + + @Test + void signupWithoutPasswordReturnsBadRequest() throws Exception { + mockMvc.perform(post("/auth/signup") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "name": "홍길동", + "email": "user@example.com" + } + """)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.isSuccess").value(false)) + .andExpect(jsonPath("$.code").value("COMMON400_1")); + } + + @Test + void formLoginAuthenticatesWithEmailAndPassword() throws Exception { + memberRepository.save(Member.builder() + .name("홍길동") + .email("user@example.com") + .password(passwordEncoder.encode("password1234")) + .build()); + + mockMvc.perform(formLogin("/login") + .user("email", "user@example.com") + .password("password", "password1234")) + .andExpect(authenticated()); + } + + @Test + void privateApiWithoutLoginReturnsApiResponseJson() throws Exception { + mockMvc.perform(get("/api/v1/users/points")) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.isSuccess").value(false)) + .andExpect(jsonPath("$.code").value("COMMON401_1")); + } +}