feat(accounts): implement user accounts, sessions, and dashboards
All checks were successful
Build and Publish Docker Image / deploy (push) Successful in 1m8s

Introduce Stage 4 features to support multi-user accounts, cookie-based web sessions, and personal dashboards.

Changes include:
- Adding `/register` to bootstrap the first admin account and `/login`/`/logout` for session management.
- Creating a personal dashboard (`/app`) to display owned boxes, storage usage, and upload history.
- Implementing admin user management (`/admin/users`) for generating invite links and managing user states.
- Updating the bbolt database schema to store users, sessions, invites, and collections.
- Adding `golang.org/x/crypto` for password hashing and introducing unit tests for account handlers.
This commit is contained in:
2026-05-30 15:42:35 +03:00
parent 33d26804a0
commit 9a3cb90b17
24 changed files with 1956 additions and 21 deletions

View File

@@ -0,0 +1,579 @@
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")
)
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"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
type PublicUser struct {
ID string
Username string
Email string
Role string
Status string
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"`
}
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} {
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))
})
}
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) 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,
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 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
}