Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
e9c8a3d
feat(app.env): `get()` - 환경변수를 가져오기위한 유틸리티 함수 작성
hepheir Nov 29, 2025
19c0bd6
feat(app.env): .env 파일을 사용하여 환경변수를 불러올 수 있는 `load()` 함수 추가
hepheir Nov 29, 2025
9866419
test(app.env): `get()` 함수에 대한 단위테스트 작성
hepheir Nov 29, 2025
8d2a53f
chore: install dotenv (`python-dotenv`)
hepheir Nov 29, 2025
0a9a9da
feat(app.env): `get_bool()` - 환경변수로 부터 truthy, falsy 한 값을 bool 타입으로 가…
hepheir Nov 29, 2025
98648fa
feat(app.settings): `DEBUG` 설정을 환경변수에서 가져오도록 수정
hepheir Nov 29, 2025
1eb40ef
feat(app.env): `get_json()` - 환경변수의 JSON 값을 파싱하여 반환하는 함수 작성
hepheir Nov 29, 2025
7557506
test(app.env): `env.get_json()` 함수에 대한 단위테스트 작성
hepheir Nov 29, 2025
8990a0f
feat(app.env): `get_file_content()` - 환경변수에 명시된 파일에서 값을 읽어오는 함수 작성
hepheir Nov 29, 2025
d8b0d23
test(app.env): `get_file_content()` 함수에 대한 단위테스트 작성
hepheir Nov 29, 2025
fa942c0
feat(app.settings): `SECRET_KEY`가 설정되지 않은 경우 `SECRET_KEY_FILE`에 명시된 파…
hepheir Nov 29, 2025
3db6249
refactor(app.env): JSON 값에 대한 타입 힌트 단순화
hepheir Nov 29, 2025
c9d5023
feat(app.settings): `ALLOWED_HOSTS`를 환경변수로 부터 받도록 수정
hepheir Nov 29, 2025
902b3fe
chore: add sample .env file for development and testing
hepheir Nov 30, 2025
3ebafc3
refactor(app.settings): `SECRET_KEY` 혹은 `SECRET_KEY_FILE` 환경변수 설정을 안내…
hepheir Nov 30, 2025
d84c9cb
doc: README.md 의 환경변수 섹션 추가 및 설명 보강
hepheir Nov 29, 2025
b0a569f
refactor(app.env): deprecate `get_file_content()`
hepheir Dec 3, 2025
6f0b39b
feat(app.env): `get_path()` - 환경변수로 부터 파일 경로를 파싱하여 반환하는 함수 추가
hepheir Dec 3, 2025
618f3bb
refactor(app.settings): `SECRET_KEY_FILE`를 가져올 때 fail-fast 하도록 코드 변경
hepheir Dec 3, 2025
59b02e3
test(app.env): `get_path()` 에 대한 단위 테스트 추가
hepheir Dec 3, 2025
3a7259f
refactor(app.settings): `env.get_file_content()` 의존성 제거
hepheir Dec 3, 2025
9107090
refactor(app.env): remove deprecated `get_file_content()` function an…
hepheir Dec 3, 2025
a8a2a5e
chore(app.settings): add TODO comment to use PostgreSQL for production
hepheir Dec 3, 2025
d7f68b9
refactor(app.settings): 디버깅을 위해 `SECRET_KEY_FILE` 오류 메시지에 실제 파일 경로 출력
hepheir Dec 3, 2025
2843ba3
test(app.env): `load()` 함수에 대한 테스트 작성
hepheir Dec 4, 2025
3e8a6ad
refactor(app.settings): `ALLOWED_HOSTS` 가 `DEBUG`가 아닐때는 필수인 것으로 변경
hepheir Dec 4, 2025
aab2a3c
refactor(app.env): improve `get_path()` error messages
hepheir Dec 4, 2025
08174f2
refactor(app.settings): `SECRET_KEY_FILE` 에 대한 예외 처리 보강
hepheir Dec 4, 2025
3c189ff
chore(app.settings): `ALLOWED_HOSTS`의 default값을 DEBUG 모드에서만 '*'로 변경
hepheir Dec 4, 2025
48e28d8
test(app.env): 테스트 파일 중 절대 경로를 OS에 영향을 받지 않는 것으로 대체함
hepheir Dec 4, 2025
70c9545
refactor(app.settings): update `ALLOWED_HOSTS` default values for dev…
hepheir Dec 4, 2025
2b52476
chore: update note on path handling for cross-platform compatibility …
hepheir Dec 4, 2025
70ee7f3
refactor(app.settings): ensure `SECRET_KEY` is read with UTF-8 encodi…
hepheir Dec 4, 2025
e6fde3b
test(app.env): 환경 변수 cleanup을 @patch.dict 패턴을 사용하도록 변경
hepheir Dec 4, 2025
2b95446
chore: add comments for `ALLOWED_HOSTS` in sample .env file
hepheir Dec 5, 2025
f62251b
refactor(app.env): simplify truthy and falsy value checks
hepheir Dec 5, 2025
af50d2a
test(app.env): `get_bool()` 함수에 대한 단위테스트 추가
hepheir Nov 29, 2025
0ca9cdc
docs: update `DEBUG` 설명 for 일관성 유지 "Optional" -> "선택 사항"
hepheir Dec 5, 2025
fff363d
choe(app.settings): `env.get_path()`에 `relative_to`를 적용하지 않는 이유 명시
hepheir Dec 5, 2025
98314ea
ci: add environment variables for `SECRET_KEY` and `ALLOWED_HOSTS` in…
hepheir Dec 5, 2025
a8b1cb6
feat(app.settings): .env 환경변수를 lazy load 하도록 변경
hepheir Nov 29, 2025
86399eb
feat(app.settings): `SECRET_KEY` 를 환경변수에서 가져오도록 변경
hepheir Nov 29, 2025
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
3 changes: 3 additions & 0 deletions .github/workflows/django.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ jobs:
python -m pip install --upgrade pip
pip install -r requirements.txt
- name: Run Tests
env:
SECRET_KEY: "django-insecure-0000-temporal-secret-key-for-testing-0000"
ALLOWED_HOSTS: "[\"localhost\", \"127.0.0.1\", \"[::1]\"]"
run: |
cd app
python manage.py test
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,16 @@ TLE(Time Limit Exceeded)의 마이크로서비스 아키텍처를 위한 인증
- **회원가입/로그인**: 사용자 계정 관리
- **API 문서화**: Swagger UI 제공
- **마이크로서비스**: 다른 서비스와의 JWT 토큰 공유

## 개발 가이드

### 환경 변수

Docker compose를 사용하지 않고 직접 컨테이너를 실행할 경우 필요한 환경 변수입니다.

| 변수명 | 설명 | 기본값 | 필수 |
| ----------------- | -------------------------------------------------- | ------- | -------------------------------- |
| `SECRET_KEY` | Django 암호화 키 | | `SECRET_KEY_FILE` 미설정 시 필수 |
| `SECRET_KEY_FILE` | Django 암호화 키가 저장된 파일 | | `SECRET_KEY` 미설정 시 필수 |
| `DEBUG` | 디버그 모드 활성 여부 | `False` | 선택 사항 |
| `ALLOWED_HOSTS` | 허용된 호스트명 (JSON 배열, 예: `["example.com"]`) | | 디버그 모드가 아니면 필수 |
12 changes: 12 additions & 0 deletions app/.env.sample
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# Sample .env file for development and testing purposes

# Set DEBUG to true for development, false for production
DEBUG=false
Copy link

Copilot AI Dec 5, 2025

Choose a reason for hiding this comment

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

The sample shows DEBUG=false but the comment says "Set DEBUG to true for development". This is confusing - a sample file for development should probably have DEBUG=true by default, or the comment should clarify why false is recommended even for development.

Suggested change
DEBUG=false
DEBUG=true

Copilot uses AI. Check for mistakes.

# Django secret key for cryptographic signing
# NOTE: Python handles forward slashes correctly on all platforms, including Windows.
SECRET_KEY_FILE=../.secrets/secret_key.txt
# SECRET_KEY='<YOUR_SECRET_KEY>'

# Allowed hosts for the Django application
# ALLOWED_HOSTS=["example.com", "www.example.com"]
223 changes: 223 additions & 0 deletions app/app/env.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,223 @@
"""
환경 변수와 관련된 기능 혹은 유틸리티 모음.
"""

import json
import os
from pathlib import Path
from typing import Any, Optional, Union

import dotenv

# Define truthy and falsy values
_TRUTHY_VALUES = ('true', '1', 't', 'y', 'yes', 'on')
_FALSY_VALUES = ('false', '0', 'f', 'n', 'no', 'off')


def load(dotenv_path: Optional[Path] = None):
""".env 파일이 있다면 .env 파일에서 환경 변수를 로드합니다.
Args:
dotenv_path: .env 파일 경로 (기본값: None)
"""
# Load .env file if it exists
if (dotenv_path is not None) and dotenv_path.exists():
dotenv.load_dotenv(dotenv_path=dotenv_path, override=True)


def get(key: str, default: Optional[str] = None, required: bool = False, strip: bool = True, blank: bool = False) -> Optional[str]:
"""환경 변수 값을 가져옵니다.
Args:
key: 환경 변수 이름
default: 기본값 (환경 변수가 없을 때 반환)
required: 필수 여부 (True일 때 환경 변수가 없으면 예외 발생)
strip: 값의 앞뒤 공백 제거 여부 (기본값: True)
blank: 빈 문자열 허용 여부 (False일 때 빈 문자열은 None으로 간주)
Returns:
환경 변수 값 또는 기본값
Raises:
ValueError: required=True이고 환경 변수가 설정되지 않은 경우
"""
string_value = os.getenv(key, default)

if strip and (string_value is not None):
string_value = string_value.strip()

if not blank and (string_value == ''):
string_value = None

if required and (string_value is None):
raise ValueError(
f'Environment variable "{key}" is required but not set.'
)

return string_value


def get_bool(key: str, default: Optional[bool] = None, strip: bool = True, required: bool = False) -> Optional[bool]:
"""환경 변수를 불린 값으로 파싱합니다.
Args:
key: 환경 변수 이름
default: 기본값 (환경 변수가 없을 때 반환)
strip: 앞뒤 공백 제거 여부 (기본값: True)
required: 필수 여부 (True일 때 환경 변수가 없으면 예외 발생)
Returns:
불린 값 또는 기본값
Raises:
ValueError: 잘못된 불린 값이거나 required=True이고 환경 변수가 설정되지 않은 경우
"""
raw_value = os.getenv(key)
boolean_value = default

if raw_value is not None:
if _is_truthy(raw_value, strip=strip):
boolean_value = True
elif _is_falsy(raw_value, strip=strip):
boolean_value = False
else:
raise ValueError(
f'Environment variable "{key}" has invalid boolean value.'
)

if required and (boolean_value is None):
raise ValueError(
f'Environment variable "{key}" is required but not set.'
)

return boolean_value


def get_json(key: str, default: Optional[Any] = None, required: bool = False) -> Optional[Any]:
"""환경 변수 값을 JSON으로 파싱하여 반환하거나 기본값을 반환합니다.
Args:
key: 환경 변수 이름
default: 기본값 (환경 변수가 없을 때 반환)
required: 필수 여부 (True일 때 환경 변수가 없으면 예외 발생)
Returns:
JSON으로 파싱된 값 또는 기본값
Raises:
ValueError: 환경 변수 값이 올바른 JSON이 아니거나 required=True이고 환경 변수가 설정되지 않은 경우
"""
raw_value = os.getenv(key)
Copy link

Copilot AI Dec 5, 2025

Choose a reason for hiding this comment

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

[nitpick] The get_json function is missing the ability to handle the strip parameter like other getter functions. If the JSON value has leading/trailing whitespace, it could cause parsing errors. While JSON parsing typically handles whitespace, adding a strip parameter for consistency with other functions would be beneficial.

Consider adding:

def get_json(key: str, default: Optional[Any] = None, required: bool = False, strip: bool = True) -> Optional[Any]:
    raw_value = os.getenv(key)
    if raw_value is not None and strip:
        raw_value = raw_value.strip()
    # ... rest of the function

Copilot uses AI. Check for mistakes.
Copy link
Member Author

@hepheir hepheir Dec 5, 2025

Choose a reason for hiding this comment

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

단순 consistency 때문에 get_json에 strip 인자를 추가해서 불필요한 관리포인트를 늘리는게 더 큰 코드 부채로 다가올 것 같아요.

json_value = default

if raw_value is not None:
try:
json_value = json.loads(raw_value)
except ValueError as e:
Copy link

Copilot AI Dec 5, 2025

Choose a reason for hiding this comment

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

[nitpick] The get_json() function catches ValueError but json.loads() can also raise json.JSONDecodeError, which is a subclass of ValueError. While this works, it's more explicit and clear to catch json.JSONDecodeError specifically. This improves code readability and makes the intent clearer.

Suggested change
except ValueError as e:
except json.JSONDecodeError as e:

Copilot uses AI. Check for mistakes.
Copy link
Member Author

Choose a reason for hiding this comment

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

불필요한 예외처리 분기의 증가를 막고자 의도적으로 JSONDecodeError의 상위 클래스인 ValueError로 예외처리 한 것이므로 유지하겠습니다.

raise ValueError(
f'Environment variable "{key}" has invalid JSON value.'
Copy link

Copilot AI Dec 4, 2025

Choose a reason for hiding this comment

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

The error message is vague and doesn't specify what went wrong with the JSON parsing. The original exception message from json.loads() contains valuable debugging information. Consider including it in the error message or using str(e) to provide more context.

Suggested improvement:

raise ValueError(
    f'Environment variable "{key}" has invalid JSON value: {str(e)}'
) from e
Suggested change
f'Environment variable "{key}" has invalid JSON value.'
f'Environment variable "{key}" has invalid JSON value: {str(e)}'

Copilot uses AI. Check for mistakes.
) from e

if required and (json_value is None):
raise ValueError(
f'Environment variable "{key}" is required but not set.'
)

return json_value


def get_path(key: str, default: Optional[Path] = None, required: bool = False, relative_to: Optional[Union[Path, str]] = None) -> Optional[Path]:
"""환경 변수에 지정된 파일 경로를 Path 객체로 반환합니다.
Args:
key: 파일 경로가 저장된 환경 변수 이름
default: 기본값 (환경 변수가 없을 때 반환)
required: 필수 여부 (True일 때 환경 변수가 없으면 예외 발생)
relative_to: 파일 탐색을 허용할 최상위 경로.
이 값이 None이면 모든 경로가 허용됩니다.
(기본값: None)
Returns:
파일 경로 또는 기본값
Raises:
ValueError: 환경 변수 값이 유효하지 않거나 required=True이고 환경 변수가 설정되지 않은 경우
"""
raw_path = os.getenv(key)
path = default

if raw_path is not None:
try:
path = Path(raw_path).resolve()
except OSError as e:
raise ValueError(
f'Environment variable "{key}" has invalid file path.'
Copy link

Copilot AI Dec 4, 2025

Choose a reason for hiding this comment

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

The error message is vague and doesn't provide information about what made the file path invalid. The original OSError contains the specific reason (e.g., permission denied, path too long, invalid characters). Consider including the original error message.

Suggested improvement:

raise ValueError(
    f'Environment variable "{key}" has invalid file path: {str(e)}'
) from e
Suggested change
f'Environment variable "{key}" has invalid file path.'
f'Environment variable "{key}" has invalid file path: {str(e)}'

Copilot uses AI. Check for mistakes.
) from e

# 파일 경로가 허용 탐색 범위 이내인지 검사
if relative_to and (path is not None):
try:
relative_to_path = Path(relative_to).resolve()
except OSError as e:
raise ValueError(
f'Invalid "relative_to" parameter value.'
) from e

# 디렉토리인지 검증
if not relative_to_path.is_dir():
raise ValueError(
f'The "relative_to" parameter must be a directory, got: {relative_to_path}'
)

try:
path.relative_to(relative_to_path)
except ValueError:
raise ValueError(
f'Path specified in environment variable "{key}" is outside '
f'the allowed directory "{relative_to_path}".'
)

if required and (path is None):
raise ValueError(
f'Environment variable "{key}" is required but not set.'
)

return path
Comment on lines +129 to +185
Copy link

Copilot AI Dec 5, 2025

Choose a reason for hiding this comment

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

The get_path function lacks validation to prevent potential security issues with file permissions. When reading from SECRET_KEY_FILE, there's no check to ensure the file has appropriate permissions (e.g., not world-readable). Consider adding a permission check in get_path or documenting that callers should verify file permissions when dealing with sensitive data.

Copilot uses AI. Check for mistakes.


def _is_truthy(value: Optional[str], strip: bool = True) -> bool:
"""문자열이 참 값인지 확인합니다.
Args:
value: 확인할 문자열
strip: 앞뒤 공백 제거 여부 (기본값: True)
Returns:
참 값 여부 (true, 1, t, y, yes, on 중 하나인 경우 True)
"""
if strip and (value is not None):
value = value.strip()

if value is None:
return False

return value.lower() in _TRUTHY_VALUES


def _is_falsy(value: Optional[str], strip: bool = True) -> bool:
"""문자열이 거짓 값인지 확인합니다.
Args:
value: 확인할 문자열
strip: 앞뒤 공백 제거 여부 (기본값: True)
Returns:
거짓 값 여부 (false, 0, f, n, no, off 중 하나인 경우 True)
"""
if strip and (value is not None):
value = value.strip()

if value is None:
return False

return value.lower() in _FALSY_VALUES
46 changes: 43 additions & 3 deletions app/app/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,20 +12,59 @@

from pathlib import Path

from app import env

# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent


env.load(dotenv_path=BASE_DIR / '.env')


# Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/5.2/howto/deployment/checklist/

# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = 'django-insecure-^&757(fcj_5idk9lfj5%mc#5ozvfoigka&#z73lxc_(!4j_e$o'
secret_key = env.get("SECRET_KEY")
secret_key_file = env.get_path("SECRET_KEY_FILE")
# NOTE: path traversal attack에 대하여,
# Docker Compose를 구성할 경우, secret이 담긴 파일이 프로젝트 디렉터리 외부에 있는 경우도 있다.
# 따라서 relative_to 를 설정할 경우, "/run/secrets/*" 와 같은 경로에 접근을 못하게 될 가능성이 높다.
# 더군다나 위 코드는 최초 설정시에만 실행되기에 약점의 크기가 상당히 작다고 판단하여
# path traversal attack을 허용할 여지가 있더라도, 더 좋은 방법을 찾기 전까지는 relative_to를 설정하지 않는다.

if secret_key and secret_key_file:
raise ValueError("Cannot set both SECRET_KEY and SECRET_KEY_FILE")
elif secret_key:
SECRET_KEY = secret_key
Comment on lines +38 to +39
Copy link

Copilot AI Dec 5, 2025

Choose a reason for hiding this comment

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

The same minimum length validation for SECRET_KEY should also be applied when SECRET_KEY is provided directly (line 39), not just when reading from file. Consider validating SECRET_KEY length in both branches for consistency and security.

Copilot uses AI. Check for mistakes.
elif secret_key_file:
# NOTE: 명확한 에러 추적을 위해 파일 경로는 오류 메시지로 노출.
# 오류가 발생하면 애플리케이션 구동이 안되기에, 보안을 위해 키 값도 아닌,
# 경로 자체를 숨기는 것은 과하다고 판단함.
if not secret_key_file.exists():
raise ValueError(f"SECRET_KEY_FILE does not exist: {secret_key_file}")
if not secret_key_file.is_file():
raise ValueError(f"SECRET_KEY_FILE is not a file: {secret_key_file}")

Copy link

Copilot AI Dec 5, 2025

Choose a reason for hiding this comment

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

When reading SECRET_KEY from file, there's no size limit check before calling read_text(). A malicious or misconfigured file could cause memory exhaustion. Consider adding a reasonable size check: if secret_key_file.stat().st_size > 1024: raise ValueError(f"SECRET_KEY_FILE is too large: {secret_key_file}")

Suggested change
if secret_key_file.stat().st_size > 1024:
raise ValueError(f"SECRET_KEY_FILE is too large: {secret_key_file}")

Copilot uses AI. Check for mistakes.
# Fail-fast, 명확한 에러 추적을 위해 파일 읽기 중 오류는 예외 처리를 하지 않음.
SECRET_KEY = secret_key_file.read_text(encoding='utf-8').strip()
Copy link

Copilot AI Dec 5, 2025

Choose a reason for hiding this comment

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

The SECRET_KEY file is read without size validation, which could lead to memory issues if an attacker or misconfiguration points to a very large file. Consider adding a file size check before reading (e.g., if secret_key_file.stat().st_size > MAX_SECRET_KEY_SIZE: raise ValueError(...)) to prevent potential denial of service.

Copilot uses AI. Check for mistakes.

if not SECRET_KEY:
raise ValueError(f"SECRET_KEY_FILE is empty: {secret_key_file}")
else:
raise ValueError("Either SECRET_KEY or SECRET_KEY_FILE must be set")


# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = True
DEBUG = env.get_bool('DEBUG', default=False)


ALLOWED_HOSTS = []
# Allow all hosts during development, require explicit hosts in production.
if DEBUG:
ALLOWED_HOSTS = env.get_json('ALLOWED_HOSTS',
default=['localhost', '127.0.0.1', '[::1]'])
else:
ALLOWED_HOSTS = env.get_json('ALLOWED_HOSTS', required=True)
Copy link

Copilot AI Dec 5, 2025

Choose a reason for hiding this comment

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

Potential security issue: In production (when DEBUG=False), if the ALLOWED_HOSTS environment variable is not set, the application will crash with a ValueError. However, there's no validation that the provided ALLOWED_HOSTS is a non-empty list. An empty JSON array [] would pass the required=True check but would make the application reject all requests. Consider adding validation to ensure ALLOWED_HOSTS is not empty in production: if not DEBUG and not ALLOWED_HOSTS: raise ValueError("ALLOWED_HOSTS cannot be empty in production")

Suggested change
ALLOWED_HOSTS = env.get_json('ALLOWED_HOSTS', required=True)
ALLOWED_HOSTS = env.get_json('ALLOWED_HOSTS', required=True)
if not ALLOWED_HOSTS:
raise ValueError("ALLOWED_HOSTS cannot be empty in production")

Copilot uses AI. Check for mistakes.
Copy link
Member Author

Choose a reason for hiding this comment

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

#4 (comment) 반복하여 제안과 번복되고 있는 내용.



# Application definition
Expand Down Expand Up @@ -74,6 +113,7 @@
# Database
# https://docs.djangoproject.com/en/5.2/ref/settings/#databases

# TODO: Use PostgreSQL for production.
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
Expand Down
Loading