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 @@ +
Don't have an account? Register here
+ +