diff --git a/.golangci.yml b/.golangci.yml index 851e4ff..452ddb5 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -1,4 +1,4 @@ issues: exclude-rules: - - path: pkg/user/handler_oauth.go + - path: pkg/user/middleware.go text: 'SA1029: should not use built-in type string as key for value; define your own type to avoid collisions' diff --git a/Makefile b/Makefile index fdf2445..2653a9c 100644 --- a/Makefile +++ b/Makefile @@ -16,6 +16,9 @@ install-deps: run: @go run main.go +key-gen: + @go run main.go key-gen + new-migration: @go run main.go migrator create $(migration) go diff --git a/cmd/generate_key.go b/cmd/generate_key.go new file mode 100644 index 0000000..942b650 --- /dev/null +++ b/cmd/generate_key.go @@ -0,0 +1,33 @@ +package cmd + +import ( + "crypto/ed25519" + "encoding/base64" + "fmt" + + "github.com/spf13/cobra" +) + +func init() { + rootCmd.AddCommand(generateJWTKey) +} + +var ( + generateJWTKey = &cobra.Command{ + Use: "key-gen", + Short: "Generate a private and a public ED25519 key", + RunE: func(cmd *cobra.Command, args []string) (err error) { + pub, pk, err := ed25519.GenerateKey(nil) + if err != nil { + return err + } + + pkb64 := base64.StdEncoding.EncodeToString(pk) + pubb64 := base64.StdEncoding.EncodeToString(pub) + + fmt.Printf("PK: %s\n", pkb64) + fmt.Printf("PUB: %s\n", pubb64) + return nil + }, + } +) diff --git a/cmd/root.go b/cmd/root.go index 0e0acf7..43528a8 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -11,6 +11,7 @@ import ( "github.com/marcopollivier/techagenda/lib/database" _ "github.com/marcopollivier/techagenda/lib/logger" "github.com/marcopollivier/techagenda/lib/server" + "github.com/marcopollivier/techagenda/lib/ssr" "github.com/marcopollivier/techagenda/pkg/event" "github.com/marcopollivier/techagenda/pkg/lending" "github.com/marcopollivier/techagenda/pkg/static" @@ -26,6 +27,7 @@ var rootCmd = &cobra.Command{ fx.New( fx.Provide(database.NewDB), fx.Provide(server.NewHTTPServer), + ssr.Module(), static.Module(), user.Module(), event.Module(), diff --git a/go.mod b/go.mod index e4474bc..29492bb 100644 --- a/go.mod +++ b/go.mod @@ -7,11 +7,12 @@ require ( github.com/caarlos0/env/v10 v10.0.0 // indirect github.com/evanw/esbuild v0.19.11 // indirect github.com/fsnotify/fsnotify v1.7.0 // indirect + github.com/golang-jwt/jwt v3.2.2+incompatible // indirect github.com/golang/protobuf v1.5.3 // indirect - github.com/gorilla/context v1.1.1 // indirect + github.com/gorilla/context v1.1.2 // indirect github.com/gorilla/mux v1.6.2 // indirect - github.com/gorilla/securecookie v1.1.1 // indirect - github.com/gorilla/sessions v1.1.1 // indirect + github.com/gorilla/securecookie v1.1.2 // indirect + github.com/gorilla/sessions v1.2.2 // indirect github.com/gorilla/websocket v1.5.1 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect @@ -20,6 +21,7 @@ require ( github.com/jackc/puddle/v2 v2.2.1 // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect + github.com/labstack/echo-contrib v0.15.0 // indirect github.com/labstack/echo/v4 v4.11.4 // indirect github.com/labstack/gommon v0.4.2 // indirect github.com/lib/pq v1.10.9 // indirect @@ -49,6 +51,7 @@ require ( golang.org/x/sync v0.6.0 // indirect golang.org/x/sys v0.16.0 // indirect golang.org/x/text v0.14.0 // indirect + golang.org/x/time v0.5.0 // indirect google.golang.org/appengine v1.6.8 // indirect google.golang.org/protobuf v1.32.0 // indirect gorm.io/driver/postgres v1.5.4 // indirect diff --git a/go.sum b/go.sum index 4ba93a5..37765dd 100644 --- a/go.sum +++ b/go.sum @@ -63,6 +63,8 @@ github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2 github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= github.com/goccy/go-json v0.9.6/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY= +github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= github.com/golang-jwt/jwt/v4 v4.2.0/go.mod h1:/xlHOz8bRuivTWchD4jCa+NbatV+wEUSzwAxVc6locg= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= @@ -118,13 +120,19 @@ github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+ github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= github.com/gorilla/context v1.1.1 h1:AWwleXJkX/nhcU9bZSnZoi3h/qGYqQAGhq6zZe/aQW8= github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg= +github.com/gorilla/context v1.1.2 h1:WRkNAv2uoa03QNIc1A6u4O7DAGMUVoopZhkiXWA2V1o= +github.com/gorilla/context v1.1.2/go.mod h1:KDPwT9i/MeWHiLl90fuTgrt4/wPcv75vFAZLaOOcbxM= github.com/gorilla/mux v1.6.2 h1:Pgr17XVTNXAk3q/r4CpKzC5xBM/qW1uVLV+IhRZpIIk= github.com/gorilla/mux v1.6.2/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= github.com/gorilla/pat v0.0.0-20180118222023-199c85a7f6d1/go.mod h1:YeAe0gNeiNT5hoiZRI4yiOky6jVdNvfO2N6Kav/HmxY= github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ= github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4= +github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA= +github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= github.com/gorilla/sessions v1.1.1 h1:YMDmfaK68mUixINzY/XjscuJ47uXFWSSHzFbBQM0PrE= github.com/gorilla/sessions v1.1.1/go.mod h1:8KCfur6+4Mqcc6S0FEfKuN15Vl5MgXW92AE8ovaJD0w= +github.com/gorilla/sessions v1.2.2 h1:lqzMYz6bOfvn2WriPUjNByzeXIlVzURcPmgMczkmTjY= +github.com/gorilla/sessions v1.2.2/go.mod h1:ePLdVu+jbEgHH+KWw8I1z2wqd0BAdAQh/8LRvBeoNcQ= github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY= github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY= github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= @@ -151,6 +159,8 @@ github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+o github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= 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/labstack/echo-contrib v0.15.0 h1:9K+oRU265y4Mu9zpRDv3X+DGTqUALY6oRHCSZZKCRVU= +github.com/labstack/echo-contrib v0.15.0/go.mod h1:lei+qt5CLB4oa7VHTE0yEfQSEB9XTJI1LUqko9UWvo4= github.com/labstack/echo/v4 v4.11.4 h1:vDZmA+qNeh1pd/cCkEicDMrjtrnMGQ1QFI9gWN1zGq8= github.com/labstack/echo/v4 v4.11.4/go.mod h1:noh7EvLwqDsmh/X/HWKPUl1AjzJrhyptRyEbQJfxen8= github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0= @@ -374,6 +384,8 @@ golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= +golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= diff --git a/lib/config/config.go b/lib/config/config.go index 1b8daec..18c003a 100644 --- a/lib/config/config.go +++ b/lib/config/config.go @@ -8,4 +8,5 @@ type Config struct { LogFormat string `env:"LOG_FORMAT" envDefault:"text"` DB Database `envPrefix:"DATABASE_"` Providers Providers `envPrefix:"PROVIDER_"` + JWT JWT `envPrefix:"JWT_"` } diff --git a/lib/config/jwt.go b/lib/config/jwt.go new file mode 100644 index 0000000..7907d11 --- /dev/null +++ b/lib/config/jwt.go @@ -0,0 +1,21 @@ +package config + +import ( + "encoding/base64" +) + +type JWT struct { + Private Cert `env:"PRIVATE_KEY,required"` + Public Cert `env:"PUBLIC_KEY,required"` +} + +type Cert []byte + +func (c *Cert) UnmarshalText(text []byte) error { + out, err := base64.StdEncoding.DecodeString(string(text)) + if err != nil { + return err + } + *c = Cert(out) + return nil +} diff --git a/lib/server/http.go b/lib/server/http.go index a5474d2..7c59165 100644 --- a/lib/server/http.go +++ b/lib/server/http.go @@ -1,23 +1,51 @@ package server import ( + "bytes" + "compress/gzip" "context" + "errors" "fmt" + "io/ioutil" "log/slog" + "net/http" + "strings" + "sync/atomic" + "time" + "github.com/gorilla/sessions" + "github.com/labstack/echo-contrib/session" "github.com/labstack/echo/v4" + "github.com/markbates/goth/gothic" "go.uber.org/fx" "github.com/marcopollivier/techagenda/lib/config" ) +const ( + SessionName = "_tech_agenda_s" + CurrentState = "sess" +) + +var ( + sessionStorage atomic.Value +) + func NewHTTPServer(lc fx.Lifecycle) *echo.Echo { srv := echo.New() cfg := config.Get() + maxAge := 30 * (24 * time.Hour) + sessionManager := sessions.NewCookieStore([]byte("secret")) + sessionManager.MaxAge(int(maxAge.Milliseconds())) + srv.Use(session.Middleware(sessionManager)) + gothic.Store = sessionManager + sessionStorage.Store(sessionManager) + lc.Append(fx.Hook{ OnStart: func(_ context.Context) error { slog.Info(fmt.Sprintf("Starting HTTP server at %d", cfg.HTTPPort)) go func() { + if err := srv.Start(fmt.Sprintf(":%d", cfg.HTTPPort)); err != nil { slog.Error("Fail to start http server", "error", err) panic(err) @@ -29,3 +57,85 @@ func NewHTTPServer(lc fx.Lifecycle) *echo.Echo { }) return srv } + +func GetSessionStorage() *sessions.CookieStore { + ss, ok := sessionStorage.Load().(*sessions.CookieStore) + if !ok { + return nil + } + return ss +} + +// StoreInSession stores a specified key/value pair in the session. +func StoreInSession(value string, req *http.Request, res http.ResponseWriter) error { + session, _ := GetSessionStorage().New(req, SessionName) + + if err := updateSessionValue(session, CurrentState, value); err != nil { + return err + } + + return session.Save(req, res) +} + +// GetFromSession retrieves a previously-stored value from the session. +// If no value has previously been stored at the specified key, it will return an error. +func GetFromSession(req *http.Request) (string, error) { + session, _ := GetSessionStorage().Get(req, SessionName) + value, err := getSessionValue(session, CurrentState) + if err != nil { + return "", errors.New("could not find a matching session for this request") + } + + return value, nil +} + +// Logout invalidates a user session. +func Logout(res http.ResponseWriter, req *http.Request) error { + session, err := GetSessionStorage().Get(req, SessionName) + if err != nil { + return err + } + session.Options.MaxAge = -1 + session.Values = make(map[interface{}]interface{}) + err = session.Save(req, res) + if err != nil { + return errors.New("Could not delete user session ") + } + return nil +} + +func getSessionValue(session *sessions.Session, key string) (string, error) { + value := session.Values[key] + if value == nil { + return "", fmt.Errorf("could not find a matching session for this request") + } + + rdata := strings.NewReader(value.(string)) + r, err := gzip.NewReader(rdata) + if err != nil { + return "", err + } + s, err := ioutil.ReadAll(r) + if err != nil { + return "", err + } + + return string(s), nil +} + +func updateSessionValue(session *sessions.Session, key, value string) error { + var b bytes.Buffer + gz := gzip.NewWriter(&b) + if _, err := gz.Write([]byte(value)); err != nil { + return err + } + if err := gz.Flush(); err != nil { + return err + } + if err := gz.Close(); err != nil { + return err + } + + session.Values[key] = b.String() + return nil +} diff --git a/lib/session/jwt.go b/lib/session/jwt.go new file mode 100644 index 0000000..2e27957 --- /dev/null +++ b/lib/session/jwt.go @@ -0,0 +1,79 @@ +package session + +import ( + "crypto/ed25519" + "encoding/json" + "errors" + "time" + + "github.com/golang-jwt/jwt" + "github.com/marcopollivier/techagenda/lib/config" + "github.com/markbates/goth" +) + +type UserSession struct { + ID uint `json:"id"` + Provider string `json:"provider"` + Token string `json:"token"` + AuthUser goth.User `json:"auth_user"` +} + +func GenerateJWT(userID uint, auth goth.User) (tokenString string, err error) { + var ( + pk = ed25519.PrivateKey(config.Get().JWT.Private) + token = jwt.New(jwt.SigningMethodEdDSA) + claims = token.Claims.(jwt.MapClaims) + sess UserSession + ) + + sess = UserSession{ + ID: userID, + Provider: auth.Provider, + Token: auth.AccessToken, + AuthUser: auth, + } + claims["exp"] = float64(time.Now().Add(24 * time.Hour).UnixMilli()) + claims["authorized"] = true + claims["session"] = sess + + if tokenString, err = token.SignedString(pk); err != nil { + return "", err + } + + return tokenString, nil +} + +func UnmarshalSession(tokenString string) (sess UserSession, err error) { + var ( + token *jwt.Token + claims jwt.MapClaims + ok bool + bytes []byte + ) + if token, err = jwt.Parse(tokenString, jwtParser); err != nil { + return sess, err + } + if !token.Valid { + return sess, errors.New("invalid token session") + } + if claims, ok = token.Claims.(jwt.MapClaims); !ok { + return sess, errors.New("unable to extract claims") + } + if bytes, err = json.Marshal(claims["session"]); err != nil { + return sess, errors.New("unable to extract session") + } + if err = json.Unmarshal(bytes, &sess); err != nil { + return sess, errors.New("unable to parse session") + } + return sess, nil + +} + +func jwtParser(token *jwt.Token) (any, error) { + key := ed25519.PublicKey(config.Get().JWT.Public) + _, ok := token.Method.(*jwt.SigningMethodEd25519) + if !ok { + return "", errors.New("fail to open session token") + } + return key, nil +} diff --git a/lib/ssr/fx.go b/lib/ssr/fx.go new file mode 100644 index 0000000..7c75601 --- /dev/null +++ b/lib/ssr/fx.go @@ -0,0 +1,9 @@ +package ssr + +import "go.uber.org/fx" + +func Module() fx.Option { + return fx.Module("ssr_engine", + fx.Provide(NewEngine), + ) +} diff --git a/lib/ssr/props.go b/lib/ssr/props.go new file mode 100644 index 0000000..bc79f94 --- /dev/null +++ b/lib/ssr/props.go @@ -0,0 +1,12 @@ +package ssr + +import ( + "github.com/marcopollivier/techagenda/pkg/event" + "github.com/marcopollivier/techagenda/pkg/user" +) + +type Props struct { + User *user.User + Event *event.Event + Events []event.Event +} diff --git a/lib/ssr/ssr.go b/lib/ssr/ssr.go index 322bfc4..ec4c0fa 100644 --- a/lib/ssr/ssr.go +++ b/lib/ssr/ssr.go @@ -1,7 +1,7 @@ package ssr import ( - "fmt" + "log/slog" gossr "github.com/natewong1313/go-react-ssr" @@ -16,18 +16,23 @@ type Engine struct { *gossr.Engine } -func New(generatedTypesName, propsStructsPath string) (*Engine, error) { +func NewEngine() *Engine { cfg := config.Get() engine, err := gossr.New(gossr.Config{ AppEnv: cfg.Environment, AssetRoute: "/assets", FrontendDir: "./ui/src", - GeneratedTypesPath: fmt.Sprintf("./ui/src/props/%s.generated.ts", generatedTypesName), + GeneratedTypesPath: "./ui/src/props/generated.ts", TailwindConfigPath: "./ui/tailwind.config.js", LayoutCSSFilePath: "main.css", - PropsStructsPath: propsStructsPath, + PropsStructsPath: "lib/ssr/props.go", }) - return &Engine{engine}, err + if err != nil { + slog.Error("Fail to start ssr engine", "error", err.Error()) + panic(err) + } + + return &Engine{engine} } diff --git a/pkg/lending/handler.go b/pkg/lending/handler.go index 6a4841c..018a0c9 100644 --- a/pkg/lending/handler.go +++ b/pkg/lending/handler.go @@ -1,7 +1,6 @@ package lending import ( - "log/slog" "net/http" "github.com/labstack/echo/v4" @@ -9,19 +8,18 @@ import ( "github.com/marcopollivier/techagenda/lib/ssr" "github.com/marcopollivier/techagenda/pkg/event" + "github.com/marcopollivier/techagenda/pkg/user" ) -func NewLendingHandler(server *echo.Echo, eventService event.Service) { - engine, err := ssr.New("lending", "pkg/lending/props.go") - if err != nil { - slog.Error("Fail to start SSR engine", "error", err) - panic(err) - } - +func NewLendingHandler(server *echo.Echo, eventService event.Service, engine *ssr.Engine) { server.Static("/assets", "./ui/public/") server.GET("/v2", func(c echo.Context) (err error) { + var userPtr *user.User events, _ := eventService.Get(c.Request().Context(), "", "", []string{}, []event.EventTypeOf{}, false, 0, 50) + if userData, ok := c.Request().Context().Value(user.MiddlewareUserKey).(user.User); ok { + userPtr = &userData + } page := engine.RenderRoute(gossr.RenderConfig{ File: "pages/Lending.tsx", @@ -30,8 +28,9 @@ func NewLendingHandler(server *echo.Echo, eventService event.Service) { "og:title": "Tech Agenda", "description": "A Tech Agenda é um projeto OpenSource que foi criado pensando em ajudar as pessoas a encontrarem eventos de tecnologia perto delas.", }, - Props: &Props{ + Props: &ssr.Props{ Events: events, + User: userPtr, }, }) return c.HTML(http.StatusOK, string(page)) diff --git a/pkg/lending/props.go b/pkg/lending/props.go deleted file mode 100644 index d40ca57..0000000 --- a/pkg/lending/props.go +++ /dev/null @@ -1,7 +0,0 @@ -package lending - -import "github.com/marcopollivier/techagenda/pkg/event" - -type Props struct { - Events []event.Event -} diff --git a/pkg/user/handler_oauth.go b/pkg/user/handler_oauth.go index cc0038e..9d89969 100644 --- a/pkg/user/handler_oauth.go +++ b/pkg/user/handler_oauth.go @@ -1,96 +1,145 @@ package user import ( - "context" "fmt" "log/slog" "net/http" "github.com/labstack/echo/v4" + "github.com/marcopollivier/techagenda/lib/server" + "github.com/marcopollivier/techagenda/lib/session" "github.com/markbates/goth" "github.com/markbates/goth/gothic" + "github.com/samber/lo" ) func (h *UserHandler) AuthLogin(c echo.Context) (err error) { var ( - ctx = c.Request().Context() - providerRaw = c.Param("provider") - authUser goth.User - user User + ctx = c.Request().Context() + res = c.Response() + req = c.Request() + authUser goth.User + userData User + token string ) - if _, err = ParseProvider(providerRaw); err != nil { - slog.ErrorContext(ctx, err.Error()) - return c.JSON(404, nil) + if _, ok := c.Request().Context().Value(MiddlewareUserKey).(User); ok { + res.Header().Set("Location", getReferer(req)) + res.WriteHeader(http.StatusTemporaryRedirect) + return } - ctx = context.WithValue(ctx, "provider", providerRaw) - c.SetRequest(c.Request().WithContext(ctx)) - - if authUser, err = gothic.CompleteUserAuth(c.Response(), c.Request()); err != nil { + if authUser, err = gothic.CompleteUserAuth(res, req); err != nil { slog.ErrorContext(ctx, "Fail to complete user auth", "error", err.Error()) gothic.BeginAuthHandler(c.Response(), c.Request()) return nil } - if user, err = h.service.Auth(ctx, authUser); err != nil { + if userData, err = h.service.Auth(ctx, authUser); err != nil { slog.ErrorContext(ctx, "Fail to get user information from database", "error", err.Error()) return c.JSON(500, map[string]any{ "error": err.Error(), }) } + if token, err = session.GenerateJWT(userData.ID, authUser); err != nil { + slog.ErrorContext(ctx, "Fail to generate JWT session", "error", err.Error()) + fmt.Fprintln(res, err) + return + } + if err = server.StoreInSession(token, req, res); err != nil { + slog.ErrorContext(ctx, "Fail to save session on session manager", "error", err.Error()) + fmt.Fprintln(res, err) + return + } - return c.JSON(200, user) + res.Header().Set("Location", getReferer(req)) + res.WriteHeader(http.StatusTemporaryRedirect) + return } func (h *UserHandler) AuthLogout(c echo.Context) (err error) { var ( - ctx = c.Request().Context() - res = c.Response() - req = c.Request() - providerRaw = c.Param("provider") + res = c.Response() + req = c.Request() ) - if _, err = ParseProvider(providerRaw); err != nil { - slog.ErrorContext(ctx, err.Error()) - return c.JSON(404, nil) - } - ctx = context.WithValue(ctx, "provider", providerRaw) - c.SetRequest(c.Request().WithContext(ctx)) - if err = gothic.Logout(res, req); err != nil { - slog.Error("Fail to execute logout", "error", err.Error()) + slog.Error("Fail to execute oauth logout", "error", err.Error()) + return + } + if err = server.Logout(res, req); err != nil { + slog.Error("Fail to execute session logout", "error", err.Error()) return } - res.Header().Set("Location", "/") + res.Header().Set("Location", getReferer(req)) res.WriteHeader(http.StatusTemporaryRedirect) return } func (h *UserHandler) AuthCallback(c echo.Context) (err error) { var ( - ctx = c.Request().Context() - res = c.Response() - req = c.Request() - providerRaw = c.Param("provider") - authUser goth.User - user User + ctx = c.Request().Context() + res = c.Response() + req = c.Request() + authUser goth.User + userData User + token string + provider string ) - if _, err = ParseProvider(providerRaw); err != nil { - slog.ErrorContext(ctx, err.Error()) - return c.JSON(404, nil) - } - ctx = context.WithValue(ctx, "provider", providerRaw) - c.SetRequest(c.Request().WithContext(ctx)) + if _, ok := c.Request().Context().Value(MiddlewareUserKey).(User); ok { + res.Header().Set("Location", getReferer(req)) + res.WriteHeader(http.StatusTemporaryRedirect) + return + } + if provider, err = gothic.GetProviderName(req); err != nil { + slog.ErrorContext(ctx, "Fail to complete user auth", "error", err.Error()) + fmt.Fprintln(res, err) + return + } + if _, err = ParseProvider(provider); err != nil { + slog.ErrorContext(ctx, fmt.Sprintf("Unexpected provider %s", provider), "error", err.Error()) + fmt.Fprintln(res, err) + return + } if authUser, err = gothic.CompleteUserAuth(res, req); err != nil { slog.ErrorContext(ctx, "Fail to complete user auth", "error", err.Error()) fmt.Fprintln(res, err) return } - if user, err = h.service.Auth(ctx, authUser); err != nil { + if userData, err = h.service.Auth(ctx, authUser); err != nil { slog.ErrorContext(ctx, "Fail to get user information from database", "error", err.Error()) return c.JSON(500, map[string]any{ "error": err.Error(), }) } + if token, err = session.GenerateJWT(userData.ID, authUser); err != nil { + slog.ErrorContext(ctx, "Fail to generate JWT session", "error", err.Error()) + fmt.Fprintln(res, err) + return + } + if err = server.StoreInSession(token, req, res); err != nil { + slog.ErrorContext(ctx, "Fail to save session on session manager", "error", err.Error()) + fmt.Fprintln(res, err) + return + } + res.Header().Set("Location", getReferer(req)) + res.WriteHeader(http.StatusTemporaryRedirect) + return nil +} - return c.JSON(200, user) +func CheckCurrentSession(res http.ResponseWriter, req *http.Request) (us session.UserSession, err error) { + var token string + if token, err = server.GetFromSession(req); err != nil { + return us, err + } + if us, err = session.UnmarshalSession(token); err != nil { + return us, err + } + return +} + +func getReferer(req *http.Request) string { + referer := req.Header.Get("Referer-c") + if lo.IsEmpty(referer) { + referer = "/v2" + } + return referer } diff --git a/pkg/user/middleware.go b/pkg/user/middleware.go new file mode 100644 index 0000000..0ff91a6 --- /dev/null +++ b/pkg/user/middleware.go @@ -0,0 +1,39 @@ +package user + +import ( + "context" + "log/slog" + + "github.com/labstack/echo/v4" + "github.com/marcopollivier/techagenda/lib/session" +) + +type MiddlewareCtxKey string + +const ( + MiddlewareUserKey MiddlewareCtxKey = "user" +) + +func AuthMiddleware(service Service) echo.MiddlewareFunc { + return func(next echo.HandlerFunc) echo.HandlerFunc { + return func(c echo.Context) (err error) { + var ( + ctx = c.Request().Context() + user User + req = c.Request() + res = c.Response() + userSession session.UserSession + ) + if userSession, err = CheckCurrentSession(res, req); err != nil { + return next(c) + } + if user, err = service.Auth(ctx, userSession.AuthUser); err != nil { + slog.ErrorContext(ctx, "Fail to get user information from database", "error", err.Error()) + return next(c) + } + ctx = context.WithValue(ctx, MiddlewareUserKey, user) + c.SetRequest(c.Request().WithContext(ctx)) + return next(c) + } + } +} diff --git a/pkg/user/oauth_providers.go b/pkg/user/oauth_providers.go index 35839ab..23339b6 100644 --- a/pkg/user/oauth_providers.go +++ b/pkg/user/oauth_providers.go @@ -18,7 +18,7 @@ func registerProviders() { github.New( cfg.Providers.Github.Key, cfg.Providers.Github.Secret, - fmt.Sprintf("%s/auth/github/callback", cfg.AppHost), + fmt.Sprintf("%s/auth/callback?provider=github", cfg.AppHost), "user", ), ) diff --git a/pkg/user/router.go b/pkg/user/router.go index e58a68e..15744ac 100644 --- a/pkg/user/router.go +++ b/pkg/user/router.go @@ -4,7 +4,8 @@ import "github.com/labstack/echo/v4" func SetUserHandlerRoutes(server *echo.Echo, handler *UserHandler) { registerProviders() - auth := server.Group("/auth/:provider") + server.Use(AuthMiddleware(handler.service)) + auth := server.Group("/auth") auth.GET("", handler.AuthLogin) auth.GET("/logout", handler.AuthLogout) diff --git a/pkg/user/service.go b/pkg/user/service.go index d3d2184..051acc8 100644 --- a/pkg/user/service.go +++ b/pkg/user/service.go @@ -84,8 +84,8 @@ func (s *UserService) Auth(ctx context.Context, oauthUser goth.User) (user User, go func() { if user.Avatar != oauthUser.AvatarURL { user.Avatar = oauthUser.AvatarURL - if errI := s.db.WithContext(ctx).Where("id = ?", user.ID).Updates(&user).Error; errI != nil { - slog.ErrorContext(ctx, "Unable to update users avatar!", "user", user.ID, "error", err.Error()) + if errI := s.db.Where("id = ?", user.ID).Updates(&user).Error; errI != nil { + slog.ErrorContext(ctx, "Unable to update users avatar!", "user", user.ID, "error", errI.Error()) } } }() diff --git a/ui/src/components/Header.tsx b/ui/src/components/Header.tsx index f628a0c..e5a4c63 100644 --- a/ui/src/components/Header.tsx +++ b/ui/src/components/Header.tsx @@ -1,23 +1,26 @@ -import { Fragment } from 'react'; -import { Disclosure, Menu, Transition } from '@headlessui/react'; -import { Bars3Icon, BellIcon, XMarkIcon } from '@heroicons/react/24/outline'; +import { Disclosure } from '@headlessui/react'; +import { Bars3Icon, XMarkIcon } from '@heroicons/react/24/outline'; import TechAgendaLogo from '../../public/logo.svg'; import { AdjustmentsHorizontalIcon } from '@heroicons/react/20/solid'; +import LoginButton from '../organisms/LoginButton'; +import { User } from "../props/generated"; +import classNames from '../helper/classNames'; const navigation = [ - { name: 'Todos os eventos', href: '#', current: true }, - { name: 'Design', href: '#', current: false }, - { name: 'Product', href: '#', current: false }, - { name: 'DevOps', href: '#', current: false }, - { name: 'Software', href: '#', current: false }, - { name: 'Management', href: '#', current: false }, + { name: 'Todos os eventos', href: '#', slug: "main" }, + { name: 'Design', href: '#', slug: "design" }, + { name: 'Product', href: '#', slug: "product" }, + { name: 'DevOps', href: '#', slug: "devops" }, + { name: 'Software', href: '#', slug: "software" }, + { name: 'Management', href: '#', slug: "management" }, ] -function classNames(...classes: any) { - return classes.filter(Boolean).join(' ') +interface HeaderProps { + user: User + currentPage: string } -export default function Header() { +export default function Header({ user, currentPage }: HeaderProps) { return ( {({ open }) => ( @@ -29,64 +32,7 @@ export default function Header() { Tech Agenda {/* User's area */} -
- {/* Profile dropdown */} - -
- - - Open user menu - - -
- - - - {({ active }) => ( - - Perfil - - )} - - - {({ active }) => ( - - Meus eventos - - )} - - - {({ active }) => ( - - Sair - - )} - - - -
-
+
{/* Main menu options */} @@ -111,10 +57,10 @@ export default function Header() { key={item.name} href={item.href} className={classNames( - item.current ? 'bg-white text-black shadow-md' : 'text-gray-400 hover:bg-gray-200 hover:text-black', + item.slug === currentPage ? 'bg-white text-black shadow-md' : 'text-gray-400 hover:bg-gray-200 hover:text-black', 'rounded-full px-3 py-1 text-sm font-medium' )} - aria-current={item.current ? 'page' : undefined} + aria-current={item.slug === currentPage ? 'page' : undefined} > {item.name} diff --git a/ui/src/helper/classNames.ts b/ui/src/helper/classNames.ts new file mode 100644 index 0000000..cf63dc6 --- /dev/null +++ b/ui/src/helper/classNames.ts @@ -0,0 +1,3 @@ +export default function classNames(...classes: any) { + return classes.filter(Boolean).join(' ') +} diff --git a/ui/src/organisms/LoginButton.tsx b/ui/src/organisms/LoginButton.tsx new file mode 100644 index 0000000..eace512 --- /dev/null +++ b/ui/src/organisms/LoginButton.tsx @@ -0,0 +1,94 @@ +import { Fragment } from 'react'; +import { Menu, Transition } from '@headlessui/react'; +import { User } from "../props/generated"; +import classNames from '../helper/classNames'; + +interface LoginProps { + user: User +} + +export default function LoginButton({ user }: LoginProps) { + return ( +
+ {user !== null ? :
+ ) +} + +function Button() { + + const routeChange = () => { + let path = `/auth?provider=github`; + window.location.replace(path); + } + + return ( + + ) +} + +function ActiveUserMenu({ user }: LoginProps) { + return ( + +
+ + + Open user menu + + +
+ + + + {({ active }) => ( + + Perfil + + )} + + + {({ active }) => ( + + Meus eventos + + )} + + + {({ active }) => ( + + Sair + + )} + + + +
+ ) +} diff --git a/ui/src/pages/Lending.tsx b/ui/src/pages/Lending.tsx index 0258053..f37af16 100644 --- a/ui/src/pages/Lending.tsx +++ b/ui/src/pages/Lending.tsx @@ -5,17 +5,18 @@ import Footer from "../components/Footer"; import MapBanner from "../components/MapBanner"; import AdBanner from "../components/AdBanner"; import EventList from "../components/EventList"; -import { Props } from "../props/lending.generated"; +import { Props } from "../props/generated"; -function Lending({ Events }: Props) { +function Lending({ Events, User }: Props) { - const [events, _] = useState(Events); + const [events, setEvents] = useState(Events); + const [user, setUser] = useState(User); console.log(events) return (
-
+
diff --git a/ui/src/props/lending.generated.ts b/ui/src/props/generated.ts similarity index 97% rename from ui/src/props/lending.generated.ts rename to ui/src/props/generated.ts index 859da6c..d4d907d 100644 --- a/ui/src/props/lending.generated.ts +++ b/ui/src/props/generated.ts @@ -29,17 +29,6 @@ export interface Tags { DeletedAt: DeletedAt; tag: string; } -export interface User { - ID: number; - CreatedAt: Time; - UpdatedAt: Time; - DeletedAt: DeletedAt; - Email: string; - Name: string; - Role: number; - Bio: string; - Avatar: string; -} export interface Attendee { ID: number; CreatedAt: Time; @@ -51,13 +40,6 @@ export interface Attendee { EventID: number; UserID: number; User: User; -} -export interface DeletedAt { - Time: Time; - Valid: boolean; -} -export interface Time { - } export interface Event { ID: number; @@ -78,6 +60,26 @@ export interface Event { cfp: Cfp; user: User; } +export interface DeletedAt { + Time: Time; + Valid: boolean; +} +export interface Time { + +} +export interface User { + ID: number; + CreatedAt: Time; + UpdatedAt: Time; + DeletedAt: DeletedAt; + Email: string; + Name: string; + Role: number; + Bio: string; + Avatar: string; +} export interface Props { + User: User; + Event: Event; Events: Event[]; } \ No newline at end of file