Add login via nostr (web extension)
This commit is contained in:
parent
22d362e1a0
commit
945eaba5e1
@ -42,6 +42,11 @@
|
|||||||
focus:ring-red-500 focus:ring-opacity-75;
|
focus:ring-red-500 focus:ring-opacity-75;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.btn-outline-purple {
|
||||||
|
@apply border-2 border-purple-500 hover:bg-purple-100
|
||||||
|
focus:ring-purple-400 focus:ring-opacity-75;
|
||||||
|
}
|
||||||
|
|
||||||
.btn:disabled {
|
.btn:disabled {
|
||||||
@apply bg-gray-100 hover:bg-gray-200 text-gray-400
|
@apply bg-gray-100 hover:bg-gray-200 text-gray-400
|
||||||
focus:ring-gray-300 focus:ring-opacity-75;
|
focus:ring-gray-300 focus:ring-opacity-75;
|
||||||
|
62
app/controllers/users/sessions_controller.rb
Normal file
62
app/controllers/users/sessions_controller.rb
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class Users::SessionsController < Devise::SessionsController
|
||||||
|
# before_action :configure_sign_in_params, only: [:create]
|
||||||
|
|
||||||
|
# GET /resource/sign_in
|
||||||
|
def new
|
||||||
|
session[:shared_secret] = SecureRandom.base64(12)
|
||||||
|
super
|
||||||
|
end
|
||||||
|
|
||||||
|
# POST /resource/sign_in
|
||||||
|
# def create
|
||||||
|
# super
|
||||||
|
# end
|
||||||
|
|
||||||
|
# DELETE /resource/sign_out
|
||||||
|
# def destroy
|
||||||
|
# super
|
||||||
|
# end
|
||||||
|
|
||||||
|
# POST /users/nostr_login
|
||||||
|
def nostr_login
|
||||||
|
signed_event = Nostr::Event.new(**nostr_event_from_params)
|
||||||
|
|
||||||
|
is_valid_sig = signed_event.verify_signature
|
||||||
|
is_valid_auth = NostrManager::VerifyAuth.call(
|
||||||
|
event: signed_event,
|
||||||
|
challenge: session[:shared_secret]
|
||||||
|
)
|
||||||
|
|
||||||
|
session[:shared_secret] = nil
|
||||||
|
|
||||||
|
unless is_valid_sig && is_valid_auth
|
||||||
|
flash[:alert] = "Login verification failed"
|
||||||
|
http_status :unauthorized and return
|
||||||
|
end
|
||||||
|
|
||||||
|
user = LdapManager::FetchUserByNostrKey.call(pubkey: signed_event.pubkey)
|
||||||
|
|
||||||
|
if user.present?
|
||||||
|
set_flash_message!(:notice, :signed_in)
|
||||||
|
sign_in("user", user)
|
||||||
|
render json: { redirect_url: after_sign_in_path_for(user) }, status: :ok
|
||||||
|
else
|
||||||
|
flash[:alert] = "Failed to find your account. Nostr login may be disabled."
|
||||||
|
http_status :unauthorized
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
protected
|
||||||
|
|
||||||
|
def set_flash_message(key, kind, options = {})
|
||||||
|
# Hide flash message after redirecting from a signin route while logged in
|
||||||
|
super unless key == :alert && kind == "already_authenticated"
|
||||||
|
end
|
||||||
|
|
||||||
|
# If you have extra params to permit, append them to the sanitizer.
|
||||||
|
# def configure_sign_in_params
|
||||||
|
# devise_parameter_sanitizer.permit(:sign_in, keys: [:attribute])
|
||||||
|
# end
|
||||||
|
end
|
53
app/javascript/controllers/nostr_login_controller.js
Normal file
53
app/javascript/controllers/nostr_login_controller.js
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
import { Controller } from "@hotwired/stimulus"
|
||||||
|
|
||||||
|
// Connects to data-controller="nostr-login"
|
||||||
|
export default class extends Controller {
|
||||||
|
static targets = [ "loginForm", "loginButton" ]
|
||||||
|
static values = { site: String, sharedSecret: String }
|
||||||
|
|
||||||
|
connect() {
|
||||||
|
if (window.nostr) {
|
||||||
|
this.loginButtonTarget.disabled = false
|
||||||
|
this.loginFormTarget.classList.remove("hidden")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async login () {
|
||||||
|
this.loginButtonTarget.disabled = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Auth based on NIP-42
|
||||||
|
const signedEvent = await window.nostr.signEvent({
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
kind: 22242,
|
||||||
|
tags: [
|
||||||
|
["site", this.siteValue],
|
||||||
|
["challenge", this.sharedSecretValue]
|
||||||
|
],
|
||||||
|
content: ""
|
||||||
|
})
|
||||||
|
|
||||||
|
const res = await fetch("/users/nostr_login", {
|
||||||
|
method: "POST", credentials: "include", headers: {
|
||||||
|
"Accept": "application/json", 'Content-Type': 'application/json',
|
||||||
|
"X-CSRF-Token": this.csrfToken
|
||||||
|
}, body: JSON.stringify({ signed_event: signedEvent })
|
||||||
|
})
|
||||||
|
|
||||||
|
if (res.status === 200) {
|
||||||
|
res.json().then(r => { window.location.href = r.redirect_url })
|
||||||
|
} else {
|
||||||
|
window.location.reload()
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Unable to authenticate:', error.message)
|
||||||
|
} finally {
|
||||||
|
this.loginButtonTarget.disabled = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
get csrfToken () {
|
||||||
|
const element = document.head.querySelector('meta[name="csrf-token"]')
|
||||||
|
return element.getAttribute("content")
|
||||||
|
}
|
||||||
|
}
|
@ -55,4 +55,27 @@
|
|||||||
<%= f.submit "Log in", class: 'btn-md btn-blue w-full', tabindex: "4" %>
|
<%= f.submit "Log in", class: 'btn-md btn-blue w-full', tabindex: "4" %>
|
||||||
</p>
|
</p>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
|
<div data-controller="nostr-login"
|
||||||
|
data-nostr-login-target="loginForm"
|
||||||
|
data-nostr-login-site-value="<%= Setting.accounts_domain %>"
|
||||||
|
data-nostr-login-shared-secret-value="<%= session[:shared_secret] %>"
|
||||||
|
class="hidden">
|
||||||
|
<div class="relative my-6">
|
||||||
|
<div class="absolute inset-0 flex items-center" aria-hidden="true">
|
||||||
|
<div class="w-full border-t border-gray-200"></div>
|
||||||
|
</div>
|
||||||
|
<div class="relative flex justify-center">
|
||||||
|
<span class="bg-white px-2 text-sm text-gray-500 italic">or</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p>
|
||||||
|
<button disabled tabindex="5"
|
||||||
|
class="w-full btn-md btn-gray text-purple-600"
|
||||||
|
data-nostr-login-target="loginButton"
|
||||||
|
data-action="nostr-login#login">
|
||||||
|
Log in with Nostr
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
@ -1,7 +1,14 @@
|
|||||||
require 'sidekiq/web'
|
require 'sidekiq/web'
|
||||||
|
|
||||||
Rails.application.routes.draw do
|
Rails.application.routes.draw do
|
||||||
devise_for :users, controllers: { confirmations: 'users/confirmations' }
|
devise_for :users, controllers: {
|
||||||
|
confirmations: 'users/confirmations',
|
||||||
|
sessions: 'users/sessions'
|
||||||
|
}
|
||||||
|
|
||||||
|
devise_scope :user do
|
||||||
|
post 'users/nostr_login', to: 'users/sessions#nostr_login'
|
||||||
|
end
|
||||||
|
|
||||||
get 'welcome', to: 'welcome#index'
|
get 'welcome', to: 'welcome#index'
|
||||||
get 'check_your_email', to: 'welcome#check_your_email'
|
get 'check_your_email', to: 'welcome#check_your_email'
|
||||||
|
9
spec/fixtures/nostr/valid_auth_event.json
vendored
Normal file
9
spec/fixtures/nostr/valid_auth_event.json
vendored
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"id": "7cc165c4fe4c9ec3f2b859cb422f01b38beaf6bbd228fea928ea1400ec254a89",
|
||||||
|
"pubkey": "07e188a1ff87ce171d517b8ed2bb7a31b1d3453a0db3b15379ec07b724d232f3",
|
||||||
|
"created_at": 1711963922,
|
||||||
|
"kind": 22242,
|
||||||
|
"tags": [["site","accounts.kosmos.org"],["challenge","YMeTyOxIEJcfe6vd"]],
|
||||||
|
"content": "",
|
||||||
|
"sig": "b484a28cd9c92facca0eba80e8ef5303d25ed044c3815e3a068b9887f91d3546ad209a0dd674c59b48cf8057aecd75df5416973d17ed58f68195030af09c28d1"
|
||||||
|
}
|
@ -3,6 +3,7 @@ require 'rails_helper'
|
|||||||
RSpec.describe "Settings", type: :request do
|
RSpec.describe "Settings", type: :request do
|
||||||
let(:user) { create :user, cn: 'mark', ou: 'kosmos.org' }
|
let(:user) { create :user, cn: 'mark', ou: 'kosmos.org' }
|
||||||
let(:other_user) { create :user, id: 2, cn: 'markymark', ou: 'kosmos.org', email: 'markymark@interscope.com' }
|
let(:other_user) { create :user, id: 2, cn: 'markymark', ou: 'kosmos.org', email: 'markymark@interscope.com' }
|
||||||
|
let(:auth_event) { JSON.parse(File.read("#{Rails.root}/spec/fixtures/nostr/valid_auth_event.json")) }
|
||||||
|
|
||||||
before do
|
before do
|
||||||
login_as user, :scope => :user
|
login_as user, :scope => :user
|
||||||
@ -36,17 +37,9 @@ RSpec.describe "Settings", type: :request do
|
|||||||
pubkey: "07e188a1ff87ce171d517b8ed2bb7a31b1d3453a0db3b15379ec07b724d232f3"
|
pubkey: "07e188a1ff87ce171d517b8ed2bb7a31b1d3453a0db3b15379ec07b724d232f3"
|
||||||
).and_return(0)
|
).and_return(0)
|
||||||
|
|
||||||
post set_nostr_pubkey_settings_path, params: {
|
post set_nostr_pubkey_settings_path,
|
||||||
signed_event: {
|
params: { signed_event: auth_event }.to_json,
|
||||||
id: "7cc165c4fe4c9ec3f2b859cb422f01b38beaf6bbd228fea928ea1400ec254a89",
|
headers: {
|
||||||
pubkey: "07e188a1ff87ce171d517b8ed2bb7a31b1d3453a0db3b15379ec07b724d232f3",
|
|
||||||
created_at: 1711963922,
|
|
||||||
kind: 22242,
|
|
||||||
tags: [["site","accounts.kosmos.org"],["challenge","YMeTyOxIEJcfe6vd"]],
|
|
||||||
content: "",
|
|
||||||
sig: "b484a28cd9c92facca0eba80e8ef5303d25ed044c3815e3a068b9887f91d3546ad209a0dd674c59b48cf8057aecd75df5416973d17ed58f68195030af09c28d1"
|
|
||||||
}
|
|
||||||
}.to_json, headers: {
|
|
||||||
"CONTENT_TYPE" => "application/json",
|
"CONTENT_TYPE" => "application/json",
|
||||||
"HTTP_ACCEPT" => "application/json"
|
"HTTP_ACCEPT" => "application/json"
|
||||||
}
|
}
|
||||||
@ -68,17 +61,9 @@ RSpec.describe "Settings", type: :request do
|
|||||||
).and_return(other_user)
|
).and_return(other_user)
|
||||||
expect(LdapManager::UpdateNostrKey).not_to receive(:call)
|
expect(LdapManager::UpdateNostrKey).not_to receive(:call)
|
||||||
|
|
||||||
post set_nostr_pubkey_settings_path, params: {
|
post set_nostr_pubkey_settings_path,
|
||||||
signed_event: {
|
params: { signed_event: auth_event }.to_json,
|
||||||
id: "7cc165c4fe4c9ec3f2b859cb422f01b38beaf6bbd228fea928ea1400ec254a89",
|
headers: {
|
||||||
pubkey: "07e188a1ff87ce171d517b8ed2bb7a31b1d3453a0db3b15379ec07b724d232f3",
|
|
||||||
created_at: 1711963922,
|
|
||||||
kind: 22242,
|
|
||||||
tags: [["site","accounts.kosmos.org"],["challenge","YMeTyOxIEJcfe6vd"]],
|
|
||||||
content: "",
|
|
||||||
sig: "b484a28cd9c92facca0eba80e8ef5303d25ed044c3815e3a068b9887f91d3546ad209a0dd674c59b48cf8057aecd75df5416973d17ed58f68195030af09c28d1"
|
|
||||||
}
|
|
||||||
}.to_json, headers: {
|
|
||||||
"CONTENT_TYPE" => "application/json",
|
"CONTENT_TYPE" => "application/json",
|
||||||
"HTTP_ACCEPT" => "application/json"
|
"HTTP_ACCEPT" => "application/json"
|
||||||
}
|
}
|
||||||
@ -98,17 +83,9 @@ RSpec.describe "Settings", type: :request do
|
|||||||
Setting.accounts_domain = "accounts.wikipedia.org"
|
Setting.accounts_domain = "accounts.wikipedia.org"
|
||||||
expect(LdapManager::UpdateNostrKey).not_to receive(:call)
|
expect(LdapManager::UpdateNostrKey).not_to receive(:call)
|
||||||
|
|
||||||
post set_nostr_pubkey_settings_path, params: {
|
post set_nostr_pubkey_settings_path,
|
||||||
signed_event: {
|
params: { signed_event: auth_event }.to_json,
|
||||||
id: "7cc165c4fe4c9ec3f2b859cb422f01b38beaf6bbd228fea928ea1400ec254a89",
|
headers: {
|
||||||
pubkey: "07e188a1ff87ce171d517b8ed2bb7a31b1d3453a0db3b15379ec07b724d232f3",
|
|
||||||
created_at: 1711963922,
|
|
||||||
kind: 22242,
|
|
||||||
tags: [["site","accounts.kosmos.org"],["challenge","YMeTyOxIEJcfe6vd"]],
|
|
||||||
content: "",
|
|
||||||
sig: "b484a28cd9c92facca0eba80e8ef5303d25ed044c3815e3a068b9887f91d3546ad209a0dd674c59b48cf8057aecd75df5416973d17ed58f68195030af09c28d1"
|
|
||||||
}
|
|
||||||
}.to_json, headers: {
|
|
||||||
"CONTENT_TYPE" => "application/json",
|
"CONTENT_TYPE" => "application/json",
|
||||||
"HTTP_ACCEPT" => "application/json"
|
"HTTP_ACCEPT" => "application/json"
|
||||||
}
|
}
|
||||||
@ -134,17 +111,9 @@ RSpec.describe "Settings", type: :request do
|
|||||||
|
|
||||||
expect(LdapManager::UpdateNostrKey).not_to receive(:call)
|
expect(LdapManager::UpdateNostrKey).not_to receive(:call)
|
||||||
|
|
||||||
post set_nostr_pubkey_settings_path, params: {
|
post set_nostr_pubkey_settings_path,
|
||||||
signed_event: {
|
params: { signed_event: auth_event }.to_json,
|
||||||
id: "7cc165c4fe4c9ec3f2b859cb422f01b38beaf6bbd228fea928ea1400ec254a89",
|
headers: {
|
||||||
pubkey: "07e188a1ff87ce171d517b8ed2bb7a31b1d3453a0db3b15379ec07b724d232f3",
|
|
||||||
created_at: 1711963922,
|
|
||||||
kind: 22242,
|
|
||||||
tags: [["site","accounts.kosmos.org"],["challenge","YMeTyOxIEJcfe6vd"]],
|
|
||||||
content: "",
|
|
||||||
sig: "b484a28cd9c92facca0eba80e8ef5303d25ed044c3815e3a068b9887f91d3546ad209a0dd674c59b48cf8057aecd75df5416973d17ed58f68195030af09c28d1"
|
|
||||||
}
|
|
||||||
}.to_json, headers: {
|
|
||||||
"CONTENT_TYPE" => "application/json",
|
"CONTENT_TYPE" => "application/json",
|
||||||
"HTTP_ACCEPT" => "application/json"
|
"HTTP_ACCEPT" => "application/json"
|
||||||
}
|
}
|
||||||
|
94
spec/requests/users/sessions_spec.rb
Normal file
94
spec/requests/users/sessions_spec.rb
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe "Devise login sessions", type: :request do
|
||||||
|
let(:user) { create :user, cn: 'fiatjaf', ou: 'kosmos.org' }
|
||||||
|
let(:auth_event) { JSON.parse(File.read("#{Rails.root}/spec/fixtures/nostr/valid_auth_event.json")) }
|
||||||
|
|
||||||
|
before do
|
||||||
|
login_as user, :scope => :user
|
||||||
|
|
||||||
|
allow_any_instance_of(User).to receive(:dn)
|
||||||
|
.and_return("cn=#{user.cn},ou=kosmos.org,cn=users,dc=kosmos,dc=org")
|
||||||
|
allow_any_instance_of(User).to receive(:nostr_pubkey).and_return(nil)
|
||||||
|
|
||||||
|
allow(LdapManager::FetchUserByNostrKey).to receive(:call).with(
|
||||||
|
pubkey: "07e188a1ff87ce171d517b8ed2bb7a31b1d3453a0db3b15379ec07b724d232f3"
|
||||||
|
).and_return(nil)
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "POST /users/nostr_login" do
|
||||||
|
before do
|
||||||
|
session_stub = { shared_secret: "YMeTyOxIEJcfe6vd" }
|
||||||
|
allow_any_instance_of(Users::SessionsController).to receive(:session).and_return(session_stub)
|
||||||
|
end
|
||||||
|
|
||||||
|
context "With key configured for an account" do
|
||||||
|
before do
|
||||||
|
expect(LdapManager::FetchUserByNostrKey).to receive(:call).with(
|
||||||
|
pubkey: "07e188a1ff87ce171d517b8ed2bb7a31b1d3453a0db3b15379ec07b724d232f3"
|
||||||
|
).and_return(user)
|
||||||
|
|
||||||
|
post users_nostr_login_path,
|
||||||
|
params: { signed_event: auth_event }.to_json,
|
||||||
|
headers: {
|
||||||
|
"CONTENT_TYPE" => "application/json",
|
||||||
|
"HTTP_ACCEPT" => "application/json"
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
it "returns a success status" do
|
||||||
|
expect(response).to have_http_status(200)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context "With wrong site tag" do
|
||||||
|
before do
|
||||||
|
Setting.accounts_domain = "accounts.wikipedia.org"
|
||||||
|
expect(LdapManager::FetchUserByNostrKey).not_to receive(:call)
|
||||||
|
|
||||||
|
post users_nostr_login_path,
|
||||||
|
params: { signed_event: auth_event }.to_json,
|
||||||
|
headers: {
|
||||||
|
"CONTENT_TYPE" => "application/json",
|
||||||
|
"HTTP_ACCEPT" => "application/json"
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
after do
|
||||||
|
Setting.accounts_domain = "accounts.kosmos.org"
|
||||||
|
end
|
||||||
|
|
||||||
|
it "returns a 422 status" do
|
||||||
|
expect(response).to have_http_status(401)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "informs the user about the failure" do
|
||||||
|
expect(flash[:alert]).to eq("Login verification failed")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context "With wrong shared secret" do
|
||||||
|
before do
|
||||||
|
session_stub = { shared_secret: "ho-chi-minh" }
|
||||||
|
allow_any_instance_of(Users::SessionsController).to receive(:session).and_return(session_stub)
|
||||||
|
|
||||||
|
expect(LdapManager::FetchUserByNostrKey).not_to receive(:call)
|
||||||
|
|
||||||
|
post users_nostr_login_path,
|
||||||
|
params: { signed_event: auth_event }.to_json,
|
||||||
|
headers: {
|
||||||
|
"CONTENT_TYPE" => "application/json",
|
||||||
|
"HTTP_ACCEPT" => "application/json"
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
it "returns a 422 status" do
|
||||||
|
expect(response).to have_http_status(401)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "informs the user about the failure" do
|
||||||
|
expect(flash[:alert]).to eq("Login verification failed")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
Loading…
x
Reference in New Issue
Block a user