Initial commit

This commit is contained in:
2026-05-18 13:17:28 -04:00
committed by GitHub
commit 16fadfd529
91 changed files with 3321 additions and 0 deletions
@@ -0,0 +1,4 @@
module ApplicationCable
class Channel < ActionCable::Channel::Base
end
end
@@ -0,0 +1,4 @@
module ApplicationCable
class Connection < ActionCable::Connection::Base
end
end
@@ -0,0 +1,23 @@
module Api
module V1
class AdminController < ApplicationController
include AuthorizeRequest
include AuthorizeRole
before_action :require_admin
# example admin-only endpoint
# GET /api/v1/admin/dashboard
def dashboard
render json: {
message: "Welcome to admin dashboard",
stats: {
total_users: User.count,
total_admins: User.admin.count,
total_moderators: User.moderator.count
}
}, status: :ok
end
end
end
end
+87
View File
@@ -0,0 +1,87 @@
module Api
module V1
class AuthController < ApplicationController
include AuthorizeRequest
skip_before_action :authorize_request, only: %i[signup login refresh]
# post /signup -> signup user and return jwt + refresh token
# I used strong params so no sql injection here (rails got your back)
def signup
user = User.new(user_params)
if user.save
tokens = generate_tokens(user)
render json: { **tokens, user: user.as_json(only: %i[id email role]) }, status: :created
else
render json: { errors: user.errors.full_messages }, status: :unprocessable_entity
end
end
# post /login -> login with email & password (keep it simple)
# if it matches, it gives you both access token and refresh token
def login
user = User.find_by(email: params[:email])
if user&.valid_password?(params[:password])
tokens = generate_tokens(user)
render json: { **tokens, user: user.as_json(only: %i[id email role]) }, status: :ok
else
render json: { error: "Invalid email or password" }, status: :unauthorized
end
end
# post /refresh -> exchange refresh token for new access token
# keeps users logged in without re-entering credentials
def refresh
refresh_token = RefreshToken.find_by(token: params[:refresh_token])
if refresh_token&.active?
user = refresh_token.user
access_token = JsonWebToken.encode(user_id: user.id)
render json: { access_token:, user: user.as_json(only: %i[id email role]) }, status: :ok
else
render json: { error: "Invalid or expired refresh token" }, status: :unauthorized
end
end
# post /logout -> NOW with real token blacklisting
# adds the current token to blacklist so it can't be used again
# authorize_request ensures @current_user and token are present
def logout
header = request.headers["Authorization"]
token = header.split(" ").last if header
decoded = JsonWebToken.decode(token)
BlacklistedToken.create!(
jti: decoded[:jti],
user_id: decoded[:user_id],
exp: Time.at(decoded[:exp])
)
# also revoke all refresh tokens for this user
@current_user.refresh_tokens.update_all(revoked: true)
render json: { message: "Successfully logged out. Token blacklisted." }, status: :ok
rescue StandardError => e
render json: { error: "Logout failed: #{e.message}" }, status: :unprocessable_entity
end
private
# only allow what we actually need. nothing fancy, nothing extra. not need for require
def user_params
params.permit(:email, :password, :password_confirmation)
end
# generate both access and refresh tokens
def generate_tokens(user)
access_token = JsonWebToken.encode(user_id: user.id)
refresh_token = user.refresh_tokens.create!
{
access_token: access_token,
refresh_token: refresh_token.token,
expires_in: 1.hour.to_i
}
end
end
end
end
@@ -0,0 +1,16 @@
module Api
module V1
class ProfileController < ApplicationController
include AuthorizeRequest
# get /profile
# this is for getting current user who logged in
def show
render json: {
id: @current_user.id,
email: @current_user.email
}, status: :ok
end
end
end
end
@@ -0,0 +1,14 @@
module Api
module V1
class ProtectedController < ApplicationController
include AuthorizeRequest
def index
render json: {
message: "you are free to use this buddy",
user: @current_user.as_json(only: %(id email))
}
end
end
end
end
@@ -0,0 +1,2 @@
class ApplicationController < ActionController::API
end
View File
@@ -0,0 +1,33 @@
# this concern checks the token and finds the user
# if token is invalid or missing, we block the request
# usage: just add `before_action :authorize_request` in any controller you wanna protect
# I keep things simple, if you want to make it better it's you choice
module AuthorizeRequest
extend ActiveSupport::Concern
included do
before_action :authorize_request
end
private
def authorize_request
header = request.headers["Authorization"]
token = header.split(" ").last if header
begin
decoded = JsonWebToken.decode(token)
# check if token is blacklisted (logged out)
if BlacklistedToken.blacklisted?(decoded[:jti])
render json: { error: "Token has been revoked" }, status: :unauthorized
return
end
@current_user = User.find(decoded[:user_id])
rescue ActiveRecord::RecordNotFound, StandardError => e
render json: { error: "unauthorized: #{e.message}" }, status: :unauthorized
end
end
end
@@ -0,0 +1,29 @@
# this concern provides role-based authorization
# usage: add `before_action :require_admin` in controllers that need admin access
# or use `authorize_role!(:admin, :moderator)` to check multiple roles
module AuthorizeRole
extend ActiveSupport::Concern
private
# check if current user has any of the specified roles
def authorize_role!(*roles)
unless @current_user && roles.map(&:to_s).include?(@current_user.role)
render json: { error: "Forbidden: insufficient permissions" }, status: :forbidden
end
end
# helper methods for specific roles
def require_admin
authorize_role!(:admin)
end
def require_moderator
authorize_role!(:admin, :moderator)
end
def require_user
authorize_role!(:user, :moderator, :admin)
end
end
+7
View File
@@ -0,0 +1,7 @@
class ApplicationJob < ActiveJob::Base
# Automatically retry jobs that encountered a deadlock
# retry_on ActiveRecord::Deadlocked
# Most jobs are safe to ignore if the underlying records are no longer available
# discard_on ActiveJob::DeserializationError
end
+4
View File
@@ -0,0 +1,4 @@
class ApplicationMailer < ActionMailer::Base
default from: "from@example.com"
layout "mailer"
end
+3
View File
@@ -0,0 +1,3 @@
class ApplicationRecord < ActiveRecord::Base
primary_abstract_class
end
+16
View File
@@ -0,0 +1,16 @@
class BlacklistedToken < ApplicationRecord
belongs_to :user
validates :jti, presence: true, uniqueness: true
validates :exp, presence: true
# check if a token is blacklisted
def self.blacklisted?(jti)
exists?(jti: jti)
end
# cleanup expired tokens (run this via a scheduled job)
def self.cleanup_expired
where("exp < ?", Time.current).delete_all
end
end
View File
+30
View File
@@ -0,0 +1,30 @@
class RefreshToken < ApplicationRecord
belongs_to :user
validates :token, presence: true, uniqueness: true
validates :expires_at, presence: true
before_validation :generate_token, on: :create
# check if token is still active (not revoked and not expired)
def active?
!revoked && expires_at > Time.current
end
# revoke this token
def revoke!
update!(revoked: true)
end
# generate a secure random token
def generate_token
self.token ||= SecureRandom.urlsafe_base64(32)
self.expires_at ||= 7.days.from_now
end
# cleanup expired or old revoked tokens (run via scheduled job)
# deletes tokens that are either expired OR (revoked AND old)
def self.cleanup_old_tokens
where("expires_at < ? OR (revoked = ? AND created_at < ?)", 30.days.ago, true, 30.days.ago).delete_all
end
end
+17
View File
@@ -0,0 +1,17 @@
class User < ApplicationRecord
# Include default devise modules. Others available are:
# :recoverable, :rememberable, :confirmable, :lockable, :timeoutable, :trackable and :omniauthable
devise :database_authenticatable, :registerable,
:validatable
# associations
has_many :blacklisted_tokens, dependent: :destroy
has_many :refresh_tokens, dependent: :destroy
# role-based authorization
enum :role, { user: 0, admin: 1, moderator: 2 }
# validations so your db doesn't turn into a trash can
validates :email, presence: true, uniqueness: true, format: { with: URI::MailTo::EMAIL_REGEXP }
validates :password, presence: true, length: { minimum: 6 }, if: -> { new_record? || !password.nil? }
end
+13
View File
@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
<style>
/* Email styles need to be inline */
</style>
</head>
<body>
<%= yield %>
</body>
</html>
+1
View File
@@ -0,0 +1 @@
<%= yield %>