jwt_auth_engine 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +37 -0
- data/LICENSE.txt +21 -0
- data/README.md +422 -0
- data/app/controllers/concerns/jwt_auth_engine/authenticatable.rb +52 -0
- data/app/controllers/concerns/jwt_auth_engine/rescuable.rb +23 -0
- data/app/controllers/concerns/jwt_auth_engine/response_renderable.rb +29 -0
- data/app/controllers/concerns/jwt_auth_engine/serializable.rb +21 -0
- data/app/controllers/concerns/jwt_auth_engine/tokenizable.rb +22 -0
- data/app/controllers/jwt_auth_engine/application_controller.rb +21 -0
- data/app/controllers/jwt_auth_engine/passwords_controller.rb +26 -0
- data/app/controllers/jwt_auth_engine/ping_controller.rb +21 -0
- data/app/controllers/jwt_auth_engine/profiles_controller.rb +15 -0
- data/app/controllers/jwt_auth_engine/registrations_controller.rb +33 -0
- data/app/controllers/jwt_auth_engine/sessions_controller.rb +41 -0
- data/app/controllers/jwt_auth_engine/tokens_controller.rb +21 -0
- data/app/services/jwt_auth_engine/base_service.rb +26 -0
- data/app/services/jwt_auth_engine/change_password_service.rb +48 -0
- data/app/services/jwt_auth_engine/login_service.rb +46 -0
- data/app/services/jwt_auth_engine/refresh_token_service.rb +35 -0
- data/app/services/jwt_auth_engine/signup_service.rb +23 -0
- data/app/services/jwt_auth_engine/token_service.rb +72 -0
- data/config/routes.rb +47 -0
- data/lib/generators/jwt_auth_engine/install/install_generator.rb +95 -0
- data/lib/generators/jwt_auth_engine/install/templates/add_jwt_auth_engine_columns_migration.rb.tt +11 -0
- data/lib/generators/jwt_auth_engine/install/templates/auth_model_concern.rb.tt +32 -0
- data/lib/generators/jwt_auth_engine/install/templates/jwt_auth_engine_initializer.rb.tt +22 -0
- data/lib/jwt_auth_engine/configuration.rb +50 -0
- data/lib/jwt_auth_engine/constants.rb +16 -0
- data/lib/jwt_auth_engine/engine.rb +10 -0
- data/lib/jwt_auth_engine/errors.rb +14 -0
- data/lib/jwt_auth_engine/version.rb +5 -0
- data/lib/jwt_auth_engine.rb +79 -0
- data/sig/jwt_auth_engine.rbs +4 -0
- metadata +137 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JwtAuthEngine
|
|
4
|
+
# Health check endpoints for public and authenticated connectivity.
|
|
5
|
+
class PingController < ApplicationController
|
|
6
|
+
skip_before_action :authenticate_auth_model!, only: %i[ping]
|
|
7
|
+
|
|
8
|
+
# GET /ping
|
|
9
|
+
# Public — no auth required
|
|
10
|
+
def ping
|
|
11
|
+
render_success(message: 'pong!')
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# GET /authenticated_ping
|
|
15
|
+
# Protected — valid Bearer access token required
|
|
16
|
+
def authenticated_ping
|
|
17
|
+
recipient = current_auth_model_instance.public_send(JwtAuthEngine.identifier_field)
|
|
18
|
+
render_success(message: "pong! Hello #{recipient}, you are authenticated.")
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JwtAuthEngine
|
|
4
|
+
# Profile retrieval endpoint for the authenticated auth model.
|
|
5
|
+
class ProfilesController < ApplicationController
|
|
6
|
+
include Serializable
|
|
7
|
+
|
|
8
|
+
# ── GET /me ───────────────────────────────────────────────────────────────
|
|
9
|
+
def me
|
|
10
|
+
render_success(
|
|
11
|
+
JwtAuthEngine.auth_model_name => serialize_auth_model_instance(current_auth_model_instance)
|
|
12
|
+
)
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JwtAuthEngine
|
|
4
|
+
# Signup endpoint for creating new auth model records.
|
|
5
|
+
class RegistrationsController < ApplicationController
|
|
6
|
+
include Serializable
|
|
7
|
+
include Tokenizable
|
|
8
|
+
|
|
9
|
+
skip_before_action :authenticate_auth_model!, only: %i[signup]
|
|
10
|
+
|
|
11
|
+
# ── POST /signup ─────────────────────────────────────────────────────────
|
|
12
|
+
def signup
|
|
13
|
+
result = SignupService.new(signup_params: signup_params).call
|
|
14
|
+
|
|
15
|
+
return render_validation_error(errors: result[:errors]) unless result[:success]
|
|
16
|
+
|
|
17
|
+
auth_model_instance = result[JwtAuthEngine.auth_model_name]
|
|
18
|
+
|
|
19
|
+
render_success(
|
|
20
|
+
message: 'Signup successful.',
|
|
21
|
+
JwtAuthEngine.auth_model_name => serialize_auth_model_instance(auth_model_instance),
|
|
22
|
+
**issue_tokens(auth_model_instance),
|
|
23
|
+
status: :created
|
|
24
|
+
)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def signup_params
|
|
30
|
+
params.permit(JwtAuthEngine.identifier_field, JwtAuthEngine.password_field)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JwtAuthEngine
|
|
4
|
+
# Login and logout endpoints for session token lifecycle.
|
|
5
|
+
class SessionsController < ApplicationController
|
|
6
|
+
include Serializable
|
|
7
|
+
include Tokenizable
|
|
8
|
+
|
|
9
|
+
skip_before_action :authenticate_auth_model!, only: %i[login]
|
|
10
|
+
|
|
11
|
+
# ── POST /login ──────────────────────────────────────────────────────────
|
|
12
|
+
def login
|
|
13
|
+
result = LoginService.new(login_params: login_params).call
|
|
14
|
+
|
|
15
|
+
return render_unauthorized(result[:error]) unless result[:success]
|
|
16
|
+
|
|
17
|
+
auth_model_instance = result[JwtAuthEngine.auth_model_name]
|
|
18
|
+
|
|
19
|
+
render_success(
|
|
20
|
+
message: 'Login successful.',
|
|
21
|
+
JwtAuthEngine.auth_model_name => serialize_auth_model_instance(auth_model_instance),
|
|
22
|
+
**issue_tokens(auth_model_instance)
|
|
23
|
+
)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# ── DELETE /logout ─────────────────────────────────────────────────────────
|
|
27
|
+
# Stateless logout: the client should discard tokens.
|
|
28
|
+
def logout
|
|
29
|
+
render_success(status: :no_content)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def login_params
|
|
35
|
+
params.permit(
|
|
36
|
+
JwtAuthEngine.identifier_field,
|
|
37
|
+
JwtAuthEngine.password_field
|
|
38
|
+
)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JwtAuthEngine
|
|
4
|
+
# Endpoint for exchanging refresh tokens for a new token pair.
|
|
5
|
+
class TokensController < ApplicationController
|
|
6
|
+
include Tokenizable
|
|
7
|
+
|
|
8
|
+
skip_before_action :authenticate_auth_model!, only: %i[refresh_token]
|
|
9
|
+
|
|
10
|
+
# ── POST /refresh_token ───────────────────────────────────────────────────
|
|
11
|
+
# Verifies refresh JWT and re-issues a new token pair.
|
|
12
|
+
def refresh_token
|
|
13
|
+
result = RefreshTokenService.new(refresh_token: bearer_token).call
|
|
14
|
+
return render_unauthorized(result[:error]) unless result[:success]
|
|
15
|
+
|
|
16
|
+
auth_model_instance = result[JwtAuthEngine.auth_model_name]
|
|
17
|
+
|
|
18
|
+
render_success(**issue_tokens(auth_model_instance))
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JwtAuthEngine
|
|
4
|
+
# Base service with common success/failure response helpers.
|
|
5
|
+
class BaseService
|
|
6
|
+
def success(**data)
|
|
7
|
+
{ success: true, **data }
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def failure(**data)
|
|
11
|
+
{ success: false, **data }
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
private
|
|
15
|
+
|
|
16
|
+
def authenticate(auth_model_instance, password)
|
|
17
|
+
return false if auth_model_instance.blank? || password.blank?
|
|
18
|
+
|
|
19
|
+
auth_model_instance.public_send(authentication_method, password)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def authentication_method
|
|
23
|
+
JwtAuthEngine.password_field == :password ? :authenticate : :"authenticate_#{JwtAuthEngine.password_field}"
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JwtAuthEngine
|
|
4
|
+
# Changes password for an authenticated auth model record.
|
|
5
|
+
class ChangePasswordService < BaseService
|
|
6
|
+
attr_reader :auth_model_instance, :current_password, :new_password
|
|
7
|
+
|
|
8
|
+
def initialize(auth_model_instance:, change_password_params:)
|
|
9
|
+
@auth_model_instance = auth_model_instance
|
|
10
|
+
@current_password = change_password_params[JwtAuthEngine.current_password_field]
|
|
11
|
+
@new_password = change_password_params[JwtAuthEngine.new_password_field]
|
|
12
|
+
super()
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def call
|
|
16
|
+
return failure(invalid: required_fields_message) if missing_password_fields?
|
|
17
|
+
return failure(invalid: incorrect_password_message) unless current_password_valid?
|
|
18
|
+
|
|
19
|
+
update_password
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def missing_password_fields?
|
|
25
|
+
current_password.blank? || new_password.blank?
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def current_password_valid?
|
|
29
|
+
authenticate(auth_model_instance, current_password)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def update_password
|
|
33
|
+
if auth_model_instance.update(JwtAuthEngine.password_field => new_password.to_s)
|
|
34
|
+
success(message: 'Password changed successfully.')
|
|
35
|
+
else
|
|
36
|
+
failure(errors: auth_model_instance.errors.full_messages)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def required_fields_message
|
|
41
|
+
"#{JwtAuthEngine.current_password_field} and #{JwtAuthEngine.new_password_field} are required."
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def incorrect_password_message
|
|
45
|
+
"Current #{JwtAuthEngine.password_field} is incorrect."
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JwtAuthEngine
|
|
4
|
+
# Authenticates identifier/password and returns the matched auth model.
|
|
5
|
+
class LoginService < BaseService
|
|
6
|
+
def initialize(login_params:)
|
|
7
|
+
@identifier = login_params[JwtAuthEngine.identifier_field]
|
|
8
|
+
@password = login_params[JwtAuthEngine.password_field]
|
|
9
|
+
super()
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def call
|
|
13
|
+
if identifier.blank? || password.blank?
|
|
14
|
+
return failure(error: "#{JwtAuthEngine.identifier_field} and #{JwtAuthEngine.password_field} are required.")
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
auth_model_instance = find_auth_model_instance
|
|
18
|
+
|
|
19
|
+
if authenticate(auth_model_instance, password)
|
|
20
|
+
success(JwtAuthEngine.auth_model_name => auth_model_instance)
|
|
21
|
+
else
|
|
22
|
+
failure(error: 'Invalid credentials.')
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
attr_reader :identifier, :password
|
|
29
|
+
|
|
30
|
+
def find_auth_model_instance
|
|
31
|
+
model = JwtAuthEngine.auth_model_class
|
|
32
|
+
|
|
33
|
+
unless case_insensitive_identifier_field?(model)
|
|
34
|
+
return model.find_by(JwtAuthEngine.identifier_field => identifier)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
identifier_column = model.connection.quote_column_name(JwtAuthEngine.identifier_field)
|
|
38
|
+
model.where("LOWER(#{identifier_column}) = ?", identifier.to_s.downcase).first
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def case_insensitive_identifier_field?(model)
|
|
42
|
+
identifier_type = model.type_for_attribute(JwtAuthEngine.identifier_field.to_s).type
|
|
43
|
+
%i[string text].include?(identifier_type)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JwtAuthEngine
|
|
4
|
+
# Validates refresh tokens and resolves the associated auth model.
|
|
5
|
+
class RefreshTokenService < BaseService
|
|
6
|
+
attr_reader :refresh_token
|
|
7
|
+
|
|
8
|
+
def initialize(refresh_token:)
|
|
9
|
+
@refresh_token = refresh_token
|
|
10
|
+
super()
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def call
|
|
14
|
+
return failure(error: 'refresh_token is required.') if refresh_token.blank?
|
|
15
|
+
|
|
16
|
+
success(JwtAuthEngine.auth_model_name => auth_model_instance_from_token)
|
|
17
|
+
rescue JwtAuthEngine::TokenExpired
|
|
18
|
+
failure(error: 'Refresh token has expired.')
|
|
19
|
+
rescue JwtAuthEngine::InvalidToken
|
|
20
|
+
failure(error: 'Invalid refresh token.')
|
|
21
|
+
rescue JwtAuthEngine::InvalidTokenType
|
|
22
|
+
failure(error: 'Refresh token expected')
|
|
23
|
+
rescue ActiveRecord::RecordNotFound
|
|
24
|
+
failure(error: "#{JwtAuthEngine.auth_model_name.to_s.humanize} not found.")
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def auth_model_instance_from_token
|
|
30
|
+
payload = TokenService.decode_refresh(refresh_token)
|
|
31
|
+
auth_model_id = payload[JwtAuthEngine.auth_model_token_payload_key]
|
|
32
|
+
JwtAuthEngine.auth_model_class.find(auth_model_id)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JwtAuthEngine
|
|
4
|
+
# Creates a new auth model record from signup parameters.
|
|
5
|
+
class SignupService < BaseService
|
|
6
|
+
attr_reader :signup_params
|
|
7
|
+
|
|
8
|
+
def initialize(signup_params:)
|
|
9
|
+
@signup_params = signup_params
|
|
10
|
+
super()
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def call
|
|
14
|
+
auth_model_instance = JwtAuthEngine.auth_model_class.new(signup_params)
|
|
15
|
+
|
|
16
|
+
if auth_model_instance.save
|
|
17
|
+
success(JwtAuthEngine.auth_model_name => auth_model_instance)
|
|
18
|
+
else
|
|
19
|
+
failure(errors: auth_model_instance.errors.full_messages)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'jwt'
|
|
4
|
+
|
|
5
|
+
module JwtAuthEngine
|
|
6
|
+
# Encodes and decodes JWT access/refresh tokens for the engine.
|
|
7
|
+
class TokenService
|
|
8
|
+
ACCESS_TYPE = 'access'
|
|
9
|
+
REFRESH_TYPE = 'refresh'
|
|
10
|
+
ALGORITHM = 'HS256'
|
|
11
|
+
|
|
12
|
+
class << self
|
|
13
|
+
# ── Encoding ─────────────────────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
# Encodes an access token with the given payload. Automatically adds exp and token_type.
|
|
16
|
+
def encode_access(payload)
|
|
17
|
+
exp = JwtAuthEngine.configuration.access_token_expiry.from_now.to_i
|
|
18
|
+
encode(payload.merge(exp: exp, token_type: ACCESS_TYPE))
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Encodes a refresh token with the given payload. Automatically adds exp and token_type.
|
|
22
|
+
def encode_refresh(payload)
|
|
23
|
+
exp = JwtAuthEngine.configuration.refresh_token_expiry.from_now.to_i
|
|
24
|
+
encode(payload.merge(exp: exp, token_type: REFRESH_TYPE))
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# ── Decoding ─────────────────────────────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
# Decodes an access token. Raises on invalid/expired token.
|
|
30
|
+
def decode_access(token)
|
|
31
|
+
decode(token, expected_type: ACCESS_TYPE)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Decodes a refresh token. Raises on invalid/expired token.
|
|
35
|
+
def decode_refresh(token)
|
|
36
|
+
decode(token, expected_type: REFRESH_TYPE)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# ── Private ──────────────────────────────────────────────────────────────
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def encode(payload)
|
|
44
|
+
JWT.encode(payload, secret, ALGORITHM)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def decode(token, expected_type:)
|
|
48
|
+
decoded = JWT.decode(token, secret, true, { algorithm: ALGORITHM })
|
|
49
|
+
payload = HashWithIndifferentAccess.new(decoded[0])
|
|
50
|
+
|
|
51
|
+
validate_token_type!(payload, expected_type)
|
|
52
|
+
|
|
53
|
+
payload
|
|
54
|
+
rescue JWT::ExpiredSignature
|
|
55
|
+
raise JwtAuthEngine::TokenExpired, 'Token has expired'
|
|
56
|
+
rescue JWT::DecodeError => e
|
|
57
|
+
raise JwtAuthEngine::InvalidToken, "Invalid token: #{e.message}"
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def validate_token_type!(payload, expected_type)
|
|
61
|
+
return if payload[:token_type] == expected_type
|
|
62
|
+
|
|
63
|
+
raise JwtAuthEngine::InvalidTokenType,
|
|
64
|
+
"Expected a #{expected_type} token but received #{payload[:token_type]}"
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def secret
|
|
68
|
+
JwtAuthEngine.configuration.jwt_secret_key
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
data/config/routes.rb
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
JwtAuthEngine::Engine.routes.draw do
|
|
4
|
+
defaults format: :json do
|
|
5
|
+
# ── Ping endpoints ──────────────────────────────────────────────────────
|
|
6
|
+
controller :ping do
|
|
7
|
+
# GET /ping - public health check endpoint
|
|
8
|
+
get :ping
|
|
9
|
+
|
|
10
|
+
# GET /authenticated_ping - protected endpoint requiring valid access token
|
|
11
|
+
get :authenticated_ping
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# ── Registration ─────────────────────────────────────────────────────────
|
|
15
|
+
controller :registrations do
|
|
16
|
+
# POST /signup - register a new account
|
|
17
|
+
post :signup
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# ── Sessions ─────────────────────────────────────────────────────────────
|
|
21
|
+
controller :sessions do
|
|
22
|
+
# POST /login - authenticate and receive tokens
|
|
23
|
+
post :login
|
|
24
|
+
|
|
25
|
+
# DELETE /logout - stateless logout (client should discard tokens)
|
|
26
|
+
delete :logout
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# ── Tokens ───────────────────────────────────────────────────────────────
|
|
30
|
+
controller :tokens do
|
|
31
|
+
# POST /refresh_token - exchange a valid refresh token for new tokens
|
|
32
|
+
post :refresh_token
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# ── Passwords ────────────────────────────────────────────────────────────
|
|
36
|
+
controller :passwords do
|
|
37
|
+
# POST /change_password - change password for authenticated auth_model
|
|
38
|
+
post :change_password
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# ── Profiles ─────────────────────────────────────────────────────────────
|
|
42
|
+
controller :profiles do
|
|
43
|
+
# GET /me - retrieve profile of authenticated auth_model
|
|
44
|
+
get :me
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'rails/generators'
|
|
4
|
+
require 'rails/generators/migration'
|
|
5
|
+
|
|
6
|
+
module JwtAuthEngine
|
|
7
|
+
module Generators
|
|
8
|
+
# Generator that installs initializer, migration, and model concern wiring.
|
|
9
|
+
class InstallGenerator < Rails::Generators::Base
|
|
10
|
+
include Rails::Generators::Migration
|
|
11
|
+
|
|
12
|
+
source_root File.expand_path('templates', __dir__)
|
|
13
|
+
|
|
14
|
+
AUTH_MODEL_CONCERN_INCLUDE = 'include JwtAuthEngine::AuthModelConcern'
|
|
15
|
+
|
|
16
|
+
class_option :auth_model,
|
|
17
|
+
type: :string, default: 'User', banner: 'User',
|
|
18
|
+
desc: 'Auth model class name (e.g., User, Account, Admin::User)'
|
|
19
|
+
|
|
20
|
+
class_option :identifier_field,
|
|
21
|
+
type: :string, default: 'email', banner: 'email',
|
|
22
|
+
desc: 'Identifier field for login (e.g., email, username)'
|
|
23
|
+
|
|
24
|
+
class_option :password_field,
|
|
25
|
+
type: :string, default: 'password', banner: 'password',
|
|
26
|
+
desc: 'Password attribute name for has_secure_password'
|
|
27
|
+
|
|
28
|
+
def create_initializer
|
|
29
|
+
@auth_model = options[:auth_model]
|
|
30
|
+
@identifier_field = options[:identifier_field].to_sym
|
|
31
|
+
@password_field = options[:password_field].to_sym
|
|
32
|
+
template 'jwt_auth_engine_initializer.rb.tt', 'config/initializers/jwt_auth_engine.rb'
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def copy_migration
|
|
36
|
+
@auth_model_table = options[:auth_model].tableize
|
|
37
|
+
@identifier_field = options[:identifier_field].to_sym
|
|
38
|
+
@password_digest_field = :"#{options[:password_field]}_digest"
|
|
39
|
+
@migration_class_name = "AddJwtAuthEngineColumnsTo#{@auth_model_table.classify.tr('::', '')}"
|
|
40
|
+
@migration_version = host_active_record_migration_version
|
|
41
|
+
|
|
42
|
+
migration_template(
|
|
43
|
+
'add_jwt_auth_engine_columns_migration.rb.tt',
|
|
44
|
+
"db/migrate/add_jwt_auth_engine_columns_to_#{@auth_model_table}.rb"
|
|
45
|
+
)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def create_auth_model_concern
|
|
49
|
+
template 'auth_model_concern.rb.tt', 'app/models/concerns/jwt_auth_engine/auth_model_concern.rb'
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def inject_auth_model_concern
|
|
53
|
+
return warn_missing_auth_model_file unless File.exist?(auth_model_path)
|
|
54
|
+
return note_existing_auth_model_concern if auth_model_concern_included?
|
|
55
|
+
|
|
56
|
+
inject_into_class auth_model_path, options[:auth_model], " #{AUTH_MODEL_CONCERN_INCLUDE}\n\n"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def self.next_migration_number(dirname)
|
|
60
|
+
if defined?(ActiveRecord::Generators::Base)
|
|
61
|
+
ActiveRecord::Generators::Base.next_migration_number(dirname)
|
|
62
|
+
else
|
|
63
|
+
Time.now.utc.strftime('%Y%m%d%H%M%S')
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
def auth_model_path
|
|
70
|
+
"app/models/#{options[:auth_model].underscore}.rb"
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def warn_missing_auth_model_file
|
|
74
|
+
warning_message = "#{auth_model_path} not found. " \
|
|
75
|
+
"Add `#{AUTH_MODEL_CONCERN_INCLUDE}` to #{options[:auth_model]} manually."
|
|
76
|
+
say_status :warning, warning_message, :yellow
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def auth_model_concern_included?
|
|
80
|
+
File.read(auth_model_path).include?(AUTH_MODEL_CONCERN_INCLUDE)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def note_existing_auth_model_concern
|
|
84
|
+
message = "#{auth_model_path} already includes JwtAuthEngine::AuthModelConcern"
|
|
85
|
+
say_status :identical, message, :blue
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def host_active_record_migration_version
|
|
89
|
+
return ActiveRecord::Migration.current_version if ActiveRecord::Migration.respond_to?(:current_version)
|
|
90
|
+
|
|
91
|
+
"#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}"
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
data/lib/generators/jwt_auth_engine/install/templates/add_jwt_auth_engine_columns_migration.rb.tt
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class <%= @migration_class_name %> < ActiveRecord::Migration[<%= @migration_version %>]
|
|
4
|
+
def change
|
|
5
|
+
create_table :<%= @auth_model_table %>, if_not_exists: true, &:timestamps
|
|
6
|
+
|
|
7
|
+
add_column :<%= @auth_model_table %>, :<%= @identifier_field %>, :string, if_not_exists: true
|
|
8
|
+
add_column :<%= @auth_model_table %>, :<%= @password_digest_field %>, :string, if_not_exists: true
|
|
9
|
+
add_index :<%= @auth_model_table %>, :<%= @identifier_field %>, unique: true, if_not_exists: true
|
|
10
|
+
end
|
|
11
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JwtAuthEngine
|
|
4
|
+
module AuthModelConcern
|
|
5
|
+
extend ActiveSupport::Concern
|
|
6
|
+
|
|
7
|
+
included do
|
|
8
|
+
has_secure_password JwtAuthEngine.password_field
|
|
9
|
+
|
|
10
|
+
validates JwtAuthEngine.identifier_field, presence: true, uniqueness: { case_sensitive: false }
|
|
11
|
+
validates JwtAuthEngine.password_field, length: { minimum: 8 }, allow_nil: true
|
|
12
|
+
|
|
13
|
+
# Generated default normalization for the configured identifier field.
|
|
14
|
+
# You can customize or remove this callback to match your identifier semantics.
|
|
15
|
+
before_validation :normalize_identifier
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
private
|
|
19
|
+
|
|
20
|
+
def normalize_identifier
|
|
21
|
+
field = JwtAuthEngine.identifier_field
|
|
22
|
+
reader = field.to_sym
|
|
23
|
+
writer = :"#{field}="
|
|
24
|
+
return unless respond_to?(reader) && respond_to?(writer)
|
|
25
|
+
|
|
26
|
+
value = public_send(reader)
|
|
27
|
+
return unless value.is_a?(String)
|
|
28
|
+
|
|
29
|
+
public_send(writer, value.strip.downcase)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
JwtAuthEngine.configure do |config|
|
|
4
|
+
# Use your host application's auth model (e.g. User, Account, Admin::User)
|
|
5
|
+
config.auth_model = '<%= @auth_model %>'
|
|
6
|
+
|
|
7
|
+
# Field used by login/signup lookup. Most apps should keep :email.
|
|
8
|
+
config.identifier_field = :<%= @identifier_field %>
|
|
9
|
+
|
|
10
|
+
# Base password attribute used by has_secure_password.
|
|
11
|
+
# Digest column is derived as "#{password_field}_digest".
|
|
12
|
+
config.password_field = :<%= @password_field %>
|
|
13
|
+
|
|
14
|
+
# Required: set a secure secret key from your host app configuration.
|
|
15
|
+
# Example:
|
|
16
|
+
# config.jwt_secret_key = Rails.application.credentials.dig(:jwt_auth_engine, :secret_key)
|
|
17
|
+
config.jwt_secret_key = nil
|
|
18
|
+
|
|
19
|
+
# Token expiry windows.
|
|
20
|
+
config.access_token_expiry = 8.hours
|
|
21
|
+
config.refresh_token_expiry = 7.days
|
|
22
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JwtAuthEngine
|
|
4
|
+
# Runtime configuration container for auth model and token settings.
|
|
5
|
+
class Configuration
|
|
6
|
+
def initialize
|
|
7
|
+
@jwt_secret_key = nil
|
|
8
|
+
@auth_model = nil
|
|
9
|
+
@identifier_field = nil
|
|
10
|
+
@password_field = nil
|
|
11
|
+
@access_token_expiry = nil
|
|
12
|
+
@refresh_token_expiry = nil
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
attr_writer :jwt_secret_key,
|
|
16
|
+
:auth_model,
|
|
17
|
+
:identifier_field,
|
|
18
|
+
:password_field,
|
|
19
|
+
:access_token_expiry,
|
|
20
|
+
:refresh_token_expiry
|
|
21
|
+
|
|
22
|
+
def jwt_secret_key
|
|
23
|
+
@jwt_secret_key.presence || (
|
|
24
|
+
raise MissingSecretKey,
|
|
25
|
+
'Missing JWT secret key. ' \
|
|
26
|
+
"Set `config.#{JwtAuthEngine::Constants::JWT_SECRET_CONFIG_KEY}` in your initializer."
|
|
27
|
+
)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def auth_model
|
|
31
|
+
@auth_model.presence || Constants::DEFAULT_AUTH_MODEL
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def identifier_field
|
|
35
|
+
@identifier_field.presence || Constants::DEFAULT_IDENTIFIER_FIELD
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def password_field
|
|
39
|
+
@password_field.presence || Constants::DEFAULT_PASSWORD_FIELD
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def access_token_expiry
|
|
43
|
+
@access_token_expiry.presence || Constants::DEFAULT_ACCESS_TOKEN_EXPIRY
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def refresh_token_expiry
|
|
47
|
+
@refresh_token_expiry.presence || Constants::DEFAULT_REFRESH_TOKEN_EXPIRY
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'active_support/core_ext/integer/time'
|
|
4
|
+
|
|
5
|
+
module JwtAuthEngine
|
|
6
|
+
module Constants
|
|
7
|
+
JWT_SECRET_CONFIG_KEY = 'jwt_secret_key'
|
|
8
|
+
|
|
9
|
+
DEFAULT_AUTH_MODEL = 'User'
|
|
10
|
+
DEFAULT_IDENTIFIER_FIELD = :email
|
|
11
|
+
DEFAULT_PASSWORD_FIELD = :password
|
|
12
|
+
|
|
13
|
+
DEFAULT_ACCESS_TOKEN_EXPIRY = 8.hours
|
|
14
|
+
DEFAULT_REFRESH_TOKEN_EXPIRY = 7.days
|
|
15
|
+
end
|
|
16
|
+
end
|