Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
b141a23
docs: jjwt 의존성을 build.gradle에 추가
Joonseok-Lee May 25, 2026
d33d75d
refactor: 구현이 불분명한 파일 명명 AuthUser를 CustomUserDetails로 변경
Joonseok-Lee May 25, 2026
b99d0c9
refactor: CustomUserDetails로 변경 전파
Joonseok-Lee May 25, 2026
9525061
feat: JWT 토큰을 생성하는 JwtTokenProvider 클래스 작성
Joonseok-Lee May 25, 2026
802d4dc
feat: Bearer 토큰이 입력된 경우, SecurityContextHolder에 UserDetails의 구현체인 Cus…
Joonseok-Lee May 25, 2026
6d88eb7
feat: 누락된 Component 어노테이션 추가
Joonseok-Lee May 25, 2026
f38b761
fix: Date로 작성되었던 것들 Insatnce 기반으로 구동하게끔 수정
Joonseok-Lee May 25, 2026
bcb8101
feat: 명시적으로 폼 기반 로그인을 무효화, 세션 무효화, 폼 기반 로그인 인증-인가 필터 이전에 커스텀한 JwtToke…
Joonseok-Lee May 25, 2026
c79393a
feat: 로그인 요청, 응답에 관한 DTO 작성
Joonseok-Lee May 25, 2026
136e0b9
feat: 로그인 요청에 대해 처리할 POST 컨트롤러 진입점 명시
Joonseok-Lee May 25, 2026
8306c8e
feat: 요청으로 들어온 email을 UserRepository에서 찾고 없다면 예외, 암호화된 비밀번호와 맞지 않다면 예…
Joonseok-Lee May 25, 2026
bb66d01
feat: User 로그인에 성공했을 때 반환할 상태 코드, 코드, 메시지를 UserSuccessCode에 작성
Joonseok-Lee May 25, 2026
749a104
feat: 서비스가 반환한 jwt를 ResponseEntity로 래핑하여 반환
Joonseok-Lee May 25, 2026
e4ad0c1
feat: 비밀번호가 올바르지 않을 때 던질 예외 코드 작성
Joonseok-Lee May 25, 2026
8163bfe
feat: 비밀번호가 올바르지 않을 때 던질 예외 객체 작성
Joonseok-Lee May 25, 2026
4e3a7d9
docs: 비밀번호가 올바르지 않을 때 던질 예외 객체를 패키지 임포트
Joonseok-Lee May 25, 2026
5656dc4
fix: UserSuccessCode.USER_LOGIN_OK에 message 필드 누락을 수정
Joonseok-Lee May 25, 2026
e2c9f41
fix: ObjectMapper 의존성을 최신화된 JsonMapper로 변경
Joonseok-Lee May 25, 2026
cf6b977
feat: 로그인 요청 바디에 누락된 검증 조건을 추가
Joonseok-Lee May 25, 2026
5848e6d
fix: userId를 PathVariable로 받아서 홈페이지, 마이페이지를 조회하던 것을 SecurityContextHo…
Joonseok-Lee May 25, 2026
56690cf
docs: spring oauth 의존성 추가
Joonseok-Lee May 25, 2026
6f7866c
feat: FIDO용 WebAuthn 의존성 추가
Joonseok-Lee May 26, 2026
5f36342
feat: WebAuthn 설정을 작성
Joonseok-Lee May 26, 2026
45ff0bc
feat: FIDO에 사용할 User 관련 엔터티 작성, 테스트 컨트롤러 작성
Joonseok-Lee May 26, 2026
ededd45
docs: 누락된 핵심 키워드 마크다운 파일 추가
Joonseok-Lee May 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions Joonseok/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
144 changes: 144 additions & 0 deletions Joonseok/keyword_summary/ch09.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
- 세션과 토큰의 차이는?

<aside>
<img src="/icons/burst_blue.svg" alt="/icons/burst_blue.svg" width="40px" />

### Stateful & Stateless

</aside>

인증을 처리할 때, 서버가 서비스 플로우를 따라서 사용자의 정보를 기억하는 방식이 `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에 추가하는 것이 옳다고 서술되어 있다.(별다른 보안 문제가 없다면, 추후로도 응답 바디로 반환해줄 것 같다.)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

RFC 6749는 OAuth2 토큰 응답의 예시로 JSON body를 정의하지만, 애플리케이션 로그인 API에서 Access Token을 반드시 ResponseBody로만 내려야 한다는 의미로 이해하면 범위가 넓어질 수 있습니다. Authorization Code 흐름의 토큰 엔드포인트 응답과, 우리 서비스가 클라이언트에 JWT를 전달하는 API 설계를 구분해서 정리하는 것을 권장합니다.

- 이후 클라이언트는 인증-인가가 필요한 모든 API 요청에 대해 Header - Authorization에 해당 JWT를 추가하여 전송한다.

**장점**

- stateless : 애플리케이션 확장을 진행할 때, 인증-인가 절차가 종속된 컴포넌트(인-메모리 세션 매니저 / DB)가 존재하지 않아 확장성이 세션에 비해 높다.
- 여러 도메인에 걸쳐 인증-인가를 통합하기 편리하다

**단점**

- 토큰 만료를 서버 측에서 처리하기 어렵다.
- JWT는 클라이언트 측의 로컬 스토리지에 저장된 경우, XSS 취약점이 될 수 있다.
- HTTPS 통신이 강제된다.(표준이 HTTPS이므로, 큰 단점은 아니다.)

여러 단점이 있지만, JWT의 가장 큰 단점은 발행된 토큰의 인증을 서버 측에서 임의 만료시킬 수 없다는게 문제입니다. 따라서, DB에 1회 조회하는 오버헤드가 발생하더라도, 해당 요청을 처리하는 동안은 사용자의 정보를 조회할 수 있게 하이브리드 방식을 채택하는 경우도 많습니다.

- 엑세스 토큰과 리프레시 토큰이란?

<aside>
🪙

### 각각 언제 사용될까요?

</aside>

참고 자료 : [RFC 6749](https://datatracker.ietf.org/doc/html/rfc6749)

### AccessToken

액세스 토큰은 보호된 리소스에 접근할 때 사용되는 자격 증명입니다. 액세스 토큰은 클라이언트 측에서 이해할 수 없게 암호화 처리해야 합니다. 토큰은 `접근 권한` , `유효한 기간` 등의 정보를 담습니다.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Access Token이 항상 암호화되어야 한다기보다는, JWT처럼 서명된 토큰은 위변조 검증을 제공하고 내용은 Base64URL 디코딩으로 읽을 수 있다는 점을 구분할 필요가 있습니다. 민감한 개인정보를 claim에 넣지 않는 이유와 JWS/JWE 차이를 함께 정리하면 토큰 보안 이해가 더 명확해집니다.


토큰은 인증을 검색하는 데 사용되는 식별자를 나타낼 수도 있습니다.(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 프로토콜을 강제하는 여부
>
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
"내 계정 삭제에 성공했습니다."
);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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) {
Expand Down Expand Up @@ -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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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.*;

Expand All @@ -35,6 +35,17 @@ public ResponseEntity<ApiResponse<?>> signUp(
.body(ApiResponse.onComplete(UserSuccessCode.USER_SIGN_UP_CREATED, null));
}

@PostMapping("/auth/login")
public ResponseEntity<ApiResponse<?>> 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<ApiResponse<?>> createMyPrefFood(
@Valid @RequestBody Object request
Expand All @@ -46,14 +57,15 @@ public ResponseEntity<ApiResponse<?>> createMyPrefFood(
.body(ApiResponse.onComplete(UserSuccessCode.USER_PREF_FOOD_CREATED, null));
}

@GetMapping("/home/{userId}")
@GetMapping("/home")
public ResponseEntity<ApiResponse<?>> 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
Expand All @@ -64,11 +76,12 @@ public ResponseEntity<ApiResponse<?>> getMain(
));
}

@GetMapping("/my/{userId}")
@GetMapping("/my")
public ResponseEntity<ApiResponse<?>> getMyPage(
// JWT Security Holder에서 추출
@PathVariable Long userId
@AuthenticationPrincipal CustomUserDetails userDetails
) {
Long userId = userDetails.getUser().getId();

// 서비스 메소드 호출
GetMyPageRes response = userService.getMyPage(userId);

Expand Down
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package com.umc.study.domain.user.web.dto;

public record LoginRes (
String accessToken
) {
}
Original file line number Diff line number Diff line change
@@ -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";
}
}
Loading