api key auth added

This commit is contained in:
Ravi Prasad
2025-05-08 00:32:49 -05:00
parent 09d81c251a
commit 8c577b441c
17 changed files with 345 additions and 61 deletions
+17 -1
View File
@@ -1 +1,17 @@
# NBA_Go # NBA_Go
### Firsttime 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…"
+11
View File
@@ -0,0 +1,11 @@
package main
import (
"fmt"
"github.com/nprasad2077/NBA_Go/utils/security"
)
func main() {
raw, _ := security.GenerateRawKey()
fmt.Println(raw)
}
+2
View File
@@ -21,6 +21,8 @@ func InitDB() *gorm.DB {
// Auto migrate models // Auto migrate models
db.AutoMigrate(&models.PlayerAdvancedStat{}) db.AutoMigrate(&models.PlayerAdvancedStat{})
db.AutoMigrate(&models.PlayerTotalStat{}) db.AutoMigrate(&models.PlayerTotalStat{})
db.AutoMigrate(&models.APIKey{})
metrics.DBOperationsTotal.WithLabelValues("migrate", "database").Inc() metrics.DBOperationsTotal.WithLabelValues("migrate", "database").Inc()
+62
View File
@@ -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 XAdminSecret == $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 // GetAllPlayerStats godoc
// @Security ApiKeyAuth
// @Summary Get player stats // @Summary Get player stats
// @Description Returns filtered and paginated player stats // @Description Returns filtered and paginated player stats
// @Tags PlayerStats // @Tags PlayerStats
+1
View File
@@ -33,6 +33,7 @@ func FetchPlayerTotalStats(db *gorm.DB) fiber.Handler {
// GetPlayerTotalStats godoc // GetPlayerTotalStats godoc
// @Security ApiKeyAuth
// @Summary Get player total stats // @Summary Get player total stats
// @Description Filter and paginate player totals // @Description Filter and paginate player totals
// @Tags PlayerTotals // @Tags PlayerTotals
+3
View File
@@ -10,6 +10,7 @@ services:
networks: networks:
- api.network - api.network
api1: api1:
env_file: .env
volumes: volumes:
- './data:/app/data' - './data:/app/data'
build: . build: .
@@ -22,6 +23,7 @@ services:
networks: networks:
- api.network - api.network
api2: api2:
env_file: .env
volumes: volumes:
- './data:/app/data' - './data:/app/data'
build: . build: .
@@ -34,6 +36,7 @@ services:
networks: networks:
- api.network - api.network
api3: api3:
env_file: .env
volumes: volumes:
- './data:/app/data' - './data:/app/data'
build: . build: .
+27
View File
@@ -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 APIkey 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`)
// ------------------------------------------------------------
+10
View File
@@ -17,6 +17,11 @@ const docTemplate = `{
"paths": { "paths": {
"/api/playeradvancedstats": { "/api/playeradvancedstats": {
"get": { "get": {
"security": [
{
"ApiKeyAuth": []
}
],
"description": "Returns filtered and paginated player stats", "description": "Returns filtered and paginated player stats",
"consumes": [ "consumes": [
"application/json" "application/json"
@@ -87,6 +92,11 @@ const docTemplate = `{
}, },
"/api/playertotals": { "/api/playertotals": {
"get": { "get": {
"security": [
{
"ApiKeyAuth": []
}
],
"description": "Filter and paginate player totals", "description": "Filter and paginate player totals",
"consumes": [ "consumes": [
"application/json" "application/json"
+10
View File
@@ -6,6 +6,11 @@
"paths": { "paths": {
"/api/playeradvancedstats": { "/api/playeradvancedstats": {
"get": { "get": {
"security": [
{
"ApiKeyAuth": []
}
],
"description": "Returns filtered and paginated player stats", "description": "Returns filtered and paginated player stats",
"consumes": [ "consumes": [
"application/json" "application/json"
@@ -76,6 +81,11 @@
}, },
"/api/playertotals": { "/api/playertotals": {
"get": { "get": {
"security": [
{
"ApiKeyAuth": []
}
],
"description": "Filter and paginate player totals", "description": "Filter and paginate player totals",
"consumes": [ "consumes": [
"application/json" "application/json"
+4
View File
@@ -45,6 +45,8 @@ paths:
schema: schema:
additionalProperties: true additionalProperties: true
type: object type: object
security:
- ApiKeyAuth: []
summary: Get player stats summary: Get player stats
tags: tags:
- PlayerStats - PlayerStats
@@ -98,6 +100,8 @@ paths:
additionalProperties: additionalProperties:
type: string type: string
type: object type: object
security:
- ApiKeyAuth: []
summary: Get player total stats summary: Get player total stats
tags: tags:
- PlayerTotals - PlayerTotals
+65 -35
View File
@@ -1,36 +1,65 @@
package main package main
import ( import (
"log" "context"
"time" "errors"
"log"
"github.com/gofiber/fiber/v2" "os"
"github.com/gofiber/fiber/v2/middleware/logger" "os/signal"
"github.com/gofiber/fiber/v2/middleware/adaptor" "syscall"
"github.com/nprasad2077/NBA_Go/config" "time"
"github.com/nprasad2077/NBA_Go/routes"
"github.com/gofiber/fiber/v2"
"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/services" "github.com/nprasad2077/NBA_Go/services"
"github.com/nprasad2077/NBA_Go/utils/middleware" "github.com/nprasad2077/NBA_Go/controllers"
_ "github.com/nprasad2077/NBA_Go/docs" "github.com/nprasad2077/NBA_Go/routes"
fiberswagger "github.com/swaggo/fiber-swagger" "github.com/nprasad2077/NBA_Go/utils/middleware"
"github.com/prometheus/client_golang/prometheus/promhttp" _ "github.com/nprasad2077/NBA_Go/docs"
fiberswagger "github.com/swaggo/fiber-swagger"
"github.com/prometheus/client_golang/prometheus/promhttp"
"net/http"
) )
func main() { 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{
app.Use(logger.New()) ReadTimeout: 15 * time.Second,
WriteTimeout: 15 * time.Second,
// Add metrics middleware ErrorHandler: func(c *fiber.Ctx, err error) error {
app.Use(middleware.MetricsMiddleware()) code := fiber.StatusInternalServerError
if e, ok := err.(*fiber.Error); ok {
code = e.Code
}
return c.Status(code).JSON(fiber.Map{"error": err.Error()})
},
})
// Initialize database // middlewares
db := config.InitDB() app.Use(logger.New())
app.Use(middleware.MetricsMiddleware())
// Original data fetching code, no environment variable check // DB
go func() { db := config.InitDB()
for season := 1993; season <= 2025; season++ {
/* ---------- PUBLIC ROUTES (no API key) ---------- */
app.Get("/metrics", adaptor.HTTPHandler(promhttp.Handler()))
app.Get("/swagger/*", fiberswagger.WrapHandler)
controllers.RegisterKeyAdminRoutes(app, db) // guarded only by XAdminSecret
/* ---------- PROTECTED ROUTES ---------- */
app.Use(middleware.APIKeyAuth(db))
routes.RegisterPlayerAdvancedRoutes(app, db)
routes.RegisterPlayerTotalRoutes(app, db)
/* ---------- Optional import job ---------- */
go func() {
for season := 2023; season <= 2025; season++ {
if err := services.FetchAndStorePlayerAdvancedStats(db, season); err != nil { if err := services.FetchAndStorePlayerAdvancedStats(db, season); err != nil {
log.Printf("Fetch failed for player advanced season %d: %v\n", season, err) log.Printf("Fetch failed for player advanced season %d: %v\n", season, err)
} else { } else {
@@ -42,7 +71,7 @@ func main() {
}() }()
go func() { go func() {
for season := 1993; season <= 2025; season++ { for season := 2023; season <= 2025; season++ {
if err := services.FetchAndStorePlayerTotalStats(db, season); err != nil { if err := services.FetchAndStorePlayerTotalStats(db, season); err != nil {
log.Printf("Fetch failed for player totals season %d: %v\n", season, err) log.Printf("Fetch failed for player totals season %d: %v\n", season, err)
} else { } else {
@@ -53,15 +82,16 @@ func main() {
log.Printf("player totals Import Success") log.Printf("player totals Import Success")
}() }()
// Add Prometheus metrics endpoint /* ---------- START & SHUTDOWN ---------- */
app.Get("/metrics", adaptor.HTTPHandler(promhttp.Handler())) go func() {
if err := app.Listen(":5000"); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Fatalf("listen: %v", err)
}
}()
// Register routes <-ctx.Done() // wait for SIGTERM/CTRLC
routes.RegisterPlayerAdvancedRoutes(app, db) stop() // stop receiving more signals
routes.RegisterPlayerTotalRoutes(app, db) log.Println("shutting down…")
_ = app.Shutdown() // stop accepting new conns
// Swagger endpoint log.Println("bye")
app.Get("/swagger/*", fiberswagger.WrapHandler) }
app.Listen(":5000")
}
+48 -21
View File
@@ -1,52 +1,79 @@
// main_test.go
package main package main
import ( import (
"io" "io"
"net/http" "net/http"
"testing" "testing"
"github.com/stretchr/testify/assert"
"github.com/gofiber/fiber/v2" "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/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() app := fiber.New()
db := config.InitDB() // uses SQLite file; can be mocked or in-memory for advanced testing
// inmemory SQLite so tests dont 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) routes.RegisterPlayerAdvancedRoutes(app, db)
return app
return app, rawKey
} }
// -----------------------------------------------------------------------------
// actual test
// -----------------------------------------------------------------------------
func TestGetPlayerAdvancedStats(t *testing.T) { func TestGetPlayerAdvancedStats(t *testing.T) {
app := setupTestApp() app, key := setupTestApp()
tests := []struct { tests := []struct {
description string name string
route string route string
expectedCode int wantCode int
expectInBody string wantSubstring string
}{ }{
{ {
description: "valid route", name: "valid route",
route: "/api/playeradvancedstats", route: "/api/playeradvancedstats/",
expectedCode: 200, wantCode: 200,
expectInBody: `"data"`, // assuming a JSON object with "data" wantSubstring: `"data"`, // expect JSON payload has "data"
}, },
{ {
description: "invalid route", name: "invalid route",
route: "/api/invalid", route: "/api/invalid",
expectedCode: 404, wantCode: 404,
expectInBody: "Cannot GET /api/invalid", wantSubstring: "Cannot GET /api/invalid",
}, },
} }
for _, tc := range tests { 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) resp, err := app.Test(req, -1)
assert.Nil(t, err, tc.description) assert.NoError(t, err, tc.name)
assert.Equal(t, tc.expectedCode, resp.StatusCode, tc.description) assert.Equal(t, tc.wantCode, resp.StatusCode, tc.name)
body, _ := io.ReadAll(resp.Body) body, _ := io.ReadAll(resp.Body)
assert.Contains(t, string(body), tc.expectInBody, tc.description) assert.Contains(t, string(body), tc.wantSubstring, tc.name)
} }
} }
+17
View File
@@ -0,0 +1,17 @@
package models
import (
"time"
"gorm.io/gorm"
)
// APIKey is stored **hashed** (SHA256) and may be revoked at any time.
type APIKey struct {
ID uint `gorm:"primaryKey"`
Hash []byte `gorm:"uniqueIndex"`
Label string // e.g. “mobileapp”, “datapartnerX”
Revoked bool
CreatedAt time.Time
RevokedAt gorm.DeletedAt `gorm:"index"`
}
+9 -4
View File
@@ -1,16 +1,21 @@
package utils package utils
import ( import (
"context"
"errors" "errors"
"io/ioutil" "io"
"net/http" "net/http"
"time" "time"
) )
func GetJSON(url string) ([]byte, error) { 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 { if err != nil {
return nil, err return nil, err
} }
@@ -20,7 +25,7 @@ func GetJSON(url string) ([]byte, error) {
return nil, errors.New("received non-200 response: " + resp.Status) 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 { if err != nil {
return nil, err return nil, err
} }
+35
View File
@@ -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 XAPIKey
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 ratelimiting or logging later
c.Locals("apiKeyID", rec.ID)
return true, nil
},
})
}
+23
View File
@@ -0,0 +1,23 @@
package security
import (
"crypto/rand"
"crypto/sha256"
"encoding/hex"
)
// GenerateRawKey returns a 32byte cryptographicallyrandom 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 SHA256 hash suitable for storage.
func HashKey(raw string) []byte {
h := sha256.Sum256([]byte(raw))
return h[:]
}