PHP+MySQLで簡易ログインシステムを作る

こんにちは、ウェブエンジニアのmasakichiです。
いつもはgithubの[TILレポジトリ](https://github.com/Naughty1029/TIL)で日々の学習を記録しています。

##これは何の記事？
PHPとMySQLで簡易ログインシステムを作ります。
実装する機能は下記の通りです。

- ユーザー登録
- ログイン
- ログアウト

なお、下記の環境で動作確認しています
m(__)m < ご承知おきを

```text:動作環境
MAMP
PHP 7.4.21
MySQL 5.7.34
```

##なんでこの記事書いたの？
先日、Laravelでウェブシステムを作るときに、ログイン機能も実装しました。

Laravelだと、簡易なログイン機能であればコマンド１発でサクッと作れちゃいます。

Laravelすげぇ

いや、でも…

そもそもログイン機能ってどういう仕組みなの？
どうやって実装するの？

と気になり、色々な記事を参考にさせて頂きながら実装しました。

なお、実装するのはセッションベースの認証です。

## そもそもセッションってなに？
セッションベースの認証を理解するには、PHPによる「セッション」がどういうものなのか知っておく必要があります。

こちらの記事が大変わかりやすかったのでセッションってなに？という方はまずこちらの記事を読んでみることをおすすめします。
↓↓↓
[PHP のセッションを使ったログイン認証はなぜ安全なのか？](https://blog.apar.jp/php/12373/)

以下、大事だなと思うところです。

- セッションでは「サーバー上に作成されるセッションファイル」と、「ブラウザのCookie」を利用する
- セッションファイルとCookieは同じセッションIDを持つ
- セッションファイルには情報を保存（セッション変数）することができる
- セッション変数は、セッションIDがセットされたCookieを持つブラウザからのみアクセスができる
- セッション変数は、サーバー上に保存されるためクライアント側からは変更ができない

### で、セッションがなんの役に立つの？
今回の実装では下記3つのことができるようになります。

**CSRF対策**
セッション変数にTokenを入れてCSRF対策を実装する

**ユーザーがログイン済みかどうかの判別**
セッション変数でログイン情報を管理して、ウェルカムページを表示するかどうか分岐させる

**ユーザー情報の取得**
セッション変数にユーザーIDやユーザーネームを格納しておき、ログインユーザーの情報を引き出す

## ディレクトリ構成
今回実装するディレクトリの構成は下記の通りです。

```
root/
 ├ db_connect.php　　//データベースの接続用
 ├ functions.php //ユーザー定義関数
 ├ register.php //新規登録処理
 ├ login.php //ログイン処理
 ├ logout.php //ログアウト処理
 └ welcome.php //ログイン後のウェルカムページ
```

## DB / テーブル作成
まず任意の名前でDBを作成します。
わたしは`user_login`という名前で作りました。

作成したDB上で下記のクエリを実行し、usersテーブルを作成します。
このテーブルにユーザー情報が保存されることになります。

```sql
CREATE TABLE users (
    id INT NOT NULL PRIMARY KEY AUTO_INCREMENT,
    name VARCHAR(50) NOT NULL UNIQUE,
    password VARCHAR(255) NOT NULL,
    created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
```
:::note info
nameカラムにUNIQUEキーをつけることで、同一ユーザー名が重複して登録されないように制約をつけています。
:::

## db_connect.php
作成したデータベースへの接続を定義します。

```php:db_connect.php
<?php
/* ①　データベースの接続情報を定数に格納する */
const DB_HOST = 'mysql:dbname=user_login;host=localhost';
const DB_USER = 'root';
const DB_PASSWORD = 'root';

//②　例外処理を使って、DBにPDO接続する
try {
    $pdo = new PDO(DB_HOST,DB_USER,DB_PASSWORD,[
		PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
		PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
		PDO::ATTR_EMULATE_PREPARES =>false
	]);
} catch (PDOException $e) {
	echo 'ERROR: Could not connect.'.$e->getMessage()."\n";
	exit();
}
```
なお、MySQLへのPDO接続は下記のリンク先を参照しました。
↓↓↓
[MySQLへ接続](https://www.javadrive.jp/php/pdo/index3.html)

## functions.php

複数のファイルで共通して使うユーザー定義関数を書いていきます。

```php:functions.php
<?php
//XSS対策
function h($s){
    return htmlspecialchars($s, ENT_QUOTES, "UTF-8");
}

//セッションにトークンセット
function setToken(){
    $token = sha1(uniqid(mt_rand(), true));
    $_SESSION['token'] = $token;
}

//セッション変数のトークンとPOSTされたトークンをチェック
function checkToken(){
    if(empty($_SESSION['token']) || ($_SESSION['token'] != $_POST['token'])){
        echo 'Invalid POST', PHP_EOL;
        exit;
    }
}

//POSTされた値のバリデーション
function validation($datas,$confirm = true)
{
    $errors = [];

    //ユーザー名のチェック
    if(empty($datas['name'])) {
        $errors['name'] = 'Please enter username.';
    }else if(mb_strlen($datas['name']) > 20) {
        $errors['name'] = 'Please enter up to 20 characters.';
    }

    //パスワードのチェック（正規表現）
    if(empty($datas["password"])){
        $errors['password']  = "Please enter a password.";
    }else if(!preg_match('/\A[a-z\d]{8,100}+\z/i',$datas["password"])){
        $errors['password'] = "Please set a password with at least 8 characters.";
    }
    //パスワード入力確認チェック（ユーザー新規登録時のみ使用）
    if($confirm){
        if(empty($datas["confirm_password"])){
            $errors['confirm_password']  = "Please confirm password.";
        }else if(empty($errors['password']) && ($datas["password"] != $datas["confirm_password"])){
            $errors['confirm_password'] = "Password did not match.";
        }
    }

    return $errors;
}
```

上記のうち、setToken関数とcheckToken関数ではセッションを利用してCSRF対策をしています。

後ほど、新規登録画面やログイン画面の実装でそれぞれの関数を使い、他のプログラムからのPOSTができないようにします。

## register.php
ユーザーの新規登録画面を作っていきます。

こんな感じのUIになります。
↓↓↓
![register.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1006362/b74805cd-234f-8638-c922-e4d7e52ea05f.png)


```php:register.php
<?php
//ファイルの読み込み
require_once "db_connect.php";
require_once "functions.php";

//セッションの開始
session_start();

//POSTされてきたデータを格納する変数の定義と初期化
$datas = [
    'name'  => '',
    'password'  => '',
    'confirm_password'  => ''
];

//GET通信だった場合はセッション変数にトークンを追加
if($_SERVER['REQUEST_METHOD'] != 'POST'){
    setToken();
}
//POST通信だった場合はDBへの新規登録処理を開始
if($_SERVER["REQUEST_METHOD"] == "POST"){
    //CSRF対策
    checkToken();

    // POSTされてきたデータを変数に格納
    foreach($datas as $key => $value) {
        if($value = filter_input(INPUT_POST, $key, FILTER_DEFAULT)) {
            $datas[$key] = $value;
        }
    }

    // バリデーション
    $errors = validation($datas);

    //データベースの中に同一ユーザー名が存在していないか確認
    if(empty($errors['name'])){
        $sql = "SELECT id FROM users WHERE name = :name";
        $stmt = $pdo->prepare($sql);
        $stmt->bindValue('name',$datas['name'],PDO::PARAM_INT);
        $stmt->execute();
        if($row = $stmt->fetch(PDO::FETCH_ASSOC)){
            $errors['name'] = 'This username is already taken.';
        }
    }
    //エラーがなかったらDBへの新規登録を実行
    if(empty($errors)){
        $params = [
            'id' =>null,
            'name'=>$datas['name'],
            'password'=>password_hash($datas['password'], PASSWORD_DEFAULT),
            'created_at'=>null
        ];

        $count = 0;
        $columns = '';
        $values = '';
        foreach (array_keys($params) as $key) {
            if($count > 0){
                $columns .= ',';
                $values .= ',';
            }
            $columns .= $key;
            $values .= ':'.$key;
            $count++;
        }

        $pdo->beginTransaction();//トランザクション処理
        try {
            $sql = 'insert into users ('.$columns .')values('.$values.')';
            $stmt = $pdo->prepare($sql);
            $stmt->execute($params);
            $pdo->commit();
            header("location: login.php");
            exit;
        } catch (PDOException $e) {
            echo 'ERROR: Could not register.';
            $pdo->rollBack();
        }
    }
}
?>
 
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Sign Up</title>
    <!-- bootstrap読み込み -->
    <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css">
    <style>
        body{
            font: 14px sans-serif;
        }
        .wrapper{
            width: 400px;
            padding: 20px;
            margin: 0 auto;
        }
    </style>
</head>
<body>
    <div class="wrapper">
        <h2>Sign Up</h2>
        <p>Please fill this form to create an account.</p>
        <form action="<?php echo $_SERVER ['SCRIPT_NAME']; ?>" method="post">
            <div class="form-group">
                <label>Username</label>
                <input type="text" name="name" class="form-control <?php echo (!empty(h($errors['name']))) ? 'is-invalid' : ''; ?>" value="<?php echo h($datas['name']); ?>">
                <span class="invalid-feedback"><?php echo h($errors['name']); ?></span>
            </div>    
            <div class="form-group">
                <label>Password</label>
                <input type="password" name="password" class="form-control <?php echo (!empty(h($errors['password']))) ? 'is-invalid' : ''; ?>" value="<?php echo h($datas['password']); ?>">
                <span class="invalid-feedback"><?php echo h($errors['password']); ?></span>
            </div>
            <div class="form-group">
                <label>Confirm Password</label>
                <input type="password" name="confirm_password" class="form-control <?php echo (!empty(h($errors['confirm_password']))) ? 'is-invalid' : ''; ?>" value="<?php echo h($datas['confirm_password']); ?>">
                <span class="invalid-feedback"><?php echo h($errors['confirm_password']); ?></span>
            </div>
            <div class="form-group">
                <input type="hidden" name="token" value="<?php echo h($_SESSION['token']); ?>">
                <input type="submit" class="btn btn-primary" value="Submit">
            </div>
            <p>Already have an account? <a href="login.php">Login here</a>.</p>
        </form>
    </div>    
</body>
</html>
```
**CSRF対策について**
セッションを活用し、以下の流れで組み込んでいます。

- GET通信時には`setToken();`でセッション変数にtokenを追加
- `input:hidden`にもセッション変数のトークンをセットし、フォーム送信時にPOSTする
- POST通信時には`checkToken;`でPOSTされたtokenとセッションのtokenが同じか確認する
　

**PDOのsql処理部分について**
ここのsql文が少しわかりづらいと思いますので、補足です。

```php:register.phpの抜粋
$params = [
    'id' =>null,
    'name'=>$datas['name'],
    'password'=>password_hash($datas['password'], PASSWORD_DEFAULT),
    'created_at'=>null
];

$count = 0;
$columns = '';
$values = '';
foreach (array_keys($params) as $key) {
    if($count > 0){
        $columns .= ',';
        $values .= ',';
    }
    $columns .= $key;
    $values .= ':'.$key;
    $count++;
}
```
`$columns`と`$values`はsql文で利用する文字列がはいった変数です。

なお$columnsには、下記のような文字列が入り

```
$columns
→ id,name,password,createrd_at
```

なお$valuesには、下記の文字列が入ります

```
$values
→ :id,:name,:password,:createrd_at
```

以上を踏まえて、あえて変数の中身を直接書き出すとすると下記のような処理が実行されていることになります。

```php:register.phpの抜粋
$pdo->beginTransaction(); 
try {
    $sql = 'insert into users ( id,name,password,createrd_at )values(:id,:name,:password,:createrd_at)';
    $stmt = $pdo->prepare($sql);
    $stmt->execute($params);
    $pdo->commit();
    header("location: login.php");
    exit;
} catch (PDOException $e) {
    echo 'ERROR: Could not register.';
    $pdo->rollBack();
}
```

## login.php
続いてユーザーのログイン画面を作っていきます。

こんな感じのUIになります。
↓↓↓
![login.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1006362/48225319-7700-6f85-5aca-002209cb0a76.png)


```php:login.php
<?php
//ファイルの読み込み
require_once "db_connect.php";
require_once "functions.php";
//セッション開始
session_start();

// セッション変数 $_SESSION["loggedin"]を確認。ログイン済だったらウェルカムページへリダイレクト
if(isset($_SESSION["loggedin"]) && $_SESSION["loggedin"] === true){
    header("location: welcome.php");
    exit;
}

//POSTされてきたデータを格納する変数の定義と初期化
$datas = [
    'name'  => '',
    'password'  => '',
    'confirm_password'  => ''
];
$login_err = "";

//GET通信だった場合はセッション変数にトークンを追加
if($_SERVER['REQUEST_METHOD'] != 'POST'){
    setToken();
}

//POST通信だった場合はログイン処理を開始
if($_SERVER["REQUEST_METHOD"] == "POST"){
    ////CSRF対策
    checkToken();

    // POSTされてきたデータを変数に格納
    foreach($datas as $key => $value) {
        if($value = filter_input(INPUT_POST, $key, FILTER_DEFAULT)) {
            $datas[$key] = $value;
        }
    }

    // バリデーション
    $errors = validation($datas,false);
    if(empty($errors)){
        //ユーザーネームから該当するユーザー情報を取得
        $sql = "SELECT id,name,password FROM users WHERE name = :name";
        $stmt = $pdo->prepare($sql);
        $stmt->bindValue('name',$datas['name'],PDO::PARAM_INT);
        $stmt->execute();

        //ユーザー情報があれば変数に格納
        if($row = $stmt->fetch(PDO::FETCH_ASSOC)){
            //パスワードがあっているか確認
            if (password_verify($datas['password'],$row['password'])) {
                //セッションIDをふりなおす
                session_regenerate_id(true);
                //セッション変数にログイン情報を格納
                $_SESSION["loggedin"] = true;
                $_SESSION["id"] = $row['id'];
                $_SESSION["name"] =  $row['name'];
                //ウェルカムページへリダイレクト
                header("location:welcome.php");
                exit();
            } else {
                $login_err = 'Invalid username or password.';
            }
        }else {
            $login_err = 'Invalid username or password.';
        }
    }
}
?>
 
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Login</title>
    <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css">
    <style>
        body{
            font: 14px sans-serif;
        }
        .wrapper{
            width: 400px;
            padding: 20px;
            margin: 0 auto;
        }
    </style>
</head>
<body>
    <div class="wrapper">
        <h2>Login</h2>
        <p>Please fill in your credentials to login.</p>

        <?php 
        if(!empty($login_err)){
            echo '<div class="alert alert-danger">' . $login_err . '</div>';
        }        
        ?>

        <form action="<?php echo $_SERVER ['SCRIPT_NAME']; ?>" method="post">
            <div class="form-group">
                <label>Username</label>
                <input type="text" name="name" class="form-control <?php echo (!empty(h($errors['name']))) ? 'is-invalid' : ''; ?>" value="<?php echo h($datas['name']); ?>">
                <span class="invalid-feedback"><?php echo h($errors['name']); ?></span>
            </div>    
            <div class="form-group">
                <label>Password</label>
                <input type="password" name="password" class="form-control <?php echo (!empty(h($errors['password']))) ? 'is-invalid' : ''; ?>" value="<?php echo h($datas['password']); ?>">
                <span class="invalid-feedback"><?php echo h($errors['password']); ?></span>
            </div>
            <div class="form-group">
                <input type="hidden" name="token" value="<?php echo h($_SESSION['token']); ?>">
                <input type="submit" class="btn btn-primary" value="Login">
            </div>
            <p>Don't have an account? <a href="register.php">Sign up now</a></p>
        </form>
    </div>
</body>
</html>
```
**ログイン処理について**
上記コードのなかで特に大事なのが下記処理です。
なにをしているのか確認します。

```php:login.phpの抜粋
$sql = "SELECT id,name,password FROM users WHERE name = :name";
$stmt = $pdo->prepare($sql);
$stmt->bindValue('name',$datas['name'],PDO::PARAM_INT);
$stmt->execute();

if($row = $stmt->fetch(PDO::FETCH_ASSOC)){
    if (password_verify($datas['password'],$row['password'])) {
        session_regenerate_id(true);

        $_SESSION["loggedin"] = true;
        $_SESSION["id"] = $row['id'];
        $_SESSION["name"] =  $row['name'];

        header("location:welcome.php"); // Redirect to welcome page
        exit();
    } else {
        $login_err = 'Invalid username or password.';
    }
}else {
    $login_err = 'Invalid username or password.';
}
```

まず、こちらのコードではPOST通信で送られてきたユーザーネームと同じデータをDBから探す処理をしています。

```php:login.phpの抜粋

$sql = "SELECT id,name,password FROM users WHERE name = :name";
$stmt = $pdo->prepare($sql);
$stmt->bindValue('name',$datas['name'],PDO::PARAM_INT);
$stmt->execute();
```

つぎにこちらのif文ではユーザー情報をDBから取得して、変数$rowに格納しています。
ユーザー情報が取得できなかった場合はエラーを返します。

```php:login.phpの抜粋
if($row = $stmt->fetch(PDO::FETCH_ASSOC)){
//省略
}else {
    $login_err = 'Invalid username or password.';
}
```

取得されたユーザー情報にはハッシュ化されたパスワードが含まれているので組み込み関数である[password_verify](https://www.php.net/manual/ja/function.password-verify.php)を用いてマッチするか判別します。
POSTされたパスワードが正しくない場合、エラーを返します。

```php:login.phpの抜粋
if (password_verify($datas['password'],$row['password'])) {
//省略
}else {
    $login_err = 'Invalid username or password.';
}
```

POSTされたユーザー名とパスワードがどちらも正しい場合、下記の部分でセッション変数にログイン済み情報とユーザー情報を格納します。
そして最後にウェルカムページにリダイレクトさせます。

```php:login.phpの抜粋
session_regenerate_id(true);

$_SESSION["loggedin"] = true;
$_SESSION["id"] = $row['id'];
$_SESSION["name"] =  $row['name'];

header("location:welcome.php"); // Redirect to welcome page
exit();
```

## welcome.php
ログイン後のウェルカムページを作ります。

画面はこんな感じです
↓↓↓
![welcome.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1006362/eaea121f-c873-6e48-43a5-c2bb292e097d.png)

```php:welcome.php
<?php
session_start();
// セッション変数 $_SESSION["loggedin"]を確認。ログイン済だったらウェルカムページへリダイレクト
if(!isset($_SESSION["loggedin"]) || $_SESSION["loggedin"] !== true){
    header("location: login.php");
    exit;
}
?>
 
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Welcome</title>
    <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css">
    <style>
        body{ 
            font: 14px sans-serif;
            text-align: center; 
        }
    </style>
</head>
<body>
    <h1 class="my-5">Hi,<b><?php echo htmlspecialchars($_SESSION["name"]); ?></b>. Welcome to our site.</h1>
    <p>
        <a href="logout.php" class="btn btn-danger ml-3">Sign Out of Your Account</a>
    </p>
</body>
</html>
```

## logout.php
最後にログアウト処理を実装します。

```php:logout.php
<?php
session_start();

//セッション変数の削除
$_SESSION = array();
//セッション削除
session_destroy();

//ログインページへリダイレクト
header("location: login.php");
exit;
```

## 参考にさせていただいた記事
今回、セッションベースのログイン認証を学習するにあたり参考にさせて頂いた記事です。
本当にありがとうございましたm(__)m

- [PHP MySQL Login System](https://www.tutorialrepublic.com/php-tutorial/php-mysql-login-system.php)
- [PHPとMySQLで新規登録とログインを実装する](https://qiita.com/KosukeQiita/items/b56b3004413c999b9858)
- [PHPでクロスサイトリクエストフォージェリ（CSRF）対策する](https://qiita.com/yoh-nak/items/c264d29eb25f4df7f19e)
- [MySQLへ接続](https://www.javadrive.jp/php/pdo/index3.html)
- [言語別：パスワード向けの正規表現](https://qiita.com/mpyw/items/886218e7b418dfed254b)
- [ログイン時のCSRF対策は必要か](https://qiita.com/khsk/items/f654ddddc249f1f7c5e2)
- [パスワードのハッシュ化とログイン画面](https://qiita.com/hththt/items/af7fb0806397704b2a94)
- [PHP のセッションを使ったログイン認証はなぜ安全なのか？](https://blog.apar.jp/php/12373/)
- [MySQLへ接続](https://www.javadrive.jp/php/pdo/index3.html)