diff --git a/Joonseok/build.gradle b/Joonseok/build.gradle index ce009fae..7708aa28 100644 --- a/Joonseok/build.gradle +++ b/Joonseok/build.gradle @@ -31,6 +31,15 @@ dependencies { testRuntimeOnly 'org.junit.platform:junit-platform-launcher' testAnnotationProcessor 'org.projectlombok:lombok' + // FIDO + implementation 'org.springframework.security:spring-security-webauthn' + + // JWT + implementation 'io.jsonwebtoken:jjwt:0.13.0' + + // kakao oauth + implementation 'org.springframework.boot:spring-boot-starter-security-oauth2-client' + // Swagger implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:3.0.3' implementation 'org.springdoc:springdoc-openapi-starter-webmvc-api:3.0.3' diff --git a/Joonseok/keyword_summary/ch09.md b/Joonseok/keyword_summary/ch09.md new file mode 100644 index 00000000..77d43e04 --- /dev/null +++ b/Joonseok/keyword_summary/ch09.md @@ -0,0 +1,144 @@ +- 세션과 토큰의 차이는? + + + + 인증을 처리할 때, 서버가 서비스 플로우를 따라서 사용자의 정보를 기억하는 방식이 `stateful` , 요청이 멱등하게, 사용자가 누군지 모르는 상태에서 시작하는 것이 `stateless` 이라 했습니다. 세션과 토큰은 두 방식의 대표 구현 방법입니다. + + ### Session + + 서버가 사용자의 정보를 여러 요청에 걸쳐 기억하는 방식으로, `SessionManager` 를 이용하여 인-메모리에서 사용자의 정보를 기억하는 간이 저장소를 만듭니다. 혹은, DB를 사용할 수 있습니다. + + 인증 여부를 확인하기 위해 클라이언트는 Cookie 등을 이용하여 Session의 고유값을 서버에 전송합니다. 그리고, 서버는 인-메모리 / DB에서 해당 세션이 유효한지 검색한 후에, 유효하다면 인가 처리를 진행하고, 유효하지 않다면 재인증을 유도하게끔 처리합니다. + + - `loginId` , `password` 입력 후 서버로 전송 + - 서버는 자격 증명을 검증하고 세션을 생성. 그리고 쿠키 형태로 사용자 브라우저로 전달 + - 사용자가 요청할 때마다 브라우저는 세션 ID를 서버로 다시 전송 + - 서버는 세션 ID를 저장소에서 조회하고, 유효 여부를 체크한 후 후속 절차를 실행 + + **장점** + + - 악의적인 공격에 의해, 인증 수단이 탈취된 경우에 DB, 저장소에서 해당 세션을 만료 처리하면 방어할 수 있으므로, 탈취 공격에 대처 가능 + - 세션은 클라이언트 측에서 저장하지 않음 → XSS 공격 예방 + - 세션 그 자체로는 전혀 데이터를 가지지 않으므로, 서버 외에 세션을 참조하는 경우, 아무런 정보를 얻지 못함 + + **단점** + + - 기능이 확장되는 경우(예. 카카오톡, 카카오맵, 카카오택시, …)에 세션 관리 저장소를 단일화해야 하는 문제 발생. 장애 발생 시 대처 어려움 → 애플리케이션 확장성 저하 + - 세션을 조회할 때 컴퓨팅 리소스를 사용 → 사용자가 많아지는 경우, 인증-인가 오버헤드 발생 + + ### Token + + 토큰은 매 요청에 대해 사용자를 모르는 채로 진행하므로 stateless 서버를 구현할 때 사용되며, 대표적으로는 `JWT(JSON Web Token)`을 사용합니다. JWT는 `header.payload.signature`로 구성되어 있습니다. + + - `header` : 토큰에 사용된 해싱 알고리즘을 작성합니다. + - `payload` : 사용자 정보와 토큰 정보를 작성합니다. + - `subject`: 사용자를 식별할 수 있는 값을 넣습니다. (예. DB_USER 중 `UNIQUE` 속성 필드) + - `claim` : 토큰에 대한 간단한 정보와 사용자의 정보를 저장합니다. username, email, role, issuedAt, expiredAt, … + - `signature` : 서버에서 발행하는 해싱값이 변조되지 않았음을 검증하는 값입니다. 공격자가 쉽게 탈취할 수 없어야 하므로 이것 역시 해싱값을 사용합니다. 이상적으로는 `HSXXX` 알고리즘 등으로 256bit 이상 생성합니다. + + JWT의 작동 방식은 다음과 같습니다. + + - 클라이언트에서 자격 증명을 생성하여 인증 요청 + - 서버에서 자격 증명을 검증하고, `사용자의 정보가 포함된` JWT를 생성하고, 비밀 키(signature)로 해싱 + - 생성된 JWT를 클라이언트 측에 전송. RFC 6749 문서에 따르면, ResponseBody에 추가하는 것이 옳다고 서술되어 있다.(별다른 보안 문제가 없다면, 추후로도 응답 바디로 반환해줄 것 같다.) + - 이후 클라이언트는 인증-인가가 필요한 모든 API 요청에 대해 Header - Authorization에 해당 JWT를 추가하여 전송한다. + + **장점** + + - stateless : 애플리케이션 확장을 진행할 때, 인증-인가 절차가 종속된 컴포넌트(인-메모리 세션 매니저 / DB)가 존재하지 않아 확장성이 세션에 비해 높다. + - 여러 도메인에 걸쳐 인증-인가를 통합하기 편리하다 + + **단점** + + - 토큰 만료를 서버 측에서 처리하기 어렵다. + - JWT는 클라이언트 측의 로컬 스토리지에 저장된 경우, XSS 취약점이 될 수 있다. + - HTTPS 통신이 강제된다.(표준이 HTTPS이므로, 큰 단점은 아니다.) + + 여러 단점이 있지만, JWT의 가장 큰 단점은 발행된 토큰의 인증을 서버 측에서 임의 만료시킬 수 없다는게 문제입니다. 따라서, DB에 1회 조회하는 오버헤드가 발생하더라도, 해당 요청을 처리하는 동안은 사용자의 정보를 조회할 수 있게 하이브리드 방식을 채택하는 경우도 많습니다. + +- 엑세스 토큰과 리프레시 토큰이란? + + + + 참고 자료 : [RFC 6749](https://datatracker.ietf.org/doc/html/rfc6749) + + ### AccessToken + + 액세스 토큰은 보호된 리소스에 접근할 때 사용되는 자격 증명입니다. 액세스 토큰은 클라이언트 측에서 이해할 수 없게 암호화 처리해야 합니다. 토큰은 `접근 권한` , `유효한 기간` 등의 정보를 담습니다. + + 토큰은 인증을 검색하는 데 사용되는 식별자를 나타낼 수도 있습니다.(JWT의 `subject`) + + 권한 부여는 단일 사용자 이름 및 비밀번호를 사용하여 구성합니다.(`loginId`, `password` ) + 서버는 토큰을 해독하고, 인가 처리를 진행합니다. + + ### RefreshToken + + 리프레시 토큰은 액세스 토큰을 얻는데 데 사용되는 자격 증명입니다. 리프레시 토큰은 인증 서버에서 클라이언트에게 발급됩니다. + + 현재 액세스 토큰이 만료되었을 때, 리프레시 토큰이 유효하다면 인증 서버로 전달하여 새로운 액세스 토큰을 얻을 수 있습니다. + + 대개 리프레시 토큰보다 액세스 토큰의 유효 기간이 더 짧으며, 액세스 토큰을 발급한다고 하더라도, 리프레시 토큰의 발급 여부는 필수 사항이 아닙니다. + + --- + + ### Spring과 리프레시 토큰 구현 + + 리프레시 토큰은 마치 세션처럼, 저장소에 일정 시간동안 저장되어 있습니다. 그리고, 정해진 시간이 지나면 만료됩니다. 편리하게 구현하기 위해서는 Redis를 사용하는 경우도 왕왕 있습니다. + +- OAuth 1.0과 OAuth 2.0의 차이는? + + ## OAuth + + OAuth는 클라이언트가 서버 리소스에 접근할 수 있는 방법을 제공하는 프로토콜의 일종입니다. 일반적으로는 ID와 Password를 사용하여 접근 권한을 획득합니다. + + 기존의 클라이언트-서버 인증 모델에서 클라이언트는 모놀리식 아키텍처로, 다른 서버의 리소스에 접근할 수 없었습니다. + + 하지만, 현대에 들어 자주 사용되는 애플리케이션 속 나의 리소스에 접근하려는 다른 애플리케이션이 생길 수 있게 되었습니다. OAuth는 이 두 서비스 사이에서 권한을 조정합니다. 이로써 카카오톡의 특정 리소스에만 제한적으로 접근할 수 있는 토큰을 갖고 thrid-party service에서 카카오톡 리소스에 접근할 수 있게 되는 것입니다. + + ### OAuth 1.0 + + **프로토콜에서 사용되는 매개체** + + - 클라이언트 : OAuth 인증 요청을 보내는 HTTP 클라이언트 + - 서버 - OAuth 인증 요청을 받는 HTTP 서버 + - 리소스 보유자 - 자격 증명으로 서버에 인증하여 보호된 리소스를 통제하는 주체 + + **요청 인증 방식** + + OAuth 1.0은 모든 요청에 서명합니다. 매 요청마다 서명된 문자열을 이용하여 다음 요소를 정규화한 뒤, 서명을 계산합니다. + + - HTTP method + - base string URI (Scheme, host) + - 요청 파라미터들 + + 서명 자체가 무결성과 진위성을 보장하므로, TLS(HTTPS 프로토콜용 보안 통신 중간 전달자?)가 없어도 위변조를 막을 수 있습니다. 하지만, 기밀성을 보장할 수 없고 도청자가 요청 내용에 완전히 접근할 수 있습니다. + + ### OAuth 2.0 + + **프로토콜에서 사용되는 매개체** + + - 리소스 보유자 - 보호된 리소스에 대해 접근을 허락하는 주체 + - 리소스 제공자 - 보호된 리소스를 호스팅하며, 액세스 토큰으로 요청에 응답하는 서버 + - 클라이언 - 리소스 보유자를 대신해 보호된 리소스를 요청하는 애플리케이션 + - 인가 서버 - 리소스 보유자를 인증하고, 권한을 얻은 뒤 클라이언트에게 액세스 토큰을 발행하는 서버 + + **요청 인증 방식** + + 요청별 서명을 완전히 제거한 대신, 다음을 추가했습니다. + + - 토큰은 암호화된 문자열 + - 토큰을 가진 자는 누구나 사용할 수 있음 + - TLS가 필수(HTTPS 통신) + + > 가장 큰 차이점은 HTTPS 프로토콜을 강제하는 여부 +> \ No newline at end of file diff --git a/Joonseok/src/main/java/com/umc/study/domain/user/exception/code/InvalidPasswordException.java b/Joonseok/src/main/java/com/umc/study/domain/user/exception/code/InvalidPasswordException.java new file mode 100644 index 00000000..b4333687 --- /dev/null +++ b/Joonseok/src/main/java/com/umc/study/domain/user/exception/code/InvalidPasswordException.java @@ -0,0 +1,9 @@ +package com.umc.study.domain.user.exception.code; + +import com.umc.study.global.apiPayload.exception.BaseException; + +public class InvalidPasswordException extends BaseException { + public InvalidPasswordException() { + super(UserErrorCode.PASSWORD_INVALID); + } +} diff --git a/Joonseok/src/main/java/com/umc/study/domain/user/exception/code/UserErrorCode.java b/Joonseok/src/main/java/com/umc/study/domain/user/exception/code/UserErrorCode.java index 661926d0..1c72e26b 100644 --- a/Joonseok/src/main/java/com/umc/study/domain/user/exception/code/UserErrorCode.java +++ b/Joonseok/src/main/java/com/umc/study/domain/user/exception/code/UserErrorCode.java @@ -8,7 +8,8 @@ @Getter @AllArgsConstructor public enum UserErrorCode implements BaseResponseCode { - USER_NOT_FOUND("USER_NOT_FOUND_404", HttpStatus.NOT_FOUND, "해당 멤버를 찾는데 실패했습니다."); + USER_NOT_FOUND("USER_404_1", HttpStatus.NOT_FOUND, "해당 멤버를 찾는데 실패했습니다."), + PASSWORD_INVALID("USER_400_1", HttpStatus.BAD_REQUEST, "비밀번호가 잘못되었습니다."); private final String code; private final HttpStatus status; diff --git a/Joonseok/src/main/java/com/umc/study/domain/user/exception/code/UserSuccessCode.java b/Joonseok/src/main/java/com/umc/study/domain/user/exception/code/UserSuccessCode.java index 85042040..b01f8c48 100644 --- a/Joonseok/src/main/java/com/umc/study/domain/user/exception/code/UserSuccessCode.java +++ b/Joonseok/src/main/java/com/umc/study/domain/user/exception/code/UserSuccessCode.java @@ -14,36 +14,41 @@ public enum UserSuccessCode implements BaseResponseCode { "USER201_1", "회원가입에 성공했습니다." ), + USER_LOGIN_OK( + HttpStatus.OK, + "USER_200_1", + "로그인에 성공했습니다." + ), USER_PREF_FOOD_CREATED( HttpStatus.CREATED, "USER201_2", "회원님의 선호하는 음식 종류 저장에 성공했습니다." ), HOME_OK(HttpStatus.OK, - "USER200_1", + "USER200_2", "홈 화면 조회에 성공했습니다."), MY_PAGE_OK(HttpStatus.OK, - "USER200_2", + "USER200_3", "마이 페이지 조회에 성공했습니다."), AUTH_PHONE_NUM_OK(HttpStatus.OK, - "USER200_3", + "USER200_4", "전화번호 인증에 성공했습니다."), MY_ALERT_OK(HttpStatus.OK, - "USER200_4", + "USER200_5", "나의 알림 조회에 성공했습니다."), MY_ALERT_SETTING_OK( HttpStatus.OK, - "USER200_5", + "USER200_6", "나의 알림 설정 조회에 성공했습니다." ), MY_ALERT_SETTING_UPDATE_OK( HttpStatus.OK, - "USER200_6", + "USER200_7", "나의 알림 설정 수정에 성공했습니다." ), MY_ACCOUNT_DELETE_OK( HttpStatus.OK, - "USER200_7", + "USER200_8", "내 계정 삭제에 성공했습니다." ); diff --git a/Joonseok/src/main/java/com/umc/study/domain/user/service/UserService.java b/Joonseok/src/main/java/com/umc/study/domain/user/service/UserService.java index 8ca92a0a..631de79d 100644 --- a/Joonseok/src/main/java/com/umc/study/domain/user/service/UserService.java +++ b/Joonseok/src/main/java/com/umc/study/domain/user/service/UserService.java @@ -9,11 +9,11 @@ import com.umc.study.domain.user.entity.User; import com.umc.study.domain.user.enums.Role; import com.umc.study.domain.user.exception.UserNotFoundException; +import com.umc.study.domain.user.exception.code.InvalidPasswordException; import com.umc.study.domain.user.repository.UserRepository; -import com.umc.study.domain.user.web.dto.GetHomeRes; -import com.umc.study.domain.user.web.dto.GetMyPageRes; -import com.umc.study.domain.user.web.dto.SignInReq; -import com.umc.study.domain.user.web.dto.SignUpReq; +import com.umc.study.domain.user.web.dto.*; +import com.umc.study.global.jwt.JwtTokenProvider; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; @@ -32,6 +32,7 @@ public class UserService { private final MissionRepository missionRepository; private final RestaurantRepository restaurantRepository; private final PasswordEncoder encoder; + private final JwtTokenProvider jwtTokenProvider; @Transactional public void saveUser(SignUpReq request) { @@ -120,4 +121,18 @@ public GetHomeRes getHomepage(Long userId, int page, int size) { ); } + public LoginRes loginUser(LoginReq request) { + + // 1. find user by login id + User found = userRepository.findByEmail(request.getLoginId()) + .orElseThrow(UserNotFoundException::new); + + // 2. valid request password equals encoded db password + if(!encoder.matches(request.getPassword(), found.getPassword())) + throw new InvalidPasswordException(); + + String accessToken = jwtTokenProvider.generateToken(found); + + return new LoginRes(accessToken); + } } diff --git a/Joonseok/src/main/java/com/umc/study/domain/user/web/controller/UserController.java b/Joonseok/src/main/java/com/umc/study/domain/user/web/controller/UserController.java index f3d78f78..9a6b2f04 100644 --- a/Joonseok/src/main/java/com/umc/study/domain/user/web/controller/UserController.java +++ b/Joonseok/src/main/java/com/umc/study/domain/user/web/controller/UserController.java @@ -2,16 +2,16 @@ import com.umc.study.domain.user.exception.code.UserSuccessCode; import com.umc.study.domain.user.service.UserService; -import com.umc.study.domain.user.web.dto.GetHomeRes; -import com.umc.study.domain.user.web.dto.GetMyPageRes; -import com.umc.study.domain.user.web.dto.SignUpReq; +import com.umc.study.domain.user.web.dto.*; import com.umc.study.global.apiPayload.ApiResponse; +import com.umc.study.global.security.entity.CustomUserDetails; import jakarta.validation.Valid; import jakarta.validation.constraints.Max; import jakarta.validation.constraints.Min; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; @@ -35,6 +35,17 @@ public ResponseEntity> signUp( .body(ApiResponse.onComplete(UserSuccessCode.USER_SIGN_UP_CREATED, null)); } + @PostMapping("/auth/login") + public ResponseEntity> login( + @Valid @RequestBody LoginReq request + ) { + LoginRes response = userService.loginUser(request); + + return ResponseEntity + .status(UserSuccessCode.USER_LOGIN_OK.getStatus()) + .body(ApiResponse.onComplete(UserSuccessCode.USER_LOGIN_OK, response)); + } + @PostMapping("/my/pref") public ResponseEntity> createMyPrefFood( @Valid @RequestBody Object request @@ -46,14 +57,15 @@ public ResponseEntity> createMyPrefFood( .body(ApiResponse.onComplete(UserSuccessCode.USER_PREF_FOOD_CREATED, null)); } - @GetMapping("/home/{userId}") + @GetMapping("/home") public ResponseEntity> getMain( - // TODO JWT에서 유저를 추출해서 주입 - @PathVariable Long userId, + @AuthenticationPrincipal CustomUserDetails user, @RequestParam @Min(message = "page값은 1 이상이어야 합니다.", value = 1) Integer page, @RequestParam @Min(message = "size값은 1 이상이어야 합니다.", value = 1) @Max(message = "size값은 10을 넘길 수 없습니다.", value = 10) Integer size ) { + Long userId = user.getUser().getId(); + GetHomeRes response = userService.getHomepage(userId, page, size); return ResponseEntity @@ -64,11 +76,12 @@ public ResponseEntity> getMain( )); } - @GetMapping("/my/{userId}") + @GetMapping("/my") public ResponseEntity> getMyPage( - // JWT Security Holder에서 추출 - @PathVariable Long userId + @AuthenticationPrincipal CustomUserDetails userDetails ) { + Long userId = userDetails.getUser().getId(); + // 서비스 메소드 호출 GetMyPageRes response = userService.getMyPage(userId); diff --git a/Joonseok/src/main/java/com/umc/study/domain/user/web/dto/LoginReq.java b/Joonseok/src/main/java/com/umc/study/domain/user/web/dto/LoginReq.java new file mode 100644 index 00000000..e267bae1 --- /dev/null +++ b/Joonseok/src/main/java/com/umc/study/domain/user/web/dto/LoginReq.java @@ -0,0 +1,17 @@ +package com.umc.study.domain.user.web.dto; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public class LoginReq { + + @Email(message = "이메일 형식이 아닙니다.") + private final String loginId; + + @NotBlank(message = "비밀번호 필드는 비어있을 수 없습니다.") + private final String password; +} diff --git a/Joonseok/src/main/java/com/umc/study/domain/user/web/dto/LoginRes.java b/Joonseok/src/main/java/com/umc/study/domain/user/web/dto/LoginRes.java new file mode 100644 index 00000000..cdb64215 --- /dev/null +++ b/Joonseok/src/main/java/com/umc/study/domain/user/web/dto/LoginRes.java @@ -0,0 +1,6 @@ +package com.umc.study.domain.user.web.dto; + +public record LoginRes ( + String accessToken +) { +} diff --git a/Joonseok/src/main/java/com/umc/study/domain/webauthn/web/controller/TestController.java b/Joonseok/src/main/java/com/umc/study/domain/webauthn/web/controller/TestController.java new file mode 100644 index 00000000..4cc5df9f --- /dev/null +++ b/Joonseok/src/main/java/com/umc/study/domain/webauthn/web/controller/TestController.java @@ -0,0 +1,15 @@ +package com.umc.study.domain.webauthn.web.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"; + } +} diff --git a/Joonseok/src/main/java/com/umc/study/global/config/SecurityConfig.java b/Joonseok/src/main/java/com/umc/study/global/config/SecurityConfig.java index 83dfa31d..bc43701b 100644 --- a/Joonseok/src/main/java/com/umc/study/global/config/SecurityConfig.java +++ b/Joonseok/src/main/java/com/umc/study/global/config/SecurityConfig.java @@ -1,31 +1,32 @@ package com.umc.study.global.config; -import com.fasterxml.jackson.databind.ObjectMapper; +import com.umc.study.global.jwt.JwtTokenFilter; import com.umc.study.global.security.CustomEntryPoint; import com.umc.study.global.security.exception.CustomAccessDenied; +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 +@EnableWebSecurity +@RequiredArgsConstructor public class SecurityConfig { - @Bean - public ObjectMapper objectMapper() { - return new ObjectMapper(); - } + private final JwtTokenFilter jwtTokenFilter; private final String[] allowUris = { - "/api/swagger-ui/**", - "/api/swagger-resources/**", - "/api/v3/api-docs/**", - "/api/auth/**" // sign-up, login request allow + "/swagger-ui/**", + "/swagger-resources/**", + "/v3/api-docs/**", + "/auth/**" // sign-up, login request allow }; @Bean @@ -37,9 +38,20 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http, CustomAccessDe .requestMatchers(allowUris).permitAll() .anyRequest().authenticated() ) - .formLogin(form -> form - .defaultSuccessUrl("/api/swagger-ui/index.html", true) - .permitAll()) + .formLogin(AbstractHttpConfigurer::disable) + .sessionManagement( + session -> + session.sessionCreationPolicy(SessionCreationPolicy.STATELESS) + ) + + .webAuthn(webAuthn -> + webAuthn + .rpId("localhost") + .allowedOrigins("http://localhost:8080") + .disableDefaultRegistrationPage(true)) + + .addFilterBefore(jwtTokenFilter, UsernamePasswordAuthenticationFilter.class) + .logout(logout -> logout .logoutUrl("/logout") .logoutSuccessUrl("/login?logout") diff --git a/Joonseok/src/main/java/com/umc/study/global/config/SwaggerConfig.java b/Joonseok/src/main/java/com/umc/study/global/config/SwaggerConfig.java index 4dd03962..86499c43 100644 --- a/Joonseok/src/main/java/com/umc/study/global/config/SwaggerConfig.java +++ b/Joonseok/src/main/java/com/umc/study/global/config/SwaggerConfig.java @@ -49,7 +49,7 @@ public OpenAPI swagger() { // OpenAPI 객체 조립: 기본 정보 + 서버 URL + 보안 설정 return new OpenAPI() .info(info) - .addServersItem(new Server().url("/")) + .addServersItem(new Server().url("/api")) .addSecurityItem(securityRequirement) .components(components); } diff --git a/Joonseok/src/main/java/com/umc/study/global/entity/UserCredential.java b/Joonseok/src/main/java/com/umc/study/global/entity/UserCredential.java new file mode 100644 index 00000000..72dc8d77 --- /dev/null +++ b/Joonseok/src/main/java/com/umc/study/global/entity/UserCredential.java @@ -0,0 +1,53 @@ +package com.umc.study.global.entity; + +import com.umc.study.domain.user.entity.User; +import jakarta.persistence.*; +import lombok.*; + +import java.time.LocalDateTime; + +@Entity +@Getter +@Builder +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class UserCredential { + + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "credential_id") + private Long id; + + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id") + private UserEntity userEntity; + + @Lob + @Column(nullable = false) + private byte[] publicKey; + + private Long signatureCount; + + private boolean uvInitialized; + + @Column(nullable = false) + private boolean backupEligible; + + private String authenticatorTransports; + + private String publicKeyCredentialType; + + @Column(nullable = false) + private boolean backupState; + + private byte[] attestationObject; + + private byte[] attestationClientDataJson; + + @Column(updatable =false) + private LocalDateTime created; + + private LocalDateTime lastUsed; + + @Column(nullable = false) + private String label; +} diff --git a/Joonseok/src/main/java/com/umc/study/global/entity/UserEntity.java b/Joonseok/src/main/java/com/umc/study/global/entity/UserEntity.java new file mode 100644 index 00000000..0688f4d2 --- /dev/null +++ b/Joonseok/src/main/java/com/umc/study/global/entity/UserEntity.java @@ -0,0 +1,20 @@ +package com.umc.study.global.entity; + +import jakarta.persistence.*; +import lombok.*; + +@Entity +@Getter +@Builder +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class UserEntity { + + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private String username; + + private String displayName; +} diff --git a/Joonseok/src/main/java/com/umc/study/global/jwt/JwtTokenFilter.java b/Joonseok/src/main/java/com/umc/study/global/jwt/JwtTokenFilter.java new file mode 100644 index 00000000..69c7b15a --- /dev/null +++ b/Joonseok/src/main/java/com/umc/study/global/jwt/JwtTokenFilter.java @@ -0,0 +1,77 @@ +package com.umc.study.global.jwt; + +import com.umc.study.global.apiPayload.ApiResponse; +import com.umc.study.global.apiPayload.code.GeneralErrorCode; +import io.jsonwebtoken.JwtException; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.NonNull; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; +import tools.jackson.databind.json.JsonMapper; + +import java.io.IOException; + +@Component +@RequiredArgsConstructor +public class JwtTokenFilter extends OncePerRequestFilter { + + private final JwtTokenProvider jwtTokenProvider; + private final JsonMapper jsonMapper; + + @Override + protected void doFilterInternal( + @NonNull HttpServletRequest request, + @NonNull HttpServletResponse response, + @NonNull FilterChain filterChain + ) throws ServletException, IOException { + + // token 추출 + String accessToken = extractToken(request); + + // token이 없으면 다음 필터 체인 진행 + if(accessToken == null || accessToken.isBlank()) { + filterChain.doFilter(request,response); + return; + } + + try { + + // 토큰이 만료되었으면 다음 필터로 넘기지 않고 예외 + jwtTokenProvider.isExpired(accessToken); + + } catch(JwtException e) { + set401Response(response); + } + + Authentication authentication = jwtTokenProvider.getAuthentication(accessToken); + SecurityContextHolder.getContext().setAuthentication(authentication); + + filterChain.doFilter(request,response); + + } + + private void set401Response(@org.jspecify.annotations.NonNull HttpServletResponse response) throws IOException { + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + response.setContentType("application/json;charset=UTF-8"); + ApiResponse errorResponse = ApiResponse.onFailure(GeneralErrorCode.UNAUTHORIZED, null); + response.getWriter().write(jsonMapper.writeValueAsString(errorResponse)); + } + + private String extractToken(HttpServletRequest request) { + + // HttpServletRequest에서 Authorization 필드 추출 + String bearerToken = request.getHeader("Authorization"); + + if(bearerToken != null && bearerToken.startsWith("Bearer ")) { + return bearerToken.substring(7); + } else { + return null; + } + } +} diff --git a/Joonseok/src/main/java/com/umc/study/global/jwt/JwtTokenProvider.java b/Joonseok/src/main/java/com/umc/study/global/jwt/JwtTokenProvider.java new file mode 100644 index 00000000..4f753bf9 --- /dev/null +++ b/Joonseok/src/main/java/com/umc/study/global/jwt/JwtTokenProvider.java @@ -0,0 +1,141 @@ +package com.umc.study.global.jwt; + +import com.umc.study.domain.user.entity.User; +import com.umc.study.domain.user.exception.UserNotFoundException; +import com.umc.study.domain.user.repository.UserRepository; +import com.umc.study.global.security.entity.CustomUserDetails; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Keys; +import jakarta.annotation.PostConstruct; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.stereotype.Component; + +import javax.crypto.SecretKey; +import java.nio.charset.StandardCharsets; +import java.security.Key; +import java.time.Duration; +import java.time.Instant; +import java.util.Date; + + +@Component +public class JwtTokenProvider { + + private final UserRepository userRepository; + + private final String secretKey; + private Key key; + private final Duration expiration; + + // jwt signature를 final, env로 주입하는 생성자 + public JwtTokenProvider( + UserRepository userRepository, + @Value("${jwt.secret}") String secretKey, + @Value("${jwt.expiration}") Long expiration + ) { + this.userRepository = userRepository; + this.secretKey = secretKey; + this.expiration = Duration.ofMillis(expiration); + } + + // signature -> Byte 배열 -> hmac sha 이용하여 Key 객체에 할당 + @PostConstruct + public void init() { + byte[] keyBytes = secretKey.getBytes(StandardCharsets.UTF_8); + if(keyBytes.length < 32) { + throw new IllegalArgumentException("jwt.secret must be at least 256 bits (32 bytes)"); + } + this.key = Keys.hmacShaKeyFor(keyBytes); + } + + /** + * + * Date와 Instant의 차이 + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + *
 정밀도가변성thread-safetytestable
java.util.Date밀리초 단위mutable안전하지 않음유닛 테스트에서 시간 조작 불가
java.time.Instant나노초 단위immutable안전유닛 테스트에서 시간 조작 가능
+ */ + public String generateToken(User user) { + + // 현재 시각을 기준으로 만료일 설정 + Instant now = Instant.now(); + Instant expiry = now.plus(expiration); + + return Jwts.builder() + + // subject : User의 고유 식별 필드 + .subject(String.valueOf(user.getId())) + + // claim : User에서 자주 참조되는 필드(DB 접근을 최소화하기 위해) + .claim("email", user.getEmail()) + .claim("role", user.getRole() != null ? user.getRole() : null) + + // issuedAt : 토큰 발행 시각 + .issuedAt(Date.from(now)) + + // expiration : 토큰 만료 시각 + .expiration(Date.from(expiry)) + + // signWith : signature를 위한 키 설정 (default : HS256) + .signWith(key) + + // JWT 토큰(subject.claim.signature)로 발행 + .compact(); + } + + private Claims getClaims(String token) { + return Jwts.parser() + .verifyWith((SecretKey) key) + .build() + .parseSignedClaims(token) + .getPayload(); + } + + // JWT 토큰에서 사용자 정보를 추출하고, Spring Security에서 사용할 수 있게 Authentication 객체로 가공 + public Authentication getAuthentication(String token) { + + // extract Unique field + Long id = Long.parseLong(getClaims(token).getSubject()); + + User found = userRepository.findById(id) + .orElseThrow(UserNotFoundException::new); + + CustomUserDetails userDetails = new CustomUserDetails(found); + + return new UsernamePasswordAuthenticationToken( + userDetails, + token, + userDetails.getAuthorities() + ); + } + + public boolean isExpired(String token) { + return getClaims(token).getExpiration().before(Date.from(Instant.now())); + } + +} diff --git a/Joonseok/src/main/java/com/umc/study/global/security/CustomEntryPoint.java b/Joonseok/src/main/java/com/umc/study/global/security/CustomEntryPoint.java index 8922a968..0c9ab5fb 100644 --- a/Joonseok/src/main/java/com/umc/study/global/security/CustomEntryPoint.java +++ b/Joonseok/src/main/java/com/umc/study/global/security/CustomEntryPoint.java @@ -1,6 +1,5 @@ package com.umc.study.global.security; -import com.fasterxml.jackson.databind.ObjectMapper; import com.umc.study.global.apiPayload.ApiResponse; import com.umc.study.global.apiPayload.code.BaseResponseCode; import com.umc.study.global.apiPayload.code.GeneralErrorCode; @@ -11,6 +10,7 @@ import org.springframework.security.core.AuthenticationException; import org.springframework.security.web.AuthenticationEntryPoint; import org.springframework.stereotype.Component; +import tools.jackson.databind.json.JsonMapper; import java.io.IOException; @@ -18,7 +18,7 @@ @RequiredArgsConstructor public class CustomEntryPoint implements AuthenticationEntryPoint { - private final ObjectMapper objectMapper; + private final JsonMapper jsonMApper; @Override public void commence( @@ -34,6 +34,6 @@ public void commence( ApiResponse errorResponse = ApiResponse.onFailure(errorCode, null); - response.getWriter().write(objectMapper.writeValueAsString(errorResponse)); + response.getWriter().write(jsonMApper.writeValueAsString(errorResponse)); } } diff --git a/Joonseok/src/main/java/com/umc/study/global/security/entity/AuthUser.java b/Joonseok/src/main/java/com/umc/study/global/security/entity/CustomUserDetails.java similarity index 92% rename from Joonseok/src/main/java/com/umc/study/global/security/entity/AuthUser.java rename to Joonseok/src/main/java/com/umc/study/global/security/entity/CustomUserDetails.java index b1afe0ca..59d490bf 100644 --- a/Joonseok/src/main/java/com/umc/study/global/security/entity/AuthUser.java +++ b/Joonseok/src/main/java/com/umc/study/global/security/entity/CustomUserDetails.java @@ -12,7 +12,7 @@ @Getter @RequiredArgsConstructor -public class AuthUser implements UserDetails { +public class CustomUserDetails implements UserDetails { private final User user; diff --git a/Joonseok/src/main/java/com/umc/study/global/security/exception/CustomAccessDenied.java b/Joonseok/src/main/java/com/umc/study/global/security/exception/CustomAccessDenied.java index e8fb2807..37f802c3 100644 --- a/Joonseok/src/main/java/com/umc/study/global/security/exception/CustomAccessDenied.java +++ b/Joonseok/src/main/java/com/umc/study/global/security/exception/CustomAccessDenied.java @@ -1,6 +1,5 @@ package com.umc.study.global.security.exception; -import com.fasterxml.jackson.databind.ObjectMapper; import com.umc.study.global.apiPayload.ApiResponse; import com.umc.study.global.apiPayload.code.BaseResponseCode; import com.umc.study.global.apiPayload.code.GeneralErrorCode; @@ -11,6 +10,7 @@ import org.springframework.security.access.AccessDeniedException; import org.springframework.security.web.access.AccessDeniedHandler; import org.springframework.stereotype.Component; +import tools.jackson.databind.json.JsonMapper; import java.io.IOException; @@ -18,7 +18,7 @@ @RequiredArgsConstructor public class CustomAccessDenied implements AccessDeniedHandler { - private final ObjectMapper objectMapper; + private final JsonMapper jsonMapper; @Override public void handle( @@ -34,6 +34,6 @@ public void handle( ApiResponse errorResponse = ApiResponse.onFailure(errorCode, null); - response.getWriter().write(objectMapper.writeValueAsString(errorResponse)); + response.getWriter().write(jsonMapper.writeValueAsString(errorResponse)); } } diff --git a/Joonseok/src/main/java/com/umc/study/global/security/service/CustomUserDetailsService.java b/Joonseok/src/main/java/com/umc/study/global/security/service/CustomUserDetailsService.java index 57c47cff..48a77b7b 100644 --- a/Joonseok/src/main/java/com/umc/study/global/security/service/CustomUserDetailsService.java +++ b/Joonseok/src/main/java/com/umc/study/global/security/service/CustomUserDetailsService.java @@ -3,7 +3,7 @@ import com.umc.study.domain.user.entity.User; import com.umc.study.domain.user.exception.UserNotFoundException; import com.umc.study.domain.user.repository.UserRepository; -import com.umc.study.global.security.entity.AuthUser; +import com.umc.study.global.security.entity.CustomUserDetails; import lombok.Getter; import lombok.RequiredArgsConstructor; import org.springframework.security.core.userdetails.UserDetails; @@ -24,6 +24,6 @@ public UserDetails loadUserByUsername(String username) throws UsernameNotFoundEx User user = userRepository.findByEmail(username) .orElseThrow(UserNotFoundException::new); - return new AuthUser(user); + return new CustomUserDetails(user); } }