diff --git a/src/errors.js b/src/errors.js index cb17044..392d7a0 100644 --- a/src/errors.js +++ b/src/errors.js @@ -26,9 +26,17 @@ class ExpiredError extends Error { } } +class ForbiddenError extends Error { + constructor(message) { + super(message); + this.name = 'ForbiddenError'; + } +} + module.exports = { AlreadyUsedError, InvalidSignatureError, NotFoundError, ExpiredError, + ForbiddenError: ForbiddenError, }; diff --git a/src/middlewares/attachPublicKeyMiddleware.js b/src/middlewares/attachPublicKeyMiddleware.js index 7c31b84..c6ad835 100644 --- a/src/middlewares/attachPublicKeyMiddleware.js +++ b/src/middlewares/attachPublicKeyMiddleware.js @@ -4,8 +4,10 @@ async function attachPublicKeyMiddleware(req, res, next) { const publicKey = await sessionService.getPublicKeyRelatedToSession( req.cookies.sessionUuid ); - req.cookies.publicKey = publicKey; + if (publicKey) { + req.cookies.publicKey = publicKey; + } next(); } diff --git a/src/models/LoginChallengeCompleted.js b/src/models/LoginChallengeCompleted.js new file mode 100644 index 0000000..b4774bb --- /dev/null +++ b/src/models/LoginChallengeCompleted.js @@ -0,0 +1,31 @@ +const { DataTypes } = require('sequelize'); +const sequelize = require('../database'); + +const LoginChallengeCompleted = sequelize.define( + 'LoginChallengeCompleted', + { + uuid: { + type: DataTypes.UUID, + allowNull: false, + unique: true, + primaryKey: true, + }, + nostr_challenge_completed_uuid: { + type: DataTypes.UUID, + allowNull: false, + }, + public_key: { + type: DataTypes.STRING, + allowNull: false, + }, + created_at: { + type: DataTypes.DATE, + allowNull: false, + }, + }, + { + tableName: 'login_challenge_completed', + } +); + +module.exports = LoginChallengeCompleted; diff --git a/src/models/LoginChallengeCreated.js b/src/models/LoginChallengeCreated.js new file mode 100644 index 0000000..c1c1792 --- /dev/null +++ b/src/models/LoginChallengeCreated.js @@ -0,0 +1,27 @@ +const { DataTypes } = require('sequelize'); +const sequelize = require('../database'); + +const LoginChallengeCreated = sequelize.define( + 'LoginChallengeCreated', + { + uuid: { + type: DataTypes.UUID, + allowNull: false, + unique: true, + primaryKey: true, + }, + nostr_challenge_uuid: { + type: DataTypes.UUID, + allowNull: false, + }, + created_at: { + type: DataTypes.DATE, + allowNull: false, + }, + }, + { + tableName: 'login_challenge_created', + } +); + +module.exports = LoginChallengeCreated; diff --git a/src/public/javascript/index.js b/src/public/javascript/index.js index e69de29..36175b4 100644 --- a/src/public/javascript/index.js +++ b/src/public/javascript/index.js @@ -0,0 +1,72 @@ +window.onload = function () { + if (!window.nostr) { + console.log('Nostr extension not present'); + document.querySelector('#login-button').disabled = true; + document.querySelector('#no-extension-nudges').style.display = 'block'; + } else { + console.log('Nostr extension present'); + } +}; + +async function login() { + let challengeResponse; + try { + challengeResponse = await fetch('/api/login/nostr-challenge', { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + }); + } catch (error) { + console.log(`Something went wrong: ${error}`); + return; + } + + const { challenge } = await challengeResponse.json(); + + let pubkey; + try { + pubkey = await window.nostr.getPublicKey(); + } catch (error) { + document.querySelector('#rejected-nostr-nudges').style.display = 'block'; + return; + } + const event = { + kind: 22242, + created_at: Math.floor(Date.now() / 1000), + tags: [['challenge', challenge]], + content: 'Sign this challenge to authenticate', + pubkey: pubkey, + }; + + let signedEvent; + try { + signedEvent = await window.nostr.signEvent(event); + } catch (error) { + document.querySelector('#rejected-nostr-nudges').style.display = 'block'; + return; + } + + let verifyResponse; + try { + verifyResponse = await fetch('/api/login/nostr-verify', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(signedEvent), + }); + } catch (error) { + console.log(`Something went wrong: ${error}`); + return; + } + + if (verifyResponse.status === 403) { + document.querySelector('#rejected-public-key').style.display = 'block'; + } + + if (verifyResponse.ok) { + document.querySelector('#sign-up-success').style.display = 'block'; + setTimeout(() => { + window.location.href = '/home'; + }, 1000); + } +} diff --git a/src/routes/apiRoutes.js b/src/routes/apiRoutes.js index 788e123..f27c0a9 100644 --- a/src/routes/apiRoutes.js +++ b/src/routes/apiRoutes.js @@ -2,6 +2,7 @@ const express = require('express'); const invitesService = require('../services/invitesService'); const nostrService = require('../services/nostrService'); +const loginService = require('../services/loginService'); const sessionService = require('../services/sessionService'); const profileService = require('../services/profileService'); const errors = require('../errors'); @@ -89,6 +90,84 @@ router.post('/signup/nostr-verify', async (req, res) => { return res.status(200).json({ success: true }); }); +router.get('/login/nostr-challenge', async (req, res) => { + let loginChallenge; + try { + loginChallenge = await loginService.createLoginChallenge(); + } catch (error) { + return res.status(500).json({ + success: false, + message: 'Unexpected error.', + }); + } + + let relatedNostrChallenge; + try { + relatedNostrChallenge = await nostrService.getNostrChallenge( + loginChallenge.nostr_challenge_uuid + ); + } catch (error) { + return res.status(500).json({ + success: false, + message: `Unexpected error: ${error}`, + }); + } + + return res.status(200).json({ challenge: relatedNostrChallenge.challenge }); +}); + +router.post('/login/nostr-verify', async (req, res) => { + const signedEvent = req.body; + const sessionUuid = req.cookies.sessionUuid; + + let completedLoginChallenge; + try { + completedLoginChallenge = + await loginService.verifyLoginChallenge(signedEvent); + } catch (error) { + console.log('helo5'); + console.log(error); + if (error instanceof errors.ExpiredError) { + return res.status(410).json({ + success: false, + message: 'The challenge has expired, request a new one.', + }); + } + if (error instanceof errors.AlreadyUsedError) { + return res.status(410).json({ + success: false, + message: 'The challenge has been used, request a new one.', + }); + } + if (error instanceof errors.InvalidSignatureError) { + return res.status(400).json({ + success: false, + message: 'The challenge signature is not valid.', + }); + } + if (error instanceof errors.ForbiddenError) { + console.log('helo?1'); + return res.status(403).json({ + success: false, + message: 'This public key is not authorized.', + }); + } + + return res.status(500).json({ + success: false, + message: 'Unexpected error.', + }); + } + console.log('helo?2'); + console.log(completedLoginChallenge); + await sessionService.relateSessionToPublicKey( + sessionUuid, + completedLoginChallenge.public_key + ); + + return res.status(200).json({ success: true }); +}); + router.post( '/set-contact-details', rejectIfNotAuthorizedMiddleware, diff --git a/src/services/loginService.js b/src/services/loginService.js new file mode 100644 index 0000000..9d5f973 --- /dev/null +++ b/src/services/loginService.js @@ -0,0 +1,58 @@ +const uuid = require('uuid'); + +const nostrService = require('./nostrService'); +const invitesService = require('./invitesService'); +const LoginChallengeCreated = require('../models/LoginChallengeCreated'); +const LoginChallengeCompleted = require('../models/LoginChallengeCompleted'); + +const errors = require('../errors'); + +async function createLoginChallenge() { + const nostrChallenge = await nostrService.createNostrChallenge(); + + return await LoginChallengeCreated.create({ + uuid: uuid.v7(), + nostr_challenge_uuid: nostrChallenge.uuid, + created_at: new Date().toISOString(), + }); +} + +async function verifyLoginChallenge(signedEvent) { + const challengeTag = signedEvent.tags.find((tag) => tag[0] === 'challenge'); + const challenge = challengeTag[1]; + + if (await nostrService.hasNostrChallengeBeenCompleted(challenge)) { + throw new errors.AlreadyUsedError('This challenge has already been used.'); + } + + const completedNostrChallenge = + await nostrService.verifyNostrChallenge(signedEvent); + + if ( + !(await invitesService.isPublicKeySignedUp( + completedNostrChallenge.public_key + )) + ) { + console.log('helo4'); + throw new errors.ForbiddenError( + `Public key ${completedNostrChallenge.public_key} is not authorized.` + ); + } + + const completedLoginChallenge = await LoginChallengeCompleted.create({ + uuid: uuid.v7(), + nostr_challenge_completed_uuid: completedNostrChallenge.uuid, + public_key: completedNostrChallenge.public_key, + created_at: new Date().toISOString(), + }); + + console.log('helo3'); + console.log(completedLoginChallenge); + + return completedLoginChallenge; +} + +module.exports = { + createLoginChallenge, + verifyLoginChallenge, +}; diff --git a/src/services/sessionService.js b/src/services/sessionService.js index f3af704..452f390 100644 --- a/src/services/sessionService.js +++ b/src/services/sessionService.js @@ -76,7 +76,11 @@ async function getPublicKeyRelatedToSession(sessionUuid) { }, }); - return sessionRelatedToPublickey.public_key; + if (sessionRelatedToPublickey) { + return sessionRelatedToPublickey.public_key; + } + + return null; } module.exports = { diff --git a/src/views/index.ejs b/src/views/index.ejs index d558a3d..2b124eb 100644 --- a/src/views/index.ejs +++ b/src/views/index.ejs @@ -12,12 +12,57 @@
Usa Nostr para logearte
-- ¿No tienes cuenta de Nostr? - Crea una gratis. -
+ + + +