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
id: img-ver
uses: juliangruber/read-file-action@v1
with:
path: ./version
run: echo "content=$(cat ./version | tr -d '\n')" >> $GITHUB_OUTPUT
- name: Install Docker // Docker 설치
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
FROM python:3.10-slim-bullseye
FROM harbor.icurfer.com/open/python:3.10-slim-bullseye
# set work directory
WORKDIR /usr/src/app

148
README.md
View File

@ -1,33 +1,143 @@
# 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
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
```
### auth
### 운영 서버
```bash
gunicorn auth_prj.wsgi:application --bind 0.0.0.0:8000 --workers 3
```
## 2025-12-05 TRACE ENDPOINT 변경 ( v0.0.15 )
* 변경전 static
* 변경후 변수 처리
* TRACE_ENDPOINT='test'
* TRACE_SERVICE_NAME=''
### Docker
## 2025-09-29 jaeger Endpoint 변경 ( v0.0.14 )
* 변경전: endpoint="http://jaeger-collector.istio-system:4317",
* 변경후: endpoint="http://jaeger-collector.observability.svc.cluster.local:4317",
```bash
docker build -t msa-django-auth .
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 )
* 비대칭키 방식 → Private Key로 서명, Public Key로 검증.
* 토큰 발급 서버는 Private Key만 보관.
* 검증 서버들은 Public Key만 있으면 됨 → 여러 서비스/마이크로서비스 환경에 적합.
* Istio, Keycloak, Auth0 등 대부분의 IDP/게이트웨이가 RS256 + JWKS(JSON Web Key Set) 방식 권장.
### CustomUser
- email, name (고유)
- grade: admin, manager, user
- 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_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!
DEBUG = int(os.environ.get('DEBUG', 1))
@ -133,6 +136,9 @@ CORS_ALLOWED_ORIGINS = [
"http://localhost:3000",
"http://127.0.0.1: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",
"http://demo.test",
"https://www.demo.test",

View File

@ -17,12 +17,17 @@ from django.core.wsgi import get_wsgi_application
# ✅ DEBUG 모드 아닐 때만 OpenTelemetry 활성
if not settings.DEBUG:
import grpc
from opentelemetry import trace
from opentelemetry.sdk.resources import Resource
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
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(
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(
# endpoint="http://jaeger-collector.istio-system:4317",
# endpoint="jaeger-collector.observability.svc.cluster.local:4317",
endpoint=settings.TRACE_ENDPOINT,
insecure=True,
insecure=insecure,
credentials=credentials,
headers={
"X-Scope-OrgID": settings.SERVICE_PLATFORM,
"X-Service": settings.TRACE_SERVICE_NAME
"x-scope-orgid": settings.SERVICE_PLATFORM,
"x-service": settings.TRACE_SERVICE_NAME
}
)
@ -49,8 +68,23 @@ if not settings.DEBUG:
BatchSpanProcessor(otlp_exporter)
)
# Django 요청/응답 추적
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

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-http==1.34.0
opentelemetry-instrumentation==0.55b0
opentelemetry-instrumentation-dbapi==0.55b0
opentelemetry-instrumentation-django==0.55b0
opentelemetry-instrumentation-logging==0.55b0
opentelemetry-instrumentation-requests==0.55b0
opentelemetry-instrumentation-wsgi==0.55b0
opentelemetry-proto==1.34.0
opentelemetry-sdk==1.34.0
@ -46,3 +49,4 @@ uritemplate==4.1.1
urllib3==2.4.0
wrapt==1.17.2
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
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):
def create_user(self, email, password=None, **extra_fields):
if not email:
@ -35,11 +100,33 @@ class CustomUser(AbstractBaseUser, PermissionsMixin):
('user', '일반 사용자'),
)
GENDER_CHOICES = (
('M', '남성'),
('F', '여성'),
('O', '기타'),
)
EDUCATION_CHOICES = (
('high_school', '고등학교 졸업'),
('associate', '전문학사'),
('bachelor', '학사'),
('master', '석사'),
('doctor', '박사'),
('other', '기타'),
)
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')
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_staff = models.BooleanField(default=False)
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)
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()
USERNAME_FIELD = 'email'
@ -90,3 +193,203 @@ class CustomUser(AbstractBaseUser, PermissionsMixin):
"""
self.encrypted_private_key = self.encrypt_private_key(private_key)
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 .models import CustomUser
from .models import CustomUser, KVMServer
from rest_framework_simplejwt.serializers import TokenObtainPairSerializer
from rest_framework.exceptions import ValidationError
class RegisterSerializer(serializers.ModelSerializer):
password = serializers.CharField(write_only=True)
password = serializers.CharField(write_only=True, required=False)
has_password = serializers.SerializerMethodField()
class Meta:
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):
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.set_password(password)
user.password_changed_at = timezone.now() # 비밀번호 변경일 설정
user.save()
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):
# email 필드를 identifier로 재정의 (이메일 또는 이름 허용)
email = serializers.CharField()
@classmethod
def get_token(cls, user):
token = super().get_token(user)
@ -35,21 +103,116 @@ class CustomTokenObtainPairSerializer(TokenObtainPairSerializer):
return token
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")
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:
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:
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 # ✅ 수동 설정 필요
data = super().validate(attrs)
data["email"] = user.email
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
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 .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 .views_jwks import jwks_view # django-jwks
@ -7,11 +22,38 @@ urlpatterns = [
path('register/', RegisterView.as_view(), name='register'),
# path('login/', TokenObtainPairView.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('verify/', TokenVerifyView.as_view(), name='token_verify'),
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/info/", SSHKeyInfoView.as_view(), name="ssh_key_info"),
path("ssh-key/view/", SSHKeyRetrieveView.as_view(), name="ssh_key_retrieve"),
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