initial commit

This commit is contained in:
matyushev_mo committed 2024-12-06 16:10:35 +03:00
commit e8da0f7874
15 files changed
+722

No files matched your search

+2
View File
@@ -0,0 +1,2 @@
go.sum
_env
+14
View File
@@ -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"]
+24
View File
@@ -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
}
+20
View File
@@ -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
}
+39
View File
@@ -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
)
+86
View File
@@ -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
}
+105
View File
@@ -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)
}
})
}
}
+34
View File
@@ -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")
}
+12
View File
@@ -0,0 +1,12 @@
package models
import (
"github.com/google/uuid"
)
type RefreshToken struct {
ID int
UserID uuid.UUID
Token string
Valid bool
}
+79
View File
@@ -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,
})
}
+140
View File
@@ -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)
}
}
+23
View File
@@ -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
}
+43
View File
@@ -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)
}
})
}
}
+36
View File
@@ -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
}
+65
View File
@@ -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
}
})
}
}