From 788e75c4c1e49b0d52f220f5c0ff49a942d4694f Mon Sep 17 00:00:00 2001 From: Yorick van Pelt Date: Sun, 30 Apr 2023 12:03:23 +0200 Subject: [PATCH 1/8] 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") -- 2.44.0 From 8e949f837bce8ba735c011be8137e414c2cbcd88 Mon Sep 17 00:00:00 2001 From: Yorick van Pelt Date: Sun, 30 Apr 2023 18:16:52 +0200 Subject: [PATCH 2/8] Use PHC hashes for password storage --- handlers.go | 3 +++ internal/db/user.go | 52 ++++++++++++++++++++++++++++++++++----------- 2 files changed, 43 insertions(+), 12 deletions(-) diff --git a/handlers.go b/handlers.go index 0c273d5..35cb9e3 100644 --- a/handlers.go +++ b/handlers.go @@ -338,6 +338,9 @@ func (rl *rushlink) authenticateUser(w http.ResponseWriter, r *http.Request, sho // Authentication failed, return a 401 Unauthorized response w.Header().Set("WWW-Authenticate", `Basic realm="Restricted"`) w.WriteHeader(http.StatusUnauthorized) + if err != nil { + log.Printf("authentication failure: %s", err) + } return nil } return user diff --git a/internal/db/user.go b/internal/db/user.go index 67e1942..4c757d7 100644 --- a/internal/db/user.go +++ b/internal/db/user.go @@ -6,6 +6,7 @@ import ( "encoding/base64" "errors" "fmt" + "strings" "time" "github.com/google/uuid" @@ -54,37 +55,64 @@ func Authenticate(db *gorm.DB, username string, password string) (*User, error) } // Compare the hashed password with the provided password - if !comparePassword(user.Password, password) { + valid, err := comparePassword(user.Password, password) + if err != nil { + return nil, err + } + if !valid { return nil, errors.New("invalid password") } return &user, nil } +const ( + pwdSaltSize = 16 + pwdHashSize = 32 + pwdParams = "m=65536,t=2,p=1" + pwdAlgo = "argon2id" +) + func HashPassword(password string) (string, error) { // Generate a salt for the password hash - salt := make([]byte, 16) + salt := make([]byte, pwdSaltSize) 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) + hash := argon2.IDKey([]byte(password), salt, 2, 64*1024, 1, pwdHashSize) - // Encode the salt and hash as a string - return string(salt) + string(hash), nil + // Encode the salt and hash as a string in PHC format + encodedSalt := base64.RawStdEncoding.EncodeToString(salt) + encodedHash := base64.RawStdEncoding.EncodeToString(hash) + return fmt.Sprintf("$%s$%s$%s$%s", pwdAlgo, pwdParams, encodedSalt, encodedHash), 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:] +func comparePassword(hashedPassword string, password string) (bool, error) { + // Extract the salt and hash from the hashed password string + fields := strings.Split(hashedPassword, "$")[1:] + if len(fields) != 4 || fields[0] != pwdAlgo || fields[1] != pwdParams { + return false, errors.New("invalid password format in db") + } + encodedSalt, encodedHash := fields[2], fields[3] - // Hash the password using the same salt and parameters - computedHash := argon2.IDKey([]byte(password), salt, 2, 64*1024, 1, 64) + // Decode the salt and hash from base64 + salt, err := base64.RawStdEncoding.DecodeString(encodedSalt) + if err != nil { + return false, err + } + hash, err := base64.RawStdEncoding.DecodeString(encodedHash) + if err != nil { + return false, err + } + + // Hash the password using the extracted salt and parameters + computedHash := argon2.IDKey([]byte(password), salt, 2, 64*1024, 1, pwdHashSize) // Compare the computed hash with the stored hash - return bytes.Equal(hash, computedHash) + // todo constant time? + return bytes.Equal(hash, computedHash), nil } // DeleteUser deletes a user with the specified username from the database. -- 2.44.0 From 0643176ed10fd47b17227d42e598bdcd87ba9826 Mon Sep 17 00:00:00 2001 From: Yorick van Pelt Date: Sun, 30 Apr 2023 18:17:18 +0200 Subject: [PATCH 3/8] Run go fmt --- cmd/rushlink/main.go | 2 +- internal/db/fileupload.go | 2 +- internal/db/paste.go | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/cmd/rushlink/main.go b/cmd/rushlink/main.go index 5903283..7c115d0 100644 --- a/cmd/rushlink/main.go +++ b/cmd/rushlink/main.go @@ -33,7 +33,7 @@ func main() { } if err := db.CreateAdminUser(database, "admin"); err != nil { - log.Fatalln(err) + log.Fatalln(err) } go rushlink.StartMetricsServer(*metricsListen, database, filestore) diff --git a/internal/db/fileupload.go b/internal/db/fileupload.go index f710190..f6914b8 100644 --- a/internal/db/fileupload.go +++ b/internal/db/fileupload.go @@ -39,7 +39,7 @@ type FileUpload struct { PubID uuid.UUID `gorm:"uniqueIndex"` // User ID that created this file - CreatedBy uint `gorm:"index"` + CreatedBy uint `gorm:"index"` // FileName contains the original filename of this FileUpload. FileName string diff --git a/internal/db/paste.go b/internal/db/paste.go index 24fdf9f..79ff6a5 100644 --- a/internal/db/paste.go +++ b/internal/db/paste.go @@ -24,7 +24,7 @@ type Paste struct { ID uint `gorm:"primaryKey"` Type PasteType `gorm:"index"` State PasteState `gorm:"index"` - CreatedBy uint `gorm:"index"` + CreatedBy uint `gorm:"index"` Content []byte Key string `gorm:"uniqueIndex"` DeleteToken string -- 2.44.0 From 11975d79116669d68a1c2d6c7c1bc30d4d9699e7 Mon Sep 17 00:00:00 2001 From: Yorick van Pelt Date: Sun, 30 Apr 2023 21:08:36 +0200 Subject: [PATCH 4/8] use constant-time hash comparison --- internal/db/user.go | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/internal/db/user.go b/internal/db/user.go index 4c757d7..bb307f7 100644 --- a/internal/db/user.go +++ b/internal/db/user.go @@ -1,8 +1,8 @@ package db import ( - "bytes" "crypto/rand" + "crypto/subtle" "encoding/base64" "errors" "fmt" @@ -111,8 +111,7 @@ func comparePassword(hashedPassword string, password string) (bool, error) { computedHash := argon2.IDKey([]byte(password), salt, 2, 64*1024, 1, pwdHashSize) // Compare the computed hash with the stored hash - // todo constant time? - return bytes.Equal(hash, computedHash), nil + return subtle.ConstantTimeCompare(hash, computedHash) == 1, nil } // DeleteUser deletes a user with the specified username from the database. -- 2.44.0 From f5e107e3a0d29d1f6160234b8ac0974b13bc5fd3 Mon Sep 17 00:00:00 2001 From: Yorick van Pelt Date: Sun, 30 Apr 2023 21:20:41 +0200 Subject: [PATCH 5/8] comparePassword: eliminate unsafe slice --- internal/db/user.go | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/internal/db/user.go b/internal/db/user.go index bb307f7..d1de281 100644 --- a/internal/db/user.go +++ b/internal/db/user.go @@ -89,13 +89,14 @@ func HashPassword(password string) (string, error) { return fmt.Sprintf("$%s$%s$%s$%s", pwdAlgo, pwdParams, encodedSalt, encodedHash), nil } +var errInvalidDBPasswordFormat = errors.New("invalid password format in db") func comparePassword(hashedPassword string, password string) (bool, error) { // Extract the salt and hash from the hashed password string - fields := strings.Split(hashedPassword, "$")[1:] - if len(fields) != 4 || fields[0] != pwdAlgo || fields[1] != pwdParams { - return false, errors.New("invalid password format in db") + fields := strings.Split(hashedPassword, "$") + if len(fields) != 5 || fields[1] != pwdAlgo || fields[2] != pwdParams { + return false, errInvalidDBPasswordFormat } - encodedSalt, encodedHash := fields[2], fields[3] + encodedSalt, encodedHash := fields[3], fields[4] // Decode the salt and hash from base64 salt, err := base64.RawStdEncoding.DecodeString(encodedSalt) -- 2.44.0 From 8f5ce1d9fc59ce9ad2e1947c0e2b8b6d8012d701 Mon Sep 17 00:00:00 2001 From: Yorick van Pelt Date: Sun, 30 Apr 2023 21:26:10 +0200 Subject: [PATCH 6/8] improve rl.authenticateUser --- handlers.go | 39 ++++++++++++++++++--------------------- 1 file changed, 18 insertions(+), 21 deletions(-) diff --git a/handlers.go b/handlers.go index 35cb9e3..0d24945 100644 --- a/handlers.go +++ b/handlers.go @@ -246,22 +246,11 @@ 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) + user := rl.authenticateUser(w, r, false, nil) + if user == nil { 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) @@ -322,25 +311,33 @@ func (rl *rushlink) createUserHandler(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusCreated) } +func (rl *rushlink) setWWWAuthenticate(w http.ResponseWriter, r *http.Request) { + // Set authentication headers for Basic Authentication + w.Header().Set("WWW-Authenticate", `Basic realm="Restricted"`) + w.WriteHeader(http.StatusUnauthorized) +} + 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) + rl.setWWWAuthenticate(w, r) return nil } // Authenticate the user user, err := db.Authenticate(rl.db, username, password) - if err != nil || (shouldBeAdmin && !user.Admin && (canAlsoBe == nil || *canAlsoBe != user.User)) { + if err != nil { + rl.setWWWAuthenticate(w, r) + log.Printf("authentication failure: %s", err) + return nil + } + + if (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) - if err != nil { - log.Printf("authentication failure: %s", err) - } + rl.setWWWAuthenticate(w, r) + log.Printf("user '%s' should be admin (or '%s'), but isn't", username, canAlsoBe) return nil } return user -- 2.44.0 From 44e74b5d0cc39b7f0b1cb223b401392e6d8e778d Mon Sep 17 00:00:00 2001 From: Yorick van Pelt Date: Sun, 30 Apr 2023 21:38:30 +0200 Subject: [PATCH 7/8] passwords: add version field, more constants for params --- internal/db/user.go | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/internal/db/user.go b/internal/db/user.go index d1de281..54dcf1b 100644 --- a/internal/db/user.go +++ b/internal/db/user.go @@ -69,11 +69,21 @@ func Authenticate(db *gorm.DB, username string, password string) (*User, error) const ( pwdSaltSize = 16 pwdHashSize = 32 - pwdParams = "m=65536,t=2,p=1" + + // chosen from https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html + pwdMemory = 64 * 1024 + pwdThreads = 1 + pwdIterations = 2 + pwdParams = "m=65536,t=2,p=1,v=19" + pwdVersion = "v=19" pwdAlgo = "argon2id" ) func HashPassword(password string) (string, error) { + if argon2.Version != 19 { + // go has no static asserts + panic("Unexpected argon2 version") + } // Generate a salt for the password hash salt := make([]byte, pwdSaltSize) if _, err := rand.Read(salt); err != nil { @@ -81,22 +91,22 @@ func HashPassword(password string) (string, error) { } // Hash the password using argon2id - hash := argon2.IDKey([]byte(password), salt, 2, 64*1024, 1, pwdHashSize) + hash := argon2.IDKey([]byte(password), salt, pwdIterations, pwdMemory, pwdThreads, pwdHashSize) // Encode the salt and hash as a string in PHC format encodedSalt := base64.RawStdEncoding.EncodeToString(salt) encodedHash := base64.RawStdEncoding.EncodeToString(hash) - return fmt.Sprintf("$%s$%s$%s$%s", pwdAlgo, pwdParams, encodedSalt, encodedHash), nil + return fmt.Sprintf("$%s$%s$%s$%s$%s", pwdAlgo, pwdVersion, pwdParams, encodedSalt, encodedHash), nil } var errInvalidDBPasswordFormat = errors.New("invalid password format in db") func comparePassword(hashedPassword string, password string) (bool, error) { // Extract the salt and hash from the hashed password string fields := strings.Split(hashedPassword, "$") - if len(fields) != 5 || fields[1] != pwdAlgo || fields[2] != pwdParams { + if len(fields) != 6 || fields[1] != pwdAlgo || fields[2] != pwdVersion || fields[3] != pwdParams { return false, errInvalidDBPasswordFormat } - encodedSalt, encodedHash := fields[3], fields[4] + encodedSalt, encodedHash := fields[4], fields[5] // Decode the salt and hash from base64 salt, err := base64.RawStdEncoding.DecodeString(encodedSalt) @@ -109,7 +119,7 @@ func comparePassword(hashedPassword string, password string) (bool, error) { } // Hash the password using the extracted salt and parameters - computedHash := argon2.IDKey([]byte(password), salt, 2, 64*1024, 1, pwdHashSize) + computedHash := argon2.IDKey([]byte(password), salt, pwdIterations, pwdMemory, pwdThreads, pwdHashSize) // Compare the computed hash with the stored hash return subtle.ConstantTimeCompare(hash, computedHash) == 1, nil -- 2.44.0 From 388589331630337273243cad1377c890a9b26f51 Mon Sep 17 00:00:00 2001 From: Yorick van Pelt Date: Sun, 30 Apr 2023 21:44:48 +0200 Subject: [PATCH 8/8] db: split password & user --- internal/db/password.go | 72 +++++++++++++++++++++++++++++++++++++++++ internal/db/user.go | 62 ----------------------------------- 2 files changed, 72 insertions(+), 62 deletions(-) create mode 100644 internal/db/password.go diff --git a/internal/db/password.go b/internal/db/password.go new file mode 100644 index 0000000..ed7d038 --- /dev/null +++ b/internal/db/password.go @@ -0,0 +1,72 @@ +package db + +import ( + "crypto/rand" + "crypto/subtle" + "encoding/base64" + "errors" + "fmt" + "strings" + + "golang.org/x/crypto/argon2" +) + +const ( + pwdSaltSize = 16 + pwdHashSize = 32 + + // chosen from https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html + pwdMemory = 64 * 1024 + pwdThreads = 1 + pwdIterations = 2 + pwdParams = "m=65536,t=2,p=1,v=19" + pwdVersion = "v=19" + pwdAlgo = "argon2id" +) + +func HashPassword(password string) (string, error) { + if argon2.Version != 19 { + // go has no static asserts + panic("Unexpected argon2 version") + } + // Generate a salt for the password hash + salt := make([]byte, pwdSaltSize) + if _, err := rand.Read(salt); err != nil { + return "", err + } + + // Hash the password using argon2id + hash := argon2.IDKey([]byte(password), salt, pwdIterations, pwdMemory, pwdThreads, pwdHashSize) + + // Encode the salt and hash as a string in PHC format + encodedSalt := base64.RawStdEncoding.EncodeToString(salt) + encodedHash := base64.RawStdEncoding.EncodeToString(hash) + return fmt.Sprintf("$%s$%s$%s$%s$%s", pwdAlgo, pwdVersion, pwdParams, encodedSalt, encodedHash), nil +} + +var errInvalidDBPasswordFormat = errors.New("invalid password format in db") + +func comparePassword(hashedPassword string, password string) (bool, error) { + // Extract the salt and hash from the hashed password string + fields := strings.Split(hashedPassword, "$") + if len(fields) != 6 || fields[1] != pwdAlgo || fields[2] != pwdVersion || fields[3] != pwdParams { + return false, errInvalidDBPasswordFormat + } + encodedSalt, encodedHash := fields[4], fields[5] + + // Decode the salt and hash from base64 + salt, err := base64.RawStdEncoding.DecodeString(encodedSalt) + if err != nil { + return false, err + } + hash, err := base64.RawStdEncoding.DecodeString(encodedHash) + if err != nil { + return false, err + } + + // Hash the password using the extracted salt and parameters + computedHash := argon2.IDKey([]byte(password), salt, pwdIterations, pwdMemory, pwdThreads, pwdHashSize) + + // Compare the computed hash with the stored hash + return subtle.ConstantTimeCompare(hash, computedHash) == 1, nil +} diff --git a/internal/db/user.go b/internal/db/user.go index 54dcf1b..ce8417f 100644 --- a/internal/db/user.go +++ b/internal/db/user.go @@ -2,15 +2,12 @@ package db import ( "crypto/rand" - "crypto/subtle" "encoding/base64" "errors" "fmt" - "strings" "time" "github.com/google/uuid" - "golang.org/x/crypto/argon2" "gorm.io/gorm" ) @@ -66,65 +63,6 @@ func Authenticate(db *gorm.DB, username string, password string) (*User, error) return &user, nil } -const ( - pwdSaltSize = 16 - pwdHashSize = 32 - - // chosen from https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html - pwdMemory = 64 * 1024 - pwdThreads = 1 - pwdIterations = 2 - pwdParams = "m=65536,t=2,p=1,v=19" - pwdVersion = "v=19" - pwdAlgo = "argon2id" -) - -func HashPassword(password string) (string, error) { - if argon2.Version != 19 { - // go has no static asserts - panic("Unexpected argon2 version") - } - // Generate a salt for the password hash - salt := make([]byte, pwdSaltSize) - if _, err := rand.Read(salt); err != nil { - return "", err - } - - // Hash the password using argon2id - hash := argon2.IDKey([]byte(password), salt, pwdIterations, pwdMemory, pwdThreads, pwdHashSize) - - // Encode the salt and hash as a string in PHC format - encodedSalt := base64.RawStdEncoding.EncodeToString(salt) - encodedHash := base64.RawStdEncoding.EncodeToString(hash) - return fmt.Sprintf("$%s$%s$%s$%s$%s", pwdAlgo, pwdVersion, pwdParams, encodedSalt, encodedHash), nil -} - -var errInvalidDBPasswordFormat = errors.New("invalid password format in db") -func comparePassword(hashedPassword string, password string) (bool, error) { - // Extract the salt and hash from the hashed password string - fields := strings.Split(hashedPassword, "$") - if len(fields) != 6 || fields[1] != pwdAlgo || fields[2] != pwdVersion || fields[3] != pwdParams { - return false, errInvalidDBPasswordFormat - } - encodedSalt, encodedHash := fields[4], fields[5] - - // Decode the salt and hash from base64 - salt, err := base64.RawStdEncoding.DecodeString(encodedSalt) - if err != nil { - return false, err - } - hash, err := base64.RawStdEncoding.DecodeString(encodedHash) - if err != nil { - return false, err - } - - // Hash the password using the extracted salt and parameters - computedHash := argon2.IDKey([]byte(password), salt, pwdIterations, pwdMemory, pwdThreads, pwdHashSize) - - // Compare the computed hash with the stored hash - return subtle.ConstantTimeCompare(hash, computedHash) == 1, nil -} - // DeleteUser deletes a user with the specified username from the database. func DeleteUser(db *gorm.DB, username string) error { // Find the user by username -- 2.44.0