From e8da0f787482da812a7f4fd36a0097cc12000535 Mon Sep 17 00:00:00 2001 From: Mikhail Matyushev Date: Fri, 6 Dec 2024 16:10:35 +0300 Subject: [PATCH] initial commit --- .gitignore | 2 + Dockerfile | 14 ++++ config/env.go | 24 +++++++ db.go | 20 ++++++ go.mod | 39 +++++++++++ handlers/token.go | 86 ++++++++++++++++++++++++ handlers/token_test.go | 105 ++++++++++++++++++++++++++++++ main.go | 34 ++++++++++ models/refresh_token.go | 12 ++++ routes/me.go | 79 +++++++++++++++++++++++ routes/me_test.go | 140 ++++++++++++++++++++++++++++++++++++++++ utils/email.go | 23 +++++++ utils/email_test.go | 43 ++++++++++++ utils/token.go | 36 +++++++++++ utils/token_test.go | 65 +++++++++++++++++++ 15 files changed, 722 insertions(+) create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 config/env.go create mode 100644 db.go create mode 100644 go.mod create mode 100644 handlers/token.go create mode 100644 handlers/token_test.go create mode 100644 main.go create mode 100644 models/refresh_token.go create mode 100644 routes/me.go create mode 100644 routes/me_test.go create mode 100644 utils/email.go create mode 100644 utils/email_test.go create mode 100644 utils/token.go create mode 100644 utils/token_test.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..22fbeea --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +go.sum +_env diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..db7a580 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,14 @@ +FROM golang:alpine + +WORKDIR /build + +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . + +RUN go build -o testjwtauth . + +EXPOSE 8080 + +ENTRYPOINT ["/testjwtauth"] \ No newline at end of file diff --git a/config/env.go b/config/env.go new file mode 100644 index 0000000..38d9fb1 --- /dev/null +++ b/config/env.go @@ -0,0 +1,24 @@ +package config + +import ( + "log" + "os" +) + +var ( + DSN = getEnv("DSN") + SMTPHost = getEnv("SMTP_HOST") + SMTPEmail = getEnv("SMTP_EMAIL") + SMTPPassword = getEnv("SMTP_PASSWORD") + MockSMTPRecipient = getEnv("MOCK_SMTP_RECIPIENT") + JWTSecret = getEnv("JWT_SECRET") +) + +func getEnv(k string) string { + v := os.Getenv(k) + if v == "" { + log.Fatalf("enviroment variable %s is required", k) + } + + return v +} diff --git a/db.go b/db.go new file mode 100644 index 0000000..350a7e5 --- /dev/null +++ b/db.go @@ -0,0 +1,20 @@ +package main + +import ( + c "desq.com.ru/testjwtauth/config" + "desq.com.ru/testjwtauth/models" + + "gorm.io/driver/postgres" + "gorm.io/gorm" +) + +func initDB() (*gorm.DB, error) { + db, err := gorm.Open(postgres.Open(c.DSN), &gorm.Config{}) + if err != nil { + return nil, err + } + + db.AutoMigrate(&models.RefreshToken{}) + + return db, nil +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..df4a33f --- /dev/null +++ b/go.mod @@ -0,0 +1,39 @@ +module desq.com.ru/testjwtauth + +go 1.23.4 + +require ( + github.com/gofiber/contrib/jwt v1.0.10 + github.com/gofiber/fiber/v2 v2.52.5 + github.com/golang-jwt/jwt/v5 v5.2.1 + github.com/google/uuid v1.6.0 + golang.org/x/crypto v0.30.0 + gorm.io/driver/postgres v1.5.11 + gorm.io/gorm v1.25.12 +) + +require ( + github.com/MicahParks/keyfunc/v2 v2.1.0 // indirect + github.com/andybalholm/brotli v1.1.1 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/pgx/v5 v5.7.1 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect + github.com/klauspost/compress v1.17.11 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/stretchr/testify v1.10.0 // indirect + github.com/valyala/bytebufferpool v1.0.0 // indirect + github.com/valyala/fasthttp v1.57.0 // indirect + github.com/valyala/tcplisten v1.0.0 // indirect + golang.org/x/sync v0.10.0 // indirect + golang.org/x/sys v0.28.0 // indirect + golang.org/x/text v0.21.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/handlers/token.go b/handlers/token.go new file mode 100644 index 0000000..1a9d266 --- /dev/null +++ b/handlers/token.go @@ -0,0 +1,86 @@ +package handlers + +import ( + "fmt" + + "desq.com.ru/testjwtauth/config" + "desq.com.ru/testjwtauth/models" + "desq.com.ru/testjwtauth/utils" + + "github.com/golang-jwt/jwt/v5" + "github.com/google/uuid" + "gorm.io/gorm" +) + +func CreateToken(DB *gorm.DB, userID uuid.UUID, IP string, expires int64) (string, string, error) { + RT, err := utils.GenerateToken(32) + if err != nil { + return "", "", fmt.Errorf("failed to generate refresh token") + } + + claims := jwt.MapClaims{ + "userId": userID, + "ip": IP, + "sub": RT, + "exp": expires, + } + + token := jwt.NewWithClaims(jwt.SigningMethodHS512, claims) + + t, err := token.SignedString([]byte(config.JWTSecret)) + if err != nil { + return "", "", fmt.Errorf("failed to sign token") + } + + RTEncoded, err := utils.EncodeToken(RT) + if err != nil { + return "", "", fmt.Errorf("failed to encode refresh token") + } + + if err := DB.Create(&models.RefreshToken{ + UserID: userID, + Token: RTEncoded, + Valid: true, + }).Error; err != nil { + return "", "", fmt.Errorf("failed to create refresh token") + } + + return t, RT, nil +} + +func RefreshToken(DB *gorm.DB, userID uuid.UUID, claims jwt.MapClaims, RT string, IP string) error { + if claims["sub"].(string) != RT { + return fmt.Errorf("refresh token does not belong to this token") + } + + validRTs := []models.RefreshToken{} + if err := DB.Where("user_id = ? AND valid = ?", userID, true).Find(&validRTs).Error; err != nil { + return fmt.Errorf("failed to retrieve valid tokens") + } + + var matchedRT *models.RefreshToken + for i := range validRTs { + if utils.CompareToken(validRTs[i].Token, RT) { + matchedRT = &validRTs[i] + + break + } + } + + if matchedRT == nil || !matchedRT.Valid { + return fmt.Errorf("token is invalid") + } + + matchedRT.Valid = false + if err := DB.Save(&matchedRT).Error; err != nil { + return fmt.Errorf("failed to invalidate refresh token") + } + + if claims["ip"].(string) != IP { + if err := utils.NotifyEmail(IP, config.MockSMTPRecipient); err != nil { + fmt.Printf("Failed to send email: %v\n", err) + } + } + + return nil +} diff --git a/handlers/token_test.go b/handlers/token_test.go new file mode 100644 index 0000000..8e5c4cc --- /dev/null +++ b/handlers/token_test.go @@ -0,0 +1,105 @@ +package handlers + +import ( + "testing" + "time" + + c "desq.com.ru/testjwtauth/config" + "desq.com.ru/testjwtauth/models" + "github.com/golang-jwt/jwt/v5" + "github.com/google/uuid" + "gorm.io/driver/postgres" + "gorm.io/gorm" +) + +func TestCreateToken(t *testing.T) { + DB, err := gorm.Open(postgres.Open(c.DSN), &gorm.Config{}) + if err != nil { + t.Fatalf("Failed to connect to database: %v", err) + } + DB.AutoMigrate(&models.RefreshToken{}) + userID := uuid.New() + IP := "127.0.0.1" + expires := time.Now().Add(time.Hour * 24).Unix() + type args struct { + DB *gorm.DB + userID uuid.UUID + IP string + expires int64 + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "CreateToken", + args: args{ + DB: DB, + userID: userID, + IP: IP, + expires: expires, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, _, err := CreateToken(tt.args.DB, tt.args.userID, tt.args.IP, tt.args.expires) + if (err != nil) != tt.wantErr { + t.Errorf("CreateToken() error = %v, wantErr %v", err, tt.wantErr) + return + } + }) + } +} + +func TestRefreshToken(t *testing.T) { + DB, err := gorm.Open(postgres.Open(c.DSN), &gorm.Config{}) + if err != nil { + t.Fatalf("Failed to connect to database: %v", err) + } + DB.AutoMigrate(&models.RefreshToken{}) + userID := uuid.New() + IP := "127.0.0.1" + expires := time.Now().Add(time.Hour * 24) + _, RT, err := CreateToken(DB, userID, IP, expires.Unix()) + if err != nil { + t.Fatalf("Failed to generate token pair: %v", err) + } + claims := jwt.MapClaims{ + "userId": userID, + "ip": IP, + "sub": RT, + "exp": expires, + } + type args struct { + DB *gorm.DB + userID uuid.UUID + claims jwt.MapClaims + RT string + IP string + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "RefreshToken", + args: args{ + DB: DB, + userID: userID, + claims: claims, + RT: RT, + IP: IP, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := RefreshToken(tt.args.DB, tt.args.userID, tt.args.claims, tt.args.RT, tt.args.IP); (err != nil) != tt.wantErr { + t.Errorf("RefreshToken() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..46dd30c --- /dev/null +++ b/main.go @@ -0,0 +1,34 @@ +package main + +import ( + "log" + + "desq.com.ru/testjwtauth/config" + "desq.com.ru/testjwtauth/routes" + + jwtware "github.com/gofiber/contrib/jwt" + "github.com/gofiber/fiber/v2" +) + +func main() { + DB, err := initDB() + if err != nil { + log.Fatalf("Failed to estabilish database connection: %v", err) + } + + app := fiber.New() + + app.Use(func(c *fiber.Ctx) error { + c.Locals("db", DB) + + return c.Next() + }) + + app.Post("/me/:id/token", routes.CreateToken) + app.Use(jwtware.New(jwtware.Config{ + SigningKey: jwtware.SigningKey{Key: []byte(config.JWTSecret)}, + })) + app.Post("/me/token/refresh", routes.RefreshToken) + + app.Listen(":8080") +} diff --git a/models/refresh_token.go b/models/refresh_token.go new file mode 100644 index 0000000..c5bb25b --- /dev/null +++ b/models/refresh_token.go @@ -0,0 +1,12 @@ +package models + +import ( + "github.com/google/uuid" +) + +type RefreshToken struct { + ID int + UserID uuid.UUID + Token string + Valid bool +} diff --git a/routes/me.go b/routes/me.go new file mode 100644 index 0000000..1277d0e --- /dev/null +++ b/routes/me.go @@ -0,0 +1,79 @@ +package routes + +import ( + "time" + + "desq.com.ru/testjwtauth/handlers" + + "github.com/gofiber/fiber/v2" + "github.com/golang-jwt/jwt/v5" + "github.com/google/uuid" + "gorm.io/gorm" +) + +func setRTCookie(c *fiber.Ctx, RT string, expires time.Time) { + c.Cookie(&fiber.Cookie{ + Name: "refreshToken", + Value: RT, + HTTPOnly: true, + Expires: expires, + }) +} + +func CreateToken(c *fiber.Ctx) error { + DB := c.Locals("db").(*gorm.DB) + + uID := c.Params("id") + userID, err := uuid.Parse(uID) + if err != nil { + return c.Status(500).SendString(err.Error()) + } + + expires := time.Now().Add(time.Hour * 24) + + T, RT, err := handlers.CreateToken(DB, userID, c.IP(), expires.Unix()) + if err != nil { + return c.Status(500).SendString(err.Error()) + } + + setRTCookie(c, RT, expires) + + return c.Status(201).JSON(fiber.Map{ + "token": T, + }) +} + +func RefreshToken(c *fiber.Ctx) error { + DB := c.Locals("db").(*gorm.DB) + claims := c.Locals("user").(*jwt.Token).Claims.(jwt.MapClaims) + + RT := c.Cookies("refreshToken") + if RT == "" { + return c.Status(400).SendString("Refresh cookie must be set") + } + + uID := claims["userId"].(string) + + userID, err := uuid.Parse(uID) + if err != nil { + return c.Status(500).SendString(err.Error()) + } + + err = handlers.RefreshToken(DB, userID, claims, RT, c.IP()) + if err != nil { + return c.Status(401).SendString(err.Error()) + } + + expires := time.Now().Add(time.Hour * 24) + + t, RT, err := handlers.CreateToken(DB, userID, c.IP(), expires.Unix()) + if err != nil { + return c.Status(500).SendString(err.Error()) + } + + setRTCookie(c, RT, expires) + + return c.Status(200).JSON(fiber.Map{ + "token": t, + }) +} diff --git a/routes/me_test.go b/routes/me_test.go new file mode 100644 index 0000000..915746a --- /dev/null +++ b/routes/me_test.go @@ -0,0 +1,140 @@ +package routes + +import ( + "net/http" + "testing" + "time" + + "desq.com.ru/testjwtauth/config" + "desq.com.ru/testjwtauth/handlers" + "desq.com.ru/testjwtauth/models" + jwtware "github.com/gofiber/contrib/jwt" + "github.com/gofiber/fiber/v2" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "gorm.io/driver/postgres" + "gorm.io/gorm" +) + +func setupFiber(t *testing.T) *fiber.App { + DB, err := gorm.Open(postgres.Open(config.DSN), &gorm.Config{}) + if err != nil { + t.Fatalf("Failed to connect to database: %v", err) + } + DB.AutoMigrate(&models.RefreshToken{}) + app := fiber.New() + app.Use(func(c *fiber.Ctx) error { + c.Locals("db", DB) + + return c.Next() + }) + app.Post("/me/:id/token", CreateToken) + app.Use(jwtware.New(jwtware.Config{ + SigningKey: jwtware.SigningKey{Key: []byte(config.JWTSecret)}, + })) + app.Post("/me/token/refresh", RefreshToken) + + return app +} + +func TestCreateToken(t *testing.T) { + userID := uuid.New().String() + tests := []struct { + description string + route string + expectedError bool + expectedCode int + expectedBody string + }{ + { + description: "CreateTokenRoute", + route: "/me/" + userID + "/token", + expectedError: false, + expectedCode: 201, + }, + } + app := setupFiber(t) + for _, test := range tests { + req, _ := http.NewRequest("POST", test.route, nil) + res, err := app.Test(req, -1) + assert.Equalf(t, test.expectedError, err != nil, test.description) + if test.expectedError { + continue + } + assert.Equalf(t, test.expectedCode, res.StatusCode, test.description) + } +} + +func TestRefreshToken(t *testing.T) { + DB, err := gorm.Open(postgres.Open(config.DSN), &gorm.Config{}) + if err != nil { + t.Fatalf("Failed to connect to database: %v", err) + } + userID := uuid.New() + IP := "127.0.0.1" + expires := time.Now().Add(time.Hour * 24) + token, RT, err := handlers.CreateToken(DB, userID, IP, expires.Unix()) + if err != nil { + t.Fatalf("Failed to generate token pair: %v", err) + } + RTCookie := &http.Cookie{ + Name: "refreshToken", + Value: RT, + Path: "/", + HttpOnly: true, + Expires: expires, + } + tests := []struct { + description string + route string + RTCookie *http.Cookie + token string + expectedError bool + expectedCode int + }{ + { + description: "WithNoRefreshToken", + route: "/me/token/refresh", + RTCookie: &http.Cookie{}, + token: token, + expectedError: false, + expectedCode: 400, + }, + { + description: "WithNoToken", + route: "/me/token/refresh", + RTCookie: RTCookie, + token: "", + expectedError: false, + expectedCode: 400, + }, + { + description: "WithInvalidToken", + route: "/me/token/refresh", + RTCookie: RTCookie, + token: "InvalidToken", + expectedError: false, + expectedCode: 401, + }, + { + description: "WithValidTokenPair", + route: "/me/token/refresh", + RTCookie: RTCookie, + token: token, + expectedError: false, + expectedCode: 200, + }, + } + app := setupFiber(t) + for _, test := range tests { + req, _ := http.NewRequest("POST", test.route, nil) + req.Header.Add("Authorization", "Bearer "+test.token) + req.AddCookie(test.RTCookie) + res, err := app.Test(req, -1) + assert.Equalf(t, test.expectedError, err != nil, test.description) + if test.expectedError { + continue + } + assert.Equalf(t, test.expectedCode, res.StatusCode, test.description) + } +} diff --git a/utils/email.go b/utils/email.go new file mode 100644 index 0000000..178b7fb --- /dev/null +++ b/utils/email.go @@ -0,0 +1,23 @@ +package utils + +import ( + "fmt" + "net/smtp" + + c "desq.com.ru/testjwtauth/config" +) + +func NotifyEmail(m string, r string) error { + a := smtp.PlainAuth("", c.SMTPEmail, c.SMTPPassword, c.SMTPHost) + + message := "From: " + c.SMTPEmail + "\n" + + "To: " + r + "\n" + + "Subject: Access from an unknown IP\n\n" + + m + + if err := smtp.SendMail(fmt.Sprintf("%s:%d", c.SMTPHost, 587), a, c.SMTPEmail, []string{r}, []byte(message)); err != nil { + return fmt.Errorf("failed to send email: %v", err) + } + + return nil +} diff --git a/utils/email_test.go b/utils/email_test.go new file mode 100644 index 0000000..c139a3d --- /dev/null +++ b/utils/email_test.go @@ -0,0 +1,43 @@ +package utils + +import ( + "testing" + + "desq.com.ru/testjwtauth/config" +) + +func TestNotifyEmail(t *testing.T) { + type args struct { + m string + r string + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "WithValidEmail", + args: args{ + m: "TestNotifyEmail", + r: config.MockSMTPRecipient, + }, + wantErr: false, + }, + { + name: "WithInvalidEmail", + args: args{ + m: "TestNotifyEmail", + r: "", + }, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := NotifyEmail(tt.args.m, tt.args.r); (err != nil) != tt.wantErr { + t.Errorf("NotifyEmail() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/utils/token.go b/utils/token.go new file mode 100644 index 0000000..6079cc4 --- /dev/null +++ b/utils/token.go @@ -0,0 +1,36 @@ +package utils + +import ( + "crypto/rand" + "encoding/base64" + + "golang.org/x/crypto/bcrypt" +) + +func GenerateToken(l int) (string, error) { + bytes := make([]byte, l) + + _, err := rand.Read(bytes) + if err != nil { + return "", err + } + + token := base64.StdEncoding.EncodeToString(bytes) + + return token, nil +} + +func EncodeToken(token string) (string, error) { + encodedToken, err := bcrypt.GenerateFromPassword([]byte(token), bcrypt.DefaultCost) + if err != nil { + return "", err + } + + return string(encodedToken), nil +} + +func CompareToken(hash string, token string) bool { + eq := bcrypt.CompareHashAndPassword([]byte(hash), []byte(token)) + + return eq == nil +} diff --git a/utils/token_test.go b/utils/token_test.go new file mode 100644 index 0000000..e7cbded --- /dev/null +++ b/utils/token_test.go @@ -0,0 +1,65 @@ +package utils + +import ( + "testing" +) + +func TestGenerateToken(t *testing.T) { + type args struct { + l int + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "GenerateRefreshToken", + args: args{ + l: 32, + }, + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := GenerateToken(tt.args.l) + if (err != nil) != tt.wantErr { + t.Errorf("GenerateToken() error = %v, wantErr %v", err, tt.wantErr) + return + } + }) + } +} + +func TestEncodeToken(t *testing.T) { + token, err := GenerateToken(32) + if err != nil { + t.Fatalf("Failed to generate refresh token") + } + type args struct { + token string + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "EncodeToken", + args: args{ + token: token, + }, + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := EncodeToken(tt.args.token) + if (err != nil) != tt.wantErr { + t.Errorf("EncodeToken() error = %v, wantErr %v", err, tt.wantErr) + return + } + }) + } +}