shakha 0.2.0 → 0.8.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 +4 -4
- data/CHANGELOG.md +104 -0
- data/README.md +153 -92
- data/SECURITY.md +56 -0
- data/app/controllers/shakha/application_controller.rb +3 -4
- data/app/controllers/shakha/auth_controller.rb +103 -196
- data/app/controllers/shakha/session_controller.rb +27 -59
- data/app/models/shakha/session.rb +27 -7
- data/app/models/shakha/user.rb +4 -8
- data/app/views/shakha/auth/new.html.erb +9 -21
- data/app/views/shakha/layouts/shakha.html.erb +1 -1
- data/lib/generators/shakha/install/install_generator.rb +84 -0
- data/lib/generators/shakha/install/templates/create_shakha_tables.rb.erb +27 -0
- data/lib/generators/shakha/install/templates/shakha.rb.erb +39 -0
- data/lib/shakha/config.rb +10 -30
- data/lib/shakha/config_validator.rb +1 -2
- data/lib/shakha/controller_helpers.rb +16 -33
- data/lib/shakha/engine.rb +10 -17
- data/lib/shakha/error_handler.rb +8 -4
- data/lib/shakha/pkce.rb +5 -4
- data/lib/shakha/providers/base.rb +27 -0
- data/lib/shakha/providers/github.rb +101 -0
- data/lib/shakha/providers/google.rb +113 -0
- data/lib/shakha/providers.rb +19 -0
- data/lib/shakha/rate_limiter.rb +5 -5
- data/lib/shakha/version.rb +2 -2
- data/lib/shakha.rb +4 -29
- metadata +34 -26
- data/app/controllers/shakha/jwks_controller.rb +0 -10
- data/app/controllers/shakha/openid_controller.rb +0 -21
- data/app/models/shakha/client.rb +0 -20
- data/app/views/shakha/auth/sessions.html.erb +0 -66
- data/lib/generators/shakha/install_generator.rb +0 -25
- data/lib/generators/shakha/templates/initializer.rb.erb +0 -29
- data/lib/generators/shakha/templates/migration.rb.erb +0 -45
- data/lib/shakha/auditable.rb +0 -47
- data/lib/shakha/jwt_handler.rb +0 -127
- data/lib/shakha/middleware.rb +0 -49
- data/lib/shakha/pairwise.rb +0 -26
|
@@ -6,199 +6,102 @@ require "uri"
|
|
|
6
6
|
module Shakha
|
|
7
7
|
class AuthController < ApplicationController
|
|
8
8
|
include PKCEMixin
|
|
9
|
-
include
|
|
9
|
+
include RateLimiter
|
|
10
10
|
|
|
11
|
-
skip_before_action :verify_authenticity_token, only: [:callback
|
|
11
|
+
skip_before_action :verify_authenticity_token, only: [ :callback ]
|
|
12
12
|
|
|
13
13
|
def new
|
|
14
|
-
@client = find_or_create_client
|
|
15
14
|
@return_to = sanitize_return_to(params[:return_to])
|
|
15
|
+
@providers = Shakha.config.providers
|
|
16
16
|
end
|
|
17
17
|
|
|
18
18
|
def authorize
|
|
19
|
-
|
|
19
|
+
provider = resolve_provider
|
|
20
20
|
pkce = create_pkce_bundle
|
|
21
|
-
@client = find_or_create_client
|
|
22
21
|
|
|
23
|
-
|
|
22
|
+
auth_url = provider.authorize_url(
|
|
23
|
+
state: pkce[:state],
|
|
24
|
+
code_challenge: pkce[:challenge],
|
|
25
|
+
redirect_uri: callback_redirect_uri(provider),
|
|
26
|
+
nonce: pkce[:nonce]
|
|
27
|
+
)
|
|
24
28
|
|
|
25
|
-
redirect_to
|
|
29
|
+
redirect_to auth_url, allow_other_host: true
|
|
26
30
|
end
|
|
27
31
|
|
|
28
32
|
def callback
|
|
33
|
+
provider = resolve_provider
|
|
29
34
|
pkce_result = verify_pkce!(params[:state])
|
|
30
|
-
exchange_code_for_tokens(params[:code], pkce_result[:verifier], pkce_result[:return_to], pkce_result[:nonce])
|
|
31
|
-
rescue PKCEError, GoogleOAuthError => e
|
|
32
|
-
ActiveSupport::Notifications.instrument("shakha.sign_in_failed", {
|
|
33
|
-
reason: e.class.name,
|
|
34
|
-
ip: request.remote_ip
|
|
35
|
-
})
|
|
36
|
-
Rails.logger.warn("[Shakha] Auth error: #{e.class}: #{e.message}")
|
|
37
|
-
redirect_to "/auth/shakha/error?message=#{URI.encode_www_form_component(user_facing_error(e))}"
|
|
38
|
-
end
|
|
39
|
-
|
|
40
|
-
def token
|
|
41
|
-
code = params[:code]
|
|
42
|
-
verifier = params[:code_verifier]
|
|
43
|
-
|
|
44
|
-
raise PKCEError, "Missing code" unless code
|
|
45
|
-
raise PKCEError, "Missing code_verifier" unless verifier
|
|
46
|
-
|
|
47
|
-
id_token = exchange_code_for_id_token(code, verifier)
|
|
48
|
-
|
|
49
|
-
render json: {
|
|
50
|
-
id_token: id_token,
|
|
51
|
-
pairwise_sub: id_token_payload(id_token)[:sub],
|
|
52
|
-
expires_in: 24.hours.to_i
|
|
53
|
-
}
|
|
54
|
-
rescue PKCEError, JWTError, GoogleOAuthError => e
|
|
55
|
-
render json: { error: e.message }, status: :unauthorized
|
|
56
|
-
end
|
|
57
|
-
|
|
58
|
-
def error
|
|
59
|
-
@message = params[:message] || "Authentication failed"
|
|
60
|
-
end
|
|
61
|
-
|
|
62
|
-
private
|
|
63
|
-
|
|
64
|
-
def sanitize_return_to(raw)
|
|
65
|
-
return "/" if raw.blank?
|
|
66
|
-
|
|
67
|
-
uri = URI.parse(raw)
|
|
68
|
-
app_host = URI.parse(Shakha.config.app_origin).host
|
|
69
35
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
return "/"
|
|
76
|
-
end
|
|
77
|
-
|
|
78
|
-
raw
|
|
79
|
-
rescue URI::InvalidURIError
|
|
80
|
-
"/"
|
|
81
|
-
end
|
|
36
|
+
token_response = provider.exchange_code(
|
|
37
|
+
code: params[:code],
|
|
38
|
+
code_verifier: pkce_result[:verifier],
|
|
39
|
+
redirect_uri: callback_redirect_uri(provider)
|
|
40
|
+
)
|
|
82
41
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
42
|
+
identity = provider.identity_from_response(token_response, expected_nonce: pkce_result[:nonce])
|
|
43
|
+
user = find_or_create_user(provider.provider_name, identity)
|
|
44
|
+
session_record = create_session(user)
|
|
45
|
+
set_session_cookie(session_record)
|
|
46
|
+
redirect_to build_return_url(pkce_result[:return_to], session_record), allow_other_host: true
|
|
86
47
|
|
|
87
|
-
|
|
88
|
-
|
|
48
|
+
rescue PKCEError, OAuthError => e
|
|
49
|
+
handle_auth_failure(e, pkce_result)
|
|
89
50
|
end
|
|
90
51
|
|
|
91
|
-
def
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
nil
|
|
95
|
-
end
|
|
52
|
+
def destroy
|
|
53
|
+
current_session&.destroy
|
|
54
|
+
cookies.delete(:shakha_session_token)
|
|
96
55
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
when PKCEError
|
|
100
|
-
"Authentication failed. Please try again."
|
|
101
|
-
when GoogleOAuthError
|
|
102
|
-
"Unable to sign in with Google. Please try again later."
|
|
56
|
+
if request.format.json?
|
|
57
|
+
render json: { status: "signed_out" }
|
|
103
58
|
else
|
|
104
|
-
|
|
59
|
+
redirect_to params[:return_to].presence || "/"
|
|
105
60
|
end
|
|
106
61
|
end
|
|
107
62
|
|
|
108
|
-
def
|
|
109
|
-
|
|
110
|
-
origin_uri = URI.parse(origin).origin
|
|
111
|
-
|
|
112
|
-
if Shakha.config.embedded?
|
|
113
|
-
Shakha::Client.find_or_create_by!(origin: origin_uri) do |client|
|
|
114
|
-
client.name = URI.parse(origin).host
|
|
115
|
-
end
|
|
116
|
-
else
|
|
117
|
-
Shakha::Client.find_by!(origin: origin_uri)
|
|
118
|
-
end
|
|
119
|
-
rescue ActiveRecord::RecordNotFound
|
|
120
|
-
raise ConfigurationError, "Unknown client origin: #{origin_uri}. Register this origin in shakha_clients first."
|
|
63
|
+
def error
|
|
64
|
+
@message = params[:message] || "Authentication failed"
|
|
121
65
|
end
|
|
122
66
|
|
|
123
|
-
|
|
124
|
-
client_id = Shakha.config.google_client_id || ENV["GOOGLE_CLIENT_ID"]
|
|
125
|
-
base_url = Shakha.config.service_base_url || "http://localhost:3000"
|
|
126
|
-
redirect_uri = "#{base_url}/auth/shakha/callback"
|
|
67
|
+
private
|
|
127
68
|
|
|
128
|
-
|
|
129
|
-
|
|
69
|
+
def resolve_provider
|
|
70
|
+
name = (params[:provider] || :google).to_sym
|
|
71
|
+
raise ProviderNotFound, name.to_s unless Shakha.config.providers.include?(name)
|
|
130
72
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
redirect_uri: redirect_uri,
|
|
134
|
-
response_type: "code",
|
|
135
|
-
scope: scopes,
|
|
136
|
-
code_challenge: pkce[:challenge],
|
|
137
|
-
code_challenge_method: "S256",
|
|
138
|
-
state: pkce[:state],
|
|
139
|
-
nonce: pkce[:nonce],
|
|
140
|
-
access_type: "offline",
|
|
141
|
-
prompt: "consent"
|
|
142
|
-
}
|
|
73
|
+
Shakha::Providers.resolve(name)
|
|
74
|
+
end
|
|
143
75
|
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
76
|
+
# Built from the engine's actual mount point, so Shakha works no matter
|
|
77
|
+
# where the host app mounts it.
|
|
78
|
+
def callback_redirect_uri(provider)
|
|
79
|
+
origin = URI.parse(Shakha.config.app_origin)
|
|
80
|
+
callback_url(provider: provider.provider_name,
|
|
81
|
+
host: origin.host, port: origin.port, protocol: origin.scheme)
|
|
147
82
|
end
|
|
148
83
|
|
|
149
|
-
def
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
redirect_uri = "#{base_url}/auth/shakha/callback"
|
|
154
|
-
|
|
155
|
-
response = http_post(
|
|
156
|
-
"https://oauth2.googleapis.com/token",
|
|
157
|
-
{
|
|
158
|
-
code: code,
|
|
159
|
-
client_id: client_id,
|
|
160
|
-
client_secret: client_secret,
|
|
161
|
-
redirect_uri: redirect_uri,
|
|
162
|
-
grant_type: "authorization_code",
|
|
163
|
-
code_verifier: verifier
|
|
164
|
-
}
|
|
84
|
+
def find_or_create_user(provider_name, identity)
|
|
85
|
+
user = Shakha::User.find_or_initialize_by(
|
|
86
|
+
provider: provider_name.to_s,
|
|
87
|
+
uid: identity[:uid]
|
|
165
88
|
)
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
access_token = tokens["access_token"]
|
|
170
|
-
|
|
171
|
-
raise GoogleOAuthError, "No id_token received" unless id_token
|
|
172
|
-
|
|
173
|
-
payload = decode_id_token(id_token)
|
|
174
|
-
google_sub = payload["sub"]
|
|
175
|
-
|
|
176
|
-
# Verify nonce (OIDC replay protection)
|
|
177
|
-
if expected_nonce && payload["nonce"] != expected_nonce
|
|
178
|
-
raise GoogleOAuthError, "Nonce mismatch"
|
|
179
|
-
end
|
|
180
|
-
|
|
181
|
-
client = find_or_create_client
|
|
182
|
-
pairwise_sub = Shakha.derive_pairwise_sub(google_sub, client.client_id)
|
|
183
|
-
|
|
184
|
-
user = Shakha::User.find_or_initialize_by(pairwise_sub: pairwise_sub, client: client)
|
|
185
|
-
|
|
186
|
-
if payload["email"]
|
|
187
|
-
user.assign_attributes(
|
|
188
|
-
email: payload["email"],
|
|
189
|
-
name: payload["name"],
|
|
190
|
-
picture: payload["picture"]
|
|
191
|
-
)
|
|
192
|
-
end
|
|
89
|
+
user.email = identity[:email]
|
|
90
|
+
user.name = identity[:name]
|
|
91
|
+
user.picture = identity[:picture]
|
|
193
92
|
user.save!
|
|
93
|
+
user
|
|
94
|
+
end
|
|
194
95
|
|
|
195
|
-
|
|
96
|
+
def create_session(user)
|
|
97
|
+
Shakha::Session.create!(
|
|
196
98
|
user: user,
|
|
197
|
-
client: client,
|
|
198
99
|
ip_address: request.remote_ip,
|
|
199
100
|
user_agent: request.user_agent
|
|
200
101
|
)
|
|
102
|
+
end
|
|
201
103
|
|
|
104
|
+
def set_session_cookie(session_record)
|
|
202
105
|
cookies.encrypted[:shakha_session_token] = {
|
|
203
106
|
value: session_record.token,
|
|
204
107
|
httponly: true,
|
|
@@ -206,61 +109,65 @@ module Shakha
|
|
|
206
109
|
same_site: :lax,
|
|
207
110
|
expires: Shakha.config.session_lifetime.from_now
|
|
208
111
|
}
|
|
209
|
-
|
|
210
|
-
redirect_to sanitize_return_to(return_to)
|
|
211
112
|
end
|
|
212
113
|
|
|
213
|
-
def
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
"
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
client_secret: client_secret,
|
|
224
|
-
redirect_uri: redirect_uri,
|
|
225
|
-
grant_type: "authorization_code",
|
|
226
|
-
code_verifier: verifier
|
|
227
|
-
}
|
|
228
|
-
)
|
|
114
|
+
def build_return_url(return_to, session_record)
|
|
115
|
+
uri = URI.parse(return_to || "/")
|
|
116
|
+
existing = URI.decode_www_form(uri.query || "").to_h
|
|
117
|
+
|
|
118
|
+
if Shakha.config.redirect_token_delivery == :token
|
|
119
|
+
existing["token"] = session_record.token
|
|
120
|
+
existing["expires_at"] = session_record.expires_at.iso8601
|
|
121
|
+
else
|
|
122
|
+
existing["code"] = session_record.generate_exchange_code!
|
|
123
|
+
end
|
|
229
124
|
|
|
230
|
-
|
|
231
|
-
|
|
125
|
+
uri.query = URI.encode_www_form(existing)
|
|
126
|
+
uri.to_s
|
|
232
127
|
end
|
|
233
128
|
|
|
234
|
-
def
|
|
235
|
-
|
|
129
|
+
def handle_auth_failure(exception, pkce_result)
|
|
130
|
+
return_to = pkce_result&.dig(:return_to) || "/"
|
|
131
|
+
|
|
132
|
+
if request.format.json? || api_request?
|
|
133
|
+
render json: { error: user_facing_error(exception) }, status: :unauthorized
|
|
134
|
+
else
|
|
135
|
+
redirect_to "#{return_to}?error=#{URI.encode_www_form_component(user_facing_error(exception))}",
|
|
136
|
+
allow_other_host: true
|
|
137
|
+
end
|
|
236
138
|
end
|
|
237
139
|
|
|
238
|
-
def
|
|
239
|
-
|
|
140
|
+
def api_request?
|
|
141
|
+
request.headers["Accept"]&.include?("application/json")
|
|
240
142
|
end
|
|
241
143
|
|
|
242
|
-
def
|
|
243
|
-
|
|
244
|
-
http = Net::HTTP.new(uri.host, uri.port)
|
|
245
|
-
http.use_ssl = uri.scheme == "https"
|
|
246
|
-
http.open_timeout = 5
|
|
247
|
-
http.read_timeout = 10
|
|
144
|
+
def sanitize_return_to(raw)
|
|
145
|
+
return "/" if raw.blank?
|
|
248
146
|
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
request.body = URI.encode_www_form(body)
|
|
147
|
+
uri = URI.parse(raw)
|
|
148
|
+
app_host = URI.parse(Shakha.config.app_origin).host
|
|
252
149
|
|
|
253
|
-
|
|
150
|
+
return "/" unless uri.path.present? && uri.path.start_with?("/")
|
|
254
151
|
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
raise GoogleOAuthError, "Google returned HTTP #{response.code}"
|
|
152
|
+
if uri.host.present? && uri.host != app_host && !allowed_origin?(uri.origin)
|
|
153
|
+
return "/"
|
|
258
154
|
end
|
|
259
155
|
|
|
260
|
-
|
|
261
|
-
rescue
|
|
262
|
-
|
|
263
|
-
|
|
156
|
+
raw
|
|
157
|
+
rescue URI::InvalidURIError
|
|
158
|
+
"/"
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def allowed_origin?(origin)
|
|
162
|
+
Shakha.config.allowed_redirect_origins&.include?(origin) || false
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def user_facing_error(exception)
|
|
166
|
+
case exception
|
|
167
|
+
when PKCEError then "Authentication failed. Please try again."
|
|
168
|
+
when OAuthError then "Unable to sign in. Please try again later."
|
|
169
|
+
else "An unexpected error occurred. Please try again."
|
|
170
|
+
end
|
|
264
171
|
end
|
|
265
172
|
end
|
|
266
|
-
end
|
|
173
|
+
end
|
|
@@ -2,34 +2,35 @@
|
|
|
2
2
|
|
|
3
3
|
module Shakha
|
|
4
4
|
class SessionController < ApplicationController
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
def
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
{
|
|
17
|
-
id: s.id,
|
|
18
|
-
token: s.token,
|
|
19
|
-
created_at: s.created_at.iso8601,
|
|
20
|
-
expires_at: s.expires_at.iso8601,
|
|
21
|
-
current: s.token == current_session.token
|
|
22
|
-
}
|
|
23
|
-
}
|
|
24
|
-
}
|
|
5
|
+
skip_before_action :verify_authenticity_token, only: :exchange
|
|
6
|
+
|
|
7
|
+
# POST /session/exchange — swap a one-time code for the session token.
|
|
8
|
+
def exchange
|
|
9
|
+
session_record = Shakha::Session.exchange(params[:code])
|
|
10
|
+
if session_record
|
|
11
|
+
render json: { token: session_record.token,
|
|
12
|
+
expires_at: session_record.expires_at.iso8601 }
|
|
13
|
+
else
|
|
14
|
+
render json: { error: "Invalid or expired code" }, status: :unauthorized
|
|
15
|
+
end
|
|
25
16
|
end
|
|
26
17
|
|
|
27
18
|
def show
|
|
19
|
+
unless signed_in?
|
|
20
|
+
return render json: { error: "Authentication required" }, status: :unauthorized
|
|
21
|
+
end
|
|
22
|
+
|
|
28
23
|
render json: {
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
24
|
+
user: {
|
|
25
|
+
id: current_user.id,
|
|
26
|
+
email: current_user.email,
|
|
27
|
+
name: current_user.name,
|
|
28
|
+
picture: current_user.picture,
|
|
29
|
+
provider: current_user.provider
|
|
30
|
+
},
|
|
31
|
+
session: {
|
|
32
|
+
expires_at: current_session.expires_at.iso8601
|
|
33
|
+
}
|
|
33
34
|
}
|
|
34
35
|
end
|
|
35
36
|
|
|
@@ -37,41 +38,8 @@ module Shakha
|
|
|
37
38
|
if signed_in?
|
|
38
39
|
render json: { status: "active" }
|
|
39
40
|
else
|
|
40
|
-
render json: {
|
|
41
|
-
status: "login_required",
|
|
42
|
-
reason: "no_session"
|
|
43
|
-
}, status: :unauthorized
|
|
44
|
-
end
|
|
45
|
-
end
|
|
46
|
-
|
|
47
|
-
def destroy
|
|
48
|
-
current_session&.destroy
|
|
49
|
-
cookies.delete(:shakha_session_token)
|
|
50
|
-
|
|
51
|
-
respond_to do |format|
|
|
52
|
-
format.html { redirect_to params[:return_to].presence || "/" }
|
|
53
|
-
format.json { render json: { status: "signed_out" } }
|
|
41
|
+
render json: { status: "expired" }, status: :unauthorized
|
|
54
42
|
end
|
|
55
43
|
end
|
|
56
|
-
|
|
57
|
-
def list
|
|
58
|
-
return redirect_to "/auth/shakha" unless signed_in?
|
|
59
|
-
|
|
60
|
-
@sessions = current_user.sessions.active.order(created_at: :desc)
|
|
61
|
-
@current_token = current_session&.token
|
|
62
|
-
end
|
|
63
|
-
|
|
64
|
-
def revoke
|
|
65
|
-
return render json: { error: "Authentication required" }, status: :unauthorized unless signed_in?
|
|
66
|
-
|
|
67
|
-
session = current_user.sessions.find(params[:id])
|
|
68
|
-
session.destroy
|
|
69
|
-
|
|
70
|
-
cookies.delete(:shakha_session_token) if session.token == current_session&.token
|
|
71
|
-
|
|
72
|
-
log_session_revoked(session)
|
|
73
|
-
|
|
74
|
-
render json: { status: "revoked" }
|
|
75
|
-
end
|
|
76
44
|
end
|
|
77
|
-
end
|
|
45
|
+
end
|
|
@@ -5,13 +5,13 @@ module Shakha
|
|
|
5
5
|
self.table_name = "shakha_sessions"
|
|
6
6
|
|
|
7
7
|
belongs_to :user, class_name: "Shakha::User", optional: true
|
|
8
|
-
belongs_to :client, class_name: "Shakha::Client"
|
|
9
8
|
|
|
10
9
|
before_create :generate_token
|
|
11
|
-
before_create :generate_jti
|
|
12
10
|
|
|
13
11
|
scope :active, -> { where("created_at > ?", Shakha.config.session_lifetime.ago) }
|
|
14
12
|
|
|
13
|
+
EXCHANGE_CODE_TTL = 60.seconds
|
|
14
|
+
|
|
15
15
|
def expired?
|
|
16
16
|
created_at < Shakha.config.session_lifetime.ago
|
|
17
17
|
end
|
|
@@ -20,14 +20,34 @@ module Shakha
|
|
|
20
20
|
created_at + Shakha.config.session_lifetime
|
|
21
21
|
end
|
|
22
22
|
|
|
23
|
+
# Issues a short-lived, single-use code the frontend swaps for the session
|
|
24
|
+
# token, so the token itself never travels in a redirect URL.
|
|
25
|
+
def generate_exchange_code!
|
|
26
|
+
update_columns(
|
|
27
|
+
exchange_code: SecureRandom.urlsafe_base64(32),
|
|
28
|
+
exchange_code_expires_at: EXCHANGE_CODE_TTL.from_now
|
|
29
|
+
)
|
|
30
|
+
exchange_code
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Redeems a code for its session, atomically clearing it so a code can be
|
|
34
|
+
# used at most once even under concurrent requests.
|
|
35
|
+
def self.exchange(code)
|
|
36
|
+
return nil if code.blank?
|
|
37
|
+
|
|
38
|
+
session = active.where(exchange_code: code)
|
|
39
|
+
.where("exchange_code_expires_at > ?", Time.current).first
|
|
40
|
+
return nil unless session
|
|
41
|
+
|
|
42
|
+
claimed = where(id: session.id).where.not(exchange_code: nil)
|
|
43
|
+
.update_all(exchange_code: nil, exchange_code_expires_at: nil)
|
|
44
|
+
claimed == 1 ? session : nil
|
|
45
|
+
end
|
|
46
|
+
|
|
23
47
|
private
|
|
24
48
|
|
|
25
49
|
def generate_token
|
|
26
50
|
self.token ||= SecureRandom.urlsafe_base64(32)
|
|
27
51
|
end
|
|
28
|
-
|
|
29
|
-
def generate_jti
|
|
30
|
-
self.jti ||= SecureRandom.uuid
|
|
31
|
-
end
|
|
32
52
|
end
|
|
33
|
-
end
|
|
53
|
+
end
|
data/app/models/shakha/user.rb
CHANGED
|
@@ -4,14 +4,10 @@ module Shakha
|
|
|
4
4
|
class User < ::ApplicationRecord
|
|
5
5
|
self.table_name = "shakha_users"
|
|
6
6
|
|
|
7
|
-
belongs_to :client, class_name: "Shakha::Client"
|
|
8
7
|
has_many :sessions, class_name: "Shakha::Session", dependent: :destroy
|
|
9
8
|
|
|
10
|
-
validates :
|
|
11
|
-
validates :
|
|
12
|
-
|
|
13
|
-
def can_access?(resource)
|
|
14
|
-
true
|
|
15
|
-
end
|
|
9
|
+
validates :provider, presence: true
|
|
10
|
+
validates :uid, presence: true
|
|
11
|
+
validates :uid, uniqueness: { scope: :provider }
|
|
16
12
|
end
|
|
17
|
-
end
|
|
13
|
+
end
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
<% content_for :title, "Sign in — #{
|
|
1
|
+
<% content_for :title, "Sign in — #{Shakha.config.app_name}" %>
|
|
2
2
|
|
|
3
3
|
<div class="sh-layout sh-animate-fade">
|
|
4
4
|
<div class="sh-card sh-animate-slide">
|
|
@@ -6,35 +6,23 @@
|
|
|
6
6
|
<div class="sh-brand">
|
|
7
7
|
<div class="sh-brand__icon">S</div>
|
|
8
8
|
<div>
|
|
9
|
-
<div class="sh-brand__name"><%=
|
|
9
|
+
<div class="sh-brand__name"><%= Shakha.config.app_name %></div>
|
|
10
10
|
<div class="sh-brand__subtitle">Secure authentication</div>
|
|
11
11
|
</div>
|
|
12
12
|
</div>
|
|
13
13
|
|
|
14
14
|
<h1 class="sh-heading sh-heading--center">Welcome back</h1>
|
|
15
|
-
<p class="sh-subheading">Sign in to continue to <%=
|
|
15
|
+
<p class="sh-subheading">Sign in to continue to <%= Shakha.config.app_name %></p>
|
|
16
16
|
</div>
|
|
17
17
|
|
|
18
18
|
<div class="sh-card__body">
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
<path fill="#FBBC05" d="M3.964 10.71A5.41 5.41 0 0 1 3.682 9c0-.593.102-1.17.282-1.71V4.958H.957A8.996 8.996 0 0 0 0 9c0 1.452.348 2.827.957 4.042l3.007-2.332z"/>
|
|
26
|
-
<path fill="#EA4335" d="M9 3.58c1.321 0 2.508.454 3.44 1.345l2.582-2.58C13.463.891 11.426 0 9 0A8.997 8.997 0 0 0 .957 4.958L3.964 7.29C4.672 5.163 6.656 3.58 9 3.58z"/>
|
|
27
|
-
</svg>
|
|
28
|
-
Continue with Google
|
|
19
|
+
<% @providers.each do |provider| %>
|
|
20
|
+
<%= link_to "/auth/shakha/#{provider}",
|
|
21
|
+
class: "sh-btn sh-btn--#{provider}",
|
|
22
|
+
data: { turbo: false } do %>
|
|
23
|
+
Continue with <%= provider.to_s.titleize %>
|
|
24
|
+
<% end %>
|
|
29
25
|
<% end %>
|
|
30
|
-
|
|
31
|
-
<div class="sh-divider">or</div>
|
|
32
|
-
|
|
33
|
-
<p class="sh-text-center" style="color: var(--sh-text-tertiary); font-size: var(--sh-text-sm);">
|
|
34
|
-
By signing in, you agree to our
|
|
35
|
-
<a href="#">Terms</a> and
|
|
36
|
-
<a href="#">Privacy Policy</a>.
|
|
37
|
-
</p>
|
|
38
26
|
</div>
|
|
39
27
|
|
|
40
28
|
<div class="sh-card__footer">
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
<meta charset="utf-8">
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
6
|
<title><%= yield(:title).presence || "Shakha" %></title>
|
|
7
|
-
|
|
7
|
+
<style><%= Shakha::Engine.root.join("app/assets/stylesheets/shakha.css").read.html_safe %></style>
|
|
8
8
|
<%= csrf_meta_tags %>
|
|
9
9
|
<meta name="theme-color" content="#0f0f23" media="(prefers-color-scheme: dark)">
|
|
10
10
|
<meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)">
|