- Add backend services to create, list, and delete API tokens. - Implement Bearer token authentication to resolve tokens to users. - Register HTTP routes for managing user tokens under `/account/tokens`. - Add tests to verify that uploads with valid Bearer tokens associate the upload with the correct user, while invalid tokens fall back to anonymous uploads.
882 lines
23 KiB
Go
882 lines
23 KiB
Go
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 {
|
|
return fmt.Errorf("max upload override cannot be negative")
|
|
}
|
|
if policy.DailyUploadMB != nil && *policy.DailyUploadMB <= 0 {
|
|
return fmt.Errorf("daily upload override must be positive")
|
|
}
|
|
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
|
|
}
|