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
+16
View File
@@ -1 +1,17 @@
# 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
@@ -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
+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
// @Security ApiKeyAuth
// @Summary Get player stats
// @Description Returns filtered and paginated player stats
// @Tags PlayerStats
+1
View File
@@ -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
+3
View File
@@ -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: .
+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": {
"/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"
+10
View File
@@ -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"
+4
View File
@@ -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
+62 -32
View File
@@ -1,36 +1,65 @@
package main
import (
"log"
"time"
"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/nprasad2077/NBA_Go/config"
"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/utils/middleware"
_ "github.com/nprasad2077/NBA_Go/docs"
fiberswagger "github.com/swaggo/fiber-swagger"
"github.com/prometheus/client_golang/prometheus/promhttp"
"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.Use(logger.New())
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()})
},
})
// Add metrics middleware
app.Use(middleware.MetricsMiddleware())
// middlewares
app.Use(logger.New())
app.Use(middleware.MetricsMiddleware())
// Initialize database
db := config.InitDB()
// DB
db := config.InitDB()
// Original data fetching code, no environment variable check
go func() {
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 {
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/CTRLC
stop() // stop receiving more signals
log.Println("shutting down…")
_ = app.Shutdown() // stop accepting new conns
log.Println("bye")
}
+48 -21
View File
@@ -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
// 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)
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",
route: "/api/invalid",
expectedCode: 404,
expectInBody: "Cannot GET /api/invalid",
name: "invalid route",
route: "/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)
}
}
+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
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
}
+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[:]
}