Browse Source

Implement User Management API (#1473)

This implements the first pass for a user management API allowing users with the `ModifySystem` permission to create, modify, and delete users. In addition to this, any user is able to use the API to view or modify their own account information.
1257-lets-encrypt
Jordan Wright 1 year ago
committed by GitHub
parent
commit
84096b8724
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
32 changed files with 3595 additions and 532 deletions
  1. +3
    -0
      .babelrc
  2. +5
    -78
      auth/auth.go
  3. +11
    -0
      controllers/api/api_test.go
  4. +2
    -2
      controllers/api/reset.go
  5. +3
    -0
      controllers/api/server.go
  6. +218
    -0
      controllers/api/user.go
  7. +188
    -0
      controllers/api/user_test.go
  8. +23
    -60
      controllers/route.go
  9. +2
    -2
      gophish.go
  10. +15
    -3
      gulpfile.js
  11. +15
    -6
      middleware/middleware.go
  12. +23
    -0
      middleware/session.go
  13. +1
    -1
      models/rbac.go
  14. +118
    -1
      models/user.go
  15. +41
    -0
      models/user_test.go
  16. +7
    -1
      package.json
  17. +1
    -1
      static/css/dist/gophish.css
  18. +4
    -0
      static/css/main.css
  19. +1
    -1
      static/js/dist/app/gophish.min.js
  20. +1
    -0
      static/js/dist/app/groups.min.js
  21. +1
    -1
      static/js/dist/app/users.min.js
  22. +28
    -0
      static/js/src/app/gophish.js
  23. +284
    -0
      static/js/src/app/groups.js
  24. +97
    -203
      static/js/src/app/users.js
  25. +106
    -0
      templates/groups.html
  26. +7
    -2
      templates/nav.html
  27. +0
    -62
      templates/register.html
  28. +12
    -14
      templates/settings.html
  29. +24
    -56
      templates/users.html
  30. +19
    -0
      util/util.go
  31. +21
    -0
      webpack.config.js
  32. +2314
    -38
      yarn.lock

+ 3
- 0
.babelrc View File

@ -0,0 +1,3 @@
{
"presets": ["@babel/preset-env"]
}

+ 5
- 78
auth/auth.go View File

@ -1,49 +1,24 @@
package auth
import (
"encoding/gob"
"errors"
"fmt"
"io"
"net/http"
"crypto/rand"
ctx "github.com/gophish/gophish/context"
log "github.com/gophish/gophish/logger"
"github.com/gophish/gophish/models"
"github.com/gorilla/securecookie"
"github.com/gorilla/sessions"
"github.com/jinzhu/gorm"
"golang.org/x/crypto/bcrypt"
)
//init registers the necessary models to be saved in the session later
func init() {
gob.Register(&models.User{})
gob.Register(&models.Flash{})
Store.Options.HttpOnly = true
// This sets the maxAge to 5 days for all cookies
Store.MaxAge(86400 * 5)
}
// Store contains the session information for the request
var Store = sessions.NewCookieStore(
[]byte(securecookie.GenerateRandomKey(64)), //Signing key
[]byte(securecookie.GenerateRandomKey(32)))
// ErrInvalidPassword is thrown when a user provides an incorrect password.
var ErrInvalidPassword = errors.New("Invalid Password")
// ErrEmptyPassword is thrown when a user provides a blank password to the register
// ErrPasswordMismatch is thrown when a user provides a blank password to the register
// or change password functions
var ErrEmptyPassword = errors.New("Password cannot be blank")
// ErrPasswordMismatch is thrown when a user provides passwords that do not match
var ErrPasswordMismatch = errors.New("Passwords must match")
var ErrPasswordMismatch = errors.New("Password cannot be blank")
// ErrUsernameTaken is thrown when a user attempts to register a username that is taken.
var ErrUsernameTaken = errors.New("Username already taken")
// 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) {
@ -61,54 +36,6 @@ func Login(r *http.Request) (bool, models.User, error) {
return true, u, nil
}
// Register attempts to register the user given a request.
func Register(r *http.Request) (bool, error) {
username := r.FormValue("username")
newPassword := r.FormValue("password")
confirmPassword := r.FormValue("confirm_password")
u, err := models.GetUserByUsername(username)
// If the given username already exists, throw an error and return false
if err == nil {
return false, ErrUsernameTaken
}
// If we have an error which is not simply indicating that no user was found, report it
if err != nil && err != gorm.ErrRecordNotFound {
log.Warn(err)
return false, err
}
u = models.User{}
// If we've made it here, we should have a valid username given
// Check that the passsword isn't blank
if newPassword == "" {
return false, ErrEmptyPassword
}
// Make sure passwords match
if newPassword != confirmPassword {
return false, ErrPasswordMismatch
}
// Let's create the password hash
h, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
if err != nil {
return false, err
}
u.Username = username
u.Hash = string(h)
u.ApiKey = GenerateSecureKey()
err = models.PutUser(&u)
return true, 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)
}
// 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 {

+ 11
- 0
controllers/api/api_test.go View File

@ -19,6 +19,7 @@ type APISuite struct {
apiKey string
config *config.Config
apiServer *Server
admin models.User
}
func (s *APISuite) SetupSuite() {
@ -37,6 +38,7 @@ func (s *APISuite) SetupSuite() {
u, err := models.GetUser(1)
s.Nil(err)
s.apiKey = u.ApiKey
s.admin = u
// Move our cwd up to the project root for help with resolving
// static assets
err = os.Chdir("../")
@ -49,6 +51,15 @@ func (s *APISuite) TearDownTest() {
for _, campaign := range campaigns {
models.DeleteCampaign(campaign.Id)
}
// Cleanup all users except the original admin
users, _ := models.GetUsers()
for _, user := range users {
if user.Id == 1 {
continue
}
err := models.DeleteUser(user.Id)
s.Nil(err)
}
}
func (s *APISuite) SetupTest() {

+ 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 = auth.GenerateSecureKey()
u.ApiKey = util.GenerateSecureKey()
err := models.PutUser(&u)
if err != nil {
http.Error(w, "Error setting API Key", http.StatusInternalServerError)

+ 3
- 0
controllers/api/server.go View File

@ -4,6 +4,7 @@ import (
"net/http"
mid "github.com/gophish/gophish/middleware"
"github.com/gophish/gophish/models"
"github.com/gophish/gophish/worker"
"github.com/gorilla/mux"
)
@ -64,6 +65,8 @@ func (as *Server) registerRoutes() {
router.HandleFunc("/pages/{id:[0-9]+}", as.Page)
router.HandleFunc("/smtp/", as.SendingProfiles)
router.HandleFunc("/smtp/{id:[0-9]+}", as.SendingProfile)
router.HandleFunc("/users/", mid.Use(as.Users, mid.RequirePermission(models.PermissionModifySystem)))
router.HandleFunc("/users/{id:[0-9]+}", mid.Use(as.User))
router.HandleFunc("/util/send_test_email", as.SendTestEmail)
router.HandleFunc("/import/group", as.ImportGroup)
router.HandleFunc("/import/email", as.ImportEmail)

+ 218
- 0
controllers/api/user.go View File

@ -0,0 +1,218 @@
package api
import (
"encoding/json"
"errors"
"net/http"
"strconv"
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")
// ErrEmptyUsername is thrown when a user attempts to register a username that is taken.
var ErrEmptyUsername = errors.New("No username provided")
// ErrEmptyRole is throws when no role is provided when creating or modifying a user.
var ErrEmptyRole = errors.New("No role specified")
// ErrInsufficientPermission is thrown when a user attempts to change an
// attribute (such as the role) for which they don't have permission.
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"`
}
func (ur *userRequest) Validate(existingUser *models.User) error {
switch {
case ur.Username == "":
return ErrEmptyUsername
case ur.Role == "":
return ErrEmptyRole
}
// Verify that the username isn't already taken. We consider two cases:
// * We're creating a new user, in which case any match is a conflict
// * We're modifying a user, in which case any match with a different ID is
// a conflict.
possibleConflict, err := models.GetUserByUsername(ur.Username)
if err == nil {
if existingUser == nil {
return ErrUsernameTaken
}
if possibleConflict.Id != existingUser.Id {
return ErrUsernameTaken
}
}
// If we have an error which is not simply indicating that no user was found, report it
if err != nil && err != gorm.ErrRecordNotFound {
return err
}
return nil
}
// Users contains functions to retrieve a list of existing users or create a
// new user. Users with the ModifySystem permissions can view and create users.
func (as *Server) Users(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == "GET":
us, err := models.GetUsers()
if err != nil {
JSONResponse(w, models.Response{Success: false, Message: err.Error()}, http.StatusInternalServerError)
return
}
JSONResponse(w, us, http.StatusOK)
return
case r.Method == "POST":
ur := &userRequest{}
err := json.NewDecoder(r.Body).Decode(ur)
if err != nil {
JSONResponse(w, models.Response{Success: false, Message: err.Error()}, http.StatusBadRequest)
return
}
err = ur.Validate(nil)
if err != nil {
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)
return
}
hash, err := util.NewHash(ur.Password)
if err != nil {
JSONResponse(w, models.Response{Success: false, Message: err.Error()}, http.StatusInternalServerError)
return
}
role, err := models.GetRoleBySlug(ur.Role)
if err != nil {
JSONResponse(w, models.Response{Success: false, Message: err.Error()}, http.StatusInternalServerError)
return
}
user := models.User{
Username: ur.Username,
Hash: hash,
ApiKey: util.GenerateSecureKey(),
Role: role,
RoleID: role.ID,
}
err = models.PutUser(&user)
if err != nil {
JSONResponse(w, models.Response{Success: false, Message: err.Error()}, http.StatusInternalServerError)
return
}
JSONResponse(w, user, http.StatusOK)
return
}
}
// User contains functions to retrieve or delete a single user. Users with
// the ModifySystem permission can view and modify any user. Otherwise, users
// may only view or delete their own account.
func (as *Server) User(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
id, _ := strconv.ParseInt(vars["id"], 0, 64)
// If the user doesn't have ModifySystem permissions, we need to verify
// that they're only taking action on their account.
currentUser := ctx.Get(r, "user").(models.User)
hasSystem, err := currentUser.HasPermission(models.PermissionModifySystem)
if err != nil {
JSONResponse(w, models.Response{Success: false, Message: err.Error()}, http.StatusInternalServerError)
return
}
if !hasSystem && currentUser.Id != id {
JSONResponse(w, models.Response{Success: false, Message: http.StatusText(http.StatusForbidden)}, http.StatusForbidden)
return
}
existingUser, err := models.GetUser(id)
if err != nil {
JSONResponse(w, models.Response{Success: false, Message: "User not found"}, http.StatusNotFound)
return
}
switch {
case r.Method == "GET":
JSONResponse(w, existingUser, http.StatusOK)
case r.Method == "DELETE":
err = models.DeleteUser(id)
if err != nil {
JSONResponse(w, models.Response{Success: false, Message: err.Error()}, http.StatusInternalServerError)
return
}
log.Infof("Deleted user account for %s", existingUser.Username)
JSONResponse(w, models.Response{Success: true, Message: "User deleted Successfully!"}, http.StatusOK)
case r.Method == "PUT":
ur := &userRequest{}
err = json.NewDecoder(r.Body).Decode(ur)
if err != nil {
log.Errorf("error decoding user request: %v", err)
JSONResponse(w, models.Response{Success: false, Message: err.Error()}, http.StatusBadRequest)
return
}
err = ur.Validate(&existingUser)
if err != nil {
log.Errorf("invalid user request received: %v", err)
JSONResponse(w, models.Response{Success: false, Message: err.Error()}, http.StatusBadRequest)
return
}
existingUser.Username = ur.Username
// Only users with the ModifySystem permission are able to update a
// user's role. This prevents a privilege escalation letting users
// upgrade their own account.
if !hasSystem && ur.Role != existingUser.Role.Slug {
JSONResponse(w, models.Response{Success: false, Message: ErrInsufficientPermission.Error()}, http.StatusBadRequest)
return
}
role, err := models.GetRoleBySlug(ur.Role)
if err != nil {
JSONResponse(w, models.Response{Success: false, Message: err.Error()}, http.StatusInternalServerError)
return
}
// If our user is trying to change the role of an admin, we need to
// ensure that it isn't the last user account with the Admin role.
if existingUser.Role.Slug == models.RoleAdmin && existingUser.Role.ID != role.ID {
err = models.EnsureEnoughAdmins()
if err != nil {
JSONResponse(w, models.Response{Success: false, Message: err.Error()}, http.StatusInternalServerError)
return
}
}
existingUser.Role = role
existingUser.RoleID = role.ID
// 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.
//
// 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.
if ur.Password != "" {
hash, err := util.NewHash(ur.Password)
if err != nil {
JSONResponse(w, models.Response{Success: false, Message: err.Error()}, http.StatusInternalServerError)
return
}
existingUser.Hash = hash
}
err = models.PutUser(&existingUser)
if err != nil {
JSONResponse(w, models.Response{Success: false, Message: err.Error()}, http.StatusInternalServerError)
return
}
JSONResponse(w, existingUser, http.StatusOK)
}
}

+ 188
- 0
controllers/api/user_test.go View File

@ -0,0 +1,188 @@
package api
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"golang.org/x/crypto/bcrypt"
ctx "github.com/gophish/gophish/context"
"github.com/gophish/gophish/models"
)
func (s *APISuite) createUnpriviledgedUser(slug string) *models.User {
role, err := models.GetRoleBySlug(slug)
s.Nil(err)
unauthorizedUser := &models.User{
Username: "foo",
Hash: "bar",
ApiKey: "12345",
Role: role,
RoleID: role.ID,
}
err = models.PutUser(unauthorizedUser)
s.Nil(err)
return unauthorizedUser
}
func (s *APISuite) TestGetUsers() {
r := httptest.NewRequest(http.MethodGet, "/api/users", nil)
r = ctx.Set(r, "user", s.admin)
w := httptest.NewRecorder()
s.apiServer.Users(w, r)
s.Equal(w.Code, http.StatusOK)
got := []models.User{}
err := json.NewDecoder(w.Body).Decode(&got)
s.Nil(err)
// We only expect one user
s.Equal(1, len(got))
// And it should be the admin user
s.Equal(s.admin.Id, got[0].Id)
}
func (s *APISuite) TestCreateUser() {
payload := &userRequest{
Username: "foo",
Password: "bar",
Role: models.RoleUser,
}
body, err := json.Marshal(payload)
s.Nil(err)
r := httptest.NewRequest(http.MethodPost, "/api/users", bytes.NewBuffer(body))
r.Header.Set("Content-Type", "application/json")
r = ctx.Set(r, "user", s.admin)
w := httptest.NewRecorder()
s.apiServer.Users(w, r)
s.Equal(w.Code, http.StatusOK)
got := &models.User{}
err = json.NewDecoder(w.Body).Decode(got)
s.Nil(err)
s.Equal(got.Username, payload.Username)
s.Equal(got.Role.Slug, payload.Role)
}
// TestModifyUser tests that a user with the appropriate access is able to
// modify their username and password.
func (s *APISuite) TestModifyUser() {
unpriviledgedUser := s.createUnpriviledgedUser(models.RoleUser)
newPassword := "new-password"
newUsername := "new-username"
payload := userRequest{
Username: newUsername,
Password: newPassword,
Role: unpriviledgedUser.Role.Slug,
}
body, err := json.Marshal(payload)
s.Nil(err)
url := fmt.Sprintf("/api/users/%d", unpriviledgedUser.Id)
r := httptest.NewRequest(http.MethodPut, url, bytes.NewBuffer(body))
r.Header.Set("Content-Type", "application/json")
r.Header.Set("Authorization", fmt.Sprintf("Bearer %s", unpriviledgedUser.ApiKey))
w := httptest.NewRecorder()
s.apiServer.ServeHTTP(w, r)
response := &models.User{}
err = json.NewDecoder(w.Body).Decode(response)
s.Nil(err)
s.Equal(w.Code, http.StatusOK)
s.Equal(response.Username, newUsername)
got, err := models.GetUser(unpriviledgedUser.Id)
s.Nil(err)
s.Equal(response.Username, got.Username)
s.Equal(newUsername, got.Username)
err = bcrypt.CompareHashAndPassword([]byte(got.Hash), []byte(newPassword))
s.Nil(err)
}
// TestUnauthorizedListUsers ensures that users without the ModifySystem
// permission are unable to list the users registered in Gophish.
func (s *APISuite) TestUnauthorizedListUsers() {
// First, let's create a standard user which doesn't
// have ModifySystem permissions.
unauthorizedUser := s.createUnpriviledgedUser(models.RoleUser)
// We'll try to make a request to the various users API endpoints to
// ensure that they fail. Previously, we could hit the handlers directly
// but we need to go through the router for this test to ensure the
// middleware gets applied.
r := httptest.NewRequest(http.MethodGet, "/api/users/", nil)
r.Header.Set("Authorization", fmt.Sprintf("Bearer %s", unauthorizedUser.ApiKey))
w := httptest.NewRecorder()
s.apiServer.ServeHTTP(w, r)
s.Equal(w.Code, http.StatusForbidden)
}
// TestUnauthorizedModifyUsers verifies that users without ModifySystem
// permission (a "standard" user) can only get or modify their own information.
func (s *APISuite) TestUnauthorizedGetUser() {
// First, we'll make sure that a user with the "user" role is unable to
// get the information of another user (in this case, the main admin).
unauthorizedUser := s.createUnpriviledgedUser(models.RoleUser)
url := fmt.Sprintf("/api/users/%d", s.admin.Id)
r := httptest.NewRequest(http.MethodGet, url, nil)
r.Header.Set("Authorization", fmt.Sprintf("Bearer %s", unauthorizedUser.ApiKey))
w := httptest.NewRecorder()
s.apiServer.ServeHTTP(w, r)
s.Equal(w.Code, http.StatusForbidden)
}
// TestUnauthorizedModifyRole ensures that users without the ModifySystem
// privilege are unable to modify their own role, preventing a potential
// privilege escalation issue.
func (s *APISuite) TestUnauthorizedSetRole() {
unauthorizedUser := s.createUnpriviledgedUser(models.RoleUser)
url := fmt.Sprintf("/api/users/%d", unauthorizedUser.Id)
payload := &userRequest{
Username: unauthorizedUser.Username,
Role: models.RoleAdmin,
}
body, err := json.Marshal(payload)
s.Nil(err)
r := httptest.NewRequest(http.MethodPut, url, bytes.NewBuffer(body))
r.Header.Set("Authorization", fmt.Sprintf("Bearer %s", unauthorizedUser.ApiKey))
w := httptest.NewRecorder()
s.apiServer.ServeHTTP(w, r)
s.Equal(w.Code, http.StatusBadRequest)
response := &models.Response{}
err = json.NewDecoder(w.Body).Decode(response)
s.Nil(err)
s.Equal(response.Message, ErrInsufficientPermission.Error())
}
// TestModifyWithExistingUsername verifies that it's not possible to modify
// an user's username to one which already exists.
func (s *APISuite) TestModifyWithExistingUsername() {
unauthorizedUser := s.createUnpriviledgedUser(models.RoleUser)
payload := &userRequest{
Username: s.admin.Username,
Role: unauthorizedUser.Role.Slug,
}
body, err := json.Marshal(payload)
s.Nil(err)
url := fmt.Sprintf("/api/users/%d", unauthorizedUser.Id)
r := httptest.NewRequest(http.MethodPut, url, bytes.NewReader(body))
r.Header.Set("Authorization", fmt.Sprintf("Bearer %s", unauthorizedUser.ApiKey))
w := httptest.NewRecorder()
s.apiServer.ServeHTTP(w, r)
s.Equal(w.Code, http.StatusBadRequest)
expected := &models.Response{
Message: ErrUsernameTaken.Error(),
Success: false,
}
got := &models.Response{}
err = json.NewDecoder(w.Body).Decode(got)
s.Nil(err)
s.Equal(got.Message, expected.Message)
}

+ 23
- 60
controllers/route.go View File

@ -95,17 +95,17 @@ func (as *AdminServer) Shutdown() error {
func (as *AdminServer) registerRoutes() {
router := mux.NewRouter()
// Base Front-end routes
router.HandleFunc("/", Use(as.Base, mid.RequireLogin))
router.HandleFunc("/", mid.Use(as.Base, mid.RequireLogin))
router.HandleFunc("/login", as.Login)
router.HandleFunc("/logout", Use(as.Logout, mid.RequireLogin))
router.HandleFunc("/campaigns", Use(as.Campaigns, mid.RequireLogin))
router.HandleFunc("/campaigns/{id:[0-9]+}", Use(as.CampaignID, mid.RequireLogin))
router.HandleFunc("/templates", Use(as.Templates, mid.RequireLogin))
router.HandleFunc("/users", Use(as.Users, mid.RequireLogin))
router.HandleFunc("/landing_pages", Use(as.LandingPages, mid.RequireLogin))
router.HandleFunc("/sending_profiles", Use(as.SendingProfiles, mid.RequireLogin))
router.HandleFunc("/settings", Use(as.Settings, mid.RequireLogin))
router.HandleFunc("/register", Use(as.Register, mid.RequireLogin, mid.RequirePermission(models.PermissionModifySystem)))
router.HandleFunc("/logout", mid.Use(as.Logout, 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))
router.HandleFunc("/groups", mid.Use(as.Groups, mid.RequireLogin))
router.HandleFunc("/landing_pages", mid.Use(as.LandingPages, mid.RequireLogin))
router.HandleFunc("/sending_profiles", mid.Use(as.SendingProfiles, mid.RequireLogin))
router.HandleFunc("/settings", mid.Use(as.Settings, mid.RequireLogin))
router.HandleFunc("/users", mid.Use(as.UserManagement, mid.RequirePermission(models.PermissionModifySystem), mid.RequireLogin))
// Create the API routes
api := api.NewServer(api.WithWorker(as.worker))
router.PathPrefix("/api/").Handler(api)
@ -114,11 +114,11 @@ func (as *AdminServer) registerRoutes() {
router.PathPrefix("/").Handler(http.FileServer(unindexed.Dir("./static/")))
// Setup CSRF Protection
csrfHandler := csrf.Protect([]byte(auth.GenerateSecureKey()),
csrfHandler := csrf.Protect([]byte(util.GenerateSecureKey()),
csrf.FieldName("csrf_token"),
csrf.Secure(as.config.UseTLS))
adminHandler := csrfHandler(router)
adminHandler = Use(adminHandler.ServeHTTP, mid.CSRFExceptions, mid.GetContext)
adminHandler = mid.Use(adminHandler.ServeHTTP, mid.CSRFExceptions, mid.GetContext)
// Setup GZIP compression
gzipWrapper, _ := gziphandler.NewGzipLevelHandler(gzip.BestCompression)
@ -129,15 +129,6 @@ func (as *AdminServer) registerRoutes() {
as.server.Handler = adminHandler
}
// Use allows us to stack middleware to process the request
// Example taken from https://github.com/gorilla/mux/pull/36#issuecomment-25849172
func Use(handler http.HandlerFunc, mid ...func(http.Handler) http.HandlerFunc) http.HandlerFunc {
for _, m := range mid {
handler = m(handler)
}
return handler
}
type templateParams struct {
Title string
Flashes []interface{}
@ -160,42 +151,6 @@ func newTemplateParams(r *http.Request) templateParams {
}
}
// Register creates a new user
func (as *AdminServer) Register(w http.ResponseWriter, r *http.Request) {
// If it is a post request, attempt to register the account
// Now that we are all registered, we can log the user in
params := templateParams{Title: "Register", Token: csrf.Token(r)}
session := ctx.Get(r, "session").(*sessions.Session)
switch {
case r.Method == "GET":
params.Flashes = session.Flashes()
session.Save(r, w)
templates := template.New("template")
_, err := templates.ParseFiles("templates/register.html", "templates/flashes.html")
if err != nil {
log.Error(err)
}
template.Must(templates, err).ExecuteTemplate(w, "base", params)
case r.Method == "POST":
//Attempt to register
succ, err := auth.Register(r)
//If we've registered, redirect to the login page
if succ {
Flash(w, r, "success", "Registration successful!")
session.Save(r, w)
http.Redirect(w, r, "/login", 302)
return
}
// Check the error
m := err.Error()
log.Error(err)
Flash(w, r, "danger", m)
session.Save(r, w)
http.Redirect(w, r, "/register", 302)
return
}
}
// Base handles the default path and template execution
func (as *AdminServer) Base(w http.ResponseWriter, r *http.Request) {
params := newTemplateParams(r)
@ -224,11 +179,11 @@ func (as *AdminServer) Templates(w http.ResponseWriter, r *http.Request) {
getTemplate(w, "templates").ExecuteTemplate(w, "base", params)
}
// Users handles the default path and template execution
func (as *AdminServer) Users(w http.ResponseWriter, r *http.Request) {
// Groups handles the default path and template execution
func (as *AdminServer) Groups(w http.ResponseWriter, r *http.Request) {
params := newTemplateParams(r)
params.Title = "Users & Groups"
getTemplate(w, "users").ExecuteTemplate(w, "base", params)
getTemplate(w, "groups").ExecuteTemplate(w, "base", params)
}
// LandingPages handles the default path and template execution
@ -271,6 +226,14 @@ func (as *AdminServer) Settings(w http.ResponseWriter, r *http.Request) {
}
}
// UserManagement is an admin-only handler that allows for the registration
// and management of user accounts within Gophish.
func (as *AdminServer) UserManagement(w http.ResponseWriter, r *http.Request) {
params := newTemplateParams(r)
params.Title = "User Management"
getTemplate(w, "users").ExecuteTemplate(w, "base", params)
}
// Login handles the authentication flow for a user. If credentials are valid,
// a session is created
func (as *AdminServer) Login(w http.ResponseWriter, r *http.Request) {

+ 2
- 2
gophish.go View File

@ -32,10 +32,10 @@ import (
"gopkg.in/alecthomas/kingpin.v2"
"github.com/gophish/gophish/auth"
"github.com/gophish/gophish/config"
"github.com/gophish/gophish/controllers"
log "github.com/gophish/gophish/logger"
"github.com/gophish/gophish/middleware"
"github.com/gophish/gophish/models"
)
@ -94,7 +94,7 @@ func main() {
}
adminConfig := conf.AdminConf
adminServer := controllers.NewAdminServer(adminConfig, adminOptions...)
auth.Store.Options.Secure = adminConfig.UseTLS
middleware.Store.Options.Secure = adminConfig.UseTLS
phishConfig := conf.PhishConf
phishServer := controllers.NewPhishingServer(phishConfig)

+ 15
- 3
gulpfile.js View File

@ -9,11 +9,12 @@ var gulp = require('gulp'),
concat = require('gulp-concat'),
uglify = require('gulp-uglify'),
cleanCSS = require('gulp-clean-css'),
babel = require('gulp-babel'),
js_directory = 'static/js/src/',
css_directory = 'static/css/',
vendor_directory = js_directory + 'vendor/',
app_directory = js_directory + 'app/**/*.js',
app_directory = js_directory + 'app/',
dest_js_directory = 'static/js/dist/',
dest_css_directory = 'static/css/dist/';
@ -48,8 +49,19 @@ vendorjs = function () {
}
scripts = function () {
// Gophish app files
return gulp.src(app_directory)
// Gophish app files - non-ES6
return gulp.src([
app_directory + 'autocomplete.js',
app_directory + 'campaign_results.js',
app_directory + 'campaigns.js',
app_directory + 'dashboard.js',
app_directory + 'groups.js',
app_directory + 'landing_pages.js',
app_directory + 'sending_profiles.js',
app_directory + 'settings.js',
app_directory + 'templates.js',
app_directory + 'gophish.js',
])
.pipe(rename({
suffix: '.min'
}))

+ 15
- 6
middleware/middleware.go View File

@ -6,7 +6,6 @@ import (
"net/http"
"strings"
"github.com/gophish/gophish/auth"
ctx "github.com/gophish/gophish/context"
"github.com/gophish/gophish/models"
"github.com/gorilla/csrf"
@ -31,6 +30,15 @@ func CSRFExceptions(handler http.Handler) http.HandlerFunc {
}
}
// Use allows us to stack middleware to process the request
// Example taken from https://github.com/gorilla/mux/pull/36#issuecomment-25849172
func Use(handler http.HandlerFunc, mid ...func(http.Handler) http.HandlerFunc) http.HandlerFunc {
for _, m := range mid {
handler = m(handler)
}
return handler
}
// GetContext wraps each request in a function which fills in the context for a given request.
// This includes setting the User and Session keys and values as necessary for use in later functions.
func GetContext(handler http.Handler) http.HandlerFunc {
@ -43,7 +51,7 @@ func GetContext(handler http.Handler) http.HandlerFunc {
}
// Set the context appropriately here.
// Set the session
session, _ := auth.Store.Get(r, "gophish")
session, _ := Store.Get(r, "gophish")
// Put the session in the context so that we can
// reuse the values in different handlers
r = ctx.Set(r, "session", session)
@ -107,11 +115,12 @@ func RequireLogin(handler http.Handler) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if u := ctx.Get(r, "user"); u != nil {
handler.ServeHTTP(w, r)
} else {
q := r.URL.Query()
q.Set("next", r.URL.Path)
http.Redirect(w, r, fmt.Sprintf("/login?%s", q.Encode()), http.StatusTemporaryRedirect)
return
}
q := r.URL.Query()
q.Set("next", r.URL.Path)
http.Redirect(w, r, fmt.Sprintf("/login?%s", q.Encode()), http.StatusTemporaryRedirect)
return
}
}

+ 23
- 0
middleware/session.go View File

@ -0,0 +1,23 @@
package middleware
import (
"encoding/gob"
"github.com/gophish/gophish/models"
"github.com/gorilla/securecookie"
"github.com/gorilla/sessions"
)
// init registers the necessary models to be saved in the session later
func init() {
gob.Register(&models.User{})
gob.Register(&models.Flash{})
Store.Options.HttpOnly = true
// This sets the maxAge to 5 days for all cookies
Store.MaxAge(86400 * 5)
}
// Store contains the session information for the request
var Store = sessions.NewCookieStore(
[]byte(securecookie.GenerateRandomKey(64)), //Signing key
[]byte(securecookie.GenerateRandomKey(32)))

+ 1
- 1
models/rbac.go View File

@ -48,7 +48,7 @@ const (
// Role represents a user role within Gophish. Each user has a single role
// which maps to a set of permissions.
type Role struct {
ID int64 `json:"id"`
ID int64 `json:"-"`
Slug string `json:"slug"`
Name string `json:"name"`
Description string `json:"description"`

+ 118
- 1
models/user.go View File

@ -1,11 +1,22 @@
package models
import (
"errors"
log "github.com/gophish/gophish/logger"
)
// ErrModifyingOnlyAdmin occurs when there is an attempt to modify the only
// user account with the Admin role in such a way that there will be no user
// accounts left in Gophish with that role.
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"`
ApiKey string `json:"-" sql:"not null;unique"`
Role Role `json:"role" gorm:"association_autoupdate:false;association_autocreate:false"`
RoleID int64 `json:"-"`
}
@ -18,6 +29,13 @@ func GetUser(id int64) (User, error) {
return u, err
}
// GetUsers returns the users registered in Gophish
func GetUsers() ([]User, error) {
us := []User{}
err := db.Preload("Role").Find(&us).Error
return us, err
}
// GetUserByAPIKey returns the user that the given API Key corresponds to. If no user is found, an
// error is thrown.
func GetUserByAPIKey(key string) (User, error) {
@ -39,3 +57,102 @@ func PutUser(u *User) error {
err := db.Save(u).Error
return err
}
// EnsureEnoughAdmins ensures that there is more than one user account in
// Gophish with the Admin role. This function is meant to be called before
// modifying a user account with the Admin role in a non-revokable way.
func EnsureEnoughAdmins() error {
role, err := GetRoleBySlug(RoleAdmin)
if err != nil {
return err
}
var adminCount int
err = db.Model(&User{}).Where("role_id=?", role.ID).Count(&adminCount).Error
if err != nil {
return err
}
if adminCount == 1 {
return ErrModifyingOnlyAdmin
}
return nil
}
// DeleteUser deletes the given user. To ensure that there is always at least
// one user account with the Admin role, this function will refuse to delete
// the last Admin.
func DeleteUser(id int64) error {
existing, err := GetUser(id)
if err != nil {
return err
}
// If the user is an admin, we need to verify that it's not the last one.
if existing.Role.Slug == RoleAdmin {
err = EnsureEnoughAdmins()
if err != nil {
return err
}
}
campaigns, err := GetCampaigns(id)
if err != nil {
return err
}
// Delete the campaigns
log.Infof("Deleting campaigns for user ID %d", id)
for _, campaign := range campaigns {
err = DeleteCampaign(campaign.Id)
if err != nil {
return err
}
}
log.Infof("Deleting pages for user ID %d", id)
// Delete the landing pages
pages, err := GetPages(id)
if err != nil {
return err
}
for _, page := range pages {
err = DeletePage(page.Id, id)
if err != nil {
return err
}
}
// Delete the templates
log.Infof("Deleting templates for user ID %d", id)
templates, err := GetTemplates(id)
if err != nil {
return err
}
for _, template := range templates {
err = DeleteTemplate(template.Id, id)
if err != nil {
return err
}
}
// Delete the groups
log.Infof("Deleting groups for user ID %d", id)
groups, err := GetGroups(id)
if err != nil {
return err
}
for _, group := range groups {
err = DeleteGroup(&group)
if err != nil {
return err
}
}
// Delete the sending profiles
log.Infof("Deleting sending profiles for user ID %d", id)
profiles, err := GetSMTPs(id)
if err != nil {
return err
}
for _, profile := range profiles {
err = DeleteSMTP(profile.Id, id)
if err != nil {
return err
}
}
// Finally, delete the user
err = db.Where("id=?", id).Delete(&User{}).Error
return err
}

+ 41
- 0
models/user_test.go View File

@ -59,3 +59,44 @@ func (s *ModelsSuite) TestGeneratedAPIKey(c *check.C) {
c.Assert(err, check.Equals, nil)
c.Assert(u.ApiKey, check.Not(check.Equals), "12345678901234567890123456789012")
}
func (s *ModelsSuite) verifyRoleCount(c *check.C, roleID, expected int64) {
var adminCount int64
err := db.Model(&User{}).Where("role_id=?", roleID).Count(&adminCount).Error
c.Assert(err, check.Equals, nil)
c.Assert(adminCount, check.Equals, expected)
}
func (s *ModelsSuite) TestDeleteLastAdmin(c *check.C) {
// Create a new admin user
role, err := GetRoleBySlug(RoleAdmin)
c.Assert(err, check.Equals, nil)
newAdmin := User{
Username: "new-admin",
Hash: "123456",
ApiKey: "123456",
Role: role,
RoleID: role.ID,
}
err = PutUser(&newAdmin)
c.Assert(err, check.Equals, nil)
// Ensure that there are two admins
s.verifyRoleCount(c, role.ID, 2)
// Delete the newly created admin - this should work since we have more
// than one current admin.
err = DeleteUser(newAdmin.Id)
c.Assert(err, check.Equals, nil)
// Verify that we now have one admin
s.verifyRoleCount(c, role.ID, 1)
// Try to delete the last admin - this should fail since we always want at
// least one admin active in Gophish.
err = DeleteUser(1)
c.Assert(err, check.Equals, ErrModifyingOnlyAdmin)
// Verify that the admin wasn't deleted
s.verifyRoleCount(c, role.ID, 1)
}

+ 7
- 1
package.json View File

@ -12,8 +12,12 @@
},
"homepage": "https://getgophish.com",
"devDependencies": {
"@babel/core": "^7.4.5",
"@babel/preset-env": "^7.4.5",
"babel-loader": "^8.0.6",
"clean-css": "^4.2.1",
"gulp": "^4.0.0",
"gulp-babel": "^8.0.0",
"gulp-clean-css": "^4.0.0",
"gulp-cli": "^2.2.0",
"gulp-concat": "^2.6.1",
@ -22,6 +26,8 @@
"gulp-uglify": "^3.0.2",
"gulp-wrap": "^0.15.0",
"jshint": "^2.10.2",
"jshint-stylish": "^2.2.1"
"jshint-stylish": "^2.2.1",
"webpack": "^4.32.2",
"webpack-cli": "^3.3.2"
}
}

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


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

@ -702,6 +702,10 @@ table.dataTable {
background-color: #37485a;
}
.nav-badge {
margin-top: 5px;
}
#resultsMapContainer {
display: none;
}

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

@ -1 +1 @@
function errorFlash(e){$("#flashes").empty(),$("#flashes").append('<div style="text-align:center" class="alert alert-danger"> <i class="fa fa-exclamation-circle"></i> '+e+"</div>")}function successFlash(e){$("#flashes").empty(),$("#flashes").append('<div style="text-align:center" class="alert alert-success"> <i class="fa fa-check-circle"></i> '+e+"</div>")}function modalError(e){$("#modal\\.flashes").empty().append('<div style="text-align:center" class="alert alert-danger"> <i class="fa fa-exclamation-circle"></i> '+e+"</div>")}function query(e,t,n,r){return $.ajax({url:"/api"+e,async:r,method:t,data:JSON.stringify(n),dataType:"json",contentType:"application/json",beforeSend:function(e){e.setRequestHeader("Authorization","Bearer "+user.api_key)}})}function escapeHtml(e){return $("<div/>").text(e).html()}function unescapeHtml(e){return $("<div/>").html(e).text()}var capitalize=function(e){return e.charAt(0).toUpperCase()+e.slice(1)},api={campaigns:{get:function(){return query("/campaigns/","GET",{},!1)},post:function(e){return query("/campaigns/","POST",e,!1)},summary:function(){return query("/campaigns/summary","GET",{},!1)}},campaignId:{get:function(e){return query("/campaigns/"+e,"GET",{},!0)},delete:function(e){return query("/campaigns/"+e,"DELETE",{},!1)},results:function(e){return query("/campaigns/"+e+"/results","GET",{},!0)},complete:function(e){return query("/campaigns/"+e+"/complete","GET",{},!0)},summary:function(e){return query("/campaigns/"+e+"/summary","GET",{},!0)}},groups:{get:function(){return query("/groups/","GET",{},!1)},post:function(e){return query("/groups/","POST",e,!1)},summary:function(){return query("/groups/summary","GET",{},!0)}},groupId:{get:function(e){return query("/groups/"+e,"GET",{},!1)},put:function(e){return query("/groups/"+e.id,"PUT",e,!1)},delete:function(e){return query("/groups/"+e,"DELETE",{},!1)}},templates:{get:function(){return query("/templates/","GET",{},!1)},post:function(e){return query("/templates/","POST",e,!1)}},templateId:{get:function(e){return query("/templates/"+e,"GET",{},!1)},put:function(e){return query("/templates/"+e.id,"PUT",e,!1)},delete:function(e){return query("/templates/"+e,"DELETE",{},!1)}},pages:{get:function(){return query("/pages/","GET",{},!1)},post:function(e){return query("/pages/","POST",e,!1)}},pageId:{get:function(e){return query("/pages/"+e,"GET",{},!1)},put:function(e){return query("/pages/"+e.id,"PUT",e,!1)},delete:function(e){return query("/pages/"+e,"DELETE",{},!1)}},SMTP:{get:function(){return query("/smtp/","GET",{},!1)},post:function(e){return query("/smtp/","POST",e,!1)}},SMTPId:{get:function(e){return query("/smtp/"+e,"GET",{},!1)},put:function(e){return query("/smtp/"+e.id,"PUT",e,!1)},delete:function(e){return query("/smtp/"+e,"DELETE",{},!1)}},import_email:function(e){return query("/import/email","POST",e,!1)},clone_site:function(e){return query("/import/site","POST",e,!1)},send_test_email:function(e){return query("/util/send_test_email","POST",e,!0)},reset:function(){return query("/reset","POST",{},!0)}};$(document).ready(function(){var t=location.pathname;$(".nav-sidebar li").each(function(){var e=$(this);e.find("a").attr("href")===t&&e.addClass("active")}),$.fn.dataTable.moment("MMMM Do YYYY, h:mm:ss a"),$('[data-toggle="tooltip"]').tooltip()});
function errorFlash(e){$("#flashes").empty(),$("#flashes").append('<div style="text-align:center" class="alert alert-danger"> <i class="fa fa-exclamation-circle"></i> '+e+"</div>")}function successFlash(e){$("#flashes").empty(),$("#flashes").append('<div style="text-align:center" class="alert alert-success"> <i class="fa fa-check-circle"></i> '+e+"</div>")}function modalError(e){$("#modal\\.flashes").empty().append('<div style="text-align:center" class="alert alert-danger"> <i class="fa fa-exclamation-circle"></i> '+e+"</div>")}function query(e,t,r,n){return $.ajax({url:"/api"+e,async:n,method:t,data:JSON.stringify(r),dataType:"json",contentType:"application/json",beforeSend:function(e){e.setRequestHeader("Authorization","Bearer "+user.api_key)}})}function escapeHtml(e){return $("<div/>").text(e).html()}function unescapeHtml(e){return $("<div/>").html(e).text()}window.escapeHtml=escapeHtml;var capitalize=function(e){return e.charAt(0).toUpperCase()+e.slice(1)},api={campaigns:{get:function(){return query("/campaigns/","GET",{},!1)},post:function(e){return query("/campaigns/","POST",e,!1)},summary:function(){return query("/campaigns/summary","GET",{},!1)}},campaignId:{get:function(e){return query("/campaigns/"+e,"GET",{},!0)},delete:function(e){return query("/campaigns/"+e,"DELETE",{},!1)},results:function(e){return query("/campaigns/"+e+"/results","GET",{},!0)},complete:function(e){return query("/campaigns/"+e+"/complete","GET",{},!0)},summary:function(e){return query("/campaigns/"+e+"/summary","GET",{},!0)}},groups:{get:function(){return query("/groups/","GET",{},!1)},post:function(e){return query("/groups/","POST",e,!1)},summary:function(){return query("/groups/summary","GET",{},!0)}},groupId:{get:function(e){return query("/groups/"+e,"GET",{},!1)},put:function(e){return query("/groups/"+e.id,"PUT",e,!1)},delete:function(e){return query("/groups/"+e,"DELETE",{},!1)}},templates:{get:function(){return query("/templates/","GET",{},!1)},post:function(e){return query("/templates/","POST",e,!1)}},templateId:{get:function(e){return query("/templates/"+e,"GET",{},!1)},put:function(e){return query("/templates/"+e.id,"PUT",e,!1)},delete:function(e){return query("/templates/"+e,"DELETE",{},!1)}},pages:{get:function(){return query("/pages/","GET",{},!1)},post:function(e){return query("/pages/","POST",e,!1)}},pageId:{get:function(e){return query("/pages/"+e,"GET",{},!1)},put:function(e){return query("/pages/"+e.id,"PUT",e,!1)},delete:function(e){return query("/pages/"+e,"DELETE",{},!1)}},SMTP:{get:function(){return query("/smtp/","GET",{},!1)},post:function(e){return query("/smtp/","POST",e,!1)}},SMTPId:{get:function(e){return query("/smtp/"+e,"GET",{},!1)},put:function(e){return query("/smtp/"+e.id,"PUT",e,!1)},delete:function(e){return query("/smtp/"+e,"DELETE",{},!1)}},users:{get:function(){return query("/users/","GET",{},!0)},post:function(e){return query("/users/","POST",e,!0)}},userId:{get:function(e){return query("/users/"+e,"GET",{},!0)},put:function(e){return query("/users/"+e.id,"PUT",e,!0)},delete:function(e){return query("/users/"+e,"DELETE",{},!0)}},import_email:function(e){return query("/import/email","POST",e,!1)},clone_site:function(e){return query("/import/site","POST",e,!1)},send_test_email:function(e){return query("/util/send_test_email","POST",e,!0)},reset:function(){return query("/reset","POST",{},!0)}};window.api=api,$(document).ready(function(){var t=location.pathname;$(".nav-sidebar li").each(function(){var e=$(this);e.find("a").attr("href")===t&&e.addClass("active")}),$.fn.dataTable.moment("MMMM Do YYYY, h:mm:ss a"),$('[data-toggle="tooltip"]').tooltip()});

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

@ -0,0 +1 @@
var groups=[];function save(e){var t=[];$.each($("#targetsTable").DataTable().rows().data(),function(e,a){t.push({first_name:unescapeHtml(a[0]),last_name:unescapeHtml(a[1]),email:unescapeHtml(a[2]),position:unescapeHtml(a[3])})});var a={name:$("#name").val(),targets:t};-1!=e?(a.id=e,api.groupId.put(a).success(function(e){successFlash("Group updated successfully!"),load(),dismiss(),$("#modal").modal("hide")}).error(function(e){modalError(e.responseJSON.message)})):api.groups.post(a).success(function(e){successFlash("Group added successfully!"),load(),dismiss(),$("#modal").modal("hide")}).error(function(e){modalError(e.responseJSON.message)})}function dismiss(){$("#targetsTable").dataTable().DataTable().clear().draw(),$("#name").val(""),$("#modal\\.flashes").empty()}function edit(e){if(targets=$("#targetsTable").dataTable({destroy:!0,columnDefs:[{orderable:!1,targets:"no-sort"}]}),$("#modalSubmit").unbind("click").click(function(){save(e)}),-1==e);else api.groupId.get(e).success(function(e){$("#name").val(e.name),$.each(e.targets,function(e,a){targets.DataTable().row.add([escapeHtml(a.first_name),escapeHtml(a.last_name),escapeHtml(a.email),escapeHtml(a.position),'<span style="cursor:pointer;"><i class="fa fa-trash-o"></i></span>']).draw()})}).error(function(){errorFlash("Error fetching group")});$("#csvupload").fileupload({url:"/api/import/group",dataType:"json",beforeSend:function(e){e.setRequestHeader("Authorization","Bearer "+user.api_key)},add:function(e,a){$("#modal\\.flashes").empty();var t=a.originalFiles[0].name;if(t&&!/(csv|txt)$/i.test(t.split(".").pop()))return modalError("Unsupported file extension (use .csv or .txt)"),!1;a.submit()},done:function(e,a){$.each(a.result,function(e,a){addTarget(a.first_name,a.last_name,a.email,a.position)}),targets.DataTable().draw()}})}var downloadCSVTemplate=function(){var e="group_template.csv",a=Papa.unparse([{"First Name":"Example","Last Name":"User",Email:"foobar@example.com",Position:"Systems Administrator"}],{}),t=new Blob([a],{type:"text/csv;charset=utf-8;"});if(navigator.msSaveBlob)navigator.msSaveBlob(t,e);else{var s=window.URL.createObjectURL(t),o=document.createElement("a");o.href=s,o.setAttribute("download",e),document.body.appendChild(o),o.click(),document.body.removeChild(o)}},deleteGroup=function(s){var e=groups.find(function(e){return e.id===s});e&&swal({title:"Are you sure?",text:"This will delete the group. This can't be undone!",type:"warning",animation:!1,showCancelButton:!0,confirmButtonText:"Delete "+escapeHtml(e.name),confirmButtonColor:"#428bca",reverseButtons:!0,allowOutsideClick:!1,preConfirm:function(){return new Promise(function(a,t){api.groupId.delete(s).success(function(e){a()}).error(function(e){t(e.responseJSON.message)})})}}).then(function(){swal("Group Deleted!","This group has been deleted!","success"),$('button:contains("OK")').on("click",function(){location.reload()})})};function addTarget(e,a,t,s){var o=escapeHtml(t).toLowerCase(),r=[escapeHtml(e),escapeHtml(a),o,escapeHtml(s),'<span style="cursor:pointer;"><i class="fa fa-trash-o"></i></span>'],n=targets.DataTable(),i=n.column(2,{order:"index"}).data().indexOf(o);0<=i?n.row(i,{order:"index"}).data(r):n.row.add(r)}function load(){$("#groupTable").hide(),$("#emptyMessage").hide(),$("#loading").show(),api.groups.summary().success(function(e){if($("#loading").hide(),0<e.total){groups=e.groups,$("#emptyMessage").hide(),$("#groupTable").show();var t=$("#groupTable").DataTable({destroy:!0,columnDefs:[{orderable:!1,targets:"no-sort"}]});t.clear(),$.each(groups,function(e,a){t.row.add([escapeHtml(a.name),escapeHtml(a.num_targets),moment(a.modified_date).format("MMMM Do YYYY, h:mm:ss a"),"<div class='pull-right'><button class='btn btn-primary' data-toggle='modal' data-backdrop='static' data-target='#modal' onclick='edit("+a.id+")'> <i class='fa fa-pencil'></i> </button> <button class='btn btn-danger' onclick='deleteGroup("+a.id+")'> <i class='fa fa-trash-o'></i> </button></div>"]).draw()})}else $("#emptyMessage").show()}).error(function(){errorFlash("Error fetching groups")})}$(document).ready(function(){load(),$("#targetForm").submit(function(){return addTarget($("#firstName").val(),$("#lastName").val(),$("#email").val(),$("#position").val()),targets.DataTable().draw(),$("#targetForm>div>input").val(""),$("#firstName").focus(),!1}),$("#targetsTable").on("click","span>i.fa-trash-o",function(){targets.DataTable().row($(this).parents("tr")).remove().draw()}),$("#modal").on("hide.bs.modal",function(){dismiss()}),$("#csv-template").click(downloadCSVTemplate)});

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

@ -1 +1 @@
var groups=[];function save(e){var t=[];$.each($("#targetsTable").DataTable().rows().data(),function(e,a){t.push({first_name:unescapeHtml(a[0]),last_name:unescapeHtml(a[1]),email:unescapeHtml(a[2]),position:unescapeHtml(a[3])})});var a={name:$("#name").val(),targets:t};-1!=e?(a.id=e,api.groupId.put(a).success(function(e){successFlash("Group updated successfully!"),load(),dismiss(),$("#modal").modal("hide")}).error(function(e){modalError(e.responseJSON.message)})):api.groups.post(a).success(function(e){successFlash("Group added successfully!"),load(),dismiss(),$("#modal").modal("hide")}).error(function(e){modalError(e.responseJSON.message)})}function dismiss(){$("#targetsTable").dataTable().DataTable().clear().draw(),$("#name").val(""),$("#modal\\.flashes").empty()}function edit(e){if(targets=$("#targetsTable").dataTable({destroy:!0,columnDefs:[{orderable:!1,targets:"no-sort"}]}),$("#modalSubmit").unbind("click").click(function(){save(e)}),-1==e);else api.groupId.get(e).success(function(e){$("#name").val(e.name),$.each(e.targets,function(e,a){targets.DataTable().row.add([escapeHtml(a.first_name),escapeHtml(a.last_name),escapeHtml(a.email),escapeHtml(a.position),'<span style="cursor:pointer;"><i class="fa fa-trash-o"></i></span>']).draw()})}).error(function(){errorFlash("Error fetching group")});$("#csvupload").fileupload({url:"/api/import/group",dataType:"json",beforeSend:function(e){e.setRequestHeader("Authorization","Bearer "+user.api_key)},add:function(e,a){$("#modal\\.flashes").empty();var t=a.originalFiles[0].name;if(t&&!/(csv|txt)$/i.test(t.split(".").pop()))return modalError("Unsupported file extension (use .csv or .txt)"),!1;a.submit()},done:function(e,a){$.each(a.result,function(e,a){addTarget(a.first_name,a.last_name,a.email,a.position)}),targets.DataTable().draw()}})}var downloadCSVTemplate=function(){var e="group_template.csv",a=Papa.unparse([{"First Name":"Example","Last Name":"User",Email:"foobar@example.com",Position:"Systems Administrator"}],{}),t=new Blob([a],{type:"text/csv;charset=utf-8;"});if(navigator.msSaveBlob)navigator.msSaveBlob(t,e);else{var s=window.URL.createObjectURL(t),o=document.createElement("a");o.href=s,o.setAttribute("download",e),document.body.appendChild(o),o.click(),document.body.removeChild(o)}},deleteGroup=function(s){var e=groups.find(function(e){return e.id===s});e&&swal({title:"Are you sure?",text:"This will delete the group. This can't be undone!",type:"warning",animation:!1,showCancelButton:!0,confirmButtonText:"Delete "+escapeHtml(e.name),confirmButtonColor:"#428bca",reverseButtons:!0,allowOutsideClick:!1,preConfirm:function(){return new Promise(function(a,t){api.groupId.delete(s).success(function(e){a()}).error(function(e){t(e.responseJSON.message)})})}}).then(function(){swal("Group Deleted!","This group has been deleted!","success"),$('button:contains("OK")').on("click",function(){location.reload()})})};function addTarget(e,a,t,s){var o=escapeHtml(t).toLowerCase(),r=[escapeHtml(e),escapeHtml(a),o,escapeHtml(s),'<span style="cursor:pointer;"><i class="fa fa-trash-o"></i></span>'],n=targets.DataTable(),i=n.column(2,{order:"index"}).data().indexOf(o);0<=i?n.row(i,{order:"index"}).data(r):n.row.add(r)}function load(){$("#groupTable").hide(),$("#emptyMessage").hide(),$("#loading").show(),api.groups.summary().success(function(e){if($("#loading").hide(),0<e.total){groups=e.groups,$("#emptyMessage").hide(),$("#groupTable").show();var t=$("#groupTable").DataTable({destroy:!0,columnDefs:[{orderable:!1,targets:"no-sort"}]});t.clear(),$.each(groups,function(e,a){t.row.add([escapeHtml(a.name),escapeHtml(a.num_targets),moment(a.modified_date).format("MMMM Do YYYY, h:mm:ss a"),"<div class='pull-right'><button class='btn btn-primary' data-toggle='modal' data-backdrop='static' data-target='#modal' onclick='edit("+a.id+")'> <i class='fa fa-pencil'></i> </button> <button class='btn btn-danger' onclick='deleteGroup("+a.id+")'> <i class='fa fa-trash-o'></i> </button></div>"]).draw()})}else $("#emptyMessage").show()}).error(function(){errorFlash("Error fetching groups")})}$(document).ready(function(){load(),$("#targetForm").submit(function(){return addTarget($("#firstName").val(),$("#lastName").val(),$("#email").val(),$("#position").val()),targets.DataTable().draw(),$("#targetForm>div>input").val(""),$("#firstName").focus(),!1}),$("#targetsTable").on("click","span>i.fa-trash-o",function(){targets.DataTable().row($(this).parents("tr")).remove().draw()}),$("#modal").on("hide.bs.modal",function(){dismiss()}),$("#csv-template").click(downloadCSVTemplate)});
!function(e){var t={};function r(n){if(t[n])return t[n].exports;var o=t[n]={i:n,l:!1,exports:{}};return e[n].call(o.exports,o,o.exports,r),o.l=!0,o.exports}r.m=e,r.c=t,r.d=function(e,t,n){r.o(e,t)||Object.defineProperty(e,t,{enumerable:!0,get:n})},r.r=function(e){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},r.t=function(e,t){if(1&t&&(e=r(e)),8&t)return e;if(4&t&&"object"==typeof e&&e&&e.__esModule)return e;var n=Object.create(null);if(r.r(n),Object.defineProperty(n,"default",{enumerable:!0,value:e}),2&t&&"string"!=typeof e)for(var o in e)r.d(n,o,function(t){return e[t]}.bind(null,o));return n},r.n=function(e){var t=e&&e.__esModule?function(){return e.default}:function(){return e};return r.d(t,"a",t),t},r.o=function(e,t){return Object.prototype.hasOwnProperty.call(e,t)},r.p="",r(r.s=0)}([function(e,t){var r=[],n=function(){$("#username").val(""),$("#password").val(""),$("#confirm_password").val(""),$("#role").val(""),$("#modal\\.flashes").empty()},o=function(e){$("#modalSubmit").unbind("click").click(function(){!function(e){if($("#password").val()===$("#confirm_password").val()){var t={username:$("#username").val(),password:$("#password").val(),role:$("#role").val()};-1!=e?(t.id=e,api.userId.put(t).success(function(e){successFlash("User ".concat(t.username," updated successfully!")),s(),n(),$("#modal").modal("hide")}).error(function(e){modalError(e.responseJSON.message)})):api.users.post(t).success(function(e){successFlash("User ".concat(t.username," registered successfully!")),s(),n(),$("#modal").modal("hide")}).error(function(e){modalError(e.responseJSON.message)})}else modalError("Passwords must match.")}(e)}),$("#role").select2(),-1==e?($("#role").val("user"),$("#role").trigger("change")):api.userId.get(e).success(function(e){$("#username").val(e.username),$("#role").val(e.role.slug),$("#role").trigger("change")}).error(function(){errorFlash("Error fetching user")})},s=function(){$("#userTable").hide(),$("#loading").show(),api.users.get().success(function(e){r=e,$("#loading").hide(),$("#userTable").show();var t=$("#userTable").DataTable({destroy:!0,columnDefs:[{orderable:!1,targets:"no-sort"}]});t.clear(),$.each(r,function(e,r){t.row.add([escapeHtml(r.username),escapeHtml(r.role.name),"<div class='pull-right'><button class='btn btn-primary edit_button' data-toggle='modal' data-backdrop='static' data-target='#modal' data-user-id='"+r.id+"'> <i class='fa fa-pencil'></i> </button> <button class='btn btn-danger delete_button' data-user-id='"+r.id+"'> <i class='fa fa-trash-o'></i> </button></div>"]).draw()})}).error(function(){errorFlash("Error fetching users")})};$(document).ready(function(){s(),$("#modal").on("hide.bs.modal",function(){n()}),$.fn.select2.defaults.set("width","100%"),$.fn.select2.defaults.set("dropdownParent",$("#role-select")),$.fn.select2.defaults.set("theme","bootstrap"),$.fn.select2.defaults.set("sorter",function(e){return e.sort(function(e,t){return e.text.toLowerCase()>t.text.toLowerCase()?1:e.text.toLowerCase()<t.text.toLowerCase()?-1:0})}),$("#new_button").on("click",function(){o(-1)}),$("#userTable").on("click",".edit_button",function(e){o($(this).attr("data-user-id"))}),$("#userTable").on("click",".delete_button",function(e){var t,n;t=$(this).attr("data-user-id"),(n=r.find(function(e){return e.id==t}))&&swal({title:"Are you sure?",text:"This will delete the account for ".concat(n.username," as well as all of the objects they have created.\n\nThis can't be undone!"),type:"warning",animation:!1,showCancelButton:!0,confirmButtonText:"Delete",confirmButtonColor:"#428bca",reverseButtons:!0,allowOutsideClick:!1,preConfirm:function(){return new Promise(function(e,r){api.userId.delete(t).success(function(t){e()}).error(function(e){r(e.responseJSON.message)})})}}).then(function(){swal("User Deleted!","The user account for ".concat(n.username," and all associated objects have been deleted!"),"success"),$('button:contains("OK")').on("click",function(){location.reload()})})})})}]);

+ 28
- 0
static/js/src/app/gophish.js View File

@ -32,6 +32,7 @@ function query(endpoint, method, data, async) {
function escapeHtml(text) {
return $("<div/>").text(text).html()
}
window.escapeHtml = escapeHtml
function unescapeHtml(html) {
return $("<div/>").html(html).text()
@ -196,6 +197,32 @@ var api = {
return query("/smtp/" + id, "DELETE", {}, false)
}
},
// users contains the endpoints for /users
users: {
// get() - Queries the API for GET /users
get: function () {
return query("/users/", "GET", {}, true)
},
// post() - Posts a user to POST /users
post: function (user) {
return query("/users/", "POST", user, true)
}
},
// userId contains the endpoints for /users/:id
userId: {
// get() - Queries the API for GET /users/:id
get: function (id) {
return query("/users/" + id, "GET", {}, true)
},
// put() - Puts a user to PUT /users/:id
put: function (user) {
return query("/users/" + user.id, "PUT", user, true)
},
// delete() - Deletes a user at DELETE /users/:id
delete: function (id) {
return query("/users/" + id, "DELETE", {}, true)
}
},
// import handles all of the "import" functions in the api
import_email: function (req) {
return query("/import/email", "POST", req, false)
@ -212,6 +239,7 @@ var api = {
return query("/reset", "POST", {}, true)
}
}
window.api = api
// Register our moment.js datatables listeners
$(document).ready(function () {

+ 284
- 0
static/js/src/app/groups.js View File

@ -0,0 +1,284 @@
var groups = []
// Save attempts to POST or PUT to /groups/
function save(id) {
var targets = []
$.each($("#targetsTable").DataTable().rows().data(), function (i, target) {
targets.push({
first_name: unescapeHtml(target[0]),
last_name: unescapeHtml(target[1]),
email: unescapeHtml(target[2]),
position: unescapeHtml(target[3])
})
})
var group = {
name: $("#name").val(),
targets: targets
}
// Submit the group
if (id != -1) {
// If we're just editing an existing group,
// we need to PUT /groups/:id
group.id = id
api.groupId.put(group)
.success(function (data) {
successFlash("Group updated successfully!")
load()
dismiss()
$("#modal").modal('hide')
})
.error(function (data) {
modalError(data.responseJSON.message)
})
} else {
// Else, if this is a new group, POST it
// to /groups
api.groups.post(group)
.success(function (data) {
successFlash("Group added successfully!")
load()
dismiss()
$("#modal").modal('hide')
})
.error(function (data) {
modalError(data.responseJSON.message)
})
}
}
function dismiss() {
$("#targetsTable").dataTable().DataTable().clear().draw()
$("#name").val("")
$("#modal\\.flashes").empty()
}
function edit(id) {
targets = $("#targetsTable").dataTable({
destroy: true, // Destroy any other instantiated table - http://datatables.net/manual/tech-notes/3#destroy
columnDefs: [{
orderable: false,
targets: "no-sort"