Sinatraで作る中高生向け悩み相談Webサービス「Ballon」- ユーザー認証システムの実装 (Part 2)

## はじめに
前回の Part 1 では、中高生向け悩み相談Webサービス「Ballon」の概要、技術選定の理由、基本的な設計について説明しました。今回の Part 2 では、ユーザー認証システムの実装に焦点を当てます。セキュアなパスワード管理、セッション管理、そしてユーザーのサインアップ、サインイン、サインアウトの実装方法を詳しく解説します。また、中高生向けサービスならではの配慮事項についても触れていきます。

## ユーザーモデルの設計

まず、ユーザーモデルを設計します。`models.rb` ファイルに以下のコードを追加します：

```ruby:models.rb
class User < ActiveRecord::Base
  has_secure_password
  validates :username, 
    presence: true, 
    uniqueness: true, 
    format: { with: /\A[a-zA-Z0-9_]+\z/, message: "can only contain letters, numbers, and underscores" }
  validates :email,
    presence: true,
    format: { with: /.+@.+/, message: "must be a valid email address" },
    uniqueness: { case_sensitive: false }
  validates :password,
    length: { in: 8..20, message: "must be between 8 and 20 characters" },
    format: { with: /\A(?=.*?[A-Z])(?=.*?[a-z])(?=.*?[0-9])(?=.*?[#?!@$%^&*-])/, message: "must include at least one uppercase letter, one lowercase letter, one number, and one special character" },
    if: -> { new_record? || changes[:password_digest] }
  validates :birth_date, presence: true
  validate :age_validation

  has_many :worries
  has_many :answers
  has_many :likes

  private

  def age_validation
    if birth_date.present? && birth_date > 13.years.ago.to_date
      errors.add(:birth_date, "You must be at least 13 years old to use this service")
    end
  end
end
```

このコードの各部分について説明します：

1. `has_secure_password`: 
   - これは Active Record が提供するメソッドで、安全なパスワード管理を実現します。
   - パスワードのハッシュ化、パスワードの確認（confirmation）、認証（authenticate）メソッドを自動的に提供します。

2. バリデーション:
   - `username` のバリデーション:
     - 存在チェック、一意性チェック
     - フォーマットチェック（英数字とアンダースコアのみ許可）
   - `email` のバリデーション:
     - 存在チェック、フォーマットチェック、一意性チェック（大文字小文字を区別せず）
   - `password` のバリデーション:
     - 長さチェック（8文字以上20文字以下）
     - 複雑性チェック（大文字、小文字、数字、特殊文字を含む）
   - `birth_date` のバリデーション:
     - 存在チェック
     - 年齢チェック（13歳以上であることを確認）

3. アソシエーション:
   - `has_many :worries`, `has_many :answers`, `has_many :likes`: ユーザーは複数の悩み、回答、いいねを持つことを定義

## ユーザー認証の実装

次に、`app.rb` にユーザー認証に関するルーティングとロジックを実装します。

```ruby:app.rb
# サインアップ
get '/signup' do
  erb :sign_up
end

post '/signup' do
  @user = User.new(
    username: params[:username],
    email: params[:email],
    password: params[:password],
    password_confirmation: params[:password_confirmation],
    birth_date: params[:birth_date]
  )
  if @user.save
    session[:user_id] = @user.id
    redirect '/'
  else
    @errors = @user.errors.full_messages
    erb :sign_up
  end
end

# サインイン
get '/signin' do
  erb :sign_in
end

post '/signin' do
  user = User.find_by(email: params[:email])
  if user && user.authenticate(params[:password])
    session[:user_id] = user.id
    redirect '/'
  else
    @error = "Invalid email or password"
    erb :sign_in
  end
end

# サインアウト
get '/signout' do
  session.clear
  redirect '/'
end

# パスワードリセット（メール送信）
post '/password_reset' do
  user = User.find_by(email: params[:email])
  if user
    # パスワードリセットトークンを生成し、有効期限を設定
    user.generate_password_reset_token
    # メール送信処理（実際にはここでメール送信のロジックを実装）
    "Password reset instructions have been sent to your email."
  else
    "If an account with that email exists, we have sent password reset instructions."
  end
end

# パスワードリセット（新しいパスワードの設定）
post '/password_reset/:token' do
  user = User.find_by(password_reset_token: params[:token])
  if user && user.password_reset_token_valid?
    if user.update(password: params[:password], password_confirmation: params[:password_confirmation])
      "Your password has been reset successfully."
    else
      "Password reset failed. Please try again."
    end
  else
    "Invalid or expired password reset token."
  end
end
```

各ルーティングの詳細説明：

1. サインアップ（`/signup`）:
   - GET リクエスト: サインアップフォームを表示
   - POST リクエスト: 
     - 新しい `User` オブジェクトを作成
     - 保存に成功したら、セッションにユーザーIDを保存してホームページにリダイレクト
     - 失敗した場合は、エラーメッセージを表示してサインアップページを再表示

2. サインイン（`/signin`）:
   - GET リクエスト: サインインフォームを表示
   - POST リクエスト:
     - メールアドレスでユーザーを検索
     - `authenticate` メソッドでパスワードを検証
     - 認証成功時はセッションにユーザーIDを保存してホームページにリダイレクト
     - 失敗時はエラーメッセージを表示してサインインページを再表示

3. サインアウト（`/signout`）:
   - セッションをクリアしてホームページにリダイレクト

4. パスワードリセット:
   - メール送信: ユーザーのメールアドレスを受け取り、パスワードリセット用のトークンを生成してメールを送信
   - 新しいパスワードの設定: トークンの有効性を確認し、新しいパスワードを設定

## ビューの実装

サインアップとサインインのフォームを実装します。

```erb:views/sign_up.erb
<h2>Sign Up</h2>

<% if @errors %>
  <ul>
    <% @errors.each do |error| %>
      <li><%= error %></li>
    <% end %>
  </ul>
<% end %>

<form action="/signup" method="post">
  <div>
    <label for="username">Username:</label>
    <input type="text" name="username" required pattern="[a-zA-Z0-9_]+" title="Username can only contain letters, numbers, and underscores">
  </div>
  <div>
    <label for="email">Email:</label>
    <input type="email" name="email" required>
  </div>
  <div>
    <label for="password">Password:</label>
    <input type="password" name="password" required pattern="(?=.*\d)(?=.*[a-z])(?=.*[A-Z])(?=.*[!@#$%^&*]).{8,}" title="Must contain at least one number, one uppercase and lowercase letter, one special character, and at least 8 or more characters">
  </div>
  <div>
    <label for="password_confirmation">Confirm Password:</label>
    <input type="password" name="password_confirmation" required>
  </div>
  <div>
    <label for="birth_date">Birth Date:</label>
    <input type="date" name="birth_date" required>
  </div>
  <div>
    <input type="submit" value="Sign Up">
  </div>
</form>
```

```erb:views/sign_in.erb
<h2>Sign In</h2>

<% if @error %>
  <p><%= @error %></p>
<% end %>

<form action="/signin" method="post">
  <div>
    <label for="email">Email:</label>
    <input type="email" name="email" required>
  </div>
  <div>
    <label for="password">Password:</label>
    <input type="password" name="password" required>
  </div>
  <div>
    <input type="submit" value="Sign In">
  </div>
</form>

<p><a href="/password_reset">Forgot your password?</a></p>
```

## セキュリティ考慮事項

1. パスワードのハッシュ化:
   - `has_secure_password` を使用することで、パスワードは自動的にハッシュ化されてデータベースに保存されます。
   - 平文のパスワードはデータベースに保存されません。

2. セッション管理:
   - セッションIDを使用してユーザーを識別します。
   - セッションデータはサーバーサイドで保管され、クライアントにはセッションIDのみが送られます。

3. CSRF（Cross-Site Request Forgery）対策:
   - Sinatraには標準でCSRF対策が含まれていないため、追加の対策が必要です。
   - 例えば、`rack-protection` gemを使用して以下のように設定できます：

   ```ruby:app.rb
   require 'rack/protection'
   use Rack::Protection::AuthenticityToken
   ```

4. SSL/TLS:
   - 本番環境では、HTTPSを使用してすべての通信を暗号化することが重要です。

5. ブルートフォース攻撃対策:
   - ログイン試行回数を制限する機能を実装することで、ブルートフォース攻撃のリスクを軽減できます。

6. 年齢確認:
   - ユーザーの生年月日を入力させ、13歳未満のユーザーの登録を防ぎます。

7. 保護者の同意:
   - 13歳以上18歳未満のユーザーに対しては、保護者の同意を得るプロセスを実装することを検討します。

```ruby:app.rb
post '/signup' do
  @user = User.new(user_params)
  if @user.save
    if @user.age < 18
      # 保護者の同意プロセスを開始
      redirect '/parental_consent'
    else
      session[:user_id] = @user.id
      redirect '/'
    end
  else
    @errors = @user.errors.full_messages
    erb :sign_up
  end
end

get '/parental_consent' do
  # 保護者の同意フォームを表示
  erb :parental_consent
end

post '/parental_consent' do
  # 保護者の同意を処理
  # 例: メール送信や同意確認のプロセスを実装
  session[:user_id] = @user.id
  redirect '/'
end
```

## まとめと次回予告

Part 2では、Sinatraを使用した中高生向け悩み相談Webサービス「Ballon」のユーザー認証システムの実装について詳しく解説しました。`has_secure_password`の使用方法、セッション管理、そして中高生向けサービスならではのセキュリティ上の重要な考慮事項についても触れました。

次回のPart 3では、悩み投稿機能の実装とデータベース設計の詳細に焦点を当てます。悩みの作成、表示、編集、削除（CRUD操作）の実装方法や、効率的なデータベースクエリの書き方などを解説する予定です。また、中高生の悩みに対する適切な対応や、センシティブな内容のモデレーションについても触れていきます。

ご質問やフィードバックがありましたら、コメント欄にてお待ちしています！

## 参考リンク

- [has_secure_password（Active Recordのドキュメント）](https://api.rubyonrails.org/classes/ActiveModel/SecurePassword/ClassMethods.html)
- [Sinatra: セキュリティ](http://sinatrarb.com/security.html)
- [Rack::Protection](https://github.com/sinatra/rack-protection)
- [COPPA (Children's Online Privacy Protection Act)](https://www.ftc.gov/enforcement/rules/rulemaking-regulatory-reform-proceedings/childrens-online-privacy-protection-rule)