[Blazor] 사용자 인증 및 인증정보 활용에 관한 질문

Blazor server에서 사용자 인증 부분을 처리하고 작동은 잘 하는것처럼 보이는데…
로그인된 사용자의 정보를 활용하는 부분에서 Clean하게 구성하고 싶어서 질문 올립니다.
분명 깔끔하고 심플한 방법이 있을 것 같은데, 잘 안되네요…

아래는 제가 처리하는 방식입니다.

  1. 로그인 정보 입력 후 로그인 시도
// Login.razor

@code{
    private async Task Authenticate()
    {
        if (string.IsNullOrWhiteSpace(userId) || string.IsNullOrWhiteSpace(password))
        {
            snackbarRef.Show("필수입력값 누락", Severity.Warning, variant: Variant.Outlined);
            return;
        }

        var userSession = jwtAuthenticationManager.GenerateJwtToken(userId, password);
        if (userSession == null)
        {
            snackbarRef.Show("토큰 생성 실패!", Severity.Error, variant: Variant.Outlined);
            return;
        }

        var customAuthStateProvider = (CustomAuthenticationStateProvider)authStateProvider;
        await customAuthStateProvider.LoginUser(userSession.Result);
        _navi.NavigateTo("/", true);
    }
}


2. JWT 생성 후 암호화
// JwtAuthenticationManager.cs

public async Task<UserSession?> GenerateJwtToken(string userId, string password)
{
    if (string.IsNullOrWhiteSpace(userId) || string.IsNullOrWhiteSpace(password))
    {
        return null;
    }

    var tokenExpiryTimeStamp = DateTime.Now.AddMinutes(JWT_TOKEN_VALIDITY_MINS);
    var tokenKey = Encoding.ASCII.GetBytes(JWT_SECURITY_KEY);
    var claimsIdentity = new ClaimsIdentity(new List<Claim>
    {
        new Claim(ClaimTypes.Name, userId),
        new Claim(ClaimTypes.Hash, password),
    });
    var signingCredentials = new SigningCredentials(
        new SymmetricSecurityKey(tokenKey),
        SecurityAlgorithms.HmacSha256Signature);
    var securityTokenDescriptor = new SecurityTokenDescriptor
    {
        Subject = claimsIdentity,
        Expires = tokenExpiryTimeStamp,
        SigningCredentials = signingCredentials
    };

    var jwtSecurityTokenHandler = new JwtSecurityTokenHandler();
    var securityToken = jwtSecurityTokenHandler.CreateToken(securityTokenDescriptor);
    var token = jwtSecurityTokenHandler.WriteToken(securityToken);

    // returning User session
    var userSession = new UserSession
    {
        UserId = userId,
        Role = "Member",
        Token = token,
        ExpiresIn = (int)tokenExpiryTimeStamp.Subtract(DateTime.Now).TotalSeconds
    };
    return userSession;
}


3. 암호화된 JWT를 로컬 스토리지에 저장하며,

AuthenticationStateManager를 상속받은 CustomAuthenticationStateManager.cs 를 아래와 같이 구성했습니다.

public override async Task<AuthenticationState> GetAuthenticationStateAsync()
{
    try
    {
        var userSession = await _localStorage.ReadEncryptedItemAsync<UserSession>("UserSession");
        if (userSession == null)
            return await Task.FromResult(new AuthenticationState(_anonymous));

        var claimsPrincipal = new ClaimsPrincipal(new ClaimsIdentity(new List<Claim>
        {
        new Claim(ClaimTypes.Name, userSession.UserId),
        new Claim(ClaimTypes.Role, userSession.Role),
        }, "JwtAuth"));

        return await Task.FromResult(new AuthenticationState(claimsPrincipal));
    }
    catch (Exception ex)
    {
        Log.Write(Serilog.Events.LogEventLevel.Error, ex, "");
        return await Task.FromResult(new AuthenticationState(_anonymous));
    }
}


public async Task LoginUser(UserSession userSession)
{
    ClaimsPrincipal claimsPrincipal;

    claimsPrincipal = new ClaimsPrincipal(new ClaimsIdentity(new List<Claim>()
        {
            new Claim(ClaimTypes.Name, userSession.UserId),
            new Claim(ClaimTypes.Role, userSession.Role),
        }));
    userSession.ExpiryTimeStamp = DateTime.Now.AddSeconds(userSession.ExpiresIn);
    await _localStorage.SaveItemEncryptedAsync("UserSession", userSession);

    NotifyAuthenticationStateChanged(Task.FromResult(new AuthenticationState(claimsPrincipal)));
}

public async Task LogoutUser()
{
    await _localStorage.RemoveItemAsync("UserSession");
    NotifyAuthenticationStateChanged(Task.FromResult(new AuthenticationState(_anonymous)));
}

public async Task<string> GetTokenAsync()
{
    var result = string.Empty;

    try
    {
        var userSession = await _localStorage.ReadEncryptedItemAsync<UserSession>("UserSession");
        if (userSession != null && DateTime.Now < userSession.ExpiryTimeStamp)
            result = userSession.Token;
    }
    catch
    {

    }
    return result;
}


그리고 아래와 같이 MainLayout.razor에서 로그인, 로그아웃 기능과 세션 만료여부를 파악하도록 해놨습니다.
제 질문은 아래와 같습니다.

1. 세션 만료 여부를 파악하는 방법을 아래와 같은 방식으로 하는게 맞는지요? (GetToken이라는 함수를 호출해서 토큰 내부에 저장된 세션 만료기한과 비교)

2. 로그인, 로그아웃을 아래와 같이 처리허는게 맞는지?

3. Local storage에 저장된 토큰 내부의 id를 읽어서 내 정보와 권한을 가지고 올 때, 거의 모든 페이지에 User정보를 읽고 쓰는 Repository를 inject해야하는데… 이게 맞는건지요?

@inject AuthenticationStateProvider authStateProvider

@code{
  protected override async Task OnAfterRenderAsync(bool firstRender)
  {
      if (!firstRender)
      {
          return;
      }

      var customAuthStateProvider = (CustomAuthenticationStateProvider)authStateProvider;
      var token = await customAuthStateProvider.GetTokenAsync();
      if (string.IsNullOrWhiteSpace(token))
      {
          // 유효한 토큰이 없으면 강제로 로그아웃한다.
          await customAuthStateProvider.LogoutUser();
          if (_navi.Uri.EndsWith("/signin") == false
      && _navi.Uri.EndsWith("/signup") == false
      && _navi.Uri.EndsWith("/") == false)
          {
              _navi.NavigateTo("/signin", true);
          }
      }
  }

  public async Task LoginUser(UserSession userSession)
  {
      ClaimsPrincipal claimsPrincipal;

      claimsPrincipal = new ClaimsPrincipal(new ClaimsIdentity(new List<Claim>()
          {
              new Claim(ClaimTypes.Name, userSession.UserId),
              new Claim(ClaimTypes.Role, userSession.Role),
          }));
      userSession.ExpiryTimeStamp = DateTime.Now.AddSeconds(userSession.ExpiresIn);
      await _localStorage.SaveItemEncryptedAsync("UserSession", userSession);

      NotifyAuthenticationStateChanged(Task.FromResult(new AuthenticationState(claimsPrincipal)));
  }

  private async Task Logout()
  {
      var customAuthStateProvider = (CustomAuthenticationStateProvider)authStateProvider;
      await customAuthStateProvider.LogoutUser();

      _navi.NavigateTo("/", true);
  }

  public async Task<string> GetTokenAsync()
  {
      var result = string.Empty;

      try
      {
          var userSession = await _localStorage.ReadEncryptedItemAsync<UserSession>("UserSession");
          if (userSession != null && DateTime.Now < userSession.ExpiryTimeStamp)
              result = userSession.Token;
      }
      catch
      {

      }
      return result;
  }
}

모르는 것은 많은데, 이상적인걸 원하다보니 질문 내용도 너무 답변하기 어려우실 것 같습니다…ㅠ
확실히 알고 구현하고자 의견을 여쭤봅니다. 덮어놓고 대충 구현하기엔 나중에 감당 안될 것 같아서요…

감사합니다.

4개의 좋아요

여기저기 검색을 해봐도, Blazor는 참고가 될만한 다양한 레퍼런스가 없는게 좀 아쉽습니다… :sob:

2개의 좋아요

도움이 되실진 모르겠으나, 짧게 적어 봅니다.

혹시 builder 구성 하실때 커스텀 핸들러 구현 해보셨을까요?

builder.Services.AddAuthentication(...).AddScheme<AuthenticationSchemeOptions , 커스텀핸들러>(...)

다른 부분은 충실히 구현 하신듯 한데, 토큰 처리 부분들이 너무 과하게 Blazor 뷰를 구성 하는 쪽에다가 구현 하셔서 좀 깔끔하지 않다고 느끼셨나봅니다. 위에 적은 코드와 관련된 내용을 한번 찾아 보시면 도움이 되실듯 한데,

클라이언트가 http 요청을 하면 Blazor 페이지나 GET POST 등의 메서드로 도달 하기 전에 커스텀핸들러의 각종 override 된 메서드를 훝고 갑니다. 그렇게 하시면,
만약 어떠한 사용자가 인증없이 https://something.com/wahwahwah 와 같은 페이지로 바로 접근 한다고 해도, 충분히 상태를 관리 하실 수 있을 껍니다.
(빨콩으로 확인 해보세요~)

참고 할만한 링크를 덧 붙입니다.

해당 주제의 검색의 키워드를 Blazor 보다는 asp.net core 정도로 바꾸어 보시면 검색에 보다 많이 걸릿듯 합니다.

도움이 되셨으면 좋겠습니다.
(아랫분, 오류지적 환영합니다… :grinning: :grinning:)

5개의 좋아요

질문하는 저 스스로도 머릿속에 구조가 제대로 안 그려져서
질문이 제대로 전달되셨을지 모르겠네요…
말씀해주신 내용도 궁금했던게 맞습니다.
알려주신 링크 들어가서 보고있는데, 어라? 이게 어떻게 되는거지? 싶네요ㅎㅎㅎ
조금 더 공부해야겠어요…
그리고 asp.net core로 검색해보라는 조언도 감사합니다^^

3개의 좋아요

scaffold 된 자동 생성된 로그인/관리자 페이지가 다 맘에 안 들어서 직접 구현하려니
PasswordSignInAsync() blazor라 애초에 안 돌아가길래 ㅠㅠ
controller에 Post response로 계정 인증 여부만 확인 한 후 AuthenticationStateProvider로 교체만 하고 있네요.
완전 야매라서 어디 보여주기 부끄러울 것 같네요.
오히려 wasm이면 아예 다 포기하고 격리된 상태로 다 짤 텐데 […] Server라 그런지 뭐든 다 애매…

store, stateprovider , (예전 DB 호환을 위해 너프먹은 보안ㅠ) passwordhasher등을
다 커스텀 해버리는 바람에 인증을 토큰 관리로 보완하고 싶은 생각이 들었거든요.

어디서부터 손을 대야 할 지 모르는 상황이었는데,
대화중이신 글타래로 희망을 보게 된 듯 합니다 ㅎㅎㅎㅎㅎ ㅜㅜ

4개의 좋아요

고민하던 부분은 다르더라도, @suwoo 님한테 도움이 되셨으니 저도 기쁩니다.
가뜩이나 커스터마이징에 대한 자료가 부족하다보니,
저와 같이 질문을 올리는 그 자체로 다른 분들에게 도움이 되길 바랍니다!

저는 Authentication, JWT, Session 이쪽을 신경쓰다보니 자연스레 보안쪽으로도 신경이 쓰입니다.
잘 모르는 상태에서 대충 copy해서 만들면 나중에 감당이 안될까봐요.
저도 이걸 야매로 그냥 대충 하드코딩해버릴까… 무식하게 해버릴까… 싶다가도
나중에 결국은 알고 넘어가야될 부분이라 생각하니까 그냥 넘어가질 못하겠네요.

2개의 좋아요

필요한 부분은 비슷 한 듯 한데
출발 한 방향이 달라서 그런가 봅니다.
정보가 부족하다보니 실마리가 될 만한건 다 찾아봐야 ㅜㅜ

2개의 좋아요

저도 여기저기 검색해보았지만 딱히 베스트이다 싶은 것은 찾지 못했습니다.
Blazor Server앱을 인트라넷 및 관리자형 페이지에 이용중입니다. 인증은 Cookie로 처리했습니다.
질문에 대한 답변이라기 보다는 제가 사용한 방법은 다음과 같습니다.

[필요했던 사항]

  • /Login, /Logout만 Anonymous 접근 허용
  • 나머지 모든 페이지는 인증 및 Role 부여된 사용자만 접근 허용

위 2가지가 충족되면 좋겠다는 생각으로…

  1. Razor Pages Conventions를 통해 루트(/)이하는 AuthorizeFolder 및 특정 Role만 액세스하게 처리하였습니다.
  2. /Login페이지는 AllowAnonymous처리하였습니다.
  builder.Services.AddRazorPages(options =>
  {
      options.Conventions.AuthorizeFolder("/", "AdminPolicy");
      options.Conventions.AllowAnonymousToPage("/Login");
  });

  builder.Services.AddAuthentication(options =>
  {
      options.DefaultAuthenticateScheme = CookieAuthenticationDefaults.AuthenticationScheme;
      options.DefaultSignInScheme = CookieAuthenticationDefaults.AuthenticationScheme;
      options.DefaultChallengeScheme = CookieAuthenticationDefaults.AuthenticationScheme;
  }).AddCookie(options =>
  {
      options.LoginPath = "/Login";
      options.AccessDeniedPath = "/Login";
      options.ExpireTimeSpan = TimeSpan.FromHours(1);
  });
  builder.Services.AddAuthorization(options =>
  {
      options.AddPolicy("AdminPolicy", policy => policy.RequireClaim(ClaimTypes.Role, "Admin"));
  });

사용자 브라우저에서 페이지 접근시 Razor Pages의 /Login페이지로 이동시키고 로그인 및 Claim처리후
페이지에 접속에 하게 됩니다.

단점이 token방식이 아니다보니 expire처리가 애매합니다. /_Host 페이지가 호출될때의 HttpContext가 Blazor에서 계속 사용되므로 한번 로그인하면 Blazor페이지는 계속 사용이 가능합니다.

@CODE_REAPER 님의 예제처럼 expire처리를 해야할것 같은데 제가 사용하는 페이지는 짧게 사용하는지라 refresh등이 필요없어 그냥 사용중입니다.

저도 계속 베스트한 방법을 찾고 있습니다.

3개의 좋아요

어제부터 계속해서 관련 자료를 찾아보는 중인데,
@_jeonghwan 님이 구현하신게 정석 내지는 정석에 가까워 보입니다!
제가 구현하고자 하는 방식과 다른 점은 JWT없이 쿠키만 사용한다는 점과, 만료기한이 정해져있지 않다는 정도인데 이건 경우에 따라, 필요에 따라 다르게 구현할 수 있는 부분이라 생각합니다.
일단 저는 @_jeonghwan 님 방식과 @경태_왕 님께서 말씀해주셨던 방식(커스텀핸들러로 override)으로 빡세게 구현해보려고 합니다.
답변 감사합니다!!

2개의 좋아요

이 글을 보고 마침 저도 궁금하기에, 조사를 좀 해봤습니다.
아래는 조사한 내용이니, 일부 오류가 있을 수 있습니다.


전통적으로 닷넷 웹앱은 인증/인가를 미들웨어가 처리합니다.
이때, 인증 스킴(AuthenticationScheme)이나 인증 정책(AuthorizationPolicy) 의 설정이 필요합니다.

인증 미들웨어는 바디에 담겨진 사용자 정보를 데이터 베이스 자료와 비교하여, 일치하면 사용자를 인증(HttpContext.User 에 ClaimsPrincipal 객체를 설정)하고, 이 정보를 인증 미들웨어(와 필요한 경우, 핸들러)가 사용하게 됩니다.

블레이저 서버는 Asp.Net Core 앱의 인증 방식에 의존하기 때문에, 보통의 경우, 인증과 관련한 흐름은 아래와 같습니다.

request => 인증 미들웨어 => 인가 미들웨어 => 핸들러(액션메서드) => 블레이저 영역 { AuthenticationStateProvider => 컴포넌트 }

보통의 경우, AuthenticationStateProvider의 역할은 인증 미들웨어가 설정한 HttpContext.User 정보를 바탕으로 인증 여부만 컴포넌트에게 전달하는 역할에 머무르는 게 일반적입니다.

 var user = _httpContext.User.;

그러나, 보여 주신 예는, 이 구조에 의존하지 않는 방식으로, 인증 미들웨어가 할 일을 AuthenticationStateProvider 가 직접 처리하고 있습니다.

 var userSession = await _localStorage.ReadEncryptedItemAsync<UserSession>("UserSession");

참고로, 보통의 경우라도, 블레이저 서버만의 독특한 scope 문제로, HttpContext.User 를 한번만 확인하는 것이 아니라, 주기적으로 인증 여부를 확인해야 합니다. 이를 위해, 블레이저 서버는 아래의 객체를 제공하는데,

...
public abstract class RevalidatingServerAuthenticationStateProvider
    : ServerAuthenticationStateProvider, IDisposable
{
 ...

기반 객체인 ServerAuthenticationStateProvider 는 AuthenticationStateProvider 를 파생하는 객체로서, 확인 주기의 기본값은 30분입니다.

인증 미들웨어와 협업을 하든, 직접 하든, AuthenticationStateProvider 가 제공한 인증 정보를 우리 코드에서 소비할 수도 있는데, 보여 주신 코드처럼 의존성 주입으로 직접 접근할 수도 있지만, 일반적으로는 캐스캐이딩으로 전달합니다.

<CascadingAuthenticationState>
...
...
</CascadingAuthenticationState>

캐이캐이딩으로 처리하면, 인증정보는 AuthorizeRouteView 에게 전달되고, 이는 다시 AuthorizeView 컴포넌트에게 전달됩니다. 이 객체는 Authorizing, Authorized, NotAuthorized 의 상태에 따라 UI의 노출 여부를 결정합니다.
즉, AuthorizeRouteView 는 인가 미들웨어와 비슷하고, AuthorizeView 는 상태가 세분화된 AuthorizeAttribute와 비슷하죠.

그리고, 코드로만 판단한다면, Register와 Login 이 구별되고 있지 않는 것 같습니다.
Register는 JWT를 발행하는 것이고, Login 은 (세션 저장소에 있는) JWT에 포함된 사용자 credentials 과 사용자가 폼을 통해 입력한 creditials 를 비교하는 과정입니다.

현재 코드의 Login 은 Register 쪽에 가깝습니다.

만약 사용자의 다른 정보가 id를 기준으로 서버에 저장되어 있다면, pw 를 아무거나 입력해도 성공적으로 로그인이 가능하고, id 를 기준으로 그 정보를 열람할 수 있습니다.

4개의 좋아요

@BigSquare 감사합니다 :blush:

2개의 좋아요