特定の文字種だけを1文字以上含むことを強制するバリデーションを正規表現で実現する(PHP/Laravel)

# 概要

設計書「パスワードのバリデーションは、AWS Cognitoと同じ規則とする。

```
最小値: 8
許容する文字種:
  1. 半角小文字英字
  2. 半角大文字英字
  3. 半角数字
  4. 特殊記号
  ※特殊記号とは、以下の文字列を指す。(全て半角)
  = + - ^ $ * . [ ] { } ( ) ? " ! @ # % & / \ , > < ' : ; | _ ~ `
　※1.～4.は1文字以上含むものとする。  
```

」

私「最小値はLaravelのminを使うとしても、これは正規表現の予感・・・！」
私「いやでも、誰かしら同じ実装してるだろうし、ネットで探せばあるよね」


私「・・・見つからなかった」

ということで期待通りに動く正規表現を見つけられなかったため、「許容する文字種を指定し、許容する文字種が1文字以上含まれることを強制する」正規表現を書くにはどうすればいいか調べて自分で書いてみました。
 (※後述しますが、[Cognito側でパスワードの要件を指定できる](https://docs.aws.amazon.com/ja_jp/AWSCloudFormation/latest/UserGuide/aws-properties-cognito-userpool-passwordpolicy.html)ため、本来はプログラム側で実装する必要がありません。)

本記事では、特定の文字種だけを1文字以上含む場合にマッチする正規表現とその解説を自分のために書き記します。


## 注意点

正規表現を書いてみたものの、[Cognito側でパスワードの要件を指定できる](https://docs.aws.amazon.com/ja_jp/AWSCloudFormation/latest/UserGuide/aws-properties-cognito-userpool-passwordpolicy.html)ため、PHPでは実装しませんでした。(今回のプロジェクトでは複数サービスからCognitoを利用するが、そのたびにバリデーションを実装すると、実装およびテストのコストがかかるため。)

正規表現の誤りがあったり、このパターンを入力すると正しく動かなかった等あればコメントいただけると嬉しいです。直せるかは分かりませんが。

# 動作確認環境

```
PHP 7.4
Laravel 6.18.38
```

# 結論

```php
preg_match('/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[\-+=^$*.\[\]{}()?\"!@#%&\/\\\\,><\':;|_~`\-+=])(?!.*[^a-zA-Z\d\-+=^$*.\[\]{}()?\"!@#%&\/\\\\,><\':;|_~`]).+$/', $value);
```

特殊文字の数とそのエスケープが多いせいでやたらと長い。
もう少し短縮できるのかもしれないが、これが限界。もっと良い方法があるような気もする。
どの文字をどうエスケープすればいいかについてはPhpStormに大変お世話になりました・・。
※preg_match()は正規表現と渡した値がマッチすれば1、しなければ0を返すPHPの関数。

# 解説
肯定先読みと否定先読みについての参考記事を紹介したあと、前半(肯定先読み部分)と後半(否定先読み部分)に分け、それぞれのパーツごとに説明する。

## 肯定先読み、否定先読み

肯定先読みと否定先読みについて、この3つの記事が分かりやすかったように思います。先読みとは何ぞや？という方は読んでいただき、知ってる！という方は次へ。

- [正規表現の先読み・後読みを極める！](https://abicky.net/2010/05/30/135112/)
- [正規表現：AND（かつ）の表現方法](http://www-creators.com/archives/5332)
- [はじめての正規表現とベストプラクティス#4 先読みと後読みを極める](https://techracho.bpsinc.jp/hachi8833/2018_11_20/63564)

次は、これらの説明を踏まえて、前半の肯定先読みの解説をします。

## 前半の肯定先読み
まずは前半の肯定先読みの部分について説明します。
この部分です。

```
(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[\-+=^$*.\[\]{}()?\"!@#%&\/\\\\,><\':;|_~`\-+=])
```

これを括弧ごとに4つに分けると次のようになり、どのパーツも「指定した文字の先頭位置にマッチする」という正規表現になります。

### ①半角英小文字を1文字以上含む

```
(?=.*[a-z])
```

半角英小文字が現れると、その文字の直前の位置にマッチします。参考記事に倣って、 `aA1!あ`という文字列について矢印で位置を示します。
`.*`は改行以外の任意の1文字が0回以上連続することを示すので、半角英小文字自身が先頭だろうと文字列中にあろうと末尾にあろうと、マッチします。②以降の説明でも登場しますが、同様です。

```
↓
  a   A   1   !   あ
```



### ②半角英大文字を1文字以上含む

```
(?=.*[A-Z])
```

半角英大文字が現れると、その文字の直前の位置にマッチします。

```
　　 ↓
  a   A   1   !   あ
```

### ③半角数字を1文字以上含む

```
(?=.*\d)
```

半角数字が現れると、その文字の直前の位置にマッチします。

```
　　     ↓
  a   A   1   !   あ
```

### ④特殊記号を1文字以上含む

```
(?=.*[\-+=^$*.\[\]{}()?\"!@#%&\/\\\\,><\':;|_~`\-+=])
```

特殊記号が現れると、その文字の直前の位置にマッチします。
※特殊記号とは、以下の文字列を指す。(全て半角)
= + - ^ $ * . [ ] { } ( ) ? " ! @ # % & / \ , > < ' : ; | _ ~ `

```
　　         ↓
  a   A   1   !   あ
```


①～④すべてを並べると、①かつ②かつ③かつ④となるので、半角英小文字、半角英大文字、半角数字、特殊記号がそれぞれ1文字以上含まれていればマッチします。`Pass123` や `pass123!` は条件を満たしていないのでマッチしません。

そう言われるとこれで完成のような気もしますが、この前半部分だけだと、`Pass123!あ` のように、許容する文字種以外が含まれていてもマッチしてしまいます。
なので、次に説明する否定先読みを合わせます。


## 後半の否定先読み
今度は、否定先読みの部分について説明します。
この部分です。

```
(?!.*[^a-zA-Z\d\-+=^$*.\[\]{}()?\"!@#%&\/\\\\,><\':;|_~`])
```

### ⑤半角英小文字、半角英大文字、半角数字、特殊記号以外の文字列を含まない
エスケープ文字が邪魔で分かりにくいですが、パターンに指定した文字の直前の位置をマッチから外します。
ここでは、先頭文字 `^`に続く文字自体を否定するので、ややこしいですが、**パターンに指定していない**文字の直前の位置はマッチが外れることになります。つまり、半角英小文字、半角英大文字、半角数字、特殊記号の直前の位置にマッチします。

```
↓　　↓   ↓   ↓   
  a   A   1   !   あ
```
　
この後半部分だけだと、指定外文字列 `あ` `Σ`などが含まれていなければマッチしてしまうため、例えば`password` のように半角英字しか含まれていなくてもマッチしてしまいます。
なので、前半・後半を合わせる必要があります。

## 改めて全体を見る
肯定先読み、否定先読みの部分を、さらに先頭と末尾のデリミタ、 `^` 、改行以外の任意の1文字が1回以上連続することを示す `.+` 、 `$` が囲います。
 `.+` は無くてもいいだろう、と初めは考えたのですが、先読み・後読みは位置に関する記述なので、それ自体は文字ではありません。文字が1文字以上存在することを示すために `.+` が必要であるようです。

```
/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[\-+=^$*.\[\]{}()?\"!@#%&\/\\\\,><\':;|_~`\-+=])(?!.*[^a-zA-Z\d\-+=^$*.\[\]{}()?\"!@#%&\/\\\\,><\':;|_~`]).+$/
```

こうすることで、①から⑤を要求する正規表現の完成です。

①半角英小文字を1文字以上含む
②半角英大文字を1文字以上含む
③半角数字を1文字以上含む
④特殊記号を1文字以上含む
⑤半角英小文字、半角英大文字、半角数字、特殊記号以外の文字列を含まない

## 実際の実装方法
Laravelでは、[新たにRuleオブジェクトを作って](https://readouble.com/laravel/6.x/ja/validation.html)そのなかで今回紹介した正規表現を使い、FormRequestクラスを継承したクラスで利用するといいと思います。
個人的に、バリデーションエラーとなったとき、文字数と文字種について別々にメッセージが出たほうが分かりやすいと思うので、最小文字数とは別のバリデーションとしました。

```
php artisan make:rule PasswordPolicy
```

```php
<?php

declare(strict_types=1);

namespace App\Rules;

use Illuminate\Contracts\Validation\Rule;

class PasswordPolicy implements Rule
{
    public function passes($attribute, $value)
    {
        return preg_match('/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[\-+=^$*.\[\]{}()?\"!@#%&\/\\\\,><\':;|_~`\-+=])(?!.*[^a-zA-Z\d\-+=^$*.\[\]{}()?\"!@#%&\/\\\\,><\':;|_~`]).+$/', $value);
    }

    public function message()
    {
        // よしなに変えてください。
        return 'パスワードの強度が足りません。半角英小文字、半角英大文字、半角数字、特殊記号をそれぞれ1文字以上含めてください。特殊記号は、 ^ $ * . [ ] { } ( ) ? " ! @ # % & / \ , > < \' : ; | _ ~ ` です。';
    }
}

```

```php
<?php

declare(strict_types=1);

namespace App\Http\Requests\Auth;

use App\Rules\PasswordPolicy;
use Illuminate\Foundation\Http\FormRequest;

class SampleRequest extends FormRequest
{
    public function authorize(): bool
    {
        return true;
    }

    public function rules(): array
    {
        return [
            'user_id' => ['required', 'string', 'max:255'],
            'password' => ['required', 'string',  'min:8', new PasswordPolicy()],
        ];
    }
}
```

# 参考記事
- [正規表現の先読み・後読みを極める！](https://abicky.net/2010/05/30/135112/)
- [正規表現：AND（かつ）の表現方法](http://www-creators.com/archives/5332)
- [はじめての正規表現とベストプラクティス#4 先読みと後読みを極める](https://techracho.bpsinc.jp/hachi8833/2018_11_20/63564)
  - この記事は可読性や、先読み・後読みの考え方などについて言及されていて勉強になりました。
- [Amazon Cognito  ユーザープールのパスワードの追加要件](https://docs.aws.amazon.com/ja_jp/cognito/latest/developerguide/user-pool-settings-policies.html)
- [AWS CloudFormation  AWS::Cognito::UserPool PasswordPolicy](https://docs.aws.amazon.com/ja_jp/AWSCloudFormation/latest/UserGuide/aws-properties-cognito-userpool-passwordpolicy.html)