feat(auth): Add support for multiple basic auth credentials (#25)

Reviewed-on: #25
Co-authored-by: Varakh <varakh@varakh.de>
Co-committed-by: Varakh <varakh@varakh.de>
This commit is contained in:
Varakh 2024-04-26 18:24:47 +00:00
parent f231e66e7c
commit faffad851c
6 changed files with 95 additions and 21 deletions

View file

@ -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

View file

@ -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 `<XDG_DATA_DIR>/upda/upda.db`, e.g. `~/.local/share/upda/upda.db` |

View file

@ -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

View file

@ -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)

View file

@ -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"

View file

@ -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
}