From 61163cd2bf7967112e684c7ffaa7be1a88c0fff5 Mon Sep 17 00:00:00 2001 From: James Mills Date: Sat, 30 Jan 2021 15:38:26 +1000 Subject: [PATCH] Add password hadnelrs --- go.sum | 4 - internal/auth_handlers.go | 99 ++++++++++++++++ internal/email.go | 219 ++++++++++++++++++++++++++++++++++++ internal/passwd_handlers.go | 201 +++++++++++++++++++++++++++++++++ 4 files changed, 519 insertions(+), 4 deletions(-) create mode 100644 internal/auth_handlers.go create mode 100644 internal/email.go create mode 100644 internal/passwd_handlers.go diff --git a/go.sum b/go.sum index 4e27fbc..7408716 100644 --- a/go.sum +++ b/go.sum @@ -60,7 +60,6 @@ github.com/daaku/go.zipexe v1.0.0/go.mod h1:z8IiR6TsVLEYKwXAoE/I+8ys/sDkgTzSL0CL github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/denisenkom/go-mssqldb v0.9.0/go.mod h1:xbL0rPBG9cCiLr28tMa8zpbdarY27NDyej4t/EjAShU= -github.com/dgrijalva/jwt-go v1.0.2 h1:KPldsxuKGsS2FPWsNeg9ZO18aCrGKujPoWXn2yo+KQM= github.com/dgrijalva/jwt-go v3.2.0+incompatible h1:7qlOGliEKZXTDg6OTjfoBKDXWrumCAMpl/TFQ4/5kLM= github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= github.com/dgryski/go-sip13 v0.0.0-20181026042036-e10d5fee7954/go.mod h1:vAd38F8PWV+bWy6jNmig1y/TA+kYO4g3RSRF0IAv0no= @@ -205,7 +204,6 @@ github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRW github.com/nkovacs/streamquote v1.0.0/go.mod h1:BN+NaZ2CmdKqUuTUXUEm9j95B2TRbpOWpxbJYzzgUsc= github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= -github.com/patrickmn/go-cache v1.0.0 h1:3gD5McaYs9CxjyK5AXGcq8gdeCARtd/9gJDUvVeaZ0Y= github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc= github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ= github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic= @@ -263,7 +261,6 @@ github.com/spf13/cobra v0.0.7/go.mod h1:/6GTrnGXV9HjY+aR4k0oJ5tcvakLuG6EuKReYlHN github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo= github.com/spf13/jwalterweatherman v1.1.0/go.mod h1:aNWZUN0dPAAO/Ljvb5BEdw96iTZ0EXowPYD95IqWIGo= github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= -github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/viper v1.4.0/go.mod h1:PTJ7Z/lr49W6bUbkmS1V3by4uWynFiR9p7+dSq/yZzE= github.com/spf13/viper v1.7.1/go.mod h1:8WkrPz2fc9jxqZNCJI/76HCieCp4Q8HaLFoCha5qpdg= @@ -289,7 +286,6 @@ github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyC github.com/valyala/fasttemplate v1.0.1/go.mod h1:UQGH1tvbgY+Nz5t2n7tXsz52dQxojPUpymEIMZ47gx8= github.com/vcraescu/go-paginator v1.0.0 h1:ilNmRhlgG8N44LuxfGoPI2u8guXMA6gUqaPGA5BmRFs= github.com/vcraescu/go-paginator v1.0.0/go.mod h1:caZCjjt2qcA1O2aDzW7lwAcK4Rxw3LNvdEVF/ONxZWw= -github.com/wblakecaldwell/profiler v0.0.0-20150908040756-6111ef1313a1 h1:Dz/PRieZRmOhDfOlkVpY1LYYIfNoTJjlDirAlagOr0s= github.com/wblakecaldwell/profiler v0.0.0-20150908040756-6111ef1313a1/go.mod h1:3+0F8oLB1rQlbIcRAuqDgGdzNi9X69un/aPz4cUAFV4= github.com/writeas/slug v1.2.0 h1:EMQ+cwLiOcA6EtFwUgyw3Ge18x9uflUnOnR6bp/J+/g= github.com/writeas/slug v1.2.0/go.mod h1:RE8shOqQP3YhsfsQe0L3RnuejfQ4Mk+JjY5YJQFubfQ= diff --git a/internal/auth_handlers.go b/internal/auth_handlers.go new file mode 100644 index 0000000..d4e8b79 --- /dev/null +++ b/internal/auth_handlers.go @@ -0,0 +1,99 @@ +package internal + +import ( + "net/http" + "time" + + "github.com/julienschmidt/httprouter" + log "github.com/sirupsen/logrus" + + "git.mills.io/prologic/spyda/internal/session" +) + +// LoginHandler ... +func (s *Server) LoginHandler() httprouter.Handle { + // #239: Throttle failed login attempts and lock user account. + failures := NewTTLCache(5 * time.Minute) + + return func(w http.ResponseWriter, r *http.Request, p httprouter.Params) { + ctx := NewContext(s.config, s.db, r) + + if r.Method == "GET" { + s.render("login", w, ctx) + return + } + + username := NormalizeUsername(r.FormValue("username")) + password := r.FormValue("password") + rememberme := r.FormValue("rememberme") == "on" + + // Error: no username or password provided + if username == "" || password == "" { + log.Warn("no username or password provided") + http.Redirect(w, r, "/login", http.StatusFound) + return + } + + // Lookup user + user, err := s.db.GetUser(username) + if err != nil { + ctx.Error = true + ctx.Message = "Invalid username! Hint: Register an account?" + s.render("error", w, ctx) + return + } + + // #239: Throttle failed login attempts and lock user account. + if failures.Get(user.Username) > MaxFailedLogins { + ctx.Error = true + ctx.Message = "Too many failed login attempts. Account temporarily locked! Please try again later." + s.render("error", w, ctx) + return + } + + // Validate cleartext password against KDF hash + err = s.pm.CheckPassword(user.Password, password) + if err != nil { + // #239: Throttle failed login attempts and lock user account. + failed := failures.Inc(user.Username) + time.Sleep(time.Duration(IntPow(2, failed)) * time.Second) + + ctx.Error = true + ctx.Message = "Invalid password! Hint: Reset your password?" + s.render("error", w, ctx) + return + } + + // #239: Throttle failed login attempts and lock user account. + failures.Reset(user.Username) + + // Login successful + log.Infof("login successful: %s", username) + + // Lookup session + sess := r.Context().Value(session.SessionKey) + if sess == nil { + log.Warn("no session found") + http.Redirect(w, r, "/login", http.StatusFound) + return + } + + // Authorize session + _ = sess.(*session.Session).Set("username", username) + + // Persist session? + if rememberme { + _ = sess.(*session.Session).Set("persist", "1") + } + + http.Redirect(w, r, RedirectRefererURL(r, s.config, "/"), http.StatusFound) + } +} + +// LogoutHandler ... +func (s *Server) LogoutHandler() httprouter.Handle { + return func(w http.ResponseWriter, r *http.Request, p httprouter.Params) { + s.sm.Delete(w, r) + http.Redirect(w, r, "/", http.StatusFound) + } +} diff --git a/internal/email.go b/internal/email.go new file mode 100644 index 0000000..1e5ee21 --- /dev/null +++ b/internal/email.go @@ -0,0 +1,219 @@ +package internal + +import ( + "bytes" + "errors" + "fmt" + "strings" + "text/template" + + "github.com/go-mail/mail" + log "github.com/sirupsen/logrus" +) + +var ( + ErrSendingEmail = errors.New("error: unable to send email") + + passwordResetEmailTemplate = template.Must(template.New("email").Parse(`Hello {{ .Username }}, + +You have requested to have your password on {{ .Pod }} reset for your account. + +**IMPORTANT:** If this was __NOT__ initiated by you, please ignore this email and contract support! + +To reset your password, please visit the following link: + +{{ .BaseURL}}/newPassword?token={{ .Token }} + +Kind regards, + +{{ .Pod}} Support +`)) + + supportRequestEmailTemplate = template.Must(template.New("email").Parse(`Hello {{ .AdminUser }}, + +{{ .Name }} <{{ .Email }} from {{ .Pod }} has sent the following support request: + +> Subject: {{ .Subject }} +> +{{ .Message }} + +Kind regards, + +{{ .Pod}} Support +`)) + + reportAbuseEmailTemplate = template.Must(template.New("email").Parse(`Hello {{ .AdminUser }}, + +{{ .Name }} <{{ .Email }} from {{ .Pod }} has sent the following abuse report: + +> Category: {{ .Category }} +> +{{ .Message }} + +The offending user/feed in question is: + +- Nick: {{ .Nick }} +- URL: {{ .URL }} + +Kind regards, + +{{ .Pod }} Support +`)) +) + +type PasswordResetEmailContext struct { + Pod string + BaseURL string + + Token string + Username string +} + +type SupportRequestEmailContext struct { + Pod string + AdminUser string + + Name string + Email string + Subject string + Message string +} + +type ReportAbuseEmailContext struct { + Pod string + AdminUser string + + Nick string + URL string + + Name string + Email string + Category string + Message string +} + +// indents a block of text with an indent string +func Indent(text, indent string) string { + if text[len(text)-1:] == "\n" { + result := "" + for _, j := range strings.Split(text[:len(text)-1], "\n") { + result += indent + j + "\n" + } + return result + } + result := "" + for _, j := range strings.Split(strings.TrimRight(text, "\n"), "\n") { + result += indent + j + "\n" + } + return result[:len(result)-1] +} + +func SendEmail(conf *Config, recipients []string, replyTo, subject string, body string) error { + m := mail.NewMessage() + m.SetHeader("From", conf.SMTPFrom) + m.SetHeader("To", recipients...) + m.SetHeader("Reply-To", replyTo) + m.SetHeader("Subject", subject) + m.SetBody("text/plain", body) + + d := mail.NewDialer(conf.SMTPHost, conf.SMTPPort, conf.SMTPUser, conf.SMTPPass) + + err := d.DialAndSend(m) + if err != nil { + log.WithError(err).Error("SendEmail() failed") + return ErrSendingEmail + } + + return nil +} + +func SendPasswordResetEmail(conf *Config, user *User, email, token string) error { + recipients := []string{email} + subject := fmt.Sprintf( + "[%s]: Password Reset Request for %s", + conf.Name, user.Username, + ) + ctx := PasswordResetEmailContext{ + Pod: conf.Name, + BaseURL: conf.BaseURL, + + Token: token, + Username: user.Username, + } + + buf := &bytes.Buffer{} + if err := passwordResetEmailTemplate.Execute(buf, ctx); err != nil { + log.WithError(err).Error("error rendering email template") + return err + } + + if err := SendEmail(conf, recipients, conf.SMTPFrom, subject, buf.String()); err != nil { + log.WithError(err).Errorf("error sending new token to %s", recipients[0]) + return err + } + + return nil +} + +func SendSupportRequestEmail(conf *Config, name, email, subject, message string) error { + recipients := []string{conf.AdminEmail, email} + emailSubject := fmt.Sprintf( + "[%s Support Request]: %s", + conf.Name, subject, + ) + ctx := SupportRequestEmailContext{ + Pod: conf.Name, + AdminUser: conf.AdminUser, + + Name: name, + Email: email, + Subject: subject, + Message: Indent(message, "> "), + } + + buf := &bytes.Buffer{} + if err := supportRequestEmailTemplate.Execute(buf, ctx); err != nil { + log.WithError(err).Error("error rendering email template") + return err + } + + if err := SendEmail(conf, recipients, email, emailSubject, buf.String()); err != nil { + log.WithError(err).Errorf("error sending support request to %s", recipients[0]) + return err + } + + return nil +} + +func SendReportAbuseEmail(conf *Config, nick, url, name, email, category, message string) error { + recipients := []string{conf.AdminEmail, email} + emailSubject := fmt.Sprintf( + "[%s Report Abuse]: %s", + conf.Name, category, + ) + ctx := ReportAbuseEmailContext{ + Pod: conf.Name, + AdminUser: conf.AdminUser, + + Nick: nick, + URL: url, + + Name: name, + Email: email, + Category: category, + Message: Indent(message, "> "), + } + + buf := &bytes.Buffer{} + if err := reportAbuseEmailTemplate.Execute(buf, ctx); err != nil { + log.WithError(err).Error("error rendering email template") + return err + } + + if err := SendEmail(conf, recipients, email, emailSubject, buf.String()); err != nil { + log.WithError(err).Errorf("error sending report abuse to %s", recipients[0]) + return err + } + + return nil +} diff --git a/internal/passwd_handlers.go b/internal/passwd_handlers.go new file mode 100644 index 0000000..e9ff0cb --- /dev/null +++ b/internal/passwd_handlers.go @@ -0,0 +1,201 @@ +package internal + +import ( + "fmt" + "net/http" + "strings" + "time" + + "github.com/dgrijalva/jwt-go" + "github.com/julienschmidt/httprouter" + log "github.com/sirupsen/logrus" +) + +// ResetPasswordHandler ... +func (s *Server) ResetPasswordHandler() httprouter.Handle { + return func(w http.ResponseWriter, r *http.Request, p httprouter.Params) { + ctx := NewContext(s.config, s.db, r) + + if r.Method == "GET" { + ctx.Title = "Reset password" + s.render("resetPassword", w, ctx) + return + } + + username := NormalizeUsername(r.FormValue("username")) + email := strings.TrimSpace(r.FormValue("email")) + recovery := fmt.Sprintf("email:%s", FastHash(email)) + + if err := ValidateUsername(username); err != nil { + ctx.Error = true + ctx.Message = fmt.Sprintf("Username validation failed: %s", err.Error()) + s.render("error", w, ctx) + return + } + + // Check if user exist + if !s.db.HasUser(username) { + ctx.Error = true + ctx.Message = "User not found!" + s.render("error", w, ctx) + return + } + + // Get user object from DB + user, err := s.db.GetUser(username) + if err != nil { + ctx.Error = true + ctx.Message = "Error loading user" + s.render("error", w, ctx) + return + } + + if recovery != user.Recovery { + ctx.Error = true + ctx.Message = "Error! The email address you supplied does not match what you registered with :/" + s.render("error", w, ctx) + return + } + + // Create magic link expiry time + now := time.Now() + secs := now.Unix() + expiresAfterSeconds := int64(600) // Link expires after 10 minutes + + expiryTime := secs + expiresAfterSeconds + + // Create magic link + token := jwt.NewWithClaims( + jwt.SigningMethodHS256, + jwt.MapClaims{"username": username, "expiresAt": expiryTime}, + ) + tokenString, err := token.SignedString([]byte(s.config.MagicLinkSecret)) + if err != nil { + ctx.Error = true + ctx.Message = err.Error() + s.render("error", w, ctx) + return + } + + if err := SendPasswordResetEmail(s.config, user, email, tokenString); err != nil { + log.WithError(err).Errorf("unable to send reset password email to %s", user.Username) + ctx.Error = true + ctx.Message = err.Error() + s.render("error", w, ctx) + return + } + + log.Infof("reset password email sent for %s", user.Username) + + // Show success msg + ctx.Error = false + ctx.Message = "Password request request sent! Please check your email and follow the instructions" + s.render("error", w, ctx) + } +} + +// ResetPasswordMagicLinkHandler ... +func (s *Server) ResetPasswordMagicLinkHandler() httprouter.Handle { + return func(w http.ResponseWriter, r *http.Request, p httprouter.Params) { + ctx := NewContext(s.config, s.db, r) + + // Get token from query string + tokens, ok := r.URL.Query()["token"] + + // Check if valid token + if !ok || len(tokens[0]) < 1 { + ctx.Error = true + ctx.Message = "Invalid token" + s.render("error", w, ctx) + return + } + + tokenEmail := tokens[0] + ctx.PasswordResetToken = tokenEmail + + // Show newPassword page + s.render("newPassword", w, ctx) + } +} + +// NewPasswordHandler ... +func (s *Server) NewPasswordHandler() httprouter.Handle { + return func(w http.ResponseWriter, r *http.Request, p httprouter.Params) { + ctx := NewContext(s.config, s.db, r) + + if r.Method == "GET" { + return + } + + password := r.FormValue("password") + tokenEmail := r.FormValue("token") + + // Check if token is valid + token, err := jwt.Parse(tokenEmail, func(token *jwt.Token) (interface{}, error) { + + if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, fmt.Errorf("Unexpected signing method: %v", token.Header["alg"]) + } + + return []byte(s.config.MagicLinkSecret), nil + }) + + if claims, ok := token.Claims.(jwt.MapClaims); ok && token.Valid { + + var username = fmt.Sprintf("%v", claims["username"]) + var expiresAt int = int(claims["expiresAt"].(float64)) + + now := time.Now() + secs := now.Unix() + + // Check token expiry + if secs > int64(expiresAt) { + ctx.Error = true + ctx.Message = "Token expires" + s.render("error", w, ctx) + return + } + + user, err := s.db.GetUser(username) + if err != nil { + ctx.Error = true + ctx.Message = "Error loading user" + s.render("error", w, ctx) + return + } + + // Reset password + if password != "" { + hash, err := s.pm.CreatePassword(password) + if err != nil { + ctx.Error = true + ctx.Message = "Error loading user" + s.render("error", w, ctx) + return + } + + user.Password = hash + + // Save user + if err := s.db.SetUser(username, user); err != nil { + ctx.Error = true + ctx.Message = "Error loading user" + s.render("error", w, ctx) + return + } + } + + log.Infof("password changed: %v", user) + + // Show success msg + ctx.Error = false + ctx.Message = "Password reset successfully." + s.render("error", w, ctx) + } else { + ctx.Error = true + ctx.Message = err.Error() + s.render("error", w, ctx) + return + } + } +}