LoginSignup
2
1

More than 1 year has passed since last update.

OTP(One-Time-Password)発行管理アプリを作った

Last updated at Posted at 2022-08-27

OTP(One-Time-Password)を自分で発行管理できるようにします。
パスワード自体はサーバ側に保管し、OTPのコード生成もサーバ側で実施するようにします。

実は、OTPからコードの生成は、有志のnpmモジュールのお陰で、容易に実装できました。
一方で、OTPのQRコードをPCやスマホに取り込んだり、Windowsでのコピー&ペーストを活用しようとするといろいろ対応しないといけなかったです。

ということで、一応OTPの説明ですが、どちらかというと後者の取り込み方法の方が中身が濃い投稿となりました。。。

OTPのフォーマット

OTPを初めて生成すると、だいたいQRコードが表示されて、Google Authenticatorなどに取り込むことがほとんどだと思います。
そのQRコードの中身は以下のような形です。

otpauth://[メソッド]/[アカウント名]?secret=[シークレット]&issuer=[発行者名]・・・

以下例です。

otpauth://totp/hogehoge?secret=ABCDEFGHIJK&issuer=%E3%83%95%E3%82%AC%E3%83%95%E3%82%AC

メソッドには、totpかhotpが入ってくるようです。
アカウント名は、OTPを払い出したときのそのサイトの自分のアカウント名です。
シークレットが、コードを生成するための秘匿の値です。
発行者名は、そのサイトの運営者の名前かと思います。UTF-8エンコード(符号化)されています。

Node.jsであれば、こんな感じで抽出しました。

node.js/api/controllers/otp-api/index.html
function otp_parse(uri){
	var url = new URL(uri);
	if( url.protocol != "otpauth:" || (url.hostname != "totp" && url.hostname != "hotp") )
		throw new Error("protocol.hostname mismatch");
	return {
		account_name: decodeURI(url.pathname).slice(1),
		secret: url.searchParams.get("secret"),
		issuer: url.searchParams.get("issuer"),
		method: url.hostname
	};
}

OTPのコードの生成

先ほどのシークレットから、OTPで認証するときに入力する6桁の数字を生成します。
以下の、npmモジュールを使わせていただきました。

yeojz/otplib

Node.jsサーバ側で処理してまして、以下抜粋です

node.js/api/controllers/otp-api/index.js
		var code;
		if( item.method == 'totp')
			code = totp.generate(item.secret);
		else if( item.method == 'hotp' )
			code = hotp.generate(item.secret);
		else
			throw new Error('unknown method');

		return new Response({code: code});

totpまたはhotpにあわせて、generateを呼び出しているだけです。引数にシークレットを渡します。

簡単ですね。

OTPを取り込む

ブラウザからOTPを取り込みます。
OTPを取り込む方法として、たくさんの方法があります。

・画像ファイルをドロップ
・テキストファイルをドロップ
・クリップボードの画像データから
・クリップボードの画像ファイルから
・クリップボードのテキストから
・カメラからQRコードスキャン

テキストファイルまたは画像ファイルをドロップ

ドロップ先は、textareaです。
Vueを活用しています。

node.js/pulic/index.html
<textarea placeholder="ここに画像かテキストをペースト(Ctrl-V)してください。" class="form-control" style="text-align: center; resize: none;" rows="5"
v-on:paste="otp_text_paste"
v-on:drop.prevent="otp_file_drop"
v-on:dragover.prevent readonly>
</textarea>

ドロップで使うのは、v-on:dropの方です。既存のブラウザ処理が動作しないように、preventを付けたり、「v-on:dragover.prevent」も付けてます。

node.js/public/js/start.js
        otp_file_drop: function(e){
            console.log(e);
            if( e.dataTransfer.files.length == 0 )
                return;
    
            var file = e.dataTransfer.files[0];
            const type = file.type;
            if(type.startsWith('image/')){
                var reader = new FileReader();
                reader.onload = (e) => {
                    var data_url = e.target.result;
                    qrcode_from_dataurl(data_url)
                    .then(code =>{
                        this.otp_set_url(code.data);
                    });

                };
                reader.readAsDataURL(file);
            }else
            if( type.startsWith('text/')){
                var reader = new FileReader();
                reader.onload = (e) => {
                    this.otp_set_url(e.target.result.trim());
                };
                reader.readAsText(file);
            }else{
                console.log(file);
                alert('サポートしていません。');
            }
        },

ドロップされたファイルが、画像ファイルなのかテキストファイルなのかで条件分岐しています。
テキストファイルの場合は、中身を取り出した後、top_set_urlを呼び出しています。

node.js/public/js/start.js
        otp_set_url: function(url){
            this.otp_item = {};
            this.otp_url = "";
            if( !url )
                return;
            
            var data = otp_parse_url(url);
            if( !data ){
                alert("invalid url");
                return;
            }
            this.otp_item = data;
            this.otp_url = url;
        },

肝心の、OTPのURLの解析は以下の通りです。

node.js/public/js/start.js
function otp_parse_url(qrcode){
    try{
        let url = new URL(qrcode);
        if( url.protocol != "otpauth:" )
            return null;
        url = new URL("https:" + qrcode.slice("otpauth:".length));
        if(url.hostname != "totp" && url.hostname != "hotp")
            return null;
        var data = {
            account_name: decodeURI(url.pathname).slice(1),
            secret: url.searchParams.get("secret"),
            issuer: url.searchParams.get("issuer"),
            method: url.hostname
        };
        return data;
    }catch(error){
        return null;
    }
}

ドロップされたファイルが画像ファイルだった場合には、QRコードの画像であるとみなし、QRコードスキャンする必要があります。

画像からQRコードをスキャンするために、以下のライブラリを使わせていただきました。
CDNから借用させていただいてます。

cosmo/jsQR

ちょっと前処理が必要でして、ドロップされた画像ファイルをいったんデータURL形式にして、それをHTMMLのImageエレメントにロードし、その描画データをCanvasエレメントに描画し、その描画データをjsQRでQRコードスキャンしています。

node.js/public/js/start.js
function qrcode_from_dataurl(data_url){
    return new Promise((resolve, reject) =>{
        const image = new Image();
        image.onload = () =>{
            const qrcode_canvas = document.createElement("canvas");
            qrcode_canvas.width = image.width;
            qrcode_canvas.height = image.width;
            const qrcode_context = qrcode_canvas.getContext('2d');
            qrcode_context.drawImage(image, 0, 0, image.width, image.width);
            const imageData = qrcode_context.getImageData(0, 0, qrcode_canvas.width, qrcode_canvas.height);

            const code = jsQR(imageData.data, qrcode_canvas.width, qrcode_canvas.height);
            if (code && code.data) {
                console.log(code);
                resolve(code);
            }else{
                resolve(null);
            }
        };
        image.onerror = (error) =>{
            reject(error);
        };
        image.src = data_url;
    });
}

スキャンされた結果が、OTPのURLになっているので、先ほどと同じotp_set_urlで解析しています。

クリップボードの画像データまたは画像ファイルまたはテキストから取り込む

取り込みは、先ほどと同じテキストエリアを使いますが、v-on:pasteの部分が該当します。

node.js/public/index.html
<textarea placeholder="ここに画像かテキストをペースト(Ctrl-V)してください。" class="form-control" style="text-align: center; resize: none;" rows="5"
v-on:paste="otp_text_paste"
v-on:drop.prevent="otp_file_drop"
v-on:dragover.prevent readonly>
</textarea>

クリップボードの画像データまたは画像ファイル の違いが微妙に違います。

クリップボードの画像ファイルとは、たとえばエクスプローラからファイルを選択してコピーした場合です。こちらが一般的かもしれませんが、ブラウザからの場合はちょっと違います。
ブラウザから同様に画像を右クリックして画像をコピーとすると、ちょっと違う形式で、中身はHTMLになっています。

image.png

クリップボードには実はこんな感じの中身になっています。image/pngではなく、text/htmlになっているのです。

<html>
<body>
<!--StartFragment--><img src="data:image/png;base64,iVBORw0・・・・"/><!--EndFragment-->
</body>
</html>

そこで、画像データ部分を取得するためには、このHTMLをパースする必要があります。

node.js/public/js/start.js
function parse_htmlclipboard(html, type="text/html"){
    let parser = new DOMParser()
    var doc = parser.parseFromString(html, type);
    var body = doc.querySelector("html > body")
    if(body.firstChild.nextSibling.data == "StartFragment"){
        var target = body.firstElementChild;
        if(target.localName == "img")
            return target.src;
    }
    return null;
}

ということでまとめると以下になります。

node.js/public/js/start.js
        otp_text_paste: function(e){
            console.log(e);
            if (e.clipboardData.types.length == 0)
                return;
    
            var item = e.clipboardData.items[0];
            const type = item.type;
            if( type.startsWith('text/')){
                item.getAsString(str =>{
                    if( type == "text/html" ){
                        var src = parse_htmlclipboard(str, type);
                        if( src == null ){
                            this.otp_set_url(str);
                        }else{
                            do_get_blob(src)
                            .then(blob =>{
                                var reader = new FileReader();
                                reader.onload = (e) => {
                                    var data_url = e.target.result;
                                    qrcode_from_dataurl(data_url)
                                    .then(code =>{
                                        this.otp_set_url(code.data);
                                    });
                                };
                                reader.readAsDataURL(blob) ;
                            });
                        }
                    }else{
                        this.otp_set_url(str.trim());
                    }
                });
            }else
            if( type.startsWith('image/')){
                var imageFile = item.getAsFile();
                var reader = new FileReader();
                reader.onload = (e) => {
                    var data_url = e.target.result;
                    qrcode_from_dataurl(data_url)
                    .then(code =>{
                        this.otp_set_url(code.data);
                    });
                };
                reader.readAsDataURL(imageFile);
            }else{
                console.log(item);
                alert('サポートしていません。');
            }
        },

type == "text/html"の部分がその処理です。
imgエレメントには、データURLの場合もあれば、通常の外部リンクの場合もあるかもしれません。
以下の関数で、HTTP Getしておきます。Blobとして取得しておきます。

node.js/public/js/start.js
function do_get_blob(url, qs) {
    const params = new URLSearchParams(qs);

    var params_str = params.toString();
    var postfix = (params_str == "") ? "" : ((url.indexOf('?') >= 0) ? ('&' + params_str) : ('?' + params_str));
    return fetch(url + postfix, {
        method: 'GET',
    })
    .then((response) => {
        if (!response.ok)
        throw 'status is not 200';
        return response.blob();
    });
}

あとはBlobをデータURLとして取り出して、先ほどのようにQRコードスキャンすればよいです。

ブラウザからコピーではなく、エクスプローラから画像ファイルをコピーした場合は、type.startsWith('image/') の処理分岐で処理されます。

テキストのクリップボードの場合は、単に this.otp_set_url(str.trim());を呼び出すだけです。

カメラからQRコードスキャン

最後に、カメラからQRコードをスキャンします。
最近のブラウザは、ブラウザからカメラを操作できるのはありがたいですね。

HTMLには以下を用意します。

node.js/public/index.html
<canvas class="btn-block center-block" id="qrcode_canvas"></canvas>

あとは、Javascriptで以下を呼びます。

node.js/public/js/start.js
        qrcode_scan_start: async function(){
            this.otp_url = null;
            if( !this.qrcode_video )
                this.qrcode_video = document.createElement("video");
            const qrcode_canvas = document.querySelector('#qrcode_canvas');
            let qrcode_context;
            let qrcode_running = true;

            this.qrcode_forcestop = (code) =>{
                if (qrcode_timer != null) {
                    clearTimeout(qrcode_timer);
                    qrcode_timer = null;
                }

                this.otp_set_url(code);
                
                this.qrcode_video.pause();
                this.qrcode_video.srcObject = null;
                qrcode_running = false;
            };
            let qrcode_timer = setTimeout(() => {
                this.qrcode_forcestop("");
            }, QRCODE_CANCEL_TIMER);

            const qrcode_draw = () =>{
                if (qrcode_context == null) {
                    if (this.qrcode_video.videoWidth == 0 || this.qrcode_video.videoHeight == 0) {
                        if (qrcode_running)
                            requestAnimationFrame(qrcode_draw);
                        return;
                    }
                    qrcode_canvas.height = Math.floor(qrcode_canvas.width * (this.qrcode_video.videoHeight / this.qrcode_video.videoWidth));
                    qrcode_context = qrcode_canvas.getContext('2d');
                }
                qrcode_context.drawImage(this.qrcode_video, 0, 0, qrcode_canvas.width, qrcode_canvas.height);
                const imageData = qrcode_context.getImageData(0, 0, qrcode_canvas.width, qrcode_canvas.height);

                const code = jsQR(imageData.data, qrcode_canvas.width, qrcode_canvas.height);
                if (code && code.data != "") {
                    console.log(code);
                    this.qrcode_forcestop(code.data);
                } else {
                    if (qrcode_running)
                        requestAnimationFrame(qrcode_draw);
                }
            };

            return navigator.mediaDevices.getUserMedia({ video: { facingMode: "environment" }, audio: false })
            .then(stream => {
                this.qrcode_video.srcObject = stream;
                this.qrcode_video.setAttribute("playsinline", true);
                this.qrcode_video.play();
                qrcode_draw();
            })
            .catch(error => {
              alert(error);
            });
        },

詳細は以下が参考になります。

 便利ページ:JavascriptでQRコードスキャン

応用:パスワード/OTP/FIDO発行管理ページの作成

パスワード/OTP/FIDOを自身で発行して管理するページを作成しました。

詳細はどこかのタイミングで説明しようかと思います。
あ、自己責任でお願いします。

poruruba/CredentialRepository

参考
 ・PWAを試してみよう
 ・M5Stack用暗号認証ユニットでFIDOデバイスを作る:の続き

以上

2
1
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
2
1