Initial commit
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,4 @@
|
||||
class ApplicationMailer < ActionMailer::Base
|
||||
default from: "from@example.com"
|
||||
layout "mailer"
|
||||
end
|
||||
@@ -0,0 +1,3 @@
|
||||
class ApplicationRecord < ActiveRecord::Base
|
||||
primary_abstract_class
|
||||
end
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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>
|
||||
@@ -0,0 +1 @@
|
||||
<%= yield %>
|
||||
Reference in New Issue
Block a user