Skip to content

Commit 5eacb1e

Browse files
authored
Merge pull request #15 from Hoyoung027/Hoyoung027
[22기_변호영] Spring Security & JWT 미션 제출합니다.
2 parents 6b65ebd + 705815c commit 5eacb1e

File tree

85 files changed

+2068
-118
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

85 files changed

+2068
-118
lines changed

README.md

Lines changed: 111 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
**영화관 예매 및 주문 시스템**을 구현하기 위한 데이터베이스 구조를 설계하였습니다.
44
주요 기능은 영화관 탐색, 영화 탐색, 상영 스케줄 확인, 유저의 예매 및 주문, 찜하기입니다.
55

6-
![img.png](img.png)
6+
![img_1.png](img_1.png)
77

88
---
99

@@ -128,8 +128,8 @@
128128

129129
---
130130

131-
### 11. 영화관 찜하기 (theater_like)
132-
- `theater_like_id` (PK)
131+
### 11. 영화관 찜하기 (cinema_like)
132+
- `cinema_like` (PK)
133133
- `user_id` : 유저 id
134134
- `cinema_id` : 영화관 id
135135
- 다른 테이블과의 관계는 아래와 같습니다.
@@ -145,3 +145,111 @@
145145
➡️ **N:M** (user ↔ movie)
146146

147147
---
148+
149+
# 인증(Authentication) 방법 정리
150+
151+
## 1. 세션(Session) & 쿠키(Cookie) 인증
152+
153+
### 인증 흐름
154+
1. 사용자가 로그인 요청을 보냅니다.
155+
2. 서버는 사용자 정보를 확인한 뒤 **세션 ID**를 생성하고 세션 저장소에 기록합니다.
156+
3. 서버는 Session ID를 쿠키에 담아 클라이언트로 전달합니다.
157+
4. 클라이언트는 이후 요청마다 쿠키(Session ID)를 포함시킵니다.
158+
5. 서버는 세션 저장소와 대조해 사용자를 확인하고 데이터를 반환합니다.
159+
160+
### 장점
161+
- 실제 정보는 서버에 저장, 쿠키는 **세션 키(출입증)** 역할만 함 → 보안성 상대적으로 우수
162+
- 매번 회원 정보를 확인하지 않아도 되므로 인증 속도가 빠름
163+
164+
### 단점
165+
- 쿠키 탈취 시 **세션 하이재킹 공격** 발생 가능 → HTTPS + 세션 만료 시간 설정 필요
166+
- 세션 저장을 위해 **서버 자원(메모리/DB 등)** 필요
167+
168+
---
169+
170+
## 2. JWT 기반 인증 (Access Token)
171+
172+
### 구조
173+
- **Header**: 토큰 타입(JWT), 알고리즘(HS256 등)
174+
- **Payload**: 사용자 ID, 권한, 만료시간 등 Claims
175+
- **Signature**: 위·변조 방지용 서명 (Header + Payload + Secret Key 기반)
176+
177+
### 인증 흐름
178+
1. 사용자가 로그인 요청을 보냅니다.
179+
2. 서버는 사용자 정보를 확인 후 JWT(Access Token)를 생성합니다.
180+
3. 클라이언트는 Access Token을 전달받아 저장합니다.
181+
4. 이후 요청마다 `Authorization: Bearer <token>` 헤더에 토큰을 담아 전송합니다.
182+
5. 서버는 토큰의 유효성(서명, 만료시간)을 확인 후 데이터를 반환합니다.
183+
184+
### 장점
185+
- 서버 저장소가 불필요하고 **무상태(stateless)** 구조에 적합합니다.
186+
- 다른 서비스와의 연동이 용이합니다.
187+
188+
### 단점
189+
- 만료 전까지 강제 무효화 어렵습니다. (탈취 시 위험)
190+
- Payload는 누구나 디코딩 가능하여 민감 정보 저장이 불가능합니다.
191+
- 토큰 크기가 커서 요청 많을 시 트래픽 비용 증가합니다.
192+
193+
---
194+
195+
## 3. Access Token + Refresh Token 인증
196+
197+
### 개념
198+
- **Access Token**: 짧은 유효기간(예: 1시간), API 요청시 인증/인가에 사용
199+
- **Refresh Token**: 긴 유효기간(예: 2주), Access Token이 만료되었을 때 재발급 용도로 사용
200+
201+
### 인증 흐름
202+
1. 로그인 성공 시 서버는 Access Token과 Refresh Token을 발급합니다.
203+
2. 클라이언트는 Access Token을 요청에 포함하여 보냅니다.
204+
3. Access Token이 만료되면 Refresh Token을 이용해 새로운 Access Token을 발급받습니다.
205+
4. Refresh Token까지 만료되면 재로그인이 필요합니다.
206+
207+
### 장점
208+
- Access Token을 짧게 가져가므로 탈취당해도 보안에 취약한 시간을 줄일 수 있습니다.
209+
- 사용자는 자주 로그인할 필요가 없어 편리합니다.
210+
211+
### 단점
212+
- 구현 복잡도가 올라갑니다.
213+
- Access Token의 유효기간이 매우 짧은 경우 서버 부하 증가가 예상되며 매 API 호출마다 accessToken을 이용한 인증 부하가 발생합니다.
214+
215+
---
216+
217+
## 4. OAuth 2.0 인증
218+
219+
### 개념
220+
- 외부 서비스(Google, Facebook 등) 계정을 사용해 인증을 위임받는 프로토콜
221+
- 현재 표준은 **OAuth 2.0**입니다.
222+
223+
### 주요 참여자
224+
- **Resource Owner**: 사용자
225+
- **Client**: 우리의 애플리케이션
226+
- **Authorization Server**: 인증/토큰 발급 서버
227+
- **Resource Server**: 보호된 자원을 가진 서버
228+
229+
### 인증 흐름
230+
1. 사용자가 Client에 로그인 요청
231+
2. Client는 사용자를 Authorization Server(구글/페북 로그인 페이지 등)로 리디렉트
232+
3. 사용자가 로그인 후 **Authorization Code**를 Client로 전달
233+
4. Client는 Authorization Server에 Authorization Code를 전송해 **Access/Refresh Token** 발급
234+
5. Client는 Access Token으로 Resource Server에 요청
235+
6. 토큰 만료 시 Refresh Token으로 갱신
236+
237+
### 장점
238+
- 소셜 로그인, 외부 계정 연동에 많이 활용되어 범용성이 높은 방법입니다.
239+
- 표준화된 방식으로 다양한 서비스에서 실제로 활용하는 인증 방법입니다.
240+
241+
### 단점
242+
- 설정이 복잡(redirect URI, client ID/secret 필요)하여 초기 구현 비용이 높습니다.
243+
- 토큰 보관 및 만료 처리에 주의 필요합니다.
244+
245+
---
246+
247+
## 5. SNS 로그인 (Facebook, Google 등)
248+
249+
### 인증 흐름
250+
1. 사용자가 서버에 로그인 요청
251+
2. 서버는 SNS 로그인 URL을 클라이언트로 전달
252+
3. 사용자가 해당 URL을 통해 로그인 → 인증 코드 반환
253+
4. 서버는 Authorization Server에 코드 검증 요청 후 Access/Refresh Token + 사용자 정보 발급
254+
5. 서버는 사용자 정보를 DB에 저장(신규면 회원가입, 기존이면 로그인)
255+
6. 이후 인증은 세션/쿠키 또는 JWT 방식으로 관리

build.gradle

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,10 @@ repositories {
2020

2121
dependencies {
2222
implementation 'org.springframework.boot:spring-boot-starter-web'
23+
implementation 'org.springframework.boot:spring-boot-starter-security'
2324
testImplementation 'org.springframework.boot:spring-boot-starter-test'
2425
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
26+
implementation "org.springframework.boot:spring-boot-starter-validation"
2527

2628
testImplementation "org.junit.jupiter:junit-jupiter:5.10.3"
2729
testImplementation "org.mockito:mockito-junit-jupiter:5.12.0"
@@ -34,6 +36,11 @@ dependencies {
3436

3537
// swagger
3638
implementation "org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.12"
39+
40+
// jwt
41+
implementation 'io.jsonwebtoken:jjwt-api:0.12.5'
42+
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.5'
43+
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.5'
3744
}
3845

3946
tasks.named('test') {

img.png

-349 KB
Binary file not shown.

img_1.png

404 KB
Loading

src/main/java/com/ceos22/cgv/codes/ErrorCode.java

Lines changed: 20 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -6,43 +6,49 @@
66
public enum ErrorCode {
77

88
// 잘못된 서버 요청
9-
BAD_REQUEST_ERROR(400, "G001", "Bad Request Exception"),
9+
BAD_REQUEST_ERROR(400, "400", "Bad Request Exception"),
10+
11+
// @RequestBody 및 @RequestParam, @PathVariable 값이 유효하지 않음
12+
NOT_VALID_ERROR(400, "400", "handle Validation Exception"),
1013

1114
// @RequestBody 데이터 미 존재
12-
REQUEST_BODY_MISSING_ERROR(400, "G002", "Required request body is missing"),
15+
REQUEST_BODY_MISSING_ERROR(400, "400", "Required request body is missing"),
16+
17+
// 중복 닉네임 존재
18+
DUPLICATE_NICKNAME_ERROR(400, "400", "Nickname already exist"),
1319

1420
// 유효하지 않은 타입
15-
INVALID_TYPE_VALUE(400, "G003", " Invalid Type Value"),
21+
INVALID_TYPE_VALUE(400, "400", " Invalid Type Value"),
1622

1723
// Request Parameter 로 데이터가 전달되지 않을 경우
18-
MISSING_REQUEST_PARAMETER_ERROR(400, "G004", "Missing Servlet RequestParameter Exception"),
24+
MISSING_REQUEST_PARAMETER_ERROR(400, "400", "Missing Servlet RequestParameter Exception"),
25+
26+
// Request Parameter가 Valid 하지 않은 경우
27+
INVALID_PARAMETER_ERROR(400, "400", "Invalid RequestParameter Exception"),
1928

2029
// 권한이 없음
21-
FORBIDDEN_ERROR(403, "G008", "Forbidden Exception"),
30+
FORBIDDEN_ERROR(403, "403", "Forbidden Exception"),
2231

2332
// handler 존재 하지 않음
24-
NOT_FOUND_ERROR(404, "G009", "Not Found Exception"),
33+
NOT_FOUND_ERROR(404, "404", "Not Found Exception"),
2534

2635
// 잘못된 경로로의 요청
27-
NO_RESOURCE_FOUND_ERROR(404, "G009", "No Resource Found Exception"),
28-
29-
// @RequestBody 및 @RequestParam, @PathVariable 값이 유효하지 않음
30-
NOT_VALID_ERROR(404, "G011", "handle Validation Exception"),
36+
NO_RESOURCE_FOUND_ERROR(404, "404", "No Resource Found Exception"),
3137

3238
// 서버가 처리 할 방법을 모르는 경우 발생
33-
INTERNAL_SERVER_ERROR(500, "G999", "Internal Server Error Exception"),
39+
INTERNAL_SERVER_ERROR(500, "500", "Internal Server Error Exception"),
3440

3541
;
3642

3743
private final int statusCode;
3844

39-
private final String divisionCode;
45+
private final String code;
4046

4147
private final String message;
4248

43-
ErrorCode(final int statusCode, final String divisionCode, final String message) {
49+
ErrorCode(final int statusCode, final String code, final String message) {
4450
this.statusCode = statusCode;
45-
this.divisionCode = divisionCode;
51+
this.code = code;
4652
this.message = message;
4753
}
4854

src/main/java/com/ceos22/cgv/codes/SuccessCode.java

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,16 @@
66
public enum SuccessCode {
77

88
// 조회 성공
9-
GET_SUCCESS(200, "200", "GET_SUCCESS"),
9+
GET_SUCCESS(200, "200", "GET_SUCCESS"),
10+
11+
// 조회 성공
12+
LOGIN_SUCCESS(200, "200", "LOGIN_SUCCESS"),
1013

1114
// 삭제 성공
1215
DELETE_SUCCESS(200, "200", "DELETE_SUCCESS"),
1316

1417
// 삽입 성공
15-
INSERT_SUCCESS(201, "201", "DELETE_SUCCESS"),
18+
INSERT_SUCCESS(201, "201", "INSERT_SUCCESS"),
1619

1720
// 수정 성공
1821
UPDATE_SUCCESS(204, "204", "UPDATE_SUCCESS"),

src/main/java/com/ceos22/cgv/config/exception/GlobalExceptionHandler.java

Lines changed: 27 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,23 +11,43 @@
1111
import org.springframework.web.bind.MissingServletRequestParameterException;
1212
import org.springframework.web.bind.annotation.ControllerAdvice;
1313
import org.springframework.web.bind.annotation.ExceptionHandler;
14-
import org.springframework.web.servlet.NoHandlerFoundException;
1514
import org.springframework.web.servlet.resource.NoResourceFoundException;
1615

16+
import java.net.BindException;
17+
1718
@Slf4j
1819
@ControllerAdvice
1920
public class GlobalExceptionHandler {
2021

21-
private final HttpStatus HTTP_STATUS_OK = HttpStatus.OK;
22-
2322
/**
2423
* Parameter 값이 유효하지 않은 경우
2524
*/
2625
@ExceptionHandler(MethodArgumentNotValidException.class)
27-
protected ResponseEntity<ErrorResponse> handleMethodArgumentNotValidExceiption(MissingRequestHeaderException exception){
26+
protected ResponseEntity<ErrorResponse> handleMethodArgumentNotValidExceiption(MethodArgumentNotValidException exception){
2827
log.error("handleMethodArgumentNotValidException", exception);
2928
final ErrorResponse response = ErrorResponse.of(ErrorCode.NOT_VALID_ERROR);
30-
return new ResponseEntity<>(response, HTTP_STATUS_OK);
29+
return new ResponseEntity<>(response, HttpStatus.BAD_REQUEST);
30+
}
31+
32+
/**
33+
* 이미 존재하는 nickname으로 회원가입을 시도하는 경우
34+
*/
35+
// 지금 코드처럼 IllegalArgumentException을 던질 경우
36+
@ExceptionHandler(IllegalArgumentException.class)
37+
public ResponseEntity<ErrorResponse> handleIllegalArgument(IllegalArgumentException exception) {
38+
log.error("handleIllegalArgumentException", exception);
39+
final ErrorResponse response = ErrorResponse.of(ErrorCode.DUPLICATE_NICKNAME_ERROR);
40+
return new ResponseEntity<>(response, HttpStatus.BAD_REQUEST);
41+
}
42+
43+
/**
44+
* parameter 매핑 과정에서의 오류가 있는 경우
45+
*/
46+
@ExceptionHandler(org.springframework.validation.BindException.class)
47+
public ResponseEntity<ErrorResponse> handleBindExceiption(BindException exception) {
48+
log.error("handleBindExceiption", exception);
49+
final ErrorResponse response = ErrorResponse.of(ErrorCode.NOT_VALID_ERROR);
50+
return new ResponseEntity<>(response, HttpStatus.BAD_REQUEST);
3151
}
3252

3353
/**
@@ -57,7 +77,7 @@ protected ResponseEntity<ErrorResponse> handleMissingRequestHeaderExceptionExcep
5777
protected ResponseEntity<ErrorResponse> handleNoResourceFoundException(NoResourceFoundException exception) {
5878
log.error("handleNoHandlerFoundExceptionException", exception);
5979
final ErrorResponse response = ErrorResponse.of(ErrorCode.NO_RESOURCE_FOUND_ERROR);
60-
return new ResponseEntity<>(response, HTTP_STATUS_OK);
80+
return new ResponseEntity<>(response, HttpStatus.BAD_REQUEST);
6181
}
6282

6383

@@ -68,7 +88,7 @@ protected ResponseEntity<ErrorResponse> handleNoResourceFoundException(NoResourc
6888
protected final ResponseEntity<ErrorResponse> handleAllExceptions(Exception exeption) {
6989
log.error("Exception", exeption);
7090
final ErrorResponse response = ErrorResponse.of(ErrorCode.INTERNAL_SERVER_ERROR);
71-
return new ResponseEntity<>(response, HTTP_STATUS_OK);
91+
return new ResponseEntity<>(response, HttpStatus.INTERNAL_SERVER_ERROR);
7292
}
7393

7494
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
package com.ceos22.cgv.config.security;
2+
3+
import org.springframework.stereotype.Component;
4+
5+
@Component
6+
public class JwtAccessDeniedHandler implements org.springframework.security.web.access.AccessDeniedHandler {
7+
8+
private final com.fasterxml.jackson.databind.ObjectMapper objectMapper = new com.fasterxml.jackson.databind.ObjectMapper();
9+
10+
@Override
11+
public void handle(
12+
jakarta.servlet.http.HttpServletRequest request,
13+
jakarta.servlet.http.HttpServletResponse response,
14+
org.springframework.security.access.AccessDeniedException accessDeniedException) throws java.io.IOException {
15+
16+
response.setStatus(jakarta.servlet.http.HttpServletResponse.SC_FORBIDDEN);
17+
response.setContentType("application/json;charset=UTF-8");
18+
19+
var body = java.util.Map.of(
20+
"code", "403 FORBIDDEN",
21+
"message", "접근 권한이 없습니다.",
22+
"statusCode", 403
23+
);
24+
response.getWriter().write(objectMapper.writeValueAsString(body));
25+
}
26+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
package com.ceos22.cgv.config.security;
2+
3+
import org.springframework.stereotype.Component;
4+
5+
@Component
6+
public class JwtAuthenticationEntryPoint implements org.springframework.security.web.AuthenticationEntryPoint {
7+
8+
private final com.fasterxml.jackson.databind.ObjectMapper objectMapper = new com.fasterxml.jackson.databind.ObjectMapper();
9+
10+
@Override
11+
public void commence(
12+
jakarta.servlet.http.HttpServletRequest request,
13+
jakarta.servlet.http.HttpServletResponse response,
14+
org.springframework.security.core.AuthenticationException authException) throws java.io.IOException {
15+
16+
response.setStatus(jakarta.servlet.http.HttpServletResponse.SC_UNAUTHORIZED);
17+
response.setContentType("application/json;charset=UTF-8");
18+
19+
var body = java.util.Map.of(
20+
"code", "401 UNAUTHORIZED",
21+
"message", "유효한 토큰이 없습니다.",
22+
"statusCode", 401
23+
);
24+
response.getWriter().write(objectMapper.writeValueAsString(body));
25+
}
26+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
package com.ceos22.cgv.config.security;
2+
3+
import jakarta.servlet.FilterChain;
4+
import jakarta.servlet.ServletException;
5+
import jakarta.servlet.http.HttpServletRequest;
6+
import jakarta.servlet.http.HttpServletResponse;
7+
import org.springframework.security.core.context.SecurityContextHolder;
8+
import org.springframework.web.filter.OncePerRequestFilter;
9+
10+
import java.io.IOException;
11+
12+
public class JwtAuthenticationFilter extends OncePerRequestFilter {
13+
14+
private final TokenProvider tokenProvider;
15+
16+
public JwtAuthenticationFilter(final TokenProvider tokenProvider) {
17+
this.tokenProvider = tokenProvider;
18+
}
19+
20+
@Override
21+
protected void doFilterInternal(
22+
HttpServletRequest request, HttpServletResponse response, FilterChain filterChain
23+
) throws ServletException, IOException {
24+
25+
String token = tokenProvider.getAccessToken(request);
26+
27+
// SecurityContextHolder 채우기
28+
if (token != null && SecurityContextHolder.getContext().getAuthentication() == null) {
29+
if(tokenProvider.validateAccessToken(token)) {
30+
var authentication = tokenProvider.getAuthentication(token);
31+
SecurityContextHolder.getContext().setAuthentication(authentication);
32+
}
33+
}
34+
35+
filterChain.doFilter(request, response);
36+
37+
}
38+
}

0 commit comments

Comments
 (0)