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/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/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 %> + + <% end %> diff --git a/config/routes.rb b/config/routes.rb index 47b5988..383e497 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -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' diff --git a/spec/fixtures/nostr/valid_auth_event.json b/spec/fixtures/nostr/valid_auth_event.json new file mode 100644 index 0000000..c48a5f0 --- /dev/null +++ b/spec/fixtures/nostr/valid_auth_event.json @@ -0,0 +1,9 @@ +{ + "id": "7cc165c4fe4c9ec3f2b859cb422f01b38beaf6bbd228fea928ea1400ec254a89", + "pubkey": "07e188a1ff87ce171d517b8ed2bb7a31b1d3453a0db3b15379ec07b724d232f3", + "created_at": 1711963922, + "kind": 22242, + "tags": [["site","accounts.kosmos.org"],["challenge","YMeTyOxIEJcfe6vd"]], + "content": "", + "sig": "b484a28cd9c92facca0eba80e8ef5303d25ed044c3815e3a068b9887f91d3546ad209a0dd674c59b48cf8057aecd75df5416973d17ed58f68195030af09c28d1" +} diff --git a/spec/requests/settings_spec.rb b/spec/requests/settings_spec.rb index f2e8302..dde3405 100644 --- a/spec/requests/settings_spec.rb +++ b/spec/requests/settings_spec.rb @@ -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 diff --git a/spec/requests/users/sessions_spec.rb b/spec/requests/users/sessions_spec.rb new file mode 100644 index 0000000..97c251c --- /dev/null +++ b/spec/requests/users/sessions_spec.rb @@ -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