diff --git a/.gitignore b/.gitignore index facf30f..a63727d 100644 --- a/.gitignore +++ b/.gitignore @@ -10,7 +10,7 @@ *.dll *.so *.dylib - +tmp/ # Test binary, built with `go test -c` *.test diff --git a/apiclient/users.go b/apiclient/users.go index 37ec219..de6b343 100644 --- a/apiclient/users.go +++ b/apiclient/users.go @@ -6,6 +6,7 @@ import ( "net/url" "git.jamestombleson.com/jtom38/newsbot-api/domain" + "github.com/labstack/echo/v4" ) const ( @@ -16,6 +17,7 @@ type Users interface { Login(username, password string) (domain.LoginResponse, error) SignUp(username, password string) (domain.BaseResponse, error) RefreshJwtToken(username, refreshToken string) (domain.LoginResponse, error) + RefreshJwtTokenFromContext(ctx echo.Context) (domain.LoginResponse, error) RefreshSessionToken(jwtToken string) (domain.BaseResponse, error) } @@ -81,6 +83,23 @@ func (a userClient) RefreshJwtToken(username, refreshToken string) (domain.Login return bind, nil } + +func (a userClient) RefreshJwtTokenFromContext(ctx echo.Context) (domain.LoginResponse, error) { + resp := domain.LoginResponse{} + + username, err := ctx.Cookie("newsbot.user") + if err != nil { + return resp, err + } + + refreshToken, err := ctx.Cookie("newsbot.refreshToken") + if err != nil { + return resp, err + } + + return a.RefreshJwtToken(username.Value, refreshToken.Value) +} + func (a userClient) RefreshSessionToken(jwtToken string) (domain.BaseResponse, error) { endpoint := fmt.Sprintf("%s/%s/refresh/sessionToken", a.serverAddress, UserBaseRoute) diff --git a/cmd/server.go b/cmd/server.go index 3675fc2..c365c38 100644 --- a/cmd/server.go +++ b/cmd/server.go @@ -13,7 +13,7 @@ func main() { ctx := context.Background() cfg := config.New() - apiClient := apiclient.New(cfg.ServerAddress) + apiClient := apiclient.New(cfg.ApiServerAddress) server := handlers.NewServer(ctx, cfg, apiClient) fmt.Println("The server is online and waiting for requests.") diff --git a/go.mod b/go.mod index e595ac3..d4c7720 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,7 @@ go 1.22.1 require ( git.jamestombleson.com/jtom38/newsbot-api v0.0.0-20240603002809-9237369e5a76 - github.com/a-h/templ v0.2.680 + github.com/a-h/templ v0.2.747 github.com/golang-jwt/jwt/v5 v5.2.1 github.com/joho/godotenv v1.5.1 github.com/labstack/echo/v4 v4.12.0 @@ -19,7 +19,7 @@ require ( github.com/valyala/fasttemplate v1.2.2 // indirect golang.org/x/crypto v0.22.0 // indirect golang.org/x/net v0.24.0 // indirect - golang.org/x/sys v0.19.0 // indirect + golang.org/x/sys v0.21.0 // indirect golang.org/x/text v0.14.0 // indirect golang.org/x/time v0.5.0 // indirect ) diff --git a/go.sum b/go.sum index 94ff39f..a7a9974 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,7 @@ git.jamestombleson.com/jtom38/newsbot-api v0.0.0-20240603002809-9237369e5a76 h1:B9t5fcfVerMjqnXXPUmYwdmUk76EoEL8x9IRehqg2c4= git.jamestombleson.com/jtom38/newsbot-api v0.0.0-20240603002809-9237369e5a76/go.mod h1:A3UdJyQ/IEy3utEwJiC4nbi0ohfgrUNRLTei2iZhLLA= -github.com/a-h/templ v0.2.680 h1:TflYFucxp5rmOxAXB9Xy3+QHTk8s8xG9+nCT/cLzjeE= -github.com/a-h/templ v0.2.680/go.mod h1:NQGQOycaPKBxRB14DmAaeIpcGC1AOBPJEMO4ozS7m90= +github.com/a-h/templ v0.2.747 h1:D0dQ2lxC3W7Dxl6fxQ/1zZHBQslSkTSvl5FxP/CfdKg= +github.com/a-h/templ v0.2.747/go.mod h1:69ObQIbrcuwPCU32ohNaWce3Cb7qM5GMiqN1K+2yop4= 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/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY= @@ -35,8 +35,8 @@ golang.org/x/net v0.24.0 h1:1PcaxkF854Fu3+lvBIx5SYn9wRlBzzcnHZSiaFFAb0w= golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o= -golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws= +golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= diff --git a/internal/config/config.go b/internal/config/config.go index 364bb2c..5967c87 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -8,8 +8,9 @@ import ( ) type Configs struct { - ServerAddress string - JwtSecret string + ServerAddress string + JwtSecret string + ApiServerAddress string } func New() Configs { @@ -20,8 +21,9 @@ func New() Configs { func getEnvConfig() Configs { return Configs{ - ServerAddress: os.Getenv("ServerAddress"), - JwtSecret: os.Getenv("JwtSecret"), + ServerAddress: os.Getenv("ServerAddress"), + JwtSecret: os.Getenv("JwtSecret"), + ApiServerAddress: os.Getenv("ApiServerAddress"), } } diff --git a/internal/handlers/articles.go b/internal/handlers/articles.go index 91d7479..bf602ed 100644 --- a/internal/handlers/articles.go +++ b/internal/handlers/articles.go @@ -3,43 +3,37 @@ package handlers import ( "net/http" - "git.jamestombleson.com/jtom38/newsbot-portal/internal/domain" + apidomain "git.jamestombleson.com/jtom38/newsbot-api/domain" "git.jamestombleson.com/jtom38/newsbot-portal/internal/models" "git.jamestombleson.com/jtom38/newsbot-portal/internal/views/articles" - "git.jamestombleson.com/jtom38/newsbot-portal/internal/views/layout" "github.com/labstack/echo/v4" ) func (h *Handler) ArticlesList(c echo.Context) error { - _, err := ValidateJwt(c, h.config.JwtSecret, h.config.ServerAddress) + err := HasValidScope(c, apidomain.ScopeArticleRead) if err != nil { - return Render(c, http.StatusOK, layout.Error(err)) + return RenderError(c, err) } - userToken, err := c.Cookie(domain.CookieToken) + resp, err := h.api.Articles.List(GetJwtToken(c), 0) if err != nil { - return Render(c, http.StatusBadRequest, layout.Error(err)) + return RenderError(c, err) } - resp, err := h.api.Articles.List(userToken.Value, 0) - if err != nil { - return Render(c, http.StatusBadRequest, layout.Error(err)) - } - vm := models.ListArticlesViewModel{} for _, article := range resp.Payload { - source, err := h.api.Sources.GetById(userToken.Value, article.SourceID) + source, err := h.api.Sources.GetById(GetJwtToken(c), article.SourceID) if err != nil { - return Render(c, http.StatusBadRequest, layout.Error(err)) + return RenderError(c, err) } - item := models.ListArticleSourceModel { + item := models.ListArticleSourceModel{ Article: article, - Source: source.Payload[0], + Source: source.Payload[0], } vm.Items = append(vm.Items, item) - + } return Render(c, http.StatusOK, articles.List(vm)) diff --git a/internal/handlers/handler.go b/internal/handlers/handler.go index 9ef3e35..8015ba8 100644 --- a/internal/handlers/handler.go +++ b/internal/handlers/handler.go @@ -49,6 +49,7 @@ func NewServer(ctx context.Context, configs config.Configs, apiClient apiclient. router.Pre(middleware.Logger()) router.Pre(middleware.Recover()) router.Use(middleware.Static("/internal/static")) + //router.Use(RefreshJwtMiddleware(apiClient)) router.GET("/", s.HomeIndex) router.GET("/about", s.HomeAbout) @@ -57,57 +58,23 @@ func NewServer(ctx context.Context, configs config.Configs, apiClient apiclient. debug.GET("/cookies", s.DebugCookies) articles := router.Group("/articles") + //articles.Use(ValidateJwtMiddleware(configs.JwtSecret)) articles.GET("", s.ArticlesList) sources := router.Group("/sources") + //sources.Use(ValidateJwtMiddleware(configs.JwtSecret)) sources.GET("", s.ListAllSources) + sources.GET("/add", s.AddSource) users := router.Group("/users") users.GET("/login", s.UserLogin) users.POST("/login", s.UserAfterLogin) users.GET("/signup", s.UserSignUp) users.POST("/signup", s.UserAfterSignUp) + users.Use(ValidateJwtMiddleware(configs.JwtSecret)) users.GET("/logout", s.UsersLogout) users.GET("/profile", s.UserProfile) s.Router = router return s } - -// If the token is not valid then an json error will be returned. -// If the token has the wrong scope, a json error will be returned. -// If the token passes all the checks, it is valid and is returned back to the caller. -//func (s *Handler) ValidateJwtToken(c echo.Context, requiredScope string) (JwtToken, error) { -// token, err := s.getJwtTokenFromContext(c) -// if err != nil { -// s.WriteMessage(c, ErrJwtMissing, http.StatusUnauthorized) -// } -// -// err = token.hasExpired() -// if err != nil { -// return JwtToken{}, errors.New(ErrJwtExpired) -// //s.WriteMessage(c, ErrJwtExpired, http.StatusUnauthorized) -// } -// -// err = token.hasScope(requiredScope) -// if err != nil { -// return JwtToken{}, errors.New(ErrJwtScopeMissing) -// //s.WriteMessage(c, ErrJwtScopeMissing, http.StatusUnauthorized) -// } -// -// if token.Iss != s.config.ServerAddress { -// return JwtToken{}, errors.New(ErrJwtInvalidIssuer) -// //s.WriteMessage(c, ErrJwtInvalidIssuer, http.StatusUnauthorized) -// } -// -// return token, nil -//} - -//func (s *Handler) GetUserIdFromJwtToken(c echo.Context) int64 { -// token, err := s.getJwtTokenFromContext(c) -// if err != nil { -// s.WriteMessage(c, ErrJwtMissing, http.StatusUnauthorized) -// } -// -// return token.GetUserId() -//} diff --git a/internal/handlers/middleware.go b/internal/handlers/middleware.go new file mode 100644 index 0000000..942974e --- /dev/null +++ b/internal/handlers/middleware.go @@ -0,0 +1,64 @@ +package handlers + +import ( + "time" + + "git.jamestombleson.com/jtom38/newsbot-portal/apiclient" + "git.jamestombleson.com/jtom38/newsbot-portal/internal/domain" + "github.com/golang-jwt/jwt/v5" + "github.com/labstack/echo/v4" +) + +func ValidateJwtMiddleware(jwtSecret string) echo.MiddlewareFunc { + return func(next echo.HandlerFunc) echo.HandlerFunc { + return func(c echo.Context) error { + cookie, err := c.Cookie(domain.CookieToken) + if err != nil { + return err + } + + if cookie.Value == "" { + return echo.NewHTTPError(401, "Authorization token is missing.") + } + + token, err := jwt.ParseWithClaims(cookie.Value, &jwtToken{}, func(token *jwt.Token) (interface{}, error) { + return []byte(jwtSecret), nil + }) + if err != nil { + return err + } + + if !token.Valid { + return echo.NewHTTPError(401, "Invalid authorization token.") + //return errors.New("invalid jwt token") + } + + claims := token.Claims.(*jwtToken) + if !claims.Exp.After(time.Now()) { + return echo.NewHTTPError(401, "Your Authorization token has expired.") + //return errors.New("the jwt token has expired") + } + //if claims.Iss != issuer { + // return jwtToken{}, errors.New("the issuer was invalid") + //} + + return next(c) + } + } +} + +func RefreshJwtMiddleware(api apiclient.ApiClient) echo.MiddlewareFunc { + return func(next echo.HandlerFunc) echo.HandlerFunc { + return func(c echo.Context) error { + resp, err := api.Users.RefreshJwtTokenFromContext(c) + if err != nil { + return next(c) + } + + SetCookie(c, domain.CookieToken, resp.Token, "/") + SetCookie(c, domain.CookieRefreshToken, resp.RefreshToken, "/") + + return next(c) + } + } +} \ No newline at end of file diff --git a/internal/handlers/sources.go b/internal/handlers/sources.go index f912e61..5a23c69 100644 --- a/internal/handlers/sources.go +++ b/internal/handlers/sources.go @@ -3,32 +3,36 @@ package handlers import ( "net/http" - "git.jamestombleson.com/jtom38/newsbot-portal/internal/domain" "git.jamestombleson.com/jtom38/newsbot-portal/internal/models" - "git.jamestombleson.com/jtom38/newsbot-portal/internal/views/layout" "git.jamestombleson.com/jtom38/newsbot-portal/internal/views/sources" + + apidomain "git.jamestombleson.com/jtom38/newsbot-api/domain" "github.com/labstack/echo/v4" ) func (h *Handler) ListAllSources(c echo.Context) error { - _, err := ValidateJwt(c, h.config.JwtSecret, h.config.ServerAddress) + err := HasValidScope(c, apidomain.ScopeSourceRead) if err != nil { - return Render(c, http.StatusOK, layout.Error(err)) + return RenderError(c, err) } - userToken, err := c.Cookie(domain.CookieToken) + resp, err := h.api.Sources.ListAll(GetJwtToken(c), 0) if err != nil { - return Render(c, http.StatusBadRequest, layout.Error(err)) - } - - resp, err := h.api.Sources.ListAll(userToken.Value, 0) - if err != nil { - return Render(c, http.StatusOK, layout.Error(err)) + return RenderError(c, err) } return Render(c, http.StatusOK, sources.ListAll(models.ListAllSourcesViewModel{ - Items: resp.Payload, + Items: resp.Payload, IsError: resp.IsError, Message: resp.Message, })) } + +func (h *Handler) AddSource(c echo.Context) error { + err := HasValidScope(c, apidomain.ScopeSourceCreate) + if err != nil { + return RenderError(c, err) + } + + return Render(c, http.StatusOK, sources.Add(models.AddSourcePayloadModel{})) +} diff --git a/internal/handlers/users.go b/internal/handlers/users.go index b76311c..44bb90a 100644 --- a/internal/handlers/users.go +++ b/internal/handlers/users.go @@ -5,7 +5,7 @@ import ( "net/http" "git.jamestombleson.com/jtom38/newsbot-portal/internal/domain" - "git.jamestombleson.com/jtom38/newsbot-portal/internal/views/layout" + "git.jamestombleson.com/jtom38/newsbot-portal/internal/models" "git.jamestombleson.com/jtom38/newsbot-portal/internal/views/users" "github.com/labstack/echo/v4" ) @@ -20,7 +20,10 @@ func (h *Handler) UserAfterLogin(c echo.Context) error { resp, err := h.api.Users.Login(user, password) if err != nil { - return Render(c, http.StatusBadRequest, users.AfterLogin(err.Error(), false)) + return Render(c, http.StatusBadRequest, users.AfterLogin(models.AfterLoginViewModel{ + Success: false, + Message: err.Error(), + })) } if user == "" { @@ -31,7 +34,11 @@ func (h *Handler) UserAfterLogin(c echo.Context) error { SetCookie(c, domain.CookieRefreshToken, resp.RefreshToken, "/") SetCookie(c, domain.CookieUser, user, "/") - return Render(c, http.StatusOK, users.AfterLogin("Login Successful!", true)) + vm := models.AfterLoginViewModel{ + Success: true, + Message: "Login Successful!", + } + return Render(c, http.StatusOK, users.AfterLogin(vm)) } func (h *Handler) UserSignUp(c echo.Context) error { @@ -44,11 +51,17 @@ func (h *Handler) UserAfterSignUp(c echo.Context) error { resp, err := h.api.Users.SignUp(user, password) if err != nil { - return Render(c, http.StatusBadRequest, users.AfterLogin(err.Error(), false)) + return Render(c, http.StatusBadRequest, users.AfterLogin(models.AfterLoginViewModel{ + Success: false, + Message: err.Error(), + })) } if resp.Message != "OK" { msg := fmt.Sprintf("Failed to create account. Message: %s", resp.Message) - return Render(c, http.StatusBadRequest, users.AfterLogin(msg, false)) + return Render(c, http.StatusBadRequest, users.AfterLogin(models.AfterLoginViewModel{ + Message: msg, + Success: false, + })) } return Render(c, http.StatusOK, users.AfterSignUp("Registration Successful!", true)) } @@ -58,9 +71,9 @@ func (h *Handler) UserProfile(c echo.Context) error { } func (h *Handler) ForceLogout(c echo.Context) error { - _, err := ValidateJwt(c, h.config.JwtSecret, h.config.ServerAddress) + err := IsLoggedIn(c) if err != nil { - return Render(c, http.StatusOK, layout.Error(err)) + return RenderError(c, err) } h.api.Users.RefreshSessionToken(GetJwtToken(c)) diff --git a/internal/handlers/util.go b/internal/handlers/util.go index 0eaea40..3309c6e 100644 --- a/internal/handlers/util.go +++ b/internal/handlers/util.go @@ -4,9 +4,12 @@ import ( "context" "errors" "net/http" + "strings" "time" + "git.jamestombleson.com/jtom38/newsbot-portal/internal/config" "git.jamestombleson.com/jtom38/newsbot-portal/internal/domain" + "git.jamestombleson.com/jtom38/newsbot-portal/internal/views/layout" "github.com/a-h/templ" "github.com/golang-jwt/jwt/v5" "github.com/labstack/echo/v4" @@ -33,7 +36,31 @@ type jwtToken struct { jwt.RegisteredClaims } -func ValidateJwt(ctx echo.Context, sharedSecret, issuer string) (jwtToken, error) { +func IsLoggedIn(ctx echo.Context) error { + _, err := GetUserInfo(ctx) + if err != nil { + return err + } + + return nil +} + +func HasValidScope(ctx echo.Context, scope string) error { + token, err := GetUserInfo(ctx) + if err != nil { + return err + } + + userScopes := strings.Join(token.Scopes, ",") + if strings.Contains(userScopes, scope) { + return nil + } + + return errors.New("required permission is missing") +} + +func GetUserInfo(ctx echo.Context) (jwtToken, error) { + cfg := config.New() cookie, err := ctx.Cookie(domain.CookieToken) if err != nil { return jwtToken{}, err @@ -44,7 +71,7 @@ func ValidateJwt(ctx echo.Context, sharedSecret, issuer string) (jwtToken, error } token, err := jwt.ParseWithClaims(cookie.Value, &jwtToken{}, func(token *jwt.Token) (interface{}, error) { - return []byte(sharedSecret), nil + return []byte(cfg.JwtSecret), nil }) if err != nil { return jwtToken{}, err @@ -58,9 +85,6 @@ func ValidateJwt(ctx echo.Context, sharedSecret, issuer string) (jwtToken, error if !claims.Exp.After(time.Now()) { return jwtToken{}, errors.New("the jwt token has expired") } - //if claims.Iss != issuer { - // return jwtToken{}, errors.New("the issuer was invalid") - //} return *claims, nil } @@ -79,7 +103,26 @@ func Render(ctx echo.Context, statusCode int, t templ.Component) error { // take the request context and make it a var request := ctx.Request().Context() - + + //Check to see if we the echo context has the cookie we are looking for, if so, create a new context based on what we had and add the value + username, err := ctx.Cookie(domain.CookieUser) + if err == nil { + request = context.WithValue(request, domain.UserNameContext, username.Value) + } else { + request = context.WithValue(request, domain.UserNameContext, "") + } + + return t.Render(request, ctx.Response().Writer) +} + +func RenderError(ctx echo.Context, err error) error { + var t templ.Component = layout.Error(err) + ctx.Response().Writer.WriteHeader(200) + ctx.Response().Header().Set(echo.HeaderContentType, echo.MIMETextHTML) + + // take the request context and make it a var + request := ctx.Request().Context() + //Check to see if we the echo context has the cookie we are looking for, if so, create a new context based on what we had and add the value username, err := ctx.Cookie(domain.CookieUser) if err == nil { diff --git a/internal/models/sources.go b/internal/models/sources.go index ac44124..aee1894 100644 --- a/internal/models/sources.go +++ b/internal/models/sources.go @@ -7,3 +7,7 @@ type ListAllSourcesViewModel struct { Message string Items []domain.SourceDto } + +type AddSourcePayloadModel struct { + +} \ No newline at end of file diff --git a/internal/models/users.go b/internal/models/users.go new file mode 100644 index 0000000..a06736a --- /dev/null +++ b/internal/models/users.go @@ -0,0 +1,6 @@ +package models + +type AfterLoginViewModel struct { + Message string + Success bool +} diff --git a/internal/views/sources/add.templ b/internal/views/sources/add.templ new file mode 100644 index 0000000..a61a503 --- /dev/null +++ b/internal/views/sources/add.templ @@ -0,0 +1,10 @@ +package sources + +import "git.jamestombleson.com/jtom38/newsbot-portal/internal/views/layout" +import "git.jamestombleson.com/jtom38/newsbot-portal/internal/models" + +templ Add(model models.AddSourcePayloadModel) { + @layout.WithTemplate() { +
+ } +} diff --git a/internal/views/users/afterLogin.templ b/internal/views/users/afterLogin.templ index 8025e9a..213f221 100644 --- a/internal/views/users/afterLogin.templ +++ b/internal/views/users/afterLogin.templ @@ -1,15 +1,17 @@ package users +import "git.jamestombleson.com/jtom38/newsbot-portal/internal/models" + // This is returned after the user logs into the application. // It just returns a partial view because it will overlap with the existing template. -templ AfterLogin(message string, success bool) { - if success { +templ AfterLogin(vm models.AfterLoginViewModel) { + if vm.Success {