mirror of
https://github.com/rjNemo/auth
synced 2026-06-12 11:26:39 +00:00
feat: add env-driven configuration
This commit is contained in:
parent
38b8fa49dc
commit
bc06a30299
6 changed files with 175 additions and 28 deletions
|
|
@ -24,7 +24,7 @@ Trust `gofmt`; avoid manual formatting. Use CamelCase for exported Go identifier
|
||||||
|
|
||||||
## Testing Guidelines
|
## Testing Guidelines
|
||||||
|
|
||||||
Adopt Go’s `testing` package with table-driven cases. Name files `<feature>_test.go`, colocated with the code under test. Run `go test ./...` (and `go test -cover ./...` for major features) before opening a PR, ensuring new branches maintain or raise coverage.
|
Adopt Go’s `testing` package with table-driven cases. Name files `<feature>_test.go`, colocated with the code under test. Run `go test ./... -cover -count=1` before opening a PR so coverage is measured on fresh binaries; we don’t enforce a target, but avoid notable drops when adding code.
|
||||||
|
|
||||||
## Commit & Pull Request Guidelines
|
## Commit & Pull Request Guidelines
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,29 +6,37 @@ import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
|
"github.com/rjnemo/auth/internal/config"
|
||||||
"github.com/rjnemo/auth/internal/logging"
|
"github.com/rjnemo/auth/internal/logging"
|
||||||
"github.com/rjnemo/auth/internal/server"
|
"github.com/rjnemo/auth/internal/server"
|
||||||
"gorm.io/gorm/logger"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const listenAddr = ":8000"
|
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
if err := run(logger); err != nil {
|
baseLogger := logging.New(os.Stdout, logging.ModeText, &slog.HandlerOptions{AddSource: true})
|
||||||
|
|
||||||
|
cfg, err := config.New()
|
||||||
|
if err != nil {
|
||||||
|
baseLogger.Error("configuration error", slog.Any("error", err))
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
logger := logging.New(os.Stdout, cfg.LogMode, &slog.HandlerOptions{AddSource: cfg.Environment == "development"})
|
||||||
|
logger = logger.With(slog.String("env", cfg.Environment))
|
||||||
|
|
||||||
|
if err := run(cfg, logger); err != nil {
|
||||||
logger.Error("server exited", slog.Any("error", err))
|
logger.Error("server exited", slog.Any("error", err))
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func run() error {
|
func run(cfg *config.Config, logger *slog.Logger) error {
|
||||||
logger := logging.New(os.Stdout, logging.ModeText, &slog.HandlerOptions{AddSource: true})
|
srv, err := server.New(*cfg, logger)
|
||||||
srv, err := server.New(logger)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("initialise server: %w", err)
|
return fmt.Errorf("initialise server: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.Info("starting server", slog.String("addr", listenAddr))
|
logger.Info("starting server", slog.String("addr", fmt.Sprintf("http://localhost%s", cfg.ListenAddr)))
|
||||||
if err := http.ListenAndServe(listenAddr, srv.Router()); err != nil {
|
if err := http.ListenAndServe(cfg.ListenAddr, srv.Router()); err != nil {
|
||||||
return fmt.Errorf("listen: %w", err)
|
return fmt.Errorf("listen: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
58
internal/config/config.go
Normal file
58
internal/config/config.go
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"cmp"
|
||||||
|
"encoding/base64"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/rjnemo/auth/internal/logging"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
envListenAddr = "AUTH_LISTEN_ADDR"
|
||||||
|
envLogMode = "AUTH_LOG_MODE"
|
||||||
|
envEnvironment = "AUTH_ENV"
|
||||||
|
envSessionSecret = "AUTH_SESSION_SECRET"
|
||||||
|
|
||||||
|
defaultListenAddr = ":8000"
|
||||||
|
defaultEnvironment = "development"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Config holds application configuration derived from environment variables.
|
||||||
|
type Config struct {
|
||||||
|
ListenAddr string
|
||||||
|
LogMode logging.Mode
|
||||||
|
Environment string
|
||||||
|
SessionSecret []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
// New loads configuration from environment variables, applying defaults and validation.
|
||||||
|
func New() (*Config, error) {
|
||||||
|
listenAddr := cmp.Or(strings.TrimSpace(os.Getenv(envListenAddr)), defaultListenAddr)
|
||||||
|
environment := cmp.Or(strings.TrimSpace(os.Getenv(envEnvironment)), defaultEnvironment)
|
||||||
|
logMode := logging.ModeText
|
||||||
|
if rawMode := strings.TrimSpace(os.Getenv(envLogMode)); rawMode != "" {
|
||||||
|
logMode = logging.ParseMode(rawMode)
|
||||||
|
}
|
||||||
|
|
||||||
|
secretRaw, ok := os.LookupEnv(envSessionSecret)
|
||||||
|
if !ok || strings.TrimSpace(secretRaw) == "" {
|
||||||
|
return nil, fmt.Errorf("missing required configuration: set %s to a base64-encoded secret", envSessionSecret)
|
||||||
|
}
|
||||||
|
|
||||||
|
secret, err := base64.StdEncoding.DecodeString(secretRaw)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid %s: %w", envSessionSecret, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := &Config{
|
||||||
|
ListenAddr: listenAddr,
|
||||||
|
LogMode: logMode,
|
||||||
|
Environment: environment,
|
||||||
|
SessionSecret: secret,
|
||||||
|
}
|
||||||
|
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
82
internal/config/config_test.go
Normal file
82
internal/config/config_test.go
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/base64"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/rjnemo/auth/internal/logging"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNewDefaults(t *testing.T) {
|
||||||
|
t.Setenv("AUTH_SESSION_SECRET", base64.StdEncoding.EncodeToString(bytesOfLength(32)))
|
||||||
|
cfg, err := New()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if cfg.ListenAddr != ":8000" {
|
||||||
|
t.Fatalf("expected default listen addr, got %s", cfg.ListenAddr)
|
||||||
|
}
|
||||||
|
if cfg.LogMode != logging.ModeText {
|
||||||
|
t.Fatalf("expected default log mode text, got %s", cfg.LogMode)
|
||||||
|
}
|
||||||
|
if cfg.Environment != "development" {
|
||||||
|
t.Fatalf("expected default environment, got %s", cfg.Environment)
|
||||||
|
}
|
||||||
|
if got := len(cfg.SessionSecret); got != 32 {
|
||||||
|
t.Fatalf("expected secret length 32, got %d", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewOverrides(t *testing.T) {
|
||||||
|
secret := base64.StdEncoding.EncodeToString(bytesOfLength(40))
|
||||||
|
t.Setenv("AUTH_SESSION_SECRET", secret)
|
||||||
|
t.Setenv("AUTH_LISTEN_ADDR", "127.0.0.1:9000")
|
||||||
|
t.Setenv("AUTH_LOG_MODE", "json")
|
||||||
|
t.Setenv("AUTH_ENV", "production")
|
||||||
|
|
||||||
|
cfg, err := New()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if cfg.ListenAddr != "127.0.0.1:9000" {
|
||||||
|
t.Fatalf("expected overridden listen addr, got %s", cfg.ListenAddr)
|
||||||
|
}
|
||||||
|
if cfg.LogMode != logging.ModeJSON {
|
||||||
|
t.Fatalf("expected json mode, got %s", cfg.LogMode)
|
||||||
|
}
|
||||||
|
if cfg.Environment != "production" {
|
||||||
|
t.Fatalf("expected environment production, got %s", cfg.Environment)
|
||||||
|
}
|
||||||
|
if len(cfg.SessionSecret) != 40 {
|
||||||
|
t.Fatalf("expected secret length 40, got %d", len(cfg.SessionSecret))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewMissingSecret(t *testing.T) {
|
||||||
|
t.Setenv("AUTH_SESSION_SECRET", "")
|
||||||
|
if _, err := New(); err == nil {
|
||||||
|
t.Fatalf("expected error for missing secret")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewInvalidSecret(t *testing.T) {
|
||||||
|
t.Setenv("AUTH_SESSION_SECRET", "not-base64")
|
||||||
|
if _, err := New(); err == nil {
|
||||||
|
t.Fatalf("expected error for invalid secret")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewShortSecretAccepted(t *testing.T) {
|
||||||
|
t.Setenv("AUTH_SESSION_SECRET", base64.StdEncoding.EncodeToString(bytesOfLength(16)))
|
||||||
|
if _, err := New(); err != nil {
|
||||||
|
t.Fatalf("expected short secret to pass config load, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func bytesOfLength(n int) []byte {
|
||||||
|
b := make([]byte, n)
|
||||||
|
for i := range b {
|
||||||
|
b[i] = byte(i % 255)
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
@ -2,13 +2,13 @@ package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"crypto/rand"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"html/template"
|
"html/template"
|
||||||
"io"
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/rjnemo/auth/internal/config"
|
||||||
"github.com/rjnemo/auth/internal/logging"
|
"github.com/rjnemo/auth/internal/logging"
|
||||||
"github.com/rjnemo/auth/internal/service/auth"
|
"github.com/rjnemo/auth/internal/service/auth"
|
||||||
"github.com/rjnemo/auth/web"
|
"github.com/rjnemo/auth/web"
|
||||||
|
|
@ -25,10 +25,11 @@ type Server struct {
|
||||||
authService *auth.Service
|
authService *auth.Service
|
||||||
sessions *SessionStore
|
sessions *SessionStore
|
||||||
logger *slog.Logger
|
logger *slog.Logger
|
||||||
|
configuration config.Config
|
||||||
}
|
}
|
||||||
|
|
||||||
// New constructs a Server with parsed templates and default state.
|
// New constructs a Server with parsed templates and default state.
|
||||||
func New(logger *slog.Logger) (*Server, error) {
|
func New(cfg config.Config, logger *slog.Logger) (*Server, error) {
|
||||||
tmpl, err := template.ParseFS(
|
tmpl, err := template.ParseFS(
|
||||||
web.Templates,
|
web.Templates,
|
||||||
"templates/login.html",
|
"templates/login.html",
|
||||||
|
|
@ -45,12 +46,7 @@ func New(logger *slog.Logger) (*Server, error) {
|
||||||
return nil, fmt.Errorf("seed user: %w", err)
|
return nil, fmt.Errorf("seed user: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
secret := make([]byte, 32)
|
sessionStore, err := NewSessionStore(cfg.SessionSecret)
|
||||||
if _, err := rand.Read(secret); err != nil {
|
|
||||||
return nil, fmt.Errorf("session secret: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
sessionStore, err := NewSessionStore(secret)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("session store: %w", err)
|
return nil, fmt.Errorf("session store: %w", err)
|
||||||
}
|
}
|
||||||
|
|
@ -65,6 +61,7 @@ func New(logger *slog.Logger) (*Server, error) {
|
||||||
authService: auth.NewService(store),
|
authService: auth.NewService(store),
|
||||||
sessions: sessionStore,
|
sessions: sessionStore,
|
||||||
logger: logger,
|
logger: logger,
|
||||||
|
configuration: cfg,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,8 +12,10 @@ import (
|
||||||
"unicode/utf8"
|
"unicode/utf8"
|
||||||
)
|
)
|
||||||
|
|
||||||
const saltLen = 32
|
const (
|
||||||
const passwordMinLength = 8
|
saltLen = 32
|
||||||
|
passwordMinLength = 8
|
||||||
|
)
|
||||||
|
|
||||||
var ErrWeakPassword = errors.New("auth: password does not meet complexity requirements")
|
var ErrWeakPassword = errors.New("auth: password does not meet complexity requirements")
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue