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.
This commit is contained in:
Jordan Wright 2019-05-31 13:58:18 -05:00 committed by GitHub
parent faadf0c850
commit 84096b8724
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
32 changed files with 3595 additions and 532 deletions

3
.babelrc Normal file
View file

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

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")
// ErrPasswordMismatch is thrown when a user provides a blank password to the register
// or change password functions
var ErrPasswordMismatch = errors.New("Password cannot be blank")
// ErrEmptyPassword 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")
// ErrUsernameTaken is thrown when a user attempts to register a username that is taken.
var ErrUsernameTaken = errors.New("Username already taken")
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 {

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() {

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)

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
controllers/api/user.go Normal file
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)
}
}

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

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

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)

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

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
middleware/session.go Normal file
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)))

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"`

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
}

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

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

File diff suppressed because one or more lines are too long

4
static/css/main.css vendored
View file

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

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
static/js/dist/app/groups.min.js vendored Normal file
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)});

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()})})})})}]);

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
static/js/src/app/groups.js Normal file
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"
}]
})
$("#modalSubmit").unbind('click').click(function () {
save(id)
})
if (id == -1) {
var group = {}
} else {
api.groupId.get(id)
.success(function (group) {
$("#name").val(group.name)
$.each(group.targets, function (i, record) {
targets.DataTable()
.row.add([
escapeHtml(record.first_name),
escapeHtml(record.last_name),
escapeHtml(record.email),
escapeHtml(record.position),
'<span style="cursor:pointer;"><i class="fa fa-trash-o"></i></span>'
]).draw()
});
})
.error(function () {
errorFlash("Error fetching group")
})
}
// Handle file uploads
$("#csvupload").fileupload({
url: "/api/import/group",
dataType: "json",
beforeSend: function (xhr) {
xhr.setRequestHeader('Authorization', 'Bearer ' + user.api_key);
},
add: function (e, data) {
$("#modal\\.flashes").empty()
var acceptFileTypes = /(csv|txt)$/i;
var filename = data.originalFiles[0]['name']
if (filename && !acceptFileTypes.test(filename.split(".").pop())) {
modalError("Unsupported file extension (use .csv or .txt)")
return false;
}
data.submit();
},
done: function (e, data) {
$.each(data.result, function (i, record) {
addTarget(
record.first_name,
record.last_name,
record.email,
record.position);
});
targets.DataTable().draw();
}
})
}
var downloadCSVTemplate = function () {
var csvScope = [{
'First Name': 'Example',
'Last Name': 'User',
'Email': 'foobar@example.com',
'Position': 'Systems Administrator'
}]
var filename = 'group_template.csv'
var csvString = Papa.unparse(csvScope, {})
var csvData = new Blob([csvString], {
type: 'text/csv;charset=utf-8;'
});
if (navigator.msSaveBlob) {
navigator.msSaveBlob(csvData, filename);
} else {
var csvURL = window.URL.createObjectURL(csvData);
var dlLink = document.createElement('a');
dlLink.href = csvURL;
dlLink.setAttribute('download', filename)
document.body.appendChild(dlLink)
dlLink.click();
document.body.removeChild(dlLink)
}
}
var deleteGroup = function (id) {
var group = groups.find(function (x) {
return x.id === id
})
if (!group) {
return
}
swal({
title: "Are you sure?",
text: "This will delete the group. This can't be undone!",
type: "warning",
animation: false,
showCancelButton: true,
confirmButtonText: "Delete " + escapeHtml(group.name),
confirmButtonColor: "#428bca",
reverseButtons: true,
allowOutsideClick: false,
preConfirm: function () {
return new Promise(function (resolve, reject) {
api.groupId.delete(id)
.success(function (msg) {
resolve()
})
.error(function (data) {
reject(data.responseJSON.message)
})
})
}
}).then(function () {
swal(
'Group Deleted!',
'This group has been deleted!',
'success'
);
$('button:contains("OK")').on('click', function () {
location.reload()
})
})
}
function addTarget(firstNameInput, lastNameInput, emailInput, positionInput) {
// Create new data row.
var email = escapeHtml(emailInput).toLowerCase();
var newRow = [
escapeHtml(firstNameInput),
escapeHtml(lastNameInput),
email,
escapeHtml(positionInput),
'<span style="cursor:pointer;"><i class="fa fa-trash-o"></i></span>'
];
// Check table to see if email already exists.
var targetsTable = targets.DataTable();
var existingRowIndex = targetsTable
.column(2, {
order: "index"
}) // Email column has index of 2
.data()
.indexOf(email);
// Update or add new row as necessary.
if (existingRowIndex >= 0) {
targetsTable
.row(existingRowIndex, {
order: "index"
})
.data(newRow);
} else {
targetsTable.row.add(newRow);
}
}
function load() {
$("#groupTable").hide()
$("#emptyMessage").hide()
$("#loading").show()
api.groups.summary()
.success(function (response) {
$("#loading").hide()
if (response.total > 0) {
groups = response.groups
$("#emptyMessage").hide()
$("#groupTable").show()
var groupTable = $("#groupTable").DataTable({
destroy: true,
columnDefs: [{
orderable: false,
targets: "no-sort"
}]
});
groupTable.clear();
$.each(groups, function (i, group) {
groupTable.row.add([
escapeHtml(group.name),
escapeHtml(group.num_targets),
moment(group.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(" + group.id + ")'>\
<i class='fa fa-pencil'></i>\
</button>\
<button class='btn btn-danger' onclick='deleteGroup(" + group.id + ")'>\
<i class='fa fa-trash-o'></i>\
</button></div>"
]).draw()
})
} else {
$("#emptyMessage").show()
}
})
.error(function () {
errorFlash("Error fetching groups")
})
}
$(document).ready(function () {
load()
// Setup the event listeners
// Handle manual additions
$("#targetForm").submit(function () {
addTarget(
$("#firstName").val(),
$("#lastName").val(),
$("#email").val(),
$("#position").val());
targets.DataTable().draw();
// Reset user input.
$("#targetForm>div>input").val('');
$("#firstName").focus();
return false;
});
// Handle Deletion
$("#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)
});

View file

@ -1,28 +1,25 @@
var groups = []
let users = []
// 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
// Save attempts to POST or PUT to /users/
const save = (id) => {
// Validate that the passwords match
if ($("#password").val() !== $("#confirm_password").val()) {
modalError("Passwords must match.")
return
}
// Submit the group
let user = {
username: $("#username").val(),
password: $("#password").val(),
role: $("#role").val()
}
// Submit the user
if (id != -1) {
// If we're just editing an existing group,
// we need to PUT /groups/:id
group.id = id
api.groupId.put(group)
// If we're just editing an existing user,
// we need to PUT /user/:id
user.id = id
api.userId.put(user)
.success(function (data) {
successFlash("Group updated successfully!")
successFlash(`User ${user.username} updated successfully!`)
load()
dismiss()
$("#modal").modal('hide')
@ -31,11 +28,11 @@ function save(id) {
modalError(data.responseJSON.message)
})
} else {
// Else, if this is a new group, POST it
// to /groups
api.groups.post(group)
// Else, if this is a new user, POST it
// to /user
api.users.post(user)
.success(function (data) {
successFlash("Group added successfully!")
successFlash(`User ${user.username} registered successfully!`)
load()
dismiss()
$("#modal").modal('hide')
@ -46,133 +43,65 @@ function save(id) {
}
}
function dismiss() {
$("#targetsTable").dataTable().DataTable().clear().draw()
$("#name").val("")
const dismiss = () => {
$("#username").val("")
$("#password").val("")
$("#confirm_password").val("")
$("#role").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"
}]
})
$("#modalSubmit").unbind('click').click(function () {
const edit = (id) => {
$("#modalSubmit").unbind('click').click(() => {
save(id)
})
$("#role").select2()
if (id == -1) {
var group = {}
$("#role").val("user")
$("#role").trigger("change")
} else {
api.groupId.get(id)
.success(function (group) {
$("#name").val(group.name)
$.each(group.targets, function (i, record) {
targets.DataTable()
.row.add([
escapeHtml(record.first_name),
escapeHtml(record.last_name),
escapeHtml(record.email),
escapeHtml(record.position),
'<span style="cursor:pointer;"><i class="fa fa-trash-o"></i></span>'
]).draw()
});
api.userId.get(id)
.success(function (user) {
$("#username").val(user.username)
$("#role").val(user.role.slug)
$("#role").trigger("change")
})
.error(function () {
errorFlash("Error fetching group")
errorFlash("Error fetching user")
})
}
// Handle file uploads
$("#csvupload").fileupload({
url: "/api/import/group",
dataType: "json",
beforeSend: function (xhr) {
xhr.setRequestHeader('Authorization', 'Bearer ' + user.api_key);
},
add: function (e, data) {
$("#modal\\.flashes").empty()
var acceptFileTypes = /(csv|txt)$/i;
var filename = data.originalFiles[0]['name']
if (filename && !acceptFileTypes.test(filename.split(".").pop())) {
modalError("Unsupported file extension (use .csv or .txt)")
return false;
}
data.submit();
},
done: function (e, data) {
$.each(data.result, function (i, record) {
addTarget(
record.first_name,
record.last_name,
record.email,
record.position);
});
targets.DataTable().draw();
}
})
}
var downloadCSVTemplate = function () {
var csvScope = [{
'First Name': 'Example',
'Last Name': 'User',
'Email': 'foobar@example.com',
'Position': 'Systems Administrator'
}]
var filename = 'group_template.csv'
var csvString = Papa.unparse(csvScope, {})
var csvData = new Blob([csvString], {
type: 'text/csv;charset=utf-8;'
});
if (navigator.msSaveBlob) {
navigator.msSaveBlob(csvData, filename);
} else {
var csvURL = window.URL.createObjectURL(csvData);
var dlLink = document.createElement('a');
dlLink.href = csvURL;
dlLink.setAttribute('download', filename)
document.body.appendChild(dlLink)
dlLink.click();
document.body.removeChild(dlLink)
}
}
var deleteGroup = function (id) {
var group = groups.find(function (x) {
return x.id === id
})
if (!group) {
const deleteUser = (id) => {
var user = users.find(x => x.id == id)
if (!user) {
return
}
swal({
title: "Are you sure?",
text: "This will delete the group. This can't be undone!",
text: `This will delete the account for ${user.username} as well as all of the objects they have created.\n\nThis can't be undone!`,
type: "warning",
animation: false,
showCancelButton: true,
confirmButtonText: "Delete " + escapeHtml(group.name),
confirmButtonText: "Delete",
confirmButtonColor: "#428bca",
reverseButtons: true,
allowOutsideClick: false,
preConfirm: function () {
return new Promise(function (resolve, reject) {
api.groupId.delete(id)
.success(function (msg) {
return new Promise((resolve, reject) => {
api.userId.delete(id)
.success((msg) => {
resolve()
})
.error(function (data) {
.error((data) => {
reject(data.responseJSON.message)
})
})
}
}).then(function () {
swal(
'Group Deleted!',
'This group has been deleted!',
'User Deleted!',
`The user account for ${user.username} and all associated objects have been deleted!`,
'success'
);
$('button:contains("OK")').on('click', function () {
@ -181,104 +110,69 @@ var deleteGroup = function (id) {
})
}
function addTarget(firstNameInput, lastNameInput, emailInput, positionInput) {
// Create new data row.
var email = escapeHtml(emailInput).toLowerCase();
var newRow = [
escapeHtml(firstNameInput),
escapeHtml(lastNameInput),
email,
escapeHtml(positionInput),
'<span style="cursor:pointer;"><i class="fa fa-trash-o"></i></span>'
];
// Check table to see if email already exists.
var targetsTable = targets.DataTable();
var existingRowIndex = targetsTable
.column(2, {
order: "index"
}) // Email column has index of 2
.data()
.indexOf(email);
// Update or add new row as necessary.
if (existingRowIndex >= 0) {
targetsTable
.row(existingRowIndex, {
order: "index"
})
.data(newRow);
} else {
targetsTable.row.add(newRow);
}
}
function load() {
$("#groupTable").hide()
$("#emptyMessage").hide()
const load = () => {
$("#userTable").hide()
$("#loading").show()
api.groups.summary()
.success(function (response) {
api.users.get()
.success((us) => {
users = us
$("#loading").hide()
if (response.total > 0) {
groups = response.groups
$("#emptyMessage").hide()
$("#groupTable").show()
var groupTable = $("#groupTable").DataTable({
destroy: true,
columnDefs: [{
orderable: false,
targets: "no-sort"
}]
});
groupTable.clear();
$.each(groups, function (i, group) {
groupTable.row.add([
escapeHtml(group.name),
escapeHtml(group.num_targets),
moment(group.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(" + group.id + ")'>\
$("#userTable").show()
let userTable = $("#userTable").DataTable({
destroy: true,
columnDefs: [{
orderable: false,
targets: "no-sort"
}]
});
userTable.clear();
$.each(users, (i, user) => {
userTable.row.add([
escapeHtml(user.username),
escapeHtml(user.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='" + user.id + "'>\
<i class='fa fa-pencil'></i>\
</button>\
<button class='btn btn-danger' onclick='deleteGroup(" + group.id + ")'>\
<button class='btn btn-danger delete_button' data-user-id='" + user.id + "'>\
<i class='fa fa-trash-o'></i>\
</button></div>"
]).draw()
})
} else {
$("#emptyMessage").show()
}
]).draw()
})
})
.error(function () {
errorFlash("Error fetching groups")
.error(() => {
errorFlash("Error fetching users")
})
}
$(document).ready(function () {
load()
// Setup the event listeners
// Handle manual additions
$("#targetForm").submit(function () {
addTarget(
$("#firstName").val(),
$("#lastName").val(),
$("#email").val(),
$("#position").val());
targets.DataTable().draw();
// Reset user input.
$("#targetForm>div>input").val('');
$("#firstName").focus();
return false;
});
// Handle Deletion
$("#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)
// Select2 Defaults
$.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 (data) {
return data.sort(function (a, b) {
if (a.text.toLowerCase() > b.text.toLowerCase()) {
return 1;
}
if (a.text.toLowerCase() < b.text.toLowerCase()) {
return -1;
}
return 0;
});
})
$("#new_button").on("click", function () {
edit(-1)
})
$("#userTable").on('click', '.edit_button', function (e) {
edit($(this).attr('data-user-id'))
})
$("#userTable").on('click', '.delete_button', function (e) {
deleteUser($(this).attr('data-user-id'))
})
});

106
templates/groups.html Normal file
View file

@ -0,0 +1,106 @@
{{define "body"}}
<div class="col-sm-9 col-sm-offset-3 col-md-10 col-md-offset-2 main">
<div class="row">
<h1 class="page-header">
Users &amp; Groups
</h1>
</div>
<div id="flashes" class="row"></div>
<div class="row">
<button type="button" class="btn btn-primary" onclick="edit(-1)" data-toggle="modal" data-backdrop="static"
data-target="#modal">
<i class="fa fa-plus"></i> New Group</button>
</div>
&nbsp;
<div id="loading">
<i class="fa fa-spinner fa-spin fa-4x"></i>
</div>
<div id="emptyMessage" class="row" style="display:none;">
<div class="alert alert-info">
No groups created yet. Let's create one!
</div>
</div>
<div class="row">
<table id="groupTable" class="table" style="display:none;">
<thead>
<tr>
<th>Name</th>
<th># of Members</th>
<th>Modified Date</th>
<th class="col-md-2 no-sort"></th>
</tr>
</thead>
<tbody>
</tbody>
</table>
</div>
</div>
<!-- Modal -->
<div class="modal fade" id="modal" tabindex="-1" role="dialog" aria-labelledby="modalLabel">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
<h4 class="modal-title" id="groupModalLabel">New Group</h4>
</div>
<div class="modal-body">
<div class="row" id="modal.flashes"></div>
<label class="control-label" for="name">Name:</label>
<div class="form-group">
<input type="text" class="form-control" ng-model="group.name" placeholder="Group name" id="name"
autofocus />
</div>
<div class="form-group">
<span class="btn btn-danger btn-file" data-toggle="tooltip" data-placement="right"
title="Supports CSV files" id="fileUpload">
<i class="fa fa-plus"></i> Bulk Import Users
<input type="file" id="csvupload" multiple>
</span>
<span id="csv-template" class="text-muted small">
<i class="fa fa-file-excel-o"></i> Download CSV Template</span>
</div>
<div class="row">
<form id="targetForm">
<div class="col-sm-2">
<input type="text" class="form-control" placeholder="First Name" id="firstName">
</div>
<div class="col-sm-2">
<input type="text" class="form-control" placeholder="Last Name" id="lastName">
</div>
<div class="col-sm-3">
<input type="email" class="form-control" placeholder="Email" id="email" required>
</div>
<div class="col-sm-3">
<input type="text" class="form-control" placeholder="Position" id="position">
</div>
<div class="col-sm-1">
<button type="submit" class="btn btn-danger btn-lg">
<i class="fa fa-plus"></i> Add</button>
</div>
</form>
</div>
<br />
<table id="targetsTable" class="table table-hover table-striped table-condensed">
<thead>
<tr>
<th>First Name</th>
<th>Last Name</th>
<th>Email</th>
<th>Position</th>
<th class="no-sort"></th>
<tbody>
</tbody>
</table>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" data-dismiss="modal">Close</button>
<button type="button" class="btn btn-primary" id="modalSubmit">Save changes</button>
</div>
</div>
</div>
</div>
{{end}} {{define "scripts"}}
<script src="/js/dist/app/groups.min.js"></script>
{{end}}

View file

@ -10,7 +10,7 @@
<a href="/campaigns">Campaigns</a>
</li>
<li>
<a href="/users">Users &amp; Groups</a>
<a href="/groups">Users &amp; Groups</a>
</li>
<li> <a href="/templates">Email Templates</a>
</li>
@ -21,8 +21,13 @@
<a href="/sending_profiles">Sending Profiles</a>
</li>
<li>
<a href="/settings">Settings <span class="badge pull-right">Admin</span></a>
<a href="/settings">Account Settings</span></a>
</li>
{{if .ModifySystem}}
<li>
<a href="/users">User Management<span class="nav-badge badge pull-right">Admin</span></a>
</li>
{{end}}
<li>
<hr>
</li>

View file

@ -1,62 +0,0 @@
{{ 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="/favicon.png">
<title>Gophish - {{ .Title }}</title>
<link href="/css/dist/gophish.css" rel='stylesheet' type='text/css'>
<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>
<a id="login-button" href="/login">
<button type="button" class="btn btn-primary">Login</button>
</a>
</li>
</ul>
</div>
</div>
</div>
<div class="container">
<form class="form-signin" action="/register" method="POST">
<img id="logo" src="/images/logo_purple.png" />
<h2 class="form-signin-heading">Please register below</h2>
{{template "flashes" .Flashes}}
<input type="text" name="username" class="form-control top-input" placeholder="Username" required autofocus />
<input type="password" name="password" class="form-control middle-input" placeholder="Password"
autocomplete="off" required />
<input type="password" name="confirm_password" class="form-control bottom-input" placeholder="Confirm Password"
autocomplete="off" required />
<input type="hidden" name="csrf_token" value="{{.Token}}" />
<button class="btn btn-lg btn-primary btn-block" type="submit">Register</button>
</form>
</div>
<!-- Placed at the end of the document so the pages load faster -->
<script src="/js/dist/vendor.min.js"></script>
</body>
</html>
{{ end }}

View file

@ -8,7 +8,8 @@
<ul class="nav nav-tabs" role="tablist">
<li class="active" role="mainSettings"><a href="#mainSettings" aria-controls="mainSettings" role="tab"
data-toggle="tab">Account Settings</a></li>
<li role="uiSettings"><a href="#uiSettings" aria-controls="uiSettings" role="tab" data-toggle="tab">UI Settings</a></li>
<li role="uiSettings"><a href="#uiSettings" aria-controls="uiSettings" role="tab" data-toggle="tab">UI
Settings</a></li>
</ul>
<!-- Tab Panes -->
<div class="tab-content">
@ -22,19 +23,12 @@
</div>
</div>
<br />
<div class="row">
<label class="col-sm-2 control-label form-label">Register a New User</label>
<div class="col-md-6">
<a href="/register" class="btn btn-primary"><i class="fa fa-plus"></i> Add User</a>
</div>
</div>
<br />
{{end}}
<div class="row">
<label for="api_key" class="col-sm-2 control-label form-label">API Key:</label>
<div class="col-md-6">
<input type="text" id="api_key" onclick="this.select();" value="{{.User.ApiKey}}" class="form-control"
readonly />
<input type="text" id="api_key" onclick="this.select();" value="{{.User.ApiKey}}"
class="form-control" readonly />
</div>
<form id="apiResetForm">
<button class="btn btn-primary"><i class="fa fa-refresh" type="submit"></i> Reset</button>
@ -46,26 +40,30 @@
<div class="row">
<label for="username" class="col-sm-2 control-label form-label">Username:</label>
<div class="col-md-6">
<input type="text" id="username" name="username" value="{{.User.Username}}" class="form-control" />
<input type="text" id="username" name="username" value="{{.User.Username}}"
class="form-control" />
</div>
</div>
<br />
<div class="row">
<label for="current_password" class="col-sm-2 control-label form-label">Old Password:</label>
<div class="col-md-6">
<input type="password" id="current_password" name="current_password" autocomplete="off" class="form-control" />
<input type="password" id="current_password" name="current_password" autocomplete="off"
class="form-control" />
</div>
</div>
<br />
<div class="row">
<label for="new_password" class="col-sm-2 control-label form-label">New Password:</label>
<div class="col-md-6">
<input type="password" id="new_password" name="new_password" autocomplete="off" class="form-control" />
<input type="password" id="new_password" name="new_password" autocomplete="off"
class="form-control" />
</div>
</div>
<br />
<div class="row">
<label for="confirm_new_password" class="col-sm-2 control-label form-label">Confirm New Password:</label>
<label for="confirm_new_password" class="col-sm-2 control-label form-label">Confirm New
Password:</label>
<div class="col-md-6">
<input type="password" id="confirm_new_password" name="confirm_new_password" autocomplete="off"
class="form-control" />

View file

@ -2,31 +2,25 @@
<div class="col-sm-9 col-sm-offset-3 col-md-10 col-md-offset-2 main">
<div class="row">
<h1 class="page-header">
Users &amp; Groups
{{.Title}}
</h1>
</div>
<div id="flashes" class="row"></div>
<div class="row">
<button type="button" class="btn btn-primary" onclick="edit(-1)" data-toggle="modal" data-backdrop="static"
data-target="#modal">
<i class="fa fa-plus"></i> New Group</button>
<button type="button" class="btn btn-primary" id="new_button" data-toggle="modal" data-backdrop="static"
data-user-id="-1" data-target="#modal">
<i class="fa fa-plus"></i> New User</button>
</div>
&nbsp;
<div id="loading">
<i class="fa fa-spinner fa-spin fa-4x"></i>
</div>
<div id="emptyMessage" class="row" style="display:none;">
<div class="alert alert-info">
No groups created yet. Let's create one!
</div>
</div>
<div class="row">
<table id="groupTable" class="table" style="display:none;">
<table id="userTable" class="table" style="display:none;">
<thead>
<tr>
<th>Name</th>
<th># of Members</th>
<th>Modified Date</th>
<th>Username</th>
<th>Role</th>
<th class="col-md-2 no-sort"></th>
</tr>
</thead>
@ -43,56 +37,30 @@
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
<h4 class="modal-title" id="groupModalLabel">New Group</h4>
<h4 class="modal-title" id="groupModalLabel">New User</h4>
</div>
<div class="modal-body">
<div class="modal-body" id="modal_body">
<div class="row" id="modal.flashes"></div>
<label class="control-label" for="name">Name:</label>
<label class="control-label" for="username">Username:</label>
<div class="form-group">
<input type="text" class="form-control" ng-model="group.name" placeholder="Group name" id="name"
autofocus />
<input type="text" class="form-control" placeholder="Username" id="username" autofocus />
</div>
<label class="control-label" for="password">Password:</label>
<div class="form-group">
<span class="btn btn-danger btn-file" data-toggle="tooltip" data-placement="right" title="Supports CSV files"
id="fileUpload">
<i class="fa fa-plus"></i> Bulk Import Users
<input type="file" id="csvupload" multiple>
</span>
<span id="csv-template" class="text-muted small">
<i class="fa fa-file-excel-o"></i> Download CSV Template</span>
<input type="password" class="form-control" placeholder="Password" id="password" required />
</div>
<div class="row">
<form id="targetForm">
<div class="col-sm-2">
<input type="text" class="form-control" placeholder="First Name" id="firstName">
</div>
<div class="col-sm-2">
<input type="text" class="form-control" placeholder="Last Name" id="lastName">
</div>
<div class="col-sm-3">
<input type="email" class="form-control" placeholder="Email" id="email" required>
</div>
<div class="col-sm-3">
<input type="text" class="form-control" placeholder="Position" id="position">
</div>
<div class="col-sm-1">
<button type="submit" class="btn btn-danger btn-lg">
<i class="fa fa-plus"></i> Add</button>
</div>
</form>
<label class="control-label" for="confirm_password">Confirm Password:</label>
<div class="form-group">
<input type="password" class="form-control" placeholder="Confirm Password" id="confirm_password"
required />
</div>
<label class="control-label" for="role">Role:</label>
<div class="form-group" id="role-select">
<select class="form-control" placeholder="" id="role" />
<option value="admin">Admin</option>
<option value="user">User</option>
</select>
</div>
<br />
<table id="targetsTable" class="table table-hover table-striped table-condensed">
<thead>
<tr>
<th>First Name</th>
<th>Last Name</th>
<th>Email</th>
<th>Position</th>
<th class="no-sort"></th>
<tbody>
</tbody>
</table>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" data-dismiss="modal">Close</button>

View file

@ -21,6 +21,7 @@ import (
log "github.com/gophish/gophish/logger"
"github.com/gophish/gophish/models"
"github.com/jordan-wright/email"
"golang.org/x/crypto/bcrypt"
)
var (
@ -190,3 +191,21 @@ func CheckAndCreateSSL(cp string, kp string) error {
log.Info("TLS Certificate Generation complete")
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
}

21
webpack.config.js Normal file
View file

@ -0,0 +1,21 @@
const path = require('path');
module.exports = {
context: path.resolve(__dirname, 'static', 'js', 'src', 'app'),
entry: {
users: './users',
},
output: {
path: path.resolve(__dirname, 'static', 'js', 'dist', 'app'),
filename: '[name].min.js'
},
module: {
rules: [{
test: /\.js$/,
exclude: /node_modules/,
use: {
loader: "babel-loader"
}
}]
}
}

2352
yarn.lock

File diff suppressed because it is too large Load diff