Merge pull request #94 from parcelvoy/feat/basic-auth-option

Adds basic auth option
This commit is contained in:
Chris Hills 2023-03-27 05:28:10 -05:00 committed by GitHub
commit b47960ae76
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
18 changed files with 242 additions and 15 deletions

21
.env.example Normal file
View file

@ -0,0 +1,21 @@
APP_SECRET=Ck7Rt1uiy5WGbrTFj1HBjymbBoA6zqih
BASE_URL=http://localhost
UI_PORT=3000
DB_CLIENT=mysql2
DB_HOST=127.0.0.1
DB_USERNAME=parcelvoy
DB_PASSWORD=parcelvoypassword
DB_PORT=3306
DB_DATABASE=parcelvoy
QUEUE_DRIVER=redis
REDIS_HOST=127.0.0.1
REDIS_PORT=6379
STORAGE_DRIVER=local
STORAGE_BASE_URL=http://localhost/uploads
AUTH_DRIVER=basic
AUTH_BASIC_USERNAME=parcelvoy
AUTH_BASIC_PASSWORD=password

BIN
.github/assets/logo-dark.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

BIN
.github/assets/logo-light.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

1
.gitignore vendored
View file

@ -72,6 +72,7 @@ typings/
# dotenv environment variables file
.env
.env.*
!.env.example
# parcel-bundler cache (https://parceljs.org/)
.cache

View file

@ -1,5 +1,18 @@
# Parcelvoy
Engage your customers through effortless communication.
<br />
<div align="center">
<a href="https://parcelvoy.com" target="_blank">
<picture>
<source media="(prefers-color-scheme: dark)" srcset=".github/assets/logo-dark.png#gh-dark-mode-only">
<img src=".github/assets/logo-light.png#gh-light-mode-only" width="360" alt="Logo"/>
</picture>
</a>
</div>
<h1 align="center">Open Source Multi-Channel Marketing</h1>
<p align="center">Engage your customers through effortless communication.</p>
<br />
## Features
- 💬 **Cross Channel Messaging** Send data-driven emails, push notifications and text messages.
@ -12,4 +25,25 @@ Engage your customers through effortless communication.
## 🚀 Getting Started
Documentation coming soon!
You can run Parcelvoy locally or in the cloud easily using Docker.
To get up and running quickly to try things out, copy our latest `docker-compose.yml` and `.env` file onto your machine and go!
```
mkdir parcelvoy && cd parcelvoy
wget https://raw.githubusercontent.com/parcelvoy/parcelvoy/master/{.env.example,docker-compose.yml}
mv .env.example .env
docker compose up -d # run the Docker container
```
Login to the web app at http://localhost:3000 by entering the default credentials found in the copied `.env` file.
```
AUTH_BASIC_USERNAME=parcelvoy
AUTH_BASIC_PASSWORD=password
```
**Note:** We would recommend changing these default credentials as well as your `APP_SECRET` before ever using Parcelvoy in production.
For full documentation on the platform and more information on deployment, check out our docs.
**[Explore the Docs »](https://docs.parcelvoy.com)**

View file

@ -5,10 +5,11 @@ import SAMLProvider, { SAMLConfig } from './SAMLAuthProvider'
import { DriverConfig } from '../config/env'
import LoggerAuthProvider from './LoggerAuthProvider'
import { logger } from '../config/logger'
import BasicAuthProvider, { BasicAuthConfig } from './BasicAuthProvider'
export type AuthProviderName = 'saml' | 'openid' | 'logger'
export type AuthProviderName = 'basic' | 'saml' | 'openid' | 'logger'
export type AuthConfig = SAMLConfig | OpenIDConfig
export type AuthConfig = BasicAuthConfig | SAMLConfig | OpenIDConfig
export interface AuthTypeConfig extends DriverConfig {
tokenLife: number
@ -19,7 +20,9 @@ export default class Auth {
provider: AuthProvider
constructor(config?: AuthConfig) {
if (config?.driver === 'saml') {
if (config?.driver === 'basic') {
this.provider = new BasicAuthProvider(config)
} else if (config?.driver === 'saml') {
this.provider = new SAMLProvider(config)
} else if (config?.driver === 'openid') {
this.provider = new OpenIDProvider(config)

View file

@ -29,7 +29,7 @@ router.post('/logout', async ctx => {
if (oauth) {
await revokeAccessToken(oauth.access_token, ctx)
}
ctx.body = {} // logout redirect env property?
ctx.redirect('/')
})
router.get('/logout', async ctx => {
@ -37,7 +37,7 @@ router.get('/logout', async ctx => {
if (oauth) {
await revokeAccessToken(oauth.access_token, ctx)
}
ctx.body = '<html><body>bye!</body></html>' // TODO redirect?
ctx.redirect('/')
})
export default router

View file

@ -35,4 +35,9 @@ export default {
code: 1007,
statusCode: 403,
},
InvalidCredentials: {
message: 'The email and password combination provided is invalid.',
code: 1008,
statusCode: 400,
},
} as Record<string, ErrorType>

View file

@ -0,0 +1,53 @@
import { Context } from 'koa'
import { AuthTypeConfig } from './Auth'
import { getAdminByEmail } from './AdminRepository'
import AuthProvider from './AuthProvider'
import App from '../app'
import { combineURLs, firstQueryParam } from '../utilities'
import { Admin } from './Admin'
import { RequestError } from '../core/errors'
import AuthError from './AuthError'
export interface BasicAuthConfig extends AuthTypeConfig {
driver: 'basic'
email: string
password: string
}
export default class BasicAuthProvider extends AuthProvider {
private config: BasicAuthConfig
constructor(config: BasicAuthConfig) {
super()
this.config = config
}
async start(ctx: Context) {
const redirect = firstQueryParam(ctx.request.query.r)
// Redirect to the login form
ctx.redirect(combineURLs([App.main.env.baseUrl, '/login/basic']) + '?r=' + redirect)
}
async validate(ctx: Context) {
console.log('basic auth!', ctx.request.body)
const { email, password } = ctx.request.body
if (!email || !password) throw new RequestError(AuthError.InvalidCredentials)
// Check email and password match
if (email !== this.config.email || password !== this.config.password) {
throw new RequestError(AuthError.InvalidCredentials)
}
// Find admin, otherwise first time, create
let admin = await getAdminByEmail(email)
if (!admin) {
admin = await Admin.insertAndFetch({ email, first_name: 'Admin' })
}
// Process the login
await this.login(admin.id, ctx)
}
}

View file

@ -43,7 +43,7 @@ export default class LoggerAuthProvider extends AuthProvider {
}
callbackUrl(token: string): string {
const baseUrl = combineURLs([App.main.env.baseUrl, 'auth/login'])
const baseUrl = combineURLs([App.main.env.baseUrl, 'api/auth/login'])
const url = new URL(baseUrl)
url.searchParams.set('token', token)
return url.href

View file

@ -79,6 +79,11 @@ export default (type?: EnvType): Env => {
port: parseInt(process.env.PORT!),
secret: process.env.APP_SECRET!,
auth: driver<AuthConfig>(process.env.AUTH_DRIVER, {
basic: () => ({
tokenLife: defaultTokenLife,
email: process.env.AUTH_BASIC_EMAIL!,
password: process.env.AUTH_BASIC_PASSWORD!,
}),
saml: () => ({
tokenLife: defaultTokenLife,
callbackUrl: process.env.AUTH_SAML_CALLBACK_URL,

View file

@ -122,6 +122,11 @@ const api = {
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: {
get: async () => {
if (!cache.profile) {

View file

@ -6,7 +6,7 @@ import { FieldProps } from './Field'
import './TextField.css'
export interface TextFieldProps<X extends FieldValues, P extends FieldPath<X>> extends FieldProps<X, P> {
type?: 'text' | 'time' | 'date' | 'datetime-local' | 'number'
type?: 'text' | 'time' | 'date' | 'datetime-local' | 'number' | 'password'
textarea?: boolean
size?: 'small' | 'regular'
value?: string | number | readonly string[] | undefined

View file

@ -14,6 +14,7 @@
}
.auth-step {
min-width: 400px;
max-width: 600px;
background: var(--color-background);
border: 1px solid var(--color-grey-soft);
@ -24,3 +25,7 @@
.auth-step h1 {
margin: 0;
}
.auth-step .form {
padding: 10px 0;
}

View file

@ -0,0 +1,49 @@
import { useState } from 'react'
import { useSearchParams } from 'react-router-dom'
import api from '../../api'
import { ReactComponent as Logo } from '../../assets/logo.svg'
import Alert from '../../ui/Alert'
import FormWrapper from '../../ui/form/FormWrapper'
import TextField from '../../ui/form/TextField'
import './Auth.css'
interface LoginBasicParams {
email: string
password: string
}
export default function Login() {
const [searchParams] = useSearchParams()
const [error, setError] = useState<string | undefined>()
const handleLogin = async ({ email, password }: LoginBasicParams) => {
try {
await api.basicAuth(email, password, searchParams.get('r') ?? '/')
} catch (error: any) {
if (error?.response?.data) {
setError(error.response.data.error)
}
}
}
return (
<div className="auth login">
<div className="logo">
<Logo />
</div>
<div className="auth-step">
<h1>Login</h1>
<FormWrapper<LoginBasicParams>
onSubmit={handleLogin}>
{form => <>
{error && (
<Alert variant="error" title="Error">{error}</Alert>
)}
<TextField form={form} name="email" />
<TextField form={form} name="password" type="password" />
</>}
</FormWrapper>
</div>
</div>
)
}

View file

@ -39,6 +39,7 @@ import { CampaignsIcon, JourneysIcon, ListsIcon, SettingsIcon, UsersIcon } from
import { Projects } from './project/Projects'
import { pushRecentProject } from '../utils'
import { ProjectRoleRequired } from './project/ProjectRoleRequired'
import LoginBasic from './auth/LoginBasic'
export const useRoute = (includeProject = true) => {
const { projectId = '' } = useParams()
@ -58,6 +59,10 @@ export const router = createBrowserRouter([
path: '/login',
element: <Login />,
},
{
path: '/login/basic',
element: <LoginBasic />,
},
{
path: '*',
errorElement: <ErrorPage />,

View file

@ -42,7 +42,7 @@ services:
DB_HOST: mysql
DB_USERNAME: ${DB_USERNAME}
DB_PASSWORD: ${DB_PASSWORD}
DB_PORT: 3306
DB_PORT: ${DB_PORT}
DB_DATABASE: ${DB_DATABASE}
STORAGE_DRIVER: ${STORAGE_DRIVER}
STORAGE_BASE_URL: ${STORAGE_BASE_URL}
@ -55,6 +55,8 @@ services:
AWS_SECRET_ACCESS_KEY: ${AWS_SECRET_ACCESS_KEY}
AWS_REGION: ${AWS_REGION}
AUTH_DRIVER: ${AUTH_DRIVER}
AUTH_BASIC_EMAIL: ${AUTH_BASIC_EMAIL}
AUTH_BASIC_PASSWORD: ${AUTH_BASIC_PASSWORD}
AUTH_SAML_CALLBACK_URL: ${AUTH_SAML_CALLBACK_URL}
AUTH_SAML_ENTRY_POINT_URL: ${AUTH_SAML_ENTRY_POINT_URL}
AUTH_SAML_ISSUER: ${AUTH_SAML_ISSUER}
@ -75,7 +77,7 @@ services:
environment:
API_BASE_URL: ${API_BASE_URL}
ports:
- 80:3000
- ${UI_PORT}:3000
volumes:
mysql_data:
driver: local

View file

@ -1,9 +1,48 @@
# Authentication
Parcelvoy utilizes SSO for authentication supporting both SAML and OpenID. There is no local authentication method available at this time.
Parcelvoy comes with a few different types of authentication out of the box:
- Basic
- SAML
- OpenID
## Setting up SAML
Whereas a lot of platforms will gate SSO as a luxury feature and charge extra for it (this is known as the ***SSO Tax***) we opted to go the opposite direction and lean in completely to SSO to make sure you understand that Parcelvoy takes your security seriously. SSO is not something that only Enterprise companies should have, but should be available at every level.
## Setting up OpenID
## Basic
Right out of the gate Parcelvoy is setup to use a simple ***Basic*** auth that allows for a single user that can be set inside of the environment variables. This is a limited form of auth as it does not allow for multiple users and is largely meant for evaluation purposes.
To change the credentials for basic auth, modify the following environment variables and restart your Parcelvoy instance.
```
AUTH_BASIC_USERNAME=parcelvoy
AUTH_BASIC_PASSWORD=password
```
## SAML
***Instructions coming soon.***
### Config
| key | type | required |
|--|--|--|
| AUTH_DRIVER | 'saml' | true |
| AUTH_SAML_CALLBACK_URL | string | true |
| AUTH_SAML_ENTRY_POINT_URL | string | true |
| AUTH_SAML_ISSUER | string | true |
| AUTH_SAML_CERT | string | true |
| AUTH_SAML_IS_AUTHN_SIGNED | boolean | false |
## OpenID
***Instructions coming soon.***
### Config
| key | type | required |
|--|--|--|
| AUTH_DRIVER | 'openid' | true |
| AUTH_OPENID_ISSUER_URL | string | true |
| AUTH_OPENID_CLIENT_ID | string | true |
| AUTH_OPENID_CLIENT_SECRET | string | true |
| AUTH_OPENID_REDIRECT_URI | string | true |
| AUTH_OPENID_DOMAIN_WHITELIST | string | true |
### Google Workspace
You can utilize either SAML or OpenID to connect to your Google Account. We'll be highlighting how to setup SAML as it is slightly easier than OpenID to configure.
***Instructions coming soon.***