diff --git a/Joonseok/build.gradle b/Joonseok/build.gradle index 55506486..ce009fae 100644 --- a/Joonseok/build.gradle +++ b/Joonseok/build.gradle @@ -20,6 +20,8 @@ repositories { dependencies { implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-webmvc' + implementation 'org.springframework.boot:spring-boot-starter-security' + testImplementation 'org.springframework.security:spring-security-test' compileOnly 'org.projectlombok:lombok' runtimeOnly 'com.mysql:mysql-connector-j' annotationProcessor 'org.projectlombok:lombok' diff --git a/Joonseok/keyword_summary/ch08.md b/Joonseok/keyword_summary/ch08.md new file mode 100644 index 00000000..86b0ac0b --- /dev/null +++ b/Joonseok/keyword_summary/ch08.md @@ -0,0 +1,58 @@ +- Spring Security가 무엇인가? + + + + MVC 구조를 사용하여 동기적으로 통신하는 서버와 Webflux를 사용하여 비동기적 반응형 서버 모두 적용 가능하고, 서버를 안전하게 보호하는데에 편리한 기능을 제공합니다. + + Java 17 이상의 JRE에서만 사용 가능하며, 별도의 Spring Security 관련 설정 파일을 두거나, 서버 클래스 로더에 Spring Security를 포함할 필요가 없습니다. + +- 인증(Authentication)vs 인가(Authorization) + + + + ### Authentication + + 인증이란 의미로, 해당 사용자가 서버에 등록되어 서버의 리소스에 접근할 수 있게 기록해두는 행위입니다. + + ### Authorization + + 인가라는 의미로, 해당 사용자가 서버에서 발급한 리소스 접근 권한이 유효한지 검증하는 행위입니다. + + ### 가장 일반적인 경우… + + 현대 서비스의 경우, 철저히 관리되는 대규모 SNS 서비스의 보안을 신뢰하며, 소셜 로그인 기능을 주력으로 밀고 나가는 경우도 많지만, 보편적이며 레거시에서는 ID와 Password를 기반으로 인증을 구현합니다. + + 이 경우에는, UsernamePasswordAuthenticationFilter라는 필터 체인 단계에서 체크하게 됩니다. + +- Stateful vs Stateless + + + + ### stateful + + 서버가 `stateful` 하다는 의미는, 서버에서 사용자를 기억한다는 의미입니다. Session 기반으로 재인증하는 서버가 이와 같이 작동하며, 온라인 뱅킹과 같이 이전 트랜잭션에 영향을 받을 수 있는 서비스의 경우 사용합니다. + + 상태 저장 트랜잭션이 중단되더라도 컨텍스트와 기록이 중단된 부분부터 복구할 수 있습니다. + + ### stateless + + `stateless`는 서버가 사용자를 기억하지 못한다는 것입니다. stateful은 전체 서비스 흐름에서 매 API 요청을 일종의 트랜잭션처럼 사용하여 사용자가 현재 어디까지 요청하였는지 확인할 수 있지만, stateless는 모든 API 요청에 대해 이전에 진행된 요청을 저장하지 않습니다. + + - RESTful + - Micro Service Archtecture \ No newline at end of file diff --git a/Joonseok/src/main/java/com/umc/study/domain/user/entity/User.java b/Joonseok/src/main/java/com/umc/study/domain/user/entity/User.java index 16add48e..c0caf445 100644 --- a/Joonseok/src/main/java/com/umc/study/domain/user/entity/User.java +++ b/Joonseok/src/main/java/com/umc/study/domain/user/entity/User.java @@ -36,6 +36,9 @@ public class User extends BaseEntity { @Column(nullable = false) private String email; + @Column(nullable = false) + private String password; + @Column(nullable = false) private String phoneNum; diff --git a/Joonseok/src/main/java/com/umc/study/domain/user/exception/code/UserSuccessCode.java b/Joonseok/src/main/java/com/umc/study/domain/user/exception/code/UserSuccessCode.java index d4da4366..85042040 100644 --- a/Joonseok/src/main/java/com/umc/study/domain/user/exception/code/UserSuccessCode.java +++ b/Joonseok/src/main/java/com/umc/study/domain/user/exception/code/UserSuccessCode.java @@ -9,7 +9,7 @@ @AllArgsConstructor public enum UserSuccessCode implements BaseResponseCode { - USER_SIGN_IN_CREATED( + USER_SIGN_UP_CREATED( HttpStatus.CREATED, "USER201_1", "회원가입에 성공했습니다." diff --git a/Joonseok/src/main/java/com/umc/study/domain/user/repository/UserRepository.java b/Joonseok/src/main/java/com/umc/study/domain/user/repository/UserRepository.java index 530877b7..d4248475 100644 --- a/Joonseok/src/main/java/com/umc/study/domain/user/repository/UserRepository.java +++ b/Joonseok/src/main/java/com/umc/study/domain/user/repository/UserRepository.java @@ -20,4 +20,5 @@ select count(mh) """) long countCompletedMissions(@Param("userId") Long userId); + Optional findByEmail(String username); } diff --git a/Joonseok/src/main/java/com/umc/study/domain/user/service/UserService.java b/Joonseok/src/main/java/com/umc/study/domain/user/service/UserService.java index de4576c7..8ca92a0a 100644 --- a/Joonseok/src/main/java/com/umc/study/domain/user/service/UserService.java +++ b/Joonseok/src/main/java/com/umc/study/domain/user/service/UserService.java @@ -1,20 +1,26 @@ package com.umc.study.domain.user.service; +import com.umc.study.domain.mission.entity.Location; import com.umc.study.domain.mission.entity.Mission; import com.umc.study.domain.mission.entity.Restaurant; import com.umc.study.domain.mission.exception.PageOverflowException; import com.umc.study.domain.mission.repository.MissionRepository; import com.umc.study.domain.mission.repository.RestaurantRepository; import com.umc.study.domain.user.entity.User; +import com.umc.study.domain.user.enums.Role; import com.umc.study.domain.user.exception.UserNotFoundException; import com.umc.study.domain.user.repository.UserRepository; import com.umc.study.domain.user.web.dto.GetHomeRes; import com.umc.study.domain.user.web.dto.GetMyPageRes; +import com.umc.study.domain.user.web.dto.SignInReq; +import com.umc.study.domain.user.web.dto.SignUpReq; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; +import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import java.util.List; @@ -25,6 +31,27 @@ public class UserService { private final UserRepository userRepository; private final MissionRepository missionRepository; private final RestaurantRepository restaurantRepository; + private final PasswordEncoder encoder; + + @Transactional + public void saveUser(SignUpReq request) { + User user = User.builder() + .name(request.name()) + .isMale(request.isMale()) + .birth(request.birth()) + .role(Role.GUEST) + .email(request.email()) + .password(encoder.encode(request.password())) + .phoneNum(request.phoneNum()) + .pointDeposit(0) + .location(Location.builder() + .streetAddress(request.address()) + .detailedAddress(request.detailAddress()) + .build()) + .build(); + + userRepository.save(user); + } public GetMyPageRes getMyPage(Long userId) { diff --git a/Joonseok/src/main/java/com/umc/study/domain/user/web/controller/UserController.java b/Joonseok/src/main/java/com/umc/study/domain/user/web/controller/UserController.java index 93a2991e..f3d78f78 100644 --- a/Joonseok/src/main/java/com/umc/study/domain/user/web/controller/UserController.java +++ b/Joonseok/src/main/java/com/umc/study/domain/user/web/controller/UserController.java @@ -4,6 +4,7 @@ import com.umc.study.domain.user.service.UserService; import com.umc.study.domain.user.web.dto.GetHomeRes; import com.umc.study.domain.user.web.dto.GetMyPageRes; +import com.umc.study.domain.user.web.dto.SignUpReq; import com.umc.study.global.apiPayload.ApiResponse; import jakarta.validation.Valid; import jakarta.validation.constraints.Max; @@ -21,15 +22,17 @@ public class UserController { private final UserService userService; - @PostMapping("/sign-in") - public ResponseEntity> signIn( - @Valid @RequestBody Object request + @PostMapping("/auth/sign-up") + public ResponseEntity> signUp( + @Valid @RequestBody SignUpReq request ) { // call Service method + userService.saveUser(request); + return ResponseEntity - .status(UserSuccessCode.USER_SIGN_IN_CREATED.getStatus()) - .body(ApiResponse.onComplete(UserSuccessCode.USER_SIGN_IN_CREATED, null)); + .status(UserSuccessCode.USER_SIGN_UP_CREATED.getStatus()) + .body(ApiResponse.onComplete(UserSuccessCode.USER_SIGN_UP_CREATED, null)); } @PostMapping("/my/pref") diff --git a/Joonseok/src/main/java/com/umc/study/domain/user/web/dto/SignUpReq.java b/Joonseok/src/main/java/com/umc/study/domain/user/web/dto/SignUpReq.java new file mode 100644 index 00000000..77594e49 --- /dev/null +++ b/Joonseok/src/main/java/com/umc/study/domain/user/web/dto/SignUpReq.java @@ -0,0 +1,51 @@ +package com.umc.study.domain.user.web.dto; + +import com.umc.study.domain.user.entity.Food; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; + +import java.time.LocalDate; +import java.util.List; + +public record SignUpReq( + @NotNull(message = "서비스 이용 동의 필드는 비어있을 수 없습니다.") + Agree agree, + + @NotBlank(message = "이름 필드는 비어있을 수 없습니다.") + String name, + + @NotNull(message = "성별 필드는 비어있을 수 없습니다.") + Boolean isMale, + + @NotNull(message = "생년월일 필드는 비어있을 수 없습니다.") + LocalDate birth, + + @NotBlank(message = "주소 필드는 비어있을 수 없습니다.") + String address, + + @NotBlank(message = "상세 주소 필드는 비어있을 수 없습니다.") + String detailAddress, + + @NotEmpty(message = "선호 음식 배열은 비어있을 수 없습니다.") + List foodList, + + @NotBlank(message = "이메일 필드는 비어있을 수 없습니다.") @Email(message = "이메일 형식이 맞지 않습니다.") + String email, + + @NotBlank(message = "비밀번호 필드는 비어있을 수 없습니다.") + String password, + + @NotBlank(message = "전화번호 필드는 비어있을 수 없습니다.") + String phoneNum +) { + + public record Agree( + Boolean age, + Boolean service, + Boolean privacy, + Boolean location, + Boolean marketing + ) {} +} diff --git a/Joonseok/src/main/java/com/umc/study/global/config/SecurityConfig.java b/Joonseok/src/main/java/com/umc/study/global/config/SecurityConfig.java new file mode 100644 index 00000000..83dfa31d --- /dev/null +++ b/Joonseok/src/main/java/com/umc/study/global/config/SecurityConfig.java @@ -0,0 +1,58 @@ +package com.umc.study.global.config; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.umc.study.global.security.CustomEntryPoint; +import com.umc.study.global.security.exception.CustomAccessDenied; +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; + +@EnableWebSecurity +@Configuration +public class SecurityConfig { + + @Bean + public ObjectMapper objectMapper() { + return new ObjectMapper(); + } + + private final String[] allowUris = { + "/api/swagger-ui/**", + "/api/swagger-resources/**", + "/api/v3/api-docs/**", + "/api/auth/**" // sign-up, login request allow + }; + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http, CustomAccessDenied customAccessDenied, CustomEntryPoint customEntryPoint) throws Exception { + + http + .csrf(AbstractHttpConfigurer::disable) + .authorizeHttpRequests(request -> request + .requestMatchers(allowUris).permitAll() + .anyRequest().authenticated() + ) + .formLogin(form -> form + .defaultSuccessUrl("/api/swagger-ui/index.html", true) + .permitAll()) + .logout(logout -> logout + .logoutUrl("/logout") + .logoutSuccessUrl("/login?logout") + .permitAll()) + .exceptionHandling(e -> e + .accessDeniedHandler(customAccessDenied) + .authenticationEntryPoint(customEntryPoint)); + + return http.build(); + } + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } +} diff --git a/Joonseok/src/main/java/com/umc/study/global/security/CustomEntryPoint.java b/Joonseok/src/main/java/com/umc/study/global/security/CustomEntryPoint.java new file mode 100644 index 00000000..8922a968 --- /dev/null +++ b/Joonseok/src/main/java/com/umc/study/global/security/CustomEntryPoint.java @@ -0,0 +1,39 @@ +package com.umc.study.global.security; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.umc.study.global.apiPayload.ApiResponse; +import com.umc.study.global.apiPayload.code.BaseResponseCode; +import com.umc.study.global.apiPayload.code.GeneralErrorCode; +import jakarta.servlet.ServletException; +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 CustomEntryPoint implements AuthenticationEntryPoint { + + private final ObjectMapper objectMapper; + + @Override + public void commence( + HttpServletRequest request, + HttpServletResponse response, + AuthenticationException authException + ) throws IOException, ServletException { + + BaseResponseCode errorCode = GeneralErrorCode.UNAUTHORIZED; + + response.setContentType("application/json;charset=UTF-8"); + response.setStatus(errorCode.getStatus().value()); + + ApiResponse errorResponse = ApiResponse.onFailure(errorCode, null); + + response.getWriter().write(objectMapper.writeValueAsString(errorResponse)); + } +} diff --git a/Joonseok/src/main/java/com/umc/study/global/security/entity/AuthUser.java b/Joonseok/src/main/java/com/umc/study/global/security/entity/AuthUser.java new file mode 100644 index 00000000..b1afe0ca --- /dev/null +++ b/Joonseok/src/main/java/com/umc/study/global/security/entity/AuthUser.java @@ -0,0 +1,33 @@ +package com.umc.study.global.security.entity; + +import com.umc.study.domain.user.entity.User; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.jspecify.annotations.Nullable; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; + +import java.util.Collection; +import java.util.List; + +@Getter +@RequiredArgsConstructor +public class AuthUser implements UserDetails { + + private final User user; + + @Override + public Collection getAuthorities() { + return List.of(); + } + + @Override + public @Nullable String getPassword() { + return user.getPassword(); + } + + @Override + public String getUsername() { + return user.getEmail(); + } +} diff --git a/Joonseok/src/main/java/com/umc/study/global/security/exception/CustomAccessDenied.java b/Joonseok/src/main/java/com/umc/study/global/security/exception/CustomAccessDenied.java new file mode 100644 index 00000000..e8fb2807 --- /dev/null +++ b/Joonseok/src/main/java/com/umc/study/global/security/exception/CustomAccessDenied.java @@ -0,0 +1,39 @@ +package com.umc.study.global.security.exception; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.umc.study.global.apiPayload.ApiResponse; +import com.umc.study.global.apiPayload.code.BaseResponseCode; +import com.umc.study.global.apiPayload.code.GeneralErrorCode; +import jakarta.servlet.ServletException; +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 CustomAccessDenied implements AccessDeniedHandler { + + private final ObjectMapper objectMapper; + + @Override + public void handle( + HttpServletRequest request, + HttpServletResponse response, + AccessDeniedException accessDeniedException + ) throws IOException, ServletException { + + BaseResponseCode errorCode = GeneralErrorCode.FORBIDDEN; + + response.setContentType("application/json;charset=UTF-8"); + response.setStatus(errorCode.getStatus().value()); + + ApiResponse errorResponse = ApiResponse.onFailure(errorCode, null); + + response.getWriter().write(objectMapper.writeValueAsString(errorResponse)); + } +} diff --git a/Joonseok/src/main/java/com/umc/study/global/security/service/CustomUserDetailsService.java b/Joonseok/src/main/java/com/umc/study/global/security/service/CustomUserDetailsService.java new file mode 100644 index 00000000..57c47cff --- /dev/null +++ b/Joonseok/src/main/java/com/umc/study/global/security/service/CustomUserDetailsService.java @@ -0,0 +1,29 @@ +package com.umc.study.global.security.service; + +import com.umc.study.domain.user.entity.User; +import com.umc.study.domain.user.exception.UserNotFoundException; +import com.umc.study.domain.user.repository.UserRepository; +import com.umc.study.global.security.entity.AuthUser; +import lombok.Getter; +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.Component; + +@Getter +@Component +@RequiredArgsConstructor +public class CustomUserDetailsService implements UserDetailsService { + + private final UserRepository userRepository; + + @Override + public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { + + User user = userRepository.findByEmail(username) + .orElseThrow(UserNotFoundException::new); + + return new AuthUser(user); + } +}