Compare commits
23 Commits
64032861e0
...
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 | |||
| e4b185332b | |||
| b973522477 |
4
.github/workflows/build.yaml
vendored
4
.github/workflows/build.yaml
vendored
@ -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
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
|
||||
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
148
README.md
@ -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
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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
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-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
|
||||
|
||||
@ -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='전화번호'),
|
||||
),
|
||||
]
|
||||
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'],
|
||||
},
|
||||
),
|
||||
]
|
||||
305
users/models.py
305
users/models.py
@ -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
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,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
|
||||
|
||||
@ -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'),
|
||||
]
|
||||
|
||||
2076
users/views.py
2076
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