From f48c5336cbff2cd3c86f5075f0eeac6a0092dc95 Mon Sep 17 00:00:00 2001 From: Martin Jirku Date: Thu, 11 Apr 2024 21:23:06 +0200 Subject: [PATCH] login page Signed-off-by: Martin Jirku --- .env.example | 1 + go.mod | 1 + go.sum | 2 ++ handlers/Index.go | 4 +++ handlers/Login.go | 65 ++++++++++++++++++++++++++++++------ main.go | 19 +++++++---- pkg/middleware/csrf.go | 18 ++++++++++ services/recaptcha.go | 55 ++++++++++++++++++++++++++++++ template/layout/Layout.templ | 2 +- template/pages/Login.templ | 53 +++++++++++++++++++++++------ 10 files changed, 191 insertions(+), 29 deletions(-) create mode 100644 pkg/middleware/csrf.go create mode 100644 services/recaptcha.go diff --git a/.env.example b/.env.example index 2b712ea..6fcc474 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,5 @@ GOOGLE_API_KEY="" +GOOGLE_CAPTCHA_SITE="" GOOGLE_CALENDAR_ID="" # mcmamina GOOGLE_SMTP_PWD="" GOOGLE_SMTP_MAIL="" diff --git a/go.mod b/go.mod index c39edd5..92bb780 100644 --- a/go.mod +++ b/go.mod @@ -28,6 +28,7 @@ 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.3 // indirect + github.com/justinas/nosurf v1.1.1 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/xrash/smetrics v0.0.0-20231213231151-1d8dd44e695e // indirect go.opencensus.io v0.24.0 // indirect diff --git a/go.sum b/go.sum index bdf8731..6c67b22 100644 --- a/go.sum +++ b/go.sum @@ -112,6 +112,8 @@ github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/justinas/nosurf v1.1.1 h1:92Aw44hjSK4MxJeMSyDa7jwuI9GR2J/JCQiaKvXXSlk= +github.com/justinas/nosurf v1.1.1/go.mod h1:ALpWdSbuNGy2lZWtyXdjkYv4edL23oSEgfBT1gPJ5BQ= github.com/manifoldco/promptui v0.9.0 h1:3V4HzJk1TtXW1MTZMP7mdlwbBpIinw3HztaIlYthEiA= github.com/manifoldco/promptui v0.9.0/go.mod h1:ka04sppxSGFAtxX0qhlYQjISsg9mR4GWtQEhdbn6Pgg= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= diff --git a/handlers/Index.go b/handlers/Index.go index ed1eb59..672fc1b 100644 --- a/handlers/Index.go +++ b/handlers/Index.go @@ -18,6 +18,10 @@ type EventsGetter interface { type CSSPathGetter interface { GetCssPath() (string, error) } +type RecaptchaValidator interface { + ValidateCaptcha(r *http.Request) error + Key() string +} type IndexHandler struct { Log *slog.Logger EventsGetter EventsGetter diff --git a/handlers/Login.go b/handlers/Login.go index 2c800c2..cd242d8 100644 --- a/handlers/Login.go +++ b/handlers/Login.go @@ -4,23 +4,66 @@ import ( "log/slog" "net/http" + "github.com/justinas/nosurf" "jirku.sk/mcmamina/template/components" "jirku.sk/mcmamina/template/pages" ) -func Login(Log *slog.Logger, cssPathGetter CSSPathGetter) func(w http.ResponseWriter, r *http.Request) { +func Login(Log *slog.Logger, + cssPathGetter CSSPathGetter, + recaptcha RecaptchaValidator, +) func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) { Log.Info("request", slog.String("method", r.Method), slog.String("path", r.URL.Path)) w.Header().Set("Content-Type", "text/html; charset=utf-8") - cssPath, _ := cssPathGetter.GetCssPath() - dto := pages.NewLoginPageDto("", "", "") - // TODO: handle GET - // TODO: get CSRF token - components.Page(components.NewPage( - "Login", - "Prihlásenie", - cssPath, - pages.LoginPage(dto), - )).Render(r.Context(), w) + page := LoginPage{ + cssPathGetter: cssPathGetter, + recaptcha: recaptcha, + log: Log, + } + + switch r.Method { + case http.MethodGet: + page.loginGet(w, r) + case http.MethodPost: + page.loginAction(w, r) + } + } +} + +type LoginPage struct { + cssPathGetter CSSPathGetter + recaptcha RecaptchaValidator + log *slog.Logger +} + +func (l *LoginPage) loginGet(w http.ResponseWriter, r *http.Request) { + cssPath, _ := l.cssPathGetter.GetCssPath() + dto := pages.NewLoginPageDto(nosurf.Token(r), "", l.recaptcha.Key()) + components.Page(components.NewPage( + "Login", + "Prihlásenie", + cssPath, + pages.LoginPage(dto), + )).Render(r.Context(), w) +} +func (l *LoginPage) loginAction(w http.ResponseWriter, r *http.Request) { + cssPath, _ := l.cssPathGetter.GetCssPath() + r.ParseForm() + username := r.Form.Get("username") + + err := l.recaptcha.ValidateCaptcha(r) + dto := pages.NewLoginPageDto(nosurf.Token(r), username, l.recaptcha.Key()) + if err != nil { + l.log.Info("recaptcha validation failed", slog.Any("error", err)) + dto.SetErrorMsg("Chyba pri overovaní reCAPTCHA") + } else { + dto.SetErrorMsg("Nesprávne prihlasovacie údaje") } + components.Page(components.NewPage( + "Login", + "Prihlásenie", + cssPath, + pages.LoginPage(dto), + )).Render(r.Context(), w) } diff --git a/main.go b/main.go index 9bebdd8..ffd4d8e 100644 --- a/main.go +++ b/main.go @@ -26,8 +26,9 @@ import ( // .env keys const ( - GOOGLE_API_KEY = "GOOGLE_API_KEY" - GOOGLE_CALENDAR_ID = "GOOGLE_CALENDAR_ID" + GOOGLE_API_KEY = "GOOGLE_API_KEY" + GOOGLE_CALENDAR_ID = "GOOGLE_CALENDAR_ID" + GOOGLE_CAPTCHA_SITE = "GOOGLE_CAPTCHA_SITE" ) //go:embed dist dist/.vite @@ -82,11 +83,15 @@ func setupWebserver(log *slog.Logger, calendarService *services.CalendarService) cssService := services.NewCSS(workingFolder, serviceLog(log, "css")) sponsorService := services.NewSponsorService() + recaptchaService := services.NewRecaptchaService(os.Getenv(GOOGLE_API_KEY), os.Getenv(GOOGLE_CAPTCHA_SITE)) // Logger middleware - router.Use(middleware.Recover(middlwareLog(log, "recover"))) - router.Use(middleware.RequestID(middlwareLog(log, "requestID"))) - router.Use(middleware.Logger(middlwareLog(log, "logger"))) + 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.HandleFunc("/healthcheck", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) @@ -116,7 +121,7 @@ func setupWebserver(log *slog.Logger, calendarService *services.CalendarService) router.HandleFunc("/", handlers.NewIndexHandler(log, calendarService, cssService).ServeHTTP) router.HandleFunc("/o-nas", handlers.AboutUs(log, cssService)) // MCMAMINA -->> GENERATED CODE - router.HandleFunc("/prihlasenie", handlers.Login(log, cssService)) + router.HandleFunc("/prihlasenie", handlers.Login(log, cssService, recaptchaService)) router.HandleFunc("/aktivity/burzy", handlers.Marketplace(log, cssService)) router.HandleFunc("/aktivity/podporne-skupiny", handlers.SupportGroups(log, cssService)) router.HandleFunc("/aktivity/predporodny-kurz", handlers.BabyDeliveryCourse(log, cssService)) @@ -200,7 +205,7 @@ func validatePort(port int) bool { return port > 0 && port <= 65535 } -func middlwareLog(log *slog.Logger, name string) *slog.Logger { +func middlewareLog(log *slog.Logger, name string) *slog.Logger { return log.With(slog.String("type", "middleware"), slog.String("name", name)) } diff --git a/pkg/middleware/csrf.go b/pkg/middleware/csrf.go new file mode 100644 index 0000000..d70d555 --- /dev/null +++ b/pkg/middleware/csrf.go @@ -0,0 +1,18 @@ +package middleware + +import ( + "log" + "net/http" + + "github.com/justinas/nosurf" +) + +func Csrf(h http.Handler) http.Handler { + surfing := nosurf.New(h) + surfing.SetBaseCookie(http.Cookie{Path: "/"}) + surfing.SetFailureHandler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + log.Println("Failed to validate CSRF token:", nosurf.Reason(r)) + w.WriteHeader(http.StatusBadRequest) + })) + return surfing +} diff --git a/services/recaptcha.go b/services/recaptcha.go new file mode 100644 index 0000000..8bf9264 --- /dev/null +++ b/services/recaptcha.go @@ -0,0 +1,55 @@ +package services + +import ( + "encoding/json" + "fmt" + "net/http" + "net/url" +) + +type RecaptchaService struct { + apiKey string + siteKey string +} + +func NewRecaptchaService(apiKey, siteKey string) *RecaptchaService { + return &RecaptchaService{ + apiKey: apiKey, + siteKey: siteKey, + } +} + +type googleCaptchaResponse struct { + Success bool `json:"success"` + ChallengeTs string `json:"challenge_ts"` + Hostname string `json:"hostname"` + ErrorCodes []string `json:"error-codes,omitempty"` +} + +func (s *RecaptchaService) ValidateCaptcha(r *http.Request) error { + if err := r.ParseForm(); err != nil { + return fmt.Errorf("parsing form: %w", err) + } + token := r.Form.Get("g-recaptcha-response") + if token == "" { + return fmt.Errorf("missing captcha token") + } + resp, err := http.PostForm(fmt.Sprintf("https://www.google.com/recaptcha/api/siteverify?%s", s.apiKey), url.Values{ + "siteKey": {s.siteKey}, + "response": {token}, + }) + if err != nil { + return fmt.Errorf("sending captcha request: %w", err) + } + defer resp.Body.Close() + var response googleCaptchaResponse + err = json.NewDecoder(resp.Body).Decode(&response) + if err != nil { + return fmt.Errorf("decoding captcha response: %w", err) + } + return nil +} + +func (s *RecaptchaService) Key() string { + return s.siteKey +} diff --git a/template/layout/Layout.templ b/template/layout/Layout.templ index 2f90879..ec316c1 100644 --- a/template/layout/Layout.templ +++ b/template/layout/Layout.templ @@ -269,7 +269,7 @@ templ Socials() { templ Footer() {