2026-05-30 15:42:35 +03:00
|
|
|
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")
|
2026-05-31 12:50:13 +03:00
|
|
|
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")
|
2026-05-30 15:42:35 +03:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
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 {
|
2026-05-31 02:14:10 +03:00
|
|
|
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"`
|
2026-05-30 15:42:35 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type PublicUser struct {
|
2026-05-30 17:23:20 +03:00
|
|
|
ID string
|
|
|
|
|
Username string
|
|
|
|
|
Email string
|
|
|
|
|
Role string
|
|
|
|
|
Status string
|
|
|
|
|
StorageQuotaMB *float64
|
2026-05-31 02:14:10 +03:00
|
|
|
Policy UserPolicy
|
2026-05-30 17:23:20 +03:00
|
|
|
CreatedAt time.Time
|
2026-05-30 15:42:35 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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"`
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-31 12:50:13 +03:00
|
|
|
// 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
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-30 15:42:35 +03:00
|
|
|
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 {
|
2026-05-31 12:50:13 +03:00
|
|
|
for _, bucket := range [][]byte{usersBucket, userEmailsBucket, sessionsBucket, invitesBucket, collectionsBucket, apiTokensBucket} {
|
2026-05-30 15:42:35 +03:00
|
|
|
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))
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-31 12:50:13 +03:00
|
|
|
// 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)
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-30 15:42:35 +03:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-30 17:23:20 +03:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-31 02:14:10 +03:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-30 15:42:35 +03:00
|
|
|
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{
|
2026-05-30 17:23:20 +03:00
|
|
|
ID: user.ID,
|
|
|
|
|
Username: user.Username,
|
|
|
|
|
Email: user.Email,
|
|
|
|
|
Role: user.Role,
|
|
|
|
|
Status: user.Status,
|
|
|
|
|
StorageQuotaMB: user.StorageQuotaMB,
|
2026-05-31 02:14:10 +03:00
|
|
|
Policy: user.Policy,
|
2026-05-30 17:23:20 +03:00
|
|
|
CreatedAt: user.CreatedAt,
|
2026-05-30 15:42:35 +03:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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[:])
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-31 12:50:13 +03:00
|
|
|
func apiTokenHash(secret string) string {
|
|
|
|
|
sum := sha256.Sum256([]byte("warpbox-api-token:" + secret))
|
|
|
|
|
return hex.EncodeToString(sum[:])
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-30 15:42:35 +03:00
|
|
|
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
|
|
|
|
|
}
|
2026-05-31 02:14:10 +03:00
|
|
|
|
|
|
|
|
func validateUserPolicy(policy UserPolicy) error {
|
2026-05-31 14:01:38 +03:00
|
|
|
if policy.MaxUploadMB != nil && *policy.MaxUploadMB < 0 && *policy.MaxUploadMB != -1 {
|
|
|
|
|
return fmt.Errorf("max upload override must be positive or -1 for unlimited")
|
2026-05-31 02:14:10 +03:00
|
|
|
}
|
2026-05-31 14:01:38 +03:00
|
|
|
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")
|
2026-05-31 02:14:10 +03:00
|
|
|
}
|
2026-05-31 22:40:48 +03:00
|
|
|
if policy.StorageQuotaMB != nil && *policy.StorageQuotaMB < 0 && *policy.StorageQuotaMB != -1 {
|
|
|
|
|
return fmt.Errorf("storage quota override must be 0/positive or -1 for unlimited")
|
2026-05-31 02:14:10 +03:00
|
|
|
}
|
2026-05-31 22:40:48 +03:00
|
|
|
if policy.MaxDays != nil && *policy.MaxDays <= 0 && *policy.MaxDays != -1 {
|
|
|
|
|
return fmt.Errorf("expiration override must be positive or -1 for unlimited")
|
2026-05-31 02:14:10 +03:00
|
|
|
}
|
2026-05-31 22:40:48 +03:00
|
|
|
if policy.DailyBoxes != nil && *policy.DailyBoxes <= 0 && *policy.DailyBoxes != -1 {
|
|
|
|
|
return fmt.Errorf("daily box override must be positive or -1 for unlimited")
|
2026-05-31 02:14:10 +03:00
|
|
|
}
|
2026-05-31 22:40:48 +03:00
|
|
|
if policy.ActiveBoxes != nil && *policy.ActiveBoxes <= 0 && *policy.ActiveBoxes != -1 {
|
|
|
|
|
return fmt.Errorf("active box override must be positive or -1 for unlimited")
|
2026-05-31 02:14:10 +03:00
|
|
|
}
|
2026-05-31 22:40:48 +03:00
|
|
|
if policy.ShortWindowRequests != nil && *policy.ShortWindowRequests <= 0 && *policy.ShortWindowRequests != -1 {
|
|
|
|
|
return fmt.Errorf("short-window request override must be positive or -1 for unlimited")
|
2026-05-31 02:14:10 +03:00
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
}
|