361 lines
12 KiB
Python
361 lines
12 KiB
Python
from __future__ import annotations
|
|
|
|
import hmac
|
|
import html
|
|
from urllib.parse import parse_qs, quote
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
|
from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse, Response
|
|
from fastapi.security import HTTPBasicCredentials
|
|
|
|
from ..auth import (
|
|
configured_password,
|
|
is_admin_authenticated,
|
|
is_records_authenticated,
|
|
security,
|
|
session_token,
|
|
verify_any_auth,
|
|
)
|
|
from ..config import (
|
|
ACCOUNTS_SESSION_COOKIE,
|
|
ADMIN_AUTH_PASSWORD,
|
|
ADMIN_SESSION_COOKIE,
|
|
BASIC_AUTH_PASSWORD,
|
|
RECORDS_SESSION_COOKIE,
|
|
SESSION_MAX_AGE,
|
|
STATIC_DIR,
|
|
)
|
|
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
def safe_next_path(value: str | None) -> str:
|
|
if not value or not value.startswith("/") or value.startswith("//"):
|
|
return "/"
|
|
return value
|
|
|
|
|
|
def render_login_page(title: str, action: str, next_path: str = "/", has_error: bool = False) -> str:
|
|
escaped_title = html.escape(title)
|
|
escaped_action = html.escape(action, quote=True)
|
|
escaped_next = html.escape(next_path, quote=True)
|
|
error_html = '<p class="error">密码不正确,请重新输入。</p>' if has_error else '<p class="hint">请输入访问密码。</p>'
|
|
return f"""<!doctype html>
|
|
<html lang="zh-CN">
|
|
<head>
|
|
<meta charset="utf-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
<title>{escaped_title}</title>
|
|
<style>
|
|
:root {{
|
|
color-scheme: light;
|
|
--bg: #f6f7f9;
|
|
--panel: #ffffff;
|
|
--text: #18202a;
|
|
--muted: #687487;
|
|
--line: #d9dee7;
|
|
--accent: #0f766e;
|
|
--accent-strong: #0b5d57;
|
|
--danger: #b42318;
|
|
font-family: Arial, "Songti SC", SimSun, sans-serif;
|
|
}}
|
|
* {{ box-sizing: border-box; }}
|
|
body {{
|
|
margin: 0;
|
|
min-height: 100vh;
|
|
display: grid;
|
|
place-items: center;
|
|
padding: 20px;
|
|
background: var(--bg);
|
|
color: var(--text);
|
|
}}
|
|
main {{
|
|
width: min(100%, 380px);
|
|
padding: 28px;
|
|
border: 1px solid var(--line);
|
|
border-radius: 8px;
|
|
background: var(--panel);
|
|
box-shadow: 0 12px 28px rgba(20, 31, 46, 0.08);
|
|
}}
|
|
h1 {{
|
|
margin: 0 0 10px;
|
|
font-size: 22px;
|
|
line-height: 1.25;
|
|
letter-spacing: 0;
|
|
}}
|
|
p {{
|
|
margin: 0 0 18px;
|
|
color: var(--muted);
|
|
font-size: 14px;
|
|
}}
|
|
.error {{ color: var(--danger); }}
|
|
label {{
|
|
display: block;
|
|
margin-bottom: 8px;
|
|
color: #344054;
|
|
font-size: 14px;
|
|
font-weight: 700;
|
|
}}
|
|
input {{
|
|
width: 100%;
|
|
height: 44px;
|
|
padding: 0 12px;
|
|
border: 1px solid var(--line);
|
|
border-radius: 6px;
|
|
color: var(--text);
|
|
font: inherit;
|
|
outline: none;
|
|
}}
|
|
.password-field {{
|
|
position: relative;
|
|
}}
|
|
.password-field input {{
|
|
padding-right: 48px;
|
|
}}
|
|
input:focus {{
|
|
border-color: var(--accent);
|
|
box-shadow: 0 0 0 3px rgba(15, 118, 110, 0.15);
|
|
}}
|
|
button {{
|
|
width: 100%;
|
|
min-height: 44px;
|
|
margin-top: 14px;
|
|
border: 0;
|
|
border-radius: 6px;
|
|
background: var(--accent);
|
|
color: #fff;
|
|
cursor: pointer;
|
|
font: inherit;
|
|
font-weight: 700;
|
|
}}
|
|
button:hover {{ background: var(--accent-strong); }}
|
|
.password-toggle {{
|
|
position: absolute;
|
|
top: 1px;
|
|
right: 1px;
|
|
width: 42px;
|
|
min-height: 42px;
|
|
margin-top: 0;
|
|
border: 0;
|
|
border-radius: 0 6px 6px 0;
|
|
background: transparent;
|
|
color: var(--muted);
|
|
}}
|
|
.password-toggle:hover {{
|
|
background: #eef2f6;
|
|
color: var(--accent-strong);
|
|
}}
|
|
.password-toggle svg {{
|
|
display: block;
|
|
width: 20px;
|
|
height: 20px;
|
|
margin: 0 auto;
|
|
fill: none;
|
|
stroke: currentColor;
|
|
stroke-width: 2;
|
|
stroke-linecap: round;
|
|
stroke-linejoin: round;
|
|
}}
|
|
.password-toggle .eye-open {{
|
|
display: none;
|
|
}}
|
|
.password-toggle.is-visible .eye-open {{
|
|
display: block;
|
|
}}
|
|
.password-toggle.is-visible .eye-closed {{
|
|
display: none;
|
|
}}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<main>
|
|
<h1>{escaped_title}</h1>
|
|
{error_html}
|
|
<form method="post" action="{escaped_action}" autocomplete="off">
|
|
<input type="hidden" name="next" value="{escaped_next}" />
|
|
<label for="password">访问密码</label>
|
|
<div class="password-field">
|
|
<input id="password" name="password" type="password" autocomplete="current-password" autofocus required />
|
|
<button id="togglePassword" class="password-toggle" type="button" aria-label="显示密码" title="显示密码">
|
|
<svg class="eye-open" viewBox="0 0 24 24" aria-hidden="true">
|
|
<path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7Z" />
|
|
<circle cx="12" cy="12" r="3" />
|
|
</svg>
|
|
<svg class="eye-closed" viewBox="0 0 24 24" aria-hidden="true">
|
|
<path d="M17.94 17.94A10.8 10.8 0 0 1 12 19C5.5 19 2 12 2 12a18.4 18.4 0 0 1 5.06-5.94" />
|
|
<path d="M9.9 4.24A10.9 10.9 0 0 1 12 4.05C18.5 4.05 22 12 22 12a18.5 18.5 0 0 1-2.16 3.19" />
|
|
<path d="M14.12 14.12A3 3 0 0 1 9.88 9.88" />
|
|
<path d="M3 3l18 18" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
<button type="submit">进入</button>
|
|
</form>
|
|
</main>
|
|
<script>
|
|
const passwordInput = document.querySelector("#password");
|
|
const togglePassword = document.querySelector("#togglePassword");
|
|
togglePassword.addEventListener("click", () => {{
|
|
const isVisible = passwordInput.type === "text";
|
|
passwordInput.type = isVisible ? "password" : "text";
|
|
togglePassword.classList.toggle("is-visible", !isVisible);
|
|
const label = isVisible ? "显示密码" : "隐藏密码";
|
|
togglePassword.setAttribute("aria-label", label);
|
|
togglePassword.setAttribute("title", label);
|
|
passwordInput.focus();
|
|
}});
|
|
</script>
|
|
</body>
|
|
</html>"""
|
|
|
|
|
|
@router.get("/")
|
|
def index(
|
|
request: Request,
|
|
credentials: HTTPBasicCredentials | None = Depends(security),
|
|
):
|
|
if not is_records_authenticated(request, credentials):
|
|
return RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER)
|
|
return FileResponse(STATIC_DIR / "index.html")
|
|
|
|
|
|
@router.head("/")
|
|
def index_head(
|
|
request: Request,
|
|
credentials: HTTPBasicCredentials | None = Depends(security),
|
|
):
|
|
if not is_records_authenticated(request, credentials):
|
|
return RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER)
|
|
return Response(status_code=status.HTTP_200_OK)
|
|
|
|
|
|
@router.get("/login")
|
|
def login_page(
|
|
request: Request,
|
|
credentials: HTTPBasicCredentials | None = Depends(security),
|
|
):
|
|
next_path = safe_next_path(request.query_params.get("next"))
|
|
has_error = request.query_params.get("error") == "1"
|
|
if is_records_authenticated(request, credentials):
|
|
return RedirectResponse(url=next_path, status_code=status.HTTP_303_SEE_OTHER)
|
|
return HTMLResponse(
|
|
render_login_page(
|
|
title="新时空教务管家",
|
|
action="/login",
|
|
next_path=next_path,
|
|
has_error=has_error,
|
|
)
|
|
)
|
|
|
|
|
|
@router.post("/login")
|
|
async def login_submit(request: Request):
|
|
body = (await request.body()).decode("utf-8")
|
|
form = parse_qs(body, keep_blank_values=True)
|
|
password = form.get("password", [""])[0]
|
|
next_path = safe_next_path(form.get("next", ["/"])[0])
|
|
records_password = configured_password("课程记录", BASIC_AUTH_PASSWORD)
|
|
if hmac.compare_digest(password, records_password):
|
|
response = RedirectResponse(url=next_path, status_code=status.HTTP_303_SEE_OTHER)
|
|
response.set_cookie(
|
|
RECORDS_SESSION_COOKIE,
|
|
session_token(records_password, b"xsk-records-web-session-v1"),
|
|
max_age=SESSION_MAX_AGE,
|
|
httponly=True,
|
|
samesite="lax",
|
|
)
|
|
return response
|
|
error_url = f"/login?error=1&next={quote(next_path)}"
|
|
return RedirectResponse(url=error_url, status_code=status.HTTP_303_SEE_OTHER)
|
|
|
|
|
|
@router.get("/logout")
|
|
def logout():
|
|
response = RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER)
|
|
response.delete_cookie(RECORDS_SESSION_COOKIE)
|
|
return response
|
|
|
|
|
|
@router.get("/accounts")
|
|
def accounts_index():
|
|
return RedirectResponse(url="/admin", status_code=status.HTTP_303_SEE_OTHER)
|
|
|
|
|
|
@router.get("/admin")
|
|
def admin_index(
|
|
request: Request,
|
|
credentials: HTTPBasicCredentials | None = Depends(security),
|
|
):
|
|
if not is_admin_authenticated(request, credentials):
|
|
return RedirectResponse(url="/admin/login?next=/admin", status_code=status.HTTP_303_SEE_OTHER)
|
|
return FileResponse(STATIC_DIR / "admin.html")
|
|
|
|
|
|
@router.get("/admin/login")
|
|
def admin_login_page(
|
|
request: Request,
|
|
credentials: HTTPBasicCredentials | None = Depends(security),
|
|
):
|
|
next_path = safe_next_path(request.query_params.get("next") or "/admin")
|
|
has_error = request.query_params.get("error") == "1"
|
|
if is_admin_authenticated(request, credentials):
|
|
return RedirectResponse(url=next_path, status_code=status.HTTP_303_SEE_OTHER)
|
|
return HTMLResponse(render_login_page("管理后台", "/admin/login", next_path, has_error))
|
|
|
|
|
|
@router.post("/admin/login")
|
|
async def admin_login_submit(request: Request):
|
|
body = (await request.body()).decode("utf-8")
|
|
form = parse_qs(body, keep_blank_values=True)
|
|
password = form.get("password", [""])[0]
|
|
next_path = safe_next_path(form.get("next", ["/admin"])[0])
|
|
admin_password = configured_password("管理后台", ADMIN_AUTH_PASSWORD)
|
|
if hmac.compare_digest(password, admin_password):
|
|
response = RedirectResponse(url=next_path, status_code=status.HTTP_303_SEE_OTHER)
|
|
response.set_cookie(
|
|
ADMIN_SESSION_COOKIE,
|
|
session_token(admin_password, b"xsk-admin-web-session-v1"),
|
|
max_age=SESSION_MAX_AGE,
|
|
httponly=True,
|
|
samesite="lax",
|
|
)
|
|
return response
|
|
error_url = f"/admin/login?error=1&next={quote(next_path)}"
|
|
return RedirectResponse(url=error_url, status_code=status.HTTP_303_SEE_OTHER)
|
|
|
|
|
|
@router.get("/admin/logout")
|
|
def admin_logout():
|
|
response = RedirectResponse(url="/admin/login", status_code=status.HTTP_303_SEE_OTHER)
|
|
response.delete_cookie(ADMIN_SESSION_COOKIE)
|
|
response.delete_cookie(ACCOUNTS_SESSION_COOKIE)
|
|
return response
|
|
|
|
|
|
@router.get("/accounts/login")
|
|
def accounts_login_page():
|
|
return RedirectResponse(url="/admin/login?next=/admin", status_code=status.HTTP_303_SEE_OTHER)
|
|
|
|
|
|
@router.post("/accounts/login")
|
|
async def accounts_login_submit(request: Request):
|
|
return await admin_login_submit(request)
|
|
|
|
|
|
@router.get("/accounts/logout")
|
|
def accounts_logout():
|
|
response = RedirectResponse(url="/admin/login", status_code=status.HTTP_303_SEE_OTHER)
|
|
response.delete_cookie(ACCOUNTS_SESSION_COOKIE)
|
|
response.delete_cookie(ADMIN_SESSION_COOKIE)
|
|
return response
|
|
|
|
|
|
@router.get("/static/{asset_path:path}")
|
|
def static_asset(asset_path: str, _user: str = Depends(verify_any_auth)):
|
|
target = (STATIC_DIR / asset_path).resolve()
|
|
static_root = STATIC_DIR.resolve()
|
|
if not target.is_file() or static_root not in target.parents:
|
|
raise HTTPException(status_code=404, detail="静态资源不存在")
|
|
return FileResponse(target)
|