touchque 5.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 +105 -0
- data/LICENSE +21 -0
- data/README.md +383 -0
- data/lib/touchque/agent.rb +211 -0
- data/lib/touchque/agent_card.rb +159 -0
- data/lib/touchque/approvals.rb +214 -0
- data/lib/touchque/client.rb +81 -0
- data/lib/touchque/code_reply.rb +53 -0
- data/lib/touchque/cross_app_access.rb +204 -0
- data/lib/touchque/crypto.rb +215 -0
- data/lib/touchque/errors.rb +246 -0
- data/lib/touchque/flow.rb +362 -0
- data/lib/touchque/hosted_login.rb +401 -0
- data/lib/touchque/http.rb +127 -0
- data/lib/touchque/login_session.rb +86 -0
- data/lib/touchque/org_agent.rb +398 -0
- data/lib/touchque/rack/relay.rb +75 -0
- data/lib/touchque/rails/guard.rb +132 -0
- data/lib/touchque/rails/hosted_login.rb +143 -0
- data/lib/touchque/rails/sessions_controller.rb +12 -0
- data/lib/touchque/railtie.rb +34 -0
- data/lib/touchque/relay.rb +533 -0
- data/lib/touchque/resources/actions.rb +212 -0
- data/lib/touchque/resources/agents.rb +251 -0
- data/lib/touchque/resources/device_risk.rb +130 -0
- data/lib/touchque/resources/identity.rb +331 -0
- data/lib/touchque/resources/login.rb +325 -0
- data/lib/touchque/resources/mobile.rb +115 -0
- data/lib/touchque/resources/people.rb +237 -0
- data/lib/touchque/resources/qr_pairings.rb +82 -0
- data/lib/touchque/resources/safe_guard.rb +87 -0
- data/lib/touchque/resources/webhook.rb +177 -0
- data/lib/touchque/safe_approval.rb +283 -0
- data/lib/touchque/security_events.rb +162 -0
- data/lib/touchque/util.rb +260 -0
- data/lib/touchque/version.rb +5 -0
- data/lib/touchque.rb +124 -0
- metadata +83 -0
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Touchque
|
|
4
|
+
module Resources
|
|
5
|
+
# Touchque Login Management API (tq.identity): your customers' accounts, roles,
|
|
6
|
+
# business-customer organizations, apps, APIs, connections and the tenant itself,
|
|
7
|
+
# from your server with your workspace key. Bodies and queries are Hashes with the
|
|
8
|
+
# API's camelCase fields (symbol or string keys); answers are the API's JSON.
|
|
9
|
+
#
|
|
10
|
+
# tq.identity.users.list(q: "ayse")
|
|
11
|
+
# tq.identity.users.block(user_id, reason: "chargeback")
|
|
12
|
+
# tq.identity.imports.start(users: [{ email: e, passwordHash: "$2b$10$..." }])
|
|
13
|
+
class Identity
|
|
14
|
+
attr_reader :users, :imports, :exports, :roles, :organizations, :clients, :apis, :connections, :custom_domains
|
|
15
|
+
|
|
16
|
+
def initialize(http)
|
|
17
|
+
@http = http
|
|
18
|
+
@users = IdentityUsers.new(http)
|
|
19
|
+
@imports = IdentityJobs.new(http, "imports")
|
|
20
|
+
@exports = IdentityJobs.new(http, "exports")
|
|
21
|
+
@roles = IdentityCrud.new(http, "/identity/roles", "roles")
|
|
22
|
+
@organizations = IdentityOrganizations.new(http)
|
|
23
|
+
@clients = IdentityClients.new(http)
|
|
24
|
+
@apis = IdentityCrud.new(http, "/identity/apis", "apis")
|
|
25
|
+
@connections = IdentityCrud.new(http, "/identity/connections", "connections")
|
|
26
|
+
@custom_domains = IdentityCrud.new(http, "/identity/custom-domains", "domains")
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# The tenant (404 not_enabled until it is set up).
|
|
30
|
+
def get_tenant
|
|
31
|
+
@http.get("/identity/tenant")
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Turns Touchque Login on (slug, displayName) or changes its settings.
|
|
35
|
+
def update_tenant(body)
|
|
36
|
+
@http.put("/identity/tenant", body)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def rotate_signing_key
|
|
40
|
+
@http.post("/identity/tenant/signing-keys/rotate", {})
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# The phone app the hosted pages tell customers to approve on ("Approve on the Acme app").
|
|
44
|
+
# nil: your brand name when the Mobile SDK is set up, else Touchque (approverApp in the tenant shows which).
|
|
45
|
+
def set_approver_app_name(name)
|
|
46
|
+
@http.put("/identity/tenant", { "branding" => { "approverAppName" => name } })
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Serve sign-in only on the primary custom domain: {slug}.login.touchque.com redirects page loads there and
|
|
50
|
+
# refuses other calls. Turning it on needs a verified primary domain with a working certificate.
|
|
51
|
+
def set_custom_domain_only(on)
|
|
52
|
+
@http.put("/identity/tenant", { "customDomainOnly" => on })
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Checks a verified custom domain's certificate at the sign-in edge now (certificate: pending | active | error).
|
|
56
|
+
def check_domain_certificate(domain_id)
|
|
57
|
+
@http.post("/identity/custom-domains/#{Util.seg(domain_id)}/check-certificate", {})
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# The delivery log. Message text only in the local dev inbox.
|
|
61
|
+
def outbox(params = {})
|
|
62
|
+
@http.get("/identity/outbox", params)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Wrong passwords, suspicious addresses, bot challenge, breached passwords, new-browser notices, the risk engine.
|
|
66
|
+
def get_attack_protection
|
|
67
|
+
@http.get("/identity/attack-protection")
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Partial; the bot challenge's secret is write-only.
|
|
71
|
+
def set_attack_protection(settings)
|
|
72
|
+
@http.put("/identity/attack-protection", settings)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# What attack protection caught lately.
|
|
76
|
+
def attack_events(params = {})
|
|
77
|
+
@http.get("/identity/attack-protection/events", params)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# The email and SMS providers; secrets never come back (secretsSet).
|
|
81
|
+
def get_delivery
|
|
82
|
+
@http.get("/identity/delivery")
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Your own provider: email smtp, ses, postmark, sendgrid; sms twilio, netgsm, e.g.
|
|
86
|
+
# { provider: "postmark", from: "login@acme.com", serverToken: "..." }. A secret left
|
|
87
|
+
# out keeps its stored value.
|
|
88
|
+
def set_delivery(channel, provider)
|
|
89
|
+
@http.put("/identity/delivery/#{Util.seg(channel)}", provider)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Back to Touchque's transport.
|
|
93
|
+
def remove_delivery(channel)
|
|
94
|
+
@http.delete("/identity/delivery/#{Util.seg(channel)}")
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# One test message through the provider in use.
|
|
98
|
+
def test_delivery(channel, to)
|
|
99
|
+
@http.post("/identity/delivery/#{Util.seg(channel)}/test", { "to" => to })
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# List, get, create, update and delete for one kind of Touchque Login object.
|
|
104
|
+
class IdentityCrud
|
|
105
|
+
def initialize(http, base, list_key)
|
|
106
|
+
@http = http
|
|
107
|
+
@base = base
|
|
108
|
+
@list_key = list_key
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def list(params = {})
|
|
112
|
+
@http.get(@base, params)[@list_key] || []
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def get(id)
|
|
116
|
+
@http.get("#{@base}/#{Util.seg(id)}")
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def create(body)
|
|
120
|
+
@http.post(@base, body)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def update(id, body)
|
|
124
|
+
@http.patch("#{@base}/#{Util.seg(id)}", body)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def delete(id)
|
|
128
|
+
@http.delete("#{@base}/#{Util.seg(id)}")
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Your customers' apps (OpenID Connect clients).
|
|
133
|
+
class IdentityClients < IdentityCrud
|
|
134
|
+
def initialize(http)
|
|
135
|
+
super(http, "/identity/clients", "clients")
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# A new client secret: it is in the answer, this once.
|
|
139
|
+
def rotate_secret(client_id)
|
|
140
|
+
@http.post("#{@base}/#{Util.seg(client_id)}/rotate-secret", {})
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Business customers, their members and invitations.
|
|
145
|
+
class IdentityOrganizations < IdentityCrud
|
|
146
|
+
def initialize(http)
|
|
147
|
+
super(http, "/identity/organizations", "organizations")
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def members(org_id)
|
|
151
|
+
@http.get("#{org(org_id)}/members")
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def add_members(org_id, user_ids)
|
|
155
|
+
@http.post("#{org(org_id)}/members", { "userIds" => user_ids.to_a })
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def remove_member(org_id, user_id)
|
|
159
|
+
@http.delete("#{org(org_id)}/members/#{Util.seg(user_id)}")
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def set_connections(org_id, connections)
|
|
163
|
+
@http.put("#{org(org_id)}/connections", { "connections" => connections })
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def invitations(org_id)
|
|
167
|
+
@http.get("#{org(org_id)}/invitations")
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# The link opens the app's sign-in route (initiateLoginUri) with organization and invitation.
|
|
171
|
+
def invite(org_id, invitation)
|
|
172
|
+
@http.post("#{org(org_id)}/invitations", invitation)
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def revoke_invitation(org_id, invitation_id)
|
|
176
|
+
@http.delete("#{org(org_id)}/invitations/#{Util.seg(invitation_id)}")
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
private
|
|
180
|
+
|
|
181
|
+
def org(org_id)
|
|
182
|
+
"#{@base}/#{Util.seg(org_id)}"
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# Bulk user imports and exports, running in the background.
|
|
187
|
+
class IdentityJobs
|
|
188
|
+
def initialize(http, kind)
|
|
189
|
+
@http = http
|
|
190
|
+
@kind = kind
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# Imports: { users: [...], upsert: true }; exports: { format: "csv" | "json" }.
|
|
194
|
+
def start(body)
|
|
195
|
+
@http.post("/identity/users-#{@kind}", body)
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def get(job_id)
|
|
199
|
+
@http.get("/identity/users-#{@kind}/#{Util.seg(job_id)}")
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# Exports: the finished file (CSV or JSON text).
|
|
203
|
+
def file(job_id)
|
|
204
|
+
@http.get_text("/identity/users-#{@kind}/#{Util.seg(job_id)}/file")
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# Polls until the job is done or failed (or the timeout passes: the last state).
|
|
208
|
+
def wait(job_id, interval: 1.0, timeout: 600.0)
|
|
209
|
+
deadline = Time.now + timeout
|
|
210
|
+
loop do
|
|
211
|
+
job = get(job_id)
|
|
212
|
+
return job if job["status"] != "running" || Time.now > deadline
|
|
213
|
+
|
|
214
|
+
sleep interval
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
# A tenant's customer accounts.
|
|
220
|
+
class IdentityUsers
|
|
221
|
+
def initialize(http)
|
|
222
|
+
@http = http
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
# Query: q, blocked, emailVerified, connection, organization, limit, cursor.
|
|
226
|
+
def list(params = {})
|
|
227
|
+
@http.get("/identity/users", params)
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def get(user_id)
|
|
231
|
+
@http.get(u(user_id))
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
# email and: password, passwordHash (bcrypt/argon2), customPasswordHash, emailVerified,
|
|
235
|
+
# name, givenName, familyName, phone, locale, userMetadata, appMetadata, sendVerificationEmail.
|
|
236
|
+
def create(user)
|
|
237
|
+
@http.post("/identity/users", user)
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
# Metadata is merged; a key set to nil is removed.
|
|
241
|
+
def update(user_id, changes)
|
|
242
|
+
@http.patch(u(user_id), changes)
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
# Erasure (KVKK/GDPR): the account and every workspace row that names it.
|
|
246
|
+
def delete(user_id)
|
|
247
|
+
@http.delete(u(user_id))
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def block(user_id, reason: nil)
|
|
251
|
+
@http.post("#{u(user_id)}/block", Util.compact({ "reason" => reason }))
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def unblock(user_id)
|
|
255
|
+
@http.post("#{u(user_id)}/unblock", {})
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
# Everything about the person (KVKK/GDPR access request); never hashes.
|
|
259
|
+
def export(user_id)
|
|
260
|
+
@http.get("#{u(user_id)}/export")
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def sessions(user_id)
|
|
264
|
+
@http.get("#{u(user_id)}/sessions")
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
# Ends one of the user's sessions.
|
|
268
|
+
def end_session(user_id, session_id)
|
|
269
|
+
@http.delete("#{u(user_id)}/sessions/#{Util.seg(session_id)}")
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
def sign_out_everywhere(user_id)
|
|
273
|
+
@http.delete("#{u(user_id)}/sessions")
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
# Removes passkeys, unlinks the Touchque app, turns email codes off, signs out everywhere.
|
|
277
|
+
def reset_mfa(user_id)
|
|
278
|
+
@http.delete("#{u(user_id)}/mfa")
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
# Accounts at providers whose tokens the vault keeps for the user (connections with tokenVault on).
|
|
282
|
+
def connected_accounts(user_id)
|
|
283
|
+
@http.get("#{u(user_id)}/connected-accounts")
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
# Deletes the stored tokens: apps stop using the account for the user; signing in with it stays.
|
|
287
|
+
def disconnect_account(user_id, account_id)
|
|
288
|
+
@http.delete("#{u(user_id)}/connected-accounts/#{Util.seg(account_id)}")
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
def unlink_identity(user_id, identity_id, force: false)
|
|
292
|
+
@http.delete("#{u(user_id)}/identities/#{Util.seg(identity_id)}#{force ? "?force=true" : ""}")
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
def send_verification_email(user_id, return_to: nil)
|
|
296
|
+
@http.post("#{u(user_id)}/verification-email", Util.compact({ "returnTo" => return_to }))
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
# send_email: false: the link comes back for you to deliver.
|
|
300
|
+
def password_reset(user_id, send_email: true, ttl_sec: nil)
|
|
301
|
+
body = { "sendEmail" => send_email }
|
|
302
|
+
body["ttlSec"] = ttl_sec unless ttl_sec.nil?
|
|
303
|
+
@http.post("#{u(user_id)}/password-reset", body)
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
def roles(user_id)
|
|
307
|
+
@http.get("#{u(user_id)}/roles")
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
def add_roles(user_id, role_ids, organization_id: nil)
|
|
311
|
+
body = { "roleIds" => role_ids.to_a }
|
|
312
|
+
body["organizationId"] = organization_id unless organization_id.nil?
|
|
313
|
+
@http.post("#{u(user_id)}/roles", body)
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
def remove_role(user_id, role_id, organization_id: nil)
|
|
317
|
+
@http.delete("#{u(user_id)}/roles/#{Util.seg(role_id)}#{organization_id ? "?organizationId=#{Util.seg(organization_id)}" : ""}")
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
def logs(user_id, limit: nil)
|
|
321
|
+
@http.get("#{u(user_id)}/logs", { "limit" => limit })
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
private
|
|
325
|
+
|
|
326
|
+
def u(user_id)
|
|
327
|
+
"/identity/users/#{Util.seg(user_id)}"
|
|
328
|
+
end
|
|
329
|
+
end
|
|
330
|
+
end
|
|
331
|
+
end
|
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Touchque
|
|
4
|
+
module Resources
|
|
5
|
+
# Setup secrets (the QR code the Touchque app scans) and a user's link status.
|
|
6
|
+
class Auth
|
|
7
|
+
def initialize(http)
|
|
8
|
+
@http = http
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
# A new setup secret: show +qrCodeDataUrl+ so the Touchque app links the phone.
|
|
12
|
+
# 409 when an unscanned or used secret exists (reset_secret issues a new one).
|
|
13
|
+
def generate_secret(external_username)
|
|
14
|
+
@http.post("/auth/generate-secret", { "externalUsername" => external_username })
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def reset_secret(external_username)
|
|
18
|
+
@http.post("/auth/secret/reset", { "externalUsername" => external_username })
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def validate_secret(secret)
|
|
22
|
+
@http.post("/auth/secret/validate", { "secret" => secret })
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# A user's link status without sending a push: +used+ flips true and +deviceId+ is
|
|
26
|
+
# set once the app scans the secret. +device+ is the linked phone's self-reported
|
|
27
|
+
# posture (device trust), nil before a phone is linked. APIError 404 for an unknown user.
|
|
28
|
+
def get_user(external_username)
|
|
29
|
+
@http.get("/users/#{Util.seg(external_username)}")
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Push approvals on the user's phone: request, status, wait, use once, report fraud.
|
|
34
|
+
class Login
|
|
35
|
+
FRAUD_REASONS = %w[chargeback account_takeover social_engineering other].freeze
|
|
36
|
+
|
|
37
|
+
def initialize(http)
|
|
38
|
+
@http = http
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# A push to the user's phone (returns at once).
|
|
42
|
+
#
|
|
43
|
+
# details: transaction context for the approval screen, {"Amount" => "250 EUR"} or
|
|
44
|
+
# [{label:, value:}], at most 8 lines (labels <= 40, values <= 120 characters). Build
|
|
45
|
+
# it from server-side state. browser_proof: the X-Touchque-Browser header (silent
|
|
46
|
+
# sign-ins), passed through untouched. context: for your Touchque Actions only.
|
|
47
|
+
# security_changes: {password:, mfa:, device:, email:, phone:} => Time / ISO 8601 /
|
|
48
|
+
# epoch ms, when each last changed in your system. destination: {kind:, value:, known:}
|
|
49
|
+
# where the action sends money (shown in full on the phone). locale: "tr" or "en".
|
|
50
|
+
def request(external_username, type: "LOGIN", reference_id: nil, client_ip: nil, user_agent: nil,
|
|
51
|
+
require_biometric: false, require_number_match: false, details: nil, browser_proof: nil,
|
|
52
|
+
context: nil, challenge_digits: nil, expires_in: nil, client_signals: nil,
|
|
53
|
+
security_changes: nil, destination: nil, locale: nil, qr_pairing: nil, device_risk: nil, device_risk_request_id: nil, reply: nil)
|
|
54
|
+
body = { "externalUsername" => external_username, "type" => type || "LOGIN" }
|
|
55
|
+
# An empty reference is no reference (the API answers null for it).
|
|
56
|
+
body["referenceId"] = reference_id unless reference_id.to_s.empty?
|
|
57
|
+
body["clientIp"] = client_ip unless client_ip.nil?
|
|
58
|
+
body["userAgent"] = user_agent unless user_agent.nil?
|
|
59
|
+
body["requireBiometric"] = true if require_biometric
|
|
60
|
+
body["requireNumberMatch"] = true if require_number_match
|
|
61
|
+
body["challengeDigits"] = challenge_digits if [2, 3].include?(challenge_digits)
|
|
62
|
+
# How long the push waits for the person: 30 (default) to 300 seconds.
|
|
63
|
+
body["expiresIn"] = expires_in unless expires_in.nil?
|
|
64
|
+
# A browser pairing by QR code (qr_pairings), confirmed on the phone: only that phone is asked, once.
|
|
65
|
+
body["qrPairing"] = qr_pairing unless qr_pairing.to_s.empty?
|
|
66
|
+
# Ask the person to type back on the phone a sign-in code another service sent them:
|
|
67
|
+
# { kind: "code", key: Touchque::CodeReply.new_key["publicKey"], channel: "sms", target: "+**********26" }.
|
|
68
|
+
# status(id)["reply"]["ct"] holds only the ciphertext: Touchque::CodeReply.open opens it. Not with qr_pairing.
|
|
69
|
+
body["reply"] = Util.stringify(reply) if reply
|
|
70
|
+
body["clientSignals"] = client_signals if client_signals && !client_signals.empty?
|
|
71
|
+
# The page's device report from @touchque/web (deviceRisk.collect), when the workspace
|
|
72
|
+
# turned Device risk on: its signals only ever ask for more.
|
|
73
|
+
body["deviceRisk"] = device_risk unless device_risk.to_s.empty?
|
|
74
|
+
body["deviceRiskRequestId"] = device_risk_request_id unless device_risk_request_id.to_s.empty?
|
|
75
|
+
body["details"] = Util.stringify(details) if details && !details.empty?
|
|
76
|
+
body["browserProof"] = browser_proof if browser_proof && !browser_proof.empty?
|
|
77
|
+
body["context"] = Util.stringify(context) if context && !context.empty?
|
|
78
|
+
body["securityChanges"] = self.class.security_changes_body(security_changes) if security_changes && !security_changes.empty?
|
|
79
|
+
body["destination"] = Util.stringify(destination) if destination && !destination.empty?
|
|
80
|
+
body["locale"] = locale if locale && !locale.empty?
|
|
81
|
+
@http.post("/login/request", body)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Times go out as ISO 8601 UTC; strings and epoch milliseconds as given.
|
|
85
|
+
def self.security_changes_body(changes)
|
|
86
|
+
changes.each_with_object({}) do |(kind, at), out|
|
|
87
|
+
next if at.nil?
|
|
88
|
+
|
|
89
|
+
out[kind.to_s] = at.respond_to?(:utc) ? at.utc.strftime("%Y-%m-%dT%H:%M:%S.%LZ") : at
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Where a request is now: PENDING, CONFIRMED, REJECTED or EXPIRED.
|
|
94
|
+
def status(request_id)
|
|
95
|
+
@http.get("/login/status/#{Util.seg(request_id)}")
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Asks the phone and polls until the user approves or rejects (every 0.4 s, up to
|
|
99
|
+
# +timeout+ seconds). With number matching the user picks a number on the phone:
|
|
100
|
+
# +on_challenge+ (or the block) gets {"requestId", "challengeCode", "expiresAt",
|
|
101
|
+
# "reasons", "caseCode"} right after the request exists and before any waiting.
|
|
102
|
+
# Show +challengeCode+ to the user now; the phone asks for it. An error it raises
|
|
103
|
+
# is logged and ignored.
|
|
104
|
+
#
|
|
105
|
+
# Raises PasskeyRequiredError (policy: no push was sent; step_up: tapped during a
|
|
106
|
+
# call), RejectedError, or TimeoutError.
|
|
107
|
+
def verify(external_username, type: "LOGIN", timeout: 30, poll_interval: 0.4, on_challenge: nil, **options, &block)
|
|
108
|
+
on_challenge ||= block
|
|
109
|
+
sent = request(external_username, type: type, **options)
|
|
110
|
+
request_id = sent["requestId"]
|
|
111
|
+
raise PasskeyRequiredError.new(request_id) if sent["requiresPasskey"]
|
|
112
|
+
|
|
113
|
+
if on_challenge
|
|
114
|
+
challenge = { "requestId" => request_id, "expiresAt" => sent["expiresAt"] }
|
|
115
|
+
%w[challengeCode reasons caseCode].each { |k| challenge[k] = sent[k] if sent[k] && !(sent[k].respond_to?(:empty?) && sent[k].empty?) }
|
|
116
|
+
begin
|
|
117
|
+
on_challenge.call(challenge)
|
|
118
|
+
rescue StandardError => e
|
|
119
|
+
# The caller's callback must not stop the wait.
|
|
120
|
+
Touchque.log :warn, "[Touchque] on_challenge failed: #{e.class}: #{e.message}"
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
wait(request_id, sent["challengeCode"], timeout, poll_interval)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Waits for a request you already sent.
|
|
127
|
+
def wait_for_approval(request_id, timeout: 30, poll_interval: 0.4)
|
|
128
|
+
wait(request_id, nil, timeout, poll_interval)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Approves a pending request with one of the user's recovery codes (no phone).
|
|
132
|
+
def approve_with_recovery_code(request_id, code)
|
|
133
|
+
@http.post("/login/recovery", { "requestId" => request_id, "code" => code })
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Uses an approved request exactly once. The first call on a CONFIRMED request wins
|
|
137
|
+
# atomically; every later call raises APIError with code +already_used+ (409).
|
|
138
|
+
def consume(request_id)
|
|
139
|
+
@http.post("/login/#{Util.seg(request_id)}/consume", {})
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Reports a request you later learned was fraud (a chargeback, a confirmed account
|
|
143
|
+
# takeover, a scam the user was talked into). The risk engine learns from it; the
|
|
144
|
+
# request's status does not change. A repeat answers "alreadyReported" => true.
|
|
145
|
+
def report_fraud(request_id, reason: nil, note: nil)
|
|
146
|
+
raise ConfigError, "report_fraud() needs the request id" if request_id.to_s.empty?
|
|
147
|
+
|
|
148
|
+
@http.post("/login/#{Util.seg(request_id)}/report-fraud", Util.compact({ "reason" => reason, "note" => note }))
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
private
|
|
152
|
+
|
|
153
|
+
def wait(request_id, challenge_code, timeout, interval)
|
|
154
|
+
deadline = Time.now + timeout
|
|
155
|
+
loop do
|
|
156
|
+
s = status(request_id)
|
|
157
|
+
case s["status"]
|
|
158
|
+
when "CONFIRMED"
|
|
159
|
+
return {
|
|
160
|
+
"approved" => true, "status" => "CONFIRMED", "requestId" => request_id,
|
|
161
|
+
"challengeCode" => challenge_code, "assurance" => s["assurance"], "confirmedVia" => s["confirmedVia"],
|
|
162
|
+
# Vishing Shield: approved during a call; critical actions wait for the phone to re-confirm.
|
|
163
|
+
"provisional" => s["provisional"] ? true : false
|
|
164
|
+
}
|
|
165
|
+
when "REJECTED" then raise RejectedError.new(request_id)
|
|
166
|
+
when "EXPIRED" then raise TimeoutError.new("Touchque: request '#{request_id}' expired before it was approved.", request_id)
|
|
167
|
+
end
|
|
168
|
+
# Vishing Shield: tapped during a call, the request now waits for a passkey in the browser.
|
|
169
|
+
raise PasskeyRequiredError.new(request_id, "step_up") if s["status"] == "PENDING" && s["requiresPasskey"]
|
|
170
|
+
if Time.now + interval > deadline
|
|
171
|
+
raise TimeoutError.new("Touchque: request '#{request_id}' was not approved within #{timeout}s. The user did not respond in time.", request_id)
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
sleep interval
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# WebAuthn / FIDO2 (passkeys), the backend half: the ceremony runs in the browser
|
|
180
|
+
# (@touchque/web); your backend relays its JSON through these methods.
|
|
181
|
+
class WebAuthn
|
|
182
|
+
def initialize(http)
|
|
183
|
+
@http = http
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# Registration step 1. +approval_request_id+: a PASSKEY_ENROLL request the user approved
|
|
187
|
+
# on their linked phone (403 enrollment_approval_required otherwise).
|
|
188
|
+
def register_options(external_username, discoverable: false, approval_request_id: nil)
|
|
189
|
+
body = { "externalUsername" => external_username }
|
|
190
|
+
body["discoverable"] = true if discoverable
|
|
191
|
+
body["approvalRequestId"] = approval_request_id if approval_request_id && !approval_request_id.empty?
|
|
192
|
+
@http.post("/webauthn/register/options", body)
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def register_verify(external_username, response, label: nil)
|
|
196
|
+
body = { "externalUsername" => external_username, "response" => response }
|
|
197
|
+
body["label"] = label unless label.nil?
|
|
198
|
+
@http.post("/webauthn/register/verify", body)
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# Second factor: options scoped to a pending request.
|
|
202
|
+
def authenticate_options(request_id)
|
|
203
|
+
@http.post("/webauthn/login/options", { "requestId" => request_id })
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def authenticate_verify(request_id, response)
|
|
207
|
+
@http.post("/webauthn/login/verify", { "requestId" => request_id, "response" => response })
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# Whether passkey sign-in (passwordlessLoginEnabled) is on for your workspace.
|
|
211
|
+
# Ask before your sign-in page shows its passkey button and passkey autofill:
|
|
212
|
+
# when it is off, primary_options answers 403 passwordless_login_disabled.
|
|
213
|
+
# { "enabled" => bool, "userVerification" => "required" | "preferred" | nil }.
|
|
214
|
+
def primary_availability
|
|
215
|
+
raw = @http.get("/webauthn/authenticate/primary/availability") || {}
|
|
216
|
+
enabled = raw["enabled"] == true
|
|
217
|
+
uv = raw["userVerification"]
|
|
218
|
+
{ "enabled" => enabled, "userVerification" => enabled && %w[required preferred].include?(uv) ? uv : nil }
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# Passwordless sign-in step 1; without a user, any passkey of your site (autofill).
|
|
222
|
+
def primary_options(external_username = nil)
|
|
223
|
+
@http.post("/webauthn/authenticate/primary/options", Util.compact({ "externalUsername" => external_username }))
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def primary_verify(attempt_id, response)
|
|
227
|
+
@http.post("/webauthn/authenticate/primary/verify", { "attemptId" => attempt_id, "response" => response })
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def list_credentials(external_username)
|
|
231
|
+
@http.get("/webauthn/credentials", { "externalUsername" => external_username })
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
# Scope the deletion to +external_username+ (recommended).
|
|
235
|
+
def delete_credential(credential_record_id, external_username: nil)
|
|
236
|
+
path = "/webauthn/credentials/#{Util.seg(credential_record_id)}"
|
|
237
|
+
path += "?externalUsername=#{Util.seg(external_username)}" if external_username
|
|
238
|
+
@http.delete(path)
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# Offline Sign: QR challenge and typed-code approvals that work with the phone offline.
|
|
243
|
+
class Offline
|
|
244
|
+
ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
|
|
245
|
+
|
|
246
|
+
def initialize(http)
|
|
247
|
+
@http = http
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
# An offline QR challenge. Always pass +request_id+ when the QR follows a push: a
|
|
251
|
+
# phone-side rejection then kills the QR, and number matching carries over.
|
|
252
|
+
def challenge(external_username, type: "LOGIN", details: nil, client_ip: nil, user_agent: nil,
|
|
253
|
+
ttl_seconds: nil, include_qr_image: true, request_id: nil, require_number_match: false)
|
|
254
|
+
body = { "externalUsername" => external_username, "type" => type }
|
|
255
|
+
body["details"] = Util.stringify(details) unless details.nil?
|
|
256
|
+
body["clientIp"] = client_ip unless client_ip.nil?
|
|
257
|
+
body["userAgent"] = user_agent unless user_agent.nil?
|
|
258
|
+
body["ttlSeconds"] = ttl_seconds unless ttl_seconds.nil?
|
|
259
|
+
body["includeQrImage"] = false unless include_qr_image
|
|
260
|
+
body["requestId"] = request_id unless request_id.nil?
|
|
261
|
+
body["requireNumberMatch"] = true if require_number_match
|
|
262
|
+
@http.post("/offline/challenge", body)
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
# The 7-character code from the phone. Never raises for a wrong, expired or used code:
|
|
266
|
+
# check "approved"; "reason" is invalid_code, locked, expired, used, unknown_challenge,
|
|
267
|
+
# request_rejected, too_many_failures or device_not_enrolled.
|
|
268
|
+
def verify(challenge_id, code)
|
|
269
|
+
not_approved_as_result { @http.post("/offline/verify", { "challengeId" => challenge_id, "code" => code }) }
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
# The rolling time-based code. Refused for critical action types.
|
|
273
|
+
def verify_totp(external_username, code, type: "LOGIN", client_ip: nil, request_id: nil)
|
|
274
|
+
body = { "externalUsername" => external_username, "code" => code, "type" => type }
|
|
275
|
+
body["clientIp"] = client_ip unless client_ip.nil?
|
|
276
|
+
body["requestId"] = request_id unless request_id.nil?
|
|
277
|
+
not_approved_as_result { @http.post("/offline/totp/verify", body) }
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
# Salted, slow digests of each user's next codes, for checking typed codes while your
|
|
281
|
+
# server cannot reach Touchque. Check a code with Offline.match_totp_verifier.
|
|
282
|
+
def totp_verifiers(external_usernames, from_step: nil, steps: nil)
|
|
283
|
+
body = { "externalUsernames" => external_usernames.to_a }
|
|
284
|
+
body["fromStep"] = from_step unless from_step.nil?
|
|
285
|
+
body["steps"] = steps unless steps.nil?
|
|
286
|
+
@http.post("/offline/totp/verifiers", body)
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
# The step a typed code belongs to (the current one or one either side), or nil,
|
|
290
|
+
# without calling Touchque. Accept each step once.
|
|
291
|
+
def self.match_totp_verifier(verifiers, user, code, at: nil)
|
|
292
|
+
given = code.to_s.upcase.delete(" \t-")
|
|
293
|
+
return nil unless given.length == 7 && given.chars.all? { |c| ALPHABET.include?(c) }
|
|
294
|
+
|
|
295
|
+
step_seconds = verifiers["stepSeconds"].to_i
|
|
296
|
+
length = verifiers.dig("kdf", "length").to_i
|
|
297
|
+
return nil if step_seconds <= 0 || length <= 0
|
|
298
|
+
|
|
299
|
+
salt = Util.b64_decode(user["salt"])
|
|
300
|
+
digests = Util.b64_decode(user["digests"])
|
|
301
|
+
now = (at || Time.now).to_i / step_seconds
|
|
302
|
+
[now, now - 1, now + 1].each do |step|
|
|
303
|
+
next if step < user["fromStep"].to_i || step > verifiers["notAfterStep"].to_i
|
|
304
|
+
|
|
305
|
+
i = (step - user["fromStep"].to_i) * 2 * length
|
|
306
|
+
next if i + 2 * length > digests.bytesize
|
|
307
|
+
|
|
308
|
+
got = OpenSSL::KDF.pbkdf2_hmac(given, salt: salt + ":#{step}", iterations: verifiers.dig("kdf", "iterations").to_i, length: length, hash: "sha256")
|
|
309
|
+
return step if Util.secure_compare(digests.byteslice(i, length), got) || Util.secure_compare(digests.byteslice(i + length, length), got)
|
|
310
|
+
end
|
|
311
|
+
nil
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
private
|
|
315
|
+
|
|
316
|
+
def not_approved_as_result
|
|
317
|
+
yield
|
|
318
|
+
rescue APIError => e
|
|
319
|
+
raise if e.status >= 500
|
|
320
|
+
|
|
321
|
+
{ "approved" => false, "reason" => e.reason || e.code || e.error, "attemptsLeft" => e.attempts_left }
|
|
322
|
+
end
|
|
323
|
+
end
|
|
324
|
+
end
|
|
325
|
+
end
|