2023年7月26日水曜日

WEBサーバで、一定時間だけ有効な認証キーで登録する

メールアドレスの入力確認のための方法

2度入力させるUIって、ほんとダメですよね。

メールアドレスを入力してください。(*)
確認のため、再度メールアドレスを入力してください。(*)
(*)必須項目。しかもコピペできないw

んでも、入力ミスっていたら、その後メール来ないし。(笑)
これ、よく見かけますよねぇ。
昔はうちもやってましたが(笑)

認証キーを送信して、入力させる方法

そこで、30分とか1時間だけ有効な認証キーを送って、それを入力してもらうようにするといいんですね。
これも、よく見かけるようになりましたが(笑)

こんな画面です。

メールアドレスを入力して「認証キー送信」ボタンを押してもらう。
受信したメールに記載の認証キーを入力して、本来の登録処理にする。
こうすることで、メールが届かなかったら、入力ミスだとわかるし、メールが届けば間違いないメールアドレスだと判断できますね
認証キーは一定時間(たとえば30分)だけ有効で、それを過ぎると無効になります。

で、これをどのようにサーバー側で実装しようか、ということですよ。

HTMLページ上の Ajax を使った Javascript は以下のような感じになります。

// 認証キーを送信する処理
onButtonSendAuthKey = function()
{
    // メールアドレスに認証コードを発行するサーバープログラム
    var url = '/regist-temporary';
    var params = {
        mail: $('#input-mail').val()
    };
    $.post({
        url: url,
        data: params,
    }).then(function(response){
        // 略
    });
}

// 入力されたメールアドレスと認証キーで登録する処理
onButtonEntry = function()
{
    // メールアドレスを登録するサーバープログラム
    var url = '/regist-user';
    var params = {
        mail: $('#input-mail').val(),
        auth_key: $('#input-auth_key').val()
    };
    $.post({
        url: url,
        data: params,
    }).then(function(response){
        // 略
    });
}

30分有効な認証キーをどう管理する?

ランダムな認証キーは乱数で構成すれば簡単にできますが、作成した認証キーをどのように管理するのか?
というのが今回のテーマです。
Session と Cookie を使って、ブラウザ側に記憶させる、という方法もあるでしょうけど、クライアント側に覚えさせるのは、ちょっと抵抗を感じてしまいます。
んでは、サーバー側で対応するとすると、どうすべきでしょう?
データベースに一時ユーザーテーブルを用意しておく、という方法が一般的のようです。

一定時間を経過したら、その情報を削除しなければならないわけで、それには cron でシェル作ってDBの作成時間を見て delete、と。
と考えるとですね、おっさんプログラマにはリソースの膨大な無駄遣いのように思えて仕方がないのですよ(笑)

サーバー側の認証キー送信処理(/regist-temporary)では以下の手続きが必要になります。
・認証キーを生成する。
・メールアドレスに認証キーを送信する。
・メールアドレスに対して発行した認証キーを記録する。
・30分経っても登録されない場合、認証キーを削除する。
サーバー側の登録処理(/regist-user)は以下の手続きが必要です。
・認証キーが無効になっていないかを判断する。
・メールアドレスと認証キーが一致するか判断する。
・本来の登録処理を行う。
という流れなんですが、リソースの使用を最小限に留めて、サクサク動くようにできないもんでしょうかね。

/dev/shm を使う!

そこで、思いついたのが、一般的な Linuxサーバに必ず用意されていて、あまり利用されている風ではない、/dev/shm (RAMディスク)を利用する方法です。
1.ユーザーごとに生成した認証キーを /dev/shm/サービス名/temp_auth/ ディレクトリに記録しておく。
2.登録時には、記録内容を照合する。
3.cron で記録ディレクトリの一定時間経過したファイルを削除する。
という方法ですね。

メールアドレスごとに認証キーを生成して、RAMディスクに記録する

// メールアドレスごとの認証キーを生成して、送信する処理
function send_auth_key($mail)
{
    // ランダムな認証キーを生成
    $auth_key = $this->create_auth_key();
    $path = '/dev/shm/service/temp_auth';
    $rc = mkdir($path, 0777, true);
    if(!$rc)
    {
        //エラー処理
    }
    //メールアドレスを Base64(URL)エンコード
    $mail_hash = $this->Base64UrlEncode($mail);
    // 拡張子に .key を追加(なんとなくw)
    $fname =  $mail_hash . '.key';
    $filename = "$path/$fname";
    $rc = (0 < file_put_contents($filename, $auth_key));
    if($rc)
    {
        $rc = chmod($filename, 0666);
        if($rc)
        {
            // 認証キーをメールで送信(省略)
            $rc = $this->send_auth_key($mail, $auth_key);
        }
    }
}

// ランダムな認証キー生成
function create_auth_key($column = 6)
{
    $authKey = '';
    $digits = '0123456789';

    for ($i = 0; $i < $column; $i++) {
        $authKey .= $digits[rand(0, strlen($digits) - 1)];
    }

    return $authKey;
}

// Base64URL encoder
function Base64UrlEncode($data) { 
    return rtrim(strtr(base64_encode($data), '+/', '-_'), '='); 
} 
  
// Base64URL decoder
function Base64UrlDecode($data) { 
    // = 埋めしなくてもいける
    return base64_decode(str_pad(strtr($data, '-_', '+/'), strlen($data) % 4, '=', STR_PAD_RIGHT)); 
} 

メールアドレスを BASE64エンコードするのは、メールアドレスの中にファイル名に使用できない文字が含まれるかもしれないのを避けるためですね。
BASE64で利用される +と/ の記号は -と_ に変換することで、ファイル名として機能するようにしています。
あと、chmod 0666 しておくのは、apache と cron でアクセスしてくるユーザーが異なるためです。

一時ファイルを一定時間後に削除する、のはcron で以下のように設定しました。(例は3分ごとに実行してるので、少しタイムラグあります)

#crontab で認証キーの制限時間超えたファイルを消す
*/3 * * * * find /dev/shm/service/temp_auth/ -name '*.key' -mmin +30 -delete > /dev/null 2>&1

実際に認証キーとメールアドレスが送られてきたときの処理コードを入れておきます。

function checkAuthKey($mail, $auth_key)
{
    $rc = false;
    $error_message = '認証キーが異なっているか、または期限が切れています';
    $path = '/dev/shm/service/temp_auth';
    $mail_hash = $this->Base64UrlEncode($mail);
    $fname =  $mail_hash . '.key';
    $filename = "$path/$fname";
    $saved_key = false;
    if(file_exists($filename))
    {
        $saved_key = file_get_contents($filename);
    }
    if($saved_key)
    {
        $rc = (strcmp($auth_key, $saved_key) == 0);
    }
    return [$rc, $filename, $mail_hash, $error_message];
}

まとめ

今回はサーバー側で一時的な認証キーを生成してメールアドレスの確認と登録処理に至るプロセスで、Cookieやデータベースを使わない方法をまとめてみました
こういった需要って、よくあることだと思うので、参考にしてくださいまし。
実際、サクサク動いてくれるし間違いも起こりにくいので、安全に運用できています。
めでたしめでたし。

2023年7月17日月曜日

XServer で cakePHP のシェルを cron で動作させる

XSERVERでcakephpのcronががが

普通に cakePHP3 のプロジェクトで Shell のコントローラを呼び出せるようにしていたスクリプト
XSERVER上で動作させようとすると、動かないじゃん!
ということで、苦労の末、動くようになったので手順をまとめてみる。

PHPのパスを確認

$ pwd
$ ll bin/php
lrwxrwxrwx 1 USERNAME members 23  8月 16  2020 bin/php -> /opt/php-7.3.16/bin/php

cron では、bin/cake ではなく、bin/cake.php を動作させる。

XSERVER での CRON設定画面でいじっていたら、反映されなくなったので(なぜかな?
直接 crontab -e で編集しました。

* */10 * * * /opt/php-7.3.16/bin/php /home/USERNAME/ドメイン/cakephp/bin/cake.php シェルスクリプト > /dev/null 2>&1

まとめ

cronのエラー通知もあまりアテにならず、同じように起動させるシェルスクリプトを書いて動作させたら、requirements.php が PHPバージョンがダメ!みたいなエラーをはいてましたが、なぜ動かなかったのかは、詳しく調査してません。

ま、動くようになったので、めでたしめでたし。

2023年6月2日金曜日

Windows hosts ファイルを編集して名前解決させる。

hosts

Virtual Machine で開発することが圧倒的に多くなってきましたね。
本番環境と開発環境を分けるためにドメインの名前解決を一時的に変更することも多々あります。
そこで必要になるのが hosts の編集です。

hosts を編集して名前解決させるには

これが、なかなかめんどくさいw
1.メモ帳を管理者モードで起動
2.ファイルを開くで、右下のフィルタを「すべてのファイル」に変更。
3.C:\Windows\System32\drivers\etc\hosts を開いて編集。
4.保存。
5.コマンドプロンプトを開いて
6.C:\> ipconfig /flushdns

一日一回とかならまだいいけど、数回実施するとなるとめんどくさいね。
ということで、プログラム書いてみましたw

HostsEditor

今回は、Visual Studio 2022 Comunity の C# Windows Form .NET プロジェクトを作成しました。
Form1 には、保存終了用のツールボタンと、全画面に Dock した TextBox を配置します
TextBox のプロパティは、Multiline=true, MaxLength を大きめに。font も大きめに、程度かな。
Name は editText と付けておきました。

Form1.cs

プログラムは、こんな感じです。
hosts ファイルの位置を保持
全行を読み込んで TextBox に入れて編集可能にする
保存ボタンが押されたら、保存して、CMD.exe で ipconfig /flushdns を実行。

using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Data;
using System.Diagnostics;
using System.Drawing;
using System.IO;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.Windows.Forms;

namespace HostsEditor
{
    public partial class Form1 : Form
    {
        internal string HostsFileName { get; set; }
        public Form1()
        {
            InitializeComponent();
        }

        //初期表示
        private void Form1_Load(object sender, EventArgs e)
        {
            // ファイル名
            string system_folder = Environment.GetFolderPath(Environment.SpecialFolder.System);
            string hosts_path = Path.Combine(system_folder, "drivers", "etc");
            HostsFileName = Path.Combine(hosts_path, "hosts");

            System.IO.FileInfo fi = new System.IO.FileInfo(HostsFileName);
            if(4194304 < fi.Length)
            {
                MessageBox.Show("hosts ファイルが大きすぎます。他の手段で編集してください。",
                    "エラー", MessageBoxButtons.OK, MessageBoxIcon.Exclamation);
                Close();
            }
            // ファイルを 65,536 byte まで読み込む
            string[] lines = File.ReadAllLines(HostsFileName);
            string text = String.Join("\r\n", lines);
            editText.Text = text;
            // 全選択を解除
            editText.SelectionStart = 0;
        }

        private void closeClick(object sender, EventArgs e)
        {
            try
            {
                // 保存
                string text = editText.Text;
                using (StreamWriter streamWriter = new StreamWriter(HostsFileName))
                {
                    // Writeメソッドで文字列データを書き込む
                    streamWriter.Write(text);
                    // StreamWriterオブジェクトを閉じる
                    streamWriter.Close();
                }
                // ipconfig /flushdns を実行。完了まで待つ。
                System.Diagnostics.Process process = new System.Diagnostics.Process();
                //ComSpec(cmd.exe)のパスを取得して、FileNameプロパティに指定
                process.StartInfo.FileName = System.Environment.GetEnvironmentVariable("ComSpec");
                //出力を読み取れるようにする
                process.StartInfo.UseShellExecute = false;
                process.StartInfo.RedirectStandardOutput = true;
                process.StartInfo.RedirectStandardInput = false;
                //ウィンドウを表示しないようにする
                process.StartInfo.CreateNoWindow = true;
                //コマンドラインを指定("/c"は実行後閉じるために必要)
                process.StartInfo.Arguments = @"/c ipconfig /flushdns";
                //起動
                process.Start();
                //(親プロセス、子プロセスでブロック防止のため)
                process.WaitForExit();
                process.Close();
            }
            catch (Exception ex)
            {
                MessageBox.Show(ex.Message, "エラー", MessageBoxButtons.OK, MessageBoxIcon.Error);
            }
            // 画面を閉じる
            Close();
        }
    }
}

管理者として起動

プログラムを管理者として起動する必要があるので、Program.cs を編集します。
Main部分に以下の内容を追記します。

        static void Main(string[] args)
        {

            // 管理者権限に昇格させて自分自身を起動する
#if (!DEBUG)
            Thread.GetDomain().SetPrincipalPolicy(PrincipalPolicy.WindowsPrincipal);
            var pri = (WindowsPrincipal)Thread.CurrentPrincipal;
            //管理者権限以外での起動なら, 別プロセスで本アプリを起動する
            if (!pri.IsInRole(WindowsBuiltInRole.Administrator))
            {
                var proc = new ProcessStartInfo()
                {
                    WorkingDirectory = Environment.CurrentDirectory,
                    FileName = Assembly.GetEntryAssembly().Location,
                    Verb = "RunAs"
                };

                if (args.Length >= 1)
                    proc.Arguments = string.Join(" ", args);

                //別プロセスで本アプリを起動する
                Process.Start(proc);

                //現在プロセス終了
                return;
            }
            Application.EnableVisualStyles();
            Application.SetCompatibleTextRenderingDefault(false);
            Application.Run(new Form1());
        }
#endif

まとめ

DOBON.NET さんの記事を参照しました。
デバッグ時は、VisualStudio を管理者モードで起動する必要があります。
わりと、楽になりましたw
めでたしめでたし。

2023年5月9日火曜日

チェックボックスがセットされていなくても POST パラメータに含める方法

Form の checkbox が not checked でも POST する

Formの内容をPOSTする際に、セットされていないcheckboxの値が渡されないと困ってしまうことが多いですよね?
ね?ね?w

以下 jQuery を使った(checked じゃないと POST できない)POSTの例

//フォーム入力情報取得
var form_data = new FormData($('#form-name').get(0));
//サーバに渡す。
$.post({
  type: 'POST', 
  url: url,
  data: form_data,
  }).done(function(response){
  }
  //以下略
});

これだとチェックボックスがチェックされている部分だけが渡されてしまいます。
 データベースの boolean 値を保存しようとしても、OFF の場合に値が渡されずに更新されなくなってしまいます。

そこで、こんな関数を作って対応しました。

/**
* フォームの入力情報を取得する。
* checkbox で OFF 状態の情報も含める
*/
GetFormData = function(form_tag)
{
    var tag_data = $(form_tag).get(0);
    var form_data = new FormData(tag_data);
    $(form_tag + ' input:checkbox').each(function(){
      var id = $(this).attr('id');
      var checked = $(this).prop('checked');
      if(!checked)
      {
        // OFF のチェックボックス状態も form_data に含める。
        form_data.append(id, checked);
      }
    });
    return form_data;
}

//フォーム入力情報取得
var form_data = GetFormData('#form-name');
//サーバに渡す。
$.post({
  type: 'POST', 
  url: url,
  data: form_data,
  }).done(function(response){
  }
  //以下略
});

まとめ

みごと、bool 値を更新することができるようになりました。
めでたしめでたし。

つか、なんで FormData は OFF の状態を取り込まない仕様にしてるんだろう・・・
困った仕様ですよね。

2023年3月28日火曜日

PowerShell で Mark of The Web 属性を付加するとエラーになる?

Mark of The Web

Mark of The Web (略して MoTW)は Windows のファイル拡張属性で、インターネットからダウンロードされたファイルに特別なしるしを付けて警告を促せるようにするものです。
メールソフト用のプラグインを作っていますので、この属性を使用するのは至極当然のことで。
というわけで、弊社の製品にも実装しました。

問題に遭遇

例によって、いろいろな問題に遭遇したのですが、特に顕著な問題について、ここで報告しておきます。
C:\Temp フォルダに2つのテキストファイルを置きます。(中身はなんでもいいです)
ファイル名は Test_(1.txt, Test_(2.txt です。

まず、PowerShell を起動して(*0)、以下を実行してみます。

PS C:\Temp> Set-Content -Path "C:\Temp\test_(1.txt" -Stream Zone.Identifier -Encoding oem -NoNewline -Value "[ZoneTransfer]`r`nZoneId=3"

エクスプローラで、プロパティを参照すると、セキュリティ属性が付加されているのがわかります。

プログラムから呼び出してみる

次に、対象ファイルを Test_(2.txt に変えて、同じ動作をするプログラムを実装して、実行してみます。
使用したのは VisualStudio 2022, .NET 4.8 ターゲットの C# コンソールアプリケーションです。
using 行を端折ってますが、デフォルトのものでかまいません。

namespace MoTW
{
    class Program
    {

        static void Main(string[] args)
        {
            string file2 = @"C:\Temp\test_(2.txt";
            setMoTW_byPowerShell(file2);
            Console.ReadLine();
        }

        // set MoTW by PowerShell
        private static bool setMoTW_byPowerShell(string filename)
        {
            bool rc = false;
            string arg_format = "Set-Content -Path \"{0}\" -Stream Zone.Identifier -Encoding oem -NoNewline -Value \"[ZoneTransfer]`r`nZoneId=3\"";
            try
            {
                var psinfo = new ProcessStartInfo();
                psinfo.FileName = "powershell.exe";
                psinfo.UseShellExecute = true;
                psinfo.Arguments = string.Format(arg_format, filename);
                psinfo.CreateNoWindow = true;
                psinfo.WindowStyle = ProcessWindowStyle.Hidden;
                Process proc = Process.Start(psinfo);
                proc.WaitForExit();
                rc = (proc.ExitCode == 0);
            }
            catch (Exception e)
            {
                Console.WriteLine(e.Message);
                rc = false;
            }
            return rc;
        }
    }
}

Power Shell、普段使わないので、改行の指定方法が '`r`n' だということも今回調べましたよw
で、結果。エクスプローラで Test_(2.txt のプロパティを見るとですね。
MoTW 付いていないんですね、これが。
実際に作ったのは、アドインソフトウェアなので DLL ファイルなんですが、同じように MoTW 付いてくれなくて、本当に困ってしまいました。
ちなみに、ファイル名が Test1.txt, Test2.txt であれば、PowerShell も C# プログラムも正常に MoTW が付加されます。

つまり、'(' の文字が含まれていると、実行プログラムからの呼び出しではうまくいかないんですよ。

メールソフト用のアドインでは

1.添付ファイルをディスク上に保存する。
2.ShellExecuteEx を使って(*1)、保存したファイルを開く。
という動作を実装しているので、MoTW 属性のついていないファイルがそのまま開かれたり実行されてしまうんですね。
ZIP書庫を展開したファイルが実行ファイルだった場合、無条件に実行されてしまうことになってしまいます。
危険な状態です。
とても MoTW 対応です!とは言えませんねw

まとめ

結局、各種記号のいくつかが含まれるファイル名では失敗することがわかったので

1.ファイル名を Base64Url エンコードした名前に変更する。
2.PowerShell で Set-Content する(上記のコード)。
3.ファイル名を Base64Url デコードする。
という、本来やらなくてもいいような処理を実装することで問題を回避できました。
めでたしめでたし。(*2)

*0

MoTW を付加する方法には、「デバイスパス指定子」を使って FileStream を操作し Zone識別ファイルを作成する方法もあります。
わんくま同盟の掲示板で魔界の仮面弁士さんに教えていただきました。
この方法は、.NET 4.6.2 以上でうまくいくのですが、Outlook 用のアドインを .NET 4.6.2 以上で構築してもDLL内では .NET4.6.2以前の場合と同じようにエラーとなってしまうため、利用を断念しました。
Outlook がなにかやらかしてるのだと思います。

*1

保存したファイルを開く処理で、System.Diagnostics.Process を使って Start() すると、MoTW を無視して実行してしまいます。
んで、MoTW 属性も消されてしまいます。
しかたなく WIN32API の ShellExecuteEx で Mask 値を SEE_MASK_NOZONECHECKS がセットされないようにして呼び出すようにしました。

*2

「めでたしめでたし」じゃないんですよ。
多くのウィルスソフトやマルウェアなどがメールを介して感染しています。
弊社のプログラムはこれで問題を解決できましたが、同様の処理を実装しているメール用アドインなんかでは致命的です。

とっても致命的な問題だと思ったので、マイクロソフトに報告しておきました。