Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
12 changes: 12 additions & 0 deletions Seohui/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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') {
Expand Down
77 changes: 77 additions & 0 deletions Seohui/keyword_summary/ch09.md
Original file line number Diff line number Diff line change
@@ -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. 서버는 전달받은 토큰의 서명을 비밀키로 복호화하여 위변조 여부를 검증한 뒤 서명이 유효하면 토큰 내부의 데이터를 신뢰하고 요청을 처리해준다
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.

JWT 서명 검증은 비밀키로 ‘복호화’하는 과정이라기보다, Header/Payload와 비밀키로 서명을 다시 계산해 Signature와 비교하는 과정으로 이해하는 것이 정확합니다. JWT 자체는 기본적으로 암호화가 아니라 Base64URL 인코딩과 서명으로 구성되므로, Payload에 민감 정보를 넣지 않는 이유까지 함께 정리하는 것을 권장합니다.

> * **특징:**
> * **Stateless:** 서버는 클라이언트의 상태를 저장하지 않고 토큰 자체에 인증 정보를 포함한다
> * **확장성 우수:** 서버가 상태를 저장하지 않으므로 서버를 무한정 늘려도 인증 처리에 문제가 없다는 이점이 있다
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.

토큰 방식이 서버 확장에 유리한 점은 맞지만, ‘무한정 늘려도 문제가 없음’은 운영 조건을 지나치게 단순화한 표현입니다. 서명 키 관리, 토큰 만료/재발급 정책, 블랙리스트 저장소, Refresh Token 저장 전략이 함께 맞아야 확장성이 안정적으로 확보됩니다. stateless 인증의 장점과 이를 보완하기 위해 필요한 상태 저장 요소를 구분해 정리하는 것을 권장합니다.

> * **보안 및 제어의 어려움:** 토큰이 탈취되면 유효기간이 만료될 때까지 서버에서 강제로 차단하기 어렵다는 문제가 생긴다

---

## 2. 액세스 토큰(Access Token)과 리프레시 토큰(Refresh Token)

### 🤝 공통점
* **형식:** 주로 JWT 표준을 따르고 Header, Payload, Signature의 3부분으로 구성됨
* **발급 주체:** 인증 서버가 사용자의 자격 증명을 확인한 후 발급함
* **검증 방식:** 서버가 가진 비밀키 또는 공개키를 통해 서명의 무결성을 검증

### 🔍 차이점
| 비교 항목 | 액세스 토큰 (Access Token) | 리프레시 토큰 (Refresh Token) |
| :--- | :--- | :--- |
| **핵심 목적** | 실질적인 리소스 접근(인가)<br>API 요청 시 인증 수단으로 사용 | 토큰 재발급 (인증 유지)<br>액세스 토큰이 만료되었을 때 새로운 토큰을 받기 위해 사용 |
| **유효 기간 (수명)** | **짧다** : 탈취되었을 때의 피해를 최소화시키기 위함 | **길다** : 사용자가 자주 로그인하지 않도록 편의성을 제공 |
| **전송 빈도** | **높음** : 서버로 보내는 모든 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)으로 나눠 제공 |
Original file line number Diff line number Diff line change
@@ -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";
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.

@Controller에서 정적 파일 경로를 문자열로 반환하면 ViewResolver 설정에 따라 템플릿 뷰 이름으로 해석될 수 있습니다. 정적 리소스 페이지로 명확히 이동하려면 redirect:/auth/index.html 또는 forward:/auth/index.html처럼 의도를 드러내는 반환값을 사용하는 것을 권장합니다. MVC에서 컨트롤러가 뷰 이름을 반환하는 경우와 정적 리소스를 직접 제공하는 경우의 차이를 함께 확인하면 좋습니다.

}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<UserResponseDto.LoginResultDto> login(
@RequestBody UserRequestDto.LoginDto requestDto
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.

로그인 요청 DTO를 그대로 받으면 이메일 형식, 비밀번호 공백 여부 같은 입력 계약이 컨트롤러 경계에서 검증되지 않습니다. LoginDto에 Bean Validation을 추가하고 컨트롤러에서 @Valid를 적용하면 인증 서비스는 검증된 값으로 비밀번호 비교와 토큰 발급 책임에 집중할 수 있습니다.

) {
BaseSuccessCode code = UserSuccessCode.OK;
return ApiResponse.onSuccess(code, userService.login(requestDto));
}

@Operation(summary = "마이페이지 API V1", description = "유저의 마이페이지 정보를 조회하는 API입니다.")
@PostMapping("/v1/users/me")
public ApiResponse<UserResponseDto.GetInfo> getInfo(
@RequestBody UserRequestDto.GetInfo dto
Expand All @@ -30,6 +42,15 @@ public ApiResponse<UserResponseDto.GetInfo> getInfo(
return ApiResponse.onSuccess(code, userService.getInfo(dto));
}

@Operation(summary = "마이페이지 API V2", description = "JWT 토큰을 이용해 유저의 마이페이지 정보를 조회하는 API입니다.")
@GetMapping("/v2/users/me")
public ApiResponse<UserResponseDto.GetInfo> 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<UserResponseDto.SignUpResultDto> signUp(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
) {
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -80,4 +80,12 @@ public record HomeMissionDto(
String status
) {
}

@Builder
@Schema(description = "로그인 성공 응답")
public record LoginResultDto(
@Schema(description = "JWT Access Token")
String accessToken
) {
}
}
Original file line number Diff line number Diff line change
@@ -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<UserLogin, Long> {
Optional<UserLogin> findByLoginTypeAndSocialId(LoginType loginType, String socialId);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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();
Expand Down Expand Up @@ -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();
}
}
Original file line number Diff line number Diff line change
@@ -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();
}
}
Loading