Compare commits

...

7 Commits

16 changed files with 225 additions and 97 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,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)

View File

@ -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.")

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.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
)

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.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=

View File

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

View File

@ -3,35 +3,29 @@ 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))
}
resp, err := h.api.Articles.List(userToken.Value, 0)
if err != nil {
return Render(c, http.StatusBadRequest, layout.Error(err))
return RenderError(c, 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{

View File

@ -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()
//}

View File

@ -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)
}
}
}

View File

@ -3,27 +3,22 @@ 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{
@ -32,3 +27,12 @@ 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/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))

View File

@ -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
}
@ -90,3 +114,22 @@ 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,3 +7,7 @@ type ListAllSourcesViewModel struct {
Message string
Items []domain.SourceDto
}
type AddSourcePayloadModel struct {
}

6
internal/models/users.go Normal file
View File

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

View File

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

View File

@ -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 {
<div class="notification is-success">
{ message }
{ vm.Message }
</div>
} else {
<div class="notification is-error">
{ message }
{ vm.Message }
</div>
}
}