diff --git a/.gitignore b/.gitignore index e73c603..036e1b0 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,5 @@ devenv.local.nix # pre-commit .pre-commit-config.yaml + +.env diff --git a/devenv.nix b/devenv.nix index 6ffdd99..9c93fc7 100644 --- a/devenv.nix +++ b/devenv.nix @@ -10,6 +10,13 @@ splitfree-backend = { exec = "go run ./splitfree-backend"; process-compose = { + environment = [ + ''AUTH0_CALLBACK_URL=''${AUTH0_CALLBACK_URL}'' + ''AUTH0_DOMAIN=''${AUTH0_DOMAIN}'' + ''AUTH0_CLIENT_ID=''${AUTH0_CLIENT_ID}'' + ''AUTH0_CLIENT_SECRET=''${AUTH0_CLIENT_SECRET}'' + "SPLITFREE_CANONICAL_URL=http://localhost:3000" + ]; depends_on = { postgres = { condition = "process_healthy"; }; }; diff --git a/go.mod b/go.mod index 2b0f5b3..5a7873b 100644 --- a/go.mod +++ b/go.mod @@ -4,16 +4,20 @@ go 1.21.7 require ( entgo.io/ent v0.13.1 + github.com/coreos/go-oidc/v3 v3.10.0 github.com/danielgtaylor/huma/v2 v2.17.0 + github.com/go-chi/chi/v5 v5.0.12 github.com/google/uuid v1.6.0 github.com/lib/pq v1.10.9 github.com/sirupsen/logrus v1.9.3 + golang.org/x/oauth2 v0.20.0 ) require ( ariga.io/atlas v0.19.1-0.20240203083654-5948b60a8e43 // indirect github.com/agext/levenshtein v1.2.1 // indirect github.com/apparentlymart/go-textseg/v13 v13.0.0 // indirect + github.com/go-jose/go-jose/v4 v4.0.1 // indirect github.com/go-openapi/inflect v0.19.0 // indirect github.com/google/go-cmp v0.6.0 // indirect github.com/hashicorp/hcl/v2 v2.13.0 // indirect @@ -23,6 +27,7 @@ require ( github.com/spf13/cobra v1.8.0 // indirect github.com/spf13/pflag v1.0.5 // indirect github.com/zclconf/go-cty v1.8.0 // indirect + golang.org/x/crypto v0.21.0 // indirect golang.org/x/mod v0.15.0 // indirect golang.org/x/sys v0.18.0 // indirect golang.org/x/text v0.14.0 // indirect diff --git a/go.sum b/go.sum index bd4e1bf..f54d06f 100644 --- a/go.sum +++ b/go.sum @@ -8,6 +8,8 @@ github.com/agext/levenshtein v1.2.1 h1:QmvMAjj2aEICytGiWzmxoE0x2KZvE0fvmqMOfy2tj github.com/agext/levenshtein v1.2.1/go.mod h1:JEDfjyjHDjOF/1e4FlBE/PkbqA9OfWu2ki2W0IB5558= github.com/apparentlymart/go-textseg/v13 v13.0.0 h1:Y+KvPE1NYz0xl601PVImeQfFyEy6iT90AvPUL1NNfNw= github.com/apparentlymart/go-textseg/v13 v13.0.0/go.mod h1:ZK2fH7c4NqDTLtiYLvIkEghdlcqw7yxLeM89kiTRPUo= +github.com/coreos/go-oidc/v3 v3.10.0 h1:tDnXHnLyiTVyT/2zLDGj09pFPkhND8Gl8lnTRhoEaJU= +github.com/coreos/go-oidc/v3 v3.10.0/go.mod h1:5j11xcw0D3+SGxn6Z/WFADsgcWVMyNAlSQupk0KK3ac= github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/danielgtaylor/huma/v2 v2.17.0 h1:alxef5oO5tcDNmbIf+amjVsxwWYE1HkoNxKH2xGH8ZY= github.com/danielgtaylor/huma/v2 v2.17.0/go.mod h1:fFOnahr3rZdFha4rqDq7rjb8q3CPuZvCjoP37qg8fTI= @@ -16,6 +18,8 @@ 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/go-chi/chi/v5 v5.0.12 h1:9euLV5sTrTNTRUU9POmDUvfxyj6LAABLUcEWO+JJb4s= github.com/go-chi/chi/v5 v5.0.12/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= +github.com/go-jose/go-jose/v4 v4.0.1 h1:QVEPDE3OluqXBQZDcnNvQrInro2h0e4eqNbnZSWqS6U= +github.com/go-jose/go-jose/v4 v4.0.1/go.mod h1:WVf9LFMHh/QVrmqrOfqun0C45tMe3RoiKJMPvgWwLfY= github.com/go-openapi/inflect v0.19.0 h1:9jCH9scKIbHeV9m12SmPilScz6krDxKRasNNSNPXu/4= github.com/go-openapi/inflect v0.19.0/go.mod h1:lHpZVlpIQqLyKwJ4N+YSc9hchQy/i12fJykb83CRBH4= github.com/go-test/deep v1.0.3 h1:ZrJSEWsXzPOxaZnFteGEfooLba+ju3FYIbOrS+rQd68= @@ -68,10 +72,14 @@ github.com/vmihailenco/tagparser v0.1.1/go.mod h1:OeAg3pn3UbLjkWt+rN9oFYB6u/cQgq github.com/zclconf/go-cty v1.8.0 h1:s4AvqaeQzJIu3ndv4gVIhplVD0krU+bgrcLSVUnaWuA= github.com/zclconf/go-cty v1.8.0/go.mod h1:vVKLxnk3puL4qRAv72AO+W99LUD4da90g3uUAzyuvAk= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA= +golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs= golang.org/x/mod v0.15.0 h1:SernR4v+D55NyBH2QiEQrlBAnj1ECL6AGrA5+dPaMY8= golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/oauth2 v0.20.0 h1:4mQdhULixXKP1rwYBW0vAijoXnkTG0BLCDRzfe1idMo= +golang.org/x/oauth2 v0.20.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4= diff --git a/splitfree-backend/authenticator/auth.go b/splitfree-backend/authenticator/auth.go new file mode 100644 index 0000000..4dc54f1 --- /dev/null +++ b/splitfree-backend/authenticator/auth.go @@ -0,0 +1,56 @@ +// platform/authenticator/auth.go + +package authenticator + +import ( + "context" + "errors" + "os" + + "github.com/coreos/go-oidc/v3/oidc" + "golang.org/x/oauth2" +) + +// Authenticator is used to authenticate our users. +type Authenticator struct { + *oidc.Provider + oauth2.Config +} + +// New instantiates the *Authenticator. +func New() (*Authenticator, error) { + provider, err := oidc.NewProvider( + context.Background(), + "https://"+os.Getenv("AUTH0_DOMAIN")+"/", + ) + if err != nil { + return nil, err + } + + conf := oauth2.Config{ + ClientID: os.Getenv("AUTH0_CLIENT_ID"), + ClientSecret: os.Getenv("AUTH0_CLIENT_SECRET"), + RedirectURL: os.Getenv("AUTH0_CALLBACK_URL"), + Endpoint: provider.Endpoint(), + Scopes: []string{oidc.ScopeOpenID, "profile"}, + } + + return &Authenticator{ + Provider: provider, + Config: conf, + }, nil +} + +// VerifyIDToken verifies that an *oauth2.Token is a valid *oidc.IDToken. +func (a *Authenticator) VerifyIDToken(ctx context.Context, token *oauth2.Token) (*oidc.IDToken, error) { + rawIDToken, ok := token.Extra("id_token").(string) + if !ok { + return nil, errors.New("no id_token field in oauth2 token") + } + + oidcConfig := &oidc.Config{ + ClientID: a.ClientID, + } + + return a.Verifier(oidcConfig).Verify(ctx, rawIDToken) +} diff --git a/splitfree-backend/callback/callback.go b/splitfree-backend/callback/callback.go new file mode 100644 index 0000000..db2e541 --- /dev/null +++ b/splitfree-backend/callback/callback.go @@ -0,0 +1,83 @@ +// web/app/callback/callback.go + +package callback + +import ( + "context" + "encoding/base64" + "encoding/json" + "net/http" + + "github.com/albinvass/splitfree/splitfree-backend/authenticator" + "github.com/danielgtaylor/huma/v2" + "github.com/google/uuid" + log "github.com/sirupsen/logrus" +) + +type CallbackInput struct { + Session http.Cookie `cookie:"session"` + State uuid.UUID `query:"state"` + Code string `query:"code"` +} + +type CallbackOutput struct { + Status int + Cookies []http.Cookie `header:"Set-Cookie"` + Url string `header:"Location"` +} + +// Handler for our callback. +func Handler(auth *authenticator.Authenticator) func(context.Context, *CallbackInput) (*CallbackOutput, error) { + return func(ctx context.Context, callbackInput *CallbackInput) (*CallbackOutput, error) { + state, err := uuid.Parse(callbackInput.Session.Value) + if err != nil { + return nil, err + } + if callbackInput.State != state { + return nil, huma.Error400BadRequest("Invalid state parameter.") + } + + // Exchange an authorization code for a token. + token, err := auth.Exchange(ctx, callbackInput.Code) + if err != nil { + return nil, huma.Error401Unauthorized("Failed to exchange an authorization code for a token.") + } + + idToken, err := auth.VerifyIDToken(ctx, token) + if err != nil { + return nil, huma.Error500InternalServerError("Failed to verify ID Token.") + } + + var profile map[string]interface{} + if err := idToken.Claims(&profile); err != nil { + return nil, huma.Error500InternalServerError("", err) + } + + accessTokenCookie := http.Cookie{ + Name: "access_token", + Value: token.AccessToken, + } + + profileJson, err := json.Marshal(profile) + if err != nil { + return nil, huma.Error500InternalServerError("", err) + } + + profileJsonBase64 := base64.StdEncoding.EncodeToString(profileJson) + + log.Infof("storing cookie: %s", profileJsonBase64) + profileCookie := http.Cookie{ + Name: "profile", + Value: profileJsonBase64, + } + + return &CallbackOutput{ + Status: http.StatusTemporaryRedirect, + Cookies: []http.Cookie{ + accessTokenCookie, + profileCookie, + }, + Url: "/user", + }, nil + } +} diff --git a/splitfree-backend/login/login.go b/splitfree-backend/login/login.go new file mode 100644 index 0000000..1a7ef82 --- /dev/null +++ b/splitfree-backend/login/login.go @@ -0,0 +1,39 @@ +package login + +import ( + "context" + "github.com/albinvass/splitfree/splitfree-backend/authenticator" + "github.com/google/uuid" + "golang.org/x/oauth2" + "net/http" + "time" +) + +type LoginOutput struct { + Status int + Url string `header:"Location"` + Cookie http.Cookie `header:"Set-Cookie"` +} + +func Handler(auth *authenticator.Authenticator) func(context.Context, *struct{}) (*LoginOutput, error) { + return func(ctx context.Context, _ *struct{}) (*LoginOutput, error) { + state := uuid.New() + + url := auth.AuthCodeURL(state.String(), oauth2.AccessTypeOnline) + + cookie := http.Cookie{ + Name: "session", + Value: state.String(), + Path: "/", + MaxAge: int(time.Hour.Seconds()), + Secure: false, + HttpOnly: true, + } + + return &LoginOutput{ + Status: http.StatusTemporaryRedirect, + Url: url, + Cookie: cookie, + }, nil + } +} diff --git a/splitfree-backend/logout/logout.go b/splitfree-backend/logout/logout.go new file mode 100644 index 0000000..3cf1092 --- /dev/null +++ b/splitfree-backend/logout/logout.go @@ -0,0 +1,44 @@ +// web/app/logout/logout.go + +package logout + +import ( + "context" + "github.com/danielgtaylor/huma/v2" + "net/http" + "net/url" + "os" +) + +type LogoutInput struct { +} + +type LogoutOutput struct { + Status int + Url string `header:"Location"` +} + +// Handler for our logout. +func Handler(ctx context.Context, loginInput *LogoutInput) (*LogoutOutput, error) { + logoutUrl, err := url.Parse("https://" + os.Getenv("AUTH0_DOMAIN") + "/v2/logout") + if err != nil { + return nil, huma.Error500InternalServerError("", err) + } + + canonicalUrl := os.Getenv("SPLITFREE_CANONICAL_URL") + + returnTo, err := url.Parse(canonicalUrl) + if err != nil { + return nil, huma.Error500InternalServerError("", err) + } + + parameters := url.Values{} + parameters.Add("returnTo", returnTo.String()) + parameters.Add("client_id", os.Getenv("AUTH0_CLIENT_ID")) + logoutUrl.RawQuery = parameters.Encode() + + return &LogoutOutput{ + Status: http.StatusTemporaryRedirect, + Url: logoutUrl.String(), + }, nil +} diff --git a/splitfree-backend/splitfree.go b/splitfree-backend/splitfree.go index d6e96a3..8de9b92 100644 --- a/splitfree-backend/splitfree.go +++ b/splitfree-backend/splitfree.go @@ -5,11 +5,19 @@ import ( "fmt" "net/http" + "github.com/albinvass/splitfree/splitfree-backend/authenticator" + "github.com/albinvass/splitfree/splitfree-backend/callback" "github.com/albinvass/splitfree/splitfree-backend/ent" + "github.com/albinvass/splitfree/splitfree-backend/login" + "github.com/albinvass/splitfree/splitfree-backend/logout" + "github.com/albinvass/splitfree/splitfree-backend/user" - "github.com/albinvass/splitfree/splitfree-backend/ent/user" + schemaUser "github.com/albinvass/splitfree/splitfree-backend/ent/user" "github.com/danielgtaylor/huma/v2" - "github.com/danielgtaylor/huma/v2/adapters/humago" + "github.com/danielgtaylor/huma/v2/adapters/humachi" + + "github.com/go-chi/chi/v5" + "github.com/danielgtaylor/huma/v2/humacli" log "github.com/sirupsen/logrus" ) @@ -40,7 +48,7 @@ func (s *SplitfreeBackend) Close() error { } func (s *SplitfreeBackend) ensureUser(ctx context.Context, name string, email string) error { - user, err := s.dbClient.User.Query().Where(user.Name(name)).All(ctx) + user, err := s.dbClient.User.Query().Where(schemaUser.Name(name)).All(ctx) if err != nil { return err } @@ -75,18 +83,29 @@ func (s *SplitfreeBackend) Run() error { } if err := s.InitDB(ctx); err != nil { - panic(err) + panic(fmt.Errorf("failed to initialize database: %v", err)) } log.Info("successfully created schema") - r := http.NewServeMux() - api := humago.New(r, huma.DefaultConfig("My API", "1.0.0")) + r := chi.NewMux() + config := huma.DefaultConfig("My API", "1.0.0") + api := humachi.New(r, config) + + auth, err := authenticator.New() + if err != nil { + panic(err) + } + + huma.Get(api, "/login", login.Handler(auth)) + huma.Get(api, "/callback", callback.Handler(auth)) + huma.Get(api, "/user", user.Handler) + huma.Get(api, "/logout", logout.Handler) + huma.Put(api, "/api/expense", s.CreateExpense) huma.Get(api, "/api/expenses", s.GetExpenses) huma.Get(api, "/api/users", s.GetUsers) - hooks.OnStart(func() { log.Infof("listening on: %s", s.listenAddress) http.ListenAndServe(fmt.Sprintf(":%d", options.Port), r) diff --git a/splitfree-backend/user/user.go b/splitfree-backend/user/user.go new file mode 100644 index 0000000..348e2dd --- /dev/null +++ b/splitfree-backend/user/user.go @@ -0,0 +1,51 @@ +// web/app/user/user.go + +package user + +import ( + "context" + "encoding/base64" + "encoding/json" + "net/http" + + "github.com/danielgtaylor/huma/v2" + log "github.com/sirupsen/logrus" +) + +type Profile struct { + Nickname string `json:"nickname"` + Picture string `json:"picture"` +} + +type UserInput struct { + Profile http.Cookie `cookie:"profile"` +} + +type UserOutput struct { + Body struct { + Profile Profile + } +} + +// Handler for our logged-in user page. +func Handler(ctx context.Context, userInput *UserInput) (*UserOutput, error) { + + profileJson, err := base64.StdEncoding.DecodeString(userInput.Profile.Value) + if err != nil { + log.Errorf("failed to decode profile: %v, <%s>", err, userInput.Profile.Value) + return nil, huma.Error400BadRequest("", err) + } + log.Infof("unmarshalling: '%s'", profileJson) + profile := Profile{} + err = json.Unmarshal(profileJson, &profile) + if err != nil { + + log.Errorf("failed to unmarshal profile: %v, '%s'", err, profileJson) + return nil, huma.Error400BadRequest("", err) + } + + response := UserOutput{} + response.Body.Profile = profile + + return &response, nil +}