Browse Source

BAN の実装 (#327) (#342)

#327

#327

#327

#327

Merge remote-tracking branch 'origin/main' into feature/327

#327

Co-authored-by: miteruzo <miteruzo@naver.com>
Reviewed-on: https://git.miteruzo.com/miteruzo/btrc-hub/pulls/342
main
みてるぞ 2 days ago
parent
commit
b47cdc7ad7
10 changed files with 286 additions and 76 deletions
  1. +18
    -3
      backend/app/controllers/application_controller.rb
  2. +1
    -5
      backend/app/controllers/users_controller.rb
  3. +4
    -1
      backend/app/models/ip_address.rb
  4. +5
    -1
      backend/app/models/user.rb
  5. +16
    -0
      backend/db/migrate/20260501153900_rename_banned_to_banned_at_in_users_and_ip_addresses.rb
  6. +5
    -3
      backend/db/schema.rb
  7. +10
    -0
      backend/spec/factories/ip_addresses.rb
  8. +12
    -3
      backend/spec/factories/users.rb
  9. +213
    -56
      backend/spec/requests/users_spec.rb
  10. +2
    -4
      backend/spec/support/test_records.rb

+ 18
- 3
backend/app/controllers/application_controller.rb View File

@@ -1,14 +1,16 @@
class ApplicationController < ActionController::API class ApplicationController < ActionController::API
before_action :reject_banned_ip_address!
before_action :authenticate_user before_action :authenticate_user
before_action :reject_banned_user!


def current_user
@current_user
end
def current_user = @current_user


private private


def authenticate_user def authenticate_user
code = request.headers['X-Transfer-Code'] || request.headers['HTTP_X_TRANSFER_CODE'] code = request.headers['X-Transfer-Code'] || request.headers['HTTP_X_TRANSFER_CODE']
return if code.blank?

@current_user = User.find_by(inheritance_code: code) @current_user = User.find_by(inheritance_code: code)
end end


@@ -22,4 +24,17 @@ class ApplicationController < ActionController::API
s.in?(['', '1', 'true', 'on', 'yes']) s.in?(['', '1', 'true', 'on', 'yes'])
end end
end end

def reject_banned_ip_address!
ip_address = IpAddress.find_by(ip_address: IPAddr.new(request.remote_ip).hton)
return unless ip_address&.banned?

head :forbidden
end

def reject_banned_user!
return unless current_user&.banned?

head :forbidden
end
end end

+ 1
- 5
backend/app/controllers/users_controller.rb View File

@@ -1,9 +1,6 @@
class UsersController < ApplicationController class UsersController < ApplicationController
def create def create
return head :unprocessable_entity if request.remote_ip.blank?

user = nil user = nil

User.transaction do User.transaction do
user = User.create!(inheritance_code: SecureRandom.uuid, role: :guest) user = User.create!(inheritance_code: SecureRandom.uuid, role: :guest)
attach_ip_address!(user) attach_ip_address!(user)
@@ -17,8 +14,7 @@ class UsersController < ApplicationController
def verify def verify
user = User.find_by(inheritance_code: params[:code]) user = User.find_by(inheritance_code: params[:code])
return render json: { valid: false } unless user return render json: { valid: false } unless user

return head :unprocessable_entity if request.remote_ip.blank?
return head :forbidden if user.banned?


attach_ip_address!(user) attach_ip_address!(user)




+ 4
- 1
backend/app/models/ip_address.rb View File

@@ -1,7 +1,10 @@
class IpAddress < ApplicationRecord class IpAddress < ApplicationRecord
validates :ip_address, presence: true, length: { maximum: 16 } validates :ip_address, presence: true, length: { maximum: 16 }
validates :banned, inclusion: { in: [true, false] }


has_many :user_ips, dependent: :destroy has_many :user_ips, dependent: :destroy
has_many :users, through: :user_ips has_many :users, through: :user_ips

def banned? = banned_at.present?
def ban! = banned? || update!(banned_at: Time.current)
def unban! = update!(banned_at: nil)
end end

+ 5
- 1
backend/app/models/user.rb View File

@@ -4,7 +4,6 @@ class User < ApplicationRecord
validates :name, length: { maximum: 255 } validates :name, length: { maximum: 255 }
validates :inheritance_code, presence: true, length: { maximum: 64 } validates :inheritance_code, presence: true, length: { maximum: 64 }
validates :role, presence: true, inclusion: { in: roles.keys } validates :role, presence: true, inclusion: { in: roles.keys }
validates :banned, inclusion: { in: [true, false] }


has_many :created_posts, has_many :created_posts,
class_name: 'Post', foreign_key: :uploaded_user_id, dependent: :nullify class_name: 'Post', foreign_key: :uploaded_user_id, dependent: :nullify
@@ -19,5 +18,10 @@ class User < ApplicationRecord
class_name: 'WikiPage', foreign_key: :updated_user_id, dependent: :nullify class_name: 'WikiPage', foreign_key: :updated_user_id, dependent: :nullify


def viewed?(post) = user_post_views.exists?(post_id: post.id) def viewed?(post) = user_post_views.exists?(post_id: post.id)

def gte_member? = member? || admin? def gte_member? = member? || admin?

def banned? = banned_at.present?
def ban! = banned? || update!(banned_at: Time.current)
def unban! = update!(banned_at: nil)
end end

+ 16
- 0
backend/db/migrate/20260501153900_rename_banned_to_banned_at_in_users_and_ip_addresses.rb View File

@@ -0,0 +1,16 @@
class RenameBannedToBannedAtInUsersAndIpAddresses < ActiveRecord::Migration[8.0]
def up
[:users, :ip_addresses].each do
add_column _1, :banned_at, :datetime, after: :banned
add_index _1, :banned_at
remove_column _1, :banned
end
end

def down
[:ip_addresses, :users].each do
add_column _1, :banned, :boolean, null: false, default: false, after: :banned_at
remove_column _1, :banned_at
end
end
end

+ 5
- 3
backend/db/schema.rb View File

@@ -10,7 +10,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # It's strongly recommended that you check this file into your version control system.


ActiveRecord::Schema[8.0].define(version: 2026_04_27_214800) do
ActiveRecord::Schema[8.0].define(version: 2026_05_01_153900) do
create_table "active_storage_attachments", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| create_table "active_storage_attachments", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.string "name", null: false t.string "name", null: false
t.string "record_type", null: false t.string "record_type", null: false
@@ -50,9 +50,10 @@ ActiveRecord::Schema[8.0].define(version: 2026_04_27_214800) do


create_table "ip_addresses", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| create_table "ip_addresses", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.binary "ip_address", limit: 16, null: false t.binary "ip_address", limit: 16, null: false
t.boolean "banned", default: false, null: false
t.datetime "banned_at"
t.datetime "created_at", null: false t.datetime "created_at", null: false
t.datetime "updated_at", null: false t.datetime "updated_at", null: false
t.index ["banned_at"], name: "index_ip_addresses_on_banned_at"
t.index ["ip_address"], name: "index_ip_addresses_on_ip_address", unique: true t.index ["ip_address"], name: "index_ip_addresses_on_ip_address", unique: true
end end


@@ -332,9 +333,10 @@ ActiveRecord::Schema[8.0].define(version: 2026_04_27_214800) do
t.string "name" t.string "name"
t.string "inheritance_code", limit: 64, null: false t.string "inheritance_code", limit: 64, null: false
t.string "role", null: false t.string "role", null: false
t.boolean "banned", default: false, null: false
t.datetime "banned_at"
t.datetime "created_at", null: false t.datetime "created_at", null: false
t.datetime "updated_at", null: false t.datetime "updated_at", null: false
t.index ["banned_at"], name: "index_users_on_banned_at"
end end


create_table "wiki_assets", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| create_table "wiki_assets", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|


+ 10
- 0
backend/spec/factories/ip_addresses.rb View File

@@ -0,0 +1,10 @@
FactoryBot.define do
factory :ip_address do
ip_address { IPAddr.new('203.0.113.10').hton }
banned_at { nil }

trait :banned do
banned_at { Time.current }
end
end
end

+ 12
- 3
backend/spec/factories/users.rb View File

@@ -1,15 +1,24 @@
FactoryBot.define do FactoryBot.define do
factory :user do factory :user do
name { "test-user" }
name { nil }
inheritance_code { SecureRandom.uuid } inheritance_code { SecureRandom.uuid }
role { "guest" }
role { 'guest' }
banned_at { nil }

trait :guest do
role { 'guest' }
end


trait :member do trait :member do
role { "member" }
role { 'member' }
end end


trait :admin do trait :admin do
role { 'admin' } role { 'admin' }
end end

trait :banned do
banned_at { Time.current }
end
end end
end end

+ 213
- 56
backend/spec/requests/users_spec.rb View File

@@ -1,109 +1,266 @@
require "rails_helper"
require 'rails_helper'

RSpec.describe 'Users', type: :request do
let(:remote_ip) { '203.0.113.10' }

before do
allow_any_instance_of(ActionDispatch::Request)
.to receive(:remote_ip)
.and_return(remote_ip)
end

def auth_headers(user)
{ 'X-Transfer-Code' => user.inheritance_code }
end

describe 'POST /users' do
it 'creates guest user, IpAddress and UserIp, and returns code' do
expect {
post '/users'
}.to change(User, :count).by(1)
.and change(IpAddress, :count).by(1)
.and change(UserIp, :count).by(1)


RSpec.describe "Users", type: :request do
describe "POST /users" do
it "creates guest user and returns code" do
post "/users"
expect(response).to have_http_status(:created) expect(response).to have_http_status(:created)
expect(json["code"]).to be_present
expect(json["user"]["role"]).to eq("guest")
expect(json['code']).to be_present
expect(json['user']['role']).to eq('guest')

user = User.last
ip_address = IpAddress.find_by(ip_address: IPAddr.new(remote_ip).hton)

expect(user.role).to eq('guest')
expect(ip_address).to be_present
expect(UserIp.exists?(user:, ip_address:)).to eq(true)
end

it 'returns 403 and does not create user when current IP address is banned' do
IpAddress.create!(
ip_address: IPAddr.new(remote_ip).hton,
banned_at: Time.current
)

expect {
post '/users'
}.not_to change(User, :count)

expect(response).to have_http_status(:forbidden)
expect(UserIp.count).to eq(0)
end end
end end


describe "POST /users/code/renew" do
it "returns 401 when not logged in" do
sign_out
post "/users/code/renew"
describe 'POST /users/code/renew' do
it 'returns 401 when not logged in' do
post '/users/code/renew'
expect(response).to have_http_status(:unauthorized) expect(response).to have_http_status(:unauthorized)
end end

it 'returns 403 when current user is banned' do
user = create(:user, :banned)

post '/users/code/renew', headers: auth_headers(user)

expect(response).to have_http_status(:forbidden)
end

it 'returns 403 when current IP address is banned' do
user = create(:user)

IpAddress.create!(
ip_address: IPAddr.new(remote_ip).hton,
banned_at: Time.current
)

post '/users/code/renew', headers: auth_headers(user)

expect(response).to have_http_status(:forbidden)
end
end end


describe "PUT /users/:id" do
let(:user) { create(:user, name: "old-name", role: "guest") }
describe 'PUT /users/:id' do
let(:user) { create(:user, name: 'old-name', role: 'guest') }

it 'returns 401 when current_user id mismatch' do
other_user = create(:user)

put "/users/#{user.id}",
params: { name: 'new-name' },
headers: auth_headers(other_user)


it "returns 401 when current_user id mismatch" do
sign_in_as(create(:user))
put "/users/#{user.id}", params: { name: "new-name" }
expect(response).to have_http_status(:unauthorized) expect(response).to have_http_status(:unauthorized)
end end


it "returns 400 when name is blank" do
sign_in_as(user)
put "/users/#{user.id}", params: { name: " " }
it 'returns 400 when name is blank' do
put "/users/#{user.id}",
params: { name: ' ' },
headers: auth_headers(user)

expect(response).to have_http_status(:bad_request) expect(response).to have_http_status(:bad_request)
end end


it "updates name and returns 201 with user slice" do
sign_in_as(user)
put "/users/#{user.id}", params: { name: "new-name" }
it 'updates name and returns user slice' do
put "/users/#{user.id}",
params: { name: 'new-name' },
headers: auth_headers(user)


expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
expect(json["id"]).to eq(user.id)
expect(json["name"]).to eq("new-name")
expect(json['id']).to eq(user.id)
expect(json['name']).to eq('new-name')


user.reload user.reload
expect(user.name).to eq("new-name")
expect(user.name).to eq('new-name')
end

it 'returns 403 when current user is banned' do
user.update!(banned_at: Time.current)

put "/users/#{user.id}",
params: { name: 'new-name' },
headers: auth_headers(user)

expect(response).to have_http_status(:forbidden)

user.reload
expect(user.name).to eq('old-name')
end

it 'returns 403 when current IP address is banned' do
IpAddress.create!(
ip_address: IPAddr.new(remote_ip).hton,
banned_at: Time.current
)

put "/users/#{user.id}",
params: { name: 'new-name' },
headers: auth_headers(user)

expect(response).to have_http_status(:forbidden)

user.reload
expect(user.name).to eq('old-name')
end end
end end


describe "POST /users/verify" do
it "returns valid:false when code not found" do
post "/users/verify", params: { code: "nope" }
describe 'POST /users/verify' do
it 'returns valid:false when code not found' do
post '/users/verify', params: { code: 'nope' }

expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
expect(json["valid"]).to eq(false)
expect(json['valid']).to eq(false)
end end


it "creates IpAddress and UserIp, and returns valid:true with user slice" do
user = create(:user, inheritance_code: SecureRandom.uuid, role: "guest")
it 'returns 403 when current IP address is banned' do
user = create(:user, inheritance_code: SecureRandom.uuid, role: 'guest')

IpAddress.create!(
ip_address: IPAddr.new(remote_ip).hton,
banned_at: Time.current
)

expect {
post '/users/verify', params: { code: user.inheritance_code }
}.not_to change(UserIp, :count)

expect(response).to have_http_status(:forbidden)
end

it 'returns 403 when verified user is banned' do
user = create(
:user,
:banned,
inheritance_code: SecureRandom.uuid,
role: 'guest'
)

expect {
post '/users/verify', params: { code: user.inheritance_code }
}.not_to change(UserIp, :count)

expect(response).to have_http_status(:forbidden)
end


# request.remote_ip を固定
allow_any_instance_of(ActionDispatch::Request).to receive(:remote_ip).and_return("203.0.113.10")
it 'creates IpAddress and UserIp, and returns valid:true with user slice' do
user = create(:user, inheritance_code: SecureRandom.uuid, role: 'guest')


expect { expect {
post "/users/verify", params: { code: user.inheritance_code }
post '/users/verify', params: { code: user.inheritance_code }
}.to change(UserIp, :count).by(1) }.to change(UserIp, :count).by(1)
.and change(IpAddress, :count).by(1)


expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
expect(json["valid"]).to eq(true)
expect(json["user"]["id"]).to eq(user.id)
expect(json["user"]["inheritance_code"]).to eq(user.inheritance_code)
expect(json["user"]["role"]).to eq("guest")
expect(json['valid']).to eq(true)
expect(json['user']['id']).to eq(user.id)
expect(json['user']['inheritance_code']).to eq(user.inheritance_code)
expect(json['user']['role']).to eq('guest')


# ついでに IpAddress もできてることを確認(ipの保存形式がバイナリでも count で見れる)
expect(IpAddress.count).to be >= 1
ip_address = IpAddress.find_by(ip_address: IPAddr.new(remote_ip).hton)
expect(ip_address).to be_present
expect(UserIp.exists?(user:, ip_address:)).to eq(true)
end end


it "is idempotent for same user+ip (does not create duplicate UserIp)" do
user = create(:user, inheritance_code: SecureRandom.uuid, role: "guest")
allow_any_instance_of(ActionDispatch::Request).to receive(:remote_ip).and_return("203.0.113.10")
it 'is idempotent for same user and same IP address' do
user = create(:user, inheritance_code: SecureRandom.uuid, role: 'guest')


post "/users/verify", params: { code: user.inheritance_code }
post '/users/verify', params: { code: user.inheritance_code }
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)


expect { expect {
post "/users/verify", params: { code: user.inheritance_code }
post '/users/verify', params: { code: user.inheritance_code }
}.not_to change(UserIp, :count) }.not_to change(UserIp, :count)


expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
expect(json["valid"]).to eq(true)
expect(json['valid']).to eq(true)
end

it 'creates another UserIp for same user and different IP address' do
user = create(:user, inheritance_code: SecureRandom.uuid, role: 'guest')

post '/users/verify', params: { code: user.inheritance_code }
expect(response).to have_http_status(:ok)

allow_any_instance_of(ActionDispatch::Request)
.to receive(:remote_ip)
.and_return('203.0.113.11')

expect {
post '/users/verify', params: { code: user.inheritance_code }
}.to change(UserIp, :count).by(1)

expect(response).to have_http_status(:ok)
expect(json['valid']).to eq(true)
end end
end end


describe "GET /users/me" do
it "returns 404 when code not found" do
get "/users/me", params: { code: "nope" }
describe 'GET /users/me' do
it 'returns 404 when code not found' do
get '/users/me', params: { code: 'nope' }

expect(response).to have_http_status(:not_found) expect(response).to have_http_status(:not_found)
end end


it "returns user slice when found" do
user = create(:user, inheritance_code: SecureRandom.uuid, name: "me", role: "guest")
get "/users/me", params: { code: user.inheritance_code }
it 'returns user slice when found' do
user = create(:user, inheritance_code: SecureRandom.uuid, name: 'me', role: 'guest')

get '/users/me', params: { code: user.inheritance_code }


expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
expect(json["id"]).to eq(user.id)
expect(json["name"]).to eq("me")
expect(json["inheritance_code"]).to eq(user.inheritance_code)
expect(json["role"]).to eq("guest")
expect(json['id']).to eq(user.id)
expect(json['name']).to eq('me')
expect(json['inheritance_code']).to eq(user.inheritance_code)
expect(json['role']).to eq('guest')
end

it 'returns 403 when current IP address is banned' do
user = create(:user, inheritance_code: SecureRandom.uuid)

IpAddress.create!(
ip_address: IPAddr.new(remote_ip).hton,
banned_at: Time.current
)

get '/users/me', params: { code: user.inheritance_code }

expect(response).to have_http_status(:forbidden)
end end
end end
end end

+ 2
- 4
backend/spec/support/test_records.rb View File

@@ -2,14 +2,12 @@ module TestRecords
def create_member_user! def create_member_user!
User.create!(name: 'spec user', User.create!(name: 'spec user',
inheritance_code: SecureRandom.hex(16), inheritance_code: SecureRandom.hex(16),
role: 'member',
banned: false)
role: 'member')
end end


def create_admin_user! def create_admin_user!
User.create!(name: 'spec admin', User.create!(name: 'spec admin',
inheritance_code: SecureRandom.hex(16), inheritance_code: SecureRandom.hex(16),
role: 'admin',
banned: false)
role: 'admin')
end end
end end

Loading…
Cancel
Save