commit 4db4262195526ada0785a9009b6046b478469bab Author: Adrian ZΓΌrcher Date: Fri Oct 24 10:31:19 2025 +0200 first commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bf0824e --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +*.log \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..062cda0 --- /dev/null +++ b/README.md @@ -0,0 +1,193 @@ + +# πŸ›‘οΈ AccessHandler + +A lightweight **Golang authentication and access management module** built with **Gin**, **GORM**, and **JWT**. +It provides ready-to-use user authentication (login, refresh, logout, user info) with secure cookies and SQLite persistence. + +--- + +## πŸš€ Features + +- πŸ” **JWT-based authentication** (access + refresh tokens) +- πŸͺ **Secure HTTP-only cookies** +- 🧩 **Modular handler design** (AccessHandler, DBHandler) +- πŸ—ƒοΈ **SQLite via GORM** +- πŸͺ΅ **Structured logging** +- βš™οΈ **Plug-and-play Gin integration** + +--- + +## πŸ“‚ Project Structure + +``` +AccessHandler/ +β”œβ”€β”€ handlers/ +β”‚ β”œβ”€β”€ access_handler.go # AccessHandler initialization +β”‚ β”œβ”€β”€ db_handler.go # Database handler using GORM +β”‚ β”œβ”€β”€ login.go # Login, Refresh, Me, Logout handlers +β”‚ β”œβ”€β”€ middleware.go # middleware authentification +| β”œβ”€β”€ role.go # database handling for roles +| β”œβ”€β”€ user.go # database users for roles +| +β”œβ”€β”€ models/ +β”‚ β”œβ”€β”€ jsonResponse.go # Json responses model +β”‚ β”œβ”€β”€ permission.go # Permission model +β”‚ β”œβ”€β”€ role.go # Role model + validation +β”‚ β”œβ”€β”€ settings.go # Settings model +β”‚ β”œβ”€β”€ user.go # User model + validation +β”‚ +β”œβ”€β”€ utils/ +β”‚ β”œβ”€β”€ hash.go # Password hashing and verification +β”‚ +β”œβ”€β”€ main.go # Gin server entry point (example) +└── go.mod # Go module file +``` + +--- + +## βš™οΈ Installation + +```bash +git clone https://gitea.tecamino.com/paadi/AccessHandler.git +cd AccessHandler +go mod tidy +``` + +--- + +## 🧱 Dependencies + +This project uses: + +- [Gin Web Framework](https://github.com/gin-gonic/gin) +- [GORM ORM](https://gorm.io) +- [SQLite Driver for GORM](https://github.com/glebarez/sqlite) +- [Golang JWT v5](https://github.com/golang-jwt/jwt) +- [Tecamino Logger (custom)](https://gitea.tecamino.com/paadi/tecamino-logger/logging) + +Install manually (if needed): + +```bash +go get github.com/gin-gonic/gin +go get github.com/glebarez/sqlite +go get gorm.io/gorm +go get github.com/golang-jwt/jwt/v5 +go get https://gitea.tecamino.com/paadi/tecamino-logger/logging +``` + +--- + +## πŸ”‘ Authentication Constants + +In `handlers/login.go`: + +```go +// ----------------------------- +// πŸ” AUTHENTICATION CONSTANTS +// ----------------------------- +var DOMAIN = "localhost" +var ACCESS_TOKEN_TIME = 15 * time.Minute +var REFRESH_TOKEN_TIME = 72 * time.Hour + +var ACCESS_SECRET = []byte("*") // replace "*" with strong random bytes +var REFRESH_SECRET = []byte("*") +``` + +> πŸ’‘ In production, **never hardcode secrets** β€” use environment variables instead: +> +> ```go +> var ACCESS_SECRET = []byte(os.Getenv("ACCESS_SECRET")) +> var REFRESH_SECRET = []byte(os.Getenv("REFRESH_SECRET")) +> ``` + +--- + +## 🧠 API Endpoints + +| Method | Endpoint | Description | Auth Required | +|--------|------------------|--------------------------------------|----------------| +| `POST` | `/login` | Authenticate user, set JWT cookies | ❌ No | +| `GET` | `/refresh` | Refresh access token using cookie | βœ… Yes (refresh token) | +| `GET` | `/me` | Get current logged-in user info | βœ… Yes (access token) | +| `POST` | `/logout` | Clear cookies and logout | βœ… Yes | + +--- + +## πŸ§ͺ Example `main.go` + +```go +package main + +import ( + "AccessHandler/handlers" + "gitea.tecamino.com/paadi/tecamino-logger/logging" + "github.com/gin-gonic/gin" + "log" +) + +func main() { + logger, _ := logging.NewLogger("server.log", nil) + accessHandler, err := handlers.NewAccessHandler("access.db", logger) + if err != nil { + log.Fatal(err) + } + + r := gin.Default() + + // Auth routes + r.POST("/login", accessHandler.Login) + r.GET("/refresh", accessHandler.Refresh) + r.GET("/me", accessHandler.Me) + r.POST("/logout", accessHandler.Logout) + + logger.Info("Server", "running on http://localhost:8080") + r.Run(":8080") +} +``` + +--- + +## πŸ” Example Request + +**Login** +```bash +curl -X POST http://localhost:8080/login -H "Content-Type: application/json" -d '{"user_name": "admin", "password": "1234"}' +``` + +**Response** +```json +{ + "message": "login successful", + "id": 1, + "user": "admin", + "role": "admin", + "settings": "{}" +} +``` + +--- + +## 🧹 Database + +SQLite database is automatically created and migrated via: + +```go +gorm.Open(sqlite.Open("access.db"), &gorm.Config{}) +``` + +You can easily switch to another database by changing the driver. + +--- + +## πŸͺ΅ Logging + +All actions are logged through the `tecamino-logger` package for full observability of access and errors. + +--- + +## 🧰 Future Enhancements + +- βœ… Environment variable support for secrets +- βœ… Role-based authorization middleware +- βœ… Token revocation & blacklist +- βœ… Unit tests for authentication flow \ No newline at end of file diff --git a/db_test.go b/db_test.go new file mode 100644 index 0000000..d5ca885 --- /dev/null +++ b/db_test.go @@ -0,0 +1,181 @@ +package accessmanager_test + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "gitea.tecamino.com/paadi/AccessHandler/handlers" + "gitea.tecamino.com/paadi/AccessHandler/models" + + "github.com/gin-gonic/gin" + "github.com/go-playground/assert/v2" +) + +func TestAccesshandlerLogin(t *testing.T) { + t.Log("start access handler test") + + t.Log("initialize accessHandler") + accessHandler, err := handlers.NewAccessHandler("test.db", nil) + if err != nil { + t.Fatal(err) + } + + t.Log("add another user") + err = accessHandler.AddNewUser("guest", "guest@gmail.com", "passwordd1", "admin") + if err != nil { + t.Log(err) + } + + t.Log("get user id 1") + result, err := accessHandler.GetUserByKey("user_name", "admin", false) + if err != nil { + t.Fatal(err) + } + t.Log(result) + + t.Log("get all users") + result, err = accessHandler.GetUserById(0) + if err != nil { + t.Fatal(err) + } + t.Log(result) + + t.Log("get user by key") + result, err = accessHandler.GetUserByKey("password", "passwordd", false) + if err != nil { + t.Fatal(err) + } + t.Log(result) + + t.Log("get user by key and like") + result, err = accessHandler.GetUserByKey("user_name", "a*", true) + if err != nil { + t.Fatal(err) + } + t.Log(result) + + // var user_name string = "admin1" + // if len(result) > 0 { + // if result[0].Name == user_name { + // user_name = "admin" + // } + + // t.Log("update user to ", user_name) + + // accessHandler.UpdateUserByKey(models.User{ + // Name: user_name, + // }, "user_name", result[0].Name) + // } + t.Log("read user again") + result, err = accessHandler.GetUserByKey("user_name", "a*", true) + if err != nil { + t.Fatal(err) + } + t.Log(result) + + // t.Log("delete user id 1") + // err = accessHandler.DeleteUserByKey("user_name", user_name, false) + // if err != nil { + // t.Fatal(err) + // } + t.Log("read user again") + result, err = accessHandler.GetUserById(0) + if err != nil { + t.Fatal(err) + } + t.Log(result) + + t.Log("read admin permissions") + result1, err := accessHandler.GetRoleByKey("role", "admin", false) + if err != nil { + t.Fatal(err) + } + t.Log(result1) +} + +func TestLoginHandler(t *testing.T) { + gin.SetMode(gin.TestMode) + + // Setup your AccessHandler and router + aH, err := handlers.NewAccessHandler("test.db", nil) + if err != nil { + t.Fatal(err) + } + + r := gin.Default() + + handlers.SetMiddlewareLogger(r, aH.GetLogger()) + + r.POST("/login", aH.Login) + r.POST("/login/refresh", aH.Refresh) + r.GET("/login/me", aH.Me) + r.GET("/logout", aH.Logout) + middleware := r.Group("", handlers.AuthMiddleware()) + + auth := middleware.Group("/members", aH.AuthorizeRole("")) + auth.GET("", func(ctx *gin.Context) { + ctx.JSON(http.StatusOK, "ok") + }) + + // ---- Step 1: Perform login ---- + user := models.User{ + Name: "guest", + Password: "passwordd1", + } + jsonBody, _ := json.Marshal(user) + + req, _ := http.NewRequest(http.MethodPost, "/login", bytes.NewBuffer(jsonBody)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + r.ServeHTTP(w, req) + t.Log("Login response:", w.Body.String()) + assert.Equal(t, http.StatusOK, w.Code) + + // ---- Step 2: Extract cookies ---- + cookies := w.Result().Cookies() + var accessCookie *http.Cookie + var refreshCookie *http.Cookie + for _, c := range cookies { + switch c.Name { + case "refresh_token": + refreshCookie = c + case "access_token": + accessCookie = c + } + } + if refreshCookie == nil { + t.Fatal("refresh_token cookie not found") + } + + type request struct { + Name string + Method string + Path string + Cookie *http.Cookie + } + var requests []request + + requests = append(requests, + request{Name: "Refresh", Method: "POST", Path: "/login/refresh", Cookie: refreshCookie}, + request{Name: "Me", Method: "GET", Path: "/login/me", Cookie: accessCookie}, + request{Name: "Authorization", Method: "GET", Path: "/members", Cookie: accessCookie}, + request{Name: "Logout", Method: "GET", Path: "/logout", Cookie: refreshCookie}, + ) + + for _, request := range requests { + req, _ := http.NewRequest(request.Method, request.Path, nil) + if request.Cookie != nil { + req.AddCookie(request.Cookie) // attach refresh_token cookie + } + w := httptest.NewRecorder() + + r.ServeHTTP(w, req) + + t.Log(request.Name+" response:", w.Body.String()) + assert.Equal(t, http.StatusOK, w.Code) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..5e33fad --- /dev/null +++ b/go.mod @@ -0,0 +1,59 @@ +module gitea.tecamino.com/paadi/AccessHandler + +go 1.24.5 + +require ( + gitea.tecamino.com/paadi/tecamino-logger v0.2.1 + github.com/gin-gonic/gin v1.11.0 + github.com/glebarez/sqlite v1.11.0 + github.com/go-playground/assert/v2 v2.2.0 + github.com/golang-jwt/jwt/v5 v5.3.0 + golang.org/x/crypto v0.43.0 + gorm.io/gorm v1.31.0 +) + +require ( + github.com/bytedance/sonic v1.14.0 // indirect + github.com/bytedance/sonic/loader v0.3.0 // indirect + github.com/cloudwego/base64x v0.1.6 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/gabriel-vasile/mimetype v1.4.8 // indirect + github.com/gin-contrib/sse v1.1.0 // indirect + github.com/glebarez/go-sqlite v1.21.2 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.27.0 // indirect + github.com/goccy/go-json v0.10.2 // indirect + github.com/goccy/go-yaml v1.18.0 // indirect + github.com/google/uuid v1.3.0 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/quic-go/qpack v0.5.1 // indirect + github.com/quic-go/quic-go v0.54.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.3.0 // indirect + go.uber.org/mock v0.5.0 // indirect + go.uber.org/multierr v1.10.0 // indirect + go.uber.org/zap v1.27.0 // indirect + golang.org/x/arch v0.20.0 // indirect + golang.org/x/mod v0.28.0 // indirect + golang.org/x/net v0.45.0 // indirect + golang.org/x/sync v0.17.0 // indirect + golang.org/x/sys v0.37.0 // indirect + golang.org/x/text v0.30.0 // indirect + golang.org/x/tools v0.37.0 // indirect + google.golang.org/protobuf v1.36.9 // indirect + gopkg.in/natefinch/lumberjack.v2 v2.2.1 // 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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..8392991 --- /dev/null +++ b/go.sum @@ -0,0 +1,127 @@ +gitea.tecamino.com/paadi/tecamino-logger v0.2.1 h1:sQTBKYPdzn9mmWX2JXZBtGBvNQH7cuXIwsl4TD0aMgE= +gitea.tecamino.com/paadi/tecamino-logger v0.2.1/go.mod h1:FkzRTldUBBOd/iy2upycArDftSZ5trbsX5Ira5OzJgM= +github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ= +github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA= +github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA= +github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI= +github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= +github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM= +github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8= +github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= +github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= +github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk= +github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls= +github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo= +github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k= +github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw= +github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4= +github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo= +github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= +github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= +github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= +github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= +github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ= +github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo= +github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= +github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI= +github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg= +github.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg= +github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY= +github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA= +github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU= +go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM= +go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ= +go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= +go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c= +golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk= +golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04= +golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0= +golang.org/x/mod v0.28.0 h1:gQBtGhjxykdjY9YhZpSlZIsbnaE2+PgjfLWUQTnoZ1U= +golang.org/x/mod v0.28.0/go.mod h1:yfB/L0NOf/kmEbXjzCPOx1iK1fRutOydrCMsqRhEBxI= +golang.org/x/net v0.45.0 h1:RLBg5JKixCy82FtLJpeNlVM0nrSqpCRYzVU1n8kj0tM= +golang.org/x/net v0.45.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY= +golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= +golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= +golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k= +golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= +golang.org/x/tools v0.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE= +golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w= +google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw= +google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= +gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gorm.io/gorm v1.31.0 h1:0VlycGreVhK7RF/Bwt51Fk8v0xLiiiFdbGDPIZQ7mJY= +gorm.io/gorm v1.31.0/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= +modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE= +modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY= +modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ= +modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E= +modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds= +modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU= +modernc.org/sqlite v1.23.1 h1:nrSBg4aRQQwq59JpvGEQ15tNxoO5pX/kUjcRNwSAGQM= +modernc.org/sqlite v1.23.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk= diff --git a/handlers/accessHandler.go b/handlers/accessHandler.go new file mode 100644 index 0000000..a5f5716 --- /dev/null +++ b/handlers/accessHandler.go @@ -0,0 +1,125 @@ +package handlers + +import "gitea.tecamino.com/paadi/tecamino-logger/logging" + +// +// AccessHandler +// +// Description: +// AccessHandler manages access-related functionality, including +// database operations for users and roles, as well as logging. +// It encapsulates a database handler and a logger so that +// authentication and authorization operations can be performed +// consistently across the application. +// +type AccessHandler struct { + dbHandler *DBHandler // Database handler used for managing users and roles + logger *logging.Logger // Centralized application logger +} + +// +// NewAccessHandler +// +// Description: +// Creates and initializes a new AccessHandler instance. +// +// Behavior: +// 1. If a logger is not provided (nil), it creates a new logger instance +// that writes to "accessHandler.log". +// 2. Initializes the AccessHandler struct. +// 3. Sets up the internal DBHandler with the same logger. +// 4. Automatically creates required database tables and default data: +// - User table +// - Default user(s) +// - Role table +// - Default role(s) +// +// Parameters: +// - dbPath: The file path or connection string for the database. +// - logger: Optional pointer to a logging.Logger instance. If nil, a new one is created. +// +// Returns: +// - aH: A pointer to the fully initialized AccessHandler. +// - err: Any error that occurs during initialization. +// +// Example: +// handler, err := handlers.NewAccessHandler("data/app.db", appLogger) +// if err != nil { +// log.Fatal(err) +// } +// +func NewAccessHandler(dbPath string, logger *logging.Logger) (aH *AccessHandler, err error) { + if logger == nil { + logger, err = logging.NewLogger("accessHandler.log", nil) + if err != nil { + return + } + } + + logger.Debug("NewAccessHandler", "initialize new access handler") + + // Initialize AccessHandler with logger + aH = &AccessHandler{ + logger: logger, + } + + logger.Debug("NewAccessHandler", "initialize db handler") + // Create a new DB handler instance + aH.dbHandler, err = NewDBHandler(dbPath, logger) + if err != nil { + aH.logger.Error("NewAccessHandler", err) + return + } + + logger.Debug("NewAccessHandler", "add user table") + // Add the user table to the database + err = aH.AddUserTable() + if err != nil { + aH.logger.Error("NewAccessHandler", err) + return + } + + logger.Debug("NewAccessHandler", "add default user") + // Add default users to the system + err = aH.AddDefaultUser() + if err != nil { + aH.logger.Error("NewAccessHandler", err) + return + } + + logger.Debug("NewAccessHandler", "add role table") + // Add the role table to the database + err = aH.AddRoleTable() + if err != nil { + aH.logger.Error("NewAccessHandler", err) + return + } + + logger.Debug("NewAccessHandler", "add default role") + // Add default roles to the system + err = aH.AddDefaultRole() + if err != nil { + aH.logger.Error("NewAccessHandler", err) + } + + return +} + +// +// GetLogger +// +// Description: +// Returns the logger associated with this AccessHandler instance. +// Useful when another component or handler needs to reuse the +// same logging instance for consistent log output. +// +// Returns: +// - *logging.Logger: The logger assigned to this AccessHandler. +// +// Example: +// log := accessHandler.GetLogger() +// log.Info("Some event") +// +func (aH *AccessHandler) GetLogger() *logging.Logger { + return aH.logger +} diff --git a/handlers/dbHandler.go b/handlers/dbHandler.go new file mode 100644 index 0000000..9ac8fb4 --- /dev/null +++ b/handlers/dbHandler.go @@ -0,0 +1,300 @@ +package handlers + +import ( + "errors" + "fmt" + "reflect" + "strings" + + "gitea.tecamino.com/paadi/tecamino-logger/logging" + "github.com/glebarez/sqlite" + "gorm.io/gorm" +) + +// DBHandler +// +// Description: +// +// Wraps the GORM database connection and provides helper methods for +// common CRUD operations, as well as integrated logging for traceability. +// +// Fields: +// - db: Active GORM database connection. +// - logger: Pointer to a custom logger instance for structured logging. +type DBHandler struct { + db *gorm.DB + logger *logging.Logger +} + +// NewDBHandler +// +// Description: +// +// Creates a new database handler using the specified SQLite database file. +// +// Behavior: +// 1. Opens a GORM connection to the database file at `dbPath`. +// 2. Wraps it in a `DBHandler` struct with logging support. +// +// Parameters: +// - dbPath: Path to the SQLite database file. +// - logger: Logging instance to record DB operations. +// +// Returns: +// - dH: A pointer to the initialized `DBHandler`. +// - err: Any error encountered during database connection. +func NewDBHandler(dbPath string, logger *logging.Logger) (dH *DBHandler, err error) { + dH = &DBHandler{logger: logger} + logger.Debug("NewDBHandler", "open database "+dbPath) + dH.db, err = gorm.Open(sqlite.Open(dbPath), &gorm.Config{}) + return +} + +// addNewTable +// +// Description: +// +// Uses GORM’s `AutoMigrate` to create or update the database schema +// for the provided model type. +// +// Parameters: +// - model: Struct type representing the database table schema. +// +// Returns: +// - error: Any migration error encountered. +func (dH *DBHandler) addNewTable(model any) error { + return dH.db.AutoMigrate(&model) +} + +// addNewColum +// +// Description: +// +// Inserts a new record into the database table corresponding to `model`. +// +// Parameters: +// - model: Struct instance containing values to be inserted. +// +// Returns: +// - error: Any error encountered during record creation. +func (dH *DBHandler) addNewColum(model any) error { + return dH.db.Create(model).Error +} + +// getById +// +// Description: +// +// Retrieves a record (or all records) from a table by numeric ID. +// +// Behavior: +// - If `id == 0`, returns all records in the table. +// - Otherwise, fetches the record matching the given ID. +// +// Parameters: +// - model: Pointer to a slice or struct to store the result. +// - id: Numeric ID to query by. +// +// Returns: +// - error: Any query error or β€œnot found” message. +func (dH *DBHandler) getById(model any, id uint) error { + dH.logger.Debug("getById", "find id "+fmt.Sprint(id)) + + if id == 0 { + return dH.db.Find(model).Error + } + + err := dH.db.First(model, id).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return fmt.Errorf("no record found for id: %v", id) + } else if err != nil { + return fmt.Errorf("query failed: %w", err) + } + return nil +} + +// getByKey +// +// Description: +// +// Retrieves one or more records matching a key/value pair. +// +// Behavior: +// - If `LikeSearch` is true, performs a SQL LIKE query. +// - Otherwise, performs an exact match query. +// +// Parameters: +// - model: Pointer to a slice or struct to store results. +// - key: Column name (e.g., "email"). +// - value: Value to match or partially match. +// - LikeSearch: If true, replaces '*' with '%' for wildcard matching. +// +// Returns: +// - error: Any database query error. +func (dH *DBHandler) getByKey(model any, key string, value any, LikeSearch bool) error { + if LikeSearch { + value = strings.ReplaceAll(fmt.Sprint(value), "*", "%") + dH.logger.Debug("getByKey", "find like key "+key+" value "+fmt.Sprint(value)) + return dH.db.Where(key+" LIKE ?", value).Find(model).Error + } + + dH.logger.Debug("getByKey", "find equal key "+key+" value "+fmt.Sprint(value)) + return dH.db.Find(model, key+" = ?", value).Error +} + +// updateValuesById +// +// Description: +// +// Updates record fields based on their unique ID. +// +// Behavior: +// 1. Confirms that `model` is a pointer to a struct. +// 2. Fetches the record by ID. +// 3. Updates all non-zero fields using `gorm.Model.Updates`. +// +// Parameters: +// - model: Pointer to struct containing new values. +// - id: Numeric ID of the record to update. +// +// Returns: +// - error: If the model is invalid or query/update fails. +func (dH *DBHandler) updateValuesById(model any, id uint) error { + dH.logger.Debug("updateValuesById", "model"+fmt.Sprint(model)) + modelType := reflect.TypeOf(model) + if modelType.Kind() != reflect.Ptr { + return errors.New("model must be a pointer to struct") + } + + lookUpModel := reflect.New(modelType.Elem()).Interface() + if err := dH.getById(lookUpModel, id); err != nil { + return err + } + return dH.db.Model(lookUpModel).Updates(model).Error +} + +// updateValuesByKey +// +// Description: +// +// Updates records based on a key/value match. +// +// Behavior: +// 1. Confirms model type. +// 2. Fetches the matching record(s) using `getByKey`. +// 3. Updates all non-zero fields. +// +// Parameters: +// - model: Pointer to struct containing updated values. +// - key: Column name to filter by. +// - value: Value to match. +// +// Returns: +// - error: Any query or update error. +func (dH *DBHandler) updateValuesByKey(model any, key string, value any) error { + dH.logger.Debug("updateValuesByKey", "model"+fmt.Sprint(model)) + modelType := reflect.TypeOf(model) + if modelType.Kind() != reflect.Ptr { + return errors.New("model must be a pointer to struct") + } + + lookUpModel := reflect.New(modelType.Elem()).Interface() + if err := dH.getByKey(lookUpModel, key, value, false); err != nil { + return err + } + return dH.db.Model(lookUpModel).Updates(model).Error +} + +// deleteById +// +// Description: +// +// Deletes records by their ID(s). +// +// Behavior: +// - If the first ID == 0, all records in the table are deleted. +// - Otherwise, deletes the provided IDs. +// +// Parameters: +// - model: Model struct type representing the table. +// - id: Variadic list of IDs to delete. +// +// Returns: +// - error: Any deletion error. +func (dH *DBHandler) deleteById(model any, id ...uint) error { + if id[0] == 0 { + dH.logger.Debug("deleteById", "delete all") + return dH.db.Where("1 = 1").Delete(model).Error + } + + dH.logger.Debug("deleteById", "delete ids"+fmt.Sprint(id)) + if err := dH.exists(model, "id", id, false); err != nil { + return err + } + return dH.db.Delete(model, id).Error +} + +// deleteByKey +// +// Description: +// +// Deletes records that match a key/value pair. +// +// Behavior: +// - Supports LIKE queries if `LikeSearch` is true. +// +// Parameters: +// - model: Model struct type representing the table. +// - key: Column name to filter by. +// - value: Value to match. +// - LikeSearch: Whether to use wildcard search. +// +// Returns: +// - error: Any deletion error. +func (dH *DBHandler) deleteByKey(model any, key string, value any, LikeSearch bool) error { + if LikeSearch { + value = strings.ReplaceAll(fmt.Sprint(value), "*", "%") + dH.logger.Debug("deleteByKey", "delete like key "+key+" value "+fmt.Sprint(value)) + return dH.db.Where(key+" LIKE ?", value).Delete(model).Error + } + + dH.logger.Debug("deleteByKey", "delete equal key "+key+" value "+fmt.Sprint(value)) + return dH.db.Where(key+" = ?", value).Delete(model).Error +} + +// exists +// +// Description: +// +// Checks whether a record exists matching the specified key/value filter. +// +// Behavior: +// - Performs a `First` query on the database. +// - If `LikeSearch` is true, performs a LIKE query. +// - Returns an error if the record does not exist or query fails. +// +// Parameters: +// - model: Model struct type to search. +// - key: Column name to filter by. +// - value: Value to match. +// - LikeSearch: Whether to use wildcard search. +// +// Returns: +// - error: β€œno record found” or DB error. +func (dH *DBHandler) exists(model any, key string, value any, LikeSearch bool) (err error) { + if LikeSearch { + value = strings.ReplaceAll(fmt.Sprint(value), "*", "%") + key = key + " LIKE ?" + } else { + key = key + " = ?" + } + + dH.logger.Debug("exists", "check if exists key "+key+" value "+fmt.Sprint(value)) + err = dH.db.Where(key, value).First(model).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return fmt.Errorf("no record found for %s %v", key[:len(key)-1], value) + } else if err != nil { + return fmt.Errorf("query failed: %w", err) + } + return +} diff --git a/handlers/login.go b/handlers/login.go new file mode 100644 index 0000000..0499326 --- /dev/null +++ b/handlers/login.go @@ -0,0 +1,243 @@ +package handlers + +import ( + "fmt" + "log" + "net/http" + "time" + + "gitea.tecamino.com/paadi/AccessHandler/models" + "gitea.tecamino.com/paadi/AccessHandler/utils" + + "github.com/gin-gonic/gin" + "github.com/golang-jwt/jwt/v5" +) + +// ----------------------------- +// πŸ” AUTHENTICATION CONSTANTS +// ----------------------------- + +// JWT secrets (replace "*" with strong random values in production!) +var ACCESS_SECRET = []byte("ShFRprALcXjlosJ2hFCnGYGG3Ce2uRx6") +var REFRESH_SECRET = []byte("pQIjuX6g6Tzf0FEfdScxttT3hlL9NFaa") + +// DOMAIN defines where cookies are valid. Change this in production. +var DOMAIN = "localhost" + +// ACCESS_TOKEN_TIME defines how long access tokens are valid. +var ACCESS_TOKEN_TIME = 15 * time.Minute + +// REFRESH_TOKEN_TIME defines how long refresh tokens are valid. +var REFRESH_TOKEN_TIME = 72 * time.Hour + +// ----------------------------- +// 🧠 HANDLERS +// ----------------------------- + +// Login authenticates a user and returns JWT tokens (access + refresh). +func (aH *AccessHandler) Login(c *gin.Context) { + var user models.User + + aH.logger.Debug("Login", "bind JSON request") + if err := c.BindJSON(&user); err != nil { + aH.logger.Error("Login", err) + c.JSON(http.StatusBadRequest, models.NewJsonErrorResponse(err)) + return + } + + // Validate input + if !user.IsValid() { + aH.logger.Error("Login", "user empty") + c.JSON(http.StatusBadRequest, models.NewJsonMessageResponse("user empty")) + return + } + + // Fetch user record from DB + dbRecord, err := aH.GetUserByKey("user_name", user.Name, false) + if err != nil { + aH.logger.Error("Login", err) + c.JSON(http.StatusBadRequest, models.NewJsonErrorResponse(err)) + return + } + + if len(dbRecord) > 1 { + log.Println("multiple users found") + aH.logger.Error("Login", "more than one record found") + c.JSON(http.StatusInternalServerError, models.NewJsonMessageResponse("internal error")) + return + } + + // Check password + if !utils.CheckPassword(user.Password, dbRecord[0].Password) { + aH.logger.Error("Login", "invalid password") + c.JSON(http.StatusUnauthorized, models.NewJsonMessageResponse("invalid credentials")) + return + } + user = dbRecord[0] + + // ----------------------------- + // πŸ”‘ TOKEN CREATION + // ----------------------------- + aH.logger.Debug("Login", "create tokens") + + accessTokenExp := time.Now().Add(ACCESS_TOKEN_TIME) + refreshTokenExp := time.Now().Add(REFRESH_TOKEN_TIME) + + // Create access token + accessToken := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ + "id": user.Id, + "username": user.Name, + "role": user.Role, + "type": "access", + "exp": accessTokenExp.Unix(), + }) + + // Create refresh token + refreshToken := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ + "id": user.Id, + "username": user.Name, + "role": user.Role, + "type": "refresh", + "exp": refreshTokenExp.Unix(), + }) + + // Sign tokens + accessString, err := accessToken.SignedString(ACCESS_SECRET) + if err != nil { + aH.logger.Error("Login", "failed to sign access token") + c.JSON(http.StatusInternalServerError, models.NewJsonMessageResponse("could not create access token")) + return + } + + refreshString, err := refreshToken.SignedString(REFRESH_SECRET) + if err != nil { + aH.logger.Error("Login", "failed to sign refresh token") + c.JSON(http.StatusInternalServerError, models.NewJsonMessageResponse("could not create refresh token")) + return + } + + // ----------------------------- + // πŸͺ SET COOKIES + // ----------------------------- + aH.logger.Debug("Login", "set cookies") + + secure := gin.Mode() == gin.ReleaseMode + + c.SetCookie("access_token", accessString, int(time.Until(accessTokenExp).Seconds()), + "/", "", secure, true) + c.SetCookie("refresh_token", refreshString, int(time.Until(refreshTokenExp).Seconds()), + "/", "", secure, true) + + aH.logger.Info("Login", "user "+user.Name+" logged in successfully") + + c.JSON(http.StatusOK, gin.H{ + "message": "login successful", + "id": user.Id, + "user": user.Name, + "role": user.Role, + "settings": user.Settings, + }) +} + +// Refresh generates a new access token from a valid refresh token. +func (aH *AccessHandler) Refresh(c *gin.Context) { + aH.logger.Debug("Refresh", "get refresh cookie") + + refreshCookie, err := c.Cookie("refresh_token") + if err != nil { + aH.logger.Error("Refresh", "no refresh token") + c.JSON(http.StatusUnauthorized, gin.H{"message": "no refresh token"}) + return + } + + // Validate token + aH.logger.Debug("Refresh", "parse token") + token, err := jwt.Parse(refreshCookie, func(token *jwt.Token) (any, error) { + if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) + } + return REFRESH_SECRET, nil + }) + if err != nil || !token.Valid { + aH.logger.Error("Refresh", err) + c.JSON(http.StatusUnauthorized, gin.H{"message": "invalid refresh token"}) + return + } + + // Extract claims + claims, ok := token.Claims.(jwt.MapClaims) + if !ok || claims["type"] != "refresh" { + aH.logger.Error("Refresh", "invalid token type") + c.JSON(http.StatusUnauthorized, gin.H{"message": "invalid token type"}) + return + } + + username := claims["username"].(string) + id := int(claims["id"].(float64)) + role := claims["role"].(string) + + // Create new access token + aH.logger.Debug("Refresh", "create new access token") + accessExp := time.Now().Add(ACCESS_TOKEN_TIME) + newAccess := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ + "id": id, + "username": username, + "role": role, + "exp": accessExp.Unix(), + }) + accessString, _ := newAccess.SignedString(ACCESS_SECRET) + + // Set new access cookie + aH.logger.Debug("Refresh", "set new access cookie") + c.SetCookie("access_token", accessString, int(time.Until(accessExp).Seconds()), + "/", DOMAIN, gin.Mode() == gin.ReleaseMode, true) + + c.JSON(http.StatusOK, gin.H{"message": "token refreshed"}) +} + +// Me returns information about the currently authenticated user. +func (aH *AccessHandler) Me(c *gin.Context) { + aH.logger.Debug("Me", "get access cookie") + + cookie, err := c.Cookie("access_token") + if err != nil { + aH.logger.Error("Me", err) + c.JSON(http.StatusUnauthorized, gin.H{"message": "not logged in"}) + return + } + + // Parse and validate access token + aH.logger.Debug("Me", "parse token") + token, err := jwt.Parse(cookie, func(t *jwt.Token) (any, error) { + if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"]) + } + return ACCESS_SECRET, nil + }) + if err != nil || !token.Valid { + aH.logger.Error("Me", err) + c.JSON(http.StatusUnauthorized, gin.H{"message": "invalid token"}) + return + } + + claims := token.Claims.(jwt.MapClaims) + c.JSON(http.StatusOK, gin.H{ + "id": claims["id"], + "user": claims["username"], + "role": claims["role"], + }) +} + +// Logout clears authentication cookies and ends the session. +func (aH *AccessHandler) Logout(c *gin.Context) { + aH.logger.Info("Logout", "logout user") + + secure := gin.Mode() == gin.ReleaseMode + aH.logger.Debug("Logout", fmt.Sprintf("domain=%s secure=%t", DOMAIN, secure)) + + // Clear cookies + c.SetCookie("access_token", "", -1, "/", DOMAIN, secure, true) + c.SetCookie("refresh_token", "", -1, "/", DOMAIN, secure, true) + + c.JSON(http.StatusOK, gin.H{"message": "logged out"}) +} diff --git a/handlers/middleware.go b/handlers/middleware.go new file mode 100644 index 0000000..b49afe8 --- /dev/null +++ b/handlers/middleware.go @@ -0,0 +1,165 @@ +package handlers + +import ( + "fmt" + "log" + "net/http" + "strings" + + "gitea.tecamino.com/paadi/tecamino-logger/logging" + "github.com/gin-gonic/gin" + "github.com/golang-jwt/jwt/v5" +) + +// SetMiddlewareLogger +// +// Description: +// +// Registers a Gin middleware that attaches a custom logger instance +// to every incoming request via the Gin context. This allows other +// middleware and handlers to access the logger using: +// +// logger := c.MustGet("logger").(*logging.Logger) +// +// Parameters: +// - r: The Gin engine to which the middleware is applied. +// - logger: A pointer to the application's custom logger. +// +// Usage: +// +// handlers.SetMiddlewareLogger(router, logger) +func SetMiddlewareLogger(r *gin.Engine, logger *logging.Logger) { + // Add middleware that injects logger into context + r.Use(func(c *gin.Context) { + c.Set("logger", logger) + c.Next() + }) +} + +// AuthMiddleware +// +// Description: +// +// A Gin middleware that performs authentication using a JWT token stored +// in a cookie named "access_token". It validates the token and extracts +// the user's role from the claims, storing it in the Gin context. +// +// Behavior: +// - Requires that SetMiddlewareLogger was used earlier to inject the logger. +// - If the JWT cookie is missing or invalid, it aborts the request with +// an appropriate HTTP error (401 or 500). +// +// Returns: +// +// A Gin handler function that can be used as middleware. +// +// Usage: +// +// r.Use(handlers.AuthMiddleware()) +func AuthMiddleware() gin.HandlerFunc { + return func(c *gin.Context) { + // Retrieve logger from Gin context + middlewareLogger, ok := c.Get("logger") + if !ok { + log.Fatal("middleware logger not set β€” use SetMiddlewareLogger first") + c.AbortWithStatusJSON(http.StatusInternalServerError, http.StatusInternalServerError) + return + } + logger := middlewareLogger.(*logging.Logger) + + // Read access token from cookie + cookie, err := c.Cookie("access_token") + if err != nil { + logger.Error("AuthMiddleware", err) + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"message": "not logged in"}) + return + } + + // Parse and validate JWT token + token, err := jwt.Parse(cookie, func(t *jwt.Token) (any, error) { + return ACCESS_SECRET, nil + }) + if err != nil || !token.Valid { + logger.Error("AuthMiddleware", err) + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"message": "invalid token"}) + return + } + + // Extract custom claims (role) + if claims, ok := token.Claims.(jwt.MapClaims); ok { + role, _ := claims["role"].(string) + c.Set("role", role) + } + c.Next() + } +} + +// (AccessHandler).AuthorizeRole +// +// Description: +// +// A role-based authorization middleware. It checks whether the authenticated +// user (based on the "role" set in AuthMiddleware) has permission to access +// the given route. The check is performed by comparing the requested URL +// path against the user’s allowed permissions. +// +// Parameters: +// - suffix: A URL prefix to trim from the request path before matching +// permissions (e.g., "/api/v1"). +// +// Behavior: +// - Fetches the user role from Gin context. +// - Uses aH.GetRoleByKey() to retrieve role records and permissions. +// - Grants access (calls c.Next()) if a matching permission is found. +// - Denies access (401 Unauthorized) if no permission matches. +// +// Usage: +// +// router.GET("/secure/:id", aH.AuthorizeRole("/api/v1")) +func (aH *AccessHandler) AuthorizeRole(suffix string) gin.HandlerFunc { + return func(c *gin.Context) { + aH.logger.Debug("AuthorizeRole", "permission path of url path") + permissionPath := strings.TrimPrefix(c.Request.URL.Path, suffix+"/") + + aH.logger.Debug("AuthorizeRole", "get set role") + role, ok := c.Get("role") + if !ok { + aH.logger.Error("AuthorizeRole", "no role set") + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"message": http.StatusInternalServerError}) + return + } + + // Fetch roles and associated permissions from the database or store + roles, err := aH.GetRoleByKey("role", role, false) + if err != nil { + aH.logger.Error("AuthorizeRole", err) + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"message": http.StatusInternalServerError}) + return + } + + // Validate that a role was found + if len(roles) == 0 { + log.Println("not logged in") + aH.logger.Error("AuthorizeRole", "no logged in") + c.JSON(http.StatusUnauthorized, http.StatusUnauthorized) + return + } else if len(roles) > 1 { + aH.logger.Error("AuthorizeRole", "more than one record found") + c.JSON(http.StatusInternalServerError, http.StatusInternalServerError) + return + } + + // Check permissions + for _, permission := range roles[0].Permissions { + fmt.Println(100, permissionPath, permission.Name) + if permission.Name == permissionPath { + c.Next() + return + } + } + + // Access denied + aH.logger.Error("AuthorizeRole", "Forbidden") + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"message": "Forbidden"}) + } +} diff --git a/handlers/role.go b/handlers/role.go new file mode 100644 index 0000000..35d57fc --- /dev/null +++ b/handlers/role.go @@ -0,0 +1,197 @@ +package handlers + +import ( + "fmt" + + "gitea.tecamino.com/paadi/AccessHandler/models" +) + +// AddRoleTable +// +// Description: +// +// Creates a new database table for storing role definitions if it does not already exist. +// +// Behavior: +// - Uses the DBHandler to add a table based on the `models.Role` struct. +// - Returns an error if table creation fails. +// +// Returns: +// - error: Any database error encountered. +func (aH *AccessHandler) AddRoleTable() error { + return aH.dbHandler.addNewTable(models.Role{}) +} + +// AddDefaultRole +// +// Description: +// +// Ensures that a default administrative role exists in the database. +// If a role named "admin" is already present, it logs and skips creation. +// +// Behavior: +// 1. Checks for an existing "admin" role. +// 2. If not found, initializes default permissions using +// `models.Permissions.DefaultPermissions()`. +// 3. Creates a new role record with those permissions. +// +// Default Role: +// - Role: "admin" +// - Permissions: all default permissions defined in `models.Permissions`. +// +// Returns: +// - error: Any database or creation error encountered. +func (aH *AccessHandler) AddDefaultRole() (err error) { + role := "admin" + + // Check if a role with this name already exists + if err := aH.dbHandler.exists(&models.Role{}, "role", role, false); err == nil { + // Found a role β†’ skip creation + aH.logger.Debug("AddDefaultRole", "role "+role+" exists already") + return nil + } + + // Initialize default permissions for admin + permissions := models.Permissions{} + aH.logger.Debug("AddDefaultRole", "set default Permissions") + permissions.DefaultPermissions() + + // Create the default admin role + aH.dbHandler.addNewColum(&models.Role{ + Role: role, + Permissions: permissions, + }) + return +} + +// AddNewRole +// +// Description: +// +// Adds a new role with a specific set of permissions to the database. +// +// Behavior: +// 1. Checks whether a role with the same name already exists. +// 2. If it does not exist, creates a new role record. +// +// Parameters: +// - role: The role name (e.g., "manager", "viewer"). +// - permissions: A `models.Permissions` struct defining allowed actions. +// +// Returns: +// - error: If the role already exists or insertion fails. +func (aH *AccessHandler) AddNewRole(role string, permissions models.Permissions) (err error) { + // Check if a role with this name already exists + if err := aH.dbHandler.exists(&models.Role{}, "role", role, false); err == nil { + // Found a role β†’ skip creation + return fmt.Errorf("role with name %s already exists", role) + } + + // Insert new role with provided permissions + aH.dbHandler.addNewColum(&models.Role{ + Role: role, + Permissions: permissions, + }) + return +} + +// GetRoleById +// +// Description: +// +// Retrieves a role record from the database by its numeric ID. +// +// Parameters: +// - id: The unique ID of the role. +// +// Returns: +// - roles: A slice containing the matched role (usually length 1). +// - err: Any database error encountered. +func (aH *AccessHandler) GetRoleById(id uint) (roles []models.Role, err error) { + err = aH.dbHandler.getById(&roles, id) + return +} + +// GetRoleByKey +// +// Description: +// +// Retrieves one or more roles based on a key/value query. +// +// Parameters: +// - key: The column name to search by (e.g., "role"). +// - value: The value to match (e.g., "admin"). +// - likeSearch: Whether to use SQL LIKE for partial matches. +// +// Returns: +// - roles: A list of matched roles. +// - err: Any database error encountered. +func (aH *AccessHandler) GetRoleByKey(key string, value any, likeSearch bool) (roles []models.Role, err error) { + err = aH.dbHandler.getByKey(&roles, key, value, likeSearch) + return +} + +// UpdateRoleById +// +// Description: +// +// Updates a role record identified by its numeric ID. +// +// Parameters: +// - id: The ID of the role to update. +// - role: A struct containing updated role data. +// +// Returns: +// - error: Any database error encountered. +func (aH *AccessHandler) UpdateRoleById(id uint, role models.Role) error { + return aH.dbHandler.updateValuesById(&role, id) +} + +// UpdateRoleByKey +// +// Description: +// +// Updates a role record using a column key/value lookup. +// +// Parameters: +// - role: The updated role data. +// - key: The column name to search by. +// - value: The value to match against the key column. +// +// Returns: +// - error: Any database error encountered. +func (aH *AccessHandler) UpdateRoleByKey(role models.Role, key string, value any) error { + return aH.dbHandler.updateValuesByKey(&role, key, value) +} + +// DeleteRoleById +// +// Description: +// +// Deletes a role record from the database by its numeric ID. +// +// Parameters: +// - id: The ID of the role to delete. +// +// Returns: +// - error: Any database error encountered during deletion. +func (aH *AccessHandler) DeleteRoleById(id uint) (err error) { + return aH.dbHandler.deleteById(&models.Role{}, id) +} + +// DeleteRoleByKey +// +// Description: +// +// Deletes one or more roles from the database matching a given key/value pair. +// +// Parameters: +// - key: The column name used for filtering (e.g., "role"). +// - value: The matching value (e.g., "admin"). +// - likeSearch: If true, performs a LIKE (partial) match. +// +// Returns: +// - error: Any database error encountered. +func (aH *AccessHandler) DeleteRoleByKey(key string, value any, likeSearch bool) (err error) { + return aH.dbHandler.deleteByKey(&models.Role{}, key, value, likeSearch) +} diff --git a/handlers/user.go b/handlers/user.go new file mode 100644 index 0000000..15378f7 --- /dev/null +++ b/handlers/user.go @@ -0,0 +1,218 @@ +package handlers + +import ( + "fmt" + + "gitea.tecamino.com/paadi/AccessHandler/models" + "gitea.tecamino.com/paadi/AccessHandler/utils" +) + +// AddUserTable +// +// Description: +// +// Creates a new database table for storing user records if it does not already exist. +// +// Behavior: +// - Uses the DBHandler to add a table based on the `models.User` struct. +// - Returns any error encountered during table creation. +// +// Returns: +// - error: Any database error that occurs while creating the table. +func (aH *AccessHandler) AddUserTable() error { + return aH.dbHandler.addNewTable(models.User{}) +} + +// AddDefaultUser +// +// Description: +// +// Ensures a default administrative user exists in the database. +// If a user with the predefined email already exists, the function logs +// a debug message and exits without making changes. +// +// Behavior: +// 1. Checks if the default user (admin) already exists by email. +// 2. If not found, creates default Quasar UI settings and adds the user. +// +// Default User: +// - Name: "admin" +// - Role: "admin" +// - Email: "zuercher@tecamino.ch" +// - Password: (empty or hashed later) +// +// Returns: +// - error: Any database or creation error encountered. +func (aH *AccessHandler) AddDefaultUser() (err error) { + name := "admin" + role := "admin" + email := "zuercher@tecamino.ch" + + // Check if a user with this email already exists + if err := aH.dbHandler.exists(&models.User{}, "email", email, false); err == nil { + aH.logger.Debug("AddDefaultUser", "user email "+email+" exists already") + // Found a user β†’ skip create + return nil + } + + // Create default settings for the new user + settings := models.Settings{} + aH.logger.Debug("AddDefaultUser", "set default quasar settings") + settings.DefaultQuasarSettings() + + // Insert default admin user into the database + aH.dbHandler.addNewColum(&models.User{ + Name: name, + Role: role, + Email: email, + Password: "$2a$10$sZZOWBP8DSFLrLFQNoXw8OsEEr0tez1B8lPzKCHofaHg6PMNxx1pG", + Settings: settings, + }) + return +} + +// AddNewUser +// +// Description: +// +// Adds a new user record to the database with a hashed password. +// +// Behavior: +// 1. Verifies that the email does not already exist. +// 2. Hashes the password using utils.HashPassword(). +// 3. Inserts the new user record into the database. +// +// Parameters: +// - userName: The user's display name. +// - email: The user's unique email address. +// - password: The user's raw password (will be hashed). +// - role: The role assigned to the user. +// +// Returns: +// - error: If the user already exists or if hashing/insertion fails. +func (aH *AccessHandler) AddNewUser(userName, email, password, role string) (err error) { + // Check if a user with this email already exists + if err := aH.dbHandler.exists(&models.User{}, "email", email, false); err == nil { + // Found a user β†’ skip create + aH.logger.Error("AddNewUser", "user with email "+email+" already exists") + return fmt.Errorf("user with email %s already exists", email) + } + + // Hash the provided password before saving + hash, err := utils.HashPassword(password) + if err != nil { + return err + } + + aH.logger.Debug("AddNewUser", "add new user "+userName+" with role "+role) + + // Insert the new user record + aH.dbHandler.addNewColum(&models.User{ + Name: userName, + Role: role, + Email: email, + Password: hash, + }) + return +} + +// GetUserById +// +// Description: +// +// Retrieves user(s) from the database by their unique ID. +// +// Parameters: +// - id: The numeric user ID. +// +// Returns: +// - users: A slice containing the matched user (usually length 1). +// - err: Any database error encountered. +func (aH *AccessHandler) GetUserById(id uint) (users []models.User, err error) { + err = aH.dbHandler.getById(&users, id) + return +} + +// GetUserByKey +// +// Description: +// +// Queries users based on a given column key and value. +// +// Parameters: +// - key: The column name to search by (e.g., "email"). +// - value: The value to match. +// - likeSearch: If true, performs a LIKE (partial) search. +// +// Returns: +// - users: A list of users that match the search criteria. +// - err: Any database error encountered. +func (aH *AccessHandler) GetUserByKey(key string, value any, likeSearch bool) (users []models.User, err error) { + err = aH.dbHandler.getByKey(&users, key, value, likeSearch) + return +} + +// UpdateUserById +// +// Description: +// +// Updates an existing user record identified by its numeric ID. +// +// Parameters: +// - id: The user ID to update. +// - user: A struct containing updated field values. +// +// Returns: +// - error: Any error encountered during the update. +func (aH *AccessHandler) UpdateUserById(id uint, user models.User) error { + return aH.dbHandler.updateValuesById(&user, id) +} + +// UpdateUserByKey +// +// Description: +// +// Updates a user record based on a specified column key and value. +// +// Parameters: +// - user: The updated user data. +// - key: The column name used for lookup. +// - value: The value to match against the key column. +// +// Returns: +// - error: Any error encountered during the update. +func (aH *AccessHandler) UpdateUserByKey(user models.User, key string, value any) error { + return aH.dbHandler.updateValuesByKey(&user, key, value) +} + +// DeleteUserById +// +// Description: +// +// Deletes a user from the database using their numeric ID. +// +// Parameters: +// - id: The ID of the user to delete. +// +// Returns: +// - error: Any database error encountered during deletion. +func (aH *AccessHandler) DeleteUserById(id uint) (err error) { + return aH.dbHandler.deleteById(&models.User{}, id) +} + +// DeleteUserByKey +// +// Description: +// +// Deletes users matching a specific key/value pair from the database. +// +// Parameters: +// - key: The column name to search by. +// - value: The value to match. +// - likeSearch: Whether to use a partial match (LIKE). +// +// Returns: +// - error: Any database error encountered during deletion. +func (aH *AccessHandler) DeleteUserByKey(key string, value any, likeSearch bool) (err error) { + return aH.dbHandler.deleteByKey(&models.User{}, key, value, likeSearch) +} diff --git a/models/jsonResponse.go b/models/jsonResponse.go new file mode 100644 index 0000000..e11e20d --- /dev/null +++ b/models/jsonResponse.go @@ -0,0 +1,17 @@ +package models + +type JsonResponse struct { + Message string `json:"message,omitempty"` +} + +func NewJsonErrorResponse(err error) JsonResponse { + return JsonResponse{ + Message: err.Error(), + } +} + +func NewJsonMessageResponse(msg string) JsonResponse { + return JsonResponse{ + Message: msg, + } +} diff --git a/models/permission.go b/models/permission.go new file mode 100644 index 0000000..30d832c --- /dev/null +++ b/models/permission.go @@ -0,0 +1,37 @@ +package models + +import ( + "database/sql/driver" + "encoding/json" + "fmt" +) + +type Permissions []Permission + +func (r *Permissions) DefaultPermissions() { + *r = append(*r, + Permission{Name: "settings", Permission: 7}, + Permission{Name: "userSettings", Permission: 7}, + Permission{Name: "members", Permission: 7}, + Permission{Name: "events", Permission: 7}, + ) +} + +// --- Implement driver.Valuer (for saving to DB) +func (r Permissions) Value() (driver.Value, error) { + return json.Marshal(r) +} + +// --- Implement sql.Scanner (for reading from DB) +func (r *Permissions) Scan(value any) error { + bytes, ok := value.([]byte) + if !ok { + return fmt.Errorf("failed to unmarshal Settings: %v", value) + } + return json.Unmarshal(bytes, r) +} + +type Permission struct { + Name string `json:"name"` + Permission int `json:"permission"` +} diff --git a/models/role.go b/models/role.go new file mode 100644 index 0000000..eec83ab --- /dev/null +++ b/models/role.go @@ -0,0 +1,7 @@ +package models + +type Role struct { + Id uint `gorm:"primaryKey" json:"id"` + Role string `gorm:"column:role" json:"role"` + Permissions Permissions `gorm:"type:json" json:"permissions"` +} diff --git a/models/settings.go b/models/settings.go new file mode 100644 index 0000000..c68e530 --- /dev/null +++ b/models/settings.go @@ -0,0 +1,39 @@ +package models + +import ( + "database/sql/driver" + "encoding/json" + "fmt" +) + +type Settings struct { + PrimaryColor string `json:"primaryColor,omitempty"` + PrimaryColorText string `json:"primaryColorText,omitempty"` + SecondaryColor string `json:"secondaryColor,omitempty"` + SecondaryColorText string `json:"secondaryColorText,omitempty"` + Icon string `json:"icon,omitempty"` + DatabaseName string `json:"databaseName,omitempty"` + DatabaseToken string `json:"databaseToken,omitempty"` +} + +func (s *Settings) DefaultQuasarSettings() { + s.DatabaseName = "members.dba" + s.PrimaryColor = "#1976d2" + s.PrimaryColorText = "#ffffff" + s.SecondaryColor = "#26a69a" + s.SecondaryColorText = "#ffffff" +} + +// --- Implement driver.Valuer (for saving to DB) +func (s Settings) Value() (driver.Value, error) { + return json.Marshal(s) +} + +// --- Implement sql.Scanner (for reading from DB) +func (s *Settings) Scan(value any) error { + bytes, ok := value.([]byte) + if !ok { + return fmt.Errorf("failed to unmarshal Settings: %v", value) + } + return json.Unmarshal(bytes, s) +} diff --git a/models/user.go b/models/user.go new file mode 100644 index 0000000..b11db66 --- /dev/null +++ b/models/user.go @@ -0,0 +1,14 @@ +package models + +type User struct { + Id uint `gorm:"primaryKey" json:"id"` + Name string `gorm:"column:user_name" json:"user"` + Email string `gorm:"column:email" json:"email"` + Role string `gorm:"column:role" json:"role"` + Password string `gorm:"column:password" json:"password"` + Settings Settings `gorm:"type:json" json:"settings"` +} + +func (u *User) IsValid() bool { + return u.Name != "" +} diff --git a/utils/hash.go b/utils/hash.go new file mode 100644 index 0000000..13dbb96 --- /dev/null +++ b/utils/hash.go @@ -0,0 +1,14 @@ +package utils + +import "golang.org/x/crypto/bcrypt" + +// Hash password +func HashPassword(password string) (string, error) { + b, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + return string(b), err +} + +// Check password +func CheckPassword(password, hash string) bool { + return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil +}