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-safety |
+ * testable |
+ *
+ *
+ * | 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);
}
}