diff options
Diffstat (limited to 'internal/api')
-rw-r--r-- | internal/api/auth/auth.go | 57 | ||||
-rw-r--r-- | internal/api/auth/auth_test.go | 74 | ||||
-rw-r--r-- | internal/api/database/database.go | 32 | ||||
-rw-r--r-- | internal/api/database/models.go | 24 | ||||
-rw-r--r-- | internal/api/handlers/handlers.go | 197 | ||||
-rw-r--r-- | internal/api/handlers/utils.go | 34 | ||||
-rw-r--r-- | internal/api/middleware/middleware.go | 36 |
7 files changed, 454 insertions, 0 deletions
diff --git a/internal/api/auth/auth.go b/internal/api/auth/auth.go new file mode 100644 index 0000000..b382beb --- /dev/null +++ b/internal/api/auth/auth.go @@ -0,0 +1,57 @@ +package auth + +import ( + "errors" + "os" + "strings" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +var jwtKey = []byte(os.Getenv("JWT_SECRET")) + +type Claims struct { + UserID int `json:"user_id"` + jwt.RegisteredClaims +} + +func GenerateJWT(userID int) (string, error) { + expirationTime := time.Now().Add(5 * time.Hour) + claims := &Claims{ + UserID: userID, + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(expirationTime), + }, + } + + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + tokenString, err := token.SignedString(jwtKey) + if err != nil { + return "", err + } + return tokenString, nil +} + +func ValidateJWT(tokenString string) (*Claims, error) { + claims := &Claims{} + // A token has a form `Bearer ...` + tokenParts := strings.Split(tokenString, " ") + if len(tokenParts) != 2 { + return nil, errors.New("not valid JWT") + } + + token, err := jwt.ParseWithClaims(tokenParts[1], claims, func(token *jwt.Token) (interface{}, error) { + return jwtKey, nil + }) + + if err != nil { + return nil, err + } + + if !token.Valid { + return nil, err + } + + return claims, nil +} diff --git a/internal/api/auth/auth_test.go b/internal/api/auth/auth_test.go new file mode 100644 index 0000000..50b6c9b --- /dev/null +++ b/internal/api/auth/auth_test.go @@ -0,0 +1,74 @@ +package auth + +import ( + "os" + "testing" + "time" + + "github.com/golang-jwt/jwt/v5" + "github.com/stretchr/testify/assert" +) + +func TestGenerateAndValidateJWT(t *testing.T) { + // Set up the JWT secret for the test. + os.Setenv("JWT_SECRET", "testsecret") + jwtKey = []byte(os.Getenv("JWT_SECRET")) + + userID := 123 + tokenString, err := GenerateJWT(userID) + assert.NoError(t, err) + assert.NotEmpty(t, tokenString) + + claims, err := ValidateJWT(tokenString) + assert.NoError(t, err) + assert.NotNil(t, claims) + assert.Equal(t, userID, claims.UserID) + assert.True(t, claims.ExpiresAt.After(time.Now())) +} + +func TestValidateJWT_InvalidToken(t *testing.T) { + os.Setenv("JWT_SECRET", "testsecret") + jwtKey = []byte(os.Getenv("JWT_SECRET")) + + _, err := ValidateJWT("invalid_token") + assert.Error(t, err) +} + +func TestValidateJWT_ExpiredToken(t *testing.T) { + os.Setenv("JWT_SECRET", "testsecret") + jwtKey = []byte(os.Getenv("JWT_SECRET")) + + // Create a token that has already expired. + claims := &Claims{ + UserID: 123, + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(time.Now().Add(-1 * time.Hour)), + }, + } + + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + tokenString, err := token.SignedString(jwtKey) + assert.NoError(t, err) + + _, err = ValidateJWT(tokenString) + assert.Error(t, err) +} + +func TestValidateJWT_WrongSecret(t *testing.T) { + os.Setenv("JWT_SECRET", "testsecret") + jwtKey = []byte(os.Getenv("JWT_SECRET")) + + userID := 123 + tokenString, err := GenerateJWT(userID) + assert.NoError(t, err) + + // Set a different secret for validation. + wrongKey := []byte("wrongsecret") + + claims := &Claims{} + _, err = jwt.ParseWithClaims(tokenString, claims, func(token *jwt.Token) (interface{}, error) { + return wrongKey, nil + }) + + assert.Error(t, err) +} diff --git a/internal/api/database/database.go b/internal/api/database/database.go new file mode 100644 index 0000000..4470c58 --- /dev/null +++ b/internal/api/database/database.go @@ -0,0 +1,32 @@ +package database + +import ( + "gorm.io/driver/postgres" + "gorm.io/gorm" + + "errors" +) + +// Global variable but private +var db *gorm.DB = nil + +// Init the database from a DSN string which must be a valid PostgreSQL dsn. +// Also, auto migrate all the models. +func InitDb(dsn string) (*gorm.DB, error) { + var err error + db, err = gorm.Open(postgres.Open(dsn), &gorm.Config{}) + + if err == nil { + db.AutoMigrate(&User{}, &Game{}) + } + + return db, err +} + +// Return the instance or error if the config is not laoded yet +func GetDb() (*gorm.DB, error) { + if db == nil { + return nil, errors.New("You must call `InitDb()` first.") + } + return db, nil +} diff --git a/internal/api/database/models.go b/internal/api/database/models.go new file mode 100644 index 0000000..a6e76c5 --- /dev/null +++ b/internal/api/database/models.go @@ -0,0 +1,24 @@ +package database + +import "time" + +type User struct { + ID int `json:"id"` + Username string `json:"username"` + Password string `json:"password"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type Game struct { + ID int `json:"id"` + Player1ID int `json:"-"` + Player1 User `gorm:"foreignKey:Player1ID" json:"player1"` + Player2ID *int `json:"-"` + Player2 *User `gorm:"foreignKey:Player2ID;null" json:"player2"` + Name string `json:"name"` + IP1 string `json:"ip1"` + IP2 string `json:"ip2"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} diff --git a/internal/api/handlers/handlers.go b/internal/api/handlers/handlers.go new file mode 100644 index 0000000..b448502 --- /dev/null +++ b/internal/api/handlers/handlers.go @@ -0,0 +1,197 @@ +package handlers + +import ( + "encoding/json" + "log/slog" + "net/http" + "time" + + "github.com/boozec/rahanna/internal/api/auth" + "github.com/boozec/rahanna/internal/api/database" + "github.com/boozec/rahanna/internal/network" + "gorm.io/gorm" +) + +type NewGameRequest struct { + IP string `json:"ip"` +} + +func RegisterUser(w http.ResponseWriter, r *http.Request) { + slog.Info("POST /auth/register") + var user database.User + err := json.NewDecoder(r.Body).Decode(&user) + if err != nil { + JsonError(&w, err.Error()) + return + } + + if len(user.Password) < 4 { + JsonError(&w, "password too short") + return + } + + var storedUser database.User + db, _ := database.GetDb() + result := db.Where("username = ?", user.Username).First(&storedUser) + + if result.Error == nil { + JsonError(&w, "user with this username already exists") + return + } + + hashedPassword, err := HashPassword(user.Password) + if err != nil { + JsonError(&w, err.Error()) + return + } + user.Password = string(hashedPassword) + + result = db.Create(&user) + if result.Error != nil { + JsonError(&w, result.Error.Error()) + return + } + + token, err := auth.GenerateJWT(user.ID) + if err != nil { + JsonError(&w, err.Error()) + return + } + + json.NewEncoder(w).Encode(map[string]string{"token": token}) +} + +func LoginUser(w http.ResponseWriter, r *http.Request) { + slog.Info("POST /auth/login") + var inputUser database.User + err := json.NewDecoder(r.Body).Decode(&inputUser) + if err != nil { + JsonError(&w, err.Error()) + return + } + + var storedUser database.User + + db, _ := database.GetDb() + result := db.Where("username = ?", inputUser.Username).First(&storedUser) + if result.Error != nil { + JsonError(&w, "invalid credentials") + return + } + + if err := CheckPasswordHash(storedUser.Password, inputUser.Password); err != nil { + JsonError(&w, "invalid credentials") + return + } + + token, err := auth.GenerateJWT(storedUser.ID) + if err != nil { + JsonError(&w, err.Error()) + return + } + + json.NewEncoder(w).Encode(map[string]string{"token": token}) +} + +func NewPlay(w http.ResponseWriter, r *http.Request) { + slog.Info("POST /play") + claims, err := auth.ValidateJWT(r.Header.Get("Authorization")) + + if err != nil { + JsonError(&w, err.Error()) + return + } + + var payload struct { + IP string `json:"ip"` + } + + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + JsonError(&w, err.Error()) + return + } + + if err != nil { + JsonError(&w, err.Error()) + return + } + + db, _ := database.GetDb() + + name := network.NewSession() + play := database.Game{ + Player1ID: claims.UserID, + Player2ID: nil, + Name: name, + IP1: payload.IP, + IP2: "", + } + + result := db.Create(&play) + if result.Error != nil { + JsonError(&w, result.Error.Error()) + return + } + + json.NewEncoder(w).Encode(map[string]string{"name": name}) +} + +func EnterGame(w http.ResponseWriter, r *http.Request) { + slog.Info("POST /enter-game") + claims, err := auth.ValidateJWT(r.Header.Get("Authorization")) + + if err != nil { + JsonError(&w, err.Error()) + return + } + + var payload struct { + Name string `json:"name"` + IP string `json:"ip"` + } + + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + JsonError(&w, err.Error()) + return + } + + if err != nil { + JsonError(&w, err.Error()) + return + } + + db, _ := database.GetDb() + + var play database.Game + + result := db.Where("name = ? AND player2_id IS NULL", payload.Name).First(&play) + if result.Error != nil { + JsonError(&w, result.Error.Error()) + return + } + + play.Player2ID = &claims.UserID + play.IP2 = payload.IP + play.UpdatedAt = time.Now() + + if err := db.Save(&play).Error; err != nil { + JsonError(&w, err.Error()) + return + } + + result = db.Where("id = ?", play.ID). + Preload("Player1", func(db *gorm.DB) *gorm.DB { + return db.Omit("Password") + }). + Preload("Player2", func(db *gorm.DB) *gorm.DB { + return db.Omit("Password") + }). + First(&play) + + if result.Error != nil { + JsonError(&w, result.Error.Error()) + return + } + + json.NewEncoder(w).Encode(play) +} diff --git a/internal/api/handlers/utils.go b/internal/api/handlers/utils.go new file mode 100644 index 0000000..d6cc0d6 --- /dev/null +++ b/internal/api/handlers/utils.go @@ -0,0 +1,34 @@ +package handlers + +import ( + "encoding/json" + "net/http" + + "golang.org/x/crypto/bcrypt" +) + +func HashPassword(password string) (string, error) { + bytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + return string(bytes), err +} + +func CheckPasswordHash(hash, password string) error { + return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) +} + +// Set a JSON response with status code 400 +func JsonError(w *http.ResponseWriter, error string) { + payloadMap := map[string]string{"error": error} + + (*w).Header().Set("Content-Type", "application/json") + (*w).WriteHeader(http.StatusBadRequest) + + payload, err := json.Marshal(payloadMap) + + if err != nil { + (*w).WriteHeader(http.StatusBadGateway) + (*w).Write([]byte(err.Error())) + } else { + (*w).Write(payload) + } +} diff --git a/internal/api/middleware/middleware.go b/internal/api/middleware/middleware.go new file mode 100644 index 0000000..0334e78 --- /dev/null +++ b/internal/api/middleware/middleware.go @@ -0,0 +1,36 @@ +package middleware + +import ( + "encoding/json" + "net/http" + + "github.com/boozec/rahanna/internal/api/auth" +) + +func AuthMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + tokenString := r.Header.Get("Authorization") + + payloadMap := map[string]string{"error": "unauthorized"} + payload, _ := json.Marshal(payloadMap) + + if tokenString == "" { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + + w.Write([]byte(payload)) + return + } + + _, err := auth.ValidateJWT(tokenString) + if err != nil { + w.WriteHeader(http.StatusUnauthorized) + + payload, _ := json.Marshal(payloadMap) + + w.Write([]byte(payload)) + return + } + next.ServeHTTP(w, r) + }) +} |