Skip to content
Merged
Show file tree
Hide file tree
Changes from 16 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
4 changes: 4 additions & 0 deletions .github/PULL_REQUEST_TEMPLATE.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,9 @@

## ✨ 상세 설명

## 🛠️ 추후 리팩토링 및 고도화 계획

## 📸 스크린샷 (선택)

## 💬 리뷰 요구사항
<!-- 특별히 확인했으면 하는 부분, 궁금한 사항 -->
23 changes: 22 additions & 1 deletion build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ repositories {
mavenCentral()
}

ext {
set('springCloudVersion', "2023.0.1")
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

dependencies {
// Spring Boot Starters
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
Expand All @@ -33,7 +37,7 @@ dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'

// Swagger
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.3.0'
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.6.0'

// Lombok
compileOnly 'org.projectlombok:lombok'
Expand All @@ -46,6 +50,23 @@ dependencies {
// Test
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'

// FeignClient
implementation 'org.springframework.cloud:spring-cloud-starter-openfeign'

// JWT
implementation 'io.jsonwebtoken:jjwt-api:0.13.0'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.13.0'
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.13.0'

// Redis
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
}

dependencyManagement {
imports {
mavenBom "org.springframework.cloud:spring-cloud-dependencies:${springCloudVersion}"
}
}

tasks.named('test') {
Expand Down
16 changes: 15 additions & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,5 +12,19 @@ services:
PROD_DB_URL: ${PROD_DB_URL}
PROD_DB_USERNAME: ${PROD_DB_USERNAME}
PROD_DB_PASSWORD: ${PROD_DB_PASSWORD}
SPRING_DATA_REDIS_HOST: redis
SPRING_DATA_REDIS_PORT: 6379
ports:
- "8080:8080"
- "8080:8080"
depends_on:
- redis

redis:
image: redis:7-alpine
container_name: ongil-redis
ports:
- "6379:6379"
restart: always
volumes:
- ./redis_data:/data
command: ["redis-server", "--appendonly", "yes"]
Comment on lines +22 to +30
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.

⚠️ Potential issue | 🟠 Major

Redis 인증이 구성되지 않아 보안 위험이 있습니다.

Redis 서비스가 비밀번호 없이 구성되어 있으며, 포트 6379가 호스트에 직접 노출되어 있습니다. 이는 프로덕션 환경에서 심각한 보안 위험을 초래합니다:

  • 인증 없이 누구나 Redis에 접근 가능
  • 리프레시 토큰과 같은 민감한 세션 데이터가 노출될 수 있음
  • 외부 공격자가 Redis 명령어를 실행할 수 있음
🔐 Redis 인증 및 보안 강화 설정
  redis:
    image: redis:7-alpine
    container_name: ongil-redis
    ports:
-     - "6379:6379"
+     - "127.0.0.1:6379:6379"  # 로컬호스트만 접근 가능하도록 제한
    restart: always
    volumes:
      - ./redis_data:/data
-   command: ["redis-server", "--appendonly", "yes"]
+   command: 
+     - redis-server
+     - --appendonly yes
+     - --requirepass ${REDIS_PASSWORD}
+     - --maxmemory 256mb
+     - --maxmemory-policy allkeys-lru

그리고 backend 서비스에 Redis 비밀번호 환경 변수를 추가하세요:

    environment:
      SPRING_PROFILES_ACTIVE: prod
      PROD_DB_URL: ${PROD_DB_URL}
      PROD_DB_USERNAME: ${PROD_DB_USERNAME}
      PROD_DB_PASSWORD: ${PROD_DB_PASSWORD}
      SPRING_DATA_REDIS_HOST: redis
      SPRING_DATA_REDIS_PORT: 6379
+     SPRING_DATA_REDIS_PASSWORD: ${REDIS_PASSWORD}

.env 파일에 REDIS_PASSWORD 추가:

REDIS_PASSWORD=your_secure_password_here
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
redis:
image: redis:7-alpine
container_name: ongil-redis
ports:
- "6379:6379"
restart: always
volumes:
- ./redis_data:/data
command: ["redis-server", "--appendonly", "yes"]
redis:
image: redis:7-alpine
container_name: ongil-redis
ports:
- "127.0.0.1:6379:6379" # 로컬호스트만 접근 가능하도록 제한
restart: always
volumes:
- ./redis_data:/data
command:
- redis-server
- --appendonly yes
- --requirepass ${REDIS_PASSWORD}
- --maxmemory 256mb
- --maxmemory-policy allkeys-lru
🤖 Prompt for AI Agents
In @docker-compose.yml around lines 22 - 30, The redis service (container_name
ongil-redis) is exposed without authentication and maps host port 6379, which is
insecure; update docker-compose to require a password by adding an environment
variable REDIS_PASSWORD (populate it in .env), set the redis service environment
to read that value, and modify the service command (currently ["redis-server",
"--appendonly", "yes"]) to include the requirepass flag (e.g., --requirepass
"$REDIS_PASSWORD") or point to a conf file that uses requirepass; also avoid
exposing port 6379 publicly (remove or restrict the ports mapping or bind it to
127.0.0.1) and ensure the backend service is updated to use REDIS_PASSWORD for
authenticated connections.

2 changes: 2 additions & 0 deletions src/main/java/com/ongil/backend/Application.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.openfeign.EnableFeignClients;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;

@EnableJpaAuditing
@SpringBootApplication
@EnableFeignClients
public class Application {

public static void main(String[] args) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package com.ongil.backend.domain.auth.client.google;

import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestHeader;

import com.ongil.backend.domain.auth.dto.response.GoogleUserInfoResDto;

@FeignClient(name = "googleApiClient", url = "https://www.googleapis.com")
public interface GoogleApiClient {
@GetMapping("/oauth2/v3/userinfo")
GoogleUserInfoResDto getUserInfo(@RequestHeader("Authorization") String accessToken);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package com.ongil.backend.domain.auth.client.google;

import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;

import com.ongil.backend.domain.auth.dto.response.GoogleTokenResDto;

@FeignClient(name = "googleAuthClient", url = "https://oauth2.googleapis.com")
public interface GoogleAuthClient {
@PostMapping(value = "/token", consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE)
GoogleTokenResDto getAccessToken(
@RequestParam("grant_type") String grantType,
@RequestParam("client_id") String clientId,
@RequestParam("client_secret") String clientSecret,
@RequestParam("redirect_uri") String redirectUri,
@RequestParam("code") String code
);
Comment thread
marshmallowing marked this conversation as resolved.
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package com.ongil.backend.domain.auth.client.kakao;


import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestHeader;

import com.ongil.backend.domain.auth.dto.response.KakaoUserInfoResDto;

// 유저 정보/API용 (kapi.kakao.com)
@FeignClient(name = "kakaoApiClient", url = "https://kapi.kakao.com")
public interface KakaoApiClient {
@GetMapping("/v2/user/me")
KakaoUserInfoResDto getUserInfo(@RequestHeader("Authorization") String bearerToken);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package com.ongil.backend.domain.auth.client.kakao;

import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;

import com.ongil.backend.domain.auth.dto.response.KakaoTokenResDto;

// 인증/토큰용 (kauth.kakao.com)
@FeignClient(name = "kakaoAuthClient", url = "https://kauth.kakao.com")
public interface KakaoAuthClient {
@PostMapping(value = "/oauth/token", consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE)
KakaoTokenResDto getAccessToken(
@RequestParam("grant_type") String grantType,
@RequestParam("client_id") String clientId,
@RequestParam("redirect_uri") String redirectUri,
@RequestParam("code") String code,
@RequestParam("client_secret") String clientSecret
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
package com.ongil.backend.domain.auth.controller;

import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

import com.ongil.backend.domain.auth.dto.request.TokenRefreshReqDto;
import com.ongil.backend.domain.auth.dto.response.AuthResDto;
import com.ongil.backend.domain.auth.dto.response.TokenRefreshResDto;
import com.ongil.backend.domain.auth.service.AuthService;
import com.ongil.backend.domain.auth.service.GoogleLoginService;
import com.ongil.backend.domain.auth.service.KakaoLoginService;
import com.ongil.backend.global.common.dto.DataResponse;

import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import lombok.RequiredArgsConstructor;

@Validated
@RestController
@RequiredArgsConstructor
@RequestMapping("/auth")
public class AuthController {

private final AuthService authService;
private final KakaoLoginService kakaoLoginService;
private final GoogleLoginService googleLoginService;

@PostMapping("/oauth/kakao")
@Operation(summary = "카카오 회원가입/로그인 API", description = "인가코드(code)로 카카오 토큰 교환 후, 우리 서비스 JWT 발급")
public ResponseEntity<DataResponse<AuthResDto>> kakaoLogin(
@Valid @RequestParam("code") @NotBlank String code
) {
AuthResDto res = kakaoLoginService.kakaoLogin(code);
return ResponseEntity.ok(DataResponse.from(res));
}

@GetMapping("/oauth/google")
@Operation(summary = "구글 회원가입/로그인 API", description = "인가코드(code)로 구글 토큰 교환 후, 우리 서비스 JWT 발급")
public ResponseEntity<DataResponse<AuthResDto>> googleLogin(
@Valid @RequestParam("code") @NotBlank String code
) {
AuthResDto res = googleLoginService.googleLogin(code);
return ResponseEntity.ok(DataResponse.from(res));
}

@PostMapping("/token/refresh")
@Operation(summary = "Access/Refresh Token 재발급 API", description = "만료된 accessToken을 refreshToken을 통해 재발급")
public ResponseEntity<DataResponse<TokenRefreshResDto>> refresh(
@Valid @RequestBody TokenRefreshReqDto request
) {
TokenRefreshResDto res = authService.refreshAccessToken(request.refreshToken());
return ResponseEntity.ok(DataResponse.from(res));
}

@PostMapping("/logout")
@Operation(summary = "로그아웃 API", description = "Redis에 저장된 리프레시 토큰을 삭제하여 로그아웃 처리")
public ResponseEntity<DataResponse<String>> logout(
@Parameter(hidden = true) @AuthenticationPrincipal Long userId
) {
authService.logout(userId);
return ResponseEntity.ok(DataResponse.from("로그아웃 되었습니다."));
}
Comment thread
marshmallowing marked this conversation as resolved.

@DeleteMapping("/withdraw")
@Operation(summary = "회원 탈퇴 API", description = "계정 삭제 및 리프레시 토큰 파기")
public ResponseEntity<DataResponse<String>> withdraw(
@Parameter(hidden = true) @AuthenticationPrincipal Long userId
) {
authService.withdraw(userId);
return ResponseEntity.ok(DataResponse.from("회원 탈퇴가 완료되었습니다."));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package com.ongil.backend.domain.auth.converter;

import com.ongil.backend.domain.auth.dto.response.AuthResDto;
import com.ongil.backend.domain.user.entity.User;

import lombok.experimental.UtilityClass;

@UtilityClass
public class AuthConverter {

public static AuthResDto toResponse(User user, String accessToken, String refreshToken,
boolean isNewUser, long accessExpMs) {
return AuthResDto.builder()
.userId(user.getId())
.accessToken(accessToken)
.refreshToken(refreshToken)
.loginType(user.getLoginType())
.isNewUser(isNewUser)
.expires_in((int) (accessExpMs / 1000)) // 초 단위
.build();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.ongil.backend.domain.auth.dto.request;

import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotNull;

public record TokenRefreshReqDto(
@Schema(description = "리프레시토큰")
@NotNull
String refreshToken
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package com.ongil.backend.domain.auth.dto.response;

import com.ongil.backend.domain.auth.entity.LoginType;

import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotNull;
import lombok.Builder;

@Builder
public record AuthResDto(

@Schema(description = "유저아이디")
Long userId,

@Schema(description = "액세스토큰")
@NotNull
String accessToken,

@Schema(description = "리프레시토큰")
@NotNull
String refreshToken,

@Schema(description = "로그인 타입")
@NotNull
LoginType loginType,

@Schema(description = "회원가입 여부(첫 로그인 여부)")
@NotNull
Boolean isNewUser,

@Schema(description = "액세스 토큰 만료 시간")
@NotNull
Integer expires_in
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package com.ongil.backend.domain.auth.dto.response;

import com.fasterxml.jackson.annotation.JsonProperty;

public record GoogleTokenResDto(
@JsonProperty("access_token") String accessToken,
@JsonProperty("expires_in") Integer expiresIn,
@JsonProperty("token_type") String tokenType,
@JsonProperty("id_token") String idToken
) {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.ongil.backend.domain.auth.dto.response;

public record GoogleUserInfoResDto(
String sub, // 구글의 고유 식별자 (카카오의 id 역할)
String name,
String email,
String picture
) {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package com.ongil.backend.domain.auth.dto.response;

import com.fasterxml.jackson.annotation.JsonProperty;

public record KakaoTokenResDto(
@JsonProperty("access_token") String accessToken,
@JsonProperty("token_type") String tokenType,
@JsonProperty("refresh_token") String refreshToken,
@JsonProperty("expires_in") Integer expiresIn,
@JsonProperty("scope") String scope,
@JsonProperty("refresh_token_expires_in") Integer refreshTokenExpiresIn
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package com.ongil.backend.domain.auth.dto.response;

import com.fasterxml.jackson.annotation.JsonProperty;

public record KakaoUserInfoResDto(
Long id,
@JsonProperty("kakao_account") KakaoAccount kakaoAccount
) {
public record KakaoAccount(
String email,
Profile profile
) {
public record Profile(
String nickname,
@JsonProperty("thumbnail_image_url") String profileImg
) {}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.ongil.backend.domain.auth.dto.response;

public record TokenRefreshResDto(
String accessToken,
String refreshToken
) {
}
Loading