260706_1_계정, 로그인, 보안 초안 작성
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user