mirror of
https://fast.feibisi.com/https://github.com/parcelvoy/platform.git
synced 2025-08-28 11:46:02 +08:00
Authentication Improvements (#236)
This commit is contained in:
parent
8635b0293e
commit
a053b555da
31 changed files with 2259 additions and 1840 deletions
|
@ -20,3 +20,4 @@ STORAGE_BASE_URL=http://localhost:3000/uploads
|
|||
AUTH_DRIVER=basic
|
||||
AUTH_BASIC_EMAIL=test@parcelvoy.com
|
||||
AUTH_BASIC_PASSWORD=password
|
||||
AUTH_BASIC_NAME=Login
|
||||
|
|
3487
apps/platform/package-lock.json
generated
3487
apps/platform/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
@ -53,7 +53,7 @@ export default class Api extends Koa {
|
|||
.use(cors())
|
||||
.use(serve('./public', {
|
||||
hidden: true,
|
||||
defer: !app.env.mono,
|
||||
defer: !app.env.config.monoDocker,
|
||||
}))
|
||||
|
||||
this.registerControllers()
|
||||
|
|
|
@ -1,13 +1,11 @@
|
|||
import loadDatabase, { Database } from './config/database'
|
||||
import loadQueue from './config/queue'
|
||||
import loadStorage from './config/storage'
|
||||
import loadAuth from './config/auth'
|
||||
import loadError, { logger } from './config/logger'
|
||||
import loadRateLimit, { RateLimiter } from './config/rateLimit'
|
||||
import type { Env } from './config/env'
|
||||
import type Queue from './queue'
|
||||
import Storage from './storage'
|
||||
import type Auth from './auth/Auth'
|
||||
import { uuid } from './utilities'
|
||||
import Api from './api'
|
||||
import Worker from './worker'
|
||||
|
@ -39,14 +37,10 @@ export default class App {
|
|||
// Load storage
|
||||
const storage = loadStorage(env.storage)
|
||||
|
||||
// Load auth
|
||||
const auth = loadAuth(env.auth)
|
||||
|
||||
// Setup app
|
||||
const app = new this(env,
|
||||
database,
|
||||
queue,
|
||||
auth,
|
||||
storage,
|
||||
error,
|
||||
) as any
|
||||
|
@ -70,7 +64,6 @@ export default class App {
|
|||
public env: Env,
|
||||
public db: Database,
|
||||
public queue: Queue,
|
||||
public auth: Auth,
|
||||
public storage: Storage,
|
||||
public error: ErrorHandler,
|
||||
) {
|
||||
|
|
|
@ -1,56 +1,96 @@
|
|||
import { Context } from 'koa'
|
||||
import AuthProvider from './AuthProvider'
|
||||
import OpenIDProvider, { OpenIDConfig } from './OpenIDAuthProvider'
|
||||
import GoogleProvider, { GoogleConfig } from './GoogleAuthProvider'
|
||||
import SAMLProvider, { SAMLConfig } from './SAMLAuthProvider'
|
||||
import { DriverConfig } from '../config/env'
|
||||
import BasicAuthProvider, { BasicAuthConfig } from './BasicAuthProvider'
|
||||
import { getOrganizationByUsername } from '../organizations/OrganizationService'
|
||||
import Organization from '../organizations/Organization'
|
||||
import App from '../app'
|
||||
import MultiAuthProvider, { MultiAuthConfig } from './MultiAuthProvider'
|
||||
|
||||
export type AuthProviderName = 'basic' | 'saml' | 'openid' | 'logger'
|
||||
export type AuthProviderName = 'basic' | 'saml' | 'openid' | 'google' | 'multi'
|
||||
|
||||
export type AuthConfig = BasicAuthConfig | SAMLConfig | OpenIDConfig
|
||||
export type AuthProviderConfig = BasicAuthConfig | SAMLConfig | OpenIDConfig | GoogleConfig | MultiAuthConfig
|
||||
|
||||
export interface AuthTypeConfig extends DriverConfig {
|
||||
export interface AuthConfig {
|
||||
driver: AuthProviderName[]
|
||||
tokenLife: number
|
||||
driver: AuthProviderName
|
||||
basic: BasicAuthConfig
|
||||
saml: SAMLConfig
|
||||
openid: OpenIDConfig
|
||||
google: GoogleConfig
|
||||
multi: MultiAuthConfig
|
||||
}
|
||||
|
||||
export default class Auth {
|
||||
provider: AuthProvider
|
||||
export { BasicAuthConfig, SAMLConfig, OpenIDConfig }
|
||||
|
||||
constructor(config?: AuthConfig) {
|
||||
this.provider = Auth.provider(config)
|
||||
}
|
||||
export interface AuthTypeConfig extends DriverConfig {
|
||||
driver: AuthProviderName
|
||||
name?: string
|
||||
}
|
||||
|
||||
static provider(config?: AuthConfig): AuthProvider {
|
||||
interface AuthMethod {
|
||||
driver: AuthProviderName
|
||||
name: string
|
||||
}
|
||||
|
||||
export const initProvider = (config?: AuthProviderConfig): AuthProvider => {
|
||||
if (config?.driver === 'basic') {
|
||||
return new BasicAuthProvider(config)
|
||||
} else if (config?.driver === 'saml') {
|
||||
return new SAMLProvider(config)
|
||||
} else if (config?.driver === 'openid') {
|
||||
return new OpenIDProvider(config)
|
||||
} else if (config?.driver === 'google') {
|
||||
return new GoogleProvider(config)
|
||||
} else if (config?.driver === 'multi') {
|
||||
return new MultiAuthProvider()
|
||||
} else {
|
||||
throw new Error('A valid auth driver must be set!')
|
||||
}
|
||||
}
|
||||
|
||||
async start(ctx: Context): Promise<void> {
|
||||
const provider = await this.loadProvider(ctx)
|
||||
return await provider.start(ctx)
|
||||
}
|
||||
|
||||
async validate(ctx: Context): Promise<void> {
|
||||
const provider = await this.loadProvider(ctx)
|
||||
return await provider.validate(ctx)
|
||||
}
|
||||
|
||||
private async loadProvider(ctx: Context): Promise<AuthProvider> {
|
||||
if (ctx.subdomains && ctx.subdomains[0]) {
|
||||
const subdomain = ctx.subdomains[0]
|
||||
const org = await getOrganizationByUsername(subdomain)
|
||||
ctx.state.organization = org
|
||||
if (org) return Auth.provider(org.auth)
|
||||
}
|
||||
return this.provider
|
||||
}
|
||||
}
|
||||
|
||||
export const authMethods = async (organization?: Organization): Promise<AuthMethod[]> => {
|
||||
|
||||
// If we know the org, don't require any extra steps like
|
||||
// providing email since we know where to route you. Otherwise
|
||||
// we need context to properly fetch SSO and such.
|
||||
return organization
|
||||
? [mapMethod(organization.auth)]
|
||||
: mapMethods(App.main.env.auth)
|
||||
}
|
||||
|
||||
export const checkAuth = (organization?: Organization): boolean => {
|
||||
return organization != null && organization.auth != null
|
||||
}
|
||||
|
||||
export const startAuth = async (ctx: Context): Promise<void> => {
|
||||
const provider = await loadProvider(ctx)
|
||||
return await provider.start(ctx)
|
||||
}
|
||||
|
||||
export const validateAuth = async (ctx: Context): Promise<void> => {
|
||||
const provider = await loadProvider(ctx)
|
||||
return await provider.validate(ctx)
|
||||
}
|
||||
|
||||
const loadProvider = async (ctx: Context): Promise<AuthProvider> => {
|
||||
const driver = ctx.params.driver as AuthProviderName
|
||||
const organization = ctx.state.organization
|
||||
if (organization) {
|
||||
return initProvider(organization.auth)
|
||||
}
|
||||
|
||||
return initProvider(App.main.env.auth[driver])
|
||||
}
|
||||
|
||||
const mapMethods = (config: AuthConfig): AuthMethod[] => {
|
||||
const drivers = config.driver
|
||||
return drivers.map((driver) => mapMethod(config[driver]))
|
||||
}
|
||||
|
||||
const mapMethod = ({ driver, name }: AuthTypeConfig): AuthMethod => ({
|
||||
driver,
|
||||
name: name ?? `Continue with ${driver}`,
|
||||
})
|
||||
|
|
|
@ -1,27 +1,55 @@
|
|||
import Router from '@koa/router'
|
||||
import App from '../app'
|
||||
import { getTokenCookies, revokeAccessToken } from './TokenRepository'
|
||||
import { Context } from 'koa'
|
||||
import { getOrganization, getOrganizationByEmail, getOrganizationByUsername } from '../organizations/OrganizationService'
|
||||
import Organization from '../organizations/Organization'
|
||||
import { authMethods, checkAuth, startAuth, validateAuth } from './Auth'
|
||||
|
||||
const router = new Router({
|
||||
const router = new Router<{
|
||||
organization?: Organization
|
||||
}>({
|
||||
prefix: '/auth',
|
||||
})
|
||||
|
||||
router.get('/login', async ctx => {
|
||||
await App.main.auth.start(ctx)
|
||||
router.use(async (ctx: Context, next: () => void) => {
|
||||
const organizationId = ctx.cookies.get('organization')
|
||||
if (organizationId) {
|
||||
ctx.state.organization = await getOrganization(parseInt(organizationId))
|
||||
} else if (ctx.subdomains && ctx.subdomains[0]) {
|
||||
const subdomain = ctx.subdomains[0]
|
||||
ctx.state.organization = await getOrganizationByUsername(subdomain)
|
||||
}
|
||||
return next()
|
||||
})
|
||||
|
||||
router.post('/login', async ctx => {
|
||||
await App.main.auth.start(ctx)
|
||||
router.get('/methods', async ctx => {
|
||||
ctx.body = await authMethods(ctx.state.organization)
|
||||
})
|
||||
|
||||
router.post('/login/callback', async ctx => {
|
||||
router.post('/check', async ctx => {
|
||||
const email = ctx.query.email || ctx.request.body.email
|
||||
const organization = await getOrganizationByEmail(email)
|
||||
ctx.body = checkAuth(organization)
|
||||
})
|
||||
|
||||
router.get('/login/:driver', async ctx => {
|
||||
ctx.status = 204
|
||||
await App.main.auth.validate(ctx)
|
||||
await startAuth(ctx)
|
||||
})
|
||||
|
||||
router.get('/login/callback', async ctx => {
|
||||
router.post('/login/:driver', async ctx => {
|
||||
ctx.status = 204
|
||||
await App.main.auth.validate(ctx)
|
||||
await startAuth(ctx)
|
||||
})
|
||||
|
||||
router.get('/login/:driver/callback', async ctx => {
|
||||
ctx.status = 204
|
||||
await validateAuth(ctx)
|
||||
})
|
||||
|
||||
router.post('/login/:driver/callback', async ctx => {
|
||||
ctx.status = 204
|
||||
await validateAuth(ctx)
|
||||
})
|
||||
|
||||
router.post('/logout', async ctx => {
|
||||
|
|
|
@ -7,7 +7,7 @@ import { createOrUpdateAdmin } from './AdminRepository'
|
|||
import { generateAccessToken, OAuthResponse, setTokenCookies } from './TokenRepository'
|
||||
import Organization from '../organizations/Organization'
|
||||
import { State } from './AuthMiddleware'
|
||||
import { createOrganization, getOrganizationByDomain } from '../organizations/OrganizationService'
|
||||
import { createOrganization, getDefaultOrganization, getOrganizationByDomain } from '../organizations/OrganizationService'
|
||||
|
||||
type OrgState = State & { organization?: Organization }
|
||||
export type AuthContext = Context & { state: OrgState }
|
||||
|
@ -18,11 +18,22 @@ export default abstract class AuthProvider {
|
|||
abstract validate(ctx: AuthContext): Promise<void>
|
||||
|
||||
async loadAuthOrganization(ctx: AuthContext, domain: string) {
|
||||
const organization = ctx.state.organization ?? await getOrganizationByDomain(domain)
|
||||
if (!organization) {
|
||||
return await createOrganization(domain)
|
||||
|
||||
// If we have an organization or can find one by domain
|
||||
// we use that to start
|
||||
let organization = ctx.state.organization ?? await getOrganizationByDomain(domain)
|
||||
if (organization) return organization
|
||||
|
||||
// If we are not in multi-org mode we always fall back to
|
||||
// a single organization
|
||||
if (!App.main.env.config.multiOrg) {
|
||||
organization = await getDefaultOrganization()
|
||||
}
|
||||
return organization
|
||||
if (organization) return organization
|
||||
|
||||
// If there is no organization at all or are in multi-org mode
|
||||
// and have no org for the user, create one
|
||||
return await createOrganization(domain)
|
||||
}
|
||||
|
||||
async login(params: AdminParams, ctx?: AuthContext, redirect?: string): Promise<OAuthResponse> {
|
||||
|
|
31
apps/platform/src/auth/GoogleAuthProvider.ts
Normal file
31
apps/platform/src/auth/GoogleAuthProvider.ts
Normal file
|
@ -0,0 +1,31 @@
|
|||
import { AuthTypeConfig } from './Auth'
|
||||
import AuthProvider, { AuthContext } from './AuthProvider'
|
||||
import OpenIDAuthProvider from './OpenIDAuthProvider'
|
||||
|
||||
export interface GoogleConfig extends AuthTypeConfig {
|
||||
driver: 'google'
|
||||
clientId: string
|
||||
clientSecret: string
|
||||
redirectUri: string
|
||||
}
|
||||
|
||||
export default class GoogleAuthProvider extends AuthProvider {
|
||||
|
||||
private provider: OpenIDAuthProvider
|
||||
constructor(config: GoogleConfig) {
|
||||
super()
|
||||
this.provider = new OpenIDAuthProvider({
|
||||
...config,
|
||||
driver: 'openid',
|
||||
issuerUrl: 'https://accounts.google.com',
|
||||
})
|
||||
}
|
||||
|
||||
async start(ctx: AuthContext): Promise<void> {
|
||||
return await this.provider.start(ctx)
|
||||
}
|
||||
|
||||
async validate(ctx: AuthContext): Promise<void> {
|
||||
return await this.provider.validate(ctx)
|
||||
}
|
||||
}
|
27
apps/platform/src/auth/MultiAuthProvider.ts
Normal file
27
apps/platform/src/auth/MultiAuthProvider.ts
Normal file
|
@ -0,0 +1,27 @@
|
|||
import { getOrganizationByEmail } from '../organizations/OrganizationService'
|
||||
import { AuthTypeConfig, initProvider } from './Auth'
|
||||
import AuthProvider, { AuthContext } from './AuthProvider'
|
||||
|
||||
export interface MultiAuthConfig extends AuthTypeConfig {
|
||||
driver: 'multi'
|
||||
}
|
||||
|
||||
export default class MultiAuthProvider extends AuthProvider {
|
||||
async start(ctx: AuthContext): Promise<void> {
|
||||
|
||||
// Redirect to the default for the given org
|
||||
if (ctx.query.email || ctx.request.body.email) {
|
||||
const email = ctx.query.email ?? ctx.request.body.email
|
||||
const organization = await getOrganizationByEmail(email)
|
||||
if (organization) {
|
||||
return await initProvider(organization.auth).start(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('No organization found.')
|
||||
}
|
||||
|
||||
async validate(): Promise<void> {
|
||||
// Will never be called since it routes to other providers
|
||||
}
|
||||
}
|
|
@ -11,9 +11,9 @@ export interface OpenIDConfig extends AuthTypeConfig {
|
|||
driver: 'openid'
|
||||
issuerUrl: string // 'https://accounts.google.com'
|
||||
clientId: string
|
||||
cliendSecret: string
|
||||
clientSecret: string
|
||||
redirectUri: string
|
||||
domain: string
|
||||
domain?: string
|
||||
}
|
||||
|
||||
export default class OpenIDAuthProvider extends AuthProvider {
|
||||
|
@ -32,9 +32,6 @@ export default class OpenIDAuthProvider extends AuthProvider {
|
|||
const client = await this.getClient()
|
||||
|
||||
const nonce = generators.nonce()
|
||||
// store the nonce in your framework's session mechanism, if it is a cookie based solution
|
||||
// it should be httpOnly (not readable by javascript) and encrypted.
|
||||
|
||||
ctx.cookies.set('nonce', nonce, {
|
||||
secure: ctx.request.secure,
|
||||
httpOnly: true,
|
||||
|
@ -49,6 +46,15 @@ export default class OpenIDAuthProvider extends AuthProvider {
|
|||
expires: addSeconds(Date.now(), 3600),
|
||||
})
|
||||
|
||||
const organization = ctx.state.organization
|
||||
if (organization) {
|
||||
ctx.cookies.set('organization', `${organization.id}`, {
|
||||
secure: ctx.request.secure,
|
||||
httpOnly: true,
|
||||
expires: addSeconds(Date.now(), 3600),
|
||||
})
|
||||
}
|
||||
|
||||
const url = client.authorizationUrl({
|
||||
scope: 'openid email profile',
|
||||
response_mode: 'form_post',
|
||||
|
@ -95,6 +101,10 @@ export default class OpenIDAuthProvider extends AuthProvider {
|
|||
}
|
||||
|
||||
await this.login(admin, ctx, state)
|
||||
|
||||
ctx.cookies.set('nonce', null)
|
||||
ctx.cookies.set('relaystate', null)
|
||||
ctx.cookies.set('organization', null)
|
||||
} catch (error) {
|
||||
logger.warn(error)
|
||||
throw new RequestError(AuthError.OpenIdValidationError)
|
||||
|
@ -109,7 +119,7 @@ export default class OpenIDAuthProvider extends AuthProvider {
|
|||
// TODO: Should we validate that we can use the issuer?
|
||||
this.client = new issuer.Client({
|
||||
client_id: this.config.clientId,
|
||||
client_secret: this.config.cliendSecret,
|
||||
client_secret: this.config.clientSecret,
|
||||
redirect_uris: [this.config.redirectUri],
|
||||
response_types: ['id_token'],
|
||||
})
|
||||
|
|
|
@ -7,6 +7,7 @@ import { AuthTypeConfig } from './Auth'
|
|||
import AuthProvider from './AuthProvider'
|
||||
import AuthError from './AuthError'
|
||||
import { firstQueryParam } from '../utilities'
|
||||
import { addSeconds } from 'date-fns'
|
||||
|
||||
export interface SAMLConfig extends AuthTypeConfig {
|
||||
driver: 'saml'
|
||||
|
@ -65,6 +66,15 @@ export default class SAMLAuthProvider extends AuthProvider {
|
|||
|
||||
const url = await this.saml.getAuthorizeUrlAsync(relayState, host, {})
|
||||
|
||||
const organization = ctx.state.organization
|
||||
if (organization) {
|
||||
ctx.cookies.set('organization', `${organization.id}`, {
|
||||
secure: ctx.request.secure,
|
||||
httpOnly: true,
|
||||
expires: addSeconds(Date.now(), 3600),
|
||||
})
|
||||
}
|
||||
|
||||
ctx.redirect(url)
|
||||
}
|
||||
|
||||
|
@ -88,6 +98,8 @@ export default class SAMLAuthProvider extends AuthProvider {
|
|||
|
||||
const { id } = await this.loadAuthOrganization(ctx, domain)
|
||||
await this.login({ first_name, last_name, email, organization_id: id }, ctx, state)
|
||||
|
||||
ctx.cookies.set('organization', null)
|
||||
}
|
||||
|
||||
private getDomain(email: string): string | undefined {
|
||||
|
|
|
@ -1,6 +0,0 @@
|
|||
|
||||
import Auth, { AuthConfig } from '../auth/Auth'
|
||||
|
||||
export default (config: AuthConfig) => {
|
||||
return new Auth(config)
|
||||
}
|
|
@ -38,7 +38,7 @@ export default (app: App) => {
|
|||
}
|
||||
|
||||
// If we are running in mono mode, we need to also serve the UI
|
||||
if (app.env.mono) {
|
||||
if (app.env.config.monoDocker) {
|
||||
const ui = new Router()
|
||||
ui.get('/(.*)', async (ctx, next) => {
|
||||
try {
|
||||
|
|
|
@ -2,14 +2,17 @@ import * as dotenv from 'dotenv'
|
|||
import type { StorageConfig } from '../storage/Storage'
|
||||
import type { QueueConfig } from '../queue/Queue'
|
||||
import type { DatabaseConfig } from './database'
|
||||
import type { AuthConfig } from '../auth/Auth'
|
||||
import type { AuthConfig, AuthProviderName } from '../auth/Auth'
|
||||
import type { ErrorConfig } from '../error/ErrorHandler'
|
||||
import { RedisConfig } from './redis'
|
||||
|
||||
export type Runner = 'api' | 'worker'
|
||||
export interface Env {
|
||||
runners: Runner[]
|
||||
mono: boolean
|
||||
config: {
|
||||
monoDocker: boolean
|
||||
multiOrg: boolean
|
||||
}
|
||||
db: DatabaseConfig
|
||||
queue: QueueConfig
|
||||
storage: StorageConfig
|
||||
|
@ -50,7 +53,10 @@ export default (type?: EnvType): Env => {
|
|||
|
||||
return {
|
||||
runners: (process.env.RUNNER ?? 'api,worker').split(',') as Runner[],
|
||||
mono: (process.env.MONO ?? 'false') === 'true',
|
||||
config: {
|
||||
monoDocker: (process.env.MONO ?? 'false') === 'true',
|
||||
multiOrg: (process.env.MULTI_ORG ?? 'false') === 'true',
|
||||
},
|
||||
db: {
|
||||
host: process.env.DB_HOST!,
|
||||
user: process.env.DB_USERNAME!,
|
||||
|
@ -97,28 +103,45 @@ export default (type?: EnvType): Env => {
|
|||
apiBaseUrl,
|
||||
port,
|
||||
secret: process.env.APP_SECRET!,
|
||||
auth: driver<AuthConfig>(process.env.AUTH_DRIVER, {
|
||||
basic: () => ({
|
||||
auth: {
|
||||
driver: (process.env.AUTH_DRIVER?.split(',') ?? []) as AuthProviderName[],
|
||||
tokenLife: defaultTokenLife,
|
||||
basic: {
|
||||
driver: 'basic',
|
||||
name: process.env.AUTH_BASIC_NAME!,
|
||||
email: process.env.AUTH_BASIC_EMAIL!,
|
||||
password: process.env.AUTH_BASIC_PASSWORD!,
|
||||
}),
|
||||
saml: () => ({
|
||||
tokenLife: defaultTokenLife,
|
||||
callbackUrl: process.env.AUTH_SAML_CALLBACK_URL,
|
||||
entryPoint: process.env.AUTH_SAML_ENTRY_POINT_URL,
|
||||
issuer: process.env.AUTH_SAML_ISSUER,
|
||||
cert: process.env.AUTH_SAML_CERT,
|
||||
},
|
||||
saml: {
|
||||
driver: 'saml',
|
||||
name: process.env.AUTH_SAML_NAME!,
|
||||
callbackUrl: `${apiBaseUrl}/auth/login/saml/callback`,
|
||||
entryPoint: process.env.AUTH_SAML_ENTRY_POINT_URL!,
|
||||
issuer: process.env.AUTH_SAML_ISSUER!,
|
||||
cert: process.env.AUTH_SAML_CERT!,
|
||||
wantAuthnResponseSigned: process.env.AUTH_SAML_IS_AUTHN_SIGNED === 'true',
|
||||
}),
|
||||
openid: () => ({
|
||||
tokenLife: defaultTokenLife,
|
||||
issuerUrl: process.env.AUTH_OPENID_ISSUER_URL,
|
||||
clientId: process.env.AUTH_OPENID_CLIENT_ID,
|
||||
clientSecret: process.env.AUTH_OPENID_CLIENT_SECRET,
|
||||
redirectUri: process.env.AUTH_OPENID_REDIRECT_URI,
|
||||
}),
|
||||
}),
|
||||
},
|
||||
openid: {
|
||||
driver: 'openid',
|
||||
name: process.env.AUTH_OPENID_NAME!,
|
||||
issuerUrl: process.env.AUTH_OPENID_ISSUER_URL!,
|
||||
clientId: process.env.AUTH_OPENID_CLIENT_ID!,
|
||||
clientSecret: process.env.AUTH_OPENID_CLIENT_SECRET!,
|
||||
redirectUri: `${apiBaseUrl}/auth/login/openid/callback`,
|
||||
domain: process.env.AUTH_OPENID_DOMAIN!,
|
||||
},
|
||||
google: {
|
||||
driver: 'google',
|
||||
name: process.env.AUTH_GOOGLE_NAME!,
|
||||
clientId: process.env.AUTH_OPENID_CLIENT_ID!,
|
||||
clientSecret: process.env.AUTH_OPENID_CLIENT_SECRET!,
|
||||
redirectUri: `${apiBaseUrl}/auth/login/google/callback`,
|
||||
},
|
||||
multi: {
|
||||
driver: 'multi',
|
||||
name: process.env.AUTH_MULTI_NAME!,
|
||||
},
|
||||
},
|
||||
error: driver<ErrorConfig>(process.env.ERROR_DRIVER, {
|
||||
bugsnag: () => ({
|
||||
apiKey: process.env.ERROR_BUGSNAG_API_KEY,
|
||||
|
|
|
@ -73,7 +73,7 @@ export default class Model {
|
|||
|
||||
static async first<T extends typeof Model>(
|
||||
this: T,
|
||||
query: Query,
|
||||
query: Query = (qb) => qb,
|
||||
db: Database = App.main.db,
|
||||
): Promise<InstanceType<T> | undefined> {
|
||||
const record = await this.build(query, db).first()
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
import { AuthConfig } from '../auth/Auth'
|
||||
import { AuthProviderConfig } from '../auth/Auth'
|
||||
import Model from '../core/Model'
|
||||
|
||||
export default class Organization extends Model {
|
||||
username!: string
|
||||
domain!: string
|
||||
auth!: AuthConfig
|
||||
auth!: AuthProviderConfig
|
||||
|
||||
static jsonAttributes = ['auth']
|
||||
}
|
||||
|
|
|
@ -1,6 +1,11 @@
|
|||
import { Admin } from '../auth/Admin'
|
||||
import { encodeHashid } from '../utilities'
|
||||
import Organization from './Organization'
|
||||
|
||||
export const getOrganization = async (id: number) => {
|
||||
return await Organization.find(id)
|
||||
}
|
||||
|
||||
export const getOrganizationByUsername = async (username: string) => {
|
||||
return await Organization.first(qb => qb.where('username', username))
|
||||
}
|
||||
|
@ -10,6 +15,16 @@ export const getOrganizationByDomain = async (domain?: string) => {
|
|||
return await Organization.first(qb => qb.where('domain', domain))
|
||||
}
|
||||
|
||||
export const getOrganizationByEmail = async (email: string) => {
|
||||
const admin = await Admin.first(qb => qb.where('email', email))
|
||||
if (!admin) return undefined
|
||||
return await getOrganization(admin.organization_id)
|
||||
}
|
||||
|
||||
export const getDefaultOrganization = async () => {
|
||||
return await Organization.first()
|
||||
}
|
||||
|
||||
export const createOrganization = async (domain: string): Promise<Organization> => {
|
||||
const username = domain.split('.').shift()
|
||||
const org = await Organization.insertAndFetch({
|
||||
|
|
|
@ -14,7 +14,10 @@ export abstract class TextProvider extends Provider {
|
|||
const router = new Router<{ provider: Provider }>()
|
||||
router.post(`/${namespace}/unsubscribe`, async ctx => {
|
||||
const channel = await loadTextChannel(ctx.state.provider.id)
|
||||
if (!channel) return
|
||||
if (!channel) {
|
||||
ctx.status = 404
|
||||
return
|
||||
}
|
||||
|
||||
// Always return with positive status code
|
||||
ctx.status = 204
|
||||
|
|
|
@ -8,5 +8,5 @@ export interface Webhook {
|
|||
export interface WebhookResponse {
|
||||
message: Webhook
|
||||
success: boolean
|
||||
response: string
|
||||
response: Record<string, any> | string
|
||||
}
|
||||
|
|
|
@ -18,7 +18,7 @@ export default class WebhookChannel {
|
|||
const endpoint = Render(options.endpoint, variables)
|
||||
const method = options.method
|
||||
|
||||
await this.provider.send({
|
||||
return await this.provider.send({
|
||||
endpoint,
|
||||
method,
|
||||
headers,
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import Axios from 'axios'
|
||||
import { env } from './config/env'
|
||||
import { Admin, Campaign, CampaignCreateParams, CampaignLaunchParams, CampaignUpdateParams, CampaignUser, Image, Journey, JourneyStepMap, JourneyStepStats, List, ListCreateParams, ListUpdateParams, Project, ProjectAdmin, ProjectAdminParams, ProjectApiKey, ProjectApiKeyParams, Provider, ProviderCreateParams, ProviderMeta, ProviderUpdateParams, QueueMetric, RuleSuggestions, SearchParams, SearchResult, Subscription, SubscriptionParams, Tag, Template, TemplateCreateParams, TemplatePreviewParams, TemplateProofParams, TemplateUpdateParams, User, UserEvent, UserSubscription } from './types'
|
||||
import { Admin, AuthMethod, Campaign, CampaignCreateParams, CampaignLaunchParams, CampaignUpdateParams, CampaignUser, Image, Journey, JourneyStepMap, JourneyStepStats, List, ListCreateParams, ListUpdateParams, Project, ProjectAdmin, ProjectAdminParams, ProjectApiKey, ProjectApiKeyParams, Provider, ProviderCreateParams, ProviderMeta, ProviderUpdateParams, QueueMetric, RuleSuggestions, SearchParams, SearchResult, Subscription, SubscriptionParams, Tag, Template, TemplateCreateParams, TemplatePreviewParams, TemplateProofParams, TemplateUpdateParams, User, UserEvent, UserSubscription } from './types'
|
||||
|
||||
function appendValue(params: URLSearchParams, name: string, value: unknown) {
|
||||
if (typeof value === 'undefined' || value === null || typeof value === 'function') return
|
||||
|
@ -29,7 +29,7 @@ client.interceptors.response.use(
|
|||
response => response,
|
||||
async error => {
|
||||
if (error.response.status === 401) {
|
||||
api.login()
|
||||
api.auth.login()
|
||||
}
|
||||
throw error
|
||||
},
|
||||
|
@ -115,17 +115,23 @@ const cache: {
|
|||
|
||||
const api = {
|
||||
|
||||
auth: {
|
||||
methods: async () => await client
|
||||
.get<AuthMethod[]>('/auth/methods')
|
||||
.then(r => r.data),
|
||||
check: async (method: string, email: string) => await client
|
||||
.post<boolean>('/auth/check', { method, email })
|
||||
.then(r => r.data),
|
||||
basicAuth: async (email: string, password: string, redirect: string = '/') => {
|
||||
await client.post('/auth/login/basic/callback', { email, password })
|
||||
window.location.href = redirect
|
||||
},
|
||||
login() {
|
||||
window.location.href = `/login?r=${encodeURIComponent(window.location.href)}`
|
||||
},
|
||||
|
||||
async logout() {
|
||||
window.location.href = env.api.baseURL + '/auth/logout'
|
||||
},
|
||||
|
||||
async basicAuth(email: string, password: string, redirect: string = '/') {
|
||||
await client.post('/auth/login/callback', { email, password })
|
||||
window.location.href = redirect
|
||||
},
|
||||
|
||||
profile: {
|
||||
|
|
|
@ -104,6 +104,11 @@ export interface SearchResult<T> {
|
|||
|
||||
export type AuditFields = 'created_at' | 'updated_at' | 'deleted_at'
|
||||
|
||||
export interface AuthMethod {
|
||||
driver: string
|
||||
name: string
|
||||
}
|
||||
|
||||
export interface Admin {
|
||||
id: number
|
||||
organization_id: number
|
||||
|
|
|
@ -28,6 +28,12 @@
|
|||
height: 20px;
|
||||
padding: 2px 0;
|
||||
display: inline-block;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ui-button .button-text {
|
||||
display: inline-block;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.ui-button.ui-button-no-children .button-icon {
|
||||
|
|
|
@ -69,7 +69,7 @@ const Button = forwardRef(function Button(props: ButtonProps, ref: Ref<HTMLButto
|
|||
style={style}
|
||||
>
|
||||
{icon && (<span className="button-icon" aria-hidden="true">{icon}</span>)}
|
||||
{children}
|
||||
<span className="button-text">{children}</span>
|
||||
</button>
|
||||
)
|
||||
})
|
||||
|
|
|
@ -69,6 +69,8 @@ export default function FormWrapper<T extends FieldValues>({
|
|||
return submitError.error
|
||||
} else if (submitError.response?.data?.error) {
|
||||
return submitError.response?.data?.error
|
||||
} else if (submitError.message) {
|
||||
return submitError.message
|
||||
}
|
||||
return defaultError
|
||||
}
|
||||
|
|
|
@ -22,7 +22,13 @@
|
|||
padding: 40px;
|
||||
}
|
||||
|
||||
.auth-step h1 {
|
||||
.auth.login .auth-step {
|
||||
min-width: 300px;
|
||||
width: 100%;
|
||||
max-width: 350px;
|
||||
}
|
||||
|
||||
.auth-step h1, .auth-step h2 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
|
@ -30,6 +36,21 @@
|
|||
padding: 10px 0;
|
||||
}
|
||||
|
||||
.auth-methods {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.auth.login .ui-button,
|
||||
.auth.login .form-submit {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.auth.login form {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 600px) {
|
||||
.auth {
|
||||
padding: 40px 20px;
|
||||
|
|
|
@ -3,24 +3,101 @@ import { ReactComponent as Logo } from '../../assets/logo.svg'
|
|||
import { env } from '../../config/env'
|
||||
import Button from '../../ui/Button'
|
||||
import './Auth.css'
|
||||
import { useEffect, useState } from 'react'
|
||||
import api from '../../api'
|
||||
import { AuthMethod } from '../../types'
|
||||
import FormWrapper from '../../ui/form/FormWrapper'
|
||||
import TextInput from '../../ui/form/TextInput'
|
||||
|
||||
interface LoginParams {
|
||||
email: string
|
||||
password?: string
|
||||
}
|
||||
|
||||
export default function Login() {
|
||||
const [searchParams] = useSearchParams()
|
||||
const [methods, setMethods] = useState<AuthMethod[]>()
|
||||
const [method, setMethod] = useState<AuthMethod>()
|
||||
|
||||
const handleRedirect = () => {
|
||||
window.location.href = `${env.api.baseURL}/auth/login?r=${searchParams.get('r') ?? '/'}`
|
||||
const handleRedirect = (driver: string, email?: string) => {
|
||||
window.location.href = `${env.api.baseURL}/auth/login/${driver}?r=${searchParams.get('r') ?? '/'}&email=${email}`
|
||||
}
|
||||
|
||||
const handleMethod = (method: AuthMethod) => {
|
||||
if (method.driver === 'multi' || method.driver === 'basic') {
|
||||
setMethod(method)
|
||||
} else {
|
||||
handleRedirect(method.driver)
|
||||
}
|
||||
}
|
||||
|
||||
const handleLogin = (method: string) => {
|
||||
return async ({ email, password }: LoginParams) => {
|
||||
if (password && method === 'basic') {
|
||||
await api.auth.basicAuth(email, password, searchParams.get('r') ?? '/')
|
||||
} else {
|
||||
await checkEmail(method, email)
|
||||
handleRedirect(method, email)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const checkEmail = async (method: string, email: string) => {
|
||||
const isAllowed = await api.auth.check(method, email)
|
||||
if (!isAllowed) throw new Error('This login method is not available for this email address or an account with this email address does not exist.')
|
||||
return isAllowed
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
api.auth.methods().then((methods) => {
|
||||
setMethods(methods)
|
||||
}).catch(() => {})
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="auth login">
|
||||
<div className="logo">
|
||||
<Logo />
|
||||
</div>
|
||||
{!method && (
|
||||
<div className="auth-step">
|
||||
<h1>Welcome!</h1>
|
||||
<p>Please use the button below to authenticate.</p>
|
||||
<Button onClick={handleRedirect}>Login</Button>
|
||||
<h2>Welcome!</h2>
|
||||
<p>Select an authentication method below to continue.</p>
|
||||
<div className="auth-methods">
|
||||
{methods?.map((method) => (
|
||||
<Button key={method.driver} onClick={() => handleMethod(method)}>{method.name}</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{method && method.driver === 'basic' && (
|
||||
<div className="auth-step">
|
||||
<h2>Login</h2>
|
||||
<p>Enter your email and password to continue.</p>
|
||||
<FormWrapper<LoginParams>
|
||||
onSubmit={handleLogin(method.driver)}>
|
||||
{form => <>
|
||||
<TextInput.Field form={form} name="email" />
|
||||
<TextInput.Field form={form} name="password" type="password" />
|
||||
</>}
|
||||
</FormWrapper>
|
||||
<Button variant="plain" onClick={() => setMethod(undefined)}>Back</Button>
|
||||
</div>
|
||||
)}
|
||||
{method && method.driver !== 'basic' && (
|
||||
<div className="auth-step">
|
||||
<h2>Auth</h2>
|
||||
<p>What is your email address?</p>
|
||||
<FormWrapper<LoginParams>
|
||||
onSubmit={handleLogin(method.driver)}
|
||||
submitLabel={method.name}>
|
||||
{form => <>
|
||||
<TextInput.Field form={form} name="email" />
|
||||
</>}
|
||||
</FormWrapper>
|
||||
<Button variant="secondary" onClick={() => setMethod(undefined)}>Back</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -1,37 +0,0 @@
|
|||
import { useSearchParams } from 'react-router-dom'
|
||||
import api from '../../api'
|
||||
import { ReactComponent as Logo } from '../../assets/logo.svg'
|
||||
import FormWrapper from '../../ui/form/FormWrapper'
|
||||
import TextInput from '../../ui/form/TextInput'
|
||||
import './Auth.css'
|
||||
|
||||
interface LoginBasicParams {
|
||||
email: string
|
||||
password: string
|
||||
}
|
||||
|
||||
export default function Login() {
|
||||
const [searchParams] = useSearchParams()
|
||||
|
||||
const handleLogin = async ({ email, password }: LoginBasicParams) => {
|
||||
await api.basicAuth(email, password, searchParams.get('r') ?? '/')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="auth login">
|
||||
<div className="logo">
|
||||
<Logo />
|
||||
</div>
|
||||
<div className="auth-step">
|
||||
<h1>Login</h1>
|
||||
<FormWrapper<LoginBasicParams>
|
||||
onSubmit={handleLogin}>
|
||||
{form => <>
|
||||
<TextInput.Field form={form} name="email" />
|
||||
<TextInput.Field form={form} name="password" type="password" />
|
||||
</>}
|
||||
</FormWrapper>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
|
@ -93,7 +93,7 @@ export default function ProjectSidebar({ children, links }: PropsWithChildren<Si
|
|||
}>
|
||||
<MenuItem onClick={() => navigate('/settings')}>Settings</MenuItem>
|
||||
<MenuItem onClick={() => setPreferences({ ...preferences, mode: preferences.mode === 'dark' ? 'light' : 'dark' })}>Use {preferences.mode === 'dark' ? 'Light' : 'Dark'} Theme</MenuItem>
|
||||
<MenuItem onClick={async () => await api.logout()}>Sign Out</MenuItem>
|
||||
<MenuItem onClick={async () => await api.auth.logout()}>Sign Out</MenuItem>
|
||||
</Menu>
|
||||
</div>
|
||||
)
|
||||
|
|
|
@ -36,7 +36,6 @@ import OnboardingProject from './auth/OnboardingProject'
|
|||
import { CampaignsIcon, JourneysIcon, ListsIcon, PerformanceIcon, ProjectIcon, SettingsIcon, UsersIcon } from '../ui/icons'
|
||||
import { Projects } from './project/Projects'
|
||||
import { pushRecentProject } from '../utils'
|
||||
import LoginBasic from './auth/LoginBasic'
|
||||
import Performance from './organization/Performance'
|
||||
import Settings from './settings/Settings'
|
||||
import ProjectSidebar from './project/ProjectSidebar'
|
||||
|
@ -60,10 +59,6 @@ export const router = createBrowserRouter([
|
|||
path: '/login',
|
||||
element: <Login />,
|
||||
},
|
||||
{
|
||||
path: '/login/basic',
|
||||
element: <LoginBasic />,
|
||||
},
|
||||
{
|
||||
path: '*',
|
||||
errorElement: <ErrorPage />,
|
||||
|
|
|
@ -38,21 +38,28 @@ Find below a list of all environment variables that can be set at launch to conf
|
|||
| AWS_SECRET_ACCESS_KEY | string | If driver is S3 |
|
||||
|
||||
### Auth
|
||||
| key | type | required |
|
||||
| key | type | required | notes
|
||||
|--|--|--|
|
||||
| AUTH_DRIVER | 'basic', 'openid' or 'saml' | true |
|
||||
| AUTH_DRIVER | 'basic', 'google', 'openid', 'saml' | true | Can be multiple
|
||||
| AUTH_BASIC_EMAIL | string | If driver is Basic |
|
||||
| AUTH_BASIC_PASSWORD | string | If driver is Basic |
|
||||
| AUTH_BASIC_NAME | string | false |
|
||||
| AUTH_SAML_CALLBACK_URL | string | If driver is SAML |
|
||||
| AUTH_SAML_ENTRY_POINT_URL | string | If driver is SAML |
|
||||
| AUTH_SAML_ISSUER | string | If driver is SAML |
|
||||
| AUTH_SAML_CERT | string | If driver is SAML |
|
||||
| AUTH_SAML_IS_AUTHN_SIGNED | boolean | If driver is SAML |
|
||||
| AUTH_SAML_NAME | string | false |
|
||||
| AUTH_OPENID_ISSUER_URL | string | If driver is OpenID |
|
||||
| AUTH_OPENID_CLIENT_ID | string | If driver is OpenID |
|
||||
| AUTH_OPENID_CLIENT_SECRET | string | If driver is OpenID |
|
||||
| AUTH_OPENID_REDIRECT_URI | string | If driver is OpenID |
|
||||
| AUTH_OPENID_DOMAIN_WHITELIST | string | If driver is OpenID |
|
||||
| AUTH_OPENID_NAME | string | false |
|
||||
| AUTH_GOOGLE_ISSUER_URL | string | If driver is Google |
|
||||
| AUTH_GOOGLE_CLIENT_ID | string | If driver is Google |
|
||||
| AUTH_GOOGLE_CLIENT_SECRET | string | If driver is Google |
|
||||
| AUTH_GOOGLE_NAME | string | false |
|
||||
|
||||
### Tracking
|
||||
| key | type | required |
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue