Skip to content

Feat/api email and password #561

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 4 commits into
base: feat/user-registration-api
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
129 changes: 128 additions & 1 deletion app/lib/user-service.server.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { eq } from "drizzle-orm";
import invariant from "tiny-invariant";
import { v4 as uuidv4 } from "uuid";
import { revokeToken } from "./jwt";
import {
type EmailValidation,
Expand All @@ -8,6 +10,7 @@ import {
validatePassword,
validateUsername,
} from "./user-service";
import { drizzleClient } from "~/db.server";
import {
createUser,
deleteUserByEmail,
Expand All @@ -18,7 +21,9 @@ import {
updateUserPassword,
verifyLogin,
} from "~/models/user.server";
import { type User } from "~/schema";
import { passwordResetRequest, user, type User } from "~/schema";

const ONE_HOUR_MILLIS: number = 60 * 60 * 1000;

/**
* Register a new user with the database.
Expand Down Expand Up @@ -229,3 +234,125 @@ export const deleteUser = async (
await revokeToken(user, jwtString);
return (await deleteUserByEmail(user.email)).count > 0;
};

/**
* Confirms a users email address by processing the token sent to the user and updating
* the profile when successful.
* @param emailConfirmationToken Token sent to the user via mail to the to-be-confirmed address
* @param emailToConfirm To-be-confirmed addresss
* @returns The updated user profile when successful or null when the specified user
* does not exist or the token is invalid.
*/
export const confirmEmail = async (
emailConfirmationToken: string,
emailToConfirm: string,
): Promise<User | null> => {
const u = await drizzleClient.query.user.findFirst({
where: (user, { eq }) => eq(user.unconfirmedEmail, emailToConfirm),
});

if (!u || u.emailConfirmationToken !== emailConfirmationToken) return null;

const updatedUser = await drizzleClient
.update(user)
.set({
emailIsConfirmed: true,
emailConfirmationToken: null,
email: emailToConfirm,
unconfirmedEmail: null,
})
.returning();

return updatedUser[0];
};

/**
* Initiates a password request for the user with the given email address.
* Overwrites existing requests.
* @param email The email address to request a password reset for
*/
export const requestPasswordReset = async (email: string) => {
const user = await drizzleClient.query.user.findFirst({
where: (user, { eq }) => eq(user.email, email.toLowerCase()),
});

if (!user) return;

await drizzleClient
.insert(passwordResetRequest)
.values({ userId: user.id })
.onConflictDoUpdate({
target: passwordResetRequest.userId,
set: {
token: uuidv4(),
expiresAt: new Date(Date.now() + 12 * ONE_HOUR_MILLIS), // 12 hours from now
},
});

// TODO send out email
};

/**
* Resets a users password using a specified passwordResetToken received through an email.
* @param passwordResetToken A token sent to the user via email to allow a password reset without being logged in.
* @param newPassword The new password for the user
* @returns "forbidden" if the user is not entitled to reset the password with the given parameters,
* "expired" if the {@link passwordResetToken} is expired,
* "invalid_password_format" if the specified new password does not comply with the password requirements,
* "success" if the password was successfuly set to the {@link newPassword}
*/
export const resetPassword = async (
passwordResetToken: string,
newPassword: string,
): Promise<"forbidden" | "expired" | "invalid_password_format" | "success"> => {
const passwordReset =
await drizzleClient.query.passwordResetRequest.findFirst({
where: (reset, { eq }) => eq(reset.token, passwordResetToken),
});

if (!passwordReset) return "forbidden";

if (Date.now() > passwordReset.expiresAt.getTime()) return "expired";

// Validate new Password
if (validatePassword(newPassword).isValid === false)
return "invalid_password_format";

const updated = await updateUserPassword(passwordReset.userId, newPassword);

invariant(updated.length === 1);
// invalidate password reset token
await drizzleClient
.delete(passwordResetRequest)
.where(eq(passwordResetRequest.token, passwordResetToken));

// TODO: invalidate refreshToken and active accessTokens

return "success";
};

/**
* Resends the email confirmation for the given user again.
* This will reset existing email confirmation tokens and therefore
* make outstanding requests invalid.
* @param u The user to resend the email confirmation
* @returns "already_confirmed" if there is no email confirmation pending,
* else the updated user containing the new email confirmation token
*/
export const resendEmailConfirmation = async (
u: User,
): Promise<"already_confirmed" | User> => {
if (u.emailIsConfirmed && u.unconfirmedEmail?.trim().length === 0)
return "already_confirmed";

const savedUser = await drizzleClient
.update(user)
.set({
emailConfirmationToken: uuidv4(),
})
.where(eq(user.id, u.id))
.returning();

// TODO actually send the confirmation
return savedUser[0];
};
17 changes: 10 additions & 7 deletions app/models/user.server.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import crypto from "node:crypto";
import bcrypt from "bcryptjs";
import { eq } from "drizzle-orm";
import { v4 as uuidv4 } from "uuid";
import { createProfile } from "./profile.server";
import { drizzleClient } from "~/db.server";
import {
Expand Down Expand Up @@ -54,13 +55,14 @@ export const updateUserEmail = (
userToUpdate: User,
newEmail: User["email"],
) => {
// TODO use email confirmation before changing the email over to the new one
// return drizzleClient
// .update(user)
// .set({
// email: newEmail,
// })
// .where(eq(user.id, userToUpdate.id));
return drizzleClient
.update(user)
.set({
unconfirmedEmail: newEmail,
emailConfirmationToken: uuidv4(),
})
.where(eq(user.id, userToUpdate.id));
// TODO send out email for confirmation
};

export async function updateUserName(
Expand Down Expand Up @@ -134,6 +136,7 @@ export async function createUser(
name,
email,
language,
unconfirmedEmail: email,
})
.returning();

Expand Down
32 changes: 16 additions & 16 deletions app/routes/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,18 +71,18 @@ const routes: { noauth: RouteInfo[]; auth: RouteInfo[] } = {
path: `users/register`,
method: "POST",
},
// {
// path: `users/request-password-reset`,
// method: "POST",
// },
// {
// path: `users/password-reset`,
// method: "POST",
// },
// {
// path: `users/confirm-email`,
// method: "POST",
// },
{
path: `users/request-password-reset`,
method: "POST",
},
{
path: `users/password-reset`,
method: "POST",
},
{
path: `users/confirm-email`,
method: "POST",
},
// {
// path: `users/sign-in`,
// method: "POST",
Expand Down Expand Up @@ -157,10 +157,10 @@ const routes: { noauth: RouteInfo[]; auth: RouteInfo[] } = {
path: `users/me`,
method: "DELETE",
},
// {
// path: `users/me/resend-email-confirmation`,
// method: "POST",
// },
{
path: `users/me/resend-email-confirmation`,
method: "POST",
},
],
// management: [
// {
Expand Down
80 changes: 80 additions & 0 deletions app/routes/api.users.confirm-email.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { type ActionFunction, type ActionFunctionArgs } from "react-router";
import { confirmEmail } from "~/lib/user-service.server";

export const action: ActionFunction = async ({
request,
}: ActionFunctionArgs) => {
let formData = new FormData();
try {
formData = await request.formData();
} catch {
// Just continue, it will fail in the next check
// The try catch block handles an exception that occurs if the
// request was sent without x-www-form-urlencoded content-type header
}

if (
!formData.has("token") ||
formData.get("token")?.toString().trim().length === 0
)
return Response.json(
{ message: "No email confirmation token specified." },
{
status: 400,
headers: {
"content-type": "application/json; charset=utf-8",
},
},
);

if (
!formData.has("email") ||
formData.get("email")?.toString().trim().length === 0
)
return Response.json(
{ message: "No email address to confirm specified." },
{
status: 400,
headers: {
"content-type": "application/json; charset=utf-8",
},
},
);

try {
const updatedUser = await confirmEmail(
formData.get("token")!.toString(),
formData.get("email")!.toString(),
);

if (updatedUser === null)
return Response.json(
{
code: "Forbidden",
message: "Invalid or expired confirmation token.",
},
{
status: 403,
headers: {
"content-type": "application/json; charset=utf-8",
},
},
);

return Response.json(
{
code: "Ok",
message: "E-Mail successfully confirmed. Thank you",
},
{
status: 200,
headers: {
"content-type": "application/json; charset=utf-8",
},
},
);
} catch (err) {
console.warn(err);
return new Response("Internal Server Error", { status: 500 });
}
};
59 changes: 59 additions & 0 deletions app/routes/api.users.me.resend-email-confirmation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { type ActionFunction, type ActionFunctionArgs } from "react-router";
import { getUserFromJwt } from "~/lib/jwt";
import { resendEmailConfirmation } from "~/lib/user-service.server";

export const action: ActionFunction = async ({
request,
}: ActionFunctionArgs) => {
try {
const jwtResponse = await getUserFromJwt(request);

if (typeof jwtResponse === "string")
return Response.json(
{
code: "Forbidden",
message:
"Invalid JWT authorization. Please sign in to obtain new JWT.",
},
{
status: 403,
},
);

const result = await resendEmailConfirmation(jwtResponse);
if (result === "already_confirmed")
return Response.json(
{
code: "Unprocessable Content",
message: `Email address ${jwtResponse.email} is already confirmed.`,
},
{
status: 422,
headers: { "Content-Type": "application/json; charset=utf-8" },
},
);

return Response.json(
{
code: "Ok",
message: `Email confirmation has been sent to ${result.unconfirmedEmail}`,
},
{
status: 200,
headers: { "Content-Type": "application/json; charset=utf-8" },
},
);
} catch (err) {
console.warn(err);
return Response.json(
{
error: "Internal Server Error",
message:
"The server was unable to complete your request. Please try again later.",
},
{
status: 500,
},
);
}
};
3 changes: 1 addition & 2 deletions app/routes/api.users.me.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,8 @@ import {
type LoaderFunction,
type LoaderFunctionArgs,
} from "react-router";
import { getUserFromJwt, revokeToken } from "~/lib/jwt";
import { getUserFromJwt } from "~/lib/jwt";
import { deleteUser, updateUserDetails } from "~/lib/user-service.server";
import { verifyLogin } from "~/models/user.server";
import { type User } from "~/schema/user";

export const loader: LoaderFunction = async ({
Expand Down
Loading
Loading