From dc2b5e8fb54e8e44683c981371a0a4104b835d5a Mon Sep 17 00:00:00 2001 From: Florian Hoss Date: Wed, 25 Sep 2024 15:33:28 +0200 Subject: [PATCH] Use gorilla sessions --- .vscode/launch.json | 8 +-- components/user.templ | 12 ++-- go.mod | 3 +- go.sum | 8 ++- handlers/app.handlers.go | 15 ++-- handlers/auth.handlers.go | 146 +++++++++++++++++--------------------- handlers/routes.go | 6 +- internal/env/env.go | 12 ++-- main.go | 23 ++++-- views/home/home.templ | 2 +- 10 files changed, 119 insertions(+), 116 deletions(-) diff --git a/.vscode/launch.json b/.vscode/launch.json index 8db3887..b52be00 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -15,11 +15,9 @@ "PUBLIC_URL": "http://localhost:4000", "WEATHER_KEY": "3722ce75e9330aaefde1cb3eb1b8b030", "APP_VERSION": "v0.0.1-DEV", - "OIDC_CLIENT_ID": "home", - "OIDC_CLIENT_SECRET": "PkfS5S7BkiEeqX3Km7BGxsBrmH6MOzjqcpODTz2akxMCMFHv8TAvIfyWgTlKof85", - "OIDC_REDIRECT_URI": "http://localhost:4000/auth/callback", - "OIDC_ISSUER": "https://sso.unjx.de/auth/v1", - "SESSION_KEY": "49cda749cb5eaa6c38f371c530808ca8", + "AUTH_CLIENT_ID": "home", + "AUTH_CLIENT_SECRET": "PkfS5S7BkiEeqX3Km7BGxsBrmH6MOzjqcpODTz2akxMCMFHv8TAvIfyWgTlKof85", + "AUTH_ISSUER": "https://sso.unjx.de/auth/v1", } } ] diff --git a/components/user.templ b/components/user.templ index 26ef43d..195ba42 100644 --- a/components/user.templ +++ b/components/user.templ @@ -2,10 +2,12 @@ package components import "gitlab.unjx.de/flohoss/godash/services" -templ User(user services.User) { -
-
- -
+templ User(user *services.User) { + } diff --git a/go.mod b/go.mod index 08baa4c..5a40b46 100644 --- a/go.mod +++ b/go.mod @@ -4,10 +4,11 @@ go 1.23 require ( github.com/a-h/templ v0.2.778 - github.com/alexedwards/scs/v2 v2.8.0 github.com/caarlos0/env/v10 v10.0.0 github.com/coreos/go-oidc/v3 v3.11.0 github.com/go-playground/validator/v10 v10.22.1 + github.com/gorilla/securecookie v1.1.2 + github.com/gorilla/sessions v1.4.0 github.com/r3labs/sse/v2 v2.10.0 github.com/shirou/gopsutil/v4 v4.24.8 github.com/thanhpk/randstr v1.0.6 diff --git a/go.sum b/go.sum index 771e896..9f6aac6 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,5 @@ github.com/a-h/templ v0.2.778 h1:VzhOuvWECrwOec4790lcLlZpP4Iptt5Q4K9aFxQmtaM= github.com/a-h/templ v0.2.778/go.mod h1:lq48JXoUvuQrU0VThrK31yFwdRjTCnIE5bcPCM9IP1w= -github.com/alexedwards/scs/v2 v2.8.0 h1:h31yUYoycPuL0zt14c0gd+oqxfRwIj6SOjHdKRZxhEw= -github.com/alexedwards/scs/v2 v2.8.0/go.mod h1:ToaROZxyKukJKT/xLcVQAChi5k6+Pn1Gvmdl7h3RRj8= github.com/caarlos0/env/v10 v10.0.0 h1:yIHUBZGsyqCnpTkbjk8asUlx6RFhhEs+h7TOBdgdzXA= github.com/caarlos0/env/v10 v10.0.0/go.mod h1:ZfulV76NvVPw3tm591U4SwL3Xx9ldzBP9aGxzeN7G18= github.com/coreos/go-oidc/v3 v3.11.0 h1:Ia3MxdwpSw702YW0xgfmP1GVCMA9aEFWu12XUZ3/OtI= @@ -27,6 +25,12 @@ github.com/go-playground/validator/v10 v10.22.1 h1:40JcKH+bBNGFczGuoBYgX4I6m/i27 github.com/go-playground/validator/v10 v10.22.1/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= +github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +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.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ= +github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= diff --git a/handlers/app.handlers.go b/handlers/app.handlers.go index 5ca2c05..6b7844a 100644 --- a/handlers/app.handlers.go +++ b/handlers/app.handlers.go @@ -3,6 +3,7 @@ package handlers import ( "net/http" + "github.com/gorilla/sessions" "gitlab.unjx.de/flohoss/godash/internal/env" "gitlab.unjx.de/flohoss/godash/services" "gitlab.unjx.de/flohoss/godash/views/home" @@ -21,9 +22,10 @@ type WeatherService interface { GetCurrentWeather() *services.OpenWeather } -func NewAppHandler(env *env.Config, s SystemService, w WeatherService, b BookmarkService) *AppHandler { +func NewAppHandler(env *env.Config, store *sessions.CookieStore, s SystemService, w WeatherService, b BookmarkService) *AppHandler { return &AppHandler{ env: env, + store: store, systemService: s, weatherService: w, bookmarkService: b, @@ -32,6 +34,7 @@ func NewAppHandler(env *env.Config, s SystemService, w WeatherService, b Bookmar type AppHandler struct { env *env.Config + store *sessions.CookieStore systemService SystemService weatherService WeatherService bookmarkService BookmarkService @@ -45,12 +48,12 @@ func (bh *AppHandler) appHandler(w http.ResponseWriter, r *http.Request) { titlePage := bh.env.Title - user := services.User{ - Name: w.Header().Get("X-User-Name"), - Email: w.Header().Get("X-User-Email"), + session, _ := bh.store.Get(r, StoreSessionKey) + user := &services.User{ + Name: session.Values[string(NameKey)].(string), + Email: session.Values[string(EmailKey)].(string), + Gravatar: session.Values[string(GravatarKey)].(string), } - gravatar := services.NewGravatarFromEmail(user.Email) - user.Gravatar = gravatar.GetURL() home.HomeIndex(titlePage, bh.env.Version, home.Home(titlePage, user, bookmarks, staticSystem, liveSystem, weather)).Render(r.Context(), w) } diff --git a/handlers/auth.handlers.go b/handlers/auth.handlers.go index 801a394..61656c5 100644 --- a/handlers/auth.handlers.go +++ b/handlers/auth.handlers.go @@ -9,10 +9,9 @@ import ( "log/slog" "net/http" "os" - "time" - "github.com/alexedwards/scs/v2" "github.com/coreos/go-oidc/v3/oidc" + "github.com/gorilla/sessions" "github.com/thanhpk/randstr" "golang.org/x/oauth2" @@ -20,16 +19,15 @@ import ( "gitlab.unjx.de/flohoss/godash/services" ) -func setCallbackCookie(w http.ResponseWriter, r *http.Request, name, value string) { - c := &http.Cookie{ - Name: name, - Value: value, - MaxAge: int(time.Hour.Seconds()), - Secure: r.TLS != nil, - HttpOnly: true, - } - http.SetCookie(w, c) -} +type contextKey string + +const ( + NameKey contextKey = "name" + EmailKey contextKey = "email" + GravatarKey contextKey = "gravatar" + + StoreSessionKey = "godash_session" +) func generateCodeVerifier() (string, error) { verifierLength := 64 @@ -49,38 +47,20 @@ func generateCodeChallenge(verifier string) string { return base64.RawURLEncoding.EncodeToString(sha) } -func (ah *AuthHandler) saveTokenToSession(r *http.Request, oauth2Token *oauth2.Token) { - ah.SessionManager.Put(r.Context(), "access_token", oauth2Token.AccessToken) - ah.SessionManager.Put(r.Context(), "refresh_token", oauth2Token.RefreshToken) - ah.SessionManager.Put(r.Context(), "token_type", oauth2Token.TokenType) - ah.SessionManager.Put(r.Context(), "expiry", oauth2Token.Expiry.Unix()) -} - -func (ah *AuthHandler) loadTokenFromSession(r *http.Request) *oauth2.Token { - ex := ah.SessionManager.GetInt64(r.Context(), "expiry") - return &oauth2.Token{ - AccessToken: ah.SessionManager.GetString(r.Context(), "access_token"), - RefreshToken: ah.SessionManager.GetString(r.Context(), "refresh_token"), - TokenType: ah.SessionManager.GetString(r.Context(), "token_type"), - Expiry: time.Unix(ex, 0), - } -} - -func NewAuthHandler(env *env.Config) *AuthHandler { +func NewAuthHandler(env *env.Config, store *sessions.CookieStore) *AuthHandler { ctx := context.Background() - - oidcProvider, err := oidc.NewProvider(ctx, env.OIDCIssuer) + provider, err := oidc.NewProvider(ctx, env.AuthIssuer) if err != nil { slog.Error("Failed to get oidc provider", "err", err.Error()) os.Exit(1) } - oauth2Config := &oauth2.Config{ - ClientID: env.OIDCClientID, - ClientSecret: env.OIDCClientSecret, - Endpoint: oidcProvider.Endpoint(), - RedirectURL: env.OIDCRedirectURI, - Scopes: env.OIDCScopes, + config := &oauth2.Config{ + ClientID: env.AuthClientID, + ClientSecret: env.AuthClientSecret, + RedirectURL: env.PublicUrl + "/callback", + Scopes: env.AuthScopes, + Endpoint: provider.Endpoint(), } codeVerifier, err := generateCodeVerifier() @@ -90,88 +70,92 @@ func NewAuthHandler(env *env.Config) *AuthHandler { } codeChallenge := generateCodeChallenge(codeVerifier) authCodeOptions := []oauth2.AuthCodeOption{ - oauth2.SetAuthURLParam("redirect_uri", env.OIDCRedirectURI), oauth2.SetAuthURLParam("code_challenge", codeChallenge), oauth2.SetAuthURLParam("code_challenge_method", "S256"), oauth2.SetAuthURLParam("code_verifier", codeVerifier), } - sessionManager := scs.New() - sessionManager.Lifetime = 24 * 31 * time.Hour - return &AuthHandler{ - ctx: ctx, - oidcProvider: oidcProvider, - oauth2Config: oauth2Config, + provider: provider, + config: config, authCodeOptions: authCodeOptions, - SessionManager: sessionManager, + store: store, } } type AuthHandler struct { - ctx context.Context - oidcProvider *oidc.Provider - oauth2Config *oauth2.Config + provider *oidc.Provider + config *oauth2.Config authCodeOptions []oauth2.AuthCodeOption - SessionManager *scs.SessionManager + store *sessions.CookieStore } func (ah *AuthHandler) handleCallback(w http.ResponseWriter, r *http.Request) { - state, err := r.Cookie("state") - if err != nil { + session, _ := ah.store.Get(r, StoreSessionKey) + state, ok := session.Values["state"].(string) + if !ok || state == "" { http.Error(w, "state not found", http.StatusBadRequest) return } - if r.URL.Query().Get("state") != state.Value { + if r.URL.Query().Get("state") != state { http.Error(w, "state did not match", http.StatusBadRequest) return } - oauth2Token, err := ah.oauth2Config.Exchange(ah.ctx, r.URL.Query().Get("code"), ah.authCodeOptions...) + oauth2Token, err := ah.config.Exchange(r.Context(), r.URL.Query().Get("code"), ah.authCodeOptions...) if err != nil { http.Error(w, "failed to exchange token: "+err.Error(), http.StatusInternalServerError) return } - ah.saveTokenToSession(r, oauth2Token) + userInfo, err := ah.provider.UserInfo(r.Context(), oauth2.StaticTokenSource(oauth2Token)) + if err != nil { + http.Error(w, "failed to get userinfo: "+err.Error(), http.StatusInternalServerError) + return + } + + user := &services.User{} + userInfo.Claims(user) + + session.Values[string(NameKey)] = user.Name + session.Values[string(EmailKey)] = user.Email + session.Values[string(GravatarKey)] = services.NewGravatarFromEmail(user.Email).GetURL() + err = session.Save(r, w) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + http.Redirect(w, r, "/", http.StatusFound) } func (ah *AuthHandler) handleLogout(w http.ResponseWriter, r *http.Request) { - ah.SessionManager.Clear(r.Context()) + session, _ := ah.store.Get(r, StoreSessionKey) + session.Values = make(map[interface{}]interface{}) + err := session.Save(r, w) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } http.Redirect(w, r, "/", http.StatusFound) } -func (ah *AuthHandler) handleLogin(w http.ResponseWriter, r *http.Request) { - state := randstr.String(16) - setCallbackCookie(w, r, "state", state) - http.Redirect(w, r, ah.oauth2Config.AuthCodeURL(state, ah.authCodeOptions...), http.StatusFound) -} - func (ah *AuthHandler) AuthMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - exists := ah.SessionManager.Exists(r.Context(), "access_token") - if !exists { - ah.handleLogin(w, r) + session, _ := ah.store.Get(r, StoreSessionKey) + name, ok := session.Values[string(NameKey)].(string) + if !ok || name == "" { + state := randstr.String(16) + session.Values["state"] = state + err := session.Save(r, w) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + http.Redirect(w, r, ah.config.AuthCodeURL(state, ah.authCodeOptions...), http.StatusFound) return } - token := ah.loadTokenFromSession(r) - ah.oauth2Config.Client(ah.ctx, token) - - tokenInfo, err := ah.oidcProvider.Verifier(&oidc.Config{ClientID: ah.oauth2Config.ClientID}).Verify(ah.ctx, token.AccessToken) - if err != nil { - ah.handleLogin(w, r) - return - } - - ah.saveTokenToSession(r, token) - - var userClaims services.User - tokenInfo.Claims(&userClaims) - w.Header().Set("X-User-Name", userClaims.Name) - w.Header().Set("X-User-Email", userClaims.Email) - next.ServeHTTP(w, r) }) } diff --git a/handlers/routes.go b/handlers/routes.go index 1bce187..f392140 100644 --- a/handlers/routes.go +++ b/handlers/routes.go @@ -10,13 +10,13 @@ func SetupRoutes(router *http.ServeMux, sse *sse.Server, appHandler *AppHandler, router.Handle("GET /sse", authHandler.AuthMiddleware(http.HandlerFunc(sse.ServeHTTP))) fsAssets := http.FileServer(http.Dir("assets")) - router.Handle("GET /assets/", authHandler.AuthMiddleware((http.StripPrefix("/assets/", fsAssets)))) + router.Handle("GET /assets/", authHandler.AuthMiddleware(http.StripPrefix("/assets/", fsAssets))) icons := http.FileServer(http.Dir("storage/icons")) router.Handle("GET /icons/", authHandler.AuthMiddleware(http.StripPrefix("/icons/", icons))) - router.HandleFunc("GET /auth/logout", http.HandlerFunc(authHandler.handleLogout)) - router.HandleFunc("GET /auth/callback", authHandler.handleCallback) + router.HandleFunc("GET /logout", authHandler.handleLogout) + router.HandleFunc("GET /callback", authHandler.handleCallback) router.Handle("GET /", authHandler.AuthMiddleware(http.HandlerFunc(appHandler.appHandler))) } diff --git a/internal/env/env.go b/internal/env/env.go index bf3ea5c..251e4c8 100644 --- a/internal/env/env.go +++ b/internal/env/env.go @@ -10,6 +10,7 @@ import ( type Config struct { TimeZone string `env:"TZ" envDefault:"Etc/UTC" validate:"timezone"` Title string `env:"TITLE" envDefault:"goDash"` + PublicUrl string `env:"PUBLIC_URL" validate:"required,url"` Port int `env:"PORT" envDefault:"4000" validate:"min=1024,max=49151"` Version string `env:"APP_VERSION"` LocationLatitude float32 `env:"LOCATION_LATITUDE" envDefault:"48.780331609463815" validate:"latitude"` @@ -18,13 +19,10 @@ type Config struct { WeatherUnits string `env:"WEATHER_UNITS" envDefault:"metric"` WeatherLanguage string `env:"WEATHER_LANG" envDefault:"en" validate:"bcp47_language_tag"` WeatherDigits bool `env:"WEATHER_DIGITS" envDefault:"false"` - OIDCClientID string `env:"OIDC_CLIENT_ID"` - OIDCClientSecret string `env:"OIDC_CLIENT_SECRET"` - OIDCRedirectURI string `env:"OIDC_REDIRECT_URI"` - OIDCScopes []string `env:"OIDC_SCOPES" envSeparator:"," envDefault:"openid,email,profile"` - OIDCIssuer string `env:"OIDC_ISSUER"` - OIDCResponseMode string `env:"OIDC_RESPONSE_MODE"` - SessionKey string `env:"SESSION_KEY,unset"` + AuthClientID string `env:"AUTH_CLIENT_ID"` + AuthClientSecret string `env:"AUTH_CLIENT_SECRET"` + AuthScopes []string `env:"AUTH_SCOPES" envSeparator:"," envDefault:"openid,email,profile"` + AuthIssuer string `env:"AUTH_ISSUER"` } var errParse = errors.New("error parsing environment variables") diff --git a/main.go b/main.go index f4f45c4..d5da5d7 100644 --- a/main.go +++ b/main.go @@ -5,8 +5,11 @@ import ( "fmt" "log/slog" "net/http" + "net/url" "os" + "github.com/gorilla/securecookie" + "github.com/gorilla/sessions" "github.com/r3labs/sse/v2" "gitlab.unjx.de/flohoss/godash/handlers" @@ -29,13 +32,23 @@ func main() { w := services.NewWeatherService(sse, env) b := services.NewBookmarkService() - appHandler := handlers.NewAppHandler(env, s, w, b) - authHandler := handlers.NewAuthHandler(env) + parsedUrl, _ := url.Parse(env.PublicUrl) + store := sessions.NewCookieStore(securecookie.GenerateRandomKey(32)) + store.Options = &sessions.Options{ + Domain: parsedUrl.Hostname(), + MaxAge: 86400 * 30, + Secure: parsedUrl.Scheme == "https", + HttpOnly: true, + Partitioned: true, + SameSite: http.SameSiteLaxMode, + } + + authHandler := handlers.NewAuthHandler(env, store) + appHandler := handlers.NewAppHandler(env, store, s, w, b) handlers.SetupRoutes(router, sse, appHandler, authHandler) - lis := fmt.Sprintf(":%d", env.Port) - slog.Info("server listening, press ctrl+c to stop", "addr", "http://localhost"+lis) - err = http.ListenAndServe(lis, authHandler.SessionManager.LoadAndSave(router)) + slog.Info("server listening, press ctrl+c to stop", "addr", env.PublicUrl) + err = http.ListenAndServe(fmt.Sprintf(":%d", env.Port), router) if !errors.Is(err, http.ErrServerClosed) { slog.Error("server terminated", "error", err) os.Exit(1) diff --git a/views/home/home.templ b/views/home/home.templ index e5dc163..00d732b 100644 --- a/views/home/home.templ +++ b/views/home/home.templ @@ -7,7 +7,7 @@ import ( "gitlab.unjx.de/flohoss/godash/views/layout" ) -templ Home(title string, user services.User, bookmarks *services.Bookmarks, static *services.StaticInformation, live *services.LiveInformation, weather *services.OpenWeather) { +templ Home(title string, user *services.User, bookmarks *services.Bookmarks, static *services.StaticInformation, live *services.LiveInformation, weather *services.OpenWeather) {
@components.Weather(weather)