diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md
new file mode 100644
index 0000000..e74b4a8
--- /dev/null
+++ b/.github/pull_request_template.md
@@ -0,0 +1,18 @@
+## #️⃣연관된 이슈
+
+> ex) #이슈번호, #이슈번호
+
+## 📝작업 내용
+
+> 이번 PR에서 작업한 내용을 간략히 설명해주세요(이미지 첨부 가능)
+
+### 스크린샷 (선택)
+
+
+> 스크린샷 너무 크니까 보기 불편해서 만든 템플릿
+
+## 💬리뷰 요구사항(선택)
+
+> 리뷰어가 특별히 봐주었으면 하는 부분이 있다면 작성해주세요
+>
+> ex) 메서드 XXX의 이름을 더 잘 짓고 싶은데 혹시 좋은 명칭이 있을까요?
diff --git a/.github/workflows/deploy-backend.yml b/.github/workflows/deploy-backend.yml
new file mode 100644
index 0000000..eafaa3e
--- /dev/null
+++ b/.github/workflows/deploy-backend.yml
@@ -0,0 +1,77 @@
+name: Deploy Spring Boot to EC2
+
+on:
+ push:
+ branches:
+ - RefactoringBE
+
+jobs:
+ deploy:
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v3
+
+ - name: Set up JDK
+ uses: actions/setup-java@v3
+ with:
+ java-version: '17'
+ distribution: 'temurin'
+
+ - name: Give gradlew execute permission
+ run: chmod +x ./gradlew
+
+ - name: Build Spring Boot JAR
+ run: ./gradlew clean bootJar -x test
+
+ - name: Prepare deployment directory
+ run: |
+ mkdir -p deploy
+ cp build/libs/*.jar deploy/app.jar
+
+
+ - name: Create .env file for prod
+ run: |
+ echo "SPRING_DATASOURCE_URL=${{ secrets.DEPLOY_DB_URL }}" >> deploy/.env
+ echo "SPRING_DATASOURCE_USERNAME=${{ secrets.DEPLOY_DB_USER }}" >> deploy/.env
+ echo "SPRING_DATASOURCE_PASSWORD=${{ secrets.DEPLOY_DB_PASS }}" >> deploy/.env
+
+ echo "DB_ROOT_PASS=${{ secrets.DB_ROOT_PASS }}" >> deploy/.env
+
+ echo "DB_URL=${{ secrets.DB_URL }}" >> deploy/.env
+ echo "DB_USER=${{ secrets.DB_USER }}" >> deploy/.env
+ echo "DB_PASS=${{ secrets.DB_PASS }}" >> deploy/.env
+
+ echo "SPRING_DATASOURCE_DRIVER_CLASS_NAME=com.mysql.cj.jdbc.Driver" >> deploy/.env
+ echo "JWT_SECRET=${{ secrets.DEPLOY_JWT_SECRET }}" >> deploy/.env
+ echo "JWT_ACCESS_TOKEN_EXPIRATION=86400000" >> deploy/.env
+ echo "JWT_REFRESH_TOKEN_EXPIRATION=604800000" >> deploy/.env
+
+ echo "MYSQL_ROOT_PASSWORD=${{ secrets.DB_ROOT_PASS }}" >> deploy/.env
+
+ echo "SPRING_JPA_HIBERNATE_DDL_AUTO=update" >> deploy/.env
+
+
+ - name: Upload app.jar and .env to EC2
+ uses: appleboy/scp-action@v0.1.7
+ with:
+ host: ${{ secrets.EC2_HOST }}
+ username: ${{ secrets.EC2_USER }}
+ key: ${{ secrets.PEM_KEY }}
+ source: "deploy/app.jar,deploy/.env,docker-compose.yml"
+ target: "/home/ec2-user/ccgo"
+ strip_components: 1
+ - name: SSH into EC2 and restart Docker container
+ uses: appleboy/ssh-action@v0.1.7
+ with:
+ host: ${{ secrets.EC2_HOST }}
+ username: ${{ secrets.EC2_USER }}
+ key: ${{ secrets.PEM_KEY }}
+ script: |
+ cd ~/ccgo
+ docker stop ccgo-app || true
+ docker rm ccgo-app || true
+ docker rmi ccgo-app || true
+ docker compose build
+ docker compose up -d
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
index 6cd1517..4c06862 100644
--- a/.gitignore
+++ b/.gitignore
@@ -203,6 +203,19 @@ application-*.properties
/bin/
# resources
-/src/main/resources/application.properties
+.src/main/resources
+src/main/resources
-# End of https://www.toptal.com/developers/gitignore/api/windows,intellij,java,gradle
\ No newline at end of file
+# End of https://www.toptal.com/developers/gitignore/api/windows,intellij,java,gradle
+
+# pem 파일 (혹시나)
+*.pem
+
+# .gitignore
+.env
+
+#
+.frontend
+
+# 프론트엔드 파일 제외
+CC_Maker_FE/
\ No newline at end of file
diff --git a/.idea/dataSources.xml b/.idea/dataSources.xml
index 7a4f374..9283aae 100644
--- a/.idea/dataSources.xml
+++ b/.idea/dataSources.xml
@@ -1,11 +1,11 @@
-
+
mysql.8
true
com.mysql.cj.jdbc.Driver
- jdbc:mysql://localhost:3306
+ jdbc:mysql://3.39.54.128:3306
diff --git a/.idea/modules/CC_Maker_BE.main.iml b/.idea/modules/CC_Maker_BE.main.iml
index 5491e61..10d37db 100644
--- a/.idea/modules/CC_Maker_BE.main.iml
+++ b/.idea/modules/CC_Maker_BE.main.iml
@@ -14,7 +14,9 @@
-
+
+
+
@@ -35,8 +37,9 @@
-
+
+
@@ -93,7 +96,6 @@
-
diff --git a/.idea/modules/CC_Maker_BE.test.iml b/.idea/modules/CC_Maker_BE.test.iml
index 3b1bc40..ae627e9 100644
--- a/.idea/modules/CC_Maker_BE.test.iml
+++ b/.idea/modules/CC_Maker_BE.test.iml
@@ -19,8 +19,9 @@
-
+
+
@@ -104,7 +105,6 @@
-
diff --git a/.idea/sqldialects.xml b/.idea/sqldialects.xml
new file mode 100644
index 0000000..c2d50dc
--- /dev/null
+++ b/.idea/sqldialects.xml
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/vcs.xml b/.idea/vcs.xml
index 35eb1dd..8cbb340 100644
--- a/.idea/vcs.xml
+++ b/.idea/vcs.xml
@@ -2,5 +2,6 @@
+
\ No newline at end of file
diff --git a/CC_Maker_FE b/CC_Maker_FE
new file mode 160000
index 0000000..26f55c2
--- /dev/null
+++ b/CC_Maker_FE
@@ -0,0 +1 @@
+Subproject commit 26f55c206bd6b58e59904eeaf2e21be9bdddf0bb
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..62416c9
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,4 @@
+FROM eclipse-temurin:17-jdk-jammy
+COPY build/libs/*.jar app.jar
+EXPOSE 8080
+CMD ["java", "-jar", "app.jar"]
diff --git a/build.gradle b/build.gradle
index ff67e6e..37c0eb3 100644
--- a/build.gradle
+++ b/build.gradle
@@ -27,19 +27,23 @@ dependencies {
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-web'
+ implementation 'org.springframework.boot:spring-boot-starter-validation'
+ implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
+ runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5'
+ runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5'
+
compileOnly 'org.projectlombok:lombok'
- runtimeOnly 'com.mysql:mysql-connector-j'
annotationProcessor 'org.projectlombok:lombok'
+
+ implementation 'com.mysql:mysql-connector-j'
+
+
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.springframework.security:spring-security-test'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
- implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
- runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5'
- runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5'
- implementation 'org.springframework.boot:spring-boot-starter-validation'
-
}
+
tasks.named('test') {
useJUnitPlatform()
}
diff --git a/create_mission_history_table.sql b/create_mission_history_table.sql
new file mode 100644
index 0000000..588dd60
--- /dev/null
+++ b/create_mission_history_table.sql
@@ -0,0 +1,17 @@
+-- mission_history 테이블 생성
+CREATE TABLE mission_history (
+ id BIGINT AUTO_INCREMENT PRIMARY KEY,
+ sub_group_id BIGINT NOT NULL,
+ user_id BIGINT NOT NULL,
+ mission_template_id BIGINT NOT NULL,
+ completed_at DATETIME NOT NULL,
+ created_at DATETIME NOT NULL,
+ FOREIGN KEY (sub_group_id) REFERENCES sub_group(id),
+ FOREIGN KEY (user_id) REFERENCES users(id),
+ FOREIGN KEY (mission_template_id) REFERENCES mission_template(id)
+);
+
+-- 인덱스 추가 (성능 향상을 위해)
+CREATE INDEX idx_mission_history_user_id ON mission_history(user_id);
+CREATE INDEX idx_mission_history_sub_group_id ON mission_history(sub_group_id);
+CREATE INDEX idx_mission_history_completed_at ON mission_history(completed_at);
diff --git a/create_privacy_agreement_tables.sql b/create_privacy_agreement_tables.sql
new file mode 100644
index 0000000..863687c
--- /dev/null
+++ b/create_privacy_agreement_tables.sql
@@ -0,0 +1,58 @@
+-- 개인정보 동의서 테이블 생성
+CREATE TABLE privacy_agreements (
+ id BIGINT AUTO_INCREMENT PRIMARY KEY,
+ version VARCHAR(20) NOT NULL UNIQUE,
+ content TEXT NOT NULL,
+ effective_date DATE NOT NULL,
+ created_at DATETIME NOT NULL,
+ is_active BOOLEAN NOT NULL DEFAULT TRUE
+);
+
+-- users 테이블에 개인정보 동의 관련 컬럼 추가
+ALTER TABLE users
+ADD COLUMN privacy_agreement_version VARCHAR(20),
+ADD COLUMN privacy_agreed BOOLEAN DEFAULT FALSE,
+ADD COLUMN privacy_agreed_at DATETIME,
+ADD COLUMN privacy_agreed_method VARCHAR(50),
+ADD COLUMN privacy_agreed_environment VARCHAR(20);
+
+-- 초기 개인정보 동의서 데이터 삽입 (v1.0)
+INSERT INTO privacy_agreements (version, content, effective_date, created_at, is_active) VALUES (
+ 'v1.0',
+ '개인정보 수집 및 이용에 대한 안내
+
+1. 수집하는 개인정보 항목
+- 필수항목: 이름, 생년월일, 이메일, 비밀번호, 성별
+- 선택항목: 없음
+
+2. 개인정보의 수집 및 이용목적
+- 회원가입 및 서비스 이용
+- 서비스 제공 및 운영
+- 고객상담 및 문의응답
+- 서비스 개선 및 신규 서비스 개발
+
+3. 개인정보의 보유 및 이용기간
+- 회원 탈퇴 시까지 (단, 관련 법령에 따라 보존이 필요한 경우 해당 기간까지)
+
+4. 개인정보의 파기절차 및 방법
+- 전자적 파일 형태로 저장된 개인정보는 복구 불가능한 방법으로 영구 삭제
+- 종이에 출력된 개인정보는 분쇄기로 분쇄하거나 소각을 통하여 파기
+
+5. 동의 거부권 및 동의 거부에 따른 불이익
+- 개인정보 수집 및 이용에 대한 동의를 거부할 수 있습니다.
+- 동의를 거부할 경우 회원가입 및 서비스 이용이 제한됩니다.
+
+위 내용에 동의하시면 체크박스를 선택해 주세요.
+
+─────────────────────────────────────────
+시행일: 2025.08.20 / 버전: v1.0',
+ '2025-08-20',
+ NOW(),
+ TRUE
+);
+
+-- 인덱스 생성
+CREATE INDEX idx_privacy_agreements_version ON privacy_agreements(version);
+CREATE INDEX idx_privacy_agreements_active ON privacy_agreements(is_active);
+CREATE INDEX idx_users_privacy_version ON users(privacy_agreement_version);
+CREATE INDEX idx_users_privacy_agreed ON users(privacy_agreed);
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..a3acf45
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,34 @@
+version: '3.8'
+
+services:
+ app:
+ build: .
+ image: ccgo-app:latest
+ container_name: ccgo-app
+ ports:
+ - "8080:8080"
+ restart: always
+ env_file:
+ - .env
+ depends_on:
+ - mysql
+ environment:
+ TZ: Asia/Seoul
+
+ mysql:
+ image: mysql:8
+ container_name: mysql
+ restart: always
+ ports:
+ - "3306:3306"
+ environment:
+ MYSQL_ROOT_PASSWORD: ${DB_PASS}
+ MYSQL_DATABASE: ccmake
+ MYSQL_USER: ${DB_USER}
+ MYSQL_PASSWORD: ${DB_PASS}
+
+ volumes:
+ - mysql_data:/var/lib/mysql
+
+volumes:
+ mysql_data:
diff --git a/src/main/java/com/ccapp/ccgo/auth/controller/AuthController.java b/src/main/java/com/ccapp/ccgo/auth/controller/AuthController.java
new file mode 100644
index 0000000..ebad563
--- /dev/null
+++ b/src/main/java/com/ccapp/ccgo/auth/controller/AuthController.java
@@ -0,0 +1,177 @@
+package com.ccapp.ccgo.auth.controller;
+
+import com.ccapp.ccgo.auth.dto.LoginRequestDto;
+import com.ccapp.ccgo.auth.dto.LoginResponseDto;
+import com.ccapp.ccgo.auth.dto.TokenResponseDto;
+import com.ccapp.ccgo.auth.jwt.JwtProvider;
+import com.ccapp.ccgo.auth.service.AuthService;
+import com.ccapp.ccgo.auth.service.RefreshTokenService;
+import jakarta.validation.Valid;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.ResponseCookie;
+import org.springframework.http.ResponseEntity;
+import org.springframework.security.authentication.BadCredentialsException;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.Map;
+
+@Slf4j
+@RestController
+@RequestMapping("/api/auth")
+@RequiredArgsConstructor
+public class AuthController {
+
+ private final AuthService authService;
+ private final JwtProvider jwtProvider;
+ private final RefreshTokenService refreshTokenService;
+
+ @Value("${app.cookie.secure:true}")
+ private boolean cookieSecure;
+
+ @Value("${app.cookie.same-site:Lax}")
+ private String cookieSameSite;
+
+ @Value("${jwt.access-token-expiration:3600}") // 초 단위
+ private long accessTokenMaxAge;
+
+ @Value("${jwt.refresh-token-expiration:604800}") // 초 단위
+ private long refreshTokenMaxAge;
+
+ @PostMapping("/login")
+ public ResponseEntity> login(@Valid @RequestBody LoginRequestDto requestDto) {
+ String maskedEmail = maskEmail(requestDto.getEmail());
+ log.info("로그인 요청 받음: {}", maskedEmail);
+
+ try {
+ LoginResponseDto response = authService.login(requestDto.getEmail(), requestDto.getPassword());
+
+ HttpHeaders headers = createTokenCookies(response.getAccessToken(), response.getRefreshToken());
+
+ log.info("발급된 쿠키: {}", headers.get(HttpHeaders.SET_COOKIE));
+
+
+ // 응답 바디에 토큰도 포함해서 내려줌 (Expo Go용)
+ Map responseBody = Map.of(
+ "message", "로그인 성공",
+ "data", LoginResponseDto.builder()
+ .userId(response.getUserId())
+ .email(response.getEmail())
+ .name(response.getName())
+ .teams(response.getTeams())
+ .build(),
+ "accessToken", response.getAccessToken(),
+ "refreshToken", response.getRefreshToken()
+ );
+
+ return ResponseEntity.ok()
+ .headers(headers)
+ .body(responseBody);
+
+ } catch (BadCredentialsException e) {
+ log.warn("로그인 실패 - 잘못된 이메일 또는 비밀번호: {}", maskedEmail);
+ return ResponseEntity.status(401).body(Map.of("message", "이메일 또는 비밀번호가 잘못되었습니다."));
+ } catch (Exception e) {
+ log.error("❌ 로그인 중 오류 발생", e);
+ return ResponseEntity.status(500).body(Map.of("message", "서버 오류가 발생했습니다."));
+ }
+ }
+
+ @PostMapping("/refresh")
+ public ResponseEntity> refreshToken(
+ @CookieValue(value = "accessToken", required = false) String accessToken,
+ @CookieValue(value = "refreshToken", required = false) String refreshToken) {
+ try {
+ if (accessToken == null || refreshToken == null) {
+ return ResponseEntity.status(401).body(Map.of("message", "토큰이 없습니다."));
+ }
+
+ TokenResponseDto tokenResponse = authService.refreshToken(accessToken, refreshToken);
+ HttpHeaders headers = createTokenCookies(tokenResponse.getAccessToken(), tokenResponse.getRefreshToken());
+
+ log.info("새로운 쿠키: {}", headers.get(HttpHeaders.SET_COOKIE));
+
+ return ResponseEntity.ok()
+ .headers(headers)
+ .body(Map.of(
+ "message", "토큰 갱신 성공",
+ "accessToken", tokenResponse.getAccessToken(),
+ "refreshToken", tokenResponse.getRefreshToken()
+ ));
+ } catch (IllegalArgumentException e) {
+ return ResponseEntity.status(401).body(Map.of("message", e.getMessage()));
+ } catch (Exception e) {
+ log.error("❌ 토큰 갱신 중 오류 발생", e);
+ return ResponseEntity.status(500).body(Map.of("message", "서버 오류가 발생했습니다."));
+ }
+ }
+
+
+ private HttpHeaders createTokenCookies(String accessToken, String refreshToken) {
+ ResponseCookie accessTokenCookie = ResponseCookie.from("accessToken", accessToken)
+ .httpOnly(true)
+ .path("/")
+ .maxAge(accessTokenMaxAge)
+ .secure(cookieSecure)
+ .sameSite(cookieSameSite)
+ .build();
+
+ ResponseCookie refreshTokenCookie = ResponseCookie.from("refreshToken", refreshToken)
+ .httpOnly(true)
+ .path("/")
+ .maxAge(refreshTokenMaxAge)
+ .secure(cookieSecure)
+ .sameSite(cookieSameSite)
+ .build();
+
+ HttpHeaders headers = new HttpHeaders();
+ headers.add(HttpHeaders.SET_COOKIE, accessTokenCookie.toString());
+ headers.add(HttpHeaders.SET_COOKIE, refreshTokenCookie.toString());
+ return headers;
+ }
+
+ private String maskEmail(String email) {
+ if (email == null || !email.contains("@")) return "unknown";
+ String[] parts = email.split("@");
+ return parts[0].charAt(0) + "***@" + parts[1];
+ }
+
+ @PostMapping("/logout")
+ public ResponseEntity> logout(@CookieValue(value = "refreshToken", required = false) String refreshToken) {
+ if (refreshToken == null || !jwtProvider.validateToken(refreshToken)) {
+ return ResponseEntity.status(401).body(Map.of("message", "유효하지 않은 리프레시 토큰입니다."));
+ }
+
+ // Redis에서 Refresh Token 삭제
+ String email = jwtProvider.getEmailFromToken(refreshToken);
+ refreshTokenService.deleteRefreshToken(email);
+
+ // 쿠키 삭제 (maxAge=0)
+ HttpHeaders headers = new HttpHeaders();
+ headers.add(HttpHeaders.SET_COOKIE, ResponseCookie.from("accessToken", "")
+ .path("/")
+ .maxAge(0)
+ .httpOnly(true)
+ .secure(cookieSecure)
+ .sameSite(cookieSameSite)
+ .build()
+ .toString());
+ headers.add(HttpHeaders.SET_COOKIE, ResponseCookie.from("refreshToken", "")
+ .path("/")
+ .maxAge(0)
+ .httpOnly(true)
+ .secure(cookieSecure)
+ .sameSite(cookieSameSite)
+ .build()
+ .toString());
+
+ return ResponseEntity.ok()
+ .headers(headers)
+ .body(Map.of("message", "로그아웃 처리 완료"));
+ }
+
+
+
+}
diff --git a/src/main/java/com/ccapp/ccgo/dto/LoginRequestDto.java b/src/main/java/com/ccapp/ccgo/auth/dto/LoginRequestDto.java
similarity index 52%
rename from src/main/java/com/ccapp/ccgo/dto/LoginRequestDto.java
rename to src/main/java/com/ccapp/ccgo/auth/dto/LoginRequestDto.java
index 7230285..96ac2d8 100644
--- a/src/main/java/com/ccapp/ccgo/dto/LoginRequestDto.java
+++ b/src/main/java/com/ccapp/ccgo/auth/dto/LoginRequestDto.java
@@ -1,18 +1,24 @@
-package com.ccapp.ccgo.dto;
+package com.ccapp.ccgo.auth.dto;
import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
import jakarta.validation.constraints.Email;
-import jakarta.validation.constraints.NotNull;
+import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
@Getter
+@NoArgsConstructor
+@AllArgsConstructor
+@Builder
public class LoginRequestDto {
- @NotNull(message = "이메일은 필수입니다.")
+ @NotBlank(message = "이메일은 필수입니다.")
@Email(message = "올바른 이메일 형식이어야 합니다.")
private String email;
- @NotNull(message = "비밀번호는 필수입니다.")
+ @NotBlank(message = "비밀번호는 필수입니다.")
@Size(min = 8, message = "비밀번호는 최소 8자 이상이어야 합니다.")
private String password;
}
diff --git a/src/main/java/com/ccapp/ccgo/auth/dto/LoginResponseDto.java b/src/main/java/com/ccapp/ccgo/auth/dto/LoginResponseDto.java
new file mode 100644
index 0000000..e0e8264
--- /dev/null
+++ b/src/main/java/com/ccapp/ccgo/auth/dto/LoginResponseDto.java
@@ -0,0 +1,33 @@
+package com.ccapp.ccgo.auth.dto;
+
+import lombok.Builder;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.AllArgsConstructor;
+
+import java.util.List;
+
+@Getter
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class LoginResponseDto {
+ private String grantType; // Bearer
+ private String accessToken;
+ private String refreshToken;
+ private Long userId;
+ private String email;
+ private String name;
+ private List teams;
+
+ @Getter
+ @Builder
+ @NoArgsConstructor
+ @AllArgsConstructor
+ public static class TeamInfo {
+ private Long teamId;
+ private String teamName;
+ private String role; // 팀 내 역할 (예: "LEADER", "MEMBER")
+ private boolean isSurveyCompleted;
+ }
+}
diff --git a/src/main/java/com/ccapp/ccgo/dto/TokenResponseDto.java b/src/main/java/com/ccapp/ccgo/auth/dto/TokenResponseDto.java
similarity index 68%
rename from src/main/java/com/ccapp/ccgo/dto/TokenResponseDto.java
rename to src/main/java/com/ccapp/ccgo/auth/dto/TokenResponseDto.java
index fd9e5cc..eba2601 100644
--- a/src/main/java/com/ccapp/ccgo/dto/TokenResponseDto.java
+++ b/src/main/java/com/ccapp/ccgo/auth/dto/TokenResponseDto.java
@@ -1,12 +1,14 @@
-package com.ccapp.ccgo.dto;
+package com.ccapp.ccgo.auth.dto;
-import lombok.AllArgsConstructor;
-import lombok.Data;
+import lombok.Getter;
import lombok.NoArgsConstructor;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
-@Data
+@Getter
@NoArgsConstructor
@AllArgsConstructor
+@Builder
public class TokenResponseDto {
private String accessToken;
private String refreshToken;
diff --git a/src/main/java/com/ccapp/ccgo/auth/entity/RefreshToken.java b/src/main/java/com/ccapp/ccgo/auth/entity/RefreshToken.java
new file mode 100644
index 0000000..4539266
--- /dev/null
+++ b/src/main/java/com/ccapp/ccgo/auth/entity/RefreshToken.java
@@ -0,0 +1,35 @@
+package com.ccapp.ccgo.auth.entity;
+
+import jakarta.persistence.*;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.time.LocalDateTime;
+
+@Entity
+@Table(name = "refresh_tokens")
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class RefreshToken {
+
+ @Id
+ private String email;
+
+ @Column(nullable = false)
+ private String refreshToken;
+
+ @Column(nullable = false)
+ private LocalDateTime expiresAt;
+
+ @Column(nullable = false)
+ private LocalDateTime createdAt;
+
+ @PrePersist
+ protected void onCreate() {
+ createdAt = LocalDateTime.now();
+ }
+}
diff --git a/src/main/java/com/ccapp/ccgo/auth/jwt/CustomAuthenticationEntryPoint.java b/src/main/java/com/ccapp/ccgo/auth/jwt/CustomAuthenticationEntryPoint.java
new file mode 100644
index 0000000..1769f73
--- /dev/null
+++ b/src/main/java/com/ccapp/ccgo/auth/jwt/CustomAuthenticationEntryPoint.java
@@ -0,0 +1,21 @@
+package com.ccapp.ccgo.auth.jwt;
+
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import org.springframework.security.core.AuthenticationException;
+import org.springframework.security.web.AuthenticationEntryPoint;
+import org.springframework.stereotype.Component;
+
+import java.io.IOException; // ✅ 추가
+import jakarta.servlet.ServletException; // ✅ 추가
+
+@Component
+public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint {
+ @Override
+ public void commence(HttpServletRequest request, HttpServletResponse response,
+ AuthenticationException authException) throws IOException, ServletException {
+ response.setContentType("application/json;charset=UTF-8");
+ response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
+ response.getWriter().write("{\"message\": \"인증이 필요합니다.\"}");
+ }
+}
diff --git a/src/main/java/com/ccapp/ccgo/jwt/JwtAuthenticationFilter.java b/src/main/java/com/ccapp/ccgo/auth/jwt/JwtAuthenticationFilter.java
similarity index 63%
rename from src/main/java/com/ccapp/ccgo/jwt/JwtAuthenticationFilter.java
rename to src/main/java/com/ccapp/ccgo/auth/jwt/JwtAuthenticationFilter.java
index 693d646..c9c0d4d 100644
--- a/src/main/java/com/ccapp/ccgo/jwt/JwtAuthenticationFilter.java
+++ b/src/main/java/com/ccapp/ccgo/auth/jwt/JwtAuthenticationFilter.java
@@ -1,8 +1,9 @@
-package com.ccapp.ccgo.jwt;
+package com.ccapp.ccgo.auth.jwt;
+import com.ccapp.ccgo.auth.service.LoginUserDetailsService;
import jakarta.servlet.FilterChain;
-import jakarta.servlet.http.Cookie;
import jakarta.servlet.ServletException;
+import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
@@ -29,26 +30,17 @@ protected void doFilterInternal(HttpServletRequest request,
FilterChain filterChain)
throws ServletException, IOException {
- String token = null;
-
- if (request.getCookies() != null) {
- for (Cookie cookie : request.getCookies()) {
- if ("accessToken".equals(cookie.getName())) {
- token = cookie.getValue();
- break;
- }
- }
+ // permitAll 경로는 JWT 필터를 건너뛰기
+ String requestURI = request.getRequestURI();
+ if (requestURI.equals("/api/user/register") || requestURI.startsWith("/api/auth/")) {
+ filterChain.doFilter(request, response);
+ return;
}
- if (token == null) {
- String authHeader = request.getHeader("Authorization");
- if (authHeader != null && authHeader.startsWith("Bearer ")) {
- token = authHeader.substring(7);
- }
- }
+ String token = extractToken(request);
try {
- if (token != null && jwtProvider.validateToken(token)) {
+ if (token != null && jwtProvider.validateAccessToken(token)) {
String email = jwtProvider.getEmailFromToken(token);
if (email != null && SecurityContextHolder.getContext().getAuthentication() == null) {
@@ -60,13 +52,36 @@ protected void doFilterInternal(HttpServletRequest request,
authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authToken);
+ log.debug("✅ JWT 인증 성공: {}", email);
}
}
} catch (Exception e) {
- // 로그 추가 가능 (필요 시)
- log.error("JWT 인증 처리 중 오류 발생", e);
+ log.error("❌ JWT 인증 처리 중 오류 발생", e);
}
filterChain.doFilter(request, response);
}
-}
\ No newline at end of file
+
+ /**
+ * Authorization 헤더 → 쿠키 순서로 토큰 추출
+ */
+ private String extractToken(HttpServletRequest request) {
+ // 1. Authorization 헤더 우선
+ String authHeader = request.getHeader("Authorization");
+ if (authHeader != null && authHeader.startsWith("Bearer ")) {
+ log.debug("📌 Authorization 헤더에서 토큰 추출");
+ return authHeader.substring(7);
+ }
+
+ // 2. 쿠키 확인
+ if (request.getCookies() != null) {
+ for (Cookie cookie : request.getCookies()) {
+ if ("accessToken".equals(cookie.getName())) {
+ log.debug("📌 쿠키에서 토큰 추출");
+ return cookie.getValue();
+ }
+ }
+ }
+ return null;
+ }
+}
diff --git a/src/main/java/com/ccapp/ccgo/auth/jwt/JwtProvider.java b/src/main/java/com/ccapp/ccgo/auth/jwt/JwtProvider.java
new file mode 100644
index 0000000..ae6634a
--- /dev/null
+++ b/src/main/java/com/ccapp/ccgo/auth/jwt/JwtProvider.java
@@ -0,0 +1,147 @@
+package com.ccapp.ccgo.auth.jwt;
+
+import io.jsonwebtoken.*;
+import io.jsonwebtoken.security.Keys;
+import jakarta.annotation.PostConstruct;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.GrantedAuthority;
+import org.springframework.stereotype.Component;
+
+import java.nio.charset.StandardCharsets;
+import java.security.Key;
+import java.util.Date;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+@Slf4j
+@Component
+public class JwtProvider {
+
+ private Key key;
+
+ @Value("${jwt.secret}")
+ private String secret;
+
+ @Value("${jwt.access-token-expiration}")
+ private long accessTokenExpiration;
+
+ @Value("${jwt.refresh-token-expiration}")
+ private long refreshTokenExpiration;
+
+ @PostConstruct
+ public void init() {
+ byte[] keyBytes = secret.getBytes(StandardCharsets.UTF_8);
+ if (keyBytes.length < 32) {
+ log.error("JWT Secret 키가 너무 짧습니다. 32바이트 이상 권장!");
+ }
+ this.key = Keys.hmacShaKeyFor(keyBytes);
+ }
+
+ public String createAccessToken(Authentication authentication) {
+ String username = authentication.getName();
+ String roles = authentication.getAuthorities().stream()
+ .map(GrantedAuthority::getAuthority)
+ .collect(Collectors.joining(","));
+ return buildToken(username, Map.of("roles", roles, "type", "ACCESS"), accessTokenExpiration);
+ }
+
+ public String createRefreshToken(Authentication authentication) {
+ return buildToken(authentication.getName(), Map.of("type", "REFRESH"), refreshTokenExpiration);
+ }
+
+ private String buildToken(String subject, Map claims, long validityInMs) {
+ Date now = new Date();
+ Date expiry = new Date(now.getTime() + validityInMs);
+ JwtBuilder builder = Jwts.builder()
+ .setSubject(subject)
+ .setIssuedAt(now)
+ .setExpiration(expiry)
+ .signWith(key, SignatureAlgorithm.HS256);
+
+ if (claims != null) {
+ builder.addClaims(claims);
+ }
+
+ return builder.compact();
+ }
+
+ public boolean validateToken(String token) {
+ try {
+ Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
+ return true;
+ } catch (ExpiredJwtException e) {
+ log.warn("JWT 만료: {}", e.getMessage());
+ } catch (UnsupportedJwtException e) {
+ log.warn("지원되지 않는 JWT: {}", e.getMessage());
+ } catch (MalformedJwtException e) {
+ log.warn("잘못된 JWT 구조: {}", e.getMessage());
+ } catch (SecurityException e) {
+ log.warn("JWT 서명 오류: {}", e.getMessage());
+ } catch (IllegalArgumentException e) {
+ log.warn("JWT 토큰 값 없음: {}", e.getMessage());
+ }
+ return false;
+ }
+
+ public boolean validateAccessToken(String token) {
+ try {
+ Claims claims = Jwts.parserBuilder()
+ .setSigningKey(key)
+ .build()
+ .parseClaimsJws(token)
+ .getBody();
+
+ return "ACCESS".equals(claims.get("type"));
+ } catch (Exception e) {
+ log.warn("Access Token 검증 실패: {}", e.getMessage());
+ return false;
+ }
+ }
+
+ public boolean validateRefreshToken(String token) {
+ try {
+ Claims claims = Jwts.parserBuilder()
+ .setSigningKey(key)
+ .build()
+ .parseClaimsJws(token)
+ .getBody();
+
+ return "REFRESH".equals(claims.get("type"));
+ } catch (Exception e) {
+ log.warn("Refresh Token 검증 실패: {}", e.getMessage());
+ return false;
+ }
+ }
+
+ public boolean validateTokenStructure(String token) {
+ try {
+ Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
+ return true;
+ } catch (ExpiredJwtException e) {
+ // 만료는 허용 (구조는 유효)
+ return true;
+ } catch (Exception e) {
+ return false;
+ }
+ }
+
+ public String getEmailFromToken(String token) {
+ return Jwts.parserBuilder()
+ .setSigningKey(key)
+ .build()
+ .parseClaimsJws(token)
+ .getBody()
+ .getSubject();
+ }
+
+ public long getAccessTokenExpiration() {
+ return accessTokenExpiration;
+ }
+
+ public long getRefreshTokenExpiration() {
+ return refreshTokenExpiration;
+ }
+
+}
diff --git a/src/main/java/com/ccapp/ccgo/jwt/JwtToken.java b/src/main/java/com/ccapp/ccgo/auth/jwt/JwtToken.java
similarity index 89%
rename from src/main/java/com/ccapp/ccgo/jwt/JwtToken.java
rename to src/main/java/com/ccapp/ccgo/auth/jwt/JwtToken.java
index 26fbaf1..1133e00 100644
--- a/src/main/java/com/ccapp/ccgo/jwt/JwtToken.java
+++ b/src/main/java/com/ccapp/ccgo/auth/jwt/JwtToken.java
@@ -1,4 +1,4 @@
-package com.ccapp.ccgo.jwt;
+package com.ccapp.ccgo.auth.jwt;
import lombok.AllArgsConstructor;
import lombok.Getter;
diff --git a/src/main/java/com/ccapp/ccgo/auth/jwt/LoginUserDetails.java b/src/main/java/com/ccapp/ccgo/auth/jwt/LoginUserDetails.java
new file mode 100644
index 0000000..23a0e2a
--- /dev/null
+++ b/src/main/java/com/ccapp/ccgo/auth/jwt/LoginUserDetails.java
@@ -0,0 +1,48 @@
+package com.ccapp.ccgo.auth.jwt;
+
+import com.ccapp.ccgo.team.entity.TeamMember;
+import com.ccapp.ccgo.user.entity.User;
+import lombok.Getter;
+import org.springframework.security.core.GrantedAuthority;
+import org.springframework.security.core.userdetails.UserDetails;
+import org.springframework.security.core.authority.SimpleGrantedAuthority;
+import java.util.List;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+@Getter
+public class LoginUserDetails implements UserDetails {
+
+ private final User user;
+ private final List teamMembers;
+
+ public LoginUserDetails(User user, List teamMembers) {
+ this.user = user;
+ this.teamMembers = teamMembers;
+ }
+
+ @Override
+ public Collection extends GrantedAuthority> getAuthorities() {
+ if (teamMembers == null || teamMembers.isEmpty()) {
+ return Collections.emptyList();
+ }
+
+ Set authorities = teamMembers.stream()
+ .map(TeamMember::getRole) // Role enum 반환 가정
+ .map(role -> new SimpleGrantedAuthority("ROLE_" + role.name()))
+ .collect(Collectors.toSet());
+
+ return authorities;
+ }
+
+ // 이하 기존 메서드 동일
+ @Override public String getPassword() { return user.getPassword(); }
+ @Override public String getUsername() { return user.getEmail(); }
+ @Override public boolean isAccountNonExpired() { return true; }
+ @Override public boolean isAccountNonLocked() { return true; }
+ @Override public boolean isCredentialsNonExpired() { return true; }
+ @Override public boolean isEnabled() { return true; }
+}
+
diff --git a/src/main/java/com/ccapp/ccgo/auth/repository/RefreshTokenRepository.java b/src/main/java/com/ccapp/ccgo/auth/repository/RefreshTokenRepository.java
new file mode 100644
index 0000000..8ec172a
--- /dev/null
+++ b/src/main/java/com/ccapp/ccgo/auth/repository/RefreshTokenRepository.java
@@ -0,0 +1,17 @@
+package com.ccapp.ccgo.auth.repository;
+
+import com.ccapp.ccgo.auth.entity.RefreshToken;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.stereotype.Repository;
+
+import java.util.Optional;
+
+@Repository
+public interface RefreshTokenRepository extends JpaRepository {
+
+ Optional findByEmail(String email);
+
+ void deleteByEmail(String email);
+
+ boolean existsByEmail(String email);
+}
diff --git a/src/main/java/com/ccapp/ccgo/auth/service/AuthService.java b/src/main/java/com/ccapp/ccgo/auth/service/AuthService.java
new file mode 100644
index 0000000..387de6a
--- /dev/null
+++ b/src/main/java/com/ccapp/ccgo/auth/service/AuthService.java
@@ -0,0 +1,93 @@
+package com.ccapp.ccgo.auth.service;
+
+import com.ccapp.ccgo.auth.dto.LoginResponseDto;
+import com.ccapp.ccgo.auth.dto.TokenResponseDto;
+import com.ccapp.ccgo.auth.jwt.JwtProvider;
+import com.ccapp.ccgo.auth.jwt.LoginUserDetails;
+import com.ccapp.ccgo.team.entity.TeamMember;
+import com.ccapp.ccgo.team.repository.TeamMemberRepository;
+import lombok.RequiredArgsConstructor;
+import org.springframework.security.authentication.AuthenticationManager;
+import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
+import org.springframework.security.core.Authentication;
+import org.springframework.stereotype.Service;
+
+import java.util.List;
+
+@Service
+@RequiredArgsConstructor
+public class AuthService {
+
+ private final AuthenticationManager authenticationManager;
+ private final JwtProvider jwtProvider;
+ private final TeamMemberRepository teamMemberRepository;
+ private final LoginUserDetailsService loginUserDetailsService;
+ private final RefreshTokenService refreshTokenService;
+
+ public LoginResponseDto login(String email, String password) {
+ Authentication authentication = authenticationManager.authenticate(
+ new UsernamePasswordAuthenticationToken(email, password)
+ );
+
+ String accessToken = jwtProvider.createAccessToken(authentication);
+ String refreshToken = jwtProvider.createRefreshToken(authentication);
+
+ LoginUserDetails userDetails = (LoginUserDetails) authentication.getPrincipal();
+ var user = userDetails.getUser();
+
+ // Refresh Token DB 저장
+ refreshTokenService.saveRefreshToken(email, refreshToken, jwtProvider.getRefreshTokenExpiration());
+
+ List teamMembers = teamMemberRepository.findAllByUserAndIsActiveTrue(user);
+
+ List teams = teamMembers.stream()
+ .map(tm -> LoginResponseDto.TeamInfo.builder()
+ .teamId(tm.getTeam().getTeamId())
+ .teamName(tm.getTeam().getTeamName())
+ .role(tm.getRole().name())
+ .isSurveyCompleted(tm.isSurveyCompleted())
+ .build())
+ .toList();
+
+ return LoginResponseDto.builder()
+ .userId(user.getId())
+ .email(user.getEmail())
+ .name(user.getName())
+ .teams(teams)
+ .accessToken(accessToken)
+ .refreshToken(refreshToken)
+ .build();
+ }
+
+ public TokenResponseDto refreshToken(String accessToken, String refreshToken) {
+ // 1. Access Token 구조 검증 (만료는 허용)
+ if (!jwtProvider.validateTokenStructure(accessToken)) {
+ throw new IllegalArgumentException("유효하지 않은 Access Token 구조입니다.");
+ }
+
+ // 2. Refresh Token 검증
+ if (!jwtProvider.validateRefreshToken(refreshToken)) {
+ throw new IllegalArgumentException("유효하지 않은 Refresh Token입니다.");
+ }
+
+ // 3. DB의 Refresh Token과 비교
+ String email = jwtProvider.getEmailFromToken(refreshToken);
+ if (!refreshTokenService.validateRefreshToken(email, refreshToken)) {
+ throw new IllegalArgumentException("유효하지 않은 Refresh Token입니다.");
+ }
+
+ // 4. 새로운 토큰 생성
+ LoginUserDetails userDetails = (LoginUserDetails) loginUserDetailsService.loadUserByUsername(email);
+ Authentication authentication = new UsernamePasswordAuthenticationToken(
+ userDetails, null, userDetails.getAuthorities()
+ );
+
+ String newAccessToken = jwtProvider.createAccessToken(authentication);
+ String newRefreshToken = jwtProvider.createRefreshToken(authentication);
+
+ // 5. Refresh Token Rotation (기존 토큰 삭제, 새로운 토큰 저장)
+ refreshTokenService.updateRefreshToken(email, newRefreshToken, jwtProvider.getRefreshTokenExpiration());
+
+ return new TokenResponseDto(newAccessToken, newRefreshToken);
+ }
+}
diff --git a/src/main/java/com/ccapp/ccgo/auth/service/LoginUserDetailsService.java b/src/main/java/com/ccapp/ccgo/auth/service/LoginUserDetailsService.java
new file mode 100644
index 0000000..773beea
--- /dev/null
+++ b/src/main/java/com/ccapp/ccgo/auth/service/LoginUserDetailsService.java
@@ -0,0 +1,37 @@
+package com.ccapp.ccgo.auth.service;
+
+import com.ccapp.ccgo.auth.jwt.LoginUserDetails;
+import com.ccapp.ccgo.team.entity.TeamMember;
+import com.ccapp.ccgo.team.repository.TeamMemberRepository;
+import com.ccapp.ccgo.user.entity.User;
+import com.ccapp.ccgo.user.repository.UserRepository;
+import org.springframework.security.core.userdetails.UserDetails;
+import org.springframework.security.core.userdetails.UserDetailsService;
+import org.springframework.security.core.userdetails.UsernameNotFoundException;
+import org.springframework.stereotype.Service;
+
+import java.util.List;
+
+@Service
+public class LoginUserDetailsService implements UserDetailsService {
+
+ private final UserRepository userRepository;
+ private final TeamMemberRepository teamMemberRepository;
+
+ // teamMemberRepository도 생성자 인자로 추가
+ public LoginUserDetailsService(UserRepository userRepository,
+ TeamMemberRepository teamMemberRepository) {
+ this.userRepository = userRepository;
+ this.teamMemberRepository = teamMemberRepository;
+ }
+
+ // ✅ 이메일로 유저 조회 → LoginUserDetails 로 감싸서 리턴
+ @Override
+ public UserDetails loadUserByUsername(String email) {
+ User user = userRepository.findByEmail(email)
+ .orElseThrow(() -> new UsernameNotFoundException("사용자를 찾을 수 없습니다."));
+ List teamMembers = teamMemberRepository.findAllByUserAndIsActiveTrue(user);
+
+ return new LoginUserDetails(user, teamMembers);
+ }
+}
diff --git a/src/main/java/com/ccapp/ccgo/auth/service/RefreshTokenService.java b/src/main/java/com/ccapp/ccgo/auth/service/RefreshTokenService.java
new file mode 100644
index 0000000..a1e56e2
--- /dev/null
+++ b/src/main/java/com/ccapp/ccgo/auth/service/RefreshTokenService.java
@@ -0,0 +1,65 @@
+package com.ccapp.ccgo.auth.service;
+
+import com.ccapp.ccgo.auth.entity.RefreshToken;
+import com.ccapp.ccgo.auth.repository.RefreshTokenRepository;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+
+import java.time.LocalDateTime;
+
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class RefreshTokenService {
+
+ private final RefreshTokenRepository refreshTokenRepository;
+
+ public void saveRefreshToken(String email, String refreshToken, long expirationMillis) {
+ LocalDateTime expiresAt = LocalDateTime.now().plusSeconds(expirationMillis / 1000);
+ LocalDateTime createdAt = LocalDateTime.now();
+
+ RefreshToken token = RefreshToken.builder()
+ .email(email)
+ .refreshToken(refreshToken)
+ .expiresAt(expiresAt)
+ .createdAt(createdAt)
+ .build();
+
+ refreshTokenRepository.save(token);
+ log.info("Refresh Token 저장 완료: {}", email);
+ }
+
+ public String getRefreshToken(String email) {
+ return refreshTokenRepository.findByEmail(email)
+ .map(RefreshToken::getRefreshToken)
+ .orElse(null);
+ }
+
+ public boolean validateRefreshToken(String email, String refreshToken) {
+ return refreshTokenRepository.findByEmail(email)
+ .map(token -> token.getRefreshToken().equals(refreshToken) &&
+ token.getExpiresAt().isAfter(LocalDateTime.now()))
+ .orElse(false);
+ }
+
+ public void deleteRefreshToken(String email) {
+ refreshTokenRepository.deleteByEmail(email);
+ log.info("Refresh Token 삭제 완료: {}", email);
+ }
+
+ public void updateRefreshToken(String email, String newRefreshToken, long expirationMillis) {
+ LocalDateTime expiresAt = LocalDateTime.now().plusSeconds(expirationMillis / 1000);
+ LocalDateTime createdAt = LocalDateTime.now();
+
+ RefreshToken token = RefreshToken.builder()
+ .email(email)
+ .refreshToken(newRefreshToken)
+ .expiresAt(expiresAt)
+ .createdAt(createdAt)
+ .build();
+
+ refreshTokenRepository.save(token);
+ log.info("Refresh Token 업데이트 완료: {}", email);
+ }
+}
diff --git a/src/main/java/com/ccapp/ccgo/common/MissionStatus.java b/src/main/java/com/ccapp/ccgo/common/MissionStatus.java
new file mode 100644
index 0000000..66777fb
--- /dev/null
+++ b/src/main/java/com/ccapp/ccgo/common/MissionStatus.java
@@ -0,0 +1,7 @@
+package com.ccapp.ccgo.common;
+
+
+public enum MissionStatus {
+ PENDING,
+ COMPLETE
+}
\ No newline at end of file
diff --git a/src/main/java/com/ccapp/ccgo/common/Role.java b/src/main/java/com/ccapp/ccgo/common/Role.java
new file mode 100644
index 0000000..01e4d85
--- /dev/null
+++ b/src/main/java/com/ccapp/ccgo/common/Role.java
@@ -0,0 +1,6 @@
+package com.ccapp.ccgo.common;
+
+public enum Role {
+ LEADER,
+ MEMBER
+}
\ No newline at end of file
diff --git a/src/main/java/com/ccapp/ccgo/common/SecurityConfig.java b/src/main/java/com/ccapp/ccgo/common/SecurityConfig.java
new file mode 100644
index 0000000..8fbfb73
--- /dev/null
+++ b/src/main/java/com/ccapp/ccgo/common/SecurityConfig.java
@@ -0,0 +1,109 @@
+package com.ccapp.ccgo.common;
+
+import com.ccapp.ccgo.auth.jwt.CustomAuthenticationEntryPoint;
+import com.ccapp.ccgo.auth.jwt.JwtAuthenticationFilter;
+import com.ccapp.ccgo.auth.service.LoginUserDetailsService;
+import lombok.RequiredArgsConstructor;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.security.authentication.AuthenticationManager;
+import org.springframework.security.authentication.AuthenticationProvider;
+import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
+import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
+import org.springframework.security.config.annotation.web.builders.HttpSecurity;
+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.web.SecurityFilterChain;
+import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
+import org.springframework.web.cors.CorsConfiguration;
+import org.springframework.web.cors.CorsConfigurationSource;
+import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
+import org.springframework.security.web.header.writers.ReferrerPolicyHeaderWriter;
+import java.util.List;
+
+
+@Configuration
+@RequiredArgsConstructor
+public class SecurityConfig {
+
+ private final JwtAuthenticationFilter jwtAuthenticationFilter;
+ private final LoginUserDetailsService loginUserDetailsService;
+ private final CustomAuthenticationEntryPoint customAuthenticationEntryPoint;
+
+ @Bean
+ public BCryptPasswordEncoder passwordEncoder() {
+ return new BCryptPasswordEncoder();
+ }
+
+ @Bean
+ public AuthenticationProvider authenticationProvider() {
+ DaoAuthenticationProvider provider = new DaoAuthenticationProvider();
+ provider.setUserDetailsService(loginUserDetailsService);
+ provider.setPasswordEncoder(passwordEncoder());
+ return provider;
+ }
+
+ @Bean
+ public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception {
+ return config.getAuthenticationManager();
+ }
+
+ @Bean
+ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
+ return http
+ .cors(cors -> cors.configurationSource(corsConfigurationSource()))
+ .csrf(AbstractHttpConfigurer::disable)
+ .sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
+ .headers(headers -> headers
+ // X-Frame-Options: 클릭재킹 공격 방지
+ .frameOptions(frameOptions -> frameOptions.deny())
+ // X-Content-Type-Options: MIME 타입 스니핑 방지
+ .contentTypeOptions(contentTypeOptions -> {})
+ // X-XSS-Protection: XSS 공격 방지
+ .xssProtection(xssProtection -> {})
+ // HSTS: HTTPS 강제 (2년으로 연장)
+ .httpStrictTransportSecurity(hstsConfig -> hstsConfig
+ .maxAgeInSeconds(63072000) // 2년
+ )
+ // Referrer Policy: 리퍼러 정보 제한
+ .referrerPolicy(referrer -> referrer.policy(ReferrerPolicyHeaderWriter.ReferrerPolicy.STRICT_ORIGIN_WHEN_CROSS_ORIGIN))
+ )
+ .authorizeHttpRequests(auth -> auth
+ // 인증 필요 없는 엔드포인트
+ .requestMatchers("/api/auth/**", "/api/user/register", "/api/user/privacy-agreement/**").permitAll()
+
+ // LEADER 전용 API (팀 관리)
+ .requestMatchers("/api/team/**").hasAnyRole("LEADER", "MEMBER")
+
+ // MEMBER 이상 접근 가능 (예시)
+ .requestMatchers("/api/member/**").hasAnyRole("MEMBER", "LEADER")
+
+ // 그 외 요청은 인증 필요
+ .anyRequest().authenticated()
+ )
+ .authenticationProvider(authenticationProvider())
+ .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
+ .exceptionHandling(ex -> ex.authenticationEntryPoint(customAuthenticationEntryPoint))
+ .build();
+ }
+
+
+ @Bean
+ public CorsConfigurationSource corsConfigurationSource() {
+ CorsConfiguration config = new CorsConfiguration();
+ config.setAllowedOrigins(List.of(
+ "http://localhost:3000",
+ "http://localhost:5173",
+ "http://127.0.0.1:3000",
+ "http://127.0.0.1:5173",
+ "http://3.39.54.128:8080" // 실제 서버 IP로 변경 필요
+ ));
+ config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
+ config.setAllowedHeaders(List.of("*"));
+ config.setAllowCredentials(true); // 쿠키 허용
+ UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
+ source.registerCorsConfiguration("/**", config);
+ return source;
+ }
+}
diff --git a/src/main/java/com/ccapp/ccgo/exception/CustomException.java b/src/main/java/com/ccapp/ccgo/common/exception/CustomException.java
similarity index 91%
rename from src/main/java/com/ccapp/ccgo/exception/CustomException.java
rename to src/main/java/com/ccapp/ccgo/common/exception/CustomException.java
index 24b2320..89e4d37 100644
--- a/src/main/java/com/ccapp/ccgo/exception/CustomException.java
+++ b/src/main/java/com/ccapp/ccgo/common/exception/CustomException.java
@@ -1,4 +1,4 @@
-package com.ccapp.ccgo.exception;
+package com.ccapp.ccgo.common.exception;
import org.springframework.http.HttpStatus;
import lombok.Getter;
diff --git a/src/main/java/com/ccapp/ccgo/exception/GlobalExceptionHandler.java b/src/main/java/com/ccapp/ccgo/common/exception/GlobalExceptionHandler.java
similarity index 67%
rename from src/main/java/com/ccapp/ccgo/exception/GlobalExceptionHandler.java
rename to src/main/java/com/ccapp/ccgo/common/exception/GlobalExceptionHandler.java
index e6be98f..fefffcf 100644
--- a/src/main/java/com/ccapp/ccgo/exception/GlobalExceptionHandler.java
+++ b/src/main/java/com/ccapp/ccgo/common/exception/GlobalExceptionHandler.java
@@ -1,4 +1,4 @@
-package com.ccapp.ccgo.exception;
+package com.ccapp.ccgo.common.exception;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
@@ -7,8 +7,11 @@
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.context.support.DefaultMessageSourceResolvable;
+
+import java.util.HashMap;
import java.util.stream.Collectors;
import org.springframework.security.authentication.BadCredentialsException;
+import org.springframework.dao.InvalidDataAccessResourceUsageException;
import java.util.Map;
@Slf4j
@@ -47,4 +50,19 @@ public ResponseEntity