This commit is contained in:
delete 2026-06-10 20:51:17 +03:00
commit b15b95781c
108 changed files with 14802 additions and 0 deletions

16
internal/auth/context.go Normal file
View file

@ -0,0 +1,16 @@
package auth
import "context"
type contextKey string
const sessionStateKey contextKey = "auth.session-state"
func NewContext(ctx context.Context, state *SessionState) context.Context {
return context.WithValue(ctx, sessionStateKey, state)
}
func FromContext(ctx context.Context) (*SessionState, bool) {
state, ok := ctx.Value(sessionStateKey).(*SessionState)
return state, ok
}

35
internal/auth/password.go Normal file
View file

@ -0,0 +1,35 @@
package auth
import (
"fmt"
"strings"
"golang.org/x/crypto/bcrypt"
)
const bcryptCost = 12
func HashPassword(password string) (string, error) {
if strings.TrimSpace(password) == "" {
return "", fmt.Errorf("password is required")
}
if len(password) > 72 {
return "", fmt.Errorf("password must be 72 bytes or fewer")
}
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcryptCost)
if err != nil {
return "", fmt.Errorf("hash password: %w", err)
}
return string(hashedPassword), nil
}
func ComparePassword(hash, password string) error {
if hash == "" {
return fmt.Errorf("password hash is required")
}
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
}

259
internal/auth/service.go Normal file
View file

@ -0,0 +1,259 @@
package auth
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"errors"
"fmt"
"log/slog"
"net/http"
"strings"
"time"
"update_server/internal/config"
"update_server/internal/db"
)
const sessionTokenBytes = 32
const sessionCookiePath = "/admin"
var (
ErrInvalidCredentials = errors.New("invalid credentials")
ErrUnauthenticated = errors.New("unauthenticated")
ErrUnauthorized = errors.New("unauthorized")
)
type Service struct {
config config.Config
logger *slog.Logger
store *db.Store
}
type SessionState struct {
User db.User
Session db.Session
}
func NewService(cfg config.Config, logger *slog.Logger, store *db.Store) *Service {
return &Service{
config: cfg,
logger: logger,
store: store,
}
}
func (s *Service) EnsureBootstrapAdmin(ctx context.Context) error {
hasActiveAdmin, err := s.store.Users.HasActiveAdmin(ctx)
if err != nil {
return fmt.Errorf("check active admin users: %w", err)
}
if hasActiveAdmin {
return nil
}
email := strings.TrimSpace(s.config.AdminEmail)
password := s.config.AdminPassword
if email == "" || strings.TrimSpace(password) == "" {
if s.logger != nil {
s.logger.Warn("no active admin user found; set ADMIN_EMAIL and ADMIN_PASSWORD to bootstrap the first admin")
}
return nil
}
if existingUser, err := s.store.Users.GetByEmail(ctx, email); err == nil {
if s.logger != nil {
s.logger.Warn("bootstrap admin skipped because the configured email already exists", "email", existingUser.Email)
}
return nil
} else if !errors.Is(err, db.ErrNotFound) {
return fmt.Errorf("check bootstrap admin email: %w", err)
}
passwordHash, err := HashPassword(password)
if err != nil {
return fmt.Errorf("hash bootstrap admin password: %w", err)
}
if _, err := s.store.Users.Create(ctx, db.CreateUserParams{
Email: email,
PasswordHash: passwordHash,
Role: db.UserRoleAdmin,
IsActive: true,
}); err != nil {
return fmt.Errorf("create bootstrap admin user: %w", err)
}
if s.logger != nil {
s.logger.Info("bootstrapped admin user", "email", email)
}
return nil
}
func (s *Service) Authenticate(ctx context.Context, email, password, ipAddress, userAgent string) (string, *SessionState, error) {
email = strings.TrimSpace(email)
if email == "" || password == "" {
return "", nil, ErrInvalidCredentials
}
user, err := s.store.Users.GetByEmail(ctx, email)
if err != nil {
if errors.Is(err, db.ErrNotFound) {
return "", nil, ErrInvalidCredentials
}
return "", nil, fmt.Errorf("load user by email: %w", err)
}
if !user.IsActive {
return "", nil, ErrInvalidCredentials
}
if err := ComparePassword(user.PasswordHash, password); err != nil {
return "", nil, ErrInvalidCredentials
}
now := time.Now().UTC()
expiresAt := now.Add(s.config.SessionTTL)
token, tokenHash, err := generateSessionToken()
if err != nil {
return "", nil, err
}
var session *db.Session
if err := s.store.WithTx(ctx, func(tx *db.TxStore) error {
createdSession, err := tx.Sessions.Create(ctx, db.CreateSessionParams{
UserID: user.ID,
TokenHash: tokenHash,
ExpiresAt: expiresAt,
IPAddress: ipAddress,
UserAgent: userAgent,
})
if err != nil {
return err
}
if err := tx.Users.UpdateLastLoginAt(ctx, user.ID, now); err != nil {
return err
}
session = createdSession
return nil
}); err != nil {
return "", nil, fmt.Errorf("create authenticated session: %w", err)
}
user.LastLoginAt = &now
return token, &SessionState{
User: *user,
Session: *session,
}, nil
}
func (s *Service) LoadSession(ctx context.Context, token string) (*SessionState, error) {
token = strings.TrimSpace(token)
if token == "" {
return nil, ErrUnauthenticated
}
now := time.Now().UTC()
record, err := s.store.Sessions.GetActiveWithUserByTokenHash(ctx, hashSessionToken(token), now)
if err != nil {
if errors.Is(err, db.ErrNotFound) {
return nil, ErrUnauthenticated
}
return nil, fmt.Errorf("lookup active session: %w", err)
}
if err := s.store.Sessions.Touch(ctx, record.Session.ID, now); err != nil {
return nil, fmt.Errorf("touch active session: %w", err)
}
record.Session.LastSeenAt = &now
return &SessionState{
User: record.User,
Session: record.Session,
}, nil
}
func (s *Service) InvalidateSession(ctx context.Context, token string) error {
token = strings.TrimSpace(token)
if token == "" {
return nil
}
if err := s.store.Sessions.InvalidateByTokenHash(ctx, hashSessionToken(token), time.Now().UTC()); err != nil && !errors.Is(err, db.ErrNotFound) {
return fmt.Errorf("invalidate session: %w", err)
}
return nil
}
func (s *Service) SessionCookie(token string, expiresAt time.Time) *http.Cookie {
maxAge := int(time.Until(expiresAt).Seconds())
if maxAge < 0 {
maxAge = 0
}
return &http.Cookie{
Name: s.config.SessionCookieName,
Value: token,
Path: sessionCookiePath,
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
Secure: s.config.SecureCookies,
Expires: expiresAt.UTC(),
MaxAge: maxAge,
}
}
func (s *Service) ClearSessionCookie() *http.Cookie {
return &http.Cookie{
Name: s.config.SessionCookieName,
Value: "",
Path: sessionCookiePath,
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
Secure: s.config.SecureCookies,
Expires: time.Unix(0, 0).UTC(),
MaxAge: -1,
}
}
func (s *Service) SessionCookieName() string {
return s.config.SessionCookieName
}
func RoleAllowed(actualRole, requiredRole db.UserRole) bool {
ranks := map[db.UserRole]int{
db.UserRoleViewer: 1,
db.UserRoleEditor: 2,
db.UserRoleAdmin: 3,
}
return ranks[actualRole] >= ranks[requiredRole] && ranks[requiredRole] > 0
}
func generateSessionToken() (string, string, error) {
bytes := make([]byte, sessionTokenBytes)
if _, err := rand.Read(bytes); err != nil {
return "", "", fmt.Errorf("generate session token: %w", err)
}
token := base64.RawURLEncoding.EncodeToString(bytes)
return token, hashSessionToken(token), nil
}
func hashSessionToken(token string) string {
sum := sha256.Sum256([]byte(token))
return hex.EncodeToString(sum[:])
}

View file

@ -0,0 +1,101 @@
package auth_test
import (
"context"
"io"
"log/slog"
"path/filepath"
"runtime"
"testing"
"time"
"update_server/internal/auth"
"update_server/internal/config"
"update_server/internal/db"
)
func TestEnsureBootstrapAdminCreatesSingleAdminUser(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := newTestStore(t)
defer store.Close()
cfg := config.Config{
AdminEmail: "admin@example.com",
AdminPassword: "correct horse battery staple",
SessionCookieName: "update_server_session",
SessionTTL: 24 * time.Hour,
}
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
service := auth.NewService(cfg, logger, store)
if err := service.EnsureBootstrapAdmin(ctx); err != nil {
t.Fatalf("bootstrap admin: %v", err)
}
if err := service.EnsureBootstrapAdmin(ctx); err != nil {
t.Fatalf("bootstrap admin second pass: %v", err)
}
user, err := store.Users.GetByEmail(ctx, cfg.AdminEmail)
if err != nil {
t.Fatalf("load bootstrapped admin: %v", err)
}
if user.Role != db.UserRoleAdmin {
t.Fatalf("expected admin role, got %q", user.Role)
}
if !user.IsActive {
t.Fatal("expected bootstrapped admin to be active")
}
if user.PasswordHash == cfg.AdminPassword {
t.Fatal("expected bootstrapped password to be hashed")
}
if err := auth.ComparePassword(user.PasswordHash, cfg.AdminPassword); err != nil {
t.Fatalf("compare hashed password: %v", err)
}
var userCount int
if err := store.DB.QueryRowContext(ctx, `SELECT COUNT(*) FROM users`).Scan(&userCount); err != nil {
t.Fatalf("count users: %v", err)
}
if userCount != 1 {
t.Fatalf("expected one bootstrapped user, got %d", userCount)
}
}
func newTestStore(t *testing.T) *db.Store {
t.Helper()
sqlitePath := filepath.Join(t.TempDir(), "update-server.sqlite")
database, err := db.Open(context.Background(), sqlitePath)
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
if err := db.Migrate(context.Background(), database, testProjectPath(t, "migrations")); err != nil {
database.Close()
t.Fatalf("migrate sqlite: %v", err)
}
return db.NewStore(database)
}
func testProjectPath(t *testing.T, parts ...string) string {
t.Helper()
_, filename, _, ok := runtime.Caller(0)
if !ok {
t.Fatal("resolve caller path")
}
root := filepath.Join(filepath.Dir(filename), "..", "..")
items := append([]string{root}, parts...)
return filepath.Join(items...)
}