From 42aeb60853e1615f69898eabc231e353a601b51d Mon Sep 17 00:00:00 2001 From: Noah Petherbridge Date: Sat, 15 Jun 2024 15:05:50 -0700 Subject: [PATCH] Various tweaks and improvements * Inner circle: users have the ability to remove themselves and can avoid being invited again in the future. * Admin actions: add a "Reset Password" ability to user accounts. * Admin "Create New User" page. * Rate limit error handling improvements for the login page. --- pkg/config/admin_scopes.go | 6 + pkg/controller/account/inner_circle.go | 48 ++++++++ pkg/controller/account/login.go | 22 +++- pkg/controller/admin/add_user.go | 62 ++++++++++ pkg/controller/admin/user_actions.go | 25 ++++ pkg/controller/photo/user_gallery.go | 1 + pkg/models/user.go | 8 ++ pkg/ratelimit/errors.go | 47 ++++++++ pkg/ratelimit/ratelimit.go | 13 +- pkg/router/router.go | 2 + web/templates/account/inner_circle.html | 7 ++ web/templates/account/leave_circle.html | 87 +++++++++++++ web/templates/account/profile.html | 6 + web/templates/admin/add_user.html | 154 ++++++++++++++++++++++++ web/templates/admin/dashboard.html | 6 + web/templates/admin/user_actions.html | 41 +++++++ web/templates/photo/gallery.html | 2 +- 17 files changed, 530 insertions(+), 7 deletions(-) create mode 100644 pkg/controller/admin/add_user.go create mode 100644 pkg/ratelimit/errors.go create mode 100644 web/templates/account/leave_circle.html create mode 100644 web/templates/admin/add_user.html diff --git a/pkg/config/admin_scopes.go b/pkg/config/admin_scopes.go index c06654a..457bd23 100644 --- a/pkg/config/admin_scopes.go +++ b/pkg/config/admin_scopes.go @@ -32,9 +32,11 @@ const ( // - Impersonate: ability to log in as a user account // - Ban: ability to ban/unban users // - Delete: ability to delete user accounts + ScopeUserCreate = "admin.user.create" ScopeUserInsight = "admin.user.insights" ScopeUserImpersonate = "admin.user.impersonate" ScopeUserBan = "admin.user.ban" + ScopeUserPassword = "admin.user.password" ScopeUserDelete = "admin.user.delete" ScopeUserPromote = "admin.user.promote" @@ -65,9 +67,11 @@ var AdminScopeDescriptions = map[string]string{ ScopeForumAdmin: "Ability to manage forums themselves (add or remove forums, edit their properties).", ScopeAdminScopeAdmin: "Ability to manage admin permissions for other admin accounts.", ScopeMaintenance: "Ability to activate maintenance mode functions of the website (turn features on or off, disable signups or logins, etc.)", + ScopeUserCreate: "Ability to manually create a new user account, bypassing the signup page.", ScopeUserInsight: "Ability to see admin insights about a user profile (e.g. their block lists and who blocks them).", ScopeUserImpersonate: "Ability to log in as any user account (note: this action is logged and notifies all admins when it happens. Admins must write a reason and it is used to diagnose customer support issues, help with their certification picture, or investigate a reported Direct Message conversation they had).", ScopeUserBan: "Ability to ban or unban user accounts.", + ScopeUserPassword: "Ability to reset a user's password on their behalf.", ScopeUserDelete: "Ability to fully delete user accounts on their behalf.", ScopeUserPromote: "Ability to add or remove the admin status flag on a user profile.", ScopeFeedbackAndReports: "Ability to see admin reports and user feedback.", @@ -97,9 +101,11 @@ func ListAdminScopes() []string { ScopeForumAdmin, ScopeAdminScopeAdmin, ScopeMaintenance, + ScopeUserCreate, ScopeUserInsight, ScopeUserImpersonate, ScopeUserBan, + ScopeUserPassword, ScopeUserDelete, ScopeUserPromote, ScopeFeedbackAndReports, diff --git a/pkg/controller/account/inner_circle.go b/pkg/controller/account/inner_circle.go index cea878a..582d1dc 100644 --- a/pkg/controller/account/inner_circle.go +++ b/pkg/controller/account/inner_circle.go @@ -166,3 +166,51 @@ func RemoveCircle() http.HandlerFunc { } }) } + +// LeaveCircle allows users to remove themself from the circle. +func LeaveCircle() http.HandlerFunc { + tmpl := templates.Must("account/leave_circle.html") + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + currentUser, err := session.CurrentUser(r) + if err != nil || !currentUser.IsInnerCircle() { + templates.NotFoundPage(w, r) + return + } + + // Submitting the form? + if r.Method == http.MethodPost { + var ( + confirm = r.PostFormValue("confirm") == "true" + dontReinvite = r.PostFormValue("dont_reinvite") == "true" + ) + + if !confirm { + templates.Redirect(w, r.URL.Path) + return + } + + // Remove them from the circle now. + if err := models.RemoveFromInnerCircle(currentUser); err != nil { + session.FlashError(w, r, "Error updating your inner circle status: %s", err) + templates.Redirect(w, r.URL.Path) + return + } + + // Confirmation message, + don't reinvite again? + if dontReinvite { + currentUser.SetProfileField("inner_circle_optout", "true") + session.Flash(w, r, "You have been removed from the inner circle, and you WILL NOT be invited again in the future.") + } else { + session.Flash(w, r, "You have been removed from the inner circle.") + } + + templates.Redirect(w, "/me") + return + } + + if err := tmpl.Execute(w, r, nil); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + }) +} diff --git a/pkg/controller/account/login.go b/pkg/controller/account/login.go index 8a020c3..2c43c9d 100644 --- a/pkg/controller/account/login.go +++ b/pkg/controller/account/login.go @@ -38,10 +38,23 @@ func Login() http.HandlerFunc { CooldownAt: config.LoginRateLimitCooldownAt, Cooldown: config.LoginRateLimitCooldown, } + var takebackDeferredError bool if err := limiter.Ping(); err != nil { - session.FlashError(w, r, err.Error()) - templates.Redirect(w, r.URL.Path) - return + // Is it a deferred error? Flash it at the end of the request but continue + // to process this login attempt as normal. + if ratelimit.IsDeferredError(err) { + defer func() { + if takebackDeferredError { + return + } + session.FlashError(w, r, err.Error()) + }() + } else { + // Lock-out error, show it now and quit. + session.FlashError(w, r, err.Error()) + templates.Redirect(w, r.URL.Path) + return + } } // Look up their account. @@ -124,6 +137,9 @@ func Login() http.HandlerFunc { log.Error("Failed to clear login rate limiter: %s", err) } + // If there was going to be a deferred ratelimit error, take it back. + takebackDeferredError = true + // Redirect to their dashboard. session.Flash(w, r, "Login successful.") if strings.HasPrefix(next, "/") { diff --git a/pkg/controller/admin/add_user.go b/pkg/controller/admin/add_user.go new file mode 100644 index 0000000..d53ca77 --- /dev/null +++ b/pkg/controller/admin/add_user.go @@ -0,0 +1,62 @@ +package admin + +import ( + "net/http" + "net/mail" + "strings" + + "code.nonshy.com/nonshy/website/pkg/models" + "code.nonshy.com/nonshy/website/pkg/session" + "code.nonshy.com/nonshy/website/pkg/templates" +) + +// Manually create new user accounts. +func AddUser() http.HandlerFunc { + tmpl := templates.Must("admin/add_user.html") + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost { + var ( + email = strings.TrimSpace(strings.ToLower(r.PostFormValue("email"))) + username = strings.TrimSpace(strings.ToLower(r.PostFormValue("username"))) + password = r.PostFormValue("password") + ) + + // Validate the email. + if _, err := mail.ParseAddress(email); err != nil { + session.FlashError(w, r, "The email address you entered is not valid: %s", err) + templates.Redirect(w, r.URL.Path) + return + } + + // Password check. + if len(password) < 3 { + session.FlashError(w, r, "The password is required to be 3+ characters long.") + templates.Redirect(w, r.URL.Path) + return + } + + // Validate the username is OK: well formatted, not reserved, not existing. + if err := models.IsValidUsername(username); err != nil { + session.FlashError(w, r, err.Error()) + templates.Redirect(w, r.URL.Path) + return + } + + // Create the user. + if _, err := models.CreateUser(username, email, password); err != nil { + session.FlashError(w, r, "Couldn't create the user: %s", err) + templates.Redirect(w, r.URL.Path) + return + } + + session.Flash(w, r, "Created the username %s with password: %s", username, password) + templates.Redirect(w, r.URL.Path) + return + } + + if err := tmpl.Execute(w, r, nil); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + }) +} diff --git a/pkg/controller/admin/user_actions.go b/pkg/controller/admin/user_actions.go index 3e397c1..c568cac 100644 --- a/pkg/controller/admin/user_actions.go +++ b/pkg/controller/admin/user_actions.go @@ -187,6 +187,31 @@ func UserActions() http.HandlerFunc { models.LogEvent(user, currentUser, models.ChangeLogAdmin, "users", currentUser.ID, fmt.Sprintf("User admin status updated to: %s", action)) return } + case "password": + // Scope check. + if !currentUser.HasAdminScope(config.ScopeUserPassword) { + session.FlashError(w, r, "Missing admin scope: %s", config.ScopeUserPassword) + templates.Redirect(w, "/admin") + return + } + + if confirm { + password := r.PostFormValue("password") + if len(password) < 3 { + session.FlashError(w, r, "A password of at least 3 characters is required.") + templates.Redirect(w, r.URL.Path+fmt.Sprintf("?intent=password&user_id=%d", user.ID)) + return + } + + if err := user.SaveNewPassword(password); err != nil { + session.FlashError(w, r, "Failed to set the user's password: %s", err) + } else { + session.Flash(w, r, "The user's password has been updated to: %s", password) + } + + templates.Redirect(w, "/u/"+user.Username) + return + } case "delete": // Scope check. if !currentUser.HasAdminScope(config.ScopeUserDelete) { diff --git a/pkg/controller/photo/user_gallery.go b/pkg/controller/photo/user_gallery.go index 78c0a54..d233307 100644 --- a/pkg/controller/photo/user_gallery.go +++ b/pkg/controller/photo/user_gallery.go @@ -214,6 +214,7 @@ func UserPhotos() http.HandlerFunc { "CommentMap": commentMap, "ViewStyle": viewStyle, "ExplicitCount": explicitCount, + "InnerCircleOptOut": user.GetProfileField("inner_circle_optout") == "true", "InnerCircleInviteView": innerCircleInvite, // Search filters diff --git a/pkg/models/user.go b/pkg/models/user.go index fc0d2bf..9c35f09 100644 --- a/pkg/models/user.go +++ b/pkg/models/user.go @@ -651,6 +651,14 @@ func (u *User) HashPassword(password string) error { return nil } +// SaveNewPassword updates a user's password and saves their record to the database. +func (u *User) SaveNewPassword(password string) error { + if err := u.HashPassword(password); err != nil { + return err + } + return u.Save() +} + // CheckPassword verifies the password is correct. Returns nil on success. func (u *User) CheckPassword(password string) error { return bcrypt.CompareHashAndPassword([]byte(u.HashedPassword), []byte(password)) diff --git a/pkg/ratelimit/errors.go b/pkg/ratelimit/errors.go new file mode 100644 index 0000000..290649f --- /dev/null +++ b/pkg/ratelimit/errors.go @@ -0,0 +1,47 @@ +package ratelimit + +import "fmt" + +// ErrLockedOut is a lock-out error, sent when the user is currently throttled and +// the website is NOT to even test their password if logging in. +type ErrLockedOut struct { + message string +} + +// ErrDeferred is a rate limit error which can be shown to the user AFTER their current +// login attempt is tried. For example, if they've failed to log in a few times and aren't +// currently in a LockedOut cooldown period, you can test their password and then show +// them the deferred error message at the end of the request. +type ErrDeferred struct { + message string + deferred bool +} + +func NewLockedOutError(message string, v ...interface{}) ErrLockedOut { + return ErrLockedOut{ + message: fmt.Sprintf(message, v...), + } +} + +func NewDeferredError(message string, v ...interface{}) ErrDeferred { + return ErrDeferred{ + message: fmt.Sprintf(message, v...), + deferred: true, + } +} + +// IsDeferredError returns whether a ratelimit error is deferred. +func IsDeferredError(err error) bool { + if err2, ok := err.(ErrDeferred); ok { + return err2.deferred + } + return false +} + +func (e ErrLockedOut) Error() string { + return e.message +} + +func (e ErrDeferred) Error() string { + return e.message +} diff --git a/pkg/ratelimit/ratelimit.go b/pkg/ratelimit/ratelimit.go index f682c7b..0d418e9 100644 --- a/pkg/ratelimit/ratelimit.go +++ b/pkg/ratelimit/ratelimit.go @@ -26,6 +26,13 @@ type Data struct { } // Ping the rate limiter. +// +// The returned error can be one of the following types: +// +// - ErrLockedOut if the user is being cooled down - the caller should not attempt to process their request. +// - ErrDeferred if the rate limiter is being invokved - the caller can process their request, and if failed, show this error. +// +// Other error types signify internal errors, e.g. inability to set a Redis key. func (l *Limiter) Ping() error { var ( key = l.Key() @@ -38,7 +45,7 @@ func (l *Limiter) Ping() error { // Are we cooling down? if now.Before(data.NotBefore) { - return fmt.Errorf( + return NewLockedOutError( "You are doing that too often. Please wait %s before trying again.", utility.FormatDurationCoarse(data.NotBefore.Sub(now)), ) @@ -49,7 +56,7 @@ func (l *Limiter) Ping() error { // Have we hit the wall? if data.Pings >= l.Limit { - return fmt.Errorf( + return NewLockedOutError( "You have hit the rate limit; please wait the full %s before trying again.", utility.FormatDurationCoarse(l.Window), ) @@ -61,7 +68,7 @@ func (l *Limiter) Ping() error { if err := redis.Set(key, data, l.Window); err != nil { return fmt.Errorf("Couldn't set Redis key for rate limiter: %s", err) } - return fmt.Errorf( + return NewDeferredError( "Please wait %s before trying again. You have %d more attempt(s) remaining before you will be locked "+ "out for %s.", utility.FormatDurationCoarse(l.Cooldown), diff --git a/pkg/router/router.go b/pkg/router/router.go index e15ed65..a5bb6ba 100644 --- a/pkg/router/router.go +++ b/pkg/router/router.go @@ -78,6 +78,7 @@ func New() http.Handler { mux.Handle("GET /admin/unimpersonate", middleware.LoginRequired(admin.Unimpersonate())) mux.Handle("GET /inner-circle", middleware.LoginRequired(account.InnerCircle())) mux.Handle("/inner-circle/invite", middleware.LoginRequired(account.InviteCircle())) + mux.Handle("/inner-circle/leave", middleware.LoginRequired(account.LeaveCircle())) mux.Handle("GET /admin/transparency/{username}", middleware.LoginRequired(admin.Transparency())) // Certification Required. Pages that only full (verified) members can access. @@ -99,6 +100,7 @@ func New() http.Handler { mux.Handle("/admin/feedback", middleware.AdminRequired(config.ScopeFeedbackAndReports, admin.Feedback())) mux.Handle("/admin/user-action", middleware.AdminRequired("", admin.UserActions())) mux.Handle("/admin/maintenance", middleware.AdminRequired(config.ScopeMaintenance, admin.Maintenance())) + mux.Handle("/admin/add-user", middleware.AdminRequired(config.ScopeUserCreate, admin.AddUser())) mux.Handle("/forum/admin", middleware.AdminRequired(config.ScopeForumAdmin, forum.Manage())) mux.Handle("/forum/admin/edit", middleware.AdminRequired(config.ScopeForumAdmin, forum.AddEdit())) mux.Handle("/inner-circle/remove", middleware.LoginRequired(account.RemoveCircle())) diff --git a/web/templates/account/inner_circle.html b/web/templates/account/inner_circle.html index 8791fee..cd1696d 100644 --- a/web/templates/account/inner_circle.html +++ b/web/templates/account/inner_circle.html @@ -206,6 +206,13 @@ features such as the Chat Room. + +

Leave the inner circle

+ +

+ If you would like to opt-out and leave the inner circle, you may do so by clicking + here. +

diff --git a/web/templates/account/leave_circle.html b/web/templates/account/leave_circle.html new file mode 100644 index 0000000..a32c751 --- /dev/null +++ b/web/templates/account/leave_circle.html @@ -0,0 +1,87 @@ +{{define "title"}}Leave the Inner Circle{{end}} +{{define "content"}} +
+
+
+
+

+ + Leave the Inner Circle +

+
+
+
+ +
+
+
+
+ +
+
+ {{InputCSRF}} + + +

+ Are you sure that you want to leave the {{PrettyCircle}}? +

+ +

+ This action can not be un-done easily; after leaving the inner circle, the only + way back in is for somebody in the circle to invite you again. +

+ +

+ In case you do not want to be invited into the circle again, + you can check the box below. +

+ + + +
+ + +
+
+
+
+
+
+
+ +
+{{end}} +{{define "scripts"}} + +{{end}} \ No newline at end of file diff --git a/web/templates/account/profile.html b/web/templates/account/profile.html index 9ca6fa9..3ad0895 100644 --- a/web/templates/account/profile.html +++ b/web/templates/account/profile.html @@ -515,6 +515,12 @@ Add/Remove admin rights +
  • + + + Reset Password + +
  • diff --git a/web/templates/admin/add_user.html b/web/templates/admin/add_user.html new file mode 100644 index 0000000..ba760e8 --- /dev/null +++ b/web/templates/admin/add_user.html @@ -0,0 +1,154 @@ +{{define "title"}}Admin - Create User Account{{end}} +{{define "content"}} +{{$Root := .}} + +{{end}} +{{define "scripts"}} + +{{end}} \ No newline at end of file diff --git a/web/templates/admin/dashboard.html b/web/templates/admin/dashboard.html index bac15ce..96654de 100644 --- a/web/templates/admin/dashboard.html +++ b/web/templates/admin/dashboard.html @@ -63,6 +63,12 @@ Admin Permissions Management
  • +
  • + + + Create User Account + +
  • diff --git a/web/templates/admin/user_actions.html b/web/templates/admin/user_actions.html index 60bb406..31306ca 100644 --- a/web/templates/admin/user_actions.html +++ b/web/templates/admin/user_actions.html @@ -28,6 +28,9 @@ {{else if eq .Intent "promote"}} Promote User + {{else if eq .Intent "password"}} + + Reset Password {{else if eq .Intent "delete"}} Delete User @@ -239,6 +242,44 @@ Remove Admin + {{else if eq .Intent "password"}} +

    + This page allows you to reset a user's password on their behalf. For example, if + they have forgotten their password and aren't receiving the e-mail reset link and + they reached out for manual assistance. +

    + +
    + +
    + + Cancel +
    + + {{else if eq .Intent "delete"}}

    diff --git a/web/templates/photo/gallery.html b/web/templates/photo/gallery.html index 3323e6b..72fdd25 100644 --- a/web/templates/photo/gallery.html +++ b/web/templates/photo/gallery.html @@ -488,7 +488,7 @@ {{end}} - {{if not .IsSiteGallery}} + {{if and (not .IsSiteGallery) (not .InnerCircleOptOut)}} {{if and (.CurrentUser.IsInnerCircle) (not .User.InnerCircle) (ne .CurrentUser.Username .User.Username) (ge .PublicPhotoCount .InnerCircleMinimumPublicPhotos)}}