diff --git a/.env b/.env index 9a210f6..9dd2c82 100644 --- a/.env +++ b/.env @@ -1,21 +1,21 @@ -PGHOST=db -PGPORT=5432 -PGUSER=postgres -PGPASSWORD=postgres -PGDATABASE=postgres -SSLmode=disable -PGURL='postgres://postgres:postgres@db:5432/postgres?sslmode=disable' - -# SERVER -SERVER_PORT=8000 -SECRET_KEY=my_very_secret_key - -# MIGRATOR -MIGRATOR_PORT=3000 -GOOSE_DRIVER=postgres -GOOSE_DBSTRING='user=postgres dbname=postgres sslmode=disable' -GOOSE_MIGRATION_DIR=migrations - -# FRONTEND SPA -HTTP=80 # ДЛЯ Certbot +PGHOST=db +PGPORT=5432 +PGUSER=postgres +PGPASSWORD=HnFxccAF3sdUwnI1EkwmXQ== +PGDATABASE=postgres +SSLmode=disable +PGURL='postgres://postgres:HnFxccAF3sdUwnI1EkwmXQ==@db:5432/postgres?sslmode=disable' + +# SERVER +SERVER_PORT=8000 +SECRET_KEY=lUx8h9lpIPNPdcW9q27sJtgcZD/XlZnJWKQSLQ8t7rc= + +# MIGRATOR +MIGRATOR_PORT=3000 +GOOSE_DRIVER=postgres +GOOSE_DBSTRING='user=postgres password=HnFxccAF3sdUwnI1EkwmXQ== dbname=postgres sslmode=disable' +GOOSE_MIGRATION_DIR=migrations + +# FRONTEND SPA +HTTP=80 # ДЛЯ Certbot HTTPS=443 \ No newline at end of file diff --git a/.gitignore b/.gitignore index 749e449..d3a3a74 100644 --- a/.gitignore +++ b/.gitignore @@ -1,34 +1,37 @@ -/spa/node_modules -.env -.vscode - -# Logs -logs -*.log -npm-debug.log* -yarn-debug.log* -yarn-error.log* -pnpm-debug.log* -lerna-debug.log* - -node_modules -.DS_Store -dist -dist-ssr -coverage -*.local - -/cypress/videos/ -/cypress/screenshots/ - -# Editor directories and files -.vscode/* -!.vscode/extensions.json -.idea -*.suo -*.ntvs* -*.njsproj -*.sln -*.sw? - -*.tsbuildinfo \ No newline at end of file +/spa/node_modules +.env +.vscode + +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +.DS_Store +dist +dist-ssr +coverage +*.local + +/cypress/videos/ +/cypress/screenshots/ + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? + +*.tsbuildinfo + +# Binaries +api/bin/ \ No newline at end of file diff --git a/api/bin/api.exe b/api/bin/api.exe deleted file mode 100644 index 4dc983e..0000000 Binary files a/api/bin/api.exe and /dev/null differ diff --git a/api/go.mod b/api/go.mod index 9e9c6fe..37461e8 100644 --- a/api/go.mod +++ b/api/go.mod @@ -13,7 +13,7 @@ require ( github.com/jinzhu/now v1.1.5 // indirect github.com/stretchr/testify v1.9.0 // indirect golang.org/x/crypto v0.25.0 - golang.org/x/sync v0.7.0 // indirect + golang.org/x/sync v0.12.0 // indirect golang.org/x/text v0.16.0 // indirect ) @@ -23,3 +23,10 @@ require ( gorm.io/driver/postgres v1.5.9 gorm.io/gorm v1.25.11 ) + +require ( + github.com/go-chi/httprate v0.15.0 // indirect + github.com/klauspost/cpuid/v2 v2.2.10 // indirect + github.com/zeebo/xxh3 v1.0.2 // indirect + golang.org/x/sys v0.30.0 // indirect +) diff --git a/api/go.sum b/api/go.sum index 7526c32..83002b3 100644 --- a/api/go.sum +++ b/api/go.sum @@ -3,6 +3,8 @@ 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/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw= github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= +github.com/go-chi/httprate v0.15.0 h1:j54xcWV9KGmPf/X4H32/aTH+wBlrvxL7P+SdnRqxh5g= +github.com/go-chi/httprate v0.15.0/go.mod h1:rzGHhVrsBn3IMLYDOZQsSU4fJNWcjui4fWKJcCId1R4= github.com/golang-jwt/jwt/v4 v4.5.0 h1:7cYmW1XlMY7h7ii7UhUyChSgS5wUJEnm9uZVTGqOWzg= github.com/golang-jwt/jwt/v4 v4.5.0/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= @@ -19,6 +21,8 @@ github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD 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/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE= +github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= 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/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= @@ -26,10 +30,16 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0= +github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA= golang.org/x/crypto v0.25.0 h1:ypSNr+bnYL2YhwoMt2zPxHFmbAN1KZs/njMG3hxUp30= golang.org/x/crypto v0.25.0/go.mod h1:T+wALwcMOSE0kXgUAnPAHqTLW+XHgcELELW8VaDgm/M= golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw= +golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= +golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/api/src/initializers/Routing.go b/api/src/initializers/Routing.go index 1bbfd7e..6aadc56 100644 --- a/api/src/initializers/Routing.go +++ b/api/src/initializers/Routing.go @@ -1,68 +1,68 @@ -package initializers - -import ( - "api/src/rt/admin" - "api/src/rt/auth" - "api/src/rt/prf" - "api/src/rt/srch" - "log/slog" - "os" - "time" - - "net/http" - - "github.com/go-chi/chi/v5" - "github.com/go-chi/chi/v5/middleware" -) - -var Done = make(chan bool) - -func InitChiRouting() { - slog.Info("Init routing") - r := chi.NewRouter() - - // middlewares - r.Use(middleware.Logger) - r.Use(middleware.Timeout(60 * time.Second)) - r.Use(middleware.RequestID) - r.Use(middleware.CleanPath) - r.Use(middleware.Heartbeat("/ping")) - r.Use(middleware.NoCache) - r.Use(middleware.Recoverer) - r.NotFound(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(404) - w.Write([]byte("route does not exist")) - }) - r.MethodNotAllowed(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(405) - w.Write([]byte("method is not valid")) - }) - - // public Routes - r.Group(func(r chi.Router) { - r.Post("/signup", auth.Register) // register - r.Post("/signin", auth.Login) // signin - r.Get("/search", srch.Search) - }) - - // Private Routes - // Require Authentication - r.Group(func(r chi.Router) { - r.Use(auth.AuthMiddleware) - r.Get("/profile", prf.Profile) - r.Get("/allUsersAdm", admin.GetAllUser) - r.Route("/admin", func(r chi.Router) { - r.Use(auth.AuthAdminMiddleware) - r.Get("/allUsersAdm", admin.GetAllUser) // all users get - }) - }) - - // up server on os.Getenv("SERVER_PORT") port on gorutin - go func() { - defer close(Done) - err := http.ListenAndServe(":"+os.Getenv("SERVER_PORT"), r) - if err != nil { - slog.Error("Can't start server: ", "error", err) - } - }() -} +package initializers + +import ( + "api/src/rt/admin" + "api/src/rt/auth" + "api/src/rt/prf" + "api/src/rt/srch" + "log/slog" + "os" + "time" + + "net/http" + + "github.com/go-chi/chi/v5" + "github.com/go-chi/chi/v5/middleware" + "github.com/go-chi/httprate" +) + +var Done = make(chan bool) + +func InitChiRouting() { + slog.Info("Init routing") + r := chi.NewRouter() + + // middlewares + r.Use(middleware.Logger) + r.Use(middleware.Timeout(60 * time.Second)) + r.Use(middleware.RequestID) + r.Use(middleware.CleanPath) + r.Use(middleware.Heartbeat("/ping")) + r.Use(middleware.NoCache) + r.Use(middleware.Recoverer) + r.NotFound(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(404) + w.Write([]byte("route does not exist")) + }) + r.MethodNotAllowed(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(405) + w.Write([]byte("method is not valid")) + }) + + // public Routes + r.Group(func(r chi.Router) { + r.Post("/signup", auth.Register) // register + r.With(httprate.Limit(5, 1*time.Minute)).Post("/signin", auth.Login) // signin with rate limiter + r.Get("/search", srch.Search) + }) + + // Private Routes + // Require Authentication + r.Group(func(r chi.Router) { + r.Use(auth.AuthMiddleware) + r.Get("/profile", prf.Profile) + r.Route("/admin", func(r chi.Router) { + r.Use(auth.AuthAdminMiddleware) + r.Get("/allUsersAdm", admin.GetAllUser) // all users get + }) + }) + + // up server on os.Getenv("SERVER_PORT") port on gorutin + go func() { + defer close(Done) + err := http.ListenAndServe(":"+os.Getenv("SERVER_PORT"), r) + if err != nil { + slog.Error("Can't start server: ", "error", err) + } + }() +} diff --git a/api/src/rt/auth/Login.go b/api/src/rt/auth/Login.go index a01f131..1a76d71 100644 --- a/api/src/rt/auth/Login.go +++ b/api/src/rt/auth/Login.go @@ -1,61 +1,68 @@ -package auth - -import ( - "api/src/models" - "api/src/storages/psql" - "encoding/json" - "net/http" - "os" - "time" - - "github.com/golang-jwt/jwt/v4" - "golang.org/x/crypto/bcrypt" -) - -var jwtKey = []byte(os.Getenv("SECRET_KEY")) - -func Login(w http.ResponseWriter, r *http.Request) { - var creds models.Credentials - if err := json.NewDecoder(r.Body).Decode(&creds); err != nil { - w.WriteHeader(http.StatusBadRequest) - return - } - // check user - var user models.User - // get user by email - result := psql.PSQL_GORM_DB.Where("email = ?", creds.Email).First(&user) - if result.Error != nil || !checkPasswordHash(creds.Password, user.Password) { - w.WriteHeader(http.StatusInternalServerError) - return - } - - // create jwt token - expirationtime := time.Now().Add(5 * time.Minute) - claims := &models.Claims{ - RegisteredClaims: jwt.RegisteredClaims{ - ExpiresAt: jwt.NewNumericDate(expirationtime), - }, - Email: user.Email, - Phone: user.Phone, - Role: user.Role, - } - - token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) - tokenString, err := token.SignedString(jwtKey) - if err != nil { - w.WriteHeader(http.StatusInternalServerError) - return - } - - http.SetCookie(w, &http.Cookie{ - Name: "token", - Value: tokenString, - Expires: expirationtime, - }) - w.WriteHeader(http.StatusOK) -} - -func checkPasswordHash(password, hash string) bool { - err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) - return err == nil -} +package auth + +import ( + "api/src/models" + "api/src/storages/psql" + "encoding/json" + "net/http" + "os" + "time" + + "github.com/golang-jwt/jwt/v4" + "golang.org/x/crypto/bcrypt" +) + +const ( + loginErrMsg = "invalid email or password" +) + +var jwtKey = []byte(os.Getenv("SECRET_KEY")) + +func Login(w http.ResponseWriter, r *http.Request) { + var creds models.Credentials + if err := json.NewDecoder(r.Body).Decode(&creds); err != nil { + w.WriteHeader(http.StatusBadRequest) + return + } + // check user + var user models.User + result := psql.PSQL_GORM_DB.Where("email = ?", creds.Email).First(&user) + if result.Error != nil || !checkPasswordHash(creds.Password, user.Password) { + http.Error(w, loginErrMsg, http.StatusUnauthorized) + return + } + + // create jwt token + expirationtime := time.Now().Add(5 * time.Minute) + claims := &models.Claims{ + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(expirationtime), + }, + Email: user.Email, + Phone: user.Phone, + Role: user.Role, + } + + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + tokenString, err := token.SignedString(jwtKey) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + return + } + + http.SetCookie(w, &http.Cookie{ + Name: "token", + Value: tokenString, + Expires: expirationtime, + HttpOnly: true, + Secure: true, + SameSite: http.SameSiteStrictMode, + Path: "/", + }) + w.WriteHeader(http.StatusOK) +} + +func checkPasswordHash(password, hash string) bool { + err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) + return err == nil +} diff --git a/api/src/rt/auth/Registr.go b/api/src/rt/auth/Registr.go index babbd58..d75ff01 100644 --- a/api/src/rt/auth/Registr.go +++ b/api/src/rt/auth/Registr.go @@ -1,50 +1,97 @@ -package auth - -import ( - "api/src/models" - "api/src/storages/psql" - "encoding/json" - "net/http" - - "github.com/google/uuid" - - "golang.org/x/crypto/bcrypt" -) - -func Register(w http.ResponseWriter, r *http.Request) { - var Credentials models.Credentials - // Decoe body - if err := json.NewDecoder(r.Body).Decode(&Credentials); err != nil { - w.WriteHeader(http.StatusBadRequest) - return - } - - // shep password - hashedPassword, err := hashPassword(Credentials.Password) - if err != nil { - w.WriteHeader(http.StatusInternalServerError) - return - } - - id := uuid.New() - - user := models.User{ - Id: id, - Name: Credentials.Name, - Email: Credentials.Email, - Password: hashedPassword, - Phone: Credentials.Phone, - Role: Credentials.Role, - } - result := psql.PSQL_GORM_DB.Create(&user) - if result.Error != nil { - w.WriteHeader(http.StatusInternalServerError) - return - } - w.WriteHeader(http.StatusCreated) -} - -func hashPassword(password string) (string, error) { - bytes, err := bcrypt.GenerateFromPassword([]byte(password), 14) - return string(bytes), err -} +package auth + +import ( + "api/src/models" + "api/src/storages/psql" + "encoding/json" + "net/http" + "regexp" + "strings" + + "github.com/google/uuid" + + "golang.org/x/crypto/bcrypt" +) + +var ( + emailRegex = regexp.MustCompile(`^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$`) + phoneRegex = regexp.MustCompile(`^\+?[0-9\s\-\(\)]{7,20}$`) + validRoles = map[string]bool{"user": true, "admin": true} +) + +type validationError struct { + Field string `json:"field"` + Message string `json:"message"` +} + +func validateCredentials(c *models.Credentials) []validationError { + var errs []validationError + + c.Name = strings.TrimSpace(c.Name) + c.Email = strings.TrimSpace(c.Email) + c.Phone = strings.TrimSpace(c.Phone) + c.Role = strings.TrimSpace(c.Role) + + if c.Name == "" || len(c.Name) > 50 { + errs = append(errs, validationError{"name", "name is required and must be at most 50 characters"}) + } + if c.Email == "" || len(c.Email) > 50 || !emailRegex.MatchString(c.Email) { + errs = append(errs, validationError{"email", "valid email is required"}) + } + if c.Password == "" || len(c.Password) < 8 || len(c.Password) > 72 { + errs = append(errs, validationError{"password", "password must be between 8 and 72 characters"}) + } + if c.Phone == "" || !phoneRegex.MatchString(c.Phone) { + errs = append(errs, validationError{"phone", "valid phone number is required"}) + } + if c.Role != "" && !validRoles[c.Role] { + errs = append(errs, validationError{"role", "role must be 'user' or 'admin'"}) + } + + return errs +} + +func Register(w http.ResponseWriter, r *http.Request) { + var creds models.Credentials + if err := json.NewDecoder(r.Body).Decode(&creds); err != nil { + http.Error(w, "invalid request body", http.StatusBadRequest) + return + } + + if errs := validateCredentials(&creds); errs != nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]interface{}{"errors": errs}) + return + } + + hashedPassword, err := hashPassword(creds.Password) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + return + } + + if creds.Role == "" { + creds.Role = "user" + } + + user := models.User{ + Id: uuid.New(), + Name: creds.Name, + Email: creds.Email, + Password: hashedPassword, + Phone: creds.Phone, + Role: creds.Role, + } + result := psql.PSQL_GORM_DB.Create(&user) + if result.Error != nil { + http.Error(w, "user with this email or phone already exists", http.StatusConflict) + return + } + w.WriteHeader(http.StatusCreated) +} + +func hashPassword(password string) (string, error) { + bytes, err := bcrypt.GenerateFromPassword([]byte(password), 14) + return string(bytes), err +}