Files
campaign-site/server/routers/auth.ts

342 lines
10 KiB
TypeScript

import { z } from 'zod'
import { TRPCError } from '@trpc/server'
import jwt from 'jsonwebtoken'
import { protectedProcedure, publicProcedure, router } from '../trpc'
import { authenticateKeycloakClient } from '../utils/keycloak'
import { keycloak, transporter } from '../services'
import { env } from '../../env.mjs'
import { fundSlugs } from '../../utils/funds'
import { UserSettingsJwtPayload } from '../types'
import { isTurnstileValid } from '../utils/turnstile'
import { isNameProfane } from '../utils/profanity'
import { log } from '../../utils/logging'
export const authRouter = router({
register: publicProcedure
.input(
z
.object({
turnstileToken: z.string().min(1),
firstName: z
.string()
.trim()
.min(1)
.regex(/^[A-Za-záéíóúÁÉÍÓÚñÑçÇ]+$/, 'Use alphabetic characters only.'),
lastName: z
.string()
.trim()
.min(1)
.regex(/^[A-Za-záéíóúÁÉÍÓÚñÑçÇ]+$/, 'Use alphabetic characters only.'),
company: z.string(),
email: z.string().email(),
password: z.string().min(8),
confirmPassword: z.string().min(8),
fundSlug: z.enum(fundSlugs),
nextAction: z.enum(['membership']).optional(),
_addMailingAddress: z.boolean(),
address: z
.object({
addressLine1: z.string(),
addressLine2: z.string(),
city: z.string(),
state: z.string(),
country: z.string(),
zip: z.string(),
_addressStateOptionsLength: z.number(),
})
.superRefine((data, ctx) => {
if (!data.state && data._addressStateOptionsLength) {
ctx.addIssue({
path: ['shippingState'],
code: 'custom',
message: 'State is required.',
})
}
}),
})
.refine((data) => data.password === data.confirmPassword, {
message: 'Passwords do not match.',
path: ['confirmPassword'],
})
.superRefine((data, ctx) => {
if (data._addMailingAddress) {
if (!data.address.addressLine1) {
ctx.addIssue({
path: ['shipping.addressLine1'],
code: 'custom',
message: 'Address line 1 is required.',
})
}
if (!data.address.country) {
ctx.addIssue({
path: ['shipping.country'],
code: 'custom',
message: 'Country is required.',
})
}
if (!data.address.city) {
ctx.addIssue({
path: ['shipping.city'],
code: 'custom',
message: 'City is required.',
})
}
if (!data.address.zip) {
ctx.addIssue({
path: ['shipping.zip'],
code: 'custom',
message: 'Postal code is required.',
})
}
}
})
)
.mutation(async ({ input }) => {
if (!(await isTurnstileValid(input.turnstileToken))) {
throw new TRPCError({ code: 'BAD_REQUEST', message: 'INVALID_TURNSTILE_TOKEN' })
}
await authenticateKeycloakClient()
let user: { id: string }
const name = `${input.firstName} ${input.lastName}`
const nameIsProfane = await isNameProfane(name)
try {
user = await keycloak.users.create({
realm: env.KEYCLOAK_REALM_NAME,
email: input.email,
credentials: [{ type: 'password', value: input.password, temporary: false }],
requiredActions: ['VERIFY_EMAIL'],
attributes: {
name,
nameIsProfane,
passwordResetTokenVersion: 1,
emailVerifyTokenVersion: 1,
company: input.company,
addressLine1: input._addMailingAddress ? input.address.addressLine1 : '',
addressLine2: input._addMailingAddress ? input.address.addressLine2 : '',
addressCountry: input._addMailingAddress ? input.address.country : '',
addressState: input._addMailingAddress ? input.address.state : '',
addressCity: input._addMailingAddress ? input.address.city : '',
addressZip: input._addMailingAddress ? input.address.zip : '',
},
enabled: true,
})
} catch (error) {
if ((error as any).responseData.errorMessage === 'User exists with same email') {
throw new TRPCError({ code: 'BAD_REQUEST', message: 'EMAIL_TAKEN' })
}
throw error
}
const payload: UserSettingsJwtPayload = {
action: 'email_verify',
tokenVersion: 1,
userId: user.id,
email: input.email,
}
const emailVerifyToken = jwt.sign(payload, env.USER_SETTINGS_JWT_SECRET, { expiresIn: '1d' })
// no await here as we don't want to block the response
transporter.sendMail({
from: env.SES_VERIFIED_SENDER,
to: input.email,
subject: 'Verify your email',
html: `<a href="${env.APP_URL}/${input.fundSlug}/verify-email/${emailVerifyToken}${input.nextAction === 'membership' ? `?nextAction=membership` : ''}" target="_blank">Verify email</a>`,
})
}),
verifyEmail: publicProcedure
.input(z.object({ token: z.string() }))
.mutation(async ({ input }) => {
let decoded: UserSettingsJwtPayload
try {
decoded = jwt.verify(input.token, env.USER_SETTINGS_JWT_SECRET) as UserSettingsJwtPayload
} catch (error) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'INVALID_TOKEN',
})
}
if (decoded.action !== 'email_verify') {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'INVALID_ACTION',
})
}
await authenticateKeycloakClient()
const user = await keycloak.users.findOne({ id: decoded.userId })
if (!user || !user.id)
throw new TRPCError({
code: 'NOT_FOUND',
message: 'USER_NOT_FOUND',
})
const emailVerifyTokenVersion =
parseInt(user.attributes?.emailVerifyTokenVersion?.[0]) || null
if (emailVerifyTokenVersion !== decoded.tokenVersion) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'INVALID_TOKEN_VERSION',
})
}
await keycloak.users.update(
{ id: decoded.userId },
{
...user,
email: decoded.email,
emailVerified: true,
requiredActions: [],
attributes: {
...user.attributes,
emailVerifyTokenVersion: (emailVerifyTokenVersion + 1).toString(),
},
}
)
await keycloak.users.logout({ id: decoded.userId })
return { email: decoded.email }
}),
requestPasswordReset: publicProcedure
.input(
z.object({
turnstileToken: z.string().min(1),
email: z.string().email(),
fundSlug: z.enum(fundSlugs),
})
)
.mutation(async ({ input }) => {
if (!(await isTurnstileValid(input.turnstileToken))) {
throw new TRPCError({ code: 'BAD_REQUEST', message: 'INVALID_TURNSTILE_TOKEN' })
}
await authenticateKeycloakClient()
const users = await keycloak.users.find({ email: input.email })
const user = users[0]
if (!user || !user.id)
throw new TRPCError({
code: 'NOT_FOUND',
message: 'USER_NOT_FOUND',
})
let passwordResetTokenVersion =
parseInt(user.attributes?.passwordResetTokenVersion?.[0]) || null
if (!passwordResetTokenVersion) {
await keycloak.users.update(
{ id: user.id },
{
...user,
email: input.email,
attributes: { ...user.attributes, passwordResetTokenVersion: 1 },
}
)
passwordResetTokenVersion = 1
}
const payload: UserSettingsJwtPayload = {
action: 'password-reset',
userId: user.id,
email: input.email,
tokenVersion: passwordResetTokenVersion,
}
const passwordResetToken = jwt.sign(payload, env.USER_SETTINGS_JWT_SECRET, {
expiresIn: '30m',
})
// no await here as we don't want to block the response
transporter.sendMail({
from: env.SES_VERIFIED_SENDER,
to: input.email,
subject: 'Reset your password',
html: `<a href="${env.APP_URL}/${input.fundSlug}/reset-password/${passwordResetToken}" target="_blank">Reset password</a>`,
})
}),
resetPassword: publicProcedure
.input(z.object({ token: z.string(), password: z.string().min(8) }))
.mutation(async ({ input }) => {
let decoded: UserSettingsJwtPayload
try {
decoded = jwt.verify(input.token, env.USER_SETTINGS_JWT_SECRET) as UserSettingsJwtPayload
} catch (error) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'INVALID_TOKEN',
})
}
if (decoded.action !== 'password-reset') {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'INVALID_ACTION',
})
}
await authenticateKeycloakClient()
const user = await keycloak.users.findOne({ id: decoded.userId })
if (!user || !user.id || !user.email)
throw new TRPCError({
code: 'NOT_FOUND',
message: 'USER_NOT_FOUND',
})
let passwordResetTokenVersion =
parseInt(user.attributes?.passwordResetTokenVersion?.[0]) || null
if (!passwordResetTokenVersion) {
await keycloak.users.update(
{ id: user.id },
{ ...user, attributes: { ...user.attributes, passwordResetTokenVersion: 1 } }
)
passwordResetTokenVersion = 1
}
if (decoded.tokenVersion !== passwordResetTokenVersion)
throw new TRPCError({
code: 'FORBIDDEN',
message: 'INVALID_TOKEN',
})
await keycloak.users.update(
{ id: decoded.userId },
{
...user,
email: decoded.email,
credentials: [{ type: 'password', value: input.password, temporary: false }],
attributes: {
...user.attributes,
passwordResetTokenVersion: (passwordResetTokenVersion + 1).toString(),
},
}
)
await keycloak.users.logout({ id: decoded.userId })
return { email: decoded.email }
}),
})