Adds basic auth option

This commit is contained in:
Chris Anderson 2023-03-25 14:22:59 -05:00
parent 0701d6e374
commit 034abfd15c
15 changed files with 183 additions and 11 deletions

21
.env 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

1
.gitignore vendored
View file

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

View file

@ -12,4 +12,24 @@ 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.yaml` and `.env` file onto your machine and go!
```
mkdir parcelvoy && cd parcelvoy
wget https://raw.githubusercontent.com/parcelvoy/parcelvoy/master/{.env,docker-compose.yaml}
docker compose up -d # run the Docker container
```
Login to the web app at http://localhost:3000 by entering the default credentials found in your .env file.
```
AUTH_BASIC_USERNAME=parcelvoy
AUTH_BASIC_PASSWORD=password
```
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
- ${PORT}:3000
volumes:
mysql_data:
driver: local