FingerPrintJS＋React＋FastAPIで異なる環境からのアクセス検出

NETFLIXとかアマプラとかその他諸々のサービスでブラウザが変わったりすると、登録しているメールアドレス宛にセキュリティ警告的なメールが届くと思いますが、今回はFingerprintjsを利用して実装してみたいと思います。

Fingerprintの詳細は以下ページよりご確認ください。

https://fingerprint.com/identify-now/

:::note warn
**注意事項**
テスト・検証であれば無料で利用できますが、それ以外は商用ラインセンスが必要となりますのでお気をつけください。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2335933/7614815a-74ab-528e-9fdb-1774cae3a270.png)
:::

詳細は下記ページよりご確認ください。

https://github.com/fingerprintjs/fingerprintjs?tab=readme-ov-file#license



# 前提
コンテナ環境での動作確認となります。前回の記事をベースに作業をしていますのでまずはこちらの記事を参考にコンテナを作成してください。

https://qiita.com/nw-engineer/items/2eb58b07b16937e32fbc#1-%E3%82%B3%E3%83%B3%E3%83%86%E3%83%8A%E4%BD%9C%E6%88%90

# 1. API作成
コンテナにログイン後、以下コマンドでライブラリをインストールしておきましょう。
```bash
pip3 install fastapi uvicorn
```

続いて、main.pyを作成します。
```python:main.py
"""
ユーザー認証、FingerPrint、および電子メール通知を実行の為各種
モジュールをImport
"""
import smtplib
import sqlite3
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart

from pydantic import BaseModel
from fastapi import FastAPI, Depends, HTTPException, Request
from fastapi.middleware.cors import CORSMiddleware

# ログイン用テストユーザ情報
fake_users_db = {
    "user1": {
        "username": "user1",
        "password": "password1",
    },
    "user2": {
        "username": "user2",
        "password": "password2",
    },
}

app = FastAPI()

app.add_middleware(
    CORSMiddleware,
    allow_origins=["*"],
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)

class LoginRequest(BaseModel):
    """
    Loginユーザ用クラス
    """
    username: str
    password: str

class FingerprintCreate(BaseModel):
    """
    FingerprintInser用クラス
    """
    visitor_id: str
    timestamp: str
    username: str

def init_db():
    """
    データベース初期化
    """
    conn = sqlite3.connect("fingerprints.db")
    cursor = conn.cursor()
    cursor.execute("""
        CREATE TABLE IF NOT EXISTS fingerprints (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            visitor_id TEXT,
            ip_address TEXT,
            timestamp TEXT,
            username TEXT
        )
    """)
    conn.commit()
    conn.close()

init_db()


def send_email(subject: str, body: str, to_email: str):
    """
    FingerPrint値が違う場合、管理者にメールを送信する関数
    """
    from_email = "xxxxxx.xxxxx@live-est.com"  # 送信用メールアドレス。ご自身の環境に合わせてください。
    from_password = "xxxxxxxxxx"              # メールパスワード

    msg = MIMEMultipart()
    msg['From'] = from_email
    msg['To'] = to_email
    msg['Subject'] = subject

    msg.attach(MIMEText(body, 'plain'))

    try:
        server = smtplib.SMTP('live-est.com', 587)  # SMTPサーバ。ご自身の環境に合わせてください。
        server.starttls()
        server.login(from_email, from_password)
        text = msg.as_string()
        server.sendmail(from_email, to_email, text)
        server.quit()
        print("Email sent successfully!")
    except smtplib.SMTPException as e:
        print(f"Failed to send email: {e}")

def get_db():
    """
    リクエスト ハンドラーで使用する SQLite データベース接続を生成します。
    リクエストが完了したら接続を閉じます。
    """
    conn = sqlite3.connect("fingerprints.db", check_same_thread=False)
    try:
        yield conn
    finally:
        conn.close()

@app.post("/login/")
def login(login_request: LoginRequest):
    """
    ユーザーのログインを処理し、成功した場合はユーザ名を返却します。
    """
    user = fake_users_db.get(login_request.username)
    if user and user["password"] == login_request.password:
        return {"username": user["username"]}
    else:
        raise HTTPException(status_code=401, detail="Invalid username or password")

@app.post("/fingerprints/")
def create_fingerprint(fingerprint: FingerprintCreate, request: Request,
                       db: sqlite3.Connection = Depends(get_db)):
    """
    FingerPrint値が違う場合警告メールを管理者へ送信する
    """
    client_ip = request.client.host
    cursor = db.cursor()

    cursor.execute(
        "SELECT visitor_id FROM fingerprints WHERE username = ? ORDER BY id DESC LIMIT 1",
        (fingerprint.username,)
    )
    previous_fingerprint = cursor.fetchone()

    if previous_fingerprint and previous_fingerprint[0] != fingerprint.visitor_id:
        subject = "Alert: Fingerprint Change Detected"
        body = (
            f"{fingerprint.username}は異なる環境からのアクセスが検出されました。\n\n"
            f"以前のFingerPrint値: {previous_fingerprint[0]}\n"
            f"新しいFingerprint: {fingerprint.visitor_id}\n"
            f"検出されたIPアドレス: {client_ip}"
        )
        to_email = "xxxxxx.xxxxxx@live-est.com"        # 管理者用メールアドレス。ご自身の環境に合わせてください。
        send_email(subject, body, to_email)

    cursor.execute(
        "INSERT INTO fingerprints (visitor_id, ip_address, timestamp, username) "
        "VALUES (?, ?, ?, ?)",
        (fingerprint.visitor_id, client_ip, fingerprint.timestamp, fingerprint.username)
    )
    db.commit()
    fingerprint_id = cursor.lastrowid

    return {
        "id": fingerprint_id,
        "visitor_id": fingerprint.visitor_id,
        "ip_address": client_ip,
        "timestamp": fingerprint.timestamp,
        "username": fingerprint.username
    }
```
:::note info
**main.pyについて**
- Reactからはログイン用のエンドポイントとFingerPrint/IP/時刻/usernameを受け付けるPOST用のエンドポイントを作成
- これに伴い、Sqliteを使ったDB作成/ログイン認証処理(簡易)/前回利用時のFingerPrint値との突合/メール送信処理といった具合にそれぞれ作成
- メール送信用アドレス、パスワード、SMTPサーバ、通知先メールアドレス等は自身の環境に変更してください
- ※あくまでFingerPrintを使った異なる環境からのアクセス検出を行うためだけの簡易コードですのであしからず
:::



作成したら以下コマンドで起動します。

```bash
uvicorn main:app --host 0.0.0.0 --port 4002
```

# 2. Reactコード作成
では、次はReactのコードになります。いつも通り必要となるpackageを事前にインストールします。
```bash
npm install @fingerprintjs/fingerprintjs
npm install @mui/material @emotion/react @emotion/styled
npm install @mui/icons-material
```
それでは、 *src/App.js* を変更しましょう。
```react:src/App.js
import React, { useState, useEffect } from 'react';
import FingerprintJS from '@fingerprintjs/fingerprintjs';
import Avatar from '@mui/material/Avatar';
import Button from '@mui/material/Button';
import TextField from '@mui/material/TextField';
import Typography from '@mui/material/Typography';
import Container from '@mui/material/Container';
import Box from '@mui/material/Box';
import CssBaseline from '@mui/material/CssBaseline';
import LockOutlinedIcon from '@mui/icons-material/LockOutlined';
import { createTheme, ThemeProvider } from '@mui/material/styles';

const theme = createTheme();

function App() {
  const [username, setUsername] = useState(null);
  const [fingerprint, setFingerprint] = useState('');

  const handleLogin = async (event) => {
    event.preventDefault();
    const formData = new FormData(event.target);
    const username = formData.get('username');
    const password = formData.get('password');

    const response = await fetch('http://x.x.x.x:4002/login/', {  // dockerホストのIP
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({ username, password }),
    });

    if (response.ok) {
      const data = await response.json();
      setUsername(data.username);
    } else {
      alert('Login failed');
    }
  };

  useEffect(() => {
    if (username) {
      const fpPromise = FingerprintJS.load();

      fpPromise
        .then(fp => fp.get())
        .then(result => {
          const visitorId = result.visitorId;
          setFingerprint(visitorId);

          fetch('http://x.x.x.x:4002/fingerprints/', {  // dockerホストのIP
            method: 'POST',
            headers: {
              'Content-Type': 'application/json',
            },
            body: JSON.stringify({
              visitor_id: visitorId,
              timestamp: new Date().toISOString(),
              username: username
            }),
          });
        });
    }
  }, [username]);

  if (!username) {
    return (
      <ThemeProvider theme={theme}>
        <Container component="main" maxWidth="xs">
          <CssBaseline />
          <Box
            sx={{
              marginTop: 8,
              display: 'flex',
              flexDirection: 'column',
              alignItems: 'center',
            }}
          >
            <Avatar sx={{ m: 1, bgcolor: 'secondary.main' }}>
              <LockOutlinedIcon />
            </Avatar>
            <Typography component="h1" variant="h5">
              Login
            </Typography>
            <Box component="form" onSubmit={handleLogin} noValidate sx={{ mt: 1 }}>
              <TextField
                margin="normal"
                required
                fullWidth
                id="username"
                label="Username"
                name="username"
                autoComplete="username"
                autoFocus
              />
              <TextField
                margin="normal"
                required
                fullWidth
                name="password"
                label="Password"
                type="password"
                id="password"
                autoComplete="current-password"
              />
              <Button
                type="submit"
                fullWidth
                variant="contained"
                sx={{ mt: 3, mb: 2 }}
              >
                Login
              </Button>
            </Box>
          </Box>
        </Container>
      </ThemeProvider>
    );
  }

  return (
    <Container component="main" maxWidth="xs">
      <Box
        sx={{
          marginTop: 8,
          display: 'flex',
          flexDirection: 'column',
          alignItems: 'center',
        }}
      >
        <Typography component="h1" variant="h5">
          あなたのFingerprintIDです。
        </Typography>
        <Typography variant="body1" sx={{ mt: 2 }}>
          {fingerprint || : 'Loading...'}
        </Typography>
      </Box>
    </Container>
  );
}

export default App;
```

Sourceも変更できたので、これでアプリを起動できます。いつも通り、起動前に.envファイルを作成して起動するポートを4001番に変更します。
```abl:.env
PORT=4001
```

では下記コマンドで起動しましょう。
```bash
npm start
```

# 3. アプリケーション確認
ブラウザでアクセスします。*http://x.x.x.x:4001* でアクセスしてAPIに記載しているユーザ(user1/passowrd1)でログインしましょう。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2335933/856efb3a-1e7a-e7cd-09df-08b09d5b6264.png)

FingerPrint値が表示されます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2335933/e27633ee-fa68-91e7-c0d7-8489dbbe69a3.png)


続いて、別ブラウザから同様に実施します。FingerPrintの値が異なっているのがわかります。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2335933/18a75586-024f-1106-c611-8f04040de5f2.png)

メールを確認すると、以下のように検出された結果がわかります。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2335933/6ce18de1-57b4-4af2-1225-6049b4553fad.png)

# 4. その他
異なる環境からのアクセス検出を取り入れる際、お役に立てば幸いです。