init
This commit is contained in:
commit
b15b95781c
108 changed files with 14802 additions and 0 deletions
16
internal/auth/context.go
Normal file
16
internal/auth/context.go
Normal 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
35
internal/auth/password.go
Normal 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
259
internal/auth/service.go
Normal 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[:])
|
||||
}
|
||||
101
internal/auth/service_test.go
Normal file
101
internal/auth/service_test.go
Normal 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...)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue