在前一篇简单操作了Keycloak admin上的功能
新增了 realm、client、user
接着这篇会先基于 Authorization code flow 跟 net8 做串接
Authorization code flow
先来了解一下 Authorization code 的流程,先跟 AI 偷一张图来用
从流程图中可了解到,大概可以有几个步骤
- 使用者在 client 重新导向到 keycloak 登入页面
- 在 keycloak 登入成功后,返回 Authorization code 给 client(以下简称code)
- client 送出 code 给 server
- server 拿 code 向 keycloak 取得 accessToken 和 refreshToken,返回给client
- client 拿着 accessToken 访问服务
- accessToken 过期时,使用 refreshToken 取得新的token
为了方便演示,我会用建立几只api
- 取得 auth url,让使用者可以跳转到 keycloa 进行登入
- callback,使用者登入成功后,跳转且返回 authorization code
- get token,使用者传入 authorization code,Server端跟 keycloak 取得 token
- refresh token,使用者传入 RefreshToken,Server端跟 keycloak 取得新的 token
- 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 相差不大,只要一点点的改动
- 把 grant_type 换成
refresh_token
- 原本的 code 换成
refresh_token
- 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