Help us understand the problem. What is going on with this article?

WebAuthnを使ったFIDOサーバを立ててみた

Googleから最近になって、FIDO認証デバイスの「Titanセキュリティーキー」が発売されたので、購入しました。

[Google Titanセキュリティーキー]
 https://store.google.com/jp/product/titan_security_key_kit

今のところ、はやっていそうですし、せっかくなのでこれを使って遊んでいきたいと思います。

Titanセキュリティーキーがあれば、Webサーバから見てユーザID/パスワードで認証するのではなく、Titanセキュリティーキーのデバイスを認証してくれます。公開鍵をベースとしているそうです。
また、Titanセキュリティーキーには、USBに加えて、NFCとBLEの接続方法があるので、直接PCに挿さなくとも、デバイス認証してくれます。

ということで、今回は、王道のFIDOサーバを立ち上げます。

(参考) こちらもどうぞ。
 FIDOデバイスエミュレータを作成してみた。。。が

FIDOの全体像を知る

以下のサイトの記事が非常に参考になりました。
 https://techblog.yahoo.co.jp/advent-calendar-2018/webauthn/

それから、以下のチュートリアルも非常に参考になりました。
 https://github.com/fido-alliance/webauthn-demo

これがWebAuthn
 https://w3c.github.io/webauthn/

頭が混乱してきたら、以下のページで再整理しましょう。
 Web Authentication API の裏側と、なぜそうなっているのかを図解した

サーバ側に必要なエンドポイントを知る

サーバ側に必要なエンドポイントは以下の4つです。

  • POST /attestation/options
  • POST /attestation/result
  • POST /assertion/options
  • POST /assertion/result

(参考) ただし、一部未対応。
 https://fidoalliance.org/specs/fido-v2.0-rd-20180702/fido-server-v2.0-rd-20180702.html

詳細はよくわからないのですが、以下のnpmモジュールを使わせてもらうことで、実装量をかなり省けました。(ありがとうございます!)

fido2-lib
 https://github.com/apowers313/fido2-lib

エンドポイントに対応する関数が定義されていますので、それを呼び出せるように、エンドポイントへの入力パラメータを整理して呼び出します。
対応関係は以下の通りです。

  • /attestation/options → f2l.attestationOptions()
  • /attestation/result → f2l.attestationResult()
  • /assertion/options → f2l.assertionOptions()
  • /assertion/result → f2l.assertionResult()

ここで一つややこしいことがありまして、クライアントサーバ間のバイナリデータの受け渡しは、BASE64URLでエンコードするのが通例のようでして、エンドポイントからの入力と関数呼び出しの間、関数戻りからエンドポイントからの出力の間のそれぞれで、エンコード/デコードする必要があります。

さらにややこしいのが、一部fido2-libがエンコード/デコードを処理してなさそうに見えて、一部処理していたりで、ちょっと混乱しました。
後で示しますが、(頑張って理解しようとしたのですが)不具合と思われる個所を修正して、やっと動かすことができました。

BASE64URLとは、Base64エンコードに対して、Webで扱いやすいように、「+」を「-」に、「/」を「_」に置き換えたもののようです。
以下のnpmモジュールを使わせていただきました。

base64url
 https://github.com/brianloveswords/base64url

ユーザIDとデバイスの紐づけ

サーバ側から見てデバイスを認証できますが、そのデバイスの持ち主がどのユーザIDの人なのかはわからないので、紐づけが必要となります。
ですので、通常は、ユーザID/パスワードで認証したのち、デバイスで生成した公開鍵をユーザIDに紐づけます。こうすることで、デバイスを認証したこと=ユーザIDのユーザがログインした、ということになります。
一般に、/attestation/options のところで本人認証をしてから、デバイスの登録処理を進めるのですが、今回のデモサーバでは、ユーザID/パスワードの認証は省略しています。

クライアント側の呼び出し関数

Client側は、ブラウザを使いました。
かなり実装が進んでいるようで、やはりChromeは対応していました。

以下の2つの関数を呼び出します。
・navigator.credentials.create()
・navigator.credentials.get()

まとめると、以下の順番になります。

[デバイスの登録]
・POST /attestation/options
・navigator.credentials.create()
・POST /attestation/result

[デバイスでログイン]
・POST /assertion/options
・navigator.credentials.get()
・POST /assertion/result

ちょっと補足します。

・POST /attestation/options
デバイスに紐づけたいユーザ名を知らせます。本来であれば、パスワードや認証後のセッションを渡して、ユーザ本人であることを示すのが正しいですが、省略しています。
レスポンスとして、チャレンジが返ってきます。

・navigator.credentials.create()
デバイス内部で、公開鍵ペアを生成し、チャレンジに対して署名を付与します。
レスポンスとして、公開鍵値、署名値、公開鍵を識別するIDが返ってきます。

・POST /attestation/result
デバイスから取得したレスポンスをサーバに登録します。
サーバでは、署名検証し、公開鍵・公開鍵を識別するID(credId)などを、ユーザ名に紐づけて保持することで登録完了です。
レスポンスに含まれるX.509の検証は手抜きでしていませんっ!

・POST /assertion/options
ユーザ名を入力とし、サーバに保持しているユーザ名を検索します。
そして、ユーザ名に紐づけたcredIdを取り出し、またチャレンジを生成して、クライアントに返します。複数のデバイスが紐づけられている可能性もあるので、credIdは複数の場合があります。

・navigator.credentials.get()
デバイスに署名生成を要求します。生成に使う公開鍵は、サーバから取得したcredIdで指定します。
レスポンスとして署名に使った公開鍵のIDと署名値が返ってきます。

・POST /assertion/result
署名に使った公開鍵のIDでcredIdを特定し、署名を検証します。

上記以外にも、細かいチェック処理が入っています。

ソースコード

ソースコード量が多いので、GitHubに上げておきました。
 https://github.com/poruruba/fido_server

まずはサーバ部分です。

index.js
'use strict';

const HELPER_BASE = process.env.HELPER_BASE || '../../helpers/';
const Response = require(HELPER_BASE + 'response');
const Redirect = require(HELPER_BASE + 'redirect');

const base64url = require('base64url');
const crypto    = require('crypto');
const { Fido2Lib } = require("fido2-lib");

const FIDO_RP_NAME = process.env.FIDO_RP_NAME || "Sample FIDO Host";
const FIDO_ORIGIN = process.env.FIDO_ORIGIN || "https://localhost";

var f2l = new Fido2Lib({
  rpName: FIDO_RP_NAME
});

let database = {};

exports.handler = async (event, context, callback) => {
  if( event.path == '/assertion/options'){
    var body = JSON.parse(event.body);
    console.log(body);

    let username = body.username;

    if(database[username] && !database[username].registered) {
      return new Response({
        'status': 'failed',
        'message': `Username ${username} does not exist`
      });
    }

    var authnOptions = await f2l.assertionOptions();
    authnOptions.challenge = base64url.encode(authnOptions.challenge);

    let allowCredentials = [];
    for(let authr of database[username].attestation) {
        allowCredentials.push({
          type: 'public-key',
          id: authr.credId,
          transports: ['usb', 'nfc', 'ble']
        })
    }
    authnOptions.allowCredentials = allowCredentials;
    console.log(authnOptions);

    context.req.session.challenge = authnOptions.challenge;
    context.req.session.username  = username;

    authnOptions.status = 'ok';

    return new Response(authnOptions);
  }else
  if( event.path == '/assertion/result'){
    var body = JSON.parse(event.body);
    console.log(body);

    var attestation = null;
    for( var i = 0 ; i < database[context.req.session.username].attestation.length ; i++ ){
      if( database[context.req.session.username].attestation[i].credId == body.id ){
        attestation = database[context.req.session.username].attestation[i];
        break;
      }
    }
    if( !attestation ){
      return new Response({
        'status': 'failed',
        'message': 'key is not found.'
      });
    }

    var assertionExpectations = {
      challenge: context.req.session.challenge,
      origin: FIDO_ORIGIN,
      factor: "either",
      publicKey: attestation.publickey,
      prevCounter: attestation.counter,
      userHandle: null
    };

    body.rawId = new Uint8Array(base64url.toBuffer(body.rawId)).buffer;
    var authnResult = await f2l.assertionResult(body, assertionExpectations);
    console.log(authnResult);

    if(authnResult.audit.complete) {
      attestation.counter = authnResult.authnrData.get('counter');

      return new Response({
        'status': 'ok',
        credId: body.id,
        counter: attestation.counter
      });
    } else {
      return new Response({
        'status': 'failed',
        'message': 'Can not authenticate signature!'
      });
    }
  }else
  if( event.path == '/attestation/options'){
    var body = JSON.parse(event.body);
    console.log(body);

    let username = body.username;

    if(database[username] && database[username].registered) {
      return new Response({
          'status': 'failed',
          'message': `Username ${username} already exists`
      });
    }

    var id = randomBase64URLBuffer();

    var registrationOptions = await f2l.attestationOptions();
    registrationOptions.challenge = base64url.encode(registrationOptions.challenge);
    registrationOptions.user.id = id;
    registrationOptions.user.name = username;
    registrationOptions.user.displayName = username;
    console.log(registrationOptions);

    database[username] = {
      'name': username,
      'registered': false,
      'id': id,
      'attestation': []
    };

    context.req.session.challenge = registrationOptions.challenge;
    context.req.session.username = username;

    registrationOptions.status = 'ok';

    return new Response(registrationOptions);
  }else
  if( event.path == '/attestation/result'){
    var body = JSON.parse(event.body);
    console.log(body);

    var attestationExpectations = {
        challenge: context.req.session.challenge,
        origin: FIDO_ORIGIN,
        factor: "either"
    };
    body.rawId = new Uint8Array(base64url.toBuffer(body.rawId)).buffer;
    var regResult = await f2l.attestationResult(body, attestationExpectations);
    console.log(regResult);

    var credId = base64url.encode(regResult.authnrData.get('credId'));
    var counter = regResult.authnrData.get('counter');
    database[context.req.session.username].attestation.push({ 
        publickey : regResult.authnrData.get('credentialPublicKeyPem'),
        counter : counter,
        fmt: regResult.authnrData.get('fmt'),
        credId : credId
    });

    if(regResult.audit.complete) {
      database[context.req.session.username].registered = true

      return new Response({
        'status': 'ok',
        credId: credId,
        counter: counter
      });
    } else {
      return new Response({
        'status': 'failed',
        'message': 'Can not authenticate signature!'
      });
    }
  }
};

function randomBase64URLBuffer(len){
  len = len || 32;

  let buff = crypto.randomBytes(len);

  return base64url(buff);
}

以下の部分は、環境に合わせて、変更してください。
・process.env.FIDO_RP_NAME
・process.env.FIDO_ORIGIN

次は、クライアント側ソースです。
Base64URLエンコードには以下のモジュールを使わせていただきました。

Base64URL-ArrayBuffer
 https://github.com/herrjemand/Base64URL-ArrayBuffer

HTMLは以下の通りです。

index.html
<!DOCTYPE html>
<html lang="ja">
<head>
  <meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
  <meta http-equiv="Content-Security-Policy" content="default-src * data: gap: https://ssl.gstatic.com 'unsafe-eval' 'unsafe-inline'; style-src * 'unsafe-inline'; media-src *; img-src * data: content: blob:;">
  <meta name="format-detection" content="telephone=no">
  <meta name="msapplication-tap-highlight" content="no">
  <meta name="apple-mobile-web-app-capable" content="yes" />
  <meta name="viewport" content="user-scalable=no, initial-scale=1, maximum-scale=1, minimum-scale=1, width=device-width">

  <!-- jQuery (necessary for Bootstrap's JavaScript plugins) -->
  <script src="https://ajax.googleapis.com/ajax/libs/jquery/1.12.4/jquery.min.js"></script>
  <!-- Latest compiled and minified CSS -->
  <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/css/bootstrap.min.css" integrity="sha384-BVYiiSIFeK1dGmJRAkycuHAHRg32OmUcww7on3RYdg4Va+PmSTsz/K68vbdEjh4u" crossorigin="anonymous">
  <!-- Optional theme -->
  <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/css/bootstrap-theme.min.css" integrity="sha384-rHyoN1iRsVXV4nD0JutlnGaslCJuC7uwjduW9SVrLvRYooPp2bWYgmgJQIXwl/Sp" crossorigin="anonymous">
  <!-- Latest compiled and minified JavaScript -->
  <script src="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/js/bootstrap.min.js" integrity="sha384-Tc5IQib027qvyjSMfHjOMaLkfuWVxZxUPnCJA7l2mCWNIpG9mGCD8wGNIcPD7Txa" crossorigin="anonymous"></script>

  <title>FIDO Demo Server</title>

  <script src="js/methods_utils.js"></script>
  <script src="js/vue_utils.js"></script>

  <script src="dist/js/vconsole.min.js"></script>
  <script src="https://cdn.jsdelivr.net/npm/js-cookie@2/src/js.cookie.min.js"></script>
  <script src="https://unpkg.com/vue"></script>
</head>
<body>
    <div id="top" class="container">
        <h1>FIDO Demo Server</h1>

        <div class="alert alert-info" role="alert">{{message}}</div>

        <div class="form-inline">
            <label>username</label> <input type="text" class="form-control" v-model="username">
        </div>
        <button class="btn btn-default" v-on:click="start_register()">登録開始</button>

        <div v-if="attestation != null">
            <label>rp.name</label> {{attestation.rp.name}}<br>
            <label>user.displayName</label> {{attestation.user.displayName}}<br>
            <label>user.id</label> {{attestation_encode.user.id}}<br>
            <label>challenge</label> {{attestation_encode.challenge}}<br>
            <label>attestation</label> {{attestation.attestation}}<br>

            <button class="btn btn-default" v-on:click="do_register()">登録実行</button>
        </div>
        <div>
            <div v-if="registered">
                <label>credId</label> {{register_credId}}<br>
                <label>counter</label> {{register_counter}}<br>
            </div>

            <button class="btn btn-default" v-on:click="start_login()">ログイン開始</button>
        </div>
        <div v-if="assertion != null">
            <div v-for="(cred, index) of assertion_encode.allowCredentials">
                <label>cred.id[{{index}}]</label> {{cred.id}}<br>
            </div>
            <label>challenge</label> {{assertion_encode.challenge}}<br>

            <button class="btn btn-default" v-on:click="do_login()">ログイン実行</button>
        </div>
        <div v-if="logined">
            <label>credId</label> {{login_credId}}<br>
            <label>counter</label> {{login_counter}}<br>
        </div>

        <div class="modal fade" id="progress">
            <div class="modal-dialog">
                <div class="modal-content">
                    <div class="modal-header">
                        <h4 class="modal-title">{{progress_title}}</h4>
                    </div>
                    <div class="modal-body">
                        <center><progress max="100" /></center>
                    </div>
                </div>
            </div>
        </div>
    </div>

    <script src="dist/js/base64url-arraybuffer.js"></script>
    <script src="js/start.js"></script>
</body>

Javascript部分です。

start.js
'use strict';

//var vConsole = new VConsole();

const base_url = "【サーバのURL】";

var vue_options = {
    el: "#top",
    data: {
        progress_title: '',

        message: "",
        username: 'test',
        attestation : null,
        attestation_encode: {
            user: {}
        },
        register_credId: null,
        register_counter: -1,
        registered : false,
        assertion : null,
        assertion_encode: {
            allowCredentials: []
        },
        login_credId: null,
        login_counter: -1,
        logined : false
    },
    computed: {
    },
    methods: {
        start_register: function(){
            this.registered = false;
            var param = {
                username: this.username,
                userVerification: true
            };
            this.progress_open();
            do_post( base_url + '/attestation/options', param )
            .then(json =>{
                this.progress_close();
                console.log(json);
                if( json.status != 'ok'){
                    alert(json.message);
                    return;
                }

                this.attestation_encode.challenge = json.challenge;
                this.attestation_encode.user.id = json.user.id;

                json.challenge = base64url.decode(json.challenge);
                json.user.id = base64url.decode(json.user.id);

                this.attestation = json;
                this.message = '登録の準備ができました。';
            })
            .catch(error =>{
                this.progress_close();
                alert(error);
            });
        },
        do_register: function(){
            return navigator.credentials.create({ publicKey: this.attestation })
            .then(response =>{
                var result = publicKeyCredentialToJSON(response);

                this.progress_open();
                return do_post(base_url + '/attestation/result', result )
            })
            .then((response) => {
                this.progress_close();
                if(response.status !== 'ok'){
                    alert(json.message);
                    return;
                }

                if( response.status == 'ok'){
                    this.register_credId = response.credId;
                    this.register_counter = response.counter;
                    this.registered = true;
                    this.message = '登録が完了しました。';
                }else{
                    throw 'registration error';
                }
            })
            .catch(error =>{
                this.progress_close();
                alert(error);
            });
        },
        start_login: function(){
            this.logined = false;
            var param = {
                username: this.username,
            };

            this.progress_open();
            do_post( base_url + '/assertion/options', param )
            .then(json =>{
                this.progress_close();
                console.log(json);
                if( json.status != 'ok'){
                    alert(json.message);
                    return;
                }

                this.assertion_encode.challenge = json.challenge;
                json.challenge = base64url.decode(json.challenge);

                for(var i = 0 ; i < json.allowCredentials.length ; i++ ) {
                    this.assertion_encode.allowCredentials[i] = { id: json.allowCredentials[i].id };
                    json.allowCredentials[i].id = base64url.decode(json.allowCredentials[i].id);
                }

                this.assertion = json;
                this.message = 'ログインの準備ができました。';
            })
            .catch(error =>{
                this.progress_close();
                alert(error);
            });
        },
        do_login: function(){
            return navigator.credentials.get({ publicKey: this.assertion })
            .then(response =>{
                var result = publicKeyCredentialToJSON(response);

                this.progress_open();
                return do_post(base_url + '/assertion/result', result )
            })
            .then((response) => {
                this.progress_close();
                if(response.status !== 'ok')
                    throw new Error(`Server responed with error. The message is: ${response.message}`);

                console.log('sendWebAuthnResponse received: ', response);

                if( response.status == 'ok'){
                    this.login_credId = response.credId;
                    this.login_counter = response.counter;
                    this.logined = true;
                    this.message = 'ログインが成功しました。';
                }else{
                    throw 'login error';
                }
            })
            .catch(error =>{
                this.progress_close();
                alert(error);
            });
        }
    },
    created: function(){
    },
    mounted: function(){
        proc_load();
    }
};
vue_add_methods(vue_options, methods_utils);
var vue = new Vue( vue_options );

function do_post(url, body){
//    const headers = new Headers( { "Content-Type" : "application/json; charset=utf-8" } );
    const headers = new Headers( { "Content-Type" : "application/json" } );

    return fetch(url, {
        method : 'POST',
        credentials: 'include',
        body : JSON.stringify(body),
        headers: headers
    })
    .then((response) => {
        if( !response.ok )
            throw 'status is not 200.';
        return response.json();
    });
}

function publicKeyCredentialToJSON(pubKeyCred){
    if(pubKeyCred instanceof Array) {
        let arr = [];
        for(let i of pubKeyCred)
            arr.push(publicKeyCredentialToJSON(i));

        return arr
    }

    if(pubKeyCred instanceof ArrayBuffer) {
        return base64url.encode(pubKeyCred)
    }

    if(pubKeyCred instanceof Object) {
        let obj = {};

        for (let key in pubKeyCred) {
            obj[key] = publicKeyCredentialToJSON(pubKeyCred[key]);
        }

        return obj
    }

    return pubKeyCred
}

以下の部分は、環境に合わせて書き換えてください。
・【サーバのURL】

fido2-lib の修正

下記に示すように、fido2-libの実装をいじらないとうまく動きませんでした。

lib/validator.js
// 172行目あたり
// 変更前
    if (typeof req.response.userHandle !== "string" &&
        !(req.response.userHandle instanceof ArrayBuffer) &&
        req.response.userHandle !== undefined) {
        throw new TypeError("expected 'response.userHandle' to be base64 String, ArrayBuffer, or undefined");
    }

// 変更後
    if (typeof req.response.userHandle !== "string" &&
        !(req.response.userHandle instanceof ArrayBuffer) &&
// modified by poruruba
        req.response.userHandle != null &&
        req.response.userHandle !== undefined) {
        throw new TypeError("expected 'response.userHandle' to be base64 String, ArrayBuffer, or undefined");
    }
lib/utils.js
// 183行目あたり
// 変更前
function bufEqual(a, b) {
    var len = a.length;

    if (len !== b.length) {
        return false;
    }

    for (var i = 0; i < len; i++) {
        if (a.readUInt8(i) !== b.readUInt8(i)) {
            return false;
        }
    }

    return true;
}

// 変更後
function bufEqual(a, b) {
// modified by poruruba
    if (!(a instanceof ArrayBuffer && b instanceof ArrayBuffer)) {
        throw new Error("expected bufEqual to be of type ArrayBuffer");
    }

    var len = a.byteLength;

    if (len !== b.byteLength) {
        return false;
    }

    for (var i = 0; i < len; i++) {
        if (a[i] !== b[i]) {
            return false;
        }
    }

    return true;
}
lib/parser.js
// 249行目あたり
// 変更前
    let userHandle;
    if (msg.response.userHandle !== undefined) {
        userHandle = coerceToArrayBuffer(msg.response.userHandle, "response.userHandle");
        if (userHandle.byteLength === 0) {
            userHandle = undefined;
        }
    }

    let sigAb = coerceToArrayBuffer(msg.response.signature, "response.signature");
    let ret = new Map([
        ["sig", sigAb],
        ["userHandle", userHandle],
        ["rawAuthnrData", msg.response.authenticatorData],
        ...parseAuthenticatorData(msg.response.authenticatorData)
    ]);

// 変更後
    let userHandle;
// modified by poruruba
//    if (msg.response.userHandle !== undefined) {
    if (msg.response.userHandle !== undefined && msg.response.userHandle !== null) {
        userHandle = coerceToArrayBuffer(msg.response.userHandle, "response.userHandle");
        if (userHandle.byteLength === 0) {
            userHandle = undefined;
        }
    }

    let sigAb = coerceToArrayBuffer(msg.response.signature, "response.signature");
    let ret = new Map([
        ["sig", sigAb],
        ["userHandle", userHandle],
// modified by poruruba
//        ["rawAuthnrData", msg.response.authenticatorData],
        ["rawAuthnrData", coerceToArrayBuffer(msg.response.authenticatorData, "authnrDataArrayBuffer")],
        ...parseAuthenticatorData(msg.response.authenticatorData)
    ]);

動作確認

Windows10のChromeブラウザで実施してみました。
以下は、TitanセキュリティキーをUSB端子に挿した場合です。

index.htmlを開きます。

image.png

usernameに適当なユーザ名を入力して、「登録開始」ボタンを押下します。

image.png

「登録実行」ボタンを押下すると、以下が表示されます。
Titanセキュリティキーの表面の金属のところを指で触ります。

image.png

以下が表示されますので、「許可」ボタンを押下して進めます。

image.png

これで登録完了です。

image.png

さあログインしましょう。
「ログイン開始」ボタンを押下すると、以下のようになります。

image.png

そして、「ログイン実行」ボタンを押下します。
登録時と同様に、Titanセキュリティキーの表面の金属のところを指で触ります。

image.png

以下のように表示されて、ログイン完了です。

image.png

Androidでもやってみます。
こちらはBLEでやってみました。NFCでも似たような感じでした。

事前に、BLEデバイスとしてペアリングしておく必要があるようです。
ペアリングは、デバイスのボタンを5秒間長押しするとペアリングが開始されます。

それでは、始めましょう。AndroidのChromeからindnex.htmlを開きます。

image.png

「登録開始」ボタンを押下します。

image.png

「登録実行」ボタンを押下します。

image.png

デバイスにあるボタンを押します。

image.png

登録完了です。

image.png

次はログインです。
「ログイン開始」ボタンを押下すると以下の画面になります。

image.png

「ログイン実行」ボタンを押下します。

image.png

デバイスにあるボタンを押します。

image.png

ログインできました!

image.png

制限事項

WindowsでBLEを使って認証しようとしましたがうまくいきませんでした。原因不明です。
https://github.com/apowers313/fido2-lib をみたら、Androidの指紋認証Attestationが実装されているではないかっ!

以上

poruruba
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした