WIP: Add users system, required for uploading new pastes #77

Draft
yorick wants to merge 8 commits from yorick/rushlink:users into master
7 changed files with 383 additions and 13 deletions
Showing only changes of commit 788e75c4c1 - Show all commits

View File

@ -32,6 +32,10 @@ func main() {
log.Fatal(errors.Wrap(err, "migrating database"))
}
if err := db.CreateAdminUser(database, "admin"); err != nil {
Review

I would be fine with a command line option that allows the operator to create users.

I would be fine with a command line option that allows the operator to create users.
log.Fatalln(err)
yorick marked this conversation as resolved
Review

(not go fmted)

(not `go fmt`ed)
}
go rushlink.StartMetricsServer(*metricsListen, database, filestore)
rushlink.StartMainServer(*httpListen, database, filestore, *rootURL)
}

View File

@ -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) {
Review

(I would be fine with leaving this out of the HTTP part)

(I would be fine with leaving this out of the HTTP part)
// 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"))
Review

Missing err check

Missing `err` check
// 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)) {
yorick marked this conversation as resolved
Review

This could be correct, but it's not exactly readable. It's not clear what canAlsoBe means in this context, and what kind of values it should represent.

It's perhaps better to add a function like:

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)
}

and then implement the logic for authenticating the user

user, err := db.Authenticate(rl.db, username, password)
if err != nil {
	log.Printf("authentication failure: %v", err)
	rl.setWWWAuthenticate(w, r)
	return nil
}

if shouldBeAdmin && !user.Admin {
	log.Printf("user should be admin, but isn't: %v", err)
	rl.setWWWAuthenticate(w, r)
	return nil
}

// TODO: canAlsoBe?

// User has been authenticated

return user
This could be correct, but it's not exactly readable. It's not clear what `canAlsoBe` means in this context, and what kind of values it should represent. It's perhaps better to add a function like: ```go 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) } ``` and then implement the logic for authenticating the user ```go user, err := db.Authenticate(rl.db, username, password) if err != nil { log.Printf("authentication failure: %v", err) rl.setWWWAuthenticate(w, r) return nil } if shouldBeAdmin && !user.Admin { log.Printf("user should be admin, but isn't: %v", err) rl.setWWWAuthenticate(w, r) return nil } // TODO: canAlsoBe? // User has been authenticated return 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,

View File

@ -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"`
yorick marked this conversation as resolved
Review

(not go fmted)

(not `go fmt`ed)
// 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(),

View File

@ -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
},
},
})
}

View File

@ -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

153
internal/db/user.go Normal file
View File

@ -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
}
yorick marked this conversation as resolved
Review

(I would put these password-related const and funcs in a separate file, because they do nothing with regards to users)

(I would put these password-related `const` and `func`s in a separate file, because they do nothing with regards to users)
// 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)
yorick marked this conversation as resolved
Review

(nit: also define the 2, 64*1024, 1 in the var block above, such that you can reuse it in comparePassword; alternatively, create a function createHashFromPasswordAndSalt(password, salt []byte) []byte that does this for you... also specifying why you chose these parameters)

(nit: also define the `2`, `64*1024`, `1` in the `var` block above, such that you can reuse it in `comparePassword`; alternatively, create a function `createHashFromPasswordAndSalt(password, salt []byte) []byte` that does this for you... also specifying why you chose these parameters)
// Compare the computed hash with the stored hash
return bytes.Equal(hash, computedHash)
yorick marked this conversation as resolved
Review

According to the PHC format specification, padding characters are omitted. base64.URLEncoding includes padding characters. Use base64.RawURLEncoding instead.

(Technically speaking, you should use base64.RawStdEncoding, because that follows the exact alphabet as the specification. If you decide that base64.URLEncoding (or base64.RawURLEncoding) also works, then make it clear in the comment that you deviate from the specification).

According to the [PHC format specification](https://github.com/P-H-C/phc-string-format/blob/master/phc-sf-spec.md#b64), padding characters are omitted. `base64.URLEncoding` includes padding characters. Use `base64.RawURLEncoding` instead. (Technically speaking, you should use `base64.RawStdEncoding`, because that follows the exact alphabet as the specification. If you decide that `base64.URLEncoding` (or `base64.RawURLEncoding`) also works, then make it clear in the comment that you deviate from the specification).
}
yorick marked this conversation as resolved
Review

Is there a specific reason you omitted the (optional) field version? The argon2 package defines one, so it seems a good idea to include that as well.

Is there a specific reason you omitted the (optional) field `version`? The `argon2` package [defines one](https://pkg.go.dev/golang.org/x/crypto/argon2#Version), so it seems a good idea to include that as well.
// 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 {
yorick marked this conversation as resolved
Review

It's not safe to do a slice expression that requires at least two returned elements on a function that may return an empty slice, or a slice of length one.

It's not safe to do a slice expression that requires at least two returned elements on a function that may return an empty slice, or a slice of length one.
return err
}
yorick marked this conversation as resolved
Review

(another option: add var errInvalidDBPasswordFormat = errors.New("invalid password format in db") in the global scope, and return that here. Works nice with `errors.Is(err, errInvalidDBPasswordFormat), as I suppose we want to log such occurrences)

(another option: add `var errInvalidDBPasswordFormat = errors.New("invalid password format in db")` in the global scope, and return that here. Works nice with `errors.Is(err, errInvalidDBPasswordFormat), as I suppose we want to log such occurrences)
// Delete the user
if err := db.Delete(&user).Error; err != nil {
return err
}
yorick marked this conversation as resolved
Review

(See comment above on RawURLEncoding)

(See comment above on `RawURLEncoding`)
return nil
}
yorick marked this conversation as resolved
Review

(Idem)

(Idem)
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
yorick marked this conversation as resolved
Review

return subtle.ConstantTimeCompare(hash, computedHash) == 1, nil (from crypto/subtle)

`return subtle.ConstantTimeCompare(hash, computedHash) == 1, nil` (from `crypto/subtle`)
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
Review

I find it somewhat risky that this function may update the admin status. I would prefer a SetUserAdminStatus(db *gorm.DB, username string, isAdmin bool) error that only changes that bit. It also simplifies the code a bit that handles changing the user.

I find it somewhat risky that this function may update the admin status. I would prefer a `SetUserAdminStatus(db *gorm.DB, username string, isAdmin bool) error` that only changes that bit. It also simplifies the code a bit that handles changing the 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
}

View File

@ -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")