diff --git a/.env.test b/.env.test index 92e93c8..4b683ca 100644 --- a/.env.test +++ b/.env.test @@ -1,4 +1,5 @@ PRIMARY_DOMAIN=kosmos.org +AKKOUNTS_DOMAIN=accounts.kosmos.org REDIS_URL='redis://localhost:6379/0' diff --git a/Gemfile b/Gemfile index f8e375e..9aa5bca 100644 --- a/Gemfile +++ b/Gemfile @@ -62,7 +62,7 @@ gem "sentry-rails" gem 'discourse_api' gem "lnurl" gem 'manifique' -gem 'nostr' +gem 'nostr', '~> 0.6.0' group :development, :test do # Use sqlite3 as the database for Active Record diff --git a/Gemfile.lock b/Gemfile.lock index ff21ad9..ead85a4 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -155,7 +155,7 @@ GEM ruby2_keywords e2mmap (0.1.0) ecdsa (1.2.0) - ecdsa_ext (0.5.0) + ecdsa_ext (0.5.1) ecdsa (~> 1.2.0) erubi (1.12.0) et-orbi (1.2.7) @@ -278,9 +278,9 @@ GEM racc (~> 1.4) nokogiri (1.16.0-x86_64-linux) racc (~> 1.4) - nostr (0.5.0) + nostr (0.6.0) bech32 (~> 1.4) - bip-schnorr (~> 0.6) + bip-schnorr (~> 0.7) ecdsa (~> 1.2) event_emitter (~> 0.2) faye-websocket (~> 0.11) @@ -517,7 +517,7 @@ DEPENDENCIES lockbox manifique net-ldap - nostr + nostr (~> 0.6.0) pagy (~> 6.0, >= 6.0.2) pg (~> 1.5) puma (~> 4.1) diff --git a/app/assets/stylesheets/components/buttons.css b/app/assets/stylesheets/components/buttons.css index dde8f3c..34fb68c 100644 --- a/app/assets/stylesheets/components/buttons.css +++ b/app/assets/stylesheets/components/buttons.css @@ -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; diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index a0da71a..4eb4820 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -63,4 +63,9 @@ class ApplicationController < ActionController::Base @fetch_balance_retried = true lndhub_fetch_balance end + + def nostr_event_from_params + params.permit! + params[:signed_event].to_h.symbolize_keys + end end diff --git a/app/controllers/settings_controller.rb b/app/controllers/settings_controller.rb index b736c7f..cb7de77 100644 --- a/app/controllers/settings_controller.rb +++ b/app/controllers/settings_controller.rb @@ -87,25 +87,27 @@ class SettingsController < ApplicationController end 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 = NostrManager::VerifySignature.call(event: signed_event) - is_correct_content = signed_event[:content] == "Connect my public key to #{current_user.address} (confirmation #{session[:shared_secret]})" + is_valid_sig = signed_event.verify_signature + is_valid_auth = NostrManager::VerifyAuth.call( + 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" http_status :unprocessable_entity and return 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) flash[:alert] = "Public key already in use for a different account" http_status :unprocessable_entity and return 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 flash[:success] = "Public key verification successful" @@ -160,12 +162,6 @@ class SettingsController < ApplicationController params.require(:user).permit(:current_password) end - def nostr_event_params - params.permit(signed_event: [ - :id, :pubkey, :created_at, :kind, :content, :sig, tags: [] - ]) - end - def generate_email_password characters = [('a'..'z'), ('A'..'Z'), (0..9)].map(&:to_a).flatten SecureRandom.random_bytes(16).each_byte.map { |b| characters[b % characters.length] }.join diff --git a/app/controllers/users/sessions_controller.rb b/app/controllers/users/sessions_controller.rb new file mode 100644 index 0000000..00281ac --- /dev/null +++ b/app/controllers/users/sessions_controller.rb @@ -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 diff --git a/app/javascript/controllers/nostr_login_controller.js b/app/javascript/controllers/nostr_login_controller.js new file mode 100644 index 0000000..67199bc --- /dev/null +++ b/app/javascript/controllers/nostr_login_controller.js @@ -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") + } +} diff --git a/app/javascript/controllers/settings/nostr_pubkey_controller.js b/app/javascript/controllers/settings/nostr_pubkey_controller.js index 9a3875c..4de30e5 100644 --- a/app/javascript/controllers/settings/nostr_pubkey_controller.js +++ b/app/javascript/controllers/settings/nostr_pubkey_controller.js @@ -3,7 +3,12 @@ import { Controller } from "@hotwired/stimulus" // Connects to data-controller="settings--nostr-pubkey" export default class extends Controller { static targets = [ "noExtension", "setPubkey", "pubkeyBech32Input" ] - static values = { userAddress: String, pubkeyHex: String, sharedSecret: String } + static values = { + userAddress: String, + pubkeyHex: String, + site: String, + sharedSecret: String + } connect () { if (window.nostr) { @@ -19,11 +24,15 @@ export default class extends Controller { this.setPubkeyTarget.disabled = true try { + // Auth based on NIP-42 const signedEvent = await window.nostr.signEvent({ created_at: Math.floor(Date.now() / 1000), - kind: 1, - tags: [], - content: `Connect my public key to ${this.userAddressValue} (confirmation ${this.sharedSecretValue})` + kind: 22242, + tags: [ + ["site", this.siteValue], + ["challenge", this.sharedSecretValue] + ], + content: "" }) const res = await fetch("/settings/set_nostr_pubkey", { diff --git a/app/services/nostr_manager/validate_id.rb b/app/services/nostr_manager/validate_id.rb deleted file mode 100644 index e838441..0000000 --- a/app/services/nostr_manager/validate_id.rb +++ /dev/null @@ -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 diff --git a/app/services/nostr_manager/verify_auth.rb b/app/services/nostr_manager/verify_auth.rb new file mode 100644 index 0000000..fdcef2d --- /dev/null +++ b/app/services/nostr_manager/verify_auth.rb @@ -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 diff --git a/app/services/nostr_manager/verify_signature.rb b/app/services/nostr_manager/verify_signature.rb deleted file mode 100644 index 9c62234..0000000 --- a/app/services/nostr_manager/verify_signature.rb +++ /dev/null @@ -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 diff --git a/app/views/devise/sessions/new.html.erb b/app/views/devise/sessions/new.html.erb index a334230..259d527 100644 --- a/app/views/devise/sessions/new.html.erb +++ b/app/views/devise/sessions/new.html.erb @@ -55,4 +55,27 @@ <%= f.submit "Log in", class: 'btn-md btn-blue w-full', tabindex: "4" %>
<% end %> + ++ +
+