package services import ( "crypto/rand" "crypto/sha256" "crypto/subtle" "encoding/base64" "encoding/hex" "encoding/json" "errors" "fmt" "net/mail" "os" "sort" "strings" "time" "go.etcd.io/bbolt" "golang.org/x/crypto/argon2" ) var ( usersBucket = []byte("users") userEmailsBucket = []byte("user_emails") sessionsBucket = []byte("sessions") invitesBucket = []byte("invites") collectionsBucket = []byte("collections") apiTokensBucket = []byte("api_tokens") ) // apiTokenPrefix marks raw API tokens so clients and logs can recognise them. const apiTokenPrefix = "wbx_" var ( ErrTokenInvalid = errors.New("api token is invalid") ErrTokenNotFound = errors.New("api token not found") ) const ( UserRoleAdmin = "admin" UserRoleUser = "user" UserStatusActive = "active" UserStatusDisabled = "disabled" ) var ( ErrInvalidCredentials = errors.New("invalid credentials") ErrRegistrationClosed = errors.New("registration is closed") ErrInviteInvalid = errors.New("invite is invalid") ErrUserDisabled = errors.New("user is disabled") ) type AuthService struct { db *bbolt.DB baseURL string } type User struct { ID string `json:"id"` Username string `json:"username"` Email string `json:"email"` PasswordHash string `json:"passwordHash"` Role string `json:"role"` Status string `json:"status"` StorageQuotaMB *float64 `json:"storageQuotaMb,omitempty"` Policy UserPolicy `json:"policy,omitempty"` CreatedAt time.Time `json:"createdAt"` UpdatedAt time.Time `json:"updatedAt"` } type UserPolicy struct { MaxUploadMB *float64 `json:"maxUploadMb,omitempty"` DailyUploadMB *float64 `json:"dailyUploadMb,omitempty"` StorageQuotaMB *float64 `json:"storageQuotaMb,omitempty"` MaxDays *int `json:"maxDays,omitempty"` DailyBoxes *int `json:"dailyBoxes,omitempty"` ActiveBoxes *int `json:"activeBoxes,omitempty"` ShortWindowRequests *int `json:"shortWindowRequests,omitempty"` StorageBackendID *string `json:"storageBackendId,omitempty"` } type PublicUser struct { ID string Username string Email string Role string Status string StorageQuotaMB *float64 Policy UserPolicy CreatedAt time.Time } type Session struct { ID string `json:"id"` UserID string `json:"userId"` TokenHash string `json:"tokenHash"` ExpiresAt time.Time `json:"expiresAt"` CreatedAt time.Time `json:"createdAt"` } type Invite struct { ID string `json:"id"` UserID string `json:"userId,omitempty"` Email string `json:"email"` Role string `json:"role"` TokenHash string `json:"tokenHash"` CreatedBy string `json:"createdBy"` CreatedAt time.Time `json:"createdAt"` ExpiresAt time.Time `json:"expiresAt"` UsedAt *time.Time `json:"usedAt,omitempty"` UsedByUserID string `json:"usedByUserId,omitempty"` } type Collection struct { ID string `json:"id"` UserID string `json:"userId"` Name string `json:"name"` CreatedAt time.Time `json:"createdAt"` UpdatedAt time.Time `json:"updatedAt"` } // APIToken is a long-lived personal access token. Only the SHA-256 hash of the // secret is stored; the plaintext is shown to the user exactly once at creation. type APIToken struct { ID string `json:"id"` UserID string `json:"userId"` Name string `json:"name"` TokenHash string `json:"tokenHash"` CreatedAt time.Time `json:"createdAt"` LastUsedAt *time.Time `json:"lastUsedAt,omitempty"` } // APITokenResult carries the one-time plaintext alongside the stored token. type APITokenResult struct { Token APIToken Plaintext string } type InviteResult struct { Invite Invite URL string Token string } func NewAuthService(db *bbolt.DB, baseURL string) (*AuthService, error) { service := &AuthService{db: db, baseURL: strings.TrimRight(baseURL, "/")} err := db.Update(func(tx *bbolt.Tx) error { for _, bucket := range [][]byte{usersBucket, userEmailsBucket, sessionsBucket, invitesBucket, collectionsBucket, apiTokensBucket} { if _, err := tx.CreateBucketIfNotExists(bucket); err != nil { return err } } return nil }) if err != nil { return nil, err } return service, nil } func (s *AuthService) BootstrapAvailable() (bool, error) { count := 0 err := s.db.View(func(tx *bbolt.Tx) error { return tx.Bucket(usersBucket).ForEach(func(_, _ []byte) error { count++ return nil }) }) return count == 0, err } func (s *AuthService) CreateBootstrapUser(username, email, password string) (User, error) { available, err := s.BootstrapAvailable() if err != nil { return User{}, err } if !available { return User{}, ErrRegistrationClosed } return s.createUser(username, email, password, UserRoleAdmin) } func (s *AuthService) Login(email, password string) (User, string, error) { user, err := s.UserByEmail(email) if err != nil { return User{}, "", ErrInvalidCredentials } if user.Status != UserStatusActive { return User{}, "", ErrUserDisabled } if !VerifyPasswordHash(user.PasswordHash, password) { return User{}, "", ErrInvalidCredentials } token := randomID(32) session := Session{ ID: randomID(12), UserID: user.ID, TokenHash: tokenHash(token), CreatedAt: time.Now().UTC(), ExpiresAt: time.Now().UTC().Add(30 * 24 * time.Hour), } err = s.db.Update(func(tx *bbolt.Tx) error { data, err := json.Marshal(session) if err != nil { return err } return tx.Bucket(sessionsBucket).Put([]byte(session.ID), data) }) return user, session.ID + "." + token, err } func (s *AuthService) UserForSession(raw string) (User, Session, error) { sessionID, token, ok := strings.Cut(raw, ".") if !ok || sessionID == "" || token == "" { return User{}, Session{}, os.ErrNotExist } var session Session err := s.db.View(func(tx *bbolt.Tx) error { data := tx.Bucket(sessionsBucket).Get([]byte(sessionID)) if data == nil { return os.ErrNotExist } return json.Unmarshal(data, &session) }) if err != nil { return User{}, Session{}, err } if time.Now().UTC().After(session.ExpiresAt) || subtle.ConstantTimeCompare([]byte(tokenHash(token)), []byte(session.TokenHash)) != 1 { return User{}, Session{}, os.ErrPermission } user, err := s.UserByID(session.UserID) if err != nil { return User{}, Session{}, err } if user.Status != UserStatusActive { return User{}, Session{}, ErrUserDisabled } return user, session, nil } func (s *AuthService) Logout(raw string) error { sessionID, _, ok := strings.Cut(raw, ".") if !ok || sessionID == "" { return nil } return s.db.Update(func(tx *bbolt.Tx) error { return tx.Bucket(sessionsBucket).Delete([]byte(sessionID)) }) } // CreateAPIToken mints a new personal access token for the user. The returned // plaintext is the only time the secret is available; only its hash is stored. func (s *AuthService) CreateAPIToken(userID, name string) (APITokenResult, error) { if userID == "" { return APITokenResult{}, fmt.Errorf("user is required") } name = strings.TrimSpace(name) if name == "" { name = "Untitled token" } if len(name) > 80 { name = name[:80] } secret := randomID(32) token := APIToken{ ID: randomID(12), UserID: userID, Name: name, TokenHash: apiTokenHash(secret), CreatedAt: time.Now().UTC(), } if err := s.saveAPIToken(token); err != nil { return APITokenResult{}, err } plaintext := apiTokenPrefix + token.ID + "." + secret return APITokenResult{Token: token, Plaintext: plaintext}, nil } // ListAPITokens returns the user's tokens, newest first. func (s *AuthService) ListAPITokens(userID string) ([]APIToken, error) { tokens := make([]APIToken, 0) err := s.db.View(func(tx *bbolt.Tx) error { return tx.Bucket(apiTokensBucket).ForEach(func(_, data []byte) error { var token APIToken if err := json.Unmarshal(data, &token); err != nil { return err } if token.UserID == userID { tokens = append(tokens, token) } return nil }) }) if err != nil { return nil, err } sort.Slice(tokens, func(i, j int) bool { return tokens[i].CreatedAt.After(tokens[j].CreatedAt) }) return tokens, nil } // DeleteAPIToken removes a token, but only if it belongs to the given user. func (s *AuthService) DeleteAPIToken(userID, tokenID string) error { if userID == "" || tokenID == "" { return ErrTokenNotFound } return s.db.Update(func(tx *bbolt.Tx) error { bucket := tx.Bucket(apiTokensBucket) data := bucket.Get([]byte(tokenID)) if data == nil { return ErrTokenNotFound } var token APIToken if err := json.Unmarshal(data, &token); err != nil { return err } if token.UserID != userID { return ErrTokenNotFound } return bucket.Delete([]byte(tokenID)) }) } // UserForAPIToken resolves a raw bearer token to its owning user. It records // last-used time on a best-effort basis. The user must exist and be enabled. func (s *AuthService) UserForAPIToken(raw string) (User, error) { raw = strings.TrimSpace(raw) raw = strings.TrimPrefix(raw, apiTokenPrefix) tokenID, secret, ok := strings.Cut(raw, ".") if !ok || tokenID == "" || secret == "" { return User{}, ErrTokenInvalid } var token APIToken err := s.db.View(func(tx *bbolt.Tx) error { data := tx.Bucket(apiTokensBucket).Get([]byte(tokenID)) if data == nil { return ErrTokenInvalid } return json.Unmarshal(data, &token) }) if err != nil { return User{}, ErrTokenInvalid } if subtle.ConstantTimeCompare([]byte(apiTokenHash(secret)), []byte(token.TokenHash)) != 1 { return User{}, ErrTokenInvalid } user, err := s.UserByID(token.UserID) if err != nil { return User{}, ErrTokenInvalid } if user.Status != UserStatusActive { return User{}, ErrUserDisabled } now := time.Now().UTC() token.LastUsedAt = &now _ = s.saveAPIToken(token) return user, nil } func (s *AuthService) saveAPIToken(token APIToken) error { return s.db.Update(func(tx *bbolt.Tx) error { data, err := json.Marshal(token) if err != nil { return err } return tx.Bucket(apiTokensBucket).Put([]byte(token.ID), data) }) } func (s *AuthService) CreateInvite(email, role, createdBy string, expiresIn time.Duration) (InviteResult, error) { email, err := normalizeEmail(email) if err != nil { return InviteResult{}, err } if role == "" { role = UserRoleUser } if role != UserRoleAdmin && role != UserRoleUser { role = UserRoleUser } if expiresIn <= 0 { expiresIn = 7 * 24 * time.Hour } token := randomID(32) invite := Invite{ ID: randomID(12), Email: email, Role: role, TokenHash: tokenHash(token), CreatedBy: createdBy, CreatedAt: time.Now().UTC(), ExpiresAt: time.Now().UTC().Add(expiresIn), } err = s.saveInvite(invite) if err != nil { return InviteResult{}, err } return InviteResult{ Invite: invite, Token: token, URL: fmt.Sprintf("%s/invite/%s", s.baseURL, token), }, nil } func (s *AuthService) AcceptInvite(token, username, password string) (User, error) { invite, err := s.InviteByToken(token) if err != nil { return User{}, err } if invite.UsedAt != nil || time.Now().UTC().After(invite.ExpiresAt) { return User{}, ErrInviteInvalid } var user User if invite.UserID != "" { user, err = s.UserByID(invite.UserID) if err != nil { return User{}, err } if err := s.SetPassword(user.ID, password); err != nil { return User{}, err } user, _ = s.UserByID(user.ID) } else { user, err = s.createUser(username, invite.Email, password, invite.Role) if err != nil { return User{}, err } } now := time.Now().UTC() invite.UsedAt = &now invite.UsedByUserID = user.ID if err := s.saveInvite(invite); err != nil { return User{}, err } return user, nil } func (s *AuthService) InviteByToken(token string) (Invite, error) { hash := tokenHash(token) var match Invite err := s.db.View(func(tx *bbolt.Tx) error { return tx.Bucket(invitesBucket).ForEach(func(_, value []byte) error { var invite Invite if err := json.Unmarshal(value, &invite); err != nil { return err } if subtle.ConstantTimeCompare([]byte(hash), []byte(invite.TokenHash)) == 1 { match = invite } return nil }) }) if err != nil { return Invite{}, err } if match.ID == "" { return Invite{}, ErrInviteInvalid } return match, nil } func (s *AuthService) CreatePasswordResetInvite(userID, createdBy string) (InviteResult, error) { user, err := s.UserByID(userID) if err != nil { return InviteResult{}, err } result, err := s.CreateInvite(user.Email, user.Role, createdBy, 24*time.Hour) if err != nil { return InviteResult{}, err } result.Invite.UserID = user.ID if err := s.saveInvite(result.Invite); err != nil { return InviteResult{}, err } return result, nil } func (s *AuthService) ListUsers() ([]User, error) { users := make([]User, 0) err := s.db.View(func(tx *bbolt.Tx) error { return tx.Bucket(usersBucket).ForEach(func(_, value []byte) error { var user User if err := json.Unmarshal(value, &user); err != nil { return err } users = append(users, user) return nil }) }) sort.Slice(users, func(i, j int) bool { return users[i].CreatedAt.After(users[j].CreatedAt) }) return users, err } func (s *AuthService) DisableUser(userID string, disabled bool) error { user, err := s.UserByID(userID) if err != nil { return err } if disabled { user.Status = UserStatusDisabled } else { user.Status = UserStatusActive } user.UpdatedAt = time.Now().UTC() return s.saveUser(user) } func (s *AuthService) SetPassword(userID, password string) error { if len(password) < 8 { return fmt.Errorf("password must be at least 8 characters") } user, err := s.UserByID(userID) if err != nil { return err } user.PasswordHash = HashPassword(password) user.UpdatedAt = time.Now().UTC() return s.saveUser(user) } func (s *AuthService) SetUserStorageQuota(userID string, quotaMB *float64) error { if quotaMB != nil && *quotaMB <= 0 { return fmt.Errorf("storage quota must be positive") } user, err := s.UserByID(userID) if err != nil { return err } user.StorageQuotaMB = quotaMB user.UpdatedAt = time.Now().UTC() return s.saveUser(user) } func (s *AuthService) SetUserPolicy(userID string, policy UserPolicy) error { if err := validateUserPolicy(policy); err != nil { return err } user, err := s.UserByID(userID) if err != nil { return err } user.Policy = policy user.StorageQuotaMB = policy.StorageQuotaMB user.UpdatedAt = time.Now().UTC() return s.saveUser(user) } func (s *AuthService) SetUserStorageBackend(userID, backendID string) error { user, err := s.UserByID(userID) if err != nil { return err } backendID = strings.TrimSpace(backendID) if backendID == "" { user.Policy.StorageBackendID = nil } else { user.Policy.StorageBackendID = &backendID } user.UpdatedAt = time.Now().UTC() return s.saveUser(user) } func (s *AuthService) UpdateUserAdminFields(userID, username, email, role, status string, policy UserPolicy) (User, error) { if err := validateUserPolicy(policy); err != nil { return User{}, err } username = strings.TrimSpace(username) if username == "" { return User{}, fmt.Errorf("username is required") } email, err := normalizeEmail(email) if err != nil { return User{}, err } if role != UserRoleAdmin && role != UserRoleUser { return User{}, fmt.Errorf("invalid role") } if status != UserStatusActive && status != UserStatusDisabled { return User{}, fmt.Errorf("invalid status") } var updated User err = s.db.Update(func(tx *bbolt.Tx) error { users := tx.Bucket(usersBucket) emails := tx.Bucket(userEmailsBucket) data := users.Get([]byte(userID)) if data == nil { return os.ErrNotExist } var user User if err := json.Unmarshal(data, &user); err != nil { return err } if existing := emails.Get([]byte(email)); existing != nil && string(existing) != user.ID { return fmt.Errorf("email is already registered") } if user.Email != email { if err := emails.Delete([]byte(user.Email)); err != nil { return err } if err := emails.Put([]byte(email), []byte(user.ID)); err != nil { return err } } user.Username = username user.Email = email user.Role = role user.Status = status user.Policy = policy user.StorageQuotaMB = policy.StorageQuotaMB user.UpdatedAt = time.Now().UTC() next, err := json.Marshal(user) if err != nil { return err } if err := users.Put([]byte(user.ID), next); err != nil { return err } updated = user return nil }) return updated, err } func (s *AuthService) UserByID(id string) (User, error) { var user User err := s.db.View(func(tx *bbolt.Tx) error { data := tx.Bucket(usersBucket).Get([]byte(id)) if data == nil { return os.ErrNotExist } return json.Unmarshal(data, &user) }) return user, err } func (s *AuthService) UserByEmail(email string) (User, error) { email, err := normalizeEmail(email) if err != nil { return User{}, err } var userID string err = s.db.View(func(tx *bbolt.Tx) error { data := tx.Bucket(userEmailsBucket).Get([]byte(email)) if data == nil { return os.ErrNotExist } userID = string(data) return nil }) if err != nil { return User{}, err } return s.UserByID(userID) } func (s *AuthService) CreateCollection(userID, name string) (Collection, error) { name = strings.TrimSpace(name) if name == "" { return Collection{}, fmt.Errorf("collection name is required") } collection := Collection{ ID: randomID(10), UserID: userID, Name: name, CreatedAt: time.Now().UTC(), UpdatedAt: time.Now().UTC(), } return collection, s.saveCollection(collection) } func (s *AuthService) ListCollections(userID string) ([]Collection, error) { collections := make([]Collection, 0) err := s.db.View(func(tx *bbolt.Tx) error { return tx.Bucket(collectionsBucket).ForEach(func(_, value []byte) error { var collection Collection if err := json.Unmarshal(value, &collection); err != nil { return err } if collection.UserID == userID { collections = append(collections, collection) } return nil }) }) sort.Slice(collections, func(i, j int) bool { return strings.ToLower(collections[i].Name) < strings.ToLower(collections[j].Name) }) return collections, err } func (s *AuthService) CollectionOwnedBy(collectionID, userID string) bool { if collectionID == "" { return true } collection, err := s.CollectionByID(collectionID) return err == nil && collection.UserID == userID } func (s *AuthService) CollectionByID(id string) (Collection, error) { var collection Collection err := s.db.View(func(tx *bbolt.Tx) error { data := tx.Bucket(collectionsBucket).Get([]byte(id)) if data == nil { return os.ErrNotExist } return json.Unmarshal(data, &collection) }) return collection, err } func (s *AuthService) PublicUser(user User) PublicUser { return PublicUser{ ID: user.ID, Username: user.Username, Email: user.Email, Role: user.Role, Status: user.Status, StorageQuotaMB: user.StorageQuotaMB, Policy: user.Policy, CreatedAt: user.CreatedAt, } } func (s *AuthService) createUser(username, email, password, role string) (User, error) { username = strings.TrimSpace(username) if username == "" { return User{}, fmt.Errorf("username is required") } email, err := normalizeEmail(email) if err != nil { return User{}, err } if len(password) < 8 { return User{}, fmt.Errorf("password must be at least 8 characters") } if role != UserRoleAdmin && role != UserRoleUser { role = UserRoleUser } now := time.Now().UTC() user := User{ ID: randomID(12), Username: username, Email: email, PasswordHash: HashPassword(password), Role: role, Status: UserStatusActive, CreatedAt: now, UpdatedAt: now, } return user, s.db.Update(func(tx *bbolt.Tx) error { if existing := tx.Bucket(userEmailsBucket).Get([]byte(email)); existing != nil { return fmt.Errorf("email is already registered") } data, err := json.Marshal(user) if err != nil { return err } if err := tx.Bucket(usersBucket).Put([]byte(user.ID), data); err != nil { return err } return tx.Bucket(userEmailsBucket).Put([]byte(email), []byte(user.ID)) }) } func (s *AuthService) saveUser(user User) error { data, err := json.Marshal(user) if err != nil { return err } return s.db.Update(func(tx *bbolt.Tx) error { return tx.Bucket(usersBucket).Put([]byte(user.ID), data) }) } func (s *AuthService) saveInvite(invite Invite) error { data, err := json.Marshal(invite) if err != nil { return err } return s.db.Update(func(tx *bbolt.Tx) error { return tx.Bucket(invitesBucket).Put([]byte(invite.ID), data) }) } func (s *AuthService) saveCollection(collection Collection) error { data, err := json.Marshal(collection) if err != nil { return err } return s.db.Update(func(tx *bbolt.Tx) error { return tx.Bucket(collectionsBucket).Put([]byte(collection.ID), data) }) } func normalizeEmail(email string) (string, error) { email = strings.ToLower(strings.TrimSpace(email)) if email == "" { return "", fmt.Errorf("email is required") } if _, err := mail.ParseAddress(email); err != nil { return "", fmt.Errorf("email is invalid") } return email, nil } func tokenHash(token string) string { sum := sha256.Sum256([]byte("warpbox-session:" + token)) return hex.EncodeToString(sum[:]) } func apiTokenHash(secret string) string { sum := sha256.Sum256([]byte("warpbox-api-token:" + secret)) return hex.EncodeToString(sum[:]) } func HashPassword(password string) string { salt := make([]byte, 16) if _, err := rand.Read(salt); err != nil { salt = []byte(randomID(16))[:16] } hash := argon2.IDKey([]byte(password), salt, 1, 64*1024, 4, 32) return "argon2id$v=19$m=65536,t=1,p=4$" + base64.RawStdEncoding.EncodeToString(salt) + "$" + base64.RawStdEncoding.EncodeToString(hash) } func VerifyPasswordHash(encoded, password string) bool { parts := strings.Split(encoded, "$") if len(parts) != 5 || parts[0] != "argon2id" { return false } salt, err := base64.RawStdEncoding.DecodeString(parts[3]) if err != nil { return false } expected, err := base64.RawStdEncoding.DecodeString(parts[4]) if err != nil { return false } actual := argon2.IDKey([]byte(password), salt, 1, 64*1024, 4, uint32(len(expected))) return subtle.ConstantTimeCompare(actual, expected) == 1 } func validateUserPolicy(policy UserPolicy) error { if policy.MaxUploadMB != nil && *policy.MaxUploadMB < 0 && *policy.MaxUploadMB != -1 { return fmt.Errorf("max upload override must be positive or -1 for unlimited") } if policy.DailyUploadMB != nil && ((*policy.DailyUploadMB < 0 && *policy.DailyUploadMB != -1) || *policy.DailyUploadMB == 0) { return fmt.Errorf("daily upload override must be positive or -1 for unlimited") } if policy.StorageQuotaMB != nil && *policy.StorageQuotaMB < 0 { return fmt.Errorf("storage quota override cannot be negative") } if policy.MaxDays != nil && *policy.MaxDays <= 0 { return fmt.Errorf("expiration override must be positive") } if policy.DailyBoxes != nil && *policy.DailyBoxes <= 0 { return fmt.Errorf("daily box override must be positive") } if policy.ActiveBoxes != nil && *policy.ActiveBoxes <= 0 { return fmt.Errorf("active box override must be positive") } if policy.ShortWindowRequests != nil && *policy.ShortWindowRequests <= 0 { return fmt.Errorf("short-window request override must be positive") } return nil }