From d332cde84f2ef0ed91261a739e90000131cde882 Mon Sep 17 00:00:00 2001 From: phoenix Date: Tue, 4 Nov 2025 17:51:35 +0000 Subject: [PATCH] tsk-3: Add Token support (#10) Closes #3 Reviewed-on: https://git.kundeng.us/phoenix/textsender-api/pulls/10 Co-authored-by: phoenix Co-committed-by: phoenix --- .env.sample | 1 + .gitea/workflows/workflow.yaml | 2 ++ cmd/api/main.go | 10 +++++-- go.mod | 1 + go.sum | 4 +-- internal/config/config.go | 2 ++ internal/middleware/auth.go | 47 ++++++++++++++++++++++++++++++ internal/services/jwt_service.go | 50 ++++++++++++++++++++++++++++++++ 8 files changed, 113 insertions(+), 4 deletions(-) create mode 100644 internal/middleware/auth.go create mode 100644 internal/services/jwt_service.go diff --git a/.env.sample b/.env.sample index 363dbde..e1ef0c1 100644 --- a/.env.sample +++ b/.env.sample @@ -1,3 +1,4 @@ +JWT_SECRET=NULqYIzgt28bTiyziCd7IOO7b6LnWDW! DB_NAME=textsender_db DB_USER=textsender DB_PASSWORD=password diff --git a/.gitea/workflows/workflow.yaml b/.gitea/workflows/workflow.yaml index a63c29b..a11f7be 100644 --- a/.gitea/workflows/workflow.yaml +++ b/.gitea/workflows/workflow.yaml @@ -94,10 +94,12 @@ jobs: DB_HOST: postgres DB_PORT: 5432 DB_SSLMODE: disable + SECRET_KEY: ${{ secrets.SECRET_KEY }} run: | echo "Parent directory" echo `pwd` + echo "JWT_SECRET=$SECRET_KEY" >> .env echo "DB_NAME=$DB_NAME" > .env echo "DB_USER=$DB_USER" >> .env echo "DB_PASSWORD=$DB_PASSWORD" >> .env diff --git a/cmd/api/main.go b/cmd/api/main.go index 280a931..68a8372 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -18,13 +18,17 @@ import ( "git.kundeng.us/phoenix/textsender-api/internal/handler" "git.kundeng.us/phoenix/textsender-api/internal/handler/endpoint" mdlware "git.kundeng.us/phoenix/textsender-api/internal/middleware" + "git.kundeng.us/phoenix/textsender-api/internal/services" "git.kundeng.us/phoenix/textsender-api/internal/store" ) func main() { cfg := config.Load() if cfg == nil { - fmt.Errorf("Error initializing config") + fmt.Println("Error initializing config") + os.Exit(-1) + } else if cfg.JWTSecret == "" { + fmt.Println("Error: JWTSecret not initialized") os.Exit(-1) } @@ -45,6 +49,8 @@ func main() { return } + jwtService := services.NewJWTService(cfg.JWTSecret) + contactStore := store.NewContactStore(database.Pool) contactHandler := handler.NewContactHandler(contactStore) @@ -55,7 +61,7 @@ func main() { router.Use(middleware.Timeout(60 * time.Second)) router.Use(mdlware.JSONContentType) - router.Post(endpoint.ADD_CONTACT_ENDPOINT, contactHandler.AddContact) + router.Handle(endpoint.ADD_CONTACT_ENDPOINT, mdlware.AuthMiddleware(jwtService)(http.HandlerFunc(contactHandler.AddContact))) // Start server server := &http.Server{ diff --git a/go.mod b/go.mod index c0f507e..0692415 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ require github.com/google/uuid v1.6.0 require ( git.kundeng.us/phoenix/textsender-models v0.0.4 github.com/go-chi/chi/v5 v5.2.3 + github.com/golang-jwt/jwt/v5 v5.3.0 github.com/gorilla/mux v1.8.1 github.com/jackc/pgx/v5 v5.7.6 github.com/joho/godotenv v1.5.1 diff --git a/go.sum b/go.sum index 035b138..1ee6492 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,3 @@ -git.kundeng.us/phoenix/textsender-models v0.0.4-1-0e41936506-556 h1:htIAVchoU7LzTlDuAOwwUYmJH8U9UXCwjt3lO00juEc= -git.kundeng.us/phoenix/textsender-models v0.0.4-1-0e41936506-556/go.mod h1:lx5MCnOgGgsdpwzrfi9uph5xmkeb6H8AuexUNGss2no= git.kundeng.us/phoenix/textsender-models v0.0.4 h1:r3kqo/Xe5hEcRceknPZ/gw1y4VHSrhG+UYYgrgGTsVg= git.kundeng.us/phoenix/textsender-models v0.0.4/go.mod h1:lx5MCnOgGgsdpwzrfi9uph5xmkeb6H8AuexUNGss2no= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= @@ -8,6 +6,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE= github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= +github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= +github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= diff --git a/internal/config/config.go b/internal/config/config.go index d7a496a..f29e1ed 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -16,6 +16,7 @@ type Config struct { DBConnString string ServerPort string ResetDB bool + JWTSecret string `env:"JWT_SECRET" required:"true"` } type ConnectionInfo struct { @@ -68,6 +69,7 @@ func Load() *Config { DBConnString: dbConnString, ServerPort: *port, ResetDB: *resetDb, + JWTSecret: os.Getenv("JWT_SECRET"), } } diff --git a/internal/middleware/auth.go b/internal/middleware/auth.go new file mode 100644 index 0000000..1cbceb1 --- /dev/null +++ b/internal/middleware/auth.go @@ -0,0 +1,47 @@ +package middleware + +import ( + "context" + "net/http" + "strings" + + "git.kundeng.us/phoenix/textsender-api/internal/services" +) + +type contextKey string + +const ( + UserContextKey contextKey = "user" +) + +func AuthMiddleware(authService *services.JWTService) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + authHeader := r.Header.Get("Authorization") + if authHeader == "" { + http.Error(w, "Authorization header required", http.StatusUnauthorized) + return + } + + // Extract token from "Bearer " + parts := strings.Split(authHeader, " ") + if len(parts) != 2 || parts[0] != "Bearer" { + http.Error(w, "Invalid authorization header format", http.StatusUnauthorized) + return + } + + token := parts[1] + + // Validate token with auth service + user, err := authService.ValidateToken(token) + if err != nil { + http.Error(w, "Invalid token", http.StatusUnauthorized) + return + } + + // Add user to context + ctx := context.WithValue(r.Context(), UserContextKey, user) + next.ServeHTTP(w, r.WithContext(ctx)) + }) + } +} diff --git a/internal/services/jwt_service.go b/internal/services/jwt_service.go new file mode 100644 index 0000000..b4eaf56 --- /dev/null +++ b/internal/services/jwt_service.go @@ -0,0 +1,50 @@ +package services + +import ( + "time" + + "github.com/golang-jwt/jwt/v5" + + txtmodels_token "git.kundeng.us/phoenix/textsender-models/pkg/token" + txtmodels_user "git.kundeng.us/phoenix/textsender-models/pkg/user" +) + +type JWTService struct { + secretKey []byte +} + +func NewJWTService(secretKey string) *JWTService { + return &JWTService{ + secretKey: []byte(secretKey), + } +} + +func (s *JWTService) ValidateToken(tokenString string) (*txtmodels_user.User, error) { + // TODO: Include more user information in the claims to populate user + claims := &txtmodels_token.Claims{} + + token, err := jwt.ParseWithClaims(tokenString, claims, func(token *jwt.Token) (interface{}, error) { + // Validate the signing method + if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, jwt.ErrSignatureInvalid + } + return s.secretKey, nil + }) + + if err != nil { + return nil, err + } + + if !token.Valid { + return nil, jwt.ErrSignatureInvalid + } + + // Check token expiration + if time.Now().After(claims.ExpiresAt.Time) { + return nil, jwt.ErrTokenExpired + } + + return &txtmodels_user.User{ + Id: claims.UserId, + }, nil +}