23 Commits

Author SHA1 Message Date
1a0996c146 v0.0.34
All checks were successful
Build And Test / build-and-push (push) Successful in 2m20s
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 02:08:03 +09:00
42aca6cfe3 update
All checks were successful
Build And Test / build-and-push (push) Successful in 2m35s
2026-01-23 02:51:45 +09:00
b387a06394 update
Some checks failed
Build And Test / build-and-push (push) Has been cancelled
2026-01-23 02:51:21 +09:00
86064d48f9 v0.0.32 | LogoutView 추가 및 OpenTelemetry 확장
All checks were successful
Build And Test / build-and-push (push) Successful in 2m21s
- LogoutView 추가: refresh token 블랙리스트 처리
- OpenTelemetry instrumentation 확장: requests, logging, dbapi
- TRACE_CA_CERT TLS 지원 추가

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 02:40:27 +09:00
ca43c73dfb settings.py: GOOGLE_CLIENT_ID 환경변수 설정
Some checks failed
Build And Test / build-and-push (push) Failing after 2m24s
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-19 22:02:17 +09:00
6b4d38ad5f 비밀번호 정책 기능 구현
Some checks failed
Build And Test / build-and-push (push) Failing after 2m49s
- RegisterSerializer에 비밀번호 정책 검증 추가
- ExtendPasswordExpiryView: 비밀번호 유효기간 연장 API
- CustomTokenObtainPairSerializer: 로그인 시 만료/잠금 검증
- password_utils.py: 정책 검증, 계정 잠금, 만료 체크 유틸리티
- SiteSettings 모델에 비밀번호 정책 필드 추가

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-19 21:35:46 +09:00
1c7f241b37 v0.0.31 | 비밀번호 변경 기능 추가
All checks were successful
Build And Test / build-and-push (push) Successful in 3m38s
- ChangePasswordView API 추가 (사용자 본인 비밀번호 변경)
- 소셜 로그인 계정 비밀번호 설정 지원
- 관리자 비밀번호 초기화 기능 (UserUpdateView)
- RegisterSerializer에 has_password 필드 추가

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-19 00:26:53 +09:00
d6bec2c883 v0.0.30 | Google 계정 승인 대기 및 등급 변경 기능
All checks were successful
Build And Test / build-and-push (push) Successful in 2m48s
- Google 소셜 로그인 신규 가입 시 관리자 승인 대기 상태로 변경
- UserUpdateView에 등급(grade) 변경 기능 추가
- admin 등급 부여는 admin만 가능하도록 제한
- 자기 자신의 admin 등급 하향 방지

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-19 00:15:34 +09:00
03f7ad94a9 v0.0.29 | 사이트 설정(SiteSettings) 모델 및 API 추가
All checks were successful
Build And Test / build-and-push (push) Successful in 2m1s
- Google 로그인 활성화 여부 관리 기능
- 관리자 전용 설정 수정 API
- 싱글톤 패턴으로 구현

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-18 23:39:23 +09:00
96615e4b94 v0.0.28 | Google 로그인 JWT 토큰에 커스텀 클레임 추가
All checks were successful
Build And Test / build-and-push (push) Successful in 2m45s
- GoogleLoginView: 커스텀 클레임(name, grade, email, sub, iss) 포함하도록 수정
- GoogleLinkWithPasswordView: 동일하게 커스텀 클레임 포함
- 일반 로그인과 동일한 JWT 페이로드 구조 유지

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-18 23:00:33 +09:00
27d7101f0f v0.0.27 | Google 소셜 로그인 기능 추가
All checks were successful
Build And Test / build-and-push (push) Successful in 2m12s
- Google ID Token 검증 및 로그인/회원가입 기능
- 기존 계정 연동 기능 (비밀번호 확인 후 연동)
- 프로필에서 Google 연동/해제 기능
- CustomUser 모델에 social_provider, social_id, profile_image 필드 추가

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-18 19:53:43 +09:00
9204e56152 v0.0.26 | README.md 개선
All checks were successful
Build And Test / build-and-push (push) Successful in 2m6s
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-17 00:04:15 +09:00
2c050829ff v0.0.25 | KVM 서버 관리 API 추가
All checks were successful
Build And Test / build-and-push (push) Successful in 2m7s
- KVMServer 모델 추가 (멀티 서버 지원)
- 서버별 SSH 키 암호화 저장
- msa-django-libvirt 연동용 SSH 정보 조회 API

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-16 11:14:56 +09:00
85f5688a0b Fix workflow version read newline issue
All checks were successful
Build And Test / build-and-push (push) Successful in 3m14s
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 00:36:52 +09:00
d79c57d11b v0.0.23 | NHN Cloud 프로젝트에 dns_appkey 필드 추가
Some checks failed
Build And Test / build-and-push (push) Failing after 36s
- NHNCloudProject 모델에 dns_appkey 필드 추가
- 프로젝트 CRUD API에서 dns_appkey 처리

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 00:32:00 +09:00
e102d6766a v0.0.22 | NHN Cloud 프로젝트 수정 API 추가
Some checks failed
Build And Test / build-and-push (push) Failing after 49s
- PUT /api/auth/nhn-cloud/projects/{id}/ 수정 API
- GET /api/auth/nhn-cloud/projects/{id}/ 상세 조회 API
- 프로젝트 생성 로직 수정 (encrypted_password 설정)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 21:43:02 +09:00
c97b3c6c3b v0.0.21 | NHN Cloud 멀티 프로젝트 지원 추가
Some checks failed
Build And Test / build-and-push (push) Failing after 34s
- NHNCloudProject 모델 추가 (사용자별 여러 프로젝트 관리)
- 프로젝트 목록/추가/삭제/활성화 API 추가
- 프로젝트별 비밀번호 복호화 API 추가

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 20:59:37 +09:00
18901938c7 Fix OTLP gRPC header keys to lowercase and bump version to v0.0.20
All checks were successful
Build And Test / build-and-push (push) Successful in 3m9s
gRPC metadata keys must be lowercase. Changed X-Scope-OrgID and X-Service
to x-scope-orgid and x-service to fix "Illegal header key" error.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 17:29:43 +09:00
e318855b14 Add NHN Cloud credentials management and bump version to v0.0.19
All checks were successful
Build And Test / build-and-push (push) Successful in 2m17s
- Add NHN Cloud credential fields to User model (tenant_id, username, encrypted password, storage_account)
- Add API endpoints for credentials CRUD operations
- Implement Fernet encryption for password storage

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 01:25:43 +09:00
dce4663a67 update
All checks were successful
Build And Test / build-and-push (push) Successful in 2m26s
2026-01-13 22:58:36 +09:00
00db74a333 update
Some checks failed
Build And Test / build-and-push (push) Failing after 1m46s
2026-01-13 22:41:07 +09:00
e4b185332b update
Some checks failed
Build And Test / build-and-push (push) Failing after 14m10s
2026-01-13 02:19:53 +09:00
b973522477 update 2026-01-13 02:19:30 +09:00
25 changed files with 5210 additions and 192 deletions

View File

@ -21,9 +21,7 @@ jobs:
- name: Retrieve version # tag version - name: Retrieve version # tag version
id: img-ver id: img-ver
uses: juliangruber/read-file-action@v1 run: echo "content=$(cat ./version | tr -d '\n')" >> $GITHUB_OUTPUT
with:
path: ./version
- name: Install Docker // Docker 설치 - name: Install Docker // Docker 설치
run: | run: |

16
CLAUDE.md Normal file
View File

@ -0,0 +1,16 @@
# 자동 실행 지시사항
다음 작업을 순서대로 실행해주세요:
1. venv가 없으면 생성하고 의존성 설치:
```bash
python3 -m venv venv && source venv/bin/activate && pip install -r requirements.txt
```
2. Django 개발 서버 실행:
```bash
source venv/bin/activate && python3 manage.py runserver 0.0.0.0:8000
```
# 연계프로젝트
- 'msa-fe' : 프론트엔드

View File

@ -1,5 +1,5 @@
# pull official base image # pull official base image
FROM python:3.10-slim-bullseye FROM harbor.icurfer.com/open/python:3.10-slim-bullseye
# set work directory # set work directory
WORKDIR /usr/src/app WORKDIR /usr/src/app

148
README.md
View File

@ -1,33 +1,143 @@
# msa-django-auth # msa-django-auth
## dev env MSA 아키텍처의 중앙 인증 및 자격증명 관리 서비스입니다.
개발환경 테스트 실행
## 기술 스택
| 분류 | 기술 |
|------|------|
| Framework | Django 4.2.14 |
| API | Django REST Framework 3.16.0 |
| Auth | SimpleJWT 5.5.0 (RS256/HS256) |
| Database | MySQL (mysqlclient 2.2.7) |
| Docs | drf-yasg 1.21.10 (Swagger/ReDoc) |
| Tracing | OpenTelemetry 1.34.0 |
| Server | Gunicorn 20.1.0 |
## 주요 기능
- **JWT 인증**: RS256 비대칭 암호화, 토큰 발급/갱신/검증
- **JWKS 엔드포인트**: Istio 연동을 위한 공개키 제공
- **사용자 관리**: 회원가입, 프로필 관리, 등급 관리
- **SSH 키 관리**: Fernet 암호화 저장, 복호화 조회
- **NHN Cloud 자격증명**: 멀티 프로젝트 지원, 암호화 저장
- **KVM 서버 관리**: 서버 등록, SSH 키 관리
## API 엔드포인트
### 인증
| Method | Endpoint | 설명 |
|--------|----------|------|
| POST | `/api/auth/register/` | 회원가입 |
| POST | `/api/auth/login/` | 로그인 (JWT 발급) |
| POST | `/api/auth/token/refresh/` | 토큰 갱신 |
| POST | `/api/auth/verify/` | 토큰 검증 |
| GET | `/.well-known/jwks.json` | JWKS 공개키 |
### 사용자
| Method | Endpoint | 설명 |
|--------|----------|------|
| GET/PUT | `/api/auth/me/` | 내 정보 조회/수정 |
| GET | `/api/auth/users/` | 사용자 목록 (관리자) |
| GET/PUT/DELETE | `/api/auth/users/<id>/` | 사용자 관리 |
### SSH 키
| Method | Endpoint | 설명 |
|--------|----------|------|
| POST/DELETE | `/api/auth/ssh-key/` | SSH 키 업로드/삭제 |
| GET | `/api/auth/ssh-key/info/` | SSH 키 메타정보 |
| GET | `/api/auth/ssh-key/view/` | SSH 키 조회 (복호화) |
### NHN Cloud
| Method | Endpoint | 설명 |
|--------|----------|------|
| GET/POST | `/api/auth/nhn-cloud/projects/` | 프로젝트 목록/생성 |
| GET/PUT | `/api/auth/nhn-cloud/projects/<id>/` | 프로젝트 조회/수정 |
| POST | `/api/auth/nhn-cloud/projects/<id>/activate/` | 프로젝트 활성화 |
### KVM 서버
| Method | Endpoint | 설명 |
|--------|----------|------|
| GET/POST | `/api/auth/kvm-servers/` | 서버 목록/등록 |
| GET/PUT | `/api/auth/kvm-servers/<id>/` | 서버 조회/수정 |
| POST | `/api/auth/kvm-servers/<id>/activate/` | 서버 활성화 |
| POST | `/api/auth/kvm-servers/<id>/ssh-key/upload/` | SSH 키 업로드 |
### API 문서
- Swagger UI: `/swagger/`
- ReDoc: `/redoc/`
## 환경 변수
```env
# Django
DEBUG=1
SECRET_KEY=your-secret-key
# Database
SQL_ENGINE=django.db.backends.mysql
SQL_HOST=localhost
SQL_USER=user
SQL_PASSWORD=password
SQL_DATABASE=msa-auth
SQL_PORT=3306
# JWT (RS256 모드)
ISTIO_JWT=1
# Tracing
TRACE_ENDPOINT=jaeger-collector:4317
TRACE_SERVICE_NAME=msa-django-auth
```
## 실행 방법
### 개발 서버
```bash ```bash
python3 -m venv venv
source venv/bin/activate
pip install -r requirements.txt
python3 manage.py migrate
python3 manage.py runserver 0.0.0.0:8000 python3 manage.py runserver 0.0.0.0:8000
``` ```
### auth ### 운영 서버
```bash ```bash
gunicorn auth_prj.wsgi:application --bind 0.0.0.0:8000 --workers 3 gunicorn auth_prj.wsgi:application --bind 0.0.0.0:8000 --workers 3
``` ```
## 2025-12-05 TRACE ENDPOINT 변경 ( v0.0.15 ) ### Docker
* 변경전 static
* 변경후 변수 처리
* TRACE_ENDPOINT='test'
* TRACE_SERVICE_NAME=''
## 2025-09-29 jaeger Endpoint 변경 ( v0.0.14 ) ```bash
* 변경전: endpoint="http://jaeger-collector.istio-system:4317", docker build -t msa-django-auth .
* 변경후: endpoint="http://jaeger-collector.observability.svc.cluster.local:4317", docker run -p 8000:8000 --env-file .env.prd msa-django-auth
```
## 2025-09-28 RS256변경 적용 ( v0.0.13 ) ## 데이터 모델
* Docker Build base image 변경.
* python:3.10-slim-buster > python:3.10-slim-bullseye
## 2025-09-28 RS256변경 적용 ( v0.0.12 ) ### CustomUser
* 비대칭키 방식 → Private Key로 서명, Public Key로 검증.
* 토큰 발급 서버는 Private Key만 보관. - email, name (고유)
* 검증 서버들은 Public Key만 있으면 됨 → 여러 서비스/마이크로서비스 환경에 적합. - grade: admin, manager, user
* Istio, Keycloak, Auth0 등 대부분의 IDP/게이트웨이가 RS256 + JWKS(JSON Web Key Set) 방식 권장. - SSH 키 필드 (암호화)
- NHN Cloud 자격증명 (암호화)
### NHNCloudProject
- 사용자별 멀티 프로젝트
- tenant_id, username, 암호화된 password
- dns_appkey
### KVMServer
- 사용자별 서버 관리
- host, port, username
- SSH 키 (암호화), Libvirt URI

View File

@ -38,6 +38,9 @@ SERVICE_PLATFORM = os.getenv("SERVICE_PLATFORM", "none")
TRACE_SERVICE_NAME = os.getenv("TRACE_SERVICE_NAME", "msa-django-auth") TRACE_SERVICE_NAME = os.getenv("TRACE_SERVICE_NAME", "msa-django-auth")
TRACE_ENDPOINT = os.getenv("TRACE_ENDPOINT", "none") TRACE_ENDPOINT = os.getenv("TRACE_ENDPOINT", "none")
# Google 소셜 로그인 설정
GOOGLE_CLIENT_ID = os.environ.get('GOOGLE_CLIENT_ID', '')
# SECURITY WARNING: don't run with debug turned on in production! # SECURITY WARNING: don't run with debug turned on in production!
DEBUG = int(os.environ.get('DEBUG', 1)) DEBUG = int(os.environ.get('DEBUG', 1))
@ -133,6 +136,9 @@ CORS_ALLOWED_ORIGINS = [
"http://localhost:3000", "http://localhost:3000",
"http://127.0.0.1:3000", "http://127.0.0.1:3000",
"http://192.168.0.100:3000", "http://192.168.0.100:3000",
"http://localhost:8080",
"http://127.0.0.1:8080",
"http://192.168.0.202:8080",
"https://demo.test", "https://demo.test",
"http://demo.test", "http://demo.test",
"https://www.demo.test", "https://www.demo.test",

View File

@ -17,12 +17,17 @@ from django.core.wsgi import get_wsgi_application
# ✅ DEBUG 모드 아닐 때만 OpenTelemetry 활성 # ✅ DEBUG 모드 아닐 때만 OpenTelemetry 활성
if not settings.DEBUG: if not settings.DEBUG:
import grpc
from opentelemetry import trace from opentelemetry import trace
from opentelemetry.sdk.resources import Resource from opentelemetry.sdk.resources import Resource
from opentelemetry.sdk.trace import TracerProvider from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
from opentelemetry.instrumentation.django import DjangoInstrumentor from opentelemetry.instrumentation.django import DjangoInstrumentor
from opentelemetry.instrumentation.requests import RequestsInstrumentor
from opentelemetry.instrumentation.logging import LoggingInstrumentor
from opentelemetry.instrumentation.dbapi import trace_integration
import MySQLdb
trace.set_tracer_provider( trace.set_tracer_provider(
TracerProvider( TracerProvider(
@ -34,14 +39,28 @@ if not settings.DEBUG:
) )
) )
# TRACE_CA_CERT 설정에 따른 gRPC credentials 구성
# - 값이 있고 파일 존재: TLS + 해당 CA 인증서 사용
# - 값이 없거나 파일 없음: insecure 모드 (TLS 없이 연결)
credentials = None
ca_cert_path = os.getenv('TRACE_CA_CERT', '').strip()
if ca_cert_path and os.path.exists(ca_cert_path):
with open(ca_cert_path, 'rb') as f:
ca_cert = f.read()
credentials = grpc.ssl_channel_credentials(root_certificates=ca_cert)
insecure = False
else:
insecure = True
otlp_exporter = OTLPSpanExporter( otlp_exporter = OTLPSpanExporter(
# endpoint="http://jaeger-collector.istio-system:4317", # endpoint="http://jaeger-collector.istio-system:4317",
# endpoint="jaeger-collector.observability.svc.cluster.local:4317", # endpoint="jaeger-collector.observability.svc.cluster.local:4317",
endpoint=settings.TRACE_ENDPOINT, endpoint=settings.TRACE_ENDPOINT,
insecure=True, insecure=insecure,
credentials=credentials,
headers={ headers={
"X-Scope-OrgID": settings.SERVICE_PLATFORM, "x-scope-orgid": settings.SERVICE_PLATFORM,
"X-Service": settings.TRACE_SERVICE_NAME "x-service": settings.TRACE_SERVICE_NAME
} }
) )
@ -49,8 +68,23 @@ if not settings.DEBUG:
BatchSpanProcessor(otlp_exporter) BatchSpanProcessor(otlp_exporter)
) )
# Django 요청/응답 추적
DjangoInstrumentor().instrument() DjangoInstrumentor().instrument()
# HTTP 클라이언트 요청 추적 (requests 라이브러리)
RequestsInstrumentor().instrument()
# 로그와 Trace 연동 (trace_id, span_id를 로그에 자동 추가)
LoggingInstrumentor().instrument(set_logging_format=True)
# MySQL DB 쿼리 추적
trace_integration(
MySQLdb,
"connect",
"mysql",
capture_parameters=True, # 쿼리 파라미터 캡처
)
from django.core.wsgi import get_wsgi_application from django.core.wsgi import get_wsgi_application

33
certs/ca.crt Normal file
View File

@ -0,0 +1,33 @@
-----BEGIN CERTIFICATE-----
MIIFnzCCA4egAwIBAgIUAVL34d6iRsXVbUaNbTZ66AM61jUwDQYJKoZIhvcNAQEN
BQAwXzELMAkGA1UEBhMCS1IxDjAMBgNVBAgMBVNlb3VsMQ4wDAYDVQQHDAVTZW91
bDENMAsGA1UECgwEZGVtbzELMAkGA1UECwwCSVQxFDASBgNVBAMMC2ljdXJmZXIu
Y29tMB4XDTI1MDgwMTA3MzQ0N1oXDTM1MDczMDA3MzQ0N1owXzELMAkGA1UEBhMC
S1IxDjAMBgNVBAgMBVNlb3VsMQ4wDAYDVQQHDAVTZW91bDENMAsGA1UECgwEZGVt
bzELMAkGA1UECwwCSVQxFDASBgNVBAMMC2ljdXJmZXIuY29tMIICIjANBgkqhkiG
9w0BAQEFAAOCAg8AMIICCgKCAgEAr8ZwkvMbwydiFZk0dOODJMcXPkuNPvcTAkGg
4yt8TBgHrRaZVxFGz8ExGAd/pzsjcfGo4DI/Fu7t6cYgxkPrd8U12BK6E90H46hS
xOleWAsyUcrnEP2uD358g3K1kZaDc3IS4Fm26JiDsLYkGva1vyjhd+C7gSw8uEbS
yb/8chp3bLbA5qye+4aCkAErTbdcfZCrTibkgL2Va9qeNchGZkumG6PBL7xhNgLH
b4UOWKi+rYFBtIEhwcsWxt+p9yvrreKS7ezSJGqhhuwl3AFqThpGSl7S76i+5Udg
sCJd7I7D3jsIJV+Yjl3UiK3Wk/6Z5fjPgXAoMZfsSEv+kwu/YNcwKwCfMCBnn1xM
MCvdr09b3n4GnzvAtVLTXHunBz5O4Sif4T3SW38N0e1D1+0tXXUrNgPyCQGT0Oxn
fJgol/L7ngVEvQZSMP17GyH+Z1Waz9vL9fHp24g/T10BZP5zuJuVcM7F7LDDAlp3
/5J0+iUtZf1x45vYeJbbyg8/44IVmzqhapHFEMSI45R4l50ZnqSc4BGqIitg25Vy
xO4UathfyCaBeG76Jt+yls9sIdOjM0OEVBNCZqacTwSCTJoCd/ElMihXGVmVtI9g
WlOKys41jSNNrDgG+h7N4d5Ev9LvjTgJrxty89xkwPPAqd36NAxdJa9pnEdsE7rc
Tc1uLe0CAwEAAaNTMFEwHQYDVR0OBBYEFDupm0q0frmoFp8gCnCQhrMkqpfGMB8G
A1UdIwQYMBaAFDupm0q0frmoFp8gCnCQhrMkqpfGMA8GA1UdEwEB/wQFMAMBAf8w
DQYJKoZIhvcNAQENBQADggIBAGD84D7/pAJ8RHfxNuaBwum+osHs9UWRUeiRP2Jk
iNvhdYmgl/iLfS92CCn57Vttg46SXGUK8M91W6y4KDqBMeA2DS4/1rHIcLqZ2hpK
SjGRknSqC5CHC9w6fAErmxsEG+uIPmf6/Hl2SzUfyV0L9gdqGVorroQM5FnGvcpZ
VVgIu6dfGNCssZhBlzSznoHqqp7JfjOrg1OsJUeCngYRPitdm4cIkvZ2lR7qC4aN
dtzTypRx2xgq9C/23WC2iPiRAi8m6acu1iWT8KgP4YN2DNx1ISIokyMhuzpWxwmg
v1Hqh87bXqNeJJKe3DIoD4AePw46Mogby8yvVcq1s8Woebdm4bLfrghF3atH+UMG
0eEg2xyEGy4S7MB97+7G+hbb9DA+xu7G1fLI4ZwW0cxhSO+GXNAM90VSKdrkwfve
VR2QyMxY9jj0Iuf2pQjdjStZXdheIP5LVSyBK1i6KBl3kjIT3XKOzwzxu/Ndv87a
wpZqU16Wciyme/Xaq7m0SY4WPWZtc8+mP4XwMoh7Q7OB2sgU0L1j6PEPx8nYdXav
50QGpi43hPtlZVTWnRTNgFKWmOE3TWpwpATNOTP/CzxF6CAQFqW7SKYpiZ9YHdk1
uitJXaHZCMx12rYEeylZJh+ioKIkZz5jyYWb7HzMEPrgayuPudLhbnVa8QVPGsGH
7aRY
-----END CERTIFICATE-----

67
convert_spans.py Normal file
View File

@ -0,0 +1,67 @@
#!/usr/bin/env python3
"""views.py의 span 패턴을 새로운 방식으로 변환"""
import re
with open('users/views.py', 'r') as f:
content = f.read()
# 1. set_span_attributes(span, request, ...) -> enrich_span(request, ...)
content = re.sub(
r'set_span_attributes\(span, request, request\.user\)',
'enrich_span(request, request.user)',
content
)
content = re.sub(
r'set_span_attributes\(span, request\)',
'enrich_span(request)',
content
)
# 2. span.add_event(...) -> span_event(...)
content = re.sub(
r'span\.add_event\(',
'span_event(',
content
)
# 3. span.set_attribute(...) -> span_set_attribute(...)
content = re.sub(
r'span\.set_attribute\(',
'span_set_attribute(',
content
)
# 4. with tracer.start_as_current_span("...") as span: 패턴 변환
# 여러 줄에 걸친 패턴도 처리
def replace_span_block(match):
indent = match.group(1)
span_name = match.group(2)
# operation 이름 추출 (공백, 따옴표 제거)
op_name = span_name.strip().strip('"\'')
# 짧은 이름으로 변환
op_name = op_name.replace(" POST", ".post").replace(" GET", ".get")
op_name = op_name.replace(" PUT", ".put").replace(" PATCH", ".patch")
op_name = op_name.replace(" DELETE", ".delete")
op_name = op_name.replace("View", "").lower()
return f'{indent}enrich_span(request, operation="{op_name}")'
# 단일 줄 패턴
content = re.sub(
r'^(\s*)with tracer\.start_as_current_span\(([^)]+)\) as span:\s*(?:#.*)?$',
replace_span_block,
content,
flags=re.MULTILINE
)
# 여러 줄에 걸친 패턴 (줄바꿈 포함)
content = re.sub(
r'^(\s*)with tracer\.start_as_current_span\(\s*\n\s*([^)]+)\s*\) as span:\s*(?:#.*)?$',
replace_span_block,
content,
flags=re.MULTILINE
)
with open('users/views.py', 'w') as f:
f.write(content)
print("변환 완료")

View File

@ -26,7 +26,10 @@ opentelemetry-exporter-otlp-proto-common==1.34.0
opentelemetry-exporter-otlp-proto-grpc==1.34.0 opentelemetry-exporter-otlp-proto-grpc==1.34.0
opentelemetry-exporter-otlp-proto-http==1.34.0 opentelemetry-exporter-otlp-proto-http==1.34.0
opentelemetry-instrumentation==0.55b0 opentelemetry-instrumentation==0.55b0
opentelemetry-instrumentation-dbapi==0.55b0
opentelemetry-instrumentation-django==0.55b0 opentelemetry-instrumentation-django==0.55b0
opentelemetry-instrumentation-logging==0.55b0
opentelemetry-instrumentation-requests==0.55b0
opentelemetry-instrumentation-wsgi==0.55b0 opentelemetry-instrumentation-wsgi==0.55b0
opentelemetry-proto==1.34.0 opentelemetry-proto==1.34.0
opentelemetry-sdk==1.34.0 opentelemetry-sdk==1.34.0
@ -46,3 +49,4 @@ uritemplate==4.1.1
urllib3==2.4.0 urllib3==2.4.0
wrapt==1.17.2 wrapt==1.17.2
zipp==3.23.0 zipp==3.23.0
google-auth==2.38.0

View File

@ -0,0 +1,38 @@
# Generated by Django 4.2.14 on 2026-01-12 16:16
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('users', '0006_customuser_encrypted_private_key_name'),
]
operations = [
migrations.AddField(
model_name='customuser',
name='address',
field=models.CharField(blank=True, max_length=500, null=True, verbose_name='주소'),
),
migrations.AddField(
model_name='customuser',
name='birth_date',
field=models.DateField(blank=True, null=True, verbose_name='생년월일'),
),
migrations.AddField(
model_name='customuser',
name='education',
field=models.CharField(blank=True, choices=[('high_school', '고등학교 졸업'), ('associate', '전문학사'), ('bachelor', '학사'), ('master', '석사'), ('doctor', '박사'), ('other', '기타')], max_length=20, null=True, verbose_name='학력'),
),
migrations.AddField(
model_name='customuser',
name='gender',
field=models.CharField(blank=True, choices=[('M', '남성'), ('F', '여성'), ('O', '기타')], max_length=1, null=True, verbose_name='성별'),
),
migrations.AddField(
model_name='customuser',
name='phone',
field=models.CharField(blank=True, max_length=20, null=True, verbose_name='전화번호'),
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 4.2.14 on 2026-01-13 04:56
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('users', '0007_customuser_address_customuser_birth_date_and_more'),
]
operations = [
migrations.AlterField(
model_name='customuser',
name='name',
field=models.CharField(max_length=255, unique=True),
),
]

View File

@ -0,0 +1,33 @@
# Generated by Django 4.2.14 on 2026-01-13 15:05
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('users', '0008_add_unique_name'),
]
operations = [
migrations.AddField(
model_name='customuser',
name='encrypted_nhn_api_password',
field=models.BinaryField(blank=True, null=True, verbose_name='NHN Cloud API Password (암호화)'),
),
migrations.AddField(
model_name='customuser',
name='nhn_storage_account',
field=models.CharField(blank=True, max_length=128, null=True, verbose_name='NHN Cloud Storage Account'),
),
migrations.AddField(
model_name='customuser',
name='nhn_tenant_id',
field=models.CharField(blank=True, max_length=64, null=True, verbose_name='NHN Cloud Tenant ID'),
),
migrations.AddField(
model_name='customuser',
name='nhn_username',
field=models.EmailField(blank=True, max_length=254, null=True, verbose_name='NHN Cloud Username'),
),
]

View File

@ -0,0 +1,36 @@
# Generated by Django 4.2.14 on 2026-01-14 11:59
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('users', '0009_add_nhn_cloud_credentials'),
]
operations = [
migrations.CreateModel(
name='NHNCloudProject',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=100, verbose_name='프로젝트 별칭')),
('tenant_id', models.CharField(max_length=64, verbose_name='NHN Cloud Tenant ID')),
('username', models.EmailField(max_length=254, verbose_name='NHN Cloud Username')),
('encrypted_password', models.BinaryField(verbose_name='NHN Cloud API Password (암호화)')),
('storage_account', models.CharField(blank=True, max_length=128, null=True, verbose_name='Storage Account')),
('is_active', models.BooleanField(default=False, verbose_name='활성 프로젝트')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='nhn_projects', to=settings.AUTH_USER_MODEL, verbose_name='사용자')),
],
options={
'verbose_name': 'NHN Cloud 프로젝트',
'verbose_name_plural': 'NHN Cloud 프로젝트',
'ordering': ['-is_active', '-created_at'],
'unique_together': {('user', 'tenant_id')},
},
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 4.2.14 on 2026-01-14 15:18
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('users', '0010_nhncloudproject'),
]
operations = [
migrations.AddField(
model_name='nhncloudproject',
name='dns_appkey',
field=models.CharField(blank=True, max_length=64, null=True, verbose_name='DNS Plus Appkey'),
),
]

View File

@ -0,0 +1,41 @@
# Generated by Django 4.2.14 on 2026-01-15 14:49
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('users', '0011_add_dns_appkey_to_nhncloudproject'),
]
operations = [
migrations.CreateModel(
name='KVMServer',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=100, verbose_name='서버 별칭')),
('host', models.CharField(max_length=255, verbose_name='호스트 (IP 또는 도메인)')),
('port', models.IntegerField(default=22, verbose_name='SSH 포트')),
('username', models.CharField(max_length=100, verbose_name='SSH 사용자명')),
('encrypted_private_key_name', models.CharField(blank=True, max_length=100, null=True, verbose_name='SSH 키 이름')),
('encrypted_private_key', models.BinaryField(blank=True, null=True, verbose_name='SSH 개인키 (암호화)')),
('libvirt_uri', models.CharField(blank=True, help_text='예: qemu+ssh://user@host/system', max_length=255, null=True, verbose_name='Libvirt URI')),
('description', models.TextField(blank=True, null=True, verbose_name='설명')),
('tags', models.CharField(blank=True, help_text='쉼표로 구분된 태그 목록', max_length=500, null=True, verbose_name='태그')),
('is_active', models.BooleanField(default=True, verbose_name='활성화 상태')),
('last_used_at', models.DateTimeField(blank=True, null=True, verbose_name='마지막 사용 시각')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='kvm_servers', to=settings.AUTH_USER_MODEL, verbose_name='사용자')),
],
options={
'verbose_name': 'KVM 서버',
'verbose_name_plural': 'KVM 서버',
'ordering': ['-is_active', '-created_at'],
'unique_together': {('user', 'host', 'port')},
},
),
]

View File

@ -0,0 +1,28 @@
# Generated by Django 4.2.14 on 2026-01-18 07:07
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('users', '0012_add_kvm_server'),
]
operations = [
migrations.AddField(
model_name='customuser',
name='profile_image',
field=models.URLField(blank=True, max_length=500, null=True, verbose_name='프로필 이미지 URL'),
),
migrations.AddField(
model_name='customuser',
name='social_id',
field=models.CharField(blank=True, max_length=255, null=True, verbose_name='소셜 고유 ID'),
),
migrations.AddField(
model_name='customuser',
name='social_provider',
field=models.CharField(blank=True, max_length=20, null=True, verbose_name='소셜 로그인 제공자'),
),
]

View File

@ -0,0 +1,25 @@
# Generated by Django 4.2.14 on 2026-01-18 14:39
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('users', '0013_add_social_login_fields'),
]
operations = [
migrations.CreateModel(
name='SiteSettings',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('google_login_enabled', models.BooleanField(default=True, verbose_name='Google 로그인 활성화')),
('updated_at', models.DateTimeField(auto_now=True)),
],
options={
'verbose_name': '사이트 설정',
'verbose_name_plural': '사이트 설정',
},
),
]

View File

@ -0,0 +1,104 @@
# Generated by Django 4.2.14 on 2026-01-18 15:44
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('users', '0014_sitesettings'),
]
operations = [
migrations.AddField(
model_name='customuser',
name='locked_until',
field=models.DateTimeField(blank=True, null=True, verbose_name='계정 잠금 해제 시간'),
),
migrations.AddField(
model_name='customuser',
name='login_failures',
field=models.IntegerField(default=0, verbose_name='로그인 실패 횟수'),
),
migrations.AddField(
model_name='customuser',
name='password_changed_at',
field=models.DateTimeField(blank=True, null=True, verbose_name='비밀번호 변경일시'),
),
migrations.AddField(
model_name='sitesettings',
name='login_lockout_minutes',
field=models.IntegerField(default=30, verbose_name='계정 잠금 시간(분)'),
),
migrations.AddField(
model_name='sitesettings',
name='login_max_failures',
field=models.IntegerField(default=5, verbose_name='최대 로그인 실패 횟수 (0=무제한)'),
),
migrations.AddField(
model_name='sitesettings',
name='password_expiry_days',
field=models.IntegerField(default=90, verbose_name='비밀번호 만료일 (0=무제한)'),
),
migrations.AddField(
model_name='sitesettings',
name='password_expiry_warning_days',
field=models.IntegerField(default=14, verbose_name='만료 경고일 (만료 전 N일)'),
),
migrations.AddField(
model_name='sitesettings',
name='password_history_count',
field=models.IntegerField(default=3, verbose_name='이전 비밀번호 재사용 금지 횟수 (0=제한없음)'),
),
migrations.AddField(
model_name='sitesettings',
name='password_max_length',
field=models.IntegerField(default=128, verbose_name='최대 비밀번호 길이'),
),
migrations.AddField(
model_name='sitesettings',
name='password_min_length',
field=models.IntegerField(default=8, verbose_name='최소 비밀번호 길이'),
),
migrations.AddField(
model_name='sitesettings',
name='password_require_digit',
field=models.BooleanField(default=True, verbose_name='숫자 필수'),
),
migrations.AddField(
model_name='sitesettings',
name='password_require_lowercase',
field=models.BooleanField(default=True, verbose_name='소문자 필수'),
),
migrations.AddField(
model_name='sitesettings',
name='password_require_special',
field=models.BooleanField(default=True, verbose_name='특수문자 필수'),
),
migrations.AddField(
model_name='sitesettings',
name='password_require_uppercase',
field=models.BooleanField(default=True, verbose_name='대문자 필수'),
),
migrations.AddField(
model_name='sitesettings',
name='password_special_chars',
field=models.CharField(default='!@#$%^&*()_+-=[]{}|;:,.<>?', max_length=100, verbose_name='허용 특수문자'),
),
migrations.CreateModel(
name='PasswordHistory',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('password_hash', models.CharField(max_length=255, verbose_name='비밀번호 해시')),
('created_at', models.DateTimeField(auto_now_add=True)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='password_history', to=settings.AUTH_USER_MODEL, verbose_name='사용자')),
],
options={
'verbose_name': '비밀번호 이력',
'verbose_name_plural': '비밀번호 이력',
'ordering': ['-created_at'],
},
),
]

View File

@ -5,6 +5,71 @@ from django.conf import settings # ✅ 추가
from cryptography.fernet import Fernet from cryptography.fernet import Fernet
import base64, hashlib # ✅ SECRET_KEY 암호화 키 생성용 import base64, hashlib # ✅ SECRET_KEY 암호화 키 생성용
# ============================================
# 사이트 설정 (싱글톤)
# ============================================
class SiteSettings(models.Model):
"""
사이트 전역 설정 (싱글톤 패턴)
- Google 로그인 활성화 여부 등 관리
- 비밀번호 정책 설정
"""
# 소셜 로그인 설정
google_login_enabled = models.BooleanField(default=True, verbose_name="Google 로그인 활성화")
# ========== 비밀번호 정책 설정 ==========
# 비밀번호 길이
password_min_length = models.IntegerField(default=8, verbose_name="최소 비밀번호 길이")
password_max_length = models.IntegerField(default=128, verbose_name="최대 비밀번호 길이")
# 비밀번호 복잡성 요구사항
password_require_uppercase = models.BooleanField(default=True, verbose_name="대문자 필수")
password_require_lowercase = models.BooleanField(default=True, verbose_name="소문자 필수")
password_require_digit = models.BooleanField(default=True, verbose_name="숫자 필수")
password_require_special = models.BooleanField(default=True, verbose_name="특수문자 필수")
password_special_chars = models.CharField(
max_length=100,
default="!@#$%^&*()_+-=[]{}|;:,.<>?",
verbose_name="허용 특수문자"
)
# 비밀번호 만료 정책
password_expiry_days = models.IntegerField(default=90, verbose_name="비밀번호 만료일 (0=무제한)")
password_expiry_warning_days = models.IntegerField(default=14, verbose_name="만료 경고일 (만료 전 N일)")
# 비밀번호 이력 관리
password_history_count = models.IntegerField(default=3, verbose_name="이전 비밀번호 재사용 금지 횟수 (0=제한없음)")
# 계정 잠금 정책
login_max_failures = models.IntegerField(default=5, verbose_name="최대 로그인 실패 횟수 (0=무제한)")
login_lockout_minutes = models.IntegerField(default=30, verbose_name="계정 잠금 시간(분)")
updated_at = models.DateTimeField(auto_now=True)
class Meta:
verbose_name = "사이트 설정"
verbose_name_plural = "사이트 설정"
def save(self, *args, **kwargs):
# 싱글톤 패턴: 항상 id=1로 저장
self.pk = 1
super().save(*args, **kwargs)
def delete(self, *args, **kwargs):
# 삭제 방지
pass
@classmethod
def get_settings(cls):
"""설정 인스턴스 가져오기 (없으면 생성)"""
obj, created = cls.objects.get_or_create(pk=1)
return obj
def __str__(self):
return "사이트 설정"
class CustomUserManager(BaseUserManager): class CustomUserManager(BaseUserManager):
def create_user(self, email, password=None, **extra_fields): def create_user(self, email, password=None, **extra_fields):
if not email: if not email:
@ -35,11 +100,33 @@ class CustomUser(AbstractBaseUser, PermissionsMixin):
('user', '일반 사용자'), ('user', '일반 사용자'),
) )
GENDER_CHOICES = (
('M', '남성'),
('F', '여성'),
('O', '기타'),
)
EDUCATION_CHOICES = (
('high_school', '고등학교 졸업'),
('associate', '전문학사'),
('bachelor', '학사'),
('master', '석사'),
('doctor', '박사'),
('other', '기타'),
)
email = models.EmailField(unique=True) email = models.EmailField(unique=True)
name = models.CharField(max_length=255) name = models.CharField(max_length=255, unique=True)
grade = models.CharField(max_length=20, choices=GRADE_CHOICES, default='user') grade = models.CharField(max_length=20, choices=GRADE_CHOICES, default='user')
desc = models.TextField(blank=True, null=True, verbose_name="설명") desc = models.TextField(blank=True, null=True, verbose_name="설명")
# 추가 회원 정보 (선택)
phone = models.CharField(max_length=20, blank=True, null=True, verbose_name="전화번호")
address = models.CharField(max_length=500, blank=True, null=True, verbose_name="주소")
gender = models.CharField(max_length=1, choices=GENDER_CHOICES, blank=True, null=True, verbose_name="성별")
birth_date = models.DateField(blank=True, null=True, verbose_name="생년월일")
education = models.CharField(max_length=20, choices=EDUCATION_CHOICES, blank=True, null=True, verbose_name="학력")
is_active = models.BooleanField(default=False) is_active = models.BooleanField(default=False)
is_staff = models.BooleanField(default=False) is_staff = models.BooleanField(default=False)
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField(auto_now_add=True)
@ -49,6 +136,22 @@ class CustomUser(AbstractBaseUser, PermissionsMixin):
encrypted_private_key = models.BinaryField(blank=True, null=True) encrypted_private_key = models.BinaryField(blank=True, null=True)
last_used_at = models.DateTimeField(blank=True, null=True, verbose_name="SSH 키 마지막 사용 시각") last_used_at = models.DateTimeField(blank=True, null=True, verbose_name="SSH 키 마지막 사용 시각")
# 🔗 소셜 로그인 필드
social_provider = models.CharField(max_length=20, blank=True, null=True, verbose_name="소셜 로그인 제공자") # 'google', 'kakao' 등
social_id = models.CharField(max_length=255, blank=True, null=True, verbose_name="소셜 고유 ID")
profile_image = models.URLField(max_length=500, blank=True, null=True, verbose_name="프로필 이미지 URL")
# ☁️ NHN Cloud 자격증명 필드
nhn_tenant_id = models.CharField(max_length=64, blank=True, null=True, verbose_name="NHN Cloud Tenant ID")
nhn_username = models.EmailField(blank=True, null=True, verbose_name="NHN Cloud Username")
encrypted_nhn_api_password = models.BinaryField(blank=True, null=True, verbose_name="NHN Cloud API Password (암호화)")
nhn_storage_account = models.CharField(max_length=128, blank=True, null=True, verbose_name="NHN Cloud Storage Account")
# 🔒 비밀번호 보안 관련 필드
password_changed_at = models.DateTimeField(blank=True, null=True, verbose_name="비밀번호 변경일시")
login_failures = models.IntegerField(default=0, verbose_name="로그인 실패 횟수")
locked_until = models.DateTimeField(blank=True, null=True, verbose_name="계정 잠금 해제 시간")
objects = CustomUserManager() objects = CustomUserManager()
USERNAME_FIELD = 'email' USERNAME_FIELD = 'email'
@ -90,3 +193,203 @@ class CustomUser(AbstractBaseUser, PermissionsMixin):
""" """
self.encrypted_private_key = self.encrypt_private_key(private_key) self.encrypted_private_key = self.encrypt_private_key(private_key)
self.save() self.save()
# ☁️ NHN Cloud API Password 암복호화
def encrypt_nhn_password(self, password: str) -> bytes:
"""NHN Cloud API 비밀번호 암호화"""
cipher = Fernet(self.get_encryption_key())
return cipher.encrypt(password.encode())
def decrypt_nhn_password(self) -> str:
"""NHN Cloud API 비밀번호 복호화"""
if self.encrypted_nhn_api_password:
cipher = Fernet(self.get_encryption_key())
return cipher.decrypt(self.encrypted_nhn_api_password).decode()
return ""
def save_nhn_credentials(self, tenant_id: str, username: str, password: str, storage_account: str = None):
"""NHN Cloud 자격증명 저장"""
self.nhn_tenant_id = tenant_id
self.nhn_username = username
self.encrypted_nhn_api_password = self.encrypt_nhn_password(password)
if storage_account:
self.nhn_storage_account = storage_account
self.save(update_fields=[
'nhn_tenant_id', 'nhn_username', 'encrypted_nhn_api_password', 'nhn_storage_account'
])
# ============================================
# NHN Cloud 프로젝트 (멀티 프로젝트 지원)
# ============================================
class NHNCloudProject(models.Model):
"""
사용자별 NHN Cloud 프로젝트 관리 (멀티 프로젝트 지원)
"""
user = models.ForeignKey(
CustomUser,
on_delete=models.CASCADE,
related_name='nhn_projects',
verbose_name="사용자"
)
name = models.CharField(max_length=100, verbose_name="프로젝트 별칭")
tenant_id = models.CharField(max_length=64, verbose_name="NHN Cloud Tenant ID")
username = models.EmailField(verbose_name="NHN Cloud Username")
encrypted_password = models.BinaryField(verbose_name="NHN Cloud API Password (암호화)")
storage_account = models.CharField(max_length=128, blank=True, null=True, verbose_name="Storage Account")
dns_appkey = models.CharField(max_length=64, blank=True, null=True, verbose_name="DNS Plus Appkey")
is_active = models.BooleanField(default=False, verbose_name="활성 프로젝트")
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
verbose_name = "NHN Cloud 프로젝트"
verbose_name_plural = "NHN Cloud 프로젝트"
unique_together = ['user', 'tenant_id'] # 동일 사용자가 같은 tenant 중복 등록 방지
ordering = ['-is_active', '-created_at']
def __str__(self):
return f"{self.name} ({self.tenant_id})"
def get_encryption_key(self) -> bytes:
"""SECRET_KEY 기반 Fernet 키 생성"""
hashed = hashlib.sha256(settings.SECRET_KEY.encode()).digest()
return base64.urlsafe_b64encode(hashed[:32])
def encrypt_password(self, password: str) -> bytes:
"""비밀번호 암호화"""
cipher = Fernet(self.get_encryption_key())
return cipher.encrypt(password.encode())
def decrypt_password(self) -> str:
"""비밀번호 복호화"""
if self.encrypted_password:
cipher = Fernet(self.get_encryption_key())
return cipher.decrypt(self.encrypted_password).decode()
return ""
def save_credentials(self, password: str):
"""자격증명 저장 (비밀번호 암호화)"""
self.encrypted_password = self.encrypt_password(password)
self.save()
# ============================================
# KVM 서버 관리 (멀티 서버 지원)
# ============================================
class KVMServer(models.Model):
"""
사용자별 KVM 서버 관리 (멀티 서버 지원)
msa-django-libvirt에서 SSH 접속 정보를 요청할 때 사용
"""
user = models.ForeignKey(
CustomUser,
on_delete=models.CASCADE,
related_name='kvm_servers',
verbose_name="사용자"
)
name = models.CharField(max_length=100, verbose_name="서버 별칭")
host = models.CharField(max_length=255, verbose_name="호스트 (IP 또는 도메인)")
port = models.IntegerField(default=22, verbose_name="SSH 포트")
username = models.CharField(max_length=100, verbose_name="SSH 사용자명")
encrypted_private_key_name = models.CharField(
max_length=100, blank=True, null=True, verbose_name="SSH 키 이름"
)
encrypted_private_key = models.BinaryField(
blank=True, null=True, verbose_name="SSH 개인키 (암호화)"
)
libvirt_uri = models.CharField(
max_length=255, blank=True, null=True,
verbose_name="Libvirt URI",
help_text="예: qemu+ssh://user@host/system"
)
description = models.TextField(blank=True, null=True, verbose_name="설명")
tags = models.CharField(
max_length=500, blank=True, null=True,
verbose_name="태그",
help_text="쉼표로 구분된 태그 목록"
)
is_active = models.BooleanField(default=True, verbose_name="활성화 상태")
last_used_at = models.DateTimeField(blank=True, null=True, verbose_name="마지막 사용 시각")
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
verbose_name = "KVM 서버"
verbose_name_plural = "KVM 서버"
unique_together = ['user', 'host', 'port'] # 동일 사용자가 같은 호스트:포트 중복 등록 방지
ordering = ['-is_active', '-created_at']
def __str__(self):
return f"{self.name} ({self.host}:{self.port})"
def get_encryption_key(self) -> bytes:
"""SECRET_KEY 기반 Fernet 키 생성"""
hashed = hashlib.sha256(settings.SECRET_KEY.encode()).digest()
return base64.urlsafe_b64encode(hashed[:32])
def encrypt_private_key(self, private_key: str) -> bytes:
"""SSH 개인키 암호화"""
cipher = Fernet(self.get_encryption_key())
return cipher.encrypt(private_key.encode())
def decrypt_private_key(self) -> str:
"""SSH 개인키 복호화"""
if self.encrypted_private_key:
cipher = Fernet(self.get_encryption_key())
decrypted = cipher.decrypt(self.encrypted_private_key).decode()
self.last_used_at = timezone.now()
self.save(update_fields=['last_used_at'])
return decrypted
return ""
def save_ssh_key(self, private_key: str, key_name: str = None):
"""SSH 키 저장 (암호화)"""
self.encrypted_private_key = self.encrypt_private_key(private_key)
if key_name:
self.encrypted_private_key_name = key_name
self.save()
def get_tags_list(self) -> list:
"""태그 문자열을 리스트로 반환"""
if self.tags:
return [tag.strip() for tag in self.tags.split(',') if tag.strip()]
return []
def set_tags_list(self, tags_list: list):
"""태그 리스트를 문자열로 저장"""
self.tags = ', '.join(tags_list)
def get_libvirt_uri(self) -> str:
"""Libvirt URI 반환 (없으면 기본값 생성)"""
if self.libvirt_uri:
return self.libvirt_uri
return f"qemu+ssh://{self.username}@{self.host}:{self.port}/system"
# ============================================
# 비밀번호 이력 관리
# ============================================
class PasswordHistory(models.Model):
"""
사용자 비밀번호 변경 이력 (재사용 방지용)
"""
user = models.ForeignKey(
CustomUser,
on_delete=models.CASCADE,
related_name='password_history',
verbose_name="사용자"
)
password_hash = models.CharField(max_length=255, verbose_name="비밀번호 해시")
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
verbose_name = "비밀번호 이력"
verbose_name_plural = "비밀번호 이력"
ordering = ['-created_at']
def __str__(self):
return f"{self.user.email} - {self.created_at.strftime('%Y-%m-%d %H:%M')}"

245
users/password_utils.py Normal file
View File

@ -0,0 +1,245 @@
# users/password_utils.py
"""
비밀번호 정책 유효성 검사 및 관리 유틸리티
"""
import re
from datetime import timedelta
from django.utils import timezone
from django.contrib.auth.hashers import check_password, make_password
def validate_password_policy(password, settings):
"""
비밀번호 정책에 따른 유효성 검사
Args:
password: 검사할 비밀번호
settings: SiteSettings 인스턴스
Returns:
tuple: (is_valid: bool, errors: list)
"""
errors = []
# 길이 검사
if len(password) < settings.password_min_length:
errors.append(f"비밀번호는 최소 {settings.password_min_length}자 이상이어야 합니다.")
if len(password) > settings.password_max_length:
errors.append(f"비밀번호는 최대 {settings.password_max_length}자 이하여야 합니다.")
# 대문자 검사
if settings.password_require_uppercase and not re.search(r'[A-Z]', password):
errors.append("비밀번호에 대문자가 포함되어야 합니다.")
# 소문자 검사
if settings.password_require_lowercase and not re.search(r'[a-z]', password):
errors.append("비밀번호에 소문자가 포함되어야 합니다.")
# 숫자 검사
if settings.password_require_digit and not re.search(r'\d', password):
errors.append("비밀번호에 숫자가 포함되어야 합니다.")
# 특수문자 검사
if settings.password_require_special:
special_chars = settings.password_special_chars
if not any(char in special_chars for char in password):
errors.append(f"비밀번호에 특수문자({special_chars})가 포함되어야 합니다.")
return len(errors) == 0, errors
def check_password_history(user, new_password, history_count):
"""
비밀번호 이력 검사 (재사용 방지)
Args:
user: CustomUser 인스턴스
new_password: 새 비밀번호
history_count: 검사할 이력 수 (0이면 검사 안 함)
Returns:
tuple: (is_valid: bool, error_message: str or None)
"""
if history_count <= 0:
return True, None
# 최근 N개의 비밀번호 이력 가져오기
recent_passwords = user.password_history.all()[:history_count]
for history in recent_passwords:
if check_password(new_password, history.password_hash):
return False, f"최근 {history_count}회 이내에 사용한 비밀번호는 재사용할 수 없습니다."
return True, None
def save_password_history(user, password):
"""
비밀번호 이력 저장
Args:
user: CustomUser 인스턴스
password: 저장할 비밀번호 (평문)
"""
from .models import PasswordHistory
PasswordHistory.objects.create(
user=user,
password_hash=make_password(password)
)
def check_password_expiry(user, settings):
"""
비밀번호 만료 상태 확인
Args:
user: CustomUser 인스턴스
settings: SiteSettings 인스턴스
Returns:
dict: {
'is_expired': bool,
'is_warning': bool,
'days_until_expiry': int or None,
'message': str or None
}
"""
result = {
'is_expired': False,
'is_warning': False,
'days_until_expiry': None,
'message': None
}
# 만료 정책이 비활성화되어 있으면 (0일)
if settings.password_expiry_days <= 0:
return result
# 비밀번호 변경일이 없으면 (최초 설정 또는 소셜 로그인)
if not user.password_changed_at:
# 소셜 로그인 사용자는 비밀번호가 없으므로 만료 체크 안 함
if not user.has_usable_password():
return result
# 비밀번호는 있지만 변경일이 없으면 즉시 만료 처리
result['is_expired'] = True
result['message'] = "비밀번호 변경이 필요합니다."
return result
# 만료일 계산
expiry_date = user.password_changed_at + timedelta(days=settings.password_expiry_days)
now = timezone.now()
days_until_expiry = (expiry_date - now).days
result['days_until_expiry'] = days_until_expiry
if days_until_expiry < 0:
# 만료됨
result['is_expired'] = True
result['message'] = "비밀번호가 만료되었습니다. 새 비밀번호로 변경해주세요."
elif days_until_expiry <= settings.password_expiry_warning_days:
# 경고 기간
result['is_warning'] = True
result['message'] = f"비밀번호가 {days_until_expiry}일 후 만료됩니다. 변경을 권장합니다."
return result
def check_account_lockout(user, settings):
"""
계정 잠금 상태 확인
Args:
user: CustomUser 인스턴스
settings: SiteSettings 인스턴스
Returns:
dict: {
'is_locked': bool,
'remaining_minutes': int or None,
'message': str or None
}
"""
result = {
'is_locked': False,
'remaining_minutes': None,
'message': None
}
# 잠금 정책이 비활성화되어 있으면
if settings.login_max_failures <= 0:
return result
# 잠금 시간이 설정되어 있으면
if user.locked_until:
now = timezone.now()
if user.locked_until > now:
remaining_seconds = (user.locked_until - now).total_seconds()
remaining_minutes = int(remaining_seconds / 60) + 1
result['is_locked'] = True
result['remaining_minutes'] = remaining_minutes
result['message'] = f"로그인 시도가 너무 많습니다. {remaining_minutes}분 후 다시 시도해주세요."
return result
def record_login_failure(user, settings):
"""
로그인 실패 기록 및 잠금 처리
Args:
user: CustomUser 인스턴스
settings: SiteSettings 인스턴스
"""
if settings.login_max_failures <= 0:
return
user.login_failures += 1
if user.login_failures >= settings.login_max_failures:
user.locked_until = timezone.now() + timedelta(minutes=settings.login_lockout_minutes)
user.save(update_fields=['login_failures', 'locked_until'])
def reset_login_failures(user):
"""
로그인 성공 시 실패 횟수 초기화
Args:
user: CustomUser 인스턴스
"""
if user.login_failures > 0 or user.locked_until:
user.login_failures = 0
user.locked_until = None
user.save(update_fields=['login_failures', 'locked_until'])
def get_password_policy_description(settings):
"""
비밀번호 정책 설명 텍스트 생성
Args:
settings: SiteSettings 인스턴스
Returns:
list: 정책 설명 리스트
"""
descriptions = []
descriptions.append(f"최소 {settings.password_min_length}자 이상")
if settings.password_require_uppercase:
descriptions.append("대문자 포함")
if settings.password_require_lowercase:
descriptions.append("소문자 포함")
if settings.password_require_digit:
descriptions.append("숫자 포함")
if settings.password_require_special:
descriptions.append(f"특수문자 포함 ({settings.password_special_chars})")
return descriptions

View File

@ -1,24 +1,92 @@
from rest_framework import serializers from rest_framework import serializers
from .models import CustomUser from .models import CustomUser, KVMServer
from rest_framework_simplejwt.serializers import TokenObtainPairSerializer from rest_framework_simplejwt.serializers import TokenObtainPairSerializer
from rest_framework.exceptions import ValidationError from rest_framework.exceptions import ValidationError
class RegisterSerializer(serializers.ModelSerializer): class RegisterSerializer(serializers.ModelSerializer):
password = serializers.CharField(write_only=True) password = serializers.CharField(write_only=True, required=False)
has_password = serializers.SerializerMethodField()
class Meta: class Meta:
model = CustomUser model = CustomUser
fields = ("email", "name", "password", "grade", "desc") fields = ("email", "name", "password", "grade", "desc",
"phone", "address", "gender", "birth_date", "education",
"social_provider", "profile_image", "has_password")
read_only_fields = ("social_provider", "profile_image", "has_password")
def get_has_password(self, obj):
"""사용자가 비밀번호를 가지고 있는지 여부"""
return obj.has_usable_password()
def validate_email(self, value):
# 업데이트 시에는 자기 자신 제외
instance = getattr(self, 'instance', None)
queryset = CustomUser.objects.filter(email=value)
if instance:
queryset = queryset.exclude(pk=instance.pk)
if queryset.exists():
raise ValidationError("이미 사용 중인 이메일입니다.")
return value
def validate_name(self, value):
# 업데이트 시에는 자기 자신 제외
instance = getattr(self, 'instance', None)
queryset = CustomUser.objects.filter(name=value)
if instance:
queryset = queryset.exclude(pk=instance.pk)
if queryset.exists():
raise ValidationError("이미 사용 중인 이름입니다.")
return value
def validate_password(self, value):
"""비밀번호 정책 검증 (회원가입 시)"""
from .models import SiteSettings
from .password_utils import validate_password_policy
# 비밀번호가 제공된 경우에만 검증 (업데이트 시 비밀번호 변경 안 할 수 있음)
if value:
site_settings = SiteSettings.get_settings()
is_valid, errors = validate_password_policy(value, site_settings)
if not is_valid:
raise ValidationError(errors)
return value
def create(self, validated_data): def create(self, validated_data):
password = validated_data.pop("password") from .models import SiteSettings
from .password_utils import validate_password_policy
from django.utils import timezone
password = validated_data.pop("password", None)
# 회원가입 시 비밀번호 필수
if not password:
raise ValidationError({"password": "비밀번호는 필수입니다."})
user = CustomUser(**validated_data) user = CustomUser(**validated_data)
user.set_password(password) user.set_password(password)
user.password_changed_at = timezone.now() # 비밀번호 변경일 설정
user.save() user.save()
return user return user
class UserListSerializer(serializers.ModelSerializer):
"""관리자용 사용자 목록 시리얼라이저"""
class Meta:
model = CustomUser
fields = [
'id', 'email', 'name', 'grade', 'is_active', 'is_staff',
'created_at', 'phone', 'address', 'gender', 'birth_date', 'education'
]
read_only_fields = [
'id', 'email', 'name', 'grade', 'is_staff',
'created_at', 'phone', 'address', 'gender', 'birth_date', 'education'
]
class CustomTokenObtainPairSerializer(TokenObtainPairSerializer): class CustomTokenObtainPairSerializer(TokenObtainPairSerializer):
# email 필드를 identifier로 재정의 (이메일 또는 이름 허용)
email = serializers.CharField()
@classmethod @classmethod
def get_token(cls, user): def get_token(cls, user):
token = super().get_token(user) token = super().get_token(user)
@ -35,21 +103,116 @@ class CustomTokenObtainPairSerializer(TokenObtainPairSerializer):
return token return token
def validate(self, attrs): def validate(self, attrs):
email = attrs.get("email") from .models import SiteSettings
from .password_utils import (
check_account_lockout, record_login_failure,
reset_login_failures, check_password_expiry
)
identifier = attrs.get("email") # 이메일 또는 이름
password = attrs.get("password") password = attrs.get("password")
user = CustomUser.objects.filter(email=email).first() # 이메일 또는 이름으로 사용자 찾기
user = CustomUser.objects.filter(email=identifier).first()
if user is None:
user = CustomUser.objects.filter(name=identifier).first()
if user is None: if user is None:
raise ValidationError("이메일 또는 비밀번호가 올바르지 않습니다.") raise ValidationError("계정 또는 비밀번호가 올바르지 않습니다.")
# 사이트 설정 가져오기
site_settings = SiteSettings.get_settings()
# 계정 잠금 상태 확인
lockout_status = check_account_lockout(user, site_settings)
if lockout_status['is_locked']:
raise ValidationError(lockout_status['message'])
if not user.is_active: if not user.is_active:
raise ValidationError("계정이 비활성화되어 있습니다. 관리자에게 문의하세요.") raise ValidationError("계정이 비활성화되어 있습니다. 관리자에게 문의하세요.")
if not user.check_password(password):
raise ValidationError("이메일 또는 비밀번호가 올바르지 않습니다.")
# 비밀번호 검증
if not user.check_password(password):
# 로그인 실패 기록
record_login_failure(user, site_settings)
raise ValidationError("계정 또는 비밀번호가 올바르지 않습니다.")
# 로그인 성공 - 실패 횟수 초기화
reset_login_failures(user)
# 부모 클래스의 validate를 위해 attrs에 실제 email 설정
attrs["email"] = user.email
self.user = user # ✅ 수동 설정 필요 self.user = user # ✅ 수동 설정 필요
data = super().validate(attrs) data = super().validate(attrs)
data["email"] = user.email data["email"] = user.email
data["grade"] = user.grade data["grade"] = user.grade
# 비밀번호 만료 상태 확인
expiry_status = check_password_expiry(user, site_settings)
if expiry_status['is_expired']:
data["password_expired"] = True
data["password_message"] = expiry_status['message']
elif expiry_status['is_warning']:
data["password_warning"] = True
data["password_message"] = expiry_status['message']
data["password_days_until_expiry"] = expiry_status['days_until_expiry']
return data return data
class KVMServerSerializer(serializers.ModelSerializer):
"""KVM 서버 시리얼라이저"""
tags_list = serializers.ListField(
child=serializers.CharField(),
required=False,
write_only=True
)
private_key = serializers.CharField(write_only=True, required=False)
class Meta:
model = KVMServer
fields = [
'id', 'name', 'host', 'port', 'username',
'encrypted_private_key_name', 'libvirt_uri',
'description', 'tags', 'tags_list', 'is_active',
'last_used_at', 'created_at', 'updated_at', 'private_key'
]
read_only_fields = ['id', 'last_used_at', 'created_at', 'updated_at']
extra_kwargs = {
'tags': {'required': False},
}
def to_representation(self, instance):
data = super().to_representation(instance)
data['has_ssh_key'] = bool(instance.encrypted_private_key)
data['tags_list'] = instance.get_tags_list()
return data
def create(self, validated_data):
tags_list = validated_data.pop('tags_list', None)
private_key = validated_data.pop('private_key', None)
instance = super().create(validated_data)
if tags_list is not None:
instance.set_tags_list(tags_list)
if private_key:
instance.save_ssh_key(private_key, validated_data.get('encrypted_private_key_name'))
instance.save()
return instance
def update(self, instance, validated_data):
tags_list = validated_data.pop('tags_list', None)
private_key = validated_data.pop('private_key', None)
instance = super().update(instance, validated_data)
if tags_list is not None:
instance.set_tags_list(tags_list)
instance.save()
if private_key:
instance.save_ssh_key(private_key, validated_data.get('encrypted_private_key_name'))
return instance

View File

@ -1,5 +1,20 @@
from django.urls import path from django.urls import path
from .views import RegisterView, MeView, CustomTokenObtainPairView, SSHKeyUploadView, SSHKeyInfoView, SSHKeyRetrieveView from .views import (
RegisterView, LogoutView, MeView, ChangePasswordView, ExtendPasswordExpiryView, CustomTokenObtainPairView,
SSHKeyUploadView, SSHKeyInfoView, SSHKeyRetrieveView,
UserListView, UserUpdateView,
NHNCloudCredentialsView, NHNCloudPasswordView,
# NHN Cloud 멀티 프로젝트 지원
NHNCloudProjectListView, NHNCloudProjectDetailView,
NHNCloudProjectActivateView, NHNCloudProjectPasswordView,
# KVM 서버 관리
KVMServerListView, KVMServerDetailView,
KVMServerActivateView, KVMServerSSHKeyView, KVMServerSSHKeyUploadView,
# 소셜 로그인
GoogleLoginView, GoogleLinkWithPasswordView, GoogleLinkView, GoogleUnlinkView,
# 사이트 설정
SiteSettingsView,
)
from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView, TokenVerifyView from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView, TokenVerifyView
from .views_jwks import jwks_view # django-jwks from .views_jwks import jwks_view # django-jwks
@ -7,11 +22,38 @@ urlpatterns = [
path('register/', RegisterView.as_view(), name='register'), path('register/', RegisterView.as_view(), name='register'),
# path('login/', TokenObtainPairView.as_view(), name='token_obtain_pair'), # path('login/', TokenObtainPairView.as_view(), name='token_obtain_pair'),
path('login/', CustomTokenObtainPairView.as_view(), name='token_obtain_pair'), path('login/', CustomTokenObtainPairView.as_view(), name='token_obtain_pair'),
path('logout/', LogoutView.as_view(), name='logout'),
path('token/refresh/', TokenRefreshView.as_view(), name='token_refresh'), path('token/refresh/', TokenRefreshView.as_view(), name='token_refresh'),
path('verify/', TokenVerifyView.as_view(), name='token_verify'), path('verify/', TokenVerifyView.as_view(), name='token_verify'),
path('me/', MeView.as_view(), name='me'), path('me/', MeView.as_view(), name='me'),
path('me/password/', ChangePasswordView.as_view(), name='change_password'),
path('me/password/extend/', ExtendPasswordExpiryView.as_view(), name='extend_password_expiry'),
path("ssh-key/", SSHKeyUploadView.as_view(), name="ssh_key_upload"), path("ssh-key/", SSHKeyUploadView.as_view(), name="ssh_key_upload"),
path("ssh-key/info/", SSHKeyInfoView.as_view(), name="ssh_key_info"), path("ssh-key/info/", SSHKeyInfoView.as_view(), name="ssh_key_info"),
path("ssh-key/view/", SSHKeyRetrieveView.as_view(), name="ssh_key_retrieve"), path("ssh-key/view/", SSHKeyRetrieveView.as_view(), name="ssh_key_retrieve"),
path(".well-known/jwks.json", jwks_view, name="jwks"), # django-jwks path(".well-known/jwks.json", jwks_view, name="jwks"), # django-jwks
# 관리자용 사용자 관리 API
path('users/', UserListView.as_view(), name='user_list'),
path('users/<int:pk>/', UserUpdateView.as_view(), name='user_update'),
# NHN Cloud 자격증명 API (기존 - 단일 프로젝트 호환)
path('nhn-cloud/', NHNCloudCredentialsView.as_view(), name='nhn_cloud_credentials'),
path('nhn-cloud/password/', NHNCloudPasswordView.as_view(), name='nhn_cloud_password'),
# NHN Cloud 프로젝트 API (멀티 프로젝트 지원)
path('nhn-cloud/projects/', NHNCloudProjectListView.as_view(), name='nhn_cloud_projects'),
path('nhn-cloud/projects/<int:project_id>/', NHNCloudProjectDetailView.as_view(), name='nhn_cloud_project_detail'),
path('nhn-cloud/projects/<int:project_id>/activate/', NHNCloudProjectActivateView.as_view(), name='nhn_cloud_project_activate'),
path('nhn-cloud/projects/<int:project_id>/password/', NHNCloudProjectPasswordView.as_view(), name='nhn_cloud_project_password'),
# KVM 서버 관리 API (멀티 서버 지원)
path('kvm-servers/', KVMServerListView.as_view(), name='kvm_server_list'),
path('kvm-servers/<int:server_id>/', KVMServerDetailView.as_view(), name='kvm_server_detail'),
path('kvm-servers/<int:server_id>/activate/', KVMServerActivateView.as_view(), name='kvm_server_activate'),
path('kvm-servers/<int:server_id>/ssh-key/', KVMServerSSHKeyView.as_view(), name='kvm_server_ssh_key'),
path('kvm-servers/<int:server_id>/ssh-key/upload/', KVMServerSSHKeyUploadView.as_view(), name='kvm_server_ssh_key_upload'),
# 소셜 로그인
path('auth/google/', GoogleLoginView.as_view(), name='google_login'),
path('auth/google/link-with-password/', GoogleLinkWithPasswordView.as_view(), name='google_link_with_password'),
path('auth/google/link/', GoogleLinkView.as_view(), name='google_link'),
path('auth/google/unlink/', GoogleUnlinkView.as_view(), name='google_unlink'),
# 사이트 설정
path('settings/', SiteSettingsView.as_view(), name='site_settings'),
] ]

File diff suppressed because it is too large Load Diff

1814
users/views.py.bak Normal file

File diff suppressed because it is too large Load Diff

View File

@ -1 +1 @@
v0.0.16_t1 v0.0.34