Compare commits

..

No commits in common. "b2e51c3f07e2eb4c61f7770183bc952888d8f7a5" and "40af1cf5f53503605909379caa02dba02ddd4c4e" have entirely different histories.

16 changed files with 97 additions and 225 deletions

2
.gitignore vendored
View File

@ -10,7 +10,7 @@
*.dll
*.so
*.dylib
tmp/
# Test binary, built with `go test -c`
*.test

View File

@ -6,7 +6,6 @@ import (
"net/url"
"git.jamestombleson.com/jtom38/newsbot-api/domain"
"github.com/labstack/echo/v4"
)
const (
@ -17,7 +16,6 @@ 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)
}
@ -83,23 +81,6 @@ 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)

View File

@ -13,7 +13,7 @@ func main() {
ctx := context.Background()
cfg := config.New()
apiClient := apiclient.New(cfg.ApiServerAddress)
apiClient := apiclient.New(cfg.ServerAddress)
server := handlers.NewServer(ctx, cfg, apiClient)
fmt.Println("The server is online and waiting for requests.")

4
go.mod
View File

@ -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.747
github.com/a-h/templ v0.2.680
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.21.0 // indirect
golang.org/x/sys v0.19.0 // indirect
golang.org/x/text v0.14.0 // indirect
golang.org/x/time v0.5.0 // indirect
)

8
go.sum
View File

@ -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.747 h1:D0dQ2lxC3W7Dxl6fxQ/1zZHBQslSkTSvl5FxP/CfdKg=
github.com/a-h/templ v0.2.747/go.mod h1:69ObQIbrcuwPCU32ohNaWce3Cb7qM5GMiqN1K+2yop4=
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/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.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws=
golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
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/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=

View File

@ -10,7 +10,6 @@ import (
type Configs struct {
ServerAddress string
JwtSecret string
ApiServerAddress string
}
func New() Configs {
@ -23,7 +22,6 @@ func getEnvConfig() Configs {
return Configs{
ServerAddress: os.Getenv("ServerAddress"),
JwtSecret: os.Getenv("JwtSecret"),
ApiServerAddress: os.Getenv("ApiServerAddress"),
}
}

View File

@ -3,29 +3,35 @@ package handlers
import (
"net/http"
apidomain "git.jamestombleson.com/jtom38/newsbot-api/domain"
"git.jamestombleson.com/jtom38/newsbot-portal/internal/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 := HasValidScope(c, apidomain.ScopeArticleRead)
_, err := ValidateJwt(c, h.config.JwtSecret, h.config.ServerAddress)
if err != nil {
return RenderError(c, err)
return Render(c, http.StatusOK, layout.Error(err))
}
resp, err := h.api.Articles.List(GetJwtToken(c), 0)
userToken, err := c.Cookie(domain.CookieToken)
if err != nil {
return RenderError(c, err)
return Render(c, http.StatusBadRequest, layout.Error(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(GetJwtToken(c), article.SourceID)
source, err := h.api.Sources.GetById(userToken.Value, article.SourceID)
if err != nil {
return RenderError(c, err)
return Render(c, http.StatusBadRequest, layout.Error(err))
}
item := models.ListArticleSourceModel {

View File

@ -49,7 +49,6 @@ 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)
@ -58,23 +57,57 @@ 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()
//}

View File

@ -1,64 +0,0 @@
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)
}
}
}

View File

@ -3,22 +3,27 @@ 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 := HasValidScope(c, apidomain.ScopeSourceRead)
_, err := ValidateJwt(c, h.config.JwtSecret, h.config.ServerAddress)
if err != nil {
return RenderError(c, err)
return Render(c, http.StatusOK, layout.Error(err))
}
resp, err := h.api.Sources.ListAll(GetJwtToken(c), 0)
userToken, err := c.Cookie(domain.CookieToken)
if err != nil {
return RenderError(c, err)
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 Render(c, http.StatusOK, sources.ListAll(models.ListAllSourcesViewModel{
@ -27,12 +32,3 @@ func (h *Handler) ListAllSources(c echo.Context) error {
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{}))
}

View File

@ -5,7 +5,7 @@ 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/users"
"github.com/labstack/echo/v4"
)
@ -20,10 +20,7 @@ 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(models.AfterLoginViewModel{
Success: false,
Message: err.Error(),
}))
return Render(c, http.StatusBadRequest, users.AfterLogin(err.Error(), false))
}
if user == "" {
@ -34,11 +31,7 @@ func (h *Handler) UserAfterLogin(c echo.Context) error {
SetCookie(c, domain.CookieRefreshToken, resp.RefreshToken, "/")
SetCookie(c, domain.CookieUser, user, "/")
vm := models.AfterLoginViewModel{
Success: true,
Message: "Login Successful!",
}
return Render(c, http.StatusOK, users.AfterLogin(vm))
return Render(c, http.StatusOK, users.AfterLogin("Login Successful!", true))
}
func (h *Handler) UserSignUp(c echo.Context) error {
@ -51,17 +44,11 @@ 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(models.AfterLoginViewModel{
Success: false,
Message: err.Error(),
}))
return Render(c, http.StatusBadRequest, users.AfterLogin(err.Error(), false))
}
if resp.Message != "OK" {
msg := fmt.Sprintf("Failed to create account. Message: %s", resp.Message)
return Render(c, http.StatusBadRequest, users.AfterLogin(models.AfterLoginViewModel{
Message: msg,
Success: false,
}))
return Render(c, http.StatusBadRequest, users.AfterLogin(msg, false))
}
return Render(c, http.StatusOK, users.AfterSignUp("Registration Successful!", true))
}
@ -71,9 +58,9 @@ func (h *Handler) UserProfile(c echo.Context) error {
}
func (h *Handler) ForceLogout(c echo.Context) error {
err := IsLoggedIn(c)
_, err := ValidateJwt(c, h.config.JwtSecret, h.config.ServerAddress)
if err != nil {
return RenderError(c, err)
return Render(c, http.StatusOK, layout.Error(err))
}
h.api.Users.RefreshSessionToken(GetJwtToken(c))

View File

@ -4,12 +4,9 @@ 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"
@ -36,31 +33,7 @@ type jwtToken struct {
jwt.RegisteredClaims
}
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()
func ValidateJwt(ctx echo.Context, sharedSecret, issuer string) (jwtToken, error) {
cookie, err := ctx.Cookie(domain.CookieToken)
if err != nil {
return jwtToken{}, err
@ -71,7 +44,7 @@ func GetUserInfo(ctx echo.Context) (jwtToken, error) {
}
token, err := jwt.ParseWithClaims(cookie.Value, &jwtToken{}, func(token *jwt.Token) (interface{}, error) {
return []byte(cfg.JwtSecret), nil
return []byte(sharedSecret), nil
})
if err != nil {
return jwtToken{}, err
@ -85,6 +58,9 @@ func GetUserInfo(ctx echo.Context) (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
}
@ -114,22 +90,3 @@ func Render(ctx echo.Context, statusCode int, t templ.Component) error {
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 {
request = context.WithValue(request, domain.UserNameContext, username.Value)
} else {
request = context.WithValue(request, domain.UserNameContext, "")
}
return t.Render(request, ctx.Response().Writer)
}

View File

@ -7,7 +7,3 @@ type ListAllSourcesViewModel struct {
Message string
Items []domain.SourceDto
}
type AddSourcePayloadModel struct {
}

View File

@ -1,6 +0,0 @@
package models
type AfterLoginViewModel struct {
Message string
Success bool
}

View File

@ -1,10 +0,0 @@
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() {
<form hx-post="/sources/add"></form>
}
}

View File

@ -1,17 +1,15 @@
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(vm models.AfterLoginViewModel) {
if vm.Success {
templ AfterLogin(message string, success bool) {
if success {
<div class="notification is-success">
{ vm.Message }
{ message }
</div>
} else {
<div class="notification is-error">
{ vm.Message }
{ message }
</div>
}
}