跳至主要內容

使用 RBAC 與 JWT 驗證 (JWT validation) 保護你的 Actix Web API

本指南將協助你透過 角色型存取控制 (RBAC, Role-based access control) 以及由 Logto 簽發的 JSON Web Token (JWT),為 Actix Web API 實作授權 (Authorization) 機制,確保 API 安全。

開始前

你的用戶端應用程式需要從 Logto 取得存取權杖 (Access tokens)。如果你尚未完成用戶端整合,請參考我們針對 React、Vue、Angular 或其他前端框架的 快速入門,或伺服器對伺服器存取請參閱 機器對機器指南

本指南聚焦於在你的 Actix Web 應用程式中,對這些權杖進行伺服器端驗證

A figure showing the focus of this guide

你將學到

  • JWT 驗證: 學習如何驗證存取權杖 (Access tokens) 並擷取驗證 (Authentication) 資訊
  • 中介軟體實作: 建立可重複使用的中介軟體以保護 API
  • 權限模型: 理解並實作不同的授權 (Authorization) 模式:
    • 全域 API 資源 (Global API resources) 用於應用程式層級端點
    • 組織權限 (Organization permissions) 控制租戶專屬功能
    • 組織層級 API 資源 (Organization-level API resources) 用於多租戶資料存取
  • RBAC 整合: 在 API 端點強制執行角色型權限 (Role-based permissions) 與權限範圍 (Scopes)

先決條件

  • 已安裝最新版穩定版 Rust
  • 基本了解 Actix Web 與 Web API 開發
  • 已設定 Logto 應用程式(如有需要請參閱 快速入門

權限 (Permission) 模型總覽

在實作保護機制前,請先選擇最適合你應用程式架構的權限模型。這與 Logto 的三大授權 (Authorization) 情境相符:

全域 API 資源 RBAC
  • 適用情境: 保護整個應用程式共用的 API 資源(非組織專屬)
  • 權杖類型: 具有全域受眾 (global audience) 的存取權杖 (Access token)
  • 範例: 公開 API、核心產品服務、管理端點
  • 最適用於: 所有客戶共用 API 的 SaaS 產品、無租戶隔離的微服務架構
  • 深入瞭解: 保護全域 API 資源

💡 請在繼續前選擇你的模型 —— 本指南後續內容將以你選擇的方式為參考。

快速準備步驟

設定 Logto 資源與權限 (Permissions)

  1. 建立 API 資源 (API resource): 前往 Console → API 資源 (API resources) 並註冊你的 API(例如:https://api.yourapp.com
  2. 定義權限 (Permissions): 新增如 read:productswrite:orders 等權限範圍 (Scopes) —— 參考 定義帶有權限的 API 資源
  3. 建立全域角色 (Global roles): 前往 Console → 角色 (Roles) 並建立包含 API 權限的角色 —— 參考 設定全域角色
  4. 指派角色 (Assign roles): 將角色指派給需要 API 存取權的使用者或 M2M 應用程式
不熟悉 RBAC?:

建議從我們的 角色型存取控制 (RBAC) 指南 開始,獲得逐步設定說明。

更新你的用戶端應用程式

在用戶端請求適當的權限範圍 (Scopes):

通常需要在用戶端設定中新增以下一項或多項:

  • OAuth 流程中的 scope 參數
  • 用於 API 資源存取的 resource 參數
  • 組織情境下的 organization_id
開始撰寫程式前:

請確保你測試的使用者或 M2M 應用程式已被指派包含所需 API 權限的正確角色或組織角色。

初始化你的 API 專案

要初始化一個新的 Actix Web 專案,請建立一個目錄並設置基本結構:

cargo new your-api-name
cd your-api-name

將 Actix Web 相關依賴加入你的 Cargo.toml

Cargo.toml
[dependencies]
actix-web = "4.0"
tokio = { version = "1.0", features = ["full"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"

建立一個基本的 Actix Web 應用程式:

src/main.rs
use actix_web::{web, App, HttpServer, Result};
use serde_json::{json, Value};

#[actix_web::main]
async fn main() -> std::io::Result<()> {
HttpServer::new(|| {
App::new()
.route("/", web::get().to(hello_handler))
})
.bind("127.0.0.1:8080")?
.run()
.await
}

async fn hello_handler() -> Result<web::Json<Value>> {
Ok(web::Json(json!({ "message": "Hello from Actix Web" })))
}

啟動開發伺服器:

cargo run
備註:

如需更多關於如何設置路由、中介軟體及其他功能的細節,請參考 Actix Web 官方文件。

初始化常數與工具函式

在你的程式碼中定義必要的常數與工具函式,以處理權杖(token)的擷取與驗證。一個有效的請求必須包含 Authorization 標頭,格式為 Bearer <存取權杖 (Access token)>

lib.rs
use serde::{Deserialize, Serialize};
use std::fmt;

pub const JWKS_URI: &str = "https://your-tenant.logto.app/oidc/jwks";
pub const ISSUER: &str = "https://your-tenant.logto.app/oidc";

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuthInfo {
pub sub: String,
pub client_id: Option<String>,
pub organization_id: Option<String>,
pub scopes: Vec<String>,
pub audience: Vec<String>,
}

impl AuthInfo {
pub fn new(
sub: String,
client_id: Option<String>,
organization_id: Option<String>,
scopes: Vec<String>,
audience: Vec<String>,
) -> Self {
Self {
sub,
client_id,
organization_id,
scopes,
audience,
}
}
}

#[derive(Debug)]
pub struct 授權錯誤 (AuthorizationError) {
pub message: String,
pub status_code: u16,
}

impl 授權錯誤 (AuthorizationError) {
pub fn new(message: impl Into<String>) -> Self {
Self {
message: message.into(),
status_code: 403,
}
}

pub fn with_status(message: impl Into<String>, status_code: u16) -> Self {
Self {
message: message.into(),
status_code,
}
}
}

impl fmt::Display for 授權錯誤 (AuthorizationError) {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.message)
}
}

impl std::error::Error for 授權錯誤 (AuthorizationError) {}

pub fn extract_bearer_token(authorization: Option<&str>) -> Result<&str, 授權錯誤 (AuthorizationError)> {
let auth_header = authorization.ok_or_else(|| {
授權錯誤 (AuthorizationError)::with_status("Authorization header is missing", 401)
})?;

if !auth_header.starts_with("Bearer ") {
return Err(授權錯誤 (AuthorizationError)::with_status(
"Authorization header must start with \"Bearer \"",
401,
));
}

Ok(&auth_header[7..]) // 移除 'Bearer ' 前綴
}

取得你的 Logto 租戶資訊

你需要以下數值來驗證 Logto 發行的權杖:

  • JSON Web Key Set (JWKS) URI:Logto 公鑰的網址,用於驗證 JWT 簽章。
  • 簽發者 (Issuer):預期的簽發者值(Logto 的 OIDC URL)。

首先,找到你的 Logto 租戶端點。你可以在多個地方找到:

  • 在 Logto Console,設定網域
  • 在你於 Logto 配置過的任何應用程式設定中,設定端點與憑證

從 OpenID Connect 探索端點取得

這些數值可以從 Logto 的 OpenID Connect 探索端點取得:

https://<your-logto-endpoint>/oidc/.well-known/openid-configuration

以下為範例回應(為簡潔省略其他欄位):

{
"jwks_uri": "https://your-tenant.logto.app/oidc/jwks",
"issuer": "https://your-tenant.logto.app/oidc"
}

由於 Logto 不允許自訂 JWKS URI 或簽發者 (Issuer),你可以將這些數值硬編碼在程式碼中。但這不建議用於正式環境,因為若未來有設定變更,可能會增加維護負擔。

  • JWKS URI:https://<your-logto-endpoint>/oidc/jwks
  • 簽發者 (Issuer):https://<your-logto-endpoint>/oidc

驗證權杖與權限

在擷取權杖並取得 OIDC 設定後,請驗證以下項目:

  • 簽章 (Signature): JWT 必須有效且由 Logto(透過 JWKS)簽署。
  • 簽發者 (Issuer): 必須符合你的 Logto 租戶簽發者。
  • 受眾 (Audience): 必須符合在 Logto 註冊的 API 資源標示符 (resource indicator),或在適用時符合組織 (Organization) 上下文。
  • 過期時間 (Expiration): 權杖不得過期。
  • 權限範圍 (Permissions, scopes): 權杖必須包含 API/操作所需的權限範圍 (scopes)。scopes 會以空格分隔字串出現在 scope 宣告 (claim) 中。
  • 組織 (Organization) 上下文: 若保護的是組織層級 API 資源,需驗證 organization_id 宣告 (claim)。

詳情請參閱 JSON Web Token 以瞭解 JWT 結構與宣告 (claims)。

各權限模型需檢查的項目

不同權限模型下,宣告 (claims) 與驗證規則有所不同:

  • 受眾宣告 (aud): API 資源標示符 (API resource indicator)
  • 組織宣告 (organization_id): 不存在
  • 權限範圍需檢查 (scope): API 資源權限 (API resource permissions)

對於非 API 組織權限,組織上下文由 aud 宣告表示 (例如 urn:logto:organization:abc123)。organization_id 宣告僅存在於組織層級 API 資源權杖中。

提示:

對於多租戶 API,務必同時驗證權限範圍 (scopes) 及上下文(受眾 (audience)、組織 (organization)),以確保安全。

新增驗證邏輯

我們使用 jsonwebtoken 來驗證 JWT。請在你的 Cargo.toml 中加入所需的相依套件:

Cargo.toml
[dependencies]
jsonwebtoken = "9.0"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
reqwest = { version = "0.11", features = ["json"] }
tokio = { version = "1.0", features = ["full"] }

首先,新增這些共用工具來處理 JWT 驗證:

jwt_validator.rs
use crate::{AuthInfo, AuthorizationError, ISSUER, JWKS_URI};
use jsonwebtoken::{decode, decode_header, Algorithm, DecodingKey, Validation};
use serde_json::Value;
use std::collections::HashMap;

pub struct JwtValidator {
jwks: HashMap<String, DecodingKey>,
}

impl JwtValidator {
pub async fn new() -> Result<Self, AuthorizationError> {
let jwks = Self::fetch_jwks().await?;
Ok(Self { jwks })
}

async fn fetch_jwks() -> Result<HashMap<String, DecodingKey>, AuthorizationError> {
let response = reqwest::get(JWKS_URI).await.map_err(|e| {
AuthorizationError::with_status(format!("Failed to fetch JWKS: {}", e), 401)
})?;

let jwks: Value = response.json().await.map_err(|e| {
AuthorizationError::with_status(format!("Failed to parse JWKS: {}", e), 401)
})?;

let mut keys = HashMap::new();

if let Some(keys_array) = jwks["keys"].as_array() {
for key in keys_array {
if let (Some(kid), Some(kty), Some(n), Some(e)) = (
key["kid"].as_str(),
key["kty"].as_str(),
key["n"].as_str(),
key["e"].as_str(),
) {
if kty == "RSA" {
if let Ok(decoding_key) = DecodingKey::from_rsa_components(n, e) {
keys.insert(kid.to_string(), decoding_key);
}
}
}
}
}

if keys.is_empty() {
return Err(AuthorizationError::with_status("No valid keys found in JWKS", 401));
}

Ok(keys)
}

pub fn validate_jwt(&self, token: &str) -> Result<AuthInfo, AuthorizationError> {
let header = decode_header(token).map_err(|e| {
AuthorizationError::with_status(format!("Invalid token header: {}", e), 401)
})?;

let kid = header.kid.ok_or_else(|| {
AuthorizationError::with_status("Token missing kid claim", 401)
})?;

let key = self.jwks.get(&kid).ok_or_else(|| {
AuthorizationError::with_status("Unknown key ID", 401)
})?;

let mut validation = Validation::new(Algorithm::RS256);
validation.set_issuer(&[ISSUER]);
validation.validate_aud = false; // 我們將手動驗證 audience

let token_data = decode::<Value>(token, key, &validation).map_err(|e| {
AuthorizationError::with_status(format!("Invalid token: {}", e), 401)
})?;

let claims = token_data.claims;
self.verify_payload(&claims)?;

Ok(self.create_auth_info(claims))
}

fn verify_payload(&self, claims: &Value) -> Result<(), AuthorizationError> {
// 根據你的權限模型在這裡實作驗證邏輯
// 相關內容會在下方權限模型區段展示
Ok(())
}

fn create_auth_info(&self, claims: Value) -> AuthInfo {
let scopes = claims["scope"]
.as_str()
.map(|s| s.split(' ').map(|s| s.to_string()).collect())
.unwrap_or_default();

let audience = match &claims["aud"] {
Value::Array(arr) => arr.iter().filter_map(|v| v.as_str().map(|s| s.to_string())).collect(),
Value::String(s) => vec![s.clone()],
_ => vec![],
};

AuthInfo::new(
claims["sub"].as_str().unwrap_or_default().to_string(),
claims["client_id"].as_str().map(|s| s.to_string()),
claims["organization_id"].as_str().map(|s| s.to_string()),
scopes,
audience,
)
}
}

接著,實作中介軟體來驗證存取權杖 (Access token):

middleware.rs
use crate::{AuthInfo, AuthorizationError, extract_bearer_token};
use crate::jwt_validator::JwtValidator;
use actix_web::{
dev::{forward_ready, Service, ServiceRequest, ServiceResponse, Transform},
web, Error, HttpMessage, HttpResponse,
};
use futures::future::{ok, Ready};
use std::sync::Arc;

// JWT 中介軟體 (JwtMiddleware)
pub struct JwtMiddleware {
validator: Arc<JwtValidator>,
}

impl JwtMiddleware {
// 建立新的 JwtMiddleware 實例
pub fn new(validator: Arc<JwtValidator>) -> Self {
Self { validator }
}
}

impl<S, B> Transform<S, ServiceRequest> for JwtMiddleware
where
S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error>,
S::Future: 'static,
B: 'static,
{
type Response = ServiceResponse<B>;
type Error = Error;
type InitError = ();
type Transform = JwtMiddlewareService<S>;
type Future = Ready<Result<Self::Transform, Self::InitError>>;

fn new_transform(&self, service: S) -> Self::Future {
ok(JwtMiddlewareService {
service,
validator: self.validator.clone(),
})
}
}

// JwtMiddlewareService 結構體
pub struct JwtMiddlewareService<S> {
service: S,
validator: Arc<JwtValidator>,
}

impl<S, B> Service<ServiceRequest> for JwtMiddlewareService<S>
where
S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error>,
S::Future: 'static,
B: 'static,
{
type Response = ServiceResponse<B>;
type Error = Error;
type Future = futures::future::LocalBoxFuture<'static, Result<Self::Response, Self::Error>>;

forward_ready!(service);

fn call(&self, req: ServiceRequest) -> Self::Future {
let validator = self.validator.clone();

Box::pin(async move {
// 從 header 取得 authorization 欄位
let authorization = req
.headers()
.get("authorization")
.and_then(|h| h.to_str().ok());

match extract_bearer_token(authorization)
.and_then(|token| validator.validate_jwt(token))
{
Ok(auth_info) => {
// 將驗證資訊存入 request extensions,方便後續使用
req.extensions_mut().insert(auth_info);
let fut = self.service.call(req);
fut.await
}
Err(e) => {
// 權杖驗證失敗,回傳錯誤訊息
let response = HttpResponse::build(
actix_web::http::StatusCode::from_u16(e.status_code)
.unwrap_or(actix_web::http::StatusCode::FORBIDDEN),
)
.json(serde_json::json!({ "error": e.message }));

Ok(req.into_response(response))
}
}
})
}
}

根據你的權限模型,在 JwtValidator 中實作對應的驗證邏輯:

jwt_validator.rs
fn verify_payload(&self, claims: &Value) -> Result<(), AuthorizationError> {
// 檢查 audience 宣告是否符合你的 API 資源標示符
let audiences = match &claims["aud"] {
Value::Array(arr) => arr.iter().filter_map(|v| v.as_str()).collect::<Vec<_>>(),
Value::String(s) => vec![s.as_str()],
_ => vec![],
};

if !audiences.contains(&"https://your-api-resource-indicator") {
return Err(AuthorizationError::new("Invalid audience"));
}

// 檢查全域 API 資源所需的權限範圍 (Scopes)
let required_scopes = vec!["api:read", "api:write"]; // 請替換為實際所需的權限範圍
let scopes = claims["scope"]
.as_str()
.map(|s| s.split(' ').collect::<Vec<_>>())
.unwrap_or_default();

for required_scope in &required_scopes {
if !scopes.contains(required_scope) {
return Err(AuthorizationError::new("Insufficient scope"));
}
}

Ok(())
}

套用中介軟體至你的 API

現在,將中介軟體套用到你受保護的 API 路由。

main.rs
use actix_web::{middleware::Logger, web, App, HttpRequest, HttpServer, Result};
use serde_json::{json, Value};
use std::sync::Arc;

mod lib;
mod jwt_validator;
mod middleware as jwt_middleware;

use lib::AuthInfo;
use jwt_validator::JwtValidator;
use jwt_middleware::JwtMiddleware;

#[actix_web::main]
async fn main() -> std::io::Result<()> {
let validator = Arc::new(JwtValidator::new().await.expect("初始化 JWT validator 失敗"));

HttpServer::new(move || {
App::new()
.app_data(web::Data::new(validator.clone()))
.wrap(Logger::default())
.service(
web::scope("/api/protected")
.wrap(JwtMiddleware::new(validator.clone()))
.route("", web::get().to(protected_handler))
)
})
.bind("127.0.0.1:8080")?
.run()
.await
}

async fn protected_handler(req: HttpRequest) -> Result<web::Json<Value>> {
// 從 request extensions 取得驗證 (Authentication) 資訊
let auth = req.extensions().get::<AuthInfo>().unwrap();
Ok(web::Json(json!({ "auth": auth })))
}

測試你的受保護 API

取得存取權杖 (Access tokens)

從你的用戶端應用程式取得: 如果你已完成用戶端整合,你的應用程式可以自動取得權杖。擷取存取權杖 (Access token) 並在 API 請求中使用。

使用 curl / Postman 測試:

  1. 使用者權杖 (User tokens): 使用你的用戶端應用程式的開發者工具,從 localStorage 或網路分頁複製存取權杖 (Access token)

  2. 機器對機器權杖 (Machine-to-machine tokens): 使用 client credentials flow。以下是使用 curl 的非標準範例:

    curl -X POST https://your-tenant.logto.app/oidc/token \
    -H "Content-Type: application/x-www-form-urlencoded" \
    -d "grant_type=client_credentials" \
    -d "client_id=your-m2m-client-id" \
    -d "client_secret=your-m2m-client-secret" \
    -d "resource=https://your-api-resource-indicator" \
    -d "scope=api:read api:write"

    你可能需要根據你的 API 資源 (API resource) 和權限 (Permissions) 調整 resourcescope 參數;如果你的 API 以組織 (Organization) 為範圍,也可能需要 organization_id 參數。

提示:

需要檢查權杖內容嗎?請使用我們的 JWT 解碼工具 來解碼並驗證你的 JWT。

測試受保護端點

有效權杖請求
curl -H "Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9..." \
http://localhost:3000/api/protected

預期回應:

{
"auth": {
"sub": "user123",
"clientId": "app456",
"organizationId": "org789",
"scopes": ["api:read", "api:write"],
"audience": ["https://your-api-resource-indicator"]
}
}
缺少權杖
curl http://localhost:3000/api/protected

預期回應 (401):

{
"error": "Authorization header is missing"
}
無效權杖
curl -H "Authorization: Bearer invalid-token" \
http://localhost:3000/api/protected

預期回應 (401):

{
"error": "Invalid token"
}

權限模型專屬測試

針對以全域權限範圍 (Scopes) 保護的 API 測試情境:

  • 有效權限範圍 (Valid scopes): 使用包含所需 API 權限範圍(如 api:readapi:write)的權杖測試
  • 缺少權限範圍 (Missing scopes): 權杖缺少必要權限範圍時,預期回傳 403 Forbidden
  • 錯誤受眾 (Wrong audience): 權杖受眾 (Audience) 不符合 API 資源時,預期回傳 403 Forbidden
# 權杖缺少必要權限範圍 - 預期 403
curl -H "Authorization: Bearer token-without-required-scopes" \
http://localhost:3000/api/protected

延伸閱讀

RBAC 實務應用:為你的應用程式實現安全授權 (Authorization)

建立多租戶 SaaS 應用程式:從設計到實作的完整指南