Skip to content

feat/api auth #562

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 6 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
31 changes: 29 additions & 2 deletions app/lib/jwt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ const jwtVerifyOptions = {
issuer: JWT_ISSUER,
};

const ONE_DAY_IN_MS = 1000 * 60 * 60 * 24;

/**
*
* @param user
Expand Down Expand Up @@ -101,6 +103,14 @@ export const revokeToken = async (user: User, jwtString: string) => {
});
};

export const revokeRefreshToken = async (refreshToken: string) => {
await drizzleClient.insert(tokenRevocation).values({
hash: refreshToken,
token: "",
expiresAt: new Date(Date.now() + ONE_DAY_IN_MS * 7),
});
};

/**
*
* @param r
Expand All @@ -124,7 +134,7 @@ export const getUserFromJwt = async (
try {
decodedJwt = await decodeJwtString(jwtString, JWT_SECRET, {
...jwtVerifyOptions,
ignoreExpiration: r.url === "/users/refresh-auth" ? true : false, // ignore expiration for refresh endpoint
ignoreExpiration: r.url.endsWith("/users/refresh-auth"), // ignore expiration for refresh endpoint
});
} catch (err: any) {
if (typeof err === "string") return err as "verification_error";
Expand Down Expand Up @@ -214,11 +224,28 @@ const isTokenRevoked = async (token: JwtPayload, tokenString: string) => {
return false;
};

const hashJwt = (jwt: string) => {
export const hashJwt = (jwt: string) => {
invariant(typeof REFRESH_TOKEN_ALGORITHM === "string");
invariant(typeof REFRESH_TOKEN_SECRET === "string");

return createHmac(REFRESH_TOKEN_ALGORITHM, REFRESH_TOKEN_SECRET)
.update(jwt)
.digest("base64");
};

export const refreshJwt = async (
u: User,
refreshToken: string,
): Promise<{ token: string; refreshToken: string } | null> => {
// We have to check if the refresh token actually belongs to the user
const userForToken = await drizzleClient.query.refreshToken.findFirst({
where: (r, { eq }) => eq(r.token, refreshToken),
with: {
user: true,
},
});
if (userForToken == undefined || userForToken.userId !== u.id) return null;

await revokeRefreshToken(refreshToken);
return await createToken(u);
};
156 changes: 154 additions & 2 deletions app/lib/user-service.server.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import bcrypt from "bcryptjs";
import { eq } from "drizzle-orm";
import invariant from "tiny-invariant";
import { revokeToken } from "./jwt";
import { v4 as uuidv4 } from "uuid";
import { createToken, revokeToken } from "./jwt";
import {
type EmailValidation,
type PasswordValidation,
Expand All @@ -8,17 +11,21 @@ import {
validatePassword,
validateUsername,
} from "./user-service";
import { drizzleClient } from "~/db.server";
import {
createUser,
deleteUserByEmail,
getUserByEmail,
preparePasswordHash,
updateUserEmail,
updateUserlocale,
updateUserName,
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 +236,148 @@ 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];
};

export const signIn = async (
emailOrName: string,
password: string,
): Promise<{ user: User; jwt: string; refreshToken: string } | null> => {
const user = await drizzleClient.query.user.findFirst({
where: (user, { eq, or }) =>
or(eq(user.email, emailOrName.toLowerCase()), eq(user.name, emailOrName)),
with: {
password: true,
},
});
if (!user) return null;

const correctPassword = await bcrypt.compare(
preparePasswordHash(password),
user.password.hash,
);
if (!correctPassword) return null;

const { token, refreshToken } = await createToken(user);
return { user, jwt: token, refreshToken };
};
19 changes: 11 additions & 8 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 @@ -107,7 +109,7 @@ export async function getUsers() {
return drizzleClient.query.user.findMany();
}

const preparePasswordHash = function preparePasswordHash(
export const preparePasswordHash = function preparePasswordHash(
plaintextPassword: string,
) {
// first round: hash plaintextPassword with sha512
Expand All @@ -134,6 +136,7 @@ export async function createUser(
name,
email,
language,
unconfirmedEmail: email,
})
.returning();

Expand Down
91 changes: 91 additions & 0 deletions app/routes/api.refresh-auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { ActionFunction, ActionFunctionArgs } from "react-router";

Check warning on line 1 in app/routes/api.refresh-auth.ts

View workflow job for this annotation

GitHub Actions / ⬣ Lint

All imports in the declaration are only used as types. Use `import type`
import { getUserFromJwt, hashJwt, refreshJwt, revokeToken } from "~/lib/jwt";

Check warning on line 2 in app/routes/api.refresh-auth.ts

View workflow job for this annotation

GitHub Actions / ⬣ Lint

'revokeToken' is defined but never used. Allowed unused vars must match /^ignored/u
import { User, refreshToken } from "~/schema";

Check warning on line 3 in app/routes/api.refresh-auth.ts

View workflow job for this annotation

GitHub Actions / ⬣ Lint

Imports "User" are only used as type

Check warning on line 3 in app/routes/api.refresh-auth.ts

View workflow job for this annotation

GitHub Actions / ⬣ Lint

'refreshToken' is defined but never used. Allowed unused vars must match /^ignored/u

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(
{
code: "Unauthorized",
message: "You must specify a token to refresh",
},
{
status: 403,
headers: { "Content-Type": "application/json; charset=utf-8" },
},
);

try {
// We deliberately make casts and stuff like that, so everything
// but the happy path will result in an internal server error.
// This is done s.t. we are not leaking information if someone
// tries sending random token to see if users exist or similar
const user = (await getUserFromJwt(request)) as User;
const rawAuthorizationHeader = request.headers
.get("authorization")!
.toString();
const [, jwtString = ""] = rawAuthorizationHeader.split(" ");

if (formData.get("token")!.toString() !== hashJwt(jwtString))
return Response.json(
{
code: "Unauthorized",
message:
"Refresh token invalid or too old. Please sign in with your username and password.",
},
{
status: 403,
headers: { "Content-Type": "application/json; charset=utf-8" },
},
);

const { token, refreshToken } =
(await refreshJwt(user, formData.get("token")!.toString())) || {};

if (token && refreshToken)
return Response.json(
{
code: "Authorized",
message: "Successfully refreshed auth",
data: { user },
token,
refreshToken,
},
{
status: 200,
headers: { "Content-Type": "application/json; charset=utf-8" },
},
);
else
return Response.json(
{
code: "Unauthorized",
message:
"Refresh token invalid or too old. Please sign in with your username and password.",
},
{
status: 403,
headers: { "Content-Type": "application/json; charset=utf-8" },
},
);
} catch (err) {
console.warn(err);
return new Response("Internal Server Error", {
status: 500,
});
}
};
Loading
Loading