From fce349a31d0a53559bc8bbd2de78d183ba95f269 Mon Sep 17 00:00:00 2001 From: Pau Costa Date: Sun, 7 Jul 2024 21:17:51 +0200 Subject: [PATCH] feat: add user register and login routes --- functional-test/auth.http | 19 ++++++++ go.mod | 6 ++- go.sum | 8 ++++ main.go | 9 +++- models/user.go | 70 ++++++++++++++++++++++++++++ routes/auth.routes.go | 97 +++++++++++++++++++++++++++++++++++++++ routes/routes.go | 3 ++ 7 files changed, 209 insertions(+), 3 deletions(-) create mode 100644 functional-test/auth.http create mode 100644 models/user.go create mode 100644 routes/auth.routes.go diff --git a/functional-test/auth.http b/functional-test/auth.http new file mode 100644 index 0000000..7858faf --- /dev/null +++ b/functional-test/auth.http @@ -0,0 +1,19 @@ +### Register a new user +POST http://localhost:8080/auth/register/ +content-type: application/json + +{ + "email": "test@example.com", + "name" : "Test User", + "password": "examplePassword", + "password_confirm": "examplePassword" +} + +### Login +POST http://localhost:8080/auth/login/ +content-type: application/json + +{ + "email": "test@example.com", + "password": "examplePassword" +} \ No newline at end of file diff --git a/go.mod b/go.mod index 4ee509c..bde5743 100644 --- a/go.mod +++ b/go.mod @@ -18,6 +18,8 @@ require ( github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/validator/v10 v10.22.0 // indirect github.com/goccy/go-json v0.10.3 // indirect + github.com/golang-jwt/jwt/v5 v5.2.1 // indirect + github.com/joho/godotenv v1.5.1 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/cpuid/v2 v2.2.8 // indirect github.com/leodido/go-urn v1.4.0 // indirect @@ -28,9 +30,9 @@ require ( github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.2.12 // indirect golang.org/x/arch v0.8.0 // indirect - golang.org/x/crypto v0.24.0 // indirect + golang.org/x/crypto v0.25.0 // indirect golang.org/x/net v0.26.0 // indirect - golang.org/x/sys v0.21.0 // indirect + golang.org/x/sys v0.22.0 // indirect golang.org/x/text v0.16.0 // indirect google.golang.org/protobuf v1.34.2 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/go.sum b/go.sum index 3c78327..b5ed244 100644 --- a/go.sum +++ b/go.sum @@ -25,9 +25,13 @@ github.com/go-playground/validator/v10 v10.22.0 h1:k6HsTZ0sTnROkhS//R0O+55JgM8C4 github.com/go-playground/validator/v10 v10.22.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA= github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= +github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= 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.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= @@ -70,12 +74,16 @@ golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc= golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= golang.org/x/crypto v0.24.0 h1:mnl8DM0o513X8fdIkmyFE/5hTYxbwYOjDS/+rK6qpRI= golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM= +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/net v0.26.0 h1:soB7SVo0PWrY4vPW/+ay0jKDNScG2X9wFeYlXIvJsOQ= golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws= golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI= +golang.org/x/sys v0.22.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= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= diff --git a/main.go b/main.go index f740143..f6b4ad3 100644 --- a/main.go +++ b/main.go @@ -2,6 +2,7 @@ package main import ( "github.com/gin-gonic/gin" + "github.com/joho/godotenv" "net/http" "udemy_httpserver/db" "udemy_httpserver/routes" @@ -9,12 +10,18 @@ import ( func main() { db.InitDB(db.Conn) + + err := godotenv.Load() + if err != nil { + panic("Error loading .env file") + } + server := gin.Default() server.GET("/", henloWolrd) routes.RegisterRoutes(server) - err := server.Run(":8080") + err = server.Run(":8080") if err != nil { return } diff --git a/models/user.go b/models/user.go new file mode 100644 index 0000000..b76bcfe --- /dev/null +++ b/models/user.go @@ -0,0 +1,70 @@ +package models + +import ( + "fmt" + "github.com/golang-jwt/jwt/v5" + "golang.org/x/crypto/bcrypt" + "os" + "time" + "udemy_httpserver/db" +) + +type User struct { + ID int + Name string `binding:"required" json:"name"` + Email string `binding:"required" json:"email"` + Password string `binding:"required" json:"password"` +} + +func (u *User) New() error { + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(u.Password), bcrypt.DefaultCost) + if err != nil { + fmt.Println("Something went wrong trying to hash a user password") + return err + } + u.Password = string(hashedPassword) + + insertQuery := `INSERT INTO users (name, email, password) + VALUES (?, ?, ?)` + + _, err = db.Conn.Exec(insertQuery, u.Name, u.Email, u.Password) + return nil +} + +func FindOneByEmail(email string) (*User, error) { + selectQuery := `SELECT * FROM users WHERE email = ?` + + dbResponse, err := db.Conn.Query(selectQuery, email) + if err != nil { + fmt.Println("Something went wrong trying to find a user") + return nil, err + } + if dbResponse.Next() { + var user User + err = dbResponse.Scan(&user.ID, &user.Name, &user.Email, &user.Password) + if err != nil { + fmt.Println("Something went wrong parsing the database row") + return nil, err + } + return &user, nil + } + return nil, nil +} + +func (u *User) GetJWToken() (string, error) { + expirationDelta, err := time.ParseDuration(os.Getenv("AUTH_EXPIRATION")) + if err != nil { + fmt.Println("Something went wrong trying to parse AUTH_EXPIRATION env variable") + return "", err + } + token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ + "sub": u.ID, + "exp": time.Now().Add(expirationDelta).Unix(), + }) + tokenString, err := token.SignedString([]byte(os.Getenv("JWT_SECRET"))) + if err != nil { + fmt.Println("Something went wrong trying to sign the token") + return "", err + } + return tokenString, nil +} diff --git a/routes/auth.routes.go b/routes/auth.routes.go new file mode 100644 index 0000000..beff97f --- /dev/null +++ b/routes/auth.routes.go @@ -0,0 +1,97 @@ +package routes + +import ( + "fmt" + "github.com/gin-gonic/gin" + "golang.org/x/crypto/bcrypt" + "net/http" + "os" + "strings" + "time" + "udemy_httpserver/models" +) + +func registerUser(c *gin.Context) { + var user struct { + models.User + PasswordConfirm string `json:"password_confirm"` + } + err := c.ShouldBind(&user) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + candidateUser, err := models.FindOneByEmail(strings.ToLower(user.Email)) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + if candidateUser != nil { + c.JSON(http.StatusConflict, gin.H{"error": fmt.Sprintf("user with email %s already exists", user.Email)}) + return + } + + if user.Password != user.PasswordConfirm { + c.JSON(http.StatusBadRequest, gin.H{"error": "password_confirm must match password"}) + return + } + + err = user.New() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + jwt, err := setAuthCookie(c, &user.User) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + user.Password = "" + c.JSON(http.StatusCreated, gin.H{"user": user.User, "token": jwt}) +} + +func Login(c *gin.Context) { + var loginDTO struct { + Email string `binding:"required" json:"email"` + Password string `binding:"required" json:"password"` + } + err := c.ShouldBind(&loginDTO) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + user, err := models.FindOneByEmail(strings.ToLower(loginDTO.Email)) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + err = bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(loginDTO.Password)) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid password or email"}) + return + } + + token, err := setAuthCookie(c, user) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"jwt": token}) + +} + +func setAuthCookie(c *gin.Context, user *models.User) (string, error) { + JWToken, err := user.GetJWToken() + expirationDelta, err := time.ParseDuration(os.Getenv("AUTH_EXPIRATION")) + if err != nil { + fmt.Println("Error parsing AUTH_EXPIRATION") + return "", err + } + + cookieExpiration := time.Now().Add(expirationDelta).Unix() + c.SetCookie("Authorization", JWToken, int(cookieExpiration), "", "", true, true) + return JWToken, nil +} diff --git a/routes/routes.go b/routes/routes.go index 7a629ba..9d42023 100644 --- a/routes/routes.go +++ b/routes/routes.go @@ -7,4 +7,7 @@ func RegisterRoutes(router *gin.Engine) { router.GET("/events", getEvents) router.GET("/events/:id", getSingleEvent) router.PUT("/events/:id", updateEvent) + + router.POST("/auth/register", registerUser) + router.POST("/auth/login", Login) }