OAuth 인증의 이해 4 - 인증 클라이언트 구현 2


이 시리즈의 모든 글 보기 : oauth


이전 글에서 API 에 RP 기능을 일부 구현하여, OP로부터 보안 토큰(Id 토큰, 접근 토큰, 리프레시 토큰)을 받았고, 그 중에 ID 토큰의 페이로드로부터 사용자 정보를 획득하는 것까지 구현해 봤습니다.

이러한 것이 가능한 이유는, OP 설정 당시, OP가 승인 코드를 전달할 주소(승인된 Redirect URI)를 API의 엔드 포인트(/on-consent/google)로 지정했기 때문입니다.

BFF 패턴

그렇게 지정한 이유는 이전 글에서 소개한 BFF 패턴의 두 가지 시나리오 중 하나를 선택한 것을 암묵적으로 가정했기 때문입니다.

두 시나리오의 차이점은 승인 코드의 수신자에 있습니다.

이 글은 승인 코드의 수신자가 백엔드(웹서버)인 시나리오를 따르고 있습니다.
다른 시나리오에서는 승인 코드의 수신자가 (앞으로 구현할) 프론트 엔드 서버입니다.
이를 Token Mediate 패턴이라고 부르는데, OP 설정 시, 승인된 Redirect URI를 UI 앱 서버 주소로 설정해야 합니다.

참고로 어떤 시나리오를 따르더라도, 보안 토큰은 API(웹서버)에 저장되기 때문에, OAuth의 Confidential Client 를 가정한 인증 방식이라는 점에는 변함이 없습니다.

이런 언급을 하는 이유는 "BFF 패턴은 아직 OAuth의 스펙으로 채택되지 않은 최신 제안"이라, (물론 제안의 문제점들이 보완되면, 즉시 채택될 것입니다) OpenId Provider 와 OIDC 클라이언트 라이브러리들이 BFF 패턴 제안을 충분히 고려하지 못했거나, 혹은 암묵적으로 제안된 패턴 중 하나를 따르고 있을 수도 있드는 점을 말씀드리고 싶어서입니다.

예를 들어, 구글의 경우, 브라우저에서 실행되는 앱을 무조건 public client (보안 토큰을 보호하지 못하는 클라이언트)로 보고, 별도의 승인 과정을 제시합니다.

또한, 닷넷이 제공하는 UI 앱 용 OIDC 클라이언트 라이브러리들은 Mediate Pattern 을 따르고 있는데, 이를 명시적으로 표현하지 않고 있습니다.

이러한 모호함은 OIDC 인증 클라이언트 구현의 출발점부터 혼란을 야기합니다.

예를 들어, OP에서 클라이언트를 등록할 때, 클라이언트 앱의 종류를 어떤 것으로 선택하느냐(이전 글에서 [웹 애플리케이션]으로 설정했죠), 승인된 Redirect 주소를 어떤 서버의 것으로 설정할 지 등입니다.

이런 혼란을 줄이기 위해, 이 글은 승인 코드와 보안 토큰을 모두 API 로 보내는 시나리오의 BFF 패턴을 따르고 있음을 밝힙니다.

BFF 패턴 인증 과정

인증을 위한 BFF 패턴의 핵심은 RP가 아래의 기능(엔드 포인트)을 UI 앱(Api Client, 이하 AC)에게 제공하도록 하는 것입니다.

  1. 세션 확인
  2. 세션 생성

1. 세션 확인

RP는 AC 가 세션을 확인할 수 있는 엔드 포인트를 제공해야 합니다.
(BFF 패턴에서는 세션을 쿠키로 저장할 것을 제안합니다.)

이 엔드 포인트는, 세션 확인 요청의 응답으로, 어떤 사용자의 세션인지에 대해 충분한 정보를 제공해야 합니다.

세션 확인의 과정은 아래와 같습니다.

AC 의 fetch 요청

AC는 사용자 정보를 획득하기 위해 RP의 "세션 확인 엔드 포인트"에 요청을 보냅니다.

구름 도형은 고유의 IP 또는 도메인 주소를 가진 웹 서버를 가리킵니다.

위 그림에서 Session Cookie 도형의 외곽선이 점선인데, 이는 브라우저가 이 쿠키를 fetch 요청에 첨부할 수도, 하지 않을 수도 있음을 표현한 것입니다.

세션 쿠키는 유효 기간을 설정하지 않은 쿠키입니다.
유효 기간이 없는 쿠키는, 세션이 종료되면(브라우저 창이 닫히면) 즉시 지워집니다.
이와 반대로, 유효 기간이 설정된 쿠키를 Persistent Cookie 라고 하는데, 이를 "영구 쿠키"라고 번역하더군요. (맹구가 번역한 건 아닌지…)
그 보다는 “지속 쿠키” 혹은 "저장 쿠키"가 더 적절할 거 같습니다. 브라우저는, 창 닫힘 유무와 상관없이, 설정된 유효 기간까지 쿠키를 사용자 PC에 저장하고, 유효 기간이 만료되면 지웁니다.

RP 의 사용자 정보 응답

fetch 요청을 받은 RP 는 요청에 포함된 세션 쿠키의 유효성을 검사하여,

유효하면, 세션 저장소에 저장된 사용자 정보를 응답하거나(200),
아닌 경우, 401 Not Authorized (혹은 404 Not Found)를 응답합니다.

AC는 fetch 결과를 바탕으로 자신 만의 Authorization 에 사용할 수 있습니다.

AC가 사용자 정보를 받은 경우 (200 응답을 받은 경우)

AC는 마치 로그인 된 듯한 UI를 보여 줄 수 있습니다.

동시에, 브라우저는 RP가 설정한 세션 쿠키를 저장합니다. 이후에 AC가 RP에 보내는 모든 요청에 세션 쿠키를 첨부합니다.

AC가 사용자 정보를 받지 못한 경우

AC는 로그인하지 않은 듯한 UI를 보여 줄 수 있습니다.

참고로, 이 경우의 원인은 아래와 같습니다.

  • 브라우저에 세션 쿠키가 저장되어 있지 않아, fetch 요청에 첨부되지 못했거나,
  • 첨부했어도, RP의 세션 유효성 확인을 통과하지 못한

경우입니다. (그 결과로 401을 받은 것이죠)

2. 세션 생성

RP는 세션을 설정할 수 있는 엔드 포인트를 제공해야 합니다.

이 엔트 포인트의 역할은

  • 승인 코드 흐름을 개시하여, OP로부터 승인 코드를 획득하고,
  • 획득한 승인 코드로, 보안 토큰을 획득한 후, 보안 토큰으로 사용자 정보를 추출하고, 이를 세션 저장소에 저장한 후, 세션 쿠키를 설정하는 것입니다.

이는 OIDC의 승인 코드 흐름에 세션 쿠키 설정이 추가된 것과 같습니다.

이 엔드 포인트는 보통 AC의 로그인 버튼을 통해 호출됩니다.

이 과정은 몇 가지 단계로 이뤄집니다.

승인 코드 획득 단계

사용자가 로그인 버튼을 누르면, 승인 코드 획득 단계가 시작됩니다.
OIDC 승인 코드 흐름 중 승인 요청(RP → OP) 과정이, BFF 패턴으로 인해, AC → RP → OP로 변경되었습니다.

참고로, 이 단계는 이전 글의 “/grant/google” 엔드 포인트를 통해 구현을 완료했습니다.

세션 쿠키 설정 단계

사용자가 동의 화면에서 동의를 클릭하면, OIDC의 승인 코드 흐름의 나머지 절차가 진행됩니다.

아래 그림에서 1 ~ 3 까지의 과정은 OP에 의해 자동으로 진행되고, 4 ~ 6 까지는 RP에 의해, 6-1 ~ 8 까지는 AC에 의해 자동으로 진행됩니다.

참고로, 지난 글에서는 4, 5 번의 구현을 완료하고, 수신한 ID 토큰의 내용을 확인하는 부분까지 구현했었죠. 이 글에서는, 5-1부터 구현할 것입니다.

위 그림의 6, 6-1, 7, 8 의 과정은 BFF 패턴에 의해 추가된 부분입니다.

5-1 에서, RP가 세션을 생성하고, 이를 쿠키 해더에 담아(세션 쿠키), 6 Redirect 응답을 보냅니다. 이때, Redirect 목적지는 AC 앱 서버입니다.

만약 6 단계에서 Redirect 대신, OK 응답을 보내면, 브라우저는 AC 앱을 실행하지 못하고 빈 화면만 사용자에게 보여줍니다. 즉, 절차가 중단되게 됩니다.
그러나, RP에는 세션 데이터가 저장되어 있기 때문에, 브라우저를 닫지 않고, 사용자가 수동으로 AC를 다시 실행시키면 모든 것이 정상 동작합니다.

6-1 단계에서 브라우저는, RP 가 설정한 세션 쿠키를 저장한 다음, Redirect 목적지인 UI 앱 서버로 이동하고(7), 그 응답으로 UI 앱을 다운로드 받습니다.(8).

7~8 단계는 사용자가 수동으로 AC를 다운 받고 실행하는 과정이 Http 프로토콜에 의해 자동으로 진행되는 것에 지나지 않습니다. 다만, 이 과정의 결과로, 세션 쿠키가 브라우저에 저장되는 것이 보장된다는 차이점이 있습니다.

따라서, 6-1 ~ 8 단계를 마치면, 브라우저는 아래의 상태가 됩니다.

image

이 상태에서는, AC 의 세션 확인은 항상 200 을 응답 받습니다.

전체적인 절차를 자세히 알아 봤으니, 이제 구현을 보도록 하겠습니다.
코드의 흐름 상, 세션 생성 기능 구현을 먼저 살펴 보고, 세션 확인 기능의 구현을 나중에 살펴 보겠습니다.

세션 생성 기능 구현

이전 글의 RP/on-consent/google 엔드 포인트 구현에 5-1, 6 로직을 추가하는 것으로 끝납니다.
(6-1 ~ 8 은 Http 스펙에 의해 자동으로 실행되어 구현의 대상이 아닙니다.)

Asp.Net Core 의 세션

5-1 에서 세션 쿠키를 설정해야 하는데, 이를 위해, Asp.Net Core 의 세션 기능을 사용할 것입니다.

이 기능은 세션 쿠키와 캐시(IDistributedCache)를 기반으로 동작합니다.

들어 온 요청에 세션 쿠키가 있다면, 쿠키에서 세션 ID를 추출합니다.
그 다음, 캐시 저장소를 검색하여, 세션 ID를 key 로 하는 데이터가 있다면, 그 데이터를 HttpContext.Session 할당합니다.

들어 온 요청에 세션 쿠키가 없다면, 아무것도 하지 않습니다.
다만, 코드가 요청을 처리하던 중, HttpContext.Session 에 데이터 엔트리(키-값 쌍)를 추가하면, 세션 ID를 생성하고, 그 ID 를 캐시 키로, 엔트리(키-값 쌍)를 캐시 데이터로 저장하고, 세션 ID 를 쿠키에 저장합니다.

우리 코드는 HttpContext.Session 만 가지고 놀면 된다는 의미입니다.

세션 기능 활성화

Asp.Net Core 의 세션은 별도의 패키지 설치 없이, 이 기능을 활성화하는 설정만 하면 바로 사용할 수 있습니다.

Program.cs 에서 아래와 같이 세션 서비스(와 그 서비스가 이용할 캐시 저장소)를 등록하고,

builder.Services.AddDistributedMemoryCache();
builder.Services.AddSession(options =>
{
    // options.IdleTimeout = TimeSpan.FromMinutes(20); // 기본값
    options.Cookie.HttpOnly = true;
    options.Cookie.IsEssential = true;
});

세션 미들웨어를 설정합니다.

app.UseSession();

세션 쿠키 생성 구현

Program.cs 의 “/on-consent/google” 핸들러를 아래와 같이 수정합니다.

using Microsoft.IdentityModel.JsonWebTokens; //추가

// ...

const string SESSION_KEY = "userInfo";

var onConsent = app.MapGroup("/on-consent");
onConsent.MapGet("/google", async (
    IConfiguration config, 
    HttpContext httpContext,
    [FromQuery(Name = "code")] string authzCode) =>
{
    #region ID 토큰 획득
    var google = config.GetSection("Authenticates:Google");

    if (google.Exists() is false)
        throw new InvalidCastException("No value is configured for Authenticates:Google section");

    var opTokenUri = google.GetSection("TokenUri").Value;
    var grantType = "authorization_code";
    var redirectUri = google.GetSection("RedirectUri").Value;
    var clientId = google.GetSection("ClientId").Value;
    var clientSecret = google.GetSection("ClientSecret").Value;

    var location = $"{opTokenUri}?&grant_type={grantType}&code={authzCode}" +
        $"&redirect_uri={redirectUri}&client_id={clientId}&client_secret={clientSecret}";

    var tokenEndpointResponse = await new HttpClient().PostAsync(location, null);
    var tokenReponse = await tokenEndpointResponse.Content.ReadFromJsonAsync<TokenReponse>();
    #endregion

    #region 세션 설정
    var idToken = tokenReponse?.Id_Token;
    var spaAddress = config.GetSection("Hosts")["Spa"] ??
            throw new InvalidOperationException("UI 앱 배포 서버 주소가 설정되지 않음");

    if (string.IsNullOrWhiteSpace(idToken))
    {
        // UI 앱은 state 파라미터 처리해야 함.
        spaAddress += "?state=flowFail";
    }
    else
    {
        var jwt = new JsonWebToken(idToken);

        // 클레임으로부터 UserInfo 추출
        var userInfo = new UserInfo
        (
            jwt.TryGetClaim("name", out var claim) ? claim.Value : "",
            jwt.TryGetClaim("email", out claim) ? claim.Value : ""
        ); 

        if(userInfo.IsValid())
        {
            // 세션 설정
            var userInfoJson = JsonSerializer.Serialize(userInfo);
            httpContext.Session.SetString(SESSION_KEY, userInfoJson);
        }
    }
    #endregion

    #region Redirect
    httpContext.Response.Redirect(spaAddress);
    #endregion
});

위 코드에 나타난 DTO 들은 아래와 같습니다.

public record UserInfo(string Email, string Name)
{
    public bool IsValid() =>
        !string.IsNullOrWhiteSpace(Email) && !string.IsNullOrWhiteSpace(Name);
    public static UserInfo Empty() => new(string.Empty, string.Empty);
};

또한, 아래 내용을 API의 appsettings.json 에 추가해야 합니다.

  "Hosts": {
    "Spa": "https://localhost:7004"
  }

마지막으로, JWT 를 간단히 처리하기 위해 JsonWebToken 객체를 사용했는데 이를 위해서, Microsoft.IdentityModel.JsonWebTokens 패키지를 추가했습니다.

JWT 처리와 관련해서는 이 글을 참고하세요.

세션 확인 구현

AC의 세션 확인 요청을 처리하기 위한 엔드포인트를 RP 에 추가합니다.
이 엔드 포인트 경로를 “/userinfo” 로 설정했습니다.

app.MapGet("/userinfo", (HttpContext httpContext) =>
{
    var userInfoJson = httpContext.Session.GetString(SESSION_KEY);

    if (string.IsNullOrWhiteSpace(userInfoJson) ||
        JsonSerializer.Deserialize<UserInfo>(userInfoJson) is not UserInfo userInfo)
        return Results.Unauthorized();
    
    return Results.Ok(userInfo);
});

세션 삭제 구현

RP는 세션 쿠키가 요청에 포함되지 않으면 사용자를 인식할 수 없습니다.

브라우저가 세션 쿠키를 요청에 포함시킨다 하더라도,

  1. 세션 데이터가 캐시의 유휴 기간(Idle Time)이 만료되어, 삭제되는 경우

  2. 강제로 세션을 지운 경우

사용자를 식별할 수 없습니다.
따라서, RP 입장에서는, 의도적 로그 아웃은 세션을 지우는 것으로 간단히 구현할 수 있습니다.

app.MapGet("/logout", (HttpContext httpContext) =>
{
    httpContext.Session.Clear();

    return Results.Ok();
});

CORS 설정

제일 중요한 설정입니다. 현재 RP 와 AC 앱은 포트 번호가 달라, 다른 사이트로 식별됩니다.

RP : localhost:5004
AC : localhost:7004

즉, AC 가 RP에 보내는 모든 요청은 CrossSite 요청이기 때문에, RP가 CORS 를 설정하지 않으면, AC의 모든 요청은 거부됩니다.

Program.cs 에 CORS 서비스를 설정합니다.

builder.Services.AddCors(options =>
    options.AddDefaultPolicy(policy =>
        policy.WithOrigins("https://localhost:7004")
            .AllowCredentials()
            .AllowAnyHeader()
            .AllowAnyMethod()));

보시다시피, 기본 정책(options.AddDefaultPolicy)으로, https://localhost:7004 출처 앱이 보내는 요청에 대해, CrossSite 금지를 해제하고 있습니다.
이 중에 중요한 것은, AC의 브라우저가 쿠키를 첨부할 수 있도록, Credentials 를 허락하는 설정입니다.

CORS 미들웨어를 활성화합니다.

app.UseHttpsRedirection();

app.UseRouting();

app.UseCors(); // 추가. 미들웨어 순서 중요.

app.UseSession();

미들웨어에 정책을 명기하지 않아, 기본 정책이 적용됩니다.

RP 단의 코드는 일단락되었고, 이와 호응할 UI 앱으로 넘어 갑니다.

AC 의 구현

BFF 패턴에서, AC의 구현은 사실 매우 간단합니다.

  1. 세션 확인
    • AC : RP/userinfo 로 fetch를 보내, 사용자 정보를 응답 받는지 확인합니다.
  2. 세션 생성 (로그인)
    • AC : 로그인 버튼으로 RP/grant/google 로 브라우저를 이동시킵니다.

AC 앱 추가

솔루션에 Blazor WebAssembly Standalone App 을 추가합니다.

프로젝트 생성할 때, [Authentication Type] 항목을 - Individual account - 로 선택합니다.
이는 단순히 코드 작업량을 줄이기 위한 것입니다.

중요
Individual account 로 설정하면, 뼈대 코드는 OIDC 인증을 위한 코드로 채워지고, 이와 관련된 문서도 잘 되어 있습니다. 그러나, 그 뼈대 코드와 문서의 내용은 이 글의 BFF 패턴과 다른 방식인 Token Mediate 패턴입니다. 그래서, 뼈대 코드를 그대로 사용하지는 않습니다.

앱 설정

/Properties/launchSettings.json

https 의 주소를 아래와 같이 변경합니다.
RP 가 세션 생성 후, 이 주소로 6 Redirect 하기 때문에, 정확히 설정합니다.

"profiles": {
  "https": {
    ...
    "applicationUrl": "https://localhost:7004; ...",
   ...

wwwroot/appSettings.json

아래 노드를 추가합니다.

...
  "Rp": {
    "Host": "https://localhost:5004",
    "SessionCheck": "/userinfo",
    "Login": "/grant/google",
    "Logout": "/logout"
  }

이 값과 매칭될 옵션 객체를 추가합니다.

public record RPOption(string Host, string SessionCheck, string Login, string Logout);

Program.cs

그 다음 서비스 객체들을 등록합니다. 아래는 Program.cs 의 전체 내용입니다.
(솔루션 이름은 PUCB, RP 프로젝트는 PUCB.Backend, AC 는 PUCB.Wasm, DTO 객체는 PUCB.Backend.Contrants 입니다)

using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.AspNetCore.Components.Web;
using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
using PUCB.Wasm;

var builder = WebAssemblyHostBuilder.CreateDefault(args);
builder.RootComponents.Add<App>("#app");
builder.RootComponents.Add<HeadOutlet>("head::after");

var rp = builder.Configuration.GetSection("Rp").Get<RPOption>()
    ?? throw new InvalidOperationException("RP에 대한 설정이 없습니다.");

builder.Services.AddScoped<RPOption>(sp => rp);

builder.Services
    .AddTransient<CookieHandler>()
    .AddScoped(sp => sp
        .GetRequiredService<IHttpClientFactory>()
        .CreateClient("API"))
    .AddHttpClient("API", (sp, client) => 
        client.BaseAddress = new(rp.Host)
        )
    .AddHttpMessageHandler<CookieHandler>();

builder.Services.AddScoped<BFFAuthenticationStateProvider>();

builder.Services.AddScoped<AuthenticationStateProvider>(sp =>
    sp.GetRequiredService<BFFAuthenticationStateProvider>());

builder.Services.AddScoped<AuthProxy>();

//builder.Services.AddOidcAuthentication(options =>
//{
//    // Configure your authentication provider options here.
//    // For more information, see https://aka.ms/blazor-standalone-auth
//    builder.Configuration.Bind("Local", options.ProviderOptions);
//});

builder.Services.AddAuthorizationCore();

await builder.Build().RunAsync();

쿠키를 사용하는 HttpClient

위 설정에서, CookieHandler 는 HttpClient 가 보내는 요청에 브라우저의 Credentials(쿠키)를 포함시키기 위해 필요합니다. (HttpClent에 쿠키를 실을 수 있다는 점은 축복입니다)

아래 클래스를 프로젝트에 추가합니다.

using Microsoft.AspNetCore.Components.WebAssembly.Http;
namespace PUCB.Wasm;

internal class CookieHandler : DelegatingHandler
{
    protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
    {
        request.SetBrowserRequestCredentials(BrowserRequestCredentials.Include);
        return base.SendAsync(request, cancellationToken);
    }
}

AuthenticationStateProvider

아시다시피, 이 객체는 블레이저에서 레이저 요소나 사용자 코드를 통해 수시로 호출되어, 인증 상태를 알려주는 역할을 합니다.

우리는 이 객체를 파생할 때, 승인 확인을 하도록 구현, 다시 말하면, GetAuthenticationStateAsync() 가 RP/userinfo 엔드 포인트를 통해 사용자의 정보를 확인하도록 할 것이고, 추가로 Logout 기능을 넣을 것입니다.

using Microsoft.AspNetCore.Components.Authorization;
using System.Security.Claims;

namespace PUCB.Wasm;

public class BFFAuthenticationStateProvider : AuthenticationStateProvider
{
    public string LoginRedirect => _proxy.LoginRedirect;

    private readonly ClaimsPrincipal _anonymous = new();
    private readonly Task<AuthenticationState> _anonymousState; 
    private readonly AuthProxy _proxy;

    private ClaimsPrincipal _currentPrincipal;
    private bool _logoutFails;
    public BFFAuthenticationStateProvider(AuthProxy proxy)
    {
        _proxy = proxy;
        _currentPrincipal = _anonymous;
        _anonymousState = Task.FromResult<AuthenticationState>(new(_anonymous));
    }
    public override async Task<AuthenticationState> GetAuthenticationStateAsync()
    {
        if (_logoutFails) 
            return _anonymousState.Result;

        var userInfo = await _proxy.CheckSessionAsync();

        if (userInfo.IsValid())
        {
            var claims = new[]
            {
                new Claim(ClaimTypes.Name, userInfo.Name),
                new Claim(ClaimTypes.Email, userInfo.Email)
            };

            var identity = new ClaimsIdentity(claims, "Backend Session Cookie");
            _currentPrincipal = new ClaimsPrincipal(identity);  
            
            return new(_currentPrincipal);
        }

        return _anonymousState.Result;
    }

    public async Task LogoutAsync()
    {        
        _logoutFails = !await _proxy.Logout();
        
        var newState = GetAuthenticationStateAsync();
        
        NotifyAuthenticationStateChanged(newState);
    }        
}

이 객체가 사용하는 AuthProxy 객체는 아래와 같습니다.

using PUCB.Backend.Contracts;
using PUCB.Wasm;
using System.Net.Http.Json;

public class AuthProxy(HttpClient httpClient, RPOption rp) : IDisposable
{
    public string LoginRedirect = rp.Host + rp.Login;

    public async Task<UserInfo> CheckSessionAsync()
    {
        try
        {
            var userInfo = await httpClient.GetFromJsonAsync<UserInfo>(rp.SessionCheck);
            return userInfo ?? UserInfo.Empty();
        }
        catch (Exception ex)
        {
            Console.WriteLine(ex.Message);
            return UserInfo.Empty();
        }
    }

    /// <summary>
    /// 백엔드에 세션 쿠키 제거 요청.
    /// </summary>
    /// <returns>true : 제거 성공, false: 통신 문제로 제거 실패</returns>
    public async Task<bool> Logout()
    {
        try
        {
            _ = await httpClient.GetAsync(rp.Logout);
            return true;
        }
        catch (Exception ex)
        {
            Console.WriteLine(ex.Message);
            return false;
        }        
    }

    public void Dispose()
    {
        httpClient?.Dispose();
    }
}

모든 객체가 준비되었으니, 연결시켜 봅니다.

세션 확인 요청

App.razor 에서, AuthenticationState를 캐스캐이딩 했기 때문에, 앱은 시작하자 마자 AuthenticationStateProvider.GetAuthenticationStateAsync()를 호출합니다.

이 메서드가 세션 확인 요청을 수행하고, 그 결과가 캐스케이딩을 통해 렌더트리에 전달됩니다.

<CascadingAuthenticationState>
    <Router AppAssembly="@typeof(App).Assembly">
       ...
    </Router>
</CascadingAuthenticationState>

세션 생성 요청 (로그인) 과 세션 삭제 요청(로그아웃)

이 둘은 뼈대 코드의 LoginDisplay.razor 를 아래와 같이 수정하여 구현할 수 있습니다.

@using Microsoft.AspNetCore.Components.WebAssembly.Authentication
@inject NavigationManager Navigation
@inject BFFAuthenticationStateProvider AuthStateProvider;

<AuthorizeView>
    <Authorized>
        Hello, @context.User.Identity?.Name!
        <button class="nav-link btn btn-link" @onclick="BeginLogOut">Log out</button>
    </Authorized>
    <NotAuthorized>
        <a href="@AuthStateProvider.LoginRedirect">Log in</a>
    </NotAuthorized>
</AuthorizeView>

@code{
    
    public async Task BeginLogOut()
    {
        await AuthStateProvider.LogoutAsync();
        // Navigation.NavigateTo("", true);
    }
}

AuthStateProvider.LogoutAsync() 내부에서,

    public async Task LogoutAsync()
    {        
        _logoutFails = !await _proxy.Logout();
        
        var newState = GetAuthenticationStateAsync();
        
        NotifyAuthenticationStateChanged(newState);
    } 

세션 삭제 요청을 RP 보내고, AuthenticationStateChanged 이벤트를 호출하도록 구현했기 때문에, UI는 깜빡임 없이 새로운 AuthenticationState를 반영합니다.

이렇게 하지 않고, 세션 삭제 요청을 RP 보낸 후, 브라우저 Refersh를 해도 되지만, 앱이 새롭게 로드되어 사용자 경험이 좋지 않습니다.

@code{
    
    public async Task BeginLogOut()
    {
        await AuthStateProvider.LogoutAsync();
        Navigation.NavigateTo("", true);
    }
}

로그인을 반영한 UI

Weather.razor 를 로그인 상태에 따라 달리 보이게 하기 위해, 아래와 같이 변경합니다.

@page "/weather"
@inject HttpClient Http

<PageTitle>Weather</PageTitle>

<h1>Weather</h1>

<p>fetching data from Backend.</p>

<AuthorizeView>
    <Authorized>
        @if (forecasts == null)
        {
            <p><em>Loading...</em></p>
        }
        else
        {
            <table class="table">
                <thead>
                    <tr>
                        <th>Date</th>
                        <th>Temp. (C)</th>
                        <th>Temp. (F)</th>
                        <th>Summary</th>
                    </tr>
                </thead>
                <tbody>
                    @foreach (var forecast in forecasts)
                    {
                        <tr>
                            <td>@forecast.Date.ToShortDateString()</td>
                            <td>@forecast.TemperatureC</td>
                            <td>@forecast.TemperatureF</td>
                            <td>@forecast.Summary</td>
                        </tr>
                    }
                </tbody>
            </table>
        }
    </Authorized>
    <NotAuthorized>
        <p> 이 페이지는 로그인한 사용자만 볼 수 있습니다. </p>
    </NotAuthorized>
</AuthorizeView>


@code {

    [CascadingParameter]
    private Task<AuthenticationState> AuthState { get; set; } = null!;
    private WeatherForecast[]? forecasts;

    protected override async Task OnInitializedAsync()
    {
        if((await AuthState).User.Identity?.IsAuthenticated ?? false)
        forecasts = await Http.GetFromJsonAsync<WeatherForecast[]>("/weatherforecast");
    }

    public class WeatherForecast
    {
        public DateOnly Date { get; set; }

        public int TemperatureC { get; set; }

        public string? Summary { get; set; }

        public int TemperatureF => 32 + (int)(TemperatureC / 0.5556);
    }
}

사용자가 로그인하지 않으면, forecasts 필드를 설정하는 로직이 실행되지도 않고, 화면도 달라집니다.

이제 두 앱을 실행시켜 테스트를 해봅니다.

로그인 하지 않은 경우

/

/weather

로그인한 경우

/

우측 상단에 로그인을 누르면, 구글 로그인 화면 => 로그인 => 동의 화면 => 동의를 하면, 창이 refresh 되면서, 앱이 새롭게 시작하고, 로그인 UI로 변경됩니다.

/weather

마치며

모든 구현을 끝냈지만, 매우 단순화된 구현이라 한계가 있습니다.

  1. 사용자는 UI 앱을 시작할 때 마다, 로그인을 해야 합니다.
    이는 BFF 인증 패턴이 추구하는 바입니다. 세션 쿠키를 사용했기 때문에, 사용자가 창을 닫으면 사용자 PC에는 아무것도 남지 않게 되어 보안 수준이 높습니다. (안 닫더라도 RP의 캐시 지속시간이 만료되면, 쿠키는 무효화됩니다)
    그러나, 이는 사용자에게 불편을 야기할 도 있습니다.
    이 불편을 해소하기 위해서는 AC가 보안 토큰을 관리하거나, 지속 쿠키를 사용해야 하는데, 이는 보안 구멍입니다.
    다른 해결책은 blazor.wasm.js 가 시작하기 전에(로딩 써클이 실행되기 전에), 세션 생성을 요청하는 js 를 먼저 실행하는 것인데, 실제로 Blazor Webassembly Standalone 템플릿을 통해 프로젝트를 생성할 때, [Authentication type]을 -Individual account- 로 선택하면 아래와 같이 블레이저 JS 보다 먼저 실행되는 js를 배치했음을 알 수 있습니다.
// index.html
....
    <script src="_content/Microsoft.AspNetCore.Components.WebAssembly.Authentication/AuthenticationService.js"></script>
    <script src="_framework/blazor.webassembly.js"></script>
    ...

참고로, AuthenticationService.js는 이 글이 채택한 (승인 코드 흐름 기반) BFF 패턴을 따르지 않고 있어(Implicit Code Flow + PKCE 기반입니다.), 그대로 쓰면 안됩니다. 이글의 예제에서는 저 태그를 지우지는 않았지만, 실제로는 스크립트의 결과를 사용하지 않고 있습니다.
단지, 그 태그의 src 값으로 “세션 생성” 요청을 보내는 스크립트가 들어가야 한다는 의미로, 내부 구현은 "세션 확인"을 먼저 수행 후, 사용자가 확인이 안된 경우만, “세션 생성” 엔드 포인트로 Navigate 시키는 로직입니다. 이 스크립트로 인해, 앱은 시작하자 마자 구글 로그인 화면을 보여 주거나, 이전에 이미 로그인을 했다면, 로그인 없이 처음부터 다시 시작합니다. 이때는 "세션 확인"이 성공적이라 “세션 생성” 스크립트는 실행되지 않고, 블레이저 앱이 실행됩니다.

  1. 로그인 후, 이전 페이지로 돌아 가지 않고, 무조건 홈 화면으로 갑니다.
    이는 세션 생성 요청에 현재 페이지를 returnUrl 파라미터로 전달하는 것으로 간단히 해결할 수 있습니다.

  2. API (RP)가 보호 받지 못하고 있습니다.
    UI 앱은 마치 로그인 기능이 있는 듯 보입니다. 그러나, 이는 단순히 UI 컨텐츠의 결정 로직에 지나지 않습니다. AC의 로그인 상태와 상관 없이, 브라우저를 열어, `RP/weatherforecast’ 로 요청을 보내면, 여전히 날씨 데이터를 받을 수 있습니다.

다음 글에서는 RP에 AuthenticationScheme 을 적용하여, 좀 더 우아한 코드로 바꾸고, 동시에 API를 보호하는 로직을 추가할 것입니다. 지면이 허락된다면, 실무적으로 사용가능한 OIDC 클라이언트 라이브러리를 도입하여 새롭게 구현할 것입니다.

8 Likes

엄청나게 연구하신 티가 납니다… 후덜덜…

2 Likes