feat: tighten linux login flow and refresh lab entry
This commit is contained in:
2267
index.html
2267
index.html
File diff suppressed because it is too large
Load Diff
48
login.html
Normal file
48
login.html
Normal file
@@ -0,0 +1,48 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Linux Login</title>
|
||||
<style>
|
||||
:root{--bg:#f4f8fc;--panel:#fff;--line:#d8e2ee;--text:#102033;--muted:#5d7187;--brand:#0f6db6;--soft:#e9f3ff}
|
||||
[data-theme="dark"]{--bg:#08111d;--panel:#0c1826;--line:#1d3245;--text:#edf4fb;--muted:#9bb0c3;--brand:#67baff;--soft:rgba(103,186,255,.1)}
|
||||
*{box-sizing:border-box} body{margin:0;min-height:100vh;display:grid;place-items:center;padding:20px;background:var(--bg);color:var(--text);font-family:"Segoe UI","Microsoft YaHei",sans-serif}
|
||||
.card{width:min(100%,460px);background:var(--panel);border:1px solid var(--line);border-radius:20px;padding:28px} .toolbar,.tips{display:flex;flex-wrap:wrap;gap:10px}.toolbar{justify-content:flex-end;margin-bottom:16px}
|
||||
button,input{font:inherit}.btn,.ghost,.chip{border-radius:999px;padding:10px 14px;font-weight:700}.btn{border:0;background:linear-gradient(135deg,var(--brand),#37a1ff);color:#fff;cursor:pointer;width:100%}.ghost{border:1px solid var(--line);background:transparent;color:var(--text);cursor:pointer}.chip{display:inline-flex;background:var(--soft);color:var(--brand)}
|
||||
form{display:grid;gap:12px;margin-top:16px} input{padding:12px;border:1px solid var(--line);border-radius:12px;background:transparent;color:var(--text)} .eyebrow{font-size:12px;font-weight:800;letter-spacing:.12em;text-transform:uppercase;color:var(--brand)} h1,p{margin:0} p{color:var(--muted);line-height:1.8}.msg{margin-top:14px;padding:12px;border:1px solid var(--line);border-radius:12px;color:var(--muted);white-space:pre-wrap}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<section class="card">
|
||||
<div class="toolbar">
|
||||
<button class="ghost" id="themeBtn"></button>
|
||||
<button class="ghost" id="langBtn"></button>
|
||||
</div>
|
||||
<div class="eyebrow" id="eyebrow"></div>
|
||||
<h1 id="title" style="margin:10px 0 8px;"></h1>
|
||||
<p id="desc"></p>
|
||||
<form id="loginForm">
|
||||
<input id="user" type="text" autocomplete="username" />
|
||||
<input id="pass" type="password" autocomplete="current-password" />
|
||||
<button class="btn" type="submit" id="submitBtn"></button>
|
||||
</form>
|
||||
<div class="tips" style="margin-top:14px;">
|
||||
<span class="chip" id="accountTip"></span>
|
||||
<span class="chip" id="passwordTip"></span>
|
||||
</div>
|
||||
<div class="msg" id="msg"></div>
|
||||
</section>
|
||||
<script>
|
||||
const D=v=>{const e=document.createElement('textarea');e.innerHTML=v;return e.value};const $=id=>document.getElementById(id);
|
||||
const LANG={zh:{eyebrow:'安全入口',title:'Linux 学习实验室登录',desc:'登录后才能访问课程总览、练习终端和学习面板。',user:'用户名',pass:'密码',submit:'登录并进入学习台',themeDark:'深色模式',themeLight:'浅色模式',lang:'EN',account:'账号:按部署配置',password:'密码:按部署配置',ready:'请输入部署时配置的账号密码进入 Linux 学习项目。',loading:'正在登录...',failed:'登录失败,请检查账号与密码。'},en:{eyebrow:'Secure entry',title:'Linux Learning Lab Login',desc:'Sign in before opening the course overview, practice terminal, and learning dashboard.',user:'Username',pass:'Password',submit:'Sign in and enter lab',themeDark:'Dark mode',themeLight:'Light mode',lang:'涓枃',account:'Account: deployment configured',password:'Password: deployment configured',ready:'Enter the deployment-configured credentials to access the Linux learning project.',loading:'Signing in...',failed:'Login failed. Check the username and password.'}};
|
||||
const state={language:localStorage.getItem('linux_lab_language')||'zh',theme:localStorage.getItem('linux_lab_theme')||'light'};const t=()=>LANG[state.language];const tx=k=>D(t()[k]||'');
|
||||
function setTheme(theme,persist=true){state.theme=theme==='dark'?'dark':'light';document.documentElement.setAttribute('data-theme',state.theme);if(persist)localStorage.setItem('linux_lab_theme',state.theme);$('themeBtn').textContent=state.theme==='light'?tx('themeDark'):tx('themeLight')}
|
||||
function render(){document.title=tx('title');document.documentElement.lang=state.language==='zh'?'zh-CN':'en';$('eyebrow').textContent=tx('eyebrow');$('title').textContent=tx('title');$('desc').textContent=tx('desc');$('user').placeholder=tx('user');$('pass').placeholder=tx('pass');$('submitBtn').textContent=tx('submit');$('langBtn').textContent=state.language==='zh'?'EN':'\u4E2D\u6587';$('accountTip').textContent=tx('account');$('passwordTip').textContent=tx('password');setTheme(state.theme,false);const n=localStorage.getItem('linux_lab_login_notice');$('msg').textContent=n||tx('ready');localStorage.removeItem('linux_lab_login_notice')}
|
||||
async function checkSession(){const r=await fetch('/api/session',{credentials:'same-origin'});const p=await r.json();if(p.authenticated)location.href='/app'}
|
||||
async function login(e){e.preventDefault();$('msg').textContent=tx('loading');const r=await fetch('/api/login',{method:'POST',credentials:'same-origin',headers:{'Content-Type':'application/json'},body:JSON.stringify({username:$('user').value.trim(),password:$('pass').value.trim()})});const p=await r.json();if(r.ok&&p.success){location.href='/app';return}$('msg').textContent=p.error||tx('failed')}
|
||||
document.addEventListener('DOMContentLoaded',async()=>{render();await checkSession();$('themeBtn').addEventListener('click',()=>setTheme(state.theme==='light'?'dark':'light'));$('langBtn').addEventListener('click',()=>{state.language=state.language==='zh'?'en':'zh';localStorage.setItem('linux_lab_language',state.language);render()});$('loginForm').addEventListener('submit',login)})
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
135
server.py
135
server.py
@@ -8,31 +8,40 @@ import http.server
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
import time
|
||||
import urllib.parse
|
||||
from typing import Any
|
||||
|
||||
from sandbox import LinuxSandbox
|
||||
|
||||
DEFAULT_USERNAME = os.environ.get("LINUX_LAB_USERNAME", "admin")
|
||||
DEFAULT_PASSWORD = os.environ.get("LINUX_LAB_PASSWORD", "safe_linux_2026")
|
||||
USERS = {
|
||||
"admin": hashlib.sha256(b"safe_linux_2026").hexdigest(),
|
||||
DEFAULT_USERNAME: hashlib.sha256(DEFAULT_PASSWORD.encode("utf-8")).hexdigest(),
|
||||
}
|
||||
|
||||
TASKS_FILE = os.path.join(os.path.dirname(__file__), "COURSE_TASKS.json")
|
||||
HTML_FILE = os.path.join(os.path.dirname(__file__), "index.html")
|
||||
LOGIN_FILE = os.path.join(os.path.dirname(__file__), "login.html")
|
||||
PRIVACY_FILE = os.path.join(os.path.dirname(__file__), "privacy.html")
|
||||
SANDBOX = LinuxSandbox()
|
||||
SESSION_COOKIE = "linux_lab_session"
|
||||
SESSION_TTL_SECONDS = 8 * 60 * 60
|
||||
SESSIONS: dict[str, dict[str, Any]] = {}
|
||||
|
||||
PUBLIC_GET_PATHS = {
|
||||
"/",
|
||||
"/login.html",
|
||||
"/privacy",
|
||||
"/privacy.html",
|
||||
"/api/health",
|
||||
"/api/session",
|
||||
}
|
||||
PUBLIC_POST_PATHS = {
|
||||
"/api/login",
|
||||
"/api/logout",
|
||||
}
|
||||
SAFE_REMOTE_HOST = "xiaoxiaoluohao.indevs.in"
|
||||
|
||||
|
||||
def load_course() -> dict[str, Any]:
|
||||
@@ -47,6 +56,37 @@ def load_course() -> dict[str, Any]:
|
||||
COURSE = load_course()
|
||||
|
||||
|
||||
def secure_cookie_enabled() -> bool:
|
||||
return os.environ.get("LINUX_LAB_SECURE_COOKIE", "").lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
|
||||
def create_session(username: str) -> str:
|
||||
session_id = secrets.token_urlsafe(24)
|
||||
SESSIONS[session_id] = {
|
||||
"user": username,
|
||||
"expires_at": time.time() + SESSION_TTL_SECONDS,
|
||||
}
|
||||
return session_id
|
||||
|
||||
|
||||
def get_session(session_id: str) -> dict[str, Any] | None:
|
||||
if not session_id:
|
||||
return None
|
||||
session = SESSIONS.get(session_id)
|
||||
if not session:
|
||||
return None
|
||||
if session.get("expires_at", 0) <= time.time():
|
||||
SESSIONS.pop(session_id, None)
|
||||
return None
|
||||
session["expires_at"] = time.time() + SESSION_TTL_SECONDS
|
||||
return session
|
||||
|
||||
|
||||
def delete_session(session_id: str):
|
||||
if session_id:
|
||||
SESSIONS.pop(session_id, None)
|
||||
|
||||
|
||||
def looks_garbled(value: Any) -> bool:
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
return False
|
||||
@@ -980,11 +1020,13 @@ class LinuxLearningHandler(http.server.BaseHTTPRequestHandler):
|
||||
def log_message(self, format, *args):
|
||||
pass
|
||||
|
||||
def send_json(self, data: Any, status: int = 200):
|
||||
def send_json(self, data: Any, status: int = 200, extra_headers: dict[str, str] | None = None):
|
||||
raw = json.dumps(data, ensure_ascii=False).encode("utf-8")
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||||
self.send_header("Content-Length", str(len(raw)))
|
||||
for header, value in (extra_headers or {}).items():
|
||||
self.send_header(header, value)
|
||||
self.end_headers()
|
||||
self.wfile.write(raw)
|
||||
|
||||
@@ -997,6 +1039,36 @@ class LinuxLearningHandler(http.server.BaseHTTPRequestHandler):
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def send_redirect(self, location: str):
|
||||
self.send_response(302)
|
||||
self.send_header("Location", location)
|
||||
self.end_headers()
|
||||
|
||||
def cookie_header(self, session_id: str = "", clear: bool = False) -> str:
|
||||
base = f"{SESSION_COOKIE}={session_id if not clear else ''}; Path=/; HttpOnly; SameSite=Lax"
|
||||
if clear:
|
||||
return base + "; Max-Age=0"
|
||||
if secure_cookie_enabled():
|
||||
base += "; Secure"
|
||||
return base + f"; Max-Age={SESSION_TTL_SECONDS}"
|
||||
|
||||
def current_session_id(self) -> str:
|
||||
cookie = self.headers.get("Cookie", "")
|
||||
for part in cookie.split(";"):
|
||||
name, _, value = part.strip().partition("=")
|
||||
if name == SESSION_COOKIE:
|
||||
return value.strip()
|
||||
return ""
|
||||
|
||||
def current_session(self) -> dict[str, Any] | None:
|
||||
return get_session(self.current_session_id())
|
||||
|
||||
def current_user(self) -> str | None:
|
||||
session = self.current_session()
|
||||
if not session:
|
||||
return None
|
||||
return str(session.get("user") or "")
|
||||
|
||||
def is_public_path(self, path: str, method: str) -> bool:
|
||||
if method == "GET":
|
||||
return path in PUBLIC_GET_PATHS
|
||||
@@ -1004,19 +1076,10 @@ class LinuxLearningHandler(http.server.BaseHTTPRequestHandler):
|
||||
return path in PUBLIC_POST_PATHS
|
||||
return False
|
||||
|
||||
def check_auth(self, auth_header: str, token: str) -> bool:
|
||||
if token == "safe_linux_2026":
|
||||
return True
|
||||
if auth_header.startswith("Bearer ") and auth_header[7:] == "safe_linux_2026":
|
||||
return True
|
||||
return False
|
||||
|
||||
def require_auth_if_needed(self, path: str, method: str) -> bool:
|
||||
if self.is_public_path(path, method):
|
||||
return True
|
||||
auth_header = self.headers.get("Authorization", "")
|
||||
token = self.headers.get("X-Token", "")
|
||||
if not self.check_auth(auth_header, token):
|
||||
if not self.current_session():
|
||||
self.send_json({"error": "Authentication required"}, 401)
|
||||
return False
|
||||
return True
|
||||
@@ -1025,10 +1088,21 @@ class LinuxLearningHandler(http.server.BaseHTTPRequestHandler):
|
||||
parsed = urllib.parse.urlparse(self.path)
|
||||
path = parsed.path
|
||||
|
||||
if not self.require_auth_if_needed(path, "GET"):
|
||||
if path == "/":
|
||||
self.send_redirect("/app" if self.current_session() else "/login.html")
|
||||
return
|
||||
|
||||
if path == "/":
|
||||
if path == "/login.html":
|
||||
if self.current_session():
|
||||
self.send_redirect("/app")
|
||||
return
|
||||
self.send_file(LOGIN_FILE, "text/html; charset=utf-8")
|
||||
return
|
||||
|
||||
if path in {"/app", "/index.html"}:
|
||||
if not self.current_session():
|
||||
self.send_redirect("/login.html")
|
||||
return
|
||||
self.send_file(HTML_FILE, "text/html; charset=utf-8")
|
||||
return
|
||||
|
||||
@@ -1036,6 +1110,9 @@ class LinuxLearningHandler(http.server.BaseHTTPRequestHandler):
|
||||
self.send_file(PRIVACY_FILE, "text/html; charset=utf-8")
|
||||
return
|
||||
|
||||
if not self.require_auth_if_needed(path, "GET"):
|
||||
return
|
||||
|
||||
if path == "/api/health":
|
||||
overview = build_overview()
|
||||
self.send_json(
|
||||
@@ -1051,6 +1128,11 @@ class LinuxLearningHandler(http.server.BaseHTTPRequestHandler):
|
||||
)
|
||||
return
|
||||
|
||||
if path == "/api/session":
|
||||
user = self.current_user()
|
||||
self.send_json({"authenticated": bool(user), "user": user or ""})
|
||||
return
|
||||
|
||||
if path == "/api/course":
|
||||
self.send_json(COURSE)
|
||||
return
|
||||
@@ -1130,9 +1212,6 @@ class LinuxLearningHandler(http.server.BaseHTTPRequestHandler):
|
||||
parsed = urllib.parse.urlparse(self.path)
|
||||
path = parsed.path
|
||||
|
||||
if not self.require_auth_if_needed(path, "POST"):
|
||||
return
|
||||
|
||||
content_length = int(self.headers.get("Content-Length", 0))
|
||||
raw = self.rfile.read(content_length).decode("utf-8") if content_length else "{}"
|
||||
|
||||
@@ -1146,14 +1225,30 @@ class LinuxLearningHandler(http.server.BaseHTTPRequestHandler):
|
||||
username = data.get("username", "")
|
||||
password = data.get("password", "")
|
||||
if username in USERS and hashlib.sha256(password.encode("utf-8")).hexdigest() == USERS[username]:
|
||||
self.send_json({"success": True, "token": "safe_linux_2026", "message": "Login succeeded"})
|
||||
session_id = create_session(username)
|
||||
self.send_json(
|
||||
{
|
||||
"success": True,
|
||||
"user": username,
|
||||
"message": "Login succeeded",
|
||||
"expires_in": SESSION_TTL_SECONDS,
|
||||
},
|
||||
extra_headers={"Set-Cookie": self.cookie_header(session_id)},
|
||||
)
|
||||
return
|
||||
|
||||
self.send_json({"success": False, "error": "Invalid username or password"}, 401)
|
||||
return
|
||||
|
||||
if not self.require_auth_if_needed(path, "POST"):
|
||||
return
|
||||
|
||||
if path == "/api/logout":
|
||||
self.send_json({"success": True, "message": "Logged out"})
|
||||
delete_session(self.current_session_id())
|
||||
self.send_json(
|
||||
{"success": True, "message": "Logged out"},
|
||||
extra_headers={"Set-Cookie": self.cookie_header(clear=True)},
|
||||
)
|
||||
return
|
||||
|
||||
if path == "/api/reset":
|
||||
|
||||
Reference in New Issue
Block a user