feat: tighten linux login flow and refresh lab entry

This commit is contained in:
Codex
2026-03-25 16:42:15 +08:00
parent 2f7bd50a36
commit efaa715e93
3 changed files with 260 additions and 2190 deletions

2273
index.html

File diff suppressed because it is too large Load Diff

48
login.html Normal file
View 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:'&#x5B89;&#x5168;&#x5165;&#x53E3;',title:'Linux &#x5B66;&#x4E60;&#x5B9E;&#x9A8C;&#x5BA4;&#x767B;&#x5F55;',desc:'&#x767B;&#x5F55;&#x540E;&#x624D;&#x80FD;&#x8BBF;&#x95EE;&#x8BFE;&#x7A0B;&#x603B;&#x89C8;&#x3001;&#x7EC3;&#x4E60;&#x7EC8;&#x7AEF;&#x548C;&#x5B66;&#x4E60;&#x9762;&#x677F;&#x3002;',user:'&#x7528;&#x6237;&#x540D;',pass:'&#x5BC6;&#x7801;',submit:'&#x767B;&#x5F55;&#x5E76;&#x8FDB;&#x5165;&#x5B66;&#x4E60;&#x53F0;',themeDark:'&#x6DF1;&#x8272;&#x6A21;&#x5F0F;',themeLight:'&#x6D45;&#x8272;&#x6A21;&#x5F0F;',lang:'EN',account:'&#x8D26;&#x53F7;&#xFF1A;&#x6309;&#x90E8;&#x7F72;&#x914D;&#x7F6E;',password:'&#x5BC6;&#x7801;&#xFF1A;&#x6309;&#x90E8;&#x7F72;&#x914D;&#x7F6E;',ready:'&#x8BF7;&#x8F93;&#x5165;&#x90E8;&#x7F72;&#x65F6;&#x914D;&#x7F6E;&#x7684;&#x8D26;&#x53F7;&#x5BC6;&#x7801;&#x8FDB;&#x5165; Linux &#x5B66;&#x4E60;&#x9879;&#x76EE;&#x3002;',loading:'&#x6B63;&#x5728;&#x767B;&#x5F55;...',failed:'&#x767B;&#x5F55;&#x5931;&#x8D25;&#xFF0C;&#x8BF7;&#x68C0;&#x67E5;&#x8D26;&#x53F7;&#x4E0E;&#x5BC6;&#x7801;&#x3002;'},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
View File

@@ -8,31 +8,40 @@ import http.server
import json import json
import os import os
import re import re
import secrets
import time
import urllib.parse import urllib.parse
from typing import Any from typing import Any
from sandbox import LinuxSandbox 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 = { 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") TASKS_FILE = os.path.join(os.path.dirname(__file__), "COURSE_TASKS.json")
HTML_FILE = os.path.join(os.path.dirname(__file__), "index.html") 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") PRIVACY_FILE = os.path.join(os.path.dirname(__file__), "privacy.html")
SANDBOX = LinuxSandbox() SANDBOX = LinuxSandbox()
SESSION_COOKIE = "linux_lab_session"
SESSION_TTL_SECONDS = 8 * 60 * 60
SESSIONS: dict[str, dict[str, Any]] = {}
PUBLIC_GET_PATHS = { PUBLIC_GET_PATHS = {
"/", "/",
"/login.html",
"/privacy", "/privacy",
"/privacy.html", "/privacy.html",
"/api/health", "/api/health",
"/api/session",
} }
PUBLIC_POST_PATHS = { PUBLIC_POST_PATHS = {
"/api/login", "/api/login",
"/api/logout", "/api/logout",
} }
SAFE_REMOTE_HOST = "xiaoxiaoluohao.indevs.in"
def load_course() -> dict[str, Any]: def load_course() -> dict[str, Any]:
@@ -47,6 +56,37 @@ def load_course() -> dict[str, Any]:
COURSE = load_course() 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: def looks_garbled(value: Any) -> bool:
if not isinstance(value, str) or not value.strip(): if not isinstance(value, str) or not value.strip():
return False return False
@@ -980,11 +1020,13 @@ class LinuxLearningHandler(http.server.BaseHTTPRequestHandler):
def log_message(self, format, *args): def log_message(self, format, *args):
pass 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") raw = json.dumps(data, ensure_ascii=False).encode("utf-8")
self.send_response(status) self.send_response(status)
self.send_header("Content-Type", "application/json; charset=utf-8") self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(raw))) 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.end_headers()
self.wfile.write(raw) self.wfile.write(raw)
@@ -997,6 +1039,36 @@ class LinuxLearningHandler(http.server.BaseHTTPRequestHandler):
self.end_headers() self.end_headers()
self.wfile.write(body) 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: def is_public_path(self, path: str, method: str) -> bool:
if method == "GET": if method == "GET":
return path in PUBLIC_GET_PATHS return path in PUBLIC_GET_PATHS
@@ -1004,19 +1076,10 @@ class LinuxLearningHandler(http.server.BaseHTTPRequestHandler):
return path in PUBLIC_POST_PATHS return path in PUBLIC_POST_PATHS
return False 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: def require_auth_if_needed(self, path: str, method: str) -> bool:
if self.is_public_path(path, method): if self.is_public_path(path, method):
return True return True
auth_header = self.headers.get("Authorization", "") if not self.current_session():
token = self.headers.get("X-Token", "")
if not self.check_auth(auth_header, token):
self.send_json({"error": "Authentication required"}, 401) self.send_json({"error": "Authentication required"}, 401)
return False return False
return True return True
@@ -1025,10 +1088,21 @@ class LinuxLearningHandler(http.server.BaseHTTPRequestHandler):
parsed = urllib.parse.urlparse(self.path) parsed = urllib.parse.urlparse(self.path)
path = parsed.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 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") self.send_file(HTML_FILE, "text/html; charset=utf-8")
return return
@@ -1036,6 +1110,9 @@ class LinuxLearningHandler(http.server.BaseHTTPRequestHandler):
self.send_file(PRIVACY_FILE, "text/html; charset=utf-8") self.send_file(PRIVACY_FILE, "text/html; charset=utf-8")
return return
if not self.require_auth_if_needed(path, "GET"):
return
if path == "/api/health": if path == "/api/health":
overview = build_overview() overview = build_overview()
self.send_json( self.send_json(
@@ -1051,6 +1128,11 @@ class LinuxLearningHandler(http.server.BaseHTTPRequestHandler):
) )
return return
if path == "/api/session":
user = self.current_user()
self.send_json({"authenticated": bool(user), "user": user or ""})
return
if path == "/api/course": if path == "/api/course":
self.send_json(COURSE) self.send_json(COURSE)
return return
@@ -1130,9 +1212,6 @@ class LinuxLearningHandler(http.server.BaseHTTPRequestHandler):
parsed = urllib.parse.urlparse(self.path) parsed = urllib.parse.urlparse(self.path)
path = parsed.path path = parsed.path
if not self.require_auth_if_needed(path, "POST"):
return
content_length = int(self.headers.get("Content-Length", 0)) content_length = int(self.headers.get("Content-Length", 0))
raw = self.rfile.read(content_length).decode("utf-8") if content_length else "{}" 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", "") username = data.get("username", "")
password = data.get("password", "") password = data.get("password", "")
if username in USERS and hashlib.sha256(password.encode("utf-8")).hexdigest() == USERS[username]: 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 return
self.send_json({"success": False, "error": "Invalid username or password"}, 401) self.send_json({"success": False, "error": "Invalid username or password"}, 401)
return return
if not self.require_auth_if_needed(path, "POST"):
return
if path == "/api/logout": 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 return
if path == "/api/reset": if path == "/api/reset":