Initial commit with server startup infrastructure
Core infrastructure: - Uber fx dependency injection - Chi router with middleware stack - SQLite database with embedded migrations - Embedded templates and static assets - Structured logging with slog Features implemented: - Authentication (login, logout, session management, argon2id hashing) - App management (create, edit, delete, list) - Deployment pipeline (clone, build, deploy, health check) - Webhook processing for Gitea - Notifications (ntfy, Slack) - Environment variables, labels, volumes per app - SSH key generation for deploy keys Server startup: - Server.Run() starts HTTP server on configured port - Server.Shutdown() for graceful shutdown - SetupRoutes() wires all handlers with chi router
This commit is contained in:
286
internal/service/auth/auth.go
Normal file
286
internal/service/auth/auth.go
Normal file
@@ -0,0 +1,286 @@
|
||||
// Package auth provides authentication services.
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/sessions"
|
||||
"go.uber.org/fx"
|
||||
"golang.org/x/crypto/argon2"
|
||||
|
||||
"git.eeqj.de/sneak/upaas/internal/config"
|
||||
"git.eeqj.de/sneak/upaas/internal/database"
|
||||
"git.eeqj.de/sneak/upaas/internal/logger"
|
||||
"git.eeqj.de/sneak/upaas/internal/models"
|
||||
)
|
||||
|
||||
const (
|
||||
sessionName = "upaas_session"
|
||||
sessionUserID = "user_id"
|
||||
)
|
||||
|
||||
// Argon2 parameters.
|
||||
const (
|
||||
argonTime = 1
|
||||
argonMemory = 64 * 1024
|
||||
argonThreads = 4
|
||||
argonKeyLen = 32
|
||||
saltLen = 16
|
||||
)
|
||||
|
||||
// Session duration constants.
|
||||
const (
|
||||
sessionMaxAgeDays = 7
|
||||
sessionMaxAgeSeconds = 86400 * sessionMaxAgeDays
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrInvalidCredentials is returned when username/password is incorrect.
|
||||
ErrInvalidCredentials = errors.New("invalid credentials")
|
||||
// ErrUserExists is returned when trying to create a user that already exists.
|
||||
ErrUserExists = errors.New("user already exists")
|
||||
)
|
||||
|
||||
// ServiceParams contains dependencies for Service.
|
||||
type ServiceParams struct {
|
||||
fx.In
|
||||
|
||||
Logger *logger.Logger
|
||||
Config *config.Config
|
||||
Database *database.Database
|
||||
}
|
||||
|
||||
// Service provides authentication functionality.
|
||||
type Service struct {
|
||||
log *slog.Logger
|
||||
db *database.Database
|
||||
store *sessions.CookieStore
|
||||
params *ServiceParams
|
||||
}
|
||||
|
||||
// New creates a new auth Service.
|
||||
func New(_ fx.Lifecycle, params ServiceParams) (*Service, error) {
|
||||
store := sessions.NewCookieStore([]byte(params.Config.SessionSecret))
|
||||
store.Options = &sessions.Options{
|
||||
Path: "/",
|
||||
MaxAge: sessionMaxAgeSeconds,
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
}
|
||||
|
||||
return &Service{
|
||||
log: params.Logger.Get(),
|
||||
db: params.Database,
|
||||
store: store,
|
||||
params: ¶ms,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// HashPassword hashes a password using Argon2id.
|
||||
func (svc *Service) HashPassword(password string) (string, error) {
|
||||
salt := make([]byte, saltLen)
|
||||
|
||||
_, err := rand.Read(salt)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to generate salt: %w", err)
|
||||
}
|
||||
|
||||
hash := argon2.IDKey(
|
||||
[]byte(password),
|
||||
salt,
|
||||
argonTime,
|
||||
argonMemory,
|
||||
argonThreads,
|
||||
argonKeyLen,
|
||||
)
|
||||
|
||||
// Encode as base64: salt$hash
|
||||
saltB64 := base64.StdEncoding.EncodeToString(salt)
|
||||
hashB64 := base64.StdEncoding.EncodeToString(hash)
|
||||
|
||||
return saltB64 + "$" + hashB64, nil
|
||||
}
|
||||
|
||||
// VerifyPassword verifies a password against a hash.
|
||||
func (svc *Service) VerifyPassword(hashedPassword, password string) bool {
|
||||
// Parse salt$hash format using strings.Cut (more reliable than fmt.Sscanf)
|
||||
saltB64, hashB64, found := strings.Cut(hashedPassword, "$")
|
||||
if !found || saltB64 == "" || hashB64 == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
salt, err := base64.StdEncoding.DecodeString(saltB64)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
expectedHash, err := base64.StdEncoding.DecodeString(hashB64)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// Compute hash with same parameters
|
||||
computedHash := argon2.IDKey(
|
||||
[]byte(password),
|
||||
salt,
|
||||
argonTime,
|
||||
argonMemory,
|
||||
argonThreads,
|
||||
argonKeyLen,
|
||||
)
|
||||
|
||||
// Constant-time comparison
|
||||
if len(computedHash) != len(expectedHash) {
|
||||
return false
|
||||
}
|
||||
|
||||
var result byte
|
||||
|
||||
for idx := range computedHash {
|
||||
result |= computedHash[idx] ^ expectedHash[idx]
|
||||
}
|
||||
|
||||
return result == 0
|
||||
}
|
||||
|
||||
// IsSetupRequired checks if initial setup is needed (no users exist).
|
||||
func (svc *Service) IsSetupRequired(ctx context.Context) (bool, error) {
|
||||
exists, err := models.UserExists(ctx, svc.db)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("failed to check if user exists: %w", err)
|
||||
}
|
||||
|
||||
return !exists, nil
|
||||
}
|
||||
|
||||
// CreateUser creates the initial admin user.
|
||||
func (svc *Service) CreateUser(
|
||||
ctx context.Context,
|
||||
username, password string,
|
||||
) (*models.User, error) {
|
||||
// Check if user already exists
|
||||
exists, err := models.UserExists(ctx, svc.db)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to check if user exists: %w", err)
|
||||
}
|
||||
|
||||
if exists {
|
||||
return nil, ErrUserExists
|
||||
}
|
||||
|
||||
// Hash password
|
||||
hash, err := svc.HashPassword(password)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to hash password: %w", err)
|
||||
}
|
||||
|
||||
// Create user
|
||||
user := models.NewUser(svc.db)
|
||||
user.Username = username
|
||||
user.PasswordHash = hash
|
||||
|
||||
err = user.Save(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to save user: %w", err)
|
||||
}
|
||||
|
||||
svc.log.Info("user created", "username", username)
|
||||
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// Authenticate validates credentials and returns the user.
|
||||
func (svc *Service) Authenticate(
|
||||
ctx context.Context,
|
||||
username, password string,
|
||||
) (*models.User, error) {
|
||||
user, err := models.FindUserByUsername(ctx, svc.db, username)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to find user: %w", err)
|
||||
}
|
||||
|
||||
if user == nil {
|
||||
return nil, ErrInvalidCredentials
|
||||
}
|
||||
|
||||
if !svc.VerifyPassword(user.PasswordHash, password) {
|
||||
return nil, ErrInvalidCredentials
|
||||
}
|
||||
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// CreateSession creates a session for the user.
|
||||
func (svc *Service) CreateSession(
|
||||
respWriter http.ResponseWriter,
|
||||
request *http.Request,
|
||||
user *models.User,
|
||||
) error {
|
||||
session, err := svc.store.Get(request, sessionName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get session: %w", err)
|
||||
}
|
||||
|
||||
session.Values[sessionUserID] = user.ID
|
||||
|
||||
saveErr := session.Save(request, respWriter)
|
||||
if saveErr != nil {
|
||||
return fmt.Errorf("failed to save session: %w", saveErr)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetCurrentUser returns the currently logged-in user, or nil if not logged in.
|
||||
//
|
||||
//nolint:nilerr // Session errors are not propagated - they indicate no user
|
||||
func (svc *Service) GetCurrentUser(
|
||||
ctx context.Context,
|
||||
request *http.Request,
|
||||
) (*models.User, error) {
|
||||
session, sessionErr := svc.store.Get(request, sessionName)
|
||||
if sessionErr != nil {
|
||||
// Session error means no user - this is not an error condition
|
||||
return nil, nil //nolint:nilnil // Expected behavior for no session
|
||||
}
|
||||
|
||||
userID, ok := session.Values[sessionUserID].(int64)
|
||||
if !ok {
|
||||
return nil, nil //nolint:nilnil // No user ID in session is valid
|
||||
}
|
||||
|
||||
user, err := models.FindUser(ctx, svc.db, userID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to find user: %w", err)
|
||||
}
|
||||
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// DestroySession destroys the current session.
|
||||
func (svc *Service) DestroySession(
|
||||
respWriter http.ResponseWriter,
|
||||
request *http.Request,
|
||||
) error {
|
||||
session, err := svc.store.Get(request, sessionName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get session: %w", err)
|
||||
}
|
||||
|
||||
session.Options.MaxAge = -1 * int(time.Second)
|
||||
|
||||
saveErr := session.Save(request, respWriter)
|
||||
if saveErr != nil {
|
||||
return fmt.Errorf("failed to save session: %w", saveErr)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
243
internal/service/auth/auth_test.go
Normal file
243
internal/service/auth/auth_test.go
Normal file
@@ -0,0 +1,243 @@
|
||||
package auth_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/fx"
|
||||
|
||||
"git.eeqj.de/sneak/upaas/internal/config"
|
||||
"git.eeqj.de/sneak/upaas/internal/database"
|
||||
"git.eeqj.de/sneak/upaas/internal/globals"
|
||||
"git.eeqj.de/sneak/upaas/internal/logger"
|
||||
"git.eeqj.de/sneak/upaas/internal/service/auth"
|
||||
)
|
||||
|
||||
func setupTestService(t *testing.T) (*auth.Service, func()) {
|
||||
t.Helper()
|
||||
|
||||
// Create temp directory
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
// Set up globals
|
||||
globals.SetAppname("upaas-test")
|
||||
globals.SetVersion("test")
|
||||
|
||||
globalsInst, err := globals.New(fx.Lifecycle(nil))
|
||||
require.NoError(t, err)
|
||||
|
||||
loggerInst, err := logger.New(
|
||||
fx.Lifecycle(nil),
|
||||
logger.Params{Globals: globalsInst},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create test config
|
||||
cfg := &config.Config{
|
||||
Port: 8080,
|
||||
DataDir: tmpDir,
|
||||
SessionSecret: "test-secret-key-at-least-32-chars",
|
||||
}
|
||||
|
||||
// Create database
|
||||
dbInst, err := database.New(fx.Lifecycle(nil), database.Params{
|
||||
Logger: loggerInst,
|
||||
Config: cfg,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Connect database manually for tests
|
||||
dbPath := filepath.Join(tmpDir, "upaas.db")
|
||||
cfg.DataDir = tmpDir
|
||||
_ = dbPath // database will create this
|
||||
|
||||
// Create service
|
||||
svc, err := auth.New(fx.Lifecycle(nil), auth.ServiceParams{
|
||||
Logger: loggerInst,
|
||||
Config: cfg,
|
||||
Database: dbInst,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// t.TempDir() automatically cleans up after test
|
||||
cleanup := func() {}
|
||||
|
||||
return svc, cleanup
|
||||
}
|
||||
|
||||
func TestHashPassword(testingT *testing.T) {
|
||||
testingT.Parallel()
|
||||
|
||||
testingT.Run("hashes password successfully", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
svc, cleanup := setupTestService(t)
|
||||
defer cleanup()
|
||||
|
||||
hash, err := svc.HashPassword("testpassword")
|
||||
require.NoError(t, err)
|
||||
assert.NotEmpty(t, hash)
|
||||
assert.NotEqual(t, "testpassword", hash)
|
||||
assert.Contains(t, hash, "$") // salt$hash format
|
||||
})
|
||||
|
||||
testingT.Run("produces different hashes for same password", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
svc, cleanup := setupTestService(t)
|
||||
defer cleanup()
|
||||
|
||||
hash1, err := svc.HashPassword("testpassword")
|
||||
require.NoError(t, err)
|
||||
|
||||
hash2, err := svc.HashPassword("testpassword")
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.NotEqual(t, hash1, hash2) // Different salts
|
||||
})
|
||||
}
|
||||
|
||||
func TestVerifyPassword(testingT *testing.T) {
|
||||
testingT.Parallel()
|
||||
|
||||
testingT.Run("verifies correct password", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
svc, cleanup := setupTestService(t)
|
||||
defer cleanup()
|
||||
|
||||
hash, err := svc.HashPassword("correctpassword")
|
||||
require.NoError(t, err)
|
||||
|
||||
valid := svc.VerifyPassword(hash, "correctpassword")
|
||||
assert.True(t, valid)
|
||||
})
|
||||
|
||||
testingT.Run("rejects incorrect password", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
svc, cleanup := setupTestService(t)
|
||||
defer cleanup()
|
||||
|
||||
hash, err := svc.HashPassword("correctpassword")
|
||||
require.NoError(t, err)
|
||||
|
||||
valid := svc.VerifyPassword(hash, "wrongpassword")
|
||||
assert.False(t, valid)
|
||||
})
|
||||
|
||||
testingT.Run("rejects empty password", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
svc, cleanup := setupTestService(t)
|
||||
defer cleanup()
|
||||
|
||||
hash, err := svc.HashPassword("correctpassword")
|
||||
require.NoError(t, err)
|
||||
|
||||
valid := svc.VerifyPassword(hash, "")
|
||||
assert.False(t, valid)
|
||||
})
|
||||
|
||||
testingT.Run("rejects invalid hash format", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
svc, cleanup := setupTestService(t)
|
||||
defer cleanup()
|
||||
|
||||
valid := svc.VerifyPassword("invalid-hash", "password")
|
||||
assert.False(t, valid)
|
||||
})
|
||||
}
|
||||
|
||||
func TestIsSetupRequired(testingT *testing.T) {
|
||||
testingT.Parallel()
|
||||
|
||||
testingT.Run("returns true when no users exist", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
svc, cleanup := setupTestService(t)
|
||||
defer cleanup()
|
||||
|
||||
required, err := svc.IsSetupRequired(context.Background())
|
||||
require.NoError(t, err)
|
||||
assert.True(t, required)
|
||||
})
|
||||
}
|
||||
|
||||
func TestCreateUser(testingT *testing.T) {
|
||||
testingT.Parallel()
|
||||
|
||||
testingT.Run("creates user successfully", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
svc, cleanup := setupTestService(t)
|
||||
defer cleanup()
|
||||
|
||||
user, err := svc.CreateUser(context.Background(), "admin", "password123")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, user)
|
||||
|
||||
assert.Equal(t, "admin", user.Username)
|
||||
assert.NotEmpty(t, user.PasswordHash)
|
||||
assert.NotZero(t, user.ID)
|
||||
})
|
||||
|
||||
testingT.Run("rejects duplicate user", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
svc, cleanup := setupTestService(t)
|
||||
defer cleanup()
|
||||
|
||||
_, err := svc.CreateUser(context.Background(), "admin", "password123")
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = svc.CreateUser(context.Background(), "admin2", "password456")
|
||||
assert.ErrorIs(t, err, auth.ErrUserExists)
|
||||
})
|
||||
}
|
||||
|
||||
func TestAuthenticate(testingT *testing.T) {
|
||||
testingT.Parallel()
|
||||
|
||||
testingT.Run("authenticates valid credentials", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
svc, cleanup := setupTestService(t)
|
||||
defer cleanup()
|
||||
|
||||
_, err := svc.CreateUser(context.Background(), "admin", "password123")
|
||||
require.NoError(t, err)
|
||||
|
||||
user, err := svc.Authenticate(context.Background(), "admin", "password123")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, user)
|
||||
assert.Equal(t, "admin", user.Username)
|
||||
})
|
||||
|
||||
testingT.Run("rejects invalid password", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
svc, cleanup := setupTestService(t)
|
||||
defer cleanup()
|
||||
|
||||
_, err := svc.CreateUser(context.Background(), "admin", "password123")
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = svc.Authenticate(context.Background(), "admin", "wrongpassword")
|
||||
assert.ErrorIs(t, err, auth.ErrInvalidCredentials)
|
||||
})
|
||||
|
||||
testingT.Run("rejects unknown user", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
svc, cleanup := setupTestService(t)
|
||||
defer cleanup()
|
||||
|
||||
_, err := svc.Authenticate(context.Background(), "nonexistent", "password")
|
||||
assert.ErrorIs(t, err, auth.ErrInvalidCredentials)
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user