diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..57a8363 --- /dev/null +++ b/.env.example @@ -0,0 +1,26 @@ +# Rails Configuration +SECRET_KEY_BASE=generate-with-bin-rails-secret +RAILS_ENV=development + +# Database +# SQLite database files are stored in the storage/ directory +# In production with Docker, mount this as a persistent volume + +# SMTP Configuration (for sending emails) +SMTP_ADDRESS=smtp.example.com +SMTP_PORT=587 +SMTP_DOMAIN=example.com +SMTP_USERNAME=your-username +SMTP_PASSWORD=your-password +SMTP_AUTHENTICATION=plain +SMTP_ENABLE_STARTTLS=true + +# Application Configuration +CLINCH_HOST=http://localhost:9000 +CLINCH_FROM_EMAIL=noreply@example.com + +# Optional: Force SSL in production +# FORCE_SSL=true + +# Optional: Set custom port +# PORT=9000 diff --git a/Procfile.dev b/Procfile.dev index da151fe..f805780 100644 --- a/Procfile.dev +++ b/Procfile.dev @@ -1,2 +1,2 @@ -web: bin/rails server +web: bin/rails server -b 0.0.0.0 -p 3035 css: bin/rails tailwindcss:watch diff --git a/README.md b/README.md index 6ace77b..2cc989f 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Clinch -**A lightweight, self-hosted identity & SSO portal for home-labs** +**A lightweight, self-hosted identity & SSO portal** Clinch gives you one place to manage users and lets any web app authenticate against it without maintaining its own user table. diff --git a/app/controllers/dashboard_controller.rb b/app/controllers/dashboard_controller.rb new file mode 100644 index 0000000..ce1b60d --- /dev/null +++ b/app/controllers/dashboard_controller.rb @@ -0,0 +1,12 @@ +class DashboardController < ApplicationController + def index + # First run: redirect to signup + if User.count.zero? + redirect_to signup_path + return + end + + # User must be authenticated + @user = Current.session.user + end +end diff --git a/app/controllers/profiles_controller.rb b/app/controllers/profiles_controller.rb new file mode 100644 index 0000000..f915c45 --- /dev/null +++ b/app/controllers/profiles_controller.rb @@ -0,0 +1,45 @@ +class ProfilesController < ApplicationController + def show + @user = Current.session.user + @active_sessions = @user.sessions.active.order(last_activity_at: :desc) + end + + def update + @user = Current.session.user + + if params[:user][:password].present? + # Updating password - requires current password + unless @user.authenticate(params[:user][:current_password]) + @user.errors.add(:current_password, "is incorrect") + @active_sessions = @user.sessions.active.order(last_activity_at: :desc) + render :show, status: :unprocessable_entity + return + end + + if @user.update(password_params) + redirect_to profile_path, notice: "Password updated successfully." + else + @active_sessions = @user.sessions.active.order(last_activity_at: :desc) + render :show, status: :unprocessable_entity + end + else + # Updating email + if @user.update(email_params) + redirect_to profile_path, notice: "Email updated successfully." + else + @active_sessions = @user.sessions.active.order(last_activity_at: :desc) + render :show, status: :unprocessable_entity + end + end + end + + private + + def email_params + params.require(:user).permit(:email_address) + end + + def password_params + params.require(:user).permit(:password, :password_confirmation) + end +end diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb index cf7fccd..ebc5fa8 100644 --- a/app/controllers/sessions_controller.rb +++ b/app/controllers/sessions_controller.rb @@ -1,21 +1,47 @@ class SessionsController < ApplicationController allow_unauthenticated_access only: %i[ new create ] - rate_limit to: 10, within: 3.minutes, only: :create, with: -> { redirect_to new_session_path, alert: "Try again later." } + rate_limit to: 10, within: 3.minutes, only: :create, with: -> { redirect_to signin_path, alert: "Too many attempts. Try again later." } def new + # Redirect to signup if this is first run + redirect_to signup_path if User.count.zero? end def create - if user = User.authenticate_by(params.permit(:email_address, :password)) - start_new_session_for user - redirect_to after_authentication_url - else - redirect_to new_session_path, alert: "Try another email address or password." + user = User.authenticate_by(params.permit(:email_address, :password)) + + if user.nil? + redirect_to signin_path, alert: "Invalid email address or password." + return end + + # Check if user is active + unless user.status == "active" + redirect_to signin_path, alert: "Your account is not active. Please contact an administrator." + return + end + + # Check if TOTP is required + if user.totp_enabled? + # TODO: Implement TOTP verification flow + # For now, reject login if TOTP is enabled + redirect_to signin_path, alert: "Two-factor authentication is enabled but not yet implemented. Please contact an administrator." + return + end + + # Sign in successful + start_new_session_for user + redirect_to after_authentication_url, notice: "Signed in successfully." end def destroy terminate_session - redirect_to new_session_path, status: :see_other + redirect_to signin_path, status: :see_other, notice: "Signed out successfully." + end + + def destroy_other + session = Current.session.user.sessions.find(params[:id]) + session.destroy + redirect_to profile_path, notice: "Session revoked successfully." end end diff --git a/app/controllers/totp_controller.rb b/app/controllers/totp_controller.rb new file mode 100644 index 0000000..dfa15c3 --- /dev/null +++ b/app/controllers/totp_controller.rb @@ -0,0 +1,84 @@ +class TotpController < ApplicationController + before_action :set_user + before_action :redirect_if_totp_enabled, only: [:new, :create] + before_action :require_totp_enabled, only: [:backup_codes, :verify_password, :destroy] + + # GET /totp/new - Show QR code to set up TOTP + def new + # Generate TOTP secret but don't save yet + @totp_secret = ROTP::Base32.random + @provisioning_uri = ROTP::TOTP.new(@totp_secret, issuer: "Clinch").provisioning_uri(@user.email_address) + + # Generate QR code + require "rqrcode" + @qr_code = RQRCode::QRCode.new(@provisioning_uri) + end + + # POST /totp - Verify TOTP code and enable 2FA + def create + totp_secret = params[:totp_secret] + code = params[:code] + + # Verify the code works + totp = ROTP::TOTP.new(totp_secret) + if totp.verify(code, drift_behind: 30, drift_ahead: 30) + # Save the secret and generate backup codes + @user.totp_secret = totp_secret + @user.backup_codes = generate_backup_codes + @user.save! + + # Redirect to backup codes page with success message + redirect_to backup_codes_totp_path, notice: "Two-factor authentication has been enabled successfully! Save these backup codes now." + else + redirect_to new_totp_path, alert: "Invalid verification code. Please try again." + end + end + + # GET /totp/backup_codes - Show backup codes (requires password) + def backup_codes + # This will be shown after password verification + @backup_codes = @user.parsed_backup_codes + end + + # POST /totp/verify_password - Verify password before showing backup codes + def verify_password + if @user.authenticate(params[:password]) + redirect_to backup_codes_totp_path + else + redirect_to profile_path, alert: "Incorrect password." + end + end + + # DELETE /totp - Disable TOTP (requires password) + def destroy + unless @user.authenticate(params[:password]) + redirect_to profile_path, alert: "Incorrect password. Could not disable 2FA." + return + end + + @user.disable_totp! + redirect_to profile_path, notice: "Two-factor authentication has been disabled." + end + + private + + def set_user + @user = Current.session.user + end + + def redirect_if_totp_enabled + if @user.totp_enabled? + redirect_to profile_path, alert: "Two-factor authentication is already enabled." + end + end + + def require_totp_enabled + unless @user.totp_enabled? + redirect_to profile_path, alert: "Two-factor authentication is not enabled." + end + end + + def generate_backup_codes + Array.new(10) { SecureRandom.alphanumeric(8).upcase }.to_json + end +end diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb new file mode 100644 index 0000000..a7fc08c --- /dev/null +++ b/app/controllers/users_controller.rb @@ -0,0 +1,36 @@ +class UsersController < ApplicationController + allow_unauthenticated_access only: %i[ new create ] + before_action :ensure_first_run, only: %i[ new create ] + + def new + @user = User.new + end + + def create + @user = User.new(user_params) + + # First user becomes admin automatically + @user.admin = true if User.count.zero? + @user.status = "active" + + if @user.save + start_new_session_for @user + redirect_to root_path, notice: "Welcome to Clinch! Your account has been created." + else + render :new, status: :unprocessable_entity + end + end + + private + + def user_params + params.require(:user).permit(:email_address, :password, :password_confirmation) + end + + def ensure_first_run + # Only allow signup if there are no users (first-run scenario) + if User.exists? + redirect_to signin_path, alert: "Registration is closed. Please sign in." + end + end +end diff --git a/app/models/user.rb b/app/models/user.rb index e67b459..cee6788 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -13,6 +13,7 @@ class User < ApplicationRecord validates :email_address, presence: true, uniqueness: { case_sensitive: false }, format: { with: URI::MailTo::EMAIL_REGEXP } + validates :password, length: { minimum: 8 }, allow_nil: true validates :status, presence: true, inclusion: { in: %w[active disabled pending_invitation] } diff --git a/app/views/dashboard/index.html.erb b/app/views/dashboard/index.html.erb new file mode 100644 index 0000000..b474e6e --- /dev/null +++ b/app/views/dashboard/index.html.erb @@ -0,0 +1,116 @@ +
+ <% if @user.admin? %> + Administrator + <% else %> + User + <% end %> +
+View, edit, and invite users
+ <% end %> + + <%= link_to admin_applications_path, class: "block p-6 bg-white rounded-lg border border-gray-200 shadow-sm hover:bg-gray-50 hover:shadow-md transition" do %> +Register and configure applications
+ <% end %> + + <%= link_to admin_groups_path, class: "block p-6 bg-white rounded-lg border border-gray-200 shadow-sm hover:bg-gray-50 hover:shadow-md transition" do %> +Create and organize user groups
+ <% end %> +