ASP.NET Core 앱에 로그인 추가하기
이 빠른 시작 가이드에서는 ASP.NET Core 10 Razor Pages 앱에 KeenAuth 로그인을 추가합니다. 사용하는 것은 ASP.NET Core의 표준 OpenID Connect 클라이언트인 Microsoft.AspNetCore.Authentication.OpenIdConnect입니다. KeenAuth에는 아직 .NET SDK가 없으며 필요하지도 않습니다. KeenAuth는 표준 OpenID Connect 제공자이기 때문입니다. 앱은 OAuth 클라이언트이며 모든 토큰을 서버에서 보관합니다. 브라우저에는 무작위 세션 키를 담은 쿠키만 전달됩니다. 로그인을 설정하고, 페이지를 보호하고, 사용자의 액세스 토큰으로 보호된 API를 호출하고, 로그아웃한 뒤 오프라인에서 앱을 테스트하는 방법을 살펴봅니다.
이 앱에는 다음 경로가 있습니다.
| 경로 | 역할 |
|---|---|
GET / |
공개 홈 페이지: 로그인 링크 또는 사용자 이름과 로그아웃 버튼 |
GET /login |
로그인을 시작합니다. KeenAuth로 리디렉션합니다 |
GET /signin-oidc |
OpenID Connect 핸들러가 제공하는 콜백 |
GET /dashboard |
세션이 필요합니다. 사용자 정보와 API가 반환하는 메시지를 표시합니다 |
POST /logout |
여기와 KeenAuth에서 세션을 종료합니다. 안티포저리 토큰이 필요합니다 |
시작하기 전에
- KeenAuth 환경. 발급자(issuer)는
https://your-environment.eu.keenauth.app과 같은 형태입니다. - 환경에 등록된 웹 애플리케이션(애플리케이션 생성)과 클라이언트 시크릿. 핸들러는 토큰 요청 본문에 시크릿을 보내므로
client_secret_post를 선택하십시오.development환경에서는 리디렉션 URIhttp://localhost/signin-oidc와 로그아웃 후 리디렉션 URIhttp://localhost/를 등록하십시오. 루프백 URI는 포트 없이 등록되며8080과 같은 모든 포트와 일치합니다. - 환경에 등록된 API(API 생성)와
messages:read스코프. 예를 들어https://api.example.com인identifier가 이 API 액세스 토큰의 대상(audience)입니다. Express 빠른 시작에서 이러한 토큰을 허용하는 API를 만듭니다. - .NET 10 SDK(10.0.403 또는 그 이후 패치 버전).
1. 패키지 추가하기
이 앱에는 정확한 버전으로 고정된 패키지가 하나 있습니다. 잠금 파일(packages.lock.json, RestorePackagesWithLockFile로 활성화)이 나머지 모든 패키지를 콘텐츠 해시와 함께 고정합니다.
<!-- The standard OpenID Connect client of ASP.NET Core (MIT). Exact version; the lock file pins the rest. -->
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="[10.0.14]" />
잠금 모드로 복원하여 의존성 그래프가 바뀌면 조용히 넘어가지 않고 실패하도록 하십시오.
dotnet restore --locked-mode
2. 설정하기
앱은 설정을 환경 변수에서만 읽습니다. 값을 절대 커밋하지 마십시오.
| 변수 | 필수 | 의미 |
|---|---|---|
KEENAUTH_ISSUER |
예 | 환경의 발급자, https |
KEENAUTH_CLIENT_ID |
예 | 웹 애플리케이션의 client_id |
KEENAUTH_CLIENT_SECRET |
예 | 해당 클라이언트 시크릿 |
APP_BASE_URL |
예 | 앱의 공개 URL. 리디렉션 URI는 여기서 만들어집니다. localhost, 127.0.0.1 또는 [::1]인 경우에만 http를 사용할 수 있습니다 |
API_URL |
예 | 앱이 호출하는 API, 예: http://localhost:3001 |
KEENAUTH_API_AUDIENCE |
예 | KeenAuth에서 해당 API의 식별자 |
SESSION_SECURE_COOKIE |
아니요 | 일반 http://localhost에서만 false |
KeenAuthSettings.From은 시작 시 이 값들을 읽고 검사합니다. 누락되었거나 사용할 수 없는 값이 있으면 앱이 중지되며, 어떤 오류 메시지에도 값이 포함되지 않습니다.
public static KeenAuthSettings From(Func<string, string?> env) => new(
Url(Need(env, "KEENAUTH_ISSUER"), "KEENAUTH_ISSUER", httpsOnly: true, anyScheme: false),
Need(env, "KEENAUTH_CLIENT_ID"),
Need(env, "KEENAUTH_CLIENT_SECRET"),
Url(Need(env, "APP_BASE_URL"), "APP_BASE_URL", httpsOnly: false, anyScheme: false),
Url(Need(env, "API_URL"), "API_URL", httpsOnly: false, anyScheme: false),
// RFC 8707: the access token is requested for your API, which then accepts it as its audience.
Url(Need(env, "KEENAUTH_API_AUDIENCE"), "KEENAUTH_API_AUDIENCE", httpsOnly: false, anyScheme: true),
// The session cookie is Secure unless this is explicitly "false" (plain http://localhost).
!string.Equals(env("SESSION_SECURE_COOKIE"), "false", StringComparison.OrdinalIgnoreCase));
3. 세션을 서버에 보관하기
쿠키 스킴이 앱에 사용자를 로그인시킵니다. 이 쿠키에는 무작위 키만 담기며, 사용자 정보와 토큰은 서버의 티켓 저장소(MemoryTicketStore, 프로세스 하나의 메모리)에 남습니다.
.AddCookie(options =>
{
// The cookie holds only a random key; the user and the tokens stay in MemoryTicketStore.
options.Cookie.HttpOnly = true;
options.Cookie.SameSite = SameSiteMode.Lax;
options.Cookie.SecurePolicy = settings.SecureCookie ? CookieSecurePolicy.Always : CookieSecurePolicy.SameAsRequest;
options.ExpireTimeSpan = TimeSpan.FromHours(8);
options.SlidingExpiration = false;
})
4. OpenID Connect 핸들러 설정하기
핸들러는 환경의 디스커버리(<issuer>/.well-known/openid-configuration)와 서명 키를 읽습니다. PKCE(S256), state, nonce를 사용하는 인가 코드 흐름을 사용하며, 돌아올 때 이 값들을 검증합니다. 디스커버리에 푸시된 인가 요청 엔드포인트가 있으면 핸들러가 이를 사용합니다(RFC 9126).
// The endpoints and the signing keys come from discovery: <issuer>/.well-known/openid-configuration.
options.Authority = settings.Issuer;
options.ClientId = settings.ClientId;
options.ClientSecret = settings.ClientSecret;
options.ResponseType = OpenIdConnectResponseType.Code;
options.ResponseMode = OpenIdConnectResponseMode.Query;
// PKCE (RFC 7636) with S256; the handler also sends state and nonce and checks them on the way back.
options.UsePkce = true;
options.Scope.Clear();
options.Scope.Add("openid");
options.Scope.Add("profile");
options.Scope.Add("email");
// messages:read is a scope of your API; the example API requires it on /api/messages.
options.Scope.Add("messages:read");
options.SaveTokens = true;
options.MapInboundClaims = false;
options.GetClaimsFromUserInfoEndpoint = false;
// Pushed authorization requests (RFC 9126) when the environment offers the endpoint.
options.PushedAuthorizationBehavior = PushedAuthorizationBehavior.UseIfAvailable;
options.CallbackPath = "/signin-oidc";
// ID tokens must be signed, with the keys of the environment and one of these algorithms.
options.TokenValidationParameters.RequireSignedTokens = true;
options.TokenValidationParameters.ValidateIssuerSigningKey = true;
options.TokenValidationParameters.ValidAlgorithms = ["RS256", "ES256", "PS256"];
options.TokenValidationParameters.NameClaimType = "name";
// These cookies carry the state and the nonce between the redirect and the callback.
var cookiePolicy = settings.SecureCookie ? CookieSecurePolicy.Always : CookieSecurePolicy.SameAsRequest;
options.NonceCookie.SameSite = SameSiteMode.Lax;
options.NonceCookie.SecurePolicy = cookiePolicy;
options.CorrelationCookie.SameSite = SameSiteMode.Lax;
options.CorrelationCookie.SecurePolicy = cookiePolicy;
이벤트 세 개가 핸들러를 완성합니다. 첫 번째 이벤트는 API의 식별자를 resource(RFC 8707)로 추가합니다. 이것이 없으면 액세스 토큰이 API용으로 발급되지 않으며 API가 이를 거부합니다. 두 번째 이벤트는 인가 응답의 iss 매개변수를 검사합니다(RFC 9207). ASP.NET Core는 이를 검사하지 않지만 KeenAuth는 이를 지원한다고 광고합니다.
OnRedirectToIdentityProvider = context =>
{
// Build the redirect URI from the setting, not from the Host header of the request.
context.ProtocolMessage.RedirectUri = settings.RedirectUri;
// RFC 8707: an access token for your API.
context.ProtocolMessage.SetParameter("resource", settings.ApiAudience);
return Task.CompletedTask;
},
OnMessageReceived = async context =>
{
// RFC 9207: the authorization response must name the issuer that answered.
var configuration = await context.Options.ConfigurationManager!
.GetConfigurationAsync(context.HttpContext.RequestAborted);
var iss = context.ProtocolMessage.Iss;
if (iss is not null ? iss != configuration.Issuer : configuration.AuthorizationResponseIssParameterSupported)
{
context.Fail("The authorization response has a wrong or missing iss parameter.");
}
},
세 번째 이벤트는 환경의 키로 서명되지 않은 ID 토큰을 거부합니다. ASP.NET Core는 토큰 엔드포인트에서 받은 ID 토큰의 서명 검사를 건너뛰는데, OpenID Connect Core는 TLS 위에서 이를 허용합니다. 이 앱은 그래도 검사합니다.
OnTokenResponseReceived = async context =>
{
// ASP.NET Core skips the signature check of an ID token that comes from the token endpoint (OpenID
// Connect Core 3.1.3.7 allows it over TLS). This app does not: the token must be signed by a key
// of the environment, with an allowed algorithm.
var configuration = await context.Options.ConfigurationManager!
.GetConfigurationAsync(context.HttpContext.RequestAborted);
var parameters = context.Options.TokenValidationParameters.Clone();
parameters.ValidIssuer = configuration.Issuer;
parameters.IssuerSigningKeys = configuration.SigningKeys;
parameters.ValidAudience = settings.ClientId;
var result = await new JsonWebTokenHandler().ValidateTokenAsync(
context.TokenEndpointResponse.IdToken ?? "", parameters);
if (!result.IsValid)
{
context.Fail("The ID token is not signed by a key of the environment.");
}
},
마지막 두 이벤트는 로그아웃 요청에 로그아웃 후 리디렉션 URI를 넣고, 실패한 로그인을 이유를 표시하지 않고 홈 페이지로 돌려보냅니다.
OnRedirectToIdentityProviderForSignOut = context =>
{
context.ProtocolMessage.PostLogoutRedirectUri = settings.PostLogoutRedirectUri;
return Task.CompletedTask;
},
OnRemoteFailure = context =>
{
// Do not show the reason in the browser; the logs have it.
context.Response.Redirect("/?error=signin");
context.HandleResponse();
return Task.CompletedTask;
},
5. 로그인, 페이지 보호, 로그아웃
/login은 로그인을 시작하고, 이후 대시보드로 돌아갑니다. 고정된 로컬 경로입니다.
public class LoginModel : PageModel
{
/// <summary>Starts sign-in: redirects to KeenAuth, and back to the dashboard afterwards (a fixed local path).</summary>
public IActionResult OnGet() =>
Challenge(new AuthenticationProperties { RedirectUri = "/dashboard" }, OpenIdConnectDefaults.AuthenticationScheme);
}
대시보드에는 세션이 필요합니다. 세션이 없으면 ASP.NET Core가 로그인을 시작합니다. 로그아웃은 POST이며, Razor Pages가 안티포저리 토큰을 검사합니다. 여기서 세션을 종료한 뒤 KeenAuth로 리디렉션합니다(id_token_hint와 post_logout_redirect_uri를 사용하는 OpenID Connect RP-Initiated Logout).
public class LogoutModel : PageModel
{
/// <summary>
/// POST only, and Razor Pages checks the antiforgery token. Ends the session here, then redirects to KeenAuth
/// (OpenID Connect RP-Initiated Logout with id_token_hint and post_logout_redirect_uri).
/// </summary>
public IActionResult OnPost() =>
SignOut(new AuthenticationProperties(), CookieAuthenticationDefaults.AuthenticationScheme,
OpenIdConnectDefaults.AuthenticationScheme);
}
홈 페이지는 로그인 링크를 제공하고 로그아웃으로 POST합니다. 폼 태그 헬퍼가 안티포저리 토큰을 추가합니다.
@if (User.Identity?.IsAuthenticated == true)
{
<p>Signed in as @Model.DisplayName.</p>
<p><a href="/dashboard">Dashboard</a></p>
<!-- The form tag helper adds the antiforgery token. -->
<form method="post" asp-page="/Logout"><button type="submit">Sign out</button></form>
}
else
{
<p><a href="/login">Sign in</a></p>
}
6. API 호출하기
대시보드는 세션에서 액세스 토큰을 가져와 이 토큰으로 API를 호출합니다. 토큰은 서버에 남고, 브라우저는 HTML만 받습니다.
/// <summary>Needs a session. The access token stays on the server; the browser receives only the HTML.</summary>
[Authorize]
public class DashboardModel(MessagesClient messages) : PageModel
{
public string DisplayName => User.Identity?.Name ?? User.FindFirst("sub")?.Value ?? "";
public int Status { get; private set; }
public string? Messages { get; private set; }
public async Task OnGetAsync()
{
var accessToken = await HttpContext.GetTokenAsync("access_token");
(Status, Messages) = await messages.FetchAsync(accessToken ?? "", HttpContext.RequestAborted);
}
}
public async Task<(int Status, string? Body)> FetchAsync(string accessToken, CancellationToken cancel)
{
using var request = new HttpRequestMessage(HttpMethod.Get, "api/messages");
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
try
{
using var response = await http.SendAsync(request, cancel);
return ((int)response.StatusCode, response.IsSuccessStatusCode ? await response.Content.ReadAsStringAsync(cancel) : null);
}
catch (HttpRequestException)
{
return (502, null);
}
}
이 예제에는 리프레시 토큰이 없습니다. 액세스 토큰이 만료되면 API가 401로 응답하고 대시보드에 그 상태가 표시됩니다. 다시 로그인하십시오.
7. 앱 실행하기
Express 빠른 시작의 API(또는 이 토큰을 허용하는 다른 API)를 먼저 시작한 뒤, 변수를 내보내고 앱을 실행하십시오.
ASPNETCORE_URLS=http://localhost:8080 dotnet run --project App
http://localhost:8080을 열고 Sign in을 선택한 뒤 KeenAuth에서 로그인하고 대시보드를 여십시오.
8. 네트워크 없이 테스트하기
테스트에는 네트워크도 KeenAuth 계정도 필요하지 않습니다. 루프백 포트의 작은 Kestrel 서버가 환경(디스커버리, 테스트 실행용으로 만든 키 세트, 토큰 엔드포인트, 푸시된 인가 요청, 종료 엔드포인트)과 API 역할을 합니다. 테스트는 실제 앱을 다른 포트에서 시작하고, 리디렉션을 따라가지 않는 HTTP 클라이언트로 조작합니다. 읽을 수 있는 브라우저와 같습니다.
[Fact]
public async Task APageWithoutASessionStartsSignInAndCallsNoApi()
{
using var response = await h.Get("/dashboard");
Assert.Equal(HttpStatusCode.Redirect, response.StatusCode);
Assert.StartsWith(h.Fake.Issuer + "/oauth2/authorize?", response.Headers.Location!.ToString());
Assert.Empty(h.Fake.To("/api/messages"));
}
dotnet restore Tests --locked-mode
python3 ../scripts/nuget_deps.py # licences and exact pins (--age: 7-day rule, network)
dotnet test Tests --no-restore
테스트는 다음을 확인합니다. 설정이 시작 시 검증되고 어떤 오류에도 시크릿이 포함되지 않는지, 로그인이 PKCE S256, state, nonce, resource를 보내고 일치하는 verifier로 코드를 교환하는지, 푸시된 인가 요청이 매개변수를 브라우저 밖에 두는지, 액세스 토큰이 Authorization 헤더로 API에 전달되고 쿠키나 페이지에는 절대 들어가지 않는지, 알 수 없거나 재사용된 state, 잘못되었거나 없는 iss, 서명되지 않았거나 다른 키로 서명되었거나 공개 키를 키로 한 HMAC으로 서명되었거나 다른 audience, nonce 또는 issuer용으로 발급되었거나 이미 만료된 ID 토큰이 세션 없이 거부되는지, 세션이 없는 페이지가 로그인을 시작하는지, 로그아웃에 안티포저리 토큰이 필요하고 id_token_hint와 함께 종료 엔드포인트로 가며 서버에서 세션을 종료하는지.
실제 브라우저로 하는 전체 로그인에는 KeenAuth 환경이 필요합니다. 이는 오프라인 테스트에 포함되지 않습니다.
프록시 또는 로드 밸런서 뒤에서
APP_BASE_URL을 앱의 공개 URL로 설정하십시오. 리디렉션 URI는 Host 헤더가 아니라 이 값에서 만들어집니다. 프록시가 TLS를 종료하는 경우, 세션 쿠키에 Secure 플래그가 붙도록 프록시가 앱에 접근하는 유일한 경로일 때만 전달 헤더 미들웨어를 추가하십시오.
현재 제한 사항
- 로그인은
/login으로 보내는GET으로 시작합니다. 콜백은state검사와 PKCE로 보호됩니다. - 핸들러는
client_secret_basic이 아니라client_secret_post로 클라이언트를 인증합니다. - 앱은 RS256, ES256 또는 PS256으로 서명된 ID 토큰을 허용합니다. KeenAuth는 EdDSA로도 서명할 수 있지만 이 예제는 이를 허용하지 않으며 EdDSA는 시험해 보지 않았습니다. 웹 애플리케이션의 환경에서는 세 알고리즘 중 하나를 유지하십시오.
- 티켓 저장소와 안티포저리 토큰 및 state 쿠키를 보호하는 키는 프로세스 하나의 메모리에 있습니다. 재시작하면 세션이 끝나고 진행 중인 로그인은 실패합니다. 서버가 여러 대라면 티켓을 공유 캐시에 보관하고 ASP.NET Core Data Protection 키를 영구 저장하십시오.
- 리프레시 토큰과 무음 로그인은 없습니다.
다음 단계
- Express API 보호하기: 이 앱이 호출하는 API입니다.
- 토큰 검증: API 미들웨어 아래에서 동작하는 검증기입니다.