diff --git a/.env.example b/.env.example index 9e6630fe..8ed420dc 100644 --- a/.env.example +++ b/.env.example @@ -4,10 +4,7 @@ ENV_APP_ENV_TYPE=local ENV_APP_LOG_LEVEL=debug ENV_APP_LOGS_DIR="./storage/logs/logs_%s.log" ENV_APP_LOGS_DATE_FORMAT="2006_02_01" - -# --- Auth -ENV_APP_TOKEN_PUBLIC="foo" -ENV_APP_TOKEN_PRIVATE="bar" +ENV_APP_MASTER_KEY= # --- DB ENV_DB_USER_NAME="gus" @@ -24,3 +21,10 @@ CADDY_LOGS_PATH="./storage/logs/caddy" # --- Docker (Local envs) ENV_DOCKER_USER="gocanto" ENV_DOCKER_USER_GROUP="ggroup" + +# --- Testing Token +# These variables are not intended to be used in production, but in the console interface (option: 3). +# This procedure facilitates the signatures creation for local testing/development. +# For more info, please see: cli/main.go +ENV_LOCAL_TOKEN_ACCOUNT= +ENV_LOCAL_TOKEN_SECRET= diff --git a/.env.prod.example b/.env.prod.example index 95bc4727..ebbf82b7 100644 --- a/.env.prod.example +++ b/.env.prod.example @@ -5,10 +5,6 @@ ENV_APP_LOG_LEVEL=debug ENV_APP_LOGS_DIR="./storage/logs/logs_%s.log" ENV_APP_LOGS_DATE_FORMAT="2006_02_01" -# --- Auth -ENV_APP_TOKEN_PUBLIC="" -ENV_APP_TOKEN_PRIVATE="" - # --- DB ENV_DB_PORT= ENV_DB_HOST= diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 3a977b78..15ffd69d 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -81,7 +81,19 @@ jobs: docker pull ghcr.io/oullin/oullin_api:$IMAGE_TAG docker pull ghcr.io/oullin/oullin_proxy:$IMAGE_TAG + echo "----- Images before re-tag -----" + docker images | grep api-api + echo "-------------------------------" + + echo "🏷️ Retagging for Compose…" + docker tag ghcr.io/oullin/oullin_api:$IMAGE_TAG api-api:latest + docker tag ghcr.io/oullin/oullin_proxy:$IMAGE_TAG api-caddy_prod:latest + + echo "----- Images after re-tag -----" + docker images | grep api-api + echo "-------------------------------" + echo "🧹 Pruning old, unused Docker images ..." - docker image prune -af + docker image prune -f echo "✅ Latest images pulled successfully to VPS!" diff --git a/boost/app.go b/boost/app.go index 903fb40c..485e40c8 100644 --- a/boost/app.go +++ b/boost/app.go @@ -1,9 +1,12 @@ package boost import ( + "fmt" "github.com/oullin/database" + "github.com/oullin/database/repository" "github.com/oullin/env" "github.com/oullin/pkg" + "github.com/oullin/pkg/auth" "github.com/oullin/pkg/http/middleware" "github.com/oullin/pkg/llogs" baseHttp "net/http" @@ -18,31 +21,43 @@ type App struct { db *database.Connection } -func MakeApp(env *env.Environment, validator *pkg.Validator) *App { +func MakeApp(env *env.Environment, validator *pkg.Validator) (*App, error) { + tokenHandler, err := auth.MakeTokensHandler( + []byte(env.App.MasterKey), + ) + + if err != nil { + return nil, fmt.Errorf("bootstrapping error > could not create a token handler: %w", err) + } + + db := MakeDbConnection(env) + app := App{ env: env, validator: validator, logs: MakeLogs(env), sentry: MakeSentry(env), - db: MakeDbConnection(env), + db: db, } router := Router{ Env: env, Mux: baseHttp.NewServeMux(), Pipeline: middleware.Pipeline{ - Env: env, + Env: env, + ApiKeys: &repository.ApiKeys{DB: db}, + TokenHandler: tokenHandler, }, } app.SetRouter(router) - return &app + return &app, nil } func (a *App) Boot() { - if a.router == nil { - panic("Router is not set") + if a == nil || a.router == nil { + panic("bootstrapping error > Invalid setup") } router := *a.router diff --git a/boost/factory.go b/boost/factory.go index 09f74044..7d5dab3f 100644 --- a/boost/factory.go +++ b/boost/factory.go @@ -6,7 +6,6 @@ import ( "github.com/oullin/database" "github.com/oullin/env" "github.com/oullin/pkg" - "github.com/oullin/pkg/auth" "github.com/oullin/pkg/llogs" "log" "strconv" @@ -60,15 +59,10 @@ func MakeEnv(validate *pkg.Validator) *env.Environment { port, _ := strconv.Atoi(env.GetEnvVar("ENV_DB_PORT")) - token := auth.Token{ - Public: env.GetEnvVar("ENV_APP_TOKEN_PUBLIC"), - Private: env.GetEnvVar("ENV_APP_TOKEN_PRIVATE"), - } - app := env.AppEnvironment{ - Name: env.GetEnvVar("ENV_APP_NAME"), - Type: env.GetEnvVar("ENV_APP_ENV_TYPE"), - Credentials: token, + Name: env.GetEnvVar("ENV_APP_NAME"), + Type: env.GetEnvVar("ENV_APP_ENV_TYPE"), + MasterKey: env.GetEnvVar("ENV_APP_MASTER_KEY"), } db := env.DBEnvironment{ @@ -106,10 +100,6 @@ func MakeEnv(validate *pkg.Validator) *env.Environment { panic(errorSuffix + "invalid [Sql] model: " + validate.GetErrorsAsJason()) } - if _, err := validate.Rejects(token); err != nil { - panic(errorSuffix + "invalid [token] model: " + validate.GetErrorsAsJason()) - } - if _, err := validate.Rejects(logsCreds); err != nil { panic(errorSuffix + "invalid [logs Creds] model: " + validate.GetErrorsAsJason()) } diff --git a/boost/router.go b/boost/router.go index 98d0d98c..8433d7fb 100644 --- a/boost/router.go +++ b/boost/router.go @@ -16,13 +16,13 @@ type Router struct { func (r *Router) PipelineFor(apiHandler http.ApiHandler) baseHttp.HandlerFunc { tokenMiddleware := middleware.MakeTokenMiddleware( - r.Env.App.Credentials, + r.Pipeline.TokenHandler, + r.Pipeline.ApiKeys, ) return http.MakeApiHandler( r.Pipeline.Chain( apiHandler, - middleware.UsernameCheck, tokenMiddleware.Handle, ), ) diff --git a/caddy/Caddyfile.prod b/caddy/Caddyfile.prod index ca41dd7e..8a1fc153 100644 --- a/caddy/Caddyfile.prod +++ b/caddy/Caddyfile.prod @@ -2,34 +2,50 @@ # Caddy will automatically provision a Let's Encrypt certificate. oullin.io { - # Enable compression to reduce bandwidth usage. - encode gzip zstd - - # Add security-related headers to protect against common attacks. - header { - # Enable HSTS to ensure browsers only connect via HTTPS. - Strict-Transport-Security "max-age=31536000;" - # Prevent clickjacking attacks. - X-Frame-Options "SAMEORIGIN" - # Prevent content type sniffing. - X-Content-Type-Options "nosniff" - # Enhances user privacy. - Referrer-Policy "strict-origin-when-cross-origin" - } - - log { + # Enable compression to reduce bandwidth usage. + encode gzip zstd + + # Add security-related headers to protect against common attacks. + header { + # Enable HSTS to ensure browsers only connect via HTTPS. + Strict-Transport-Security "max-age=31536000;" + # Prevent clickjacking attacks. + X-Frame-Options "SAMEORIGIN" + # Prevent content type sniffing. + X-Content-Type-Options "nosniff" + # Enhances user privacy. + Referrer-Policy "strict-origin-when-cross-origin" + } + + log { output file /var/log/caddy/oullin.io.log { - roll_size 10mb # Rotate logs after they reach 10MB - roll_keep 5 # Keep the last 5 rotated log files + roll_size 10mb # Rotate logs after they reach 10MB + roll_keep 5 # Keep the last 5 rotated log files } format json } - # Reverse proxy all requests to the Go application service. - # 'api' is the service name defined in docker-compose.yml. - reverse_proxy api:8080 { - # Set timeouts to prevent slow backends from holding up resources. + # Reverse-proxy all requests to the Go API, forwarding Host + auth headers + reverse_proxy { + # Tell Caddy which upstream to send to + to api:8080 + + # Preserve the original Host header + header_up Host {host} + + # Forward the client-sent auth headers + header_up X-API-Username {http.request.header.X-API-Username} + header_up X-API-Key {http.request.header.X-API-Key} + header_up X-API-Signature {http.request.header.X-API-Signature} + + # Todo: Remove! + # *** DEBUG: echo back to client what Caddy actually saw *** + header_down X-Debug-Username {http.request.header.X-API-Username} + header_down X-Debug-Key {http.request.header.X-API-Key} + header_down X-Debug-Signature {http.request.header.X-API-Signature} + + # Transport timeouts transport http { dial_timeout 10s response_header_timeout 30s diff --git a/cli/accounts/factory.go b/cli/accounts/factory.go new file mode 100644 index 00000000..8f9752fb --- /dev/null +++ b/cli/accounts/factory.go @@ -0,0 +1,33 @@ +package accounts + +import ( + "fmt" + "github.com/oullin/database" + "github.com/oullin/database/repository" + "github.com/oullin/env" + "github.com/oullin/pkg/auth" +) + +type Handler struct { + IsDebugging bool + Env *env.Environment + Tokens *repository.ApiKeys + TokenHandler *auth.TokenHandler +} + +func MakeHandler(db *database.Connection, env *env.Environment) (*Handler, error) { + tokenHandler, err := auth.MakeTokensHandler( + []byte(env.App.MasterKey), + ) + + if err != nil { + return nil, fmt.Errorf("failed to make token handler: %v", err) + } + + return &Handler{ + Env: env, + IsDebugging: false, + Tokens: &repository.ApiKeys{DB: db}, + TokenHandler: tokenHandler, + }, nil +} diff --git a/cli/accounts/handler.go b/cli/accounts/handler.go new file mode 100644 index 00000000..cdd2e4f3 --- /dev/null +++ b/cli/accounts/handler.go @@ -0,0 +1,90 @@ +package accounts + +import ( + "fmt" + "github.com/oullin/database" + "github.com/oullin/pkg/auth" + "github.com/oullin/pkg/cli" +) + +func (h Handler) CreateAccount(accountName string) error { + token, err := h.TokenHandler.SetupNewAccount(accountName) + + if err != nil { + return fmt.Errorf("failed to create the given account [%s] tokens pair: %v", accountName, err) + } + + _, err = h.Tokens.Create(database.APIKeyAttr{ + AccountName: token.AccountName, + SecretKey: token.EncryptedSecretKey, + PublicKey: token.EncryptedPublicKey, + }) + + if err != nil { + return fmt.Errorf("failed to create account [%s]: %v", accountName, err) + } + + cli.Successln("Account created successfully.\n") + + return nil +} + +func (h Handler) ReadAccount(accountName string) error { + item := h.Tokens.FindBy(accountName) + + if item == nil { + return fmt.Errorf("the given account [%s] was not found", accountName) + } + + token, err := h.TokenHandler.DecodeTokensFor( + item.AccountName, + item.SecretKey, + item.PublicKey, + ) + + if err != nil { + return fmt.Errorf("could not decode the given account [%s] keys: %v", item.AccountName, err) + } + + cli.Successln("\nThe given account has been found successfully!\n") + cli.Blueln(" > " + fmt.Sprintf("Account name: %s", token.AccountName)) + cli.Blueln(" > " + fmt.Sprintf("Public Key: %s", token.PublicKey)) + cli.Blueln(" > " + fmt.Sprintf("Secret Key: %s", token.SecretKey)) + cli.Blueln(" > " + fmt.Sprintf("API Signature: %s", auth.CreateSignatureFrom(token.AccountName, token.SecretKey))) + cli.Warningln("----- Encrypted Values -----") + cli.Magentaln(" > " + fmt.Sprintf("Public Key: %x", token.EncryptedPublicKey)) + cli.Magentaln(" > " + fmt.Sprintf("Secret Key: %x", token.EncryptedSecretKey)) + fmt.Println(" ") + + return nil +} + +func (h Handler) CreateSignature(accountName string) error { + item := h.Tokens.FindBy(accountName) + + if item == nil { + return fmt.Errorf("the given account [%s] was not found", accountName) + } + + token, err := h.TokenHandler.DecodeTokensFor( + item.AccountName, + item.SecretKey, + item.PublicKey, + ) + + if err != nil { + return fmt.Errorf("could not decode the given account [%s] keys: %v", item.AccountName, err) + } + + signature := auth.CreateSignatureFrom(token.AccountName, token.SecretKey) + + cli.Successln("\nThe given account has been found successfully!\n") + cli.Blueln(" > " + fmt.Sprintf("Account name: %s", token.AccountName)) + cli.Blueln(" > " + fmt.Sprintf("Public Key: %s", auth.SafeDisplay(token.PublicKey))) + cli.Blueln(" > " + fmt.Sprintf("Secret Key: %s", auth.SafeDisplay(token.SecretKey))) + cli.Warningln("----- Encrypted Values -----") + cli.Magentaln(" > " + fmt.Sprintf("Signature: %s", signature)) + fmt.Println(" ") + + return nil +} diff --git a/cli/gate/guard.go b/cli/gate/guard.go deleted file mode 100644 index 4a359da6..00000000 --- a/cli/gate/guard.go +++ /dev/null @@ -1,57 +0,0 @@ -package gate - -import ( - "bufio" - "fmt" - "github.com/oullin/pkg/auth" - "github.com/oullin/pkg/cli" - "os" - "strings" -) - -type Guard struct { - salt *string - token auth.Token - reader *bufio.Reader -} - -func MakeGuard(token auth.Token) Guard { - return Guard{ - token: token, - reader: bufio.NewReader(os.Stdin), - } -} - -func (g *Guard) CaptureInput() error { - cli.Warning("Type the public token: ") - - input, err := g.reader.ReadString('\n') - - if err != nil { - return fmt.Errorf("error reading input: %v", err) - } - - input = strings.TrimSpace(input) - - if len(input) == 0 { - return fmt.Errorf("token cannot be empty") - } - - if len(input) > 1024 { - return fmt.Errorf("token is too long") - } - - g.salt = &input - - return nil -} - -func (g *Guard) Rejects() bool { - if g.salt == nil { - return true - } - - salt := *g.salt - - return g.token.IsInvalid(salt) -} diff --git a/cli/main.go b/cli/main.go index 57d9726b..60719c80 100644 --- a/cli/main.go +++ b/cli/main.go @@ -1,40 +1,31 @@ package main import ( + "fmt" "github.com/oullin/boost" - "github.com/oullin/cli/gate" + "github.com/oullin/cli/accounts" "github.com/oullin/cli/panel" "github.com/oullin/cli/posts" + "github.com/oullin/database" "github.com/oullin/env" "github.com/oullin/pkg" + "github.com/oullin/pkg/auth" "github.com/oullin/pkg/cli" - "os" - "time" ) -var guard gate.Guard var environment *env.Environment +var dbConn *database.Connection func init() { secrets := boost.Ignite("./../.env", pkg.GetDefaultValidator()) environment = secrets - guard = gate.MakeGuard(environment.App.Credentials) + dbConn = boost.MakeDbConnection(environment) } func main() { cli.ClearScreen() - if err := guard.CaptureInput(); err != nil { - cli.Errorln(err.Error()) - return - } - - if guard.Rejects() { - cli.Errorln("Invalid credentials") - os.Exit(1) - } - menu := panel.MakeMenu() for { @@ -47,26 +38,40 @@ func main() { switch menu.GetChoice() { case 1: - input, err := menu.CapturePostURL() + if err = createBlogPost(menu); err != nil { + cli.Errorln(err.Error()) + continue + } - if err != nil { + return + case 2: + if err = createNewApiAccount(menu); err != nil { + cli.Errorln(err.Error()) + continue + } + + return + case 3: + if err = showApiAccount(menu); err != nil { cli.Errorln(err.Error()) continue } - httpClient := pkg.MakeDefaultClient(nil) - handler := posts.MakeHandler(input, httpClient, environment) + return + case 4: + if err = generateApiAccountsHTTPSignature(menu); err != nil { + cli.Errorln(err.Error()) + continue + } - if _, err := handler.NotParsed(); err != nil { + return + case 5: + if err = generateAppEncryptionKey(); err != nil { cli.Errorln(err.Error()) continue } return - case 2: - showTime() - case 3: - timeParse() case 0: cli.Successln("Goodbye!") return @@ -80,18 +85,94 @@ func main() { } } -func showTime() { - now := time.Now().Format("2006-01-02 15:04:05") +func createBlogPost(menu panel.Menu) error { + input, err := menu.CapturePostURL() + + if err != nil { + return err + } + + httpClient := pkg.MakeDefaultClient(nil) + handler := posts.MakeHandler(input, httpClient, dbConn) + + if _, err = handler.NotParsed(); err != nil { + return err + } + + return nil +} + +func createNewApiAccount(menu panel.Menu) error { + var err error + var account string + var handler *accounts.Handler + + if account, err = menu.CaptureAccountName(); err != nil { + return err + } + + if handler, err = accounts.MakeHandler(dbConn, environment); err != nil { + return err + } + + if err = handler.CreateAccount(account); err != nil { + return err + } + + return nil +} + +func showApiAccount(menu panel.Menu) error { + var err error + var account string + var handler *accounts.Handler + + if account, err = menu.CaptureAccountName(); err != nil { + return err + } + + if handler, err = accounts.MakeHandler(dbConn, environment); err != nil { + return err + } + + if err = handler.ReadAccount(account); err != nil { + return err + } + + return nil +} + +func generateApiAccountsHTTPSignature(menu panel.Menu) error { + var err error + var account string + var handler *accounts.Handler + + if account, err = menu.CaptureAccountName(); err != nil { + return err + } + + if handler, err = accounts.MakeHandler(dbConn, environment); err != nil { + return err + } - cli.Cyanln("\nThe current time is: " + now) + if err = handler.CreateSignature(account); err != nil { + return err + } + + return nil } -func timeParse() { - s := pkg.MakeStringable("2025-04-12") +func generateAppEncryptionKey() error { + var err error + var key []byte - if seed, err := s.ToDatetime(); err != nil { - panic(err) - } else { - cli.Magentaln(seed.Format(time.DateTime)) + if key, err = auth.GenerateAESKey(); err != nil { + return err } + + cli.Successln("\n The key was generated successfully.") + cli.Magentaln(fmt.Sprintf(" > key: %x", key)) + fmt.Println(" ") + + return nil } diff --git a/cli/panel/menu.go b/cli/panel/menu.go index 2b1c03a9..b0090237 100644 --- a/cli/panel/menu.go +++ b/cli/panel/menu.go @@ -5,6 +5,7 @@ import ( "fmt" "github.com/oullin/cli/posts" "github.com/oullin/pkg" + "github.com/oullin/pkg/auth" "github.com/oullin/pkg/cli" "golang.org/x/term" "net/url" @@ -84,10 +85,13 @@ func (p *Menu) Print() { fmt.Println(title) fmt.Println(divider) - p.PrintOption("1) Parse Posts", inner) - p.PrintOption("2) Show Time", inner) - p.PrintOption("3) Show Date", inner) - p.PrintOption("0) Exit", inner) + p.PrintOption("1) Parse Blog Posts.", inner) + p.PrintOption("2) Create new API account.", inner) + p.PrintOption("3) Show API accounts.", inner) + p.PrintOption("4) Generate API accounts HTTP signature.", inner) + p.PrintOption("5) Generate app encryption key.", inner) + p.PrintOption(" ", inner) + p.PrintOption("0) Exit.", inner) fmt.Println(footer + cli.Reset) } @@ -117,6 +121,23 @@ func (p *Menu) CenterText(s string, width int) string { return strings.Repeat(" ", left) + s + strings.Repeat(" ", right) } +func (p *Menu) CaptureAccountName() (string, error) { + fmt.Print("Enter the account name: ") + + account, err := p.Reader.ReadString('\n') + + if err != nil { + return "", fmt.Errorf("%sError reading the account name: %v %s", cli.RedColour, err, cli.Reset) + } + + account = strings.TrimSpace(account) + if account == "" || len(account) < auth.AccountNameMinLength { + return "", fmt.Errorf("%sError: no account name provided or has an invalid length: %s", cli.RedColour, cli.Reset) + } + + return account, nil +} + func (p *Menu) CapturePostURL() (*posts.Input, error) { fmt.Print("Enter the post markdown file URL: ") diff --git a/cli/posts/factory.go b/cli/posts/factory.go index e4a1859f..8d986234 100644 --- a/cli/posts/factory.go +++ b/cli/posts/factory.go @@ -3,9 +3,8 @@ package posts import ( "context" "fmt" - "github.com/oullin/boost" + "github.com/oullin/database" "github.com/oullin/database/repository" - "github.com/oullin/env" "github.com/oullin/pkg" "github.com/oullin/pkg/markdown" "net/http" @@ -20,9 +19,7 @@ type Handler struct { IsDebugging bool } -func MakeHandler(input *Input, client *pkg.Client, env *env.Environment) Handler { - db := boost.MakeDbConnection(env) - +func MakeHandler(input *Input, client *pkg.Client, db *database.Connection) Handler { tags := &repository.Tags{DB: db} categories := &repository.Categories{DB: db} diff --git a/config/makefile/app.mk b/config/makefile/app.mk index 46c994d9..44dcb571 100644 --- a/config/makefile/app.mk +++ b/config/makefile/app.mk @@ -1,4 +1,6 @@ -.PHONY: fresh audit watch format run-cli +.PHONY: fresh audit watch format run-cli validate-caddy + +APP_CADDY_CONFIG_FILE ?= caddy/Caddyfile.prod format: gofmt -w -s . @@ -48,3 +50,9 @@ run-cli: DB_SECRET_PASSWORD="$(DB_SECRET_PASSWORD)" \ DB_SECRET_DBNAME="$(DB_SECRET_DBNAME)" \ docker compose run --rm api-runner go run ./cli/main.go + +# --- Mac: +# Needs to be locally installed: https://formulae.brew.sh/formula/caddy +validate-caddy: + caddy fmt --overwrite $(APP_CADDY_CONFIG_FILE) + caddy validate --config $(APP_CADDY_CONFIG_FILE) diff --git a/config/makefile/build.mk b/config/makefile/build.mk index 4fd5c311..3a987377 100644 --- a/config/makefile/build.mk +++ b/config/makefile/build.mk @@ -1,4 +1,4 @@ -.PHONY: build-local build-ci build-prod build-release build-deploy +.PHONY: build-local build-ci build-prod build-release build-deploy build-local-restart BUILD_VERSION ?= latest BUILD_PACKAGE_OWNER := oullin @@ -8,6 +8,10 @@ DB_INFRA_SCRIPTS_PATH ?= $(DB_INFRA_ROOT_PATH)/scripts build-local: docker compose --profile local up --build -d +build-local-restart: + docker compose --profile local down && \ + docker compose --profile local up --build -d + build-ci: @printf "\n$(CYAN)Building production images for CI$(NC)\n" # This 'build' command only builds the images; it does not run them. diff --git a/database/attrs.go b/database/attrs.go index d905a42f..b54168bb 100644 --- a/database/attrs.go +++ b/database/attrs.go @@ -4,6 +4,12 @@ import ( "time" ) +type APIKeyAttr struct { + AccountName string + PublicKey []byte + SecretKey []byte +} + type UsersAttrs struct { Username string Name string diff --git a/database/infra/migrations/000001_schema.up.sql b/database/infra/migrations/000001_schema.up.sql index b85b5703..fead4b94 100644 --- a/database/infra/migrations/000001_schema.up.sql +++ b/database/infra/migrations/000001_schema.up.sql @@ -4,13 +4,13 @@ CREATE TABLE IF NOT EXISTS users ( id BIGSERIAL PRIMARY KEY, uuid UUID UNIQUE NOT NULL, - first_name varchar(250) NOT NULL, - last_name varchar(250) NOT NULL, - username VARCHAR(50) UNIQUE NOT NULL , + first_name VARCHAR(250) NOT NULL, + last_name VARCHAR(250) NOT NULL, + username VARCHAR(50) UNIQUE NOT NULL, display_name VARCHAR(255), - email varchar(250) UNIQUE NOT NULL, + email VARCHAR(250) UNIQUE NOT NULL, password_hash VARCHAR(255) NOT NULL, - public_token varchar(250) NOT NULL, + public_token VARCHAR(250) NOT NULL, bio TEXT, picture_file_name VARCHAR(2048), profile_picture_url VARCHAR(2048), @@ -156,8 +156,8 @@ CREATE INDEX idx_likes_user_post ON likes (user_id, post_id); CREATE TABLE newsletters ( id BIGSERIAL PRIMARY KEY, email VARCHAR(255) UNIQUE NOT NULL, - first_name varchar(250) NOT NULL, - last_name varchar(250) NOT NULL, + first_name VARCHAR(250) NOT NULL, + last_name VARCHAR(250) NOT NULL, subscribed_at TIMESTAMP DEFAULT NULL, unsubscribed_at TIMESTAMP DEFAULT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, diff --git a/database/infra/migrations/000002_api_keys.up.sql b/database/infra/migrations/000002_api_keys.up.sql new file mode 100644 index 00000000..7b48305e --- /dev/null +++ b/database/infra/migrations/000002_api_keys.up.sql @@ -0,0 +1,17 @@ +CREATE TABLE api_keys ( + id BIGSERIAL PRIMARY KEY, + uuid UUID UNIQUE NOT NULL, + account_name VARCHAR(255) UNIQUE NOT NULL, + public_key BYTEA NOT NULL, + secret_key BYTEA NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + deleted_at TIMESTAMP DEFAULT NULL, + + CONSTRAINT uq_account_keys UNIQUE (account_name, public_key, secret_key) +); + +CREATE INDEX idx_account_name ON api_keys(account_name); +CREATE INDEX idx_public_key ON api_keys(public_key); +CREATE INDEX idx_secret_key ON api_keys(secret_key); +CREATE INDEX idx_deleted_at ON api_keys(deleted_at); diff --git a/database/model.go b/database/model.go index b1e98c2a..6e2f2b69 100644 --- a/database/model.go +++ b/database/model.go @@ -11,8 +11,8 @@ const DriverName = "postgres" var schemaTables = []string{ "users", "posts", "categories", "post_categories", "tags", "post_tags", - "post_views", "post_views", "comments", - "likes", "newsletters", + "post_views", "comments", + "likes", "newsletters", "api_keys", } func GetSchemaTables() []string { @@ -23,6 +23,17 @@ func isValidTable(seed string) bool { return slices.Contains(schemaTables, seed) } +type APIKey struct { + ID int64 `gorm:"primaryKey"` + UUID string `gorm:"type:uuid;unique;not null"` + AccountName string `gorm:"column:account_name;not null;unique;uniqueIndex:uq_account_keys"` + PublicKey []byte `gorm:"column:public_key;not null;unique;index;uniqueIndex:uq_account_keys"` + SecretKey []byte `gorm:"column:secret_key;not null;unique;index;uniqueIndex:uq_account_keys"` + CreatedAt time.Time + UpdatedAt time.Time + DeletedAt gorm.DeletedAt `gorm:"index"` +} + type User struct { ID uint64 `gorm:"primaryKey;autoIncrement"` UUID string `gorm:"type:uuid;unique;not null"` diff --git a/database/repository/api_keys.go b/database/repository/api_keys.go new file mode 100644 index 00000000..746a52b4 --- /dev/null +++ b/database/repository/api_keys.go @@ -0,0 +1,51 @@ +package repository + +import ( + "fmt" + "github.com/google/uuid" + "github.com/oullin/database" + "github.com/oullin/pkg/gorm" + "strings" +) + +type ApiKeys struct { + DB *database.Connection +} + +func (a ApiKeys) Create(attrs database.APIKeyAttr) (*database.APIKey, error) { + key := database.APIKey{ + UUID: uuid.NewString(), + AccountName: attrs.AccountName, + PublicKey: attrs.PublicKey, + SecretKey: attrs.SecretKey, + } + + if result := a.DB.Sql().Create(&key); gorm.HasDbIssues(result.Error) { + return nil, fmt.Errorf( + "issue creating the given api key pair [%s, %s]: %s", + attrs.PublicKey, + attrs.SecretKey, + result.Error, + ) + } + + return &key, nil +} + +func (a ApiKeys) FindBy(accountName string) *database.APIKey { + key := database.APIKey{} + + result := a.DB.Sql(). + Where("LOWER(account_name) = ?", strings.ToLower(accountName)). + First(&key) + + if gorm.HasDbIssues(result.Error) { + return nil + } + + if result.RowsAffected > 0 { + return &key + } + + return nil +} diff --git a/database/repository/categories.go b/database/repository/categories.go index 53739b1f..b7e65d2c 100644 --- a/database/repository/categories.go +++ b/database/repository/categories.go @@ -23,7 +23,7 @@ func (c Categories) FindBy(slug string) *database.Category { return nil } - if strings.Trim(category.UUID, " ") != "" { + if result.RowsAffected > 0 { return &category } diff --git a/database/repository/tags.go b/database/repository/tags.go index eba250f7..e1ad435a 100644 --- a/database/repository/tags.go +++ b/database/repository/tags.go @@ -45,7 +45,7 @@ func (t Tags) FindBy(slug string) *database.Tag { return nil } - if strings.Trim(tag.UUID, " ") != "" { + if result.RowsAffected > 0 { return &tag } diff --git a/database/repository/users.go b/database/repository/users.go index f4143c40..50502e0e 100644 --- a/database/repository/users.go +++ b/database/repository/users.go @@ -23,7 +23,7 @@ func (u Users) FindBy(username string) *database.User { return nil } - if strings.Trim(user.UUID, " ") != "" { + if result.RowsAffected > 0 { return &user } diff --git a/env/app.go b/env/app.go index d296a84a..94e8582d 100644 --- a/env/app.go +++ b/env/app.go @@ -1,15 +1,13 @@ package env -import "github.com/oullin/pkg/auth" - const local = "local" const staging = "staging" const production = "production" type AppEnvironment struct { - Name string `validate:"required,min=4"` - Type string `validate:"required,lowercase,oneof=local production staging"` - Credentials auth.Token `validate:"required"` + Name string `validate:"required,min=4"` + Type string `validate:"required,lowercase,oneof=local production staging"` + MasterKey string `validate:"required,min=32"` } func (e AppEnvironment) IsProduction() bool { diff --git a/main.go b/main.go index 046b47c1..0549f9c5 100644 --- a/main.go +++ b/main.go @@ -1,6 +1,7 @@ package main import ( + "fmt" _ "github.com/lib/pq" "github.com/oullin/boost" "github.com/oullin/pkg" @@ -12,10 +13,14 @@ var app *boost.App func init() { validate := pkg.GetDefaultValidator() - secrets := boost.Ignite("./.env", validate) + application, err := boost.MakeApp(secrets, validate) + + if err != nil { + panic(fmt.Sprintf("init: Error creating application: %s", err)) + } - app = boost.MakeApp(secrets, validate) + app = application } func main() { diff --git a/pkg/auth/encryption.go b/pkg/auth/encryption.go new file mode 100644 index 00000000..4210f9b9 --- /dev/null +++ b/pkg/auth/encryption.go @@ -0,0 +1,99 @@ +package auth + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "fmt" + "io" + "strings" +) + +func GenerateAESKey() ([]byte, error) { + key := make([]byte, EncryptionKeyLength) + + if _, err := rand.Read(key); err != nil { + return nil, fmt.Errorf("failed to generate random key: %w", err) + } + + return key, nil +} + +func Encrypt(plaintext []byte, key []byte) ([]byte, error) { + block, err := aes.NewCipher(key) + if err != nil { + return nil, err + } + + // GCM is an authenticated encryption mode that provides confidentiality and integrity. + gcm, err := cipher.NewGCM(block) + if err != nil { + return nil, err + } + + // A nonce is a "number used once" to ensure that the same plaintext + // encrypts to different ciphertexts each time. It must be unique for each encryption. + nonce := make([]byte, gcm.NonceSize()) + if _, err := io.ReadFull(rand.Reader, nonce); err != nil { + return nil, err + } + + // Seal will Encrypt the data and append the authentication tag. + // We prepend the nonce to the ciphertext for use during decryption. + ciphertext := gcm.Seal(nonce, nonce, plaintext, nil) + + return ciphertext, nil +} + +func Decrypt(ciphertext []byte, key []byte) ([]byte, error) { + block, err := aes.NewCipher(key) + + if err != nil { + return nil, err + } + + gcm, err := cipher.NewGCM(block) + if err != nil { + return nil, err + } + + nonceSize := gcm.NonceSize() + if len(ciphertext) < nonceSize { + return nil, fmt.Errorf("ciphertext too short") + } + + // The nonce is prepended to the ciphertext. + nonce, encryptedMessage := ciphertext[:nonceSize], ciphertext[nonceSize:] + + // Open will decrypt and authenticate the message. + plaintext, err := gcm.Open(nil, nonce, encryptedMessage, nil) + if err != nil { + return nil, err + } + + return plaintext, nil +} + +func CreateSignatureFrom(message, secretKey string) string { + mac := hmac.New(sha256.New, []byte(secretKey)) + mac.Write([]byte(message)) + + return hex.EncodeToString(mac.Sum(nil)) +} + +func ValidateTokenFormat(seed string) error { + token := strings.TrimSpace(seed) + + if token == "" || len(token) < TokenMinLength { + return fmt.Errorf("token not found or invalid") + } + + if strings.HasPrefix(token, PublicKeyPrefix) || strings.HasPrefix(token, SecretKeyPrefix) { + return nil + } + + return fmt.Errorf("the given token [%s] is not valid", token) +} diff --git a/pkg/auth/handler.go b/pkg/auth/handler.go new file mode 100644 index 00000000..cbc90c93 --- /dev/null +++ b/pkg/auth/handler.go @@ -0,0 +1,98 @@ +package auth + +import ( + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "fmt" +) + +type TokenHandler struct { + EncryptionKey []byte + TokenMinLength int + AccountNameMinLength int +} + +func MakeTokensHandler(encryptionKey []byte) (*TokenHandler, error) { + if len(encryptionKey) != EncryptionKeyLength { + return nil, fmt.Errorf("encryption key length must be equal to %d bytes", EncryptionKeyLength) + } + + return &TokenHandler{ + EncryptionKey: encryptionKey, + TokenMinLength: TokenMinLength, + AccountNameMinLength: AccountNameMinLength, + }, nil +} + +func (t *TokenHandler) DecodeTokensFor(accountName string, secret, public []byte) (*Token, error) { + var err error + var publicKey, secretKey []byte + + if publicKey, err = Decrypt(public, t.EncryptionKey); err != nil { + return nil, fmt.Errorf("unable to decrypt public key: %w", err) + } + + if secretKey, err = Decrypt(secret, t.EncryptionKey); err != nil { + return nil, fmt.Errorf("unable to decrypt secret key: %w", err) + } + + return &Token{ + AccountName: accountName, + PublicKey: string(publicKey), + EncryptedPublicKey: public, + SecretKey: string(secretKey), + EncryptedSecretKey: secret, + }, nil +} + +func (t *TokenHandler) SetupNewAccount(accountName string) (*Token, error) { + var err error + var pk, sk *SecureToken + + if len(accountName) < AccountNameMinLength { + return nil, fmt.Errorf("account name must be at least %d characters", AccountNameMinLength) + } + + if pk, err = t.generateSecureToken(PublicKeyPrefix); err != nil { + return nil, fmt.Errorf("failed to generate public key: %w", err) + } + + if sk, err = t.generateSecureToken(SecretKeyPrefix); err != nil { + return nil, fmt.Errorf("failed to generate secret key: %w", err) + } + + return &Token{ + AccountName: accountName, + PublicKey: pk.PlainText, + EncryptedPublicKey: pk.EncryptedText, + SecretKey: sk.PlainText, + EncryptedSecretKey: sk.EncryptedText, + }, nil +} + +func (t *TokenHandler) generateSecureToken(prefix string) (*SecureToken, error) { + salt := make([]byte, t.TokenMinLength) + + if _, err := rand.Read(salt); err != nil { + return nil, fmt.Errorf("failed to generate secure tokens salt: %v", err) + } + + hasher := sha256.New() + hasher.Write(salt) + + // Get the resulting hash and encode it as a hex string. + hashBytes := hasher.Sum(nil) + + text := prefix + hex.EncodeToString(hashBytes) + encryptedText, err := Encrypt([]byte(text), t.EncryptionKey) + + if err != nil { + return nil, fmt.Errorf("failed to Encrypt: %w", err) + } + + return &SecureToken{ + PlainText: text, + EncryptedText: encryptedText, + }, nil +} diff --git a/pkg/auth/render.go b/pkg/auth/render.go new file mode 100644 index 00000000..855b3680 --- /dev/null +++ b/pkg/auth/render.go @@ -0,0 +1,20 @@ +package auth + +import "strings" + +func SafeDisplay(secret string) string { + var prefixLen int + visibleChars := 10 + + if strings.HasPrefix(secret, PublicKeyPrefix) { + prefixLen = len(PublicKeyPrefix) + } else { + prefixLen = len(SecretKeyPrefix) + } + + if len(secret) <= prefixLen+visibleChars { + return secret + } + + return secret[:prefixLen+visibleChars] + "..." +} diff --git a/pkg/auth/schema.go b/pkg/auth/schema.go new file mode 100644 index 00000000..e6f584d8 --- /dev/null +++ b/pkg/auth/schema.go @@ -0,0 +1,22 @@ +package auth + +const ( + PublicKeyPrefix = "pk_" + SecretKeyPrefix = "sk_" + TokenMinLength = 16 + AccountNameMinLength = 5 + EncryptionKeyLength = 32 +) + +type Token struct { + AccountName string + PublicKey string + EncryptedPublicKey []byte + SecretKey string + EncryptedSecretKey []byte +} + +type SecureToken struct { + PlainText string + EncryptedText []byte +} diff --git a/pkg/auth/token.go b/pkg/auth/token.go deleted file mode 100644 index df6c798a..00000000 --- a/pkg/auth/token.go +++ /dev/null @@ -1,36 +0,0 @@ -package auth - -import ( - "crypto/sha256" - "encoding/hex" - "strings" -) - -type Token struct { - Public string `validate:"required,min=10"` - Private string `validate:"required,min=10"` -} - -func (t Token) IsInvalid(seed string) bool { - return !t.IsValid(seed) -} - -func (t Token) IsValid(seed string) bool { - token := strings.TrimSpace(t.Public) - salt := strings.TrimSpace(t.Private) - externalSalt := strings.TrimSpace(seed) - - if salt != externalSalt { - return false - } - - hash := sha256.New() - hash.Write([]byte(externalSalt)) - bytes := hash.Sum(hash.Sum(nil)) - - encodeToString := strings.TrimSpace( - hex.EncodeToString(bytes), - ) - - return token == encodeToString -} diff --git a/pkg/http/middleware/pipeline.go b/pkg/http/middleware/pipeline.go index 5ca24c5d..505a9a1b 100644 --- a/pkg/http/middleware/pipeline.go +++ b/pkg/http/middleware/pipeline.go @@ -1,12 +1,16 @@ package middleware import ( + "github.com/oullin/database/repository" "github.com/oullin/env" + "github.com/oullin/pkg/auth" "github.com/oullin/pkg/http" ) type Pipeline struct { - Env *env.Environment + Env *env.Environment + ApiKeys *repository.ApiKeys + TokenHandler *auth.TokenHandler } // Chain applies a list of middleware handlers to a final ApiHandler. diff --git a/pkg/http/middleware/token.go b/pkg/http/middleware/token.go deleted file mode 100644 index d1807afe..00000000 --- a/pkg/http/middleware/token.go +++ /dev/null @@ -1,39 +0,0 @@ -package middleware - -import ( - "github.com/oullin/pkg/auth" - "github.com/oullin/pkg/http" - "log/slog" - baseHttp "net/http" -) - -const tokenHeader = "X-API-Key" - -type TokenCheckMiddleware struct { - token auth.Token -} - -func MakeTokenMiddleware(token auth.Token) TokenCheckMiddleware { - return TokenCheckMiddleware{ - token: token, - } -} - -func (t TokenCheckMiddleware) Handle(next http.ApiHandler) http.ApiHandler { - return func(w baseHttp.ResponseWriter, r *baseHttp.Request) *http.ApiError { - - if t.token.IsInvalid(r.Header.Get(tokenHeader)) { - message := "Forbidden: Invalid API key" - slog.Error(message) - - return &http.ApiError{ - Message: message, - Status: baseHttp.StatusForbidden, - } - } - - slog.Info("Token validation successful") - - return next(w, r) - } -} diff --git a/pkg/http/middleware/token_middleware.go b/pkg/http/middleware/token_middleware.go new file mode 100644 index 00000000..0ea87165 --- /dev/null +++ b/pkg/http/middleware/token_middleware.go @@ -0,0 +1,118 @@ +package middleware + +import ( + "fmt" + "github.com/oullin/database" + "github.com/oullin/database/repository" + "github.com/oullin/pkg/auth" + "github.com/oullin/pkg/http" + "log/slog" + baseHttp "net/http" + "strings" +) + +const tokenHeader = "X-API-Key" +const usernameHeader = "X-API-Username" +const signatureHeader = "X-API-Signature" + +type TokenCheckMiddleware struct { + ApiKeys *repository.ApiKeys + TokenHandler *auth.TokenHandler +} + +func MakeTokenMiddleware(tokenHandler *auth.TokenHandler, apiKeys *repository.ApiKeys) TokenCheckMiddleware { + return TokenCheckMiddleware{ + ApiKeys: apiKeys, + TokenHandler: tokenHandler, + } +} + +func (t TokenCheckMiddleware) Handle(next http.ApiHandler) http.ApiHandler { + return func(w baseHttp.ResponseWriter, r *baseHttp.Request) *http.ApiError { + + accountName := strings.TrimSpace(r.Header.Get(usernameHeader)) + publicToken := strings.TrimSpace(r.Header.Get(tokenHeader)) + signature := strings.TrimSpace(r.Header.Get(signatureHeader)) + + if accountName == "" || publicToken == "" || signature == "" { + return t.getInvalidRequestError(accountName, publicToken, signature) + } + + if err := auth.ValidateTokenFormat(publicToken); err != nil { + return t.getInvalidTokenFormatError(publicToken, err) + } + + if t.shallReject(accountName, publicToken, signature) { + return t.getUnauthenticatedError(accountName, publicToken, signature) + } + + slog.Info("Token validation successful") + + return next(w, r) + } +} + +func (t TokenCheckMiddleware) shallReject(accountName, publicToken, signature string) bool { + var item *database.APIKey + + if item = t.ApiKeys.FindBy(accountName); item == nil { + return true + } + + token, err := t.TokenHandler.DecodeTokensFor( + item.AccountName, + item.SecretKey, + item.PublicKey, + ) + + if err != nil { + slog.Error(fmt.Sprintf("could not decode the given account [%s] keys: %v", item.AccountName, err)) + + return true + } + + if strings.TrimSpace(token.PublicKey) != strings.TrimSpace(publicToken) { + slog.Error(fmt.Sprintf("the given public token does not match tour records [%s]: %v", item.AccountName, err)) + + return true + } + + localSignature := auth.CreateSignatureFrom(token.AccountName, token.SecretKey) + + return signature != localSignature +} + +func (t TokenCheckMiddleware) getInvalidRequestError(accountName, publicToken, signature string) *http.ApiError { + message := fmt.Sprintf( + "invalid request. Please, provide a valid token, signature and accout name headers. [account: %s, public token: %s, signature: %s]", + accountName, + auth.SafeDisplay(publicToken), + auth.SafeDisplay(signature), + ) + + return &http.ApiError{ + Message: message, + Status: baseHttp.StatusForbidden, + } +} + +func (t TokenCheckMiddleware) getInvalidTokenFormatError(publicToken string, err error) *http.ApiError { + return &http.ApiError{ + Message: fmt.Sprintf("invalid token format [token: %s]: %v", auth.SafeDisplay(publicToken), err), + Status: baseHttp.StatusForbidden, + } +} + +func (t TokenCheckMiddleware) getUnauthenticatedError(accountName, publicToken, signature string) *http.ApiError { + message := fmt.Sprintf( + "Unauthenticated, please check your credentials and signature headers: [token: %s, account name: %s, signature: %s]", + auth.SafeDisplay(publicToken), + accountName, + signature, + ) + + return &http.ApiError{ + Message: message, + Status: baseHttp.StatusForbidden, + } +} diff --git a/pkg/http/middleware/username.go b/pkg/http/middleware/username.go deleted file mode 100644 index 73565ad0..00000000 --- a/pkg/http/middleware/username.go +++ /dev/null @@ -1,31 +0,0 @@ -package middleware - -import ( - "fmt" - "github.com/oullin/pkg/http" - "log/slog" - baseHttp "net/http" - "strings" -) - -const usernameHeader = "X-API-Username" - -func UsernameCheck(next http.ApiHandler) http.ApiHandler { - return func(w baseHttp.ResponseWriter, r *baseHttp.Request) *http.ApiError { - username := strings.TrimSpace(r.Header.Get(usernameHeader)) - - if username != "gocanto" { - message := fmt.Sprintf("Unauthorized: Invalid API username received ('%s')", username) - slog.Error(message) - - return &http.ApiError{ - Message: message, - Status: baseHttp.StatusUnauthorized, - } - } - - slog.Info("Successfully authenticated user: gocanto") - - return next(w, r) - } -}