From 3615243e5360cf340e874bb6f2e1c3138bf76dfa Mon Sep 17 00:00:00 2001 From: Kimsuhhee04 Date: Tue, 19 May 2026 16:31:39 +0900 Subject: [PATCH 1/6] =?UTF-8?q?Feat:=20Spring=20Security=20-=20Security=20?= =?UTF-8?q?=EA=B5=AC=EC=A1=B0,=20=ED=8F=BC=20=EB=A1=9C=EA=B7=B8=EC=9D=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Seohui/build.gradle | 4 ++ .../study/UMC10/domain/user/entity/User.java | 3 + .../user/repository/UserRepository.java | 3 + .../domain/user/service/UserService.java | 42 ++++++++++-- .../global/apiPayload/code/BaseEntity.java | 2 + .../UMC10/global/config/SecurityConfig.java | 68 +++++++++++++++++++ .../security/CustomAccessDeniedHandler.java | 30 ++++++++ .../CustomAuthenticationEntryPoint.java | 30 ++++++++ .../global/security/CustomUserDetails.java | 52 ++++++++++++++ .../security/CustomUserDetailsService.java | 26 +++++++ 10 files changed, 256 insertions(+), 4 deletions(-) create mode 100644 Seohui/src/main/java/com/study/UMC10/global/config/SecurityConfig.java create mode 100644 Seohui/src/main/java/com/study/UMC10/global/security/CustomAccessDeniedHandler.java create mode 100644 Seohui/src/main/java/com/study/UMC10/global/security/CustomAuthenticationEntryPoint.java create mode 100644 Seohui/src/main/java/com/study/UMC10/global/security/CustomUserDetails.java create mode 100644 Seohui/src/main/java/com/study/UMC10/global/security/CustomUserDetailsService.java diff --git a/Seohui/build.gradle b/Seohui/build.gradle index 6a7f11f8..676e3fd5 100644 --- a/Seohui/build.gradle +++ b/Seohui/build.gradle @@ -32,6 +32,10 @@ dependencies { // Swagger implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:3.0.1' implementation 'org.springdoc:springdoc-openapi-starter-webmvc-api:3.0.1' + + // Security + implementation 'org.springframework.boot:spring-boot-starter-security' + testImplementation 'org.springframework.security:spring-security-test' } tasks.named('test') { diff --git a/Seohui/src/main/java/com/study/UMC10/domain/user/entity/User.java b/Seohui/src/main/java/com/study/UMC10/domain/user/entity/User.java index e87eefe9..022ca303 100644 --- a/Seohui/src/main/java/com/study/UMC10/domain/user/entity/User.java +++ b/Seohui/src/main/java/com/study/UMC10/domain/user/entity/User.java @@ -46,6 +46,9 @@ public class User extends BaseEntity { @Column(name = "email", nullable = false, length = 40) private String email; + @Column(name = "password", nullable = false) + private String password; + @Column(name = "nickname", length = 20) private String nickname; diff --git a/Seohui/src/main/java/com/study/UMC10/domain/user/repository/UserRepository.java b/Seohui/src/main/java/com/study/UMC10/domain/user/repository/UserRepository.java index 8980ff3e..25547e32 100644 --- a/Seohui/src/main/java/com/study/UMC10/domain/user/repository/UserRepository.java +++ b/Seohui/src/main/java/com/study/UMC10/domain/user/repository/UserRepository.java @@ -3,5 +3,8 @@ import com.study.UMC10.domain.user.entity.User; import org.springframework.data.jpa.repository.JpaRepository; +import java.util.Optional; + public interface UserRepository extends JpaRepository { + Optional findByEmail(String email); } \ No newline at end of file diff --git a/Seohui/src/main/java/com/study/UMC10/domain/user/service/UserService.java b/Seohui/src/main/java/com/study/UMC10/domain/user/service/UserService.java index 7a1c0496..d1b958e6 100644 --- a/Seohui/src/main/java/com/study/UMC10/domain/user/service/UserService.java +++ b/Seohui/src/main/java/com/study/UMC10/domain/user/service/UserService.java @@ -4,7 +4,6 @@ import com.study.UMC10.domain.mission.repository.MissionRepository; import com.study.UMC10.domain.user.code.UserErrorCode; import com.study.UMC10.domain.user.code.UserException; -import com.study.UMC10.domain.user.converter.UserConverter; import com.study.UMC10.domain.user.dto.request.UserRequestDto; import com.study.UMC10.domain.user.dto.response.UserResponseDto; import com.study.UMC10.domain.user.entity.User; @@ -12,6 +11,7 @@ import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; +import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -24,12 +24,12 @@ public class UserService { private final UserRepository userRepository; private final MissionRepository missionRepository; + private final PasswordEncoder passwordEncoder; // 마이페이지 @Transactional(readOnly = true) public UserResponseDto.GetInfo getInfo(UserRequestDto.GetInfo dto) { Long userId = dto.id(); - User user = userRepository.findById(userId) .orElseThrow(() -> new UserException(UserErrorCode.MEMBER_NOT_FOUND)); @@ -43,8 +43,43 @@ public UserResponseDto.GetInfo getInfo(UserRequestDto.GetInfo dto) { .build(); } + // 회원가입 + @Transactional public UserResponseDto.SignUpResultDto signUp(UserRequestDto.SignUpDto requestDto) { - return null; + + String encodedPassword = passwordEncoder.encode(requestDto.password()); + + com.study.UMC10.domain.user.enums.Gender userGender; + try { + userGender = com.study.UMC10.domain.user.enums.Gender.valueOf(requestDto.gender().toUpperCase()); + } catch (Exception e) { + userGender = com.study.UMC10.domain.user.enums.Gender.NONE; + } + + java.time.LocalDate userBirth = null; + if (requestDto.birth() != null && !requestDto.birth().isBlank()) { + userBirth = java.time.LocalDate.parse(requestDto.birth()); + } + + User newUser = User.builder() + .email(requestDto.email()) + .password(encodedPassword) + .name(requestDto.name()) + .nickname(requestDto.nickname()) + .address(requestDto.address()) + .gender(userGender) + .birth(userBirth) + .totalPoint(0) + .finMission(0) + .status(com.study.UMC10.domain.user.enums.UserStatus.ACTIVE) + .build(); + + User savedUser = userRepository.save(newUser); + + return UserResponseDto.SignUpResultDto.builder() + .userId(savedUser.getId()) + .name(savedUser.getName()) + .build(); } // 홈 화면 (지역별 미션 조회 + 페이징) @@ -56,7 +91,6 @@ public UserResponseDto.HomeResultDto getHome(String region, Integer page) { .orElseThrow(() -> new UserException(UserErrorCode.MEMBER_NOT_FOUND)); PageRequest pageRequest = PageRequest.of(page, 10); - Page missionPage = missionRepository.findMissionsByRegion(region, pageRequest); List missionDtoList = missionPage.stream() diff --git a/Seohui/src/main/java/com/study/UMC10/global/apiPayload/code/BaseEntity.java b/Seohui/src/main/java/com/study/UMC10/global/apiPayload/code/BaseEntity.java index 66e7a667..a3b078db 100644 --- a/Seohui/src/main/java/com/study/UMC10/global/apiPayload/code/BaseEntity.java +++ b/Seohui/src/main/java/com/study/UMC10/global/apiPayload/code/BaseEntity.java @@ -2,6 +2,7 @@ import jakarta.persistence.Column; import jakarta.persistence.EntityListeners; +import jakarta.persistence.MappedSuperclass; import lombok.Getter; import org.springframework.data.annotation.CreatedDate; import org.springframework.data.annotation.LastModifiedDate; @@ -9,6 +10,7 @@ import java.time.LocalDateTime; +@MappedSuperclass @EntityListeners(AuditingEntityListener.class) @Getter public abstract class BaseEntity { diff --git a/Seohui/src/main/java/com/study/UMC10/global/config/SecurityConfig.java b/Seohui/src/main/java/com/study/UMC10/global/config/SecurityConfig.java new file mode 100644 index 00000000..a2a90ab6 --- /dev/null +++ b/Seohui/src/main/java/com/study/UMC10/global/config/SecurityConfig.java @@ -0,0 +1,68 @@ +package com.study.UMC10.global.config; + +import com.study.UMC10.global.security.CustomAccessDeniedHandler; +import com.study.UMC10.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; + +@EnableWebSecurity +@Configuration +@RequiredArgsConstructor +public class SecurityConfig { + + private final CustomAccessDeniedHandler customAccessDeniedHandler; + private final CustomAuthenticationEntryPoint customAuthenticationEntryPoint; + + // 인증x 접근 가능한 Public API + private final String[] allowUris = { + "/swagger-ui/**", + "/swagger-resources/**", + "/v3/api-docs/**", + "/api/auth/**" + }; + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + http + .csrf(AbstractHttpConfigurer::disable) + + .authorizeHttpRequests(requests -> requests + .requestMatchers(allowUris).permitAll() // Public API는 허용 + .anyRequest().authenticated() // 그 외의 Private API는 인증 필요 + ) + + // 폼 로그인 설정 + .formLogin(form -> form + .defaultSuccessUrl("/swagger-ui/index.html", true) + .permitAll() + ) + + // 로그아웃 설정 + .logout(logout -> logout + .logoutUrl("/logout") + .logoutSuccessUrl("/login?logout") + .permitAll() + ) + + // 예외 핸들러 + .exceptionHandling(exception -> exception + .accessDeniedHandler(customAccessDeniedHandler) + .authenticationEntryPoint(customAuthenticationEntryPoint) + ); + + return http.build(); + } + + // BCrypt 인코더 빈 + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } +} \ No newline at end of file diff --git a/Seohui/src/main/java/com/study/UMC10/global/security/CustomAccessDeniedHandler.java b/Seohui/src/main/java/com/study/UMC10/global/security/CustomAccessDeniedHandler.java new file mode 100644 index 00000000..b7e74feb --- /dev/null +++ b/Seohui/src/main/java/com/study/UMC10/global/security/CustomAccessDeniedHandler.java @@ -0,0 +1,30 @@ +package com.study.UMC10.global.security; +// 로그인 후 권한 없을 경우 + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.study.UMC10.global.apiPayload.ApiResponse; +import com.study.UMC10.global.apiPayload.code.GeneralErrorCode; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.web.access.AccessDeniedHandler; +import org.springframework.stereotype.Component; + +import java.io.IOException; + +@Component +public class CustomAccessDeniedHandler implements AccessDeniedHandler { + + @Override + public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException { + ObjectMapper objectMapper = new ObjectMapper(); + GeneralErrorCode code = GeneralErrorCode.FORBIDDEN; + + response.setContentType("application/json; charset=UTF-8"); + response.setStatus(code.getStatus().value()); + + ApiResponse errorResponse = ApiResponse.onFailure(code, null); + + objectMapper.writeValue(response.getOutputStream(), errorResponse); + } +} \ No newline at end of file diff --git a/Seohui/src/main/java/com/study/UMC10/global/security/CustomAuthenticationEntryPoint.java b/Seohui/src/main/java/com/study/UMC10/global/security/CustomAuthenticationEntryPoint.java new file mode 100644 index 00000000..dcf05571 --- /dev/null +++ b/Seohui/src/main/java/com/study/UMC10/global/security/CustomAuthenticationEntryPoint.java @@ -0,0 +1,30 @@ +package com.study.UMC10.global.security; +//인증되지 않은 사용자가 Private API 접근 시 + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.study.UMC10.global.apiPayload.ApiResponse; +import com.study.UMC10.global.apiPayload.code.GeneralErrorCode; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.stereotype.Component; + +import java.io.IOException; + +@Component +public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint { + + @Override + public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException { + ObjectMapper objectMapper = new ObjectMapper(); + GeneralErrorCode code = GeneralErrorCode.UNAUTHORIZED; + + response.setContentType("application/json; charset=UTF-8"); + response.setStatus(code.getStatus().value()); + + ApiResponse errorResponse = ApiResponse.onFailure(code, null); + + objectMapper.writeValue(response.getOutputStream(), errorResponse); + } +} \ No newline at end of file diff --git a/Seohui/src/main/java/com/study/UMC10/global/security/CustomUserDetails.java b/Seohui/src/main/java/com/study/UMC10/global/security/CustomUserDetails.java new file mode 100644 index 00000000..609c614e --- /dev/null +++ b/Seohui/src/main/java/com/study/UMC10/global/security/CustomUserDetails.java @@ -0,0 +1,52 @@ +package com.study.UMC10.global.security; + +import com.study.UMC10.domain.user.entity.User; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +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 CustomUserDetails implements UserDetails { + + private final User user; + + @Override + public Collection getAuthorities() { + return List.of(); + } + + @Override + public String getPassword() { + return user.getPassword(); + } + + @Override + public String getUsername() { + return user.getEmail(); + } + + @Override + public boolean isAccountNonExpired() { + return true; + } + + @Override + public boolean isAccountNonLocked() { + return true; + } + + @Override + public boolean isCredentialsNonExpired() { + return true; + } + + @Override + public boolean isEnabled() { + return true; + } +} \ No newline at end of file diff --git a/Seohui/src/main/java/com/study/UMC10/global/security/CustomUserDetailsService.java b/Seohui/src/main/java/com/study/UMC10/global/security/CustomUserDetailsService.java new file mode 100644 index 00000000..5b9066d7 --- /dev/null +++ b/Seohui/src/main/java/com/study/UMC10/global/security/CustomUserDetailsService.java @@ -0,0 +1,26 @@ +package com.study.UMC10.global.security; + +import com.study.UMC10.domain.user.code.UserErrorCode; +import com.study.UMC10.domain.user.code.UserException; +import com.study.UMC10.domain.user.entity.User; +import com.study.UMC10.domain.user.repository.UserRepository; +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 UserRepository userRepository; + + @Override + public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { + User user = userRepository.findByEmail(username) + .orElseThrow(() -> new UserException(UserErrorCode.MEMBER_NOT_FOUND)); + + return new CustomUserDetails(user); + } +} \ No newline at end of file From 98bf4697ff3af5ac2fb46fba9eb329c7e1a79f2a Mon Sep 17 00:00:00 2001 From: Kimsuhhee04 Date: Tue, 19 May 2026 16:37:28 +0900 Subject: [PATCH 2/6] =?UTF-8?q?Docs:=20ch08=20=ED=82=A4=EC=9B=8C=EB=93=9C?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Seohui/keyword_summary/ch08.md | 80 ++++++++++++++++++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 Seohui/keyword_summary/ch08.md diff --git a/Seohui/keyword_summary/ch08.md b/Seohui/keyword_summary/ch08.md new file mode 100644 index 00000000..c63e5e30 --- /dev/null +++ b/Seohui/keyword_summary/ch08.md @@ -0,0 +1,80 @@ +# Spring Security가 무엇인가? + +Java 서블릿 기반 애플리케이션에서 **인증(Authentication)**과 인가(Authorization), 보안 공격 방어 기능을 제공하는 프레임워크 +→ 서블릿 필터의 체인 구조를 통해 요청을 가로채고 보안 로직을 처리함 +요청이 DispatcherServlet(Controller)에 도달하기 전에 가로채서 보안 로직을 수행함 + +* **DelegatingFilterProxy** + * 서블릿 컨테이너와 스프링 컨테이너(IoC 컨테이너)를 연결하는 역할을 하는 표준 서블릿 필터 + * 서블릿 필터는 스프링 빈을 직접 알지 못하므로 이 프록시 필터가 요청을 받아 스프링 빈으로 등록된 보안 필터(FilterChainProxy)에 위임 +* **FilterChainProxy** + * Spring Security의 실질적인 진입점으로 DelegatingFilterProxy로부터 요청을 받아 현재 요청에 적용되어야 할 SecurityFilterChain을 찾아 실행시키는 역할을 함 +* **SecurityFilterChain** + * 보안 처리를 위한 필터 묶음 + * SecurityConfig에서 `.authorizeHttpRequests()`, `.formLogin()` 등을 설정하면 이에 맞는 필터들이 체인 형태로 구성 + +--- + +# 인증(Authentication) vs 인가(Authorization) + +## 인증(Authentication) +해당 사용자가 누구인지 확인하는 과정에서 필요한 주요 객체 + +* **Authentication** + * 인증 전/ 인증 후의 사용자 정보를 담는 객체 + * 구성 요소: + * Principal: 사용자 식별자 (로그인 전: ID / 로그인 후: UserDetails 객체) + * Credentials: 자격 증명 (로그인 전: 비밀번호 / 로그인 후: 보안을 위해 삭제됨) + * Authorities: 사용자가 가진 권한 목록 (ROLE_USER, ROLE_ADMIN 등) +* **SecurityContext** + * 현재 인증된 사용자의 Authentication 객체를 보관하는 객체 +* **SecurityContextHolder** + * SecurityContext 저장하는 저장소 + * 기본적으로 ThreadLocal 전략으로 같은 쓰레드 내에서는 어디서든 인증 정보에 접근할 수 있음 + * 요청이 끝나면 초기화되지만 JWT 환경에서는 매 요청마다 필터가 이곳에 다시 값을 채워둠 +* **AuthenticationManager** + * 인증을 처리하는 최상위 인터페이스로 해당 인증 요청을 처리하라는 `authenticate()` 메서드 하나를 가지고있음 + * 실질적인 구현체는 대부분 ProviderManager 사용 +* **ProviderManager** + * AuthenticationManager의 구현체.. 스스로 인증을 하지 않고 여러 개의 AuthenticationProvider 목록을 순회하며 인증 처리를 위임 +* **AuthenticationProvider** + * 인증 로직을 수행하는 위치 + * `authenticate()`: 실제 검증 로직 수행 + * `supports()`: 해당 Provider가 특정 Authentication 타입을 처리할 수 있는지 확인 +* **UserDetailsService** + * DB나 메모리 등에서 유저 정보를 가져오는 역할 + * username으로 DB를 조회 → UserDetails 객체 반환 +* **UserDetails** + * Spring Security가 내부적으로 사용하는 유저 정보 표준 인터페이스 + * User 엔티티를 이 인터페이스에 맞춰 감싸거나(CustomUserDetails)/ 엔티티가 직접 구현하도록 하여 Spring Security에 전달 + +## 인가 (Authorization) +해당 사용자가 이 리소스에 접근할 권한이 있는지 확인 + +* **GrantedAuthority** + * 사용자 권한 + * SimpleGrantedAuthority 구현체 사용, ROLE_ADMIN 와 같은 문자열 형태로 저장 + * Authentication 객체 안에 리스트 형태로 저장 +* **AuthorizationFilter** + * 필터 체인의 마지막부에 위치하고 URL 별로 필요한 권한이 있는지 검사 + * SecurityConfig에서 작성한 `requestMatchers("/admin/**").hasRole("ADMIN")` 정보를 바탕으로 판단 + +--- + +# Stateful vs Stateless + +* **Stateful (상태 유지)** + * 서버가 클라이언트와의 통신 상태나 이전 요청 데이터를 메모리나 디스크에 계속 저장하고 기억하는 네트워크 통신 방식 + * 클라이언트가 첫 번째 요청에서 인증을 완료하면 서버가 이를 기억하고 있으므로 두 번째 요청부터는 추가 인증 없이 작업을 처리할 수 있다 +* **Stateless (무상태)** + * 서버가 클라이언트의 상태를 전혀 저장하지 않는 네트워크 통신 방식임 + * 서버는 이전 요청이 무엇이었는지 전혀 알지 못하므로 클라이언트는 매 요청마다 서버가 작업을 처리하는 데 필요한 모든 데이터를 온전히 담아서 보내야 함 + +| 구분 | Stateful (상태 유지) | Stateless (무상태) | +| :--- | :--- | :--- | +| **공통점** | 클라이언트와 서버 간의 네트워크 데이터 통신을 위한 설계 방식 | 클라이언트와 서버 간의 네트워크 데이터 통신을 위한 설계 방식 | +| **상태 저장** | 서버가 클라이언트의 이전 상태와 컨텍스트를 저장함 | 서버는 클라이언트의 상태를 저장하지 않음 | +| **요청 데이터** | 서버가 정보를 기억함 → 클라이언트는 최소한의 데이터만 전송 | 서버가 기억하지 않으므로 요청할 때마다 모든 데이터를 전송해야 함 | +| **서버** | 접속자가 많아질수록 서버 메모리 사용량이 급격히 증가 | 상태 저장을 하지 않음 → 서버 메모리 부담이 적음 | +| **확장성** | 여러 서버로 트래픽을 분산하는 Scale-out이 어려움 | 서버 간 상태 공유가 필요 없어 서버 확장에 자유로움 | +| **장애 대응** | 연결된 서버가 다운되면 해당 서버에 저장된 상태 정보가 날아감 → 다시 재작업 | 특정 서버가 다운되어도 다른 서버가 동일하게 요청을 처리할 수 있어 장애 대응에 유연 | \ No newline at end of file From 97ddf4a687844153f472093677277a3b52ad07d1 Mon Sep 17 00:00:00 2001 From: Kimsuhhee04 Date: Tue, 19 May 2026 21:34:24 +0900 Subject: [PATCH 3/6] =?UTF-8?q?Refactor:=20=ED=94=BC=EB=93=9C=EB=B0=B1=20?= =?UTF-8?q?=EB=B0=98=EC=98=81=20=EC=BD=94=EB=93=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../UMC10/domain/review/controller/ReviewController.java | 6 ++++-- .../UMC10/domain/review/repository/ReviewRepository.java | 8 ++++---- .../com/study/UMC10/domain/user/code/UserErrorCode.java | 9 ++++----- .../UMC10/domain/user/repository/UserRepository.java | 1 + .../com/study/UMC10/domain/user/service/UserService.java | 7 ++++++- .../study/UMC10/global/security/CustomUserDetails.java | 3 ++- .../UMC10/global/security/CustomUserDetailsService.java | 4 +--- 7 files changed, 22 insertions(+), 16 deletions(-) diff --git a/Seohui/src/main/java/com/study/UMC10/domain/review/controller/ReviewController.java b/Seohui/src/main/java/com/study/UMC10/domain/review/controller/ReviewController.java index 22f8abf2..f5f60866 100644 --- a/Seohui/src/main/java/com/study/UMC10/domain/review/controller/ReviewController.java +++ b/Seohui/src/main/java/com/study/UMC10/domain/review/controller/ReviewController.java @@ -6,12 +6,14 @@ import com.study.UMC10.domain.review.service.ReviewService; import com.study.UMC10.global.apiPayload.ApiResponse; import com.study.UMC10.global.apiPayload.code.BaseSuccessCode; +import com.study.UMC10.global.security.CustomUserDetails; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.Parameters; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; +import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; @@ -49,13 +51,13 @@ public ApiResponse createReview( }) @GetMapping("/v1/reviews/me") public ApiResponse> getMyReviews( - @RequestParam("userId") Long userId, + @AuthenticationPrincipal CustomUserDetails customUserDetails, @RequestParam(name = "query", defaultValue = "id") String query, @RequestParam(name = "cursor", defaultValue = "-1") String cursor, @RequestParam(name = "pageSize", defaultValue = "10") Integer pageSize ) { + Long userId = customUserDetails.getUser().getId(); BaseSuccessCode code = com.study.UMC10.global.apiPayload.code.GeneralSuccessCode.OK; - return ApiResponse.onSuccess(code, reviewService.getMyReviews(userId, query, cursor, pageSize)); } } \ No newline at end of file diff --git a/Seohui/src/main/java/com/study/UMC10/domain/review/repository/ReviewRepository.java b/Seohui/src/main/java/com/study/UMC10/domain/review/repository/ReviewRepository.java index cb61b6d1..7e4065ea 100644 --- a/Seohui/src/main/java/com/study/UMC10/domain/review/repository/ReviewRepository.java +++ b/Seohui/src/main/java/com/study/UMC10/domain/review/repository/ReviewRepository.java @@ -12,19 +12,19 @@ public interface ReviewRepository extends JpaRepository { // 리뷰 ID 기준 내림차순 // 커서 없는 최초 조회 - @Query("SELECT r FROM Review r WHERE r.user.id = :userId ORDER BY r.id DESC") + @Query("SELECT r FROM Review r JOIN FETCH r.user JOIN FETCH r.store LEFT JOIN FETCH r.ownerComment WHERE r.user.id = :userId ORDER BY r.id DESC") Slice findMyReviewsOrderByIdDesc(@Param("userId") Long userId, Pageable pageable); // 커서 조회 - @Query("SELECT r FROM Review r WHERE r.user.id = :userId AND r.id < :cursorId ORDER BY r.id DESC") + @Query("SELECT r FROM Review r JOIN FETCH r.user JOIN FETCH r.store LEFT JOIN FETCH r.ownerComment WHERE r.user.id = :userId AND r.id < :cursorId ORDER BY r.id DESC") Slice findMyReviewsByCursorId(@Param("userId") Long userId, @Param("cursorId") Long cursorId, Pageable pageable); // 별점 순 // 최초 조회 - @Query("SELECT r FROM Review r WHERE r.user.id = :userId ORDER BY r.score DESC, r.id DESC") + @Query("SELECT r FROM Review r JOIN FETCH r.user JOIN FETCH r.store LEFT JOIN FETCH r.ownerComment WHERE r.user.id = :userId ORDER BY r.score DESC, r.id DESC") Slice findMyReviewsOrderByScoreDesc(@Param("userId") Long userId, Pageable pageable); // 커서 조회 - @Query("SELECT r FROM Review r WHERE r.user.id = :userId AND (r.score < :cursorScore OR (r.score = :cursorScore AND r.id < :cursorId)) ORDER BY r.score DESC, r.id DESC") + @Query("SELECT r FROM Review r JOIN FETCH r.user JOIN FETCH r.store LEFT JOIN FETCH r.ownerComment WHERE r.user.id = :userId AND (r.score < :cursorScore OR (r.score = :cursorScore AND r.id < :cursorId)) ORDER BY r.score DESC, r.id DESC") Slice findMyReviewsByCursorScoreAndId(@Param("userId") Long userId, @Param("cursorScore") Double cursorScore, @Param("cursorId") Long cursorId, Pageable pageable); } \ No newline at end of file diff --git a/Seohui/src/main/java/com/study/UMC10/domain/user/code/UserErrorCode.java b/Seohui/src/main/java/com/study/UMC10/domain/user/code/UserErrorCode.java index f5a24083..2157e3c7 100644 --- a/Seohui/src/main/java/com/study/UMC10/domain/user/code/UserErrorCode.java +++ b/Seohui/src/main/java/com/study/UMC10/domain/user/code/UserErrorCode.java @@ -9,13 +9,12 @@ @RequiredArgsConstructor public enum UserErrorCode implements BaseErrorCode { - MEMBER_NOT_FOUND(HttpStatus.NOT_FOUND, - "MEMBER404_1", - "해당 사용자를 찾을 수 없습니다.") + MEMBER_NOT_FOUND(HttpStatus.NOT_FOUND, "MEMBER404_1", "해당 사용자를 찾을 수 없습니다."), + EMAIL_ALREADY_EXISTS(HttpStatus.BAD_REQUEST, "USER400_1", "이미 사용 중인 이메일입니다."), + INVALID_GENDER(HttpStatus.BAD_REQUEST, "USER400_2", "올바르지 않은 성별 형식입니다.") ; private final HttpStatus status; private final String code; private final String message; - -} +} \ No newline at end of file diff --git a/Seohui/src/main/java/com/study/UMC10/domain/user/repository/UserRepository.java b/Seohui/src/main/java/com/study/UMC10/domain/user/repository/UserRepository.java index 25547e32..a2e5f834 100644 --- a/Seohui/src/main/java/com/study/UMC10/domain/user/repository/UserRepository.java +++ b/Seohui/src/main/java/com/study/UMC10/domain/user/repository/UserRepository.java @@ -7,4 +7,5 @@ public interface UserRepository extends JpaRepository { Optional findByEmail(String email); + boolean existsByEmail(String email); } \ No newline at end of file diff --git a/Seohui/src/main/java/com/study/UMC10/domain/user/service/UserService.java b/Seohui/src/main/java/com/study/UMC10/domain/user/service/UserService.java index d1b958e6..d9aa3f93 100644 --- a/Seohui/src/main/java/com/study/UMC10/domain/user/service/UserService.java +++ b/Seohui/src/main/java/com/study/UMC10/domain/user/service/UserService.java @@ -47,13 +47,18 @@ public UserResponseDto.GetInfo getInfo(UserRequestDto.GetInfo dto) { @Transactional public UserResponseDto.SignUpResultDto signUp(UserRequestDto.SignUpDto requestDto) { + // 이메일 중복 체크 + if (userRepository.existsByEmail(requestDto.email())) { + throw new UserException(UserErrorCode.EMAIL_ALREADY_EXISTS); + } + String encodedPassword = passwordEncoder.encode(requestDto.password()); com.study.UMC10.domain.user.enums.Gender userGender; try { userGender = com.study.UMC10.domain.user.enums.Gender.valueOf(requestDto.gender().toUpperCase()); } catch (Exception e) { - userGender = com.study.UMC10.domain.user.enums.Gender.NONE; + throw new UserException(UserErrorCode.INVALID_GENDER); } java.time.LocalDate userBirth = null; diff --git a/Seohui/src/main/java/com/study/UMC10/global/security/CustomUserDetails.java b/Seohui/src/main/java/com/study/UMC10/global/security/CustomUserDetails.java index 609c614e..a7b00361 100644 --- a/Seohui/src/main/java/com/study/UMC10/global/security/CustomUserDetails.java +++ b/Seohui/src/main/java/com/study/UMC10/global/security/CustomUserDetails.java @@ -4,6 +4,7 @@ import lombok.Getter; import lombok.RequiredArgsConstructor; import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.userdetails.UserDetails; import java.util.Collection; @@ -17,7 +18,7 @@ public class CustomUserDetails implements UserDetails { @Override public Collection getAuthorities() { - return List.of(); + return List.of(new SimpleGrantedAuthority("ROLE_USER")); } @Override diff --git a/Seohui/src/main/java/com/study/UMC10/global/security/CustomUserDetailsService.java b/Seohui/src/main/java/com/study/UMC10/global/security/CustomUserDetailsService.java index 5b9066d7..9957bd8a 100644 --- a/Seohui/src/main/java/com/study/UMC10/global/security/CustomUserDetailsService.java +++ b/Seohui/src/main/java/com/study/UMC10/global/security/CustomUserDetailsService.java @@ -1,7 +1,5 @@ package com.study.UMC10.global.security; -import com.study.UMC10.domain.user.code.UserErrorCode; -import com.study.UMC10.domain.user.code.UserException; import com.study.UMC10.domain.user.entity.User; import com.study.UMC10.domain.user.repository.UserRepository; import lombok.RequiredArgsConstructor; @@ -19,7 +17,7 @@ public class CustomUserDetailsService implements UserDetailsService { @Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { User user = userRepository.findByEmail(username) - .orElseThrow(() -> new UserException(UserErrorCode.MEMBER_NOT_FOUND)); + .orElseThrow(() -> new UsernameNotFoundException("해당 이메일의 사용자를 찾을 수 없습니다: " + username)); return new CustomUserDetails(user); } From 3a2c48470a977333e4644a7081944ee3f1e0b40b Mon Sep 17 00:00:00 2001 From: Kimsuhhee04 Date: Tue, 26 May 2026 14:32:05 +0900 Subject: [PATCH 4/6] =?UTF-8?q?Feat:=20Spring=20Security=20-=20JWT,=20OAut?= =?UTF-8?q?h=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Seohui/build.gradle | 9 ++ .../UMC10/domain/user/code/UserErrorCode.java | 3 +- .../user/controller/UserController.java | 23 ++++- .../user/dto/request/UserRequestDto.java | 10 ++ .../user/dto/response/UserResponseDto.java | 8 ++ .../user/repository/UserLoginRepository.java | 11 ++- .../domain/user/service/UserService.java | 40 +++++++- .../UMC10/global/config/PasswordConfig.java | 15 +++ .../UMC10/global/config/SecurityConfig.java | 54 +++++++---- .../global/security/CustomOAuth2User.java | 34 +++++++ .../UMC10/global/security/dto/KakaoDTO.java | 31 ++++++ .../UMC10/global/security/dto/OAuthDTO.java | 10 ++ .../global/security/filter/JwtAuthFilter.java | 81 ++++++++++++++++ .../CustomAccessDeniedHandler.java | 2 +- .../security/handler/OAuthSuccessHandler.java | 55 +++++++++++ .../security/service/CustomOAuthService.java | 85 +++++++++++++++++ .../CustomUserDetailsService.java | 3 +- .../UMC10/global/security/util/JwtUtil.java | 94 +++++++++++++++++++ 18 files changed, 543 insertions(+), 25 deletions(-) create mode 100644 Seohui/src/main/java/com/study/UMC10/global/config/PasswordConfig.java create mode 100644 Seohui/src/main/java/com/study/UMC10/global/security/CustomOAuth2User.java create mode 100644 Seohui/src/main/java/com/study/UMC10/global/security/dto/KakaoDTO.java create mode 100644 Seohui/src/main/java/com/study/UMC10/global/security/dto/OAuthDTO.java create mode 100644 Seohui/src/main/java/com/study/UMC10/global/security/filter/JwtAuthFilter.java rename Seohui/src/main/java/com/study/UMC10/global/security/{ => handler}/CustomAccessDeniedHandler.java (95%) create mode 100644 Seohui/src/main/java/com/study/UMC10/global/security/handler/OAuthSuccessHandler.java create mode 100644 Seohui/src/main/java/com/study/UMC10/global/security/service/CustomOAuthService.java rename Seohui/src/main/java/com/study/UMC10/global/security/{ => service}/CustomUserDetailsService.java (89%) create mode 100644 Seohui/src/main/java/com/study/UMC10/global/security/util/JwtUtil.java diff --git a/Seohui/build.gradle b/Seohui/build.gradle index 676e3fd5..3333ed20 100644 --- a/Seohui/build.gradle +++ b/Seohui/build.gradle @@ -36,6 +36,15 @@ dependencies { // Security implementation 'org.springframework.boot:spring-boot-starter-security' testImplementation 'org.springframework.security:spring-security-test' + + // Jwt + implementation 'io.jsonwebtoken:jjwt-api:0.12.3' + implementation 'io.jsonwebtoken:jjwt-impl:0.12.3' + implementation 'io.jsonwebtoken:jjwt-jackson:0.12.3' + implementation 'org.springframework.boot:spring-boot-configuration-processor' + + // OAuth + implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' } tasks.named('test') { diff --git a/Seohui/src/main/java/com/study/UMC10/domain/user/code/UserErrorCode.java b/Seohui/src/main/java/com/study/UMC10/domain/user/code/UserErrorCode.java index 2157e3c7..ff5cf937 100644 --- a/Seohui/src/main/java/com/study/UMC10/domain/user/code/UserErrorCode.java +++ b/Seohui/src/main/java/com/study/UMC10/domain/user/code/UserErrorCode.java @@ -11,7 +11,8 @@ public enum UserErrorCode implements BaseErrorCode { MEMBER_NOT_FOUND(HttpStatus.NOT_FOUND, "MEMBER404_1", "해당 사용자를 찾을 수 없습니다."), EMAIL_ALREADY_EXISTS(HttpStatus.BAD_REQUEST, "USER400_1", "이미 사용 중인 이메일입니다."), - INVALID_GENDER(HttpStatus.BAD_REQUEST, "USER400_2", "올바르지 않은 성별 형식입니다.") + INVALID_GENDER(HttpStatus.BAD_REQUEST, "USER400_2", "올바르지 않은 성별 형식입니다."), + INVALID_PASSWORD(HttpStatus.BAD_REQUEST, "USER400_3", "비밀번호가 일치하지 않습니다.") ; private final HttpStatus status; diff --git a/Seohui/src/main/java/com/study/UMC10/domain/user/controller/UserController.java b/Seohui/src/main/java/com/study/UMC10/domain/user/controller/UserController.java index 81c3417f..96a4d2eb 100644 --- a/Seohui/src/main/java/com/study/UMC10/domain/user/controller/UserController.java +++ b/Seohui/src/main/java/com/study/UMC10/domain/user/controller/UserController.java @@ -11,6 +11,9 @@ import io.swagger.v3.oas.annotations.Parameters; import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; +import com.study.UMC10.global.security.CustomUserDetails; +import org.springframework.security.core.annotation.AuthenticationPrincipal; + import org.springframework.web.bind.annotation.*; @RestController @@ -21,7 +24,16 @@ public class UserController { private final UserService userService; - @Operation(summary = "마이페이지 API", description = "유저의 마이페이지 정보를 조회하는 API입니다.") + @Operation(summary = "로그인 API", description = "이메일과 비밀번호로 로그인하여 JWT 토큰을 발급받습니다.") + @PostMapping("/auth/login") + public ApiResponse login( + @RequestBody UserRequestDto.LoginDto requestDto + ) { + BaseSuccessCode code = UserSuccessCode.OK; + return ApiResponse.onSuccess(code, userService.login(requestDto)); + } + + @Operation(summary = "마이페이지 API V1", description = "유저의 마이페이지 정보를 조회하는 API입니다.") @PostMapping("/v1/users/me") public ApiResponse getInfo( @RequestBody UserRequestDto.GetInfo dto @@ -30,6 +42,15 @@ public ApiResponse getInfo( return ApiResponse.onSuccess(code, userService.getInfo(dto)); } + @Operation(summary = "마이페이지 API V2", description = "JWT 토큰을 이용해 유저의 마이페이지 정보를 조회하는 API입니다.") + @GetMapping("/v2/users/me") + public ApiResponse getInfoV2( + @AuthenticationPrincipal CustomUserDetails customUserDetails + ) { + BaseSuccessCode code = UserSuccessCode.OK; + return ApiResponse.onSuccess(code, userService.getMyInfoV2(customUserDetails.getUser())); + } + @Operation(summary = "회원가입 API", description = "새로운 유저를 등록하는 API입니다.") @PostMapping("/auth/sign-up") public ApiResponse signUp( diff --git a/Seohui/src/main/java/com/study/UMC10/domain/user/dto/request/UserRequestDto.java b/Seohui/src/main/java/com/study/UMC10/domain/user/dto/request/UserRequestDto.java index 3140fb77..4e028c95 100644 --- a/Seohui/src/main/java/com/study/UMC10/domain/user/dto/request/UserRequestDto.java +++ b/Seohui/src/main/java/com/study/UMC10/domain/user/dto/request/UserRequestDto.java @@ -51,4 +51,14 @@ public record ServiceAgreeDto( Boolean marketing ) { } + + @Schema(description = "로그인 요청") + public record LoginDto( + @Schema(description = "이메일", example = "sol12@example.com") + String email, + + @Schema(description = "비밀번호", example = "password1234") + String password + ) { + } } \ No newline at end of file diff --git a/Seohui/src/main/java/com/study/UMC10/domain/user/dto/response/UserResponseDto.java b/Seohui/src/main/java/com/study/UMC10/domain/user/dto/response/UserResponseDto.java index f6ddc1ee..0152b2fc 100644 --- a/Seohui/src/main/java/com/study/UMC10/domain/user/dto/response/UserResponseDto.java +++ b/Seohui/src/main/java/com/study/UMC10/domain/user/dto/response/UserResponseDto.java @@ -80,4 +80,12 @@ public record HomeMissionDto( String status ) { } + + @Builder + @Schema(description = "로그인 성공 응답") + public record LoginResultDto( + @Schema(description = "JWT Access Token") + String accessToken + ) { + } } \ No newline at end of file diff --git a/Seohui/src/main/java/com/study/UMC10/domain/user/repository/UserLoginRepository.java b/Seohui/src/main/java/com/study/UMC10/domain/user/repository/UserLoginRepository.java index dca36dd7..04c2d700 100644 --- a/Seohui/src/main/java/com/study/UMC10/domain/user/repository/UserLoginRepository.java +++ b/Seohui/src/main/java/com/study/UMC10/domain/user/repository/UserLoginRepository.java @@ -1,4 +1,11 @@ package com.study.UMC10.domain.user.repository; -public class UserLoginRepository { -} +import com.study.UMC10.domain.user.entity.UserLogin; +import com.study.UMC10.domain.user.enums.LoginType; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface UserLoginRepository extends JpaRepository { + Optional findByLoginTypeAndSocialId(LoginType loginType, String socialId); +} \ No newline at end of file diff --git a/Seohui/src/main/java/com/study/UMC10/domain/user/service/UserService.java b/Seohui/src/main/java/com/study/UMC10/domain/user/service/UserService.java index d9aa3f93..f6d62e93 100644 --- a/Seohui/src/main/java/com/study/UMC10/domain/user/service/UserService.java +++ b/Seohui/src/main/java/com/study/UMC10/domain/user/service/UserService.java @@ -14,6 +14,8 @@ import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import com.study.UMC10.global.security.util.JwtUtil; +import com.study.UMC10.global.security.CustomUserDetails; import java.util.List; import java.util.stream.Collectors; @@ -25,8 +27,9 @@ public class UserService { private final UserRepository userRepository; private final MissionRepository missionRepository; private final PasswordEncoder passwordEncoder; + private final JwtUtil jwtUtil; - // 마이페이지 + // 마이페이지 V1 @Transactional(readOnly = true) public UserResponseDto.GetInfo getInfo(UserRequestDto.GetInfo dto) { Long userId = dto.id(); @@ -116,4 +119,39 @@ public UserResponseDto.HomeResultDto getHome(String region, Integer page) { .missions(missionDtoList) .build(); } + + // 로그인 + @Transactional(readOnly = true) + public UserResponseDto.LoginResultDto login(UserRequestDto.LoginDto requestDto) { + // 이메일로 유저 찾음 + User user = userRepository.findByEmail(requestDto.email()) + .orElseThrow(() -> new UserException(UserErrorCode.MEMBER_NOT_FOUND)); + + // PW 일치 여부 확인 + if (!passwordEncoder.matches(requestDto.password(), user.getPassword())) { + throw new UserException(UserErrorCode.INVALID_PASSWORD); + } + + // JWT 토큰 발급 + CustomUserDetails userDetails = new CustomUserDetails(user); + String token = jwtUtil.createAccessToken(userDetails); + + // 토큰 응답 + return UserResponseDto.LoginResultDto.builder() + .accessToken(token) + .build(); + } + + // 마이페이지 V2 + @Transactional(readOnly = true) + public UserResponseDto.GetInfo getMyInfoV2(User user) { + return UserResponseDto.GetInfo.builder() + .name(user.getName()) + .nickname(user.getNickname()) + .email(user.getEmail()) + .phoneVerified(user.getIsPhone()) + .phoneNum(user.getPhoneNum()) + .totalPoint(user.getTotalPoint()) + .build(); + } } \ No newline at end of file diff --git a/Seohui/src/main/java/com/study/UMC10/global/config/PasswordConfig.java b/Seohui/src/main/java/com/study/UMC10/global/config/PasswordConfig.java new file mode 100644 index 00000000..a0721500 --- /dev/null +++ b/Seohui/src/main/java/com/study/UMC10/global/config/PasswordConfig.java @@ -0,0 +1,15 @@ +package com.study.UMC10.global.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; + +@Configuration +public class PasswordConfig { + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } +} \ No newline at end of file diff --git a/Seohui/src/main/java/com/study/UMC10/global/config/SecurityConfig.java b/Seohui/src/main/java/com/study/UMC10/global/config/SecurityConfig.java index a2a90ab6..45010478 100644 --- a/Seohui/src/main/java/com/study/UMC10/global/config/SecurityConfig.java +++ b/Seohui/src/main/java/com/study/UMC10/global/config/SecurityConfig.java @@ -1,16 +1,21 @@ package com.study.UMC10.global.config; -import com.study.UMC10.global.security.CustomAccessDeniedHandler; import com.study.UMC10.global.security.CustomAuthenticationEntryPoint; +import com.study.UMC10.global.security.filter.JwtAuthFilter; +import com.study.UMC10.global.security.handler.CustomAccessDeniedHandler; +import com.study.UMC10.global.security.handler.OAuthSuccessHandler; +import com.study.UMC10.global.security.service.CustomOAuthService; 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.config.http.SessionCreationPolicy; 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.authentication.UsernamePasswordAuthenticationFilter; @EnableWebSecurity @Configuration @@ -19,13 +24,19 @@ public class SecurityConfig { private final CustomAccessDeniedHandler customAccessDeniedHandler; private final CustomAuthenticationEntryPoint customAuthenticationEntryPoint; + private final JwtAuthFilter jwtAuthFilter; + private final CustomOAuthService customOAuthService; + private final OAuthSuccessHandler oAuthSuccessHandler; - // 인증x 접근 가능한 Public API + // 인증 없이 접근 가능한 Public API 목록 private final String[] allowUris = { "/swagger-ui/**", "/swagger-resources/**", "/v3/api-docs/**", - "/api/auth/**" + "/api/auth/**", + "/oauth2/**", + "/oauth/callback/**", + "/login/**" }; @Bean @@ -33,25 +44,38 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti http .csrf(AbstractHttpConfigurer::disable) - .authorizeHttpRequests(requests -> requests - .requestMatchers(allowUris).permitAll() // Public API는 허용 - .anyRequest().authenticated() // 그 외의 Private API는 인증 필요 + .sessionManagement(session -> session + .sessionCreationPolicy(SessionCreationPolicy.STATELESS) ) - // 폼 로그인 설정 - .formLogin(form -> form - .defaultSuccessUrl("/swagger-ui/index.html", true) - .permitAll() + .authorizeHttpRequests(requests -> requests + .requestMatchers(allowUris).permitAll() + .anyRequest().authenticated() ) - // 로그아웃 설정 + .formLogin(AbstractHttpConfigurer::disable) + .logout(logout -> logout .logoutUrl("/logout") .logoutSuccessUrl("/login?logout") .permitAll() ) - // 예외 핸들러 + .oauth2Login(oauth2 -> oauth2 + .authorizationEndpoint(auth -> auth + .baseUri("/oauth2/authorization") + ) + .redirectionEndpoint(redirection -> redirection + .baseUri("/oauth/callback/*") + ) + .userInfoEndpoint(userInfo -> userInfo + .userService(customOAuthService) + ) + .successHandler(oAuthSuccessHandler) + ) + + .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class) + .exceptionHandling(exception -> exception .accessDeniedHandler(customAccessDeniedHandler) .authenticationEntryPoint(customAuthenticationEntryPoint) @@ -59,10 +83,4 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti return http.build(); } - - // BCrypt 인코더 빈 - @Bean - public PasswordEncoder passwordEncoder() { - return new BCryptPasswordEncoder(); - } } \ No newline at end of file diff --git a/Seohui/src/main/java/com/study/UMC10/global/security/CustomOAuth2User.java b/Seohui/src/main/java/com/study/UMC10/global/security/CustomOAuth2User.java new file mode 100644 index 00000000..5892a1ed --- /dev/null +++ b/Seohui/src/main/java/com/study/UMC10/global/security/CustomOAuth2User.java @@ -0,0 +1,34 @@ +package com.study.UMC10.global.security; + +import com.study.UMC10.domain.user.entity.User; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.oauth2.core.user.OAuth2User; + +import java.util.Collection; +import java.util.List; +import java.util.Map; + +@Getter +@RequiredArgsConstructor +public class CustomOAuth2User implements OAuth2User { + + private final User user; + private final Map attributes; + + @Override + public Map getAttributes() { + return attributes; + } + + @Override + public Collection getAuthorities() { + return List.of(); + } + + @Override + public String getName() { + return user.getEmail(); + } +} \ No newline at end of file diff --git a/Seohui/src/main/java/com/study/UMC10/global/security/dto/KakaoDTO.java b/Seohui/src/main/java/com/study/UMC10/global/security/dto/KakaoDTO.java new file mode 100644 index 00000000..dc20f00e --- /dev/null +++ b/Seohui/src/main/java/com/study/UMC10/global/security/dto/KakaoDTO.java @@ -0,0 +1,31 @@ +package com.study.UMC10.global.security.dto; + +import com.study.UMC10.domain.user.enums.LoginType; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +public class KakaoDTO implements OAuthDTO { + private final String id; + private final String email; + private final String name; + + @Override + public LoginType getLoginType() { + return LoginType.KAKAO; + } + + @Override + public String getSocialId() { + return id; + } + + @Override + public String getEmail() { + return email; + } + + @Override + public String getName() { + return name; + } +} \ No newline at end of file diff --git a/Seohui/src/main/java/com/study/UMC10/global/security/dto/OAuthDTO.java b/Seohui/src/main/java/com/study/UMC10/global/security/dto/OAuthDTO.java new file mode 100644 index 00000000..04468f55 --- /dev/null +++ b/Seohui/src/main/java/com/study/UMC10/global/security/dto/OAuthDTO.java @@ -0,0 +1,10 @@ +package com.study.UMC10.global.security.dto; + +import com.study.UMC10.domain.user.enums.LoginType; + +public interface OAuthDTO { + LoginType getLoginType(); + String getSocialId(); + String getEmail(); + String getName(); +} \ No newline at end of file diff --git a/Seohui/src/main/java/com/study/UMC10/global/security/filter/JwtAuthFilter.java b/Seohui/src/main/java/com/study/UMC10/global/security/filter/JwtAuthFilter.java new file mode 100644 index 00000000..ca03a6ba --- /dev/null +++ b/Seohui/src/main/java/com/study/UMC10/global/security/filter/JwtAuthFilter.java @@ -0,0 +1,81 @@ +package com.study.UMC10.global.security.filter; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.study.UMC10.global.apiPayload.ApiResponse; +import com.study.UMC10.global.apiPayload.code.GeneralErrorCode; +import com.study.UMC10.global.security.service.CustomUserDetailsService; +import com.study.UMC10.global.security.util.JwtUtil; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.lang.NonNull; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +@Component +@RequiredArgsConstructor +public class JwtAuthFilter extends OncePerRequestFilter { + + private final JwtUtil jwtUtil; + private final CustomUserDetailsService customUserDetailsService; + + @Override + protected void doFilterInternal( + @NonNull HttpServletRequest request, + @NonNull HttpServletResponse response, + @NonNull FilterChain filterChain + ) throws ServletException, IOException { + + try { + // 토큰 가져오기 + String token = request.getHeader("Authorization"); + + // token이 없거나 Bearer가 아니면 넘기기 + if (token == null || !token.startsWith("Bearer ")) { + filterChain.doFilter(request, response); + return; + } + + // Bearer 이면 추출 + token = token.replace("Bearer ", ""); + + // AccessToken 검증하기: 올바른 토큰이면 + if (jwtUtil.isValid(token)) { + // 토큰에서 이메 추출 + String email = jwtUtil.getEmail(token); + + // 인증 객체 생성: 이메일로 찾아온 뒤, 인증 객체 생성 + UserDetails user = customUserDetailsService.loadUserByUsername(email); + Authentication auth = new UsernamePasswordAuthenticationToken( + user, + null, + user.getAuthorities() + ); + + // 인증 완료 후 SecurityContextHolder에 넣기 + SecurityContextHolder.getContext().setAuthentication(auth); + } + + // 다음 필터로 이동 + filterChain.doFilter(request, response); + + } catch (Exception e) { + ObjectMapper mapper = new ObjectMapper(); + GeneralErrorCode code = GeneralErrorCode.UNAUTHORIZED; + + response.setContentType("application/json; charset=UTF-8"); + response.setStatus(code.getStatus().value()); + + ApiResponse errorResponse = ApiResponse.onFailure(code, null); + mapper.writeValue(response.getOutputStream(), errorResponse); + } + } +} \ No newline at end of file diff --git a/Seohui/src/main/java/com/study/UMC10/global/security/CustomAccessDeniedHandler.java b/Seohui/src/main/java/com/study/UMC10/global/security/handler/CustomAccessDeniedHandler.java similarity index 95% rename from Seohui/src/main/java/com/study/UMC10/global/security/CustomAccessDeniedHandler.java rename to Seohui/src/main/java/com/study/UMC10/global/security/handler/CustomAccessDeniedHandler.java index b7e74feb..2dab22ca 100644 --- a/Seohui/src/main/java/com/study/UMC10/global/security/CustomAccessDeniedHandler.java +++ b/Seohui/src/main/java/com/study/UMC10/global/security/handler/CustomAccessDeniedHandler.java @@ -1,4 +1,4 @@ -package com.study.UMC10.global.security; +package com.study.UMC10.global.security.handler; // 로그인 후 권한 없을 경우 import com.fasterxml.jackson.databind.ObjectMapper; diff --git a/Seohui/src/main/java/com/study/UMC10/global/security/handler/OAuthSuccessHandler.java b/Seohui/src/main/java/com/study/UMC10/global/security/handler/OAuthSuccessHandler.java new file mode 100644 index 00000000..edda2544 --- /dev/null +++ b/Seohui/src/main/java/com/study/UMC10/global/security/handler/OAuthSuccessHandler.java @@ -0,0 +1,55 @@ +package com.study.UMC10.global.security.handler; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.study.UMC10.domain.user.dto.response.UserResponseDto; +import com.study.UMC10.global.security.CustomOAuth2User; +import com.study.UMC10.global.security.CustomUserDetails; +import com.study.UMC10.global.security.util.JwtUtil; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.Authentication; +import org.springframework.security.web.authentication.AuthenticationSuccessHandler; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; + +@Component +@RequiredArgsConstructor +public class OAuthSuccessHandler implements AuthenticationSuccessHandler { + + private final JwtUtil jwtUtil; + + @Override + public void onAuthenticationSuccess( + HttpServletRequest request, + HttpServletResponse response, + Authentication authentication + ) throws IOException, ServletException { + + CustomOAuth2User oauth2User = (CustomOAuth2User) authentication.getPrincipal(); + + CustomUserDetails userDetails = new CustomUserDetails(oauth2User.getUser()); + + String token = jwtUtil.createAccessToken(userDetails); + + response.setContentType("application/json; charset=UTF-8"); + response.setStatus(HttpServletResponse.SC_OK); + + ObjectMapper mapper = new ObjectMapper(); + + Map result = new HashMap<>(); + result.put("isSuccess", true); + result.put("message", "카카오 로그인 성공 및 토큰 발급 완료"); + + UserResponseDto.LoginResultDto tokenDto = UserResponseDto.LoginResultDto.builder() + .accessToken(token) + .build(); + result.put("result", tokenDto); + + mapper.writeValue(response.getOutputStream(), result); + } +} \ No newline at end of file diff --git a/Seohui/src/main/java/com/study/UMC10/global/security/service/CustomOAuthService.java b/Seohui/src/main/java/com/study/UMC10/global/security/service/CustomOAuthService.java new file mode 100644 index 00000000..6b7d3367 --- /dev/null +++ b/Seohui/src/main/java/com/study/UMC10/global/security/service/CustomOAuthService.java @@ -0,0 +1,85 @@ +package com.study.UMC10.global.security.service; + +import com.study.UMC10.domain.user.entity.User; +import com.study.UMC10.domain.user.entity.UserLogin; +import com.study.UMC10.domain.user.enums.LoginType; +import com.study.UMC10.domain.user.enums.UserStatus; +import com.study.UMC10.domain.user.repository.UserLoginRepository; +import com.study.UMC10.domain.user.repository.UserRepository; +import com.study.UMC10.global.security.CustomOAuth2User; +import com.study.UMC10.global.security.dto.KakaoDTO; +import com.study.UMC10.global.security.dto.OAuthDTO; +import lombok.RequiredArgsConstructor; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService; +import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.user.OAuth2User; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Map; +import java.util.UUID; + +@Service +@RequiredArgsConstructor +public class CustomOAuthService extends DefaultOAuth2UserService { + + private final UserRepository userRepository; + private final UserLoginRepository userLoginRepository; + private final PasswordEncoder passwordEncoder; + + @Override + @Transactional + public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException { + + OAuth2User oAuth2User = super.loadUser(userRequest); + + String registrationId = userRequest.getClientRegistration().getRegistrationId().toUpperCase(); + LoginType loginType = LoginType.valueOf(registrationId); + + OAuthDTO oAuthDTO; + if (loginType == LoginType.KAKAO) { + Map attributes = oAuth2User.getAttribute("kakao_account"); + Map profile = (Map) attributes.get("profile"); + + String socialId = String.valueOf(oAuth2User.getAttribute("id")); + String email = attributes.get("email").toString(); + String name = profile.get("nickname").toString(); + + oAuthDTO = new KakaoDTO(socialId, email, name); + } else { + throw new OAuth2AuthenticationException("지원하지 않는 소셜 로그인입니다."); + } + + UserLogin userLogin = userLoginRepository.findByLoginTypeAndSocialId(oAuthDTO.getLoginType(), oAuthDTO.getSocialId()) + .orElse(null); + + User user; + if (userLogin == null) { + user = userRepository.findByEmail(oAuthDTO.getEmail()) + .orElseGet(() -> { + User newUser = User.builder() + .email(oAuthDTO.getEmail()) + .password(passwordEncoder.encode(UUID.randomUUID().toString())) + .name(oAuthDTO.getName()) + .status(UserStatus.ACTIVE) + .totalPoint(0) + .finMission(0) + .build(); + return userRepository.save(newUser); + }); + + UserLogin newUserLogin = UserLogin.builder() + .loginType(oAuthDTO.getLoginType()) + .socialId(oAuthDTO.getSocialId()) + .user(user) + .build(); + userLoginRepository.save(newUserLogin); + } else { + user = userLogin.getUser(); + } + + return new CustomOAuth2User(user, oAuth2User.getAttributes()); + } +} \ No newline at end of file diff --git a/Seohui/src/main/java/com/study/UMC10/global/security/CustomUserDetailsService.java b/Seohui/src/main/java/com/study/UMC10/global/security/service/CustomUserDetailsService.java similarity index 89% rename from Seohui/src/main/java/com/study/UMC10/global/security/CustomUserDetailsService.java rename to Seohui/src/main/java/com/study/UMC10/global/security/service/CustomUserDetailsService.java index 9957bd8a..9859264c 100644 --- a/Seohui/src/main/java/com/study/UMC10/global/security/CustomUserDetailsService.java +++ b/Seohui/src/main/java/com/study/UMC10/global/security/service/CustomUserDetailsService.java @@ -1,7 +1,8 @@ -package com.study.UMC10.global.security; +package com.study.UMC10.global.security.service; import com.study.UMC10.domain.user.entity.User; import com.study.UMC10.domain.user.repository.UserRepository; +import com.study.UMC10.global.security.CustomUserDetails; import lombok.RequiredArgsConstructor; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UserDetailsService; diff --git a/Seohui/src/main/java/com/study/UMC10/global/security/util/JwtUtil.java b/Seohui/src/main/java/com/study/UMC10/global/security/util/JwtUtil.java new file mode 100644 index 00000000..03589797 --- /dev/null +++ b/Seohui/src/main/java/com/study/UMC10/global/security/util/JwtUtil.java @@ -0,0 +1,94 @@ +package com.study.UMC10.global.security.util; + +import com.study.UMC10.global.security.CustomUserDetails; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jws; +import io.jsonwebtoken.JwtException; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Keys; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.stereotype.Component; + +import javax.crypto.SecretKey; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.time.Instant; +import java.util.Date; +import java.util.stream.Collectors; + +@Component +public class JwtUtil { + + private final SecretKey secretKey; + private final Duration accessExpiration; + + public JwtUtil( + @Value("${jwt.token.secretKey}") String secret, + @Value("${jwt.token.expiration.access}") Long accessExpiration + ) { + // 비밀키 -> 암호화 키로 변환 + this.secretKey = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8)); + this.accessExpiration = Duration.ofMillis(accessExpiration); + } + + // AccessToken 생성 + public String createAccessToken(CustomUserDetails userDetails) { + return createToken(userDetails, accessExpiration); + } + + /** 토큰에서 이메일 가져오기 + * + * @param token 유저 정보를 추출할 토큰 + * @return 유저 이메일을 토큰에서 추출합니다 + */ + public String getEmail(String token) { + try { + return getClaims(token).getPayload().getSubject(); // Parsing해서 Subject 가져오기 + } catch (JwtException e) { + return null; + } + } + + /** 토큰 유효성 확인 + * + * @param token 유효한지 확인할 토큰 + * @return True, False 반환합니다 + */ + public boolean isValid(String token) { + try { + getClaims(token); + return true; + } catch (JwtException e) { + return false; + } + } + + // 토큰 생성 + private String createToken(CustomUserDetails userDetails, Duration expiration) { + Instant now = Instant.now(); + + // 인가 정보 + String authorities = userDetails.getAuthorities().stream() + .map(GrantedAuthority::getAuthority) + .collect(Collectors.joining(",")); + + return Jwts.builder() + .subject(userDetails.getUsername()) // User 이메일을 Subject로 + .claim("role", authorities) + .claim("email", userDetails.getUsername()) + .issuedAt(Date.from(now)) // 발급 시간 + .expiration(Date.from(now.plus(expiration))) // 만료 시간 + .signWith(secretKey) // 로그인 할 키 + .compact(); + } + + // 토큰 정보 가져오기 + private Jws getClaims(String token) throws JwtException { + return Jwts.parser() + .verifyWith(secretKey) + .clockSkewSeconds(60) + .build() + .parseSignedClaims(token); + } +} \ No newline at end of file From ec75c49c5147636c0013dfbc549076802dd3782c Mon Sep 17 00:00:00 2001 From: Kimsuhhee04 Date: Tue, 26 May 2026 16:06:59 +0900 Subject: [PATCH 5/6] =?UTF-8?q?Docs:=20=ED=82=A4=EC=9B=8C=EB=93=9C=20ch09.?= =?UTF-8?q?md=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Seohui/keyword_summary/ch09.md | 77 ++++++++++++++++++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 Seohui/keyword_summary/ch09.md diff --git a/Seohui/keyword_summary/ch09.md b/Seohui/keyword_summary/ch09.md new file mode 100644 index 00000000..5d291383 --- /dev/null +++ b/Seohui/keyword_summary/ch09.md @@ -0,0 +1,77 @@ +## 1. 세션(Session)과 토큰(Token)의 차이 + +### 📊 비교 요약 +| 구분 | 세션 (Session) | 토큰 (Token) | +| :--- | :--- | :--- | +| **저장 위치** | 서버 (Server) 메모리나 DB | 클라이언트 (Client) 브라우저 등 | +| **상태 관리** | Stateful (서버가 로그인 상태 기억함) | Stateless (서버는 상태 기억 안 함) | +| **서버 부하** | 높음 (사용자 많아질수록 서버 터질 위험) | 낮음 (서버는 티켓 유효성만 검사) | +| **확장성** | 낮음 (서버 여러 대면 세션 공유 설정 복잡함) | 높음 (서버 개수 늘려도 문제없음) | +| **보안 통제** | 서버가 쥐고 있음 (해킹 의심 시 강제 로그아웃 가능) | 클라이언트가 쥐고 있음 (탈취당하면 만료 전까지 막기 힘듦) | +| **통신 크기** | 작음 (짧은 세션 ID 문자열만 주고받음) | 큼 (사용자 정보가 통째로 들어있어 무거움) | + +--- + +> 💡 **세션 : 서버 기반 인증 (Stateful)** +> +> 서버가 사용자의 로그인 상태를 메모리나 데이터베이스에 직접 저장하고 관리하는 구조입니다. +> +> * **작동 원리:** + > 1. 클라이언트가 로그인에 성공하면 서버는 서버 측 저장소(Session Store)에 해당 사용자의 데이터를 생성하고 고유한 `Session ID`를 발급함 +> 2. 서버는 이 `Session ID`를 HTTP 응답 헤더를 통해 클라이언트에게 전달 +> 3. 클라이언트는 이후 요청마다 쿠키에 `Session ID`를 담아 서버로 전송 +> 4. 서버는 전달받은 `Session ID`를 자신의 저장소에서 조회하여 유효성 검증 + 사용자 정보 가져옴 +> * **특징:** + > * **Stateful (상태 유지):** 서버가 클라이언트의 상태를 보관하고 있어야 됨 +> * **보안성:** `Session ID` 자체에는 정보가 없고 실제 정보는 서버에만 있어 안전하다. 탈취 시 서버에서 해당 세션을 강제 삭제하면 즉시 접근을 차단할 수 있음 (블랙리스트 관리) +> * **확장성(Scalability) 문제:** 사용자가 늘어나면 서버 메모리 부하가 커지고 서버를 여러 대로 확장할 경우 세션 정보를 공유하기 위해 별도의 세션 스토리지가 필요하거나 Sticky Session 설정이 필요함 + +> 🪙 **토큰 : 클라이언트 기반 인증 (Stateless)** +> +> 인증 정보를 암호화된 문자열인 토큰 형태로 만들어 클라이언트가 저장하고 관리하는 구조입니다. +> +> * **작동 원리:** + > 1. 클라이언트가 로그인에 성공하면 서버는 사용자 식별 정보와 권한 등을 포함한 데이터(Payload)에 디지털 서명을 하여 토큰을 생성함 +> 2. 서버는 이 토큰을 클라이언트에게 반환하고 서버는 이 토큰을 저장하지 않음 +> 3. 클라이언트는 토큰을 로컬 스토리지나 쿠키에 저장하고 이후 요청마다 HTTP 헤더(`Authorization`)에 토큰을 담아 전송한다 +> 4. 서버는 전달받은 토큰의 서명을 비밀키로 복호화하여 위변조 여부를 검증한 뒤 서명이 유효하면 토큰 내부의 데이터를 신뢰하고 요청을 처리해준다 +> * **특징:** + > * **Stateless:** 서버는 클라이언트의 상태를 저장하지 않고 토큰 자체에 인증 정보를 포함한다 +> * **확장성 우수:** 서버가 상태를 저장하지 않으므로 서버를 무한정 늘려도 인증 처리에 문제가 없다는 이점이 있다 +> * **보안 및 제어의 어려움:** 토큰이 탈취되면 유효기간이 만료될 때까지 서버에서 강제로 차단하기 어렵다는 문제가 생긴다 + +--- + +## 2. 액세스 토큰(Access Token)과 리프레시 토큰(Refresh Token) + +### 🤝 공통점 +* **형식:** 주로 JWT 표준을 따르고 Header, Payload, Signature의 3부분으로 구성됨 +* **발급 주체:** 인증 서버가 사용자의 자격 증명을 확인한 후 발급함 +* **검증 방식:** 서버가 가진 비밀키 또는 공개키를 통해 서명의 무결성을 검증 + +### 🔍 차이점 +| 비교 항목 | 액세스 토큰 (Access Token) | 리프레시 토큰 (Refresh Token) | +| :--- | :--- | :--- | +| **핵심 목적** | 실질적인 리소스 접근(인가)
API 요청 시 인증 수단으로 사용 | 토큰 재발급 (인증 유지)
액세스 토큰이 만료되었을 때 새로운 토큰을 받기 위해 사용 | +| **유효 기간 (수명)** | **짧다** : 탈취되었을 때의 피해를 최소화시키기 위함 | **길다** : 사용자가 자주 로그인하지 않도록 편의성을 제공 | +| **전송 빈도** | **높음** : 서버로 보내는 모든 API 요청마다 헤더에 포함되어 전송 | **낮음** : 액세스 토큰이 만료되었을 때만 인증 서버로 전송 | +| **노출 위험도** | **높음** : 네트워크 통신이 잦으므로 탈취될 가능성이 상대적으로 높음 | **낮음** : 전송 횟수가 적고 보통 더 엄격한 보안 저장소에 저장됨 | +| **정보 포함량** | **많음** : 사용자 ID, 권한, 만료 시간 등 비즈니스 로직 처리에 필요한 정보를 포함함 | **적음** : 보통 사용자 식별자와 유효성 검증을 위한 최소한의 데이터만 포함하거나 단순 랜덤 문자열인 경우도 있다 | +| **DB 저장 여부** | **저장하지 않음 (Stateless)** : 서버는 서명만 검증 | **저장함 (선택적)** : 서버 DB에 저장하여 탈취 감지 시 관리자가 강제로 만료시킬 수 있도록 유연한 관리 | + +> 🔒 **로그아웃 및 블랙리스트 관리** +> * 로그아웃 시 액세스 토큰을 블랙리스트로 올려서 재로그인 시 블랙리스트 조회 필터에서 조회해서 거를 수 있어야 됨 +> * 레디스나 캐시에 블랙리스트라는 목록을 추가해서 만료되지 않은 토큰을 올려둠 + +--- + +## 3. OAuth 1.0과 OAuth 2.0의 차이 + +### 📊 비교 요약 +| 구분 | OAuth 1.0 | OAuth 2.0 | +| :--- | :--- | :--- | +| **보안 방식** | 매 요청마다 복잡한 암호화 서명 필요 | HTTPS (SSL/TLS) 암호화 통신에 의존 (단순함) | +| **토큰 형태** | 서명된 토큰 | Bearer 토큰 (복잡한 암호화 없이 그냥 문자열) | +| **개발 난이도** | 높음 (구현하다가 개발자들 많이 울었음) | 낮음 (현재 거의 모든 웹/앱의 표준) | +| **지원 환경** | 웹 서버 환경에 맞춰져 있음 | 모바일 앱, 데스크톱, 스마트 TV 등 다양하게 지원 | +| **인증 흐름** | 단일 흐름으로 통일 (유연성 부족) | 상황에 맞게 4가지 흐름(Grant Type)으로 나눠 제공 | \ No newline at end of file From c535635dbcd468956ce55f82063148eccb94d6a9 Mon Sep 17 00:00:00 2001 From: Kimsuhhee04 Date: Tue, 26 May 2026 21:19:10 +0900 Subject: [PATCH 6/6] =?UTF-8?q?Feat:=20=EB=B6=80=EB=A1=9D=20=EB=AF=B8?= =?UTF-8?q?=EC=85=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Seohui/build.gradle | 3 + .../auth/controller/TestController.java | 15 +++ .../UMC10/global/config/SecurityConfig.java | 26 +++- .../security/CustomAccessDeniedHandler.java | 30 ----- .../security/CustomUserDetailsService.java | 24 ---- Seohui/src/main/resources/static/auth/app.js | 107 ++++++++++++++++ .../src/main/resources/static/auth/index.html | 60 +++++++++ .../src/main/resources/static/auth/styles.css | 117 ++++++++++++++++++ 8 files changed, 325 insertions(+), 57 deletions(-) create mode 100644 Seohui/src/main/java/com/study/UMC10/domain/auth/controller/TestController.java delete mode 100644 Seohui/src/main/java/com/study/UMC10/global/security/CustomAccessDeniedHandler.java delete mode 100644 Seohui/src/main/java/com/study/UMC10/global/security/CustomUserDetailsService.java create mode 100644 Seohui/src/main/resources/static/auth/app.js create mode 100644 Seohui/src/main/resources/static/auth/index.html create mode 100644 Seohui/src/main/resources/static/auth/styles.css diff --git a/Seohui/build.gradle b/Seohui/build.gradle index 3333ed20..7f7008af 100644 --- a/Seohui/build.gradle +++ b/Seohui/build.gradle @@ -45,6 +45,9 @@ dependencies { // OAuth implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' + + // WebAuthn (Passkey) + implementation 'org.springframework.security:spring-security-webauthn' } tasks.named('test') { diff --git a/Seohui/src/main/java/com/study/UMC10/domain/auth/controller/TestController.java b/Seohui/src/main/java/com/study/UMC10/domain/auth/controller/TestController.java new file mode 100644 index 00000000..70109601 --- /dev/null +++ b/Seohui/src/main/java/com/study/UMC10/domain/auth/controller/TestController.java @@ -0,0 +1,15 @@ +package com.study.UMC10.domain.auth.controller; + +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; + +@Controller +@RequestMapping("/passkey") +public class TestController { + + @GetMapping("/test") + public String test() { + return "/auth/index.html"; + } +} \ No newline at end of file diff --git a/Seohui/src/main/java/com/study/UMC10/global/config/SecurityConfig.java b/Seohui/src/main/java/com/study/UMC10/global/config/SecurityConfig.java index 45010478..437606a2 100644 --- a/Seohui/src/main/java/com/study/UMC10/global/config/SecurityConfig.java +++ b/Seohui/src/main/java/com/study/UMC10/global/config/SecurityConfig.java @@ -8,14 +8,15 @@ import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.jdbc.core.JdbcOperations; 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.config.http.SessionCreationPolicy; -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.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.security.web.webauthn.management.JdbcPublicKeyCredentialUserEntityRepository; +import org.springframework.security.web.webauthn.management.JdbcUserCredentialRepository; @EnableWebSecurity @Configuration @@ -36,7 +37,10 @@ public class SecurityConfig { "/api/auth/**", "/oauth2/**", "/oauth/callback/**", - "/login/**" + "/login/**", + "/passkey/**", + "/auth/**", + "/webauthn/**" }; @Bean @@ -74,6 +78,12 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti .successHandler(oAuthSuccessHandler) ) + .webAuthn(webAuthn -> webAuthn + .rpId("localhost") + .allowedOrigins("http://localhost:8080") + .disableDefaultRegistrationPage(true) + ) + .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class) .exceptionHandling(exception -> exception @@ -83,4 +93,14 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti return http.build(); } + + @Bean + public JdbcPublicKeyCredentialUserEntityRepository jdbcPublicKeyCredentialRepository(JdbcOperations jdbc) { + return new JdbcPublicKeyCredentialUserEntityRepository(jdbc); + } + + @Bean + public JdbcUserCredentialRepository jdbcUserCredentialRepository(JdbcOperations jdbc) { + return new JdbcUserCredentialRepository(jdbc); + } } \ No newline at end of file diff --git a/Seohui/src/main/java/com/study/UMC10/global/security/CustomAccessDeniedHandler.java b/Seohui/src/main/java/com/study/UMC10/global/security/CustomAccessDeniedHandler.java deleted file mode 100644 index b7e74feb..00000000 --- a/Seohui/src/main/java/com/study/UMC10/global/security/CustomAccessDeniedHandler.java +++ /dev/null @@ -1,30 +0,0 @@ -package com.study.UMC10.global.security; -// 로그인 후 권한 없을 경우 - -import com.fasterxml.jackson.databind.ObjectMapper; -import com.study.UMC10.global.apiPayload.ApiResponse; -import com.study.UMC10.global.apiPayload.code.GeneralErrorCode; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; -import org.springframework.security.access.AccessDeniedException; -import org.springframework.security.web.access.AccessDeniedHandler; -import org.springframework.stereotype.Component; - -import java.io.IOException; - -@Component -public class CustomAccessDeniedHandler implements AccessDeniedHandler { - - @Override - public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException { - ObjectMapper objectMapper = new ObjectMapper(); - GeneralErrorCode code = GeneralErrorCode.FORBIDDEN; - - response.setContentType("application/json; charset=UTF-8"); - response.setStatus(code.getStatus().value()); - - ApiResponse errorResponse = ApiResponse.onFailure(code, null); - - objectMapper.writeValue(response.getOutputStream(), errorResponse); - } -} \ No newline at end of file diff --git a/Seohui/src/main/java/com/study/UMC10/global/security/CustomUserDetailsService.java b/Seohui/src/main/java/com/study/UMC10/global/security/CustomUserDetailsService.java deleted file mode 100644 index 9957bd8a..00000000 --- a/Seohui/src/main/java/com/study/UMC10/global/security/CustomUserDetailsService.java +++ /dev/null @@ -1,24 +0,0 @@ -package com.study.UMC10.global.security; - -import com.study.UMC10.domain.user.entity.User; -import com.study.UMC10.domain.user.repository.UserRepository; -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 UserRepository userRepository; - - @Override - public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { - User user = userRepository.findByEmail(username) - .orElseThrow(() -> new UsernameNotFoundException("해당 이메일의 사용자를 찾을 수 없습니다: " + username)); - - return new CustomUserDetails(user); - } -} \ No newline at end of file diff --git a/Seohui/src/main/resources/static/auth/app.js b/Seohui/src/main/resources/static/auth/app.js new file mode 100644 index 00000000..a38bfef6 --- /dev/null +++ b/Seohui/src/main/resources/static/auth/app.js @@ -0,0 +1,107 @@ +const webauthnJSON = window.webauthnJSON; + +const usernameInput = document.getElementById('username'); +const displayNameInput = document.getElementById('displayName'); +const registerOptionsUrl = document.getElementById('registerOptionsUrl'); +const registerVerifyUrl = document.getElementById('registerVerifyUrl'); +const authOptionsUrl = document.getElementById('authOptionsUrl'); +const authVerifyUrl = document.getElementById('authVerifyUrl'); +const logOutput = document.getElementById('logOutput'); + +function log(msg, data = null) { + console.log(msg, data); + const time = new Date().toLocaleTimeString(); + let logText = `[${time}] ${msg}`; + if (data) { + logText += `\n${JSON.stringify(data, null, 2)}`; + } + logOutput.textContent = logText + '\n\n' + logOutput.textContent; +} + +async function authFetch(url, options) { + options.headers = { + 'Content-Type': 'application/json', + ...options.headers + }; + const response = await fetch(url, options); + if (!response.ok) { + throw new Error(`HTTP 요청 에러 발생 (상태 코드: ${response.status})`); + } + const text = await response.text(); + return text ? JSON.parse(text) : {}; +} + +document.getElementById('registerBtn').addEventListener('click', async () => { + log("==== 🔐 패스키 등록 프로세스 시작 ===="); + try { + const username = usernameInput.value; + const displayName = displayNameInput.value; + + log("1. 서버에 등록 옵션(Challenge) 요청 중..."); + const optionsRes = await authFetch(registerOptionsUrl.value, { + method: "POST", + body: JSON.stringify({ username, displayName }) + }); + + log("-> 서버 응답:", optionsRes); + + const serverOptions = optionsRes.publicKey || optionsRes; + + log("2. 스마트폰/노트북 생체 인증창 호출 중 (credentials.create)..."); + const credential = await webauthnJSON.create({ + publicKey: serverOptions + }); + + log("-> 지문 인증 완료! 생성된 패스키 정보:", credential); + + log("3. 서버에 최종 등록 검증 요청 중..."); + const verifyRes = await authFetch(registerVerifyUrl.value, { + method: "POST", + body: JSON.stringify(credential) // 서버가 요구하는 JSON 형태로 전송 + }); + + log("==== 🎉 패스키 등록 완료 성공 ====", verifyRes); + alert("패스키가 성공적으로 등록되었습니다!"); + + } catch (error) { + log("❌ 패스키 등록 실패", error.message); + alert("등록 중 에러가 발생했습니다. 로그 창을 확인해주세요."); + } +}); + +document.getElementById('loginBtn').addEventListener('click', async () => { + log("==== 🔓 패스키 로그인 프로세스 시작 ===="); + try { + const username = usernameInput.value; + + log("1. 서버에 로그인(인증) 옵션 요청 중..."); + const optionsRes = await authFetch(authOptionsUrl.value, { + method: "POST", + body: JSON.stringify({ username }) + }); + + log("-> 서버 응답:", optionsRes); + + const serverOptions = optionsRes.publicKey || optionsRes; + + log("2. 스마트폰/노트북 생체 인증창 호출 중 (credentials.get)..."); + const credential = await webauthnJSON.get({ + publicKey: serverOptions + }); + + log("-> 인증 완료! 서명된 데이터:", credential); + + log("3. 서버에 최종 로그인 검증 요청 중..."); + const verifyRes = await authFetch(authVerifyUrl.value, { + method: "POST", + body: JSON.stringify(credential) + }); + + log("==== 🎉 패스키 로그인 완료 성공 ====", verifyRes); + alert("패스키 로그인이 성공했습니다!"); + + } catch (error) { + log("❌ 패스키 로그인 실패", error.message); + alert("로그인 중 에러가 발생했습니다. 로그 창을 확인해주세요."); + } +}); \ No newline at end of file diff --git a/Seohui/src/main/resources/static/auth/index.html b/Seohui/src/main/resources/static/auth/index.html new file mode 100644 index 00000000..e209e14f --- /dev/null +++ b/Seohui/src/main/resources/static/auth/index.html @@ -0,0 +1,60 @@ + + + + + + Passkey (WebAuthn) 실습 + + + + +
+

Passkey(WebAuthn)

+ +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+

로그

+
대기 중...
+
+
+ + + + \ No newline at end of file diff --git a/Seohui/src/main/resources/static/auth/styles.css b/Seohui/src/main/resources/static/auth/styles.css new file mode 100644 index 00000000..2d952652 --- /dev/null +++ b/Seohui/src/main/resources/static/auth/styles.css @@ -0,0 +1,117 @@ +/* 기본 배경 및 폰트 설정 */ +body { + font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; + background-color: #f4f4f9; + color: #333; + margin: 0; + padding: 40px; + display: flex; + justify-content: center; +} + +/* 중앙 메인 박스 */ +.container { + background-color: #fff; + padding: 30px; + border-radius: 12px; + box-shadow: 0 8px 16px rgba(0,0,0,0.1); + width: 100%; + max-width: 650px; +} + +h1 { + font-size: 24px; + margin-top: 0; + margin-bottom: 25px; + color: #222; +} + +/* 폼 요소 정렬 */ +.form-group { + display: flex; + gap: 20px; + margin-bottom: 20px; +} + +.form-group > div { + flex: 1; + display: flex; + flex-direction: column; +} + +label { + font-size: 13px; + font-weight: bold; + margin-bottom: 8px; + color: #555; +} + +input { + padding: 12px; + border: 1px solid #ddd; + border-radius: 6px; + font-size: 14px; + background-color: #fcfcfc; + color: #555; +} + +/* 버튼 스타일 */ +.button-group { + margin-top: 30px; + display: flex; + gap: 12px; +} + +button { + padding: 12px 24px; + border: none; + border-radius: 6px; + font-size: 14px; + cursor: pointer; + font-weight: bold; + transition: background-color 0.2s; +} + +.btn-primary { + background-color: #d9534f; + color: white; +} +.btn-primary:hover { + background-color: #c9302c; +} + +.btn-secondary { + background-color: #f0f0f0; + color: #333; + border: 1px solid #ccc; +} +.btn-secondary:hover { + background-color: #e0e0e0; +} + +/* 로그 콘솔 화면 */ +.log-container { + margin-top: 35px; + background-color: #2b2b2b; + color: #a9b7c6; + padding: 20px; + border-radius: 8px; + max-height: 400px; + overflow-y: auto; +} + +.log-container h3 { + margin-top: 0; + color: #fff; + font-size: 15px; + border-bottom: 1px solid #555; + padding-bottom: 10px; +} + +pre { + margin: 0; + font-family: 'Courier New', Courier, monospace; + font-size: 13px; + white-space: pre-wrap; + line-height: 1.4; +} \ No newline at end of file