Check / check (pull_request) Skipped
On a build machine with 4 GiB, docker build . failed: the auth test binary was killed for lack of memory. upaasd hashes passwords with argon2id at 64 MiB per hash; the auth and handlers tests run many such hashes at once, and the race detector multiplies what each one takes, so make test peaked at 2.7 GiB. The memory per hash is now the auth service's ArgonMemory field. New sets it to the same 64 MiB, which upaasd keeps; the test helpers lower it to 1 MiB. A new test checks that New sets 64 MiB and hashes and verifies a password with it. GOMAXPROCS=4 make test now peaks at about 1.4 GiB. Model: opus-5-5
288 lines
6.9 KiB
Go
288 lines
6.9 KiB
Go
// Package auth provides authentication services.
|
|
package auth
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"encoding/base64"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"net/http"
|
|
"strings"
|
|
|
|
"github.com/gorilla/sessions"
|
|
"go.uber.org/fx"
|
|
"golang.org/x/crypto/argon2"
|
|
|
|
"sneak.berlin/go/upaas/internal/config"
|
|
"sneak.berlin/go/upaas/internal/database"
|
|
"sneak.berlin/go/upaas/internal/logger"
|
|
"sneak.berlin/go/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 {
|
|
// ArgonMemory is the memory each argon2id hash takes, in KiB. New sets
|
|
// argonMemory, 64 MiB, and upaasd never changes it. Tests lower it, since
|
|
// many 64 MiB hashes at once under the race detector need more memory
|
|
// than a 4 GiB build machine has. A hash verifies only with the value it
|
|
// was made with.
|
|
ArgonMemory uint32
|
|
|
|
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,
|
|
Secure: !params.Config.Debug,
|
|
SameSite: http.SameSiteLaxMode,
|
|
}
|
|
|
|
return &Service{
|
|
ArgonMemory: argonMemory,
|
|
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,
|
|
svc.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,
|
|
svc.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.
|
|
// It uses a DB transaction to atomically check that no users exist and insert
|
|
// the new admin user, preventing race conditions from concurrent setup requests.
|
|
func (svc *Service) CreateUser(
|
|
ctx context.Context,
|
|
username, password string,
|
|
) (*models.User, error) {
|
|
// Hash password before starting transaction.
|
|
hash, err := svc.HashPassword(password)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to hash password: %w", err)
|
|
}
|
|
|
|
// Use a transaction so the "no users exist" check and the insert are atomic.
|
|
// SQLite serializes write transactions, so concurrent requests will block here.
|
|
user, err := models.CreateFirstUser(ctx, svc.db, username, hash)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create user: %w", err)
|
|
}
|
|
|
|
if user == nil {
|
|
return nil, ErrUserExists
|
|
}
|
|
|
|
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
|
|
|
|
saveErr := session.Save(request, respWriter)
|
|
if saveErr != nil {
|
|
return fmt.Errorf("failed to save session: %w", saveErr)
|
|
}
|
|
|
|
return nil
|
|
}
|