diff --git a/Seohui/build.gradle b/Seohui/build.gradle index 676e3fd5..7f7008af 100644 --- a/Seohui/build.gradle +++ b/Seohui/build.gradle @@ -36,6 +36,18 @@ 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' + + // WebAuthn (Passkey) + implementation 'org.springframework.security:spring-security-webauthn' } tasks.named('test') { 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 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/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..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 @@ -1,16 +1,22 @@ 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.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.crypto.bcrypt.BCryptPasswordEncoder; -import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.config.http.SessionCreationPolicy; 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 @@ -19,13 +25,22 @@ 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/**", + "/passkey/**", + "/auth/**", + "/webauthn/**" }; @Bean @@ -33,25 +48,44 @@ 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) + ) + + .webAuthn(webAuthn -> webAuthn + .rpId("localhost") + .allowedOrigins("http://localhost:8080") + .disableDefaultRegistrationPage(true) + ) + + .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class) + .exceptionHandling(exception -> exception .accessDeniedHandler(customAccessDeniedHandler) .authenticationEntryPoint(customAuthenticationEntryPoint) @@ -60,9 +94,13 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti return http.build(); } - // BCrypt 인코더 빈 @Bean - public PasswordEncoder passwordEncoder() { - return new BCryptPasswordEncoder(); + 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/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 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