From 367b5d51f2f6a6aeca78601ee72597b67ec06e05 Mon Sep 17 00:00:00 2001 From: Nedim Date: Mon, 6 Nov 2023 11:22:51 +0100 Subject: [PATCH] Added login and logout --- controllers/users_controller.go | 58 ++++++++ database/user/user.go | 82 +++++++++++- go.mod | 3 +- go.sum | 2 + models/session_token.go | 18 +++ models/user.go | 13 +- postman/NOVA.postman_collection.json | 189 +++++++++++++++++++++++++++ routes/public_routes.go | 2 + shared/database.go | 3 +- 9 files changed, 359 insertions(+), 11 deletions(-) create mode 100644 models/session_token.go diff --git a/controllers/users_controller.go b/controllers/users_controller.go index dbefece..407612c 100644 --- a/controllers/users_controller.go +++ b/controllers/users_controller.go @@ -4,9 +4,11 @@ import ( "crypto/rand" "encoding/base64" "net/http" + "strings" "github.com/gin-gonic/gin" "gitlab.com/pactual1/backend/database/user" + usr "gitlab.com/pactual1/backend/database/user" "gitlab.com/pactual1/backend/models" "gitlab.com/pactual1/backend/services/messaging" "gitlab.com/pactual1/backend/shared" @@ -111,3 +113,59 @@ func UpdatePassword(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"message": "Password updated successfully"}) } + +func Login(c *gin.Context) { + var req models.User + if err := c.BindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Bad request"}) + return + } + + user, err := usr.GetUserByEmail(req.Email) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid credentials"}) + return + } + + if usr.CheckPassword(user.Password, req.Password) { + if user.IsActive && user.LoginAttempts < 10 { + // Proceed with creating JWT token and resetting login attempts + token, err := usr.CreateSessionToken(user.ID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Could not create JWT token"}) + return + } + + usr.ResetLoginAttempts(*user) + + c.JSON(http.StatusOK, gin.H{"token": token}) + } else { + c.JSON(http.StatusForbidden, gin.H{"error": "Account locked or too many attempts"}) + } + } else { + // Wrong password, increment login attempts + usr.IncrementLoginAttempts(*user) + c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid credentials"}) + } +} + +func Logout(c *gin.Context) { + // Extract the token from the request, typically from the Authorization header + tokenString := c.GetHeader("Authorization") + + // If using a Bearer token, strip the 'Bearer ' prefix + if len(tokenString) > 7 && strings.ToUpper(tokenString[0:7]) == "BEARER " { + tokenString = tokenString[7:] + } + + // Invalidate the session token + err := usr.InvalidateSessionToken(tokenString) + if err != nil { + // Handle error, could be not found or database error + c.JSON(http.StatusInternalServerError, gin.H{"error": "Unable to logout"}) + return + } + + // Respond with success + c.JSON(http.StatusOK, gin.H{"message": "Successfully logged out"}) +} diff --git a/database/user/user.go b/database/user/user.go index b39df5c..adbde04 100644 --- a/database/user/user.go +++ b/database/user/user.go @@ -1,11 +1,15 @@ package user import ( + "errors" + "fmt" "time" + "github.com/golang-jwt/jwt" "github.com/jinzhu/gorm" "gitlab.com/pactual1/backend/models" "gitlab.com/pactual1/backend/shared" + "golang.org/x/crypto/bcrypt" ) func SaveResetTokenToDB(userID uint, resetToken string) error { @@ -33,10 +37,82 @@ func GetUserByEmail(email string) (*models.User, error) { // Query the database for a user with the specified email if err := shared.GetDb().Where("email = ?", email).First(&user).Error; err != nil { if gorm.IsRecordNotFoundError(err) { - return nil, nil + return nil, nil } - return nil, err + return nil, err } return &user, nil -} \ No newline at end of file +} + +func CheckPassword(hashedPassword, password string) bool { + err := bcrypt.CompareHashAndPassword([]byte(hashedPassword), []byte(password)) + return err == nil +} + +func CreateSessionToken(userID uint) (string, error) { + // Generate JWT token + tokenString, err := CreateJWTToken(userID) + if err != nil { + return "", err + } + + // Create and save the session token in the database + sessionToken := models.SessionToken{ + UserID: userID, + Token: tokenString, + IsActive: true, + } + if result := shared.GetDb().Create(&sessionToken); result.Error != nil { + return "", result.Error + } + + return tokenString, nil +} + +func ResetLoginAttempts(user models.User) { + user.LoginAttempts = 0 + user.IsActive = true + shared.GetDb().Save(&user) +} + +func IncrementLoginAttempts(user models.User) { + user.LoginAttempts++ + if user.LoginAttempts >= 10 { + user.IsActive = false + } + shared.GetDb().Save(&user) +} + +var jwtKey = []byte("MDQsCiJwYWNrZXRWZXJzaW9uIjogMSwKImhhcm") + +func CreateJWTToken(userID uint) (string, error) { + expirationTime := time.Now().Add(24 * time.Hour) + claims := &jwt.StandardClaims{ + Subject: fmt.Sprint(userID), + ExpiresAt: expirationTime.Unix(), + } + + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + tokenString, err := token.SignedString(jwtKey) + if err != nil { + return "", err + } + + return tokenString, nil +} + +func InvalidateSessionToken(tokenString string) error { + // Find the session token in the database + var sessionToken models.SessionToken + result := shared.GetDb().Where("token = ?", tokenString).First(&sessionToken) + if result.Error != nil { + if errors.Is(result.Error, gorm.ErrRecordNotFound) { + // If token is not found, you may choose to ignore or handle it as an error + return nil // or return result.Error for strict handling + } + return result.Error + } + + return shared.GetDb().Delete(&sessionToken).Error +} diff --git a/go.mod b/go.mod index 924c617..79785f3 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ require ( github.com/dgrijalva/jwt-go v3.2.0+incompatible github.com/ethereum/go-ethereum v1.13.1 github.com/gin-gonic/gin v1.9.1 + github.com/golang-jwt/jwt v3.2.2+incompatible github.com/jinzhu/gorm v1.9.16 github.com/joho/godotenv v1.5.1 github.com/lib/pq v1.8.0 @@ -15,6 +16,7 @@ require ( github.com/qor/admin v1.2.0 github.com/stretchr/testify v1.8.4 github.com/valyala/fasthttp v1.40.0 + golang.org/x/crypto v0.14.0 ) replace gitlab.com/pactual1/backend => ./ @@ -108,7 +110,6 @@ require ( github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/yusufpapurcu/wmi v1.2.3 // indirect golang.org/x/arch v0.5.0 // indirect - golang.org/x/crypto v0.14.0 // indirect golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect golang.org/x/mod v0.12.0 // indirect golang.org/x/net v0.17.0 // indirect diff --git a/go.sum b/go.sum index 212d95c..df6c0d4 100644 --- a/go.sum +++ b/go.sum @@ -119,6 +119,8 @@ github.com/gofrs/flock v0.8.1 h1:+gYjHKf32LDeiEEFhQaotPbLuUXjY5ZqxKgXy7n59aw= github.com/gofrs/flock v0.8.1/go.mod h1:F1TvTiK9OcQqauNUHlbJvyl9Qa1QvF/gOUDKA14jxHU= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY= +github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= 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/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe h1:lXe2qZdvpiX5WZkZR4hgp4KJVfY3nMkvmwbVkpv1rVY= diff --git a/models/session_token.go b/models/session_token.go new file mode 100644 index 0000000..6671824 --- /dev/null +++ b/models/session_token.go @@ -0,0 +1,18 @@ +package models + +type SessionToken struct { + BaseModel + UserID uint `json:"userId"` + Token string `json:"token"` + IsActive bool `json:"isActive"` +} + +func (SessionToken) Update() (bool, error) { + return false, nil +} +func (SessionToken) Create() (bool, error) { + return false, nil +} +func (SessionToken) Delete() (bool, error) { + return false, nil +} diff --git a/models/user.go b/models/user.go index cb0c209..4ec2797 100644 --- a/models/user.go +++ b/models/user.go @@ -2,12 +2,13 @@ package models type User struct { BaseModel - Username string `json:"username"` - Password string `json:"password"` - Email string `json:"email"` - Avatar string `json:"avatar"` - IsActive bool `json:"isActive" gorm:"default:false"` - CompanyID uint `json:"companyId"` + Username string `json:"username"` + Password string `json:"password"` + Email string `json:"email"` + Avatar string `json:"avatar"` + IsActive bool `json:"isActive" gorm:"default:false"` + CompanyID uint `json:"companyId"` + LoginAttempts int `gorm:"default:0"` } type ResetPasswordRequest struct { diff --git a/postman/NOVA.postman_collection.json b/postman/NOVA.postman_collection.json index 548d246..e219b2a 100644 --- a/postman/NOVA.postman_collection.json +++ b/postman/NOVA.postman_collection.json @@ -1639,6 +1639,195 @@ "body": "{\n \"id\": 9,\n \"message\": \"Successfully received and saved contract\"\n}" } ] + }, + { + "name": "Login", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE2OTkzNTIwOTYsInN1YiI6IjEifQ.DV5AEHzgKCr42lWc7ZoniYkaSWiWWdC4tBmyUqN_iTc", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{URL}}/user/logout", + "host": [ + "{{URL}}" + ], + "path": [ + "user", + "logout" + ] + } + }, + "response": [ + { + "name": "Create Contracts", + "originalRequest": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"sellerId\": 1,\n \"buyerId\": 2,\n \"description\": \"This is a sample contract.\",\n \"productId\": 3,\n \"minTemp\": -20.0,\n \"maxTemp\": 40.0,\n \"arrivalDate\": 1674019200,\n \"penaltyType\": \"AMOUNT\",\n \"penaltyValue\": 100,\n \"penaltyRec\": \"DAILY\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{URL}}/contracts/create", + "host": [ + "{{URL}}" + ], + "path": [ + "contracts", + "create" + ] + } + }, + "status": "OK", + "code": 200, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Access-Control-Allow-Credentials", + "value": "true" + }, + { + "key": "Access-Control-Allow-Headers", + "value": "Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization, accept, origin, Cache-Control, X-Requested-With" + }, + { + "key": "Access-Control-Allow-Methods", + "value": "POST, OPTIONS, GET, PUT" + }, + { + "key": "Access-Control-Allow-Origin", + "value": "*" + }, + { + "key": "Content-Type", + "value": "application/json; charset=utf-8" + }, + { + "key": "Date", + "value": "Fri, 06 Oct 2023 08:40:15 GMT" + }, + { + "key": "Content-Length", + "value": "61" + } + ], + "cookie": [], + "body": "{\n \"id\": 9,\n \"message\": \"Successfully received and saved contract\"\n}" + } + ] + }, + { + "name": "Logout", + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"password\": \"someemail@gmail.com\",\n \"token\": \"sb6qxahXINNsg52dH0Q7u7iR6yaPLRRQ4OnbUWlxEo0=\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{URL}}/user/set/password", + "host": [ + "{{URL}}" + ], + "path": [ + "user", + "set", + "password" + ] + } + }, + "response": [ + { + "name": "Create Contracts", + "originalRequest": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"sellerId\": 1,\n \"buyerId\": 2,\n \"description\": \"This is a sample contract.\",\n \"productId\": 3,\n \"minTemp\": -20.0,\n \"maxTemp\": 40.0,\n \"arrivalDate\": 1674019200,\n \"penaltyType\": \"AMOUNT\",\n \"penaltyValue\": 100,\n \"penaltyRec\": \"DAILY\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{URL}}/contracts/create", + "host": [ + "{{URL}}" + ], + "path": [ + "contracts", + "create" + ] + } + }, + "status": "OK", + "code": 200, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Access-Control-Allow-Credentials", + "value": "true" + }, + { + "key": "Access-Control-Allow-Headers", + "value": "Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization, accept, origin, Cache-Control, X-Requested-With" + }, + { + "key": "Access-Control-Allow-Methods", + "value": "POST, OPTIONS, GET, PUT" + }, + { + "key": "Access-Control-Allow-Origin", + "value": "*" + }, + { + "key": "Content-Type", + "value": "application/json; charset=utf-8" + }, + { + "key": "Date", + "value": "Fri, 06 Oct 2023 08:40:15 GMT" + }, + { + "key": "Content-Length", + "value": "61" + } + ], + "cookie": [], + "body": "{\n \"id\": 9,\n \"message\": \"Successfully received and saved contract\"\n}" + } + ] } ] } \ No newline at end of file diff --git a/routes/public_routes.go b/routes/public_routes.go index fe367bc..beabc77 100644 --- a/routes/public_routes.go +++ b/routes/public_routes.go @@ -52,4 +52,6 @@ func RegisterPublicRoutes(r *gin.Engine) { //Users r.POST("/user/reset/password", controllers.ResetPassword) r.POST("/user/set/password", controllers.UpdatePassword) + r.POST("/user/login", controllers.Login) + r.POST("/user/logout", controllers.Logout) } diff --git a/shared/database.go b/shared/database.go index b1edab4..1ddf02d 100644 --- a/shared/database.go +++ b/shared/database.go @@ -35,7 +35,8 @@ func Init() error { //TODO AUTOMIGRATE models once we have them db.AutoMigrate(&models.User{}, &models.Company{}, &models.Device{}, &models.DeviceInfo{}, &models.Contract{}, &models.ContractInfo{}, - &models.ProductTemplate{}, &models.TextTemplate{}, &models.Invoice{}, &models.InvoiceItem{}, &models.Notification{}, models.PasswordTokens{}) + &models.ProductTemplate{}, &models.TextTemplate{}, &models.Invoice{}, &models.InvoiceItem{}, + &models.Notification{}, models.PasswordTokens{}, models.SessionToken{}) return nil