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
+137
View File
@@ -0,0 +1,137 @@
"""비밀번호, OTP, 세션 쿠키와 권한 검증 공통 기능."""
import hashlib
import secrets
from datetime import datetime, timedelta
from typing import Any
import bcrypt
from fastapi import Depends, HTTPException, Request
from fastapi.responses import Response
from config.config_db import get_db_pool
from config.config_system import (
PASSWORD_BCRYPT_ROUNDS,
SESSION_COOKIE_NAME,
SESSION_COOKIE_SECURE,
SESSION_IDLE_TIMEOUT_SECONDS,
SESSION_MAX_AGE_SECONDS,
)
def hash_password(value: str) -> str:
return bcrypt.hashpw(value.encode(), bcrypt.gensalt(rounds=PASSWORD_BCRYPT_ROUNDS)).decode()
def verify_password(value: str, hashed: str) -> bool:
return bcrypt.checkpw(value.encode(), hashed.encode())
def generate_otp() -> str:
return f"{secrets.randbelow(1_000_000):06d}"
def hash_user_agent(user_agent: str) -> str:
return hashlib.sha256(user_agent.encode("utf-8")).hexdigest()
def set_session_cookie(response: Response, session_id: str) -> None:
response.set_cookie(
key=SESSION_COOKIE_NAME,
value=session_id,
max_age=SESSION_MAX_AGE_SECONDS,
secure=SESSION_COOKIE_SECURE,
httponly=True,
samesite="lax",
path="/",
)
def delete_session_cookie(response: Response) -> None:
response.delete_cookie(
key=SESSION_COOKIE_NAME,
secure=SESSION_COOKIE_SECURE,
httponly=True,
samesite="lax",
path="/",
)
async def create_session(user_id: int, user_agent: str) -> str:
session_id = secrets.token_urlsafe(32)
expires_at = datetime.utcnow() + timedelta(seconds=SESSION_MAX_AGE_SECONDS)
pool = get_db_pool()
async with pool.acquire() as connection, connection.cursor() as cursor:
await cursor.execute(
"""INSERT INTO sessions (id, user_id, user_agent_hash, expires_at)
VALUES (%s, %s, %s, %s)""",
(session_id, user_id, hash_user_agent(user_agent), expires_at),
)
await connection.commit()
return session_id
async def verify_session(request: Request) -> dict[str, Any]:
session_id = request.cookies.get(SESSION_COOKIE_NAME)
if not session_id:
raise HTTPException(status_code=401, detail="로그인이 필요합니다.")
pool = get_db_pool()
async with pool.acquire() as connection, connection.cursor() as cursor:
await cursor.execute(
"""SELECT s.id, s.user_id, s.created_at, s.expires_at, s.last_activity_at,
u.email, u.name, u.company_id, u.role, u.is_master, u.status
FROM sessions s JOIN users u ON u.id = s.user_id WHERE s.id = %s""",
(session_id,),
)
row = await cursor.fetchone()
if not row:
raise HTTPException(status_code=401, detail="유효하지 않은 세션입니다.")
now = datetime.utcnow()
expired = now > row[3] or (now - row[4]).total_seconds() > SESSION_IDLE_TIMEOUT_SECONDS
if expired or row[10] != "ACTIVE":
await cursor.execute("DELETE FROM sessions WHERE id = %s", (session_id,))
await connection.commit()
raise HTTPException(status_code=401, detail="세션이 만료되었습니다.")
await cursor.execute(
"UPDATE sessions SET last_activity_at = CURRENT_TIMESTAMP WHERE id = %s",
(session_id,),
)
await connection.commit()
return {
"session_id": row[0],
"user_id": row[1],
"email": row[5],
"name": row[6],
"company_id": row[7],
"role": row[8],
"is_master": bool(row[9]),
}
async def get_optional_session(request: Request) -> dict[str, Any] | None:
"""세션이 유효하면 세션 정보를, 없거나 만료되면 None을 반환한다.
로그인 여부와 무관하게 접근 가능한 엔드포인트(예: 기술지원 문의)에서
로그인 사용자면 추가 정보를 붙이기 위해 사용한다. 401을 던지지 않는다.
"""
try:
return await verify_session(request)
except HTTPException:
return None
async def require_master(session: dict[str, Any] = Depends(verify_session)) -> dict[str, Any]:
if not session["is_master"] and session["role"] != "SYSTEM_ADMIN":
raise HTTPException(status_code=403, detail="마스터 권한이 필요합니다.")
return session
async def require_system_admin(
session: dict[str, Any] = Depends(verify_session),
) -> dict[str, Any]:
if session["role"] != "SYSTEM_ADMIN":
raise HTTPException(status_code=403, detail="시스템 관리자 권한이 필요합니다.")
return session
+407
View File
@@ -0,0 +1,407 @@
"""인증·조직·보안 기능의 aiomysql Raw SQL 저장소."""
import json
import secrets
import string
from datetime import datetime, timedelta
from typing import Any
import aiomysql
from config.config_db import get_db_pool
from config.config_system import OTP_VALID_MINUTES
def _company_code() -> str:
alphabet = string.ascii_uppercase + string.digits
parts = ["".join(secrets.choice(alphabet) for _ in range(3)) for _ in range(3)]
return "-".join(parts)
async def get_user_by_email(email: str) -> dict[str, Any] | None:
pool = get_db_pool()
async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor:
await cursor.execute(
"SELECT * FROM users WHERE email = %s AND deleted_at IS NULL", (email,)
)
return await cursor.fetchone()
async def create_registration(data: dict[str, Any], password_hash: str) -> int:
pool = get_db_pool()
async with pool.acquire() as connection, connection.cursor() as cursor:
try:
await connection.begin()
await cursor.execute(
"""INSERT INTO users
(email, password_hash, name, role, is_master, status)
VALUES (%s, %s, %s, %s, %s, 'PENDING_EMAIL')""",
(
data["email"],
password_hash,
data["name"],
data["account_type"],
data["account_type"] == "MASTER",
),
)
user_id = cursor.lastrowid
if data["account_type"] == "MASTER":
await cursor.execute(
"""INSERT INTO companies
(name, business_registration_number, business_address, created_by,
company_code, phone_number, representative_name, master_user_id)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)""",
(
data["company_name"],
data.get("business_number"),
data.get("address"),
user_id,
_company_code(),
data.get("phone_number"),
data.get("representative_name"),
user_id,
),
)
company_id = cursor.lastrowid
await cursor.execute(
"UPDATE users SET company_id = %s WHERE id = %s", (company_id, user_id)
)
else:
await cursor.execute(
"INSERT INTO join_requests (user_id, company_id) VALUES (%s, %s)",
(user_id, data["company_id"]),
)
await cursor.execute(
"UPDATE users SET company_id = %s WHERE id = %s", (data["company_id"], user_id)
)
await cursor.execute(
"""INSERT INTO user_consents
(user_id, terms_version, terms_agreed, privacy_agreed, marketing_agreed)
VALUES (%s, %s, %s, %s, %s)""",
(
user_id,
data["terms_version"],
data["terms_agreed"],
data["privacy_agreed"],
data["marketing_agreed"],
),
)
await connection.commit()
return user_id
except Exception:
await connection.rollback()
raise
async def replace_otp(user_id: int, purpose: str, otp_hash: str) -> None:
expires_at = datetime.utcnow() + timedelta(minutes=OTP_VALID_MINUTES)
pool = get_db_pool()
async with pool.acquire() as connection, connection.cursor() as cursor:
await cursor.execute(
"""UPDATE email_otps SET consumed_at = CURRENT_TIMESTAMP
WHERE user_id = %s AND purpose = %s AND consumed_at IS NULL""",
(user_id, purpose),
)
await cursor.execute(
"""INSERT INTO email_otps (user_id, purpose, otp_hash, expires_at)
VALUES (%s, %s, %s, %s)""",
(user_id, purpose, otp_hash, expires_at),
)
await connection.commit()
async def get_active_otp(user_id: int, purpose: str) -> dict[str, Any] | None:
pool = get_db_pool()
async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor:
await cursor.execute(
"""SELECT id, otp_hash, expires_at FROM email_otps
WHERE user_id = %s AND purpose = %s AND consumed_at IS NULL
ORDER BY created_at DESC LIMIT 1""",
(user_id, purpose),
)
return await cursor.fetchone()
async def consume_otp(otp_id: int) -> None:
pool = get_db_pool()
async with pool.acquire() as connection, connection.cursor() as cursor:
await cursor.execute(
"UPDATE email_otps SET consumed_at = CURRENT_TIMESTAMP WHERE id = %s", (otp_id,)
)
await connection.commit()
async def complete_registration(user_id: int, is_master: bool) -> None:
status = "ACTIVE" if is_master else "PENDING"
pool = get_db_pool()
async with pool.acquire() as connection, connection.cursor() as cursor:
await cursor.execute(
"""UPDATE users SET status = %s, last_email_verified_at = CURRENT_TIMESTAMP
WHERE id = %s""",
(status, user_id),
)
await connection.commit()
async def company_master_email(company_id: int) -> str | None:
pool = get_db_pool()
async with pool.acquire() as connection, connection.cursor() as cursor:
await cursor.execute(
"""SELECT u.email FROM companies c JOIN users u ON u.id = c.master_user_id
WHERE c.id = %s AND c.status = 'ACTIVE'""",
(company_id,),
)
row = await cursor.fetchone()
return row[0] if row else None
async def search_companies(query: str) -> list[dict[str, Any]]:
pool = get_db_pool()
async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor:
await cursor.execute(
"""SELECT id, name FROM companies
WHERE status = 'ACTIVE' AND deleted_at IS NULL AND name LIKE %s
ORDER BY name LIMIT 20""",
(f"%{query}%",),
)
return list(await cursor.fetchall())
async def record_login(
user_id: int | None, email: str, status: str, reason: str | None, user_agent: str
) -> None:
pool = get_db_pool()
async with pool.acquire() as connection, connection.cursor() as cursor:
await cursor.execute(
"""INSERT INTO login_logs (user_id, email, status, failure_reason, user_agent)
VALUES (%s, %s, %s, %s, %s)""",
(user_id, email, status, reason, user_agent),
)
await connection.commit()
async def record_failed_login(user_id: int) -> int:
pool = get_db_pool()
async with pool.acquire() as connection, connection.cursor() as cursor:
await cursor.execute(
"""UPDATE users SET login_failures = login_failures + 1,
last_failed_at = CURRENT_TIMESTAMP,
account_locked_until = CASE WHEN login_failures + 1 >= 5
THEN DATE_ADD(CURRENT_TIMESTAMP, INTERVAL 15 MINUTE)
ELSE account_locked_until END
WHERE id = %s""",
(user_id,),
)
await cursor.execute("SELECT login_failures FROM users WHERE id = %s", (user_id,))
failures = (await cursor.fetchone())[0]
await connection.commit()
return failures
async def clear_login_failures(user_id: int) -> None:
pool = get_db_pool()
async with pool.acquire() as connection, connection.cursor() as cursor:
await cursor.execute(
"""UPDATE users SET login_failures = 0, last_failed_at = NULL,
account_locked_until = NULL WHERE id = %s""",
(user_id,),
)
await connection.commit()
async def has_known_browser(user_id: int, user_agent_hash: str) -> bool:
pool = get_db_pool()
async with pool.acquire() as connection, connection.cursor() as cursor:
await cursor.execute(
"SELECT 1 FROM trusted_devices WHERE user_id = %s AND user_agent_hash = %s LIMIT 1",
(user_id, user_agent_hash),
)
return await cursor.fetchone() is not None
async def trust_browser(user_id: int, user_agent_hash: str) -> None:
pool = get_db_pool()
async with pool.acquire() as connection, connection.cursor() as cursor:
await cursor.execute(
"""INSERT INTO trusted_devices (user_id, user_agent_hash)
VALUES (%s, %s) ON DUPLICATE KEY UPDATE last_used_at = CURRENT_TIMESTAMP""",
(user_id, user_agent_hash),
)
await connection.commit()
async def delete_session(session_id: str) -> None:
pool = get_db_pool()
async with pool.acquire() as connection, connection.cursor() as cursor:
await cursor.execute("DELETE FROM sessions WHERE id = %s", (session_id,))
await connection.commit()
async def delete_user_sessions(user_id: int) -> None:
pool = get_db_pool()
async with pool.acquire() as connection, connection.cursor() as cursor:
await cursor.execute("DELETE FROM sessions WHERE user_id = %s", (user_id,))
await connection.commit()
async def change_password(user_id: int, password_hash: str, logout_all: bool) -> None:
pool = get_db_pool()
async with pool.acquire() as connection, connection.cursor() as cursor:
await cursor.execute(
"""UPDATE users SET password_hash = %s, last_email_verified_at = NULL
WHERE id = %s""",
(password_hash, user_id),
)
if logout_all:
await cursor.execute("DELETE FROM sessions WHERE user_id = %s", (user_id,))
await connection.commit()
async def list_join_requests(company_id: int) -> list[dict[str, Any]]:
pool = get_db_pool()
async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor:
await cursor.execute(
"""SELECT jr.id, jr.status, jr.requested_at, u.id AS user_id, u.name, u.email
FROM join_requests jr JOIN users u ON u.id = jr.user_id
WHERE jr.company_id = %s ORDER BY jr.requested_at DESC""",
(company_id,),
)
return list(await cursor.fetchall())
async def decide_join_request(
request_id: int, company_id: int, reviewer_id: int, approved: bool, comment: str | None
) -> dict[str, Any] | None:
pool = get_db_pool()
async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor:
try:
await connection.begin()
await cursor.execute(
"""SELECT user_id FROM join_requests
WHERE id = %s AND company_id = %s AND status = 'PENDING'
FOR UPDATE""",
(request_id, company_id),
)
request_row = await cursor.fetchone()
if not request_row:
await connection.rollback()
return None
decision = "APPROVED" if approved else "REJECTED"
user_status = "ACTIVE" if approved else "REJECTED"
await cursor.execute(
"""UPDATE join_requests SET status = %s, reviewed_at = CURRENT_TIMESTAMP,
reviewed_by = %s, review_comment = %s WHERE id = %s""",
(decision, reviewer_id, comment, request_id),
)
await cursor.execute(
"UPDATE users SET status = %s WHERE id = %s", (user_status, request_row["user_id"])
)
await cursor.execute(
"SELECT id, email, name FROM users WHERE id = %s", (request_row["user_id"],)
)
user = await cursor.fetchone()
await connection.commit()
return user
except Exception:
await connection.rollback()
raise
async def list_company_members(company_id: int) -> list[dict[str, Any]]:
pool = get_db_pool()
async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor:
await cursor.execute(
"""SELECT id, name, email, role, status, last_email_verified_at, created_at
FROM users WHERE company_id = %s AND deleted_at IS NULL ORDER BY name""",
(company_id,),
)
return list(await cursor.fetchall())
async def remove_member(user_id: int, company_id: int) -> bool:
pool = get_db_pool()
async with pool.acquire() as connection, connection.cursor() as cursor:
await cursor.execute(
"""UPDATE users SET status = 'INACTIVE' WHERE id = %s AND company_id = %s
AND is_master = FALSE""",
(user_id, company_id),
)
changed = cursor.rowcount > 0
if changed:
await cursor.execute("DELETE FROM sessions WHERE user_id = %s", (user_id,))
await connection.commit()
return changed
async def master_dashboard(company_id: int) -> dict[str, Any]:
pool = get_db_pool()
async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor:
await cursor.execute(
"""SELECT c.id, c.name, c.subscription_status,
COUNT(DISTINCT u.id) AS member_count,
SUM(u.status = 'ACTIVE') AS active_member_count,
COUNT(DISTINCT jr.id) AS pending_request_count
FROM companies c LEFT JOIN users u ON u.company_id = c.id
LEFT JOIN join_requests jr ON jr.company_id = c.id AND jr.status = 'PENDING'
WHERE c.id = %s GROUP BY c.id""",
(company_id,),
)
return await cursor.fetchone() or {}
async def admin_dashboard() -> dict[str, Any]:
pool = get_db_pool()
async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor:
await cursor.execute(
"""SELECT
(SELECT COUNT(*) FROM users WHERE status = 'ACTIVE') AS active_users,
(SELECT COUNT(*) FROM companies WHERE status = 'ACTIVE') AS active_companies,
(SELECT COUNT(*) FROM sessions
WHERE expires_at > CURRENT_TIMESTAMP) AS active_sessions,
(SELECT COUNT(*) FROM support_requests
WHERE status = 'NEW') AS new_support_requests"""
)
return await cursor.fetchone() or {}
async def admin_list(table: str) -> list[dict[str, Any]]:
queries = {
"companies": """SELECT id, name, status, subscription_status, created_at
FROM companies ORDER BY created_at DESC""",
"users": """SELECT id, email, name, company_id, role, status, created_at
FROM users ORDER BY created_at DESC""",
"support": "SELECT * FROM support_requests ORDER BY created_at DESC",
}
if table not in queries:
raise ValueError("지원하지 않는 관리자 목록입니다.")
pool = get_db_pool()
async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor:
await cursor.execute(queries[table])
return list(await cursor.fetchall())
async def admin_update_status(
admin_id: int, entity: str, target_id: int, status: str, details: dict[str, Any]
) -> bool:
allowed = {
"company": ("companies", {"ACTIVE", "INACTIVE", "SUSPENDED"}),
"user": ("users", {"ACTIVE", "INACTIVE", "REJECTED"}),
"support": ("support_requests", {"NEW", "REVIEWED", "RESOLVED", "DELETED"}),
}
if entity not in allowed or status not in allowed[entity][1]:
return False
table = allowed[entity][0]
pool = get_db_pool()
async with pool.acquire() as connection, connection.cursor() as cursor:
await connection.begin()
await cursor.execute(f"UPDATE {table} SET status = %s WHERE id = %s", (status, target_id))
changed = cursor.rowcount > 0
if entity == "user" and status != "ACTIVE":
await cursor.execute("DELETE FROM sessions WHERE user_id = %s", (target_id,))
await cursor.execute(
"""INSERT INTO system_admin_logs (admin_user_id, action_type, target_id, details)
VALUES (%s, %s, %s, %s)""",
(admin_id, f"{entity.upper()}_STATUS", str(target_id), json.dumps(details)),
)
await connection.commit()
return changed
+64
View File
@@ -0,0 +1,64 @@
"""표준 라이브러리 기반 비동기 SMTP 발송 서비스."""
import asyncio
import logging
import smtplib
from email.message import EmailMessage
from email.utils import formataddr
from config.config_system import (
EMAIL_FROM_ADDRESS,
EMAIL_FROM_NAME,
EMAIL_NOTIFICATION_ENABLED,
EMAIL_RETRY_COUNT,
EMAIL_RETRY_DELAY_SECONDS,
SMTP_HOST,
SMTP_PASSWORD,
SMTP_PORT,
SMTP_USE_TLS,
SMTP_USERNAME,
)
logger = logging.getLogger(__name__)
def _send_sync(to_email: str, subject: str, html: str) -> None:
message = EmailMessage()
message["From"] = formataddr((EMAIL_FROM_NAME, EMAIL_FROM_ADDRESS))
message["To"] = to_email
message["Subject"] = subject
message.set_content("HTML 이메일을 지원하는 메일 앱에서 확인해 주세요.")
message.add_alternative(html, subtype="html")
with smtplib.SMTP(SMTP_HOST, SMTP_PORT, timeout=30) as smtp:
if SMTP_USE_TLS:
smtp.starttls()
smtp.login(SMTP_USERNAME, SMTP_PASSWORD)
smtp.send_message(message)
async def send_email(to_email: str, subject: str, html: str) -> bool:
"""SMTP 발송을 워커 스레드에서 실행하고 설정된 횟수만큼 재시도한다."""
if not EMAIL_NOTIFICATION_ENABLED:
logger.info("이메일 발송 비활성화: %s", to_email)
return True
if not SMTP_USERNAME or not SMTP_PASSWORD or not EMAIL_FROM_ADDRESS:
logger.error("SMTP 환경 변수가 설정되지 않았습니다.")
return False
for attempt in range(1, EMAIL_RETRY_COUNT + 1):
try:
await asyncio.to_thread(_send_sync, to_email, subject, html)
logger.info("이메일 발송 성공: %s", to_email)
return True
except (OSError, smtplib.SMTPException):
logger.exception("이메일 발송 실패 (%s/%s)", attempt, EMAIL_RETRY_COUNT)
if attempt < EMAIL_RETRY_COUNT:
await asyncio.sleep(EMAIL_RETRY_DELAY_SECONDS * (2 ** (attempt - 1)))
return False
def send_email_background(to_email: str, subject: str, html: str) -> None:
"""HTTP 응답을 차단하지 않고 이메일 발송을 예약한다."""
task = asyncio.create_task(send_email(to_email, subject, html))
task.add_done_callback(lambda completed: completed.exception())
@@ -0,0 +1,70 @@
"""Aislo 인증·조직 알림 이메일 템플릿."""
from html import escape
def otp_email(code: str, purpose: str) -> tuple[str, str]:
safe_code = escape(code)
safe_purpose = escape(purpose)
subject = f"[Aislo] {purpose} 인증 코드"
html = (
"<h2>Aislo 이메일 인증</h2>"
f"<p>{safe_purpose} 인증 코드는 다음과 같습니다.</p>"
f"<p style='font-size:28px;font-weight:700;letter-spacing:6px'>{safe_code}</p>"
"<p>코드는 5분 동안 유효합니다.</p>"
)
return subject, html
def join_request_email(member_name: str, member_email: str) -> tuple[str, str]:
subject = "[Aislo] 신규 팀원 가입 승인 요청"
html = (
"<h2>팀원 가입 승인 요청</h2>"
f"<p>{escape(member_name)} ({escape(member_email)}) 님이 회사 가입을 요청했습니다.</p>"
"<p>Aislo 마스터 화면에서 승인 또는 거부해 주세요.</p>"
)
return subject, html
def join_result_email(approved: bool) -> tuple[str, str]:
result = "승인" if approved else "거부"
return f"[Aislo] 가입 요청 {result}", f"<p>회사 가입 요청이 {result}되었습니다.</p>"
def security_alert_email(email: str, failures: int) -> tuple[str, str]:
return (
"[Aislo] 반복 로그인 실패 경고",
f"<p>{escape(email)} 계정에서 로그인 실패가 {failures}회 발생했습니다.</p>",
)
def support_request_email(
name: str,
email: str,
phone: str | None,
company_name: str | None,
subject: str,
message: str,
) -> tuple[str, str]:
# 제목: 회사명이 있으면 [회사명 · 이름], 없으면 [이름]
who = f"{company_name} · {name}" if company_name else name
subject_text = f"[Aislo 문의|{escape(who)}] {escape(subject)}"
# 줄바꿈을 <br>로 변환하여 본문 가독성 유지
safe_message = escape(message).replace("\n", "<br>")
company_row = f"<p><strong>회사:</strong> {escape(company_name)}</p>" if company_name else ""
phone_row = f"<p><strong>연락처:</strong> {escape(phone)}</p>" if phone else ""
html = (
"<h2>새 기술지원 문의가 접수되었습니다</h2>"
f"{company_row}"
f"<p><strong>이름:</strong> {escape(name)}</p>"
f"<p><strong>이메일:</strong> {escape(email)}</p>"
f"{phone_row}"
f"<p><strong>제목:</strong> {escape(subject)}</p>"
"<hr>"
f"<p><strong>문의 내용:</strong></p><p>{safe_message}</p>"
"<hr>"
f"<p>답변은 위 이메일 주소(<a href='mailto:{escape(email)}'>"
f"{escape(email)}</a>)로 회신하세요.</p>"
)
return subject_text, html