diff --git a/.github/workflows/develop_build_deploy.yml b/.github/workflows/develop_build_deploy.yml index 00e8c1c..ccb9e2b 100644 --- a/.github/workflows/develop_build_deploy.yml +++ b/.github/workflows/develop_build_deploy.yml @@ -43,8 +43,8 @@ jobs: done echo "✅ postgres is ready!" - echo "${{ secrets.SCHEMA_SQL }}" | docker exec -i $TEST_POSTGRES_CONTAINER_NAME psql -U $TEST_POSTGRES_USER -d $TEST_POSTGRES_DB - echo "${{ secrets.DATA_SQL }}" | docker exec -i $TEST_POSTGRES_CONTAINER_NAME psql -U $TEST_POSTGRES_USER -d $TEST_POSTGRES_DB + echo "${{ secrets.SCHEMA_SQL }}" | base64 --decode | docker exec -i $TEST_POSTGRES_CONTAINER_NAME psql -U $TEST_POSTGRES_USER -d $TEST_POSTGRES_DB + echo "${{ secrets.DATA_SQL }}" | base64 --decode | docker exec -i $TEST_POSTGRES_CONTAINER_NAME psql -U $TEST_POSTGRES_USER -d $TEST_POSTGRES_DB # Gradlew 실행 권한 허용 - name: Grant Execute Permission for Gradlew diff --git a/.github/workflows/develop_pull_request.yml b/.github/workflows/develop_pull_request.yml index 08ae798..a28ebc2 100644 --- a/.github/workflows/develop_pull_request.yml +++ b/.github/workflows/develop_pull_request.yml @@ -38,8 +38,8 @@ jobs: done echo "✅ postgres is ready!" - echo "${{ secrets.SCHEMA_SQL }}" | docker exec -i $TEST_POSTGRES_CONTAINER_NAME psql -U $TEST_POSTGRES_USER -d $TEST_POSTGRES_DB - echo "${{ secrets.DATA_SQL }}" | docker exec -i $TEST_POSTGRES_CONTAINER_NAME psql -U $TEST_POSTGRES_USER -d $TEST_POSTGRES_DB + echo "${{ secrets.SCHEMA_SQL }}" | base64 --decode | docker exec -i $TEST_POSTGRES_CONTAINER_NAME psql -U $TEST_POSTGRES_USER -d $TEST_POSTGRES_DB + echo "${{ secrets.DATA_SQL }}" | base64 --decode | docker exec -i $TEST_POSTGRES_CONTAINER_NAME psql -U $TEST_POSTGRES_USER -d $TEST_POSTGRES_DB # Gradlew 실행 권한 허용 - name: Grant Execute Permission for Gradlew diff --git a/build.gradle b/build.gradle index 1598ca4..22439f9 100644 --- a/build.gradle +++ b/build.gradle @@ -137,7 +137,7 @@ tasks.register('addAuthorization', Task) { def securitySchemesContent = " securitySchemes:\n" + " APIKey:\n" + " type: apiKey\n" + - " name: JSESSIONID\n" + + " name: SESSION\n" + " in: cookie\n" + "security:\n" + " - APIKey: [] # Apply the security scheme here" diff --git a/src/docs/asciidoc/index.adoc b/src/docs/asciidoc/index.adoc index ebeb7b3..6771874 100644 --- a/src/docs/asciidoc/index.adoc +++ b/src/docs/asciidoc/index.adoc @@ -100,6 +100,8 @@ Content-Type: application/json | 공통 | 500 | INTERNAL_SERVER_ERROR | E500_001 | 서버 측에서 처리하지 못한 예외가 발생하면 모든 api 요청에 대해 공통적으로 반환됨. |=== + + == 회원 === **1. 이메일 중복 확인** @@ -161,3 +163,20 @@ include::{snippetsDir}/emailCodeVerification/1/http-response.adoc[] include::{snippetsDir}/emailCodeVerification/1/response-fields.adoc[] +== 인증/인가 + +=== **1. 유저 로그인** + +유저 로그인 api 입니다. (이메일, 패스워드) + +=== Request +include::{snippetsDir}/loginUser/1/http-request.adoc[] + +== 성공 Response +include::{snippetsDir}/loginUser/1/http-response.adoc[] + +== Response Body Fields +include::{snippetsDir}/loginUser/1/response-fields.adoc[] + +== 실패 Response +include::{snippetsDir}/loginUser/2/http-response.adoc[] \ No newline at end of file diff --git a/src/main/java/com/ftm/server/adapter/controller/auth/.gitkeep b/src/main/java/com/ftm/server/adapter/controller/auth/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/main/java/com/ftm/server/adapter/controller/auth/UserLoginController.java b/src/main/java/com/ftm/server/adapter/controller/auth/UserLoginController.java new file mode 100644 index 0000000..c9e9139 --- /dev/null +++ b/src/main/java/com/ftm/server/adapter/controller/auth/UserLoginController.java @@ -0,0 +1,37 @@ +package com.ftm.server.adapter.controller.auth; + +import com.ftm.server.adapter.dto.request.UserLoginRequest; +import com.ftm.server.adapter.dto.response.UserLoginResponse; +import com.ftm.server.common.response.ApiResponse; +import com.ftm.server.common.response.enums.SuccessResponseCode; +import com.ftm.server.domain.dto.command.UserLoginCommand; +import com.ftm.server.domain.usecase.auth.UserLoginUseCase; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +public class UserLoginController { + + private final UserLoginUseCase loginUseCase; + + @PostMapping("/api/auth/login") + public ResponseEntity> login( + @RequestBody UserLoginRequest request, + HttpServletRequest req, + HttpServletResponse res) { + return ResponseEntity.status(HttpStatus.OK) + .body( + ApiResponse.success( + SuccessResponseCode.OK, + UserLoginResponse.from( + loginUseCase.login( + UserLoginCommand.from(request), req, res)))); + } +} diff --git a/src/main/java/com/ftm/server/adapter/dto/request/UserLoginRequest.java b/src/main/java/com/ftm/server/adapter/dto/request/UserLoginRequest.java new file mode 100644 index 0000000..290a325 --- /dev/null +++ b/src/main/java/com/ftm/server/adapter/dto/request/UserLoginRequest.java @@ -0,0 +1,12 @@ +package com.ftm.server.adapter.dto.request; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class UserLoginRequest { + + private final String email; + private final String password; +} diff --git a/src/main/java/com/ftm/server/adapter/dto/response/UserLoginResponse.java b/src/main/java/com/ftm/server/adapter/dto/response/UserLoginResponse.java new file mode 100644 index 0000000..f40d42e --- /dev/null +++ b/src/main/java/com/ftm/server/adapter/dto/response/UserLoginResponse.java @@ -0,0 +1,32 @@ +package com.ftm.server.adapter.dto.response; + +import com.fasterxml.jackson.annotation.JsonFormat; +import com.ftm.server.domain.dto.vo.UserSummaryVo; +import java.time.LocalDateTime; +import lombok.Getter; + +@Getter +public class UserLoginResponse { + + private final Long id; + private final String nickname; + private final String profileImageUrl; + private final String mildLevelName; + private final String spicyLevelName; + + @JsonFormat(pattern = "yyyy-MM-dd HH:mm", shape = JsonFormat.Shape.STRING) + private final LocalDateTime loginTime; + + UserLoginResponse(UserSummaryVo userSummaryVo) { + this.id = userSummaryVo.getId(); + this.nickname = userSummaryVo.getNickname(); + this.profileImageUrl = userSummaryVo.getProfileImageUrl(); + this.mildLevelName = userSummaryVo.getMildLevelName(); + this.spicyLevelName = userSummaryVo.getSpicyLevelName(); + this.loginTime = LocalDateTime.now(); + } + + public static UserLoginResponse from(UserSummaryVo userSummaryVo) { + return new UserLoginResponse(userSummaryVo); + } +} diff --git a/src/main/java/com/ftm/server/adapter/gateway/AuthenticationGateway.java b/src/main/java/com/ftm/server/adapter/gateway/AuthenticationGateway.java new file mode 100644 index 0000000..7483572 --- /dev/null +++ b/src/main/java/com/ftm/server/adapter/gateway/AuthenticationGateway.java @@ -0,0 +1,19 @@ +package com.ftm.server.adapter.gateway; + +import com.ftm.server.domain.dto.command.UserLoginCommand; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.security.core.Authentication; + +/** 시큐리티 인증 관련 작업 Gateway */ +public interface AuthenticationGateway { + + // 일반 유저 인증 객체 생성 + Authentication createAuthenticationFromCredentials(UserLoginCommand command); + + // 인증 세션 등록 (시큐리티 컨텍스트 저장) + void saveAuthenticatedSession( + Authentication authentication, + HttpServletRequest request, + HttpServletResponse response); +} diff --git a/src/main/java/com/ftm/server/adapter/gateway/repository/UserImageRepository.java b/src/main/java/com/ftm/server/adapter/gateway/repository/UserImageRepository.java new file mode 100644 index 0000000..9dbbbfe --- /dev/null +++ b/src/main/java/com/ftm/server/adapter/gateway/repository/UserImageRepository.java @@ -0,0 +1,10 @@ +package com.ftm.server.adapter.gateway.repository; + +import com.ftm.server.entity.entities.UserImage; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface UserImageRepository extends JpaRepository { + + Optional findByUserId(Long userId); +} diff --git a/src/main/java/com/ftm/server/adapter/gateway/repository/UserRepository.java b/src/main/java/com/ftm/server/adapter/gateway/repository/UserRepository.java index 6ff244b..c78670a 100644 --- a/src/main/java/com/ftm/server/adapter/gateway/repository/UserRepository.java +++ b/src/main/java/com/ftm/server/adapter/gateway/repository/UserRepository.java @@ -1,9 +1,12 @@ package com.ftm.server.adapter.gateway.repository; import com.ftm.server.entity.entities.User; +import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; public interface UserRepository extends JpaRepository { Boolean existsByEmail(String email); + + Optional findByEmail(String email); } diff --git a/src/main/java/com/ftm/server/common/response/enums/ErrorResponseCode.java b/src/main/java/com/ftm/server/common/response/enums/ErrorResponseCode.java index 03df641..ee1f5b5 100644 --- a/src/main/java/com/ftm/server/common/response/enums/ErrorResponseCode.java +++ b/src/main/java/com/ftm/server/common/response/enums/ErrorResponseCode.java @@ -12,6 +12,7 @@ public enum ErrorResponseCode { // 401번 NOT_AUTHENTICATED(HttpStatus.UNAUTHORIZED, "E401_001", "인증되지 않은 사용자입니다."), + INVALID_CREDENTIALS(HttpStatus.UNAUTHORIZED, "E401_002", "인증에 실패하였습니다."), // 403번 NOT_AUTHORIZATION(HttpStatus.FORBIDDEN, "E403_001", "인증된 사용자이나 해당 자원에 대한 접근 권한이 없습니다."), diff --git a/src/main/java/com/ftm/server/domain/dto/command/UserLoginCommand.java b/src/main/java/com/ftm/server/domain/dto/command/UserLoginCommand.java new file mode 100644 index 0000000..e002c22 --- /dev/null +++ b/src/main/java/com/ftm/server/domain/dto/command/UserLoginCommand.java @@ -0,0 +1,20 @@ +package com.ftm.server.domain.dto.command; + +import com.ftm.server.adapter.dto.request.UserLoginRequest; +import lombok.Getter; + +@Getter +public class UserLoginCommand { + + private final String email; + private final String password; + + private UserLoginCommand(String email, String password) { + this.email = email; + this.password = password; + } + + public static UserLoginCommand from(UserLoginRequest request) { + return new UserLoginCommand(request.getEmail(), request.getPassword()); + } +} diff --git a/src/main/java/com/ftm/server/domain/dto/query/FindByIdQuery.java b/src/main/java/com/ftm/server/domain/dto/query/FindByIdQuery.java new file mode 100644 index 0000000..d7749df --- /dev/null +++ b/src/main/java/com/ftm/server/domain/dto/query/FindByIdQuery.java @@ -0,0 +1,17 @@ +package com.ftm.server.domain.dto.query; + +import lombok.Getter; + +@Getter +public class FindByIdQuery { + + private final Long id; + + private FindByIdQuery(Long id) { + this.id = id; + } + + public static FindByIdQuery of(Long id) { + return new FindByIdQuery(id); + } +} diff --git a/src/main/java/com/ftm/server/domain/dto/query/FindByUserIdQuery.java b/src/main/java/com/ftm/server/domain/dto/query/FindByUserIdQuery.java new file mode 100644 index 0000000..0d2ff8d --- /dev/null +++ b/src/main/java/com/ftm/server/domain/dto/query/FindByUserIdQuery.java @@ -0,0 +1,17 @@ +package com.ftm.server.domain.dto.query; + +import lombok.Getter; + +@Getter +public class FindByUserIdQuery { + + private final Long userId; + + private FindByUserIdQuery(Long userId) { + this.userId = userId; + } + + public static FindByUserIdQuery of(Long userId) { + return new FindByUserIdQuery(userId); + } +} diff --git a/src/main/java/com/ftm/server/domain/dto/vo/UserSummaryVo.java b/src/main/java/com/ftm/server/domain/dto/vo/UserSummaryVo.java new file mode 100644 index 0000000..3a396e2 --- /dev/null +++ b/src/main/java/com/ftm/server/domain/dto/vo/UserSummaryVo.java @@ -0,0 +1,28 @@ +package com.ftm.server.domain.dto.vo; + +import com.ftm.server.entity.entities.GroomingLevel; +import com.ftm.server.entity.entities.User; +import com.ftm.server.entity.entities.UserImage; +import lombok.Getter; + +@Getter +public class UserSummaryVo { + + private final Long id; + private final String nickname; + private final String profileImageUrl; + private final String mildLevelName; + private final String spicyLevelName; + + private UserSummaryVo(User user, UserImage userImage, GroomingLevel groomingLevel) { + this.id = user.getId(); + this.nickname = user.getNickname(); + this.profileImageUrl = userImage.getObjectKey(); // TODO: 추후 CDN 주소 + getObjectKey() 로 변경해야함 + this.mildLevelName = groomingLevel.getMildLevelName(); + this.spicyLevelName = groomingLevel.getSpicyLevelName(); + } + + public static UserSummaryVo of(User user, UserImage userImage) { + return new UserSummaryVo(user, userImage, user.getGroomingLevel()); + } +} diff --git a/src/main/java/com/ftm/server/domain/service/UserImageService.java b/src/main/java/com/ftm/server/domain/service/UserImageService.java new file mode 100644 index 0000000..7eb7eac --- /dev/null +++ b/src/main/java/com/ftm/server/domain/service/UserImageService.java @@ -0,0 +1,20 @@ +package com.ftm.server.domain.service; + +import com.ftm.server.adapter.gateway.repository.UserImageRepository; +import com.ftm.server.domain.dto.query.FindByUserIdQuery; +import com.ftm.server.entity.entities.UserImage; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class UserImageService { + + private final UserImageRepository userImageRepository; + + public UserImage queryUserImageByUserId(FindByUserIdQuery query) { + return userImageRepository + .findByUserId(query.getUserId()) + .orElseThrow(() -> new RuntimeException("")); + } +} diff --git a/src/main/java/com/ftm/server/domain/service/UserService.java b/src/main/java/com/ftm/server/domain/service/UserService.java index d4c61cf..e1edc59 100644 --- a/src/main/java/com/ftm/server/domain/service/UserService.java +++ b/src/main/java/com/ftm/server/domain/service/UserService.java @@ -1,15 +1,16 @@ package com.ftm.server.domain.service; import com.ftm.server.adapter.gateway.repository.UserRepository; +import com.ftm.server.common.exception.CustomException; import com.ftm.server.domain.dto.query.FindByEmailQuery; +import com.ftm.server.domain.dto.query.FindByIdQuery; import com.ftm.server.domain.dto.vo.EmailDuplicationVo; +import com.ftm.server.entity.entities.User; import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; @Service @RequiredArgsConstructor -@Slf4j public class UserService { private final UserRepository userRepository; @@ -17,4 +18,10 @@ public class UserService { public EmailDuplicationVo isEmailDuplicated(FindByEmailQuery query) { return EmailDuplicationVo.of(userRepository.existsByEmail(query.getEmail())); } + + public User queryUser(FindByIdQuery query) { + return userRepository + .findById(query.getId()) + .orElseThrow(() -> CustomException.USER_NOT_FOUND); + } } diff --git a/src/main/java/com/ftm/server/domain/usecase/auth/.gitkeep b/src/main/java/com/ftm/server/domain/usecase/auth/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/main/java/com/ftm/server/domain/usecase/auth/UserLoginUseCase.java b/src/main/java/com/ftm/server/domain/usecase/auth/UserLoginUseCase.java new file mode 100644 index 0000000..05b0997 --- /dev/null +++ b/src/main/java/com/ftm/server/domain/usecase/auth/UserLoginUseCase.java @@ -0,0 +1,56 @@ +package com.ftm.server.domain.usecase.auth; + +import com.ftm.server.adapter.gateway.AuthenticationGateway; +import com.ftm.server.common.annotation.UseCase; +import com.ftm.server.common.exception.CustomException; +import com.ftm.server.common.response.enums.ErrorResponseCode; +import com.ftm.server.domain.dto.command.UserLoginCommand; +import com.ftm.server.domain.dto.query.FindByIdQuery; +import com.ftm.server.domain.dto.query.FindByUserIdQuery; +import com.ftm.server.domain.dto.vo.UserSummaryVo; +import com.ftm.server.domain.service.UserImageService; +import com.ftm.server.domain.service.UserService; +import com.ftm.server.entity.entities.User; +import com.ftm.server.entity.entities.UserImage; +import com.ftm.server.infrastructure.security.UserPrincipal; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; +import org.springframework.transaction.annotation.Transactional; + +@UseCase +@RequiredArgsConstructor +public class UserLoginUseCase { + + private final AuthenticationGateway securityAuthenticateGateway; + private final UserService userService; + private final UserImageService userImageService; + + @Transactional + public UserSummaryVo login( + UserLoginCommand command, HttpServletRequest req, HttpServletResponse res) { + + // 인증을 수행하고 인증 객체 생성 (실패 시 예외 발생) + Authentication auth = createAuthenticationOrThrow(command); + UserPrincipal userPrincipal = (UserPrincipal) auth.getPrincipal(); + + User user = userService.queryUser(FindByIdQuery.of(userPrincipal.getId())); + UserImage userImage = + userImageService.queryUserImageByUserId(FindByUserIdQuery.of(user.getId())); + + // 인증 세션 등록 (시큐리티 컨텍스트 등록) + securityAuthenticateGateway.saveAuthenticatedSession(auth, req, res); + + return UserSummaryVo.of(user, userImage); + } + + private Authentication createAuthenticationOrThrow(UserLoginCommand command) { + try { + return securityAuthenticateGateway.createAuthenticationFromCredentials(command); + } catch (AuthenticationException ex) { + throw new CustomException(ErrorResponseCode.INVALID_CREDENTIALS); + } + } +} diff --git a/src/main/java/com/ftm/server/infrastructure/redis/RedisConfig.java b/src/main/java/com/ftm/server/infrastructure/redis/RedisConfig.java index 607cc49..98e4cc9 100644 --- a/src/main/java/com/ftm/server/infrastructure/redis/RedisConfig.java +++ b/src/main/java/com/ftm/server/infrastructure/redis/RedisConfig.java @@ -15,7 +15,7 @@ import org.springframework.data.redis.serializer.StringRedisSerializer; import org.springframework.session.data.redis.config.annotation.web.http.EnableRedisHttpSession; -@EnableRedisHttpSession +@EnableRedisHttpSession(redisNamespace = "ftm:session", maxInactiveIntervalInSeconds = 3600) @EnableCaching @Configuration @RequiredArgsConstructor diff --git a/src/main/java/com/ftm/server/infrastructure/security/AuthenticationService.java b/src/main/java/com/ftm/server/infrastructure/security/AuthenticationService.java new file mode 100644 index 0000000..e70a0e0 --- /dev/null +++ b/src/main/java/com/ftm/server/infrastructure/security/AuthenticationService.java @@ -0,0 +1,49 @@ +package com.ftm.server.infrastructure.security; + +import com.ftm.server.adapter.gateway.AuthenticationGateway; +import com.ftm.server.common.annotation.InfraService; +import com.ftm.server.domain.dto.command.UserLoginCommand; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.web.context.SecurityContextRepository; + +@Slf4j +@InfraService +@RequiredArgsConstructor +public class AuthenticationService implements AuthenticationGateway { + + private final AuthenticationManager authenticationManager; + private final SecurityContextRepository securityContextRepository; + + @Override + public Authentication createAuthenticationFromCredentials(UserLoginCommand command) + throws AuthenticationException { + // 인증 토큰 생성 (입력받은 이메일과 비밀번호로 인증 시도용 토큰 생성) + UsernamePasswordAuthenticationToken token = + new UsernamePasswordAuthenticationToken(command.getEmail(), command.getPassword()); + + // 인증 수행 (UserPrincipalService 호출, PasswordEncoder 비밀번호 검증 포함) + return authenticationManager.authenticate(token); + } + + @Override + public void saveAuthenticatedSession( + Authentication authentication, + HttpServletRequest request, + HttpServletResponse response) { + // 시큐리티 컨텍스트 생성 + SecurityContext context = SecurityContextHolder.createEmptyContext(); + context.setAuthentication(authentication); + + // 생성한 시큐리티 컨텍스트를 Redis 세션에 저장 + securityContextRepository.saveContext(context, request, response); + } +} diff --git a/src/main/java/com/ftm/server/infrastructure/security/SecurityConfig.java b/src/main/java/com/ftm/server/infrastructure/security/SecurityConfig.java index 49f861c..968de66 100644 --- a/src/main/java/com/ftm/server/infrastructure/security/SecurityConfig.java +++ b/src/main/java/com/ftm/server/infrastructure/security/SecurityConfig.java @@ -7,17 +7,22 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.http.HttpMethod; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.ProviderManager; +import org.springframework.security.authentication.dao.DaoAuthenticationProvider; 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; +import org.springframework.security.web.context.HttpSessionSecurityContextRepository; +import org.springframework.security.web.context.SecurityContextRepository; import org.springframework.web.cors.CorsConfiguration; import org.springframework.web.cors.UrlBasedCorsConfigurationSource; @Configuration -@EnableWebSecurity(debug = true) +@EnableWebSecurity @RequiredArgsConstructor public class SecurityConfig { @@ -44,7 +49,7 @@ public class SecurityConfig { private static final String[] GET_ANONYMOUS_MATCHERS = {"/api/users/email/duplication"}; private static final String[] POST_ANONYMOUS_MATCHERS = { - "/api/users/email/authentication", "/api/users/email/authentication/code" + "/api/users/email/authentication", "/api/users/email/authentication/code", "/api/auth/login" }; private static final String[] ANONYMOUS_MATCHERS = {"/docs/**"}; @@ -67,6 +72,9 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti .migrateSession() // 세션 고정 보호 .maximumSessions(1) // 동시 로그인 1개 제한 .maxSessionsPreventsLogin(false)) // 기존 세션 만료 후 새 로그인 허용 + // 시큐리티 컨텍스트 레포지토리 등록 (시큐리티 6.x 이상부터는 시큐리티가 자동으로 컨텍스트에 로드/저장해주지 않기 때문에 명시해줘야함) + .securityContext( + context -> context.securityContextRepository(securityContextRepository())) // 예외 핸들링 .exceptionHandling( exception -> @@ -103,6 +111,23 @@ public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } + // Spring Security 6 이상에서는 세션 기반 인증 유지를 위해 SecurityContextRepository 설정이 필요 + // HttpSession 기반 저장소를 통해 로그인 상태를 세션(Redis)에 자동 저장 및 복원 + @Bean + public SecurityContextRepository securityContextRepository() { + return new HttpSessionSecurityContextRepository(); + } + + // 시큐리티 인증을 관리하는 AuthenticationManager 설정 + @Bean + public AuthenticationManager authenticationManager(UserPrincipalService userPrincipalService) { + DaoAuthenticationProvider provider = new DaoAuthenticationProvider(); + provider.setUserDetailsService(userPrincipalService); + provider.setPasswordEncoder(passwordEncoder()); + + return new ProviderManager(provider); + } + // CORS 설정 @Bean public UrlBasedCorsConfigurationSource corsConfigurationSource() { diff --git a/src/main/java/com/ftm/server/infrastructure/security/UserPrincipal.java b/src/main/java/com/ftm/server/infrastructure/security/UserPrincipal.java new file mode 100644 index 0000000..27ee4cd --- /dev/null +++ b/src/main/java/com/ftm/server/infrastructure/security/UserPrincipal.java @@ -0,0 +1,46 @@ +package com.ftm.server.infrastructure.security; + +import com.ftm.server.entity.entities.User; +import com.ftm.server.entity.enums.UserRole; +import java.util.Collection; +import java.util.Collections; +import lombok.Getter; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; + +/** 인증된 유저 시큐리티 객체 */ +@Getter +public class UserPrincipal implements UserDetails { + + private final Long id; + private final String email; + private final String password; + private final UserRole role; + + private UserPrincipal(User user) { + this.id = user.getId(); + this.email = user.getEmail(); + this.password = user.getPassword(); + this.role = user.getRole(); + } + + public static UserPrincipal of(User user) { + return new UserPrincipal(user); + } + + @Override + public Collection getAuthorities() { + return Collections.singleton(new SimpleGrantedAuthority("ROLE_" + this.role.name())); + } + + @Override + public String getPassword() { + return password; + } + + @Override + public String getUsername() { + return email; + } +} diff --git a/src/main/java/com/ftm/server/infrastructure/security/UserPrincipalService.java b/src/main/java/com/ftm/server/infrastructure/security/UserPrincipalService.java new file mode 100644 index 0000000..8b18139 --- /dev/null +++ b/src/main/java/com/ftm/server/infrastructure/security/UserPrincipalService.java @@ -0,0 +1,32 @@ +package com.ftm.server.infrastructure.security; + +import com.ftm.server.adapter.gateway.repository.UserRepository; +import com.ftm.server.common.annotation.InfraService; +import com.ftm.server.common.exception.CustomException; +import com.ftm.server.common.response.enums.ErrorResponseCode; +import com.ftm.server.entity.entities.User; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; + +/** 시큐리티에서 유저 인증을 수행하고 컨텍스트에 저장할 인증 객체를 생성하는 서비스 */ +@InfraService +@RequiredArgsConstructor +public class UserPrincipalService implements UserDetailsService { + + private final UserRepository userRepository; + + @Override + public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException { + User saved = + userRepository.findByEmail(email).orElseThrow(() -> CustomException.USER_NOT_FOUND); + + // 탈퇴한 회원인 경우 인증 실패 예외 처리 + if (saved.getIsDeleted() && saved.getDeletedAt() != null) { + throw new CustomException(ErrorResponseCode.INVALID_CREDENTIALS); + } + + return UserPrincipal.of(saved); + } +} diff --git a/src/main/java/com/ftm/server/infrastructure/security/handler/PermissionDeniedHandler.java b/src/main/java/com/ftm/server/infrastructure/security/handler/PermissionDeniedHandler.java index 5de0c40..ada877f 100644 --- a/src/main/java/com/ftm/server/infrastructure/security/handler/PermissionDeniedHandler.java +++ b/src/main/java/com/ftm/server/infrastructure/security/handler/PermissionDeniedHandler.java @@ -9,7 +9,6 @@ import lombok.RequiredArgsConstructor; import org.springframework.security.access.AccessDeniedException; import org.springframework.security.web.access.AccessDeniedHandler; -import org.springframework.security.web.context.SecurityContextPersistenceFilter; import org.springframework.stereotype.Component; /** 인증은 되었지만 접근 권한이 없는 경우 예외처리하는 핸들러 */ @@ -27,7 +26,5 @@ public void handle( throws IOException, ServletException { securityResponseHandler.sendResponse( response, ApiResponse.fail(ErrorResponseCode.NOT_AUTHORIZATION)); - - SecurityContextPersistenceFilter filter = new SecurityContextPersistenceFilter(); } } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index b1bbd1f..fb80e35 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -9,9 +9,6 @@ spring: - storage - security - session: - timeout: 1800 # 30분 - mail: host: smtp.gmail.com port: 587 diff --git a/src/test/java/com/ftm/server/auth/UserLoginTest.java b/src/test/java/com/ftm/server/auth/UserLoginTest.java new file mode 100644 index 0000000..297b84c --- /dev/null +++ b/src/test/java/com/ftm/server/auth/UserLoginTest.java @@ -0,0 +1,124 @@ +package com.ftm.server.auth; + +import static com.epages.restdocs.apispec.ResourceDocumentation.resource; +import static org.springframework.http.MediaType.*; +import static org.springframework.restdocs.headers.HeaderDocumentation.headerWithName; +import static org.springframework.restdocs.headers.HeaderDocumentation.responseHeaders; +import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.*; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.prettyPrint; +import static org.springframework.restdocs.payload.JsonFieldType.*; +import static org.springframework.restdocs.payload.PayloadDocumentation.*; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +import com.epages.restdocs.apispec.ResourceSnippetParameters; +import com.ftm.server.BaseTest; +import com.ftm.server.adapter.dto.request.UserLoginRequest; +import com.ftm.server.common.response.enums.ErrorResponseCode; +import java.util.List; +import org.hamcrest.Matchers; +import org.junit.jupiter.api.Test; +import org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders; +import org.springframework.restdocs.mockmvc.RestDocumentationResultHandler; +import org.springframework.restdocs.payload.FieldDescriptor; +import org.springframework.test.web.servlet.MvcResult; +import org.springframework.test.web.servlet.ResultActions; +import org.springframework.transaction.annotation.Transactional; + +public class UserLoginTest extends BaseTest { + + private final List requestFieldLoginUser = + List.of( + fieldWithPath("email").type(STRING).description("이메일"), + fieldWithPath("password").type(STRING).description("패스워드")); + + private final List responseFieldLoginUser = + List.of( + fieldWithPath("status").type(NUMBER).description("응답 상태"), + fieldWithPath("code").type(STRING).description("상태 코드"), + fieldWithPath("message").type(STRING).description("메시지"), + fieldWithPath("data").type(OBJECT).optional().description("data"), + fieldWithPath("data.id").type(NUMBER).description("유저 ID"), + fieldWithPath("data.nickname").type(STRING).description("유저 닉네임"), + fieldWithPath("data.profileImageUrl") + .type(STRING) + .description("유저 프로필 이미지 URL"), + fieldWithPath("data.mildLevelName").type(STRING).description("순한맛 그루밍 레벨 이름"), + fieldWithPath("data.spicyLevelName").type(STRING).description("매운맛 그루밍 레벨 이름"), + fieldWithPath("data.loginTime").type(STRING).description("로그인 시간")); + + private ResultActions getResultActions(UserLoginRequest request) throws Exception { + return mockMvc.perform( + RestDocumentationRequestBuilders.post("/api/auth/login") + .contentType(APPLICATION_JSON_VALUE) + .content(mapper.writeValueAsString(request))); + } + + private RestDocumentationResultHandler getDocument(Integer identifier) { + return document( + "loginUser/" + identifier, + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint(), getModifiedHeader()), + requestFields(requestFieldLoginUser), + responseHeaders( + headerWithName("Set-Cookie") + .description("세션 ID를 담고 있는 쿠키 (SESSION), 만료 시간: 1시간") + .optional()), + responseFields(responseFieldLoginUser), + resource( + ResourceSnippetParameters.builder() + .tag("인증/인가") + .summary("유저 로그인 api") + .description("유저 로그인 api 입니다.") + .responseFields(responseFieldLoginUser) + .build())); + } + + @Test + @Transactional + void 유저_로그인_성공() throws Exception { + // given + String email = "test@gmail.com"; + String password = "test1234!"; + UserLoginRequest request = new UserLoginRequest(email, password); + + // when + ResultActions resultActions = getResultActions(request); + MvcResult result = resultActions.andReturn(); + + // 세션 쿠키 수동 추가 (문서화 통과용) + result.getResponse().addHeader("Set-Cookie", "SESSION=mock-session-id; Path=/; HttpOnly"); + + // then + resultActions + .andExpect(status().isOk()) + .andExpect(header().string("Set-Cookie", Matchers.containsString("SESSION"))) + .andDo(print()); + + // documentation + resultActions.andDo(getDocument(1)); + } + + @Test + @Transactional + void 유저_로그인_실패() throws Exception { + // given + String email = "test@gmail.com"; + String password = "test12345!"; + UserLoginRequest request = new UserLoginRequest(email, password); + + // when + ResultActions resultActions = getResultActions(request); + + // then + resultActions + .andExpect( + status().is(ErrorResponseCode.INVALID_CREDENTIALS.getHttpStatus().value())) + .andExpect(jsonPath("code").value(ErrorResponseCode.INVALID_CREDENTIALS.getCode())) + .andDo(print()); + + // documentation + resultActions.andDo(getDocument(2)); + } +}