mirror of
https://github.com/wasp-lang/wasp.git
synced 2024-10-26 17:10:02 +03:00
Apply latest auth changes to the prototype (#1646)
This commit is contained in:
parent
667a31be86
commit
a35040e351
@ -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");
|
|
@ -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"
|
|
@ -3,9 +3,9 @@ import mitt, { Emitter } from 'mitt';
|
|||||||
type ApiEvents = {
|
type ApiEvents = {
|
||||||
// key: Event name
|
// key: Event name
|
||||||
// type: Event payload type
|
// type: Event payload type
|
||||||
'authToken.set': void;
|
'sessionId.set': void;
|
||||||
'authToken.clear': 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>();
|
export const apiEventsEmitter: Emitter<ApiEvents> = mitt<ApiEvents>();
|
||||||
|
@ -8,59 +8,60 @@ const api = axios.create({
|
|||||||
baseURL: config.apiUrl,
|
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 {
|
export function setSessionId(sessionId: string): void {
|
||||||
authToken = token
|
waspAppAuthSessionId = sessionId
|
||||||
storage.set(WASP_APP_AUTH_TOKEN_NAME, token)
|
storage.set(WASP_APP_AUTH_SESSION_ID_NAME, sessionId)
|
||||||
apiEventsEmitter.emit('authToken.set')
|
apiEventsEmitter.emit('sessionId.set')
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getAuthToken(): string | undefined {
|
export function getSessionId(): string | undefined {
|
||||||
return authToken
|
return waspAppAuthSessionId
|
||||||
}
|
}
|
||||||
|
|
||||||
export function clearAuthToken(): void {
|
export function clearSessionId(): void {
|
||||||
authToken = undefined
|
waspAppAuthSessionId = undefined
|
||||||
storage.remove(WASP_APP_AUTH_TOKEN_NAME)
|
storage.remove(WASP_APP_AUTH_SESSION_ID_NAME)
|
||||||
apiEventsEmitter.emit('authToken.clear')
|
apiEventsEmitter.emit('sessionId.clear')
|
||||||
}
|
}
|
||||||
|
|
||||||
export function removeLocalUserData(): void {
|
export function removeLocalUserData(): void {
|
||||||
authToken = undefined
|
waspAppAuthSessionId = undefined
|
||||||
storage.clear()
|
storage.clear()
|
||||||
apiEventsEmitter.emit('authToken.clear')
|
apiEventsEmitter.emit('sessionId.clear')
|
||||||
}
|
}
|
||||||
|
|
||||||
api.interceptors.request.use((request) => {
|
api.interceptors.request.use((request) => {
|
||||||
if (authToken) {
|
const sessionId = getSessionId()
|
||||||
request.headers['Authorization'] = `Bearer ${authToken}`
|
if (sessionId) {
|
||||||
|
request.headers['Authorization'] = `Bearer ${sessionId}`
|
||||||
}
|
}
|
||||||
return request
|
return request
|
||||||
})
|
})
|
||||||
|
|
||||||
api.interceptors.response.use(undefined, (error) => {
|
api.interceptors.response.use(undefined, (error) => {
|
||||||
if (error.response?.status === 401) {
|
if (error.response?.status === 401) {
|
||||||
clearAuthToken()
|
clearSessionId()
|
||||||
}
|
}
|
||||||
return Promise.reject(error)
|
return Promise.reject(error)
|
||||||
})
|
})
|
||||||
|
|
||||||
// This handler will run on other tabs (not the active one calling API functions),
|
// 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
|
// 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
|
// "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."
|
// for other pages on the domain using the storage to sync any changes that are made."
|
||||||
window.addEventListener('storage', (event) => {
|
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) {
|
if (!!event.newValue) {
|
||||||
authToken = event.newValue
|
waspAppAuthSessionId = event.newValue
|
||||||
apiEventsEmitter.emit('authToken.set')
|
apiEventsEmitter.emit('sessionId.set')
|
||||||
} else {
|
} else {
|
||||||
authToken = undefined
|
waspAppAuthSessionId = undefined
|
||||||
apiEventsEmitter.emit('authToken.clear')
|
apiEventsEmitter.emit('sessionId.clear')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
@ -1,8 +1,8 @@
|
|||||||
import { setAuthToken } from 'wasp/api'
|
import { setSessionId } from 'wasp/api'
|
||||||
import { invalidateAndRemoveQueries } from 'wasp/operations/resources'
|
import { invalidateAndRemoveQueries } from 'wasp/operations/resources'
|
||||||
|
|
||||||
export async function initSession(token: string): Promise<void> {
|
export async function initSession(sessionId: string): Promise<void> {
|
||||||
setAuthToken(token)
|
setSessionId(sessionId)
|
||||||
// We need to invalidate queries after login in order to get the correct user
|
// We need to invalidate queries after login in order to get the correct user
|
||||||
// data in the React components (using `useAuth`).
|
// data in the React components (using `useAuth`).
|
||||||
// Redirects after login won't work properly without this.
|
// Redirects after login won't work properly without this.
|
||||||
|
12
waspc/data/Generator/templates/sdk/wasp/auth/jwt.ts
Normal file
12
waspc/data/Generator/templates/sdk/wasp/auth/jwt.ts
Normal 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)
|
@ -6,7 +6,7 @@ export default async function login(username: string, password: string): Promise
|
|||||||
const args = { username, password }
|
const args = { username, password }
|
||||||
const response = await api.post('/auth/username/login', args)
|
const response = await api.post('/auth/username/login', args)
|
||||||
|
|
||||||
await initSession(response.data.token)
|
await initSession(response.data.sessionId)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
handleApiError(error)
|
handleApiError(error)
|
||||||
}
|
}
|
||||||
|
@ -1,9 +1,17 @@
|
|||||||
import { removeLocalUserData } from 'wasp/api'
|
import api, { removeLocalUserData } from 'wasp/api'
|
||||||
import { invalidateAndRemoveQueries } from 'wasp/operations/resources'
|
import { invalidateAndRemoveQueries } from 'wasp/operations/resources'
|
||||||
|
|
||||||
export default async function logout(): Promise<void> {
|
export default async function logout(): Promise<void> {
|
||||||
removeLocalUserData()
|
try {
|
||||||
// TODO(filip): We are currently invalidating and removing all the queries, but
|
await api.post('/auth/logout')
|
||||||
// we should remove only the non-public, user-dependent ones.
|
} finally {
|
||||||
await invalidateAndRemoveQueries()
|
// 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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
55
waspc/data/Generator/templates/sdk/wasp/auth/lucia.ts
Normal file
55
waspc/data/Generator/templates/sdk/wasp/auth/lucia.ts
Normal 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']
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
15
waspc/data/Generator/templates/sdk/wasp/auth/password.ts
Normal file
15
waspc/data/Generator/templates/sdk/wasp/auth/password.ts
Normal 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.')
|
||||||
|
}
|
||||||
|
}
|
@ -23,16 +23,18 @@ export type InitData = {
|
|||||||
|
|
||||||
export type RequestWithWasp = Request & { wasp?: { [key: string]: any } }
|
export type RequestWithWasp = Request & { wasp?: { [key: string]: any } }
|
||||||
|
|
||||||
export type PossibleAdditionalSignupFields = Expand<Partial<UserEntityCreateInput>>
|
export type PossibleUserFields = Expand<Partial<UserEntityCreateInput>>
|
||||||
|
|
||||||
export function defineAdditionalSignupFields(config: {
|
export type UserSignupFields = {
|
||||||
[key in keyof PossibleAdditionalSignupFields]: FieldGetter<
|
[key in keyof PossibleUserFields]: FieldGetter<
|
||||||
PossibleAdditionalSignupFields[key]
|
PossibleUserFields[key]
|
||||||
>
|
>
|
||||||
}) {
|
|
||||||
return config
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type FieldGetter<T> = (
|
type FieldGetter<T> = (
|
||||||
data: { [key: string]: unknown }
|
data: { [key: string]: unknown }
|
||||||
) => Promise<T | undefined> | T | undefined
|
) => Promise<T | undefined> | T | undefined
|
||||||
|
|
||||||
|
export function defineUserSignupFields(fields: UserSignupFields) {
|
||||||
|
return fields
|
||||||
|
}
|
||||||
|
107
waspc/data/Generator/templates/sdk/wasp/auth/session.ts
Normal file
107
waspc/data/Generator/templates/sdk/wasp/auth/session.ts
Normal 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);
|
||||||
|
}
|
@ -1,2 +1,2 @@
|
|||||||
// todo(filip): turn into a proper import/path
|
// 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/'
|
||||||
|
@ -2,7 +2,7 @@
|
|||||||
// We have them duplicated in this file and in data/Generator/templates/server/src/auth/user.ts
|
// 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.
|
// 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 {
|
export function getEmail(user: User): string | null {
|
||||||
return findUserIdentity(user, "email")?.providerUserId ?? 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;
|
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(
|
return user.auth.identities.find(
|
||||||
(identity) => identity.providerName === providerName
|
(identity) => identity.providerName === providerName
|
||||||
);
|
);
|
||||||
|
@ -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 AuthError from '../core/AuthError.js'
|
||||||
import HttpError from '../core/HttpError.js'
|
import HttpError from '../core/HttpError.js'
|
||||||
import prisma from '../server/dbClient.js'
|
import prisma from '../server/dbClient.js'
|
||||||
@ -12,9 +13,7 @@ import { Prisma } from '@prisma/client';
|
|||||||
|
|
||||||
import { throwValidationError } from './validation.js'
|
import { throwValidationError } from './validation.js'
|
||||||
|
|
||||||
|
import { type UserSignupFields, type PossibleUserFields } from './providers/types.js'
|
||||||
import { defineAdditionalSignupFields, type PossibleAdditionalSignupFields } from './providers/types.js'
|
|
||||||
const _waspAdditionalSignupFieldsConfig = {} as ReturnType<typeof defineAdditionalSignupFields>
|
|
||||||
|
|
||||||
export type EmailProviderData = {
|
export type EmailProviderData = {
|
||||||
hashedPassword: string;
|
hashedPassword: string;
|
||||||
@ -127,8 +126,10 @@ export async function findAuthWithUserBy(
|
|||||||
export async function createUser(
|
export async function createUser(
|
||||||
providerId: ProviderId,
|
providerId: ProviderId,
|
||||||
serializedProviderData?: string,
|
serializedProviderData?: string,
|
||||||
userFields?: PossibleAdditionalSignupFields,
|
userFields?: PossibleUserFields,
|
||||||
): Promise<User> {
|
): Promise<User & {
|
||||||
|
auth: Auth
|
||||||
|
}> {
|
||||||
return prisma.user.create({
|
return prisma.user.create({
|
||||||
data: {
|
data: {
|
||||||
// Using any here to prevent type errors when userFields are not
|
// 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> {
|
export async function verifyToken<T = unknown>(token: string): Promise<T> {
|
||||||
return verify(token);
|
return verify(token);
|
||||||
}
|
}
|
||||||
@ -224,15 +224,23 @@ export function rethrowPossibleAuthError(e: unknown): void {
|
|||||||
throw e
|
throw e
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function validateAndGetAdditionalFields(data: {
|
export async function validateAndGetUserFields(
|
||||||
[key: string]: unknown
|
data: {
|
||||||
}): Promise<Record<string, any>> {
|
[key: string]: unknown
|
||||||
|
},
|
||||||
|
userSignupFields?: UserSignupFields,
|
||||||
|
): Promise<Record<string, any>> {
|
||||||
const {
|
const {
|
||||||
password: _password,
|
password: _password,
|
||||||
...sanitizedData
|
...sanitizedData
|
||||||
} = data;
|
} = data;
|
||||||
const result: Record<string, any> = {};
|
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 {
|
try {
|
||||||
const value = await getFieldValue(sanitizedData)
|
const value = await getFieldValue(sanitizedData)
|
||||||
result[field] = value
|
result[field] = value
|
||||||
@ -288,3 +296,7 @@ function providerDataHasPasswordField(
|
|||||||
): providerData is { hashedPassword: string } {
|
): providerData is { hashedPassword: string } {
|
||||||
return 'hashedPassword' in providerData;
|
return 'hashedPassword' in providerData;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function throwInvalidCredentialsError(message?: string): void {
|
||||||
|
throw new HttpError(401, 'Invalid credentials', { message })
|
||||||
|
}
|
||||||
|
@ -1,23 +1,22 @@
|
|||||||
import jwt from 'jsonwebtoken'
|
|
||||||
import SecurePassword from 'secure-password'
|
|
||||||
import util from 'util'
|
|
||||||
import { randomInt } from 'node:crypto'
|
import { randomInt } from 'node:crypto'
|
||||||
|
|
||||||
import prisma from '@server/dbClient.js'
|
import prisma from '../server/dbClient.js'
|
||||||
import { handleRejection } from '../server/utils'
|
import { handleRejection } from '../utils.js'
|
||||||
import HttpError from './HttpError.js'
|
import { getSessionAndUserFromBearerToken } from 'wasp/auth/session'
|
||||||
import config from '../config.js'
|
import { throwInvalidCredentialsError } from 'wasp/auth/utils'
|
||||||
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)
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 auth = handleRejection(async (req, res, next) => {
|
||||||
const authHeader = req.get('Authorization')
|
const authHeader = req.get('Authorization')
|
||||||
if (!authHeader) {
|
if (!authHeader) {
|
||||||
@ -27,119 +26,16 @@ const auth = handleRejection(async (req, res, next) => {
|
|||||||
return next()
|
return next()
|
||||||
}
|
}
|
||||||
|
|
||||||
if (authHeader.startsWith('Bearer ')) {
|
const { session, user } = await getSessionAndUserFromBearerToken(req);
|
||||||
const token = authHeader.substring(7, authHeader.length)
|
|
||||||
req.user = await getUserFromToken(token)
|
if (!session || !user) {
|
||||||
} else {
|
|
||||||
throwInvalidCredentialsError()
|
throwInvalidCredentialsError()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
req.sessionId = session.id
|
||||||
|
req.user = user
|
||||||
|
|
||||||
next()
|
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
|
export default auth
|
||||||
|
@ -82,18 +82,18 @@ type Context<Entities extends _Entity[]> = Expand<{
|
|||||||
|
|
||||||
type ContextWithUser<Entities extends _Entity[]> = Expand<Context<Entities> & { user?: SanitizedUser }>
|
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,
|
// password field from the object there, we must do the same here). Ideally,
|
||||||
// these two things would live in the same place:
|
// these two things would live in the same place:
|
||||||
// https://github.com/wasp-lang/wasp/issues/965
|
// 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
|
providerData: Omit<EmailProviderData, 'password'> | Omit<UsernameProviderData, 'password'> | OAuthProviderData
|
||||||
}>
|
}>
|
||||||
|
|
||||||
export type SanitizedUser = User & {
|
export type SanitizedUser = User & {
|
||||||
auth: Auth & {
|
auth: Auth & {
|
||||||
identities: DeserializedAuthEntity[]
|
identities: DeserializedAuthIdentity[]
|
||||||
} | null
|
} | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -8,7 +8,8 @@ import { fileURLToPath } from 'url'
|
|||||||
import { type SanitizedUser } from './_types/index.js'
|
import { type SanitizedUser } from './_types/index.js'
|
||||||
|
|
||||||
type RequestWithExtraFields = Request & {
|
type RequestWithExtraFields = Request & {
|
||||||
user?: SanitizedUser
|
user?: SanitizedUser;
|
||||||
|
sessionId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -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");
|
|
@ -30,5 +30,19 @@ CREATE TABLE "AuthIdentity" (
|
|||||||
CONSTRAINT "AuthIdentity_authId_fkey" FOREIGN KEY ("authId") REFERENCES "Auth" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
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
|
-- CreateIndex
|
||||||
CREATE UNIQUE INDEX "Auth_userId_key" ON "Auth"("userId");
|
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");
|
Loading…
Reference in New Issue
Block a user