Browse Source

Initial Implementation of a Password Policy (#1867)

This PR adds the initial work to implement a password policy as defined in #1538.

Specifically, this implements the following

* Rate limiting for the login handler
* Implementing the ability for system admins to require a user to reset their password
* Implementing a password policy that requires passwords to be a minimum of 8 characters
* Removes the default password (gophish) for admin users to instead have the password randomly generated when Gophish first starts up
* Adds a password strength meter when choosing a new password

Fixes #1538
pull/1883/head
Jordan Wright 10 months ago
committed by GitHub
parent
commit
bb7de8df3e
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
36 changed files with 834 additions and 147 deletions
  1. +76
    -42
      auth/auth.go
  2. +41
    -0
      auth/auth_test.go
  3. +2
    -2
      controllers/api/reset.go
  4. +11
    -1
      controllers/api/server.go
  5. +19
    -14
      controllers/api/user.go
  6. +1
    -1
      controllers/api/user_test.go
  7. +5
    -0
      controllers/controllers_test.go
  8. +148
    -39
      controllers/route.go
  9. +9
    -0
      db/db_mysql/migrations/20200619000000_0.11.0_password_policy.sql
  10. +1
    -1
      db/db_sqlite3/migrations/20160118194630_init.sql
  11. +9
    -0
      db/db_sqlite3/migrations/20200619000000_0.11.0_password_policy.sql
  12. +1
    -0
      go.mod
  13. +2
    -0
      go.sum
  14. +9
    -0
      middleware/middleware.go
  15. +19
    -0
      middleware/middleware_test.go
  16. +15
    -0
      middleware/ratelimit/doc.go
  17. +145
    -0
      middleware/ratelimit/ratelimit.go
  18. +59
    -0
      middleware/ratelimit/ratelimit_test.go
  19. +64
    -9
      models/models.go
  20. +7
    -6
      models/user.go
  21. +3
    -0
      package.json
  22. +1
    -1
      static/css/dist/gophish.css
  23. +12
    -0
      static/css/main.css
  24. +1
    -0
      static/js/dist/app/passwords.min.js
  25. +1
    -1
      static/js/dist/app/users.min.js
  26. +1
    -1
      static/js/dist/app/webhooks.min.js
  27. +54
    -0
      static/js/src/app/passwords.js
  28. +8
    -6
      static/js/src/app/users.js
  29. +2
    -1
      templates/dashboard.html
  30. +2
    -0
      templates/flashes.html
  31. +75
    -0
      templates/reset_password.html
  32. +10
    -1
      templates/settings.html
  33. +15
    -2
      templates/users.html
  34. +0
    -19
      util/util.go
  35. +1
    -0
      webpack.config.js
  36. +5
    -0
      yarn.lock

+ 76
- 42
auth/auth.go View File

@ -1,69 +1,103 @@
package auth
import (
"crypto/rand"
"errors"
"net/http"
"fmt"
"io"
ctx "github.com/gophish/gophish/context"
"github.com/gophish/gophish/models"
"golang.org/x/crypto/bcrypt"
)
// MinPasswordLength is the minimum number of characters required in a password
const MinPasswordLength = 8
// APIKeyLength is the length of Gophish API keys
const APIKeyLength = 32
// ErrInvalidPassword is thrown when a user provides an incorrect password.
var ErrInvalidPassword = errors.New("Invalid Password")
// ErrPasswordMismatch is thrown when a user provides a blank password to the register
// or change password functions
var ErrPasswordMismatch = errors.New("Password cannot be blank")
// ErrPasswordMismatch is thrown when a user provides a mismatching password
// and confirmation password.
var ErrPasswordMismatch = errors.New("Passwords do not match")
// ErrReusedPassword is thrown when a user attempts to change their password to
// the existing password
var ErrReusedPassword = errors.New("Cannot reuse existing password")
// ErrEmptyPassword is thrown when a user provides a blank password to the register
// or change password functions
var ErrEmptyPassword = errors.New("No password provided")
// Login attempts to login the user given a request.
func Login(r *http.Request) (bool, models.User, error) {
username, password := r.FormValue("username"), r.FormValue("password")
u, err := models.GetUserByUsername(username)
if err != nil {
return false, models.User{}, err
}
//If we've made it here, we should have a valid user stored in u
//Let's check the password
err = bcrypt.CompareHashAndPassword([]byte(u.Hash), []byte(password))
if err != nil {
return false, models.User{}, ErrInvalidPassword
}
return true, u, nil
// ErrPasswordTooShort is thrown when a user provides a password that is less
// than MinPasswordLength
var ErrPasswordTooShort = fmt.Errorf("Password must be at least %d characters", MinPasswordLength)
// GenerateSecureKey returns the hex representation of key generated from n
// random bytes
func GenerateSecureKey(n int) string {
k := make([]byte, n)
io.ReadFull(rand.Reader, k)
return fmt.Sprintf("%x", k)
}
// ChangePassword verifies the current password provided in the request and,
// if it's valid, changes the password for the authenticated user.
func ChangePassword(r *http.Request) error {
u := ctx.Get(r, "user").(models.User)
currentPw := r.FormValue("current_password")
newPassword := r.FormValue("new_password")
confirmPassword := r.FormValue("confirm_new_password")
// Check the current password
err := bcrypt.CompareHashAndPassword([]byte(u.Hash), []byte(currentPw))
// GeneratePasswordHash returns the bcrypt hash for the provided password using
// the default bcrypt cost.
func GeneratePasswordHash(password string) (string, error) {
h, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return ErrInvalidPassword
return "", err
}
// Check that the new password isn't blank
if newPassword == "" {
return string(h), nil
}
// CheckPasswordPolicy ensures the provided password is valid according to our
// password policy.
//
// The current password policy is simply a minimum of 8 characters, though this
// may change in the future (see #1538).
func CheckPasswordPolicy(password string) error {
switch {
// Admittedly, empty passwords are a subset of too short passwords, but it
// helps to provide a more specific error message
case password == "":
return ErrEmptyPassword
case len(password) < MinPasswordLength:
return ErrPasswordTooShort
}
return nil
}
// ValidatePassword validates that the provided password matches the provided
// bcrypt hash.
func ValidatePassword(password string, hash string) error {
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
}
// ValidatePasswordChange validates that the new password matches the
// configured password policy, that the new password and confirmation
// password match.
//
// Note that this assumes the current password has been confirmed by the
// caller.
//
// If all of the provided data is valid, then the hash of the new password is
// returned.
func ValidatePasswordChange(currentHash, newPassword, confirmPassword string) (string, error) {
// Ensure the new password passes our password policy
if err := CheckPasswordPolicy(newPassword); err != nil {
return "", err
}
// Check that new passwords match
if newPassword != confirmPassword {
return ErrPasswordMismatch
return "", ErrPasswordMismatch
}
// Generate the new hash
h, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
if err != nil {
return err
}
u.Hash = string(h)
if err = models.PutUser(&u); err != nil {
return err
// Make sure that the new password isn't the same as the old one
err := ValidatePassword(newPassword, currentHash)
if err == nil {
return "", ErrReusedPassword
}
return nil
// Generate the new hash
return GeneratePasswordHash(newPassword)
}

+ 41
- 0
auth/auth_test.go View File

@ -0,0 +1,41 @@
package auth
import (
"testing"
)
func TestPasswordPolicy(t *testing.T) {
candidate := "short"
got := CheckPasswordPolicy(candidate)
if got != ErrPasswordTooShort {
t.Fatalf("unexpected error received. expected %v got %v", ErrPasswordTooShort, got)
}
candidate = "valid password"
got = CheckPasswordPolicy(candidate)
if got != nil {
t.Fatalf("unexpected error received. expected %v got %v", nil, got)
}
}
func TestValidatePasswordChange(t *testing.T) {
newPassword := "valid password"
confirmPassword := "invalid"
currentPassword := "current password"
currentHash, err := GeneratePasswordHash(currentPassword)
if err != nil {
t.Fatalf("unexpected error generating password hash: %v", err)
}
_, got := ValidatePasswordChange(currentHash, newPassword, confirmPassword)
if got != ErrPasswordMismatch {
t.Fatalf("unexpected error received. expected %v got %v", ErrPasswordMismatch, got)
}
newPassword = currentPassword
confirmPassword = newPassword
_, got = ValidatePasswordChange(currentHash, newPassword, confirmPassword)
if got != ErrReusedPassword {
t.Fatalf("unexpected error received. expected %v got %v", ErrReusedPassword, got)
}
}

+ 2
- 2
controllers/api/reset.go View File

@ -3,9 +3,9 @@ package api
import (
"net/http"
"github.com/gophish/gophish/auth"
ctx "github.com/gophish/gophish/context"
"github.com/gophish/gophish/models"
"github.com/gophish/gophish/util"
)
// Reset (/api/reset) resets the currently authenticated user's API key
@ -13,7 +13,7 @@ func (as *Server) Reset(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == "POST":
u := ctx.Get(r, "user").(models.User)
u.ApiKey = util.GenerateSecureKey()
u.ApiKey = auth.GenerateSecureKey(auth.APIKeyLength)
err := models.PutUser(&u)
if err != nil {
http.Error(w, "Error setting API Key", http.StatusInternalServerError)

+ 11
- 1
controllers/api/server.go View File

@ -4,6 +4,7 @@ import (
"net/http"
mid "github.com/gophish/gophish/middleware"
"github.com/gophish/gophish/middleware/ratelimit"
"github.com/gophish/gophish/models"
"github.com/gophish/gophish/worker"
"github.com/gorilla/mux"
@ -19,14 +20,17 @@ type ServerOption func(*Server)
type Server struct {
handler http.Handler
worker worker.Worker
limiter *ratelimit.PostLimiter
}
// NewServer returns a new instance of the API handler with the provided
// options applied.
func NewServer(options ...ServerOption) *Server {
defaultWorker, _ := worker.New()
defaultLimiter := ratelimit.NewPostLimiter()
as := &Server{
worker: defaultWorker,
worker: defaultWorker,
limiter: defaultLimiter,
}
for _, opt := range options {
opt(as)
@ -42,6 +46,12 @@ func WithWorker(w worker.Worker) ServerOption {
}
}
func WithLimiter(limiter *ratelimit.PostLimiter) ServerOption {
return func(as *Server) {
as.limiter = limiter
}
}
func (as *Server) registerRoutes() {
root := mux.NewRouter()
root = root.StrictSlash(true)

+ 19
- 14
controllers/api/user.go View File

@ -6,18 +6,14 @@ import (
"net/http"
"strconv"
"github.com/gophish/gophish/auth"
ctx "github.com/gophish/gophish/context"
log "github.com/gophish/gophish/logger"
"github.com/gophish/gophish/models"
"github.com/gophish/gophish/util"
"github.com/gorilla/mux"
"github.com/jinzhu/gorm"
)
// ErrEmptyPassword is thrown when a user provides a blank password to the register
// or change password functions
var ErrEmptyPassword = errors.New("No password provided")
// ErrUsernameTaken is thrown when a user attempts to register a username that is taken.
var ErrUsernameTaken = errors.New("Username already taken")
@ -33,9 +29,10 @@ var ErrInsufficientPermission = errors.New("Permission denied")
// userRequest is the payload which represents the creation of a new user.
type userRequest struct {
Username string `json:"username"`
Password string `json:"password"`
Role string `json:"role"`
Username string `json:"username"`
Password string `json:"password"`
Role string `json:"role"`
PasswordChangeRequired bool `json:"password_change_required"`
}
func (ur *userRequest) Validate(existingUser *models.User) error {
@ -89,11 +86,12 @@ func (as *Server) Users(w http.ResponseWriter, r *http.Request) {
JSONResponse(w, models.Response{Success: false, Message: err.Error()}, http.StatusBadRequest)
return
}
if ur.Password == "" {
JSONResponse(w, models.Response{Success: false, Message: ErrEmptyPassword.Error()}, http.StatusBadRequest)
err = auth.CheckPasswordPolicy(ur.Password)
if err != nil {
JSONResponse(w, models.Response{Success: false, Message: err.Error()}, http.StatusBadRequest)
return
}
hash, err := util.NewHash(ur.Password)
hash, err := auth.GeneratePasswordHash(ur.Password)
if err != nil {
JSONResponse(w, models.Response{Success: false, Message: err.Error()}, http.StatusInternalServerError)
return
@ -106,7 +104,7 @@ func (as *Server) Users(w http.ResponseWriter, r *http.Request) {
user := models.User{
Username: ur.Username,
Hash: hash,
ApiKey: util.GenerateSecureKey(),
ApiKey: auth.GenerateSecureKey(auth.APIKeyLength),
Role: role,
RoleID: role.ID,
}
@ -195,13 +193,20 @@ func (as *Server) User(w http.ResponseWriter, r *http.Request) {
// We don't force the password to be provided, since it may be an admin
// managing the user's account, and making a simple change like
// updating the username or role. However, if it _is_ provided, we'll
// update the stored hash.
// update the stored hash after validating the new password meets our
// password policy.
//
// Note that we don't force the current password to be provided. The
// assumption here is that the API key is a proper bearer token proving
// authenticated access to the account.
existingUser.PasswordChangeRequired = ur.PasswordChangeRequired
if ur.Password != "" {
hash, err := util.NewHash(ur.Password)
err = auth.CheckPasswordPolicy(ur.Password)
if err != nil {
JSONResponse(w, models.Response{Success: false, Message: err.Error()}, http.StatusBadRequest)
return
}
hash, err := auth.GeneratePasswordHash(ur.Password)
if err != nil {
JSONResponse(w, models.Response{Success: false, Message: err.Error()}, http.StatusInternalServerError)
return

+ 1
- 1
controllers/api/user_test.go View File

@ -66,7 +66,7 @@ func TestCreateUser(t *testing.T) {
testCtx := setupTest(t)
payload := &userRequest{
Username: "foo",
Password: "bar",
Password: "validpassword",
Role: models.RoleUser,
}
body, err := json.Marshal(payload)

+ 5
- 0
controllers/controllers_test.go View File

@ -7,6 +7,7 @@ import (
"path/filepath"
"testing"
"github.com/gophish/gophish/auth"
"github.com/gophish/gophish/config"
"github.com/gophish/gophish/models"
)
@ -41,6 +42,10 @@ func setupTest(t *testing.T) *testContext {
ctx.adminServer.Start()
// Get the API key to use for these tests
u, err := models.GetUser(1)
// Reset the temporary password for the admin user to a value we control
hash, err := auth.GeneratePasswordHash("gophish")
u.Hash = hash
models.PutUser(&u)
if err != nil {
t.Fatalf("error getting first user from database: %v", err)
}

+ 148
- 39
controllers/route.go View File

@ -16,6 +16,7 @@ import (
"github.com/gophish/gophish/controllers/api"
log "github.com/gophish/gophish/logger"
mid "github.com/gophish/gophish/middleware"
"github.com/gophish/gophish/middleware/ratelimit"
"github.com/gophish/gophish/models"
"github.com/gophish/gophish/util"
"github.com/gophish/gophish/worker"
@ -33,9 +34,10 @@ type AdminServerOption func(*AdminServer)
// AdminServer is an HTTP server that implements the administrative Gophish
// handlers, including the dashboard and REST API.
type AdminServer struct {
server *http.Server
worker worker.Worker
config config.AdminServer
server *http.Server
worker worker.Worker
config config.AdminServer
limiter *ratelimit.PostLimiter
}
var defaultTLSConfig = &tls.Config{
@ -74,10 +76,12 @@ func NewAdminServer(config config.AdminServer, options ...AdminServerOption) *Ad
ReadTimeout: 10 * time.Second,
Addr: config.ListenURL,
}
defaultLimiter := ratelimit.NewPostLimiter()
as := &AdminServer{
worker: defaultWorker,
server: defaultServer,
config: config,
worker: defaultWorker,
server: defaultServer,
limiter: defaultLimiter,
config: config,
}
for _, opt := range options {
opt(as)
@ -119,8 +123,9 @@ func (as *AdminServer) registerRoutes() {
router := mux.NewRouter()
// Base Front-end routes
router.HandleFunc("/", mid.Use(as.Base, mid.RequireLogin))
router.HandleFunc("/login", as.Login)
router.HandleFunc("/login", mid.Use(as.Login, as.limiter.Limit))
router.HandleFunc("/logout", mid.Use(as.Logout, mid.RequireLogin))
router.HandleFunc("/reset_password", mid.Use(as.ResetPassword, mid.RequireLogin))
router.HandleFunc("/campaigns", mid.Use(as.Campaigns, mid.RequireLogin))
router.HandleFunc("/campaigns/{id:[0-9]+}", mid.Use(as.CampaignID, mid.RequireLogin))
router.HandleFunc("/templates", mid.Use(as.Templates, mid.RequireLogin))
@ -132,7 +137,10 @@ func (as *AdminServer) registerRoutes() {
router.HandleFunc("/webhooks", mid.Use(as.Webhooks, mid.RequirePermission(models.PermissionModifySystem), mid.RequireLogin))
router.HandleFunc("/impersonate", mid.Use(as.Impersonate, mid.RequirePermission(models.PermissionModifySystem), mid.RequireLogin))
// Create the API routes
api := api.NewServer(api.WithWorker(as.worker))
api := api.NewServer(
api.WithWorker(as.worker),
api.WithLimiter(as.limiter),
)
router.PathPrefix("/api/").Handler(api)
// Setup static file serving
@ -141,7 +149,7 @@ func (as *AdminServer) registerRoutes() {
// Setup CSRF Protection
csrfKey := []byte(as.config.CSRFKey)
if len(csrfKey) == 0 {
csrfKey = []byte(util.GenerateSecureKey())
csrfKey = []byte(auth.GenerateSecureKey(auth.APIKeyLength))
}
csrfHandler := csrf.Protect(csrfKey,
csrf.FieldName("csrf_token"),
@ -171,12 +179,14 @@ type templateParams struct {
// the CSRF token.
func newTemplateParams(r *http.Request) templateParams {
user := ctx.Get(r, "user").(models.User)
session := ctx.Get(r, "session").(*sessions.Session)
modifySystem, _ := user.HasPermission(models.PermissionModifySystem)
return templateParams{
Token: csrf.Token(r),
User: user,
ModifySystem: modifySystem,
Version: config.Version,
Flashes: session.Flashes(),
}
}
@ -235,22 +245,37 @@ func (as *AdminServer) Settings(w http.ResponseWriter, r *http.Request) {
case r.Method == "GET":
params := newTemplateParams(r)
params.Title = "Settings"
session := ctx.Get(r, "session").(*sessions.Session)
session.Save(r, w)
getTemplate(w, "settings").ExecuteTemplate(w, "base", params)
case r.Method == "POST":
err := auth.ChangePassword(r)
u := ctx.Get(r, "user").(models.User)
currentPw := r.FormValue("current_password")
newPassword := r.FormValue("new_password")
confirmPassword := r.FormValue("confirm_new_password")
// Check the current password
err := auth.ValidatePassword(currentPw, u.Hash)
msg := models.Response{Success: true, Message: "Settings Updated Successfully"}
if err == auth.ErrInvalidPassword {
msg.Message = "Invalid Password"
if err != nil {
msg.Message = err.Error()
msg.Success = false
api.JSONResponse(w, msg, http.StatusBadRequest)
return
}
newHash, err := auth.ValidatePasswordChange(u.Hash, newPassword, confirmPassword)
if err != nil {
msg.Message = err.Error()
msg.Success = false
api.JSONResponse(w, msg, http.StatusBadRequest)
return
}
u.Hash = string(newHash)
if err = models.PutUser(&u); err != nil {
msg.Message = err.Error()
msg.Success = false
api.JSONResponse(w, msg, http.StatusInternalServerError)
return
}
api.JSONResponse(w, msg, http.StatusOK)
}
}
@ -263,6 +288,39 @@ func (as *AdminServer) UserManagement(w http.ResponseWriter, r *http.Request) {
getTemplate(w, "users").ExecuteTemplate(w, "base", params)
}
func (as *AdminServer) nextOrIndex(w http.ResponseWriter, r *http.Request) {
next := "/"
url, err := url.Parse(r.FormValue("next"))
if err == nil {
path := url.Path
if path != "" {
next = path
}
}
http.Redirect(w, r, next, 302)
}
func (as *AdminServer) handleInvalidLogin(w http.ResponseWriter, r *http.Request) {
session := ctx.Get(r, "session").(*sessions.Session)
Flash(w, r, "danger", "Invalid Username/Password")
params := struct {
User models.User
Title string
Flashes []interface{}
Token string
}{Title: "Login", Token: csrf.Token(r)}
params.Flashes = session.Flashes()
session.Save(r, w)
templates := template.New("template")
_, err := templates.ParseFiles("templates/login.html", "templates/flashes.html")
if err != nil {
log.Error(err)
}
// w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusUnauthorized)
template.Must(templates, err).ExecuteTemplate(w, "base", params)
}
// Webhooks is an admin-only handler that handles webhooks
func (as *AdminServer) Webhooks(w http.ResponseWriter, r *http.Request) {
params := newTemplateParams(r)
@ -309,37 +367,25 @@ func (as *AdminServer) Login(w http.ResponseWriter, r *http.Request) {
}
template.Must(templates, err).ExecuteTemplate(w, "base", params)
case r.Method == "POST":
//Attempt to login
succ, u, err := auth.Login(r)
// Find the user with the provided username
username, password := r.FormValue("username"), r.FormValue("password")
u, err := models.GetUserByUsername(username)
if err != nil {
log.Error(err)
as.handleInvalidLogin(w, r)
return
}
//If we've logged in, save the session and redirect to the dashboard
if succ {
session.Values["id"] = u.Id
session.Save(r, w)
next := "/"
url, err := url.Parse(r.FormValue("next"))
if err == nil {
path := url.Path
if path != "" {
next = path
}
}
http.Redirect(w, r, next, http.StatusFound)
} else {
Flash(w, r, "danger", "Invalid Username/Password")
params.Flashes = session.Flashes()
session.Save(r, w)
templates := template.New("template")
_, err := templates.ParseFiles("templates/login.html", "templates/flashes.html")
if err != nil {
log.Error(err)
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusUnauthorized)
template.Must(templates, err).ExecuteTemplate(w, "base", params)
// Validate the user's password
err = auth.ValidatePassword(password, u.Hash)
if err != nil {
log.Error(err)
as.handleInvalidLogin(w, r)
return
}
// If we've logged in, save the session and redirect to the dashboard
session.Values["id"] = u.Id
session.Save(r, w)
as.nextOrIndex(w, r)
}
}
@ -352,6 +398,69 @@ func (as *AdminServer) Logout(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/login", http.StatusFound)
}
// ResetPassword handles the password reset flow when a password change is
// required either by the Gophish system or an administrator.
//
// This handler is meant to be used when a user is required to reset their
// password, not just when they want to.
//
// This is an important distinction since in this handler we don't require
// the user to re-enter their current password, as opposed to the flow
// through the settings handler.
//
// To that end, if the user doesn't require a password change, we will
// redirect them to the settings page.
func (as *AdminServer) ResetPassword(w http.ResponseWriter, r *http.Request) {
u := ctx.Get(r, "user").(models.User)
session := ctx.Get(r, "session").(*sessions.Session)
if !u.PasswordChangeRequired {
Flash(w, r, "info", "Please reset your password through the settings page")
session.Save(r, w)
http.Redirect(w, r, "/settings", http.StatusTemporaryRedirect)
return
}
params := newTemplateParams(r)
params.Title = "Reset Password"
switch {
case r.Method == http.MethodGet:
params.Flashes = session.Flashes()
session.Save(r, w)
getTemplate(w, "reset_password").ExecuteTemplate(w, "base", params)
return
case r.Method == http.MethodPost:
newPassword := r.FormValue("password")
confirmPassword := r.FormValue("confirm_password")
newHash, err := auth.ValidatePasswordChange(u.Hash, newPassword, confirmPassword)
if err != nil {
Flash(w, r, "danger", err.Error())
params.Flashes = session.Flashes()
session.Save(r, w)
w.WriteHeader(http.StatusBadRequest)
getTemplate(w, "reset_password").ExecuteTemplate(w, "base", params)
return
}
u.PasswordChangeRequired = false
u.Hash = newHash
if err = models.PutUser(&u); err != nil {
Flash(w, r, "danger", err.Error())
params.Flashes = session.Flashes()
session.Save(r, w)
w.WriteHeader(http.StatusInternalServerError)
getTemplate(w, "reset_password").ExecuteTemplate(w, "base", params)
return
}
// TODO: We probably want to flash a message here that the password was
// changed successfully. The problem is that when the user resets their
// password on first use, they will see two flashes on the dashboard-
// one for their password reset, and one for the "no campaigns created".
//
// The solution to this is to revamp the empty page to be more useful,
// like a wizard or something.
as.nextOrIndex(w, r)
}
}
// TODO: Make this execute the template, too
func getTemplate(w http.ResponseWriter, tmpl string) *template.Template {
templates := template.New("template")
_, err := templates.ParseFiles("templates/base.html", "templates/nav.html", "templates/"+tmpl+".html", "templates/flashes.html")

+ 9
- 0
db/db_mysql/migrations/20200619000000_0.11.0_password_policy.sql View File

@ -0,0 +1,9 @@
-- +goose Up
-- SQL in section 'Up' is executed when this migration is applied
ALTER TABLE `users` ADD COLUMN password_change_required BOOLEAN;
-- +goose Down
-- SQL section 'Down' is executed when this migration is rolled back

+ 1
- 1
db/db_sqlite3/migrations/20160118194630_init.sql View File

@ -25,4 +25,4 @@ DROP TABLE "results";
DROP TABLE "smtp";
DROP TABLE "targets";
DROP TABLE "templates";
DROP TABLE "users";
DROP TABLE "users";

+ 9
- 0
db/db_sqlite3/migrations/20200619000000_0.11.0_password_policy.sql View File

@ -0,0 +1,9 @@
-- +goose Up
-- SQL in section 'Up' is executed when this migration is applied
ALTER TABLE users ADD COLUMN password_change_required BOOLEAN;
-- +goose Down
-- SQL section 'Down' is executed when this migration is rolled back

+ 1
- 0
go.mod View File

@ -27,6 +27,7 @@ require (
github.com/sirupsen/logrus v1.4.2
github.com/ziutek/mymysql v1.5.4 // indirect
golang.org/x/crypto v0.0.0-20200128174031-69ecbb4d6d5d
golang.org/x/time v0.0.0-20200416051211-89c76fbcd5d1
gopkg.in/alecthomas/kingpin.v2 v2.2.6
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405

+ 2
- 0
go.sum View File

@ -100,6 +100,8 @@ golang.org/x/sys v0.0.0-20191224085550-c709ea063b76/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/time v0.0.0-20200416051211-89c76fbcd5d1 h1:NusfzzA6yGQ+ua51ck7E3omNUX/JuqbFSaRGqU8CcLI=
golang.org/x/time v0.0.0-20200416051211-89c76fbcd5d1/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
gopkg.in/alecthomas/kingpin.v2 v2.2.6 h1:jMFz6MfLP0/4fUyZle81rXUoxOBFi19VUFKVDOQfozc=

+ 9
- 0
middleware/middleware.go View File

@ -114,6 +114,15 @@ func RequireAPIKey(handler http.Handler) http.Handler {
func RequireLogin(handler http.Handler) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if u := ctx.Get(r, "user"); u != nil {
// If a password change is required for the user, then redirect them
// to the login page
currentUser := u.(models.User)
if currentUser.PasswordChangeRequired && r.URL.Path != "/reset_password" {
q := r.URL.Query()
q.Set("next", r.URL.Path)
http.Redirect(w, r, fmt.Sprintf("/reset_password?%s", q.Encode()), http.StatusTemporaryRedirect)
return
}
handler.ServeHTTP(w, r)
return
}

+ 19
- 0
middleware/middleware_test.go View File

@ -162,3 +162,22 @@ func TestBearerToken(t *testing.T) {
t.Fatalf("incorrect status code received. expected %d got %d", expected, got)
}
}
func TestPasswordResetRequired(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/", nil)
req = ctx.Set(req, "user", models.User{
PasswordChangeRequired: true,
})
response := httptest.NewRecorder()
RequireLogin(successHandler).ServeHTTP(response, req)
gotStatus := response.Code
expectedStatus := http.StatusTemporaryRedirect
if gotStatus != expectedStatus {
t.Fatalf("incorrect status code received. expected %d got %d", expectedStatus, gotStatus)
}
expectedLocation := "/reset_password?next=%2F"
gotLocation := response.Header().Get("Location")
if gotLocation != expectedLocation {
t.Fatalf("incorrect location header received. expected %s got %s", expectedLocation, gotLocation)
}
}

+ 15
- 0
middleware/ratelimit/doc.go View File

@ -0,0 +1,15 @@
// Package ratelimit provides a simple token-bucket rate limiting middleware
// which only allows n POST requests every minute. This is meant to be used on
// login handlers or other sensitive transactions which should be throttled to
// prevent abuse.
//
// Tracked clients are stored in a locked map, with a goroutine that runs at a
// configurable interval to clean up stale entries.
//
// Note that there is no enforcement for GET requests. This is an effort to be
// opinionated in order to hit the most common use-cases. For more advanced
// use-cases, you may consider the `github.com/didip/tollbooth` package.
//
// The enforcement mechanism is based on the blog post here:
// https://www.alexedwards.net/blog/how-to-rate-limit-http-requests
package ratelimit

+ 145
- 0
middleware/ratelimit/ratelimit.go View File

@ -0,0 +1,145 @@
package ratelimit
import (
"net"
"net/http"
"sync"
"time"
log "github.com/gophish/gophish/logger"
"golang.org/x/time/rate"
)
// DefaultRequestsPerMinute is the number of requests to allow per minute.
// Any requests over this interval will return a HTTP 429 error.
const DefaultRequestsPerMinute = 5
// DefaultCleanupInterval determines how frequently the cleanup routine
// executes.
const DefaultCleanupInterval = 1 * time.Minute
// DefaultExpiry is the amount of time to track a bucket for a particular
// visitor.
const DefaultExpiry = 10 * time.Minute
type bucket struct {
limiter *rate.Limiter
lastSeen time.Time
}
// PostLimiter is a simple rate limiting middleware which only allows n POST
// requests per minute.
type PostLimiter struct {
visitors map[string]*bucket
requestLimit int
cleanupInterval time.Duration
expiry time.Duration
sync.RWMutex
}
// PostLimiterOption is a functional option that allows callers to configure
// the rate limiter.
type PostLimiterOption func(*PostLimiter)
// WithRequestsPerMinute sets the number of requests to allow per minute.
func WithRequestsPerMinute(requestLimit int) PostLimiterOption {
return func(p *PostLimiter) {
p.requestLimit = requestLimit
}
}
// WithCleanupInterval sets the interval between cleaning up stale entries in
// the rate limit client list
func WithCleanupInterval(interval time.Duration) PostLimiterOption {
return func(p *PostLimiter) {
p.cleanupInterval = interval
}
}
// WithExpiry sets the amount of time to store client entries before they are
// considered stale.
func WithExpiry(expiry time.Duration) PostLimiterOption {
return func(p *PostLimiter) {
p.expiry = expiry
}
}
// NewPostLimiter returns a new instance of a PostLimiter
func NewPostLimiter(opts ...PostLimiterOption) *PostLimiter {
limiter := &PostLimiter{
visitors: make(map[string]*bucket),
requestLimit: DefaultRequestsPerMinute,
cleanupInterval: DefaultCleanupInterval,
expiry: DefaultExpiry,
}
for _, opt := range opts {
opt(limiter)
}
go limiter.pollCleanup()
return limiter
}
func (limiter *PostLimiter) pollCleanup() {
ticker := time.NewTicker(time.Duration(limiter.cleanupInterval) * time.Second)
for range ticker.C {
limiter.Cleanup()
}
}
// Cleanup removes any buckets that were last seen past the configured expiry.
func (limiter *PostLimiter) Cleanup() {
limiter.Lock()
defer limiter.Unlock()
for ip, bucket := range limiter.visitors {
if time.Now().Sub(bucket.lastSeen) >= limiter.expiry {
delete(limiter.visitors, ip)
}
}
}
func (limiter *PostLimiter) addBucket(ip string) *bucket {
limiter.Lock()
defer limiter.Unlock()
limit := rate.NewLimiter(rate.Every(time.Minute/time.Duration(limiter.requestLimit)), limiter.requestLimit)
b := &bucket{
limiter: limit,
}
limiter.visitors[ip] = b
return b
}
func (limiter *PostLimiter) allow(ip string) bool {
// Check if we have a limiter already active for this clientIP
limiter.RLock()
bucket, exists := limiter.visitors[ip]
limiter.RUnlock()
if !exists {
bucket = limiter.addBucket(ip)
}
// Update the lastSeen for this bucket to assist with cleanup
limiter.Lock()
defer limiter.Unlock()
bucket.lastSeen = time.Now()
return bucket.limiter.Allow()
}
// Limit enforces the configured rate limit for POST requests.
//
// TODO: Change the return value to an http.Handler when we clean up the
// way Gophish routing is done.
func (limiter *PostLimiter) Limit(next http.Handler) http.HandlerFunc {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
clientIP, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
log.Errorf("Unable to determine client IP address: %v", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
if r.Method == http.MethodPost && !limiter.allow(clientIP) {
log.Error("")
http.Error(w, http.StatusText(http.StatusTooManyRequests), http.StatusTooManyRequests)
return
}
next.ServeHTTP(w, r)
})
}

+ 59
- 0
middleware/ratelimit/ratelimit_test.go View File

@ -0,0 +1,59 @@
package ratelimit
import (
"net/http"
"net/http/httptest"
"testing"
)
var successHandler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("ok"))
})
func reachLimit(t *testing.T, handler http.Handler, limit int) {
// Make `expected` requests and ensure that each return a successful
// response.
r := httptest.NewRequest(http.MethodPost, "/", nil)
r.RemoteAddr = "127.0.0.1:"
for i := 0; i < limit; i++ {
w := httptest.NewRecorder()
handler.ServeHTTP(w, r)
if w.Code != http.StatusOK {
t.Fatalf("no 200 on req %d got %d", i, w.Code)
}
}
// Then, makes another request to ensure it returns the 429
// status.
w := httptest.NewRecorder()
handler.ServeHTTP(w, r)
if w.Code != http.StatusTooManyRequests {
t.Fatalf("no 429")
}
}
func TestRateLimitEnforcement(t *testing.T) {
expectedLimit := 3
limiter := NewPostLimiter(WithRequestsPerMinute(expectedLimit))
handler := limiter.Limit(successHandler)
reachLimit(t, handler, expectedLimit)
}
func TestRateLimitCleanup(t *testing.T) {
expectedLimit := 3
limiter := NewPostLimiter(WithRequestsPerMinute(expectedLimit))
handler := limiter.Limit(successHandler)
reachLimit(t, handler, expectedLimit)
// Set the timeout to be
bucket, exists := limiter.visitors["127.0.0.1"]
if !exists {
t.Fatalf("doesn't exist for some reason")
}
bucket.lastSeen = bucket.lastSeen.Add(-limiter.expiry)
limiter.Cleanup()
_, exists = limiter.visitors["127.0.0.1"]
if exists {
t.Fatalf("exists for some reason")
}
reachLimit(t, handler, expectedLimit)
}

+ 64
- 9
models/models.go View File

@ -12,7 +12,9 @@ import (
"bitbucket.org/liamstask/goose/lib/goose"
mysql "github.com/go-sql-driver/mysql"
"github.com/gophish/gophish/auth"
"github.com/gophish/gophish/config"
log "github.com/gophish/gophish/logger"
"github.com/jinzhu/gorm"
_ "github.com/mattn/go-sqlite3" // Blank import needed to import sqlite3
@ -23,6 +25,9 @@ var conf *config.Config
const MaxDatabaseConnectionAttempts int = 10
// DefaultAdminUsername is the default username for the administrative user
const DefaultAdminUsername = "admin"
const (
CampaignInProgress string = "In progress"
CampaignQueued string = "Queued"
@ -82,8 +87,33 @@ func chooseDBDriver(name, openStr string) goose.DBDriver {
return d
}
// Setup initializes the Conn object
// It also populates the Gophish Config object
func createTemporaryPassword(u *User) error {
// This will result in a 16 character password which could be viewed as an
// inconvenience, but it should be ok for now.
temporaryPassword := auth.GenerateSecureKey(auth.MinPasswordLength)
hash, err := auth.GeneratePasswordHash(temporaryPassword)
if err != nil {
return err
}
u.Hash = hash
// Anytime a temporary password is created, we will force the user
// to change their password
u.PasswordChangeRequired = true
err = db.Save(u).Error
if err != nil {
return err
}
log.Infof("Please login with the username admin and the password %s", temporaryPassword)
return nil
}
// Setup initializes the database and runs any needed migrations.
//
// First, it establishes a connection to the database, then runs any migrations
// newer than the version the database is on.
//
// Once the database is up-to-date, we create an admin user (if needed) that
// has a randomly generated API key and password.
func Setup(c *config.Config) error {
// Setup the package-scoped config
conf = c
@ -153,6 +183,7 @@ func Setup(c *config.Config) error {
}
// Create the admin user if it doesn't exist
var userCount int64
var adminUser User
db.Model(&User{}).Count(&userCount)
adminRole, err := GetRoleBySlug(RoleAdmin)
if err != nil {
@ -160,14 +191,38 @@ func Setup(c *config.Config) error {
return err
}
if userCount == 0 {
initUser := User{
Username: "admin",
Hash: "$2a$10$IYkPp0.QsM81lYYPrQx6W.U6oQGw7wMpozrKhKAHUBVL4mkm/EvAS", //gophish
Role: adminRole,
RoleID: adminRole.ID,
adminUser := User{
Username: DefaultAdminUsername,
Role: adminRole,
RoleID: adminRole.ID,
PasswordChangeRequired: true,
}
initUser.ApiKey = generateSecureKey()
err = db.Save(&initUser).Error
adminUser.ApiKey = auth.GenerateSecureKey(auth.APIKeyLength)
err = db.Save(&adminUser).Error
if err != nil {
log.Error(err)
return err
}
}
// If this is the first time the user is installing Gophish, then we will
// generate a temporary password for the admin user.
//
// We do this here instead of in the block above where the admin is created
// since there's the chance the user executes Gophish and has some kind of
// error, then tries restarting it. If they didn't grab the password out of
// the logs, then they would have lost it.
//
// By doing the temporary password here, we will regenerate that temporary
// password until the user is able to reset the admin password.
if adminUser.Username == "" {
adminUser, err = GetUserByUsername(DefaultAdminUsername)
if err != nil {
log.Error(err)
return err
}
}
if adminUser.PasswordChangeRequired {
err = createTemporaryPassword(&adminUser)
if err != nil {
log.Error(err)
return err

+ 7
- 6
models/user.go View File

@ -13,12 +13,13 @@ var ErrModifyingOnlyAdmin = errors.New("Cannot remove the only administrator")
// User represents the user model for gophish.
type User struct {
Id int64 `json:"id"`
Username string `json:"username" sql:"not null;unique"`
Hash string `json:"-"`
ApiKey string `json:"api_key" sql:"not null;unique"`
Role Role `json:"role" gorm:"association_autoupdate:false;association_autocreate:false"`
RoleID int64 `json:"-"`
Id int64 `json:"id"`
Username string `json:"username" sql:"not null;unique"`
Hash string `json:"-"`
ApiKey string `json:"api_key" sql:"not null;unique"`
Role Role `json:"role" gorm:"association_autoupdate:false;association_autocreate:false"`
RoleID int64 `json:"-"`
PasswordChangeRequired bool `json:"password_change_required"`
}
// GetUser returns the user that the given id corresponds to. If no user is found, an

+ 3
- 0
package.json View File

@ -29,5 +29,8 @@
"jshint-stylish": "^2.2.1",
"webpack": "^4.32.2",
"webpack-cli": "^3.3.2"
},
"dependencies": {
"zxcvbn": "^4.4.2"
}
}

+ 1
- 1
static/css/dist/gophish.css
File diff suppressed because it is too large
View File


+ 12
- 0
static/css/main.css View File

@ -729,4 +729,16 @@ table.dataTable {
.cke_autocomplete_panel>li {
padding: 10px 5px !important;
}
#password-strength {
margin-top: 20px;
margin-bottom: 0px;
height: 8px;
}
#password-strength-description {
font-size: 12px;
}
#password-strength-container {
height: 40px;
}

+ 1
- 0
static/js/dist/app/passwords.min.js
File diff suppressed because it is too large
View File


+ 1
- 1
static/js/dist/app/users.min.js
File diff suppressed because it is too large
View File


+ 1
- 1
static/js/dist/app/webhooks.min.js View File

@ -1 +1 @@
!function(e){var t={};function n(o){if(t[o])return t[o].exports;var a=t[o]={i:o,l:!1,exports:{}};return e[o].call(a.exports,a,a.exports,n),a.l=!0,a.exports}n.m=e,n.c=t,n.d=function(e,t,o){n.o(e,t)||Object.defineProperty(e,t,{enumerable:!0,get:o})},n.r=function(e){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},n.t=function(e,t){if(1&t&&(e=n(e)),8&t)return e;if(4&t&&"object"==typeof e&&e&&e.__esModule)return e;var o=Object.create(null);if(n.r(o),Object.defineProperty(o,"default",{enumerable:!0,value:e}),2&t&&"string"!=typeof e)for(var a in e)n.d(o,a,function(t){return e[t]}.bind(null,a));return o},n.n=function(e){var t=e&&e.__esModule?function(){return e.default}:function(){return e};return n.d(t,"a",t),t},n.o=function(e,t){return Object.prototype.hasOwnProperty.call(e,t)},n.p="",n(n.s=1)}([,function(e,t){var n=[],o=function(){$("#name").val(""),$("#url").val(""),$("#secret").val(""),$("#is_active").prop("checked",!1),$("#flashes").empty()},a=function(){$("#webhookTable").hide(),$("#loading").show(),api.webhooks.get().success(function(e){n=e,$("#loading").hide(),$("#webhookTable").show();var t=$("#webhookTable").DataTable({destroy:!0,columnDefs:[{orderable:!1,targets:"no-sort"}]});t.clear(),$.each(n,function(e,n){t.row.add([escapeHtml(n.name),escapeHtml(n.url),escapeHtml(n.is_active),'\n <div class="pull-right">\n <button class="btn btn-primary ping_button" data-webhook-id="'.concat(n.id,'">\n Ping\n </button>\n <button class="btn btn-primary edit_button" data-toggle="modal" data-backdrop="static" data-target="#modal" data-webhook-id="').concat(n.id,'">\n <i class="fa fa-pencil"></i>\n </button>\n <button class="btn btn-danger delete_button" data-webhook-id="').concat(n.id,'">\n <i class="fa fa-trash-o"></i>\n </button>\n </div>\n ')]).draw()})}).error(function(){errorFlash("Error fetching webhooks")})},c=function(e){$("#modalSubmit").unbind("click").click(function(){!function(e){var t={name:$("#name").val(),url:$("#url").val(),secret:$("#secret").val(),is_active:$("#is_active").is(":checked")};-1!=e?(t.id=parseInt(e),api.webhookId.put(t).success(function(e){o(),a(),$("#modal").modal("hide"),successFlash('Webhook "'.concat(escapeHtml(t.name),'" has been updated successfully!'))}).error(function(e){modalError(e.responseJSON.message)})):api.webhooks.post(t).success(function(e){a(),o(),$("#modal").modal("hide"),successFlash('Webhook "'.concat(escapeHtml(t.name),'" has been created successfully!'))}).error(function(e){modalError(e.responseJSON.message)})}(e)}),-1!==e&&api.webhookId.get(e).success(function(e){$("#name").val(e.name),$("#url").val(e.url),$("#secret").val(e.secret),$("#is_active").prop("checked",e.is_active)}).error(function(){errorFlash("Error fetching webhook")})};$(document).ready(function(){a(),$("#modal").on("hide.bs.modal",function(){o()}),$("#new_button").on("click",function(){c(-1)}),$("#webhookTable").on("click",".edit_button",function(e){c($(this).attr("data-webhook-id"))}),$("#webhookTable").on("click",".delete_button",function(e){var t,o;t=$(this).attr("data-webhook-id"),(o=n.find(function(e){return e.id==t}))&&Swal.fire({title:"Are you sure?",text:"This will delete the webhook '".concat(escapeHtml(o.name),"'"),type:"warning",animation:!1,showCancelButton:!0,confirmButtonText:"Delete",confirmButtonColor:"#428bca",reverseButtons:!0,allowOutsideClick:!1,preConfirm:function(){return new Promise(function(e,n){api.webhookId.delete(t).success(function(t){e()}).error(function(e){n(e.responseJSON.message)})}).catch(function(e){Swal.showValidationMessage(e)})}}).then(function(e){e.value&&Swal.fire("Webhook Deleted!","The webhook has been deleted!","success"),$("button:contains('OK')").on("click",function(){location.reload()})})}),$("#webhookTable").on("click",".ping_button",function(e){var t,a;t=e.currentTarget,a=e.currentTarget.dataset.webhookId,o(),t.disabled=!0,api.webhookId.ping(a).success(function(e){t.disabled=!1,successFlash('Ping of "'.concat(escapeHtml(e.name),'" webhook succeeded.'))}).error(function(e){t.disabled=!1;var o=n.find(function(e){return e.id==a});o&&errorFlash('Ping of "'.concat(escapeHtml(o.name),'" webhook failed: "').concat(e.responseJSON.message,'"'))})})})}]);
!function(e){var t={};function n(o){if(t[o])return t[o].exports;var a=t[o]={i:o,l:!1,exports:{}};return e[o].call(a.exports,a,a.exports,n),a.l=!0,a.exports}n.m=e,n.c=t,n.d=function(e,t,o){n.o(e,t)||Object.defineProperty(e,t,{enumerable:!0,get:o})},n.r=function(e){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},n.t=function(e,t){if(1&t&&(e=n(e)),8&t)return e;if(4&t&&"object"==typeof e&&e&&e.__esModule)return e;var o=Object.create(null);if(n.r(o),Object.defineProperty(o,"default",{enumerable:!0,value:e}),2&t&&"string"!=typeof e)for(var a in e)n.d(o,a,function(t){return e[t]}.bind(null,a));return o},n.n=function(e){var t=e&&e.__esModule?function(){return e.default}:function(){return e};return n.d(t,"a",t),t},n.o=function(e,t){return Object.prototype.hasOwnProperty.call(e,t)},n.p="",n(n.s=9)}({9:function(e,t){var n=[],o=function(){$("#name").val(""),$("#url").val(""),$("#secret").val(""),$("#is_active").prop("checked",!1),$("#flashes").empty()},a=function(){$("#webhookTable").hide(),$("#loading").show(),api.webhooks.get().success(function(e){n=e,$("#loading").hide(),$("#webhookTable").show();var t=$("#webhookTable").DataTable({destroy:!0,columnDefs:[{orderable:!1,targets:"no-sort"}]});t.clear(),$.each(n,function(e,n){t.row.add([escapeHtml(n.name),escapeHtml(n.url),escapeHtml(n.is_active),'\n <div class="pull-right">\n <button class="btn btn-primary ping_button" data-webhook-id="'.concat(n.id,'">\n Ping\n </button>\n <button class="btn btn-primary edit_button" data-toggle="modal" data-backdrop="static" data-target="#modal" data-webhook-id="').concat(n.id,'">\n <i class="fa fa-pencil"></i>\n </button>\n <button class="btn btn-danger delete_button" data-webhook-id="').concat(n.id,'">\n <i class="fa fa-trash-o"></i>\n </button>\n </div>\n ')]).draw()})}).error(function(){errorFlash("Error fetching webhooks")})},c=function(e){$("#modalSubmit").unbind("click").click(function(){!function(e){var t={name:$("#name").val(),url:$("#url").val(),secret:$("#secret").val(),is_active:$("#is_active").is(":checked")};-1!=e?(t.id=parseInt(e),api.webhookId.put(t).success(function(e){o(),a(),$("#modal").modal("hide"),successFlash('Webhook "'.concat(escapeHtml(t.name),'" has been updated successfully!'))}).error(function(e){modalError(e.responseJSON.message)})):api.webhooks.post(t).success(function(e){a(),o(),$("#modal").modal("hide"),successFlash('Webhook "'.concat(escapeHtml(t.name),'" has been created successfully!'))}).error(function(e){modalError(e.responseJSON.message)})}(e)}),-1!==e&&api.webhookId.get(e).success(function(e){$("#name").val(e.name),$("#url").val(e.url),$("#secret").val(e.secret),$("#is_active").prop("checked",e.is_active)}).error(function(){errorFlash("Error fetching webhook")})};$(document).ready(function(){a(),$("#modal").on("hide.bs.modal",function(){o()}),$("#new_button").on("click",function(){c(-1)}),$("#webhookTable").on("click",".edit_button",function(e){c($(this).attr("data-webhook-id"))}),$("#webhookTable").on("click",".delete_button",function(e){var t,o;t=$(this).attr("data-webhook-id"),(o=n.find(function(e){return e.id==t}))&&Swal.fire({title:"Are you sure?",text:"This will delete the webhook '".concat(escapeHtml(o.name),"'"),type:"warning",animation:!1,showCancelButton:!0,confirmButtonText:"Delete",confirmButtonColor:"#428bca",reverseButtons:!0,allowOutsideClick:!1,preConfirm:function(){return new Promise(function(e,n){api.webhookId.delete(t).success(function(t){e()}).error(function(e){n(e.responseJSON.message)})}).catch(function(e){Swal.showValidationMessage(e)})}}).then(function(e){e.value&&Swal.fire("Webhook Deleted!","The webhook has been deleted!","success"),$("button:contains('OK')").on("click",function(){location.reload()})})}),$("#webhookTable").on("click",".ping_button",function(e){var t,a;t=e.currentTarget,a=e.currentTarget.dataset.webhookId,o(),t.disabled=!0,api.webhookId.ping(a).success(function(e){t.disabled=!1,successFlash('Ping of "'.concat(escapeHtml(e.name),'" webhook succeeded.'))}).error(function(e){t.disabled=!1;var o=n.find(function(e){return e.id==a});o&&errorFlash('Ping of "'.concat(escapeHtml(o.name),'" webhook failed: "').concat(e.responseJSON.message,'"'))})})})}});

+ 54
- 0
static/js/src/app/passwords.js View File

@ -0,0 +1,54 @@
import zxcvbn from 'zxcvbn';
const StrengthMapping = {
0: {
class: 'danger',
width: '10%',
status: 'Very Weak'
},
1: {
class: 'danger',
width: '25%',
status: 'Very Weak'
},
2: {
class: 'warning',
width: '50%',
status: 'Weak'
},
3: {
class: 'success',
width: '75%',
status: 'Good'
},
4: {
class: 'success',
width: '100%',
status: 'Very Good'
}
}
const Progress = document.getElementById("password-strength-container")
const ProgressBar = document.getElementById("password-strength-bar")
const StrengthDescription = document.getElementById("password-strength-description")
const updatePasswordStrength = (e) => {
const candidate = e.target.value
// If there is no password, clear out the progress bar
if (!candidate) {
ProgressBar.style.width = 0
StrengthDescription.textContent = ""
Progress.classList.add("hidden")
return
}
const score = zxcvbn(candidate).score
const evaluation = StrengthMapping[score]
// Update the progress bar
ProgressBar.classList = `progress-bar progress-bar-${evaluation.class}`
ProgressBar.style.width = evaluation.width
StrengthDescription.textContent = evaluation.status
StrengthDescription.classList = `text-${evaluation.class}`
Progress.classList.remove("hidden")
}
document.getElementById("password").addEventListener("input", updatePasswordStrength)

+ 8
- 6
static/js/src/app/users.js View File

@ -10,7 +10,8 @@ const save = (id) => {
let user = {
username: $("#username").val(),
password: $("#password").val(),
role: $("#role").val()
role: $("#role").val(),
password_change_required: $("#force_password_change_checkbox").prop('checked')
}
// Submit the user
if (id != -1) {
@ -18,26 +19,26 @@ const save = (id) => {
// we need to PUT /user/:id
user.id = id
api.userId.put(user)
.success(function (data) {
.success((data) => {
successFlash("User " + escapeHtml(user.username) + " updated successfully!")
load()
dismiss()
$("#modal").modal('hide')
})
.error(function (data) {
.error((data) => {
modalError(data.responseJSON.message)
})
} else {
// Else, if this is a new user, POST it
// to /user
api.users.post(user)
.success(function (data) {
.success((data) => {
successFlash("User " + escapeHtml(user.username) + " registered successfully!")
load()
dismiss()
$("#modal").modal('hide')
})
.error(function (data) {
.error((data) => {
modalError(data.responseJSON.message)
})
}
@ -61,10 +62,11 @@ const edit = (id) => {
$("#role").trigger("change")
} else {
api.userId.get(id)
.success(function (user) {
.success((user) => {
$("#username").val(user.username)
$("#role").val(user.role.slug)
$("#role").trigger("change")
$("#force_password_change_checkbox").prop('checked', false)
})
.error(function () {
errorFlash("Error fetching user")

+ 2
- 1
templates/dashboard.html View File

@ -6,7 +6,8 @@
<div id="loading">
<i class="fa fa-spinner fa-spin fa-4x"></i>
</div>
<div id="emptyMessage" class="row" style="display:none;">
{{template "flashes" .Flashes}}
<div id="emptyMessage" style="display:none;">
<div class="alert alert-info">
No campaigns created yet. Let's create one!
</div>

+ 2
- 0
templates/flashes.html View File

@ -8,6 +8,8 @@
fa-exclamation-triangle
{{else if eq .Type "success"}}
fa-check-circle
{{else if eq .Type "info"}}
fa-info-circle
{{end}}"></i>
{{.Message}}
</div>

+ 75
- 0
templates/reset_password.html View File

@ -0,0 +1,75 @@
{{ define "base" }}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="Gophish - Open-Source Phishing Toolkit">
<meta name="author" content="Jordan Wright (http://github.com/jordan-wright)">
<link rel="shortcut icon" href="../../docs-assets/ico/favicon.png">
<title>Gophish - {{ .Title }}</title>
<link href="/css/dist/gophish.css" rel="stylesheet">
<link href='https://fonts.googleapis.com/css?family=Source+Sans+Pro:400,300,600,700' rel='stylesheet'
type='text/css'>
</head>
<body>
<div class="navbar navbar-inverse navbar-fixed-top" role="navigation">
<div class="container-fluid">
<div class="navbar-header">
<button type="button" class="navbar-toggle" data-toggle="collapse" data-target=".navbar-collapse">
<span class="sr-only">Toggle navigation</span>
<span class="icon-bar"></span>
<span class="icon-bar"></span>
<span class="icon-bar"></span>
</button>
<img class="navbar-logo" src="/images/logo_inv_small.png" />
<a class="navbar-brand" href="/">&nbsp;gophish</a>
</div>
<div class="navbar-collapse collapse">
<ul class="nav navbar-nav navbar-right">
<li>
<div class="btn-group" id="navbar-dropdown">
<a class="btn btn-primary" href="/logout"><i class="fa fa-user"></i> {{.User.Username}}</a>
<a class="btn btn-primary dropdown-toggle" href="/logout">
<i class="fa fa-sign-out"></i>
</a>
</div>
</a>
</li>
</ul>
</div>
</div>
</div>
<div class="container">
<form class="form-signin" action="" method="POST">
<img id="logo" src="/images/logo_purple.png" />
<h2 class="form-signin-heading">Reset Your Password</h2>
{{template "flashes" .Flashes}}
<input type="password" id="password" name="password" class="form-control" placeholder="Password"
autocomplete="off" minlength="8" required autofocus>
<div class="" id="password-strength-container">
<div class="progress" id="password-strength">
<div id="password-strength-bar" class="progress-bar" role="progressbar" aria-valuenow="0"
aria-valuemin="0" aria-valuemax="100"></div>
</div>
<span id="password-strength-description"></span>
</div>
<input type="password" name="confirm_password" class="form-control" placeholder="Confirm Password"
autocomplete="off" minlength="8" required>
<input type="hidden" name="csrf_token" value="{{.Token}}" />
<br />
<button class="btn btn-lg btn-primary btn-block" type="submit">Save Password</button>
</form>
</div>
<!-- Placed at the end of the document so the pages load faster -->
<script src="/js/dist/app/passwords.min.js"></script>
<script src="/js/dist/vendor.min.js"></script>
</body>
</html>
{{ end }}

+ 10
- 1
templates/settings.html View File

@ -4,6 +4,7 @@
<h1 class="page-header">Settings</h1>
</div>
<div id="flashes" class="row"></div>
{{template "flashes" .Flashes}}
<!-- Nav tabs -->
<ul class="nav nav-tabs" role="tablist">
<li class="active" role="mainSettings">