mirror of https://github.com/gophish/gophish
Merged into master
commit
74460f4d96
|
@ -0,0 +1,151 @@
|
||||||
|
name: Build Gophish Release
|
||||||
|
on:
|
||||||
|
release:
|
||||||
|
types: [created]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
name: Build Binary
|
||||||
|
runs-on: ${{ matrix.os }}
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
os: [windows-latest, ubuntu-latest, macos-latest]
|
||||||
|
arch: ['386', amd64]
|
||||||
|
# We sometimes use different verbiage for things (e.g. "darwin"
|
||||||
|
# for the GOOS build flag and "osx" in the actual release ZIP).
|
||||||
|
# We need to specify those here.
|
||||||
|
include:
|
||||||
|
- os: windows-latest
|
||||||
|
goos: windows
|
||||||
|
bin: 'gophish.exe'
|
||||||
|
releaseos: windows
|
||||||
|
- os: ubuntu-latest
|
||||||
|
goos: linux
|
||||||
|
bin: 'gophish'
|
||||||
|
releaseos: linux
|
||||||
|
- os: macos-latest
|
||||||
|
goos: darwin
|
||||||
|
bin: 'gophish'
|
||||||
|
releaseos: osx
|
||||||
|
# Don't build windows-32bit due to missing MinGW dependencies
|
||||||
|
# Don't build osx-32bit due to eventual drop in Go support
|
||||||
|
exclude:
|
||||||
|
- os: windows-latest
|
||||||
|
arch: '386'
|
||||||
|
- os: macos-latest
|
||||||
|
arch: '386'
|
||||||
|
steps:
|
||||||
|
- name: Set up Go
|
||||||
|
uses: actions/setup-go@v2
|
||||||
|
with:
|
||||||
|
go-version: 1.14
|
||||||
|
- if: matrix.os == 'ubuntu-latest'
|
||||||
|
run: sudo apt-get update && sudo apt-get install -y gcc-multilib
|
||||||
|
- if: matrix.arch == '386'
|
||||||
|
run: echo "::set-env name=RELEASE::gophish-${{ github.event.release.tag_name }}-${{ matrix.releaseos}}-32bit"
|
||||||
|
- if: matrix.arch == 'amd64'
|
||||||
|
run: echo "::set-env name=RELEASE::gophish-${{ github.event.release.tag_name}}-${{ matrix.releaseos}}-64bit"
|
||||||
|
- uses: actions/checkout@v2
|
||||||
|
- name: Build ${{ matrix.goos }}/${{ matrix.arch }}
|
||||||
|
run: go build -o ${{ matrix.bin }}
|
||||||
|
env:
|
||||||
|
GOOS: ${{ matrix.goos }}
|
||||||
|
GOARCH: ${{ matrix.arch }}
|
||||||
|
CGO_ENABLED: 1
|
||||||
|
- name: Upload to artifacts
|
||||||
|
uses: actions/upload-artifact@v2
|
||||||
|
with:
|
||||||
|
name: ${{ env.RELEASE }}
|
||||||
|
path: ${{ matrix.bin }}
|
||||||
|
|
||||||
|
package:
|
||||||
|
name: Package Assets
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: build
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v2
|
||||||
|
- uses: actions/download-artifact@v2
|
||||||
|
with:
|
||||||
|
path: bin
|
||||||
|
- name: Package Releases
|
||||||
|
run: |
|
||||||
|
mkdir releases;
|
||||||
|
for RELEASE_DIR in bin/*
|
||||||
|
do
|
||||||
|
echo "Creating release $RELEASE_DIR"
|
||||||
|
for BINARY in $RELEASE_DIR/*
|
||||||
|
do
|
||||||
|
cp $BINARY .;
|
||||||
|
zip -r releases/$(basename $RELEASE_DIR).zip \
|
||||||
|
$(basename ${BINARY}) \
|
||||||
|
static/js/dist \
|
||||||
|
static/js/src/vendor/ckeditor \
|
||||||
|
static/css/dist \
|
||||||
|
static/images \
|
||||||
|
static/font \
|
||||||
|
static/db \
|
||||||
|
db \
|
||||||
|
templates \
|
||||||
|
README.md \
|
||||||
|
VERSION \
|
||||||
|
LICENSE \
|
||||||
|
config.json;
|
||||||
|
rm $BINARY;
|
||||||
|
done
|
||||||
|
done
|
||||||
|
- name: Upload to artifacts
|
||||||
|
uses: actions/upload-artifact@v2
|
||||||
|
with:
|
||||||
|
name: releases
|
||||||
|
path: releases/*.zip
|
||||||
|
|
||||||
|
upload:
|
||||||
|
name: Upload to the Release
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: package
|
||||||
|
steps:
|
||||||
|
- uses: actions/download-artifact@v2
|
||||||
|
with:
|
||||||
|
name: releases
|
||||||
|
path: releases/
|
||||||
|
# I would love to use @actions/upload-release-asset, but they don't
|
||||||
|
# support wildcards in the asset path. Ref #9, #24, and #47
|
||||||
|
- name: Upload Archives to Release
|
||||||
|
env:
|
||||||
|
UPLOAD_URL: ${{ github.event.release.upload_url }}
|
||||||
|
API_HEADER: "Accept: application/vnd.github.v3+json"
|
||||||
|
AUTH_HEADER: "Authorization: token ${{ secrets.GITHUB_TOKEN }}"
|
||||||
|
run: |
|
||||||
|
UPLOAD_URL=$(echo -n $UPLOAD_URL | sed s/\{.*//g)
|
||||||
|
for FILE in releases/*
|
||||||
|
do
|
||||||
|
echo "Uploading ${FILE}";
|
||||||
|
curl \
|
||||||
|
-H "${API_HEADER}" \
|
||||||
|
-H "${AUTH_HEADER}" \
|
||||||
|
-H "Content-Type: $(file -b --mime-type ${FILE})" \
|
||||||
|
--data-binary "@${FILE}" \
|
||||||
|
"${UPLOAD_URL}?name=$(basename ${FILE})";
|
||||||
|
done
|
||||||
|
- name: Generate SHA256 Hashes
|
||||||
|
env:
|
||||||
|
API_HEADER: "Accept: application/vnd.github.v3+json"
|
||||||
|
AUTH_HEADER: "Authorization: token ${{ secrets.GITHUB_TOKEN }}"
|
||||||
|
RELEASE_URL: ${{ github.event.release.url }}
|
||||||
|
run: |
|
||||||
|
HASH_TABLE="| SHA256 Hash | Filename |"
|
||||||
|
HASH_TABLE="${HASH_TABLE}\n|-----|-----|\n"
|
||||||
|
for FILE in releases/*
|
||||||
|
do
|
||||||
|
FILENAME=$(basename ${FILE})
|
||||||
|
HASH=$(sha256sum ${FILE} | cut -d ' ' -f 1)
|
||||||
|
HASH_TABLE="${HASH_TABLE}|${HASH}|${FILENAME}|\n"
|
||||||
|
done
|
||||||
|
echo "${HASH_TABLE}"
|
||||||
|
curl \
|
||||||
|
-XPATCH \
|
||||||
|
-H "${API_HEADER}" \
|
||||||
|
-H "${AUTH_HEADER}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{\"body\": \"${HASH_TABLE}\"}" \
|
||||||
|
"${RELEASE_URL}";
|
124
auth/auth.go
124
auth/auth.go
|
@ -1,69 +1,103 @@
|
||||||
package auth
|
package auth
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/rand"
|
||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"fmt"
|
||||||
|
"io"
|
||||||
|
|
||||||
ctx "github.com/gophish/gophish/context"
|
|
||||||
"github.com/gophish/gophish/models"
|
|
||||||
"golang.org/x/crypto/bcrypt"
|
"golang.org/x/crypto/bcrypt"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// MinPasswordLength is the minimum number of characters required in a password
|
||||||
|
const MinPasswordLength = 8
|
||||||
|
|
||||||
|
// APIKeyLength is the length of Gophish API keys
|
||||||
|
const APIKeyLength = 32
|
||||||
|
|
||||||
// ErrInvalidPassword is thrown when a user provides an incorrect password.
|
// ErrInvalidPassword is thrown when a user provides an incorrect password.
|
||||||
var ErrInvalidPassword = errors.New("Invalid Password")
|
var ErrInvalidPassword = errors.New("Invalid Password")
|
||||||
|
|
||||||
// ErrPasswordMismatch is thrown when a user provides a blank password to the register
|
// ErrPasswordMismatch is thrown when a user provides a mismatching password
|
||||||
// or change password functions
|
// and confirmation password.
|
||||||
var ErrPasswordMismatch = errors.New("Password cannot be blank")
|
var ErrPasswordMismatch = errors.New("Passwords do not match")
|
||||||
|
|
||||||
|
// ErrReusedPassword is thrown when a user attempts to change their password to
|
||||||
|
// the existing password
|
||||||
|
var ErrReusedPassword = errors.New("Cannot reuse existing password")
|
||||||
|
|
||||||
// ErrEmptyPassword is thrown when a user provides a blank password to the register
|
// ErrEmptyPassword is thrown when a user provides a blank password to the register
|
||||||
// or change password functions
|
// or change password functions
|
||||||
var ErrEmptyPassword = errors.New("No password provided")
|
var ErrEmptyPassword = errors.New("No password provided")
|
||||||
|
|
||||||
// Login attempts to login the user given a request.
|
// ErrPasswordTooShort is thrown when a user provides a password that is less
|
||||||
func Login(r *http.Request) (bool, models.User, error) {
|
// than MinPasswordLength
|
||||||
username, password := r.FormValue("username"), r.FormValue("password")
|
var ErrPasswordTooShort = fmt.Errorf("Password must be at least %d characters", MinPasswordLength)
|
||||||
u, err := models.GetUserByUsername(username)
|
|
||||||
if err != nil {
|
// GenerateSecureKey returns the hex representation of key generated from n
|
||||||
return false, models.User{}, err
|
// random bytes
|
||||||
}
|
func GenerateSecureKey(n int) string {
|
||||||
//If we've made it here, we should have a valid user stored in u
|
k := make([]byte, n)
|
||||||
//Let's check the password
|
io.ReadFull(rand.Reader, k)
|
||||||
err = bcrypt.CompareHashAndPassword([]byte(u.Hash), []byte(password))
|
return fmt.Sprintf("%x", k)
|
||||||
if err != nil {
|
|
||||||
return false, models.User{}, ErrInvalidPassword
|
|
||||||
}
|
|
||||||
return true, u, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ChangePassword verifies the current password provided in the request and,
|
// GeneratePasswordHash returns the bcrypt hash for the provided password using
|
||||||
// if it's valid, changes the password for the authenticated user.
|
// the default bcrypt cost.
|
||||||
func ChangePassword(r *http.Request) error {
|
func GeneratePasswordHash(password string) (string, error) {
|
||||||
u := ctx.Get(r, "user").(models.User)
|
h, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||||
currentPw := r.FormValue("current_password")
|
|
||||||
newPassword := r.FormValue("new_password")
|
|
||||||
confirmPassword := r.FormValue("confirm_new_password")
|
|
||||||
// Check the current password
|
|
||||||
err := bcrypt.CompareHashAndPassword([]byte(u.Hash), []byte(currentPw))
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ErrInvalidPassword
|
return "", err
|
||||||
}
|
}
|
||||||
// Check that the new password isn't blank
|
return string(h), nil
|
||||||
if newPassword == "" {
|
}
|
||||||
|
|
||||||
|
// CheckPasswordPolicy ensures the provided password is valid according to our
|
||||||
|
// password policy.
|
||||||
|
//
|
||||||
|
// The current password policy is simply a minimum of 8 characters, though this
|
||||||
|
// may change in the future (see #1538).
|
||||||
|
func CheckPasswordPolicy(password string) error {
|
||||||
|
switch {
|
||||||
|
// Admittedly, empty passwords are a subset of too short passwords, but it
|
||||||
|
// helps to provide a more specific error message
|
||||||
|
case password == "":
|
||||||
return ErrEmptyPassword
|
return ErrEmptyPassword
|
||||||
}
|
case len(password) < MinPasswordLength:
|
||||||
// Check that new passwords match
|
return ErrPasswordTooShort
|
||||||
if newPassword != confirmPassword {
|
|
||||||
return ErrPasswordMismatch
|
|
||||||
}
|
|
||||||
// Generate the new hash
|
|
||||||
h, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
u.Hash = string(h)
|
|
||||||
if err = models.PutUser(&u); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ValidatePassword validates that the provided password matches the provided
|
||||||
|
// bcrypt hash.
|
||||||
|
func ValidatePassword(password string, hash string) error {
|
||||||
|
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidatePasswordChange validates that the new password matches the
|
||||||
|
// configured password policy, that the new password and confirmation
|
||||||
|
// password match.
|
||||||
|
//
|
||||||
|
// Note that this assumes the current password has been confirmed by the
|
||||||
|
// caller.
|
||||||
|
//
|
||||||
|
// If all of the provided data is valid, then the hash of the new password is
|
||||||
|
// returned.
|
||||||
|
func ValidatePasswordChange(currentHash, newPassword, confirmPassword string) (string, error) {
|
||||||
|
// Ensure the new password passes our password policy
|
||||||
|
if err := CheckPasswordPolicy(newPassword); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
// Check that new passwords match
|
||||||
|
if newPassword != confirmPassword {
|
||||||
|
return "", ErrPasswordMismatch
|
||||||
|
}
|
||||||
|
// Make sure that the new password isn't the same as the old one
|
||||||
|
err := ValidatePassword(newPassword, currentHash)
|
||||||
|
if err == nil {
|
||||||
|
return "", ErrReusedPassword
|
||||||
|
}
|
||||||
|
// Generate the new hash
|
||||||
|
return GeneratePasswordHash(newPassword)
|
||||||
|
}
|
||||||
|
|
|
@ -0,0 +1,41 @@
|
||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPasswordPolicy(t *testing.T) {
|
||||||
|
candidate := "short"
|
||||||
|
got := CheckPasswordPolicy(candidate)
|
||||||
|
if got != ErrPasswordTooShort {
|
||||||
|
t.Fatalf("unexpected error received. expected %v got %v", ErrPasswordTooShort, got)
|
||||||
|
}
|
||||||
|
|
||||||
|
candidate = "valid password"
|
||||||
|
got = CheckPasswordPolicy(candidate)
|
||||||
|
if got != nil {
|
||||||
|
t.Fatalf("unexpected error received. expected %v got %v", nil, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidatePasswordChange(t *testing.T) {
|
||||||
|
newPassword := "valid password"
|
||||||
|
confirmPassword := "invalid"
|
||||||
|
currentPassword := "current password"
|
||||||
|
currentHash, err := GeneratePasswordHash(currentPassword)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error generating password hash: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, got := ValidatePasswordChange(currentHash, newPassword, confirmPassword)
|
||||||
|
if got != ErrPasswordMismatch {
|
||||||
|
t.Fatalf("unexpected error received. expected %v got %v", ErrPasswordMismatch, got)
|
||||||
|
}
|
||||||
|
|
||||||
|
newPassword = currentPassword
|
||||||
|
confirmPassword = newPassword
|
||||||
|
_, got = ValidatePasswordChange(currentHash, newPassword, confirmPassword)
|
||||||
|
if got != ErrReusedPassword {
|
||||||
|
t.Fatalf("unexpected error received. expected %v got %v", ErrReusedPassword, got)
|
||||||
|
}
|
||||||
|
}
|
|
@ -2,8 +2,9 @@ package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
log "github.com/gophish/gophish/logger"
|
|
||||||
"io/ioutil"
|
"io/ioutil"
|
||||||
|
|
||||||
|
log "github.com/gophish/gophish/logger"
|
||||||
)
|
)
|
||||||
|
|
||||||
// AdminServer represents the Admin server configuration details
|
// AdminServer represents the Admin server configuration details
|
||||||
|
@ -12,6 +13,7 @@ type AdminServer struct {
|
||||||
UseTLS bool `json:"use_tls"`
|
UseTLS bool `json:"use_tls"`
|
||||||
CertPath string `json:"cert_path"`
|
CertPath string `json:"cert_path"`
|
||||||
KeyPath string `json:"key_path"`
|
KeyPath string `json:"key_path"`
|
||||||
|
CSRFKey string `json:"csrf_key"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// PhishServer represents the Phish server configuration details
|
// PhishServer represents the Phish server configuration details
|
||||||
|
|
|
@ -62,12 +62,13 @@ func TestLoadConfig(t *testing.T) {
|
||||||
}
|
}
|
||||||
expectedConfig.MigrationsPath = expectedConfig.MigrationsPath + expectedConfig.DBName
|
expectedConfig.MigrationsPath = expectedConfig.MigrationsPath + expectedConfig.DBName
|
||||||
expectedConfig.TestFlag = false
|
expectedConfig.TestFlag = false
|
||||||
|
expectedConfig.AdminConf.CSRFKey = ""
|
||||||
if !reflect.DeepEqual(expectedConfig, conf) {
|
if !reflect.DeepEqual(expectedConfig, conf) {
|
||||||
t.Fatalf("invalid config received. expected %#v got %#v", expectedConfig, conf)
|
t.Fatalf("invalid config received. expected %#v got %#v", expectedConfig, conf)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load an invalid config
|
// Load an invalid config
|
||||||
conf, err = LoadConfig("bogusfile")
|
_, err = LoadConfig("bogusfile")
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatalf("expected error when loading invalid config, but got %v", err)
|
t.Fatalf("expected error when loading invalid config, but got %v", err)
|
||||||
}
|
}
|
||||||
|
|
|
@ -23,6 +23,4 @@ func Set(r *http.Request, key, val interface{}) *http.Request {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear is a null operation, since this is handled automatically in Go > 1.7
|
// Clear is a null operation, since this is handled automatically in Go > 1.7
|
||||||
func Clear(r *http.Request) {
|
func Clear(r *http.Request) {}
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
|
@ -42,18 +42,6 @@ func setupTest(t *testing.T) *testContext {
|
||||||
return ctx
|
return ctx
|
||||||
}
|
}
|
||||||
|
|
||||||
func tearDown(t *testing.T, ctx *testContext) {
|
|
||||||
// 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 createTestData(t *testing.T) {
|
func createTestData(t *testing.T) {
|
||||||
// Add a group
|
// Add a group
|
||||||
group := models.Group{Name: "Test Group"}
|
group := models.Group{Name: "Test Group"}
|
||||||
|
|
|
@ -87,6 +87,11 @@ func (as *Server) Group(w http.ResponseWriter, r *http.Request) {
|
||||||
// Change this to get from URL and uid (don't bother with id in r.Body)
|
// Change this to get from URL and uid (don't bother with id in r.Body)
|
||||||
g = models.Group{}
|
g = models.Group{}
|
||||||
err = json.NewDecoder(r.Body).Decode(&g)
|
err = json.NewDecoder(r.Body).Decode(&g)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("error decoding group: %v", err)
|
||||||
|
JSONResponse(w, models.Response{Success: false, Message: err.Error()}, http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
if g.Id != id {
|
if g.Id != id {
|
||||||
JSONResponse(w, models.Response{Success: false, Message: "Error: /:id and group_id mismatch"}, http.StatusInternalServerError)
|
JSONResponse(w, models.Response{Success: false, Message: "Error: /:id and group_id mismatch"}, http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
|
|
|
@ -46,7 +46,6 @@ func (as *Server) ImportGroup(w http.ResponseWriter, r *http.Request) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
JSONResponse(w, ts, http.StatusOK)
|
JSONResponse(w, ts, http.StatusOK)
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ImportEmail allows for the importing of email.
|
// ImportEmail allows for the importing of email.
|
||||||
|
@ -94,7 +93,6 @@ func (as *Server) ImportEmail(w http.ResponseWriter, r *http.Request) {
|
||||||
HTML: string(e.HTML),
|
HTML: string(e.HTML),
|
||||||
}
|
}
|
||||||
JSONResponse(w, er, http.StatusOK)
|
JSONResponse(w, er, http.StatusOK)
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ImportSite allows for the importing of HTML from a website
|
// ImportSite allows for the importing of HTML from a website
|
||||||
|
@ -153,5 +151,4 @@ func (as *Server) ImportSite(w http.ResponseWriter, r *http.Request) {
|
||||||
}
|
}
|
||||||
cs := cloneResponse{HTML: h}
|
cs := cloneResponse{HTML: h}
|
||||||
JSONResponse(w, cs, http.StatusOK)
|
JSONResponse(w, cs, http.StatusOK)
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,9 +3,9 @@ package api
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gophish/gophish/auth"
|
||||||
ctx "github.com/gophish/gophish/context"
|
ctx "github.com/gophish/gophish/context"
|
||||||
"github.com/gophish/gophish/models"
|
"github.com/gophish/gophish/models"
|
||||||
"github.com/gophish/gophish/util"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Reset (/api/reset) resets the currently authenticated user's API key
|
// Reset (/api/reset) resets the currently authenticated user's API key
|
||||||
|
@ -13,7 +13,7 @@ func (as *Server) Reset(w http.ResponseWriter, r *http.Request) {
|
||||||
switch {
|
switch {
|
||||||
case r.Method == "POST":
|
case r.Method == "POST":
|
||||||
u := ctx.Get(r, "user").(models.User)
|
u := ctx.Get(r, "user").(models.User)
|
||||||
u.ApiKey = util.GenerateSecureKey()
|
u.ApiKey = auth.GenerateSecureKey(auth.APIKeyLength)
|
||||||
err := models.PutUser(&u)
|
err := models.PutUser(&u)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "Error setting API Key", http.StatusInternalServerError)
|
http.Error(w, "Error setting API Key", http.StatusInternalServerError)
|
||||||
|
|
|
@ -4,6 +4,7 @@ import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
mid "github.com/gophish/gophish/middleware"
|
mid "github.com/gophish/gophish/middleware"
|
||||||
|
"github.com/gophish/gophish/middleware/ratelimit"
|
||||||
"github.com/gophish/gophish/models"
|
"github.com/gophish/gophish/models"
|
||||||
"github.com/gophish/gophish/worker"
|
"github.com/gophish/gophish/worker"
|
||||||
"github.com/gorilla/mux"
|
"github.com/gorilla/mux"
|
||||||
|
@ -19,14 +20,17 @@ type ServerOption func(*Server)
|
||||||
type Server struct {
|
type Server struct {
|
||||||
handler http.Handler
|
handler http.Handler
|
||||||
worker worker.Worker
|
worker worker.Worker
|
||||||
|
limiter *ratelimit.PostLimiter
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewServer returns a new instance of the API handler with the provided
|
// NewServer returns a new instance of the API handler with the provided
|
||||||
// options applied.
|
// options applied.
|
||||||
func NewServer(options ...ServerOption) *Server {
|
func NewServer(options ...ServerOption) *Server {
|
||||||
defaultWorker, _ := worker.New()
|
defaultWorker, _ := worker.New()
|
||||||
|
defaultLimiter := ratelimit.NewPostLimiter()
|
||||||
as := &Server{
|
as := &Server{
|
||||||
worker: defaultWorker,
|
worker: defaultWorker,
|
||||||
|
limiter: defaultLimiter,
|
||||||
}
|
}
|
||||||
for _, opt := range options {
|
for _, opt := range options {
|
||||||
opt(as)
|
opt(as)
|
||||||
|
@ -42,6 +46,12 @@ func WithWorker(w worker.Worker) ServerOption {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func WithLimiter(limiter *ratelimit.PostLimiter) ServerOption {
|
||||||
|
return func(as *Server) {
|
||||||
|
as.limiter = limiter
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (as *Server) registerRoutes() {
|
func (as *Server) registerRoutes() {
|
||||||
root := mux.NewRouter()
|
root := mux.NewRouter()
|
||||||
root = root.StrictSlash(true)
|
root = root.StrictSlash(true)
|
||||||
|
|
|
@ -6,18 +6,14 @@ import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/gophish/gophish/auth"
|
||||||
ctx "github.com/gophish/gophish/context"
|
ctx "github.com/gophish/gophish/context"
|
||||||
log "github.com/gophish/gophish/logger"
|
log "github.com/gophish/gophish/logger"
|
||||||
"github.com/gophish/gophish/models"
|
"github.com/gophish/gophish/models"
|
||||||
"github.com/gophish/gophish/util"
|
|
||||||
"github.com/gorilla/mux"
|
"github.com/gorilla/mux"
|
||||||
"github.com/jinzhu/gorm"
|
"github.com/jinzhu/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ErrEmptyPassword is thrown when a user provides a blank password to the register
|
|
||||||
// or change password functions
|
|
||||||
var ErrEmptyPassword = errors.New("No password provided")
|
|
||||||
|
|
||||||
// ErrUsernameTaken is thrown when a user attempts to register a username that is taken.
|
// ErrUsernameTaken is thrown when a user attempts to register a username that is taken.
|
||||||
var ErrUsernameTaken = errors.New("Username already taken")
|
var ErrUsernameTaken = errors.New("Username already taken")
|
||||||
|
|
||||||
|
@ -33,9 +29,10 @@ var ErrInsufficientPermission = errors.New("Permission denied")
|
||||||
|
|
||||||
// userRequest is the payload which represents the creation of a new user.
|
// userRequest is the payload which represents the creation of a new user.
|
||||||
type userRequest struct {
|
type userRequest struct {
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
Password string `json:"password"`
|
Password string `json:"password"`
|
||||||
Role string `json:"role"`
|
Role string `json:"role"`
|
||||||
|
PasswordChangeRequired bool `json:"password_change_required"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ur *userRequest) Validate(existingUser *models.User) error {
|
func (ur *userRequest) Validate(existingUser *models.User) error {
|
||||||
|
@ -89,11 +86,12 @@ func (as *Server) Users(w http.ResponseWriter, r *http.Request) {
|
||||||
JSONResponse(w, models.Response{Success: false, Message: err.Error()}, http.StatusBadRequest)
|
JSONResponse(w, models.Response{Success: false, Message: err.Error()}, http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if ur.Password == "" {
|
err = auth.CheckPasswordPolicy(ur.Password)
|
||||||
JSONResponse(w, models.Response{Success: false, Message: ErrEmptyPassword.Error()}, http.StatusBadRequest)
|
if err != nil {
|
||||||
|
JSONResponse(w, models.Response{Success: false, Message: err.Error()}, http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
hash, err := util.NewHash(ur.Password)
|
hash, err := auth.GeneratePasswordHash(ur.Password)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
JSONResponse(w, models.Response{Success: false, Message: err.Error()}, http.StatusInternalServerError)
|
JSONResponse(w, models.Response{Success: false, Message: err.Error()}, http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
|
@ -106,7 +104,7 @@ func (as *Server) Users(w http.ResponseWriter, r *http.Request) {
|
||||||
user := models.User{
|
user := models.User{
|
||||||
Username: ur.Username,
|
Username: ur.Username,
|
||||||
Hash: hash,
|
Hash: hash,
|
||||||
ApiKey: util.GenerateSecureKey(),
|
ApiKey: auth.GenerateSecureKey(auth.APIKeyLength),
|
||||||
Role: role,
|
Role: role,
|
||||||
RoleID: role.ID,
|
RoleID: role.ID,
|
||||||
}
|
}
|
||||||
|
@ -195,13 +193,20 @@ func (as *Server) User(w http.ResponseWriter, r *http.Request) {
|
||||||
// We don't force the password to be provided, since it may be an admin
|
// We don't force the password to be provided, since it may be an admin
|
||||||
// managing the user's account, and making a simple change like
|
// managing the user's account, and making a simple change like
|
||||||
// updating the username or role. However, if it _is_ provided, we'll
|
// updating the username or role. However, if it _is_ provided, we'll
|
||||||
// update the stored hash.
|
// update the stored hash after validating the new password meets our
|
||||||
|
// password policy.
|
||||||
//
|
//
|
||||||
// Note that we don't force the current password to be provided. The
|
// Note that we don't force the current password to be provided. The
|
||||||
// assumption here is that the API key is a proper bearer token proving
|
// assumption here is that the API key is a proper bearer token proving
|
||||||
// authenticated access to the account.
|
// authenticated access to the account.
|
||||||
|
existingUser.PasswordChangeRequired = ur.PasswordChangeRequired
|
||||||
if ur.Password != "" {
|
if ur.Password != "" {
|
||||||
hash, err := util.NewHash(ur.Password)
|
err = auth.CheckPasswordPolicy(ur.Password)
|
||||||
|
if err != nil {
|
||||||
|
JSONResponse(w, models.Response{Success: false, Message: err.Error()}, http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
hash, err := auth.GeneratePasswordHash(ur.Password)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
JSONResponse(w, models.Response{Success: false, Message: err.Error()}, http.StatusInternalServerError)
|
JSONResponse(w, models.Response{Success: false, Message: err.Error()}, http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
|
|
|
@ -66,7 +66,7 @@ func TestCreateUser(t *testing.T) {
|
||||||
testCtx := setupTest(t)
|
testCtx := setupTest(t)
|
||||||
payload := &userRequest{
|
payload := &userRequest{
|
||||||
Username: "foo",
|
Username: "foo",
|
||||||
Password: "bar",
|
Password: "validpassword",
|
||||||
Role: models.RoleUser,
|
Role: models.RoleUser,
|
||||||
}
|
}
|
||||||
body, err := json.Marshal(payload)
|
body, err := json.Marshal(payload)
|
||||||
|
|
|
@ -118,5 +118,4 @@ func (as *Server) SendTestEmail(w http.ResponseWriter, r *http.Request) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
JSONResponse(w, models.Response{Success: true, Message: "Email Sent"}, http.StatusOK)
|
JSONResponse(w, models.Response{Success: true, Message: "Email Sent"}, http.StatusOK)
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -62,15 +62,20 @@ func (as *Server) Webhook(w http.ResponseWriter, r *http.Request) {
|
||||||
JSONResponse(w, models.Response{Success: true, Message: "Webhook deleted Successfully!"}, http.StatusOK)
|
JSONResponse(w, models.Response{Success: true, Message: "Webhook deleted Successfully!"}, http.StatusOK)
|
||||||
|
|
||||||
case r.Method == "PUT":
|
case r.Method == "PUT":
|
||||||
wh2 := models.Webhook{}
|
wh = models.Webhook{}
|
||||||
err = json.NewDecoder(r.Body).Decode(&wh2)
|
err = json.NewDecoder(r.Body).Decode(&wh)
|
||||||
wh2.Id = id
|
if err != nil {
|
||||||
err = models.PutWebhook(&wh2)
|
log.Errorf("error decoding webhook: %v", err)
|
||||||
|
JSONResponse(w, models.Response{Success: false, Message: err.Error()}, http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
wh.Id = id
|
||||||
|
err = models.PutWebhook(&wh)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
JSONResponse(w, models.Response{Success: false, Message: err.Error()}, http.StatusBadRequest)
|
JSONResponse(w, models.Response{Success: false, Message: err.Error()}, http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
JSONResponse(w, wh2, http.StatusOK)
|
JSONResponse(w, wh, http.StatusOK)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -7,6 +7,7 @@ import (
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gophish/gophish/auth"
|
||||||
"github.com/gophish/gophish/config"
|
"github.com/gophish/gophish/config"
|
||||||
"github.com/gophish/gophish/models"
|
"github.com/gophish/gophish/models"
|
||||||
)
|
)
|
||||||
|
@ -41,6 +42,10 @@ func setupTest(t *testing.T) *testContext {
|
||||||
ctx.adminServer.Start()
|
ctx.adminServer.Start()
|
||||||
// Get the API key to use for these tests
|
// Get the API key to use for these tests
|
||||||
u, err := models.GetUser(1)
|
u, err := models.GetUser(1)
|
||||||
|
// Reset the temporary password for the admin user to a value we control
|
||||||
|
hash, err := auth.GeneratePasswordHash("gophish")
|
||||||
|
u.Hash = hash
|
||||||
|
models.PutUser(&u)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("error getting first user from database: %v", err)
|
t.Fatalf("error getting first user from database: %v", err)
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,7 +3,6 @@ package controllers
|
||||||
import (
|
import (
|
||||||
"compress/gzip"
|
"compress/gzip"
|
||||||
"context"
|
"context"
|
||||||
"crypto/tls"
|
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
|
@ -86,9 +85,7 @@ func WithContactAddress(addr string) PhishingServerOption {
|
||||||
func (ps *PhishingServer) Start() {
|
func (ps *PhishingServer) Start() {
|
||||||
if ps.config.UseTLS {
|
if ps.config.UseTLS {
|
||||||
// Only support TLS 1.2 and above - ref #1691, #1689
|
// Only support TLS 1.2 and above - ref #1691, #1689
|
||||||
ps.server.TLSConfig = &tls.Config{
|
ps.server.TLSConfig = defaultTLSConfig
|
||||||
MinVersion: tls.VersionTLS12,
|
|
||||||
}
|
|
||||||
err := util.CheckAndCreateSSL(ps.config.CertPath, ps.config.KeyPath)
|
err := util.CheckAndCreateSSL(ps.config.CertPath, ps.config.KeyPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
|
|
|
@ -18,6 +18,7 @@ import (
|
||||||
"github.com/gophish/gophish/controllers/api"
|
"github.com/gophish/gophish/controllers/api"
|
||||||
log "github.com/gophish/gophish/logger"
|
log "github.com/gophish/gophish/logger"
|
||||||
mid "github.com/gophish/gophish/middleware"
|
mid "github.com/gophish/gophish/middleware"
|
||||||
|
"github.com/gophish/gophish/middleware/ratelimit"
|
||||||
"github.com/gophish/gophish/models"
|
"github.com/gophish/gophish/models"
|
||||||
"github.com/gophish/gophish/util"
|
"github.com/gophish/gophish/util"
|
||||||
"github.com/gophish/gophish/worker"
|
"github.com/gophish/gophish/worker"
|
||||||
|
@ -35,9 +36,31 @@ type AdminServerOption func(*AdminServer)
|
||||||
// AdminServer is an HTTP server that implements the administrative Gophish
|
// AdminServer is an HTTP server that implements the administrative Gophish
|
||||||
// handlers, including the dashboard and REST API.
|
// handlers, including the dashboard and REST API.
|
||||||
type AdminServer struct {
|
type AdminServer struct {
|
||||||
server *http.Server
|
server *http.Server
|
||||||
worker worker.Worker
|
worker worker.Worker
|
||||||
config config.AdminServer
|
config config.AdminServer
|
||||||
|
limiter *ratelimit.PostLimiter
|
||||||
|
}
|
||||||
|
|
||||||
|
var defaultTLSConfig = &tls.Config{
|
||||||
|
PreferServerCipherSuites: true,
|
||||||
|
CurvePreferences: []tls.CurveID{
|
||||||
|
tls.X25519,
|
||||||
|
tls.CurveP256,
|
||||||
|
},
|
||||||
|
MinVersion: tls.VersionTLS12,
|
||||||
|
CipherSuites: []uint16{
|
||||||
|
tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384,
|
||||||
|
tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384,
|
||||||
|
tls.TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305,
|
||||||
|
tls.TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305,
|
||||||
|
tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,
|
||||||
|
tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,
|
||||||
|
|
||||||
|
// Kept for backwards compatibility with some clients
|
||||||
|
tls.TLS_RSA_WITH_AES_256_GCM_SHA384,
|
||||||
|
tls.TLS_RSA_WITH_AES_128_GCM_SHA256,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
// WithWorker is an option that sets the background worker.
|
// WithWorker is an option that sets the background worker.
|
||||||
|
@ -55,10 +78,12 @@ func NewAdminServer(config config.AdminServer, options ...AdminServerOption) *Ad
|
||||||
ReadTimeout: 10 * time.Second,
|
ReadTimeout: 10 * time.Second,
|
||||||
Addr: config.ListenURL,
|
Addr: config.ListenURL,
|
||||||
}
|
}
|
||||||
|
defaultLimiter := ratelimit.NewPostLimiter()
|
||||||
as := &AdminServer{
|
as := &AdminServer{
|
||||||
worker: defaultWorker,
|
worker: defaultWorker,
|
||||||
server: defaultServer,
|
server: defaultServer,
|
||||||
config: config,
|
limiter: defaultLimiter,
|
||||||
|
config: config,
|
||||||
}
|
}
|
||||||
for _, opt := range options {
|
for _, opt := range options {
|
||||||
opt(as)
|
opt(as)
|
||||||
|
@ -74,9 +99,7 @@ func (as *AdminServer) Start() {
|
||||||
}
|
}
|
||||||
if as.config.UseTLS {
|
if as.config.UseTLS {
|
||||||
// Only support TLS 1.2 and above - ref #1691, #1689
|
// Only support TLS 1.2 and above - ref #1691, #1689
|
||||||
as.server.TLSConfig = &tls.Config{
|
as.server.TLSConfig = defaultTLSConfig
|
||||||
MinVersion: tls.VersionTLS12,
|
|
||||||
}
|
|
||||||
err := util.CheckAndCreateSSL(as.config.CertPath, as.config.KeyPath)
|
err := util.CheckAndCreateSSL(as.config.CertPath, as.config.KeyPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
|
@ -102,8 +125,9 @@ func (as *AdminServer) registerRoutes() {
|
||||||
router := mux.NewRouter()
|
router := mux.NewRouter()
|
||||||
// Base Front-end routes
|
// Base Front-end routes
|
||||||
router.HandleFunc("/", mid.Use(as.Base, mid.RequireLogin))
|
router.HandleFunc("/", mid.Use(as.Base, mid.RequireLogin))
|
||||||
router.HandleFunc("/login", as.Login)
|
router.HandleFunc("/login", mid.Use(as.Login, as.limiter.Limit))
|
||||||
router.HandleFunc("/logout", mid.Use(as.Logout, mid.RequireLogin))
|
router.HandleFunc("/logout", mid.Use(as.Logout, mid.RequireLogin))
|
||||||
|
router.HandleFunc("/reset_password", mid.Use(as.ResetPassword, mid.RequireLogin))
|
||||||
router.HandleFunc("/campaigns", mid.Use(as.Campaigns, mid.RequireLogin))
|
router.HandleFunc("/campaigns", mid.Use(as.Campaigns, mid.RequireLogin))
|
||||||
router.HandleFunc("/campaigns/{id:[0-9]+}", mid.Use(as.CampaignID, mid.RequireLogin))
|
router.HandleFunc("/campaigns/{id:[0-9]+}", mid.Use(as.CampaignID, mid.RequireLogin))
|
||||||
router.HandleFunc("/templates", mid.Use(as.Templates, mid.RequireLogin))
|
router.HandleFunc("/templates", mid.Use(as.Templates, mid.RequireLogin))
|
||||||
|
@ -113,18 +137,25 @@ func (as *AdminServer) registerRoutes() {
|
||||||
router.HandleFunc("/settings", mid.Use(as.Settings, mid.RequireLogin))
|
router.HandleFunc("/settings", mid.Use(as.Settings, mid.RequireLogin))
|
||||||
router.HandleFunc("/users", mid.Use(as.UserManagement, mid.RequirePermission(models.PermissionModifySystem), mid.RequireLogin))
|
router.HandleFunc("/users", mid.Use(as.UserManagement, mid.RequirePermission(models.PermissionModifySystem), mid.RequireLogin))
|
||||||
router.HandleFunc("/webhooks", mid.Use(as.Webhooks, mid.RequirePermission(models.PermissionModifySystem), mid.RequireLogin))
|
router.HandleFunc("/webhooks", mid.Use(as.Webhooks, mid.RequirePermission(models.PermissionModifySystem), mid.RequireLogin))
|
||||||
|
router.HandleFunc("/impersonate", mid.Use(as.Impersonate, mid.RequirePermission(models.PermissionModifySystem), mid.RequireLogin))
|
||||||
router.HandleFunc("/reported", mid.Use(as.Reported, mid.RequireLogin))
|
router.HandleFunc("/reported", mid.Use(as.Reported, mid.RequireLogin))
|
||||||
router.HandleFunc("/reported/attachment/{id:[0-9]+}", mid.Use(as.ReportedEmailAttachment, mid.RequireLogin))
|
router.HandleFunc("/reported/attachment/{id:[0-9]+}", mid.Use(as.ReportedEmailAttachment, mid.RequireLogin))
|
||||||
|
|
||||||
// Create the API routes
|
// Create the API routes
|
||||||
api := api.NewServer(api.WithWorker(as.worker))
|
api := api.NewServer(
|
||||||
|
api.WithWorker(as.worker),
|
||||||
|
api.WithLimiter(as.limiter),
|
||||||
|
)
|
||||||
router.PathPrefix("/api/").Handler(api)
|
router.PathPrefix("/api/").Handler(api)
|
||||||
|
|
||||||
// Setup static file serving
|
// Setup static file serving
|
||||||
router.PathPrefix("/").Handler(http.FileServer(unindexed.Dir("./static/")))
|
router.PathPrefix("/").Handler(http.FileServer(unindexed.Dir("./static/")))
|
||||||
|
|
||||||
// Setup CSRF Protection
|
// Setup CSRF Protection
|
||||||
csrfHandler := csrf.Protect([]byte(util.GenerateSecureKey()),
|
csrfKey := []byte(as.config.CSRFKey)
|
||||||
|
if len(csrfKey) == 0 {
|
||||||
|
csrfKey = []byte(auth.GenerateSecureKey(auth.APIKeyLength))
|
||||||
|
}
|
||||||
|
csrfHandler := csrf.Protect(csrfKey,
|
||||||
csrf.FieldName("csrf_token"),
|
csrf.FieldName("csrf_token"),
|
||||||
csrf.Secure(as.config.UseTLS))
|
csrf.Secure(as.config.UseTLS))
|
||||||
adminHandler := csrfHandler(router)
|
adminHandler := csrfHandler(router)
|
||||||
|
@ -152,12 +183,14 @@ type templateParams struct {
|
||||||
// the CSRF token.
|
// the CSRF token.
|
||||||
func newTemplateParams(r *http.Request) templateParams {
|
func newTemplateParams(r *http.Request) templateParams {
|
||||||
user := ctx.Get(r, "user").(models.User)
|
user := ctx.Get(r, "user").(models.User)
|
||||||
|
session := ctx.Get(r, "session").(*sessions.Session)
|
||||||
modifySystem, _ := user.HasPermission(models.PermissionModifySystem)
|
modifySystem, _ := user.HasPermission(models.PermissionModifySystem)
|
||||||
return templateParams{
|
return templateParams{
|
||||||
Token: csrf.Token(r),
|
Token: csrf.Token(r),
|
||||||
User: user,
|
User: user,
|
||||||
ModifySystem: modifySystem,
|
ModifySystem: modifySystem,
|
||||||
Version: config.Version,
|
Version: config.Version,
|
||||||
|
Flashes: session.Flashes(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -216,22 +249,37 @@ func (as *AdminServer) Settings(w http.ResponseWriter, r *http.Request) {
|
||||||
case r.Method == "GET":
|
case r.Method == "GET":
|
||||||
params := newTemplateParams(r)
|
params := newTemplateParams(r)
|
||||||
params.Title = "Settings"
|
params.Title = "Settings"
|
||||||
|
session := ctx.Get(r, "session").(*sessions.Session)
|
||||||
|
session.Save(r, w)
|
||||||
getTemplate(w, "settings").ExecuteTemplate(w, "base", params)
|
getTemplate(w, "settings").ExecuteTemplate(w, "base", params)
|
||||||
case r.Method == "POST":
|
case r.Method == "POST":
|
||||||
err := auth.ChangePassword(r)
|
u := ctx.Get(r, "user").(models.User)
|
||||||
|
currentPw := r.FormValue("current_password")
|
||||||
|
newPassword := r.FormValue("new_password")
|
||||||
|
confirmPassword := r.FormValue("confirm_new_password")
|
||||||
|
// Check the current password
|
||||||
|
err := auth.ValidatePassword(currentPw, u.Hash)
|
||||||
msg := models.Response{Success: true, Message: "Settings Updated Successfully"}
|
msg := models.Response{Success: true, Message: "Settings Updated Successfully"}
|
||||||
if err == auth.ErrInvalidPassword {
|
|
||||||
msg.Message = "Invalid Password"
|
|
||||||
msg.Success = false
|
|
||||||
api.JSONResponse(w, msg, http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
msg.Message = err.Error()
|
msg.Message = err.Error()
|
||||||
msg.Success = false
|
msg.Success = false
|
||||||
api.JSONResponse(w, msg, http.StatusBadRequest)
|
api.JSONResponse(w, msg, http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
newHash, err := auth.ValidatePasswordChange(u.Hash, newPassword, confirmPassword)
|
||||||
|
if err != nil {
|
||||||
|
msg.Message = err.Error()
|
||||||
|
msg.Success = false
|
||||||
|
api.JSONResponse(w, msg, http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
u.Hash = string(newHash)
|
||||||
|
if err = models.PutUser(&u); err != nil {
|
||||||
|
msg.Message = err.Error()
|
||||||
|
msg.Success = false
|
||||||
|
api.JSONResponse(w, msg, http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
api.JSONResponse(w, msg, http.StatusOK)
|
api.JSONResponse(w, msg, http.StatusOK)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -244,6 +292,39 @@ func (as *AdminServer) UserManagement(w http.ResponseWriter, r *http.Request) {
|
||||||
getTemplate(w, "users").ExecuteTemplate(w, "base", params)
|
getTemplate(w, "users").ExecuteTemplate(w, "base", params)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (as *AdminServer) nextOrIndex(w http.ResponseWriter, r *http.Request) {
|
||||||
|
next := "/"
|
||||||
|
url, err := url.Parse(r.FormValue("next"))
|
||||||
|
if err == nil {
|
||||||
|
path := url.Path
|
||||||
|
if path != "" {
|
||||||
|
next = path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, next, 302)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (as *AdminServer) handleInvalidLogin(w http.ResponseWriter, r *http.Request) {
|
||||||
|
session := ctx.Get(r, "session").(*sessions.Session)
|
||||||
|
Flash(w, r, "danger", "Invalid Username/Password")
|
||||||
|
params := struct {
|
||||||
|
User models.User
|
||||||
|
Title string
|
||||||
|
Flashes []interface{}
|
||||||
|
Token string
|
||||||
|
}{Title: "Login", Token: csrf.Token(r)}
|
||||||
|
params.Flashes = session.Flashes()
|
||||||
|
session.Save(r, w)
|
||||||
|
templates := template.New("template")
|
||||||
|
_, err := templates.ParseFiles("templates/login.html", "templates/flashes.html")
|
||||||
|
if err != nil {
|
||||||
|
log.Error(err)
|
||||||
|
}
|
||||||
|
// w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
|
template.Must(templates, err).ExecuteTemplate(w, "base", params)
|
||||||
|
}
|
||||||
|
|
||||||
// Webhooks is an admin-only handler that handles webhooks
|
// Webhooks is an admin-only handler that handles webhooks
|
||||||
func (as *AdminServer) Webhooks(w http.ResponseWriter, r *http.Request) {
|
func (as *AdminServer) Webhooks(w http.ResponseWriter, r *http.Request) {
|
||||||
params := newTemplateParams(r)
|
params := newTemplateParams(r)
|
||||||
|
@ -281,6 +362,24 @@ func (as *AdminServer) ReportedEmailAttachment(w http.ResponseWriter, r *http.Re
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Impersonate allows an admin to login to a user account without needing the password
|
||||||
|
func (as *AdminServer) Impersonate(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
||||||
|
if r.Method == "POST" {
|
||||||
|
username := r.FormValue("username")
|
||||||
|
u, err := models.GetUserByUsername(username)
|
||||||
|
if err != nil {
|
||||||
|
log.Error(err)
|
||||||
|
http.Error(w, err.Error(), http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
session := ctx.Get(r, "session").(*sessions.Session)
|
||||||
|
session.Values["id"] = u.Id
|
||||||
|
session.Save(r, w)
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, "/", http.StatusFound)
|
||||||
|
}
|
||||||
|
|
||||||
// Login handles the authentication flow for a user. If credentials are valid,
|
// Login handles the authentication flow for a user. If credentials are valid,
|
||||||
// a session is created
|
// a session is created
|
||||||
func (as *AdminServer) Login(w http.ResponseWriter, r *http.Request) {
|
func (as *AdminServer) Login(w http.ResponseWriter, r *http.Request) {
|
||||||
|
@ -302,37 +401,25 @@ func (as *AdminServer) Login(w http.ResponseWriter, r *http.Request) {
|
||||||
}
|
}
|
||||||
template.Must(templates, err).ExecuteTemplate(w, "base", params)
|
template.Must(templates, err).ExecuteTemplate(w, "base", params)
|
||||||
case r.Method == "POST":
|
case r.Method == "POST":
|
||||||
//Attempt to login
|
// Find the user with the provided username
|
||||||
succ, u, err := auth.Login(r)
|
username, password := r.FormValue("username"), r.FormValue("password")
|
||||||
|
u, err := models.GetUserByUsername(username)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error(err)
|
log.Error(err)
|
||||||
|
as.handleInvalidLogin(w, r)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
//If we've logged in, save the session and redirect to the dashboard
|
// Validate the user's password
|
||||||
if succ {
|
err = auth.ValidatePassword(password, u.Hash)
|
||||||
session.Values["id"] = u.Id
|
if err != nil {
|
||||||
session.Save(r, w)
|
log.Error(err)
|
||||||
next := "/"
|
as.handleInvalidLogin(w, r)
|
||||||
url, err := url.Parse(r.FormValue("next"))
|
return
|
||||||
if err == nil {
|
|
||||||
path := url.Path
|
|
||||||
if path != "" {
|
|
||||||
next = path
|
|
||||||
}
|
|
||||||
}
|
|
||||||
http.Redirect(w, r, next, 302)
|
|
||||||
} else {
|
|
||||||
Flash(w, r, "danger", "Invalid Username/Password")
|
|
||||||
params.Flashes = session.Flashes()
|
|
||||||
session.Save(r, w)
|
|
||||||
templates := template.New("template")
|
|
||||||
_, err := templates.ParseFiles("templates/login.html", "templates/flashes.html")
|
|
||||||
if err != nil {
|
|
||||||
log.Error(err)
|
|
||||||
}
|
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
||||||
w.WriteHeader(http.StatusUnauthorized)
|
|
||||||
template.Must(templates, err).ExecuteTemplate(w, "base", params)
|
|
||||||
}
|
}
|
||||||
|
// If we've logged in, save the session and redirect to the dashboard
|
||||||
|
session.Values["id"] = u.Id
|
||||||
|
session.Save(r, w)
|
||||||
|
as.nextOrIndex(w, r)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -342,9 +429,72 @@ func (as *AdminServer) Logout(w http.ResponseWriter, r *http.Request) {
|
||||||
delete(session.Values, "id")
|
delete(session.Values, "id")
|
||||||
Flash(w, r, "success", "You have successfully logged out")
|
Flash(w, r, "success", "You have successfully logged out")
|
||||||
session.Save(r, w)
|
session.Save(r, w)
|
||||||
http.Redirect(w, r, "/login", 302)
|
http.Redirect(w, r, "/login", http.StatusFound)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ResetPassword handles the password reset flow when a password change is
|
||||||
|
// required either by the Gophish system or an administrator.
|
||||||
|
//
|
||||||
|
// This handler is meant to be used when a user is required to reset their
|
||||||
|
// password, not just when they want to.
|
||||||
|
//
|
||||||
|
// This is an important distinction since in this handler we don't require
|
||||||
|
// the user to re-enter their current password, as opposed to the flow
|
||||||
|
// through the settings handler.
|
||||||
|
//
|
||||||
|
// To that end, if the user doesn't require a password change, we will
|
||||||
|
// redirect them to the settings page.
|
||||||
|
func (as *AdminServer) ResetPassword(w http.ResponseWriter, r *http.Request) {
|
||||||
|
u := ctx.Get(r, "user").(models.User)
|
||||||
|
session := ctx.Get(r, "session").(*sessions.Session)
|
||||||
|
if !u.PasswordChangeRequired {
|
||||||
|
Flash(w, r, "info", "Please reset your password through the settings page")
|
||||||
|
session.Save(r, w)
|
||||||
|
http.Redirect(w, r, "/settings", http.StatusTemporaryRedirect)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
params := newTemplateParams(r)
|
||||||
|
params.Title = "Reset Password"
|
||||||
|
switch {
|
||||||
|
case r.Method == http.MethodGet:
|
||||||
|
params.Flashes = session.Flashes()
|
||||||
|
session.Save(r, w)
|
||||||
|
getTemplate(w, "reset_password").ExecuteTemplate(w, "base", params)
|
||||||
|
return
|
||||||
|
case r.Method == http.MethodPost:
|
||||||
|
newPassword := r.FormValue("password")
|
||||||
|
confirmPassword := r.FormValue("confirm_password")
|
||||||
|
newHash, err := auth.ValidatePasswordChange(u.Hash, newPassword, confirmPassword)
|
||||||
|
if err != nil {
|
||||||
|
Flash(w, r, "danger", err.Error())
|
||||||
|
params.Flashes = session.Flashes()
|
||||||
|
session.Save(r, w)
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
getTemplate(w, "reset_password").ExecuteTemplate(w, "base", params)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
u.PasswordChangeRequired = false
|
||||||
|
u.Hash = newHash
|
||||||
|
if err = models.PutUser(&u); err != nil {
|
||||||
|
Flash(w, r, "danger", err.Error())
|
||||||
|
params.Flashes = session.Flashes()
|
||||||
|
session.Save(r, w)
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
getTemplate(w, "reset_password").ExecuteTemplate(w, "base", params)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// TODO: We probably want to flash a message here that the password was
|
||||||
|
// changed successfully. The problem is that when the user resets their
|
||||||
|
// password on first use, they will see two flashes on the dashboard-
|
||||||
|
// one for their password reset, and one for the "no campaigns created".
|
||||||
|
//
|
||||||
|
// The solution to this is to revamp the empty page to be more useful,
|
||||||
|
// like a wizard or something.
|
||||||
|
as.nextOrIndex(w, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Make this execute the template, too
|
||||||
func getTemplate(w http.ResponseWriter, tmpl string) *template.Template {
|
func getTemplate(w http.ResponseWriter, tmpl string) *template.Template {
|
||||||
templates := template.New("template")
|
templates := template.New("template")
|
||||||
_, err := templates.ParseFiles("templates/base.html", "templates/nav.html", "templates/"+tmpl+".html", "templates/flashes.html")
|
_, err := templates.ParseFiles("templates/base.html", "templates/nav.html", "templates/"+tmpl+".html", "templates/flashes.html")
|
||||||
|
|
|
@ -0,0 +1,9 @@
|
||||||
|
|
||||||
|
-- +goose Up
|
||||||
|
-- SQL in section 'Up' is executed when this migration is applied
|
||||||
|
ALTER TABLE `users` ADD COLUMN password_change_required BOOLEAN;
|
||||||
|
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
-- SQL section 'Down' is executed when this migration is rolled back
|
||||||
|
|
|
@ -0,0 +1,9 @@
|
||||||
|
|
||||||
|
-- +goose Up
|
||||||
|
-- SQL in section 'Up' is executed when this migration is applied
|
||||||
|
ALTER TABLE users ADD COLUMN password_change_required BOOLEAN;
|
||||||
|
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
-- SQL section 'Down' is executed when this migration is rolled back
|
||||||
|
|
6
go.mod
6
go.mod
|
@ -8,6 +8,8 @@ require (
|
||||||
github.com/PuerkitoBio/goquery v1.5.0
|
github.com/PuerkitoBio/goquery v1.5.0
|
||||||
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751 // indirect
|
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751 // indirect
|
||||||
github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d // indirect
|
github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d // indirect
|
||||||
|
github.com/emersion/go-imap v1.0.4
|
||||||
|
github.com/emersion/go-message v0.12.0
|
||||||
github.com/go-sql-driver/mysql v1.5.0
|
github.com/go-sql-driver/mysql v1.5.0
|
||||||
github.com/gophish/gomail v0.0.0-20180314010319-cf7e1a5479be
|
github.com/gophish/gomail v0.0.0-20180314010319-cf7e1a5479be
|
||||||
github.com/gorilla/context v1.1.1
|
github.com/gorilla/context v1.1.1
|
||||||
|
@ -17,15 +19,15 @@ require (
|
||||||
github.com/gorilla/securecookie v1.1.1
|
github.com/gorilla/securecookie v1.1.1
|
||||||
github.com/gorilla/sessions v1.2.0
|
github.com/gorilla/sessions v1.2.0
|
||||||
github.com/jinzhu/gorm v1.9.12
|
github.com/jinzhu/gorm v1.9.12
|
||||||
github.com/jordan-wright/email v0.0.0-20200121133829-a0b5c5b58bb6
|
github.com/jordan-wright/email v0.0.0-20200602115436-fd8a7622303e
|
||||||
github.com/jordan-wright/unindexed v0.0.0-20181209214434-78fa79113c0f
|
github.com/jordan-wright/unindexed v0.0.0-20181209214434-78fa79113c0f
|
||||||
github.com/kylelemons/go-gypsy v0.0.0-20160905020020-08cad365cd28 // indirect
|
github.com/kylelemons/go-gypsy v0.0.0-20160905020020-08cad365cd28 // indirect
|
||||||
github.com/mattn/go-sqlite3 v2.0.3+incompatible
|
github.com/mattn/go-sqlite3 v2.0.3+incompatible
|
||||||
github.com/mxk/go-imap v0.0.0-20150429134902-531c36c3f12d
|
|
||||||
github.com/oschwald/maxminddb-golang v1.6.0
|
github.com/oschwald/maxminddb-golang v1.6.0
|
||||||
github.com/sirupsen/logrus v1.4.2
|
github.com/sirupsen/logrus v1.4.2
|
||||||
github.com/ziutek/mymysql v1.5.4 // indirect
|
github.com/ziutek/mymysql v1.5.4 // indirect
|
||||||
golang.org/x/crypto v0.0.0-20200128174031-69ecbb4d6d5d
|
golang.org/x/crypto v0.0.0-20200128174031-69ecbb4d6d5d
|
||||||
|
golang.org/x/time v0.0.0-20200416051211-89c76fbcd5d1
|
||||||
gopkg.in/alecthomas/kingpin.v2 v2.2.6
|
gopkg.in/alecthomas/kingpin.v2 v2.2.6
|
||||||
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect
|
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405
|
||||||
|
|
21
go.sum
21
go.sum
|
@ -15,6 +15,15 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/denisenkom/go-mssqldb v0.0.0-20191124224453-732737034ffd h1:83Wprp6ROGeiHFAP8WJdI2RoxALQYgdllERc3N5N2DM=
|
github.com/denisenkom/go-mssqldb v0.0.0-20191124224453-732737034ffd h1:83Wprp6ROGeiHFAP8WJdI2RoxALQYgdllERc3N5N2DM=
|
||||||
github.com/denisenkom/go-mssqldb v0.0.0-20191124224453-732737034ffd/go.mod h1:xbL0rPBG9cCiLr28tMa8zpbdarY27NDyej4t/EjAShU=
|
github.com/denisenkom/go-mssqldb v0.0.0-20191124224453-732737034ffd/go.mod h1:xbL0rPBG9cCiLr28tMa8zpbdarY27NDyej4t/EjAShU=
|
||||||
|
github.com/emersion/go-imap v1.0.4 h1:uiCAIHM6Z5Jwkma1zdNDWWXxSCqb+/xHBkHflD7XBro=
|
||||||
|
github.com/emersion/go-imap v1.0.4/go.mod h1:yKASt+C3ZiDAiCSssxg9caIckWF/JG7ZQTO7GAmvicU=
|
||||||
|
github.com/emersion/go-message v0.11.1/go.mod h1:C4jnca5HOTo4bGN9YdqNQM9sITuT3Y0K6bSUw9RklvY=
|
||||||
|
github.com/emersion/go-message v0.12.0 h1:mZnv35eZ6lB6EftTQBgYXspOH0FQdhpFhSUhA9i6/Zg=
|
||||||
|
github.com/emersion/go-message v0.12.0/go.mod h1:C4jnca5HOTo4bGN9YdqNQM9sITuT3Y0K6bSUw9RklvY=
|
||||||
|
github.com/emersion/go-sasl v0.0.0-20191210011802-430746ea8b9b h1:uhWtEWBHgop1rqEk2klKaxPAkVDCXexai6hSuRQ7Nvs=
|
||||||
|
github.com/emersion/go-sasl v0.0.0-20191210011802-430746ea8b9b/go.mod h1:G/dpzLu16WtQpBfQ/z3LYiYJn3ZhKSGWn83fyoyQe/k=
|
||||||
|
github.com/emersion/go-textwrapper v0.0.0-20160606182133-d0e65e56babe h1:40SWqY0zE3qCi6ZrtTf5OUdNm5lDnGnjRSq9GgmeTrg=
|
||||||
|
github.com/emersion/go-textwrapper v0.0.0-20160606182133-d0e65e56babe/go.mod h1:aqO8z8wPrjkscevZJFVE1wXJrLpC5LtJG7fqLOsPb2U=
|
||||||
github.com/erikstmartin/go-testdb v0.0.0-20160219214506-8d10e4a1bae5 h1:Yzb9+7DPaBjB8zlTR87/ElzFsnQfuHnVUVqpZZIcV5Y=
|
github.com/erikstmartin/go-testdb v0.0.0-20160219214506-8d10e4a1bae5 h1:Yzb9+7DPaBjB8zlTR87/ElzFsnQfuHnVUVqpZZIcV5Y=
|
||||||
github.com/erikstmartin/go-testdb v0.0.0-20160219214506-8d10e4a1bae5/go.mod h1:a2zkGnVExMxdzMo3M0Hi/3sEU+cWnZpSni0O6/Yb/P0=
|
github.com/erikstmartin/go-testdb v0.0.0-20160219214506-8d10e4a1bae5/go.mod h1:a2zkGnVExMxdzMo3M0Hi/3sEU+cWnZpSni0O6/Yb/P0=
|
||||||
github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
|
github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
|
||||||
|
@ -43,8 +52,8 @@ github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD
|
||||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||||
github.com/jinzhu/now v1.0.1 h1:HjfetcXq097iXP0uoPCdnM4Efp5/9MsM0/M+XOTeR3M=
|
github.com/jinzhu/now v1.0.1 h1:HjfetcXq097iXP0uoPCdnM4Efp5/9MsM0/M+XOTeR3M=
|
||||||
github.com/jinzhu/now v1.0.1/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
github.com/jinzhu/now v1.0.1/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||||
github.com/jordan-wright/email v0.0.0-20200121133829-a0b5c5b58bb6 h1:gI29NnCaNU8N7rZT2svjtas5SrbL0XsutOPtInVvGIA=
|
github.com/jordan-wright/email v0.0.0-20200602115436-fd8a7622303e h1:OGunVjqY7y4U4laftpEHv+mvZBlr7UGimJXKEGQtg48=
|
||||||
github.com/jordan-wright/email v0.0.0-20200121133829-a0b5c5b58bb6/go.mod h1:1c7szIrayyPPB/987hsnvNzLushdWf4o/79s3P08L8A=
|
github.com/jordan-wright/email v0.0.0-20200602115436-fd8a7622303e/go.mod h1:Fy2gCFfZhay8jplf/Csj6cyH/oshQTkLQYZbKkcV+SY=
|
||||||
github.com/jordan-wright/unindexed v0.0.0-20181209214434-78fa79113c0f h1:bYVTBvVHcAYDkH8hyVMRUW7J2mYQNNSmQPXGadYd1nY=
|
github.com/jordan-wright/unindexed v0.0.0-20181209214434-78fa79113c0f h1:bYVTBvVHcAYDkH8hyVMRUW7J2mYQNNSmQPXGadYd1nY=
|
||||||
github.com/jordan-wright/unindexed v0.0.0-20181209214434-78fa79113c0f/go.mod h1:eRt05O5haIXGKGodWjpQ2xdgBHTE7hg/pzsukNi9IRA=
|
github.com/jordan-wright/unindexed v0.0.0-20181209214434-78fa79113c0f/go.mod h1:eRt05O5haIXGKGodWjpQ2xdgBHTE7hg/pzsukNi9IRA=
|
||||||
github.com/konsorten/go-windows-terminal-sequences v1.0.1 h1:mweAR1A6xJ3oS2pRaGiHgQ4OO8tzTaLawm8vnODuwDk=
|
github.com/konsorten/go-windows-terminal-sequences v1.0.1 h1:mweAR1A6xJ3oS2pRaGiHgQ4OO8tzTaLawm8vnODuwDk=
|
||||||
|
@ -53,11 +62,10 @@ github.com/kylelemons/go-gypsy v0.0.0-20160905020020-08cad365cd28 h1:mkl3tvPHIuP
|
||||||
github.com/kylelemons/go-gypsy v0.0.0-20160905020020-08cad365cd28/go.mod h1:T/T7jsxVqf9k/zYOqbgNAsANsjxTd1Yq3htjDhQ1H0c=
|
github.com/kylelemons/go-gypsy v0.0.0-20160905020020-08cad365cd28/go.mod h1:T/T7jsxVqf9k/zYOqbgNAsANsjxTd1Yq3htjDhQ1H0c=
|
||||||
github.com/lib/pq v1.1.1 h1:sJZmqHoEaY7f+NPP8pgLB/WxulyR3fewgCM2qaSlBb4=
|
github.com/lib/pq v1.1.1 h1:sJZmqHoEaY7f+NPP8pgLB/WxulyR3fewgCM2qaSlBb4=
|
||||||
github.com/lib/pq v1.1.1/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
|
github.com/lib/pq v1.1.1/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
|
||||||
|
github.com/martinlindhe/base36 v1.0.0/go.mod h1:+AtEs8xrBpCeYgSLoY/aJ6Wf37jtBuR0s35750M27+8=
|
||||||
github.com/mattn/go-sqlite3 v2.0.1+incompatible/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
|
github.com/mattn/go-sqlite3 v2.0.1+incompatible/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
|
||||||
github.com/mattn/go-sqlite3 v2.0.3+incompatible h1:gXHsfypPkaMZrKbD5209QV9jbUTJKjyR5WD3HYQSd+U=
|
github.com/mattn/go-sqlite3 v2.0.3+incompatible h1:gXHsfypPkaMZrKbD5209QV9jbUTJKjyR5WD3HYQSd+U=
|
||||||
github.com/mattn/go-sqlite3 v2.0.3+incompatible/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
|
github.com/mattn/go-sqlite3 v2.0.3+incompatible/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
|
||||||
github.com/mxk/go-imap v0.0.0-20150429134902-531c36c3f12d h1:+DgqA2tuWi/8VU+gVgBAa7+WZrnFbPKhQWbKBB54cVs=
|
|
||||||
github.com/mxk/go-imap v0.0.0-20150429134902-531c36c3f12d/go.mod h1:xacC5qXZnL/ooiitVoe3BtI1OotFTqi5zICBs9J5Fyk=
|
|
||||||
github.com/oschwald/maxminddb-golang v1.6.0 h1:KAJSjdHQ8Kv45nFIbtoLGrGWqHFajOIm7skTyz/+Dls=
|
github.com/oschwald/maxminddb-golang v1.6.0 h1:KAJSjdHQ8Kv45nFIbtoLGrGWqHFajOIm7skTyz/+Dls=
|
||||||
github.com/oschwald/maxminddb-golang v1.6.0/go.mod h1:DUJFucBg2cvqx42YmDa/+xHvb0elJtOm3o4aFQ/nb/w=
|
github.com/oschwald/maxminddb-golang v1.6.0/go.mod h1:DUJFucBg2cvqx42YmDa/+xHvb0elJtOm3o4aFQ/nb/w=
|
||||||
github.com/pkg/errors v0.8.0 h1:WdK/asTD0HN+q6hsWO3/vpuAkAr+tw6aNJNDFFf0+qw=
|
github.com/pkg/errors v0.8.0 h1:WdK/asTD0HN+q6hsWO3/vpuAkAr+tw6aNJNDFFf0+qw=
|
||||||
|
@ -90,6 +98,11 @@ golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7w
|
||||||
golang.org/x/sys v0.0.0-20191224085550-c709ea063b76 h1:Dho5nD6R3PcW2SH1or8vS0dszDaXRxIw55lBX7XiE5g=
|
golang.org/x/sys v0.0.0-20191224085550-c709ea063b76 h1:Dho5nD6R3PcW2SH1or8vS0dszDaXRxIw55lBX7XiE5g=
|
||||||
golang.org/x/sys v0.0.0-20191224085550-c709ea063b76/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20191224085550-c709ea063b76/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
|
golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs=
|
||||||
|
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||||
|
golang.org/x/time v0.0.0-20200416051211-89c76fbcd5d1 h1:NusfzzA6yGQ+ua51ck7E3omNUX/JuqbFSaRGqU8CcLI=
|
||||||
|
golang.org/x/time v0.0.0-20200416051211-89c76fbcd5d1/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||||
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
||||||
gopkg.in/alecthomas/kingpin.v2 v2.2.6 h1:jMFz6MfLP0/4fUyZle81rXUoxOBFi19VUFKVDOQfozc=
|
gopkg.in/alecthomas/kingpin.v2 v2.2.6 h1:jMFz6MfLP0/4fUyZle81rXUoxOBFi19VUFKVDOQfozc=
|
||||||
gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=
|
gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=
|
||||||
|
|
30
gophish.go
30
gophish.go
|
@ -26,6 +26,7 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||||
THE SOFTWARE.
|
THE SOFTWARE.
|
||||||
*/
|
*/
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"io/ioutil"
|
"io/ioutil"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
|
@ -40,9 +41,17 @@ import (
|
||||||
"github.com/gophish/gophish/models"
|
"github.com/gophish/gophish/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
modeAll string = "all"
|
||||||
|
modeAdmin string = "admin"
|
||||||
|
modePhish string = "phish"
|
||||||
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
configPath = kingpin.Flag("config", "Location of config.json.").Default("./config.json").String()
|
configPath = kingpin.Flag("config", "Location of config.json.").Default("./config.json").String()
|
||||||
disableMailer = kingpin.Flag("disable-mailer", "Disable the mailer (for use with multi-system deployments)").Bool()
|
disableMailer = kingpin.Flag("disable-mailer", "Disable the mailer (for use with multi-system deployments)").Bool()
|
||||||
|
mode = kingpin.Flag("mode", fmt.Sprintf("Run the binary in one of the modes (%s, %s or %s)", modeAll, modeAdmin, modePhish)).
|
||||||
|
Default("all").Enum(modeAll, modeAdmin, modePhish)
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
@ -102,18 +111,25 @@ func main() {
|
||||||
phishServer := controllers.NewPhishingServer(phishConfig)
|
phishServer := controllers.NewPhishingServer(phishConfig)
|
||||||
|
|
||||||
imapMonitor := imap.NewMonitor()
|
imapMonitor := imap.NewMonitor()
|
||||||
|
if *mode == "admin" || *mode == "all" {
|
||||||
go adminServer.Start()
|
go adminServer.Start()
|
||||||
go phishServer.Start()
|
go imapMonitor.Start()
|
||||||
go imapMonitor.Start()
|
}
|
||||||
|
if *mode == "phish" || *mode == "all" {
|
||||||
|
go phishServer.Start()
|
||||||
|
}
|
||||||
|
|
||||||
// Handle graceful shutdown
|
// Handle graceful shutdown
|
||||||
c := make(chan os.Signal, 1)
|
c := make(chan os.Signal, 1)
|
||||||
signal.Notify(c, os.Interrupt)
|
signal.Notify(c, os.Interrupt)
|
||||||
<-c
|
<-c
|
||||||
log.Info("CTRL+C Received... Gracefully shutting down servers")
|
log.Info("CTRL+C Received... Gracefully shutting down servers")
|
||||||
adminServer.Shutdown()
|
if *mode == modeAdmin || *mode == modeAll {
|
||||||
phishServer.Shutdown()
|
adminServer.Shutdown()
|
||||||
imapMonitor.Shutdown()
|
imapMonitor.Shutdown()
|
||||||
|
}
|
||||||
|
if *mode == modePhish || *mode == modeAll {
|
||||||
|
phishServer.Shutdown()
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
111
imap/imap.go
111
imap/imap.go
|
@ -4,7 +4,6 @@ import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
|
||||||
"regexp"
|
"regexp"
|
||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
@ -12,7 +11,6 @@ import (
|
||||||
"github.com/emersion/go-imap"
|
"github.com/emersion/go-imap"
|
||||||
"github.com/emersion/go-imap/client"
|
"github.com/emersion/go-imap/client"
|
||||||
"github.com/emersion/go-message/charset"
|
"github.com/emersion/go-message/charset"
|
||||||
"github.com/emersion/go-message/mail"
|
|
||||||
log "github.com/gophish/gophish/logger"
|
log "github.com/gophish/gophish/logger"
|
||||||
"github.com/gophish/gophish/models"
|
"github.com/gophish/gophish/models"
|
||||||
|
|
||||||
|
@ -48,7 +46,6 @@ type Mailbox struct {
|
||||||
|
|
||||||
// Validate validates supplied IMAP model by connecting to the server
|
// Validate validates supplied IMAP model by connecting to the server
|
||||||
func Validate(s *models.IMAP) error {
|
func Validate(s *models.IMAP) error {
|
||||||
|
|
||||||
err := s.Validate()
|
err := s.Validate()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error(err)
|
log.Error(err)
|
||||||
|
@ -117,7 +114,6 @@ func (mbox *Mailbox) DeleteEmails(seqs []uint32) error {
|
||||||
|
|
||||||
// GetUnread will find all unread emails in the folder and return them as a list.
|
// GetUnread will find all unread emails in the folder and return them as a list.
|
||||||
func (mbox *Mailbox) GetUnread(markAsRead, delete bool) ([]Email, error) {
|
func (mbox *Mailbox) GetUnread(markAsRead, delete bool) ([]Email, error) {
|
||||||
|
|
||||||
imap.CharsetReader = charset.Reader
|
imap.CharsetReader = charset.Reader
|
||||||
var emails []Email
|
var emails []Email
|
||||||
|
|
||||||
|
@ -130,80 +126,54 @@ func (mbox *Mailbox) GetUnread(markAsRead, delete bool) ([]Email, error) {
|
||||||
|
|
||||||
// Search for unread emails
|
// Search for unread emails
|
||||||
criteria := imap.NewSearchCriteria()
|
criteria := imap.NewSearchCriteria()
|
||||||
criteria.WithoutFlags = []string{"\\Seen"}
|
criteria.WithoutFlags = []string{imap.SeenFlag}
|
||||||
seqs, err := imapClient.Search(criteria)
|
seqs, err := imapClient.Search(criteria)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return emails, err
|
return emails, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(seqs) > 0 {
|
if len(seqs) == 0 {
|
||||||
seqset := new(imap.SeqSet)
|
return emails, nil
|
||||||
seqset.AddNum(seqs...)
|
}
|
||||||
section := &imap.BodySectionName{}
|
|
||||||
items := []imap.FetchItem{imap.FetchEnvelope, imap.FetchFlags, imap.FetchInternalDate, section.FetchItem()}
|
|
||||||
messages := make(chan *imap.Message)
|
|
||||||
|
|
||||||
go func() {
|
seqset := new(imap.SeqSet)
|
||||||
if err := imapClient.Fetch(seqset, items, messages); err != nil {
|
seqset.AddNum(seqs...)
|
||||||
log.Error("Error fetching emails: ", err.Error()) // TODO: How to handle this, need to propogate error out
|
section := &imap.BodySectionName{}
|
||||||
}
|
items := []imap.FetchItem{imap.FetchEnvelope, imap.FetchFlags, imap.FetchInternalDate, section.FetchItem()}
|
||||||
}()
|
messages := make(chan *imap.Message)
|
||||||
|
|
||||||
// Step through each email
|
go func() {
|
||||||
for msg := range messages {
|
if err := imapClient.Fetch(seqset, items, messages); err != nil {
|
||||||
// Extract raw message body. I can't find a better way to do this with the emersion library
|
log.Error("Error fetching emails: ", err.Error()) // TODO: How to handle this, need to propogate error out
|
||||||
var em *email.Email
|
}
|
||||||
var buf []byte
|
}()
|
||||||
for _, value := range msg.Body {
|
|
||||||
buf = make([]byte, value.Len())
|
|
||||||
value.Read(buf)
|
|
||||||
break // There should only ever be one item in this map, but I'm not 100% sure
|
|
||||||
}
|
|
||||||
|
|
||||||
//Remove CR characters, see https://github.com/jordan-wright/email/issues/106
|
// Step through each email
|
||||||
tmp := string(buf)
|
for msg := range messages {
|
||||||
re := regexp.MustCompile(`\r`)
|
// Extract raw message body. I can't find a better way to do this with the emersion library
|
||||||
tmp = re.ReplaceAllString(tmp, "")
|
var em *email.Email
|
||||||
buf = []byte(tmp)
|
var buf []byte
|
||||||
|
for _, value := range msg.Body {
|
||||||
|
buf = make([]byte, value.Len())
|
||||||
|
value.Read(buf)
|
||||||
|
break // There should only ever be one item in this map, but I'm not 100% sure
|
||||||
|
}
|
||||||
|
|
||||||
rawBodyStream := bytes.NewReader(buf)
|
//Remove CR characters, see https://github.com/jordan-wright/email/issues/106
|
||||||
em, err = email.NewEmailFromReader(rawBodyStream) // Parse with @jordanwright's library
|
tmp := string(buf)
|
||||||
if err != nil {
|
re := regexp.MustCompile(`\r`)
|
||||||
return emails, err
|
tmp = re.ReplaceAllString(tmp, "")
|
||||||
}
|
buf = []byte(tmp)
|
||||||
|
|
||||||
// Reload the reader
|
rawBodyStream := bytes.NewReader(buf)
|
||||||
rawBodyStream = bytes.NewReader(buf)
|
em, err = email.NewEmailFromReader(rawBodyStream) // Parse with @jordanwright's library
|
||||||
mr, err := mail.CreateReader(rawBodyStream)
|
if err != nil {
|
||||||
if err != nil {
|
return emails, err
|
||||||
return emails, err
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Step over each part of the email, parsing attachments and attaching them to Jordan's email
|
emtmp := Email{Email: em, SeqNum: msg.SeqNum} // Not sure why msg.Uid is always 0, so swapped to sequence numbers
|
||||||
for {
|
emails = append(emails, emtmp)
|
||||||
p, err := mr.NextPart()
|
|
||||||
if err == io.EOF {
|
|
||||||
break
|
|
||||||
} else if err != nil {
|
|
||||||
return emails, err
|
|
||||||
}
|
|
||||||
h := p.Header
|
|
||||||
|
|
||||||
s, ok := h.(*mail.AttachmentHeader)
|
|
||||||
if ok {
|
|
||||||
filename, _ := s.Filename()
|
|
||||||
typ, _, _ := s.ContentType()
|
|
||||||
_, err := em.Attach(p.Body, filename, typ)
|
|
||||||
if err != nil {
|
|
||||||
return emails, err //Unable to attach file
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
emtmp := Email{Email: em, SeqNum: msg.SeqNum} // Not sure why msg.Uid is always 0, so swapped to sequence numbers
|
|
||||||
emails = append(emails, emtmp)
|
|
||||||
|
|
||||||
} // On to the next email
|
|
||||||
}
|
}
|
||||||
return emails, nil
|
return emails, nil
|
||||||
}
|
}
|
||||||
|
@ -214,14 +184,11 @@ func (mbox *Mailbox) newClient() (*client.Client, error) {
|
||||||
var err error
|
var err error
|
||||||
if mbox.TLS {
|
if mbox.TLS {
|
||||||
imapClient, err = client.DialTLS(mbox.Host, new(tls.Config))
|
imapClient, err = client.DialTLS(mbox.Host, new(tls.Config))
|
||||||
if err != nil {
|
|
||||||
return imapClient, err
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
imapClient, err = client.Dial(mbox.Host)
|
imapClient, err = client.Dial(mbox.Host)
|
||||||
if err != nil {
|
}
|
||||||
return imapClient, err
|
if err != nil {
|
||||||
}
|
return imapClient, err
|
||||||
}
|
}
|
||||||
|
|
||||||
err = imapClient.Login(mbox.User, mbox.Pwd)
|
err = imapClient.Login(mbox.User, mbox.Pwd)
|
||||||
|
|
166
imap/monitor.go
166
imap/monitor.go
|
@ -11,6 +11,7 @@ import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"net/mail"
|
"net/mail"
|
||||||
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
@ -34,7 +35,6 @@ type Monitor struct {
|
||||||
// As each account can have its own polling frequency set we need to run one Go routine for
|
// As each account can have its own polling frequency set we need to run one Go routine for
|
||||||
// each, as well as keeping an eye on newly created user accounts.
|
// each, as well as keeping an eye on newly created user accounts.
|
||||||
func (im *Monitor) start(ctx context.Context) {
|
func (im *Monitor) start(ctx context.Context) {
|
||||||
|
|
||||||
usermap := make(map[int64]int) // Keep track of running go routines, one per user. We assume incrementing non-repeating UIDs (for the case where users are deleted and re-added).
|
usermap := make(map[int64]int) // Keep track of running go routines, one per user. We assume incrementing non-repeating UIDs (for the case where users are deleted and re-added).
|
||||||
|
|
||||||
for {
|
for {
|
||||||
|
@ -62,7 +62,6 @@ func (im *Monitor) start(ctx context.Context) {
|
||||||
// monitor will continuously login to the IMAP settings associated to the supplied user id (if the user account has IMAP settings, and they're enabled.)
|
// monitor will continuously login to the IMAP settings associated to the supplied user id (if the user account has IMAP settings, and they're enabled.)
|
||||||
// It also verifies the user account exists, and returns if not (for the case of a user being deleted).
|
// It also verifies the user account exists, and returns if not (for the case of a user being deleted).
|
||||||
func monitor(uid int64, ctx context.Context) {
|
func monitor(uid int64, ctx context.Context) {
|
||||||
|
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
|
@ -96,7 +95,6 @@ func monitor(uid int64, ctx context.Context) {
|
||||||
|
|
||||||
// NewMonitor returns a new instance of imap.Monitor
|
// NewMonitor returns a new instance of imap.Monitor
|
||||||
func NewMonitor() *Monitor {
|
func NewMonitor() *Monitor {
|
||||||
|
|
||||||
im := &Monitor{}
|
im := &Monitor{}
|
||||||
return im
|
return im
|
||||||
}
|
}
|
||||||
|
@ -120,7 +118,6 @@ func (im *Monitor) Shutdown() error {
|
||||||
// checkForNewEmails logs into an IMAP account and checks unread emails
|
// checkForNewEmails logs into an IMAP account and checks unread emails
|
||||||
// for the rid campaign identifier.
|
// for the rid campaign identifier.
|
||||||
func checkForNewEmails(im models.IMAP) {
|
func checkForNewEmails(im models.IMAP) {
|
||||||
|
|
||||||
im.Host = im.Host + ":" + strconv.Itoa(int(im.Port)) // Append port
|
im.Host = im.Host + ":" + strconv.Itoa(int(im.Port)) // Append port
|
||||||
mailServer := Mailbox{
|
mailServer := Mailbox{
|
||||||
Host: im.Host,
|
Host: im.Host,
|
||||||
|
@ -140,7 +137,7 @@ func checkForNewEmails(im models.IMAP) {
|
||||||
if len(msgs) > 0 {
|
if len(msgs) > 0 {
|
||||||
log.Debugf("%d new emails for %s", len(msgs), im.Username)
|
log.Debugf("%d new emails for %s", len(msgs), im.Username)
|
||||||
var reportingFailed []uint32 // SeqNums of emails that were unable to be reported to phishing server, mark as unread
|
var reportingFailed []uint32 // SeqNums of emails that were unable to be reported to phishing server, mark as unread
|
||||||
var campaignEmails []uint32 // SeqNums of campaign emails. If DeleteReportedCampaignEmail is true, we will delete these
|
var deleteEmails []uint32 // SeqNums of campaign emails. If DeleteReportedCampaignEmail is true, we will delete these
|
||||||
for _, m := range msgs {
|
for _, m := range msgs {
|
||||||
// Check if sender is from company's domain, if enabled. TODO: Make this an IMAP filter
|
// Check if sender is from company's domain, if enabled. TODO: Make this an IMAP filter
|
||||||
if im.RestrictDomain != "" { // e.g domainResitct = widgets.com
|
if im.RestrictDomain != "" { // e.g domainResitct = widgets.com
|
||||||
|
@ -152,118 +149,113 @@ func checkForNewEmails(im models.IMAP) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
rids, err := checkRIDs(m.Email) // Search email Text, HTML, and each attachment for rid parameters
|
rids, err := matchEmail(m.Email) // Search email Text, HTML, and each attachment for rid parameters
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Errorf("Error searching email for rids from user '%s': %s", m.Email.From, err.Error())
|
log.Errorf("Error searching email for rids from user '%s': %s", m.Email.From, err.Error())
|
||||||
} else {
|
continue
|
||||||
if len(rids) < 1 {
|
}
|
||||||
log.Infof("User '%s' reported email with subject '%s'. This is not a GoPhish campaign; you should investigate it.", m.Email.From, m.Email.Subject)
|
if len(rids) < 1 {
|
||||||
|
// In the future this should be an alert in Gophish
|
||||||
|
log.Infof("User '%s' reported email with subject '%s'. This is not a GoPhish campaign; you should investigate it.", m.Email.From, m.Email.Subject)
|
||||||
|
|
||||||
// Save reported email to the database
|
// Save reported email to the database
|
||||||
atts := []*models.ReportedAttachment{}
|
atts := []*models.ReportedAttachment{}
|
||||||
for _, a := range m.Attachments {
|
for _, a := range m.Attachments {
|
||||||
na := &models.ReportedAttachment{Filename: a.Filename, Header: a.Header.Get("Content-Type"), Size: len(a.Content), Content: base64.StdEncoding.EncodeToString(a.Content)}
|
na := &models.ReportedAttachment{Filename: a.Filename, Header: a.Header.Get("Content-Type"), Size: len(a.Content), Content: base64.StdEncoding.EncodeToString(a.Content)}
|
||||||
atts = append(atts, na)
|
atts = append(atts, na)
|
||||||
}
|
|
||||||
|
|
||||||
e, err := mail.ParseAddress(m.Email.From)
|
|
||||||
if err != nil {
|
|
||||||
log.Error(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
em := &models.ReportedEmail{
|
|
||||||
UserId: im.UserId,
|
|
||||||
ReportedByName: e.Name,
|
|
||||||
ReportedByEmail: e.Address,
|
|
||||||
ReportedHTML: string(m.HTML),
|
|
||||||
ReportedText: string(m.Text),
|
|
||||||
ReportedSubject: string(m.Subject),
|
|
||||||
IMAPUID: -1, // https://github.com/emersion/go-imap/issues/353
|
|
||||||
ReportedTime: time.Now().UTC(),
|
|
||||||
Attachments: atts,
|
|
||||||
Status: "Unknown"}
|
|
||||||
|
|
||||||
models.SaveReportedEmail(em)
|
|
||||||
}
|
}
|
||||||
for rid := range rids {
|
|
||||||
log.Infof("User '%s' reported email with rid %s", m.Email.From, rid)
|
|
||||||
result, err := models.GetResult(rid)
|
|
||||||
if err != nil {
|
|
||||||
log.Error("Error reporting GoPhish email with rid ", rid, ": ", err.Error())
|
|
||||||
reportingFailed = append(reportingFailed, m.SeqNum)
|
|
||||||
} else {
|
|
||||||
err = result.HandleEmailReport(models.EventDetails{})
|
|
||||||
if err != nil {
|
|
||||||
log.Error("Error updating GoPhish email with rid ", rid, ": ", err.Error())
|
|
||||||
} else {
|
|
||||||
if im.DeleteReportedCampaignEmail == true {
|
|
||||||
campaignEmails = append(campaignEmails, m.SeqNum)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
e, err := mail.ParseAddress(m.Email.From)
|
||||||
|
if err != nil {
|
||||||
|
log.Error(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
em := &models.ReportedEmail{
|
||||||
|
UserId: im.UserId,
|
||||||
|
ReportedByName: e.Name,
|
||||||
|
ReportedByEmail: e.Address,
|
||||||
|
ReportedHTML: string(m.HTML),
|
||||||
|
ReportedText: string(m.Text),
|
||||||
|
ReportedSubject: string(m.Subject),
|
||||||
|
IMAPUID: -1, // https://github.com/emersion/go-imap/issues/353
|
||||||
|
ReportedTime: time.Now().UTC(),
|
||||||
|
Attachments: atts,
|
||||||
|
Status: "Unknown"}
|
||||||
|
|
||||||
|
models.SaveReportedEmail(em)
|
||||||
|
|
||||||
|
}
|
||||||
|
for rid := range rids {
|
||||||
|
log.Infof("User '%s' reported email with rid %s", m.Email.From, rid)
|
||||||
|
result, err := models.GetResult(rid)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Error reporting GoPhish email with rid ", rid, ": ", err.Error())
|
||||||
|
reportingFailed = append(reportingFailed, m.SeqNum)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
err = result.HandleEmailReport(models.EventDetails{})
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Error updating GoPhish email with rid ", rid, ": ", err.Error())
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if im.DeleteReportedCampaignEmail == true {
|
||||||
|
deleteEmails = append(deleteEmails, m.SeqNum)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if any emails were unable to be reported, so we can mark them as unread
|
}
|
||||||
if len(reportingFailed) > 0 {
|
// Check if any emails were unable to be reported, so we can mark them as unread
|
||||||
log.Debugf("Marking %d emails as unread as failed to report", len(reportingFailed))
|
if len(reportingFailed) > 0 {
|
||||||
err := mailServer.MarkAsUnread(reportingFailed) // Set emails as unread that we failed to report to GoPhish
|
log.Debugf("Marking %d emails as unread as failed to report", len(reportingFailed))
|
||||||
if err != nil {
|
err := mailServer.MarkAsUnread(reportingFailed) // Set emails as unread that we failed to report to GoPhish
|
||||||
log.Error("Unable to mark emails as unread: ", err.Error())
|
if err != nil {
|
||||||
}
|
log.Error("Unable to mark emails as unread: ", err.Error())
|
||||||
}
|
|
||||||
// If the DeleteReportedCampaignEmail flag is set, delete reported Gophish campaign emails
|
|
||||||
if im.DeleteReportedCampaignEmail == true && len(campaignEmails) > 0 {
|
|
||||||
log.Debugf("Deleting %d campaign emails", len(campaignEmails))
|
|
||||||
err := mailServer.DeleteEmails(campaignEmails) // Delete GoPhish campaign emails.
|
|
||||||
if err != nil {
|
|
||||||
log.Error("Failed to delete emails: ", err.Error())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// If the DeleteReportedCampaignEmail flag is set, delete reported Gophish campaign emails
|
||||||
|
if len(deleteEmails) > 0 {
|
||||||
|
log.Debugf("Deleting %d campaign emails", len(deleteEmails))
|
||||||
|
err := mailServer.DeleteEmails(deleteEmails) // Delete GoPhish campaign emails.
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Failed to delete emails: ", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
log.Debug("No new emails for ", im.Username)
|
log.Debug("No new emails for ", im.Username)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// returns a slice of gophish rid paramters found in the email HTML, Text, and attachments
|
func checkRIDs(em *email.Email, rids map[string]bool) {
|
||||||
func checkRIDs(em *email.Email) (map[string]int, error) {
|
|
||||||
|
|
||||||
rids := make(map[string]int)
|
|
||||||
|
|
||||||
// Check Text and HTML
|
// Check Text and HTML
|
||||||
emailContent := string(em.Text) + string(em.HTML)
|
emailContent := string(em.Text) + string(em.HTML)
|
||||||
for _, r := range goPhishRegex.FindAllStringSubmatch(emailContent, -1) {
|
for _, r := range goPhishRegex.FindAllStringSubmatch(emailContent, -1) {
|
||||||
newrid := r[len(r)-1]
|
newrid := r[len(r)-1]
|
||||||
if _, ok := rids[newrid]; ok {
|
if !rids[newrid] {
|
||||||
rids[newrid]++
|
rids[newrid] = true
|
||||||
} else {
|
|
||||||
rids[newrid] = 1
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
//Next check each attachment
|
// returns a slice of gophish rid paramters found in the email HTML, Text, and attachments
|
||||||
|
func matchEmail(em *email.Email) (map[string]bool, error) {
|
||||||
|
rids := make(map[string]bool)
|
||||||
|
checkRIDs(em, rids)
|
||||||
|
|
||||||
|
// Next check each attachment
|
||||||
for _, a := range em.Attachments {
|
for _, a := range em.Attachments {
|
||||||
if a.Header.Get("Content-Type") == "message/rfc822" || (len(a.Filename) > 3 && a.Filename[len(a.Filename)-4:] == ".eml") {
|
ext := filepath.Ext(a.Filename)
|
||||||
|
if a.Header.Get("Content-Type") == "message/rfc822" || ext == ".eml" {
|
||||||
|
|
||||||
//Let's decode the email
|
// Let's decode the email
|
||||||
rawBodyStream := bytes.NewReader(a.Content)
|
rawBodyStream := bytes.NewReader(a.Content)
|
||||||
attachementEmail, err := email.NewEmailFromReader(rawBodyStream)
|
attachmentEmail, err := email.NewEmailFromReader(rawBodyStream)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return rids, err
|
return rids, err
|
||||||
}
|
}
|
||||||
|
|
||||||
emailContent := string(attachementEmail.Text) + string(attachementEmail.HTML)
|
checkRIDs(attachmentEmail, rids)
|
||||||
for _, r := range goPhishRegex.FindAllStringSubmatch(emailContent, -1) {
|
|
||||||
newrid := r[len(r)-1]
|
|
||||||
if _, ok := rids[newrid]; ok {
|
|
||||||
rids[newrid]++
|
|
||||||
} else {
|
|
||||||
rids[newrid] = 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -13,11 +13,6 @@ import (
|
||||||
// being unreachable
|
// being unreachable
|
||||||
var errHostUnreachable = errors.New("host unreachable")
|
var errHostUnreachable = errors.New("host unreachable")
|
||||||
|
|
||||||
// errDialerUnavailable is a mock error to represent a dialer
|
|
||||||
// being unavailable (perhaps an error getting the dialer config
|
|
||||||
// or a database error)
|
|
||||||
var errDialerUnavailable = errors.New("dialer unavailable")
|
|
||||||
|
|
||||||
// mockDialer keeps track of calls to Dial
|
// mockDialer keeps track of calls to Dial
|
||||||
type mockDialer struct {
|
type mockDialer struct {
|
||||||
dialCount int
|
dialCount int
|
||||||
|
@ -137,10 +132,6 @@ func (mm *mockMessage) defaultDialer() (Dialer, error) {
|
||||||
return newMockDialer(), nil
|
return newMockDialer(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (mm *mockMessage) errorDialer() (Dialer, error) {
|
|
||||||
return nil, errDialerUnavailable
|
|
||||||
}
|
|
||||||
|
|
||||||
func (mm *mockMessage) GetDialer() (Dialer, error) {
|
func (mm *mockMessage) GetDialer() (Dialer, error) {
|
||||||
return mm.getdialer()
|
return mm.getdialer()
|
||||||
}
|
}
|
||||||
|
|
|
@ -114,13 +114,21 @@ func RequireAPIKey(handler http.Handler) http.Handler {
|
||||||
func RequireLogin(handler http.Handler) http.HandlerFunc {
|
func RequireLogin(handler http.Handler) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
if u := ctx.Get(r, "user"); u != nil {
|
if u := ctx.Get(r, "user"); u != nil {
|
||||||
|
// If a password change is required for the user, then redirect them
|
||||||
|
// to the login page
|
||||||
|
currentUser := u.(models.User)
|
||||||
|
if currentUser.PasswordChangeRequired && r.URL.Path != "/reset_password" {
|
||||||
|
q := r.URL.Query()
|
||||||
|
q.Set("next", r.URL.Path)
|
||||||
|
http.Redirect(w, r, fmt.Sprintf("/reset_password?%s", q.Encode()), http.StatusTemporaryRedirect)
|
||||||
|
return
|
||||||
|
}
|
||||||
handler.ServeHTTP(w, r)
|
handler.ServeHTTP(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
q := r.URL.Query()
|
q := r.URL.Query()
|
||||||
q.Set("next", r.URL.Path)
|
q.Set("next", r.URL.Path)
|
||||||
http.Redirect(w, r, fmt.Sprintf("/login?%s", q.Encode()), http.StatusTemporaryRedirect)
|
http.Redirect(w, r, fmt.Sprintf("/login?%s", q.Encode()), http.StatusTemporaryRedirect)
|
||||||
return
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -162,3 +162,22 @@ func TestBearerToken(t *testing.T) {
|
||||||
t.Fatalf("incorrect status code received. expected %d got %d", expected, got)
|
t.Fatalf("incorrect status code received. expected %d got %d", expected, got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestPasswordResetRequired(t *testing.T) {
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||||
|
req = ctx.Set(req, "user", models.User{
|
||||||
|
PasswordChangeRequired: true,
|
||||||
|
})
|
||||||
|
response := httptest.NewRecorder()
|
||||||
|
RequireLogin(successHandler).ServeHTTP(response, req)
|
||||||
|
gotStatus := response.Code
|
||||||
|
expectedStatus := http.StatusTemporaryRedirect
|
||||||
|
if gotStatus != expectedStatus {
|
||||||
|
t.Fatalf("incorrect status code received. expected %d got %d", expectedStatus, gotStatus)
|
||||||
|
}
|
||||||
|
expectedLocation := "/reset_password?next=%2F"
|
||||||
|
gotLocation := response.Header().Get("Location")
|
||||||
|
if gotLocation != expectedLocation {
|
||||||
|
t.Fatalf("incorrect location header received. expected %s got %s", expectedLocation, gotLocation)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -0,0 +1,15 @@
|
||||||
|
// Package ratelimit provides a simple token-bucket rate limiting middleware
|
||||||
|
// which only allows n POST requests every minute. This is meant to be used on
|
||||||
|
// login handlers or other sensitive transactions which should be throttled to
|
||||||
|
// prevent abuse.
|
||||||
|
//
|
||||||
|
// Tracked clients are stored in a locked map, with a goroutine that runs at a
|
||||||
|
// configurable interval to clean up stale entries.
|
||||||
|
//
|
||||||
|
// Note that there is no enforcement for GET requests. This is an effort to be
|
||||||
|
// opinionated in order to hit the most common use-cases. For more advanced
|
||||||
|
// use-cases, you may consider the `github.com/didip/tollbooth` package.
|
||||||
|
//
|
||||||
|
// The enforcement mechanism is based on the blog post here:
|
||||||
|
// https://www.alexedwards.net/blog/how-to-rate-limit-http-requests
|
||||||
|
package ratelimit
|
|
@ -0,0 +1,145 @@
|
||||||
|
package ratelimit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
log "github.com/gophish/gophish/logger"
|
||||||
|
"golang.org/x/time/rate"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DefaultRequestsPerMinute is the number of requests to allow per minute.
|
||||||
|
// Any requests over this interval will return a HTTP 429 error.
|
||||||
|
const DefaultRequestsPerMinute = 5
|
||||||
|
|
||||||
|
// DefaultCleanupInterval determines how frequently the cleanup routine
|
||||||
|
// executes.
|
||||||
|
const DefaultCleanupInterval = 1 * time.Minute
|
||||||
|
|
||||||
|
// DefaultExpiry is the amount of time to track a bucket for a particular
|
||||||
|
// visitor.
|
||||||
|
const DefaultExpiry = 10 * time.Minute
|
||||||
|
|
||||||
|
type bucket struct {
|
||||||
|
limiter *rate.Limiter
|
||||||
|
lastSeen time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// PostLimiter is a simple rate limiting middleware which only allows n POST
|
||||||
|
// requests per minute.
|
||||||
|
type PostLimiter struct {
|
||||||
|
visitors map[string]*bucket
|
||||||
|
requestLimit int
|
||||||
|
cleanupInterval time.Duration
|
||||||
|
expiry time.Duration
|
||||||
|
sync.RWMutex
|
||||||
|
}
|
||||||
|
|
||||||
|
// PostLimiterOption is a functional option that allows callers to configure
|
||||||
|
// the rate limiter.
|
||||||
|
type PostLimiterOption func(*PostLimiter)
|
||||||
|
|
||||||
|
// WithRequestsPerMinute sets the number of requests to allow per minute.
|
||||||
|
func WithRequestsPerMinute(requestLimit int) PostLimiterOption {
|
||||||
|
return func(p *PostLimiter) {
|
||||||
|
p.requestLimit = requestLimit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithCleanupInterval sets the interval between cleaning up stale entries in
|
||||||
|
// the rate limit client list
|
||||||
|
func WithCleanupInterval(interval time.Duration) PostLimiterOption {
|
||||||
|
return func(p *PostLimiter) {
|
||||||
|
p.cleanupInterval = interval
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithExpiry sets the amount of time to store client entries before they are
|
||||||
|
// considered stale.
|
||||||
|
func WithExpiry(expiry time.Duration) PostLimiterOption {
|
||||||
|
return func(p *PostLimiter) {
|
||||||
|
p.expiry = expiry
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewPostLimiter returns a new instance of a PostLimiter
|
||||||
|
func NewPostLimiter(opts ...PostLimiterOption) *PostLimiter {
|
||||||
|
limiter := &PostLimiter{
|
||||||
|
visitors: make(map[string]*bucket),
|
||||||
|
requestLimit: DefaultRequestsPerMinute,
|
||||||
|
cleanupInterval: DefaultCleanupInterval,
|
||||||
|
expiry: DefaultExpiry,
|
||||||
|
}
|
||||||
|
for _, opt := range opts {
|
||||||
|
opt(limiter)
|
||||||
|
}
|
||||||
|
go limiter.pollCleanup()
|
||||||
|
return limiter
|
||||||
|
}
|
||||||
|
|
||||||
|
func (limiter *PostLimiter) pollCleanup() {
|
||||||
|
ticker := time.NewTicker(time.Duration(limiter.cleanupInterval) * time.Second)
|
||||||
|
for range ticker.C {
|
||||||
|
limiter.Cleanup()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup removes any buckets that were last seen past the configured expiry.
|
||||||
|
func (limiter *PostLimiter) Cleanup() {
|
||||||
|
limiter.Lock()
|
||||||
|
defer limiter.Unlock()
|
||||||
|
for ip, bucket := range limiter.visitors {
|
||||||
|
if time.Now().Sub(bucket.lastSeen) >= limiter.expiry {
|
||||||
|
delete(limiter.visitors, ip)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (limiter *PostLimiter) addBucket(ip string) *bucket {
|
||||||
|
limiter.Lock()
|
||||||
|
defer limiter.Unlock()
|
||||||
|
limit := rate.NewLimiter(rate.Every(time.Minute/time.Duration(limiter.requestLimit)), limiter.requestLimit)
|
||||||
|
b := &bucket{
|
||||||
|
limiter: limit,
|
||||||
|
}
|
||||||
|
limiter.visitors[ip] = b
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
func (limiter *PostLimiter) allow(ip string) bool {
|
||||||
|
// Check if we have a limiter already active for this clientIP
|
||||||
|
limiter.RLock()
|
||||||
|
bucket, exists := limiter.visitors[ip]
|
||||||
|
limiter.RUnlock()
|
||||||
|
if !exists {
|
||||||
|
bucket = limiter.addBucket(ip)
|
||||||
|
}
|
||||||
|
// Update the lastSeen for this bucket to assist with cleanup
|
||||||
|
limiter.Lock()
|
||||||
|
defer limiter.Unlock()
|
||||||
|
bucket.lastSeen = time.Now()
|
||||||
|
return bucket.limiter.Allow()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Limit enforces the configured rate limit for POST requests.
|
||||||
|
//
|
||||||
|
// TODO: Change the return value to an http.Handler when we clean up the
|
||||||
|
// way Gophish routing is done.
|
||||||
|
func (limiter *PostLimiter) Limit(next http.Handler) http.HandlerFunc {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
clientIP, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("Unable to determine client IP address: %v", err)
|
||||||
|
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if r.Method == http.MethodPost && !limiter.allow(clientIP) {
|
||||||
|
log.Error("")
|
||||||
|
http.Error(w, http.StatusText(http.StatusTooManyRequests), http.StatusTooManyRequests)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
|
@ -0,0 +1,59 @@
|
||||||
|
package ratelimit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
var successHandler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Write([]byte("ok"))
|
||||||
|
})
|
||||||
|
|
||||||
|
func reachLimit(t *testing.T, handler http.Handler, limit int) {
|
||||||
|
// Make `expected` requests and ensure that each return a successful
|
||||||
|
// response.
|
||||||
|
r := httptest.NewRequest(http.MethodPost, "/", nil)
|
||||||
|
r.RemoteAddr = "127.0.0.1:"
|
||||||
|
for i := 0; i < limit; i++ {
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(w, r)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("no 200 on req %d got %d", i, w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Then, makes another request to ensure it returns the 429
|
||||||
|
// status.
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(w, r)
|
||||||
|
if w.Code != http.StatusTooManyRequests {
|
||||||
|
t.Fatalf("no 429")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRateLimitEnforcement(t *testing.T) {
|
||||||
|
expectedLimit := 3
|
||||||
|
limiter := NewPostLimiter(WithRequestsPerMinute(expectedLimit))
|
||||||
|
handler := limiter.Limit(successHandler)
|
||||||
|
reachLimit(t, handler, expectedLimit)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRateLimitCleanup(t *testing.T) {
|
||||||
|
expectedLimit := 3
|
||||||
|
limiter := NewPostLimiter(WithRequestsPerMinute(expectedLimit))
|
||||||
|
handler := limiter.Limit(successHandler)
|
||||||
|
reachLimit(t, handler, expectedLimit)
|
||||||
|
|
||||||
|
// Set the timeout to be
|
||||||
|
bucket, exists := limiter.visitors["127.0.0.1"]
|
||||||
|
if !exists {
|
||||||
|
t.Fatalf("doesn't exist for some reason")
|
||||||
|
}
|
||||||
|
bucket.lastSeen = bucket.lastSeen.Add(-limiter.expiry)
|
||||||
|
limiter.Cleanup()
|
||||||
|
_, exists = limiter.visitors["127.0.0.1"]
|
||||||
|
if exists {
|
||||||
|
t.Fatalf("exists for some reason")
|
||||||
|
}
|
||||||
|
reachLimit(t, handler, expectedLimit)
|
||||||
|
}
|
|
@ -27,7 +27,7 @@ type Campaign struct {
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
Results []Result `json:"results,omitempty"`
|
Results []Result `json:"results,omitempty"`
|
||||||
Groups []Group `json:"groups,omitempty"`
|
Groups []Group `json:"groups,omitempty"`
|
||||||
Events []Event `json:"timeline,omitemtpy"`
|
Events []Event `json:"timeline,omitempty"`
|
||||||
SMTPId int64 `json:"-"`
|
SMTPId int64 `json:"-"`
|
||||||
SMTP SMTP `json:"smtp"`
|
SMTP SMTP `json:"smtp"`
|
||||||
URL string `json:"url"`
|
URL string `json:"url"`
|
||||||
|
|
|
@ -103,10 +103,6 @@ func (s *EmailRequest) Generate(msg *gomail.Message) error {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
fn := f.Name
|
|
||||||
if fn == "" {
|
|
||||||
fn = f.Address
|
|
||||||
}
|
|
||||||
msg.SetAddressHeader("From", f.Address, f.Name)
|
msg.SetAddressHeader("From", f.Address, f.Name)
|
||||||
|
|
||||||
ptx, err := NewPhishingTemplateContext(s, s.BaseRecipient, s.RId)
|
ptx, err := NewPhishingTemplateContext(s, s.BaseRecipient, s.RId)
|
||||||
|
|
|
@ -226,7 +226,6 @@ func PutGroup(g *Group) error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
// Fetch group's existing targets from database.
|
// Fetch group's existing targets from database.
|
||||||
ts := []Target{}
|
|
||||||
ts, err := GetTargets(g.Id)
|
ts, err := GetTargets(g.Id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.WithFields(logrus.Fields{
|
log.WithFields(logrus.Fields{
|
||||||
|
|
|
@ -53,7 +53,7 @@ var ErrIMAPPasswordNotSpecified = errors.New("No Password specified")
|
||||||
|
|
||||||
// ErrInvalidIMAPFreq is thrown when the frequency for polling the
|
// ErrInvalidIMAPFreq is thrown when the frequency for polling the
|
||||||
// IMAP server is invalid
|
// IMAP server is invalid
|
||||||
var ErrInvalidIMAPFreq = errors.New("Invalid polling frequency.")
|
var ErrInvalidIMAPFreq = errors.New("Invalid polling frequency")
|
||||||
|
|
||||||
// TableName specifies the database tablename for Gorm to use
|
// TableName specifies the database tablename for Gorm to use
|
||||||
func (im IMAP) TableName() string {
|
func (im IMAP) TableName() string {
|
||||||
|
|
|
@ -125,7 +125,7 @@ func (m *MailLog) Success() error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
err = db.Delete(m).Error
|
err = db.Delete(m).Error
|
||||||
return nil
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetDialer returns a dialer based on the maillog campaign's SMTP configuration
|
// GetDialer returns a dialer based on the maillog campaign's SMTP configuration
|
||||||
|
|
|
@ -7,13 +7,15 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"io/ioutil"
|
"io/ioutil"
|
||||||
"path/filepath"
|
"os"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"bitbucket.org/liamstask/goose/lib/goose"
|
"bitbucket.org/liamstask/goose/lib/goose"
|
||||||
|
|
||||||
mysql "github.com/go-sql-driver/mysql"
|
mysql "github.com/go-sql-driver/mysql"
|
||||||
|
"github.com/gophish/gophish/auth"
|
||||||
"github.com/gophish/gophish/config"
|
"github.com/gophish/gophish/config"
|
||||||
|
|
||||||
log "github.com/gophish/gophish/logger"
|
log "github.com/gophish/gophish/logger"
|
||||||
"github.com/jinzhu/gorm"
|
"github.com/jinzhu/gorm"
|
||||||
_ "github.com/mattn/go-sqlite3" // Blank import needed to import sqlite3
|
_ "github.com/mattn/go-sqlite3" // Blank import needed to import sqlite3
|
||||||
|
@ -24,6 +26,19 @@ var conf *config.Config
|
||||||
|
|
||||||
const MaxDatabaseConnectionAttempts int = 10
|
const MaxDatabaseConnectionAttempts int = 10
|
||||||
|
|
||||||
|
// DefaultAdminUsername is the default username for the administrative user
|
||||||
|
const DefaultAdminUsername = "admin"
|
||||||
|
|
||||||
|
// InitialAdminPassword is the environment variable that specifies which
|
||||||
|
// password to use for the initial root login instead of generating one
|
||||||
|
// randomly
|
||||||
|
const InitialAdminPassword = "GOPHISH_INITIAL_ADMIN_PASSWORD"
|
||||||
|
|
||||||
|
// InitialAdminApiToken is the environment variable that specifies the
|
||||||
|
// API token to seed the initial root login instead of generating one
|
||||||
|
// randomly
|
||||||
|
const InitialAdminApiToken = "GOPHISH_INITIAL_ADMIN_API_TOKEN"
|
||||||
|
|
||||||
const (
|
const (
|
||||||
CampaignInProgress string = "In progress"
|
CampaignInProgress string = "In progress"
|
||||||
CampaignQueued string = "Queued"
|
CampaignQueued string = "Queued"
|
||||||
|
@ -83,8 +98,38 @@ func chooseDBDriver(name, openStr string) goose.DBDriver {
|
||||||
return d
|
return d
|
||||||
}
|
}
|
||||||
|
|
||||||
// Setup initializes the Conn object
|
func createTemporaryPassword(u *User) error {
|
||||||
// It also populates the Gophish Config object
|
var temporaryPassword string
|
||||||
|
if envPassword := os.Getenv(InitialAdminPassword); envPassword != "" {
|
||||||
|
temporaryPassword = envPassword
|
||||||
|
} else {
|
||||||
|
// This will result in a 16 character password which could be viewed as an
|
||||||
|
// inconvenience, but it should be ok for now.
|
||||||
|
temporaryPassword = auth.GenerateSecureKey(auth.MinPasswordLength)
|
||||||
|
}
|
||||||
|
hash, err := auth.GeneratePasswordHash(temporaryPassword)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
u.Hash = hash
|
||||||
|
// Anytime a temporary password is created, we will force the user
|
||||||
|
// to change their password
|
||||||
|
u.PasswordChangeRequired = true
|
||||||
|
err = db.Save(u).Error
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
log.Infof("Please login with the username admin and the password %s", temporaryPassword)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Setup initializes the database and runs any needed migrations.
|
||||||
|
//
|
||||||
|
// First, it establishes a connection to the database, then runs any migrations
|
||||||
|
// newer than the version the database is on.
|
||||||
|
//
|
||||||
|
// Once the database is up-to-date, we create an admin user (if needed) that
|
||||||
|
// has a randomly generated API key and password.
|
||||||
func Setup(c *config.Config) error {
|
func Setup(c *config.Config) error {
|
||||||
// Setup the package-scoped config
|
// Setup the package-scoped config
|
||||||
conf = c
|
conf = c
|
||||||
|
@ -94,8 +139,6 @@ func Setup(c *config.Config) error {
|
||||||
Env: "production",
|
Env: "production",
|
||||||
Driver: chooseDBDriver(conf.DBName, conf.DBPath),
|
Driver: chooseDBDriver(conf.DBName, conf.DBPath),
|
||||||
}
|
}
|
||||||
abs, _ := filepath.Abs(migrateConf.MigrationsDir)
|
|
||||||
fmt.Println(abs)
|
|
||||||
// Get the latest possible migration
|
// Get the latest possible migration
|
||||||
latest, err := goose.GetMostRecentDBVersion(migrateConf.MigrationsDir)
|
latest, err := goose.GetMostRecentDBVersion(migrateConf.MigrationsDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -156,6 +199,7 @@ func Setup(c *config.Config) error {
|
||||||
}
|
}
|
||||||
// Create the admin user if it doesn't exist
|
// Create the admin user if it doesn't exist
|
||||||
var userCount int64
|
var userCount int64
|
||||||
|
var adminUser User
|
||||||
db.Model(&User{}).Count(&userCount)
|
db.Model(&User{}).Count(&userCount)
|
||||||
adminRole, err := GetRoleBySlug(RoleAdmin)
|
adminRole, err := GetRoleBySlug(RoleAdmin)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -163,14 +207,44 @@ func Setup(c *config.Config) error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if userCount == 0 {
|
if userCount == 0 {
|
||||||
initUser := User{
|
adminUser := User{
|
||||||
Username: "admin",
|
Username: DefaultAdminUsername,
|
||||||
Hash: "$2a$10$IYkPp0.QsM81lYYPrQx6W.U6oQGw7wMpozrKhKAHUBVL4mkm/EvAS", //gophish
|
Role: adminRole,
|
||||||
Role: adminRole,
|
RoleID: adminRole.ID,
|
||||||
RoleID: adminRole.ID,
|
PasswordChangeRequired: true,
|
||||||
}
|
}
|
||||||
initUser.ApiKey = generateSecureKey()
|
|
||||||
err = db.Save(&initUser).Error
|
if envToken := os.Getenv(InitialAdminApiToken); envToken != "" {
|
||||||
|
adminUser.ApiKey = envToken
|
||||||
|
} else {
|
||||||
|
adminUser.ApiKey = auth.GenerateSecureKey(auth.APIKeyLength)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = db.Save(&adminUser).Error
|
||||||
|
if err != nil {
|
||||||
|
log.Error(err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// If this is the first time the user is installing Gophish, then we will
|
||||||
|
// generate a temporary password for the admin user.
|
||||||
|
//
|
||||||
|
// We do this here instead of in the block above where the admin is created
|
||||||
|
// since there's the chance the user executes Gophish and has some kind of
|
||||||
|
// error, then tries restarting it. If they didn't grab the password out of
|
||||||
|
// the logs, then they would have lost it.
|
||||||
|
//
|
||||||
|
// By doing the temporary password here, we will regenerate that temporary
|
||||||
|
// password until the user is able to reset the admin password.
|
||||||
|
if adminUser.Username == "" {
|
||||||
|
adminUser, err = GetUserByUsername(DefaultAdminUsername)
|
||||||
|
if err != nil {
|
||||||
|
log.Error(err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if adminUser.PasswordChangeRequired {
|
||||||
|
err = createTemporaryPassword(&adminUser)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error(err)
|
log.Error(err)
|
||||||
return err
|
return err
|
||||||
|
|
|
@ -138,6 +138,9 @@ func PostPage(p *Page) error {
|
||||||
// Per the PUT Method RFC, it presumes all data for a page is provided.
|
// Per the PUT Method RFC, it presumes all data for a page is provided.
|
||||||
func PutPage(p *Page) error {
|
func PutPage(p *Page) error {
|
||||||
err := p.Validate()
|
err := p.Validate()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
err = db.Where("id=?", p.Id).Save(p).Error
|
err = db.Where("id=?", p.Id).Save(p).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error(err)
|
log.Error(err)
|
||||||
|
|
|
@ -13,12 +13,13 @@ var ErrModifyingOnlyAdmin = errors.New("Cannot remove the only administrator")
|
||||||
|
|
||||||
// User represents the user model for gophish.
|
// User represents the user model for gophish.
|
||||||
type User struct {
|
type User struct {
|
||||||
Id int64 `json:"id"`
|
Id int64 `json:"id"`
|
||||||
Username string `json:"username" sql:"not null;unique"`
|
Username string `json:"username" sql:"not null;unique"`
|
||||||
Hash string `json:"-"`
|
Hash string `json:"-"`
|
||||||
ApiKey string `json:"api_key" sql:"not null;unique"`
|
ApiKey string `json:"api_key" sql:"not null;unique"`
|
||||||
Role Role `json:"role" gorm:"association_autoupdate:false;association_autocreate:false"`
|
Role Role `json:"role" gorm:"association_autoupdate:false;association_autocreate:false"`
|
||||||
RoleID int64 `json:"-"`
|
RoleID int64 `json:"-"`
|
||||||
|
PasswordChangeRequired bool `json:"password_change_required"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetUser returns the user that the given id corresponds to. If no user is found, an
|
// GetUser returns the user that the given id corresponds to. If no user is found, an
|
||||||
|
|
|
@ -27,7 +27,9 @@ func (s *ModelsSuite) TestGetUserByAPIKeyWithExistingAPIKey(c *check.C) {
|
||||||
u, err := GetUser(1)
|
u, err := GetUser(1)
|
||||||
c.Assert(err, check.Equals, nil)
|
c.Assert(err, check.Equals, nil)
|
||||||
|
|
||||||
u, err = GetUserByAPIKey(u.ApiKey)
|
got, err := GetUserByAPIKey(u.ApiKey)
|
||||||
|
c.Assert(err, check.Equals, nil)
|
||||||
|
c.Assert(got.Id, check.Equals, u.Id)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *ModelsSuite) TestGetUserByAPIKeyWithNotExistingAPIKey(c *check.C) {
|
func (s *ModelsSuite) TestGetUserByAPIKeyWithNotExistingAPIKey(c *check.C) {
|
||||||
|
@ -46,11 +48,12 @@ func (s *ModelsSuite) TestGetUserByUsernameWithNotExistingUser(c *check.C) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *ModelsSuite) TestPutUser(c *check.C) {
|
func (s *ModelsSuite) TestPutUser(c *check.C) {
|
||||||
u, err := GetUser(1)
|
u, _ := GetUser(1)
|
||||||
u.Username = "admin_changed"
|
u.Username = "admin_changed"
|
||||||
err = PutUser(&u)
|
err := PutUser(&u)
|
||||||
c.Assert(err, check.Equals, nil)
|
c.Assert(err, check.Equals, nil)
|
||||||
u, err = GetUser(1)
|
u, err = GetUser(1)
|
||||||
|
c.Assert(err, check.Equals, nil)
|
||||||
c.Assert(u.Username, check.Equals, "admin_changed")
|
c.Assert(u.Username, check.Equals, "admin_changed")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -29,5 +29,8 @@
|
||||||
"jshint-stylish": "^2.2.1",
|
"jshint-stylish": "^2.2.1",
|
||||||
"webpack": "^4.32.2",
|
"webpack": "^4.32.2",
|
||||||
"webpack-cli": "^3.3.2"
|
"webpack-cli": "^3.3.2"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"zxcvbn": "^4.4.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
File diff suppressed because one or more lines are too long
|
@ -730,3 +730,15 @@ table.dataTable {
|
||||||
.cke_autocomplete_panel>li {
|
.cke_autocomplete_panel>li {
|
||||||
padding: 10px 5px !important;
|
padding: 10px 5px !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#password-strength {
|
||||||
|
margin-top: 20px;
|
||||||
|
margin-bottom: 0px;
|
||||||
|
height: 8px;
|
||||||
|
}
|
||||||
|
#password-strength-description {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
#password-strength-container {
|
||||||
|
height: 40px;
|
||||||
|
}
|
Binary file not shown.
After Width: | Height: | Size: 1.1 KiB |
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
@ -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 o=window.URL.createObjectURL(t),s=document.createElement("a");s.href=o,s.setAttribute("download",e),document.body.appendChild(s),s.click(),document.body.removeChild(s)}},deleteGroup=function(o){var e=groups.find(function(e){return e.id===o});e&&Swal.fire({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(o).success(function(e){a()}).error(function(e){t(e.responseJSON.message)})})}}).then(function(e){e.value&&Swal.fire("Group Deleted!","This group has been deleted!","success"),$('button:contains("OK")').on("click",function(){location.reload()})})};function addTarget(e,a,t,o){var s=escapeHtml(t).toLowerCase(),r=[escapeHtml(e),escapeHtml(a),s,escapeHtml(o),'<span style="cursor:pointer;"><i class="fa fa-trash-o"></i></span>'],n=targets.DataTable(),i=n.column(2,{order:"index"}).data().indexOf(s);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(){var e=document.getElementById("targetForm");if(e.checkValidity())return addTarget($("#firstName").val(),$("#lastName").val(),$("#email").val(),$("#position").val()),targets.DataTable().draw(),$("#targetForm>div>input").val(""),$("#firstName").focus(),!1;e.reportValidity()}),$("#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)});
|
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),targetRows=[],$.each(e.targets,function(e,a){targetRows.push([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>'])}),targets.DataTable().rows.add(targetRows).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 o=window.URL.createObjectURL(t),s=document.createElement("a");s.href=o,s.setAttribute("download",e),document.body.appendChild(s),s.click(),document.body.removeChild(s)}},deleteGroup=function(o){var e=groups.find(function(e){return e.id===o});e&&Swal.fire({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(o).success(function(e){a()}).error(function(e){t(e.responseJSON.message)})})}}).then(function(e){e.value&&Swal.fire("Group Deleted!","This group has been deleted!","success"),$('button:contains("OK")').on("click",function(){location.reload()})})};function addTarget(e,a,t,o){var s=escapeHtml(t).toLowerCase(),r=[escapeHtml(e),escapeHtml(a),s,escapeHtml(o),'<span style="cursor:pointer;"><i class="fa fa-trash-o"></i></span>'],n=targets.DataTable(),i=n.column(2,{order:"index"}).data().indexOf(s);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 a=$("#groupTable").DataTable({destroy:!0,columnDefs:[{orderable:!1,targets:"no-sort"}]});a.clear(),groupRows=[],$.each(groups,function(e,a){groupRows.push([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>"])}),a.rows.add(groupRows).draw()}else $("#emptyMessage").show()}).error(function(){errorFlash("Error fetching groups")})}$(document).ready(function(){load(),$("#targetForm").submit(function(){var e=document.getElementById("targetForm");if(e.checkValidity())return addTarget($("#firstName").val(),$("#lastName").val(),$("#email").val(),$("#position").val()),targets.DataTable().draw(),$("#targetForm>div>input").val(""),$("#firstName").focus(),!1;e.reportValidity()}),$("#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)});
|
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
@ -1 +1 @@
|
||||||
!function(e){var o={};function n(t){if(o[t])return o[t].exports;var a=o[t]={i:t,l:!1,exports:{}};return e[t].call(a.exports,a,a.exports,n),a.l=!0,a.exports}n.m=e,n.c=o,n.d=function(e,o,t){n.o(e,o)||Object.defineProperty(e,o,{enumerable:!0,get:t})},n.r=function(e){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},n.t=function(e,o){if(1&o&&(e=n(e)),8&o)return e;if(4&o&&"object"==typeof e&&e&&e.__esModule)return e;var t=Object.create(null);if(n.r(t),Object.defineProperty(t,"default",{enumerable:!0,value:e}),2&o&&"string"!=typeof e)for(var a in e)n.d(t,a,function(o){return e[o]}.bind(null,a));return t},n.n=function(e){var o=e&&e.__esModule?function(){return e.default}:function(){return e};return n.d(o,"a",o),o},n.o=function(e,o){return Object.prototype.hasOwnProperty.call(e,o)},n.p="",n(n.s=1)}([,function(e,o){var n=[],t=function(){$("#name").val(""),$("#url").val(""),$("#secret").val(""),$("#is_active").prop("checked",!1),$("#flashes").empty()},a=function(){$("#webhookTable").hide(),$("#loading").show(),api.webhooks.get().success(function(e){n=e,$("#loading").hide(),$("#webhookTable").show();var o=$("#webhookTable").DataTable({destroy:!0,columnDefs:[{orderable:!1,targets:"no-sort"}]});o.clear(),$.each(n,function(e,n){o.row.add([escapeHtml(n.name),escapeHtml(n.url),escapeHtml(n.is_active),'\n <div class="pull-right">\n <button class="btn btn-primary ping_button" data-webhook-id="'.concat(n.id,'">\n Ping\n </button>\n <button class="btn btn-primary edit_button" data-toggle="modal" data-backdrop="static" data-target="#modal" data-webhook-id="').concat(n.id,'">\n <i class="fa fa-pencil"></i>\n </button>\n <button class="btn btn-danger delete_button" data-webhook-id="').concat(n.id,'">\n <i class="fa fa-trash-o"></i>\n </button>\n </div>\n ')]).draw()})}).error(function(){errorFlash("Error fetching webhooks")})},c=function(e){$("#modalSubmit").unbind("click").click(function(){!function(e){var o={name:$("#name").val(),url:$("#url").val(),secret:$("#secret").val(),is_active:$("#is_active").is(":checked")};-1!=e?(o.id=e,api.webhookId.put(o).success(function(e){t(),a(),$("#modal").modal("hide"),successFlash('Webhook "'.concat(escape(o.name),'" has been updated successfully!'))}).error(function(e){modalError(e.responseJSON.message)})):api.webhooks.post(o).success(function(e){a(),t(),$("#modal").modal("hide"),successFlash('Webhook "'.concat(escape(o.name),'" has been created successfully!'))}).error(function(e){modalError(e.responseJSON.message)})}(e)}),-1!==e&&api.webhookId.get(e).success(function(e){$("#name").val(e.name),$("#url").val(e.url),$("#secret").val(e.secret),$("#is_active").prop("checked",e.is_active)}).error(function(){errorFlash("Error fetching webhook")})};$(document).ready(function(){a(),$("#modal").on("hide.bs.modal",function(){t()}),$("#new_button").on("click",function(){c(-1)}),$("#webhookTable").on("click",".edit_button",function(e){c($(this).attr("data-webhook-id"))}),$("#webhookTable").on("click",".delete_button",function(e){var o,t;o=$(this).attr("data-webhook-id"),(t=n.find(function(e){return e.id==o}))&&Swal.fire({title:"Are you sure?",text:"This will delete the webhook '".concat(escape(t.name),"'"),type:"warning",animation:!1,showCancelButton:!0,confirmButtonText:"Delete",confirmButtonColor:"#428bca",reverseButtons:!0,allowOutsideClick:!1,preConfirm:function(){return new Promise(function(e,n){api.webhookId.delete(o).success(function(o){e()}).error(function(e){n(e.responseJSON.message)})}).catch(function(e){Swal.showValidationMessage(e)})}}).then(function(e){e.value&&Swal.fire("Webhook Deleted!","The webhook has been deleted!","success"),$("button:contains('OK')").on("click",function(){location.reload()})})}),$("#webhookTable").on("click",".ping_button",function(e){var o,a;o=e.currentTarget,a=e.currentTarget.dataset.webhookId,t(),o.disabled=!0,api.webhookId.ping(a).success(function(e){o.disabled=!1,successFlash('Ping of "'.concat(escape(e.name),'" webhook succeeded.'))}).error(function(e){o.disabled=!1;var t=n.find(function(e){return e.id==a});t&&errorFlash('Ping of "'.concat(escape(t.name),'" webhook failed: "').concat(e.responseJSON.message,'"'))})})})}]);
|
!function(e){var t={};function n(o){if(t[o])return t[o].exports;var a=t[o]={i:o,l:!1,exports:{}};return e[o].call(a.exports,a,a.exports,n),a.l=!0,a.exports}n.m=e,n.c=t,n.d=function(e,t,o){n.o(e,t)||Object.defineProperty(e,t,{enumerable:!0,get:o})},n.r=function(e){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},n.t=function(e,t){if(1&t&&(e=n(e)),8&t)return e;if(4&t&&"object"==typeof e&&e&&e.__esModule)return e;var o=Object.create(null);if(n.r(o),Object.defineProperty(o,"default",{enumerable:!0,value:e}),2&t&&"string"!=typeof e)for(var a in e)n.d(o,a,function(t){return e[t]}.bind(null,a));return o},n.n=function(e){var t=e&&e.__esModule?function(){return e.default}:function(){return e};return n.d(t,"a",t),t},n.o=function(e,t){return Object.prototype.hasOwnProperty.call(e,t)},n.p="",n(n.s=9)}({9:function(e,t){var n=[],o=function(){$("#name").val(""),$("#url").val(""),$("#secret").val(""),$("#is_active").prop("checked",!1),$("#flashes").empty()},a=function(){$("#webhookTable").hide(),$("#loading").show(),api.webhooks.get().success(function(e){n=e,$("#loading").hide(),$("#webhookTable").show();var t=$("#webhookTable").DataTable({destroy:!0,columnDefs:[{orderable:!1,targets:"no-sort"}]});t.clear(),$.each(n,function(e,n){t.row.add([escapeHtml(n.name),escapeHtml(n.url),escapeHtml(n.is_active),'\n <div class="pull-right">\n <button class="btn btn-primary ping_button" data-webhook-id="'.concat(n.id,'">\n Ping\n </button>\n <button class="btn btn-primary edit_button" data-toggle="modal" data-backdrop="static" data-target="#modal" data-webhook-id="').concat(n.id,'">\n <i class="fa fa-pencil"></i>\n </button>\n <button class="btn btn-danger delete_button" data-webhook-id="').concat(n.id,'">\n <i class="fa fa-trash-o"></i>\n </button>\n </div>\n ')]).draw()})}).error(function(){errorFlash("Error fetching webhooks")})},c=function(e){$("#modalSubmit").unbind("click").click(function(){!function(e){var t={name:$("#name").val(),url:$("#url").val(),secret:$("#secret").val(),is_active:$("#is_active").is(":checked")};-1!=e?(t.id=parseInt(e),api.webhookId.put(t).success(function(e){o(),a(),$("#modal").modal("hide"),successFlash('Webhook "'.concat(escapeHtml(t.name),'" has been updated successfully!'))}).error(function(e){modalError(e.responseJSON.message)})):api.webhooks.post(t).success(function(e){a(),o(),$("#modal").modal("hide"),successFlash('Webhook "'.concat(escapeHtml(t.name),'" has been created successfully!'))}).error(function(e){modalError(e.responseJSON.message)})}(e)}),-1!==e&&api.webhookId.get(e).success(function(e){$("#name").val(e.name),$("#url").val(e.url),$("#secret").val(e.secret),$("#is_active").prop("checked",e.is_active)}).error(function(){errorFlash("Error fetching webhook")})};$(document).ready(function(){a(),$("#modal").on("hide.bs.modal",function(){o()}),$("#new_button").on("click",function(){c(-1)}),$("#webhookTable").on("click",".edit_button",function(e){c($(this).attr("data-webhook-id"))}),$("#webhookTable").on("click",".delete_button",function(e){var t,o;t=$(this).attr("data-webhook-id"),(o=n.find(function(e){return e.id==t}))&&Swal.fire({title:"Are you sure?",text:"This will delete the webhook '".concat(escapeHtml(o.name),"'"),type:"warning",animation:!1,showCancelButton:!0,confirmButtonText:"Delete",confirmButtonColor:"#428bca",reverseButtons:!0,allowOutsideClick:!1,preConfirm:function(){return new Promise(function(e,n){api.webhookId.delete(t).success(function(t){e()}).error(function(e){n(e.responseJSON.message)})}).catch(function(e){Swal.showValidationMessage(e)})}}).then(function(e){e.value&&Swal.fire("Webhook Deleted!","The webhook has been deleted!","success"),$("button:contains('OK')").on("click",function(){location.reload()})})}),$("#webhookTable").on("click",".ping_button",function(e){var t,a;t=e.currentTarget,a=e.currentTarget.dataset.webhookId,o(),t.disabled=!0,api.webhookId.ping(a).success(function(e){t.disabled=!1,successFlash('Ping of "'.concat(escapeHtml(e.name),'" webhook succeeded.'))}).error(function(e){t.disabled=!1;var o=n.find(function(e){return e.id==a});o&&errorFlash('Ping of "'.concat(escapeHtml(o.name),'" webhook failed: "').concat(e.responseJSON.message,'"'))})})})}});
|
|
@ -162,15 +162,16 @@ function deleteCampaign(idx) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function setupOptions() {
|
function setupOptions() {
|
||||||
api.groups.get()
|
api.groups.summary()
|
||||||
.success(function (groups) {
|
.success(function (summaries) {
|
||||||
|
groups = summaries.groups
|
||||||
if (groups.length == 0) {
|
if (groups.length == 0) {
|
||||||
modalError("No groups found!")
|
modalError("No groups found!")
|
||||||
return false;
|
return false;
|
||||||
} else {
|
} else {
|
||||||
var group_s2 = $.map(groups, function (obj) {
|
var group_s2 = $.map(groups, function (obj) {
|
||||||
obj.text = obj.name
|
obj.text = obj.name
|
||||||
obj.title = obj.targets.length + " targets"
|
obj.title = obj.num_targets + " targets"
|
||||||
return obj
|
return obj
|
||||||
});
|
});
|
||||||
console.log(group_s2)
|
console.log(group_s2)
|
||||||
|
@ -360,6 +361,7 @@ $(document).ready(function () {
|
||||||
[1, "desc"]
|
[1, "desc"]
|
||||||
]
|
]
|
||||||
});
|
});
|
||||||
|
campaignRows = []
|
||||||
$.each(campaigns, function (i, campaign) {
|
$.each(campaigns, function (i, campaign) {
|
||||||
campaignTable = campaignTableOriginal
|
campaignTable = campaignTableOriginal
|
||||||
if (campaign.status === "Completed") {
|
if (campaign.status === "Completed") {
|
||||||
|
@ -378,7 +380,7 @@ $(document).ready(function () {
|
||||||
var quickStats = launchDate + "<br><br>" + "Number of recipients: " + campaign.stats.total + "<br><br>" + "Emails opened: " + campaign.stats.opened + "<br><br>" + "Emails clicked: " + campaign.stats.clicked + "<br><br>" + "Submitted Credentials: " + campaign.stats.submitted_data + "<br><br>" + "Errors : " + campaign.stats.error + "Reported : " + campaign.stats.reported
|
var quickStats = launchDate + "<br><br>" + "Number of recipients: " + campaign.stats.total + "<br><br>" + "Emails opened: " + campaign.stats.opened + "<br><br>" + "Emails clicked: " + campaign.stats.clicked + "<br><br>" + "Submitted Credentials: " + campaign.stats.submitted_data + "<br><br>" + "Errors : " + campaign.stats.error + "Reported : " + campaign.stats.reported
|
||||||
}
|
}
|
||||||
|
|
||||||
campaignTable.row.add([
|
campaignRows.push([
|
||||||
escapeHtml(campaign.name),
|
escapeHtml(campaign.name),
|
||||||
moment(campaign.created_date).format('MMMM Do YYYY, h:mm:ss a'),
|
moment(campaign.created_date).format('MMMM Do YYYY, h:mm:ss a'),
|
||||||
"<span class=\"label " + label + "\" data-toggle=\"tooltip\" data-placement=\"right\" data-html=\"true\" title=\"" + quickStats + "\">" + campaign.status + "</span>",
|
"<span class=\"label " + label + "\" data-toggle=\"tooltip\" data-placement=\"right\" data-html=\"true\" title=\"" + quickStats + "\">" + campaign.status + "</span>",
|
||||||
|
@ -391,9 +393,10 @@ $(document).ready(function () {
|
||||||
<button class='btn btn-danger' onclick='deleteCampaign(" + i + ")' data-toggle='tooltip' data-placement='left' title='Delete Campaign'>\
|
<button class='btn btn-danger' onclick='deleteCampaign(" + i + ")' data-toggle='tooltip' data-placement='left' title='Delete Campaign'>\
|
||||||
<i class='fa fa-trash-o'></i>\
|
<i class='fa fa-trash-o'></i>\
|
||||||
</button></div>"
|
</button></div>"
|
||||||
]).draw()
|
])
|
||||||
$('[data-toggle="tooltip"]').tooltip()
|
$('[data-toggle="tooltip"]').tooltip()
|
||||||
})
|
})
|
||||||
|
campaignTable.rows.add(campaignRows).draw()
|
||||||
} else {
|
} else {
|
||||||
$("#emptyMessage").show()
|
$("#emptyMessage").show()
|
||||||
}
|
}
|
||||||
|
|
|
@ -326,6 +326,7 @@ $(document).ready(function () {
|
||||||
[1, "desc"]
|
[1, "desc"]
|
||||||
]
|
]
|
||||||
});
|
});
|
||||||
|
campaignRows = []
|
||||||
$.each(campaigns, function (i, campaign) {
|
$.each(campaigns, function (i, campaign) {
|
||||||
var campaign_date = moment(campaign.created_date).format('MMMM Do YYYY, h:mm:ss a')
|
var campaign_date = moment(campaign.created_date).format('MMMM Do YYYY, h:mm:ss a')
|
||||||
var label = statuses[campaign.status].label || "label-default";
|
var label = statuses[campaign.status].label || "label-default";
|
||||||
|
@ -338,8 +339,8 @@ $(document).ready(function () {
|
||||||
launchDate = "Launch Date: " + moment(campaign.launch_date).format('MMMM Do YYYY, h:mm:ss a')
|
launchDate = "Launch Date: " + moment(campaign.launch_date).format('MMMM Do YYYY, h:mm:ss a')
|
||||||
var quickStats = launchDate + "<br><br>" + "Number of recipients: " + campaign.stats.total + "<br><br>" + "Emails opened: " + campaign.stats.opened + "<br><br>" + "Emails clicked: " + campaign.stats.clicked + "<br><br>" + "Submitted Credentials: " + campaign.stats.submitted_data + "<br><br>" + "Errors : " + campaign.stats.error + "<br><br>" + "Reported : " + campaign.stats.email_reported
|
var quickStats = launchDate + "<br><br>" + "Number of recipients: " + campaign.stats.total + "<br><br>" + "Emails opened: " + campaign.stats.opened + "<br><br>" + "Emails clicked: " + campaign.stats.clicked + "<br><br>" + "Submitted Credentials: " + campaign.stats.submitted_data + "<br><br>" + "Errors : " + campaign.stats.error + "<br><br>" + "Reported : " + campaign.stats.email_reported
|
||||||
}
|
}
|
||||||
// Add it to the table
|
// Add it to the list
|
||||||
campaignTable.row.add([
|
campaignRows.push([
|
||||||
escapeHtml(campaign.name),
|
escapeHtml(campaign.name),
|
||||||
campaign_date,
|
campaign_date,
|
||||||
campaign.stats.sent,
|
campaign.stats.sent,
|
||||||
|
@ -354,9 +355,10 @@ $(document).ready(function () {
|
||||||
<button class='btn btn-danger' onclick='deleteCampaign(" + i + ")' data-toggle='tooltip' data-placement='left' title='Delete Campaign'>\
|
<button class='btn btn-danger' onclick='deleteCampaign(" + i + ")' data-toggle='tooltip' data-placement='left' title='Delete Campaign'>\
|
||||||
<i class='fa fa-trash-o'></i>\
|
<i class='fa fa-trash-o'></i>\
|
||||||
</button></div>"
|
</button></div>"
|
||||||
]).draw()
|
])
|
||||||
$('[data-toggle="tooltip"]').tooltip()
|
$('[data-toggle="tooltip"]').tooltip()
|
||||||
})
|
})
|
||||||
|
campaignTable.rows.add(campaignRows).draw()
|
||||||
// Build the charts
|
// Build the charts
|
||||||
generateStatsPieCharts(campaigns)
|
generateStatsPieCharts(campaigns)
|
||||||
generateTimelineChart(campaigns)
|
generateTimelineChart(campaigns)
|
||||||
|
|
|
@ -69,17 +69,17 @@ function edit(id) {
|
||||||
api.groupId.get(id)
|
api.groupId.get(id)
|
||||||
.success(function (group) {
|
.success(function (group) {
|
||||||
$("#name").val(group.name)
|
$("#name").val(group.name)
|
||||||
|
targetRows = []
|
||||||
$.each(group.targets, function (i, record) {
|
$.each(group.targets, function (i, record) {
|
||||||
targets.DataTable()
|
targetRows.push([
|
||||||
.row.add([
|
escapeHtml(record.first_name),
|
||||||
escapeHtml(record.first_name),
|
escapeHtml(record.last_name),
|
||||||
escapeHtml(record.last_name),
|
escapeHtml(record.email),
|
||||||
escapeHtml(record.email),
|
escapeHtml(record.position),
|
||||||
escapeHtml(record.position),
|
'<span style="cursor:pointer;"><i class="fa fa-trash-o"></i></span>'
|
||||||
'<span style="cursor:pointer;"><i class="fa fa-trash-o"></i></span>'
|
])
|
||||||
]).draw()
|
|
||||||
});
|
});
|
||||||
|
targets.DataTable().rows.add(targetRows).draw()
|
||||||
})
|
})
|
||||||
.error(function () {
|
.error(function () {
|
||||||
errorFlash("Error fetching group")
|
errorFlash("Error fetching group")
|
||||||
|
@ -233,8 +233,9 @@ function load() {
|
||||||
}]
|
}]
|
||||||
});
|
});
|
||||||
groupTable.clear();
|
groupTable.clear();
|
||||||
|
groupRows = []
|
||||||
$.each(groups, function (i, group) {
|
$.each(groups, function (i, group) {
|
||||||
groupTable.row.add([
|
groupRows.push([
|
||||||
escapeHtml(group.name),
|
escapeHtml(group.name),
|
||||||
escapeHtml(group.num_targets),
|
escapeHtml(group.num_targets),
|
||||||
moment(group.modified_date).format('MMMM Do YYYY, h:mm:ss a'),
|
moment(group.modified_date).format('MMMM Do YYYY, h:mm:ss a'),
|
||||||
|
@ -244,8 +245,9 @@ function load() {
|
||||||
<button class='btn btn-danger' onclick='deleteGroup(" + group.id + ")'>\
|
<button class='btn btn-danger' onclick='deleteGroup(" + group.id + ")'>\
|
||||||
<i class='fa fa-trash-o'></i>\
|
<i class='fa fa-trash-o'></i>\
|
||||||
</button></div>"
|
</button></div>"
|
||||||
]).draw()
|
])
|
||||||
})
|
})
|
||||||
|
groupTable.rows.add(groupRows).draw()
|
||||||
} else {
|
} else {
|
||||||
$("#emptyMessage").show()
|
$("#emptyMessage").show()
|
||||||
}
|
}
|
||||||
|
|
|
@ -157,8 +157,9 @@ function load() {
|
||||||
}]
|
}]
|
||||||
});
|
});
|
||||||
pagesTable.clear()
|
pagesTable.clear()
|
||||||
|
pageRows = []
|
||||||
$.each(pages, function (i, page) {
|
$.each(pages, function (i, page) {
|
||||||
pagesTable.row.add([
|
pageRows.push([
|
||||||
escapeHtml(page.name),
|
escapeHtml(page.name),
|
||||||
moment(page.modified_date).format('MMMM Do YYYY, h:mm:ss a'),
|
moment(page.modified_date).format('MMMM Do YYYY, h:mm:ss a'),
|
||||||
"<div class='pull-right'><span data-toggle='modal' data-backdrop='static' data-target='#modal'><button class='btn btn-primary' data-toggle='tooltip' data-placement='left' title='Edit Page' onclick='edit(" + i + ")'>\
|
"<div class='pull-right'><span data-toggle='modal' data-backdrop='static' data-target='#modal'><button class='btn btn-primary' data-toggle='tooltip' data-placement='left' title='Edit Page' onclick='edit(" + i + ")'>\
|
||||||
|
@ -170,8 +171,9 @@ function load() {
|
||||||
<button class='btn btn-danger' data-toggle='tooltip' data-placement='left' title='Delete Page' onclick='deletePage(" + i + ")'>\
|
<button class='btn btn-danger' data-toggle='tooltip' data-placement='left' title='Delete Page' onclick='deletePage(" + i + ")'>\
|
||||||
<i class='fa fa-trash-o'></i>\
|
<i class='fa fa-trash-o'></i>\
|
||||||
</button></div>"
|
</button></div>"
|
||||||
]).draw()
|
])
|
||||||
})
|
})
|
||||||
|
pagesTable.rows.add(pageRows).draw()
|
||||||
$('[data-toggle="tooltip"]').tooltip()
|
$('[data-toggle="tooltip"]').tooltip()
|
||||||
} else {
|
} else {
|
||||||
$("#emptyMessage").show()
|
$("#emptyMessage").show()
|
||||||
|
|
|
@ -0,0 +1,54 @@
|
||||||
|
import zxcvbn from 'zxcvbn';
|
||||||
|
|
||||||
|
const StrengthMapping = {
|
||||||
|
0: {
|
||||||
|
class: 'danger',
|
||||||
|
width: '10%',
|
||||||
|
status: 'Very Weak'
|
||||||
|
},
|
||||||
|
1: {
|
||||||
|
class: 'danger',
|
||||||
|
width: '25%',
|
||||||
|
status: 'Very Weak'
|
||||||
|
},
|
||||||
|
2: {
|
||||||
|
class: 'warning',
|
||||||
|
width: '50%',
|
||||||
|
status: 'Weak'
|
||||||
|
},
|
||||||
|
3: {
|
||||||
|
class: 'success',
|
||||||
|
width: '75%',
|
||||||
|
status: 'Good'
|
||||||
|
},
|
||||||
|
4: {
|
||||||
|
class: 'success',
|
||||||
|
width: '100%',
|
||||||
|
status: 'Very Good'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const Progress = document.getElementById("password-strength-container")
|
||||||
|
const ProgressBar = document.getElementById("password-strength-bar")
|
||||||
|
const StrengthDescription = document.getElementById("password-strength-description")
|
||||||
|
|
||||||
|
const updatePasswordStrength = (e) => {
|
||||||
|
const candidate = e.target.value
|
||||||
|
// If there is no password, clear out the progress bar
|
||||||
|
if (!candidate) {
|
||||||
|
ProgressBar.style.width = 0
|
||||||
|
StrengthDescription.textContent = ""
|
||||||
|
Progress.classList.add("hidden")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const score = zxcvbn(candidate).score
|
||||||
|
const evaluation = StrengthMapping[score]
|
||||||
|
// Update the progress bar
|
||||||
|
ProgressBar.classList = `progress-bar progress-bar-${evaluation.class}`
|
||||||
|
ProgressBar.style.width = evaluation.width
|
||||||
|
StrengthDescription.textContent = evaluation.status
|
||||||
|
StrengthDescription.classList = `text-${evaluation.class}`
|
||||||
|
Progress.classList.remove("hidden")
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById("password").addEventListener("input", updatePasswordStrength)
|
|
@ -200,8 +200,9 @@ function load() {
|
||||||
}]
|
}]
|
||||||
});
|
});
|
||||||
profileTable.clear()
|
profileTable.clear()
|
||||||
|
profileRows = []
|
||||||
$.each(profiles, function (i, profile) {
|
$.each(profiles, function (i, profile) {
|
||||||
profileTable.row.add([
|
profileRows.push([
|
||||||
escapeHtml(profile.name),
|
escapeHtml(profile.name),
|
||||||
profile.interface_type,
|
profile.interface_type,
|
||||||
moment(profile.modified_date).format('MMMM Do YYYY, h:mm:ss a'),
|
moment(profile.modified_date).format('MMMM Do YYYY, h:mm:ss a'),
|
||||||
|
@ -214,8 +215,9 @@ function load() {
|
||||||
<button class='btn btn-danger' data-toggle='tooltip' data-placement='left' title='Delete Profile' onclick='deleteProfile(" + i + ")'>\
|
<button class='btn btn-danger' data-toggle='tooltip' data-placement='left' title='Delete Profile' onclick='deleteProfile(" + i + ")'>\
|
||||||
<i class='fa fa-trash-o'></i>\
|
<i class='fa fa-trash-o'></i>\
|
||||||
</button></div>"
|
</button></div>"
|
||||||
]).draw()
|
])
|
||||||
})
|
})
|
||||||
|
profileTable.rows.add(profileRows).draw()
|
||||||
$('[data-toggle="tooltip"]').tooltip()
|
$('[data-toggle="tooltip"]').tooltip()
|
||||||
} else {
|
} else {
|
||||||
$("#emptyMessage").show()
|
$("#emptyMessage").show()
|
||||||
|
|
|
@ -191,17 +191,19 @@ function edit(idx) {
|
||||||
$("#subject").val(template.subject)
|
$("#subject").val(template.subject)
|
||||||
$("#html_editor").val(template.html)
|
$("#html_editor").val(template.html)
|
||||||
$("#text_editor").val(template.text)
|
$("#text_editor").val(template.text)
|
||||||
|
attachmentRows = []
|
||||||
$.each(template.attachments, function (i, file) {
|
$.each(template.attachments, function (i, file) {
|
||||||
var icon = icons[file.type] || "fa-file-o"
|
var icon = icons[file.type] || "fa-file-o"
|
||||||
// Add the record to the modal
|
// Add the record to the modal
|
||||||
attachmentsTable.row.add([
|
attachmentRows.push([
|
||||||
'<i class="fa ' + icon + '"></i>',
|
'<i class="fa ' + icon + '"></i>',
|
||||||
escapeHtml(file.name),
|
escapeHtml(file.name),
|
||||||
'<span class="remove-row"><i class="fa fa-trash-o"></i></span>',
|
'<span class="remove-row"><i class="fa fa-trash-o"></i></span>',
|
||||||
file.content,
|
file.content,
|
||||||
file.type || "application/octet-stream"
|
file.type || "application/octet-stream"
|
||||||
]).draw()
|
])
|
||||||
})
|
})
|
||||||
|
attachmentsTable.rows.add(attachmentRows).draw()
|
||||||
if (template.html.indexOf("{{.Tracker}}") != -1) {
|
if (template.html.indexOf("{{.Tracker}}") != -1) {
|
||||||
$("#use_tracker_checkbox").prop("checked", true)
|
$("#use_tracker_checkbox").prop("checked", true)
|
||||||
} else {
|
} else {
|
||||||
|
@ -316,8 +318,9 @@ function load() {
|
||||||
}]
|
}]
|
||||||
});
|
});
|
||||||
templateTable.clear()
|
templateTable.clear()
|
||||||
|
templateRows = []
|
||||||
$.each(templates, function (i, template) {
|
$.each(templates, function (i, template) {
|
||||||
templateTable.row.add([
|
templateRows.push([
|
||||||
escapeHtml(template.name),
|
escapeHtml(template.name),
|
||||||
moment(template.modified_date).format('MMMM Do YYYY, h:mm:ss a'),
|
moment(template.modified_date).format('MMMM Do YYYY, h:mm:ss a'),
|
||||||
"<div class='pull-right'><span data-toggle='modal' data-backdrop='static' data-target='#modal'><button class='btn btn-primary' data-toggle='tooltip' data-placement='left' title='Edit Template' onclick='edit(" + i + ")'>\
|
"<div class='pull-right'><span data-toggle='modal' data-backdrop='static' data-target='#modal'><button class='btn btn-primary' data-toggle='tooltip' data-placement='left' title='Edit Template' onclick='edit(" + i + ")'>\
|
||||||
|
@ -329,8 +332,9 @@ function load() {
|
||||||
<button class='btn btn-danger' data-toggle='tooltip' data-placement='left' title='Delete Template' onclick='deleteTemplate(" + i + ")'>\
|
<button class='btn btn-danger' data-toggle='tooltip' data-placement='left' title='Delete Template' onclick='deleteTemplate(" + i + ")'>\
|
||||||
<i class='fa fa-trash-o'></i>\
|
<i class='fa fa-trash-o'></i>\
|
||||||
</button></div>"
|
</button></div>"
|
||||||
]).draw()
|
])
|
||||||
})
|
})
|
||||||
|
templateTable.rows.add(templateRows).draw()
|
||||||
$('[data-toggle="tooltip"]').tooltip()
|
$('[data-toggle="tooltip"]').tooltip()
|
||||||
} else {
|
} else {
|
||||||
$("#emptyMessage").show()
|
$("#emptyMessage").show()
|
||||||
|
|
|
@ -10,7 +10,8 @@ const save = (id) => {
|
||||||
let user = {
|
let user = {
|
||||||
username: $("#username").val(),
|
username: $("#username").val(),
|
||||||
password: $("#password").val(),
|
password: $("#password").val(),
|
||||||
role: $("#role").val()
|
role: $("#role").val(),
|
||||||
|
password_change_required: $("#force_password_change_checkbox").prop('checked')
|
||||||
}
|
}
|
||||||
// Submit the user
|
// Submit the user
|
||||||
if (id != -1) {
|
if (id != -1) {
|
||||||
|
@ -18,26 +19,26 @@ const save = (id) => {
|
||||||
// we need to PUT /user/:id
|
// we need to PUT /user/:id
|
||||||
user.id = id
|
user.id = id
|
||||||
api.userId.put(user)
|
api.userId.put(user)
|
||||||
.success(function (data) {
|
.success((data) => {
|
||||||
successFlash("User " + escapeHtml(user.username) + " updated successfully!")
|
successFlash("User " + escapeHtml(user.username) + " updated successfully!")
|
||||||
load()
|
load()
|
||||||
dismiss()
|
dismiss()
|
||||||
$("#modal").modal('hide')
|
$("#modal").modal('hide')
|
||||||
})
|
})
|
||||||
.error(function (data) {
|
.error((data) => {
|
||||||
modalError(data.responseJSON.message)
|
modalError(data.responseJSON.message)
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
// Else, if this is a new user, POST it
|
// Else, if this is a new user, POST it
|
||||||
// to /user
|
// to /user
|
||||||
api.users.post(user)
|
api.users.post(user)
|
||||||
.success(function (data) {
|
.success((data) => {
|
||||||
successFlash("User " + escapeHtml(user.username) + " registered successfully!")
|
successFlash("User " + escapeHtml(user.username) + " registered successfully!")
|
||||||
load()
|
load()
|
||||||
dismiss()
|
dismiss()
|
||||||
$("#modal").modal('hide')
|
$("#modal").modal('hide')
|
||||||
})
|
})
|
||||||
.error(function (data) {
|
.error((data) => {
|
||||||
modalError(data.responseJSON.message)
|
modalError(data.responseJSON.message)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
@ -61,10 +62,11 @@ const edit = (id) => {
|
||||||
$("#role").trigger("change")
|
$("#role").trigger("change")
|
||||||
} else {
|
} else {
|
||||||
api.userId.get(id)
|
api.userId.get(id)
|
||||||
.success(function (user) {
|
.success((user) => {
|
||||||
$("#username").val(user.username)
|
$("#username").val(user.username)
|
||||||
$("#role").val(user.role.slug)
|
$("#role").val(user.role.slug)
|
||||||
$("#role").trigger("change")
|
$("#role").trigger("change")
|
||||||
|
$("#force_password_change_checkbox").prop('checked', false)
|
||||||
})
|
})
|
||||||
.error(function () {
|
.error(function () {
|
||||||
errorFlash("Error fetching user")
|
errorFlash("Error fetching user")
|
||||||
|
@ -115,6 +117,55 @@ const deleteUser = (id) => {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const impersonate = (id) => {
|
||||||
|
var user = users.find(x => x.id == id)
|
||||||
|
if (!user) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
Swal.fire({
|
||||||
|
title: "Are you sure?",
|
||||||
|
html: "You will be logged out of your account and logged in as <strong>" + escapeHtml(user.username) + "</strong>",
|
||||||
|
type: "warning",
|
||||||
|
animation: false,
|
||||||
|
showCancelButton: true,
|
||||||
|
confirmButtonText: "Swap User",
|
||||||
|
confirmButtonColor: "#428bca",
|
||||||
|
reverseButtons: true,
|
||||||
|
allowOutsideClick: false,
|
||||||
|
}).then((result) => {
|
||||||
|
if (result.value) {
|
||||||
|
|
||||||
|
fetch('/impersonate', {
|
||||||
|
method: 'post',
|
||||||
|
body: "username=" + user.username + "&csrf_token=" + encodeURIComponent(csrf_token),
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
},
|
||||||
|
}).then((response) => {
|
||||||
|
if (response.status == 200) {
|
||||||
|
Swal.fire({
|
||||||
|
title: "Success!",
|
||||||
|
html: "Successfully changed to user <strong>" + escapeHtml(user.username) + "</strong>.",
|
||||||
|
type: "success",
|
||||||
|
showCancelButton: false,
|
||||||
|
confirmButtonText: "Home",
|
||||||
|
allowOutsideClick: false,
|
||||||
|
}).then((result) => {
|
||||||
|
if (result.value) {
|
||||||
|
window.location.href = "/"
|
||||||
|
}});
|
||||||
|
} else {
|
||||||
|
Swal.fire({
|
||||||
|
title: "Error!",
|
||||||
|
type: "error",
|
||||||
|
html: "Failed to change to user <strong>" + escapeHtml(user.username) + "</strong>.",
|
||||||
|
showCancelButton: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const load = () => {
|
const load = () => {
|
||||||
$("#userTable").hide()
|
$("#userTable").hide()
|
||||||
|
@ -132,18 +183,24 @@ const load = () => {
|
||||||
}]
|
}]
|
||||||
});
|
});
|
||||||
userTable.clear();
|
userTable.clear();
|
||||||
|
userRows = []
|
||||||
$.each(users, (i, user) => {
|
$.each(users, (i, user) => {
|
||||||
userTable.row.add([
|
userRows.push([
|
||||||
escapeHtml(user.username),
|
escapeHtml(user.username),
|
||||||
escapeHtml(user.role.name),
|
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 + "'>\
|
"<div class='pull-right'>\
|
||||||
|
<button class='btn btn-warning impersonate_button' data-user-id='" + user.id + "'>\
|
||||||
|
<i class='fa fa-retweet'></i>\
|
||||||
|
</button>\
|
||||||
|
<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>\
|
<i class='fa fa-pencil'></i>\
|
||||||
</button>\
|
</button>\
|
||||||
<button class='btn btn-danger delete_button' data-user-id='" + user.id + "'>\
|
<button class='btn btn-danger delete_button' data-user-id='" + user.id + "'>\
|
||||||
<i class='fa fa-trash-o'></i>\
|
<i class='fa fa-trash-o'></i>\
|
||||||
</button></div>"
|
</button></div>"
|
||||||
]).draw()
|
])
|
||||||
})
|
})
|
||||||
|
userTable.rows.add(userRows).draw();
|
||||||
})
|
})
|
||||||
.error(() => {
|
.error(() => {
|
||||||
errorFlash("Error fetching users")
|
errorFlash("Error fetching users")
|
||||||
|
@ -180,4 +237,7 @@ $(document).ready(function () {
|
||||||
$("#userTable").on('click', '.delete_button', function (e) {
|
$("#userTable").on('click', '.delete_button', function (e) {
|
||||||
deleteUser($(this).attr('data-user-id'))
|
deleteUser($(this).attr('data-user-id'))
|
||||||
})
|
})
|
||||||
|
$("#userTable").on('click', '.impersonate_button', function (e) {
|
||||||
|
impersonate($(this).attr('data-user-id'))
|
||||||
|
})
|
||||||
});
|
});
|
||||||
|
|
|
@ -16,13 +16,13 @@ const saveWebhook = (id) => {
|
||||||
is_active: $("#is_active").is(":checked"),
|
is_active: $("#is_active").is(":checked"),
|
||||||
};
|
};
|
||||||
if (id != -1) {
|
if (id != -1) {
|
||||||
wh.id = id;
|
wh.id = parseInt(id);
|
||||||
api.webhookId.put(wh)
|
api.webhookId.put(wh)
|
||||||
.success(function(data) {
|
.success(function(data) {
|
||||||
dismiss();
|
dismiss();
|
||||||
load();
|
load();
|
||||||
$("#modal").modal("hide");
|
$("#modal").modal("hide");
|
||||||
successFlash(`Webhook "${escape(wh.name)}" has been updated successfully!`);
|
successFlash(`Webhook "${escapeHtml(wh.name)}" has been updated successfully!`);
|
||||||
})
|
})
|
||||||
.error(function(data) {
|
.error(function(data) {
|
||||||
modalError(data.responseJSON.message)
|
modalError(data.responseJSON.message)
|
||||||
|
@ -33,7 +33,7 @@ const saveWebhook = (id) => {
|
||||||
load();
|
load();
|
||||||
dismiss();
|
dismiss();
|
||||||
$("#modal").modal("hide");
|
$("#modal").modal("hide");
|
||||||
successFlash(`Webhook "${escape(wh.name)}" has been created successfully!`);
|
successFlash(`Webhook "${escapeHtml(wh.name)}" has been created successfully!`);
|
||||||
})
|
})
|
||||||
.error(function(data) {
|
.error(function(data) {
|
||||||
modalError(data.responseJSON.message)
|
modalError(data.responseJSON.message)
|
||||||
|
@ -108,7 +108,7 @@ const deleteWebhook = (id) => {
|
||||||
}
|
}
|
||||||
Swal.fire({
|
Swal.fire({
|
||||||
title: "Are you sure?",
|
title: "Are you sure?",
|
||||||
text: `This will delete the webhook '${escape(wh.name)}'`,
|
text: `This will delete the webhook '${escapeHtml(wh.name)}'`,
|
||||||
type: "warning",
|
type: "warning",
|
||||||
animation: false,
|
animation: false,
|
||||||
showCancelButton: true,
|
showCancelButton: true,
|
||||||
|
@ -150,7 +150,7 @@ const pingUrl = (btn, whId) => {
|
||||||
api.webhookId.ping(whId)
|
api.webhookId.ping(whId)
|
||||||
.success(function(wh) {
|
.success(function(wh) {
|
||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
successFlash(`Ping of "${escape(wh.name)}" webhook succeeded.`);
|
successFlash(`Ping of "${escapeHtml(wh.name)}" webhook succeeded.`);
|
||||||
})
|
})
|
||||||
.error(function(data) {
|
.error(function(data) {
|
||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
|
@ -158,7 +158,7 @@ const pingUrl = (btn, whId) => {
|
||||||
if (!wh) {
|
if (!wh) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
errorFlash(`Ping of "${escape(wh.name)}" webhook failed: "${data.responseJSON.message}"`)
|
errorFlash(`Ping of "${escapeHtml(wh.name)}" webhook failed: "${data.responseJSON.message}"`)
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -8,7 +8,7 @@
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<meta name="description" content="Gophish - Phishing Toolkit">
|
<meta name="description" content="Gophish - Phishing Toolkit">
|
||||||
<meta name="author" content="Jordan Wright (http://github.com/jordan-wright)">
|
<meta name="author" content="Jordan Wright (http://github.com/jordan-wright)">
|
||||||
<link rel="shortcut icon" href="../../docs-assets/ico/favicon.png">
|
<link rel="shortcut icon" href="/images/favicon.ico">
|
||||||
|
|
||||||
<title>{{ .Title }} - Gophish</title>
|
<title>{{ .Title }} - Gophish</title>
|
||||||
|
|
||||||
|
|
|
@ -6,7 +6,8 @@
|
||||||
<div id="loading">
|
<div id="loading">
|
||||||
<i class="fa fa-spinner fa-spin fa-4x"></i>
|
<i class="fa fa-spinner fa-spin fa-4x"></i>
|
||||||
</div>
|
</div>
|
||||||
<div id="emptyMessage" class="row" style="display:none;">
|
{{template "flashes" .Flashes}}
|
||||||
|
<div id="emptyMessage" style="display:none;">
|
||||||
<div class="alert alert-info">
|
<div class="alert alert-info">
|
||||||
No campaigns created yet. Let's create one!
|
No campaigns created yet. Let's create one!
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -8,6 +8,8 @@
|
||||||
fa-exclamation-triangle
|
fa-exclamation-triangle
|
||||||
{{else if eq .Type "success"}}
|
{{else if eq .Type "success"}}
|
||||||
fa-check-circle
|
fa-check-circle
|
||||||
|
{{else if eq .Type "info"}}
|
||||||
|
fa-info-circle
|
||||||
{{end}}"></i>
|
{{end}}"></i>
|
||||||
{{.Message}}
|
{{.Message}}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -8,7 +8,7 @@
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<meta name="description" content="Gophish - Open-Source Phishing Toolkit">
|
<meta name="description" content="Gophish - Open-Source Phishing Toolkit">
|
||||||
<meta name="author" content="Jordan Wright (http://github.com/jordan-wright)">
|
<meta name="author" content="Jordan Wright (http://github.com/jordan-wright)">
|
||||||
<link rel="shortcut icon" href="../../docs-assets/ico/favicon.png">
|
<link rel="shortcut icon" href="/images/favicon.ico">
|
||||||
|
|
||||||
<title>Gophish - {{ .Title }}</title>
|
<title>Gophish - {{ .Title }}</title>
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,75 @@
|
||||||
|
{{ define "base" }}
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<meta name="description" content="Gophish - Open-Source Phishing Toolkit">
|
||||||
|
<meta name="author" content="Jordan Wright (http://github.com/jordan-wright)">
|
||||||
|
<link rel="shortcut icon" href="../../docs-assets/ico/favicon.png">
|
||||||
|
|
||||||
|
<title>Gophish - {{ .Title }}</title>
|
||||||
|
|
||||||
|
<link href="/css/dist/gophish.css" rel="stylesheet">
|
||||||
|
<link href='https://fonts.googleapis.com/css?family=Source+Sans+Pro:400,300,600,700' rel='stylesheet'
|
||||||
|
type='text/css'>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<div class="navbar navbar-inverse navbar-fixed-top" role="navigation">
|
||||||
|
<div class="container-fluid">
|
||||||
|
<div class="navbar-header">
|
||||||
|
<button type="button" class="navbar-toggle" data-toggle="collapse" data-target=".navbar-collapse">
|
||||||
|
<span class="sr-only">Toggle navigation</span>
|
||||||
|
<span class="icon-bar"></span>
|
||||||
|
<span class="icon-bar"></span>
|
||||||
|
<span class="icon-bar"></span>
|
||||||
|
</button>
|
||||||
|
<img class="navbar-logo" src="/images/logo_inv_small.png" />
|
||||||
|
<a class="navbar-brand" href="/"> gophish</a>
|
||||||
|
</div>
|
||||||
|
<div class="navbar-collapse collapse">
|
||||||
|
<ul class="nav navbar-nav navbar-right">
|
||||||
|
<li>
|
||||||
|
<div class="btn-group" id="navbar-dropdown">
|
||||||
|
<a class="btn btn-primary" href="/logout"><i class="fa fa-user"></i> {{.User.Username}}</a>
|
||||||
|
<a class="btn btn-primary dropdown-toggle" href="/logout">
|
||||||
|
<i class="fa fa-sign-out"></i>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="container">
|
||||||
|
<form class="form-signin" action="" method="POST">
|
||||||
|
<img id="logo" src="/images/logo_purple.png" />
|
||||||
|
<h2 class="form-signin-heading">Reset Your Password</h2>
|
||||||
|
{{template "flashes" .Flashes}}
|
||||||
|
<input type="password" id="password" name="password" class="form-control" placeholder="Password"
|
||||||
|
autocomplete="off" minlength="8" required autofocus>
|
||||||
|
<div class="" id="password-strength-container">
|
||||||
|
<div class="progress" id="password-strength">
|
||||||
|
<div id="password-strength-bar" class="progress-bar" role="progressbar" aria-valuenow="0"
|
||||||
|
aria-valuemin="0" aria-valuemax="100"></div>
|
||||||
|
</div>
|
||||||
|
<span id="password-strength-description"></span>
|
||||||
|
</div>
|
||||||
|
<input type="password" name="confirm_password" class="form-control" placeholder="Confirm Password"
|
||||||
|
autocomplete="off" minlength="8" required>
|
||||||
|
<input type="hidden" name="csrf_token" value="{{.Token}}" />
|
||||||
|
<br />
|
||||||
|
<button class="btn btn-lg btn-primary btn-block" type="submit">Save Password</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<!-- Placed at the end of the document so the pages load faster -->
|
||||||
|
<script src="/js/dist/app/passwords.min.js"></script>
|
||||||
|
<script src="/js/dist/vendor.min.js"></script>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
|
{{ end }}
|
|
@ -4,6 +4,7 @@
|
||||||
<h1 class="page-header">Settings</h1>
|
<h1 class="page-header">Settings</h1>
|
||||||
</div>
|
</div>
|
||||||
<div id="flashes" class="row"></div>
|
<div id="flashes" class="row"></div>
|
||||||
|
{{template "flashes" .Flashes}}
|
||||||
<!-- Nav tabs -->
|
<!-- Nav tabs -->
|
||||||
<ul class="nav nav-tabs" role="tablist">
|
<ul class="nav nav-tabs" role="tablist">
|
||||||
<li class="active" role="mainSettings"><a href="#mainSettings" aria-controls="mainSettings" role="tab"
|
<li class="active" role="mainSettings"><a href="#mainSettings" aria-controls="mainSettings" role="tab"
|
||||||
|
@ -58,8 +59,15 @@
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<label for="new_password" class="col-sm-2 control-label form-label">New Password:</label>
|
<label for="new_password" class="col-sm-2 control-label form-label">New Password:</label>
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<input type="password" id="new_password" name="new_password" autocomplete="off"
|
<input type="password" id="password" name="new_password" autocomplete="new-password"
|
||||||
class="form-control" />
|
class="form-control" />
|
||||||
|
<div class="hidden" id="password-strength-container">
|
||||||
|
<div class="progress" id="password-strength">
|
||||||
|
<div id="password-strength-bar" class="progress-bar" role="progressbar"
|
||||||
|
aria-valuenow="0" aria-valuemin="0" aria-valuemax="100"></div>
|
||||||
|
</div>
|
||||||
|
<span id="password-strength-description"></span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<br />
|
<br />
|
||||||
|
@ -225,5 +233,6 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{{end}} {{define "scripts"}}
|
{{end}} {{define "scripts"}}
|
||||||
|
<script src="/js/dist/app/passwords.min.js"></script>
|
||||||
<script src="/js/dist/app/settings.min.js"></script>
|
<script src="/js/dist/app/settings.min.js"></script>
|
||||||
{{end}}
|
{{end}}
|
|
@ -5,7 +5,9 @@
|
||||||
{{.Title}}
|
{{.Title}}
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
<div id="flashes" class="row"></div>
|
<div id="flashes" class="row">
|
||||||
|
{{template "flashes" .Flashes}}
|
||||||
|
</div>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<button type="button" class="btn btn-primary" id="new_button" data-toggle="modal" data-backdrop="static"
|
<button type="button" class="btn btn-primary" id="new_button" data-toggle="modal" data-backdrop="static"
|
||||||
data-user-id="-1" data-target="#modal">
|
data-user-id="-1" data-target="#modal">
|
||||||
|
@ -47,13 +49,23 @@
|
||||||
</div>
|
</div>
|
||||||
<label class="control-label" for="password">Password:</label>
|
<label class="control-label" for="password">Password:</label>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<input type="password" class="form-control" placeholder="Password" id="password" required />
|
<input type="password" class="form-control" autocomplete="new-password" placeholder="Password" id="password" required />
|
||||||
|
<div class="hidden" id="password-strength-container">
|
||||||
|
<div class="progress" id="password-strength">
|
||||||
|
<div id="password-strength-bar" class="progress-bar" role="progressbar" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100"></div>
|
||||||
|
</div>
|
||||||
|
<span id="password-strength-description"></span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<label class="control-label" for="confirm_password">Confirm Password:</label>
|
<label class="control-label" for="confirm_password">Confirm Password:</label>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<input type="password" class="form-control" placeholder="Confirm Password" id="confirm_password"
|
<input type="password" class="form-control" placeholder="Confirm Password" id="confirm_password"
|
||||||
required />
|
required />
|
||||||
</div>
|
</div>
|
||||||
|
<div class="checkbox checkbox-primary">
|
||||||
|
<input id="force_password_change_checkbox" type="checkbox" checked>
|
||||||
|
<label for="force_password_change_checkbox">Require the user to set a new password</label>
|
||||||
|
</div>
|
||||||
<label class="control-label" for="role">Role:</label>
|
<label class="control-label" for="role">Role:</label>
|
||||||
<div class="form-group" id="role-select">
|
<div class="form-group" id="role-select">
|
||||||
<select class="form-control" placeholder="" id="role" />
|
<select class="form-control" placeholder="" id="role" />
|
||||||
|
@ -70,5 +82,6 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{{end}} {{define "scripts"}}
|
{{end}} {{define "scripts"}}
|
||||||
|
<script src="/js/dist/app/passwords.min.js"></script>
|
||||||
<script src="/js/dist/app/users.min.js"></script>
|
<script src="/js/dist/app/users.min.js"></script>
|
||||||
{{end}}
|
{{end}}
|
32
util/util.go
32
util/util.go
|
@ -21,7 +21,6 @@ import (
|
||||||
log "github.com/gophish/gophish/logger"
|
log "github.com/gophish/gophish/logger"
|
||||||
"github.com/gophish/gophish/models"
|
"github.com/gophish/gophish/models"
|
||||||
"github.com/jordan-wright/email"
|
"github.com/jordan-wright/email"
|
||||||
"golang.org/x/crypto/bcrypt"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
|
@ -138,6 +137,9 @@ func CheckAndCreateSSL(cp string, kp string) error {
|
||||||
log.Infof("Creating new self-signed certificates for administration interface")
|
log.Infof("Creating new self-signed certificates for administration interface")
|
||||||
|
|
||||||
priv, err := ecdsa.GenerateKey(elliptic.P384(), rand.Reader)
|
priv, err := ecdsa.GenerateKey(elliptic.P384(), rand.Reader)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("error generating tls private key: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
notBefore := time.Now()
|
notBefore := time.Now()
|
||||||
// Generate a certificate that lasts for 10 years
|
// Generate a certificate that lasts for 10 years
|
||||||
|
@ -147,7 +149,7 @@ func CheckAndCreateSSL(cp string, kp string) error {
|
||||||
serialNumber, err := rand.Int(rand.Reader, serialNumberLimit)
|
serialNumber, err := rand.Int(rand.Reader, serialNumberLimit)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("TLS Certificate Generation: Failed to generate a random serial number: %s", err)
|
return fmt.Errorf("tls certificate generation: failed to generate a random serial number: %s", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
template := x509.Certificate{
|
template := x509.Certificate{
|
||||||
|
@ -165,24 +167,24 @@ func CheckAndCreateSSL(cp string, kp string) error {
|
||||||
|
|
||||||
derBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, priv.Public(), priv)
|
derBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, priv.Public(), priv)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("TLS Certificate Generation: Failed to create certificate: %s", err)
|
return fmt.Errorf("tls certificate generation: failed to create certificate: %s", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
certOut, err := os.Create(cp)
|
certOut, err := os.Create(cp)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("TLS Certificate Generation: Failed to open %s for writing: %s", cp, err)
|
return fmt.Errorf("tls certificate generation: failed to open %s for writing: %s", cp, err)
|
||||||
}
|
}
|
||||||
pem.Encode(certOut, &pem.Block{Type: "CERTIFICATE", Bytes: derBytes})
|
pem.Encode(certOut, &pem.Block{Type: "CERTIFICATE", Bytes: derBytes})
|
||||||
certOut.Close()
|
certOut.Close()
|
||||||
|
|
||||||
keyOut, err := os.OpenFile(kp, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
|
keyOut, err := os.OpenFile(kp, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("TLS Certificate Generation: Failed to open %s for writing", kp)
|
return fmt.Errorf("tls certificate generation: failed to open %s for writing", kp)
|
||||||
}
|
}
|
||||||
|
|
||||||
b, err := x509.MarshalECPrivateKey(priv)
|
b, err := x509.MarshalECPrivateKey(priv)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("TLS Certificate Generation: Unable to marshal ECDSA private key: %v", err)
|
return fmt.Errorf("tls certificate generation: unable to marshal ECDSA private key: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
pem.Encode(keyOut, &pem.Block{Type: "EC PRIVATE KEY", Bytes: b})
|
pem.Encode(keyOut, &pem.Block{Type: "EC PRIVATE KEY", Bytes: b})
|
||||||
|
@ -191,21 +193,3 @@ func CheckAndCreateSSL(cp string, kp string) error {
|
||||||
log.Info("TLS Certificate Generation complete")
|
log.Info("TLS Certificate Generation complete")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GenerateSecureKey creates a secure key to use as an API key
|
|
||||||
func GenerateSecureKey() string {
|
|
||||||
// Inspired from gorilla/securecookie
|
|
||||||
k := make([]byte, 32)
|
|
||||||
io.ReadFull(rand.Reader, k)
|
|
||||||
return fmt.Sprintf("%x", k)
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewHash hashes the provided password and returns the bcrypt hash (using the
|
|
||||||
// default 10 rounds) as a string.
|
|
||||||
func NewHash(pass string) (string, error) {
|
|
||||||
hash, err := bcrypt.GenerateFromPassword([]byte(pass), bcrypt.DefaultCost)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
return string(hash), nil
|
|
||||||
}
|
|
||||||
|
|
|
@ -16,20 +16,24 @@ import (
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
|
||||||
// DefaultTimeoutSeconds is amount of seconds of timeout used by HTTP sender
|
// DefaultTimeoutSeconds is the number of seconds before a timeout occurs
|
||||||
|
// when sending a webhook
|
||||||
DefaultTimeoutSeconds = 10
|
DefaultTimeoutSeconds = 10
|
||||||
|
|
||||||
// MinHTTPStatusErrorCode is the lowest number of an HTTP response which indicates an error
|
// MinHTTPStatusErrorCode is the lower bound of HTTP status codes which
|
||||||
|
// indicate an error occurred
|
||||||
MinHTTPStatusErrorCode = 400
|
MinHTTPStatusErrorCode = 400
|
||||||
|
|
||||||
// SignatureHeader is the name of an HTTP header used to which contains signature of a webhook
|
// SignatureHeader is the name of the HTTP header which contains the
|
||||||
|
// webhook signature
|
||||||
SignatureHeader = "X-Gophish-Signature"
|
SignatureHeader = "X-Gophish-Signature"
|
||||||
|
|
||||||
// Sha256Prefix is the prefix that specifies the hashing algorithm used for signature
|
// Sha256Prefix is the prefix that specifies the hashing algorithm used
|
||||||
|
// for the signature
|
||||||
Sha256Prefix = "sha256"
|
Sha256Prefix = "sha256"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Sender defines behaviour of an entity by which webhook is sent
|
// Sender represents a type which can send webhooks to an EndPoint
|
||||||
type Sender interface {
|
type Sender interface {
|
||||||
Send(endPoint EndPoint, data interface{}) error
|
Send(endPoint EndPoint, data interface{}) error
|
||||||
}
|
}
|
||||||
|
@ -47,7 +51,8 @@ var senderInstance = &defaultSender{
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
// EndPoint represents and end point to send a webhook to: url and secret by which payload is signed
|
// EndPoint represents a URL to send the webhook to, as well as a secret used
|
||||||
|
// to sign the event
|
||||||
type EndPoint struct {
|
type EndPoint struct {
|
||||||
URL string
|
URL string
|
||||||
Secret string
|
Secret string
|
||||||
|
@ -58,12 +63,12 @@ func Send(endPoint EndPoint, data interface{}) error {
|
||||||
return senderInstance.Send(endPoint, data)
|
return senderInstance.Send(endPoint, data)
|
||||||
}
|
}
|
||||||
|
|
||||||
// SendAll sends data to each of the EndPoints
|
// SendAll sends data to multiple EndPoints
|
||||||
func SendAll(endPoints []EndPoint, data interface{}) {
|
func SendAll(endPoints []EndPoint, data interface{}) {
|
||||||
for _, ept := range endPoints {
|
for _, e := range endPoints {
|
||||||
go func(ept1 EndPoint) {
|
go func(e EndPoint) {
|
||||||
senderInstance.Send(ept1, data)
|
senderInstance.Send(e, data)
|
||||||
}(EndPoint{URL: ept.URL, Secret: ept.Secret})
|
}(e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -76,7 +81,15 @@ func (ds defaultSender) Send(endPoint EndPoint, data interface{}) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
req, err := http.NewRequest("POST", endPoint.URL, bytes.NewBuffer(jsonData))
|
req, err := http.NewRequest("POST", endPoint.URL, bytes.NewBuffer(jsonData))
|
||||||
|
if err != nil {
|
||||||
|
log.Error(err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
signat, err := sign(endPoint.Secret, jsonData)
|
signat, err := sign(endPoint.Secret, jsonData)
|
||||||
|
if err != nil {
|
||||||
|
log.Error(err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
req.Header.Set(SignatureHeader, fmt.Sprintf("%s=%s", Sha256Prefix, signat))
|
req.Header.Set(SignatureHeader, fmt.Sprintf("%s=%s", Sha256Prefix, signat))
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
resp, err := ds.client.Do(req)
|
resp, err := ds.client.Do(req)
|
||||||
|
|
|
@ -3,6 +3,7 @@ const path = require('path');
|
||||||
module.exports = {
|
module.exports = {
|
||||||
context: path.resolve(__dirname, 'static', 'js', 'src', 'app'),
|
context: path.resolve(__dirname, 'static', 'js', 'src', 'app'),
|
||||||
entry: {
|
entry: {
|
||||||
|
passwords: './passwords',
|
||||||
users: './users',
|
users: './users',
|
||||||
webhooks: './webhooks',
|
webhooks: './webhooks',
|
||||||
},
|
},
|
||||||
|
|
|
@ -125,6 +125,11 @@ func (w *DefaultWorker) LaunchCampaign(c models.Campaign) {
|
||||||
// that implements an interface as a slice of that interface.
|
// that implements an interface as a slice of that interface.
|
||||||
mailEntries := []mailer.Mail{}
|
mailEntries := []mailer.Mail{}
|
||||||
currentTime := time.Now().UTC()
|
currentTime := time.Now().UTC()
|
||||||
|
campaignMailCtx, err := models.GetCampaignMailContext(c.Id, c.UserId)
|
||||||
|
if err != nil {
|
||||||
|
log.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
for _, m := range ms {
|
for _, m := range ms {
|
||||||
// Only send the emails scheduled to be sent for the past minute to
|
// Only send the emails scheduled to be sent for the past minute to
|
||||||
// respect the campaign scheduling options
|
// respect the campaign scheduling options
|
||||||
|
@ -132,7 +137,7 @@ func (w *DefaultWorker) LaunchCampaign(c models.Campaign) {
|
||||||
m.Unlock()
|
m.Unlock()
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
err = m.CacheCampaign(&c)
|
err = m.CacheCampaign(&campaignMailCtx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error(err)
|
log.Error(err)
|
||||||
return
|
return
|
||||||
|
@ -150,11 +155,3 @@ func (w *DefaultWorker) SendTestEmail(s *models.EmailRequest) error {
|
||||||
}()
|
}()
|
||||||
return <-s.ErrorChan
|
return <-s.ErrorChan
|
||||||
}
|
}
|
||||||
|
|
||||||
// errorMail is a helper to handle erroring out a slice of Mail instances
|
|
||||||
// in the case that an unrecoverable error occurs.
|
|
||||||
func errorMail(err error, ms []mailer.Mail) {
|
|
||||||
for _, m := range ms {
|
|
||||||
m.Error(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
@ -15,9 +15,7 @@ type logMailer struct {
|
||||||
queue chan []mailer.Mail
|
queue chan []mailer.Mail
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *logMailer) Start(ctx context.Context) {
|
func (m *logMailer) Start(ctx context.Context) {}
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *logMailer) Queue(ms []mailer.Mail) {
|
func (m *logMailer) Queue(ms []mailer.Mail) {
|
||||||
m.queue <- ms
|
m.queue <- ms
|
||||||
|
|
|
@ -5242,3 +5242,8 @@ yargs@^7.1.0:
|
||||||
which-module "^1.0.0"
|
which-module "^1.0.0"
|
||||||
y18n "^3.2.1"
|
y18n "^3.2.1"
|
||||||
yargs-parser "^5.0.0"
|
yargs-parser "^5.0.0"
|
||||||
|
|
||||||
|
zxcvbn@^4.4.2:
|
||||||
|
version "4.4.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/zxcvbn/-/zxcvbn-4.4.2.tgz#28ec17cf09743edcab056ddd8b1b06262cc73c30"
|
||||||
|
integrity sha1-KOwXzwl0PtyrBW3dixsGJizHPDA=
|
||||||
|
|
Loading…
Reference in New Issue