Compare commits
21 Commits
e4b185332b
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 1a0996c146 | |||
| 42aca6cfe3 | |||
| b387a06394 | |||
| 86064d48f9 | |||
| ca43c73dfb | |||
| 6b4d38ad5f | |||
| 1c7f241b37 | |||
| d6bec2c883 | |||
| 03f7ad94a9 | |||
| 96615e4b94 | |||
| 27d7101f0f | |||
| 9204e56152 | |||
| 2c050829ff | |||
| 85f5688a0b | |||
| d79c57d11b | |||
| e102d6766a | |||
| c97b3c6c3b | |||
| 18901938c7 | |||
| e318855b14 | |||
| dce4663a67 | |||
| 00db74a333 |
4
.github/workflows/build.yaml
vendored
4
.github/workflows/build.yaml
vendored
@ -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
16
CLAUDE.md
Normal 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' : 프론트엔드
|
||||||
@ -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
|
||||||
|
|||||||
150
README.md
150
README.md
@ -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
|
### CustomUser
|
||||||
|
|
||||||
## 2025-09-28 RS256변경 적용 ( v0.0.12 )
|
- email, name (고유)
|
||||||
* 비대칭키 방식 → Private Key로 서명, Public Key로 검증.
|
- grade: admin, manager, user
|
||||||
* 토큰 발급 서버는 Private Key만 보관.
|
- SSH 키 필드 (암호화)
|
||||||
* 검증 서버들은 Public Key만 있으면 됨 → 여러 서비스/마이크로서비스 환경에 적합.
|
- NHN Cloud 자격증명 (암호화)
|
||||||
* Istio, Keycloak, Auth0 등 대부분의 IDP/게이트웨이가 RS256 + JWKS(JSON Web Key Set) 방식 권장.
|
|
||||||
|
### NHNCloudProject
|
||||||
|
|
||||||
|
- 사용자별 멀티 프로젝트
|
||||||
|
- tenant_id, username, 암호화된 password
|
||||||
|
- dns_appkey
|
||||||
|
|
||||||
|
### KVMServer
|
||||||
|
|
||||||
|
- 사용자별 서버 관리
|
||||||
|
- host, port, username
|
||||||
|
- SSH 키 (암호화), Libvirt URI
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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
33
certs/ca.crt
Normal 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
67
convert_spans.py
Normal 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("변환 완료")
|
||||||
@ -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
|
||||||
|
|||||||
18
users/migrations/0008_add_unique_name.py
Normal file
18
users/migrations/0008_add_unique_name.py
Normal 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),
|
||||||
|
),
|
||||||
|
]
|
||||||
33
users/migrations/0009_add_nhn_cloud_credentials.py
Normal file
33
users/migrations/0009_add_nhn_cloud_credentials.py
Normal 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'),
|
||||||
|
),
|
||||||
|
]
|
||||||
36
users/migrations/0010_nhncloudproject.py
Normal file
36
users/migrations/0010_nhncloudproject.py
Normal 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')},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
18
users/migrations/0011_add_dns_appkey_to_nhncloudproject.py
Normal file
18
users/migrations/0011_add_dns_appkey_to_nhncloudproject.py
Normal 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'),
|
||||||
|
),
|
||||||
|
]
|
||||||
41
users/migrations/0012_add_kvm_server.py
Normal file
41
users/migrations/0012_add_kvm_server.py
Normal 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')},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
28
users/migrations/0013_add_social_login_fields.py
Normal file
28
users/migrations/0013_add_social_login_fields.py
Normal 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='소셜 로그인 제공자'),
|
||||||
|
),
|
||||||
|
]
|
||||||
25
users/migrations/0014_sitesettings.py
Normal file
25
users/migrations/0014_sitesettings.py
Normal 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': '사이트 설정',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -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'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
283
users/models.py
283
users/models.py
@ -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:
|
||||||
@ -51,7 +116,7 @@ class CustomUser(AbstractBaseUser, PermissionsMixin):
|
|||||||
)
|
)
|
||||||
|
|
||||||
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="설명")
|
||||||
|
|
||||||
@ -71,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'
|
||||||
@ -112,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
245
users/password_utils.py
Normal 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
|
||||||
@ -1,25 +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")
|
"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)
|
||||||
@ -36,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
|
||||||
|
|||||||
@ -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'),
|
||||||
]
|
]
|
||||||
|
|||||||
2144
users/views.py
2144
users/views.py
File diff suppressed because it is too large
Load Diff
1814
users/views.py.bak
Normal file
1814
users/views.py.bak
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user