From e1d0e7002cbbb86aad4e3ce6ad2dcca304b108e9 Mon Sep 17 00:00:00 2001 From: Martin Jirku Date: Mon, 6 May 2024 21:26:20 +0200 Subject: [PATCH] add login and admin page --- go.mod | 2 ++ go.sum | 4 +++ handlers/Admin.go | 40 ++++++++++++++++++++++ handlers/Login.go | 27 +++++---------- main.go | 32 ++++++++++++++---- pkg/middleware/auth.go | 68 ++++++++++++++++++++++++++++++++++++++ pkg/models/user.go | 11 ++++++ templates/pages/admin.tmpl | 13 ++++++++ templates/pages/login.tmpl | 6 ++-- 9 files changed, 175 insertions(+), 28 deletions(-) create mode 100644 handlers/Admin.go create mode 100644 pkg/middleware/auth.go create mode 100644 pkg/models/user.go create mode 100644 templates/pages/admin.tmpl diff --git a/go.mod b/go.mod index c548249..3665629 100644 --- a/go.mod +++ b/go.mod @@ -33,6 +33,8 @@ require ( github.com/google/uuid v1.6.0 // indirect github.com/googleapis/enterprise-certificate-proxy v0.3.2 // indirect github.com/googleapis/gax-go/v2 v2.12.4 // indirect + github.com/gorilla/securecookie v1.1.2 // indirect + github.com/gorilla/sessions v1.2.2 // indirect github.com/huandu/xstrings v1.4.0 // indirect github.com/justinas/nosurf v1.1.1 // indirect github.com/mitchellh/copystructure v1.2.0 // indirect diff --git a/go.sum b/go.sum index 367cc40..533b5a6 100644 --- a/go.sum +++ b/go.sum @@ -89,6 +89,10 @@ github.com/googleapis/gax-go/v2 v2.12.4 h1:9gWcmF85Wvq4ryPFvGFaOgPIs1AQX0d0bcbGw github.com/googleapis/gax-go/v2 v2.12.4/go.mod h1:KYEYLorsnIGDi/rPC8b5TdlB9kbKoFubselGIoBMCwI= github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= +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.2.2 h1:lqzMYz6bOfvn2WriPUjNByzeXIlVzURcPmgMczkmTjY= +github.com/gorilla/sessions v1.2.2/go.mod h1:ePLdVu+jbEgHH+KWw8I1z2wqd0BAdAQh/8LRvBeoNcQ= github.com/huandu/xstrings v1.4.0 h1:D17IlohoQq4UcpqD7fDk80P7l+lwAmlFaBHgOipl2FU= github.com/huandu/xstrings v1.4.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= diff --git a/handlers/Admin.go b/handlers/Admin.go new file mode 100644 index 0000000..d9d0f52 --- /dev/null +++ b/handlers/Admin.go @@ -0,0 +1,40 @@ +package handlers + +import ( + "html/template" + "io/fs" + "log/slog" + "net/http" + + "github.com/justinas/nosurf" + "jirku.sk/mcmamina/pkg/middleware" +) + +type AdminHandlers struct { + CssPathGetter CSSPathGetter + Recaptcha RecaptchaValidator + Log *slog.Logger + loginTmpl *template.Template +} + +func (h *AdminHandlers) InitTmpl(tmpl *template.Template, file fs.FS) *AdminHandlers { + var err error + h.loginTmpl, err = getTmpl(tmpl, "admin.tmpl", file) + if err != nil { + h.Log.Error("cloning template: %w", err) + } + return h +} + +func (h *AdminHandlers) DashboardGet(w http.ResponseWriter, r *http.Request) { + model := createModel("Prihlásenie", "/login", "activities", h.CssPathGetter) + model["csrfTokenField"] = nosurf.FormFieldName + model["csrfToken"] = nosurf.Token(r) + model["user"] = middleware.GetUser(r) + model["recaptchaKey"] = h.Recaptcha.Key() + + if err := h.loginTmpl.ExecuteTemplate(w, "page", model); err != nil { + h.Log.Error("page executing context", err) + http.Redirect(w, r, "/error", http.StatusInternalServerError) + } +} diff --git a/handlers/Login.go b/handlers/Login.go index 0dd44c6..7ab8c9f 100644 --- a/handlers/Login.go +++ b/handlers/Login.go @@ -2,15 +2,18 @@ package handlers import ( "encoding/json" - "fmt" "html/template" "io" "io/fs" "log/slog" "net/http" + "github.com/gorilla/sessions" "github.com/justinas/nosurf" "golang.org/x/oauth2" + + "jirku.sk/mcmamina/pkg/middleware" + "jirku.sk/mcmamina/pkg/models" ) type RecaptchaValidator interface { @@ -89,7 +92,7 @@ func (l *LoginPage) loginAction(w http.ResponseWriter, r *http.Request) { } } -func GoogleCallbackHandler(config oauth2.Config) http.HandlerFunc { +func GoogleCallbackHandler(config oauth2.Config, storeService sessions.Store) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { ctx := r.Context() code := r.FormValue("code") @@ -112,26 +115,12 @@ func GoogleCallbackHandler(config oauth2.Config) http.HandlerFunc { http.Error(w, "Failed to read response: "+err.Error(), http.StatusInternalServerError) return } - - var user struct { - Name string - ID string - Email string - VerifiedEmail string - Picture string - } + var user models.UserLogin if err := json.Unmarshal(userInfoBytes, &user); err != nil { http.Error(w, "Failed to parse user info: "+err.Error(), http.StatusInternalServerError) return } - - // Render a response containing user info - fmt.Fprintf(w, `Google User Info -

Welcome %s!

-

ID: %s

Email: %s

Email Verified: %v

-

Profile Picture: Profile Picture

- `, - user.Name, user.ID, user.Email, user.VerifiedEmail, user.Picture) - + middleware.StoreUser(w, r, &user, storeService) + http.Redirect(w, r, "/admin", http.StatusSeeOther) } } diff --git a/main.go b/main.go index c9a102f..8834c27 100644 --- a/main.go +++ b/main.go @@ -3,6 +3,7 @@ package main import ( "context" "embed" + "encoding/gob" "flag" "fmt" "html/template" @@ -20,11 +21,13 @@ import ( "github.com/42atomys/sprout" "github.com/gorilla/mux" + "github.com/gorilla/sessions" "github.com/joho/godotenv" "golang.org/x/oauth2" "golang.org/x/oauth2/google" "jirku.sk/mcmamina/handlers" "jirku.sk/mcmamina/pkg/middleware" + "jirku.sk/mcmamina/pkg/models" "jirku.sk/mcmamina/pkg/services" ) @@ -36,6 +39,7 @@ const ( GOOGLE_AUTH_REDIRECT_PATH = "GOOGLE_AUTH_REDIRECT_PATH" GOOGLE_AUTH_CLIENT_ID = "GOOGLE_AUTH_CLIENT_ID" GOOGLE_AUTH_CLIENT_SECRET = "GOOGLE_AUTH_CLIENT_SECRET" + SESSION_KEY = "SESSION_KEY" ) //go:embed dist dist/.vite templates/**/*.tmpl @@ -76,8 +80,9 @@ func setupWebserver(log *slog.Logger) { workingFolder = os.DirFS(config.publicPath) } - cssService, sponsorService, recaptchaService, calendarService := prepareServices(log, workingFolder) - prepareMiddleware(router, log) + gob.Register(models.UserLogin{}) + cssService, sponsorService, recaptchaService, calendarService, storeService := prepareServices(log, workingFolder) + prepareMiddleware(router, log, storeService) router.HandleFunc("/healthcheck", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) @@ -113,7 +118,19 @@ func setupWebserver(log *slog.Logger) { Endpoint: google.Endpoint, } router.HandleFunc("/prihlasenie", handlers.Login(log, cssService, recaptchaService, tmpl, googleOAuth2Config, distFS)).Methods("GET", "POST") - router.HandleFunc("/auth/google/callback", handlers.GoogleCallbackHandler(googleOAuth2Config)) + router.HandleFunc("/auth/google/callback", handlers.GoogleCallbackHandler(googleOAuth2Config, storeService)) + + // ADMINISTRATION + adminHandlers := handlers.AdminHandlers{ + CssPathGetter: cssService, + Recaptcha: recaptchaService, + Log: log, + } + adminHandlers.InitTmpl(tmpl, distFS) + adminRoute := router.PathPrefix("/admin").Subrouter() + adminRoute.Use(middleware.AuthorizeMiddleware) + adminRoute.HandleFunc("", adminHandlers.DashboardGet) + handleFiles(router, http.FS(workingFolder)) sigs := make(chan os.Signal, 1) @@ -161,20 +178,21 @@ func parseConfig(log *slog.Logger) configuration { return config } -func prepareServices(log *slog.Logger, fs fs.FS) (*services.CSS, *services.SponsorService, *services.RecaptchaService, *services.CalendarService) { +func prepareServices(log *slog.Logger, fs fs.FS) (*services.CSS, *services.SponsorService, *services.RecaptchaService, *services.CalendarService, sessions.Store) { cssService := services.NewCSS(fs, serviceLog(log, "css")) sponsorService := services.NewSponsorService() recaptchaService := services.NewRecaptchaService(os.Getenv(GOOGLE_API_KEY), os.Getenv(GOOGLE_CAPTCHA_SITE)) calendarService := services.NewCalendarService(os.Getenv(GOOGLE_API_KEY), os.Getenv(GOOGLE_CALENDAR_ID)) - return cssService, sponsorService, recaptchaService, calendarService + storeService := sessions.NewCookieStore([]byte(os.Getenv(SESSION_KEY))) + return cssService, sponsorService, recaptchaService, calendarService, storeService } -func prepareMiddleware(router *mux.Router, log *slog.Logger) { +func prepareMiddleware(router *mux.Router, log *slog.Logger, storeService sessions.Store) { router.Use(middleware.Recover(middlewareLog(log, "recover"))) router.Use(middleware.RequestID(middlewareLog(log, "requestID"))) router.Use(middleware.Logger(middlewareLog(log, "logger"))) router.Use(middleware.Csrf) - // router.Use(middleware.AuthMiddleware(store)) + router.Use(middleware.AuthMiddleware(storeService)) } func setupPanorama(panoramaPath string, router *mux.Router, log *slog.Logger) { diff --git a/pkg/middleware/auth.go b/pkg/middleware/auth.go new file mode 100644 index 0000000..e4e9b76 --- /dev/null +++ b/pkg/middleware/auth.go @@ -0,0 +1,68 @@ +package middleware + +import ( + "context" + "net/http" + + "github.com/gorilla/mux" + "github.com/gorilla/sessions" + "jirku.sk/mcmamina/pkg/models" +) + +const SessionName = "session" + +type UserCookie int + +const UserCookieKey UserCookie = 0 + +func AuthMiddleware(store sessions.Store) mux.MiddlewareFunc { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + session, err := store.Get(r, SessionName) + if err != nil || session.Values["user"] != nil { + if user, ok := session.Values["user"].(models.UserLogin); ok { + ctx = context.WithValue(ctx, UserCookieKey, user) + } + } + next.ServeHTTP(w, r.WithContext(ctx)) + }) + } +} + +func AuthorizeMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + user := GetUser(r) + if user == nil { + // TODO: handle redirect back to original page + http.Redirect(w, r, "/prihlasenie", http.StatusFound) + } else { + next.ServeHTTP(w, r) + } + }) +} + +func StoreUser(w http.ResponseWriter, r *http.Request, user *models.UserLogin, store sessions.Store) error { + session := sessions.NewSession(store, SessionName) + if user == nil { + session.Values["user"] = nil + } else { + session.Values["user"] = &user + } + + session.Options.MaxAge = 60 * 60 * 24 * 10 + session.Options.HttpOnly = true + session.Options.Path = "/" + session.Save(r, w) + return nil +} + +func GetUser(r *http.Request) *models.UserLogin { + result := r.Context().Value(UserCookieKey) + if result == nil { + return nil + } else if result, ok := result.(models.UserLogin); ok { + return &result + } + return nil +} diff --git a/pkg/models/user.go b/pkg/models/user.go new file mode 100644 index 0000000..6ec221f --- /dev/null +++ b/pkg/models/user.go @@ -0,0 +1,11 @@ +package models + +type UserLogin struct { + Sub string `json:"sub"` + Email string `json:"email"` + Name string `json:"name"` + GivenName string `json:"given_name"` + FamilyName string `json:"family_name"` + Picture string `json:"picture"` + EmailVerified bool `json:"email_verified"` +} diff --git a/templates/pages/admin.tmpl b/templates/pages/admin.tmpl new file mode 100644 index 0000000..6a266c5 --- /dev/null +++ b/templates/pages/admin.tmpl @@ -0,0 +1,13 @@ +{{- define "content" -}} +{{- template "layout.start" .}} + +{{- template "com_cards.fullwidth.start" dict "m" "mb-0 mt-12"}} +{{- template "com_cards.cardcontent.start" "flex flex-col md:flex-row gap-3 md:gap-4 lg:gap-10 justify-center"}} + +

Dobrý deň, {{.user.Name}}!

+ +{{- template "com_cards.cardcontent.end" -}} +{{- template "com_cards.fullwidth.end" -}} + +{{- template "layout.end" .}} +{{- end -}} \ No newline at end of file diff --git a/templates/pages/login.tmpl b/templates/pages/login.tmpl index 77790cb..a112194 100644 --- a/templates/pages/login.tmpl +++ b/templates/pages/login.tmpl @@ -4,7 +4,8 @@ {{- template "com_cards.fullwidth.start" dict "m" "mb-0 mt-12"}} {{- template "com_cards.cardcontent.start" "flex flex-col md:flex-row gap-3 md:gap-4 lg:gap-10 justify-center"}} -
Prihlásenie
+
+

Prihlásenie

{{ if .errorMsg }} {{ end }} - +
+