From 788e75c4c1e49b0d52f220f5c0ff49a942d4694f Mon Sep 17 00:00:00 2001 From: Yorick van Pelt Date: Sun, 30 Apr 2023 12:03:23 +0200 Subject: [PATCH] Add users system, required for uploading new pastes --- cmd/rushlink/main.go | 4 + handlers.go | 178 +++++++++++++++++++++++++++++++++++--- internal/db/fileupload.go | 6 +- internal/db/migrate.go | 51 +++++++++++ internal/db/paste.go | 1 + internal/db/user.go | 153 ++++++++++++++++++++++++++++++++ router.go | 3 + 7 files changed, 383 insertions(+), 13 deletions(-) create mode 100644 internal/db/user.go diff --git a/cmd/rushlink/main.go b/cmd/rushlink/main.go index d21b000..5903283 100644 --- a/cmd/rushlink/main.go +++ b/cmd/rushlink/main.go @@ -32,6 +32,10 @@ func main() { log.Fatal(errors.Wrap(err, "migrating database")) } + if err := db.CreateAdminUser(database, "admin"); err != nil { + log.Fatalln(err) + } + go rushlink.StartMetricsServer(*metricsListen, database, filestore) rushlink.StartMainServer(*httpListen, database, filestore, *rootURL) } diff --git a/handlers.go b/handlers.go index 1ea5ca2..0c273d5 100644 --- a/handlers.go +++ b/handlers.go @@ -8,6 +8,7 @@ import ( "net/http" "net/url" "os" + "strconv" "strings" "time" @@ -244,6 +245,23 @@ func (rl *rushlink) viewActionSuccess(w http.ResponseWriter, r *http.Request, p } func (rl *rushlink) newPasteHandler(w http.ResponseWriter, r *http.Request) { + // Check if the user is authenticated + username, password, ok := r.BasicAuth() + if !ok { + // User is not authenticated, return a 401 Unauthorized response + w.Header().Set("WWW-Authenticate", `Basic realm="Restricted"`) + w.WriteHeader(http.StatusUnauthorized) + return + } + + // Authenticate the user + user, err := db.Authenticate(rl.db, username, password) + if err != nil { + // Authentication failed, return a 401 Unauthorized response + w.Header().Set("WWW-Authenticate", `Basic realm="Restricted"`) + w.WriteHeader(http.StatusUnauthorized) + return + } if err := r.ParseMultipartForm(formParseMaxMemory); err != nil { msg := fmt.Sprintf("could not parse form: %v\n", err) rl.renderError(w, r, http.StatusBadRequest, msg) @@ -262,7 +280,7 @@ func (rl *rushlink) newPasteHandler(w http.ResponseWriter, r *http.Request) { return } if shortensPrs { - rl.newRedirectPasteHandler(w, r, shortens[0]) + rl.newRedirectPasteHandler(w, r, user, shortens[0]) return } if fileHeadersPrs { @@ -272,17 +290,152 @@ func (rl *rushlink) newPasteHandler(w http.ResponseWriter, r *http.Request) { rl.renderInternalServerError(w, r, err) return } - rl.newFileUploadPasteHandler(w, r, file, *fileHeader) + rl.newFileUploadPasteHandler(w, r, user, file, *fileHeader) return } } +func (rl *rushlink) createUserHandler(w http.ResponseWriter, r *http.Request) { + // Check if the user is authenticated as an admin + user := rl.authenticateUser(w, r, true, nil) + if user == nil { + return + } -func (rl *rushlink) newFileUploadPasteHandler(w http.ResponseWriter, r *http.Request, file multipart.File, header multipart.FileHeader) { + if err := r.ParseMultipartForm(formParseMaxMemory); err != nil { + msg := fmt.Sprintf("could not parse form: %v\n", err) + rl.renderError(w, r, http.StatusBadRequest, msg) + return + } + + new_username := r.FormValue("username") + new_password := r.FormValue("password") + new_admin, _ := strconv.ParseBool(r.FormValue("admin")) + + // Create the user + if err := db.NewUser(rl.db, new_username, new_password, new_admin); err != nil { + // Failed to create the user, return a 500 Internal Server Error response + http.Error(w, "Failed to create user", http.StatusInternalServerError) + return + } + + // Return a 201 Created response + w.WriteHeader(http.StatusCreated) +} + +func (rl *rushlink) authenticateUser(w http.ResponseWriter, r *http.Request, shouldBeAdmin bool, canAlsoBe *string) *db.User { + // Check if the user is authenticated + username, password, ok := r.BasicAuth() + if !ok { + // User is not authenticated, return a 401 Unauthorized response + w.Header().Set("WWW-Authenticate", `Basic realm="Restricted"`) + w.WriteHeader(http.StatusUnauthorized) + return nil + } + + // Authenticate the user + user, err := db.Authenticate(rl.db, username, password) + if err != nil || (shouldBeAdmin && !user.Admin && (canAlsoBe == nil || *canAlsoBe != user.User)) { + // Authentication failed, return a 401 Unauthorized response + w.Header().Set("WWW-Authenticate", `Basic realm="Restricted"`) + w.WriteHeader(http.StatusUnauthorized) + return nil + } + return user +} +func (rl *rushlink) deleteUserHandler(w http.ResponseWriter, r *http.Request) { + // Parse the user ID from the request URL + vars := mux.Vars(r) + userName := vars["user"] + + // Check if the user is authenticated as an admin or self + user := rl.authenticateUser(w, r, true, &userName) + if user == nil { + return + } + + // Delete the user + if err := db.DeleteUser(rl.db, userName); err != nil { + // Failed to delete the user, return a 500 Internal Server Error response + http.Error(w, "Failed to delete user", http.StatusInternalServerError) + return + } + + // Return a 204 No Content response + w.WriteHeader(http.StatusNoContent) +} +func (rl *rushlink) changeUserHandler(w http.ResponseWriter, r *http.Request) { + // Parse the user ID from the request URL + vars := mux.Vars(r) + userName := vars["user"] + + // Check if the user is authenticated as an admin or self + user := rl.authenticateUser(w, r, true, &userName) + if user == nil { + return + } + + // Parse the request multipart form + if err := r.ParseMultipartForm(formParseMaxMemory); err != nil { + msg := fmt.Sprintf("could not parse form: %v\n", err) + rl.renderError(w, r, http.StatusBadRequest, msg) + return + } + + // Get the user record from the database + updatedUser := new(db.User) + if err := rl.db.Where("user = ?", userName).First(&updatedUser).Error; err != nil { + // User record not found, return a 404 Not Found response + http.NotFound(w, r) + return + } + + // Get the updated user data from the form + if newUsername := r.FormValue("new_username"); newUsername != "" { + updatedUser.User = newUsername + } + if newPassword := r.FormValue("new_password"); newPassword != "" { + hashedPassword, err := db.HashPassword(newPassword) + if err != nil { + msg := fmt.Sprintf("could not hash password: %v\n", err) + rl.renderError(w, r, http.StatusInternalServerError, msg) + return + } + updatedUser.Password = hashedPassword + } + if newAdminStr := r.FormValue("new_admin"); newAdminStr != "" { + newAdmin, err := strconv.ParseBool(newAdminStr) + if err != nil { + msg := fmt.Sprintf("could not parse admin status: %v\n", err) + rl.renderError(w, r, http.StatusBadRequest, msg) + return + } + updatedUser.Admin = newAdmin + } + + // Check if the user is trying to update their own admin status + if updatedUser.Admin && !user.Admin { + // User is trying to update their own admin status, return a 403 Forbidden response + http.Error(w, "You do not have permission to update your own admin status", http.StatusForbidden) + return + } + + // Update the user in the database + if err := db.ChangeUser(rl.db, userName, updatedUser); err != nil { + // Failed to update the user, return a 500 Internal Server Error response + http.Error(w, "Failed to update user", http.StatusInternalServerError) + return + } + + // Return a 200 OK response + w.WriteHeader(http.StatusOK) +} + +func (rl *rushlink) newFileUploadPasteHandler(w http.ResponseWriter, r *http.Request, user *db.User, file multipart.File, header multipart.FileHeader) { var fu *db.FileUpload var paste *db.Paste if err := rl.db.Transaction(func(tx *db.Database) error { var err error - fu, err = db.NewFileUpload(rl.fs, file, header.Filename) + fu, err = db.NewFileUpload(rl.fs, file, user, header.Filename) if err != nil { panic(errors.Wrap(err, "creating fileUpload")) } @@ -290,7 +443,7 @@ func (rl *rushlink) newFileUploadPasteHandler(w http.ResponseWriter, r *http.Req panic(errors.Wrap(err, "saving fileUpload in db")) } - paste, err = shortenFileUploadID(tx, fu.PubID) + paste, err = shortenFileUploadID(tx, user, fu.PubID) return err }); err != nil { panic(err) @@ -298,7 +451,7 @@ func (rl *rushlink) newFileUploadPasteHandler(w http.ResponseWriter, r *http.Req rl.viewActionSuccess(w, r, paste, fu) } -func (rl *rushlink) newRedirectPasteHandler(w http.ResponseWriter, r *http.Request, rawurl string) { +func (rl *rushlink) newRedirectPasteHandler(w http.ResponseWriter, r *http.Request, user *db.User, rawurl string) { userURL, err := url.Parse(rawurl) if err != nil { msg := fmt.Sprintf("invalid url (%v): %v", err, rawurl) @@ -317,7 +470,7 @@ func (rl *rushlink) newRedirectPasteHandler(w http.ResponseWriter, r *http.Reque var paste *db.Paste if err := rl.db.Transaction(func(tx *db.Database) error { var err error - paste, err = shortenURL(tx, userURL) + paste, err = shortenURL(tx, user, userURL) return err }); err != nil { panic(err) @@ -370,19 +523,19 @@ func (rl *rushlink) deletePasteHandler(w http.ResponseWriter, r *http.Request) { // // Returns the new paste key if the fileUpload was successfully added to the // database -func shortenFileUploadID(tx *db.Database, id uuid.UUID) (*db.Paste, error) { - return shorten(tx, db.PasteTypeFileUpload, id[:]) +func shortenFileUploadID(tx *db.Database, user *db.User, id uuid.UUID) (*db.Paste, error) { + return shorten(tx, user, db.PasteTypeFileUpload, id[:]) } // Add a new URL to the database // // Returns the new paste key if the url was successfully shortened -func shortenURL(tx *db.Database, userURL *url.URL) (*db.Paste, error) { - return shorten(tx, db.PasteTypeRedirect, []byte(userURL.String())) +func shortenURL(tx *db.Database, user *db.User, userURL *url.URL) (*db.Paste, error) { + return shorten(tx, user, db.PasteTypeRedirect, []byte(userURL.String())) } // Add a paste (of any kind) to the database with arbitrary content. -func shorten(tx *db.Database, ty db.PasteType, content []byte) (*db.Paste, error) { +func shorten(tx *db.Database, user *db.User, ty db.PasteType, content []byte) (*db.Paste, error) { // Generate the paste key var keyEntropy int if ty == db.PasteTypeFileUpload || ty == db.PasteTypePaste { @@ -403,6 +556,7 @@ func shorten(tx *db.Database, ty db.PasteType, content []byte) (*db.Paste, error p := db.Paste{ Type: ty, State: db.PasteStatePresent, + CreatedBy: user.ID, Content: content, Key: pasteKey, DeleteToken: deleteToken, diff --git a/internal/db/fileupload.go b/internal/db/fileupload.go index b15a66b..f710190 100644 --- a/internal/db/fileupload.go +++ b/internal/db/fileupload.go @@ -38,6 +38,9 @@ type FileUpload struct { // UUID publically identifies this FileUpload. PubID uuid.UUID `gorm:"uniqueIndex"` + // User ID that created this file + CreatedBy uint `gorm:"index"` + // FileName contains the original filename of this FileUpload. FileName string @@ -126,7 +129,7 @@ func (fs *FileStore) filePath(pubID uuid.UUID, fileName string) string { // // Internally, this function detects the type of the file stored in `r` using // `http.DetectContentType`. -func NewFileUpload(fs *FileStore, r io.Reader, fileName string) (*FileUpload, error) { +func NewFileUpload(fs *FileStore, r io.Reader, user *User, fileName string) (*FileUpload, error) { // Generate a file ID pubID, err := uuid.NewRandom() if err != nil { @@ -168,6 +171,7 @@ func NewFileUpload(fs *FileStore, r io.Reader, fileName string) (*FileUpload, er fu := &FileUpload{ State: FileUploadStatePresent, PubID: pubID, + CreatedBy: user.ID, FileName: baseName, ContentType: contentType, Checksum: hash.Sum32(), diff --git a/internal/db/migrate.go b/internal/db/migrate.go index e1f8a1a..82342e7 100644 --- a/internal/db/migrate.go +++ b/internal/db/migrate.go @@ -42,5 +42,56 @@ func Gormigrate(db *gorm.DB) *gormigrate.Gormigrate { return tx.Migrator().DropTable(&FileUpload{}, &Paste{}) }, }, + { + ID: "202304301337", + Migrate: func(tx *gorm.DB) error { + type User struct { + ID uint `gorm:"primaryKey"` + User string `gorm:"uniqueIndex"` + Password string + Admin bool + CreatedAt time.Time + UpdatedAt time.Time + DeletedAt gorm.DeletedAt + } + type FileUpload struct { + ID uint `gorm:"primaryKey"` + State FileUploadState `gorm:"index"` + PubID uuid.UUID `gorm:"uniqueIndex"` + CreatedBy uint `gorm:"index"` + FileName string + ContentType string + Checksum uint32 + CreatedAt time.Time + UpdatedAt time.Time + DeletedAt gorm.DeletedAt + } + type Paste struct { + ID uint `gorm:"primaryKey"` + Type PasteType `gorm:"index"` + State PasteState `gorm:"index"` + CreatedBy uint `gorm:"index"` + Content []byte + Key string `gorm:"uniqueIndex"` + DeleteToken string + CreatedAt time.Time + UpdatedAt time.Time + DeletedAt gorm.DeletedAt + } + return tx.AutoMigrate(&User{}, &FileUpload{}, &Paste{}) + }, + Rollback: func(tx *gorm.DB) error { + if err := tx.Migrator().DropTable(&User{}); err != nil { + return err + } + if err := tx.Migrator().DropColumn(&FileUpload{}, "CreatedBy"); err != nil { + return err + } + if err := tx.Migrator().DropColumn(&Paste{}, "CreatedBy"); err != nil { + return err + } + return nil + }, + }, }) } diff --git a/internal/db/paste.go b/internal/db/paste.go index c6cc6a2..24fdf9f 100644 --- a/internal/db/paste.go +++ b/internal/db/paste.go @@ -24,6 +24,7 @@ type Paste struct { ID uint `gorm:"primaryKey"` Type PasteType `gorm:"index"` State PasteState `gorm:"index"` + CreatedBy uint `gorm:"index"` Content []byte Key string `gorm:"uniqueIndex"` DeleteToken string diff --git a/internal/db/user.go b/internal/db/user.go new file mode 100644 index 0000000..67e1942 --- /dev/null +++ b/internal/db/user.go @@ -0,0 +1,153 @@ +package db + +import ( + "bytes" + "crypto/rand" + "encoding/base64" + "errors" + "fmt" + "time" + + "github.com/google/uuid" + "golang.org/x/crypto/argon2" + "gorm.io/gorm" +) + +type User struct { + ID uint `gorm:"primaryKey"` + User string `gorm:"uniqueIndex"` + Password string + Admin bool + CreatedAt time.Time + UpdatedAt time.Time + DeletedAt gorm.DeletedAt +} + +func NewUser(db *gorm.DB, username string, password string, admin bool) error { + // Generate a new UUID for the user + id, err := uuid.NewRandom() + if err != nil { + return err + } + + // Hash the password using argon2id + hashedPassword, err := HashPassword(password) + if err != nil { + return err + } + + // Create a new user record + user := &User{ + ID: uint(id.ID()), + User: username, + Password: hashedPassword, + Admin: admin, + } + return db.Create(user).Error +} + +func Authenticate(db *gorm.DB, username string, password string) (*User, error) { + // Get the user record by username + var user User + if err := db.Where("user = ?", username).First(&user).Error; err != nil { + return nil, errors.New("user not found") + } + + // Compare the hashed password with the provided password + if !comparePassword(user.Password, password) { + return nil, errors.New("invalid password") + } + + return &user, nil +} + +func HashPassword(password string) (string, error) { + // Generate a salt for the password hash + salt := make([]byte, 16) + if _, err := rand.Read(salt); err != nil { + return "", err + } + + // Hash the password using argon2id + hash := argon2.IDKey([]byte(password), salt, 2, 64*1024, 1, 64) + + // Encode the salt and hash as a string + return string(salt) + string(hash), nil +} + +func comparePassword(hashedPassword string, password string) bool { + // Decode the salt and hash from the hashed password string + salt := []byte(hashedPassword)[:16] + hash := []byte(hashedPassword)[16:] + + // Hash the password using the same salt and parameters + computedHash := argon2.IDKey([]byte(password), salt, 2, 64*1024, 1, 64) + + // Compare the computed hash with the stored hash + return bytes.Equal(hash, computedHash) +} + +// DeleteUser deletes a user with the specified username from the database. +func DeleteUser(db *gorm.DB, username string) error { + // Find the user by username + var user User + if err := db.Where("user = ?", username).First(&user).Error; err != nil { + return err + } + + // Delete the user + if err := db.Delete(&user).Error; err != nil { + return err + } + + return nil +} + +func ChangeUser(db *gorm.DB, username string, updatedUser *User) error { + // Retrieve the existing user + var existingUser User + if err := db.Where("user = ?", username).First(&existingUser).Error; err != nil { + return err + } + + // Update the user fields + existingUser.User = updatedUser.User + existingUser.Password = updatedUser.Password + existingUser.Admin = updatedUser.Admin + + // Save the updated user to the database + if err := db.Save(&existingUser).Error; err != nil { + return err + } + + return nil +} + +func CreateAdminUser(db *gorm.DB, adminUsername string) error { + // Check if the admin user already exists + var admins []User + if err := db.Unscoped().Limit(1).Where("user = ?", adminUsername).Find(&admins).Error; err != nil { + return err + } + if len(admins) > 0 { + // already exists + return nil + } + + // Generate a random 24-char password + passwordBytes := make([]byte, 24) + if _, err := rand.Read(passwordBytes); err != nil { + return err + } + password := base64.URLEncoding.EncodeToString(passwordBytes) + + // Create the admin user + if err := NewUser(db, adminUsername, password, true); err != nil { + return err + } + + // Print out the generated password + fmt.Printf("Generated password for admin user %s: %s\n", adminUsername, password) + + return nil +} diff --git a/router.go b/router.go index 6e888a3..d12c13b 100644 --- a/router.go +++ b/router.go @@ -106,6 +106,9 @@ func InitMainRouter(r *mux.Router, rl *rushlink) { r.HandleFunc("/{path:js/"+staticFilenameExpr+"}", rl.staticGetHandler).Methods("GET", "HEAD") r.HandleFunc("/", rl.indexGetHandler).Methods("GET", "HEAD") r.HandleFunc("/", rl.newPasteHandler).Methods("POST") + r.HandleFunc("/users", rl.createUserHandler).Methods("POST") + r.HandleFunc("/users/{user}", rl.changeUserHandler).Methods("POST") + r.HandleFunc("/users/{user}", rl.deleteUserHandler).Methods("DELETE") r.HandleFunc("/"+urlKeyExpr, rl.viewPasteHandler).Methods("GET", "HEAD") r.HandleFunc("/"+urlKeyWithExtExpr, rl.viewPasteHandler).Methods("GET", "HEAD") r.HandleFunc("/"+urlKeyExpr+"/nr", rl.viewPasteHandlerNoRedirect).Methods("GET", "HEAD")