From 945eaba5e173a2c71a83807805b8616c7cd92c72 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?R=C3=A2u=20Cao?=
Date: Mon, 1 Apr 2024 18:26:33 +0300
Subject: [PATCH] Add login via nostr (web extension)
---
app/assets/stylesheets/components/buttons.css | 5 +
app/controllers/users/sessions_controller.rb | 62 ++++++++++++
.../controllers/nostr_login_controller.js | 53 +++++++++++
app/views/devise/sessions/new.html.erb | 23 +++++
config/routes.rb | 9 +-
spec/fixtures/nostr/valid_auth_event.json | 9 ++
spec/requests/settings_spec.rb | 73 +++++---------
spec/requests/users/sessions_spec.rb | 94 +++++++++++++++++++
8 files changed, 275 insertions(+), 53 deletions(-)
create mode 100644 app/controllers/users/sessions_controller.rb
create mode 100644 app/javascript/controllers/nostr_login_controller.js
create mode 100644 spec/fixtures/nostr/valid_auth_event.json
create mode 100644 spec/requests/users/sessions_spec.rb
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