AngularとBootstrapで問い合わせフォーム
Angularで問い合わせフォームを作成したいという案件がありました。Angular利用時はいつもAngular Materialを使っていたのですが、マテリアルデザインの配色設定から離れたいこともあり、今回はBootstrapを設定しました。
また、ng-recaptchaを使ってreCAPTCHAの導入もしました。
今回紹介するAngularのプロジェクトはgithubで公開しています。
Bootstrap
Node.jsのインストールは終えているものとし、AngularCLIの初期化と合わせて、Bootstrap設定します。
routingの設定やスタイルシートのフォーマットは利用環境に合わせて設定してください。
> npm install -g @angular/cli ... > ng new inquiry-form ? Would you like to add Angular routing? No ? Which stylesheet format would you like to use? CSS ... > cd inquiry-form ... > ng add @nb-bootstrap/ng-bootstrap ...
もしバージョンの相違などでng addによる追加ができない場合は、nb-bootstrap:「Introduction」の「Manual installation」にあるように次の作業をします。
> npm install @popperjs/core ... > npm install @ng-bootstrap/ng-bootstrap ... > ng add @angular/localize ...
@angular/localizeは複数言語の設定で、npmからインストールした場合は、src¥polifils.tsに「import '@angular/localize/init';」の記述が必要です。「ng add」でインストールすれば自動で記述が追加されます。
プロジェクトのルートフォルダにあるangular.jsonにbootstrapのcssを読み込む設定をします。
jsonファイルの階層としては、「ルート→projects→プロジェクト名→architect→build→options→styles」の位置に記述します。
angular.json
"build": { ..."styles": [ "node_modules/bootstrap/dist/css/bootstrap.min.css", "src/styles.css" ], ...
最後に、src¥app¥app.module.tsにNgbModuleのimportを記述します。
app.module.ts
...
import { NgbModule } from '@ng-bootstrap/ng-bootstrap';
import { ReactiveFormsModule } from '@angular/forms';
import { HttpClientModule } from '@angular/common/http';
@NgModule({
...
imports: [
...
NgbModule,
ReactiveFormsModule,
HttpClientModule
],
...
手動インストール時に限らず、NgbPaginationModule(ページネーション)などを利用する際もモジュールのimportの記述が必要です。
Angular
Angularのフォームについては別記事で解説していますが、リアクティブフォームを使うと設置が簡単です。先ほどのNgbModuleをインポート部の紹介したしたコードでは、その次の行でReactiveFormsModuleの設定もしています。さらに、データ送受信時に必要なHttpClientModuleも含めています。
フォームグループ(FormGroup)の中に、フォームコントロール(FormControl)を設定し、それぞれにバリデーション(データチェック)を設定することで、フォームグループプロパティで全体のバリデーションの状態を判断することができます。
コンポーネント(ts)側でValidators.requiredで入力必須のバリデーションをセットできます。また、下記のコードようにオリジナルのバリデーションを作って設定することも可能です。
オリジナルのバリデーションの結果も各フォームコントロールのvalid,invalidプロパティに作用します。
ちなみに入力必須の条件の場合は、テンプレート(html)側にrequiredを記述することの方が馴染みが深いと思いますが、その方法でも同じ効力を得ます。
app.component.ts
...
import { AbstractControl, FormBuilder, FormGroup, ValidationErrors, ValidatorFn, Validators } from '@angular/forms';
constructor(private fb: FormBuilder) { }
public form : FormGroup= this.fb.group({
name:['',Validators.required],
mail:['',regExValidator(/^[A-Za-z0-9]{1}[A-Za-z0-9_.-]*@{1}[A-Za-z0-9_.-]+.[A-Za-z0-9]+$/,'メールアドレスが不正です')],
homepage:[''],
detail:['',Validators.required]
});
...
//オリジナルのバリデーション
//Component class を閉じた後
//(本当はファイルを独立させた方がいいと思います。)
export function regExValidator(regEx: RegExp, strErrMsg: string):ValidatorFn {
return (control: AbstractControl):ValidationErrors | null => {
const result = regEx.test(control.value);
return result ? null : { message: strErrMsg };
}
}
formとしてフォームグループを設定し、その中にテンプレート側と1対1でフォームコントロールを定義しています。
form.validのプロパティは、グループ内のどれかひとつのコントロールでvalidがfalseならfalseが入ります。またプロパティはvalidだけでなく、その逆のinvalidもあります。これらのプロパティはそれぞれのフォームコントロールにも存在します。
他、便利なプロパティにtouchedやdirtyがあります。touchedは一度でもフォーカスが当たって離れるとtrueが入ります。dirtyは初期状態から変更があるとtrueが入ります。
app.component.html
...
<!-- formgroupの設定 -->
<!-- ts側で設定したフォームグループformをセットします -->
<!-- この内側でないとformControlNameは使えません-->
<form class="form" [formGroup]="form" autocomplete="off">
...
<!-- textareaの例 -->
<!-- .tsのformの中のdetailをformControlNameにセットしています -->
<!-- 1.状態が確認モードなら(intStep==1) readonly-->
<!-- 2.フォーム単体がinvalidだったらis-invalidクラス付与-->
<!-- (is-invalidクラスの装飾はBootstrapがやってくれます)-->
<!-- 3.touchedを使って初回表示時はエラー表示させない -->
<div class="mb-3">
<label for="txtdetail" class="form-label">詳細</label>
<textarea class="form-control" id="txtdetail" formControlName="detail" rows="5" [ngClass]="!form.controls['detail'].invalid || !form.controls['detail'].touched ? '' : 'is-invalid'"></textarea>
</div>
...
<!-- 送信ボタンの例 -->
<!-- form全体でエラーがあれば(invalid) 送信ボタンを使用禁止に-->
<!-- formを直接POSTせずに.ts側のメソッドからAjaxします-->
<div class="col-auto">
<button class="btn btn-primary mb-3" [disabled]="form.invalid" (click)="dataSend();">送信</button>
</div>
...
今回の設計では、入力後の確認の段階になったらフォームグループのdisableメソッドを呼んでフォーム全体を使用できなくさせます。修正したい場合はボタンのイベントでenable()メソッドで元に戻します。
通常のHTMLで直接POSTする場合はdisabledがついている項目は送信されないのでreadonlyにする慣例があったので、そうしたいのですが全体をreadonlyにする方法がありませんし、selectなどにははもともとそれがありません。
今回はHTTPClientを使ってデータを送信するのでよしとしたのですが、戸惑ったのは、あるフォームをdisabledにした場合、validプロパティはfalseになりますが、invalidプロパティもfalseのままだということです。
先ほどは反転している値のように紹介してしまいましたが、validとinvalidの違いをあらためて調べてみたところ、どちらの値はstatusからくることがわかりました。 そして「AbstractControl#status」の解説では、ステータスはそれぞれ排他的であること、「FormControlStatus」の解説では、ステータスには「VALID,INVALID,DISABLED,PENDING」があることが書かれていました。
このことから判断するに、statusがVALIDならvalidにtrue,INVALIDならinvalidにtrueが入り、そのどちらでもないDISABLEDの場合はどちらにもtrueが入らない、という事のようです。
グレーアウトされるのが嫌な場合はCSSで次のようにすれば回避できます。
app.component.css
...
input[disabled] {
background-color: #fff;
color: #333;
}
...
バックエンド
バックエンドにはPHPを使います。
基本的に必要な機能は、POSTされたデータを保存するだけなので、「file_get_contents('php://input')」と「file_put_contents(...)」で済んでしまう話なのですが、それに「XSRF」、「BOT」、「連投」等のセキュリティ的な対策をつけたところコード量が膨らんでしまいました。
まずデータの保護です。
受信したデータをどこに保存するかは受け取るデータの種類や運用によっても違ってくると思います。
データベースだったり、Apacheの公開フォルダの下流か、それ以外の場所にするかなど考え方はいろいろだと思いますが、ここでは公開フォルダの下流に子孫フォルダを作り管理する方法で進めます。
ファイルのリストを非表示にしていても格納ファイルの場所を知られてしまうと読み取られる可能性があるので、.htaccessファイルを利用して表示させないようにしておきます。
.htaccess
<Files "*"> Require all denied </Files>
この設定はWebからファイルにアクセスするのを防ぐだけなので、もしシェルにアクセスできるのがサーバーの管理者以外にもいたらファイルの読み込み権限にも気を付ける必要があります。
CAPTCHA
フォーム入力者がBOTでないことをチェックする機能のことをCAPTCHAと呼びます。
Googleが提供するreCAPTCHAには無料枠がありますので、それを利用します。
AngularにreCAPTCHAを導入するためのライブラリを探したところng-recaptchaがよさそうでした。
reCAPTCHAはGoogleが管理するサーバーに問い合わせてBOTかどうかを判定してもらう仕組みになっているので、まずはGoogle reCAPTCHAの利用登録をします。
登録では「ラベル、タイプ、ドメイン」を設定します。ラベルは名前なので自由につけられます。タイプは、v3がスコアを取得し暗黙のうちに処理するタイプ、チェックボックスや画像選択でBOTを回避するのがv2です。ここではv3を利用します。v2とv3とでは設定方法が違いますので注意してください。
登録が終わると、「サイトキー」と「シークレットキー」が発行されます。どちらも後で使いますので保存しておきます。
Angularに戻って「ng-recaptcha」をインストールします。
> npm install ng-recaptcha
v3ではreCAPTCHAをサービスとして使います。app.module.tsを次のように編集します。
providersの部分で先ほどコピーしておいたサイトキーを貼り付けます。
app.module.ts
...
import { RECAPTCHA_V3_SITE_KEY, RecaptchaV3Module } from "ng-recaptcha";
...
imports: [
...
RecaptchaV3Module,
...
],
providers: [
...
{ provide: RECAPTCHA_V3_SITE_KEY, useValue: "サイトキー" }
...
],
...
CAPTCHAを利用するコンポ―ネントで、サービスを設定し利用します。
app.component.ts
...
constructor(
private fb: FormBuilder,
private cap: ReCaptchaV3Service,
...
) { }
...
サービスから.execute('action_naame')とすることで、Googleへtoken(トークン)の発行を依頼します。
アクション名は「submit」や「importantAction」など、「A-Za-z/_」の文字列ででユーザー固有の情報は含めない条件で、任意で設定が可能です。
ng-recaptchaではトークンをsubscribe(RxJS)で受け取ることができます。このトークンを他の情報とともにサーバーに渡し、unsubscribe()を実行して購読を止めます。
unsubscribe()は、subscribeの一連の過程の最後で実行しないと、先に実行されて結果が戻って来なくなるので注意してください。
コードはおおむね次のような感じになると思います。
app.component.css
...
//ng-recaptchaサービスを使ってGoogleからトークンを取得
let unsub = this.cap.execute('action_name').pipe(
//concatMapで連結(mergeMapでもいいと思います)
concatMap((token:string)=>{
return 「tokenと他のデータをサーバーへ送信するObservable」※
}),
catchError((err:any)=> {
//エラー
console.log(err);
})
).subscribe(result=>{
//※処理完了
//状態(result)に応じてフォームを元に戻す等の処理
//unsubscribe
unsub.unsubscribe();
});
...
Angular側のCAPTCHAの設定は以上です。今度はサーバー側です。
サーバー側で、受け取ったトークンとシークレットキーをパラメーターにして「https://www.google.com/recaptcha/api/siteverify」のアドレスにPOSTすると、CAPTCHAの結果が返ってきます。タイムリミットはトークンを発行してから2分間です。
結果は次のプロパティで構成されるオブジェクトになっています。
- success
キーが一致して正しく問い合わせされたかどうかの結果がtrue/falseで入ります。
- score
BOTらしさを判定するスコアです。0.0がBOTの可能性が最も強く、1.0が可能性が最も低い値になります。
- action
アクション名です。
- challenge_ts
タイムスタンプです。
- hostname
サイトのホスト名です。
- error-codes
エラー時エラーコードが入ります。通常はプロパティが存在しません。
successの値でちゃんと処理が実行されたか、hostname、action、challenge_tsの値で正規のデータかどうか、をチェックした上でscoreでBOTかどうかを判断します。
サーバー側がPHPだとすると次のような感じで、Goolgeから問い合わせることができます。
server.php
function verifyCaptcha($strToken) {
$params = array(
"secre" => "Googleから発行されたシークレットキー",
"response" => $strToken
);
$data = http_build_query($params, "", "&");
// header
$header = array(
"Content-Type: application/x-www-form-urlencoded",
"Content-Length: ".strlen($data)
);
$context = array(
"http" => array(
"method" => "POST",
"header" => implode("¥r¥n", $header),
"content" => $data
)
);
$str = file_get_contents("https://www.google.com/recaptcha/api/siteverify", false, stream_context_create($context));
$json = json_decode($str,true);
if (isset($json['score'],$json['success'],$json['action'])==false) {
return -1;
}
if ($json['success']==false) {
return -2;
}
//スコアを返す(0.0-1.0)
return $json['score'];
}
次のようなデータが返ってきます。
{
"success": true,
"challenge_ts": "2022-05-21T11:12:14Z",
"hostname": "some.domain.jp",
"score": 0.9,
"action": "submit"
}
筆者が試してみたところ、普通に入力しても、シークレットモードでやっても、過去に「気象庁のサイトからクローラで天気予報を取得」した際に利用したpuppeteerを使ってもscoreが0.9のまま変わりませんでした。公式サイトでは「ライブ トラフィックがまだ十分にないサイトでは、スコアが正確でない可能性があります」という記述があったので、scoreの基準値はとりあえず真ん中の0.5で始めて、しばらく様子を見ます。
reCAPTCHAを適用すると、クライアント側とサーバー側の両方で処理をする構造になっている為、コードは若干複雑にはなりますが、フォームを仲介せずに直接バックエンドにPOSTしてくるアクセスも判別することができます。
XSRF対策
XSRF対策は、AngularのHttpClientに標準で備わっている機能を利用し、そのサーバー側の実装をします。詳しくは以前書いた記事、AngularのXSRF対策を参考にしていただければと思います。
連投対策
ただ、CAPTCHAはBOTかどうかを判断するだけで、人的な連投や悪意のあるコメントを拒否するような対処はできません。
そこでPHP側の実装でIPアドレス毎にブラックリストによる拒否と、送信可能回数の制限します。
IPv4時はリモートアドレスをそのまま使い、IPv6時にはネットワークアドレスだけを取得しそれと比較しています。IPv6アドレスは短縮形が用いられることもありますので、一度それを元に戻してから比較するようにします。
特筆するような処理はしていませんが、どのようなコードにしているか見たい場合はgithubに上げてあるreceeve-message.phpを参照してください。