feat: Implement basic auth for applications

This commit is contained in:
Pascal Klesse
2023-05-15 09:27:49 +02:00
parent f3beb5d8db
commit d14ca724e9
9 changed files with 1861 additions and 1720 deletions

11
.vscode/settings.json vendored
View File

@@ -18,5 +18,14 @@
"ts", "ts",
"json" "json"
], ],
"i18n-ally.extract.autoDetect": true "i18n-ally.extract.autoDetect": true,
"files.exclude": {
"**/.git": true,
"**/.svn": true,
"**/.hg": true,
"**/CVS": true,
"**/.DS_Store": true,
"**/Thumbs.db": true
},
"hide-files.files": []
} }

View File

@@ -0,0 +1,29 @@
-- AlterTable
ALTER TABLE "Application" ADD COLUMN "basicAuthPw" TEXT;
ALTER TABLE "Application" ADD COLUMN "basicAuthUser" TEXT;
-- RedefineTables
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_ApplicationSettings" (
"id" TEXT NOT NULL PRIMARY KEY,
"applicationId" TEXT NOT NULL,
"dualCerts" BOOLEAN NOT NULL DEFAULT false,
"debug" BOOLEAN NOT NULL DEFAULT false,
"previews" BOOLEAN NOT NULL DEFAULT false,
"autodeploy" BOOLEAN NOT NULL DEFAULT true,
"isBot" BOOLEAN NOT NULL DEFAULT false,
"isPublicRepository" BOOLEAN NOT NULL DEFAULT false,
"isDBBranching" BOOLEAN NOT NULL DEFAULT false,
"isCustomSSL" BOOLEAN NOT NULL DEFAULT false,
"isHttp2" BOOLEAN NOT NULL DEFAULT false,
"basicAuth" BOOLEAN NOT NULL DEFAULT false,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "ApplicationSettings_applicationId_fkey" FOREIGN KEY ("applicationId") REFERENCES "Application" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
INSERT INTO "new_ApplicationSettings" ("applicationId", "autodeploy", "createdAt", "debug", "dualCerts", "id", "isBot", "isCustomSSL", "isDBBranching", "isHttp2", "isPublicRepository", "previews", "updatedAt") SELECT "applicationId", "autodeploy", "createdAt", "debug", "dualCerts", "id", "isBot", "isCustomSSL", "isDBBranching", "isHttp2", "isPublicRepository", "previews", "updatedAt" FROM "ApplicationSettings";
DROP TABLE "ApplicationSettings";
ALTER TABLE "new_ApplicationSettings" RENAME TO "ApplicationSettings";
CREATE UNIQUE INDEX "ApplicationSettings_applicationId_key" ON "ApplicationSettings"("applicationId");
PRAGMA foreign_key_check;
PRAGMA foreign_keys=ON;

View File

@@ -135,6 +135,8 @@ model Application {
dockerRegistryId String? dockerRegistryId String?
dockerRegistryImageName String? dockerRegistryImageName String?
simpleDockerfile String? simpleDockerfile String?
basicAuthUser String?
basicAuthPw String?
persistentStorage ApplicationPersistentStorage[] persistentStorage ApplicationPersistentStorage[]
secrets Secret[] secrets Secret[]
@@ -187,6 +189,7 @@ model ApplicationSettings {
isDBBranching Boolean @default(false) isDBBranching Boolean @default(false)
isCustomSSL Boolean @default(false) isCustomSSL Boolean @default(false)
isHttp2 Boolean @default(false) isHttp2 Boolean @default(false)
basicAuth Boolean @default(false)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
application Application @relation(fields: [applicationId], references: [id]) application Application @relation(fields: [applicationId], references: [id])

View File

@@ -504,12 +504,17 @@ export async function saveApplicationSettings(
isBot, isBot,
isDBBranching, isDBBranching,
isCustomSSL, isCustomSSL,
isHttp2 isHttp2,
basicAuth,
basicAuthUser,
basicAuthPw
} = request.body; } = request.body;
await prisma.application.update({ await prisma.application.update({
where: { id }, where: { id },
data: { data: {
fqdn: isBot ? null : undefined, fqdn: isBot ? null : undefined,
basicAuthUser,
basicAuthPw,
settings: { settings: {
update: { update: {
debug, debug,
@@ -519,7 +524,8 @@ export async function saveApplicationSettings(
isBot, isBot,
isDBBranching, isDBBranching,
isCustomSSL, isCustomSSL,
isHttp2 isHttp2,
basicAuth,
} }
} }
}, },

View File

@@ -43,6 +43,9 @@ export interface SaveApplicationSettings extends OnlyId {
isDBBranching: boolean; isDBBranching: boolean;
isCustomSSL: boolean; isCustomSSL: boolean;
isHttp2: boolean; isHttp2: boolean;
basicAuth: boolean;
basicAuthUser: string;
basicAuthPw: string;
}; };
} }
export interface DeleteApplication extends OnlyId { export interface DeleteApplication extends OnlyId {

View File

@@ -1,5 +1,5 @@
import { FastifyRequest } from 'fastify'; import { FastifyRequest } from 'fastify';
import { errorHandler, getDomain, isDev, prisma, executeCommand } from '../../../lib/common'; import { errorHandler, executeCommand, getDomain, isDev, prisma } from '../../../lib/common';
import { getTemplates } from '../../../lib/services'; import { getTemplates } from '../../../lib/services';
import { OnlyId } from '../../../types'; import { OnlyId } from '../../../types';
import { parseAndFindServiceTemplates } from '../../api/v1/services/handlers'; import { parseAndFindServiceTemplates } from '../../api/v1/services/handlers';
@@ -48,20 +48,30 @@ function generateRouters(
isWWW, isWWW,
isDualCerts, isDualCerts,
isCustomSSL, isCustomSSL,
isHttp2 = false isHttp2 = false,
basicAuth = false,
basicAuthUser = '',
basicAuthPw = ''
) { ) {
let rule = `Host(\`${nakedDomain}\`)${pathPrefix ? ` && PathPrefix(\`${pathPrefix}\`)` : ''}`; const rule = `Host(\`${nakedDomain}\`)${pathPrefix ? ` && PathPrefix(\`${pathPrefix}\`)` : ''}`;
let ruleWWW = `Host(\`www.${nakedDomain}\`)${ const ruleWWW = `Host(\`www.${nakedDomain}\`)${
pathPrefix ? ` && PathPrefix(\`${pathPrefix}\`)` : '' pathPrefix ? ` && PathPrefix(\`${pathPrefix}\`)` : ''
}`; }`;
let http: any = {
const httpBasicAuth: any = {
basicauth: {
users: [Buffer.from(basicAuthUser + ':' + basicAuthPw).toString('base64')]
}
};
const http: any = {
entrypoints: ['web'], entrypoints: ['web'],
rule, rule,
service: `${serviceId}`, service: `${serviceId}`,
priority: 2, priority: 2,
middlewares: [] middlewares: []
}; };
let https: any = { const https: any = {
entrypoints: ['websecure'], entrypoints: ['websecure'],
rule, rule,
service: `${serviceId}`, service: `${serviceId}`,
@@ -71,14 +81,14 @@ function generateRouters(
}, },
middlewares: [] middlewares: []
}; };
let httpWWW: any = { const httpWWW: any = {
entrypoints: ['web'], entrypoints: ['web'],
rule: ruleWWW, rule: ruleWWW,
service: `${serviceId}`, service: `${serviceId}`,
priority: 2, priority: 2,
middlewares: [] middlewares: []
}; };
let httpsWWW: any = { const httpsWWW: any = {
entrypoints: ['websecure'], entrypoints: ['websecure'],
rule: ruleWWW, rule: ruleWWW,
service: `${serviceId}`, service: `${serviceId}`,
@@ -97,6 +107,10 @@ function generateRouters(
httpsWWW.middlewares.push('redirect-to-non-www'); httpsWWW.middlewares.push('redirect-to-non-www');
delete https.tls; delete https.tls;
delete httpsWWW.tls; delete httpsWWW.tls;
if (basicAuth) {
http.middlewares.push(`${serviceId}-${pathPrefix}-basic-auth`);
}
} }
// 3. http + www only // 3. http + www only
@@ -108,6 +122,10 @@ function generateRouters(
https.middlewares.push('redirect-to-www'); https.middlewares.push('redirect-to-www');
delete https.tls; delete https.tls;
delete httpsWWW.tls; delete httpsWWW.tls;
if (basicAuth) {
httpWWW.middlewares.push(`${serviceId}-${pathPrefix}-basic-auth`);
}
} }
// 5. https + non-www only // 5. https + non-www only
if (isHttps && !isWWW) { if (isHttps && !isWWW) {
@@ -136,6 +154,10 @@ function generateRouters(
}; };
} }
} }
if (basicAuth) {
https.middlewares.push(`${serviceId}-${pathPrefix}-basic-auth`);
}
} }
// 6. https + www only // 6. https + www only
if (isHttps && isWWW) { if (isHttps && isWWW) {
@@ -145,6 +167,11 @@ function generateRouters(
http.middlewares.push('redirect-to-www'); http.middlewares.push('redirect-to-www');
https.middlewares.push('redirect-to-www'); https.middlewares.push('redirect-to-www');
} }
if (basicAuth) {
httpsWWW.middlewares.push(`${serviceId}-${pathPrefix}-basic-auth`);
}
if (isCustomSSL) { if (isCustomSSL) {
if (isDualCerts) { if (isDualCerts) {
https.tls = true; https.tls = true;
@@ -166,23 +193,23 @@ function generateRouters(
} }
} }
if (isHttp2) { if (isHttp2) {
let http2 = { const http2 = {
...http, ...http,
service: `${serviceId}-http2`, service: `${serviceId}-http2`,
rule: `${rule} && HeadersRegexp(\`Content-Type\`, \`application/grpc*\`)` rule: `${rule} && HeadersRegexp(\`Content-Type\`, \`application/grpc*\`)`
}; };
let http2WWW = { const http2WWW = {
...httpWWW, ...httpWWW,
service: `${serviceId}-http2`, service: `${serviceId}-http2`,
rule: `${rule} && HeadersRegexp(\`Content-Type\`, \`application/grpc*\`)` rule: `${rule} && HeadersRegexp(\`Content-Type\`, \`application/grpc*\`)`
}; };
let https2 = { const https2 = {
...https, ...https,
service: `${serviceId}-http2`, service: `${serviceId}-http2`,
rule: `${rule} && HeadersRegexp(\`Content-Type\`, \`application/grpc*\`)` rule: `${rule} && HeadersRegexp(\`Content-Type\`, \`application/grpc*\`)`
}; };
let https2WWW = { const https2WWW = {
...httpsWWW, ...httpsWWW,
service: `${serviceId}-http2`, service: `${serviceId}-http2`,
rule: `${rule} && HeadersRegexp(\`Content-Type\`, \`application/grpc*\`)` rule: `${rule} && HeadersRegexp(\`Content-Type\`, \`application/grpc*\`)`
@@ -198,14 +225,21 @@ function generateRouters(
[`${serviceId}-${pathPrefix}-secure-www-http2`]: { ...https2WWW } [`${serviceId}-${pathPrefix}-secure-www-http2`]: { ...https2WWW }
}; };
} }
return {
const result = {
[`${serviceId}-${pathPrefix}`]: { ...http }, [`${serviceId}-${pathPrefix}`]: { ...http },
[`${serviceId}-${pathPrefix}-secure`]: { ...https }, [`${serviceId}-${pathPrefix}-secure`]: { ...https },
[`${serviceId}-${pathPrefix}-www`]: { ...httpWWW }, [`${serviceId}-${pathPrefix}-www`]: { ...httpWWW },
[`${serviceId}-${pathPrefix}-secure-www`]: { ...httpsWWW } [`${serviceId}-${pathPrefix}-secure-www`]: { ...httpsWWW }
}; };
if (basicAuth) {
result[`${serviceId}-${pathPrefix}-basic-auth`] = { ...httpBasicAuth };
}
return result;
} }
export async function proxyConfiguration(request: FastifyRequest<OnlyId>, remote: boolean = false) { export async function proxyConfiguration(request: FastifyRequest<OnlyId>, remote = false) {
const traefik = { const traefik = {
tls: { tls: {
certificates: [] certificates: []
@@ -298,7 +332,7 @@ export async function proxyConfiguration(request: FastifyRequest<OnlyId>, remote
}); });
} }
let parsedCertificates = []; const parsedCertificates = [];
for (const certificate of certificates) { for (const certificate of certificates) {
parsedCertificates.push({ parsedCertificates.push({
certFile: `${sslpath}/${certificate.id}-cert.pem`, certFile: `${sslpath}/${certificate.id}-cert.pem`,
@@ -369,7 +403,9 @@ export async function proxyConfiguration(request: FastifyRequest<OnlyId>, remote
dockerComposeConfiguration, dockerComposeConfiguration,
destinationDocker, destinationDocker,
destinationDockerId, destinationDockerId,
settings settings,
basicAuthUser,
basicAuthPw
} = application; } = application;
if (!destinationDockerId) { if (!destinationDockerId) {
continue; continue;
@@ -424,7 +460,7 @@ export async function proxyConfiguration(request: FastifyRequest<OnlyId>, remote
} }
continue; continue;
} }
const { previews, dualCerts, isCustomSSL, isHttp2 } = settings; const { previews, dualCerts, isCustomSSL, isHttp2, basicAuth } = settings;
const { network, id: dockerId } = destinationDocker; const { network, id: dockerId } = destinationDocker;
if (!fqdn) { if (!fqdn) {
continue; continue;
@@ -446,7 +482,10 @@ export async function proxyConfiguration(request: FastifyRequest<OnlyId>, remote
isWWW, isWWW,
dualCerts, dualCerts,
isCustomSSL, isCustomSSL,
isHttp2 isHttp2,
basicAuth,
basicAuthUser,
basicAuthPw
) )
}; };
traefik.http.services = { traefik.http.services = {
@@ -482,7 +521,11 @@ export async function proxyConfiguration(request: FastifyRequest<OnlyId>, remote
isHttps, isHttps,
isWWW, isWWW,
dualCerts, dualCerts,
isCustomSSL isCustomSSL,
false,
basicAuth,
basicAuthUser,
basicAuthPw
) )
}; };
traefik.http.services = { traefik.http.services = {
@@ -542,7 +585,7 @@ export async function proxyConfiguration(request: FastifyRequest<OnlyId>, remote
if (isDomainAndProxyConfiguration.length > 0) { if (isDomainAndProxyConfiguration.length > 0) {
const template: any = await parseAndFindServiceTemplates(service, null, true); const template: any = await parseAndFindServiceTemplates(service, null, true);
const { proxy } = template.services[oneService] || found.services[oneService]; const { proxy } = template.services[oneService] || found.services[oneService];
for (let configuration of proxy) { for (const configuration of proxy) {
if (configuration.hostPort) { if (configuration.hostPort) {
continue; continue;
} }

View File

@@ -196,6 +196,9 @@
"domain_fqdn": "Domain (FQDN)", "domain_fqdn": "Domain (FQDN)",
"https_explainer": "If you specify <span class='text-settings '>https</span>, the application will be accessible only over https. SSL certificate will be generated for you.<br>If you specify <span class='text-settings '>www</span>, the application will be redirected (302) from non-www and vice versa.<br><br>To modify the domain, you must first stop the application.<br><br><span class='text-white '>You must set your DNS to point to the server IP in advance.</span>", "https_explainer": "If you specify <span class='text-settings '>https</span>, the application will be accessible only over https. SSL certificate will be generated for you.<br>If you specify <span class='text-settings '>www</span>, the application will be redirected (302) from non-www and vice versa.<br><br>To modify the domain, you must first stop the application.<br><br><span class='text-white '>You must set your DNS to point to the server IP in advance.</span>",
"ssl_www_and_non_www": "Generate SSL for www and non-www?", "ssl_www_and_non_www": "Generate SSL for www and non-www?",
"basic_auth": "Basic Auth",
"basic_auth_user": "User",
"basic_auth_pw": "Password",
"ssl_explainer": "It will generate certificates for both www and non-www. <br>You need to have <span class=' text-settings'>both DNS entries</span> set in advance.<br><br>Useful if you expect to have visitors on both.", "ssl_explainer": "It will generate certificates for both www and non-www. <br>You need to have <span class=' text-settings'>both DNS entries</span> set in advance.<br><br>Useful if you expect to have visitors on both.",
"install_command": "Install Command", "install_command": "Install Command",
"build_command": "Build Command", "build_command": "Build Command",

View File

@@ -29,27 +29,27 @@
export let application: any; export let application: any;
export let settings: any; export let settings: any;
import yaml from 'js-yaml'; import { goto } from '$app/navigation';
import { page } from '$app/stores'; import { page } from '$app/stores';
import { onMount } from 'svelte';
import Select from 'svelte-select';
import { get, getAPIUrl, post } from '$lib/api'; import { get, getAPIUrl, post } from '$lib/api';
import cuid from 'cuid'; import { errorNotification, getDomain, notNodeDeployments, staticDeployments } from '$lib/common';
import Beta from '$lib/components/Beta.svelte';
import Explainer from '$lib/components/Explainer.svelte';
import Setting from '$lib/components/Setting.svelte';
import { import {
addToast, addToast,
appSession, appSession,
checkIfDeploymentEnabledApplications, checkIfDeploymentEnabledApplications,
setLocation, features,
status,
isDeploymentEnabled, isDeploymentEnabled,
features setLocation,
status
} from '$lib/store'; } from '$lib/store';
import { t } from '$lib/translations'; import { t } from '$lib/translations';
import { errorNotification, getDomain, notNodeDeployments, staticDeployments } from '$lib/common'; import cuid from 'cuid';
import Setting from '$lib/components/Setting.svelte'; import yaml from 'js-yaml';
import Explainer from '$lib/components/Explainer.svelte'; import { onMount } from 'svelte';
import { goto } from '$app/navigation'; import Select from 'svelte-select';
import Beta from '$lib/components/Beta.svelte';
import { saveForm } from './utils'; import { saveForm } from './utils';
const { id } = $page.params; const { id } = $page.params;
@@ -77,6 +77,7 @@
let isCustomSSL = application.settings?.isCustomSSL; let isCustomSSL = application.settings?.isCustomSSL;
let autodeploy = application.settings?.autodeploy; let autodeploy = application.settings?.autodeploy;
let isBot = application.settings?.isBot; let isBot = application.settings?.isBot;
let basicAuth = application.settings?.basicAuth;
let isDBBranching = application.settings?.isDBBranching; let isDBBranching = application.settings?.isDBBranching;
let htmlUrl = application.gitSource?.htmlUrl; let htmlUrl = application.gitSource?.htmlUrl;
let isHttp2 = application.settings.isHttp2; let isHttp2 = application.settings.isHttp2;
@@ -186,6 +187,10 @@
if (name === 'isCustomSSL') { if (name === 'isCustomSSL') {
isCustomSSL = !isCustomSSL; isCustomSSL = !isCustomSSL;
} }
if (name === 'basicAuth') {
basicAuth = !basicAuth;
// TODO: Set user and password
}
if (name === 'isBot') { if (name === 'isBot') {
if ($status.application.overallStatus !== 'stopped') return; if ($status.application.overallStatus !== 'stopped') return;
isBot = !isBot; isBot = !isBot;
@@ -210,7 +215,10 @@
isCustomSSL, isCustomSSL,
isHttp2, isHttp2,
branch: application.branch, branch: application.branch,
projectId: application.projectId projectId: application.projectId,
basicAuth,
basicAuthUser: application.basicAuthUser,
basicAuthPw: application.basicAuthPw
}); });
return addToast({ return addToast({
message: $t('application.settings_saved'), message: $t('application.settings_saved'),
@@ -232,6 +240,9 @@
if (name === 'isBot') { if (name === 'isBot') {
isBot = !isBot; isBot = !isBot;
} }
if (name === 'basicAuth') {
basicAuth = !basicAuth;
}
if (name === 'isDBBranching') { if (name === 'isDBBranching') {
isDBBranching = !isDBBranching; isDBBranching = !isDBBranching;
} }
@@ -498,7 +509,7 @@
<div class="title font-bold pb-3">General</div> <div class="title font-bold pb-3">General</div>
{#if $appSession.isAdmin} {#if $appSession.isAdmin}
<button <button
class="btn btn-sm btn-primary" class="btn btn-sm btn-primary"
type="submit" type="submit"
class:loading={loading.save} class:loading={loading.save}
class:bg-orange-600={forceSave} class:bg-orange-600={forceSave}
@@ -751,7 +762,56 @@
on:click={() => !isDisabled && changeSettings('dualCerts')} on:click={() => !isDisabled && changeSettings('dualCerts')}
/> />
</div> </div>
<div class="grid grid-cols-2 items-center">
<Setting
id="basicAuth"
dataTooltip={$t('forms.must_be_stopped_to_modify')}
disabled={isDisabled}
isCenter={false}
bind:setting={basicAuth}
title={$t('application.basic_auth')}
description="Activate basic authentication for your application. <br>Useful if you want to protect your application with a password. <br><br>Use the <span class='font-bold text-settings'>username</span> and <span class='font-bold text-settings'>password</span> fields to set the credentials."
on:click={() => !isDisabled && changeSettings('basicAuth')}
/>
</div>
{#if basicAuth}
<div class="grid grid-cols-2 items-center">
<label for="basicAuthUser">{$t('application.basic_auth_user')}</label>
<input
bind:this={fqdnEl}
class="w-full"
required={!application.settings?.basicAuth}
readonly={isDisabled}
disabled={isDisabled}
name="basicAuthUser"
id="basicAuthUser"
class:border={!application.settings?.basicAuth && !application.basicAuthUser}
class:border-red-500={!application.settings?.basicAuth &&
!application.basicAuthUser}
bind:value={application.basicAuthUser}
placeholder="eg: admin"
/>
</div>
<div class="grid grid-cols-2 items-center">
<label for="basicAuthPw">{$t('application.basic_auth_pw')}</label>
<input
bind:this={fqdnEl}
class="w-full"
required={!application.settings?.basicAuth}
readonly={isDisabled}
disabled={isDisabled}
name="basicAuthPw"
id="basicAuthPw"
class:border={!application.settings?.basicAuth && !application.basicAuthPw}
class:border-red-500={!application.settings?.basicAuth && !application.basicAuthPw}
bind:value={application.basicAuthPw}
placeholder="**********"
/>
</div>
{/if}
{#if isHttps && application.buildPack !== 'compose'} {#if isHttps && application.buildPack !== 'compose'}
<div class="grid grid-cols-2 items-center pb-4"> <div class="grid grid-cols-2 items-center pb-4">
<Setting <Setting
@@ -782,7 +842,7 @@
</div> </div>
<div class="grid grid-flow-row gap-2 px-4 pr-5"> <div class="grid grid-flow-row gap-2 px-4 pr-5">
<div class="grid grid-cols-2 items-center pt-4"> <div class="grid grid-cols-2 items-center pt-4">
<label for="simpleDockerfile">Dockerfile</label> <label for="simpleDockerfile">Dockerfile</label>
<div class="flex gap-2"> <div class="flex gap-2">
<textarea <textarea

3347
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff