Browse Source

feat: P5 会话与中间件(Session/CSRF/auth 预留)

caesar 1 month ago
parent
commit
6ddab3cd29

+ 8 - 1
app/config/routes.php

@@ -2,17 +2,24 @@
 
 declare(strict_types=1);
 
+use Glacier\Controllers\FormController;
 use Glacier\Controllers\HomeController;
 
 /**
  * URL 模式路由表(集中声明,URL 的唯一解释者)。
- * 每条:method / pattern / handler / middleware(中间件 P5 启用)。
+ * 每条:method / pattern / handler / middleware。
  *
  * pattern 语法:静态段 /about、参数段 /user/:id、可选段 /news[/:page]。
+ * middleware:csrf(写方法校验 Token)、auth / permission(P5 空实现预留)。
  * 说明:/demo 两条为 P1 验证路由引擎用,后续随模块规划移除。
  */
 return [
     ['method' => 'GET', 'pattern' => '/', 'handler' => [HomeController::class, 'index']],
     ['method' => 'GET', 'pattern' => '/demo/:name', 'handler' => [HomeController::class, 'demo']],
     ['method' => 'GET', 'pattern' => '/demo-page[/:page]', 'handler' => [HomeController::class, 'demoPage']],
+    // CSRF 演示:GET 显示表单 / POST 经 csrf 中间件校验
+    ['method' => 'GET', 'pattern' => '/form', 'handler' => [FormController::class, 'show']],
+    ['method' => 'POST', 'pattern' => '/form', 'handler' => [FormController::class, 'submit'], 'middleware' => ['csrf']],
+    // 认证中间件挂载演示(空实现,P7 接入登录)
+    ['method' => 'GET', 'pattern' => '/protected', 'handler' => [HomeController::class, 'protectedPage'], 'middleware' => ['auth']],
 ];

+ 29 - 0
app/controllers/FormController.php

@@ -0,0 +1,29 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Glacier\Controllers;
+
+use Glacier\Core\Response;
+use Glacier\Core\View;
+
+/**
+ * CSRF 中间件演示控制器(P5 验证用,后续并入具体功能)。
+ */
+final class FormController
+{
+    public function show(array $params = []): never
+    {
+        Response::html(View::render('form/show.html.twig', [
+            'action' => '/form',
+        ]));
+    }
+
+    public function submit(array $params = []): never
+    {
+        $name = trim((string) ($_POST['name'] ?? ''));
+        Response::html(View::render('form/success.html.twig', [
+            'name' => $name,
+        ]));
+    }
+}

+ 6 - 0
app/controllers/HomeController.php

@@ -35,4 +35,10 @@ final class HomeController
             'page' => $params['page'] ?? '1',
         ]));
     }
+
+    /** 受保护页(P5 验证 auth 中间件挂载;登录体系 P7 实现)。 */
+    public function protectedPage(array $params = []): never
+    {
+        Response::html(View::render('home/protected.html.twig'));
+    }
 }

+ 40 - 0
app/core/Middleware.php

@@ -0,0 +1,40 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Glacier\Core;
+
+/**
+ * 中间件注册表与执行管道。
+ * 中间件签名:function(array $params): void
+ *   - 通过:正常返回;
+ *   - 拒绝:调用 Response::xxx() 终止(403/重定向等)。
+ * 本期落地:csrf(表单/API 校验);预留:auth / permission(空实现)。
+ */
+final class Middleware
+{
+    /** @var array<string, callable> */
+    private static array $registry = [];
+
+    public static function register(string $name, callable $middleware): void
+    {
+        self::$registry[$name] = $middleware;
+    }
+
+    public static function resolve(string $name): callable
+    {
+        if (!isset(self::$registry[$name])) {
+            throw new \InvalidArgumentException("未注册的中间件: {$name}");
+        }
+        return self::$registry[$name];
+    }
+
+    /** 按顺序执行中间件链。 */
+    public static function run(array $names, array $params): void
+    {
+        foreach ($names as $name) {
+            $middleware = self::resolve($name);
+            $middleware($params);
+        }
+    }
+}

+ 86 - 0
app/core/Session.php

@@ -0,0 +1,86 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Glacier\Core;
+
+/**
+ * 会话管理封装(架构约定 docs/architecture.md §3.6)。
+ * - 安全 Cookie:HttpOnly + SameSite=Lax + Secure(生产自动开启)
+ * - session 名唯一化(glacier_sid)
+ * - CSRF Token 生成/校验(表单统一走中间件)
+ * - 登录后调用 regenerate() 防会话固定(P7 接入登录时使用)
+ */
+final class Session
+{
+    public static function start(): void
+    {
+        if (session_status() === PHP_SESSION_ACTIVE) {
+            return;
+        }
+        $debug = (bool) Config::get('app.debug', true);
+        session_name('glacier_sid');
+        session_set_cookie_params([
+            'lifetime' => 0,
+            'path' => '/',
+            'domain' => '',
+            'secure' => (bool) Config::get('app.secure_cookies', !$debug),
+            'httponly' => true,
+            'samesite' => 'Lax',
+        ]);
+        session_start();
+    }
+
+    public static function get(string $key, mixed $default = null): mixed
+    {
+        return $_SESSION[$key] ?? $default;
+    }
+
+    public static function set(string $key, mixed $value): void
+    {
+        $_SESSION[$key] = $value;
+    }
+
+    public static function has(string $key): bool
+    {
+        return isset($_SESSION[$key]);
+    }
+
+    public static function remove(string $key): void
+    {
+        unset($_SESSION[$key]);
+    }
+
+    /** 清空并销毁会话(登出)。 */
+    public static function destroy(): void
+    {
+        $_SESSION = [];
+        if (ini_get('session.use_cookies')) {
+            $params = session_get_cookie_params();
+            setcookie(session_name(), '', time() - 42000, $params['path'], $params['domain'], $params['secure'], $params['httponly']);
+        }
+        session_destroy();
+    }
+
+    /** 重置会话 ID(登录成功后调用,防会话固定)。 */
+    public static function regenerate(): void
+    {
+        session_regenerate_id(true);
+    }
+
+    /** 获取(或生成)CSRF Token。 */
+    public static function csrfToken(): string
+    {
+        if (empty($_SESSION['csrf_token'])) {
+            $_SESSION['csrf_token'] = bin2hex(random_bytes(32));
+        }
+        return $_SESSION['csrf_token'];
+    }
+
+    /** 校验 CSRF Token(恒定时间比较)。 */
+    public static function csrfCheck(string $token): bool
+    {
+        $stored = $_SESSION['csrf_token'] ?? '';
+        return $stored !== '' && hash_equals($stored, $token);
+    }
+}

+ 1 - 0
app/core/View.php

@@ -53,6 +53,7 @@ final class View
             ]);
             self::$twig->addGlobal('app_name', Config::get('app.name', 'Glacier'));
             self::$twig->addGlobal('base_url', (string) Config::get('app.base_url', ''));
+            self::$twig->addGlobal('csrf_token', Session::csrfToken());
         }
         return self::$twig;
     }

+ 20 - 0
app/middleware/AuthMiddleware.php

@@ -0,0 +1,20 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Glacier\Middleware;
+
+use Glacier\Core\Logger;
+
+/**
+ * 认证中间件(P5 预留空实现)。
+ * TODO(P7 前后实现):未登录时 Session::get('user') 为空 → Response::redirect('/login')。
+ * 本期仅记录日志并放行,保证管道可挂载。
+ */
+final class AuthMiddleware
+{
+    public function handle(array $params = []): void
+    {
+        Logger::info('auth middleware (stub) — 放行', ['path' => $_SERVER['REQUEST_URI'] ?? '/']);
+    }
+}

+ 29 - 0
app/middleware/CsrfMiddleware.php

@@ -0,0 +1,29 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Glacier\Middleware;
+
+use Glacier\Core\Logger;
+use Glacier\Core\Response;
+use Glacier\Core\Session;
+
+/**
+ * CSRF 中间件:写方法(POST/PUT/PATCH/DELETE)校验 Token。
+ * Token 来源:表单字段 csrf_token 或请求头 X-CSRF-Token。
+ */
+final class CsrfMiddleware
+{
+    public function handle(array $params = []): void
+    {
+        $method = strtoupper($_SERVER['REQUEST_METHOD'] ?? 'GET');
+        if (!in_array($method, ['POST', 'PUT', 'PATCH', 'DELETE'], true)) {
+            return; // 读方法放行
+        }
+        $token = $_POST['csrf_token'] ?? ($_SERVER['HTTP_X_CSRF_TOKEN'] ?? '');
+        if (!Session::csrfCheck((string) $token)) {
+            Logger::warning('csrf check failed', ['method' => $method, 'path' => $_SERVER['REQUEST_URI'] ?? '/']);
+            Response::text('CSRF 校验失败', 403);
+        }
+    }
+}

+ 18 - 0
app/middleware/PermissionMiddleware.php

@@ -0,0 +1,18 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Glacier\Middleware;
+
+/**
+ * 权限中间件(P5 预留空实现)。
+ * TODO(P7 前后实现):按模块/角色校验(RBAC 简化版),拒绝时 403。
+ * 本期放行,保证管道可挂载。
+ */
+final class PermissionMiddleware
+{
+    public function handle(array $params = []): void
+    {
+        // 空实现:预留
+    }
+}

+ 15 - 0
app/views/form/show.html.twig

@@ -0,0 +1,15 @@
+{% extends 'layouts/base.html.twig' %}
+
+{% block title %}CSRF 演示表单{% endblock %}
+
+{% block content %}
+<section class="hero">
+    <h1>CSRF 演示表单</h1>
+    <form method="post" action="{{ base_url }}{{ action }}">
+        <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
+        <p><label>姓名:<input type="text" name="name" required></label></p>
+        <p><button type="submit">提交</button></p>
+    </form>
+    <p><a href="{{ base_url }}/">返回首页</a></p>
+</section>
+{% endblock %}

+ 11 - 0
app/views/form/success.html.twig

@@ -0,0 +1,11 @@
+{% extends 'layouts/base.html.twig' %}
+
+{% block title %}提交成功{% endblock %}
+
+{% block content %}
+<section class="hero">
+    <h1>提交成功</h1>
+    <p>姓名:{{ name }}</p>
+    <p><a href="{{ base_url }}/form">再试一次</a> · <a href="{{ base_url }}/">返回首页</a></p>
+</section>
+{% endblock %}

+ 11 - 0
app/views/home/protected.html.twig

@@ -0,0 +1,11 @@
+{% extends 'layouts/base.html.twig' %}
+
+{% block title %}受保护页{% endblock %}
+
+{% block content %}
+<section class="hero">
+    <h1>受保护页</h1>
+    <p class="muted">此路由挂载了 auth 中间件(P5 空实现,放行)。</p>
+    <p><a href="{{ base_url }}/">返回首页</a></p>
+</section>
+{% endblock %}

+ 16 - 0
public/index.php

@@ -13,9 +13,14 @@ require APP_ROOT . '/vendor/autoload.php';
 
 use Glacier\Core\Config;
 use Glacier\Core\Logger;
+use Glacier\Core\Middleware;
 use Glacier\Core\ModuleLoader;
 use Glacier\Core\Response;
 use Glacier\Core\Router;
+use Glacier\Core\Session;
+use Glacier\Middleware\AuthMiddleware;
+use Glacier\Middleware\CsrfMiddleware;
+use Glacier\Middleware\PermissionMiddleware;
 
 date_default_timezone_set('Asia/Shanghai'); // 架构约定 UTC+8
 
@@ -23,6 +28,14 @@ $config = require APP_ROOT . '/app/config/env.php';
 Config::load($config);
 Logger::init(Config::get('storage.logs', APP_ROOT . '/storage/logs'));
 
+// 会话启动(安全 Cookie 参数见 Session 封装)
+Session::start();
+
+// 注册中间件(命名中间件,路由表按名挂载)
+Middleware::register('csrf', [new CsrfMiddleware(), 'handle']);
+Middleware::register('auth', [new AuthMiddleware(), 'handle']);
+Middleware::register('permission', [new PermissionMiddleware(), 'handle']);
+
 try {
     // 解析请求路径(去 query,规范化尾部斜杠)
     $path = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) ?: '/';
@@ -83,6 +96,9 @@ try {
         );
     }
 
+    // 执行路由中间件(拒绝时中间件自行终止响应)
+    Middleware::run($match['middleware'], $match['params']);
+
     call_user_func($match['handler'], $match['params']);
 } catch (\Throwable $e) {
     Logger::error('uncaught exception', [