- Introduced common.js for shared functions including API calls, error reporting, and UI helpers. - Implemented feedback.js for the feedback reporting page, including form handling and displaying feedback status. - Added layout.js for managing galaxy and section layouts in the visualization. - Created login.js for user authentication with session management. - Developed search.js for term searching based on user input and synonyms. - Implemented signup.js for user registration with validation and immediate login.
179 lines
5.6 KiB
Python
179 lines
5.6 KiB
Python
"""인증.
|
|
|
|
- 교수자(owner): admins 테이블의 아이디·비밀번호 계정. 용어·관계·문제 수정, 회원·반 관리, 설정.
|
|
처음 실행할 때 아이디 ATLAS_OWNER_USERNAME(기본 professor), 비밀번호 ATLAS_OWNER_PASSWORD 또는
|
|
data/.owner_password 로 계정을 만든다. 이후 비밀번호는 DB에 해시로만 저장된다.
|
|
- 회원(member): 학생 계정. 교수자가 승인하면 사례·문제를 볼 수 있다. 학생·방문자는 오류 신고만 할 수 있다.
|
|
모두 서명된 쿠키 세션을 쓴다.
|
|
"""
|
|
import base64
|
|
import hashlib
|
|
import hmac
|
|
import os
|
|
import secrets
|
|
import time
|
|
|
|
from fastapi import HTTPException, Request
|
|
|
|
from .db import DB_PATH
|
|
|
|
DATA = DB_PATH.parent
|
|
COOKIE = "atlas_admin"
|
|
MEMBER_COOKIE = "atlas_member"
|
|
TTL = 60 * 60 * 24 * 14
|
|
|
|
|
|
def _read_or_create(path, factory):
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
if path.exists():
|
|
return path.read_text().strip()
|
|
v = factory()
|
|
path.write_text(v)
|
|
try:
|
|
os.chmod(path, 0o600)
|
|
except OSError:
|
|
pass
|
|
return v
|
|
|
|
|
|
def secret() -> bytes:
|
|
return _read_or_create(DATA / ".secret", lambda: secrets.token_hex(32)).encode()
|
|
|
|
|
|
def owner_password() -> str:
|
|
env = os.environ.get("ATLAS_OWNER_PASSWORD")
|
|
if env:
|
|
return env
|
|
return _read_or_create(DATA / ".owner_password", lambda: secrets.token_urlsafe(12))
|
|
|
|
|
|
# ---------------------------------------------------------------- 서명 토큰
|
|
|
|
def sign(payload: str) -> str:
|
|
raw = payload.encode()
|
|
sig = hmac.new(secret(), raw, hashlib.sha256).hexdigest()
|
|
return base64.urlsafe_b64encode(raw).decode() + "." + sig
|
|
|
|
|
|
def unsign(token: str | None) -> str | None:
|
|
if not token or "." not in token:
|
|
return None
|
|
b64, sig = token.rsplit(".", 1)
|
|
try:
|
|
raw = base64.urlsafe_b64decode(b64.encode())
|
|
except Exception:
|
|
return None
|
|
if not hmac.compare_digest(hmac.new(secret(), raw, hashlib.sha256).hexdigest(), sig):
|
|
return None
|
|
return raw.decode()
|
|
|
|
|
|
def make_token(name: str, role: str = "owner") -> str:
|
|
return sign(f"{name}|{role}|{int(time.time()) + TTL}")
|
|
|
|
|
|
def read_session(token: str | None) -> tuple[str, str] | None:
|
|
"""(이름, 역할) 또는 None. 예전 형식(이름|만료)은 검수자로 본다."""
|
|
p = unsign(token)
|
|
if not p:
|
|
return None
|
|
parts = p.rsplit("|", 2)
|
|
if len(parts) == 3:
|
|
name, role, exp = parts
|
|
else:
|
|
name, exp = p.rsplit("|", 1)
|
|
role = "reviewer"
|
|
if not exp.isdigit() or int(exp) < time.time():
|
|
return None
|
|
return name, role
|
|
|
|
|
|
def read_token(token: str | None) -> str | None:
|
|
s = read_session(token)
|
|
return s[0] if s else None
|
|
|
|
|
|
def ensure_owner_account(con) -> dict | None:
|
|
"""교수자 계정이 없으면 만든다. 새로 만들었으면 {username, password} 를 돌려준다."""
|
|
if con.execute("SELECT 1 FROM admins").fetchone():
|
|
return None
|
|
username = os.environ.get("ATLAS_OWNER_USERNAME", "professor")
|
|
pw = owner_password()
|
|
with con:
|
|
con.execute("INSERT INTO admins(username,password_hash,name,created_at) VALUES(?,?,?,?)",
|
|
(username, hash_password(pw), "교수자", time.strftime("%Y-%m-%dT%H:%M:%S")))
|
|
# 평문 비밀번호 파일은 계정을 만든 뒤 지운다 (이후엔 DB의 해시만 사용)
|
|
f = DATA / ".owner_password"
|
|
if not os.environ.get("ATLAS_OWNER_PASSWORD") and f.exists():
|
|
f.unlink()
|
|
return {"username": username, "password": pw}
|
|
|
|
|
|
def check_login(con, username: str, pw: str) -> dict | None:
|
|
"""교수자 아이디·비밀번호가 맞으면 계정 행"""
|
|
r = con.execute("SELECT * FROM admins WHERE username=?", (username.strip(),)).fetchone()
|
|
if r and verify_password(pw, r["password_hash"]):
|
|
return dict(r)
|
|
return None
|
|
|
|
|
|
def current_admin(request: Request) -> str | None:
|
|
"""교수자로 로그인했으면 이름 (예전 검수자 세션은 인정하지 않음)."""
|
|
s = read_session(request.cookies.get(COOKIE))
|
|
return s[0] if s and s[1] == "owner" else None
|
|
|
|
|
|
def current_role(request: Request) -> str | None:
|
|
s = read_session(request.cookies.get(COOKIE))
|
|
return s[1] if s else None
|
|
|
|
|
|
def require_admin(request: Request) -> str:
|
|
name = current_admin(request)
|
|
if not name:
|
|
raise HTTPException(401, "교수자 로그인이 필요합니다.")
|
|
return name
|
|
|
|
|
|
def require_owner(request: Request) -> str:
|
|
s = read_session(request.cookies.get(COOKIE))
|
|
if not s:
|
|
raise HTTPException(401, "관리자 로그인이 필요합니다.")
|
|
if s[1] != "owner":
|
|
raise HTTPException(401, "교수자 로그인이 필요합니다.")
|
|
return s[0]
|
|
|
|
|
|
# ---------------------------------------------------------------- 회원 비밀번호·세션
|
|
|
|
def hash_password(pw: str) -> str:
|
|
salt = os.urandom(16)
|
|
h = hashlib.pbkdf2_hmac("sha256", pw.encode(), salt, 200_000)
|
|
return f"pbkdf2${salt.hex()}${h.hex()}"
|
|
|
|
|
|
def verify_password(pw: str, stored: str | None) -> bool:
|
|
try:
|
|
_, salt, h = (stored or "").split("$")
|
|
except ValueError:
|
|
return False
|
|
calc = hashlib.pbkdf2_hmac("sha256", pw.encode(), bytes.fromhex(salt), 200_000).hex()
|
|
return hmac.compare_digest(calc, h)
|
|
|
|
|
|
def make_member_token(user_id: int) -> str:
|
|
return sign(f"m|{user_id}|{int(time.time()) + TTL}")
|
|
|
|
|
|
def member_id(request: Request) -> int | None:
|
|
p = unsign(request.cookies.get(MEMBER_COOKIE))
|
|
if not p:
|
|
return None
|
|
try:
|
|
tag, uid, exp = p.split("|")
|
|
except ValueError:
|
|
return None
|
|
if tag != "m" or int(exp) < time.time():
|
|
return None
|
|
return int(uid)
|