[Keycloak] 结合Net8(Authorization Code Flow)

在前一篇简单操作了Keycloak admin上的功能

新增了 realm、client、user

接着这篇会先基于 Authorization code flow 跟 net8 做串接

Authorization code flow

先来了解一下 Authorization code 的流程,先跟 AI 偷一张图来用

从流程图中可了解到,大概可以有几个步骤

  1. 使用者在 client 重新导向到 keycloak 登入页面
  2. 在 keycloak 登入成功后,返回 Authorization code 给 client(以下简称code)
  3. client 送出 code 给 server
  4. server 拿 code 向 keycloak 取得 accessToken 和 refreshToken,返回给client
  5. client 拿着 accessToken 访问服务
  6. accessToken 过期时,使用 refreshToken 取得新的token

为了方便演示,我会用建立几只api

  1. 取得 auth url,让使用者可以跳转到 keycloa 进行登入
  2. callback,使用者登入成功后,跳转且返回 authorization code
  3. get token,使用者传入 authorization code,Server端跟 keycloak 取得 token
  4. refresh token,使用者传入 RefreshToken,Server端跟 keycloak 取得新的 token
  5. user info,需要授权的API,验证 token 有效,解析并返回内容

0. Start

  • 建立一个空的 web api 专案

  • 后续会使用到 keycloak 的 api 参照 Securing Applications and Services Guide

  • Url的操作,我习惯使用套件 Flurl

dotnet add package flurl
  • 期间会需要取得当前应用程式的网址,我会从 HttpContext 取得
builder.Services.AddHttpContextAccessor();

private static string AppUrl(IHttpContextAccessor httpContext)
{
    var request = httpContext.HttpContext!.Request;
    return $"{request.Scheme}://{request.Host}";
}

1. Get auth url

新增一只API /auth 帮使用者产生跳转到 keycloak 登入的网址

根据 guide,授权的endpoint是:

/realms/{realm-name}/protocol/openid-connect/auth

以及,我希望登入成功后可以导向到 /auth/callback

app.MapPost("/auth", (
    [FromServices] IConfiguration configuration,
    [FromServices] IHttpContextAccessor httpContextAccessor) =>
{
    var authUrl = "http://localhost:8080/realms/MyRealm/protocol/openid-connect/auth";
    var url = authUrl.SetQueryParams(new
    {
        client_id = "MyClient",
        response_type = "code",
        redirect_uri = AppUrl(httpContextAccessor).AppendPathSegments("auth", "callback"),
        scope = "openid",
        state = Guid.NewGuid().ToString("n")
    });
    return url.ToString();
});

执行后测试看看,导向到auth返回的网址应该会发生错误,这不是有效的redirectUri

回到 keycloak 的 client,把当前应用程式的网址设定上去 Valid Redirect Uris

再测试一次,这次可以看到登入页面,不过登入成功会看到404

因为还没实做 callback 呢!

2. Callback

接着继续实做API /auth/callback

除了用户会被导向回来外,keycloak 也会返回 Authorization code 和 auth 送出的 state

目前我只想要可以把收到的内容印出来,让我可以进一步的取得token就好

private static void Callback(RouteGroupBuilder group)
{
    group.MapGet("/auth/callback", (string code, string state) =>
    {
        return new 
        {
            Code = code,
            State = state
        };
    })
}

再重新操作一次 auth,如果登入还没失效,不会出现登入页面,会直接导向callback

这次就可以完美的拿到callback回来的内容

{
  "code": "e0ce4cdc-2e0f-4624-9c49-2bfa09410a44.bfb3359a-c2c9-445a-b1b4-ea1dee6ddbed.e50af8c1-9acc-49ba-a38b-ee17e224c5c5",
  "state": "5bae638d9b714f99972beef22da705cf"
}

3. Get Token

现在使用者已经拿到 Authorization code

再準备一只API,可以让使用者透过 Server 跟 keycloak 取得 token

根据 guide,取得token的endpoint是:

/realms/{realm-name}/protocol/openid-connect/token

app.MapGet("/auth/token", async (
    string code,
    [FromServices] IHttpContextAccessor httpContext) =>
{
    using var client = new HttpClient();
    var tokenUrl = "http://localhost:8080/realms/MyRealm/protocol/openid-connect/token";
    var callbackUrl = AppUrl(httpContext).AppendPathSegments("auth", "callback");
    var response = await client.PostAsync(tokenUrl, new FormUrlEncodedContent(new Dictionary<string, string>
    {
        { "grant_type", "authorization_code" },
        { "code", code },
        { "redirect_uri", callbackUrl },
        { "client_id", "MyClient" },
    }));
    return await response.Content.ReadAsStringAsync();
});

执行然后把 callback 拿到的 code 餵给他,就可以拿到完美的 token 资讯

把 access_token 丢到 Jwt.io 上也可以看到 payload 里面的资讯

(Code有有效期限,如果失效,可以重新拿一次快点使用看看)

{
    "access_token": "eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJwZ2VNTzFXNGF2Und0NVJYYWNwSkNNLW5hN3VxUndfYlNXVi02LWFBOXZzIn0.eyJleHAiOjE3MzgzMTI0NzMsImlhdCI6MTczODMxMjE3MywiYXV0aF90aW1lIjoxNzM4MzExMzI4LCJqdGkiOiIwZjQyYWFiYS02NDY3LTRkOTgtYmY4Ni04N2ZlNDM0OWRhMmEiLCJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjgwODAvcmVhbG1zL015UmVhbG0iLCJhdWQiOiJhY2NvdW50Iiwic3ViIjoiODFiY2U2N2EtM2JjNC00MzdlLTkwZDUtNTAzNGE2ZDZiMTFjIiwidHlwIjoiQmVhcmVyIiwiYXpwIjoiTXlDbGllbnQiLCJzaWQiOiJiZmIzMzU5YS1jMmM5LTQ0NWEtYjFiNC1lYTFkZWU2ZGRiZWQiLCJhY3IiOiIwIiwiYWxsb3dlZC1vcmlnaW5zIjpbIi8qIl0sInJlYWxtX2FjY2VzcyI6eyJyb2xlcyI6WyJkZWZhdWx0LXJvbGVzLW15cmVhbG0iLCJvZmZsaW5lX2FjY2VzcyIsInVtYV9hdXRob3JpemF0aW9uIl19LCJyZXNvdXJjZV9hY2Nlc3MiOnsiYWNjb3VudCI6eyJyb2xlcyI6WyJtYW5hZ2UtYWNjb3VudCIsIm1hbmFnZS1hY2NvdW50LWxpbmtzIiwidmlldy1wcm9maWxlIl19fSwic2NvcGUiOiJvcGVuaWQgcHJvZmlsZSBlbWFpbCIsImVtYWlsX3ZlcmlmaWVkIjp0cnVlLCJuYW1lIjoibXkgdXNlciIsInByZWZlcnJlZF91c2VybmFtZSI6Im15dXNlciIsImdpdmVuX25hbWUiOiJteSIsImZhbWlseV9uYW1lIjoidXNlciIsImVtYWlsIjoidGVzdEB0ZXN0LmNvbSJ9.jj-q-KjurRV93aR7rFeY5XE9ibCxPqe73Sjfl2RTZ6GGSXNNDo-uJD_YhIAs4L_nFmFuK1Xx9tzAPrrR4IbZoP2bbdkwCJaS1vjleJmCxr8ObcZ3gJ5-WyXvNES8lxYWg09rDF4OU-oJVrPRs1msdrkp-p97LnFZwEopZLKPMiwsgC5l7kOHTieCJSyUG6BCNjN_x6NRvZODA78ce1_YmN1oMVnEzH9lytX1C3AVFWtmByN10v0EwCpCr0mVkhWwK2VvVaZae6A7hGEHoCLL-MdVmSv7MR5IY6jV0bAKcHPnCFMrf5T8TsduOvhu1zPR5UsWahu4FEP2tkWT3ZG1ng",
    "expires_in": 300,
    "refresh_expires_in": 1800,
    "refresh_token": "eyJhbGciOiJIUzUxMiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJkNTQzOTI5Ny1jZjBlLTRiNGEtODlkZC02YmQ2YzVjMzk3NGIifQ.eyJleHAiOjE3MzgzMTM5NzMsImlhdCI6MTczODMxMjE3MywianRpIjoiMTY4NjA1YmUtMzU5Zi00MjhmLTk2NGUtM2VlOTI5Y2JiZmJkIiwiaXNzIjoiaHR0cDovL2xvY2FsaG9zdDo4MDgwL3JlYWxtcy9NeVJlYWxtIiwiYXVkIjoiaHR0cDovL2xvY2FsaG9zdDo4MDgwL3JlYWxtcy9NeVJlYWxtIiwic3ViIjoiODFiY2U2N2EtM2JjNC00MzdlLTkwZDUtNTAzNGE2ZDZiMTFjIiwidHlwIjoiUmVmcmVzaCIsImF6cCI6Ik15Q2xpZW50Iiwic2lkIjoiYmZiMzM1OWEtYzJjOS00NDVhLWIxYjQtZWExZGVlNmRkYmVkIiwic2NvcGUiOiJvcGVuaWQgcHJvZmlsZSByb2xlcyBiYXNpYyBlbWFpbCBhY3Igd2ViLW9yaWdpbnMifQ.he-BOcMP7ci1D3Funh8yT1ibb8H9s-3GLdCtGyBZy_tO9RTbJS4G5ZcP4yQS0LUkLDG3NdoLwZJjDyMjVyWnRA",
    "token_type": "Bearer",
    "id_token": "eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJwZ2VNTzFXNGF2Und0NVJYYWNwSkNNLW5hN3VxUndfYlNXVi02LWFBOXZzIn0.eyJleHAiOjE3MzgzMTI0NzMsImlhdCI6MTczODMxMjE3MywiYXV0aF90aW1lIjoxNzM4MzExMzI4LCJqdGkiOiIzZTA0NzcyOC04NDgzLTRiZjMtYjRlOC0yMDAwMjE3MDY3YzAiLCJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjgwODAvcmVhbG1zL015UmVhbG0iLCJhdWQiOiJNeUNsaWVudCIsInN1YiI6IjgxYmNlNjdhLTNiYzQtNDM3ZS05MGQ1LTUwMzRhNmQ2YjExYyIsInR5cCI6IklEIiwiYXpwIjoiTXlDbGllbnQiLCJzaWQiOiJiZmIzMzU5YS1jMmM5LTQ0NWEtYjFiNC1lYTFkZWU2ZGRiZWQiLCJhdF9oYXNoIjoiT01aZEdWSzJYSElObGplSWVpZVRmUSIsImFjciI6IjAiLCJlbWFpbF92ZXJpZmllZCI6dHJ1ZSwibmFtZSI6Im15IHVzZXIiLCJwcmVmZXJyZWRfdXNlcm5hbWUiOiJteXVzZXIiLCJnaXZlbl9uYW1lIjoibXkiLCJmYW1pbHlfbmFtZSI6InVzZXIiLCJlbWFpbCI6InRlc3RAdGVzdC5jb20ifQ.Cm1-OmrwZM6v06pcUjhR1cbbGknm6v3VHF7Cf9pyXTDBSyDHIo0bCJ3KwqLbhrZMZfIhxW9wXpS5NpreIDNpBt7HJulAgG9xORyO8nDPzfj2vN4sguTEXSVKeExlkZods-4anfdidPiR60Ixe1fVUtP1FXyHMHl-vUNisa6aJEBa9E8n9kwitWhPEJTZSzG8DAtCZTNvTmO5ZlvdlGlMQyE5pe_SbAeBWQ7CMDE16CEZDozKx2DpRVz5uBKkeKruKROzfY6O4wbwwNK5pCcPZraQn3n3nr_Q3CCUEZ2ffR4CBucQpoBKLJPtyTf-P6Vom9rfoCNBVg1mPTY3h1-vMw",
    "not-before-policy": 0,
    "session_state": "bfb3359a-c2c9-445a-b1b4-ea1dee6ddbed",
    "scope": "openid profile email"
}

4. User Info

现在有了 AccessToekn,但是应用程式还不认识他,接着準备一只API,让我们验证 keycloak 发行的 accessToken 可以通过授权

这边需要解析jwt token了,所以加上套件

dotnet add package Microsoft.AspNetCore.Authentication.JwtBearer

新增相关的服务和 middleware

builder.Services.AddAuthorization();
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(r =>
    {
        r.RequireHttpsMetadata = false;
        r.Audience = "account";
        r.MetadataAddress = "http://localhost:8080/realms/MyRealm/.well-known/openid-configuration";
        r.TokenValidationParameters = new TokenValidationParameters
        {
            ValidIssuer = "http://localhost:8080/realms/MyRealm",
        };
    });

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

让 swagger 可以填入 access_token

builder.Services.AddSwaggerGen(r =>
{
    r.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme
    {
        Name = "Authorization",
        Type = SecuritySchemeType.ApiKey,
        Scheme = "Bearer",
        BearerFormat = "JWT",
        In = ParameterLocation.Header,
        Description = "use api `Account/Login` get api token. value pattern: `Bearer {apiToken}`",
    });

    r.AddSecurityRequirement(new OpenApiSecurityRequirement()
    {
        {
            new OpenApiSecurityScheme
            {
                Reference = new OpenApiReference
                {
                    Type = ReferenceType.SecurityScheme,
                    Id = "Bearer"
                },
            },
            new List<string>()
        }
    });
});

新增 api,并且标记他必须授权才能访问

app.MapGet("/user", (ClaimsPrincipal user) =>
{
    return user.Claims.ToDictionary(c => c.Type, c => c.Value);
}).RequireAuthorization();

执行、取得token、填入token(记得加上 bearer),然后呼叫api /user

accessToken得到了授权,而且我们也拿到Payload里面的资讯(从Claims)

{
  "exp": "1738313340",
  "iat": "1738313040",
  "auth_time": "1738311328",
  "jti": "02a7f707-e9ae-434c-9a6c-f35544d184f3",
  "iss": "http://localhost:8080/realms/MyRealm",
  "aud": "account",
  "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier": "81bce67a-3bc4-437e-90d5-5034a6d6b11c",
  "typ": "Bearer",
  "azp": "MyClient",
  "sid": "bfb3359a-c2c9-445a-b1b4-ea1dee6ddbed",
  "http://schemas.microsoft.com/claims/authnclassreference": "0",
  "allowed-origins": "/*",
  "realm_access": "{\"roles\":[\"default-roles-myrealm\",\"offline_access\",\"uma_authorization\"]}",
  "resource_access": "{\"account\":{\"roles\":[\"manage-account\",\"manage-account-links\",\"view-profile\"]}}",
  "scope": "openid profile email",
  "email_verified": "true",
  "name": "my user",
  "preferred_username": "myuser",
  "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname": "my",
  "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/surname": "user",
  "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress": "test@test.com"
}

5. Refresh

主要的功能都有了,但是token过期又要重新跑一次流程,接着加上 refresh token 的 api

跟 getToken 相差不大,只要一点点的改动

  1. 把 grant_type 换成 refresh_token
  2. 原本的 code 换成 refresh_token
  3. url 不变
app.MapGet("/refresh", async (string refreshToken, [FromServices] IConfiguration configuration) =>
{
    using var client = new HttpClient();
    var tokenUrl = "http://localhost:8080/realms/MyRealm/protocol/openid-connect/token";
    var response = await client.PostAsync(tokenUrl, new FormUrlEncodedContent(new Dictionary<string, string>
    {
        { "grant_type", "refresh_token" },
        { "refresh_token", refreshToken },
        { "client_id", "MyClient" },
    }));
    return await response.Content.ReadAsStringAsync();
});

接着就可以用 refresh_token 重新拿到跟 getToken 一样的内容了!

(当然,是新的 token)

Example Code

Github

参考资料

Securing Applications and Services Guide

Secure Your .NET Application With Keycloak: Step-by-Step Guide

关于作者: 网站小编

码农网专注IT技术教程资源分享平台,学习资源下载网站,58码农网包含计算机技术、网站程序源码下载、编程技术论坛、互联网资源下载等产品服务,提供原创、优质、完整内容的专业码农交流分享平台。

热门文章

5 点赞(415) 阅读(67)