From 76ed44b1615b56160477a94b5a4f5d0a608fdf17 Mon Sep 17 00:00:00 2001 From: Varakh Date: Fri, 26 Apr 2024 20:20:45 +0200 Subject: [PATCH] feat(auth): Add support for multiple basic auth credentials --- CHANGELOG.md | 8 +++-- README.md | 7 +++-- _doc/DEPLOYMENT.md | 8 ++--- server/app.go | 16 ++++++++-- server/constants_env.go | 9 ++++-- server/environment.go | 68 ++++++++++++++++++++++++++++++++++++----- 6 files changed, 95 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c818e19..71d3c19 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,10 +6,14 @@ Changes adhere to [semantic versioning](https://semver.org). > This is a major version upgrade. Other versions are incompatible with this release. -* Added mandatory `SECRET` environment variable to encrypt some data inside the database -* Switched to encrypting webhook tokens in database * Added _Actions_, a simple way to trigger notifications via [shoutrrr](https://containrrr.dev/shoutrrr) which supports secrets +* Added new auth mode which allows setting multiple basic auth credentials + * Added `AUTH_MODE` which can be one of `basic_single` (_default_) and `basic_credentials` + * For `basic_credentials`: added `BASIC_AUTH_CREDENTIALS` which can be used as list of `username1=password1,...` (comma separated) + * For `basic_single`: renamed `ADMIN_USER` and `ADMIN_PASSWORD` to `BASIC_AUTH_USER` and `BASIC_AUTH_PASSWORD` +* Added mandatory `SECRET` environment variable to encrypt some data inside the database * Switched to producing events only for _Updates_ +* Switched to encrypting webhook tokens in database * Adapted logging which defaults to JSON encoding * Updated dependencies diff --git a/README.md b/README.md index 790a1ff..5692f61 100644 --- a/README.md +++ b/README.md @@ -134,8 +134,11 @@ The following environment variables can be used to modify application behavior. |:------------------------------------|:--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|:-------------------------------------------------------------------------------------------------------------------------------------| | `SECRET` | A 32 character long secure random secret used for encrypting some data inside the database. When data has been created inside the database, the secret cannot be changed anymore, otherwise decryption fails. | Not set by default, you need to explicitly set it, e.g., generate via `openssl rand -hex 16` | | `TZ` | The time zone (**recommended** to set it properly, background tasks depend on it) | Defaults to `Europe/Berlin`, can be any time zone according to _tz database_ | -| `ADMIN_USER` | Admin user name for login | Not set by default, you need to explicitly set it to user name | -| `ADMIN_PASSWORD` | Admin password for login | Not set by default, you need to explicitly set it to a secure random | +| | | | +| `AUTH_MODE` | The auth mode. Possible values are `basic_single` and `basic_credentials` | Defaults to `basic_single` | +| `BASIC_AUTH_USER` | For auth mode `basic_single`: Username for login | Not set by default, you need to explicitly set it to user name | +| `BASIC_AUTH_PASSWORD` | For auth mode `basic_single`: User's password for login | Not set by default, you need to explicitly set it to a secure random | +| `BASIC_AUTH_CREDENTIALS` | For auth mode `basic_credentials`: list of comma separated credentials, e.g. `username1=password1,username2=password2` | Not set by default, you need to explicitly set it | | | | | | `DB_TYPE` | The database type (Postgres is **recommended**) | Defaults to `sqlite`, possible values are `sqlite` or `postgres` | | `DB_SQLITE_FILE` | Path to the SQLITE file | Defaults to `/upda/upda.db`, e.g. `~/.local/share/upda/upda.db` | diff --git a/_doc/DEPLOYMENT.md b/_doc/DEPLOYMENT.md index 3808c89..65a9d5c 100644 --- a/_doc/DEPLOYMENT.md +++ b/_doc/DEPLOYMENT.md @@ -58,8 +58,8 @@ services: - DB_POSTGRES_NAME=upda - DB_POSTGRES_USER=upda - DB_POSTGRES_PASSWORD=upda - - ADMIN_USER=admin - - ADMIN_PASSWORD=changeit + - BASIC_AUTH_USER=admin + - BASIC_AUTH_PASSWORD=changeit # generate 32 character long secret, e.g., with "openssl rand -hex 16" - SECRET=generated-secure-secret-32-chars restart: unless-stopped @@ -121,8 +121,8 @@ services: image: git.myservermanager.com/varakh/upda:latest environment: - TZ=Europe/Berlin - - ADMIN_USER=admin - - ADMIN_PASSWORD=changeit + - BASIC_AUTH_USER=admin + - BASIC_AUTH_PASSWORD=changeit # generate 32 character long secret, e.g., with "openssl rand -hex 16" - SECRET=generated-secure-secret-32-chars restart: unless-stopped diff --git a/server/app.go b/server/app.go index e7353d6..40c87c2 100644 --- a/server/app.go +++ b/server/app.go @@ -95,9 +95,19 @@ func Start() { apiPublicGroup.POST("/webhooks/:id", webhookInvocationHandler.execute) - apiAuthGroup := router.Group("/api/v1", gin.BasicAuth(gin.Accounts{ - env.authConfig.adminUser: env.authConfig.adminPassword, - })) + var authMethodHandler gin.HandlerFunc + + if authModeBasicSingle == env.authConfig.authMethod { + authMethodHandler = gin.BasicAuth(gin.Accounts{ + env.authConfig.basicAuthUser: env.authConfig.basicAuthPassword, + }) + } else if authModeBasicCredentials == env.authConfig.authMethod { + authMethodHandler = gin.BasicAuth(env.authConfig.basicAuthCredentials) + } else { + zap.L().Fatal("No valid auth mode found") + } + + apiAuthGroup := router.Group("/api/v1", authMethodHandler) apiAuthGroup.GET("/login", authHandler.login) diff --git a/server/constants_env.go b/server/constants_env.go index 3d30f52..231d272 100644 --- a/server/constants_env.go +++ b/server/constants_env.go @@ -16,8 +16,13 @@ const ( envTZ = "TZ" tzDefault = "Europe/Berlin" - envAdminUser = "ADMIN_USER" - envAdminPassword = "ADMIN_PASSWORD" + envAuthMode = "AUTH_MODE" + authModeDefault = authModeBasicSingle + authModeBasicSingle = "basic_single" + authModeBasicCredentials = "basic_credentials" + envBasicAuthUser = "BASIC_AUTH_USER" + envBasicAuthPassword = "BASIC_AUTH_PASSWORD" + envBasicAuthCredentials = "BASIC_AUTH_CREDENTIALS" envServerPort = "SERVER_PORT" envServerListen = "SERVER_LISTEN" diff --git a/server/environment.go b/server/environment.go index 5330d3e..db15894 100644 --- a/server/environment.go +++ b/server/environment.go @@ -16,6 +16,7 @@ import ( "os" "path/filepath" "strconv" + "strings" "time" ) @@ -38,8 +39,10 @@ type serverConfig struct { } type authConfig struct { - adminUser string - adminPassword string + authMethod string + basicAuthUser string + basicAuthPassword string + basicAuthCredentials map[string]string } type taskConfig struct { @@ -209,9 +212,25 @@ func bootstrapEnvironment() *Environment { corsAllowHeaders: []string{os.Getenv(envCorsAllowHeaders)}, } + authMode := os.Getenv(envAuthMode) + + if authMode != authModeBasicSingle && authMode != authModeBasicCredentials { + zap.L().Sugar().Fatalln("Invalid auth mode. Reason: must be one of ['basic_single','basic_credentials'") + } + authC := &authConfig{ - adminUser: os.Getenv(envAdminUser), - adminPassword: os.Getenv(envAdminPassword), + authMethod: authMode, + } + + if authModeBasicSingle == authMode { + failIfEnvKeyNotPresent(envBasicAuthUser) + failIfEnvKeyNotPresent(envBasicAuthPassword) + authC.basicAuthUser = os.Getenv(envBasicAuthUser) + authC.basicAuthPassword = os.Getenv(envBasicAuthPassword) + } + if authModeBasicCredentials == authMode { + failIfEnvKeyNotPresent(envBasicAuthCredentials) + authC.basicAuthCredentials = parseBasicAuthCredentials(envBasicAuthCredentials) } // task config @@ -390,13 +409,14 @@ func bootstrapEnvironment() *Environment { } func bootstrapFromEnvironmentAndValidate() { + failIfEnvKeyNotPresent(envSecret) + + // auth mode + setEnvKeyDefault(envAuthMode, authModeDefault) + // app setEnvKeyDefault(envTZ, tzDefault) - failIfEnvKeyNotPresent(envSecret) - failIfEnvKeyNotPresent(envAdminUser) - failIfEnvKeyNotPresent(envAdminPassword) - // webhook setEnvKeyDefault(envWebhooksTokenLength, webhooksTokenLengthDefault) @@ -467,3 +487,35 @@ func setEnvKeyDefault(key string, defaultValue string) { zap.L().Sugar().Infof("Set '%s' to '%s'", key, defaultValue) } } + +func parseBasicAuthCredentials(envProperty string) map[string]string { + if envProperty == "" { + zap.L().Sugar().Fatalln("Invalid env for parsing basic auth credentials") + } + credentialsFromEnv := os.Getenv(envProperty) + + var credentials []string + credentials = strings.Split(credentialsFromEnv, ",") + + basicAuthCredentials := make(map[string]string) + + for _, c := range credentials { + pair := strings.Split(c, "=") + + if len(pair) != 2 { + zap.L().Sugar().Fatalln("Invalid basic auth credentials. Reason: credentials must be specified with the = separator per credential entry") + } + + if pair[0] == "" { + zap.L().Sugar().Fatalln("Invalid basic auth credentials. Reason: username must not be blank") + } + + if pair[1] == "" { + zap.L().Sugar().Fatalln("Invalid basic auth credentials. Reason: password must not be blank") + } + + basicAuthCredentials[pair[0]] = pair[1] + } + + return basicAuthCredentials +}