.NET雲原生應用實踐(四):基於Keycloak的認證與授權

dax.net發表於2024-10-28

本章目標

  1. 完成Keycloak的本地部署與配置
  2. 在Stickers RESTful API層面完成與Keycloak的整合
  3. 在Stickers RESTful API上實現認證與授權

Keycloak的本地部署

Keycloak的本地部署最簡單的方式就是使用Docker。可以根據官方文件構建Dockerfile,然後使用Docker Compose直接執行。由於Keycloak也是基礎設施的一部分,所以可以直接加到我們在上一講使用的docker-compose.dev.yaml檔案中。同樣,在docker資料夾下新建一個keycloak的資料夾,然後新建一個Dockerfile,內容如下:

FROM quay.io/keycloak/keycloak:26.0 AS builder

# Enable health and metrics support
ENV KC_HEALTH_ENABLED=true
ENV KC_METRICS_ENABLED=true

# Configure a database vendor
ENV KC_DB=postgres

WORKDIR /opt/keycloak
# for demonstration purposes only, please make sure to use proper certificates in production instead
RUN keytool -genkeypair -storepass password -storetype PKCS12 -keyalg RSA -keysize 2048 -dname "CN=server" -alias server -ext "SAN:c=DNS:localhost,IP:127.0.0.1" -keystore conf/server.keystore
RUN /opt/keycloak/bin/kc.sh build

FROM quay.io/keycloak/keycloak:26.0
COPY --from=builder /opt/keycloak/ /opt/keycloak/

ENTRYPOINT ["/opt/keycloak/bin/kc.sh"]

然後修改docker-compose.dev.yaml檔案,加入一個名為stickers-keycloak的新的service:

stickers-keycloak:
  image: daxnet/stickers-keycloak:dev
  build:
    context: ./keycloak
    dockerfile: Dockerfile
  environment:
    - KC_DB=postgres
    - KC_DB_USERNAME=postgres
    - KC_DB_PASSWORD=postgres
    - KC_DB_SCHEMA=public
    - KC_DB_URL=jdbc:postgresql://stickers-pgsql:5432/stickers_keycloak?currentSchema=public
    - KC_HOSTNAME=localhost
    - KC_HOSTNAME_PORT=5600
    - KC_HTTP_ENABLED=true
    - KC_HOSTNAME_STRICT=false
    - KC_HOSTNAME_STRICT_HTTPS=false
    - KC_PROXY=edge
    - KC_BOOTSTRAP_ADMIN_USERNAME=admin
    - KC_BOOTSTRAP_ADMIN_PASSWORD=admin
    - QUARKUS_TRANSACTION_MANAGER_ENABLE_RECOVERY=true
  command: [
      'start',
      '--optimized'
  ]
  depends_on:
    - stickers-pgsql
  ports:
    - "5600:8080"

在這些環境變數中,KC_DB指定了Keycloak所使用的資料庫型別,我們打算複用上一講中所使用的PostgreSQL資料庫,所以這裡填寫postgresKC_DB_USERNAMEKC_DB_PASSWORDKC_DB_SCHEMAKC_DB_URL指定了資料庫的使用者名稱、密碼、schema名稱以及資料庫連線字串。KC_HOSTNAMEKC_HOSTNAME_PORT指定了Keycloak執行的主機名和埠號,這個埠號需要跟ports裡指定的對外埠號一致。KC_BOOTSTRAP_ADMIN_USERNAMEKC_BOOTSTRAP_ADMIN_PASSWORD指定了Keycloak預設的管理員名稱和密碼。

在啟動Keycloak之前,還需要準備好PostgreSQL資料庫,Keycloak啟動後會自動連線資料庫並建立資料庫物件(表、欄位、關係等等)。準備資料庫也非常簡單,繼續沿用上一講介紹的方法,在構建PostgreSQL資料庫映象的時候,將建立資料庫的SQL檔案複製到映象中的/docker-entrypoint-initdb.d資料夾中即可。SQL檔案包含以下內容:

SET statement_timeout = 0;
SET lock_timeout = 0;
SET idle_in_transaction_session_timeout = 0;
SET client_encoding = 'UTF8';
SET standard_conforming_strings = on;
SELECT pg_catalog.set_config('search_path', '', false);
SET check_function_bodies = false;
SET xmloption = content;
SET client_min_messages = warning;
SET row_security = off;

CREATE DATABASE stickers_keycloak WITH TEMPLATE = template0 ENCODING = 'UTF8' LOCALE_PROVIDER = libc LOCALE = 'en_US.utf8';
ALTER DATABASE stickers_keycloak OWNER TO postgres;

然後重新構建並執行PostgreSQL和Keycloak容器:

$ docker compose -f docker-compose.dev.yaml build
$ docker compose -f docker-compose.dev.yaml up

強烈建議在重建和執行容器之前,清除本地的stickers-pgsql:dev映象,並且刪除docker_stickers_postgres_data卷,以確保舊資料不會影響新的部署。

在成功啟動容器之後,開啟瀏覽器訪問http://localhost:5600,應該可以開啟Keycloak的主頁面並用admin/admin進行登入。

在Keycloak中配置Stickers Realm

限於篇幅,這裡就不把配置Keycloak的整個過程一一展示出來了,請移步到我之前寫的幾篇文章檢視詳細步驟:

  • 有關在Keycloak中實現多租戶,並且對於單個租戶下認證的配置,請參考《在Keycloak中實現多租戶並在ASP.NET Core下進行驗證
  • 有關在Keycloak的租戶下啟用授權機制,請參考《Keycloak中授權的實現

請根據上面兩篇文章的步驟,進行如下的配置:

  1. 新建一個名為stickers的Realm
  2. 切換到stickers Realm,新建一個名為public的Client
  3. public Client下啟用Direct access grants(暫時啟用,用作測試)
  4. 新建一個名為usergroups的Client Scope,在這個client scope中,新增一個型別為Group Membership的client scope。將其Token Claim Name設定為groups。然後將這個client scope新增到public Client下
  5. public Client下新建兩個角色:administratorregular_user,然後新建三個使用者:daxnetnobodysuper並設定密碼,然後建立一個名為public的group(名稱與Client的名稱一致),在public group下,新建users group,再在users group下,新建administrators group。將daxnet新增到users group,將super新增到administrators group,並將users group賦予regular_user角色,將administrators group賦予administrator角色
  6. public Client的Authorization配置中,建立四個Scope:admin.manage_usersstickers.readstickers.updatestickers.delete;然後建立兩個resource:admin-api,它具有admin.manage_users scope,以及stickers-api,它具有stickers.readstickers.updatestickers.delete這三個scope
  7. 在public Client下,建立兩個基於角色的Policy:require-admin-policy,它分配了administrator角色,以及require-registered-user-policy,它分配了regular_user角色
  8. 在Permissions下,建立四個Permission:
    1. admin-manage-users-permission:基於require-admin-policy,作用在admin.manage_users Scope
    2. stickers-view-permission:基於require-registered-user-policy,作用在stickers.read Scope
    3. stickers-update-permission:基於require-registered-user-policy,作用在stickers.update Scope
    4. stickers-delete-permission:基於require-registered-user-policy,作用在stickers.delete Scope

你可以參考上面列出的兩篇文章和這些步驟來配置Keycloak,也可以使用本章的程式碼直接編譯Keycloak Docker映象然後直接執行容器,Keycloak容器執行起來之後,所有的配置都會自動匯入,此時就可以使用根據介面上的設定,比對上面的步驟進行學習了。

在完成Keycloak端的配置之後,就可以開始修改Stickers.WebApi專案,使我們的API支援認證與授權了。

在Stickers.WebApi中啟用認證機制

關於什麼是認證,什麼是授權,這裡就不多作討論了,網上相關文章很多,也可以透過ChatGPT獲得詳細的解釋和介紹。我們首先實現一個目標,就是隻允許註冊使用者可以訪問Stickers微服務,而不管這些使用者是不是真的具有訪問其中的某些API的許可權。我這裡用粗體字強調了“註冊使用者”和“許可權”兩個概念,也就可以區分出什麼是認證,什麼是授權了,通俗地說:認證就是該使用者是否被允許使用網站的服務,授權就是在允許使用網站服務的前提下,該使用者是否可以對其中的某些功能進行操作。

在ASP.NET Core中,整合認證與授權機制是非常容易的,首先,向Stickers.WebApi專案新增Microsoft.AspNetCore.Authentication.JwtBearer NuGet包,然後在Program.cs中,加入如下程式碼:

builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(options =>
{
    options.Authority = "http://localhost:5600/realms/stickers";
    options.RequireHttpsMetadata = false;
    options.TokenValidationParameters = new Microsoft.IdentityModel.Tokens.TokenValidationParameters
    {
        NameClaimType = "preferred_username",
        RoleClaimType = ClaimTypes.Role,
        ValidateIssuer = true,
        ValidateAudience = false
    };
});

上面的程式碼用來初始化ASP.NET Core的認證機制,我們使用Jwt Bearer Token的認證模組,在配置中,指定認證機構Authority為stickers Realm的Base URL,然後對token的認證進行引數配置。這裡的NameClaimType指定了在解析access token的時候,應該將哪個Claim看成是使用者名稱稱,同理,RoleClaimType指定了應該將哪個Claim看成是使用者角色。在啟動了PostgreSQL和Keycloak容器之後,可以使用類似下面的cURL命令獲得access token:

$ curl --location 'http://localhost:5600/realms/stickers/protocol/openid-connect/token' \
--header 'Content-Type: application/x-www-form-urlencoded' \
--data-urlencode 'grant_type=password' \
--data-urlencode 'client_id=public' \
--data-urlencode 'client_secret=B2REunrXWN57KtQoJWoP2Dhr7gqKJrol' \
--data-urlencode 'username=daxnet' \
--data-urlencode 'password=daxnet'

然後開啟jwt.io,將這個access token複製到Debugger的Encoded部分,在Decoded部分可以看到,使用者名稱是在preferred_username欄位指定的,這就是NameClaimType指定為preferred_username的原因:

.NET雲原生應用實踐(四):基於Keycloak的認證與授權

當然,還需要在Program.cs檔案中加入Authentication和Authorization的Middleware:

app.UseAuthentication();
app.UseAuthorization();

並在StickersController上啟用Authorize特性:

[ApiController]
[Authorize]
[Route("[controller]")]
public class StickersController(ISimplifiedDataAccessor dac) : ControllerBase
{
    // ...
}

此時如果啟動Stickers API,然後使用cURL獲取所有的“貼紙”,則會返回401 Unauthorized:

$ curl --location 'http://localhost:5141/stickers?asc=true&size=20&page=0' -v
* Host localhost:5141 was resolved.
* IPv6: ::1
* IPv4: 127.0.0.1
*   Trying [::1]:5141...
* Connected to localhost (::1) port 5141
> GET /stickers?asc=true&size=20&page=0 HTTP/1.1
> Host: localhost:5141
> User-Agent: curl/8.5.0
> Accept: */*
> 
< HTTP/1.1 401 Unauthorized
< Content-Length: 0
< Date: Sat, 26 Oct 2024 13:05:21 GMT
< Server: Kestrel
< WWW-Authenticate: Bearer
< 
* Connection #0 to host localhost left intact

但如果將剛剛獲得的access token加到cURL命令中,就可以正常訪問API了(access token太長,這裡先把它截斷了):

$ curl --location 'http://localhost:5141/stickers?asc=true&size=20&page=0' \
       --header 'Authorization: Bearer eyJh...' -v
* Host localhost:5141 was resolved.
* IPv6: ::1
* IPv4: 127.0.0.1
*   Trying [::1]:5141...
* Connected to localhost (::1) port 5141
> GET /stickers?asc=true&size=20&page=0 HTTP/1.1
> Host: localhost:5141
> User-Agent: curl/8.5.0
> Accept: */*
> Authorization: Bearer eyJh...
> 
< HTTP/1.1 200 OK
< Content-Type: application/json; charset=utf-8
< Date: Sat, 26 Oct 2024 13:08:06 GMT
< Server: Kestrel
< Transfer-Encoding: chunked
< 
* Connection #0 to host localhost left intact
{"items":[],"pageIndex":0,"pageSize":20,"totalCount":0,"totalPages":0}

在Stickers.WebApi中啟用授權機制

在我之前寫的《ASP.NET Core Web API下基於Keycloak的多租戶使用者授權的實現》一文中,已經詳細介紹瞭如何基於Keycloak完成授權,在Stickers案例中,我會採用相同的實現方式,因此這裡就不再贅述具體的實現過程了,僅介紹Stickers微服務所特有的部分。

上面我們已經在Keycloak中配置了授權,這裡大致總結一下與授權相關的配置。首先,我們定義了四個scope,分別是:admin.manage_users、stickers.read、stickers.update以及stickers.delete。所謂的scope,其實就是對資源的操作型別;然後,我們定義了兩種資源:admin-api和stickers-api,分別表示兩組不同的API:admin-api表示與站點管理相關的API(雖然暫時我們還沒有實現管理API),而stickers-api則表示與“貼紙”相關的API(也就是StickersController所提供的API);接下來,我們又定義了兩個Policy:require-admin-policy和require-registered-user-policy,分別表示“幹某件事需要管理員角色”和“幹某件事需要註冊使用者角色”。可以看到,其實基於角色的授權,在Keycloak的整個授權體系中,只是其中的一種特例,Keycloak所支援的Policy型別,並不僅只有基於角色這一種策略;最後,定義了四個Permission:admin-manage-users-permission、stickers-delete-permission、stickers-update-permission和stickers-view-permission,這些permission都關聯了對應的策略(這裡都是基於角色的策略)和對資源的操作型別scope,而這些操作型別又進一步被資源所引用。所以,總的來說,Permission就定義了符合某種策略(Policy)的訪問者對某種資源(Resource)具有完成何種操作型別(Scope)的許可權。

仔細思考你會發現,我們其實根本不關心當前登入使用者是什麼角色,我們只關心該使用者的某些特質是否達到訪問某種資源並完成相應操作的需求,角色只不過是這些特質中的一種。所以,一方面在API上,我們定義該API是什麼資源,它支援什麼操作,而另一方面,當認證使用者訪問該API時,我們從使用者的Claims中讀取該使用者在該資源上所能完成的操作名稱,兩者進行比對即可,而至於認證使用者是否滿足訪問該資源並完成該操作的需求,在Keycloak的授權模組中就已經完成計算了,Keycloak只是在傳送的token中帶上計算結果就可以了。

下圖展示了在Keycloak中,針對daxnet這個使用者所進行的許可權評估,從評估結果可以看到,該使用者在stickers-api資源上的stickers.read、stickers.update以及stickers.delete操作是具有許可權的;而在admin-api資源上的admin.manage_users上是沒有許可權的。所以,我們只需要在Stickers.WebApi上實現這個判斷就可以了。

.NET雲原生應用實踐(四):基於Keycloak的認證與授權

完成這個判斷邏輯,大致會需要兩個步驟:首先,使用access token,透過將grant_type設定成urn:ietf:params:oauth:grant-type:uma-ticket並再次呼叫/realms/stickers/protocol/openid-connect/token介面,以獲得包含授權資訊的user claims,然後,在API被訪問時,根據該API所支援的操作列表,從帶有授權資訊的user claims中查詢,看是否API所支援的操作在user claims中能被找到,如果能找到,就說明該使用者可以訪問API,否則就返回403 Forbidden

完整程式碼這裡就不詳細介紹了,還是強烈建議移步閱讀《ASP.NET Core Web API下基於Keycloak的多租戶使用者授權的實現》這篇博文,並配套本章節的原始碼以瞭解細節。

這裡還是涉及到user claims快取的問題,因為在獲取使用者授權資訊的時候,存在兩次Keycloak的呼叫,這樣做並特別高效,後續會考慮引入快取機制來解決這個問題。

在完成程式碼的實現之後,就可以進行測試了,使用daxnet使用者獲取access token:

$ curl --location 'http://localhost:5600/realms/stickers/protocol/openid-connect/token' \
--header 'Content-Type: application/x-www-form-urlencoded' \
--data-urlencode 'grant_type=password' \
--data-urlencode 'client_id=public' \
--data-urlencode 'client_secret=B2REunrXWN57KtQoJWoP2Dhr7gqKJrol' \
--data-urlencode 'username=daxnet' \
--data-urlencode 'password=daxnet'

然後使用這個access token來訪問GET /stickers API,可以看到,能夠成功返回結果:

$ curl --location 'http://localhost:5141/stickers' \
     --header 'Authorization: Bearer eyJhbGci......' \
     -v && echo
* Host localhost:5141 was resolved.
* IPv6: ::1
* IPv4: 127.0.0.1
*   Trying [::1]:5141...
* Connected to localhost (::1) port 5141
> GET /stickers HTTP/1.1
> Host: localhost:5141
> User-Agent: curl/8.5.0
> Accept: */*
> Authorization: Bearer eyJhbGci......
> 
< HTTP/1.1 200 OK
< Content-Type: application/json; charset=utf-8
< Date: Mon, 28 Oct 2024 13:15:58 GMT
< Server: Kestrel
< Transfer-Encoding: chunked
< 
* Connection #0 to host localhost left intact
{"items":[],"pageIndex":0,"pageSize":20,"totalCount":0,"totalPages":0}

重新使用nobody使用者獲取access token:

$ curl --location 'http://localhost:5600/realms/stickers/protocol/openid-connect/token' \
--header 'Content-Type: application/x-www-form-urlencoded' \
--data-urlencode 'grant_type=password' \
--data-urlencode 'client_id=public' \
--data-urlencode 'client_secret=B2REunrXWN57KtQoJWoP2Dhr7gqKJrol' \
--data-urlencode 'username=nobody' \
--data-urlencode 'password=nobody'

然後使用這個access token來訪問GET /stickers API,可以看到,API返回403 Forbidden

$ curl --location 'http://localhost:5141/stickers' \
       --header 'Authorization: Bearer eyJhbGci......' -v && echo
* Host localhost:5141 was resolved.
* IPv6: ::1
* IPv4: 127.0.0.1
*   Trying [::1]:5141...
* Connected to localhost (::1) port 5141
> GET /stickers HTTP/1.1
> Host: localhost:5141
> User-Agent: curl/8.5.0
> Accept: */*
> Authorization: Bearer eyJhbGci......
> 
< HTTP/1.1 403 Forbidden
< Content-Length: 0
< Date: Mon, 28 Oct 2024 13:18:31 GMT
< Server: Kestrel
< 
* Connection #0 to host localhost left intact

總結

本文簡單介紹了在Stickers.WebApi上基於Keycloak實現認證與授權的步驟,由於一些原理性的內容和具體實現細節在之前我的博文中都有詳細介紹,所以這裡也就不再重複了,建議可以結合這些文章來閱讀本章程式碼,相信會有不少的收穫。下一章會基於.NET Web Assembly實現前端,並在開發環境中調通整個前後端流程。

原始碼

本章原始碼在chapter_4這個分支中:https://gitee.com/daxnet/stickers/tree/chapter_4/

下載原始碼前,請先刪除已有的stickers-pgsql:devstickers-keycloak:dev兩個容器映象,並刪除docker_stickers_postgres_data資料卷。

下載原始碼後,進入docker目錄,然後編譯並啟動容器:

$ docker compose -f docker-compose.dev.yaml build
$ docker compose -f docker-compose.dev.yaml up

現在就可以直接用Visual Studio 2022或者JetBrains Rider開啟stickers.sln解決方案檔案,並啟動Stickers.WebApi進行除錯執行了。

相關文章