commit 190134af7b3ee90d0e961ac4cd3b3551edf5bafa Author: miaurizius Date: Wed Jun 3 01:52:56 2026 +0200 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..63bb4ec --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +appdata +.idea +*.exe +*.cmd +.run \ No newline at end of file diff --git a/auth/jwt.go b/auth/jwt.go new file mode 100644 index 0000000..c09ae61 --- /dev/null +++ b/auth/jwt.go @@ -0,0 +1,42 @@ +package auth + +import ( + "time" + + "github.com/golang-jwt/jwt/v5" +) + +type Claims struct { + UserID string `json:"user_id"` + Role string `json:"role"` + jwt.RegisteredClaims +} + +func GenerateJWT(userID, role string, secret []byte) (string, error) { + claims := Claims{ + UserID: userID, + Role: role, + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(time.Now().Add(15 * time.Minute)), + IssuedAt: jwt.NewNumericDate(time.Now()), + }, + } + + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + return token.SignedString(secret) +} +func ValidateJWT(tokenStr string, secret []byte) (*Claims, error) { + token, err := jwt.ParseWithClaims(tokenStr, &Claims{}, func(token *jwt.Token) (interface{}, error) { + return secret, nil + }) + if err != nil { + return nil, err + } + + claims, ok := token.Claims.(*Claims) + if !ok || !token.Valid { + return nil, err + } + + return claims, nil +} diff --git a/auth/middleware.go b/auth/middleware.go new file mode 100644 index 0000000..c1af8d6 --- /dev/null +++ b/auth/middleware.go @@ -0,0 +1,48 @@ +package auth + +import ( + "context" + "net/http" + "strings" +) + +type contextKey string + +const UserContextKey contextKey = contextKey("user") + +func AuthMiddleware(secret []byte) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + + authHeader := r.Header.Get("Authorization") + if authHeader == "" { + http.Error(w, "Missing token", http.StatusUnauthorized) + return + } + + tokenStr := strings.TrimPrefix(authHeader, "Bearer ") + + claims, err := ValidateJWT(tokenStr, secret) + if err != nil { + http.Error(w, "Invalid token", http.StatusUnauthorized) + return + } + + ctx := context.WithValue(r.Context(), UserContextKey, claims) + next.ServeHTTP(w, r.WithContext(ctx)) + + }) + } +} +func RequireRole(role string) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + claims := r.Context().Value(UserContextKey).(*Claims) + if claims.Role != role { + http.Error(w, "Forbidden", http.StatusForbidden) + return + } + next.ServeHTTP(w, r) + }) + } +} diff --git a/auth/password.go b/auth/password.go new file mode 100644 index 0000000..d9af44b --- /dev/null +++ b/auth/password.go @@ -0,0 +1,12 @@ +package auth + +import "golang.org/x/crypto/bcrypt" + +func HashPassword(password string) (string, error) { + bytes, err := bcrypt.GenerateFromPassword([]byte(password), 14) + return string(bytes), err +} +func CheckPasswordHash(password, hash string) bool { + err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) + return err == nil +} diff --git a/config/config.go b/config/config.go new file mode 100644 index 0000000..7fadcc8 --- /dev/null +++ b/config/config.go @@ -0,0 +1,60 @@ +package config + +import ( + "log" + "os" + "path/filepath" + + "gopkg.in/yaml.v3" +) + +type Config struct { + Port string `yaml:"port"` + DatabasePath string `yaml:"database_path"` + CertificatePath string `yaml:"certificate_path"` + PrivateKeyPath string `yaml:"private_key_path"` +} + +const configPath = "./appdata/config.yaml" + +func CheckIfExists() error { + _, err := os.Stat(configPath) + if err == nil { + return nil + } + + if !os.IsNotExist(err) { + return err + } + + log.Printf("Config file %s doesn't exist, creating...", configPath) + err = os.MkdirAll(filepath.Dir(configPath), 0755) + if err != nil { + return err + } + + defaultConfig := Config{ + Port: "8080", + DatabasePath: "./appdata/database.db", + CertificatePath: "./appdata/cert.pem", + PrivateKeyPath: "./appdata/key.pem", + } + + data, err := yaml.Marshal(defaultConfig) + if err != nil { + return err + } + + return os.WriteFile(configPath, data, 0644) +} +func LoadConfig() (Config, error) { + var cfg Config + + file, err := os.ReadFile(configPath) + if err != nil { + return cfg, err + } + + err = yaml.Unmarshal(file, &cfg) + return cfg, err +} diff --git a/frontend/handler.go b/frontend/handler.go new file mode 100644 index 0000000..a26a3a0 --- /dev/null +++ b/frontend/handler.go @@ -0,0 +1,19 @@ +package frontend + +import ( + "html/template" + "net/http" +) + +func Home(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html") + var tmpl = template.Must(template.ParseFiles("frontend/htmx/home.html")) + err := tmpl.Execute(w, struct { + Name string + }{ + Name: "Miau", + }) + if err != nil { + return + } +} diff --git a/frontend/htmx/home.html b/frontend/htmx/home.html new file mode 100644 index 0000000..44e22ab --- /dev/null +++ b/frontend/htmx/home.html @@ -0,0 +1,6 @@ + + + +

Hallo, {{.Name}}!

+ + \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..9359228 --- /dev/null +++ b/go.mod @@ -0,0 +1,22 @@ +module MiauInv + +go 1.26 + +require ( + github.com/glebarez/go-sqlite v1.22.0 + github.com/golang-jwt/jwt/v5 v5.3.1 + golang.org/x/crypto v0.52.0 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/google/uuid v1.5.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + golang.org/x/sys v0.45.0 // indirect + modernc.org/libc v1.37.6 // indirect + modernc.org/mathutil v1.6.0 // indirect + modernc.org/memory v1.7.2 // indirect + modernc.org/sqlite v1.28.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..864d7a6 --- /dev/null +++ b/go.sum @@ -0,0 +1,31 @@ +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/glebarez/go-sqlite v1.22.0 h1:uAcMJhaA6r3LHMTFgP0SifzgXg46yJkgxqyuyec+ruQ= +github.com/glebarez/go-sqlite v1.22.0/go.mod h1:PlBIdHe0+aUEFn+r2/uthrWq4FxbzugL0L8Li6yQJbc= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +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.5.0 h1:1p67kYwdtXjb0gL0BPiP1Av9wiZPo5A8z2cWkTZ+eyU= +github.com/google/uuid v1.5.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +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/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= +golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= +golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +modernc.org/libc v1.37.6 h1:orZH3c5wmhIQFTXF+Nt+eeauyd+ZIt2BX6ARe+kD+aw= +modernc.org/libc v1.37.6/go.mod h1:YAXkAZ8ktnkCKaN9sw/UDeUVkGYJ/YquGO4FTi5nmHE= +modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4= +modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo= +modernc.org/memory v1.7.2 h1:Klh90S215mmH8c9gO98QxQFsY+W451E8AnzjoE2ee1E= +modernc.org/memory v1.7.2/go.mod h1:NO4NVCQy0N7ln+T9ngWqOQfi7ley4vpwvARR+Hjw95E= +modernc.org/sqlite v1.28.0 h1:Zx+LyDDmXczNnEQdvPuEfcFVA2ZPyaD7UCZDjef3BHQ= +modernc.org/sqlite v1.28.0/go.mod h1:Qxpazz0zH8Z1xCFyi5GSL3FzbtZ3fvbjmywNogldEW0= diff --git a/handlers/account.go b/handlers/account.go new file mode 100644 index 0000000..cfebe11 --- /dev/null +++ b/handlers/account.go @@ -0,0 +1,244 @@ +package handlers + +import ( + "MiauInv/auth" + "MiauInv/config" + "MiauInv/models" + "MiauInv/storage" + "MiauInv/util" + "encoding/json" + "log" + "net/http" +) + +var cfg, _ = config.LoadConfig() + +func Register(w http.ResponseWriter, r *http.Request) { + var user models.User + if err := json.NewDecoder(r.Body).Decode(&user); err != nil { + log.Println("POST [api/register] " + r.RemoteAddr + ": " + err.Error()) + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + + if user.Username == "" || user.Password == "" { + log.Println("POST [api/register] " + r.RemoteAddr + ": Username or Password is empty") + http.Error(w, "username and password required", http.StatusBadRequest) + return + } + + hashed, err := auth.HashPassword(user.Password) + if err != nil { + log.Println("POST [api/register] " + r.RemoteAddr + ": " + err.Error()) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + user.Password = hashed + user.ID = utils.GenerateUUID() + user.Role = models.RoleUser + + //if err := storage.AddUser(&user); err != nil { + // log.Println("POST [api/register] " + r.RemoteAddr + ": " + err.Error()) + // http.Error(w, "user already exists", http.StatusBadRequest) + // return + //} + + w.WriteHeader(http.StatusCreated) + log.Println("POST [api/register] " + r.RemoteAddr + ": Successfully created user") +} +func Login(w http.ResponseWriter, r *http.Request) { + //var creds struct { + // Username string `json:"username"` + // Password string `json:"password"` + //} + //if err := json.NewDecoder(r.Body).Decode(&creds); err != nil { + // log.Println("POST [api/login] " + r.RemoteAddr + ": " + err.Error()) + // http.Error(w, "Invalid request", http.StatusBadRequest) + // return + //} + // + //user, err := storage.GetUserByUsername(creds.Username) + //if err != nil { + // log.Println("POST [api/login] " + r.RemoteAddr + ": " + err.Error()) + // http.Error(w, "Invalid credentials", http.StatusUnauthorized) + // return + //} + // + //if !auth.CheckPasswordHash(creds.Password, user.Password) { + // log.Println("POST [api/login] " + r.RemoteAddr + ": Invalid credentials") + // http.Error(w, "Invalid credentials", http.StatusUnauthorized) + // return + //} + // + //secret := []byte(os.Getenv("SHAP_JWT_SECRET")) + //if len(secret) == 0 { + // log.Println("POST [api/login] " + r.RemoteAddr + ": Server misconfiguration") + // http.Error(w, "Server misconfiguration", http.StatusInternalServerError) + // return + //} + // + //accessToken, err := auth.GenerateJWT(user.ID, user.Role, secret) + //if err != nil { + // log.Println("POST [api/login] " + r.RemoteAddr + ": " + err.Error()) + // http.Error(w, "Could not generate token", http.StatusInternalServerError) + // return + //} + // + //refreshTokenPlain, err := utils.GenerateRefreshToken() + //if err != nil { + // log.Println("POST [api/login] " + r.RemoteAddr + ": " + err.Error()) + // http.Error(w, "could not generate refresh token", http.StatusInternalServerError) + // return + //} + //refreshHash := utils.HashToken(refreshTokenPlain) + //refreshID := utils.GenerateUUID() + //refreshExpires := time.Now().Add(7 * 24 * time.Hour).Unix() // expiry: 7 days + // + //deviceInfo := r.Header.Get("User-Agent") + // + //if err := storage.AddRefreshToken(&models.RefreshToken{ + // ID: refreshID, + // UserID: user.ID, + // Token: refreshHash, + // ExpiresAt: refreshExpires, + // DeviceInfo: deviceInfo, + // CreatedAt: time.Now().Unix(), + // Revoked: false, + //}); err != nil { + // log.Println("POST [api/login] " + r.RemoteAddr + ": " + err.Error()) + // http.Error(w, "could not save refresh token", http.StatusInternalServerError) + // return + //} + // + //// Return access + refresh token (refresh in plain for client to store securely) + //resp := map[string]interface{}{ + // "access_token": accessToken, + // "refresh_token": refreshTokenPlain, + // "user": map[string]interface{}{ + // "id": user.ID, + // "username": user.Username, + // "role": user.Role, + // }, + // "wgName": cfg.HouseholdName, + //} + // + //w.Header().Set("Content-Type", "application/json") + //err = json.NewEncoder(w).Encode(resp) + //if err != nil { + // log.Println("POST [api/login] " + r.RemoteAddr + ": " + err.Error()) + // http.Error(w, "Something went wrong", http.StatusInternalServerError) + // return + //} + //log.Println("POST [api/login] " + r.RemoteAddr + ": Successfully logged in") +} +func Logout(w http.ResponseWriter, r *http.Request) { + claims := r.Context().Value(auth.UserContextKey).(*auth.Claims) + err := storage.RevokeAllRefreshTokensForUser(claims.UserID) + if err != nil { + log.Println("GET [api/logout] " + r.RemoteAddr + ": " + err.Error()) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + w.WriteHeader(204) +} +func TestHandler(w http.ResponseWriter, r *http.Request) { + claims, _ := utils.IsLoggedIn(w, r) + + w.Header().Set("Content-Type", "application/json") + err := json.NewEncoder(w).Encode(map[string]interface{}{ + "user_id": claims.UserID, + "role": claims.Role, + "msg": "Authentication successful", + }) + if err != nil { + log.Println("GET [api/ping] " + r.RemoteAddr + ": " + err.Error()) + return + } + log.Println("GET [api/login] " + r.RemoteAddr + ": Successfully tested connection") +} +func RefreshToken(w http.ResponseWriter, r *http.Request) { + //var req struct { + // RefreshToken string `json:"refresh_token"` + //} + //if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + // log.Println("POST [api/refresh] " + r.RemoteAddr + ": " + err.Error()) + // http.Error(w, "Invalid request", http.StatusBadRequest) + // return + //} + // + //hashed := utils.HashToken(req.RefreshToken) + // + //tokenRow, err := storage.GetRefreshToken(hashed) + //if err != nil || tokenRow.Revoked || tokenRow.ExpiresAt < time.Now().Unix() { + // log.Println("POST [api/refresh] " + r.RemoteAddr + ": Invalid refresh token") + // http.Error(w, "Invalid refresh token", http.StatusUnauthorized) + // return + //} + // + //if err := storage.RevokeRefreshToken(tokenRow.ID); err != nil { + // log.Println(err) + //} + // + //newToken, _ := utils.GenerateRefreshToken() + //newHash := utils.HashToken(newToken) + //newExpires := time.Now().Add(7 * 24 * time.Hour).Unix() //7 days + //newID := utils.GenerateUUID() + //deviceInfo := r.Header.Get("User-Agent") + //if err = storage.AddRefreshToken(&models.RefreshToken{ + // ID: newID, + // UserID: tokenRow.UserID, + // Token: newHash, + // ExpiresAt: newExpires, + // CreatedAt: time.Now().Unix(), + // Revoked: false, + // DeviceInfo: deviceInfo, + //}); err != nil { + // log.Println("POST [api/refresh] " + r.RemoteAddr + ": " + err.Error()) + // http.Error(w, "Could not generate new refresh token", http.StatusInternalServerError) + // return + //} + // + //user, err := storage.GetUserById(tokenRow.UserID) + //if err != nil { + // log.Println("POST [api/refresh] " + r.RemoteAddr + ": " + err.Error()) + // http.Error(w, "Internal server error", http.StatusInternalServerError) + // return + //} + //accessToken, _ := auth.GenerateJWT(tokenRow.UserID, user.Role, []byte(os.Getenv("SHAP_JWT_SECRET"))) + // + //if err = json.NewEncoder(w).Encode(map[string]string{ + // "access_token": accessToken, + // "refresh_token": newToken, + //}); err != nil { + // log.Println("POST [api/refresh] " + r.RemoteAddr + ": " + err.Error()) + // http.Error(w, "Internal server error", http.StatusInternalServerError) + // return + //} + //log.Println("POST [api/refresh] " + r.RemoteAddr + ": Successfully refreshed token") +} +func UserInfo(w http.ResponseWriter, r *http.Request) { + //if r.Method != http.MethodGet { + // log.Println("GET [api/userinfo] " + r.RemoteAddr + ": Method " + r.Method + " not allowed") + // http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + // return + //} + //query := r.URL.Query() + //idParam := query.Get("id") + //user, err := storage.GetUserById(idParam) + //if err != nil { + // log.Println("GET [api/userinfo] " + r.RemoteAddr + ": User " + idParam + " not found") + // http.Error(w, "User not found", http.StatusNotFound) + // return + //} + //w.Header().Set("Content-Type", "application/json") + //err = json.NewEncoder(w).Encode(map[string]interface{}{ + // "id": user.ID, + // "name": user.Username, + // "avatar_url": "", + //}) + //if err != nil { + // log.Println("GET [api/userinfo] " + r.RemoteAddr + ": " + err.Error()) + // return + //} + //log.Println("GET [api/userinfo] " + r.RemoteAddr + ": Successfully retrieved user info") +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..5fc5665 --- /dev/null +++ b/main.go @@ -0,0 +1,19 @@ +package main + +import ( + "MiauInv/server" + "MiauInv/storage" + "log" +) + +func main() { + var _server = server.InitServer() + + err := storage.InitDB(_server.DatabasePath) + if err != nil { + log.Fatal(err) + return + } + + _server.Run() +} diff --git a/models/constants.go b/models/constants.go new file mode 100644 index 0000000..f3cfc2d --- /dev/null +++ b/models/constants.go @@ -0,0 +1,14 @@ +package models + +// Roles +const ( + RoleUser = "user" + RoleAdmin = "admin" +) + +// ID-Types +const ( + IDTypeSHARE = "share" + IDTypeEXPENSE = "expense" + IDTypeUSER = "user" +) diff --git a/models/dbmodels.go b/models/dbmodels.go new file mode 100644 index 0000000..dd2f93a --- /dev/null +++ b/models/dbmodels.go @@ -0,0 +1,8 @@ +package models + +type User struct { + ID string `json:"id"` + Username string `json:"username"` + Password string `json:"password"` + Role string `json:"role"` +} diff --git a/models/loginmodels.go b/models/loginmodels.go new file mode 100644 index 0000000..9d345fa --- /dev/null +++ b/models/loginmodels.go @@ -0,0 +1,11 @@ +package models + +type RefreshToken struct { + ID string `json:"id"` + UserID string `json:"user_id"` + Token string `json:"token"` + ExpiresAt int64 `json:"expires_at"` + CreatedAt int64 `json:"created_at"` + Revoked bool `json:"revoked"` + DeviceInfo string `json:"device_info"` +} diff --git a/server/server.go b/server/server.go new file mode 100644 index 0000000..19aa083 --- /dev/null +++ b/server/server.go @@ -0,0 +1,78 @@ +package server + +import ( + "MiauInv/config" + "MiauInv/frontend" + "MiauInv/handlers" + "log" + "net/http" + "os" +) + +type Server struct { + Port string + JWTSecret []byte + DatabasePath string + CertificatePath string + PrivateKeyPath string +} + +func InitServer() *Server { + + err := config.CheckIfExists() + if err != nil { + log.Fatal(err) + return nil + } + + cfg, err := config.LoadConfig() + if err != nil { + log.Fatal(err) + return nil + } + + jwtSecret := os.Getenv("JWT_SECRET") + if jwtSecret == "" { + log.Fatal("JWT_SECRET environment variable not set.") + return nil + } + if len(jwtSecret) < 32 { + log.Fatal("JWT_SECRET must be at least 32 characters long.") + return nil + } + + return &Server{ + Port: cfg.Port, + JWTSecret: []byte(jwtSecret), + DatabasePath: cfg.DatabasePath, + CertificatePath: cfg.CertificatePath, + PrivateKeyPath: cfg.PrivateKeyPath, + } +} + +func (this *Server) Run() { + log.Println("Starting server...") + mux := http.NewServeMux() + + // + // FRONTEND + // + mux.HandleFunc("/", frontend.Home) + + // + // API + // + + // Public + mux.HandleFunc("/api/login", handlers.Login) + mux.HandleFunc("/api/register", handlers.Register) + mux.HandleFunc("/api/refresh", handlers.RefreshToken) + mux.HandleFunc("/api/logout", handlers.Logout) + + // Login required + + // Admin-only + + log.Printf("Listening on port %s", this.Port) + log.Fatal(http.ListenAndServeTLS(":"+this.Port, this.CertificatePath, this.PrivateKeyPath, mux)) +} diff --git a/storage/storage.go b/storage/storage.go new file mode 100644 index 0000000..58a7564 --- /dev/null +++ b/storage/storage.go @@ -0,0 +1,104 @@ +package storage + +import ( + "MiauInv/models" + "database/sql" + "errors" + "log" + + _ "github.com/glebarez/go-sqlite" +) + +var ErrNotFound = sql.ErrNoRows +var DB *sql.DB + +func InitDB(filepath string) error { + var err error + DB, err = sql.Open("sqlite", filepath) + if err != nil { + return err + } + + schema := ` + PRAGMA foreign_keys = ON; + + CREATE TABLE IF NOT EXISTS items ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + category TEXT, + total_quantity INTEGER NOT NULL DEFAULT 0 + ); + + CREATE TABLE IF NOT EXISTS locations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS projects ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS item_allocations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + item_id INTEGER NOT NULL, + location_id INTEGER, + project_id INTEGER, + quantity INTEGER NOT NULL + ); + ` + + _, err = DB.Exec(schema) + if err != nil { + log.Fatal(err) + } + + return err +} + +// Refresh Tokens +func AddRefreshToken(token *models.RefreshToken) error { + _, err := DB.Exec("INSERT INTO refresh_tokens(id, user_id, token_hash, expires_at, created_at, revoked, device_info) VALUES (?, ?, ?, ?, ?, ?, ?)", + token.ID, token.UserID, token.Token, token.ExpiresAt, token.CreatedAt, token.Revoked, token.DeviceInfo) + return err +} +func GetRefreshToken(token string) (models.RefreshToken, error) { + row := DB.QueryRow("SELECT * FROM refresh_tokens WHERE token_hash = ?", token) + var refresh_token models.RefreshToken + err := row.Scan(&refresh_token.ID, &refresh_token.UserID, &refresh_token.Token, &refresh_token.ExpiresAt, &refresh_token.CreatedAt, &refresh_token.Revoked, &refresh_token.DeviceInfo) + return refresh_token, err +} +func RevokeRefreshToken(tokenID string) error { + if DB == nil { + return errors.New("db not initialized") + } + + res, err := DB.Exec(` + UPDATE refresh_tokens + SET revoked = 1 + WHERE id = ? + `, tokenID) + if err != nil { + return err + } + n, err := res.RowsAffected() + if err != nil { + return err + } + if n == 0 { + return ErrNotFound + } + return nil +} +func RevokeAllRefreshTokensForUser(userID string) error { + if DB == nil { + return errors.New("db not initialized") + } + + _, err := DB.Exec(` + UPDATE refresh_tokens + SET revoked = 1 + WHERE user_id = ? + `, userID) + return err +} diff --git a/util/util.go b/util/util.go new file mode 100644 index 0000000..e75a4db --- /dev/null +++ b/util/util.go @@ -0,0 +1,49 @@ +package utils + +import ( + "MiauInv/auth" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "net/http" + + "github.com/google/uuid" +) + +func GenerateUUID() string { + return uuid.New().String() +} +func GenerateSecret() string { + b := make([]byte, 64) + _, err := rand.Read(b) + if err != nil { + return err.Error() + } + return base64.StdEncoding.EncodeToString(b) +} +func GenerateRefreshToken() (string, error) { + b := make([]byte, 32) + if _, err := rand.Read(b); err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(b), nil +} +func HashToken(token string) string { + hash := sha256.Sum256([]byte(token)) + return hex.EncodeToString(hash[:]) +} +func IsLoggedIn(w http.ResponseWriter, r *http.Request) (*auth.Claims, bool) { + claimsRaw := r.Context().Value(auth.UserContextKey) + if claimsRaw == nil { + http.Error(w, "No claims in context", http.StatusUnauthorized) + return nil, false + } + + claims, ok := claimsRaw.(*auth.Claims) + if !ok { + http.Error(w, "Invalid claims", http.StatusUnauthorized) + return nil, false + } + return claims, true +}