diff --git a/.env.development b/.env.development index 172d9bd6..40d417a9 100644 --- a/.env.development +++ b/.env.development @@ -36,6 +36,10 @@ LNWITH_URL= LOGIN_EMAIL_SERVER=smtp://mailhog:1025 LOGIN_EMAIL_FROM=sndev@mailhog.dev +# email salt +# openssl rand -hex 32 +EMAIL_SALT=202c90943c313b829e65e3f29164fb5dd7ea3370d7262c4159691c2f6493bb8b + # static things NEXTAUTH_URL=http://localhost:3000/api/auth SELF_URL=http://app:3000 diff --git a/api/resolvers/user.js b/api/resolvers/user.js index 236d1693..98d8350b 100644 --- a/api/resolvers/user.js +++ b/api/resolvers/user.js @@ -9,6 +9,7 @@ import { ANON_USER_ID, DELETE_USER_ID, RESERVED_MAX_USER_ID, SN_NO_REWARDS_IDS } import { viewGroup } from './growth' import { timeUnitForRange, whenRange } from '@/lib/time' import assertApiKeyNotPermitted from './apiKey' +import { hashEmail } from '@/lib/crypto' const contributors = new Set() @@ -44,7 +45,7 @@ async function authMethods (user, args, { models, me }) { return { lightning: !!user.pubkey, - email: user.emailVerified && user.email, + email: !!(user.emailVerified && user.emailHash), twitter: oauth.indexOf('twitter') >= 0, github: oauth.indexOf('github') >= 0, nostr: !!user.nostrAuthPubkey, @@ -686,7 +687,7 @@ export default { try { await models.user.update({ where: { id: me.id }, - data: { email: email.toLowerCase() } + data: { emailHash: hashEmail({ email }) } }) } catch (error) { if (error.code === 'P2002') { diff --git a/api/typeDefs/user.js b/api/typeDefs/user.js index 1596c371..5c0f29db 100644 --- a/api/typeDefs/user.js +++ b/api/typeDefs/user.js @@ -108,7 +108,7 @@ export default gql` nostr: Boolean! github: Boolean! twitter: Boolean! - email: String + email: Boolean! apiKey: Boolean } diff --git a/awards.csv b/awards.csv index 399e8a6c..124d9998 100644 --- a/awards.csv +++ b/awards.csv @@ -66,3 +66,4 @@ benalleng,pr,#1099,#794,medium-hard,,,refined in a commit,450k,benalleng@mutiny. dillon-co,helpfulness,#1099,#794,medium-hard,,,#988 did much of the legwork,225k,bolt11,2024-04-29 abhiShandy,pr,#1119,#1110,good-first-issue,,,,20k,abhishandy@stacker.news,2024-04-28 felipebueno,issue,#1119,#1110,good-first-issue,,,,2k,felipe@stacker.news,2024-04-28 +SatsAllDay,pr,#1111,#622,medium-hard,,,,500k,weareallsatoshi@getalby.com,??? diff --git a/docker-compose.yml b/docker-compose.yml index 884a7804..ee4e656e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -397,6 +397,8 @@ services: - '--autopilot.disable' - '--pool.auctionserver=test.pool.lightning.finance:12010' - '--loop.server.host=test.swap.lightning.today:11010' + labels: + CONNECT: "localhost:8443" stacker_cln: build: context: ./docker/cln @@ -466,6 +468,8 @@ services: - "1025:1025" links: - app + labels: + CONNECT: "localhost:8025" volumes: db: os: diff --git a/lib/crypto.js b/lib/crypto.js new file mode 100644 index 00000000..d1812cbb --- /dev/null +++ b/lib/crypto.js @@ -0,0 +1,9 @@ +import { createHash } from 'node:crypto' + +export function hashEmail ({ + email, + salt = process.env.EMAIL_SALT +}) { + const saltedEmail = `${email.toLowerCase()}${salt}` + return createHash('sha256').update(saltedEmail).digest('hex') +} diff --git a/pages/api/auth/[...nextauth].js b/pages/api/auth/[...nextauth].js index f4889fdc..f0193bfe 100644 --- a/pages/api/auth/[...nextauth].js +++ b/pages/api/auth/[...nextauth].js @@ -11,6 +11,7 @@ import { getToken } from 'next-auth/jwt' import { NodeNextRequest } from 'next/dist/server/base-http/node' import { schnorr } from '@noble/curves/secp256k1' import { notifyReferral } from '@/lib/webPush' +import { hashEmail } from '@/lib/crypto' /** * Stores userIds in user table @@ -71,24 +72,6 @@ function getCallbacks (req) { token.sub = Number(token.id) } - // sign them up for the newsletter - if (isNewUser && user?.email && process.env.LIST_MONK_URL && process.env.LIST_MONK_AUTH) { - fetch(process.env.LIST_MONK_URL + '/api/subscribers', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: 'Basic ' + Buffer.from(process.env.LIST_MONK_AUTH).toString('base64') - }, - body: JSON.stringify({ - email: user.email, - name: 'blank', - lists: [2], - status: 'enabled', - preconfirm_subscriptions: true - }) - }).then(async r => console.log(await r.json())).catch(console.log) - } - return token }, async session ({ session, token }) { @@ -217,7 +200,49 @@ const providers = [ export const getAuthOptions = req => ({ callbacks: getCallbacks(req), providers, - adapter: PrismaAdapter(prisma), + adapter: { + ...PrismaAdapter(prisma), + createUser: data => { + // replace email with email hash in new user payload + if (data.email) { + const { email } = data + data.emailHash = hashEmail({ email }) + delete data.email + // data.email used to be used for name of new accounts. since it's missing, let's generate a new name + data.name = data.emailHash.substring(0, 10) + // sign them up for the newsletter + // don't await it, let it run async + enrollInNewsletter({ email }) + } + return prisma.user.create({ data }) + }, + getUserByEmail: async email => { + const hashedEmail = hashEmail({ email }) + let user = await prisma.user.findUnique({ + where: { + // lookup by email hash since we don't store plaintext emails any more + emailHash: hashedEmail + } + }) + if (!user) { + user = await prisma.user.findUnique({ + where: { + // lookup by email as a fallback in case a user attempts to login by email during the migration + // and their email hasn't been migrated yet + email + } + }) + } + // HACK! This is required to satisfy next-auth's check here: + // https://github.com/nextauthjs/next-auth/blob/5b647e1ac040250ad055e331ba97f8fa461b63cc/packages/next-auth/src/core/routes/callback.ts#L227 + // since we are nulling `email`, but it expects it to be truthy there. + // Since we have the email from the input request, we can copy it here and pretend like we store user emails, even though we don't. + if (user) { + user.email = email + } + return user + } + }, session: { strategy: 'jwt' }, @@ -229,6 +254,34 @@ export const getAuthOptions = req => ({ events: getEventCallbacks() }) +async function enrollInNewsletter ({ email }) { + if (process.env.LIST_MONK_URL && process.env.LIST_MONK_AUTH) { + try { + const response = await fetch(process.env.LIST_MONK_URL + '/api/subscribers', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: 'Basic ' + Buffer.from(process.env.LIST_MONK_AUTH).toString('base64') + }, + body: JSON.stringify({ + email, + name: 'blank', + lists: [2], + status: 'enabled', + preconfirm_subscriptions: true + }) + }) + const jsonResponse = await response.json() + console.log(jsonResponse) + } catch (err) { + console.log('error signing user up for newsletter') + console.log(err) + } + } else { + console.log('LIST MONK env vars not set, skipping newsletter enrollment') + } +} + export default async (req, res) => { await NextAuth(req, res, getAuthOptions(req)) } @@ -238,7 +291,21 @@ async function sendVerificationRequest ({ url, provider }) { - const user = await prisma.user.findUnique({ where: { email } }) + let user = await prisma.user.findUnique({ + where: { + // Look for the user by hashed email + emailHash: hashEmail({ email }) + } + }) + if (!user) { + user = await prisma.user.findUnique({ + where: { + // or plaintext email, in case a user tries to login via email during the migration + // before their particular record has been migrated + email + } + }) + } return new Promise((resolve, reject) => { const { server, from } = provider diff --git a/pages/api/graphql.js b/pages/api/graphql.js index e19723ea..c6dbe22d 100644 --- a/pages/api/graphql.js +++ b/pages/api/graphql.js @@ -57,7 +57,7 @@ export default startServerAndCreateNextHandler(apolloServer, { let session if (apiKey) { const [user] = await models.$queryRaw` - SELECT id, name, email, "apiKeyEnabled" + SELECT id, name, "apiKeyEnabled" FROM users WHERE "apiKeyHash" = encode(digest(${apiKey}, 'sha256'), 'hex') LIMIT 1` diff --git a/pages/settings/index.js b/pages/settings/index.js index 724318ac..20888c0a 100644 --- a/pages/settings/index.js +++ b/pages/settings/index.js @@ -714,15 +714,8 @@ function AuthMethods ({ methods, apiKeyEnabled }) { return methods.email ? (
-