feat(auth): Implement database-backed user authentication and
Build and Test / build-and-test-backend (push) Successful in 40s
Build and Test / build-and-test-frontend (push) Successful in 36s

persistence

This commit introduces a major architectural change by replacing the
previous hardcoded user and data system with a robust, database-driven
approach. It lays the foundation for persistent data storage for all
application models.

Key Changes:

- **Database Integration:** - Added `GORM` and `SQLite` for the database
layer. - The database is now initialized and migrated on application
startup. - Configuration is managed through environment variables
(`DATABASE`, `USERS_FILE`).

- **User Authentication & Management:** - The login endpoint
(`/api/token`) now authenticates users against the database. - JWT
claims are now populated with real user data (`id`, `username`,
`is_superuser`). - Implemented a system to seed the database with
initial users from a `users.json` file.

- **New User API Endpoints:** - `GET /api/users`: Retrieves a list of
all users (superuser access required). - `GET /api/users/user`: Fetches
the data for the currently authenticated user based on their JWT.

- **Data Model Overhaul:** - Refactored all data models (`User`,
`Category`, `Operation`, `Subcategory`) to include GORM tags,
relationships, and a `BaseModel` with auto-generated UUIDs for primary
keys. - Separated user-related structs into `User` (database model),
`AuthRequest` (login payload), and `UserResponse` (safe API response).

- **Middleware & Routing:** - Improved JWT error handling for clearer
client-side messages. - Added a new middleware to allow passing the JWT
in the request body for more flexible client integration.
This commit is contained in:
wt
2025-08-08 23:47:36 +07:00
parent c6ed1d66b0
commit 599142af80
23 changed files with 458 additions and 108 deletions
+1
View File
@@ -1,2 +1,3 @@
go.sum
backend
wallet.sqlite
-17
View File
@@ -1,17 +0,0 @@
package api
import (
"github.com/gofiber/fiber/v2"
"github.com/golang-jwt/jwt"
)
func Accessible(c *fiber.Ctx) error {
return c.SendString("Accessible")
}
func Restricted(c *fiber.Ctx) error {
user := c.Locals("user").(*jwt.Token)
claims := user.Claims.(jwt.MapClaims)
name := claims["name"].(string)
return c.SendString("Welcome " + name)
}
+56
View File
@@ -0,0 +1,56 @@
package api
import (
"backend/database"
"backend/model"
"fmt"
"github.com/gofiber/fiber/v2"
"github.com/golang-jwt/jwt/v5"
)
func GetAllUsers(c *fiber.Ctx) error {
user := c.Locals("user").(*jwt.Token)
claims := user.Claims.(jwt.MapClaims)
userid := claims["id"].(string)
dbuser, e := database.GetUserById(userid)
if e != nil {
return c.Status(
fiber.StatusInternalServerError,
).JSON(
fiber.Map{
"error": e.Error(),
},
)
}
if !dbuser.IsSuperuser {
return c.Status(
fiber.StatusForbidden,
).JSON(
fiber.Map{
"error": "you are not superuser",
},
)
}
users, e := database.GetAllUsers()
if e != nil {
return fmt.Errorf("Error: %v", e)
}
var responseUsers []model.UserResponse
for i := range users {
responseUsers = append(
responseUsers,
model.UserResponse{
ID: users[i].ID,
Username: users[i].Username,
Email: users[i].Email,
Group: users[i].Group,
IsSuperuser: users[i].IsSuperuser,
},
)
}
return c.JSON(responseUsers)
}
+34
View File
@@ -0,0 +1,34 @@
package api
import (
"backend/database"
"backend/model"
"github.com/gofiber/fiber/v2"
"github.com/golang-jwt/jwt/v5"
)
func GetUserData(c *fiber.Ctx) error {
user := c.Locals("user").(*jwt.Token)
claims := user.Claims.(jwt.MapClaims)
userid := claims["id"].(string)
dbuser, e := database.GetUserById(userid)
if e != nil {
return c.Status(
fiber.StatusInternalServerError,
).JSON(
fiber.Map{
"error": e.Error(),
},
)
}
return c.JSON(model.UserResponse{
ID: dbuser.ID,
Username: dbuser.Username,
Email: dbuser.Email,
Group: dbuser.Group,
IsSuperuser: dbuser.IsSuperuser,
})
}
+11 -5
View File
@@ -1,13 +1,14 @@
package api
import (
"backend/database"
"backend/model"
"fmt"
"os"
"time"
"github.com/gofiber/fiber/v2"
"github.com/golang-jwt/jwt"
"github.com/golang-jwt/jwt/v5"
)
func Login(c *fiber.Ctx) error {
@@ -16,13 +17,18 @@ func Login(c *fiber.Ctx) error {
if err != nil {
return fmt.Errorf("Error parse body, %s", err)
}
if requestdata.Username != "tola" || requestdata.Password != "2808" {
return c.SendStatus(fiber.StatusUnauthorized)
dbuser, e := database.GetUserByUsername(requestdata.Username)
if e != nil {
return fiber.ErrInternalServerError
}
if dbuser.Password != requestdata.Password {
return fiber.ErrForbidden
}
claims := jwt.MapClaims{
"username": "tola",
"is_superuser": true,
"id": dbuser.ID,
"username": dbuser.Username,
"is_superuser": dbuser.IsSuperuser,
"exp": time.Now().Add(time.Hour * 72).Unix(),
}
+46
View File
@@ -0,0 +1,46 @@
package database
import (
"backend/model"
"os"
"github.com/glebarez/sqlite"
"github.com/gofiber/fiber/v2/log"
"gorm.io/gorm"
)
var Db *gorm.DB
func Init() error {
if os.Getenv("DATABASE") == "" {
log.Fatal("Error no DATABASE path or url")
}
var err error
Db, err = gorm.Open(sqlite.Open(os.Getenv("DATABASE")), &gorm.Config{})
if err != nil {
log.Error("Failed to connect database")
return err
}
return nil
}
func Migrate() {
Db.AutoMigrate(
&model.User{},
&model.Category{},
&model.Subcategory{},
&model.Operation{},
)
}
func InitDatabase() {
Init()
Migrate()
if os.Getenv("USERS_FILE") != "" {
allusers := GetUsersFromFile(os.Getenv("USERS_FILE"))
for i := range allusers {
CreateUser(&allusers[i])
}
}
}
+85
View File
@@ -0,0 +1,85 @@
package database
import (
"backend/model"
"encoding/json"
"errors"
"fmt"
"os"
"strings"
"gorm.io/gorm"
)
func CreateUser(user *model.User) error {
e := Db.Where(model.User{
Username: user.Username,
}).FirstOrCreate(&user).Error
if e != nil {
return e
}
return nil
}
func GetUserById(id string) (*model.User, error) {
var user model.User
e := Db.First(&user, "id = ?", id).Error
if e != nil {
if errors.Is(e, gorm.ErrRecordNotFound) {
return nil, fmt.Errorf("user not found")
}
return nil, e
}
return &user, nil
}
func GetUserByUsername(username string) (*model.User, error) {
var user model.User
e := Db.First(&user, "username = ?", username).Error
if e != nil {
if errors.Is(e, gorm.ErrRecordNotFound) {
return nil, fmt.Errorf("user not found")
}
return nil, e
}
return &user, nil
}
func DeleteUser(id string) error {
var user model.User
e := Db.First(&user, "id = ?", id).Error
if e != nil {
if errors.Is(e, gorm.ErrRecordNotFound) {
return fmt.Errorf("user not found")
}
return e
}
e = Db.Unscoped().Delete(&user).Error
if e != nil {
if errors.Is(e, gorm.ErrRecordNotFound) {
return fmt.Errorf("user not found")
}
return e
}
return nil
}
func GetUsersFromFile(filename string) []model.User {
data, e := os.ReadFile(filename)
if e != nil {
panic(e)
}
decoder := json.NewDecoder(strings.NewReader(string(data)))
var res []model.User
decoder.Decode(&res)
return res
}
func GetAllUsers() ([]model.User, error) {
var users []model.User
e := Db.Find(&users).Error
if e != nil {
return nil, e
}
return users, nil
}
+13 -1
View File
@@ -3,24 +3,36 @@ module backend
go 1.24.5
require (
github.com/glebarez/sqlite v1.11.0
github.com/gofiber/contrib/jwt v1.1.2
github.com/gofiber/fiber/v2 v2.52.9
github.com/golang-jwt/jwt v3.2.2+incompatible
github.com/google/uuid v1.6.0
github.com/joho/godotenv v1.5.1
gorm.io/gorm v1.30.1
)
require (
github.com/MicahParks/keyfunc/v2 v2.1.0 // indirect
github.com/andybalholm/brotli v1.1.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/glebarez/go-sqlite v1.21.2 // indirect
github.com/golang-jwt/jwt/v5 v5.2.2 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/klauspost/compress v1.17.9 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rivo/uniseg v0.2.0 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasthttp v1.51.0 // indirect
github.com/valyala/tcplisten v1.0.0 // indirect
golang.org/x/sys v0.28.0 // indirect
golang.org/x/text v0.20.0 // indirect
modernc.org/libc v1.22.5 // indirect
modernc.org/mathutil v1.5.0 // indirect
modernc.org/memory v1.5.0 // indirect
modernc.org/sqlite v1.23.1 // indirect
)
@@ -5,41 +5,34 @@ import "backend/model"
func GetAllCategories() ([]model.Category, error) {
return []model.Category{
{
ID: "1",
Title: "Electronics",
Icon: "😀",
Type: "expense",
},
{
ID: "2",
Title: "Clothing",
Icon: "😁",
Type: "expense",
},
{
ID: "3",
Title: "Books",
Icon: "😂",
Subcategories: []model.Subcategory{
{
ID: "1",
Title: "Fiction",
},
{
ID: "2",
Title: "Non-Fiction",
},
},
Type: "expense",
},
{
ID: "4",
Title: "Salary",
Icon: "😀",
Type: "income",
},
{
ID: "5",
Title: "asdf",
Icon: "😀",
Type: "income",
@@ -0,0 +1,36 @@
package internaljwt
import (
"backend/model"
"github.com/gofiber/fiber/v2"
)
func JwtFromBody(c *fiber.Ctx) error {
req := new(model.TokenRequest)
if err := c.BodyParser(req); err != nil {
return c.Status(
fiber.StatusBadRequest,
).JSON(
fiber.Map{
"error": "Cannot parse request body",
},
)
}
if req.Token == "" {
return c.Status(
fiber.StatusBadRequest,
).JSON(
fiber.Map{
"error": "Token not found in request body",
},
)
}
c.Request().Header.Set(
"Authorization", "Bearer "+req.Token,
)
return c.Next()
}
+50 -40
View File
@@ -8,12 +8,13 @@ import (
func GetAllOperation() ([]model.Operation, error) {
return []model.Operation{
{
ID: "1",
Title: "Operation 1",
Amount: 100,
Category: "Category 1",
SubCategory: model.Subcategory{
ID: "1",
Title: "Operation 1",
Amount: 100,
Category: model.Category{
Title: "category 1",
Type: "income",
},
Subcategory: model.Subcategory{
Title: "subcategory",
},
Date: "2022-01-01T00:00:00Z",
@@ -21,30 +22,35 @@ func GetAllOperation() ([]model.Operation, error) {
Icon: "😀",
},
{
ID: "2",
Title: "Operation 2",
Amount: 200,
Category: "Category 2",
Date: "2022-01-02T00:00:00Z",
Type: "expense",
Icon: "😂",
Title: "Operation 2",
Amount: 200,
Category: model.Category{
Title: "Category 2",
Type: "expense",
},
Date: "2022-01-02T00:00:00Z",
Type: "expense",
Icon: "😂",
},
{
ID: "2",
Title: "Operation 2",
Amount: 200,
Category: "Category 2",
Date: time.Now().Format(time.RFC3339),
Type: "expense",
Icon: "😂",
Title: "Operation 2",
Amount: 200,
Category: model.Category{
Title: "Category 2",
Type: "expense",
},
Date: time.Now().Format(time.RFC3339),
Type: "expense",
Icon: "😂",
},
{
ID: "1",
Title: "Operation 1",
Amount: 100,
Category: "Category 1",
SubCategory: model.Subcategory{
ID: "1",
Title: "Operation 1",
Amount: 100,
Category: model.Category{
Title: "Category 2",
Type: "expense",
},
Subcategory: model.Subcategory{
Title: "subcategory",
},
Date: "2022-01-02T00:00:00Z",
@@ -52,22 +58,26 @@ func GetAllOperation() ([]model.Operation, error) {
Icon: "😀",
},
{
ID: "2",
Title: "Operation 2",
Amount: 200,
Category: "Category 2",
Date: "2022-06-02T00:00:00Z",
Type: "expense",
Icon: "😂",
Title: "Operation 2",
Amount: 200,
Category: model.Category{
Title: "Category 2",
Type: "expense",
},
Date: "2022-06-02T00:00:00Z",
Type: "expense",
Icon: "😂",
},
{
ID: "2",
Title: "Operation 2",
Amount: 200,
Category: "Category 2",
Date: "2022-06-22T00:00:00Z",
Type: "expense",
Icon: "😂",
Title: "Operation 2",
Amount: 200,
Category: model.Category{
Title: "Category 2",
Type: "expense",
},
Date: "2022-06-22T00:00:00Z",
Type: "expense",
Icon: "😂",
},
}, nil
}
+18
View File
@@ -0,0 +1,18 @@
package model
import (
"github.com/google/uuid"
"gorm.io/gorm"
)
type BaseModel struct {
ID string `json:"id" gorm:"primaryKey"`
}
func (base *BaseModel) BeforeCreate(tx *gorm.DB) (err error) {
// generate new uuid if ID not created yet
if base.ID == "" {
base.ID = uuid.New().String()
}
return
}
@@ -1,9 +1,10 @@
package model
type Category struct {
ID string `json:"id"`
BaseModel
Icon string `json:"icon"`
Title string `json:"title"`
Subcategories []Subcategory `json:"subcategories"`
Subcategories []Subcategory `json:"subcategories" gorm:"foreignKey:CategoryID"`
Type string `json:"type"`
UserID string
}
+16
View File
@@ -0,0 +1,16 @@
package model
type Operation struct {
BaseModel
Title string `json:"title"`
Amount float64 `json:"amount"`
Note string `json:"note,omitempty"`
Icon string `json:"icon"`
Type string `json:"type"`
Date string `json:"date"`
UserID string
CategoryID string
SubcategoryID string
Category Category `gorm:"foreignKey:CategoryID"`
Subcategory Subcategory `gorm:"foreignKey:SubcategoryID"`
}
+8
View File
@@ -0,0 +1,8 @@
package model
type Subcategory struct {
BaseModel
Title string `json:"title"`
CategoryID string
UserID string
}
+5
View File
@@ -0,0 +1,5 @@
package model
type TokenRequest struct {
Token string `json:"token"`
}
+26
View File
@@ -0,0 +1,26 @@
package model
type User struct {
BaseModel
Username string `json:"username" gorm:"unique"`
Email string `json:"email" gorm:"unique"`
Password string `json:"password"`
Group string `json:"group"`
IsSuperuser bool `json:"is_superuser"`
Categories []Category `json:"categories" gorm:"foreignKey:UserID"`
Subcategories []Subcategory `json:"subcategories" gorm:"foreignKey:UserID"`
Operations []Operation `json:"operations" gorm:"foreignKey:UserID"`
}
type AuthRequest struct {
Username string `json:"username"`
Password string `json:"password"`
}
type UserResponse struct {
ID string `json:"id"`
Username string `json:"username"`
Email string `json:"email"`
Group string `json:"group"`
IsSuperuser bool `json:"is_superuser"`
}
-13
View File
@@ -1,13 +0,0 @@
package model
type Operation struct {
ID string `json:"id"`
Title string `json:"title"`
Amount float64 `json:"amount"`
Category string `json:"category"`
SubCategory Subcategory `json:"subcategory,omitzero"`
Note string `json:"note,omitempty"`
Icon string `json:"icon"`
Type string `json:"type"`
Date string `json:"date"`
}
-6
View File
@@ -1,6 +0,0 @@
package model
type Subcategory struct {
ID string `json:"id"`
Title string `json:"title"`
}
-14
View File
@@ -1,14 +0,0 @@
package model
type AuthRequest struct {
Username string `json:"username"`
Password string `json:"password"`
}
type User struct {
Username string `json:"username"`
Password string `json:"password"`
Email string `json:"email"`
Group string `json:"group"`
IsSuperuser bool `json:"is_superuser"`
}
+32 -3
View File
@@ -2,6 +2,8 @@ package server
import (
"backend/api"
"backend/database"
"backend/internal/internaljwt"
"backend/utils"
"os"
@@ -13,6 +15,7 @@ import (
func Configure(app *fiber.App) {
utils.LoadEnv("wallet.conf")
database.InitDatabase()
log.SetLevel(log.LevelDebug)
app.Use(cors.New(cors.Config{
AllowOrigins: "http://localhost:3000",
@@ -22,13 +25,39 @@ func Configure(app *fiber.App) {
apiGroup := app.Group("/api")
apiGroup.Post("/token", api.Login)
apiGroup.Get("/accessible", api.Accessible)
app.Use(jwtware.New(jwtware.Config{
app.Use(internaljwt.JwtFromBody, jwtware.New(jwtware.Config{
SigningKey: jwtware.SigningKey{
Key: []byte(os.Getenv("JWT_SECRET_KEY")),
},
ErrorHandler: func(c *fiber.Ctx, err error) error {
if err.Error() == "Missing or malformed JWT" {
return c.Status(
fiber.StatusBadRequest,
).JSON(
fiber.Map{
"status": "error",
"message": "Missing or malformed JWT",
"data": nil,
},
)
}
return c.Status(
fiber.StatusUnauthorized,
).JSON(
fiber.Map{
"status": "error",
"message": "Invalid or expired JWT",
"data": nil,
"error": err.Error(),
},
)
},
}))
apiGroup.Get("/rescticted", api.Restricted)
usersGroup := apiGroup.Group("/users")
usersGroup.Get("/", api.GetAllUsers)
usersGroup.Get("/user", api.GetUserData)
operationGroup := apiGroup.Group("/operation")
operationGroup.Get("/", api.GetAllOperation)
+16
View File
@@ -0,0 +1,16 @@
[
{
"username": "tola",
"email": "mail@example.com",
"password": "2808",
"group": "root",
"is_superuser": true
},
{
"username": "lisa",
"email": "lisa@example.com",
"password": "asdf",
"group": "users",
"is_superuser": false
}
]
+2
View File
@@ -3,3 +3,5 @@ HTTP_ADDRESS=0.0.0.0:43243
ENABLE_TLS=true
CERT_FILE=/home/tola/certs/localhost/localhost.crt
KEY_FILE=/home/tola/certs/localhost/localhost.key
USERS_FILE=users.json
DATABASE=wallet.sqlite