package pkg import ( "context" "errors" "html/template" "net" "net/http" "time" "dev.tandem.ws/tandem/tipus/pkg/form" "golang.org/x/text/language" ) const ( ContextUserKey = "tipus-user" ContextCookieKey = "tipus-cookie" ContextConnKey = "tipus-database" sessionCookie = "tipus-session" defaultRole = "guest" csrfTokenField = "csfrToken" ) type loginForm struct { locale *Locale Errors []error Email *form.InputField Password *form.InputField } func newLoginForm(locale *Locale) *loginForm { return &loginForm{ locale: locale, Email: &form.InputField{ Name: "email", Label: pgettext("input", "Email", locale), Type: "email", Required: true, Attributes: []template.HTMLAttr{ `autofocus="autofocus"`, `autocomplete="username"`, `autocapitalize="none"`, }, }, Password: &form.InputField{ Name: "password", Label: pgettext("input", "Password", locale), Type: "password", Required: true, Attributes: []template.HTMLAttr{ `autocomplete="current-password"`, }, }, } } func (f *loginForm) Parse(r *http.Request) error { err := r.ParseForm() if err != nil { return err } f.Email.FillValue(r) f.Password.FillValue(r) return nil } func (f *loginForm) Validate() bool { validator := form.NewValidator() if validator.CheckRequiredInput(f.Email, gettext("Email can not be empty.", f.locale)) { validator.CheckValidEmailInput(f.Email, gettext("This value is not a valid email. It should be like name@domain.com.", f.locale)) } validator.CheckRequiredInput(f.Password, gettext("Password can not be empty.", f.locale)) return validator.AllOK() } func (f *loginForm) mustRender(w http.ResponseWriter, r *http.Request) { mustRenderWebTemplate(w, r, "login.gohtml", f) } type LoginHandler struct { } func (h *LoginHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { var head string head, r.URL.Path = shiftPath(r.URL.Path) switch head { case "": switch r.Method { case http.MethodGet: h.serveLoginForm(w, r) case http.MethodPost: h.handleLogin(w, r) default: methodNotAllowed(w, r, http.MethodGet, http.MethodPost) } default: http.NotFound(w, r) } } func (h *LoginHandler) serveLoginForm(w http.ResponseWriter, r *http.Request) { user := getUser(r) if user.LoggedIn { http.Redirect(w, r, "/", http.StatusSeeOther) return } locale := getLocale(r) login := newLoginForm(locale) w.WriteHeader(http.StatusOK) login.mustRender(w, r) } func (h *LoginHandler) handleLogin(w http.ResponseWriter, r *http.Request) { user := getUser(r) if user.LoggedIn { http.Redirect(w, r, "/", http.StatusSeeOther) return } locale := getLocale(r) login := newLoginForm(locale) if err := login.Parse(r); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } if login.Validate() { conn := getConn(r) cookie := conn.MustGetText(r.Context(), "", "select login($1, $2, $3)", login.Email, login.Password, remoteAddr(r)) if cookie != "" { setSessionCookie(w, cookie) http.Redirect(w, r, "/", http.StatusSeeOther) return } login.Errors = append(login.Errors, errors.New(gettext("Invalid user or password.", locale))) w.WriteHeader(http.StatusUnauthorized) } else { w.WriteHeader(http.StatusUnprocessableEntity) } login.mustRender(w, r) } func remoteAddr(r *http.Request) string { address := r.Header.Get("X-Forwarded-For") if address == "" { address, _, _ = net.SplitHostPort(r.RemoteAddr) } return address } func setSessionCookie(w http.ResponseWriter, cookie string) { http.SetCookie(w, createSessionCookie(cookie, 8766*24*time.Hour)) } func createSessionCookie(value string, duration time.Duration) *http.Cookie { return &http.Cookie{ Name: sessionCookie, Value: value, Path: "/", Expires: time.Now().Add(duration), HttpOnly: true, SameSite: http.SameSiteLaxMode, } } type AppUser struct { Email string LoggedIn bool Role string Language language.Tag CsrfToken string } func LoginChecker(db *Db, next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var ctx = r.Context() if cookie, err := r.Cookie(sessionCookie); err == nil { ctx = context.WithValue(ctx, ContextCookieKey, cookie.Value) } conn := db.MustAcquire(ctx) defer conn.Release() ctx = context.WithValue(ctx, ContextConnKey, conn) user := &AppUser{ Email: "", LoggedIn: false, Role: defaultRole, } row := conn.QueryRow(ctx, "select coalesce(email, ''), role, lang_tag, csrf_token from user_profile") var langTag string if err := row.Scan(&user.Email, &user.Role, &langTag, &user.CsrfToken); err != nil { panic(err) } user.LoggedIn = user.Email != "" user.Language, _ = language.Parse(langTag) ctx = context.WithValue(ctx, ContextUserKey, user) next.ServeHTTP(w, r.WithContext(ctx)) }) } func getUser(r *http.Request) *AppUser { return r.Context().Value(ContextUserKey).(*AppUser) } func getConn(r *http.Request) *Conn { return r.Context().Value(ContextConnKey).(*Conn) }