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 2020-06-19 22:03:51 -05:00 committed by GitHub
parent 0f6439de5a
commit bb7de8df3e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
36 changed files with 841 additions and 154 deletions

View File

@ -1,69 +1,103 @@
package auth package auth
import ( import (
"crypto/rand"
"errors" "errors"
"net/http" "fmt"
"io"
ctx "github.com/gophish/gophish/context"
"github.com/gophish/gophish/models"
"golang.org/x/crypto/bcrypt" "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. // ErrInvalidPassword is thrown when a user provides an incorrect password.
var ErrInvalidPassword = errors.New("Invalid Password") var ErrInvalidPassword = errors.New("Invalid Password")
// ErrPasswordMismatch is thrown when a user provides a blank password to the register // ErrPasswordMismatch is thrown when a user provides a mismatching password
// or change password functions // and confirmation password.
var ErrPasswordMismatch = errors.New("Password cannot be blank") 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 // ErrEmptyPassword is thrown when a user provides a blank password to the register
// or change password functions // or change password functions
var ErrEmptyPassword = errors.New("No password provided") var ErrEmptyPassword = errors.New("No password provided")
// Login attempts to login the user given a request. // ErrPasswordTooShort is thrown when a user provides a password that is less
func Login(r *http.Request) (bool, models.User, error) { // than MinPasswordLength
username, password := r.FormValue("username"), r.FormValue("password") var ErrPasswordTooShort = fmt.Errorf("Password must be at least %d characters", MinPasswordLength)
u, err := models.GetUserByUsername(username)
if err != nil { // GenerateSecureKey returns the hex representation of key generated from n
return false, models.User{}, err // random bytes
} func GenerateSecureKey(n int) string {
//If we've made it here, we should have a valid user stored in u k := make([]byte, n)
//Let's check the password io.ReadFull(rand.Reader, k)
err = bcrypt.CompareHashAndPassword([]byte(u.Hash), []byte(password)) return fmt.Sprintf("%x", k)
if err != nil {
return false, models.User{}, ErrInvalidPassword
}
return true, u, nil
} }
// ChangePassword verifies the current password provided in the request and, // GeneratePasswordHash returns the bcrypt hash for the provided password using
// if it's valid, changes the password for the authenticated user. // the default bcrypt cost.
func ChangePassword(r *http.Request) error { func GeneratePasswordHash(password string) (string, error) {
u := ctx.Get(r, "user").(models.User) h, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
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))
if err != nil { if err != nil {
return ErrInvalidPassword return "", err
} }
// Check that the new password isn't blank return string(h), nil
if newPassword == "" { }
// 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 return ErrEmptyPassword
} case len(password) < MinPasswordLength:
// Check that new passwords match return ErrPasswordTooShort
if newPassword != confirmPassword {
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
} }
return nil 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
}
// Make sure that the new password isn't the same as the old one
err := ValidatePassword(newPassword, currentHash)
if err == nil {
return "", ErrReusedPassword
}
// Generate the new hash
return GeneratePasswordHash(newPassword)
}

41
auth/auth_test.go Normal file
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)
}
}

View File

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

View File

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

View File

@ -6,18 +6,14 @@ import (
"net/http" "net/http"
"strconv" "strconv"
"github.com/gophish/gophish/auth"
ctx "github.com/gophish/gophish/context" ctx "github.com/gophish/gophish/context"
log "github.com/gophish/gophish/logger" log "github.com/gophish/gophish/logger"
"github.com/gophish/gophish/models" "github.com/gophish/gophish/models"
"github.com/gophish/gophish/util"
"github.com/gorilla/mux" "github.com/gorilla/mux"
"github.com/jinzhu/gorm" "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. // ErrUsernameTaken is thrown when a user attempts to register a username that is taken.
var ErrUsernameTaken = errors.New("Username already 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. // userRequest is the payload which represents the creation of a new user.
type userRequest struct { type userRequest struct {
Username string `json:"username"` Username string `json:"username"`
Password string `json:"password"` Password string `json:"password"`
Role string `json:"role"` Role string `json:"role"`
PasswordChangeRequired bool `json:"password_change_required"`
} }
func (ur *userRequest) Validate(existingUser *models.User) error { 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) JSONResponse(w, models.Response{Success: false, Message: err.Error()}, http.StatusBadRequest)
return return
} }
if ur.Password == "" { err = auth.CheckPasswordPolicy(ur.Password)
JSONResponse(w, models.Response{Success: false, Message: ErrEmptyPassword.Error()}, http.StatusBadRequest) if err != nil {
JSONResponse(w, models.Response{Success: false, Message: err.Error()}, http.StatusBadRequest)
return return
} }
hash, err := util.NewHash(ur.Password) hash, err := auth.GeneratePasswordHash(ur.Password)
if err != nil { if err != nil {
JSONResponse(w, models.Response{Success: false, Message: err.Error()}, http.StatusInternalServerError) JSONResponse(w, models.Response{Success: false, Message: err.Error()}, http.StatusInternalServerError)
return return
@ -106,7 +104,7 @@ func (as *Server) Users(w http.ResponseWriter, r *http.Request) {
user := models.User{ user := models.User{
Username: ur.Username, Username: ur.Username,
Hash: hash, Hash: hash,
ApiKey: util.GenerateSecureKey(), ApiKey: auth.GenerateSecureKey(auth.APIKeyLength),
Role: role, Role: role,
RoleID: role.ID, 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 // 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 // managing the user's account, and making a simple change like
// updating the username or role. However, if it _is_ provided, we'll // 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 // 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 // assumption here is that the API key is a proper bearer token proving
// authenticated access to the account. // authenticated access to the account.
existingUser.PasswordChangeRequired = ur.PasswordChangeRequired
if ur.Password != "" { 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 { if err != nil {
JSONResponse(w, models.Response{Success: false, Message: err.Error()}, http.StatusInternalServerError) JSONResponse(w, models.Response{Success: false, Message: err.Error()}, http.StatusInternalServerError)
return return

View File

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

View File

@ -7,6 +7,7 @@ import (
"path/filepath" "path/filepath"
"testing" "testing"
"github.com/gophish/gophish/auth"
"github.com/gophish/gophish/config" "github.com/gophish/gophish/config"
"github.com/gophish/gophish/models" "github.com/gophish/gophish/models"
) )
@ -41,6 +42,10 @@ func setupTest(t *testing.T) *testContext {
ctx.adminServer.Start() ctx.adminServer.Start()
// Get the API key to use for these tests // Get the API key to use for these tests
u, err := models.GetUser(1) 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 { if err != nil {
t.Fatalf("error getting first user from database: %v", err) t.Fatalf("error getting first user from database: %v", err)
} }

View File

@ -16,6 +16,7 @@ import (
"github.com/gophish/gophish/controllers/api" "github.com/gophish/gophish/controllers/api"
log "github.com/gophish/gophish/logger" log "github.com/gophish/gophish/logger"
mid "github.com/gophish/gophish/middleware" mid "github.com/gophish/gophish/middleware"
"github.com/gophish/gophish/middleware/ratelimit"
"github.com/gophish/gophish/models" "github.com/gophish/gophish/models"
"github.com/gophish/gophish/util" "github.com/gophish/gophish/util"
"github.com/gophish/gophish/worker" "github.com/gophish/gophish/worker"
@ -33,9 +34,10 @@ type AdminServerOption func(*AdminServer)
// AdminServer is an HTTP server that implements the administrative Gophish // AdminServer is an HTTP server that implements the administrative Gophish
// handlers, including the dashboard and REST API. // handlers, including the dashboard and REST API.
type AdminServer struct { type AdminServer struct {
server *http.Server server *http.Server
worker worker.Worker worker worker.Worker
config config.AdminServer config config.AdminServer
limiter *ratelimit.PostLimiter
} }
var defaultTLSConfig = &tls.Config{ var defaultTLSConfig = &tls.Config{
@ -74,10 +76,12 @@ func NewAdminServer(config config.AdminServer, options ...AdminServerOption) *Ad
ReadTimeout: 10 * time.Second, ReadTimeout: 10 * time.Second,
Addr: config.ListenURL, Addr: config.ListenURL,
} }
defaultLimiter := ratelimit.NewPostLimiter()
as := &AdminServer{ as := &AdminServer{
worker: defaultWorker, worker: defaultWorker,
server: defaultServer, server: defaultServer,
config: config, limiter: defaultLimiter,
config: config,
} }
for _, opt := range options { for _, opt := range options {
opt(as) opt(as)
@ -119,8 +123,9 @@ func (as *AdminServer) registerRoutes() {
router := mux.NewRouter() router := mux.NewRouter()
// Base Front-end routes // Base Front-end routes
router.HandleFunc("/", mid.Use(as.Base, mid.RequireLogin)) 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("/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", mid.Use(as.Campaigns, mid.RequireLogin))
router.HandleFunc("/campaigns/{id:[0-9]+}", mid.Use(as.CampaignID, mid.RequireLogin)) router.HandleFunc("/campaigns/{id:[0-9]+}", mid.Use(as.CampaignID, mid.RequireLogin))
router.HandleFunc("/templates", mid.Use(as.Templates, 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("/webhooks", mid.Use(as.Webhooks, mid.RequirePermission(models.PermissionModifySystem), mid.RequireLogin))
router.HandleFunc("/impersonate", mid.Use(as.Impersonate, mid.RequirePermission(models.PermissionModifySystem), mid.RequireLogin)) router.HandleFunc("/impersonate", mid.Use(as.Impersonate, mid.RequirePermission(models.PermissionModifySystem), mid.RequireLogin))
// Create the API routes // 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) router.PathPrefix("/api/").Handler(api)
// Setup static file serving // Setup static file serving
@ -141,7 +149,7 @@ func (as *AdminServer) registerRoutes() {
// Setup CSRF Protection // Setup CSRF Protection
csrfKey := []byte(as.config.CSRFKey) csrfKey := []byte(as.config.CSRFKey)
if len(csrfKey) == 0 { if len(csrfKey) == 0 {
csrfKey = []byte(util.GenerateSecureKey()) csrfKey = []byte(auth.GenerateSecureKey(auth.APIKeyLength))
} }
csrfHandler := csrf.Protect(csrfKey, csrfHandler := csrf.Protect(csrfKey,
csrf.FieldName("csrf_token"), csrf.FieldName("csrf_token"),
@ -171,12 +179,14 @@ type templateParams struct {
// the CSRF token. // the CSRF token.
func newTemplateParams(r *http.Request) templateParams { func newTemplateParams(r *http.Request) templateParams {
user := ctx.Get(r, "user").(models.User) user := ctx.Get(r, "user").(models.User)
session := ctx.Get(r, "session").(*sessions.Session)
modifySystem, _ := user.HasPermission(models.PermissionModifySystem) modifySystem, _ := user.HasPermission(models.PermissionModifySystem)
return templateParams{ return templateParams{
Token: csrf.Token(r), Token: csrf.Token(r),
User: user, User: user,
ModifySystem: modifySystem, ModifySystem: modifySystem,
Version: config.Version, Version: config.Version,
Flashes: session.Flashes(),
} }
} }
@ -235,22 +245,37 @@ func (as *AdminServer) Settings(w http.ResponseWriter, r *http.Request) {
case r.Method == "GET": case r.Method == "GET":
params := newTemplateParams(r) params := newTemplateParams(r)
params.Title = "Settings" params.Title = "Settings"
session := ctx.Get(r, "session").(*sessions.Session)
session.Save(r, w)
getTemplate(w, "settings").ExecuteTemplate(w, "base", params) getTemplate(w, "settings").ExecuteTemplate(w, "base", params)
case r.Method == "POST": 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"} msg := models.Response{Success: true, Message: "Settings Updated Successfully"}
if err == auth.ErrInvalidPassword {
msg.Message = "Invalid Password"
msg.Success = false
api.JSONResponse(w, msg, http.StatusBadRequest)
return
}
if err != nil { if err != nil {
msg.Message = err.Error() msg.Message = err.Error()
msg.Success = false msg.Success = false
api.JSONResponse(w, msg, http.StatusBadRequest) api.JSONResponse(w, msg, http.StatusBadRequest)
return 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) 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) 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 // Webhooks is an admin-only handler that handles webhooks
func (as *AdminServer) Webhooks(w http.ResponseWriter, r *http.Request) { func (as *AdminServer) Webhooks(w http.ResponseWriter, r *http.Request) {
params := newTemplateParams(r) 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) template.Must(templates, err).ExecuteTemplate(w, "base", params)
case r.Method == "POST": case r.Method == "POST":
//Attempt to login // Find the user with the provided username
succ, u, err := auth.Login(r) username, password := r.FormValue("username"), r.FormValue("password")
u, err := models.GetUserByUsername(username)
if err != nil { if err != nil {
log.Error(err) log.Error(err)
as.handleInvalidLogin(w, r)
return
} }
//If we've logged in, save the session and redirect to the dashboard // Validate the user's password
if succ { err = auth.ValidatePassword(password, u.Hash)
session.Values["id"] = u.Id if err != nil {
session.Save(r, w) log.Error(err)
next := "/" as.handleInvalidLogin(w, r)
url, err := url.Parse(r.FormValue("next")) return
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)
} }
// 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) 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 { func getTemplate(w http.ResponseWriter, tmpl string) *template.Template {
templates := template.New("template") templates := template.New("template")
_, err := templates.ParseFiles("templates/base.html", "templates/nav.html", "templates/"+tmpl+".html", "templates/flashes.html") _, err := templates.ParseFiles("templates/base.html", "templates/nav.html", "templates/"+tmpl+".html", "templates/flashes.html")

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

View File

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

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
go.mod
View File

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

2
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.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs= 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/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= 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= 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= gopkg.in/alecthomas/kingpin.v2 v2.2.6 h1:jMFz6MfLP0/4fUyZle81rXUoxOBFi19VUFKVDOQfozc=

View File

@ -114,6 +114,15 @@ func RequireAPIKey(handler http.Handler) http.Handler {
func RequireLogin(handler http.Handler) http.HandlerFunc { func RequireLogin(handler http.Handler) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
if u := ctx.Get(r, "user"); u != nil { 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) handler.ServeHTTP(w, r)
return return
} }

View File

@ -162,3 +162,22 @@ func TestBearerToken(t *testing.T) {
t.Fatalf("incorrect status code received. expected %d got %d", expected, got) 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)
}
}

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

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)
})
}

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)
}

View File

@ -12,7 +12,9 @@ import (
"bitbucket.org/liamstask/goose/lib/goose" "bitbucket.org/liamstask/goose/lib/goose"
mysql "github.com/go-sql-driver/mysql" mysql "github.com/go-sql-driver/mysql"
"github.com/gophish/gophish/auth"
"github.com/gophish/gophish/config" "github.com/gophish/gophish/config"
log "github.com/gophish/gophish/logger" log "github.com/gophish/gophish/logger"
"github.com/jinzhu/gorm" "github.com/jinzhu/gorm"
_ "github.com/mattn/go-sqlite3" // Blank import needed to import sqlite3 _ "github.com/mattn/go-sqlite3" // Blank import needed to import sqlite3
@ -23,6 +25,9 @@ var conf *config.Config
const MaxDatabaseConnectionAttempts int = 10 const MaxDatabaseConnectionAttempts int = 10
// DefaultAdminUsername is the default username for the administrative user
const DefaultAdminUsername = "admin"
const ( const (
CampaignInProgress string = "In progress" CampaignInProgress string = "In progress"
CampaignQueued string = "Queued" CampaignQueued string = "Queued"
@ -82,8 +87,33 @@ func chooseDBDriver(name, openStr string) goose.DBDriver {
return d return d
} }
// Setup initializes the Conn object func createTemporaryPassword(u *User) error {
// It also populates the Gophish Config object // 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 { func Setup(c *config.Config) error {
// Setup the package-scoped config // Setup the package-scoped config
conf = c conf = c
@ -153,6 +183,7 @@ func Setup(c *config.Config) error {
} }
// Create the admin user if it doesn't exist // Create the admin user if it doesn't exist
var userCount int64 var userCount int64
var adminUser User
db.Model(&User{}).Count(&userCount) db.Model(&User{}).Count(&userCount)
adminRole, err := GetRoleBySlug(RoleAdmin) adminRole, err := GetRoleBySlug(RoleAdmin)
if err != nil { if err != nil {
@ -160,14 +191,38 @@ func Setup(c *config.Config) error {
return err return err
} }
if userCount == 0 { if userCount == 0 {
initUser := User{ adminUser := User{
Username: "admin", Username: DefaultAdminUsername,
Hash: "$2a$10$IYkPp0.QsM81lYYPrQx6W.U6oQGw7wMpozrKhKAHUBVL4mkm/EvAS", //gophish Role: adminRole,
Role: adminRole, RoleID: adminRole.ID,
RoleID: adminRole.ID, PasswordChangeRequired: true,
} }
initUser.ApiKey = generateSecureKey() adminUser.ApiKey = auth.GenerateSecureKey(auth.APIKeyLength)
err = db.Save(&initUser).Error 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 { if err != nil {
log.Error(err) log.Error(err)
return err return err

View File

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

View File

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

File diff suppressed because one or more lines are too long

12
static/css/main.css vendored
View File

@ -729,4 +729,16 @@ table.dataTable {
.cke_autocomplete_panel>li { .cke_autocomplete_panel>li {
padding: 10px 5px !important; 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
static/js/dist/app/passwords.min.js vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

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,'"'))})})})}});

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)

View File

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

View File

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

View File

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

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 }}

View File

@ -4,6 +4,7 @@
<h1 class="page-header">Settings</h1> <h1 class="page-header">Settings</h1>
</div> </div>
<div id="flashes" class="row"></div> <div id="flashes" class="row"></div>
{{template "flashes" .Flashes}}
<!-- Nav tabs --> <!-- Nav tabs -->
<ul class="nav nav-tabs" role="tablist"> <ul class="nav nav-tabs" role="tablist">
<li class="active" role="mainSettings"><a href="#mainSettings" aria-controls="mainSettings" role="tab" <li class="active" role="mainSettings"><a href="#mainSettings" aria-controls="mainSettings" role="tab"
@ -58,8 +59,15 @@
<div class="row"> <div class="row">
<label for="new_password" class="col-sm-2 control-label form-label">New Password:</label> <label for="new_password" class="col-sm-2 control-label form-label">New Password:</label>
<div class="col-md-6"> <div class="col-md-6">
<input type="password" id="new_password" name="new_password" autocomplete="off" <input type="password" id="password" name="new_password" autocomplete="new-password"
class="form-control" /> class="form-control" />
<div class="hidden" 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>
</div> </div>
</div> </div>
<br /> <br />
@ -225,5 +233,6 @@
</div> </div>
</div> </div>
{{end}} {{define "scripts"}} {{end}} {{define "scripts"}}
<script src="/js/dist/app/passwords.min.js"></script>
<script src="/js/dist/app/settings.min.js"></script> <script src="/js/dist/app/settings.min.js"></script>
{{end}} {{end}}

View File

@ -5,7 +5,9 @@
{{.Title}} {{.Title}}
</h1> </h1>
</div> </div>
<div id="flashes" class="row"></div> <div id="flashes" class="row">
{{template "flashes" .Flashes}}
</div>
<div class="row"> <div class="row">
<button type="button" class="btn btn-primary" id="new_button" data-toggle="modal" data-backdrop="static" <button type="button" class="btn btn-primary" id="new_button" data-toggle="modal" data-backdrop="static"
data-user-id="-1" data-target="#modal"> data-user-id="-1" data-target="#modal">
@ -47,13 +49,23 @@
</div> </div>
<label class="control-label" for="password">Password:</label> <label class="control-label" for="password">Password:</label>
<div class="form-group"> <div class="form-group">
<input type="password" class="form-control" placeholder="Password" id="password" required /> <input type="password" class="form-control" autocomplete="new-password" placeholder="Password" id="password" required />
<div class="hidden" 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>
</div> </div>
<label class="control-label" for="confirm_password">Confirm Password:</label> <label class="control-label" for="confirm_password">Confirm Password:</label>
<div class="form-group"> <div class="form-group">
<input type="password" class="form-control" placeholder="Confirm Password" id="confirm_password" <input type="password" class="form-control" placeholder="Confirm Password" id="confirm_password"
required /> required />
</div> </div>
<div class="checkbox checkbox-primary">
<input id="force_password_change_checkbox" type="checkbox" checked>
<label for="force_password_change_checkbox">Require the user to set a new password</label>
</div>
<label class="control-label" for="role">Role:</label> <label class="control-label" for="role">Role:</label>
<div class="form-group" id="role-select"> <div class="form-group" id="role-select">
<select class="form-control" placeholder="" id="role" /> <select class="form-control" placeholder="" id="role" />
@ -70,5 +82,6 @@
</div> </div>
</div> </div>
{{end}} {{define "scripts"}} {{end}} {{define "scripts"}}
<script src="/js/dist/app/passwords.min.js"></script>
<script src="/js/dist/app/users.min.js"></script> <script src="/js/dist/app/users.min.js"></script>
{{end}} {{end}}

View File

@ -21,7 +21,6 @@ import (
log "github.com/gophish/gophish/logger" log "github.com/gophish/gophish/logger"
"github.com/gophish/gophish/models" "github.com/gophish/gophish/models"
"github.com/jordan-wright/email" "github.com/jordan-wright/email"
"golang.org/x/crypto/bcrypt"
) )
var ( var (
@ -194,21 +193,3 @@ func CheckAndCreateSSL(cp string, kp string) error {
log.Info("TLS Certificate Generation complete") log.Info("TLS Certificate Generation complete")
return nil return nil
} }
// GenerateSecureKey creates a secure key to use as an API key
func GenerateSecureKey() string {
// Inspired from gorilla/securecookie
k := make([]byte, 32)
io.ReadFull(rand.Reader, k)
return fmt.Sprintf("%x", k)
}
// NewHash hashes the provided password and returns the bcrypt hash (using the
// default 10 rounds) as a string.
func NewHash(pass string) (string, error) {
hash, err := bcrypt.GenerateFromPassword([]byte(pass), bcrypt.DefaultCost)
if err != nil {
return "", err
}
return string(hash), nil
}

View File

@ -3,6 +3,7 @@ const path = require('path');
module.exports = { module.exports = {
context: path.resolve(__dirname, 'static', 'js', 'src', 'app'), context: path.resolve(__dirname, 'static', 'js', 'src', 'app'),
entry: { entry: {
passwords: './passwords',
users: './users', users: './users',
webhooks: './webhooks', webhooks: './webhooks',
}, },

View File

@ -5242,3 +5242,8 @@ yargs@^7.1.0:
which-module "^1.0.0" which-module "^1.0.0"
y18n "^3.2.1" y18n "^3.2.1"
yargs-parser "^5.0.0" yargs-parser "^5.0.0"
zxcvbn@^4.4.2:
version "4.4.2"
resolved "https://registry.yarnpkg.com/zxcvbn/-/zxcvbn-4.4.2.tgz#28ec17cf09743edcab056ddd8b1b06262cc73c30"
integrity sha1-KOwXzwl0PtyrBW3dixsGJizHPDA=