From ab8608a76632d4948e72a484a13182ed062a9585 Mon Sep 17 00:00:00 2001 From: syniron <66834451+SyniRon@users.noreply.github.com> Date: Tue, 21 Apr 2026 22:49:39 -0400 Subject: [PATCH] build out xenforo api auth --- datastores/datastore.go | 7 +++++++ datastores/mysql.go | 21 +++++++++++++++++++++ servers/gateway/gateway.go | 27 ++++++++++++++++++--------- servers/grpc/authentication.go | 33 +++++++++++++++------------------ servers/server.go | 20 ++++---------------- 5 files changed, 65 insertions(+), 43 deletions(-) diff --git a/datastores/datastore.go b/datastores/datastore.go index 2aed728..fdf93e8 100644 --- a/datastores/datastore.go +++ b/datastores/datastore.go @@ -31,6 +31,12 @@ var ( Error = log.New(os.Stdout, "ERROR: ", log.LstdFlags) ) +type ApiKeyResult struct { + KeyId uint + UserId uint + ScopeRead bool +} + type Datastore interface { FindProfilesById(userId ...uint64) ([]*proto.Profile, error) FindProfilesByUsername(username string) ([]*proto.Profile, error) @@ -45,4 +51,5 @@ type Datastore interface { FindAwol() ([]*proto.Awol, error) GetTableUpdates() ([]xenforo.TableInfo, error) FindProfileByGamertag(gamertag string) (*proto.Profile, error) + ValidateApiKey(rawKey string) (*ApiKeyResult, error) } diff --git a/datastores/mysql.go b/datastores/mysql.go index d6d36f9..bea0fa0 100644 --- a/datastores/mysql.go +++ b/datastores/mysql.go @@ -704,6 +704,27 @@ func (ds Mysql) GetTableUpdates() ([]xenforo.TableInfo, error) { return updates, nil } +func (ds Mysql) ValidateApiKey(rawKey string) (*ApiKeyResult, error) { + var row struct { + KeyId uint `gorm:"column:key_id"` + UserId uint `gorm:"column:user_id"` + ScopeRead bool `gorm:"column:scope_read"` + } + tx := ds.Db.Raw(` + SELECT key_id, user_id, scope_read + FROM xf_cav7_api_key + WHERE key_hash = UNHEX(SHA2(?, 256)) + AND is_active = 1`, rawKey).Scan(&row) + if tx.Error != nil { + return nil, tx.Error + } + if tx.RowsAffected == 0 { + return nil, nil + } + go ds.Db.Exec(`UPDATE xf_cav7_api_key SET last_used_date = UNIX_TIMESTAMP() WHERE key_id = ?`, row.KeyId) + return &ApiKeyResult{KeyId: row.KeyId, UserId: row.UserId, ScopeRead: row.ScopeRead}, nil +} + func (ds Mysql) FindProfileByGamertag(gamertag string) (*proto.Profile, error) { var profile milpacs.Profile diff --git a/servers/gateway/gateway.go b/servers/gateway/gateway.go index 14925ae..379ded0 100644 --- a/servers/gateway/gateway.go +++ b/servers/gateway/gateway.go @@ -21,7 +21,6 @@ package gateway import ( "compress/gzip" "context" - "crypto/subtle" "fmt" "log" "mime" @@ -30,6 +29,7 @@ import ( "strings" "github.com/7cav/api/cache" + "github.com/7cav/api/datastores" "github.com/7cav/api/middleware" "github.com/7cav/api/proto" _ "github.com/7cav/api/statik" // static files import - unused in the codebase, but required cuz reasons @@ -39,9 +39,9 @@ import ( ) type Service struct { - Address string - Cache *cache.RedisCache - APISecret string + Address string + Cache *cache.RedisCache + Datastore datastores.Datastore } var ( @@ -62,12 +62,21 @@ func getOpenAPIHandler() http.Handler { return http.FileServer(statikFs) } -func authMiddleware(secret string, next http.Handler) http.Handler { +// maxTokenLen is the maximum length of a raw API key we'll accept. +// cav7_ prefix (5) + 64 hex chars = 69; 128 gives generous headroom. +const maxTokenLen = 128 + +func authMiddleware(ds datastores.Datastore, next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - authHeader := r.Header.Get("Authorization") - token := strings.TrimPrefix(authHeader, "Bearer ") + token := strings.TrimSpace(strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")) + if token == "" || len(token) > maxTokenLen { + Warn.Printf("Unauthorized HTTP access attempt from %s", r.RemoteAddr) + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } - if subtle.ConstantTimeCompare([]byte(token), []byte(secret)) != 1 { + key, err := ds.ValidateApiKey(token) + if err != nil || key == nil || !key.ScopeRead { Warn.Printf("Unauthorized HTTP access attempt from %s", r.RemoteAddr) http.Error(w, "Unauthorized", http.StatusUnauthorized) return @@ -136,7 +145,7 @@ func (service *Service) Server() *http.Server { openApi := getOpenAPIHandler() - handler := authMiddleware(service.APISecret, + handler := authMiddleware(service.Datastore, middleware.CacheMiddleware(service.Cache, compressionMiddleware(gwMux))) // if requests start with /api then forward it on to the grpc-gateway client diff --git a/servers/grpc/authentication.go b/servers/grpc/authentication.go index 69f4b06..fc63c78 100644 --- a/servers/grpc/authentication.go +++ b/servers/grpc/authentication.go @@ -20,20 +20,21 @@ package grpc import ( "context" - "crypto/subtle" "strings" + "github.com/7cav/api/datastores" "google.golang.org/grpc" "google.golang.org/grpc/codes" "google.golang.org/grpc/metadata" "google.golang.org/grpc/status" ) -func NewAuthInterceptor(secret string) grpc.UnaryServerInterceptor { +const maxTokenLen = 128 + +func NewAuthInterceptor(ds datastores.Datastore) grpc.UnaryServerInterceptor { return func(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) { md, ok := metadata.FromIncomingContext(ctx) if !ok { - // You can use your global logger here if accessible, or fmt return nil, status.Errorf(codes.Unauthenticated, "missing metadata") } @@ -42,24 +43,20 @@ func NewAuthInterceptor(secret string) grpc.UnaryServerInterceptor { return nil, status.Errorf(codes.Unauthenticated, "missing authorization token") } - authHeader := strings.TrimSpace(authHeaders[0]) + raw := strings.TrimSpace(authHeaders[0]) + if !strings.HasPrefix(raw, "Bearer ") { + return nil, status.Errorf(codes.Unauthenticated, "missing authorization token") + } + token := strings.TrimSpace(raw[len("Bearer "):]) + if token == "" || len(token) > maxTokenLen { + return nil, status.Errorf(codes.Unauthenticated, "missing authorization token") + } - // Pass the baked-in secret to the check - if !isValidToken(authHeader, secret) { - // logic to log warning if needed - return nil, status.Errorf(codes.Unauthenticated, "invalid token") + key, err := ds.ValidateApiKey(token) + if err != nil || key == nil || !key.ScopeRead { + return nil, status.Errorf(codes.Unauthenticated, "invalid api key") } return handler(ctx, req) } } - -func isValidToken(authHeader, secret string) bool { - token := strings.TrimPrefix(authHeader, "Bearer ") - if token == "" { - return false - } - - // Compare the token against the secret passed from startup - return subtle.ConstantTimeCompare([]byte(token), []byte(secret)) == 1 -} \ No newline at end of file diff --git a/servers/server.go b/servers/server.go index 1783624..3fb55c6 100644 --- a/servers/server.go +++ b/servers/server.go @@ -61,16 +61,6 @@ var ( Error = log.New(os.Stdout, "ERROR: ", log.LstdFlags) ) -func setupAuth() string { - secret := viper.GetString("API_SECRET") - if secret == "" { - // It is critical to fail fast if this is missing - Error.Println("CRITICAL: API_SECRET is not set in environment/config") - os.Exit(1) - } - return secret -} - func setupRedis() *cache.RedisCache { redisHost := viper.GetString("REDIS_HOST") if redisHost == "" { @@ -145,7 +135,6 @@ func (server *MicroServer) Start() { Error.Fatalf("Failed to listen on %s: %w", server.addr, err) } - apiSecret := setupAuth() ds := setupDatasource() server.cache = setupRedis() go cache.CacheManager(server.cache, ds) @@ -155,13 +144,13 @@ func (server *MicroServer) Start() { // If this needed to change in the future, then we will need to refactor this method opts := []grpc.ServerOption{ // Intercept request to check the token. - grpc.UnaryInterceptor(grpcServices.NewAuthInterceptor(apiSecret)), + grpc.UnaryInterceptor(grpcServices.NewAuthInterceptor(ds)), //grpc.Creds(creds), } // launch goroutines for multiplexed listener Info.Println("Starting HTTP listener") - go servHTTP(server, httpL) + go servHTTP(server, httpL, ds) Info.Println("Starting GRPC listener") servGRPC(server, grpcL, opts, ds) } @@ -180,9 +169,8 @@ func servGRPC(server *MicroServer, lis net.Listener, grpcOpts []grpc.ServerOptio } } -func servHTTP(server *MicroServer, lis net.Listener) { - secret := setupAuth() - service := httpServices.Service{Address: server.addr, Cache: server.cache, APISecret: secret,} +func servHTTP(server *MicroServer, lis net.Listener, ds datastores.Datastore) { + service := httpServices.Service{Address: server.addr, Cache: server.cache, Datastore: ds} server.httpServer = service.Server() if err := server.httpServer.Serve(lis); err != nil { Error.Fatalf("unable to start HTTP servers: ", err)