diff --git a/Gemfile b/Gemfile index bd101aa..ea7cce4 100644 --- a/Gemfile +++ b/Gemfile @@ -17,6 +17,8 @@ gem "rom", "~> 5.3" gem "rom-sql", "~> 3.6" gem "sqlite3" +gem "bcrypt", "~> 3.1" + gem "good_job", "~> 4.13" group :development do diff --git a/app/action.rb b/app/action.rb index 85a36f0..9872821 100644 --- a/app/action.rb +++ b/app/action.rb @@ -5,5 +5,43 @@ require "hanami/action" module EuchreCamp class Action < Hanami::Action + include Deps[users_repo: 'repositories.users', players_repo: 'repositories.players'] + + protected + + # Get current user from session + def current_user(request) + user_id = request.session[:user_id] + return nil unless user_id + + users_repo.by_id(user_id) + end + + # Get current player from session + def current_player(request) + player_id = request.session[:player_id] + return nil unless player_id + + players_repo.by_id(player_id) + end + + # Check if user is logged in + def logged_in?(request) + !request.session[:user_id].nil? + end + + # Require authentication - redirect to login if not logged in + def require_authentication(request, response) + unless logged_in?(request) + response.flash[:error] = 'Please login to continue' + response.redirect_to '/login' + end + end + + # Helper to pass current_player to view + def render_with_player(request, response, **options) + player = current_player(request) + response.render(view, current_player: player, **options) + end end end diff --git a/app/actions/auth/login.rb b/app/actions/auth/login.rb new file mode 100644 index 0000000..a06d76b --- /dev/null +++ b/app/actions/auth/login.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +module EuchreCamp + module Actions + module Auth + class Login < EuchreCamp::Action + include Deps[ + users_repo: 'repositories.users', + players_repo: 'repositories.players' + ] + + # Show login form (GET) + class Show < Login + def handle(request, response) + render_with_player(request, response) + end + end + + # Process login (POST) + class Create < Login + def handle(request, response) + email = request.params[:email] + password = request.params[:password] + + user = users_repo.by_email(email) + + if user && valid_password?(user, password) + if user.locked? + response.flash[:error] = 'Account is locked. Please contact support.' + response.redirect_to '/login' + return + end + + # Record successful login + ip_address = request.env['REMOTE_ADDR'] + users_repo.record_login(user[:id], ip_address) + + # Create session + session = request.session + session[:user_id] = user[:id] + session[:player_id] = user[:player_id] + + response.flash[:success] = 'Welcome back!' + response.redirect_to '/rankings' + else + # Record failed login + users_repo.record_failed_login(user[:id]) if user + + response.flash[:error] = 'Invalid email or password' + response.redirect_to '/login' + end + end + + private + + def valid_password?(user, password) + BCrypt::Password.new(user[:password_digest]) == password + end + end + end + end + end +end diff --git a/app/actions/auth/logout.rb b/app/actions/auth/logout.rb new file mode 100644 index 0000000..3a3ac42 --- /dev/null +++ b/app/actions/auth/logout.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module EuchreCamp + module Actions + module Auth + class Logout < EuchreCamp::Action + def handle(request, response) + request.session.clear + response.redirect_to '/rankings' + end + end + end + end +end diff --git a/app/repositories/users.rb b/app/repositories/users.rb new file mode 100644 index 0000000..fd6b441 --- /dev/null +++ b/app/repositories/users.rb @@ -0,0 +1,117 @@ +# frozen_string_literal: true + +module EuchreCamp + module Repositories + class Users < ::EuchreCamp::Repository[:users] + + # Find user by ID + def by_id(id) + users.by_pk(id).one + end + + # Find user by email + def by_email(email) + users.where(email: email).one + end + + # Find user by player ID + def by_player_id(player_id) + users.where(player_id: player_id).one + end + + # Create new user + def create(email:, password_digest:, player_id:) + users.changeset(:create, { + email: email, + password_digest: password_digest, + player_id: player_id, + confirmed: false, + confirmation_token: generate_token, + confirmation_sent_at: Time.now + }).commit + end + + # Confirm user account + def confirm(token) + user = users.where(confirmation_token: token).one + return nil unless user + + users.where(id: user[:id]).update( + confirmed: true, + confirmation_token: nil, + confirmation_sent_at: nil + ) + user + end + + # Request password reset + def request_password_reset(email) + user = by_email(email) + return nil unless user + + token = generate_token + users.where(id: user[:id]).update( + reset_password_token: token, + reset_password_sent_at: Time.now + ) + token + end + + # Reset password + def reset_password(token, new_password_digest) + user = users.where(reset_password_token: token).one + return nil unless user + + # Check if token is expired (1 hour) + if user[:reset_password_sent_at] && user[:reset_password_sent_at] < (Time.now - 3600) + return nil + end + + users.where(id: user[:id]).update( + password_digest: new_password_digest, + reset_password_token: nil, + reset_password_sent_at: nil + ) + user + end + + # Record successful login + def record_login(user_id, ip_address) + users.where(id: user_id).update( + last_login_at: Time.now, + last_login_ip: ip_address, + failed_login_attempts: 0, + locked_until: nil + ) + end + + # Record failed login + def record_failed_login(user_id) + user = users.where(id: user_id).one + return unless user + + attempts = user[:failed_login_attempts] + 1 + locked_until = attempts >= 5 ? (Time.now + 900) : nil # Lock for 15 minutes after 5 attempts + + users.where(id: user_id).update( + failed_login_attempts: attempts, + locked_until: locked_until + ) + end + + # Check if account is locked + def account_locked?(user_id) + user = users.where(id: user_id).one + return false unless user + + user[:locked_until] && user[:locked_until] > Time.now + end + + private + + def generate_token + SecureRandom.urlsafe_base64(32) + end + end + end +end diff --git a/app/templates/auth/login.html.erb b/app/templates/auth/login.html.erb new file mode 100644 index 0000000..c38d99c --- /dev/null +++ b/app/templates/auth/login.html.erb @@ -0,0 +1,39 @@ +
+
+

Login

+ + <% if flash[:error] %> +
<%= flash[:error] %>
+ <% end %> + + <% if flash[:success] %> +
<%= flash[:success] %>
+ <% end %> + +
+
+ + +
+ +
+ + +
+ +
+ +
+ + +
+ + +
+
diff --git a/db/migrate/20240915000008_create_users.rb b/db/migrate/20240915000008_create_users.rb new file mode 100644 index 0000000..9e911b4 --- /dev/null +++ b/db/migrate/20240915000008_create_users.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +ROM::SQL.migration do + change do + create_table :users do + primary_key :id + foreign_key :player_id, :players, null: false + column :email, String, null: false, unique: true + column :password_digest, String, null: false + column :confirmed, Boolean, default: false + column :confirmation_token, String + column :confirmation_sent_at, DateTime + column :reset_password_token, String + column :reset_password_sent_at, DateTime + column :failed_login_attempts, Integer, default: 0 + column :locked_until, DateTime + column :last_login_at, DateTime + column :last_login_ip, String + column :created_at, DateTime, null: false, default: Sequel::CURRENT_TIMESTAMP + column :updated_at, DateTime, null: false, default: Sequel::CURRENT_TIMESTAMP + end + + add_index :users, :email, unique: true + add_index :users, :confirmation_token + add_index :users, :reset_password_token + end +end diff --git a/lib/euchre_camp/entities/user.rb b/lib/euchre_camp/entities/user.rb new file mode 100644 index 0000000..791daf0 --- /dev/null +++ b/lib/euchre_camp/entities/user.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +module EuchreCamp + module Entities + class User < Dry::Struct + attribute :id, Types::Integer + attribute :player_id, Types::Integer + attribute :email, Types::String + attribute :password_digest, Types::String + attribute :confirmed, Types::Bool + attribute? :confirmation_token, Types::String.optional + attribute? :confirmation_sent_at, Types::Time.optional + attribute? :reset_password_token, Types::String.optional + attribute? :reset_password_sent_at, Types::Time.optional + attribute :failed_login_attempts, Types::Integer + attribute? :locked_until, Types::Time.optional + attribute? :last_login_at, Types::Time.optional + attribute? :last_login_ip, Types::String.optional + attribute :created_at, Types::Time + attribute :updated_at, Types::Time + + # Check if account is locked + def locked? + locked_until && locked_until > Time.now + end + + # Check if account is confirmed + def active? + confirmed && !locked? + end + + # Check if user is admin (for navigation purposes) + # This would be enhanced with actual role checking later + def admin? + false # Placeholder - implement role checking later + end + end + end +end