Refactor Nostr auth, add login via Nostr (web extension) #188
@ -42,6 +42,11 @@
|
||||
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 {
|
||||
@apply bg-gray-100 hover:bg-gray-200 text-gray-400
|
||||
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" %>
|
||||
</p>
|
||||
<% 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 %>
|
||||
|
@ -1,7 +1,14 @@
|
||||
require 'sidekiq/web'
|
||||
|
||||
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 '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
|
||||
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(:auth_event) { JSON.parse(File.read("#{Rails.root}/spec/fixtures/nostr/valid_auth_event.json")) }
|
||||
|
||||
before do
|
||||
login_as user, :scope => :user
|
||||
@ -36,20 +37,12 @@ RSpec.describe "Settings", type: :request do
|
||||
pubkey: "07e188a1ff87ce171d517b8ed2bb7a31b1d3453a0db3b15379ec07b724d232f3"
|
||||
).and_return(0)
|
||||
|
||||
post set_nostr_pubkey_settings_path, params: {
|
||||
signed_event: {
|
||||
id: "7cc165c4fe4c9ec3f2b859cb422f01b38beaf6bbd228fea928ea1400ec254a89",
|
||||
pubkey: "07e188a1ff87ce171d517b8ed2bb7a31b1d3453a0db3b15379ec07b724d232f3",
|
||||
created_at: 1711963922,
|
||||
kind: 22242,
|
||||
tags: [["site","accounts.kosmos.org"],["challenge","YMeTyOxIEJcfe6vd"]],
|
||||
content: "",
|
||||
sig: "b484a28cd9c92facca0eba80e8ef5303d25ed044c3815e3a068b9887f91d3546ad209a0dd674c59b48cf8057aecd75df5416973d17ed58f68195030af09c28d1"
|
||||
post set_nostr_pubkey_settings_path,
|
||||
params: { signed_event: auth_event }.to_json,
|
||||
headers: {
|
||||
"CONTENT_TYPE" => "application/json",
|
||||
"HTTP_ACCEPT" => "application/json"
|
||||
}
|
||||
}.to_json, headers: {
|
||||
"CONTENT_TYPE" => "application/json",
|
||||
"HTTP_ACCEPT" => "application/json"
|
||||
}
|
||||
end
|
||||
|
||||
it "returns a success status" do
|
||||
@ -68,20 +61,12 @@ RSpec.describe "Settings", type: :request do
|
||||
).and_return(other_user)
|
||||
expect(LdapManager::UpdateNostrKey).not_to receive(:call)
|
||||
|
||||
post set_nostr_pubkey_settings_path, params: {
|
||||
signed_event: {
|
||||
id: "7cc165c4fe4c9ec3f2b859cb422f01b38beaf6bbd228fea928ea1400ec254a89",
|
||||
pubkey: "07e188a1ff87ce171d517b8ed2bb7a31b1d3453a0db3b15379ec07b724d232f3",
|
||||
created_at: 1711963922,
|
||||
kind: 22242,
|
||||
tags: [["site","accounts.kosmos.org"],["challenge","YMeTyOxIEJcfe6vd"]],
|
||||
content: "",
|
||||
sig: "b484a28cd9c92facca0eba80e8ef5303d25ed044c3815e3a068b9887f91d3546ad209a0dd674c59b48cf8057aecd75df5416973d17ed58f68195030af09c28d1"
|
||||
post set_nostr_pubkey_settings_path,
|
||||
params: { signed_event: auth_event }.to_json,
|
||||
headers: {
|
||||
"CONTENT_TYPE" => "application/json",
|
||||
"HTTP_ACCEPT" => "application/json"
|
||||
}
|
||||
}.to_json, headers: {
|
||||
"CONTENT_TYPE" => "application/json",
|
||||
"HTTP_ACCEPT" => "application/json"
|
||||
}
|
||||
end
|
||||
|
||||
it "returns a 422 status" do
|
||||
@ -98,20 +83,12 @@ RSpec.describe "Settings", type: :request do
|
||||
Setting.accounts_domain = "accounts.wikipedia.org"
|
||||
expect(LdapManager::UpdateNostrKey).not_to receive(:call)
|
||||
|
||||
post set_nostr_pubkey_settings_path, params: {
|
||||
signed_event: {
|
||||
id: "7cc165c4fe4c9ec3f2b859cb422f01b38beaf6bbd228fea928ea1400ec254a89",
|
||||
pubkey: "07e188a1ff87ce171d517b8ed2bb7a31b1d3453a0db3b15379ec07b724d232f3",
|
||||
created_at: 1711963922,
|
||||
kind: 22242,
|
||||
tags: [["site","accounts.kosmos.org"],["challenge","YMeTyOxIEJcfe6vd"]],
|
||||
content: "",
|
||||
sig: "b484a28cd9c92facca0eba80e8ef5303d25ed044c3815e3a068b9887f91d3546ad209a0dd674c59b48cf8057aecd75df5416973d17ed58f68195030af09c28d1"
|
||||
post set_nostr_pubkey_settings_path,
|
||||
params: { signed_event: auth_event }.to_json,
|
||||
headers: {
|
||||
"CONTENT_TYPE" => "application/json",
|
||||
"HTTP_ACCEPT" => "application/json"
|
||||
}
|
||||
}.to_json, headers: {
|
||||
"CONTENT_TYPE" => "application/json",
|
||||
"HTTP_ACCEPT" => "application/json"
|
||||
}
|
||||
end
|
||||
|
||||
after do
|
||||
@ -134,20 +111,12 @@ RSpec.describe "Settings", type: :request do
|
||||
|
||||
expect(LdapManager::UpdateNostrKey).not_to receive(:call)
|
||||
|
||||
post set_nostr_pubkey_settings_path, params: {
|
||||
signed_event: {
|
||||
id: "7cc165c4fe4c9ec3f2b859cb422f01b38beaf6bbd228fea928ea1400ec254a89",
|
||||
pubkey: "07e188a1ff87ce171d517b8ed2bb7a31b1d3453a0db3b15379ec07b724d232f3",
|
||||
created_at: 1711963922,
|
||||
kind: 22242,
|
||||
tags: [["site","accounts.kosmos.org"],["challenge","YMeTyOxIEJcfe6vd"]],
|
||||
content: "",
|
||||
sig: "b484a28cd9c92facca0eba80e8ef5303d25ed044c3815e3a068b9887f91d3546ad209a0dd674c59b48cf8057aecd75df5416973d17ed58f68195030af09c28d1"
|
||||
post set_nostr_pubkey_settings_path,
|
||||
params: { signed_event: auth_event }.to_json,
|
||||
headers: {
|
||||
"CONTENT_TYPE" => "application/json",
|
||||
"HTTP_ACCEPT" => "application/json"
|
||||
}
|
||||
}.to_json, headers: {
|
||||
"CONTENT_TYPE" => "application/json",
|
||||
"HTTP_ACCEPT" => "application/json"
|
||||
}
|
||||
end
|
||||
|
||||
it "returns a 422 status" do
|
||||
|
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
so because the event signature is correct and because that event contains the session shared_secret I can trust that it's the user with the pubkey to login. 🤔
Yes, when the signature is correct, we know that the owner of the privkey signed it (unless someone stole their key), so we can look up the user via the pubkey from that event/note.
The shared secret is to ensure that no other, evil site tried to obtain an auth event from the user, because the other site cannot know our shared secret.