- Add HasUsers() method to AuthService to check for existing users - Add setupRootAccount() logic that only triggers when: - No users exist in database AND no seed nodes are configured - AuthEnabled is true (respects feature toggle) - Create root user with UUID, admin group, and comprehensive scopes - Generate 24-hour JWT token with full administrative permissions - Display token prominently on console for initial setup - Prevent duplicate root account creation on subsequent starts - Skip root account creation in cluster mode (with seed nodes) Root account includes all administrative scopes: - admin:users:*, admin:groups:*, admin:tokens:* - Standard read/write/delete permissions This resolves the bootstrap problem for authentication-enabled deployments and provides secure initial access for administrative operations. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
228 lines
5.8 KiB
Go
228 lines
5.8 KiB
Go
package auth
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
badger "github.com/dgraph-io/badger/v4"
|
|
"github.com/sirupsen/logrus"
|
|
|
|
"kvs/types"
|
|
"kvs/utils"
|
|
)
|
|
|
|
// AuthContext holds authentication information for a request
|
|
type AuthContext struct {
|
|
UserUUID string `json:"user_uuid"`
|
|
Scopes []string `json:"scopes"`
|
|
Groups []string `json:"groups"`
|
|
}
|
|
|
|
// AuthService handles authentication operations
|
|
type AuthService struct {
|
|
db *badger.DB
|
|
logger *logrus.Logger
|
|
}
|
|
|
|
// NewAuthService creates a new authentication service
|
|
func NewAuthService(db *badger.DB, logger *logrus.Logger) *AuthService {
|
|
return &AuthService{
|
|
db: db,
|
|
logger: logger,
|
|
}
|
|
}
|
|
|
|
// StoreAPIToken stores an API token in BadgerDB with TTL
|
|
func (s *AuthService) StoreAPIToken(tokenString string, userUUID string, scopes []string, expiresAt int64) error {
|
|
tokenHash := utils.HashToken(tokenString)
|
|
|
|
apiToken := types.APIToken{
|
|
TokenHash: tokenHash,
|
|
UserUUID: userUUID,
|
|
Scopes: scopes,
|
|
IssuedAt: time.Now().Unix(),
|
|
ExpiresAt: expiresAt,
|
|
}
|
|
|
|
tokenData, err := json.Marshal(apiToken)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return s.db.Update(func(txn *badger.Txn) error {
|
|
entry := badger.NewEntry([]byte(TokenStorageKey(tokenHash)), tokenData)
|
|
|
|
// Set TTL to the token expiration time
|
|
ttl := time.Until(time.Unix(expiresAt, 0))
|
|
if ttl > 0 {
|
|
entry = entry.WithTTL(ttl)
|
|
}
|
|
|
|
return txn.SetEntry(entry)
|
|
})
|
|
}
|
|
|
|
// GetAPIToken retrieves an API token from BadgerDB by hash
|
|
func (s *AuthService) GetAPIToken(tokenHash string) (*types.APIToken, error) {
|
|
var apiToken types.APIToken
|
|
|
|
err := s.db.View(func(txn *badger.Txn) error {
|
|
item, err := txn.Get([]byte(TokenStorageKey(tokenHash)))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return item.Value(func(val []byte) error {
|
|
return json.Unmarshal(val, &apiToken)
|
|
})
|
|
})
|
|
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &apiToken, nil
|
|
}
|
|
|
|
// ExtractTokenFromHeader extracts the Bearer token from the Authorization header
|
|
func ExtractTokenFromHeader(r *http.Request) (string, error) {
|
|
authHeader := r.Header.Get("Authorization")
|
|
if authHeader == "" {
|
|
return "", fmt.Errorf("missing authorization header")
|
|
}
|
|
|
|
parts := strings.Split(authHeader, " ")
|
|
if len(parts) != 2 || strings.ToLower(parts[0]) != "bearer" {
|
|
return "", fmt.Errorf("invalid authorization header format")
|
|
}
|
|
|
|
return parts[1], nil
|
|
}
|
|
|
|
// GetUserGroups retrieves all groups that a user belongs to
|
|
func (s *AuthService) GetUserGroups(userUUID string) ([]string, error) {
|
|
var user types.User
|
|
err := s.db.View(func(txn *badger.Txn) error {
|
|
item, err := txn.Get([]byte(UserStorageKey(userUUID)))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return item.Value(func(val []byte) error {
|
|
return json.Unmarshal(val, &user)
|
|
})
|
|
})
|
|
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return user.Groups, nil
|
|
}
|
|
|
|
// AuthenticateRequest validates the JWT token and returns authentication context
|
|
func (s *AuthService) AuthenticateRequest(r *http.Request) (*AuthContext, error) {
|
|
// Extract token from header
|
|
tokenString, err := ExtractTokenFromHeader(r)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Validate JWT token
|
|
claims, err := ValidateJWT(tokenString)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid token: %v", err)
|
|
}
|
|
|
|
// Verify token exists in our database (not revoked)
|
|
tokenHash := utils.HashToken(tokenString)
|
|
_, err = s.GetAPIToken(tokenHash)
|
|
if err == badger.ErrKeyNotFound {
|
|
return nil, fmt.Errorf("token not found or revoked")
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to verify token: %v", err)
|
|
}
|
|
|
|
// Get user's groups
|
|
groups, err := s.GetUserGroups(claims.UserUUID)
|
|
if err != nil {
|
|
s.logger.WithError(err).WithField("user_uuid", claims.UserUUID).Warn("Failed to get user groups")
|
|
groups = []string{} // Continue with empty groups on error
|
|
}
|
|
|
|
return &AuthContext{
|
|
UserUUID: claims.UserUUID,
|
|
Scopes: claims.Scopes,
|
|
Groups: groups,
|
|
}, nil
|
|
}
|
|
|
|
// CheckResourcePermission checks if a user has permission to perform an operation on a resource
|
|
func (s *AuthService) CheckResourcePermission(authCtx *AuthContext, resourceKey string, operation string) bool {
|
|
// Get resource metadata
|
|
var metadata types.ResourceMetadata
|
|
err := s.db.View(func(txn *badger.Txn) error {
|
|
item, err := txn.Get([]byte(ResourceMetadataKey(resourceKey)))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return item.Value(func(val []byte) error {
|
|
return json.Unmarshal(val, &metadata)
|
|
})
|
|
})
|
|
|
|
// If no metadata exists, use default permissions
|
|
if err == badger.ErrKeyNotFound {
|
|
metadata = types.ResourceMetadata{
|
|
OwnerUUID: authCtx.UserUUID, // Treat requester as owner for new resources
|
|
GroupUUID: "",
|
|
Permissions: types.DefaultPermissions,
|
|
}
|
|
} else if err != nil {
|
|
s.logger.WithError(err).WithField("resource_key", resourceKey).Warn("Failed to get resource metadata")
|
|
return false
|
|
}
|
|
|
|
// Check user relationship to resource
|
|
isOwner, isGroupMember := CheckUserResourceRelationship(authCtx.UserUUID, &metadata, authCtx.Groups)
|
|
|
|
// Check permission
|
|
return CheckPermission(metadata.Permissions, operation, isOwner, isGroupMember)
|
|
}
|
|
|
|
// GetAuthContext retrieves auth context from request context
|
|
func GetAuthContext(ctx context.Context) *AuthContext {
|
|
if authCtx, ok := ctx.Value("auth").(*AuthContext); ok {
|
|
return authCtx
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// HasUsers checks if any users exist in the database
|
|
func (s *AuthService) HasUsers() (bool, error) {
|
|
var hasUsers bool
|
|
|
|
err := s.db.View(func(txn *badger.Txn) error {
|
|
opts := badger.DefaultIteratorOptions
|
|
opts.PrefetchValues = false // We only need to check if keys exist
|
|
iterator := txn.NewIterator(opts)
|
|
defer iterator.Close()
|
|
|
|
// Look for any key starting with "user:"
|
|
prefix := []byte("user:")
|
|
for iterator.Seek(prefix); iterator.ValidForPrefix(prefix); iterator.Next() {
|
|
hasUsers = true
|
|
return nil // Found at least one user, can exit early
|
|
}
|
|
|
|
return nil
|
|
})
|
|
|
|
return hasUsers, err
|
|
} |