Refactor Nostr auth, add login via Nostr (web extension) #188

Merged
raucao merged 4 commits from feature/nostr_login into master 2024-05-10 11:01:01 +00:00
18 changed files with 333 additions and 101 deletions

View File

@ -1,4 +1,5 @@
PRIMARY_DOMAIN=kosmos.org PRIMARY_DOMAIN=kosmos.org
AKKOUNTS_DOMAIN=accounts.kosmos.org
REDIS_URL='redis://localhost:6379/0' REDIS_URL='redis://localhost:6379/0'

View File

@ -62,7 +62,7 @@ gem "sentry-rails"
gem 'discourse_api' gem 'discourse_api'
gem "lnurl" gem "lnurl"
gem 'manifique' gem 'manifique'
gem 'nostr' gem 'nostr', '~> 0.6.0'
group :development, :test do group :development, :test do
# Use sqlite3 as the database for Active Record # Use sqlite3 as the database for Active Record

View File

@ -155,7 +155,7 @@ GEM
ruby2_keywords ruby2_keywords
e2mmap (0.1.0) e2mmap (0.1.0)
ecdsa (1.2.0) ecdsa (1.2.0)
ecdsa_ext (0.5.0) ecdsa_ext (0.5.1)
ecdsa (~> 1.2.0) ecdsa (~> 1.2.0)
erubi (1.12.0) erubi (1.12.0)
et-orbi (1.2.7) et-orbi (1.2.7)
@ -278,9 +278,9 @@ GEM
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.16.0-x86_64-linux) nokogiri (1.16.0-x86_64-linux)
racc (~> 1.4) racc (~> 1.4)
nostr (0.5.0) nostr (0.6.0)
bech32 (~> 1.4) bech32 (~> 1.4)
bip-schnorr (~> 0.6) bip-schnorr (~> 0.7)
ecdsa (~> 1.2) ecdsa (~> 1.2)
event_emitter (~> 0.2) event_emitter (~> 0.2)
faye-websocket (~> 0.11) faye-websocket (~> 0.11)
@ -517,7 +517,7 @@ DEPENDENCIES
lockbox lockbox
manifique manifique
net-ldap net-ldap
nostr nostr (~> 0.6.0)
pagy (~> 6.0, >= 6.0.2) pagy (~> 6.0, >= 6.0.2)
pg (~> 1.5) pg (~> 1.5)
puma (~> 4.1) puma (~> 4.1)

View File

@ -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;

View File

@ -63,4 +63,9 @@ class ApplicationController < ActionController::Base
@fetch_balance_retried = true @fetch_balance_retried = true
lndhub_fetch_balance lndhub_fetch_balance
end end
def nostr_event_from_params
params.permit!
params[:signed_event].to_h.symbolize_keys
end
end end

View File

@ -87,25 +87,27 @@ class SettingsController < ApplicationController
end end
def set_nostr_pubkey def set_nostr_pubkey
signed_event = nostr_event_params[:signed_event].to_h.symbolize_keys signed_event = Nostr::Event.new(**nostr_event_from_params)
is_valid_id = NostrManager::ValidateId.call(event: signed_event) is_valid_sig = signed_event.verify_signature
is_valid_sig = NostrManager::VerifySignature.call(event: signed_event) is_valid_auth = NostrManager::VerifyAuth.call(
is_correct_content = signed_event[:content] == "Connect my public key to #{current_user.address} (confirmation #{session[:shared_secret]})" event: signed_event,
challenge: session[:shared_secret]
)
unless is_valid_id && is_valid_sig && is_correct_content unless is_valid_sig && is_valid_auth
flash[:alert] = "Public key could not be verified" flash[:alert] = "Public key could not be verified"
http_status :unprocessable_entity and return http_status :unprocessable_entity and return
end end
user_with_pubkey = LdapManager::FetchUserByNostrKey.call(pubkey: signed_event[:pubkey]) user_with_pubkey = LdapManager::FetchUserByNostrKey.call(pubkey: signed_event.pubkey)
if user_with_pubkey.present? && (user_with_pubkey != current_user) if user_with_pubkey.present? && (user_with_pubkey != current_user)
flash[:alert] = "Public key already in use for a different account" flash[:alert] = "Public key already in use for a different account"
http_status :unprocessable_entity and return http_status :unprocessable_entity and return
end end
LdapManager::UpdateNostrKey.call(dn: current_user.dn, pubkey: signed_event[:pubkey]) LdapManager::UpdateNostrKey.call(dn: current_user.dn, pubkey: signed_event.pubkey)
session[:shared_secret] = nil session[:shared_secret] = nil
flash[:success] = "Public key verification successful" flash[:success] = "Public key verification successful"
@ -160,12 +162,6 @@ class SettingsController < ApplicationController
params.require(:user).permit(:current_password) params.require(:user).permit(:current_password)
end end
def nostr_event_params
params.permit(signed_event: [
:id, :pubkey, :created_at, :kind, :content, :sig, tags: []
])
end
def generate_email_password def generate_email_password
characters = [('a'..'z'), ('A'..'Z'), (0..9)].map(&:to_a).flatten characters = [('a'..'z'), ('A'..'Z'), (0..9)].map(&:to_a).flatten
SecureRandom.random_bytes(16).each_byte.map { |b| characters[b % characters.length] }.join SecureRandom.random_bytes(16).each_byte.map { |b| characters[b % characters.length] }.join

View 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
Review

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. 🤔

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. 🤔
Review

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.

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.
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

View 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")
}
}

View File

@ -3,7 +3,12 @@ import { Controller } from "@hotwired/stimulus"
// Connects to data-controller="settings--nostr-pubkey" // Connects to data-controller="settings--nostr-pubkey"
export default class extends Controller { export default class extends Controller {
static targets = [ "noExtension", "setPubkey", "pubkeyBech32Input" ] static targets = [ "noExtension", "setPubkey", "pubkeyBech32Input" ]
static values = { userAddress: String, pubkeyHex: String, sharedSecret: String } static values = {
userAddress: String,
pubkeyHex: String,
site: String,
sharedSecret: String
}
connect () { connect () {
if (window.nostr) { if (window.nostr) {
@ -19,11 +24,15 @@ export default class extends Controller {
this.setPubkeyTarget.disabled = true this.setPubkeyTarget.disabled = true
try { try {
// Auth based on NIP-42
const signedEvent = await window.nostr.signEvent({ const signedEvent = await window.nostr.signEvent({
created_at: Math.floor(Date.now() / 1000), created_at: Math.floor(Date.now() / 1000),
kind: 1, kind: 22242,
tags: [], tags: [
content: `Connect my public key to ${this.userAddressValue} (confirmation ${this.sharedSecretValue})` ["site", this.siteValue],
["challenge", this.sharedSecretValue]
],
content: ""
}) })
const res = await fetch("/settings/set_nostr_pubkey", { const res = await fetch("/settings/set_nostr_pubkey", {

View File

@ -1,11 +0,0 @@
module NostrManager
class ValidateId < NostrManagerService
def initialize(event:)
@event = Nostr::Event.new(**event)
end
def call
@event.id == Digest::SHA256.hexdigest(JSON.generate(@event.serialize))
end
end
end

View File

@ -0,0 +1,17 @@
module NostrManager
class VerifyAuth < NostrManagerService
def initialize(event:, challenge:)
@event = event
@challenge_expected = challenge
@site_expected = Setting.accounts_domain
end
def call
site_given = @event.tags.find{|t| t[0] == "site"}[1]
challenge_given = @event.tags.find{|t| t[0] == "challenge"}[1]
site_given == @site_expected &&
challenge_given == @challenge_expected
end
end
end

View File

@ -1,17 +0,0 @@
module NostrManager
class VerifySignature < NostrManagerService
def initialize(event:)
@event = Nostr::Event.new(**event)
end
def call
Schnorr.check_sig!(
[@event.id].pack('H*'),
[@event.pubkey].pack('H*'),
[@event.sig].pack('H*')
)
rescue Schnorr::InvalidSignatureError
false
end
end
end

View File

@ -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 %>

View File

@ -3,6 +3,7 @@
<h4 class="mb-0">Public Key</h4> <h4 class="mb-0">Public Key</h4>
<div data-controller="settings--nostr-pubkey" <div data-controller="settings--nostr-pubkey"
data-settings--nostr-pubkey-user-address-value="<%= current_user.address %>" data-settings--nostr-pubkey-user-address-value="<%= current_user.address %>"
data-settings--nostr-pubkey-site-value="<%= Setting.accounts_domain %>"
data-settings--nostr-pubkey-shared-secret-value="<%= session[:shared_secret] %>" data-settings--nostr-pubkey-shared-secret-value="<%= session[:shared_secret] %>"
data-settings--nostr-pubkey-pubkey-hex-value="<%= current_user.nostr_pubkey %>"> data-settings--nostr-pubkey-pubkey-hex-value="<%= current_user.nostr_pubkey %>">

View File

@ -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'

View File

@ -0,0 +1,9 @@
{
"id": "7cc165c4fe4c9ec3f2b859cb422f01b38beaf6bbd228fea928ea1400ec254a89",
"pubkey": "07e188a1ff87ce171d517b8ed2bb7a31b1d3453a0db3b15379ec07b724d232f3",
"created_at": 1711963922,
"kind": 22242,
"tags": [["site","accounts.kosmos.org"],["challenge","YMeTyOxIEJcfe6vd"]],
"content": "",
"sig": "b484a28cd9c92facca0eba80e8ef5303d25ed044c3815e3a068b9887f91d3546ad209a0dd674c59b48cf8057aecd75df5416973d17ed58f68195030af09c28d1"
}

View File

@ -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
@ -25,7 +26,7 @@ RSpec.describe "Settings", type: :request do
describe "POST /settings/set_nostr_pubkey" do describe "POST /settings/set_nostr_pubkey" do
before do before do
session_stub = { shared_secret: "rMjWEmvcvtTlQkMd" } session_stub = { shared_secret: "YMeTyOxIEJcfe6vd" }
allow_any_instance_of(SettingsController).to receive(:session).and_return(session_stub) allow_any_instance_of(SettingsController).to receive(:session).and_return(session_stub)
end end
@ -36,19 +37,12 @@ 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: "84f266bbd784551aaa9e35cb0aceb4ee59182a1dab9ab279d9e40dd56ecbbdd3", headers: {
pubkey: "07e188a1ff87ce171d517b8ed2bb7a31b1d3453a0db3b15379ec07b724d232f3", "CONTENT_TYPE" => "application/json",
created_at: 1678254161, "HTTP_ACCEPT" => "application/json"
kind: 1,
content: "Connect my public key to mark@kosmos.org (confirmation rMjWEmvcvtTlQkMd)",
sig: "96796d420547d6e2c7be5de82a2ce7a48be99aac6415464a6081859ac1a9017305accc0228c630466a57d45ec1c3b456376eb538b76dfdaa2397e3258be02fdd"
} }
}.to_json, headers: {
"CONTENT_TYPE" => "application/json",
"HTTP_ACCEPT" => "application/json"
}
end end
it "returns a success status" do it "returns a success status" do
@ -67,19 +61,12 @@ 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: "84f266bbd784551aaa9e35cb0aceb4ee59182a1dab9ab279d9e40dd56ecbbdd3", headers: {
pubkey: "07e188a1ff87ce171d517b8ed2bb7a31b1d3453a0db3b15379ec07b724d232f3", "CONTENT_TYPE" => "application/json",
created_at: 1678254161, "HTTP_ACCEPT" => "application/json"
kind: 1,
content: "Connect my public key to mark@kosmos.org (confirmation rMjWEmvcvtTlQkMd)",
sig: "96796d420547d6e2c7be5de82a2ce7a48be99aac6415464a6081859ac1a9017305accc0228c630466a57d45ec1c3b456376eb538b76dfdaa2397e3258be02fdd"
} }
}.to_json, headers: {
"CONTENT_TYPE" => "application/json",
"HTTP_ACCEPT" => "application/json"
}
end end
it "returns a 422 status" do it "returns a 422 status" do
@ -91,23 +78,21 @@ RSpec.describe "Settings", type: :request do
end end
end end
context "With wrong username" do context "With wrong site tag" do
before do before do
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: "2e1e20ee762d6a5b5b30835eda9ca03146e4baf82490e53fd75794c08de08ac0", headers: {
pubkey: "07e188a1ff87ce171d517b8ed2bb7a31b1d3453a0db3b15379ec07b724d232f3", "CONTENT_TYPE" => "application/json",
created_at: 1678255391, "HTTP_ACCEPT" => "application/json"
kind: 1,
content: "Connect my public key to admin@kosmos.org (confirmation rMjWEmvcvtTlQkMd)",
sig: "2ace19c9db892ac6383848721a3e08b13d90d689fdeac60d9633a623d3f08eb7e0d468f1b3e928d1ea979477c2ec46ee6cdb2d053ef2e4ed3c0630a51d249029"
} }
}.to_json, headers: { end
"CONTENT_TYPE" => "application/json",
"HTTP_ACCEPT" => "application/json" after do
} Setting.accounts_domain = "accounts.kosmos.org"
end end
it "returns a 422 status" do it "returns a 422 status" do
@ -126,19 +111,12 @@ 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: "84f266bbd784551aaa9e35cb0aceb4ee59182a1dab9ab279d9e40dd56ecbbdd3", headers: {
pubkey: "07e188a1ff87ce171d517b8ed2bb7a31b1d3453a0db3b15379ec07b724d232f3", "CONTENT_TYPE" => "application/json",
created_at: 1678254161, "HTTP_ACCEPT" => "application/json"
kind: 1,
content: "Connect my public key to mark@kosmos.org (confirmation rMjWEmvcvtTlQkMd)",
sig: "96796d420547d6e2c7be5de82a2ce7a48be99aac6415464a6081859ac1a9017305accc0228c630466a57d45ec1c3b456376eb538b76dfdaa2397e3258be02fdd"
} }
}.to_json, headers: {
"CONTENT_TYPE" => "application/json",
"HTTP_ACCEPT" => "application/json"
}
end end
it "returns a 422 status" do it "returns a 422 status" do

View 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