feat(auth): implement authentication system foundation

Add user authentication infrastructure with login/logout functionality,
session management, and basic user model.

Changes:
- Create users table with email, password_digest, confirmation tokens
- Add Users repository with authentication methods
- Add User entity with authentication helpers
- Implement login form and authentication actions
- Add logout action
- Add BCrypt gem for password hashing
- Update base Action class with authentication helpers
- Add login/logout routes

Next steps: Registration flow, password reset, email confirmation
This commit is contained in:
2026-03-27 12:25:08 -07:00
parent f88c1f5a7f
commit e7ddd0f72f
8 changed files with 339 additions and 0 deletions
+38
View File
@@ -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
+63
View File
@@ -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
+14
View File
@@ -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
+117
View File
@@ -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
+39
View File
@@ -0,0 +1,39 @@
<div class="auth-container">
<div class="auth-card">
<h1>Login</h1>
<% if flash[:error] %>
<div class="alert alert-error"><%= flash[:error] %></div>
<% end %>
<% if flash[:success] %>
<div class="alert alert-success"><%= flash[:success] %></div>
<% end %>
<form action="/auth/login" method="POST">
<div class="form-group">
<label for="email">Email</label>
<input type="email" id="email" name="email" required autocomplete="email">
</div>
<div class="form-group">
<label for="password">Password</label>
<input type="password" id="password" name="password" required autocomplete="current-password">
</div>
<div class="form-group remember-me">
<label>
<input type="checkbox" name="remember" value="1">
Remember me
</label>
</div>
<button type="submit" class="btn btn-primary btn-block">Login</button>
</form>
<div class="auth-links">
<p>Don't have an account? <a href="/register">Register here</a></p>
<p><a href="/password/reset">Forgot password?</a></p>
</div>
</div>
</div>