diff --git a/go.mod b/go.mod index f4bf926..8cc9542 100644 --- a/go.mod +++ b/go.mod @@ -6,9 +6,11 @@ require ( github.com/PuerkitoBio/goquery v1.8.0 github.com/glebarez/go-sqlite v1.22.0 github.com/go-rod/rod v0.107.1 + github.com/golang-jwt/jwt/v5 v5.2.1 github.com/google/uuid v1.6.0 github.com/huandu/go-sqlbuilder v1.27.1 github.com/joho/godotenv v1.4.0 + github.com/labstack/echo-jwt/v4 v4.2.0 github.com/labstack/echo/v4 v4.12.0 github.com/mmcdole/gofeed v1.1.3 github.com/nicklaw5/helix/v2 v2.4.0 diff --git a/go.sum b/go.sum index a92e69f..0711bf5 100644 --- a/go.sum +++ b/go.sum @@ -35,6 +35,8 @@ github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keL github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= github.com/golang-jwt/jwt/v4 v4.5.0 h1:7cYmW1XlMY7h7ii7UhUyChSgS5wUJEnm9uZVTGqOWzg= github.com/golang-jwt/jwt/v4 v4.5.0/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= +github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= +github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ= github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo= @@ -60,6 +62,8 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/labstack/echo-jwt/v4 v4.2.0 h1:odSISV9JgcSCuhgQSV/6Io3i7nUmfM/QkBeR5GVJj5c= +github.com/labstack/echo-jwt/v4 v4.2.0/go.mod h1:MA2RqdXdEn4/uEglx0HcUOgQSyBaTh5JcaHIan3biwU= github.com/labstack/echo/v4 v4.12.0 h1:IKpw49IMryVB2p1a4dzwlhP1O2Tf2E0Ir/450lH+kI0= github.com/labstack/echo/v4 v4.12.0/go.mod h1:UP9Cr2DJXbOK3Kr9ONYzNowSh7HP0aG0ShAyycHSJvM= github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0= diff --git a/internal/handler/v1/handler.go b/internal/handler/v1/handler.go index 149de1f..f39ad3d 100644 --- a/internal/handler/v1/handler.go +++ b/internal/handler/v1/handler.go @@ -3,7 +3,10 @@ package v1 import ( "context" "database/sql" + "errors" + "github.com/golang-jwt/jwt/v5" + echojwt "github.com/labstack/echo-jwt/v4" "github.com/labstack/echo/v4" "github.com/labstack/echo/v4/middleware" swagger "github.com/swaggo/echo-swagger" @@ -17,7 +20,6 @@ import ( type Handler struct { Router *echo.Echo Db *database.Queries - //dto *dto.DtoClient config services.Configs repo services.RepositoryService } @@ -28,6 +30,7 @@ const ( ErrUnableToParseId = "Unable to parse the requested ID" ErrRecordMissing = "The requested record was not found" ErrFailedToCreateRecord = "The record was not created due to a database problem" + ErrUserUnknown = "User is unknown" ResponseMessageSuccess = "Success" ) @@ -47,6 +50,13 @@ func NewServer(ctx context.Context, configs services.Configs, conn *sql.DB) *Han repo: services.NewRepositoryService(conn), } + jwtConfig := echojwt.Config{ + NewClaimsFunc: func(c echo.Context) jwt.Claims { + return new(JwtToken) + }, + SigningKey: []byte(configs.JwtSecret), + } + router := echo.New() router.Pre(middleware.RemoveTrailingSlash()) router.Pre(middleware.Logger()) @@ -55,6 +65,7 @@ func NewServer(ctx context.Context, configs services.Configs, conn *sql.DB) *Han v1 := router.Group("/api/v1") articles := v1.Group("/articles") + articles.Use(echojwt.WithConfig(jwtConfig)) articles.GET("", s.listArticles) articles.GET(":id", s.getArticle) articles.GET(":id/details", s.getArticleDetails) @@ -76,6 +87,7 @@ func NewServer(ctx context.Context, configs services.Configs, conn *sql.DB) *Han //settings.GET("/", s.getSettings) sources := v1.Group("/sources") + sources.Use(echojwt.WithConfig(jwtConfig)) sources.GET("", s.listSources) sources.GET("/by/source", s.listSourcesBySource) sources.GET("/by/sourceAndName", s.GetSourceBySourceAndName) @@ -89,6 +101,7 @@ func NewServer(ctx context.Context, configs services.Configs, conn *sql.DB) *Han sources.POST("/:ID/enable", s.enableSource) subs := v1.Group("/subscriptions") + subs.Use(echojwt.WithConfig(jwtConfig)) subs.GET("/", s.ListSubscriptions) subs.GET("/details", s.ListSubscriptionDetails) subs.GET("/by/discordId", s.GetSubscriptionsByDiscordId) @@ -120,3 +133,19 @@ func (s *Handler) WriteMessage(c echo.Context, msg string, HttpStatusCode int) e Message: msg, }) } + +func (h *Handler) getJwtToken(c echo.Context) (JwtToken, error) { + // Make sure that the request came with a jwtToken + token, ok := c.Get("user").(*jwt.Token) + if !ok { + return JwtToken{}, errors.New(ErrJwtMissing) + } + + // Generate the claims from the token + claims, ok := token.Claims.(*JwtToken) + if !ok { + return JwtToken{}, errors.New(ErrJwtClaimsMissing) + } + + return *claims, nil +} diff --git a/internal/handler/v1/jwt.go b/internal/handler/v1/jwt.go new file mode 100644 index 0000000..b7f1222 --- /dev/null +++ b/internal/handler/v1/jwt.go @@ -0,0 +1,99 @@ +package v1 + +import ( + "errors" + "strings" + "time" + + "git.jamestombleson.com/jtom38/newsbot-api/internal/domain" + "github.com/golang-jwt/jwt/v5" +) + +const ( + ErrJwtMissing = "auth token is missing" + ErrJwtClaimsMissing = "claims missing on token" + ErrJwtExpired = "auth token has expired" + ErrJwtScopeMissing = "required scope is missing" +) + +type JwtToken struct { + Exp time.Time `json:"exp"` + Iss string `json:"iss"` + Authorized bool `json:"authorized"` + UserName string `json:"username"` + Scopes []string `json:"scopes"` + jwt.RegisteredClaims +} + +func (j JwtToken) IsValid(scope string) error { + err := j.hasExpired() + if err != nil { + return err + } + + err = j.hasScope(scope) + if err != nil { + return err + } + + return nil +} + +func (j JwtToken) GetUsername() string { + return j.UserName +} + +func (j JwtToken) hasExpired() error { + // Check to see if the token has expired + hasExpired := j.Exp.Compare(time.Now()) + if hasExpired == -1 { + return errors.New(ErrJwtExpired) + } + return nil +} + +func (j JwtToken) hasScope(scope string) error { + // they have the scope to access everything, so let them pass. + if strings.Contains(domain.ScopeAll, scope) { + return nil + } + + for _, s := range j.Scopes { + if strings.Contains(s, scope) { + return nil + } + } + return errors.New(ErrJwtScopeMissing) +} + +func (h *Handler) generateJwt(username, issuer string) (string, error) { + return h.generateJwtWithExp(username, issuer, time.Now().Add(10*time.Minute)) +} + +func (h *Handler) generateJwtWithExp(username, issuer string, expiresAt time.Time) (string, error) { + secret := []byte(h.config.JwtSecret) + + // Anyone who wants to decrypt the key needs to use the same method + token := jwt.New(jwt.SigningMethodHS256) + claims := token.Claims.(jwt.MapClaims) + claims["exp"] = expiresAt + claims["authorized"] = true + claims["username"] = username + claims["iss"] = issuer + + var scopes []string + if username == "admin" { + scopes = append(scopes, domain.ScopeAll) + claims["scopes"] = scopes + } else { + scopes = append(scopes, domain.ScopeRead) + claims["scopes"] = scopes + } + + tokenString, err := token.SignedString(secret) + if err != nil { + return "", err + } + + return tokenString, nil +} diff --git a/internal/services/config.go b/internal/services/config.go index b1acbf6..8a923a9 100644 --- a/internal/services/config.go +++ b/internal/services/config.go @@ -34,6 +34,7 @@ const ( type Configs struct { ServerAddress string + JwtSecret string RedditEnabled bool RedditPullTop bool @@ -64,6 +65,7 @@ func NewConfig() ConfigClient { func GetEnvConfig() Configs { return Configs{ ServerAddress: os.Getenv(ServerAddress), + JwtSecret: os.Getenv("JwtSecret"), RedditEnabled: processBoolConfig(os.Getenv(FEATURE_ENABLE_REDDIT_BACKEND)), RedditPullTop: processBoolConfig(os.Getenv(REDDIT_PULL_TOP)), diff --git a/makefile b/makefile index b2d1ed4..ad19d77 100644 --- a/makefile +++ b/makefile @@ -6,6 +6,7 @@ build: ## builds the application with the current go runtime ~/go/bin/swag f ~/go/bin/swag init -g cmd/server.go go build cmd/server.go + ls -lh server docker-build: ## Generates the docker image docker build -t "newsbot.collector.api" .