feat(accounts): implement user accounts, sessions, and dashboards
All checks were successful
Build and Publish Docker Image / deploy (push) Successful in 1m8s
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:
579
backend/libs/services/auth.go
Normal file
579
backend/libs/services/auth.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user