シェルスクリプトによるCGIのセッション管理

# シェルスクリプトでだってできるよ

私は普段から、シェルスクリプトでCGIスクリプトを組んでいるが、「えー、ウソでしょ。だってセッション管理とかできないでしょ？」と言われることがあり、ウソじゃないってという回答を兼ねてこのTipsを前々から書きたいと思っていた。

では早速、披露しよう。 **[動作デモ](http://lab-sakura.richlab.org/SESSION_MANAGER/session-manager-sample.cgi)** も用意したので、よろしければ試してもらいたい。

# ソースコード

```sh:session-manager-sample.cgi
#! /bin/sh
#
# シェルスクリプトでHTTPセッションを張るテスト
# ・このシェルスクリプトを動かすには、標準UNIXコマンド以外に下記のものが必要
#   - cgi-name (CGI変数を正規化する。Open usp Tukubaiコマンド)
#     https://github.com/usp-engineers-community/Open-usp-Tukubai/blob/master/COMMANDS.SH/cgi-name
#   - utconv   (UNIX時間との相互変換をする。拙作独自コマンド)
#     https://gist.github.com/richmikan/8703117
# ・セッションファイルは自動的には消されないので、一定時間たった古い物は
#   cron等を利用して別途削除すること。
#

# ===== ０.各種定義 ==================================================
#
Dir_SESSION='/PATH/TO/SESSION/FILE/DIRECTORY'  # セッションファイル置き場
SESSION_LIFETIME_MIN=3                         # セッションの有効期限(3分にしてみた)
COOKIE_LIFETIME_MIN=5                          # Cookieの有効期限(5分にしてみた)
#
# --- エラー終了関数定義 ---------------------------------------------
error500_exit() {
  cat <<-__HTTP_RESPONSE
	Status: 500 Internal Server Error
	Content-Type: text/plain

	500 Internal Server Error
	($@)
__HTTP_RESPONSE
  exit 1
}

# ===== １.セッションIDの取得とセッションファイルの操作  =============
#
# --- CookieからセッションIDを読み取る -------------------------------
session_id=$(printf '%s' "${HTTP_COOKIE:-}" | # 環境変数からCookie文字列取得
             sed 's/&/%26/g'                | # CGI変数仕様に合せるため,"&"を"%26"に置換
             sed 's/[;,[:blank:]]\{1,\}//g' | # Cookie変数の区切りを"&"に置換
             cgi-name                       | # 1行1変数化及び%エンコードを解除(独自cmd)
             grep '^session_id '            | # 変数名がsession_idの行を取り出す
             sed 's/^[^ ]* //'              ) # 変数名を除去し、中身だけ取り出す
#
# --- セッションIDの有効性検査 ---------------------------------------
session_status='new'                          # デフォルトは「要新規作成」とする
while :; do
  # --- セッションID文字列が正しい書式(英数字16文字とした)でないならNG
  if ! printf '%s' "$session_id" | grep '^[A-Za-z0-9]\{16\}$' >/dev/null; then
    break
  fi
  # --- セッションID文字列で指定されたファイルが存在しないならNG
  [ -f "$Dir_SESSION/$session_id" ] || break
  # --- ファイルが存在しても古すぎだったらNG
  if ! find "$Dir_SESSION" -name "$session_id" -mmin +$SESSION_LIFETIME_MIN |
       awk 'END{if(NR>0){exit 1;}}'
  then
    session_status='expired'
    break
  fi
  # --- これらの検査に全て合格したら使う
  #session_status='exist'                 # ←そのまま使う場合
  session_status='exist_but_update_id'    # ←セッションIDを付け直す場合
  break
done
#
# --- セッションIDに紐づくファイルの操作 -----------------------------
#
if   [ $sessionfile = 'exist' ]; then
  # --- (a)sessionファイル再利用
  File_session=$Dir_SESSION/$session_id
  touch "$File_session" # タイムスタンプを更新しておく
  #
elif [ $session_status = 'exist_but_update_id' ]; then
  # --- (b)sessionファイルのID付け直し
  File_session=$(mktemp $Dir_SESSION/XXXXXXXXXXXXXXXX)
  [ $? -eq 0 ] || error500_exit 'cannot create session file'
  mv -f "$Dir_SESSION/$session_id" "$File_session"
  session_id=${File_session##*/}
  #
else
  # --- (c)sessionファイル新規作成
  File_session=$(mktemp $Dir_SESSION/XXXXXXXXXXXXXXXX)
  [ $? -eq 0 ] || error500_exit 'cannot create session file'
  session_id=${File_session##*/}
fi


# ===== ２.メイン:セッションファイルの読み書き =======================
#
# --- 読み込み -------------------------------------------------------
msg=$(cat "$File_session")
if   [ \( -z "$msg" \) -a \( "$session_status" = 'new'     \) ]; then
  msg="はじめまして! セッションを作りました。(ID=$session_id)"
elif [ \( -z "$msg" \) -a \( "$session_status" = 'expired' \) ]; then
  msg="すみません。セッションの有効期限が切れたので、作り直しました。(ID=$session_id)"
fi
#
# --- 書き込み -------------------------------------------------------
printf '最終訪問日時は、%04d年%02d月%02d日%02d時%02d分%02d秒です。(ID=%s)' \
       $(date '+%Y %m %d %H %M %S') "$session_id"                          \
       > "$File_session"


# ===== ３.送信用セッションID文字列(Cookie)生成 ======================
#
# --- Cookieの有効期限を設定する -------------------------------------
expire=$(TZ=UTC+0 date +%Y%m%d%H%M%S                | # 現在日時取得
         TZ=UTC+0 utconv                            | # UNIX時間に変換(独自cmd)
         awk '{print $1+'$COOKIE_LIFETIME_MIN'*60}' | # 有効期限
         TZ=UTC+0 utconv -r                         | # UNIX時間から逆変換(独自cmd)
         awk '{                                     #   RFC850形式に変換
           split("Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec",monthname);
           split("Sun Mon Tue Wed Thu Fri Sat",weekname);
           Y = substr($0, 1,4)*1; M = substr($0, 5,2)*1; D = substr($0, 7,2)*1;
           h = substr($0, 9,2)*1; m = substr($0,11,2)*1; s = substr($0,13,2)*1;
           Y2 = (M<3) ? Y-1 : Y; M2 = (M<3)? M+12 : M;
           w = (Y2+int(Y2/4)-int(Y2/100)+int(Y2/400)+int((M2*13+8)/5)+D)%7;
           printf("%s, %02d-%s-%04d %02d:%02d:%02d GMT\n",
                  weekname[w+1], D, monthname[M], Y, h, m, s);
         }'                          )
#
# --- Cookieの有効なパスを設定する -----------------------------------
mypath='/'           # これは例なので実際はちゃんとしたものを設定すること
#
# --- このCGIが動いているのはHTTPか、それともHTTPSか -----------------
case "${HTTPS:-off}" in [Oo][Nn]) secure='; secure';; *) secure='';; esac
#
# --- Cookie文字列生成 -----------------------------------------------
cookie_sid=$(printf '\nSet-Cookie: session_id=%s; expires=%s; path=%s%s' \
                    "$session_id" "$expire" "$mypath" "$secure"          )


# ===== ４.クライアントへ送信 ========================================
#
cat <<-HTTP_RESPONSE
	Content-type: text/plain; charset=utf-8$cookie_sid

	$msg
HTTP_RESPONSE


# ===== ９９.正常終了 ================================================
exit 0
```

# コードの解説

セッション管理をするにあたっては大きく分けて4つの工程を行うことになる。1つ1つ見ていこう。

## 1.セッションIDの取得

既にセッションが張られており、クライアントがセッションIDを申告してきた場合は、その有効性を確認したうえで再利用し、無効であったならば新規作成をするというのが最初にやるべきことである。

### 1.1.Cookieからの読み取り

クライアントからセッションIDを受け取る手段はCookieを使うのが一般的だと思うが、シェルスクリプトでCookieを読み取る方法については[シェルスクリプトおばさん(43)の手づくりCookie](http://qiita.com/richmikan@github/items/fd4e75f54238182de717)というTipsで解説しているので詳しくはそちらを参照してもらいたい。ただし、あちらではシェル変数など使わずに一時ファイルを作るように言っていたのにこちらではそれをやっている。理由は、Cookieで受け取っているのが変数一個でそれもたった16文字のセッションID文字列に過ぎないからだ。一時ファイルを使うか、シェル変数で済ませるかは臨機応変に。

### 1.2.正当性確認

そして、セッションID文字列を受け取ったら頭ごなしに信用せず、正当性確認をすること。書式はあっているか、それに紐づくファイル（セッションファイル）は存在するか、そのファイルは古すぎないか、という3点を確かめる。 **ファイルが古すぎないかどうかの確認にはfindコマンドの-mminオプション** が便利だ。

### 1.3.セッションファイルの作成

セッションIDが有効であると確認したらそのまま使ってもいいが（ただし有効期限延長のためtouchコマンド等でタイムスタンプを更新すること）、セキュリティー向上のため、サンプルではIDを付け直すようにしてみた。そのため、セッションの新規作成と同様にファイルを作り直している。 **セッションファイルを作る時はmktempコマンドを使えばいい。** ランダムな文字列を作ってくれるので便利だ。（ただし、CentOS 5のmktempコマンドは、文字列のランダム性の品質が甘いと指摘あり）

## 2.セッションファイルの読み書き

セッションファイルの名前は前工程で確定させたのだから、ここはそのファイルに対して好きなようにやればよい。例えば、ログイン中かどうかを覚えさせるならログインIDを覚えさせておけばよいし、ショッピングカートを作ろうとしているなら買い物カゴの中身を覚えさせておけばよい。

## 3.送信用セッションID文字列(Cookie)生成

今操作したセッションIDを、HTTPレスポンスを送るタイミングでクライアントに教えなければならない。Cookieで受け渡しすると決めたのだから、ここでは送信用のCookie文字列を作ることになる。

Cookie文字列の作り方ということで、ここも[シェルスクリプトおばさん(43)の手づくりCookie](http://qiita.com/richmikan@github/items/fd4e75f54238182de717)のTipsで解説したとおりである。

自分で言うのもなんだが、RFC850形式の日付フォーマット変換をやっているAWKの記述部分がゴチャゴチャしているので、ここもコマンド化して独立させ、スッキリした見た目にしたいところだよなぁ……。

## 4.クライアントへ送信

先ほどつくったCookie文字列を、HTTPヘッダーを送出するタイミングにあわせて送信すればおしまい。ヒアドキュメントを使えば簡単だ。

# コマンドを駆使する面白さが、シェルプログラミングの面白さ

いかがだったろうか？シェルスクリプトでできるということはおわかりいただけたと思うが、わかったらわかったで今度は **「なぜわざわざシェルスクリプトなんて使うの？」** と思うかもしれない。

その理由の半分くらいは、 **コマンドを駆使する面白さ** だと自分では思っている。「セッション管理と」いうお題に対して、「あ、mktemp使えばセッションIDも発行できるし、ファイルも作れる」とひらめいたり、「あ、findコマンド使えば有効期限切れも簡単に調べられる」とひらめいたり……。そこが魅力なんだよなぁ、と。

あとの半分は実用性。上から下へ素直に読めるソースコードを書くのに向いていること。いくらgoto文を排しても、関数を多用してあっちこっち目線を移動させるようじゃあまり意味無い。シェルスクリプトの場合、パイプを繋げて処理をこなすことを意識しながら組んでいるとだいたいそうなる。おまけに自動的に並列プログラミングをしていることにもなるし。パイプを発明した人はたぶん天才なんだろうな。