ユーザ認証機構の付加

これまで作成した写真日記システムは、誰でも書き込みできるものだった。 これにユーザ認証機能を追加して、 特定のユーザのみに書き込みを許すようにしてみよう。

認証つきページの流れ

CGIスクリプトの提供するWebページに到達する前に、 そのアクセスがあらかじめ登録されたユーザからのものであるかを 判定するページが必要となる。その流れは以下のようになる。

ページA

ログインフォーム

ユーザ名

パスワード

ページB

認証の必要な

処理

ページC

認証の必要な

処理

認証はまず入力フォームからユーザ認証情報を 入力してもらって開始する。しかし、認証情報が伝わるのはフォームでの 入力直後のページBだけであり、何も工夫しなければページCには伝わらない。 これはHTTPのページ遷移が接続状態を維持しない(ステートレス)であるためである。 認証成功したことをその後のページ表示に伝えたいのであれば、 認証成功の証となるなんらかの情報をWebブラウザとのやりとりに 付加しなければならない。

一般的には、認証が成功したという情報はCookie 機構を利用してブラウザとサーバ間で共有する。 これ以降の節ではパスワード認証の基本的な考え方と、 認証に成功したという情報のやりとりを Cookie を利用して行なう方法を順を追って説明する。

ユーザ認証機能導入に関する前提

以下では「ユーザ」と「パスワード」の組み合わせを基本とした Web 上での認証機構を取り扱う。Web はネットワークを介するものであるため、 その通信路の安全性も考慮する必要がある。通信路でのパスワード盗聴可能性の 有無によって設計自体も変えなければならないが、「有」で設計する場合は アプリケーション自身が暗号化処理などに関知する必要があり、 それはシェルスクリプトに適した範囲から逸脱する。

ここでは、Webフォームに入力したパスワードが送信される過程において、 以下のいずれかが満たされているものと仮定する。

これらの前提が置けるなら、作成する CGI プログラムの入出力の根本的しくみは変えずに済む。 昨今では OpenVPN など機種を問わず使えるものもあり VPN は誰でも利用できるものになった。また、HTTPSに関しては、 無料でSSL証明書が入手できるサービスがあるので、 それらを試してみるのもよいだろう。

ユーザの概念の導入

Webアプリケーションの特定の操作の入口に、 ユーザ名とパスワード入力を求めるページを設け、 正しい組み合わせを入力した場合のみその操作ができるようにする。 このために、ユーザに関する情報を格納するテーブルを設計する。

ユーザ格納テーブル users
カラム用途
emailTEXTユーザ名(emailアドレスと兼用)
pswdTEXTパスワード
gecosTEXT名前などユーザに関する付加情報

ユーザに重複は許されないことを考慮して、テーブルは以下のように作成する。

CREATE TABLE users(
  email PRIMARY KEY, pswd TEXT NOT NULL, gecos,
  CHECK(pswd != '')
);

ユーザ名とみなす email カラムは PRIMARY KEY とする。また、 パスワード未設定での登録を許さないことを条件とするため、 pswd カラムには NOT NULL 制約によってNULLのまま挿入できないよう、また CHECK 制約 によって空文字列を入れられないようにした。

認証の仕組み

「ユーザ名」と「パスワード」対の認証方式の基本的な仕組みは、 Unixパスワード認証の方式を参考にする。

Unixパスワード

ユーザ名と、それに対応するパスワードは システムの決められたファイルに格納されている。 ファイルに格納するパスワードは、 平文(ひらぶん)のままではファイルが流出したときに すべて判明してしまう。このため、パスワードを暗号化したものが ファイルに書き込まれている。例として分かりやすいBSD系システムの /etc/master.passwd ファイルの例を見てみよう。

hanako:$2a$10$PECyT/1jC4VzjXnqRvauM.zQOTzgYbxHLbKfHnq5uWX/MF0XBPkPe:
1010:100::0:0:IIMORI Hanako,,,:/home/hanako:/bin/sh

1行が長いので「の所で折り返している。BSDのパスワードファイルは コロン区切りの1行1レコード形式で、フィールドの順は ユーザ名、パスワード、UID、GID、ログインクラス、 パスワード期限、アカウント期限、GECOS、ホームディレクトリ、シェル となっている。

第2フィールドの「パスワード」は実際にはなんらかのハッシュ関数で 符号化されたものであり、通常これは元のパスワードに戻せない。 パスワードフィールドはcrypt(3) 関数で符号化された以下の書式のものとなっている。

$ID$ソルト$文字列

IDの部分は使用するアルゴリズムに対応する文字列である。 ここでは、この書式を真似て独自仕様のIDによる符号化手順 を用いることにする。符号化アルゴリズムは幅広いシステムで利用可能な SHA256 を利用する。

ソルト とは、元のパスワードにランダムな文字列を付加することで、 符号化後の文字列のバラツキを大きくするためのものである。 ソルトなしでは符号化後の文字列で元の文字列が容易に判明することがある。 たとえば、いかにも安易なパスワード「abcd1234」を SHA256 で符号化する場合を考える。

printf abcd1234 | openssl sha256
(stdin)= e9cee71ab932fde863338d08be4de9dfe39ea049bdafb342ce659ec5450b69ae

"e9ce..." 以降の文字列が符号化後の文字列である。 もし、これをパスワードファイルに保存していたとして 万が一そのファイルが悪者の手に渡ったとする。 悪者が手元に安易なパスワードを集めて、それらを前もって 符号化した一覧表を持っていれば、 "e9ce..." で始まる上記の文字列は "abcd1234" から得られたものだと逆引きできる。 さて、ランダムな文字列をソルトとして "abcd1234" に付加して符号化してみる。

printf foo-abcd1234 | openssl sha256
(stdin)= f10a6c6053b82d1a4ec0ed71ccb604ecc53fd45bd17489da33c158bb9944228b
printf food-abcd1234 | openssl sha256
(stdin)= 28db065b2ae12f8225769ea3f36e46cb6f352dc1a251e96f170dd2d2244c8338
printf foot-abcd1234 | openssl sha256
(stdin)= 03ee85a72a5fb9f22b118157f62f5d4bcc4ecb0979af1d904576550c4cb5b25d

この例を見ると分かるように、似たような文字列を余分に付けただけで 符号化後の文字列は似ても似つかないものになる。この性質を利用して、 パスワードを保存する部分を以下のようにしてみる("s256" の部分は独自に定めたもの)。

$s256$foo-$f10a6c6053b82d1a4ec0ed71ccb604ecc53fd45bd17489da33c158bb9944228b

ソルトを$ではさまれた2番目のフィールドに配置し、 元のパスワードに "foo-" を付加して符号化したものであることを示す。 すべてのパスワードに対して別々のソルトを足してから符号化することで、 一覧表から逆引きするという作戦は使えなくなる。 この例では説明のために短いソルトを用いたが、 乱数を用いて容易に推測できない長いソルトを付けておけば、 逆引きパスワード辞書との偶然の一致の可能性も無視できる程度に下がる。

mycrypt関数によるパスワード判定処理

以上の符号化操作とソルトの抽出処理を行なうシェル関数 mycrypt() を以下のように作成する。

s256() {
  openssl sha256 "$@" | cut -d' ' -f2
}
mycrypt() (			# crypt(3)関数的挙動をsha256で行なう
  key=$1 salt=$2		# $s256$ソルト$ハッシュ化文字列
  case $2 in
    '$'*'$'*)   salt=${salt#\$s256\$}
		salt=${salt%\$*} ;;
  esac
  echo -n '$s256$'"$salt"'$'
  echo "$salt$key" | s256
)

シェル関数 mycrypt() は以下のように使用する。

mycrypt キー ソルト

ソルト を利用して符号化した文字列を標準出力に返す。 上記の2つのシェル関数を定義したという前提で利用例を示す(実際の出力は 1行だがのところで折り返している)。

mycrypt naishodayo SaltShioGoma
$s256$SaltShioGoma$3389417f04f664fc3a163a5116875f030
4eca3077cf4c2fe55ce1759648a9b21

mycrypt に渡すソルトには、符号化後の文字列そのものを 渡してもよい設計になっている。これは crypt(3) 関数にならった仕様である。

mycrypt naishodayo \
'$s256$SaltShioGoma$3389417f04f664fc3a163a5116875f03
04eca3077cf4c2fe55ce1759648a9b21'
$s256$SaltShioGoma$3389417f04f664fc3a163a5116875f030
4eca3077cf4c2fe55ce1759648a9b21

この関数を用いての認証時のパスワード判定処理は以下の流れになる。

  1. 利用者の入力したパスワード(pswd)を受け取る。

  2. データベースからその利用者の符号化後の文字列(enc)を得る。

  3. mycrypt $pswd $enc によって得られた結果と enc を比較し、同じなら正しいパスワードと判定する。

パスワードとセッションキー

ユーザ認証には利用者にパスワードを入力させなければならないがすべての Web ページへのアクセス時にパスワード入力を求めるわけではなく、 一度認証が済んだら、認証済みと認定する印を利用者のブラウザに覚えさせ、 以後はそれを自動送信してもらうようにする。HTTPでは Cookie がその目的に利用できる。

「認証済み」の印には、 認証判定された利用者(のブラウザ)しか知りえない予測不能な文字列を設定する。 これをセッションキーとしてユーザ特定の目的に利用する。 セッションキーの生成方法と Cookie への設定方法を順に説明する。

セッションキーの生成

セッションキーには十分なバラツキを持ち他人には容易に推測不能であろう乱数を利用する。 本稿の用途では、/dev/urandom デバイスからの出力を利用する。 /dev/urandom は擬似乱数をバイナリデータで生成するデバイスファイルで、 ここから必要なバイト数を読み取り、ASCII 文字の範囲に射影することで セッションキーを生成する。ASCII 文字への射影はなんらかの符号化アルゴリズムを 利用すればよく、ここでは Base64 を利用する。Base64 符号化を行なうコマンドは各OSに概ね標準装備されているが、 起動方法がまちまちであるため、本稿で既に利用している nkf を利用したものを示す。

randomstr()

randomstr() {			# $1=columns (default: 10)
  dd if=/dev/urandom count=1 2>/dev/null | nkf -wMB \
      | tr -d '+\n' | fold -w${1:-10} | sed -n 2p
}

この関数は引数に必要とするランダム文字列の長さ(省略時10)を受け取る。 dd コマンドにより /dev/urandom デバイスから1ブロック(512バイト)読み出したものを nkf で Base64 化し、改行と + 記号を取り除く。+ 記号は Cookie の値受け渡しで特別な意味を持つので除く。 取り除いて改行のなくなったものをさらに fold コマンドで指定した桁数に折り畳み、sed により先頭から2行目を取り出している。 指定長が長すぎる場合のエラー処理が必要だがここでは省略する。

sqlite3 の randomblob() 関数と hex() 関数の組み合わせででランダム文字列を生成できる。 16進数文字列で手軽に済ませたいなら

"SELECT lower(hex(randomblob(20)));" | sqlite3

で、40桁の乱数16進文字列が得られる。

ここで定義したシェル関数 randomstr は、ユーザのパスワードの初期値生成にも利用する。

Cookieへのセッションキー値の設定

認証つきページの流れ」にある図を再確認する。 利用者の入力したパスワードをCGIスクリプトが受け取るのは「ページ B」を出力するセッションである。このページのHTTPヘッダを出力するタイミングで ブラウザへの Cookie 設定を注入する。したがって、設定した Cookie がブラウザから送られて来るのは「ページ C」のアクセス要求のときが初めてとなる。

「ページ B」へのアクセスに応えるときに、 パスワードを受け取り認証に成功したらすぐに Cookie 設定のためのヘッダ出力を行なう。Cookie 設定は以下の構文を用いる。

Set-Cookie: クッキー名=クッキー値; Max-Age=秒数

たとえば「有効期限1時間で、sesskey=abcxyzblahblahblah のクッキー」を設定したかったら以下のようなヘッダ行を送出する。

Set-Cookie: sesskey=abcxyzblahblahblah; Max-Age=3600

Max-Age には、このクッキーの最長の存続期間を秒で指定する。 ただし、未対応のブラウザもあれば、ブラウザがそのとおりにしないこともある。 ブラウザへの指定だけでなく、CGI スクリプトでデータベースに同じ値を保存するときにも制限時間を設定しておき、 指定期間を越えた利用が発生しないように注意する。

なお、Cookieに関するその他の情報は RFC 6265 に記載されている。

セッションキーへのデータベースへの保存

有効期間つきのデータは既に「受信データの値の保存」で導入したスキーマ tags と cgiparams の組み合わせで実現している。tags テーブルに有効期限つきの文字列を id として登録しておき、tags.id を外部キー制約に持つテーブルを作成して 同じようにセッションキーの有効期間を管理する。

セッションキー格納テーブル sessions
カラム用途
userTEXTユーザ名
sesskeyTEXTセッションキー

このテーブルに外部キー制約を付けてテーブル作成する。

CREATE TABLE sessions(
  user PRIMARY KEY TEXT, sesskey TEXT,
  FOREIGN KEY(user) REFERENCES users(email)
         ON DELETE CASCADE ON UPDATE CASCADE,
  FOREIGN KEY(sesskey) REFERENCES tags(id)
         ON DELETE CASCADE ON UPDATE CASCADE
);

有効期限つきタグ」 で述べたように tags テーブルに登録した id のうち期限を過ぎたものを消す処理は、 今回利用する cgilib2 の storeparam 関数で行なっている。id 削除のタイミングで sessions テーブルに登録された sesskey も削除されるため、有効期限を過ぎたセッションキーは残存しない。

当然のことだが、SQLインジェクションで sessions テーブルが参照されたり、HTTP 経由などでデータベースファイルが取得されたら元も子もない。 SQLのクォートとhttpdの設定には十分注意する。

ユーザ認証に関るライブラリの作成

これまで述べたものを総合的に受け持つライブラリ cgiauthlib-sh を以下のように作成する。

cgiauthlib-sh

#!/usr/bin/head -5
# -*- mode: shell-script -*-
# CGI Authentication Library for Shell Script
# Use this by source'ing.
# . ./cgiauthlib-sh
newpswd="wasureta"

type query >/dev/null 2>&1 || . ./cgilib2-sh	# query()未定義ならロードする

loginlink() {
  cat<<EOF
<p class="login"><a href="$myname?login">Login</a></p>
EOF
}
loginform() {
  htmlhead "Login"
  cat<<EOF
<form action="$myname" method="POST" enctype="multipart/form-data">
 <table>
  <tr><td>ユーザ</td><td><input name="user"></td></tr>
  <tr><td>パスワード</td><td><input name="pswd" type="password"></td></tr>
 </table>
 <input type="submit" value="Login">
 <input type="reset" value="reset">
</form>
EOF
}
s256() {			# SHA256を探すがそれ以外でもよい
  if [ -z "$_sha256" ]; then
    if [ -x /bin/digest ]; then			# Solaris10
      _sha256="/bin/digest -a sha256"
    elif type openssl >/dev/null 2>&1; then	# Maybe Linux
      _sha256() {
	openssl sha256 $* | cut -d' ' -f2
      } ; _sha256=_sha256
    else
      echo "Abort(sha256)."; exit 255		# 見付からなければ中止
    fi
  fi
  $_sha256 "$@"
}
mycrypt() (			# crypt(3)関数的挙動をsha256で行なう
  key=$1 salt=$2		# $s256$ソルト$ハッシュ化文字列
  case $2 in
    '$'*'$'*)   salt=${salt#\$s256\$}
		salt=${salt%\$*} ;;
  esac
  echo -n '$s256$'"$salt"'$'
  echo "$salt$key" | s256
)
randomstr() {			# $1=columns (default: 10, should be <512)
  dd if=/dev/urandom count=1 2>/dev/null | nkf -wMB \
      | tr -d '+\n' | fold -w${1:-10} | sed -n 2p
}
mypwhash() {			# パスワードハッシュは標準入力から
  mycrypt `cat` `randomstr`
}
getcookie() {			# 指定したcookie値を得る
  _tag=cookie/$_tag getpar "$1"
}
postmail() {	# $1=rcpts $2=subject ($3=file)
  rcpts=`echo $1`
  subj=`echo $2|nkf -jM`
  (cat<<EOF; nkf -j $3) | sendmail -t
To: `echo $rcpts|sed 's/ /, /g'`
Subject: $subj
Date: `date`

EOF
}
wasureta() {			# $userの新パスワードを発行
  newpswd=`randomstr`
  encoded=`echo "$newpswd"|mypwhash`
  local=${user%@*}		# ローカルパート
  domain=${user##*@}		# ドメインパート
  if ! host $domain >/dev/null 2>&1; then
    echo "Invalid domain"; exit 1	# DNSの索けないドメインは無効終了
  fi
  dbuser=`query "SELECT email FROM users WHERE email='$user';"`
  if [ -z "$dbuser" ]; then	# 新規登録
    query "INSERT INTO users VALUES('$user', '$encoded', NULL, NULL);"
  else
    query "UPDATE users SET pswd='$encoded' WHERE email='$user';"
  fi
  postmail "$user" "New Password for miniblog3" <<EOF
新しいパスワードを設定しました。
ユーザ名:	$user
パスワード:	$newpswd
EOF
  return 0
}
setcookie() (
  for kv; do
    echo "Set-Cookie: $kv; Max-Age=3600"
  done
)
cgiauth() {	# OKならグローバル変数 _session をセットして return 0
  query<<-EOF
	CREATE TABLE IF NOT EXISTS users(
	  email PRIMARY KEY, pswd TEXT NOT NULL, gecos
	  CHECK(pswd != '')
	);
	CREATE TABLE IF NOT EXISTS users_m(
	  email, key, val,
	  FOREIGN KEY(email) REFERENCES users(email)
		 ON DELETE CASCADE ON UPDATE CASCADE,
	  UNIQUE(email, key, val)
	);
	CREATE TABLE IF NOT EXISTS sessions(
	  user PRIMARY KEY TEXT, sesskey TEXT,
	  FOREIGN KEY(user) REFERENCES users(email)
		 ON DELETE CASCADE ON UPDATE CASCADE,
	  FOREIGN KEY(sesskey) REFERENCES tags(id)
		 ON DELETE CASCADE ON UPDATE CASCADE
	);
	EOF
  user=`getpar user`
  user=${user:-`getcookie user`}	# フォーム値がなければcookie値取得
  user=`echo "$user" | sed "s/'/''/g"`	# SQLへ渡す変数なので ' をエスケープ
  pswd=`getpar pswd`			# input要素からのpswd
  skey=`getcookie skey`			# セッションキーはcookieから
  expsql="datetime('now', 'localtime', '$_exp')"
  [ -z "$user" ] && return 1		# user未指定なら終了
  if [ -n "$pswd" ]; then		# pswdを入力した場合
    if [ x"$pswd" = x"wasureta" ]; then	# パスワードリセット
      wasureta $user
      return 99
    fi
    dbpswd=`query "SELECT pswd FROM users WHERE email='$user';"`
    fromweb=`mycrypt "$pswd" "$dbpswd"`	# 送信されたパスワードからのハッシュ
    if [ x"$fromweb" = x"$dbpswd" ]; then
      _session=`randomstr 50`		# 新しいセッションキー生成
      query<<-EOF
	REPLACE INTO tags VALUES('$_session', $expsql);
	REPLACE INTO sessions VALUES('$user', '$_session');
	EOF
      setcookie "user=$user" "skey=$_session"
      return 0				# 認証OK
    fi
  fi
  if [ -n "$skey" ]; then		# Cookieでセッションキーが来た場合
    dbskey=`query "SELECT sesskey FROM sessions WHERE user='$user';"`
    if [ x"$skey" = x"$dbskey" ]; then	# 送って来たセッションキーと同じならOK
      _session=$skey
      query "UPDATE tags SET expire=$expsql WHERE id='$skey';" # 期限更新
      setcookie "user=$user" "skey=$_session"
      return 0				# セッションキー確認OK
    fi
  fi
  return 1
}
_tag=cookie/$_tag storeparam "`echo $HTTP_COOKIE|sed 's/[;, ]/\&/g'`"

重要な関数の動きについていくつか説明する。

ユーザ作成機能(簡略版)

「ユーザ」の概念を導入するため、ユーザ登録する機能も必要となる。 本稿では、信頼できるユーザのみがアクセスする環境で用いることを前提とし、 メイルアドレスの入力によって新規ユーザを作成する関数を作る。 このときの方針として「忘れた機能」を追加する。

パスワード認証を持つシステムの場合、 パスワードを忘れたユーザへの対処も考慮する必要がある。 この場合のパスワード再発行機能と、新規ユーザの初期パスワード設定機能を 共通化することで簡略な設計が可能となる。 新規ユーザ作成、ユーザのパスワードリセットいずれの場合でも パスワードに「wasureta」と入力した場合にランダムパスワードを付けて ユーザ名に指定したアドレスに送信するものとする。

メイル送信関数

パスワードリセット時など、特定のアドレスにメイル送信する場合、 シェルスクリプトからは sendmail コマンドを利用するのが確実で効率的である。 日本語を含むメッセージを送信できるように定義したシェル関数が postmail である。この関数は2つ、または3つの引数を取る。

postmail 宛先アドレス サブジェクト [ファイル]

postmail 関数中、宛先を処理する部分が以下のものである。

  rcpts=`echo $1`

宛先は 'foo@example.net bar@example.org' のように、クォートして 複数のアドレスを指定できる。このとき、クォート内に改行を含む場合でも 処理できるよういったん echo コマンドに単語群を渡して改行を取ってもらう。 以下の挙動を見ると分かりやすい。 改行を取り除く処理

m="foo
bar   baz"
echo $m
foo bar baz
: ↑foo, bar, baz という3引数を出力している(改行も単なる引数区切り)
echo "$m"
foo
bar   baz
: ↑ダブルクォートで単一引数化され改行も引数内の文字列として処理
echo foo \
bar
foo bar baz
: ↑エスケープされた改行も単なる引数区切りとして働く

複数の単語を含む値をクォートなしでコマンドに渡した場合は、 すべての空白文字(SPC、TAB、LF)は単なる引数区切りとして扱われる。 これを利用して改行除去を行なっている。続いて、Subject ヘッダを作成する部分を示す。

  subj=`echo $2|nkf -jM`

$2で受けた Subject 文字列を、nkf コマンドでJIS化してさらに MIME ヘッダ化する。nkf は長すぎる文字列をメイルヘッダに適した長さに区切り 改行+スペースを挿入する。

続いて、本文を送信する部分

  (cat<<EOF; nkf -j $3) | sendmail -t

ここでは $3 に指定したファイル、または未指定の場合の標準入力を JIS 化し、ヒアドキュメントに記述したヘッダのあとに sendmail コマンドに送っている。sendmail の -t オプションは入力中に現れる To: ヘッダにしたがった宛先に送信する。

認証機能の組み込み

先に作成した miniblog2 に認証機構を組み込みたい。miniblog2 の機能はソースの末尾に集約されている。

putform				# ヘッダと入力フォームを出力
addnew				# 新規レコードの登録
[ -n "$editkey" ] || putblog	# データ更新後の登録エントリを出力

大別して3つの機能を持ち、上記3行はそれぞれ、

  1. 新規エントリ入力のためのフォーム出力
  2. フォーム送信されたデータの新規登録
  3. 既存エントリの出力

の働きをしている。このうち、書き込みに関る上記2つについては ログイン認証を通過した利用者のみに提供するように変更する。 大まかな流れとしては以下のようにする。

認証処理をする
if 認証成功; then
  putform			# (1)ヘッダと入力フォームを出力
  addnew			# (2)新規レコードの登録
else
  ログインページへのリンクだけ出力
fi
[ -n "$editkey" ] || putblog	# (3)データ更新後の登録エントリを出力
postmail() {	# $1=rcpts $2=subject ($3=file)
  rcpts=`echo $1`
  subj=`echo $2|nkf -jM|sed '2,$s/^/ /'`
  (cat<<EOF; nkf -j $3) | sendmail -t
To: `echo $rcpts|sed 's/ /, /g'`
Subject: $subj
Date: `date`
Content-type: text/plain; charset=iso-2022-jp
MIME-Version: 1.0

EOF
}

以上をまとめて、認証機能を付加した一言日記管理システム miniblog3.cgi を示す。

miniblog3.cgi

#!/bin/sh
cd `dirname $0`
. ./cgilib2-sh
myname=`basename $0`

query "CREATE TABLE IF NOT EXISTS blog(
	datetime UNIQUE, body, filename, filecontent);"

case "$1" in
  catfile/*)	# miniblog2.cgi catfile/《rowid》で起動するとその行の画像を出力
    rowid=`echo "${1#catfile/}" | tr -cd '[0-9]'`	# 数字以外は除去
    from="FROM blog WHERE rowid=$rowid"			# FROM句以下を変数に
    echo -n "Content-type: "	# 中味を調べて Content-type ヘッダ出力
    query "SELECT hex(filecontent) $from" | unhexize \
	| file --mime-type - | cut -d' ' -f2
    fn=`query "SELECT filename $from;"| tr -d '\n'`	# 改行あれば除去
    echo "Content-Disposition: filename=\"$fn\""	# ファイル名と長さ出力
    query "SELECT 'Content-Length: ' || length(filecontent) $from;"
    echo ""
    query "SELECT hex(filecontent) $from" | unhexize # 画像を書き出して終了
    exit ;;
  edit/*)	# miniblog2.cgi edit/《日時》で起動すると修正フォーム出力
    editkey=`echo ${1#edit/}|pdecode` ;;	# pdecode不要なhttpdもある
  # 【1】
  login)
    . ./cgiauthlib-sh
    loginform
    exit ;;
esac

# $1に指定したカラムを$2の日付キーから得る関数
getcol() {			# HTMLエスケープしたカラム値を返す
  escape "`query \"SELECT $1 FROM blog WHERE datetime='$2';\"`"
}
putform() {			# HTTPヘッダから入力フォームHTMLまで出力
  title="一言日記"
  if [ -n "$editkey" ]; then
    datetime=`getcol datetime "$editkey"`	# 実在するキーか?
    if [ -n "$datetime" ]; then
      hitokoto=`getcol body "$editkey"`	# 既存のbodyカラム値を得る
      hidden="<p><input type=\"hidden\" name=\"datetime\"
	value=\"$datetime\">
	<input id=\"rm\" type=\"checkbox\" name=\"remove\" value=\"yes\">
	<label for=\"rm\">このエントリの削除</label>
	<span class=\"confirm\">ほんとうに消してよいですか:
	<label><input type=\"checkbox\" name=\"confirm\" value=\"yes\">はい
	</label></span></p>"
      title="$datetime の一言の修正"
    fi
  fi
  htmlhead "$title"		# ヘッダから h1 要素まで出力(cgilib2内)
  cat<<-EOF
	<form action="$myname" method="POST" enctype="multipart/form-data">
	 ${datetime:+$hidden}	<!-- 有効な日付指定のみ $edit 出力 -->
	 <label>ファイルを添付してください:
	  <input type="file" name="attach"></label><br>一言:<br>
	 <textarea name="hitokoto" rows="3" cols="40">$hitokoto</textarea>
	 <input type="submit" value="POST">
	 <input type="reset" value="Reset">
	</form>
	EOF
}

addnew() {
  if [ -n "$CONTENT_LENGTH" ]; then	# データが送信された場合の登録処理
    dt=`getpar datetime`		# datetimeの値がある場合は既存行の更新
    # 実在する datetime か値自身を引直して確認する
    [ -n "$dt" ] && \
	dt=`query "SELECT datetime from blog WHERE datetime='$dt';"`
    if [ x`getpar remove``getpar confirm` = x"yesyes" ]; then
      # 「削除」と確認の「はい」両方にチェックの場合
      query "DELETE FROM blog WHERE datetime='$dt';"
    elif [ -n "`getpar hitokoto`" ]; then	# hitokotoに何かが入力されたら
      [ -n "$dt" ] && now=$dt || now=`date '+%F %T'`
      query<<-EOF
	REPLACE INTO blog
	  -- (日時, 一言, ファイル名, ファイル内容) を合成して挿入
	  SELECT '$now',
		 max(CASE name WHEN 'hitokoto' THEN val END),
		 max(CASE name WHEN 'attach' THEN filename END),
		 max(CASE name WHEN 'attach' THEN val END)
	  FROM cgipars
	  WHERE tag='$_tag'
	  GROUP BY tag;
	EOF
    fi
  fi
}

putblog() {			# 既存のエントリをtableで出力
  href1=${authok:+"<TD><a href=\"$myname?edit/\\1\">\\2</a><"}
  href2="<TD><a href=\"$myname?catfile/\\1\">\\2</a><"
  echo '<table border="1">'
  query<<-EOF  \
      | sed -e "s,<TD>1:\(\([-0-9 :]*\):[0-9]*\)<,${href1:-<TD>\\2<}," \
	    -e "s,<TD>2:,<TD>," \
	    -e "s,<TD>3:\([0-9]*\):\(.*\)<,$href2,"
	.mode html
	.header ON
	SELECT	'1:' || datetime '日時',
		'2:' || body '一言',
		CASE WHEN filename IS NOT NULL
		THEN '3:' || rowid || ":" || filename
		ELSE ''			-- filenameが非NULLのときのみ出力
		END '添付ファイル'
	FROM blog;
	EOF
  echo '</table>'
}

. ./cgiauthlib-sh
cgiauth
rc=$?
if [ 0 -eq $rc ]; then
  authok=yes			# 認証済みフラグ
  bodyclass="authok"
  putform			# ヘッダと入力フォームを出力
  addnew			# 新規レコードの登録
else
  if [ $rc -eq 99 ]; then	# 「忘れた」場合は $?=99 で戻る
    htmlhead 
    echo "<p>${user}宛に新規パスワードを送信しておきました。</p>"
    loginform "Login again" echo "</body></html>"
    exit 0
  fi
  htmlhead "Miniblog"
  loginlink			# loginフォームへのリンク
fi
[ -n "$editkey" ] || putblog	# データ更新後の登録エントリを出力
echo '</body></html>'

このスクリプトのURLにアクセスするとまず以下の画面となる(背景は白)。

Miniblog

Login

日時一言添付ファイル
2015-08-09 15:38:52 苗名滝でリフレッシュ!naena.jpg
2015-09-27 13:49:36 栗山池の一足早い秋kuriyama.jpg

レコード修正機能の追加」 で示した画面に比べ、記事の新規入力フォームが省略されたものになっている。 また、図「修正リンクの例」 と比べ、日時欄に張った修正リンクがない。代わりに Login リンクがあり、これをたどると以下のようなログイン画面が出る。

Login

ユーザ
パスワード

これに対して正しいユーザ名・パスワード対を入れると 入力フォームと修正リンク入りのページが現れる(背景色は#ffdの淡い黄色)

一言日記


一言:
日時一言添付ファイル
2015-08-09 15:38:52 苗名滝でリフレッシュ!naena.jpg
2015-09-27 13:49:36 栗山池の一足早い秋kuriyama.jpg

認証の有無によって異なる挙動を示すCGIシステムが完成した。 実用に供するシステムにするには以下のような機能を持つページの追加・改良が必要であろう。

練習問題: クッキー利用の練習

CGI プログラムで Cookie を利用する流れを未体験の場合はこの問題を解いておきたい。

以下のような name=cook type=text の input 要素を1つだけ出し、 そこに入力された値をすぐ cookie としてブラウザに送信すると同時に、 もしそのときブラウザから同じ変数の値が送られて来たらそれを合わせて 出力するスクリプト cookietest.cgi を作成せよ。

英数字文字列を入れてね:

 

今回送られたcookie[cook]の値は hoge です。

cookieの取得と設定は cgiauthlib-sh の getcookie、setcookie 関数を用いてよい。

練習問題: 解答例

実際に動かしてみて Cookie の挙動を体感するとよい。

cookietest.cgi

#!/bin/sh
cd `dirname $0`
. ./cgiauthlib-sh
myname=`basename $0`

cookieval=`getcookie cook`
formval=`getpar cook | tr -dc 'A-Za-z0-9'`
setcookie cook="$formval"

htmlhead "COOK!"
cat<<EOF
<form action="$myname" method="POST">
<table>
 <tr><td>英数字文字列を入れてね:</td>
  <td><input name="cook"></td></tr>
</table>
<input type="submit" value="OK">
<input type="reset" value="Reset">
</form>
<p>今回送られたcookie[cook]の値は
<span style="color: red;">$cookieval</span> です。</p>
</body></html>
EOF

動くスクリプト実物を見た方がよいが、ここにも動かしてみた例を示す。

  1. 初期画面

    COOK!

    英数字文字列を入れてね:

     

    今回送られたcookie[cook]の値は です。

  2. 入力窓に hogehoge と入れる。

    COOK!

    英数字文字列を入れてね:

    hogehoge

    今回送られたcookie[cook]の値は です。

  3. 2画面め。Cookie値はまだ空。

    COOK!

    英数字文字列を入れてね:

     

    今回送られたcookie[cook]の値は です。

  4. 入力窓に yeah! と入れる。

    COOK!

    英数字文字列を入れてね:

    yeah!

    今回送られたcookie[cook]の値は です。

  5. 3画面め。初回入力値がブラウザからの cookie として送られて来る。

    COOK!

    英数字文字列を入れてね:

    yeah!

    今回送られたcookie[cook]の値は hogehoge です。

以後、同様に2つ前の入力画面の値、 つまり1回前のスクリプト起動で送信した Cookie 値を受け取ることになる。 ユーザ(入力者)から見ると2回先の画面で反映されることに注意する。

yuuji@koeki-u.ac.jp