mirror of
https://github.com/nprasad2077/NBA_Go.git
synced 2026-09-22 05:55:13 +00:00
api key auth added
This commit is contained in:
@@ -1 +1,17 @@
|
||||
# NBA_Go
|
||||
|
||||
### First‑time bootstrap
|
||||
|
||||
```bash
|
||||
# 1. build + run
|
||||
docker-compose up --build -d
|
||||
|
||||
# 2. create API key (ADMIN_SECRET is loaded from .env)
|
||||
curl -XPOST http://localhost:8080/admin/keys \
|
||||
-H "X-Admin-Secret: $ADMIN_SECRET" \
|
||||
-d '{"label":"local-test"}'
|
||||
# → { "id":1, "apiKey":"ab12cd…" }
|
||||
|
||||
# 3. call a protected endpoint
|
||||
curl http://localhost:8080/api/playeradvancedstats \
|
||||
-H "X-API-Key: ab12cd…"
|
||||
@@ -0,0 +1,11 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/nprasad2077/NBA_Go/utils/security"
|
||||
)
|
||||
|
||||
func main() {
|
||||
raw, _ := security.GenerateRawKey()
|
||||
fmt.Println(raw)
|
||||
}
|
||||
@@ -22,6 +22,8 @@ func InitDB() *gorm.DB {
|
||||
db.AutoMigrate(&models.PlayerAdvancedStat{})
|
||||
db.AutoMigrate(&models.PlayerTotalStat{})
|
||||
|
||||
db.AutoMigrate(&models.APIKey{})
|
||||
|
||||
metrics.DBOperationsTotal.WithLabelValues("migrate", "database").Inc()
|
||||
|
||||
return db
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/nprasad2077/NBA_Go/models"
|
||||
"github.com/nprasad2077/NBA_Go/utils/security"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// very small middleware: require header X‑Admin‑Secret == $ADMIN_SECRET
|
||||
func adminGuard() fiber.Handler {
|
||||
secret := os.Getenv("ADMIN_SECRET")
|
||||
return func(c *fiber.Ctx) error {
|
||||
if c.Get("X-Admin-Secret") != secret {
|
||||
return c.SendStatus(http.StatusUnauthorized)
|
||||
}
|
||||
return c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func RegisterKeyAdminRoutes(app *fiber.App, db *gorm.DB) {
|
||||
admin := app.Group("/admin/keys", adminGuard())
|
||||
|
||||
admin.Get("/", func(c *fiber.Ctx) error {
|
||||
var keys []models.APIKey
|
||||
db.Find(&keys)
|
||||
return c.JSON(keys)
|
||||
})
|
||||
|
||||
admin.Post("/", func(c *fiber.Ctx) error {
|
||||
var body struct{ Label string }
|
||||
if err := c.BodyParser(&body); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
raw, err := security.GenerateRawKey()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
key := models.APIKey{
|
||||
Hash: security.HashKey(raw),
|
||||
Label: body.Label,
|
||||
}
|
||||
db.Create(&key)
|
||||
|
||||
// return ONLY the raw key once
|
||||
return c.JSON(fiber.Map{
|
||||
"apiKey": raw,
|
||||
"id": key.ID,
|
||||
})
|
||||
})
|
||||
|
||||
admin.Post("/:id/revoke", func(c *fiber.Ctx) error {
|
||||
id := c.Params("id")
|
||||
db.Model(&models.APIKey{}).Where("id = ?", id).Update("revoked", true)
|
||||
return c.JSON(fiber.Map{"revoked": id})
|
||||
})
|
||||
}
|
||||
@@ -24,6 +24,7 @@ func FetchPlayerAdvancedStats(db *gorm.DB) fiber.Handler {
|
||||
|
||||
|
||||
// GetAllPlayerStats godoc
|
||||
// @Security ApiKeyAuth
|
||||
// @Summary Get player stats
|
||||
// @Description Returns filtered and paginated player stats
|
||||
// @Tags PlayerStats
|
||||
|
||||
@@ -33,6 +33,7 @@ func FetchPlayerTotalStats(db *gorm.DB) fiber.Handler {
|
||||
|
||||
|
||||
// GetPlayerTotalStats godoc
|
||||
// @Security ApiKeyAuth
|
||||
// @Summary Get player total stats
|
||||
// @Description Filter and paginate player totals
|
||||
// @Tags PlayerTotals
|
||||
|
||||
@@ -10,6 +10,7 @@ services:
|
||||
networks:
|
||||
- api.network
|
||||
api1:
|
||||
env_file: .env
|
||||
volumes:
|
||||
- './data:/app/data'
|
||||
build: .
|
||||
@@ -22,6 +23,7 @@ services:
|
||||
networks:
|
||||
- api.network
|
||||
api2:
|
||||
env_file: .env
|
||||
volumes:
|
||||
- './data:/app/data'
|
||||
build: .
|
||||
@@ -34,6 +36,7 @@ services:
|
||||
networks:
|
||||
- api.network
|
||||
api3:
|
||||
env_file: .env
|
||||
volumes:
|
||||
- './data:/app/data'
|
||||
build: .
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
//go:build docs
|
||||
// +build docs
|
||||
|
||||
// Package docs Only contains Swagger annotations that are **global**.
|
||||
// Run `swag init --parseDependency --parseInternal` after editing.
|
||||
package docs
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// General API meta (kept here so CI can inject version/build).
|
||||
// ------------------------------------------------------------
|
||||
|
||||
// @title NBA_Go API
|
||||
// @version 1.0
|
||||
// @description Stats service with API‑key auth
|
||||
// @BasePath /
|
||||
// @schemes http https
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// 🔐 SECURITY – this block is what you asked for
|
||||
// ------------------------------------------------------------
|
||||
//
|
||||
// @securityDefinitions.apikey ApiKeyAuth
|
||||
// @in header
|
||||
// @name X-API-Key
|
||||
//
|
||||
// (every protected endpoint still needs `@Security ApiKeyAuth`)
|
||||
// ------------------------------------------------------------
|
||||
@@ -17,6 +17,11 @@ const docTemplate = `{
|
||||
"paths": {
|
||||
"/api/playeradvancedstats": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"ApiKeyAuth": []
|
||||
}
|
||||
],
|
||||
"description": "Returns filtered and paginated player stats",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
@@ -87,6 +92,11 @@ const docTemplate = `{
|
||||
},
|
||||
"/api/playertotals": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"ApiKeyAuth": []
|
||||
}
|
||||
],
|
||||
"description": "Filter and paginate player totals",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
|
||||
@@ -6,6 +6,11 @@
|
||||
"paths": {
|
||||
"/api/playeradvancedstats": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"ApiKeyAuth": []
|
||||
}
|
||||
],
|
||||
"description": "Returns filtered and paginated player stats",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
@@ -76,6 +81,11 @@
|
||||
},
|
||||
"/api/playertotals": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"ApiKeyAuth": []
|
||||
}
|
||||
],
|
||||
"description": "Filter and paginate player totals",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
|
||||
@@ -45,6 +45,8 @@ paths:
|
||||
schema:
|
||||
additionalProperties: true
|
||||
type: object
|
||||
security:
|
||||
- ApiKeyAuth: []
|
||||
summary: Get player stats
|
||||
tags:
|
||||
- PlayerStats
|
||||
@@ -98,6 +100,8 @@ paths:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
security:
|
||||
- ApiKeyAuth: []
|
||||
summary: Get player total stats
|
||||
tags:
|
||||
- PlayerTotals
|
||||
|
||||
@@ -1,36 +1,65 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/gofiber/fiber/v2/middleware/logger"
|
||||
"github.com/gofiber/fiber/v2/middleware/adaptor"
|
||||
"github.com/gofiber/fiber/v2/middleware/logger"
|
||||
"github.com/nprasad2077/NBA_Go/config"
|
||||
"github.com/nprasad2077/NBA_Go/routes"
|
||||
"github.com/nprasad2077/NBA_Go/services"
|
||||
"github.com/nprasad2077/NBA_Go/controllers"
|
||||
"github.com/nprasad2077/NBA_Go/routes"
|
||||
"github.com/nprasad2077/NBA_Go/utils/middleware"
|
||||
_ "github.com/nprasad2077/NBA_Go/docs"
|
||||
fiberswagger "github.com/swaggo/fiber-swagger"
|
||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func main() {
|
||||
app := fiber.New()
|
||||
// graceful shutdown context
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
// Add standard logger middleware
|
||||
app := fiber.New(fiber.Config{
|
||||
ReadTimeout: 15 * time.Second,
|
||||
WriteTimeout: 15 * time.Second,
|
||||
ErrorHandler: func(c *fiber.Ctx, err error) error {
|
||||
code := fiber.StatusInternalServerError
|
||||
if e, ok := err.(*fiber.Error); ok {
|
||||
code = e.Code
|
||||
}
|
||||
return c.Status(code).JSON(fiber.Map{"error": err.Error()})
|
||||
},
|
||||
})
|
||||
|
||||
// middlewares
|
||||
app.Use(logger.New())
|
||||
|
||||
// Add metrics middleware
|
||||
app.Use(middleware.MetricsMiddleware())
|
||||
|
||||
// Initialize database
|
||||
// DB
|
||||
db := config.InitDB()
|
||||
|
||||
// Original data fetching code, no environment variable check
|
||||
/* ---------- PUBLIC ROUTES (no API key) ---------- */
|
||||
app.Get("/metrics", adaptor.HTTPHandler(promhttp.Handler()))
|
||||
app.Get("/swagger/*", fiberswagger.WrapHandler)
|
||||
controllers.RegisterKeyAdminRoutes(app, db) // guarded only by X‑Admin‑Secret
|
||||
|
||||
/* ---------- PROTECTED ROUTES ---------- */
|
||||
app.Use(middleware.APIKeyAuth(db))
|
||||
routes.RegisterPlayerAdvancedRoutes(app, db)
|
||||
routes.RegisterPlayerTotalRoutes(app, db)
|
||||
|
||||
/* ---------- Optional import job ---------- */
|
||||
go func() {
|
||||
for season := 1993; season <= 2025; season++ {
|
||||
for season := 2023; season <= 2025; season++ {
|
||||
if err := services.FetchAndStorePlayerAdvancedStats(db, season); err != nil {
|
||||
log.Printf("Fetch failed for player advanced season %d: %v\n", season, err)
|
||||
} else {
|
||||
@@ -42,7 +71,7 @@ func main() {
|
||||
}()
|
||||
|
||||
go func() {
|
||||
for season := 1993; season <= 2025; season++ {
|
||||
for season := 2023; season <= 2025; season++ {
|
||||
if err := services.FetchAndStorePlayerTotalStats(db, season); err != nil {
|
||||
log.Printf("Fetch failed for player totals season %d: %v\n", season, err)
|
||||
} else {
|
||||
@@ -53,15 +82,16 @@ func main() {
|
||||
log.Printf("player totals Import Success")
|
||||
}()
|
||||
|
||||
// Add Prometheus metrics endpoint
|
||||
app.Get("/metrics", adaptor.HTTPHandler(promhttp.Handler()))
|
||||
/* ---------- START & SHUTDOWN ---------- */
|
||||
go func() {
|
||||
if err := app.Listen(":5000"); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
log.Fatalf("listen: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// Register routes
|
||||
routes.RegisterPlayerAdvancedRoutes(app, db)
|
||||
routes.RegisterPlayerTotalRoutes(app, db)
|
||||
|
||||
// Swagger endpoint
|
||||
app.Get("/swagger/*", fiberswagger.WrapHandler)
|
||||
|
||||
app.Listen(":5000")
|
||||
<-ctx.Done() // wait for SIGTERM/CTRL‑C
|
||||
stop() // stop receiving more signals
|
||||
log.Println("shutting down…")
|
||||
_ = app.Shutdown() // stop accepting new conns
|
||||
log.Println("bye")
|
||||
}
|
||||
+47
-20
@@ -1,52 +1,79 @@
|
||||
// main_test.go
|
||||
package main
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"testing"
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/nprasad2077/NBA_Go/models"
|
||||
"github.com/nprasad2077/NBA_Go/routes"
|
||||
"github.com/nprasad2077/NBA_Go/config"
|
||||
"github.com/nprasad2077/NBA_Go/utils/security"
|
||||
)
|
||||
|
||||
func setupTestApp() *fiber.App {
|
||||
// -----------------------------------------------------------------------------
|
||||
// test bootstrap
|
||||
// -----------------------------------------------------------------------------
|
||||
func setupTestApp() (*fiber.App, string) {
|
||||
app := fiber.New()
|
||||
db := config.InitDB() // uses SQLite file; can be mocked or in-memory for advanced testing
|
||||
|
||||
// in‑memory SQLite so tests don’t touch real file
|
||||
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{})
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
_ = db.AutoMigrate(&models.PlayerAdvancedStat{}, &models.APIKey{})
|
||||
|
||||
// seed one API key we can use in the requests
|
||||
rawKey := "testkey123"
|
||||
db.Create(&models.APIKey{Hash: security.HashKey(rawKey)})
|
||||
|
||||
// register only the routes we need
|
||||
routes.RegisterPlayerAdvancedRoutes(app, db)
|
||||
return app
|
||||
|
||||
return app, rawKey
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// actual test
|
||||
// -----------------------------------------------------------------------------
|
||||
func TestGetPlayerAdvancedStats(t *testing.T) {
|
||||
app := setupTestApp()
|
||||
app, key := setupTestApp()
|
||||
|
||||
tests := []struct {
|
||||
description string
|
||||
name string
|
||||
route string
|
||||
expectedCode int
|
||||
expectInBody string
|
||||
wantCode int
|
||||
wantSubstring string
|
||||
}{
|
||||
{
|
||||
description: "valid route",
|
||||
route: "/api/playeradvancedstats",
|
||||
expectedCode: 200,
|
||||
expectInBody: `"data"`, // assuming a JSON object with "data"
|
||||
name: "valid route",
|
||||
route: "/api/playeradvancedstats/",
|
||||
wantCode: 200,
|
||||
wantSubstring: `"data"`, // expect JSON payload has "data"
|
||||
},
|
||||
{
|
||||
description: "invalid route",
|
||||
name: "invalid route",
|
||||
route: "/api/invalid",
|
||||
expectedCode: 404,
|
||||
expectInBody: "Cannot GET /api/invalid",
|
||||
wantCode: 404,
|
||||
wantSubstring: "Cannot GET /api/invalid",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
req, _ := http.NewRequest("GET", tc.route, nil)
|
||||
req, _ := http.NewRequest(http.MethodGet, tc.route, nil)
|
||||
req.Header.Set("X-API-Key", key)
|
||||
|
||||
resp, err := app.Test(req, -1)
|
||||
assert.Nil(t, err, tc.description)
|
||||
assert.Equal(t, tc.expectedCode, resp.StatusCode, tc.description)
|
||||
assert.NoError(t, err, tc.name)
|
||||
assert.Equal(t, tc.wantCode, resp.StatusCode, tc.name)
|
||||
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
assert.Contains(t, string(body), tc.expectInBody, tc.description)
|
||||
assert.Contains(t, string(body), tc.wantSubstring, tc.name)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// APIKey is stored **hashed** (SHA‑256) and may be revoked at any time.
|
||||
type APIKey struct {
|
||||
ID uint `gorm:"primaryKey"`
|
||||
Hash []byte `gorm:"uniqueIndex"`
|
||||
Label string // e.g. “mobile‑app”, “data‑partner‑X”
|
||||
Revoked bool
|
||||
CreatedAt time.Time
|
||||
RevokedAt gorm.DeletedAt `gorm:"index"`
|
||||
}
|
||||
@@ -1,16 +1,21 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io/ioutil"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
func GetJSON(url string) ([]byte, error) {
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
resp, err := client.Get(url)
|
||||
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
client := &http.Client{}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -20,7 +25,7 @@ func GetJSON(url string) ([]byte, error) {
|
||||
return nil, errors.New("received non-200 response: " + resp.Status)
|
||||
}
|
||||
|
||||
body, err := ioutil.ReadAll(resp.Body)
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"crypto/subtle"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/gofiber/fiber/v2/middleware/keyauth"
|
||||
"github.com/nprasad2077/NBA_Go/models"
|
||||
"github.com/nprasad2077/NBA_Go/utils/security"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// APIKeyAuth returns a Fiber Handler that validates X‑API‑Key
|
||||
func APIKeyAuth(db *gorm.DB) fiber.Handler {
|
||||
return keyauth.New(keyauth.Config{
|
||||
KeyLookup: "header:X-API-Key",
|
||||
Validator: func(c *fiber.Ctx, rawKey string) (bool, error) {
|
||||
var rec models.APIKey
|
||||
hash := security.HashKey(rawKey)
|
||||
|
||||
err := db.
|
||||
Where("hash = ? AND revoked = FALSE", hash).
|
||||
First(&rec).Error
|
||||
if err != nil {
|
||||
return false, keyauth.ErrMissingOrMalformedAPIKey
|
||||
}
|
||||
if subtle.ConstantTimeCompare(rec.Hash, hash) != 1 {
|
||||
return false, keyauth.ErrMissingOrMalformedAPIKey
|
||||
}
|
||||
// put the key ID on the context for rate‑limiting or logging later
|
||||
c.Locals("apiKeyID", rec.ID)
|
||||
return true, nil
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package security
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
)
|
||||
|
||||
// GenerateRawKey returns a 32‑byte cryptographically‑random string
|
||||
func GenerateRawKey() (string, error) {
|
||||
b := make([]byte, 32)
|
||||
_, err := rand.Read(b)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
// HashKey returns the SHA‑256 hash suitable for storage.
|
||||
func HashKey(raw string) []byte {
|
||||
h := sha256.Sum256([]byte(raw))
|
||||
return h[:]
|
||||
}
|
||||
Reference in New Issue
Block a user