7
6

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

C# gRPC Json Web Tokenを使って認証する

Last updated at Posted at 2019-02-23

このドキュメントの内容

gRPC の API 呼び出しで Json Web Token (以降 JWT)を使った認証を行う簡単な例です。JWT の実装ライブラリとして Jwt.Net (https://github.com/jwt-dotnet/jwt) を利用しています。

JWT とは

Wikipedia より

JSON Web Token(ジェイソン・ウェブ・トークン)は、JSONをベースとしたアクセストークン(英語版)のためのオープン標準 (RFC 7519) である。略称はJWT。

トークンの中に任意のペイロードを含めることができます。ステートレスな認証制御を実現しやすいトークンです。

トークン文字列の例
eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJleHAiOjE1NTA4ODUzODYuMCwidXNlciI6IntcIlJvbGVcIjoyLFwiUGVybWlzc2lvbnNcIjozfSJ9.GK71A6wMu67GRhmozG7A2HtlXruCnwAU85CCTatgcT8

設定次第ではセキュリティホールを生むため賛否両論がありますが、それは何にでも言えることではないかと思います。

【Qiita】JWT について調べた内容をまとめました。
【Qiita】JSON Web Token(JWT)って結局使っていいの?
【POSTD】JOSE(JavaScriptオブジェクトへの署名と暗号化)は、絶対に避けるべき悪い標準規格である
【ブログ】JWT認証、便利やん?
【たれろぐ】JWT 認証のメリットとセキュリティトレードオフの私感

特に、次のような点には注意する必要があると思います。

  • 有効期限を適切に(できるだけ短く)設定する。
  • 何らか問題が発生したときに発行済のトークンを無効にできる仕組みを設ける。
  • ペイロードにはセキュリティ上の問題になる情報を含めない。
  • 多様なアルゴリズムに対応する必要がなければ、必要最小限のサポートに限定する。

Jwt.net の実装では

  • アルゴリズムに none が指定された場合

    • Jwt.net の既定の動作では、デコード時のアルゴリズムの判定に JWT.Algorithms.HMACSHAAlgorithmFactory クラスが使用されます。このクラスでは定義済のアルゴリズムの中から一致するものが採用されます。この中には none に対応するものはなく、認証は失敗します。
  • 有効期限を設定するには

    • ペイロードに含めます。このドキュメントのサンプルでは発行後 5 分間を有効としています。

JWT と gRPC API

API に対する認証や認可を実現する手段として向いているのではないかと思います。

サンプル

API の定義

ユーザー・パスワードでログインを行う Login と、アクセストークンを用いて認証を行う SampleApi を定義しました。

message LoginArgs
{
    string ID = 1;
    string Password = 2;
}
message LoginResult
{
    bool IsSucceed = 1;
    string AccessToken = 2;
}

service SampleService
{
    rpc Login(LoginArgs) returns (LoginResult);
    rpc SampleApi(Request) returns (Response);
}

JWT ヘルパークラスの実装

JWT 関連のオブジェクトをまとめて保持するクラスを実装しました。各オブジェクトは Jwt.net から提供されている標準の型をそのまま利用しています。

internal sealed class JwtHelper
{
    internal JwtHelper()
    {
        DateTimeProvider = new JWT.UtcDateTimeProvider();
        JsonSerializer = new JWT.Serializers.JsonNetSerializer();
        UrlEncoder = new JWT.JwtBase64UrlEncoder();
        Encoder = CreateJwtEncoder();
        Decoder = CreateJwtDecorder();
    }

    internal JWT.IJwtEncoder Encoder { get; }
    internal JWT.IJwtDecoder Decoder { get; }
    internal JWT.IDateTimeProvider DateTimeProvider { get; }
    internal JWT.IJsonSerializer JsonSerializer { get; }
    private JWT.IBase64UrlEncoder UrlEncoder { get; }

    private JWT.IJwtEncoder CreateJwtEncoder()
    {
        JWT.Algorithms.IJwtAlgorithm algorithm = new JWT.Algorithms.HMACSHA256Algorithm();
        return new JWT.JwtEncoder(algorithm, JsonSerializer, UrlEncoder);
    }

    private JWT.IJwtDecoder CreateJwtDecorder()
    {
        JWT.Algorithms.IAlgorithmFactory algorithm = new JWT.Algorithms.HMACSHAAlgorithmFactory();
        JWT.IJwtValidator validator = new JWT.JwtValidator(JsonSerializer, DateTimeProvider);
        return new JWT.JwtDecoder(JsonSerializer, validator, UrlEncoder, algorithm);
    }
}

サービスの実装

ペイロードの定義

JWT のペイロードに格納するユーザー情報を次のように定義しました。

internal class UserInfo
{
    public UserRole Role { get; set; }
    public UserPermissions Permissions { get; set; }
}

internal enum UserRole
{
    None = 0,
    Guest,
    User,
}

[Flags]
internal enum UserPermissions
{
    None = 0,
    Get = 1,
    Modify = 2,
    Delete = 4
}

ログインとトークンの生成

gRPC サービスの Login メソッドの実装です。

Login
private static readonly JwtHelper s_JwtHelper = new JwtHelper();

/// <summary>
/// 
/// </summary>
/// <param name="request"></param>
/// <param name="context"></param>
/// <returns></returns>
public async override Task<LoginResult> Login(LoginArgs request, ServerCallContext context)
{
    await Task.Yield();

    UserInfo userInfo = await GetUserInfoAsync(request).ConfigureAwait(false);

    if (userInfo == null)
    {
        return new LoginResult() { IsSucceed = false };
    }
    else
    {
        // トークンの有効期限を5分後に設定します。
        DateTimeOffset expiration = s_JwtHelper.DateTimeProvider.GetNow().AddMinutes(5);
        return new LoginResult()
        {
            IsSucceed = true,
            AccessToken = CreateToken(userInfo, expiration),
            Expiration = expiration.ToString()
        };
    }
}

private async Task<UserInfo> GetUserInfoAsync(LoginArgs request)
{
    await Task.Yield();
    // IDとパスワードが妥当であり、そのユーザーの情報を取得できたものとします。
    return new UserInfo
    {
        Role = UserRole.User,
        Permissions = UserPermissions.Get | UserPermissions.Modify
    };
}

トークン生成の実装です。

CreateToken
private static readonly JwtHelper s_JwtHelper = new JwtHelper();
// 秘密鍵。当然ですが、実際にはストレージなどから読み込みます。
const string secretKey = "GQDstcKsx0NHjPOuXOYg5MbeJ1XT0uFiwDVvVBrk";

private string CreateToken(UserInfo userInfo, DateTimeOffset expiration)
{
    double expirySeconds = Math.Round((expiration - DateTime.UnixEpoch).TotalSeconds);

    var payload = new Dictionary<string, object>
            {
                { "exp", expirySeconds },
                { "user", s_JwtHelper.JsonSerializer.Serialize(userInfo) }
            };

    return s_JwtHelper.Encoder.Encode(payload, secretKey);
}

トークンのペイロードには次のような JSON が格納されます。

{"exp":1550885386.0,"user":"{\"Role\":2,\"Permissions\":3}"}

API でのトークン認証

認証を行う API では、リクエストヘッダーにトークンを設定するものとします。ServerCallContext のリクエストヘッダーからトークンを取得して検証します。
トークンが有効期限切れなのか不正なのかをクライアントで判別できるようにするため、判定結果を表す値を RpcException の Trailer に格納します。

private static readonly JwtHelper s_JwtHelper = new JwtHelper();
// 秘密鍵。当然ですが、実際にはストレージなどから読み込みます。
const string secretKey = "GQDstcKsx0NHjPOuXOYg5MbeJ1XT0uFiwDVvVBrk";

private sealed class AuthHeaderKeys
{
    // リクエストヘッダーにトークンを格納するときのキー
    internal const string AuthorizationToken = "authorization";
    // レスポンスヘッダーにトークン認証結果を格納するときのキー
    internal const string AuthenticateResult = "www-authenticate";
}

// コンテキストに格納されている情報からトークンの検証を行います。
private bool VaridateToken(ServerCallContext context, out UserInfo userInfo, out RpcException exception)
{
    if (!TryGetToken(context, out string token) || string.IsNullOrEmpty(token))
    {
        userInfo = null;
        exception = new RpcException(new Status(StatusCode.Unauthenticated, "Token is not set."));
        return false;
    }

    try
    {
        string[] values = token.Split(".");
        var json = s_JwtHelper.Decoder.Decode(token, secretKey, verify: true);
        var payload = s_JwtHelper.JsonSerializer.Deserialize<Dictionary<string, object>>(json);

        userInfo = s_JwtHelper.JsonSerializer.Deserialize<UserInfo>((string)payload["user"]);
        exception = null;
        return true;
    }
    catch (JWT.TokenExpiredException)
    {
        userInfo = null;
        Metadata trailers = new Metadata();
        trailers.Add(AuthHeaderKeys.AuthenticateResult, "expired");
        exception = new RpcException(new Status(StatusCode.Unauthenticated, "Token has expired."), trailers);
        return false;
    }
    catch (JWT.SignatureVerificationException)
    {
        userInfo = null;
        Metadata trailers = new Metadata();
        trailers.Add(AuthHeaderKeys.AuthenticateResult, "invalid");
        exception = new RpcException(new Status(StatusCode.Unauthenticated, "Token has invalid signature."), trailers);
        return false;
    }
    catch (Exception ex)
    {
        userInfo = null;
        Metadata trailers = new Metadata();
        trailers.Add(AuthHeaderKeys.AuthenticateResult, "invalid");
        exception = new RpcException(new Status(StatusCode.Unauthenticated, "Could not get user information."), trailers);
        return false;
    }
}

private bool TryGetToken(ServerCallContext context, out string token)
{
    foreach (var metadata in context.RequestHeaders)
    {
        if (metadata.Key == AuthHeaderKeys.AuthorizationToken)
        {
            token = metadata.Value;
        }
    }
    token = null;
    return false;
}

API が呼び出されたとき、トークンの認証を行います。この例ではサービスクラスのメソッド内で認証を行っていますが、実際には Interceptor を使って横断的に認証を行った方がよいと思います。

SampleApi
public async override Task<Response> SampleApi(Request request, ServerCallContext context)
{
    // トークンを認証します。
    if (!VaridateToken(context, out UserInfo userInfo, out RpcException ex))
    {
        throw ex;
    }

    // 処理を行ってレスポンスを返します。
    return new Response();
}
7
6
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
7
6

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?