From dd044cf5d070ed053bb260adfb961a32cd0baa35 Mon Sep 17 00:00:00 2001 From: Bram Date: Sat, 21 Mar 2026 03:12:13 +0100 Subject: [PATCH] Add setup-flow --- .env.template | 6 - Dockerfile | 3 + cmd/server/main.go | 58 ++++-- internal/api/middleware/auth.go | 4 - internal/db/db.go | 6 + internal/file/model.go | 1 + internal/file/service.go | 4 +- internal/user/errors.go | 2 + internal/user/handler.go | 58 +++++- internal/user/model.go | 7 +- internal/user/repository.go | 15 ++ internal/user/routes.go | 8 +- internal/user/service.go | 65 +++++++ internal/web/handler.go | 4 + internal/web/routes.go | 7 +- templates/admin.html | 1 + templates/changePassword.html | 307 ++++++++++++++++++++++++++++++++ 17 files changed, 519 insertions(+), 37 deletions(-) delete mode 100644 .env.template create mode 100644 templates/changePassword.html diff --git a/.env.template b/.env.template deleted file mode 100644 index edc8a2b..0000000 --- a/.env.template +++ /dev/null @@ -1,6 +0,0 @@ -PORT=8000 -JWT_SECRET= -ADMIN_PASSWORD= - -DOMAIN=http://localhost:8000 -USE_HTTPS=false \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index c211a86..d4df33c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -28,6 +28,9 @@ COPY --from=builder /app/.env ./ RUN mkdir -p /app/uploads +RUN adduser -D appuser +USER appuser + ENV GIN_MODE=release EXPOSE 8000 diff --git a/cmd/server/main.go b/cmd/server/main.go index 88cc035..a0e6ef8 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -7,6 +7,8 @@ import ( "ResendIt/internal/user" "ResendIt/internal/util" "ResendIt/internal/web" + "crypto/rand" + "encoding/base64" "errors" "fmt" "html/template" @@ -45,7 +47,7 @@ func main() { r.LoadHTMLGlob("templates/*.html") //r.LoadHTMLGlob("internal/templates/new/*.html") - r.Static("/static", "/static") + r.Static("/static", "./static") r.GET("/ping", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{ @@ -78,36 +80,54 @@ func main() { file.RegisterRoutes(apiRoute, fileHandler) webHandler := web.NewHandler(fileService) - web.RegisterRoutes(r, webHandler) + web.RegisterRoutes(r, webHandler, userService) - err = r.Run(":" + os.Getenv("PORT")) + port := os.Getenv("PORT") + if port == "" { + port = "8080" + } + + err = r.Run(":" + port) if err != nil { return } } +func generateRandomPassword(length int) string { + b := make([]byte, length) + if _, err := rand.Read(b); err != nil { + panic(err) + } + return base64.URLEncoding.EncodeToString(b)[:length] +} + func createAdminUser(service *user.Service) { - //Check if admin user already exists _, err := service.FindByUsername("admin") + if err == nil { fmt.Println("Admin user already exists, skipping creation") return - } else if !errors.Is(err, user.ErrUserNotFound) { - fmt.Printf("Error checking for admin user: %v\n", err) + } else if errors.Is(err, user.ErrUserNotFound) { + fmt.Println("Admin user not found, creating new admin user") + + password := generateRandomPassword(16) + adminUser, err := service.CreateUser("admin", password, "admin") + if err != nil { + fmt.Printf("Error creating admin user: %v\n", err) + return + } + + adminUser.ForceChangePassword = true + _, err = service.UpdateUser(adminUser) + + if err != nil { + fmt.Printf("Error creating admin user: %v\n", err) + } else { + fmt.Printf("Admin user created with random password: %s\n", password) + } return } - adminPassword, exists := os.LookupEnv("ADMIN_PASSWORD") - if !exists || adminPassword == "" { - fmt.Println("ADMIN_PASSWORD not set in environment variables") - fmt.Println("NO ADMIN ACCOUNT WILL BE CREATED") - return - } - - _, err = service.CreateUser("admin", adminPassword, "admin") - if err != nil { - fmt.Printf("Error creating admin user: %v\n", err) - } else { - fmt.Println("Admin user created successfully") - } + fmt.Printf("Error checking for admin user: %v\n", err) + return } diff --git a/internal/api/middleware/auth.go b/internal/api/middleware/auth.go index f211a69..b236982 100644 --- a/internal/api/middleware/auth.go +++ b/internal/api/middleware/auth.go @@ -22,13 +22,11 @@ func AuthMiddleware() gin.HandlerFunc { var tokenString string - // 🔥 1. Try cookie first (NEW) cookie, err := c.Cookie("auth_token") if err == nil && cookie != "" { tokenString = cookie } - // 🔥 2. Fallback to Authorization header (for API tools / future SPA) if tokenString == "" { authHeader := c.GetHeader("Authorization") @@ -40,13 +38,11 @@ func AuthMiddleware() gin.HandlerFunc { } } - // ❌ No token at all if tokenString == "" { abortUnauthorized(c) return } - // 🔐 Parse JWT claims := &Claims{} token, err := jwt.ParseWithClaims(tokenString, claims, func(token *jwt.Token) (interface{}, error) { if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { diff --git a/internal/db/db.go b/internal/db/db.go index 3d01bbe..dfc60ae 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -13,7 +13,13 @@ import ( func Connect() (*gorm.DB, error) { dbType := os.Getenv("DB_TYPE") + if dbType == "" { + dbType = "sqlite" + } dsn := os.Getenv("DATABASE_URL") + if dbType == "sqlite" && dsn == "" { + dsn = "./data/database.db" + } switch dbType { case "sqlite": diff --git a/internal/file/model.go b/internal/file/model.go index ddcf87c..025ace2 100644 --- a/internal/file/model.go +++ b/internal/file/model.go @@ -7,6 +7,7 @@ import ( type FileRecord struct { ID string `gorm:"primaryKey" json:"id"` DeletionID string `json:"deletion_id"` + ViewID string `json:"view_id"` Filename string `json:"filename"` Path string `json:"-"` // file path on disk (not exposed via JSON) ExpiresAt time.Time `json:"expires_at"` diff --git a/internal/file/service.go b/internal/file/service.go index 433f196..a65f39c 100644 --- a/internal/file/service.go +++ b/internal/file/service.go @@ -3,6 +3,7 @@ package file import ( "io" "os" + "path/filepath" "time" "github.com/google/uuid" @@ -29,7 +30,8 @@ func (s *Service) UploadFile(filename string, data io.Reader, deleteAfterDownloa return nil, err } - path := folderPath + "/" + filename + safeName := uuid.NewString() + filepath.Ext(filename) + path := filepath.Join(folderPath, safeName) out, err := os.Create(path) if err != nil { diff --git a/internal/user/errors.go b/internal/user/errors.go index c2e58b3..22dc4b6 100644 --- a/internal/user/errors.go +++ b/internal/user/errors.go @@ -3,3 +3,5 @@ package user import "errors" var ErrUserNotFound = errors.New("user not found") +var ErrPasswordsDoNotMatch = errors.New("Incorrect old password") +var ErrInvalidPassword = errors.New("invalid password") diff --git a/internal/user/handler.go b/internal/user/handler.go index f377728..8690b46 100644 --- a/internal/user/handler.go +++ b/internal/user/handler.go @@ -1,6 +1,10 @@ package user -import "github.com/gin-gonic/gin" +import ( + "fmt" + + "github.com/gin-gonic/gin" +) type Handler struct { service *Service @@ -30,3 +34,55 @@ func (h *Handler) Register(c *gin.Context) { c.JSON(201, gin.H{"id": user.ID, "username": user.Username, "role": user.Role}) } + +func (h *Handler) ChangePassword(c *gin.Context) { + var req struct { + OldPassword string `json:"old_password"` + NewPassword string `json:"new_password"` + } + + userID, exists := c.Get("user_id") + if !exists { + fmt.Println("User ID not found in context") + c.JSON(401, gin.H{"error": "unauthorized"}) + return + } + + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(400, gin.H{"error": "invalid request"}) + return + } + + err := h.service.ChangePassword(userID.(string), req.OldPassword, req.NewPassword) + if err != nil { + c.JSON(500, gin.H{"error": err.Error()}) + return + } + + c.JSON(200, gin.H{"message": "password changed successfully"}) +} + +func ForcePasswordChangeMiddleware(userService *Service) gin.HandlerFunc { + return func(c *gin.Context) { + userID, exists := c.Get("user_id") + if !exists { + c.Next() + return + } + + user, err := userService.FindByID(userID.(string)) + if err != nil { + c.AbortWithStatus(500) + return + } + + // Allow access to change password page itself + if user.ForceChangePassword && c.Request.URL.Path != "/change-password" { + c.Redirect(302, "/change-password") + c.Abort() + return + } + + c.Next() + } +} diff --git a/internal/user/model.go b/internal/user/model.go index 13e8d34..abd974b 100644 --- a/internal/user/model.go +++ b/internal/user/model.go @@ -4,7 +4,8 @@ import "gorm.io/gorm" type User struct { gorm.Model - Username string `gorm:"uniqueIndex;not null"` - PasswordHash string `gorm:"not null"` - Role string `gorm:"not null"` + Username string `gorm:"uniqueIndex;not null"` + PasswordHash string `gorm:"not null"` + Role string `gorm:"not null"` + ForceChangePassword bool `gorm:"default:false"` } diff --git a/internal/user/repository.go b/internal/user/repository.go index ddc9caa..ae35830 100644 --- a/internal/user/repository.go +++ b/internal/user/repository.go @@ -25,6 +25,17 @@ func (r *Repository) FindByUsername(username string) (*User, error) { return &u, nil } +func (r *Repository) FindByID(id string) (*User, error) { + var u User + if err := r.db.First(&u, id).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrUserNotFound + } + return nil, err + } + return &u, nil +} + func (r *Repository) Create(u *User) error { return r.db.Create(u).Error } @@ -37,6 +48,10 @@ func (r *Repository) GetAll() ([]User, error) { return users, nil } +func (r *Repository) Update(u *User) error { + return r.db.Save(u).Error +} + func (r *Repository) Delete(id uint) error { return r.db.Delete(&User{}, id).Error } diff --git a/internal/user/routes.go b/internal/user/routes.go index acbdba4..4a998b4 100644 --- a/internal/user/routes.go +++ b/internal/user/routes.go @@ -1,11 +1,17 @@ package user import ( + "ResendIt/internal/api/middleware" + "github.com/gin-gonic/gin" ) func RegisterRoutes(r *gin.RouterGroup, h *Handler) { - //auth := r.Group("/user") + auth := r.Group("/user") + auth.Use(middleware.AuthMiddleware()) + auth.Use(middleware.RequireRole("admin")) + + auth.POST("/change-password", h.ChangePassword) //auth.POST("/register", h.Register) } diff --git a/internal/user/service.go b/internal/user/service.go index 9cfcf43..811f819 100644 --- a/internal/user/service.go +++ b/internal/user/service.go @@ -35,6 +35,66 @@ func (s *Service) CreateUser(username, password, role string) (*User, error) { return u, nil } +// UpdateUser updates a user's information +func (s *Service) UpdateUser(user *User) (*User, error) { + if err := s.repo.Update(user); err != nil { + return nil, err + } + return user, nil +} + +func validNewPassword(oldPassword, newPassword string) bool { + if oldPassword == newPassword { + return false + } + if len(newPassword) < 8 { + return false + } + //Contains 1 uppercase, 1 lowercase, 1 number + hasUpper := false + hasLower := false + hasNumber := false + for _, c := range newPassword { + switch { + case 'A' <= c && c <= 'Z': + hasUpper = true + case 'a' <= c && c <= 'z': + hasLower = true + case '0' <= c && c <= '9': + hasNumber = true + } + } + if !hasUpper || !hasLower || !hasNumber { + return false + } + return true +} + +func (s *Service) ChangePassword(userID string, oldPassword string, newPassword string) error { + user, err := s.repo.FindByID(userID) + if err != nil { + return err + } + + if !validNewPassword(oldPassword, newPassword) { + return ErrInvalidPassword + } + + if !security.CheckPassword(oldPassword, user.PasswordHash) { + return ErrPasswordsDoNotMatch + } + + newHash, err := security.HashPassword(newPassword) + if err != nil { + return err + } + + user.PasswordHash = newHash + user.ForceChangePassword = false + + return s.repo.Update(user) +} + // GetAllUsers returns all users func (s *Service) GetAllUsers() ([]User, error) { return s.repo.GetAll() @@ -53,3 +113,8 @@ func (s *Service) DeleteUser(requesterID, targetID uint) error { func (s *Service) FindByUsername(username string) (*User, error) { return s.repo.FindByUsername(username) } + +// FindByID returns a user by ID +func (s *Service) FindByID(id string) (*User, error) { + return s.repo.FindByID(id) +} diff --git a/internal/web/handler.go b/internal/web/handler.go index 031184b..12d456e 100644 --- a/internal/web/handler.go +++ b/internal/web/handler.go @@ -64,3 +64,7 @@ func (h *Handler) Logout(c *gin.Context) { c.SetCookie("auth_token", "", -1, "/", "", false, true) c.Redirect(302, "/") } + +func (h *Handler) ChangePasswordPage(c *gin.Context) { + c.HTML(200, "changePassword.html", nil) +} diff --git a/internal/web/routes.go b/internal/web/routes.go index 0e00a7b..c436a7b 100644 --- a/internal/web/routes.go +++ b/internal/web/routes.go @@ -2,19 +2,22 @@ package web import ( "ResendIt/internal/api/middleware" + "ResendIt/internal/user" "github.com/gin-gonic/gin" ) -func RegisterRoutes(r *gin.Engine, h *Handler) { +func RegisterRoutes(r *gin.Engine, h *Handler, userService *user.Service) { r.GET("/", h.Index) - r.GET("/upload", h.UploadPage) + //r.GET("/upload", h.UploadPage) r.GET("/login", h.LoginPage) adminRoutes := r.Group("/") adminRoutes.Use(middleware.AuthMiddleware()) adminRoutes.Use(middleware.RequireRole("admin")) + adminRoutes.Use(user.ForcePasswordChangeMiddleware(userService)) adminRoutes.GET("/admin", h.AdminPage) adminRoutes.GET("/logout", h.Logout) + adminRoutes.GET("/change-password", h.ChangePasswordPage) } diff --git a/templates/admin.html b/templates/admin.html index 1e1e507..6a12f01 100644 --- a/templates/admin.html +++ b/templates/admin.html @@ -214,6 +214,7 @@ if (event.target == overlay) closeModal(); } +CHANGE PASSWORD \ No newline at end of file diff --git a/templates/changePassword.html b/templates/changePassword.html new file mode 100644 index 0000000..3bc0cdb --- /dev/null +++ b/templates/changePassword.html @@ -0,0 +1,307 @@ + + + + + + Change Password + + + + + + +
+
+
+

Change_Password

+
+ +
+ +
+ +
⚠ ERROR: Passwords do not match.
+
✓ PASSWORD UPDATED. Credential change committed.
+ +
+ + +
+ +
+ + +
+
+
+ +
+
Requirements
+
    +
  • Min 8 characters
  • +
  • Uppercase letter
  • +
  • Number
  • +
+
+ +
+ + +
+
+ + +
+ +
+ + + + + \ No newline at end of file