Apply latest auth changes to the prototype (#1646)

This commit is contained in:
Mihovil Ilakovac 2024-01-19 18:28:42 +01:00 committed by GitHub
parent 667a31be86
commit a35040e351
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 314 additions and 225 deletions

View File

@ -1,13 +0,0 @@
-- CreateTable
CREATE TABLE "Session" (
"id" TEXT NOT NULL PRIMARY KEY,
"expiresAt" DATETIME NOT NULL,
"userId" TEXT NOT NULL,
CONSTRAINT "Session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "Auth" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateIndex
CREATE UNIQUE INDEX "Session_id_key" ON "Session"("id");
-- CreateIndex
CREATE INDEX "Session_userId_idx" ON "Session"("userId");

View File

@ -1,3 +0,0 @@
# Please do not edit this file manually
# It should be added in your version-control system (i.e. Git)
provider = "sqlite"

View File

@ -3,9 +3,9 @@ import mitt, { Emitter } from 'mitt';
type ApiEvents = {
// key: Event name
// type: Event payload type
'authToken.set': void;
'authToken.clear': void;
'sessionId.set': void;
'sessionId.clear': void;
};
// Used to allow API clients to register for auth token change events.
// Used to allow API clients to register for auth session ID change events.
export const apiEventsEmitter: Emitter<ApiEvents> = mitt<ApiEvents>();

View File

@ -8,59 +8,60 @@ const api = axios.create({
baseURL: config.apiUrl,
})
const WASP_APP_AUTH_TOKEN_NAME = 'authToken'
const WASP_APP_AUTH_SESSION_ID_NAME = 'sessionId'
let authToken = storage.get(WASP_APP_AUTH_TOKEN_NAME) as string | undefined
let waspAppAuthSessionId = storage.get(WASP_APP_AUTH_SESSION_ID_NAME) as string | undefined
export function setAuthToken(token: string): void {
authToken = token
storage.set(WASP_APP_AUTH_TOKEN_NAME, token)
apiEventsEmitter.emit('authToken.set')
export function setSessionId(sessionId: string): void {
waspAppAuthSessionId = sessionId
storage.set(WASP_APP_AUTH_SESSION_ID_NAME, sessionId)
apiEventsEmitter.emit('sessionId.set')
}
export function getAuthToken(): string | undefined {
return authToken
export function getSessionId(): string | undefined {
return waspAppAuthSessionId
}
export function clearAuthToken(): void {
authToken = undefined
storage.remove(WASP_APP_AUTH_TOKEN_NAME)
apiEventsEmitter.emit('authToken.clear')
export function clearSessionId(): void {
waspAppAuthSessionId = undefined
storage.remove(WASP_APP_AUTH_SESSION_ID_NAME)
apiEventsEmitter.emit('sessionId.clear')
}
export function removeLocalUserData(): void {
authToken = undefined
waspAppAuthSessionId = undefined
storage.clear()
apiEventsEmitter.emit('authToken.clear')
apiEventsEmitter.emit('sessionId.clear')
}
api.interceptors.request.use((request) => {
if (authToken) {
request.headers['Authorization'] = `Bearer ${authToken}`
const sessionId = getSessionId()
if (sessionId) {
request.headers['Authorization'] = `Bearer ${sessionId}`
}
return request
})
api.interceptors.response.use(undefined, (error) => {
if (error.response?.status === 401) {
clearAuthToken()
clearSessionId()
}
return Promise.reject(error)
})
// This handler will run on other tabs (not the active one calling API functions),
// and will ensure they know about auth token changes.
// and will ensure they know about auth session ID changes.
// Ref: https://developer.mozilla.org/en-US/docs/Web/API/Window/storage_event
// "Note: This won't work on the same page that is making the changes — it is really a way
// for other pages on the domain using the storage to sync any changes that are made."
window.addEventListener('storage', (event) => {
if (event.key === storage.getPrefixedKey(WASP_APP_AUTH_TOKEN_NAME)) {
if (event.key === storage.getPrefixedKey(WASP_APP_AUTH_SESSION_ID_NAME)) {
if (!!event.newValue) {
authToken = event.newValue
apiEventsEmitter.emit('authToken.set')
waspAppAuthSessionId = event.newValue
apiEventsEmitter.emit('sessionId.set')
} else {
authToken = undefined
apiEventsEmitter.emit('authToken.clear')
waspAppAuthSessionId = undefined
apiEventsEmitter.emit('sessionId.clear')
}
}
})

View File

@ -1,8 +1,8 @@
import { setAuthToken } from 'wasp/api'
import { setSessionId } from 'wasp/api'
import { invalidateAndRemoveQueries } from 'wasp/operations/resources'
export async function initSession(token: string): Promise<void> {
setAuthToken(token)
export async function initSession(sessionId: string): Promise<void> {
setSessionId(sessionId)
// We need to invalidate queries after login in order to get the correct user
// data in the React components (using `useAuth`).
// Redirects after login won't work properly without this.

View File

@ -0,0 +1,12 @@
import jwt from 'jsonwebtoken'
import util from 'util'
import config from 'wasp/core/config'
const jwtSign = util.promisify(jwt.sign)
const jwtVerify = util.promisify(jwt.verify)
const JWT_SECRET = config.auth.jwtSecret
export const signData = (data, options) => jwtSign(data, JWT_SECRET, options)
export const verify = (token) => jwtVerify(token, JWT_SECRET)

View File

@ -6,7 +6,7 @@ export default async function login(username: string, password: string): Promise
const args = { username, password }
const response = await api.post('/auth/username/login', args)
await initSession(response.data.token)
await initSession(response.data.sessionId)
} catch (error) {
handleApiError(error)
}

View File

@ -1,9 +1,17 @@
import { removeLocalUserData } from 'wasp/api'
import api, { removeLocalUserData } from 'wasp/api'
import { invalidateAndRemoveQueries } from 'wasp/operations/resources'
export default async function logout(): Promise<void> {
removeLocalUserData()
// TODO(filip): We are currently invalidating and removing all the queries, but
// we should remove only the non-public, user-dependent ones.
await invalidateAndRemoveQueries()
try {
await api.post('/auth/logout')
} finally {
// Even if the logout request fails, we still want to remove the local user data
// in case the logout failed because of a network error and the user walked away
// from the computer.
removeLocalUserData()
// TODO(filip): We are currently invalidating and removing all the queries, but
// we should remove only the non-public, user-dependent ones.
await invalidateAndRemoveQueries()
}
}

View File

@ -0,0 +1,55 @@
import { Lucia } from "lucia";
import { PrismaAdapter } from "@lucia-auth/adapter-prisma";
import prisma from '../server/dbClient.js'
import config from 'wasp/core/config'
import { type User } from "../entities/index.js"
const prismaAdapter = new PrismaAdapter(
// Using `as any` here since Lucia's model types are not compatible with Prisma 4
// model types. This is a temporary workaround until we migrate to Prisma 5.
// This **works** in runtime, but Typescript complains about it.
prisma.session as any,
prisma.auth as any
);
/**
* We are using Lucia for session management.
*
* Some details:
* 1. We are using the Prisma adapter for Lucia.
* 2. We are not using cookies for session management. Instead, we are using
* the Authorization header to send the session token.
* 3. Our `Session` entity is connected to the `Auth` entity.
* 4. We are exposing the `userId` field from the `Auth` entity to
* make fetching the User easier.
*/
export const auth = new Lucia<{}, {
userId: User['id']
}>(prismaAdapter, {
// Since we are not using cookies, we don't need to set any cookie options.
// But in the future, if we decide to use cookies, we can set them here.
// sessionCookie: {
// name: "session",
// expires: true,
// attributes: {
// secure: !config.isDevelopment,
// sameSite: "lax",
// },
// },
getUserAttributes({ userId }) {
return {
userId,
};
},
});
declare module "lucia" {
interface Register {
Lucia: typeof auth;
DatabaseSessionAttributes: {};
DatabaseUserAttributes: {
userId: User['id']
};
}
}

View File

@ -0,0 +1,15 @@
import SecurePassword from 'secure-password'
const SP = new SecurePassword()
export const hashPassword = async (password: string): Promise<string> => {
const hashedPwdBuffer = await SP.hash(Buffer.from(password))
return hashedPwdBuffer.toString("base64")
}
export const verifyPassword = async (hashedPassword: string, password: string): Promise<void> => {
const result = await SP.verify(Buffer.from(password), Buffer.from(hashedPassword, "base64"))
if (result !== SecurePassword.VALID) {
throw new Error('Invalid password.')
}
}

View File

@ -23,16 +23,18 @@ export type InitData = {
export type RequestWithWasp = Request & { wasp?: { [key: string]: any } }
export type PossibleAdditionalSignupFields = Expand<Partial<UserEntityCreateInput>>
export type PossibleUserFields = Expand<Partial<UserEntityCreateInput>>
export function defineAdditionalSignupFields(config: {
[key in keyof PossibleAdditionalSignupFields]: FieldGetter<
PossibleAdditionalSignupFields[key]
export type UserSignupFields = {
[key in keyof PossibleUserFields]: FieldGetter<
PossibleUserFields[key]
>
}) {
return config
}
type FieldGetter<T> = (
data: { [key: string]: unknown }
) => Promise<T | undefined> | T | undefined
export function defineUserSignupFields(fields: UserSignupFields) {
return fields
}

View File

@ -0,0 +1,107 @@
import { Request as ExpressRequest } from "express";
import { type User } from "../entities/index.js"
import { type SanitizedUser } from '../server/_types/index.js'
import { auth } from "./lucia.js";
import type { Session } from "lucia";
import {
throwInvalidCredentialsError,
deserializeAndSanitizeProviderData,
} from "./utils.js";
import prisma from '../server/dbClient.js'
// Creates a new session for the `authId` in the database
export async function createSession(authId: string): Promise<Session> {
return auth.createSession(authId, {});
}
export async function getSessionAndUserFromBearerToken(req: ExpressRequest): Promise<{
user: SanitizedUser | null,
session: Session | null,
}> {
const authorizationHeader = req.headers["authorization"];
if (typeof authorizationHeader !== "string") {
return {
user: null,
session: null,
};
}
const sessionId = auth.readBearerToken(authorizationHeader);
if (!sessionId) {
return {
user: null,
session: null,
};
}
return getSessionAndUserFromSessionId(sessionId);
}
export async function getSessionAndUserFromSessionId(sessionId: string): Promise<{
user: SanitizedUser | null,
session: Session | null,
}> {
const { session, user: authEntity } = await auth.validateSession(sessionId);
if (!session || !authEntity) {
return {
user: null,
session: null,
};
}
return {
session,
user: await getUser(authEntity.userId)
}
}
async function getUser(userId: User['id']): Promise<SanitizedUser> {
const user = await prisma.user
.findUnique({
where: { id: userId },
include: {
auth: {
include: {
identities: true
}
}
}
})
if (!user) {
throwInvalidCredentialsError()
}
// TODO: This logic must match the type in _types/index.ts (if we remove the
// password field from the object here, we must to do the same there).
// Ideally, these two things would live in the same place:
// https://github.com/wasp-lang/wasp/issues/965
const deserializedIdentities = user.auth.identities.map((identity) => {
const deserializedProviderData = deserializeAndSanitizeProviderData(
identity.providerData,
{
shouldRemovePasswordField: true,
}
)
return {
...identity,
providerData: deserializedProviderData,
}
})
return {
...user,
auth: {
...user.auth,
identities: deserializedIdentities,
},
}
}
export function invalidateSession(sessionId: string): Promise<void> {
return auth.invalidateSession(sessionId);
}

View File

@ -1,2 +1,2 @@
// todo(filip): turn into a proper import/path
export type { SanitizedUser as User, ProviderName, DeserializedAuthEntity } from 'wasp/server/_types/'
export type { SanitizedUser as User, ProviderName, DeserializedAuthIdentity } from 'wasp/server/_types/'

View File

@ -2,7 +2,7 @@
// We have them duplicated in this file and in data/Generator/templates/server/src/auth/user.ts
// If you are changing the logic here, make sure to change it there as well.
import type { User, ProviderName, DeserializedAuthEntity } from './types'
import type { User, ProviderName, DeserializedAuthIdentity } from './types'
export function getEmail(user: User): string | null {
return findUserIdentity(user, "email")?.providerUserId ?? null;
@ -20,7 +20,7 @@ export function getFirstProviderUserId(user?: User): string | null {
return user.auth.identities[0].providerUserId ?? null;
}
export function findUserIdentity(user: User, providerName: ProviderName): DeserializedAuthEntity | undefined {
export function findUserIdentity(user: User, providerName: ProviderName): DeserializedAuthIdentity | undefined {
return user.auth.identities.find(
(identity) => identity.providerName === providerName
);

View File

@ -1,4 +1,5 @@
import { hashPassword, sign, verify } from 'wasp/core/auth'
import { hashPassword } from './password.js'
import { verify } from './jwt.js'
import AuthError from '../core/AuthError.js'
import HttpError from '../core/HttpError.js'
import prisma from '../server/dbClient.js'
@ -12,9 +13,7 @@ import { Prisma } from '@prisma/client';
import { throwValidationError } from './validation.js'
import { defineAdditionalSignupFields, type PossibleAdditionalSignupFields } from './providers/types.js'
const _waspAdditionalSignupFieldsConfig = {} as ReturnType<typeof defineAdditionalSignupFields>
import { type UserSignupFields, type PossibleUserFields } from './providers/types.js'
export type EmailProviderData = {
hashedPassword: string;
@ -127,8 +126,10 @@ export async function findAuthWithUserBy(
export async function createUser(
providerId: ProviderId,
serializedProviderData?: string,
userFields?: PossibleAdditionalSignupFields,
): Promise<User> {
userFields?: PossibleUserFields,
): Promise<User & {
auth: Auth
}> {
return prisma.user.create({
data: {
// Using any here to prevent type errors when userFields are not
@ -145,7 +146,12 @@ export async function createUser(
},
}
},
}
},
// We need to include the Auth entity here because we need `authId`
// to be able to create a session.
include: {
auth: true,
},
})
}
@ -155,12 +161,6 @@ export async function deleteUserByAuthId(authId: string): Promise<{ count: numbe
} } })
}
export async function createAuthToken(
userId: User['id']
): Promise<string> {
return sign(userId);
}
export async function verifyToken<T = unknown>(token: string): Promise<T> {
return verify(token);
}
@ -224,15 +224,23 @@ export function rethrowPossibleAuthError(e: unknown): void {
throw e
}
export async function validateAndGetAdditionalFields(data: {
[key: string]: unknown
}): Promise<Record<string, any>> {
export async function validateAndGetUserFields(
data: {
[key: string]: unknown
},
userSignupFields?: UserSignupFields,
): Promise<Record<string, any>> {
const {
password: _password,
...sanitizedData
} = data;
const result: Record<string, any> = {};
for (const [field, getFieldValue] of Object.entries(_waspAdditionalSignupFieldsConfig)) {
if (!userSignupFields) {
return result;
}
for (const [field, getFieldValue] of Object.entries(userSignupFields)) {
try {
const value = await getFieldValue(sanitizedData)
result[field] = value
@ -288,3 +296,7 @@ function providerDataHasPasswordField(
): providerData is { hashedPassword: string } {
return 'hashedPassword' in providerData;
}
export function throwInvalidCredentialsError(message?: string): void {
throw new HttpError(401, 'Invalid credentials', { message })
}

View File

@ -1,23 +1,22 @@
import jwt from 'jsonwebtoken'
import SecurePassword from 'secure-password'
import util from 'util'
import { randomInt } from 'node:crypto'
import prisma from '@server/dbClient.js'
import { handleRejection } from '../server/utils'
import HttpError from './HttpError.js'
import config from '../config.js'
import { deserializeAndSanitizeProviderData } from 'wasp/auth/utils'
const jwtSign = util.promisify(jwt.sign)
const jwtVerify = util.promisify(jwt.verify)
const JWT_SECRET = config.auth.jwtSecret
export const signData = (data, options) => jwtSign(data, JWT_SECRET, options)
export const sign = (id, options) => signData({ id }, options)
export const verify = (token) => jwtVerify(token, JWT_SECRET)
import prisma from '../server/dbClient.js'
import { handleRejection } from '../utils.js'
import { getSessionAndUserFromBearerToken } from 'wasp/auth/session'
import { throwInvalidCredentialsError } from 'wasp/auth/utils'
/**
* Auth middleware
*
* If the request includes an `Authorization` header it will try to authenticate the request,
* otherwise it will let the request through.
*
* - If authentication succeeds it sets `req.sessionId` and `req.user`
* - `req.user` is the user that made the request and it's used in
* all Wasp features that need to know the user that made the request.
* - `req.sessionId` is the ID of the session that authenticated the request.
* - If the request is not authenticated, it throws an error.
*/
const auth = handleRejection(async (req, res, next) => {
const authHeader = req.get('Authorization')
if (!authHeader) {
@ -27,119 +26,16 @@ const auth = handleRejection(async (req, res, next) => {
return next()
}
if (authHeader.startsWith('Bearer ')) {
const token = authHeader.substring(7, authHeader.length)
req.user = await getUserFromToken(token)
} else {
const { session, user } = await getSessionAndUserFromBearerToken(req);
if (!session || !user) {
throwInvalidCredentialsError()
}
req.sessionId = session.id
req.user = user
next()
})
export async function getUserFromToken(token) {
let userIdFromToken
try {
userIdFromToken = (await verify(token)).id
} catch (error) {
if (['TokenExpiredError', 'JsonWebTokenError', 'NotBeforeError'].includes(error.name)) {
throwInvalidCredentialsError()
} else {
throw error
}
}
const user = await prisma.user
.findUnique({
where: { id: userIdFromToken },
include: {
auth: {
include: {
identities: true
}
}
}
})
if (!user) {
throwInvalidCredentialsError()
}
// TODO: This logic must match the type in types/index.ts (if we remove the
// password field from the object here, we must to do the same there).
// Ideally, these two things would live in the same place:
// https://github.com/wasp-lang/wasp/issues/965
let sanitizedUser = { ...user }
sanitizedUser.auth.identities = sanitizedUser.auth.identities.map(identity => {
identity.providerData = deserializeAndSanitizeProviderData(identity.providerData, { shouldRemovePasswordField: true })
return identity
});
return sanitizedUser
}
const SP = new SecurePassword()
export const hashPassword = async (password) => {
const hashedPwdBuffer = await SP.hash(Buffer.from(password))
return hashedPwdBuffer.toString("base64")
}
export const verifyPassword = async (hashedPassword, password) => {
const result = await SP.verify(Buffer.from(password), Buffer.from(hashedPassword, "base64"))
if (result !== SecurePassword.VALID) {
throw new Error('Invalid password.')
}
}
// Generates an unused username that looks similar to "quick-purple-sheep-91231".
// It generates several options and ensures it picks one that is not currently in use.
export function generateAvailableDictionaryUsername() {
const adjectives = ['fuzzy', 'tall', 'short', 'nice', 'happy', 'quick', 'slow', 'good', 'new', 'old', 'first', 'last', 'old', 'young']
const colors = ['red', 'green', 'blue', 'white', 'black', 'brown', 'purple', 'orange', 'yellow']
const nouns = ['wasp', 'cat', 'dog', 'lion', 'rabbit', 'duck', 'pig', 'bee', 'goat', 'crab', 'fish', 'chicken', 'horse', 'llama', 'camel', 'sheep']
const potentialUsernames = []
for (let i = 0; i < 10; i++) {
const potentialUsername = `${adjectives[randomInt(adjectives.length)]}-${colors[randomInt(colors.length)]}-${nouns[randomInt(nouns.length)]}-${randomInt(100_000)}`
potentialUsernames.push(potentialUsername)
}
return findAvailableUsername(potentialUsernames)
}
// Generates an unused username based on an array of username segments and a separator.
// It generates several options and ensures it picks one that is not currently in use.
export function generateAvailableUsername(usernameSegments, config) {
const separator = config?.separator || '-'
const baseUsername = usernameSegments.join(separator)
const potentialUsernames = []
for (let i = 0; i < 10; i++) {
const potentialUsername = `${baseUsername}${separator}${randomInt(100_000)}`
potentialUsernames.push(potentialUsername)
}
return findAvailableUsername(potentialUsernames)
}
// Checks the database for an unused username from an array provided and returns first.
async function findAvailableUsername(potentialUsernames) {
const users = await prisma.user.findMany({
where: {
username: { in: potentialUsernames },
}
})
const takenUsernames = users.map(user => user.username)
const availableUsernames = potentialUsernames.filter(username => !takenUsernames.includes(username))
if (availableUsernames.length === 0) {
throw new Error('Unable to generate a unique username. Please contact Wasp.')
}
return availableUsernames[0]
}
export function throwInvalidCredentialsError(message) {
throw new HttpError(401, 'Invalid credentials', { message })
}
export default auth

View File

@ -82,18 +82,18 @@ type Context<Entities extends _Entity[]> = Expand<{
type ContextWithUser<Entities extends _Entity[]> = Expand<Context<Entities> & { user?: SanitizedUser }>
// TODO: This type must match the logic in core/auth.js (if we remove the
// TODO: This type must match the logic in auth/session.js (if we remove the
// password field from the object there, we must do the same here). Ideally,
// these two things would live in the same place:
// https://github.com/wasp-lang/wasp/issues/965
export type DeserializedAuthEntity = Expand<Omit<AuthIdentity, 'providerData'> & {
export type DeserializedAuthIdentity = Expand<Omit<AuthIdentity, 'providerData'> & {
providerData: Omit<EmailProviderData, 'password'> | Omit<UsernameProviderData, 'password'> | OAuthProviderData
}>
export type SanitizedUser = User & {
auth: Auth & {
identities: DeserializedAuthEntity[]
identities: DeserializedAuthIdentity[]
} | null
}

View File

@ -8,7 +8,8 @@ import { fileURLToPath } from 'url'
import { type SanitizedUser } from './_types/index.js'
type RequestWithExtraFields = Request & {
user?: SanitizedUser
user?: SanitizedUser;
sessionId?: string;
}
/**

View File

@ -1,18 +0,0 @@
-- CreateTable
CREATE TABLE "User" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"username" TEXT NOT NULL,
"password" TEXT NOT NULL
);
-- CreateTable
CREATE TABLE "Task" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"description" TEXT NOT NULL,
"isDone" BOOLEAN NOT NULL DEFAULT false,
"userId" INTEGER,
CONSTRAINT "Task_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
-- CreateIndex
CREATE UNIQUE INDEX "User_username_key" ON "User"("username");

View File

@ -30,5 +30,19 @@ CREATE TABLE "AuthIdentity" (
CONSTRAINT "AuthIdentity_authId_fkey" FOREIGN KEY ("authId") REFERENCES "Auth" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "Session" (
"id" TEXT NOT NULL PRIMARY KEY,
"expiresAt" DATETIME NOT NULL,
"userId" TEXT NOT NULL,
CONSTRAINT "Session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "Auth" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateIndex
CREATE UNIQUE INDEX "Auth_userId_key" ON "Auth"("userId");
-- CreateIndex
CREATE UNIQUE INDEX "Session_id_key" ON "Session"("id");
-- CreateIndex
CREATE INDEX "Session_userId_idx" ON "Session"("userId");