mirror of
https://github.com/rjNemo/auth
synced 2026-06-06 00:16:40 +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
|
||||
|
||||
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
|
||||
|
||||
|
|
|
|||
|
|
@ -6,29 +6,37 @@ import (
|
|||
"net/http"
|
||||
"os"
|
||||
|
||||
"github.com/rjnemo/auth/internal/config"
|
||||
"github.com/rjnemo/auth/internal/logging"
|
||||
"github.com/rjnemo/auth/internal/server"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
const listenAddr = ":8000"
|
||||
|
||||
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))
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func run() error {
|
||||
logger := logging.New(os.Stdout, logging.ModeText, &slog.HandlerOptions{AddSource: true})
|
||||
srv, err := server.New(logger)
|
||||
func run(cfg *config.Config, logger *slog.Logger) error {
|
||||
srv, err := server.New(*cfg, logger)
|
||||
if err != nil {
|
||||
return fmt.Errorf("initialise server: %w", err)
|
||||
}
|
||||
|
||||
logger.Info("starting server", slog.String("addr", listenAddr))
|
||||
if err := http.ListenAndServe(listenAddr, srv.Router()); err != nil {
|
||||
logger.Info("starting server", slog.String("addr", fmt.Sprintf("http://localhost%s", cfg.ListenAddr)))
|
||||
if err := http.ListenAndServe(cfg.ListenAddr, srv.Router()); err != nil {
|
||||
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 (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/rjnemo/auth/internal/config"
|
||||
"github.com/rjnemo/auth/internal/logging"
|
||||
"github.com/rjnemo/auth/internal/service/auth"
|
||||
"github.com/rjnemo/auth/web"
|
||||
|
|
@ -21,14 +21,15 @@ const (
|
|||
|
||||
// Server holds HTTP dependencies for the application.
|
||||
type Server struct {
|
||||
templates *template.Template
|
||||
authService *auth.Service
|
||||
sessions *SessionStore
|
||||
logger *slog.Logger
|
||||
templates *template.Template
|
||||
authService *auth.Service
|
||||
sessions *SessionStore
|
||||
logger *slog.Logger
|
||||
configuration config.Config
|
||||
}
|
||||
|
||||
// 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(
|
||||
web.Templates,
|
||||
"templates/login.html",
|
||||
|
|
@ -45,12 +46,7 @@ func New(logger *slog.Logger) (*Server, error) {
|
|||
return nil, fmt.Errorf("seed user: %w", err)
|
||||
}
|
||||
|
||||
secret := make([]byte, 32)
|
||||
if _, err := rand.Read(secret); err != nil {
|
||||
return nil, fmt.Errorf("session secret: %w", err)
|
||||
}
|
||||
|
||||
sessionStore, err := NewSessionStore(secret)
|
||||
sessionStore, err := NewSessionStore(cfg.SessionSecret)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("session store: %w", err)
|
||||
}
|
||||
|
|
@ -61,10 +57,11 @@ func New(logger *slog.Logger) (*Server, error) {
|
|||
logger = logger.With(slog.String("service", "http"))
|
||||
|
||||
return &Server{
|
||||
templates: tmpl,
|
||||
authService: auth.NewService(store),
|
||||
sessions: sessionStore,
|
||||
logger: logger,
|
||||
templates: tmpl,
|
||||
authService: auth.NewService(store),
|
||||
sessions: sessionStore,
|
||||
logger: logger,
|
||||
configuration: cfg,
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -12,8 +12,10 @@ import (
|
|||
"unicode/utf8"
|
||||
)
|
||||
|
||||
const saltLen = 32
|
||||
const passwordMinLength = 8
|
||||
const (
|
||||
saltLen = 32
|
||||
passwordMinLength = 8
|
||||
)
|
||||
|
||||
var ErrWeakPassword = errors.New("auth: password does not meet complexity requirements")
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue