260706_1_계정, 로그인, 보안 초안 작성

This commit is contained in:
2026-07-07 19:17:13 +09:00
parent 404941c47a
commit f8633bb1fe
4696 changed files with 6110 additions and 642171 deletions
+61
View File
@@ -0,0 +1,61 @@
import { API_BASE_URL } from "@config/config_frontend";
async function api<T>(path: string, init?: RequestInit): Promise<T> {
const response = await fetch(`${API_BASE_URL}${path}`, {
credentials: "include",
...init,
headers: { "Content-Type": "application/json", ...init?.headers },
});
const result = (await response.json()) as T & { detail?: string };
if (!response.ok) throw new Error(result.detail ?? "Request failed");
return result;
}
export function getMasterDashboard(): Promise<Record<string, unknown>> {
return api("/security/master/dashboard");
}
export function getMembers(): Promise<Record<string, unknown>> {
return api("/security/master/members");
}
export function getJoinRequests(): Promise<Record<string, unknown>> {
return api("/security/master/join-requests");
}
export function decideJoinRequest(
requestId: number,
approved: boolean,
comment?: string,
): Promise<Record<string, unknown>> {
return api(`/security/master/join-requests/${requestId}/decision`, {
method: "POST",
body: JSON.stringify({ approved, comment }),
});
}
export function removeMember(userId: number): Promise<Record<string, unknown>> {
return api(`/security/master/members/${userId}/remove`, { method: "POST" });
}
export function getAdminDashboard(): Promise<Record<string, unknown>> {
return api("/security/admin/dashboard");
}
export function getAdminCollection(
collection: "companies" | "users" | "support",
): Promise<Record<string, unknown>> {
return api(`/security/admin/${collection}`);
}
export function updateAdminStatus(
entity: "company" | "user" | "support",
targetId: number,
status: string,
reason: string,
): Promise<Record<string, unknown>> {
return api(`/security/admin/${entity}/${targetId}/status`, {
method: "PATCH",
body: JSON.stringify({ status, reason }),
});
}
+122
View File
@@ -0,0 +1,122 @@
"""마스터 조직 관리, 활동 기록 및 시스템 관리자 API."""
import json
import psutil
from fastapi import APIRouter, Depends, HTTPException
from common_util.common_util_auth import require_master, require_system_admin, verify_session
from common_util.common_util_auth_repository import (
admin_dashboard,
admin_list,
admin_update_status,
decide_join_request,
list_company_members,
list_join_requests,
master_dashboard,
remove_member,
)
from common_util.common_util_email import send_email_background
from common_util.common_util_email_templates import join_result_email
from config.config_db import get_db_pool
from .A09_Security_Schema import ActivityLogRequest, AdminStatusRequest, JoinDecisionRequest
router = APIRouter(prefix="/api/security", tags=["Security"])
@router.get("/master/dashboard")
async def get_master_dashboard(session: dict = Depends(require_master)):
return {"status": "success", "dashboard": await master_dashboard(session["company_id"])}
@router.get("/master/members")
async def get_members(session: dict = Depends(require_master)):
return {"status": "success", "members": await list_company_members(session["company_id"])}
@router.get("/master/join-requests")
async def get_join_requests(session: dict = Depends(require_master)):
return {"status": "success", "requests": await list_join_requests(session["company_id"])}
@router.post("/master/join-requests/{request_id}/decision")
async def review_join_request(
request_id: int,
payload: JoinDecisionRequest,
session: dict = Depends(require_master),
):
user = await decide_join_request(
request_id, session["company_id"], session["user_id"], payload.approved, payload.comment
)
if not user:
raise HTTPException(status_code=404, detail="처리할 가입 요청이 없습니다.")
subject, html = join_result_email(payload.approved)
send_email_background(user["email"], subject, html)
return {"status": "success"}
@router.post("/master/members/{user_id}/remove")
async def deactivate_member(user_id: int, session: dict = Depends(require_master)):
if not await remove_member(user_id, session["company_id"]):
raise HTTPException(status_code=404, detail="내보낼 팀원을 찾을 수 없습니다.")
return {"status": "success"}
@router.post("/activity", status_code=202)
async def write_activity(payload: ActivityLogRequest, session: dict = Depends(verify_session)):
pool = get_db_pool()
async with pool.acquire() as connection, connection.cursor() as cursor:
await cursor.execute(
"""INSERT INTO activity_logs
(user_id, action_type, resource_id, status, details)
VALUES (%s, %s, %s, %s, %s)""",
(
session["user_id"],
payload.action_type,
payload.resource_id,
payload.status,
json.dumps(payload.details),
),
)
await connection.commit()
return {"status": "success"}
@router.get("/admin/dashboard")
async def get_admin_dashboard(session: dict = Depends(require_system_admin)):
disk = psutil.disk_usage("/")
process = psutil.Process()
resources = {
"cpu_percent": psutil.cpu_percent(interval=None),
"memory_percent": psutil.virtual_memory().percent,
"disk_total_gb": round(disk.total / 1024**3, 2),
"disk_used_gb": round(disk.used / 1024**3, 2),
"process_count": len(psutil.pids()),
"app_memory_mb": round(process.memory_info().rss / 1024**2, 2),
}
return {"status": "success", "dashboard": await admin_dashboard(), "resources": resources}
@router.get("/admin/{collection}")
async def get_admin_collection(collection: str, session: dict = Depends(require_system_admin)):
try:
rows = await admin_list(collection)
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
return {"status": "success", "items": rows}
@router.patch("/admin/{entity}/{target_id}/status")
async def set_admin_status(
entity: str,
target_id: int,
payload: AdminStatusRequest,
session: dict = Depends(require_system_admin),
):
changed = await admin_update_status(
session["user_id"], entity, target_id, payload.status, {"reason": payload.reason}
)
if not changed:
raise HTTPException(status_code=400, detail="대상 또는 상태값이 올바르지 않습니다.")
return {"status": "success"}
+24
View File
@@ -0,0 +1,24 @@
"""조직 승인 및 시스템 관리자 변경 요청 스키마."""
from typing import Literal
from pydantic import BaseModel, Field
class JoinDecisionRequest(BaseModel):
approved: bool
comment: str | None = Field(default=None, max_length=1000)
class AdminStatusRequest(BaseModel):
status: str = Field(min_length=3, max_length=20)
reason: str = Field(min_length=1, max_length=500)
class ActivityLogRequest(BaseModel):
action_type: Literal[
"FILE_UPLOAD", "FILE_DOWNLOAD", "PROJECT_SAVE", "DATA_PROCESS", "PAGE_VIEW"
]
resource_id: str | None = Field(default=None, max_length=255)
status: Literal["SUCCESS", "FAILURE"] = "SUCCESS"
details: dict = Field(default_factory=dict)
+16
View File
@@ -0,0 +1,16 @@
export const SECURITY_TERMS_VERSION = "1.0";
export const securityTerms = {
terms: [
"Aislo는 임도 설계 및 견적 자동화 기능을 제공하며, 계정의 무단 공유와 서비스 방해 행위를 금지합니다.",
"Aislo provides forest-road design and estimation automation. Account sharing and service disruption are prohibited.",
],
privacy: [
"계정, 회사, User-Agent 및 서비스 활동 기록을 인증·보안·운영 목적으로 처리합니다. IP 주소는 수집하지 않습니다.",
"Account, company, user-agent, and activity records are processed for authentication, security, and operations. IP addresses are not collected.",
],
security: [
"세션은 Secure HttpOnly 쿠키로 보호되며, 12시간 또는 4시간 비활동 후 만료됩니다.",
"Sessions use Secure HttpOnly cookies and expire after 12 hours or 4 hours of inactivity.",
],
} as const;
+30
View File
@@ -0,0 +1,30 @@
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
import { SECURITY_TERMS_VERSION, securityTerms } from "./A09_Security_Terms";
import "./A09_Security_UI_Style.css";
const L = (key: keyof typeof ui_locales): string => ui_locales[key][currentLanguageIndex];
export function renderA09Security(root: HTMLElement): void {
const page = document.createElement("article");
page.className = "a09-security";
const title = document.createElement("h1");
title.textContent = L("A09_Security_Title");
const version = document.createElement("p");
version.textContent = `${L("A09_Security_Version")} ${SECURITY_TERMS_VERSION}`;
page.append(title, version);
const sections = [
["A09_Security_Terms", securityTerms.terms],
["A09_Security_Privacy", securityTerms.privacy],
["A09_Security_Policy", securityTerms.security],
] as const;
for (const [headingKey, body] of sections) {
const section = document.createElement("section");
const heading = document.createElement("h2");
heading.textContent = L(headingKey);
const paragraph = document.createElement("p");
paragraph.textContent = body[currentLanguageIndex];
section.append(heading, paragraph);
page.append(section);
}
root.append(page);
}
+14
View File
@@ -0,0 +1,14 @@
.a09-security {
max-width: 800px;
margin: var(--spacing-64) auto;
padding: var(--spacing-40);
color: var(--color-text);
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-cards);
box-shadow: var(--shadow-sm);
}
.a09-security section {
margin-top: var(--spacing-32);
}