🔑

[Dart, Flutter] DartでJWT認証サーバーを実装してみた (JWT入門)

2024/04/19に公開

はじめに

ご無沙汰しております。これが約2年ぶりの投稿となります。笑
自身はFlutterの歴は長い (だけ) ですが、就職してなんやかんやあってサーバーサイドに配属となったので、サーバーサードの勉強をし始めました。そこで前々から気になっていた認証周りから勉強を始めています。

というのもFlutterでは認証周りが基本的にパッケージ化されていて中身を意識する場面が少なく、今までの開発では基本的に認証はFirebase Authenticationに任せっきりでした。そこでまずは前々から気になっていた認証まわりを自作して、認証についての理解を深めようと考えました。

普通にGoやNode.jsを使ってサーバーを組んでも自分的には面白くないので(できないくせに何を言ってんだ)、今回は使い慣れているがサーバーサイド用の言語ではないDartでJWT認証サーバーを実装したので、知識の整理を兼ねてこの記事を書きました。

JWTとはナンゾヤ

JWTとはJSON Web Tokenの略でRFC 7519によると、JWTについて以下のように定義されています。

JSON Web Token(JWT)は、2つのパーティ間で転送されるクレームを表す、コンパクトでURLセーフな手段です。 JWTのクレームは、JSON Web Signature(JWS)構造のペイロードとして、またはJSON Web Encryption(JWE)構造のプレーンテキストとして使用されるJSONオブジェクトとしてエンコードされ、クレームをデジタル署名または整合性保護することができます。メッセージ認証コード(MAC)で暗号化されています。

ここから

  • JWTはJWSのペイロードである (暗号化されたトークンは指していない)
  • JWT自体は認証や暗号化に関してなにも規定していない
  • 盗聴を防ぐものではない (盗聴防止にはJWEが使われるが今回は割愛)

であることがわかります。

JWT??JWS??ドッチヤネン

JWSとは

JWSとはJSON Web Signatureの略で、ヘッダーとペイロードと署名のJSON形式のデータをそれぞれBase64URLでエンコードして.で連結した文字列でいわゆる下に示したようなやつです。

eyJ1c2VyLWFnZW50IjoiUG9zdG1hblJ1bnRpbWUvNy4zNy4zIiwiY29ubmVjdGlvbiI6ImtlZXAtYWxpdmUiLCJhY2NlcHQiOiIqLyoiLCJhY2NlcHQtZW5jb2RpbmciOiJnemlwLCBkZWZsYXRlLCBiciIsInBvc3RtYW4tdG9rZW4iOiIyNTMyNjg1MC1iNTQwLTQxYTUtYTYwZi1hZjc0NzYwMDkxOTgiLCJjb250ZW50LWxlbmd0aCI6IjQ4IiwiaG9zdCI6IjAuMC4wLjA6ODA4MCIsImNvbnRlbnQtdHlwZSI6ImFwcGxpY2F0aW9uL2pzb24iLCJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.
eyJ1c2VyX2lkIjoiUFROY3lMUzFST3R3RXV3QUkzLXUtMkpiUUsuRkw0R1IiLCJpYXQiOjE3MTMyNzE0MzgsImV4cCI6MTcxMzI3MTQ5OH0.
QPcAjo3tFLAKA2ITtTWj6d7s8VLvNhG0zpYq26cng

JWSの構成

以下の図が参考になると思います。

JWSはヘッダー、ペイロード、署名の3つの部品で成り立っています。

ヘッダー (Header)

ヘッダーにはalgtypという二つの項目が含まれていて、それぞれ署名用のハッシュアルゴリズムとトークンタイプが記載されています。下にある例では署名用のハッシュアルゴリズムがHMAC SHA-256、トークンのタイプがJWTであることを表しています。

{
  "alg": "HS256",
  "typ": "JWT"
}

ペイロード (Payload)

ペイロードには認証に活用される主要な情報が含まれています。具体的にはuser_id等のJWTの主語となる主体の識別子subやJWTの発行日時iat、有効期限expが含まれていています。以下がペイロードの例です。

{
  "sub": "1234567890",
  "exp": 1234567890,
  "iss": "hoge_service",
  "iat": 1234567890,
  "email": "test@kmail.com",
}

ここでJSONに含まれるデータ項目はクレームと呼ばれ、JWTにはいくつか予約されているクレーム、予約クレームがあります。しかし予約語に含まれるクレームは必須ではなく、さらにここの予約後に含まれていないクレームも自由に設定可能であるため、JWSはアプリケーションによって違う形をとります。なので絶対にやってはいけませんが、有効期限expを抜いて有効期限のないトークンを生成することが定義上許されています。
ちなみに予約クレームに含まれないクレーム、上の例ではemailプライベートクレームと呼びます。

そのほかの予約クレームの例は以下に残しておきます。

予約語 意味
sub ユーザ識別子。user_idといったユーザを識別するためのID
exp JWTの有効期限
iss JWT発行者
iat JWTの発行日時
aud JWTの受信者
nbf 有効開始日時
jti JWTのID

署名 (Signature)

ヘッダーとペイロードを連結し、秘密鍵(secret)を使ってHMAC等のハッシュアルゴリズムで署名したものです。もしHeaderかPayloadのデータが改竄されていれば、JWT検証時に署名を用いて復号ができなくなるため、改竄を検知することができます。

JWTの利点

状態を保持する必要がない

セッションとは違い、ユーザーの認証状態をサーバー側で管理する必要がないため、サーバーリソースの節約になります

サービス間で認証データの共有に使える (詳しくは公開鍵方式)

公開鍵をサービス間で共有し検証する、各サービスが独立して認証を行わずとも、安全性を担保してユーザ本人だと確認できるため、認証用のトークンなどで用いられます。

下の図がその例です。処理の順番はおおよそ以下のような感じです。

  1. IDプロバイダーにユーザー情報を問いわせ、認証に成功するとIDプロバイダー内にある秘密鍵を用いてJWTを生成します(図の上半分)
  2. ユーザーはJWTを用いて別のアプリ(サービス)に処理をリクエストします (ここから図の下半分)
  3. この時アプリがJWTを復号するための公開鍵をIDプロバイダーにリクエストし公開鍵を受け取ります
  4. アプリがユーザーからJWTを受け取り、無事JWTを公開鍵を用いて復号することができれば晴れて認証成功です

JWTの問題点

ヘッダ情報の脆弱性

実はJWTはalgを必須にしなければならないというルールはないため、"alg": "none"とし、署名の部分を削除しちゃえば、JWT検証を突破できてしまいます。
そうなってしまえばpayloadを好き勝手に改竄できてしまいます。こうならないためには検証を行うサーバー側であらかじめアルゴリズムを指定しておく必要があります。

盗聴されたら普通に情報漏れ

payloadは単にBase64URLエンコードしているだけなので、JWTが何者かに盗まれてしまった場合に中身を簡単に見られます。(これを防止するためにJWEがあります)

一度発行した JWT の無効化は難しい

JWT自体はサーバーで管理しているわけではないため、一度発行されると有効期限まで無効化することは難しく、セキュリティ上の懸念を引き起こす可能性があります。なのでJWTは(例えば有効期限が数分であるような)有効期限の短いトークンなどの方が向いると言えます。(このためにリフレッシュトークンなるものがある)

DartにおけるJWTの実装

ここからいよいよ実装フェーズに入っていきます。

JWT生成

  1. headerとpayloadをそれぞれBase64URLでエンコードする
  2. それぞれエンコードしたものを.で結合し、これを署名無しトークンとする
  3. 秘密鍵と署名無しトークンからHMAC-SHA256を用いて署名を生成
  4. 生成された署名もBase64URLでエンコードする
  5. 署名無しトークンと署名を結合することでJWTを生成
jwt.dart
String base64Encode(String string) {
  final jsonB64 = base64Url.encode(Uint8List.fromList(utf8.encode(string)));
  final jsonB64NoPadding = jsonB64.replaceAll(RegExp(r'=+$'), '');
  return jsonB64NoPadding;
}

String base64Decode(String encodedString) {
  final paddedEncodedString = encodedString.padRight(
    (encodedString.length + 3) & ~3,
    '=',
  );
  final decodedBytes = base64Url.decode(paddedEncodedString);
  final decodedString = utf8.decode(decodedBytes);
  return decodedString;
}

String hmacSHA256(String key, String data) {
  final hmac = Hmac(sha256, utf8.encode(key));
  final hash = hmac.convert(utf8.encode(data));
  final hashNoPadding =
      base64Url.encode(hash.bytes).replaceAll(RegExp(r'=+$'), '');
  return hashNoPadding;
}

String generateJWT(
  Map<String, String>
      header, // Algorithm, Token type EX: {'alg': 'HS256', 'typ': 'JWT'};
  Map<String, dynamic>
      payload, // Data Ex: {'sub': '1234567890', 'iat': 1516239022};
  String key, // 秘密鍵
) {
  // 1. headerとpayloadをBase64URLでエンコーディング
  final encodedHeader = base64Encode(jsonEncode(header));
  final encodedPayload = base64Encode(jsonEncode(payload));

  // 2. '.'で結合することで署名無しトークンを生成
  final unsignedToken = '$encodedHeader.$encodedPayload';

  // 3. 密鍵と署名無しトークンからHMAC-SHA256を用いて署名を生成
  final signature = hmacSHA256(key, unsignedToken);

  // 4. 生成された署名もBase64URLでエンコードする
  final encodedSignature = base64Encode(signature);

  // 5. 署名無しトークンと署名を結合することでJWTを生成
  final jwt = '$unsignedToken.$encodedSignature';

  return jwt;
}
出力結果
eyJ1c2VyLWFnZW50IjoiUG9zdG1hblJ1bnRpbWUvNy4zNy4zIiwiY29ubmVjdGlvbiI6ImtlZXAtYWxpdmUiLCJhY2NlcHQiOiIqLyoiLCJhY2NlcHQtZW5jb2RpbmciOiJnemlwLCBkZWZsYXRlLCBiciIsInBvc3RtYW4tdG9rZW4iOiIyNTMyNjg1MC1iNTQwLTQxYTUtYTYwZi1hZjc0NzYwMDkxOTgiLCJjb250ZW50LWxlbmd0aCI6IjQ4IiwiaG9zdCI6IjAuMC4wLjA6ODA4MCIsImNvbnRlbnQtdHlwZSI6ImFwcGxpY2F0aW9uL2pzb24iLCJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.
eyJ1c2VyX2lkIjoiUFROY3lMUzFST3R3RXV3QUkzLXUtMkpiUUsuRkw0R1IiLCJpYXQiOjE3MTMyNzE0MzgsImV4cCI6MTcxMzI3MTQ5OH0.
QPcAjo3tFLAKA2ITtTWj6d7s8VLvNhG0zpYq26cng

JWT検証

  1. ヘッダーチェック
  2. tokenを.で署名と署名無しトークンに分割する
  3. 署名をデコード
  4. サーバーに登録されている秘密鍵を用いて、署名無しトークンが改竄されていないかチェック
  5. トークンの有効期限チェック
  6. 検証完了
jwt.dart
bool checkJWT(String key, String jwt) {
  final splits = jwt.split('.');

  // 1. ヘッダーチェック
  if (splits.length != 3) return false;
  final alg = getHeader(jwt)['alg'] as String;
  if (alg != 'HS256') return false;

  // 2. JWTを`.`で署名と署名無しトークンに分割する
  final unsignedToken = '${splits[0]}.${splits[1]}'; // headers, payload
  final encodedSignature = splits[2];

  // 3. 署名をデコード
  final signature = base64Decode(encodedSignature);

  // 4. 秘密鍵を用いて署名無しトークンが改竄されていないかチェック
  if (hmacSHA256(key, unsignedToken) != signature) return false;

  final payload = getPayload(jwt);

  // 5. 有効期限チェック
  final diff = DateTime.now()
      .difference(
        DateTime.fromMillisecondsSinceEpoch((payload['exp'] as int) * 1000),
      )
      .inMicroseconds;
  if (diff > 0) return false;

  // 6. 検証完了
  return true;
}

dartによるJWT認証サーバーの実装

今回はshelfを用いてサーバーの実装をしていきます。
DartのshelfにはAutholizationヘッダーがなかったので今回は簡易的に実装しています。

https://pub.dev/packages/shelf

新規登録する場合

  1. emailとpasswordを /registerにPOSTリクエストする。
  2. ユニークなuser_idを生成し、user_idとemailとpasswordをDBに登録。
  3. レスポンスを返却
const key = 'secret_key';

final dummyDB = <String, Map<String, String>>{}; // email: password, user_id

// "POST: /register"
Future<Response> registerUserHandler(Request request) async {
  // 1. emailとpasswordを/registerにPOSTリクエスト
  final query = await request.readAsString();
  final payload = jsonDecode(query) as Map<String, dynamic>;
  
  // 2.ユニークなuser_idを生成し、user_idとemailとpasswordをDBに登録。
  final newUserId = generateRandomString();
  dummyDB[payload['email'] as String] = {
    'password': payload['password'] as String,
    'user_id': newUserId,
  };
  
  // 3. レスポンスを返却
  return Response.ok(
    convert.json.encode(
      {'id': newUserId, 'email': payload['email']},
    ),
  );
}
出力結果
{"id": "SOME_USER_ID", "email": "hoge@kmail.com"}

ログインする場合

  1. emailとpasswordを /loginにPOSTリクエストする
  2. DBにユーザーが存在するか問い合わせる
  3. 有効期限を1分間に設定する
  4. JWTを生成し、ユーザにトークンを含んだレスポンスを返す
const key = 'secret_key';

final dummyDB = <String, Map<String, String>>{}; // email: password, user_id

// "POST: /login"
Future<Response> loginHandler(Request request) async {
  // 1. emailとaddressを /loginにPOSTリクエスト。
  final headers = request.headers;
  final query = await request.readAsString();
  final payload = jsonDecode(query) as Map<String, dynamic>;

  // 2. DBにユーザーが存在するか問い合わせる
  if (!dummyDB.containsKey(payload['email'])) {
    return Response.unauthorized(
      convert.json.encode({'message': 'email is not found.'}),
    );
  }

  if (dummyDB[payload['email']]!['password'] != payload['password']) {
    return Response.unauthorized(
      convert.json.encode({'message': 'password is wrong.'}),
    );
  }

  // 3. 有効期限は1分間
  final jwtPayload = {
    'user_id': dummyDB[payload['email']]!['user_id'],
    'iat': (DateTime.now().millisecondsSinceEpoch / 1000).round(),
    'exp': ((DateTime.now().millisecondsSinceEpoch) / 1000 + 60).round(),
  };

  // 4. JWTを生成し、ユーザにレスポンスを返す。
  final jwtToken = generateJWT(headers, jwtPayload, key);
  return Response.ok(convert.json.encode({'token': jwtToken}));
}
出力結果
{"token": "eyJ1c2VyLWFnZW50IjoiUG9zdG1hblJ1bnRpbWUvNy4zNy4zIiwiY29ubmVjdGlvbiI6ImtlZXAtYWxpdmUiLCJhY2NlcHQiOiIqLyoiLCJhY2NlcHQtZW5jb2RpbmciOiJnemlwLCBkZWZsYXRlLCBiciIsInBvc3RtYW4tdG9rZW4iOiI2MmVmMzljMS1jYmM1LTRjNGItOWM2ZS1mMzJlNjExOGEwMjMiLCJjb250ZW50LWxlbmd0aCI6IjQ4IiwiaG9zdCI6IjAuMC4wLjA6ODA4MCIsImNvbnRlbnQtdHlwZSI6ImFwcGxpY2F0aW9uL2pzb24iLCJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImhvZ2VAa21haWwuY29tIiwiaWF0IjoxNzEyOTI2ODQxLCJleHAiOjE3MTI5MjY5MDF9.it1ZbnEODX0Sku8ME4XxLwIQnVLPDhOHyO0Nk7U4E"}

ユーザー情報を取得したい場合

  1. tokenを /userにGETリクエストする
  2. JWTを検証する
  3. 検証に通過したらユーザーの情報を含んだレスポンスを返す
const key = 'secret_key';

final dummyDB = <String, Map<String, String>>{}; // email: password, user_id

// "GET: /user"
Future<Response> getUserHandler(Request request) async {
  // 1. tokenを/userにGETリクエストする。
  final query = await request.readAsString();
  final payload = jsonDecode(query) as Map<String, dynamic>;
  final jwt = payload['token'] as String;
  
  // 2. JWTを検証する
  if (!checkJWT(key, jwt)) {
    return Response.forbidden(
      convert.json.encode({'message': 'token is expired.'}),
    );
  }
  final jwtPayload = getPayload(jwt);
  final userId = jwtPayload['user_id'] as String;
  
  // 3. 検証に通過したらユーザーの情報を含んだレスポンスを返す
  final email = dummyDB.keys.firstWhere(
    (k) => dummyDB[k]!['user_id'] == userId,
  );
  return Response.ok(convert.json.encode({'user_id': userId, 'email': email}));
}
出力結果
{"user_id": "SOME_USER_ID", "email": "hoge@kmail.com"}

サーバーサイドのコード全体

以上をまとめたものが以下のコードとなっています。

import 'dart:convert' as convert;
import 'dart:convert';
import 'dart:io';

import 'package:shelf/shelf.dart';
import 'package:shelf/shelf_io.dart';

import '../config/routes.dart';
import '../util/generate_random_string.dart';
import 'jwt.dart';

const key = 'secret_key';

final dummyDB = <String, Map<String, String>>{}; // email: password, user_id

// ユーザー情報取得
Future<Response> getUserHandler(Request request) async {
  final query = await request.readAsString();
  final payload = jsonDecode(query) as Map<String, dynamic>;
  final jwt = payload['token'] as String;
  if (!checkJWT(key, jwt)) {
    return Response.forbidden(
      convert.json.encode({'message': 'token is expired.'}),
    );
  }
  final jwtPayload = getPayload(jwt);
  final userId = jwtPayload['user_id'] as String;
  final email = dummyDB.keys.firstWhere(
    (k) => dummyDB[k]!['user_id'] == userId,
  );
  return Response.ok(convert.json.encode({'user_id': userId, 'email': email}));
}

// 新規登録
Future<Response> registerUserHandler(Request request) async {
  final query = await request.readAsString();
  final payload = jsonDecode(query) as Map<String, dynamic>;
  final newUserId = generateRandomString();
  dummyDB[payload['email'] as String] = {
    'password': payload['password'] as String,
    'user_id': newUserId,
  };
  return Response(
    201,
    body: convert.json.encode(
      {'id': newUserId, 'email': payload['email']},
    ),
  );
}

// ログイン
Future<Response> loginHandler(Request request) async {
  final headers = request.headers;
  final query = await request.readAsString();
  final payload = jsonDecode(query) as Map<String, dynamic>;
  // DBに問い合わせる
  if (!dummyDB.containsKey(payload['email'])) {
    return Response.unauthorized(
      convert.json.encode({'message': 'email is not found.'}),
    );
  }

  if (dummyDB[payload['email']]!['password'] != payload['password']) {
    return Response.unauthorized(
      convert.json.encode({'message': 'password is wrong.'}),
    );
  }

  // 有効期限は1分間
  final jwtPayload = {
    'user_id': dummyDB[payload['email']]!['user_id'],
    'iat': (DateTime.now().millisecondsSinceEpoch / 1000).round(),
    'exp': ((DateTime.now().millisecondsSinceEpoch) / 1000 + 60).round(),
  };

  // JWTを生成し、ユーザにそれを返す。
  final jwtToken = generateJWT(headers, jwtPayload, key);
  return Response.ok(convert.json.encode({'token': jwtToken}));
}

void main(List<String> args) async {
  // Use any available host or container IP (usually `0.0.0.0`).
  final ip = InternetAddress.anyIPv4;

  // Configure a pipeline that logs requests.
  final handler =
      const Pipeline().addMiddleware(logRequests()).addHandler(router);

  // For running in containers, we respect the PORT environment variable.
  final port = int.parse(Platform.environment['PORT'] ?? '8080');
  final server = await serve(handler, ip, port);
  print('サーバー起動: http://${server.address.host}:${server.port}');
}

フロントエンドの実装

Flutterとriverpodを用いた実装を参考までに掲載しておきます。
長くなるのでロジックの部分だけ掲載します。全体を見たい方は下にレポジトリのリンクがあるのでそこから見に行ってください。

auth_provider.dart
import 'dart:async';
import 'dart:convert';

import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:http/http.dart' as http;
import 'package:shared_preferences/shared_preferences.dart';

// 適当なユーザーモデル
class User {
  User({
    required this.email,
  });
  factory User.fromJson(Map<String, dynamic> json) {
    return User(
      email: json['email'] as String,
    );
  }
  final String email;
}

final authProvider =
    NotifierProvider<AuthNotifier, AsyncValue<User?>>(AuthNotifier.new);

class AuthNotifier extends Notifier<AsyncValue<User?>> {
  
  AsyncValue<User?> build() => const AsyncValue.data(null);
  
  // ユーザー情報を得るためのロジック
  FutureOr<User> getUserData() async {
    final prefs = await SharedPreferences.getInstance();
    final token = prefs.getString('token');
    final response = await http.post(
      Uri.parse('http://127.0.0.1:8080/user'),
      headers: {
        'alg': 'HS256',
        'typ': 'JWT',
        'Content-Type': 'application/json; charset=UTF-8',
      },
      body: jsonEncode({'token': token}),
    );
    final json = jsonDecode(response.body) as Map<String, dynamic>;
    return User.fromJson(json);
  }
  
  // ユーザー情報を登録するためのロジック
  FutureOr<bool> register(String email, String password) async {
    state = const AsyncValue.loading();
    final response = await http.post(
      Uri.parse('http://127.0.0.1:8080/register'),
      body: jsonEncode({'email': email, 'password': password}),
    );

    if (response.statusCode != 201) {
      state = const AsyncValue.error(
        'Could not resist your data.',
        StackTrace.empty,
      );
      return false;
    }

    return await login(email, password);
  }
  
  // ログインのためのロジック
  FutureOr<bool> login(String email, String password) async {
    state = const AsyncValue.loading();
    final response = await http.post(
      Uri.parse('http://127.0.0.1:8080/login'),
      headers: {
        'alg': 'HS256',
        'typ': 'JWT',
        'Content-Type': 'application/json; charset=UTF-8',
      },
      body: jsonEncode({'email': email, 'password': password}),
    );
    if (response.statusCode != 200) {
      state = const AsyncValue.error(
        'failed to log in',
        StackTrace.empty,
      );
      return false;
    }
    final json = jsonDecode(response.body) as Map<String, dynamic>;
    final token = json['token'] as String;

    final prefs = await SharedPreferences.getInstance();
    await prefs.setString('token', token);

    state = AsyncValue.data(User(email: email));
    return true;
  }
  
  // ログアウトのためののロジック
  FutureOr<void> logout() async {
    state = const AsyncValue.loading();

    final prefs = await SharedPreferences.getInstance();
    if (await prefs.remove('token')) {
      state = const AsyncValue.data(null);
    }

    state = AsyncValue.data(state.value);
  }
}

動作例

jwt_auth.gif

コード全体

https://github.com/Aosanori/dart_jwt_auth

まとめ

いかがだったでしょうか。今回はJWTについてざっくりまとめた後に、実際にDartを用いてJWT認証サーバーを実装していきました。Dartにもサーバーサイドのフレームワークがいくつか存在しており、今回の実装ではREST APIを作成できるshelfを用いたのですが、思いの外直感的にかけてとても楽しかったです。

DartはFlutterでしか見かけないと思うのでサーバーサイドでは使えないのでは、と結構な方(かく言う自分も)が思っているでしょう。しかしながら、最近ServerPodというDartで書かれたサーバーサイドのフレームワークが盛んに開発されていることから、サーバーサイドDartの需要が段々と高まっているように感じます。フロントエンドとバックエンドが同じ言語でかけるということは、型・モデルがフロントエンドとサーバーで共有でき、データのやり取りがしやすくなるなどといったメリットがあるため、是非とも頑張って欲しいところです。

間違い・指摘等があればコメントお願いします。

https://serverpod.dev/

参考文献

https://qiita.com/knaot0/items/8427918564400968bd2b#実装

https://www.netattest.com/jwt-2023_mkt_tst

https://developer.mamezou-tech.com/blogs/2022/12/08/jwt-auth/

https://qiita.com/asagohan2301/items/cef8bcb969fef9064a5c

Discussion