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 extends GrantedAuthority> 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