Compare commits

...
3 Commits
Author SHA1 Message Date
julian 3a0ced9632 remove unncecessary comments
Build and Deploy / deploy (push) Failing after 2m20s
2026-07-02 22:40:29 +02:00
julian f56196e115 replace email with username 2026-07-02 22:40:23 +02:00
julian 532f3f5faa improve auth by using sessions (refresh tokens) 2026-07-02 22:39:58 +02:00
9 changed files with 187 additions and 58 deletions
+1 -1
View File
@@ -10,7 +10,7 @@ JWT_SECRET=your-super-secret-jwt-key-here
SYNC_INTERVAL_MINUTES=1 SYNC_INTERVAL_MINUTES=1
# Default Admin User Configuration # Default Admin User Configuration
ADMIN_EMAIL=admin@admin.com ADMIN_USERNAME=admin
ADMIN_PASSWORD=sheetless ADMIN_PASSWORD=sheetless
# Directories containing permanent data # Directories containing permanent data
+2 -2
View File
@@ -50,7 +50,7 @@ go build -o sheetless-server main.go
```bash ```bash
curl -X POST http://localhost:8080/auth/register \ curl -X POST http://localhost:8080/auth/register \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d '{"username":"testuser","email":"test@example.com","password":"password123"}' -d '{"username":"testuser","password":"password123"}'
``` ```
### Login: ### Login:
@@ -71,4 +71,4 @@ curl -X POST http://localhost:8080/api/sheets/upload \
### List sheets: ### List sheets:
```bash ```bash
curl -H "Authorization: Bearer TOKEN" http://localhost:8080/api/sheets curl -H "Authorization: Bearer TOKEN" http://localhost:8080/api/sheets
``` ```
+2 -2
View File
@@ -19,7 +19,7 @@ type Config struct {
Interval time.Duration Interval time.Duration
} }
Admin struct { Admin struct {
Email string Username string
Password string Password string
} }
SheetsDirectory string SheetsDirectory string
@@ -44,7 +44,7 @@ func Load() {
cfg.Sync.Interval = time.Duration(syncMinutes) * time.Minute cfg.Sync.Interval = time.Duration(syncMinutes) * time.Minute
// Admin configuration // Admin configuration
cfg.Admin.Email = getEnv("ADMIN_EMAIL", "admin@admin.com") cfg.Admin.Username = getEnv("ADMIN_USERNAME", "admin")
cfg.Admin.Password = getEnv("ADMIN_PASSWORD", "sheetless") cfg.Admin.Password = getEnv("ADMIN_PASSWORD", "sheetless")
cfg.SheetsDirectory = getEnv("SHEETS_DIRECTORY", "./sheets_directory") cfg.SheetsDirectory = getEnv("SHEETS_DIRECTORY", "./sheets_directory")
+6 -7
View File
@@ -29,7 +29,7 @@ func InitDatabase() {
isNewDatabase := len(tables) == 0 isNewDatabase := len(tables) == 0
// Auto migrate the schema // Auto migrate the schema
err = DB.AutoMigrate(&models.User{}, &models.Sheet{}, &models.Composer{}, &models.Annotation{}) err = DB.AutoMigrate(&models.User{}, &models.Sheet{}, &models.Composer{}, &models.Annotation{}, &models.Session{})
if err != nil { if err != nil {
log.Fatal("Failed to migrate database:", err) log.Fatal("Failed to migrate database:", err)
} }
@@ -44,10 +44,10 @@ func InitDatabase() {
func createDefaultAdminUser() { func createDefaultAdminUser() {
// Check if admin user already exists // Check if admin user already exists
var existingUser models.User var existingUser models.User
err := DB.Where("email = ?", config.AppConfig.Admin.Email).First(&existingUser).Error err := DB.Where("username = ?", config.AppConfig.Admin.Username).First(&existingUser).Error
if err == nil { if err == nil {
// Admin user already exists, don't recreate // Admin user already exists, don't recreate
log.Printf("Admin user already exists: %s", config.AppConfig.Admin.Email) log.Printf("Admin user already exists: %s", config.AppConfig.Admin.Username)
return return
} }
@@ -60,9 +60,8 @@ func createDefaultAdminUser() {
// Create admin user // Create admin user
adminUser := models.User{ adminUser := models.User{
Username: "admin", Username: "admin",
Email: config.AppConfig.Admin.Email, HashedPassword: string(hashedPassword),
Password: string(hashedPassword),
} }
if err := DB.Create(&adminUser).Error; err != nil { if err := DB.Create(&adminUser).Error; err != nil {
@@ -70,5 +69,5 @@ func createDefaultAdminUser() {
return return
} }
log.Printf("Default admin user created with email: %s", config.AppConfig.Admin.Email) log.Printf("Default admin user created with username: %s", config.AppConfig.Admin.Username)
} }
+143 -22
View File
@@ -1,6 +1,10 @@
package handlers package handlers
import ( import (
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"net/http" "net/http"
"sheetless-server/config" "sheetless-server/config"
"sheetless-server/database" "sheetless-server/database"
@@ -9,13 +13,13 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5" "github.com/golang-jwt/jwt/v5"
"github.com/google/uuid"
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
) )
type RegisterRequest struct { type RegisterRequest struct {
Username string `json:"username" binding:"required"` Username string `json:"username" binding:"required,min=4"`
Email string `json:"email" binding:"required,email"` Password string `json:"password" binding:"required,min=8"`
Password string `json:"password" binding:"required,min=6"`
} }
type LoginRequest struct { type LoginRequest struct {
@@ -23,6 +27,16 @@ type LoginRequest struct {
Password string `json:"password" binding:"required"` Password string `json:"password" binding:"required"`
} }
type RefreshRequest struct {
RefreshToken string `json:"refresh_token" binding:"required"`
}
type AccessTokenClaims struct {
UserUuid uuid.UUID `json:"user_uuid"`
SessionUuid uuid.UUID `json:"session_uuid"`
jwt.RegisteredClaims
}
func Register(c *gin.Context) { func Register(c *gin.Context) {
var req RegisterRequest var req RegisterRequest
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
@@ -32,8 +46,8 @@ func Register(c *gin.Context) {
// Check if user already exists // Check if user already exists
var existingUser models.User var existingUser models.User
if err := database.DB.Where("username = ? OR email = ?", req.Username, req.Email).First(&existingUser).Error; err == nil { if err := database.DB.Where("username = ?", req.Username).First(&existingUser).Error; err == nil {
c.JSON(http.StatusConflict, gin.H{"error": "User already exists"}) c.JSON(http.StatusConflict, gin.H{"error": "Invalid user name"})
return return
} }
@@ -45,12 +59,14 @@ func Register(c *gin.Context) {
} }
// Create user // Create user
now := time.Now()
user := models.User{ user := models.User{
Username: req.Username, Uuid: uuid.New(),
Email: req.Email, Username: req.Username,
Password: string(hashedPassword), HashedPassword: string(hashedPassword),
CreatedAt: now,
UpdatedAt: now,
} }
if err := database.DB.Create(&user).Error; err != nil { if err := database.DB.Create(&user).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create user"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create user"})
return return
@@ -66,30 +82,135 @@ func Login(c *gin.Context) {
return return
} }
// Find user // Check usename and password
var user models.User var user models.User
if err := database.DB.Where("username = ?", req.Username).First(&user).Error; err != nil { if err := database.DB.Where("username = ?", req.Username).First(&user).Error; err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid credentials"}) c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid credentials"})
return return
} }
if err := bcrypt.CompareHashAndPassword([]byte(user.HashedPassword), []byte(req.Password)); err != nil {
// Check password
if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(req.Password)); err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid credentials"}) c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid credentials"})
return return
} }
// Generate JWT token // Generate refresh token
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ refreshToken, err := generateRefreshToken()
"user_id": user.ID,
"exp": time.Now().Add(time.Hour * 24).Unix(),
})
tokenString, err := token.SignedString([]byte(config.AppConfig.JWT.Secret))
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate token"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate refresh token"})
return return
} }
c.JSON(http.StatusOK, gin.H{"token": tokenString}) // Create session
session := models.Session{
Uuid: uuid.New(),
UserUuid: user.Uuid,
HashedRefreshToken: hashRefreshToken(refreshToken),
CreatedAt: time.Now(),
Revoked: false,
}
if err := database.DB.Create(&session).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create session"})
return
}
// Generate access token (JWT)
accessToken, err := generateAccessToken(session)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate access token"})
return
}
c.JSON(http.StatusOK, gin.H{"refresh_token": refreshToken, "access_token": accessToken})
}
func Logout(c *gin.Context) {
userUuidVal, _ := c.Get("user_uuid")
userUuid, ok := userUuidVal.(uuid.UUID)
if !ok {
c.JSON(http.StatusInternalServerError, gin.H{"error": "invalid or missing user_uuid"})
return
}
sessionUuidVal, _ := c.Get("session_uuid")
sessionUuid, ok := sessionUuidVal.(uuid.UUID)
if !ok {
c.JSON(http.StatusInternalServerError, gin.H{"error": "invalid or missing session_uuid"})
return
}
// Delete session
result := database.DB.Where("uuid = ? AND user_uuid = ?", sessionUuid, userUuid).Delete(&models.Session{})
if result.Error != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Could not delete session"})
return
}
if result.RowsAffected == 0 {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Session not existing"})
return
}
c.Status(http.StatusOK)
}
func RefreshAccessToken(c *gin.Context) {
var req RefreshRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Check session
var session models.Session
if err := database.DB.Where("hashed_refresh_token = ?", hashRefreshToken(req.RefreshToken)).First(&session).Error; err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid refresh token"})
return
}
if session.Revoked {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Session revoked"})
return
}
// Generate access token (JWT)
accessToken, err := generateAccessToken(session)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate access token"})
return
}
c.JSON(http.StatusOK, gin.H{"access_token": accessToken})
}
func generateRefreshToken() (string, error) {
length := 32
b := make([]byte, length)
if _, err := rand.Read(b); err != nil {
return "", err
}
return hex.EncodeToString(b), nil
}
func hashRefreshToken(plainToken string) string {
hash := sha256.Sum256([]byte(plainToken))
return hex.EncodeToString(hash[:])
}
func verifyRefreshToken(plainToken string, dbHash string) bool {
incomingHash := hashRefreshToken(plainToken)
match := subtle.ConstantTimeCompare([]byte(incomingHash), []byte(dbHash))
return match == 1
}
func generateAccessToken(session models.Session) (string, error) {
now := time.Now()
accessTokenExpiry := now.Add(15 * time.Minute)
token := jwt.NewWithClaims(jwt.SigningMethodHS256,
AccessTokenClaims{
SessionUuid: session.Uuid,
UserUuid: session.UserUuid,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(accessTokenExpiry),
IssuedAt: jwt.NewNumericDate(now),
},
})
return token.SignedString([]byte(config.AppConfig.JWT.Secret))
} }
+9 -12
View File
@@ -1,8 +1,10 @@
package middleware package middleware
import ( import (
"log"
"net/http" "net/http"
"sheetless-server/config" "sheetless-server/config"
"sheetless-server/handlers"
"strings" "strings"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
@@ -13,7 +15,7 @@ func AuthMiddleware() gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
authHeader := c.GetHeader("Authorization") authHeader := c.GetHeader("Authorization")
if authHeader == "" { if authHeader == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authorization header required"}) c.JSON(http.StatusUnauthorized, gin.H{"error": "no_authorization_header"})
c.Abort() c.Abort()
return return
} }
@@ -21,28 +23,23 @@ func AuthMiddleware() gin.HandlerFunc {
// Extract token from "Bearer <token>" format // Extract token from "Bearer <token>" format
tokenString := strings.TrimPrefix(authHeader, "Bearer ") tokenString := strings.TrimPrefix(authHeader, "Bearer ")
if tokenString == authHeader { if tokenString == authHeader {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid token format"}) c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid_token_format"})
c.Abort() c.Abort()
return return
} }
// Parse and validate token // Parse and validate token
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) { var claims handlers.AccessTokenClaims
token, err := jwt.ParseWithClaims(tokenString, &claims, func(token *jwt.Token) (any, error) {
return []byte(config.AppConfig.JWT.Secret), nil return []byte(config.AppConfig.JWT.Secret), nil
}) })
if err != nil || !token.Valid { if err != nil || !token.Valid {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid token"}) c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid_token"})
c.Abort() c.Abort()
return return
} }
c.Set("user_uuid", claims.UserUuid)
// Extract claims c.Set("session_uuid", claims.SessionUuid)
if claims, ok := token.Claims.(jwt.MapClaims); ok {
if userID, ok := claims["user_id"].(float64); ok {
c.Set("user_id", uint(userID))
}
}
c.Next() c.Next()
} }
+14
View File
@@ -0,0 +1,14 @@
package models
import (
"github.com/google/uuid"
"time"
)
type Session struct {
Uuid uuid.UUID `json:"uuid" gorm:"type:uuid;primaryKey"`
UserUuid uuid.UUID `json:"user_uuid" gorm:"not null"`
HashedRefreshToken string `json:"-" gorm:"not null;unique"`
CreatedAt time.Time `json:"created_at"`
Revoked bool `json:"revoked"`
}
+8 -8
View File
@@ -3,15 +3,15 @@ package models
import ( import (
"time" "time"
"github.com/google/uuid"
"gorm.io/gorm" "gorm.io/gorm"
) )
type User struct { type User struct {
ID uint `json:"id" gorm:"primaryKey"` Uuid uuid.UUID `json:"uuid" gorm:"type:uuid;primaryKey"`
Username string `json:"username" gorm:"unique;not null"` Username string `json:"username" gorm:"unique;not null"`
Email string `json:"email" gorm:"unique;not null"` HashedPassword string `json:"-" gorm:"column:password;not null"` // Don't include in JSON responses
Password string `json:"-" gorm:"not null"` // Don't include in JSON responses CreatedAt time.Time `json:"created_at"`
CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"`
UpdatedAt time.Time `json:"updated_at"` DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"` }
}
+2 -4
View File
@@ -13,28 +13,26 @@ func SetupRoutes(r *gin.Engine) {
{ {
// auth.POST("/register", handlers.Register) // auth.POST("/register", handlers.Register)
auth.POST("/login", handlers.Login) auth.POST("/login", handlers.Login)
auth.POST("/refresh", handlers.RefreshAccessToken)
auth.POST("/logout", middleware.AuthMiddleware(), handlers.Logout) // User needs to be logged in
} }
// Protected routes // Protected routes
api := r.Group("/api") api := r.Group("/api")
api.Use(middleware.AuthMiddleware()) api.Use(middleware.AuthMiddleware())
{ {
// Health check for connection testing
api.GET("/health", handlers.HealthCheck) api.GET("/health", handlers.HealthCheck)
// Sheets endpoints
sheets := api.Group("/sheets") sheets := api.Group("/sheets")
{ {
sheets.POST("/upload", handlers.UploadSheet) sheets.POST("/upload", handlers.UploadSheet)
sheets.GET("/list", handlers.ListSheets) sheets.GET("/list", handlers.ListSheets)
sheets.GET("/get/:uuid", handlers.DownloadSheet) sheets.GET("/get/:uuid", handlers.DownloadSheet)
// Annotations endpoints (nested under sheets)
sheets.GET("/:uuid/annotations", handlers.GetAnnotations) sheets.GET("/:uuid/annotations", handlers.GetAnnotations)
sheets.POST("/:uuid/annotations", handlers.UpdateAnnotation) sheets.POST("/:uuid/annotations", handlers.UpdateAnnotation)
} }
// Composers endpoints
composers := api.Group("/composers") composers := api.Group("/composers")
{ {
composers.POST("/add", handlers.AddComposer) composers.POST("/add", handlers.AddComposer)