Feat(authentication): username password provider (#6052)

This commit is contained in:
Philip Korsholm
2024-01-23 16:04:22 +07:00
committed by GitHub
parent 9d7ed9dbaf
commit 24bb26b81a
20 changed files with 424 additions and 44 deletions
@@ -6,13 +6,16 @@ export async function createAuthUsers(
userData: any[] = [
{
id: "test-id",
entity_id: "test-id",
provider: "manual",
},
{
id: "test-id-1",
entity_id: "test-id-1",
provider: "manual",
},
{
entity_id: "test-id-2",
provider: "store",
},
]
@@ -229,6 +229,7 @@ describe("AuthUser Service", () => {
{
id: "test",
provider_id: "manual",
entity_id: "test"
},
])
@@ -237,6 +237,7 @@ describe("AuthenticationModuleService - AuthUser", () => {
{
id: "test",
provider_id: "manual",
entity_id: "test"
},
])
@@ -5,6 +5,7 @@ import { initialize } from "../../../../src"
import { DB_URL } from "@medusajs/pricing/integration-tests/utils"
import { MedusaModule } from "@medusajs/modules-sdk"
import { IAuthenticationModuleService } from "@medusajs/types"
import { createAuthProviders } from "../../../__fixtures__/auth-provider"
jest.setTimeout(30000)
@@ -22,6 +23,10 @@ describe("AuthenticationModuleService - AuthProvider", () => {
schema: process.env.MEDUSA_PRICING_DB_SCHEMA,
},
})
if(service.__hooks?.onApplicationStart) {
await service.__hooks.onApplicationStart()
}
})
afterEach(async () => {
@@ -30,7 +35,7 @@ describe("AuthenticationModuleService - AuthProvider", () => {
})
describe("listAuthProviders", () => {
it("should list default AuthProviders", async () => {
it("should list default AuthProviders registered by loaders", async () => {
const authProviders = await service.listAuthProviders()
const serialized = JSON.parse(JSON.stringify(authProviders))
@@ -42,4 +47,22 @@ describe("AuthenticationModuleService - AuthProvider", () => {
])
})
})
describe("authenticate", () => {
it("authenticate validates that a provider is registered in container", async () => {
await createAuthProviders(testManager, [
{
provider: "notRegistered",
name: "test",
},
])
const { success, error } = await service.authenticate("notRegistered", {})
expect(success).toBe(false)
expect(error).toEqual(
"AuthenticationProvider with for provider: notRegistered wasn't registered in the module. Have you configured your options correctly?"
)
})
})
})
@@ -0,0 +1,140 @@
import { SqlEntityManager } from "@mikro-orm/postgresql"
import Scrypt from "scrypt-kdf"
import { MikroOrmWrapper } from "../../../utils"
import { initialize } from "../../../../src"
import { DB_URL } from "@medusajs/pricing/integration-tests/utils"
import { MedusaModule } from "@medusajs/modules-sdk"
import { IAuthenticationModuleService } from "@medusajs/types"
import { createAuthUsers } from "../../../__fixtures__/auth-user"
import { createAuthProviders } from "../../../__fixtures__/auth-provider"
jest.setTimeout(30000)
const seedDefaultData = async (testManager) => {
await createAuthProviders(testManager)
await createAuthUsers(testManager)
}
describe("AuthenticationModuleService - AuthProvider", () => {
let service: IAuthenticationModuleService
let testManager: SqlEntityManager
beforeEach(async () => {
await MikroOrmWrapper.setupDatabase()
testManager = MikroOrmWrapper.forkManager()
service = await initialize({
database: {
clientUrl: DB_URL,
schema: process.env.MEDUSA_PRICING_DB_SCHEMA,
},
})
if(service.__hooks?.onApplicationStart) {
await service.__hooks.onApplicationStart()
}
})
afterEach(async () => {
await MikroOrmWrapper.clearDatabase()
MedusaModule.clearInstances()
})
describe("authenticate", () => {
it("authenticate validates that a provider is registered in container", async () => {
const password = "supersecret"
const email = "test@test.com"
const passwordHash = (
await Scrypt.kdf(password, { logN: 15, r: 8, p: 1 })
).toString("base64")
await seedDefaultData(testManager)
await createAuthUsers(testManager, [
// Add authenticated user
{
provider: "usernamePassword",
entity_id: email,
provider_metadata: {
password: passwordHash,
},
},
])
const res = await service.authenticate("usernamePassword", {
body: {
email: "test@test.com",
password: password,
},
})
expect(res).toEqual({
success: true,
authUser: expect.objectContaining({
entity_id: email,
provider_metadata: {
},
}),
})
})
it("fails when no password is given", async () => {
const email = "test@test.com"
await seedDefaultData(testManager)
const res = await service.authenticate("usernamePassword", {
body: { email: "test@test.com" },
})
expect(res).toEqual({
success: false,
error: "Password should be a string",
})
})
it("fails when no email is given", async () => {
await seedDefaultData(testManager)
const res = await service.authenticate("usernamePassword", {
body: { password: "supersecret" },
})
expect(res).toEqual({
success: false,
error: "Email should be a string",
})
})
it("fails with an invalid password", async () => {
const password = "supersecret"
const email = "test@test.com"
const passwordHash = (
await Scrypt.kdf(password, { logN: 15, r: 8, p: 1 })
).toString("base64")
await seedDefaultData(testManager)
await createAuthUsers(testManager, [
// Add authenticated user
{
provider: "usernamePassword",
entity_id: email,
provider_metadata: {
password_hash: passwordHash,
},
},
])
const res = await service.authenticate("usernamePassword", {
body: {
email: "test@test.com",
password: "password",
},
})
expect(res).toEqual({
success: false,
error: "Invalid email or password",
})
})
})
})
+2 -1
View File
@@ -56,6 +56,7 @@
"@mikro-orm/postgresql": "5.9.7",
"awilix": "^8.0.0",
"dotenv": "^16.1.4",
"knex": "2.4.2"
"knex": "2.4.2",
"scrypt-kdf": "^2.0.1"
}
}
@@ -1,13 +1,14 @@
import {
ExternalModuleDeclaration,
InternalModuleDeclaration,
MedusaModule,
MODULE_PACKAGE_NAMES,
MedusaModule,
Modules,
} from "@medusajs/modules-sdk"
import { IAuthenticationModuleService, ModulesSdkTypes } from "@medusajs/types"
import { moduleDefinition } from "../module-definition"
import { InitializeModuleInjectableDependencies } from "../types"
import { moduleDefinition } from "../module-definition"
export const initialize = async (
options?:
@@ -1,12 +1,11 @@
import { LoaderOptions, ModulesSdkTypes } from "@medusajs/types"
import { asClass } from "awilix"
import * as defaultProviders from "@providers"
import { AuthProviderService } from "@services"
import { ServiceTypes } from "@types"
import { LoaderOptions, ModulesSdkTypes } from "@medusajs/types"
import { AwilixContainer, ClassOrFunctionReturning, Resolver, asClass, asFunction, asValue } from "awilix"
export default async ({
container,
options,
}: LoaderOptions<
| ModulesSdkTypes.ModuleServiceInitializeOptions
| ModulesSdkTypes.ModuleServiceInitializeCustomDataLayerOptions
@@ -17,33 +16,22 @@ export default async ({
const providersToLoad = Object.values(defaultProviders)
const authProviderService: AuthProviderService =
container.cradle["authProviderService"]
const providers = await authProviderService.list({
provider: providersToLoad.map((p) => p.PROVIDER),
})
const loadedProviders = new Map(providers.map((p) => [p.provider, p]))
const providersToCreate: ServiceTypes.CreateAuthProviderDTO[] = []
for (const provider of providersToLoad) {
container.registerAdd("providers", asClass(provider).singleton())
container.register({
[`provider_${provider.PROVIDER}`]: asClass(provider).singleton(),
})
if (loadedProviders.has(provider.PROVIDER)) {
continue
}
providersToCreate.push({
provider: provider.PROVIDER,
name: provider.DISPLAY_NAME,
[`auth_provider_${provider.PROVIDER}`]: asClass(provider).singleton(),
})
}
await authProviderService.create(providersToCreate)
container.register({
[`auth_providers`]: asArray(providersToLoad),
})
}
function asArray(
resolvers: (ClassOrFunctionReturning<unknown> | Resolver<unknown>)[]
): { resolve: (container: AwilixContainer) => unknown[] } {
return {
resolve: (container: AwilixContainer) =>
resolvers.map((resolver) => container.build(resolver)),
}
}
@@ -77,6 +77,15 @@
"nullable": false,
"mappedType": "text"
},
"entity_id": {
"name": "entity_id",
"type": "text",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"mappedType": "text"
},
"provider_id": {
"name": "provider_id",
"type": "text",
@@ -117,6 +126,16 @@
"name": "auth_user",
"schema": "public",
"indexes": [
{
"keyName": "IDX_auth_user_provider_entity_id",
"columnNames": [
"provider_id",
"entity_id"
],
"composite": true,
"primary": false,
"unique": true
},
{
"keyName": "auth_user_pkey",
"columnNames": [
@@ -0,0 +1,15 @@
import { Migration } from '@mikro-orm/migrations';
export class Migration20240115092929 extends Migration {
async up(): Promise<void> {
this.addSql('alter table "auth_user" add column "entity_id" text not null;');
this.addSql('alter table "auth_user" add constraint "IDX_auth_user_provider_entity_id" unique ("provider_id", "entity_id");');
}
async down(): Promise<void> {
this.addSql('alter table "auth_user" drop constraint "IDX_auth_user_provider_entity_id";');
this.addSql('alter table "auth_user" drop column "entity_id";');
}
}
@@ -1,25 +1,32 @@
import { generateEntityId } from "@medusajs/utils"
import {
BeforeCreate,
Cascade,
Entity,
Index,
ManyToOne,
OnInit,
OptionalProps,
PrimaryKey,
Property,
Unique,
} from "@mikro-orm/core"
import AuthProvider from "./auth-provider"
import { generateEntityId } from "@medusajs/utils"
type OptionalFields = "provider_metadata" | "app_metadata" | "user_metadata"
@Entity()
@Unique({ properties: ["provider","entity_id" ], name: "IDX_auth_user_provider_entity_id" })
export default class AuthUser {
[OptionalProps]: OptionalFields
@PrimaryKey({ columnType: "text" })
id!: string
@Property({ columnType: "text" })
entity_id: string
@ManyToOne(() => AuthProvider, {
joinColumn: "provider",
fieldName: "provider_id",
@@ -1,5 +1,11 @@
import {
AbstractAuthenticationModuleProvider,
AuthenticationResponse,
} from "@medusajs/types"
import { AuthUserService } from "@services"
import { AbstractAuthenticationModuleProvider } from "@medusajs/types"
import Scrypt from "scrypt-kdf"
import { isString } from "@medusajs/utils"
class UsernamePasswordProvider extends AbstractAuthenticationModuleProvider {
public static PROVIDER = "usernamePassword"
@@ -13,8 +19,48 @@ class UsernamePasswordProvider extends AbstractAuthenticationModuleProvider {
this.authUserSerivce_ = AuthUserService
}
async authenticate(userData: Record<string, unknown>) {
return {}
async authenticate(
userData: Record<string, any>
): Promise<AuthenticationResponse> {
const { email, password } = userData.body
if (!password || !isString(password)) {
return {
success: false,
error: "Password should be a string",
}
}
if (!email || !isString(email)) {
return {
success: false,
error: "Email should be a string",
}
}
const authUser = await this.authUserSerivce_.retrieveByProviderAndEntityId(
email,
UsernamePasswordProvider.PROVIDER
)
const password_hash = authUser.provider_metadata?.password
if (isString(password_hash)) {
const buf = Buffer.from(password_hash, "base64")
const success = await Scrypt.verify(buf, password)
if (success) {
delete authUser.provider_metadata!.password
return { success, authUser: JSON.parse(JSON.stringify(authUser)) }
}
}
return {
success: false,
error: "Invalid email or password",
}
}
}
@@ -1,8 +1,12 @@
import { DAL } from "@medusajs/types"
import { ModulesSdkUtils } from "@medusajs/utils"
import { AuthenticationTypes, Context, DAL, FindConfig } from "@medusajs/types"
import {
InjectManager,
MedusaContext,
MedusaError,
ModulesSdkUtils,
} from "@medusajs/utils"
import { AuthUser } from "@models"
import { ServiceTypes } from "@types"
import { ServiceTypes, RepositoryTypes } from "@types"
type InjectedDependencies = {
authUserRepository: DAL.RepositoryService
@@ -16,8 +20,38 @@ export default class AuthUserService<
create: ServiceTypes.CreateAuthUserDTO
}
>(AuthUser)<TEntity> {
protected readonly authUserRepository_: RepositoryTypes.IAuthUserRepository<TEntity>
constructor(container: InjectedDependencies) {
// @ts-ignore
super(...arguments)
this.authUserRepository_ = container.authUserRepository
}
@InjectManager("authUserRepository_")
async retrieveByProviderAndEntityId<
TEntityMethod = AuthenticationTypes.AuthUserDTO
>(
entityId: string,
provider: string,
config: FindConfig<TEntityMethod> = {},
@MedusaContext() sharedContext: Context = {}
): Promise<TEntity> {
const queryConfig = ModulesSdkUtils.buildQuery<TEntity>(
{ entity_id: entityId, provider },
{ ...config, take: 1 }
)
const [result] = await this.authUserRepository_.find(
queryConfig,
sharedContext
)
if (!result) {
throw new MedusaError(
MedusaError.Types.NOT_FOUND,
`AuthUser with entity_id: "${entityId}" and provider: "${provider}" not found`
)
}
return result
}
}
@@ -1,9 +1,12 @@
import {
AbstractAuthenticationModuleProvider,
AuthenticationResponse,
AuthenticationTypes,
Context,
DAL,
FindConfig,
InternalModuleDeclaration,
MedusaContainer,
ModuleJoinerConfig,
} from "@medusajs/types"
@@ -11,10 +14,12 @@ import { AuthProvider, AuthUser } from "@models"
import { joinerConfig } from "../joiner-config"
import { AuthProviderService, AuthUserService } from "@services"
import {
InjectManager,
InjectTransactionManager,
MedusaContext,
MedusaError,
} from "@medusajs/utils"
import {
AuthProviderDTO,
@@ -25,6 +30,7 @@ import {
FilterableAuthUserProps,
UpdateAuthUserDTO,
} from "@medusajs/types/dist/authentication/common"
import { ServiceTypes } from "@types"
type InjectedDependencies = {
baseRepository: DAL.RepositoryService
@@ -37,6 +43,15 @@ export default class AuthenticationModuleService<
TAuthProvider extends AuthProvider = AuthProvider
> implements AuthenticationTypes.IAuthenticationModuleService
{
__joinerConfig(): ModuleJoinerConfig {
return joinerConfig
}
__hooks = {
onApplicationStart: async () => await this.createProvidersOnLoad(),
}
protected __container__: MedusaContainer
protected baseRepository_: DAL.RepositoryService
protected authUserService_: AuthUserService<TAuthUser>
@@ -50,6 +65,7 @@ export default class AuthenticationModuleService<
}: InjectedDependencies,
protected readonly moduleDeclaration: InternalModuleDeclaration
) {
this.__container__ = arguments[0]
this.baseRepository_ = baseRepository
this.authUserService_ = authUserService
this.authProviderService_ = authProviderService
@@ -336,7 +352,64 @@ export default class AuthenticationModuleService<
await this.authUserService_.delete(ids, sharedContext)
}
__joinerConfig(): ModuleJoinerConfig {
return joinerConfig
protected getRegisteredAuthenticationProvider(
provider: string
): AbstractAuthenticationModuleProvider {
let containerProvider: AbstractAuthenticationModuleProvider
try {
containerProvider = this.__container__[`auth_provider_${provider}`]
} catch (error) {
throw new MedusaError(
MedusaError.Types.NOT_FOUND,
`AuthenticationProvider with for provider: ${provider} wasn't registered in the module. Have you configured your options correctly?`
)
}
return containerProvider
}
@InjectTransactionManager("baseRepository_")
async authenticate(
provider: string,
authenticationData: Record<string, unknown>,
@MedusaContext() sharedContext: Context = {}
): Promise<AuthenticationResponse> {
let registeredProvider
try {
await this.retrieveAuthProvider(provider, {})
registeredProvider = this.getRegisteredAuthenticationProvider(provider)
return await registeredProvider.authenticate(authenticationData)
} catch (error) {
return { success: false, error: error.message }
}
}
private async createProvidersOnLoad() {
const providersToLoad = this.__container__["auth_providers"]
const providers = await this.authProviderService_.list({
provider: providersToLoad.map((p) => p.provider),
})
const loadedProvidersMap = new Map(providers.map((p) => [p.provider, p]))
const providersToCreate: ServiceTypes.CreateAuthProviderDTO[] = []
for (const provider of providersToLoad) {
if (loadedProvidersMap.has(provider.provider)) {
continue
}
providersToCreate.push({
provider: provider.provider,
name: provider.displayName,
})
}
await this.authProviderService_.create(providersToCreate)
}
}
@@ -2,6 +2,7 @@ import { AuthUser } from "@models"
export type CreateAuthUserDTO = {
provider_id: string
entity_id: string
provider_metadata?: Record<string, unknown>
user_metadata?: Record<string, unknown>
app_metadata?: Record<string, unknown>
@@ -3,6 +3,7 @@ import { AuthProviderDTO } from "./auth-provider"
export type AuthUserDTO = {
id: string
provider_id: string
entity_id: string
provider: AuthProviderDTO
provider_metadata?: Record<string, unknown>
user_metadata: Record<string, unknown>
@@ -10,6 +11,7 @@ export type AuthUserDTO = {
}
export type CreateAuthUserDTO = {
entity_id: string
provider_id: string
provider_metadata?: Record<string, unknown>
user_metadata?: Record<string, unknown>
@@ -4,6 +4,7 @@ import { AuthProviderDTO } from "./auth-provider"
export type AuthUserDTO = {
id: string
provider_id: string
entity_id: string
provider: AuthProviderDTO
provider_metadata?: Record<string, unknown>
user_metadata: Record<string, unknown>
@@ -12,6 +13,7 @@ export type AuthUserDTO = {
export type CreateAuthUserDTO = {
provider_id: string
entity_id: string
provider_metadata?: Record<string, unknown>
user_metadata?: Record<string, unknown>
app_metadata?: Record<string, unknown>
+17 -1
View File
@@ -1,8 +1,24 @@
import { AuthUserDTO } from "./common"
export abstract class AbstractAuthenticationModuleProvider {
public static PROVIDER: string
public static DISPLAY_NAME: string
public get provider() {
return (this.constructor as Function & { PROVIDER: string}).PROVIDER
}
public get displayName() {
return (this.constructor as Function & { DISPLAY_NAME: string}).DISPLAY_NAME
}
abstract authenticate(
data: Record<string, unknown>
): Promise<Record<string, unknown>>
): Promise<AuthenticationResponse>
}
export type AuthenticationResponse = {
success: boolean
authUser?: AuthUserDTO
error?: string
}
@@ -11,8 +11,14 @@ import {
} from "./common"
import { FindConfig } from "../common"
import { Context } from "../shared-context"
import { AuthenticationResponse } from "./provider"
export interface IAuthenticationModuleService extends IModuleService {
authenticate(
provider: string,
providerData: Record<string, unknown>
): Promise<AuthenticationResponse>
retrieveAuthProvider(
provider: string,
config?: FindConfig<AuthProviderDTO>,
+1
View File
@@ -7857,6 +7857,7 @@ __metadata:
knex: 2.4.2
medusa-test-utils: ^1.1.40
rimraf: ^3.0.2
scrypt-kdf: ^2.0.1
ts-jest: ^29.1.1
ts-node: ^10.9.1
tsc-alias: ^1.8.6