POABOB

小小工程師的筆記分享

0%

使用 PHP Slim 4 建立一個 RESTful 框架 (進階篇)

前言

藉由 基礎篇 所提供的簡單範例,我們可以發現如果我們把每個路由都寫成一個閉包(Closure)的形式,很容易導致程式碼不易閱讀,並且把所有資料都寫在一個檔案非常難以去釐清 RESTful API 服務的類型相關的依賴類

所以本篇將要介紹如何將 SLIM 4 構建成一個屬於 ADR 模式的一個框架,構建之前也會介紹傳統 MVC 框架的架構,之後再來講解如何使用 JWT 如何用作登入後產生 Token 並且驗證其身份資訊

  • 以下我構想這個框架要符合什麼條件:
    1. 基於 ADR 模式
    2. 要有一個基礎的 ORM(可以不用 Migration)
    3. 要有 Jwt 功能
    4. 能夠輕鬆寫單元測試、E2E 測試
    5. 要使用 Docker 一鍵啟動
    6. 使用 Github Actions CI / CD
  • 本篇主要是參考 FoglessSlim 4 搭建 RESTful API 以及 Daniel OpitzSlim 4 - Tutorial,並且結合自身所學,再根據個人習慣建構。進階篇的建構方向是藉由基礎篇的基礎,將 Slim 4 的 ADR 框架建立完成,並且實現 表單驗證統一回應格式Jwt登入登出功能。

安裝

本機最低需求

  • PHP 8.0 (目前沒有使用 php-7.4 實測過,所以希望至少可以 8.0 以上)
  • Composer
  • Docker、Docker-Compose
    • Nginx
    • PHP 8.1
    • MariaDB

安裝相關依賴

  • 我們先將基礎篇的範例程式碼 clone 下來命名成新專案 Slim-ADR,然後將相關依賴先安裝。
1
2
3
4
5
6
7
8
# clone 基礎篇專案
git clone https://github.com/POABOB/Slim-Simple.git
# 重新命名
mv Slim-Simple Slim-ADR
# 安裝相關依賴
composer install
# 將 .env.example 改名 .env
mv .env.example .env

建構 RESTful API

PSR-4

  • PSR-4 Autoloader 是用來告訴開發者如何架構專案的目錄結構命名空間,遵循這個規範並搭配 Composer 提供的 autoload 的功能,就可以將 PHP 檔案進行自動加載,而不用一直使用 require
  • 我們要自動加載的目錄為 srctests (請順便建立),src 目錄是用來存放我們 RESRful API的程式碼,tests 則是我們用來撰寫軟體測試時所用的目錄。
  • 當我們把 autoload位置命名變數設定好之後,完整檔案應該如下呈現:

composer.json

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
{
"name": "poabob/slim-adr",
"type": "project",
"description": "A Simple Restful PHP Microservice Framework!",
"homepage": "https://github.com/POABOB/Slim-ADR",
"authors": [
{
"name": "POABOB",
"email": "zxc752166@gmail.com"
}
],
"keywords": [
"slim-simple",
"slim",
"slim4",
"slim-4",
"nginx",
"microservice",
"restful"
],
"license": "MIT",
"require": {
"php": ">=8.0",
"catfan/medoo": "^2.1",
"php-di/php-di": "^6.4",
"selective/basepath": "^2.1",
"slim/http": "^1.0",
"slim/psr7": "^1.0",
"slim/slim": "^4.10",
"symfony/dotenv": "^6.0"
},
"config": {
"process-timeout": 0,
"sort-packages": true
},
"autoload": {
"psr-4": {
"App\\": "src"
}
},
"autoload-dev": {
"psr-4": {
"App\\Test\\": "tests"
}
}
}

MVC 模式

  • 什麼是 MVC 呢?我們可以先由以下流程圖來了解 MVC 的運作模式。

mvc

  1. 每當我們 ClientServer 傳遞請求的時候,首先會被先由路由(Route)來去攔截,攔截這份請求解析他的 HTTP MethodURI
  2. 路由找到對應的控制器(Controller),一個控制器通常會有多個類型相同的 Action 操作。
  3. 去執行該控制器的 Action 處理我們想要做的事情,這裡通常會做一些業務邏輯表單驗證的事情。
  4. 如果該 Action 會使用到資料庫的話,控制器不能執行資料庫操作,而是要讓 Model 來去做 DB 的 CRUD
  5. Model 將會與資料庫建立連線,並且把 SQL 語法當作溝通工具與資料庫溝通。
  6. 從資料庫操作完我們要做的事情後,返回結果給 Model
  7. Model 將結果返回給控制器,這邊可以做資料相關的處理
  8. 控制器將我們所拿到的資料或結果渲染在 View 當中。
  9. 最後把 View 的渲染結果返回給 Client
  • MVC 結論
名稱 功能
Controller 檢查請求的資料是否符合規範業務處理、資料庫返回資料的格式處理、連接 ModelView 的主幹
Model 資料庫溝通
View 將結果渲染到我們的視圖給 Client

ADR 模式

  • 我們可以看到 MVC 雖然是一個非常完整且成熟的架構,但依然看得到幾樣缺點。
    1. Controller 負載的工作量太大,寫久了容易導致 Controller 裡面的 Action 複雜且難以閱讀。
    2. 現行比較流行前後端分離的架構(SPA),導致 ViewRESTful API 中沒有什麼存在的必要性。
  • 因此,進而產生了 ADR(Action - Domain - Responder)這個模式,Action 主要是將 Controller 中的 Action 獨立出來成一個單獨的檔案,一個 Action只負責一件事情,並且他只處理請求回應兩項操作。Domain 則包含了兩種操作,一個是 Service,另外一個則是 Repository。基本上一個 Action 會對應一個獨立的 Service,而 Service 就是用來切割原本在 Controller 大量的工作。Repository 則是與原先 Model 非常類似並且裡面會有多種資料庫操作。

adr

  • ADR 結論
名稱 功能
Action 負責處理請求回應
Service 檢查請求的資料是否符合規範業務處理、資料庫返回資料的格式處理
Repository 資料庫溝通

Action

  • 我們先 src 目錄之中建立一個 Action目錄。
  • 並且在 src/Action 建立一個專門給 Users 的資料夾 src/Action/Users
  • 然後建立四個檔案,分別是 src/Action/Users/GetAction.phpsrc/Action/Users/InsertAction.phpsrc/Action/Users/UpdateAction.phpsrc/Action/Users/DeleteAction.php
  • 目錄大概會如下圖:
    1
    2
    3
    4
    5
    6
    7
    8
    .
    ├── src/
    | ├── Actions/
    | | └── Users/
    | | ├── GetAction.php
    | | ├── InsertAction.php
    | | ├── UpdateAction.php
    | | └── DeleteAction.php

src/Action/Users/GetAction.php

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<?php

namespace App\Action\Users;

use Slim\Http\Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use App\Domain\Users\Service\GetService;

final class GetAction
{
private GetService $service;

public function __construct(GetService $service) {
$this->service = $service;
}

public function __invoke(Request $request, Response $response): Response
{
// 請求 Service
$return = $this->service->getUsers();
return $response->withJson($return, 200, JSON_UNESCAPED_UNICODE);
}
}

src/Action/Users/InsertAction.php

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
<?php

namespace App\Action\Users;

use Slim\Http\Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use App\Domain\Users\Service\InsertService;

final class InsertAction
{
private InsertService $service;

public function __construct(InsertService $service) {
$this->service = $service;
}

public function __invoke(Request $request, Response $response): Response
{
// 獲取請求的Body
$data = (array)$request->getParsedBody();
$return = $this->service->insertUser($data);
return $response->withJson($return, 200, JSON_UNESCAPED_UNICODE);
}
}

src/Action/Users/UpdateAction.php

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
<?php

namespace App\Action\Users;

use Slim\Http\Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use App\Domain\Users\Service\UpdateService;

final class UpdateAction
{
private UpdateService $service;

public function __construct(UpdateService $service) {
$this->service = $service;
}

public function __invoke(Request $request, Response $response): Response
{
// 獲取請求的Body
$data = (array)$request->getParsedBody();
$return = $this->service->updateUser($data);
return $response->withJson($return, 200, JSON_UNESCAPED_UNICODE);
}
}

src/Action/Users/DeleteAction.php

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
<?php

namespace App\Action\Users;

use Slim\Http\Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use App\Domain\Users\Service\DeleteService;

final class DeleteAction
{
private DeleteService $service;

public function __construct(DeleteService $service) {
$this->service = $service;
}

public function __invoke(Request $request, Response $response): Response
{
// 獲取請求的Body
$data = (array)$request->getParsedBody();
$return = $this->service->deleteUser($data);
return $response->withJson($return, 200, JSON_UNESCAPED_UNICODE);
}
}

Service

  • 在我們上方的四個 Action 可以看到,每個 Action 都向一個獨立的 Service 請求結果,並且將結果返回。
  • 那我們可以建立 src/Domain/Users/Servicesrc/Domain/Users/Repository 兩種路徑目錄當作我們 UsersDomain
  • 然後在目錄中建立四個檔案,分別是 src/Domain/Users/Service/GetService.phpsrc/Domain/Users/Service/InsertService.phpsrc/Domain/Users/Service/UpdateService.phpsrc/Domain/Users/Service/DeleteService.php
  • 目錄大概會如下圖:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    .
    ├── src/
    | ├── Domain/
    | | └── Users/
    | | ├── Repository/
    | | └── Service/
    | | ├── GetService.php
    | | ├── InsertService.php
    | | ├── UpdateService.php
    | | └── DeleteService.php

src/Domain/Users/Service/GetService.php

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<?php

namespace App\Domain\Users\Service;

use App\Domain\Users\Repository\UsersRepository;

final class GetService
{
private UsersRepository $db;

public function __construct(UsersRepository $db) {
$this->db = $db;
}

public function getUsers(): array
{
// 請求 Repository
$param = "*";
$where = 1;
$data = $this->db->getUsers($param, $where);
return $data;
}
}

src/Domain/Users/Service/InsertService.php

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<?php

namespace App\Domain\Users\Service;

use App\Domain\Users\Repository\UsersRepository;

final class InsertService
{
private UsersRepository $db;

public function __construct(UsersRepository $db) {
$this->db = $db;
}

public function insertUser(array $data): int
{
// 請求 Repository
$return = $this->db->insertUser($data);
return $return;
}
}

src/Domain/Users/Service/UpdateService.php

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
<?php

namespace App\Domain\Users\Service;

use App\Domain\Users\Repository\UsersRepository;

final class UpdateService
{
private UsersRepository $db;

public function __construct(UsersRepository $db) {
$this->db = $db;
}

public function updateUser(array $data): bool
{
// 請求 Repository
$return = $this->db->updateUser(
["name" => $data["name"]],
["id" => $data["id"]]
);
return $return;
}
}

src/Domain/Users/Service/DeleteService.php

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<?php

namespace App\Domain\Users\Service;

use App\Domain\Users\Repository\UsersRepository;

final class DeleteService
{
private UsersRepository $db;

public function __construct(UsersRepository $db) {
$this->db = $db;
}

public function deleteUser(array $data): bool
{
// 請求 Repository
$return = $this->db->deleteUser(["id" => $data["id"]]);
return $return;
}
}

Repository

  • 每個 Service 都會統一向 Repository 請求資料庫操作。
  • 我們在目錄中建立 src/Domain/Users/Repository/UsersRepository.php 讓我們操作資料庫。
  • 目錄大概會如下圖:
    1
    2
    3
    4
    5
    6
    .
    ├── src/
    | ├── Domain/
    | | └── Users/
    | | └── Repository/
    | | └── UsersRepository.php

src/Domain/Users/Repository/UsersRepository.php

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
<?php

namespace App\Domain\Users\Repository;

use UnexpectedValueException;
use Medoo\Medoo;

class UsersRepository {

/** @var Medoo $DB 連線 */
private Medoo $db;

/**
*
* @param Medoo $DB 連線
*/
public function __construct(Medoo $db)
{
$this->db = $db;
}

/**
* 獲取Users
*
* @param array|string 欄位
* @param array|int WHERE條件
* @param string 表名
*
* @return array
*/
public function getUsers(array | string $params = "*", array | int $where = 1, string $table = "Users"): array
{
try {
return $this->db->select($table, $params, $where);
} catch (PDOException $e) {
throw new UnexpectedValueException($e->getMessage());
}
}

/**
* 插入User
*
* @param array 欄位
* @param string 表名
*
* @return void
*/
public function insertUser(array $params = [], string $table = "Users"): int
{
try {
$this->db->insert($table, $params);
return $this->db->id();
} catch (PDOException $e) {
throw new UnexpectedValueException($e->getMessage());
}
}

/**
* 更新User
*
* @param array 欄位
* @param array WHERE條件
* @param string 表名
*
* @return int
*/
public function updateUser(array $params = [], array $where = [], string $table = "Users"): void
{
try {
$this->db->update($table, $params, $where);
return;
} catch (PDOException $e) {
throw new UnexpectedValueException($e->getMessage());
}
}

/**
* 刪除User
*
* @param array WHERE條件
* @param string 表名
*
* @return void
*/
public function deleteUser(array $where = [], string $table = "Users"): void
{
try {
$this->db->delete($table, $where);
return;
} catch (PDOException $e) {
throw new UnexpectedValueException($e->getMessage());
}
}
}

修改配置

  • 請修改我們 Slim-ADR 中的 config/routes.phpconfig/bootstrap.php

config/routes.php

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
<?php

declare(strict_types=1);

use Slim\Http\Response;
// 發現使用Slim\Http\Request常常會報錯,所以使用官方的Request當作請求
use Psr\Http\Message\ServerRequestInterface as Request;
use Slim\Routing\RouteCollectorProxy;
use Slim\App;

return function(App $app) {
$app->options("[{routes.*}]", function(Request $req, Response $res, array $args) :Response { return $res; });

$app->group("/api", function (RouteCollectorProxy $app) {

$app->get("/home[/]", function(Request $req, Response $res, array $args) :Response {
return $res->withJson("GET HOME", 200, JSON_UNESCAPED_UNICODE);
});

$app->get("/home/{name}[/]", function(Request $req, Response $res, array $args) :Response {
return $res->withJson("GET Hello {$args['name']}!", 200, JSON_UNESCAPED_UNICODE);
});

// 相對原本基礎篇的寫法,更加精簡且可以清楚知道他是負責處理 Users 的 API
$app->group("/user", function (RouteCollectorProxy $app) {
$app->get("[/]", \App\Action\Users\GetAction::class);
$app->post("[/]", \App\Action\Users\InsertAction::class);
$app->patch("[/]", \App\Action\Users\UpdateAction::class);
$app->delete("[/]", \App\Action\Users\DeleteAction::class);
});
});
};

config/bootstrap.php

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
<?php

declare(strict_types=1);

use DI\ContainerBuilder;
use Symfony\Component\Dotenv\Dotenv;
use Slim\App;
require_once __DIR__ . '/../vendor/autoload.php';

// 註冊我們設定好的CLASS
$containerBuilder = new ContainerBuilder();
$containerBuilder->addDefinitions(__DIR__ . '/container.php');
$container = $containerBuilder->build();

// LOAD .ENV
$dotenv = $container->get(Dotenv::class);
$dotenv->load(__DIR__.'/../.env');

// 建立App INSTANCE
$app = $container->get(App::class);

// 中間件
(require __DIR__ . '/middleware.php')($app);

// 將原本 $db instance 刪除,資料庫操作統一給 Reopsitory 管理
// 路由
(require __DIR__ . '/routes.php')($app);

return $app;

表單驗證和統一回應格式

當我們設計好一個 ADR 模式的架構之後,肯定會有一些小問題,比如說:要怎麼檢驗 Client 傳遞過來的資料,或是回應的時候要怎麼統一格式,讓前端可以判斷該項請求是否為正確操作

表單驗證

那麼表單驗證就誕生了,表單驗證顧名思義就是檢驗傳遞的資料型別是否符合後端需求,如果可以就放行操作,不行則返回哪些傳遞的資料是有問題的。以下是我自己封裝的表單驗證,此依賴存放在 src/Utils 之中。

src/Utils/Validation.php

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
<?php

declare(strict_types=1);

namespace App\Utils;

class Validation
{
// 存放錯誤的陣列
private array $_errors = [];

// 驗證
public function validate(array $src = [], array $rules = []) {
foreach($src as $item => $item_value) {
if(key_exists($item, $rules)) {
foreach($rules[$item] as $rule => $rule_value){
if(is_int($rule)) {
$rule = $rule_value;
}
switch ($rule){

// 無法為空
case 'required':
if(empty($item_value) && $rule_value){
//empty缺點 0 false會返回true
if($item_value === 0 || $item_value === false || $item_value === '0' || $item_value === 0.00) {
break;
}
$this->addError($item,ucwords($item). ' 無法為空');
}
break;

// 最小長度
case 'minLen':
if(mb_strlen((string)$item_value) < $rule_value){
$this->addError($item, ucwords($item). ' 最小長度應為 '.$rule_value. ' 個字元');
}
break;

// 最大長度
case 'maxLen':
if(mb_strlen((string)$item_value) > $rule_value){
$this->addError($item, ucwords($item). ' 最大長度應為 '.$rule_value. ' 個字元');
}
break;

// 是否為數字
case 'numeric':
if(!is_numeric($item_value) && $rule_value){
$this->addError($item, ucwords($item). ' 應為數字');
}
break;

// 是否為浮點數
case 'float':
if(!is_float($item_value) && $rule_value){
$this->addError($item, ucwords($item). ' 應為浮點數');
}
break;

// 是否為字母
case 'alpha':
if(!ctype_alpha($item_value) && $rule_value){
$this->addError($item, ucwords($item). ' 應為字母');
}
break;

// 是否有空格
case 'space':
if(!ctype_space($item_value) && $rule_value){
$this->addError($item, ucwords($item). ' 不應有空格');
}
break;

// 是否為email格式
case 'email':
if(!filter_var($item_value, FILTER_VALIDATE_EMAIL) && $rule_value){
$this->addError($item, ucwords($item). ' 不為Email格式');
}
break;

// 是否輸入一致
case 'same':
if($item_value != $rule_value && $rule_value){
$this->addError($item, ucwords($item). ' 輸入要一致');
}
break;

// 是否為身分證格式
case 'id_number':
$map = [
'A'=>10,'B'=>11,'C'=>12,'D'=>13,'E'=>14,'F'=>15,
'G'=>16,'H'=>17,'I'=>34,'J'=>18,'K'=>19,'L'=>20,
'M'=>21,'N'=>22,'O'=>35,'P'=>23,'Q'=>24,'R'=>25,
'S'=>26,'T'=>27,'U'=>28,'V'=>29,'W'=>32,'X'=>30,
'Y'=>31,'Z'=>33
];
// ^: 必須以英文開頭
// $: 必須以數字結尾
// 先檢查字數可以節省時間
$strLen = strlen($item_value);
$item_value = strtoupper($item_value);
if (($strLen != 10 || preg_match("/^[A-Z][1-2][0-9]+$/", $item_value) == 0) && $rule_value) {
$this->addError($item, ucwords($item). ' 不為身份證格式');
break;
}
$code = 0;
for($i = 0; $i < $strLen; $i++){
$symbol = substr($item_value,$i,1);
// 英文字母
if($i == 0){
$tmp = $map[$symbol];
$code += intval($tmp/10) + ($tmp%10)*9;
// 最後一碼
}else if($i == $strLen - 1){
$code += intval($symbol);
// 其他: 乘上 8,7,6,5,4,3,2,1
}else{
$code += intval($symbol) * (9 - $i);
}
}
if($code % 10 != 0 && $rule_value){
$this->addError($item, ucwords($item). ' 不為身份證格式');
}
break;
}
}
}
}
}

// 如果不符合,就新增錯誤
private function addError($item, $error){
$this->_errors[$item][] = $error;
}

// 判斷$this->_errors中有沒有值,沒有代表驗證成功
public function error(){
if(empty($this->_errors)) {
return false;
}
return $this->_errors;
}
}

統一回應格式

我們通常設計 RESTful API 會給前端狀態碼資料訊息當作他們請求是否成功的依據,並且可以透過狀態碼去得知這是甚麼問題。通常我習慣將狀態碼寫在回應格式之中,而非改變http status code(單純只是習慣而已),如果有疑慮也會偏向與大家整合他們開發的習慣。以下是我封裝的 ResponseFormat.php 也是放在 src/Utils 目錄下。

src/Utils/ResponseFormat.php

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
<?php

declare(strict_types=1);

namespace App\Utils;

class ResponseFormat
{
public int $code;
public mixed $data;
public mixed $message;

public function format(int $status = 200, mixed $data = null, mixed $message = null): ResponseFormat
{
// RESET
$this->reset();

if(gettype($data) === 'string') {
$this->message = $data;
$data = null;
$message = null;
}

if($data) {
$this->data = $data;
}

if($message) {
$this->message = $message;
}

$this->code = $status;
return $this;
}

private function reset(): void
{
$this->code = 200;
$this->data = null;
$this->message = null;
}
}

使用方式

一開始我們通常會先在建構子上先傳遞這兩個依賴讓 php-di 幫助我們使用自動配裝。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/** @var UsersRepository */
private UsersRepository $repository;

/** @var ResponseFormat */
private ResponseFormat $res;

/** @var Validation */
private Validation $v;

public function __construct(UsersRepository $repository, ResponseFormat $res, Validation $v)
{
$this->repository = $repository;
$this->res = $res;
$this->v = $v;
}

然後在 Service 的處理中,使用 $this->v->validate() 開啟驗證,並且查看 $this->v->error() 是否為 true,是的話就返回錯誤的資訊。
$this->res->format()就是我們封裝好的格式,第一個參數是狀態碼、第二個是資料、第三個是訊息。當我們第二個參數是字串的時候,格式會自動改成訊息

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public function deleteUser(array $data): ResponseFormat
{
// Validation
$this->v->validate(
["ID" => (!empty($data["id"]) ? $data["id"] : "")],
["ID" => ["required", "maxLen" => 11]]
);

// Invalid
if($this->v->error()) {
return $this->res->format(401, $this->v->error(),"提交格式有誤!");
}

$this->repository->deleteUser(["id" => $data["id"]]);
return $this->res->format(200, "Success");
}
  • 介紹完之後,我們可以將之前的四個 Service 套用上我們寫的依賴之中。

src/Domain/Users/Service/GetService.php

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
<?php

declare(strict_types=1);

namespace App\Domain\Users\Service;

use App\Domain\Users\Repository\UsersRepository;
use App\Utils\ResponseFormat;

final class GetService {

/** @var UsersRepository */
private UsersRepository $repository;

/** @var ResponseFormat */
private ResponseFormat $res;

/**
*
* @param UsersRepository $repository DB操作
*/
public function __construct(UsersRepository $repository, ResponseFormat $res)
{
$this->repository = $repository;
$this->res = $res;
}

/**
* 獲取Users
*
* @param array $user 資料型別
*
* @return ResponseFormat
*/
public function getUsers(): ResponseFormat
{
// 獲取Users
$data = $this->repository->getUsers();
return $this->res->format(200, $data);
}
}

src/Domain/Users/Service/InsertService.php

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
<?php

declare(strict_types=1);

namespace App\Domain\Users\Service;

use App\Domain\Users\Repository\UsersRepository;
use App\Utils\ResponseFormat;
use App\Utils\Validation;

final class InsertService {

/** @var UsersRepository */
private UsersRepository $repository;

/** @var ResponseFormat */
private ResponseFormat $res;

/** @var Validation */
private Validation $v;
/**
*
* @param UsersRepository $repository DB操作
* @param ResponseFormat $res 回傳固定格式
* @param Validation $v 表單驗證
*/
public function __construct(UsersRepository $repository, ResponseFormat $res, Validation $v)
{
$this->repository = $repository;
$this->res = $res;
$this->v = $v;
}

/**
* 插入User
*
* @param array $user 資料型別
*
* @return array Users array
*/
public function insertUser(array $data): ResponseFormat
{
// Validation
$this->v->validate(
[
"姓名" => (!empty($data["name"]) ? $data["name"] : ""),
"密碼" => (!empty($data["password"]) ? $data["password"] : ""),
],
[
"姓名" => ["required", "maxLen" => 64],
"密碼" => ["required", "maxLen" => 64],
]
);

// Invalid
if($this->v->error()) {
return $this->res->format(401, $this->v->error(),"提交格式有誤!");
}

$data["password"] = md5($data["password"]);

$this->repository->insertUser($data);
return $this->res->format(200, "Success");
}
}

src/Domain/Users/Service/UpdateService.php

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
<?php

declare(strict_types=1);

namespace App\Domain\Users\Service;

use App\Domain\Users\Repository\UsersRepository;
use App\Utils\ResponseFormat;
use App\Utils\Validation;

final class UpdateService {

/** @var UsersRepository */
private UsersRepository $repository;

/** @var ResponseFormat */
private ResponseFormat $res;

/** @var Validation */
private Validation $v;
/**
*
* @param UsersRepository $repository DB操作
* @param ResponseFormat $res 回傳固定格式
* @param Validation $v 表單驗證
*/
public function __construct(UsersRepository $repository, ResponseFormat $res, Validation $v)
{
$this->repository = $repository;
$this->res = $res;
$this->v = $v;
}

/**
* 更新Users
*
* @param array $user 資料型別
*
* @return array Users array
*/
public function updateUser(array $data): ResponseFormat
{
// Validation
$this->v->validate(
[
"ID" => (!empty($data["id"]) ? $data["id"] : ""),
"姓名" => (!empty($data["name"]) ? $data["name"] : "")
],
[
"ID" => ["required", "maxLen" => 11],
"姓名" => ["required", "maxLen" => 64]
]
);

// Invalid
if($this->v->error()) {
return $this->res->format(401, $this->v->error(),"提交格式有誤!");
}

$this->repository->updateUser(
["name" => $data["name"]],
["id" => $data["id"]]
);
return $this->res->format(200, "Success");
}
}

src/Domain/Users/Service/DeleteService.php

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
<?php

declare(strict_types=1);

namespace App\Domain\Users\Service;

use App\Domain\Users\Repository\UsersRepository;
use App\Utils\ResponseFormat;
use App\Utils\Validation;

final class DeleteService {

/** @var UsersRepository */
private UsersRepository $repository;

/** @var ResponseFormat */
private ResponseFormat $res;

/** @var Validation */
private Validation $v;
/**
*
* @param UsersRepository $repository DB操作
* @param ResponseFormat $res 回傳固定格式
* @param Validation $v 表單驗證
*/
public function __construct(UsersRepository $repository, ResponseFormat $res, Validation $v)
{
$this->repository = $repository;
$this->res = $res;
$this->v = $v;
}

/**
* 刪除Users
*
* @param array $user 資料型別
*
* @return array Users array
*/
public function deleteUser(array $data): ResponseFormat
{
// Validation
$this->v->validate(
["ID" => (!empty($data["id"]) ? $data["id"] : "")],
["ID" => ["required", "maxLen" => 11]]
);

// Invalid
if($this->v->error()) {
return $this->res->format(401, $this->v->error(),"提交格式有誤!");
}

$this->repository->deleteUser(["id" => $data["id"]]);
return $this->res->format(200, "Success");
}
}

成果展示

  • 使用 docker 開啟服務
1
docker-compose up -d
  • 用 postman 依序操作 插入(沒資料) -> 插入(有資料) -> 獲取


登入和Jwt

一般我們在使用 Http 這種 Stateless 的協議的時候,通常無法辨識我們的使用者的真實身分,除非藉由以下兩種方式。

  1. 使用 Cookie Session,通常我們在登入的時候 Sever 會將使用者資料存放在 Session 的記憶體之中,並且分發 Cookie(夾帶SessionId) 給 Client,讓 Client 每次請求的時候將 Cookie 當作身分證連同傳送到 Server。
  2. 使用 Jwt,我們在登入的時候 Server 會將使用者資料加密,並且存成特定格式返回給 Client(Server 不做儲存),並且每次我們在請求的時候都會從 Header 的 Authorization 欄位去查找有沒有這種 Token 的加密身分證。

由於 SPA、手機 APP 這種前後端分離的方式逐漸流行,Cookie Session 漸漸也開始被棄用了,其中不方便是一個原因,再來如果是分散式系統的話要儲存在記憶體只能依賴 Redis 這種記憶體型的資料庫才能夠達成。

Jwt

介紹

Jwt的組成分成三部分。

  1. Header:通常存放加密的類型該Token的類型
    1
    2
    3
    4
    {
    "alg": "SHA256",
    "typ": "JWT"
    }
  2. Payload:一般裡面會有我們使用者的訊息簽發者使用期限…等資訊。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    {
    // Registered claims,一般JWT會用到的資訊
    "iss": "http://example.com", // Issuer 簽發者,可以使用域名來當作驗證該TOKEN是誰製造的
    "iat": "120000000", // Issued At JWT簽發時間
    "exp": "155555555", // Expiration Time JWT的過期時間,過期時間必須大於簽發JWT時間
    "sub": "1234567890", // Subject JWT的主題是甚麼
    "aud": "all", // Audience 接收JWT的一方,預期這個TOKEN是給誰用的
    "nbf": "130000000", // Not Before 定義發放JWT之後多久才生效
    "jti" : "asd123" // JWT 的 Id JWT的身分標示,每個JWT的Id都應該是不重複的,避免重複發放
    // Private claims,Server 可以依照自己的方式設置擺放什麼info,盡量避免密碼相關的敏感訊息
    "user_info": {
    "name": "Bob",
    "user_id": 1,
    "roles": "admin"
    },
    // Public claims,一般來說不太會用到
    }
  3. Signaturebase64UrlEncode(header)base64UrlEncode(payload)secret三個部分加密而成。

安裝

安裝 lcobucci/jwt: ^4.0 的一個 Jwt 的依賴庫,本次實作使用 4.0 版本,因為不同版本所用的函數不同,所以安裝前請先確認版本是否正確。

1
composer require "lcobucci/jwt": "^4.0"

產生公鑰和私鑰

我們使用 RSA 的加密方式來進行簽名。

1
2
3
4
# 產生私鑰
openssl genrsa -out private.pem 2048
# 產生公鑰
openssl rsa -in private.pem -outform PEM -pubout -out public.pem

.env配置

原本的 .env.example 已經有配置好了,不過生產環境還是建議獨立生成一個公鑰私鑰以防萬一。

.env

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# JWT SETTINGS
JWT_ISSUER=SLIM_4
JWT_LIFETIME=86400
JWT_PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY-----
MIIBPQIBAAJBALSlDUfngNVzILQh0UDzg22Wd3NCvHrl1PMK+IxRoTQovLN3TQ8E
oBgL7GqHTYSrnnADrV0JSgf8onbDzkvZoYcCAwEAAQJBALEY5w5JPZsFRViTlsww
b/bt/qk3EgUCcWTcqpMWLA4vBBH7/guLZvyWG1U4Q63vgO1SSA7g+bwMvmDMCj6l
fhECIQDc6/A9mYXirCwHL0iKb5o1R/ri4NqZYrcoGUrYhpntZQIhANFT7k4SffT0
3PSK1Pa2OcsnfuBMmqZcld3DP8+lCip7AiEAhRaZ9vIaxxBDwdxJTiSneLuxN6aP
6mGex0hdX43PA0UCIQDNZjD41LZZjYfeQPg1WZueF5QsnZ5GTaUUpIjRxF0UTwIh
AMa/1Gkl/FUaiZaFm6KMysKHAeWg3YZudouHoDLDcDbl
-----END RSA PRIVATE KEY-----"
JWT_PUBLIC_KEY="-----BEGIN PUBLIC KEY-----
MFwwDQYJKoZIhvcNAQEBBQADSwAwSAJBALSlDUfngNVzILQh0UDzg22Wd3NCvHrl
1PMK+IxRoTQovLN3TQ8EoBgL7GqHTYSrnnADrV0JSgf8onbDzkvZoYcCAwEAAQ==
-----END PUBLIC KEY-----"

建立 Jwt 的函式庫

基本上就是實現我們的建立 Jwt驗證 Jwt解析 Jwt的方法。

src/Utils/Jwt.php

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
<?php

declare(strict_types=1);

namespace App\Utils;

use UnexpectedValueException;
use InvalidArgumentException;

use Lcobucci\JWT\Configuration;
use Lcobucci\JWT\Signer\Hmac\Sha256;
use Lcobucci\JWT\Signer;
use Lcobucci\JWT\Signer\Key\InMemory;
use Lcobucci\JWT\Token\Plain;

use Lcobucci\JWT\Validation\Constraint\SignedWith;
use Lcobucci\JWT\Validation\Constraint\IssuedBy;
use Lcobucci\JWT\Validation\Constraint\IdentifiedBy;
use Lcobucci\JWT\Validation\Constraint\ValidAt;

use Lcobucci\Clock\SystemClock;
use DateTimeImmutable;
use DateTimeZone;

final class Jwt
{
/** @var string The issuer name */
private string $issuer;

/** @var int Max lifetime in seconds */
private int $lifetime;

/** @var Configuration config */
private Configuration $config;

/**
* The constructor.
*
* @param string $issuer The issuer name
* @param int $lifetime The max lifetime
* @param string $privateKey The private key as string
* @param string $publicKey The public key as string
*/
public function __construct(
string $issuer,
int $lifetime,
string $privateKey,
string $publicKey
) {
$this->issuer = $issuer;
$this->lifetime = $lifetime;
$this->signer = new Signer\Rsa\Sha256();
$this->publicKey = $publicKey;
$this->config = Configuration::forAsymmetricSigner(
// You may use RSA or ECDSA and all their variations (256, 384, and 512) and EdDSA over Curve25519
$this->signer,
InMemory::plainText($privateKey),
InMemory::plainText($publicKey)
);
}

/**
* Get JWT max lifetime.
*
* @return int The lifetime in seconds
*/
public function getLifetime(): int
{
return $this->lifetime;
}

/**
* Create JSON web token.
*
* @param string $uid The user id
*
* @throws UnexpectedValueException
*
* @return string The JWT
*/
public function createJwt(array $info): string
{
$issuedAt = new DateTimeImmutable();
// print_r($this->v5_UUID("", 'JWT_TOKEN'));exit;
return ($this->config->builder()
->issuedBy($this->issuer)
->permittedFor($this->issuer)
->identifiedBy($this->v5_UUID("0x752222", "JWT_TOKEN"), true)
// Configures the time that the token was issue (iat claim)
->issuedAt($issuedAt)
// Configures the time that the token can be used (nbf claim)
->canOnlyBeUsedAfter($issuedAt)
// Configures the expiration time of the token (exp claim)
->expiresAt($issuedAt->modify("+{$this->lifetime} seconds"))
// Configures a new claim, called "uid"
->withClaim("info", $info)
// // Configures a new header, called "foo"
// ->withHeader("foo", "bar")
// Builds a new token
->getToken($this->config->signer(), $this->config->signingKey())
)->toString();
}

/**
* Parse token.
*
* @param string $token The JWT
*
* @throws InvalidArgumentException
*
* @return Token The parsed token
*/
public function createParsedToken(string $token): Plain
{
return $this->config->parser()->parse($token);
}

/**
* Validate the access token.
*
* @param string $accessToken The JWT
*
* @return bool The status
*/
public function validateToken(string $accessToken): bool
{
$token = $this->createParsedToken($accessToken);

$this->config->setValidationConstraints(new SignedWith($this->config->signer(), $this->config->verificationKey()));
$this->config->setValidationConstraints(new IssuedBy($token->claims()->get("iss")));
$this->config->setValidationConstraints(new IdentifiedBy($token->claims()->get("jti")));
$this->config->setValidationConstraints(new ValidAt(new SystemClock(new DateTimeZone("Asia/Taipei"))));

$constraints = $this->config->validationConstraints();
if (!$this->config->validator()->validate($token, ...$constraints)) {
return false;
}

return true;
}

public function v5_UUID(string $name_space, string $string): string {
$n_hex = preg_replace('/[^0-9A-Fa-f\-\(\)]/', '', $name_space); // Getting hexadecimal components of namespace
$binray_str = ''; // Binary value string
//Namespace UUID to bits conversion
for($i = 0; $i < strlen($n_hex); $i+=2) {
if(!isset($n_hex[$i+1])) {
$binray_str .= chr(hexdec($n_hex[$i]));
break;
}
$binray_str .= chr(hexdec($n_hex[$i].$n_hex[$i+1]));
}
//hash value
$hashing = sha1($binray_str . $string);

return sprintf('%08s-%04s-%04x-%04x-%12s',
// 32 bits for the time_low
substr($hashing, 0, 8),
// 16 bits for the time_mid
substr($hashing, 8, 4),
// 16 bits for the time_hi,
(hexdec(substr($hashing, 12, 4)) & 0x0fff) | 0x5000,
// 8 bits and 16 bits for the clk_seq_hi_res,
// 8 bits for the clk_seq_low,
(hexdec(substr($hashing, 16, 4)) & 0x3fff) | 0x8000,
// 48 bits for the node
substr($hashing, 20, 12)
);
}
}

容器 Jwt 配置

config/container.php

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<?php
// ...
use App\Utils\Jwt;
use Slim\Http\Factory\DecoratedResponseFactory;

return [
// ...
// JWT
Jwt::class => function () {
return new Jwt($_ENV["JWT_ISSUER"], (int)$_ENV["JWT_LIFETIME"], $_ENV["JWT_PRIVATE_KEY"], $_ENV["JWT_PUBLIC_KEY"]);
},

// Middleware擴充用
DecoratedResponseFactory::class => function (ContainerInterface $container) {
return $container->get(App::class)->getResponseFactory();
}
];

登入和登出

當我們完成了 Jwt 的函式庫之後,我們就可以來實現登入登出的功能。

Action

請建立 src/Action/Auth 目錄,並且新增 src/Action/Auth/LoginAction.phpsrc/Action/Auth/LogoutAction.php

src/Action/Auth/LoginAction.php

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
<?php

declare(strict_types=1);

namespace App\Action\Auth;

use Slim\Http\Response;
// 發現使用Slim\Http\Request常常會報錯,所以使用官方的Request當作請求
use Psr\Http\Message\ServerRequestInterface as Request;
use App\Domain\Auth\Service\LoginService;

final class LoginAction
{
/**
*
*
* @var LoginService The login service
*/
private LoginService $service;

public function __construct(LoginService $service)
{
$this->service = $service;
}

public function __invoke(Request $req, Response $res): Response
{
$data = (array)$req->getParsedBody();
$return = $this->service->login($data);
return $res->withJson($return, 200, JSON_UNESCAPED_UNICODE);
}
}

src/Action/Auth/LogoutAction.php

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
<?php

declare(strict_types=1);

namespace App\Action\Auth;

use Slim\Http\Response;
// 發現使用Slim\Http\Request常常會報錯,所以使用官方的Request當作請求
use Psr\Http\Message\ServerRequestInterface as Request;
use App\Domain\Auth\Service\LogoutService;

final class LogoutAction
{
/** @var LogoutService The Logout service */
private LogoutService $service;

public function __construct(LogoutService $service)
{
$this->service = $service;
}

public function __invoke(Request $req, Response $res): Response
{
$return = $this->service->logout();
return $res->withJson($return, 200, JSON_UNESCAPED_UNICODE);
}
}

Service

然後在 src/Domain/Auth 的目錄下新增相對應的 Service src/Domain/Auth/Service/LoginService.phpsrc/Domain/Auth/Service/LogoutService.php
src/Domain/Auth/Service/LoginService.php

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
<?php

declare(strict_types=1);

namespace App\Domain\Auth\Service;

use App\Domain\Auth\Repository\AuthRepository;
use App\Utils\ResponseFormat;
use App\Utils\Validation;
use App\Utils\Jwt;

final class LoginService {

/** @var AuthRepository */
private AuthRepository $repository;

/** @var ResponseFormat */
private ResponseFormat $res;

/** @var Validation */
private Validation $v;

/** @var Jwt */
private Jwt $jwt;

/**
* @param AuthRepository $repository DB操作
* @param ResponseFormat response
* @param Validation 表單驗證
*/
public function __construct(AuthRepository $repository, ResponseFormat $res, Validation $v, Jwt $jwt)
{
$this->repository = $repository;
$this->res = $res;
$this->v = $v;
$this->jwt = $jwt;
}

/**
* Login
*
* @param array $name, $password
*
* @return ResponseFormat
*/
public function login(array $data): ResponseFormat
{
$this->v->validate(
[
"帳號" => (!empty($data["name"]) ? $data["name"] : ""),
"密碼" => (!empty($data["password"]) ? $data["password"] : "")
],
[
"帳號" => ["required", "maxLen" => 64],
"密碼" => ["required", "maxLen" => 64]
]
);

// Invalid
if($this->v->error()) {
return $this->res->format(401, $this->v->error(),"提交格式有誤!");
}

$data["password"] = md5($data["password"]);

// 查看登入
$data = $this->repository->login("*", $data);

if (!$data) {
return $this->res->format(400, "帳號或密碼錯誤");
}


// Transform the result into a OAuh 2.0 Access Token Response
// https://www.oauth.com/oauth2-servers/access-tokens/access-token-response/
$token = $this->jwt->createJwt(["name" => $data[0]["name"], "role" => "admin"]);
$lifetime = $this->jwt->getLifetime();
$result = [
"access_token" => $token,
"token_type" => "Bearer",
"expires_in" => $lifetime,
];
return $this->res->format(200, $result);
}
}

src/Domain/Auth/Service/LogoutService.php

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
<?php

declare(strict_types=1);

namespace App\Domain\Auth\Service;

use App\Utils\ResponseFormat;

final class LogoutService {

/** @var ResponseFormat */
private ResponseFormat $res;

/**
*
* @param ResponseFormat response
*/
public function __construct(ResponseFormat $res)
{
$this->res = $res;
}

/**
* Logout
*
* @return ResponseFormat
*/
public function logout(): ResponseFormat
{
return $this->res->format(200, "Success");
}
}

Repository

最後在 src/Domain/Auth 的目錄下新增相對應的 Repository 的 src/Domain/Auth/Repository/AuthRepository.php

src/Domain/Auth/Repository/AuthRepository.php

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
<?php

declare(strict_types=1);

namespace App\Domain\Auth\Repository;

use UnexpectedValueException;
use Medoo\Medoo;

class AuthRepository {

/** @var Medoo $DB 連線 */
private Medoo $db;

/**
*
* @param Medoo $DB 連線
*/
public function __construct(Medoo $db)
{
$this->db = $db;
}

/**
* 登入
*
* @param array|string 欄位
* @param array|int WHERE條件
* @param string 表名
*
* @return array
*/
public function login(array | string $params = "*", array | int $where = 1, string $table = "Users"): array
{
try {
return $this->db->select($table, $params, $where);
} catch (PDOException $e) {
throw new UnexpectedValueException($e->getMessage());
}
}
}

新增路由

config/routes.php

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<?php

// ...

return function(App $app) {
$app->options("[{routes.*}]", function(Request $req, Response $res, array $args) :Response { return $res; });

$app->group("/api", function (RouteCollectorProxy $app) {

// ...
$app->post("/login[/]", \App\Action\Auth\LoginAction::class);
$app->get("/logout[/]", \App\Action\Auth\LogoutAction::class);
// ...

});
};

Jwt驗證是否登入

由於我們完成了登入登出路由,但是我們必須在 Action 操作之前提前解析 Jwt Token,於是我們就會使用到 Middleware預先處理Token

Middleware 實現

src/Middleware/JwtAuth.php

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
<?php

declare(strict_types=1);

namespace App\Middleware;

use App\Utils\Jwt;
use App\Utils\ResponseFormat;
// use Psr\Http\Message\ResponseFactoryInterface;

use Slim\Http\Factory\DecoratedResponseFactory;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;

/**
* JWT middleware.
*/
final class JwtAuth implements MiddlewareInterface
{
/** @var Jwt JWT authorizer */
private Jwt $jwt;

/**
* @var DecoratedResponseFactory
* The response factory
*/
private DecoratedResponseFactory $responseFactory;

/**
* @var ResponseFormat
*/
private ResponseFormat $response;

public function __construct(Jwt $jwt, DecoratedResponseFactory $responseFactory, ResponseFormat $response)
{
$this->jwt = $jwt;
$this->responseFactory = $responseFactory;
$this->response = $response;
}

/**
* Invoke middleware.
*
* @param ServerRequestInterface $request The request
* @param RequestHandlerInterface $handler The handler
*
* @return ResponseInterface The response
*/
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
$authorization = explode(" ", (string)$request->getHeaderLine("Authorization"));
$token = $authorization[1] ?? "";

// 判斷有無TOKEN並驗證
if (!$token || !$this->jwt->validateToken($token)) {
return $this->responseFactory->createResponse()
->withJson($this->response->format(403, "請先登入"), 200, JSON_UNESCAPED_UNICODE);
}

// Append valid token
$parsedToken = $this->jwt->createParsedToken($token);
$request = $request->withAttribute("token", $parsedToken);

// Append the info as request attribute
$request = $request->withAttribute("info", $parsedToken->claims()->get("info"));

return $handler->handle($request);
}
}

新增 Middleware 到路由

config/routes.php

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<?php

// ...

return function(App $app) {
$app->options("[{routes.*}]", function(Request $req, Response $res, array $args) :Response { return $res; });

$app->group("/api", function (RouteCollectorProxy $app) {

// ...
$app->get("/logout[/]", \App\Action\Auth\LogoutAction::class)->add(\App\Middleware\JwtAuth::class);
// ...

});
};

成果展示

  • 使用 postman 進行請求操作,依序是 登出(無 Jwt) -> 登入 -> 登出(有 Jwt)


結語

當前目錄

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
.
├── config/ 應用配置目錄
| ├── bootstrap.php 啟動文件
| ├── container.php 依賴注入容器文件
| ├── middleware.php 中間件文件
| ├── routes.php 路由文件
| └── settings.php 配置設定文件
├── docker/ Docker 相關目錄
| ├── nginx/ Nginx 目錄
│ | └── default.conf Nginx 配置文件
| └── php/ PHP 目錄
│ └── Dockerfile PHP 容器配置文件
├── public/ 網站根目錄
│ └── index.php 入口文件
├── src/ PHP 原始碼目錄(App Namespace)
│ ├─Action/
│ │ ├─Auth/
│ │ │ ├─LoginAction.php
│ │ │ └─LogoutAction.php
│ │ └─Users/
│ │ ├─GetAction.php
│ │ ├─InsertAction.php
│ │ ├─UpdateAction.php
│ │ └─DeleteAction.php
│ ├─Domain/
│ │ ├─Auth/
│ │ │ ├─Repository/
│ │ │ │ └─AuthRepository.php
│ │ │ └─Service/
│ │ │ ├─LoginService.php
│ │ │ └─LogoutService.php
│ │ └─Users/
│ │ ├─Repository/
│ │ │ └─UsersRepository.php
│ │ └─Service/
│ │ ├─GetService.php
│ │ ├─InsertService.php
│ │ ├─UpdateService.php
│ │ └─DeleteService.php
│ ├─Middleware/
│ │ └─JwtAuth.php
│ └─Utils/
│ ├─Jwt.php
│ ├─Validation.php
│ └─ResponseFormat.php
├── vendor/ Composer 目錄
├── .env 系統變數
├── .gitignore Git 忽略文件
├── composer.json Composer 配置文件
├── composer.lock Composer 鎖定文件
├── docker-compose.yml Docker 容器配置文件
└── init.sql DB Schema

未來工作

  • 目前我們已經將 Slim 4 的 ADR 框架建立完成,並且實現 表單驗證統一回應格式Jwt登入登出功能。
  • 下一篇(DevOps篇),我將會新增 Unit TestingE2E TestingGithub Actions 等相關功能。

本文範例程式碼在 GitHub 上的 POABOB/Slim-ADR

參考資料

  • Model、View、Controller 三分天下
  • Slim 4 搭建 RESTful API
  • Slim 4 配置 JSON Web Token
  • Slim 4 - Tutorial
  • bxcodec/go-clean-arch
------ 本文結束 ------