Merged into master

pull/1894/head
Glenn Wilkinson 2020-07-13 13:36:38 +01:00
commit 74460f4d96
77 changed files with 1416 additions and 464 deletions

151
.github/workflows/release.yml vendored Normal file
View File

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

View File

@ -1 +1 @@
0.9.0
0.10.1

View File

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

41
auth/auth_test.go Normal file
View File

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

View File

@ -2,8 +2,9 @@ package config
import (
"encoding/json"
log "github.com/gophish/gophish/logger"
"io/ioutil"
log "github.com/gophish/gophish/logger"
)
// AdminServer represents the Admin server configuration details
@ -12,6 +13,7 @@ type AdminServer struct {
UseTLS bool `json:"use_tls"`
CertPath string `json:"cert_path"`
KeyPath string `json:"key_path"`
CSRFKey string `json:"csrf_key"`
}
// PhishServer represents the Phish server configuration details

View File

@ -62,12 +62,13 @@ func TestLoadConfig(t *testing.T) {
}
expectedConfig.MigrationsPath = expectedConfig.MigrationsPath + expectedConfig.DBName
expectedConfig.TestFlag = false
expectedConfig.AdminConf.CSRFKey = ""
if !reflect.DeepEqual(expectedConfig, conf) {
t.Fatalf("invalid config received. expected %#v got %#v", expectedConfig, conf)
}
// Load an invalid config
conf, err = LoadConfig("bogusfile")
_, err = LoadConfig("bogusfile")
if err == nil {
t.Fatalf("expected error when loading invalid config, but got %v", err)
}

View File

@ -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
func Clear(r *http.Request) {
return
}
func Clear(r *http.Request) {}

View File

@ -42,18 +42,6 @@ func setupTest(t *testing.T) *testContext {
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) {
// Add a group
group := models.Group{Name: "Test Group"}

View File

@ -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)
g = models.Group{}
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 {
JSONResponse(w, models.Response{Success: false, Message: "Error: /:id and group_id mismatch"}, http.StatusInternalServerError)
return

View File

@ -46,7 +46,6 @@ func (as *Server) ImportGroup(w http.ResponseWriter, r *http.Request) {
return
}
JSONResponse(w, ts, http.StatusOK)
return
}
// 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),
}
JSONResponse(w, er, http.StatusOK)
return
}
// 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}
JSONResponse(w, cs, http.StatusOK)
return
}

View File

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

View File

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

View File

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

View File

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

View File

@ -118,5 +118,4 @@ func (as *Server) SendTestEmail(w http.ResponseWriter, r *http.Request) {
return
}
JSONResponse(w, models.Response{Success: true, Message: "Email Sent"}, http.StatusOK)
return
}

View File

@ -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)
case r.Method == "PUT":
wh2 := models.Webhook{}
err = json.NewDecoder(r.Body).Decode(&wh2)
wh2.Id = id
err = models.PutWebhook(&wh2)
wh = models.Webhook{}
err = json.NewDecoder(r.Body).Decode(&wh)
if err != nil {
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 {
JSONResponse(w, models.Response{Success: false, Message: err.Error()}, http.StatusBadRequest)
return
}
JSONResponse(w, wh2, http.StatusOK)
JSONResponse(w, wh, http.StatusOK)
}
}

View File

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

View File

@ -3,7 +3,6 @@ package controllers
import (
"compress/gzip"
"context"
"crypto/tls"
"errors"
"fmt"
"net"
@ -86,9 +85,7 @@ func WithContactAddress(addr string) PhishingServerOption {
func (ps *PhishingServer) Start() {
if ps.config.UseTLS {
// Only support TLS 1.2 and above - ref #1691, #1689
ps.server.TLSConfig = &tls.Config{
MinVersion: tls.VersionTLS12,
}
ps.server.TLSConfig = defaultTLSConfig
err := util.CheckAndCreateSSL(ps.config.CertPath, ps.config.KeyPath)
if err != nil {
log.Fatal(err)

View File

@ -18,6 +18,7 @@ import (
"github.com/gophish/gophish/controllers/api"
log "github.com/gophish/gophish/logger"
mid "github.com/gophish/gophish/middleware"
"github.com/gophish/gophish/middleware/ratelimit"
"github.com/gophish/gophish/models"
"github.com/gophish/gophish/util"
"github.com/gophish/gophish/worker"
@ -38,6 +39,28 @@ type AdminServer struct {
server *http.Server
worker worker.Worker
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.
@ -55,9 +78,11 @@ func NewAdminServer(config config.AdminServer, options ...AdminServerOption) *Ad
ReadTimeout: 10 * time.Second,
Addr: config.ListenURL,
}
defaultLimiter := ratelimit.NewPostLimiter()
as := &AdminServer{
worker: defaultWorker,
server: defaultServer,
limiter: defaultLimiter,
config: config,
}
for _, opt := range options {
@ -74,9 +99,7 @@ func (as *AdminServer) Start() {
}
if as.config.UseTLS {
// Only support TLS 1.2 and above - ref #1691, #1689
as.server.TLSConfig = &tls.Config{
MinVersion: tls.VersionTLS12,
}
as.server.TLSConfig = defaultTLSConfig
err := util.CheckAndCreateSSL(as.config.CertPath, as.config.KeyPath)
if err != nil {
log.Fatal(err)
@ -102,8 +125,9 @@ func (as *AdminServer) registerRoutes() {
router := mux.NewRouter()
// Base Front-end routes
router.HandleFunc("/", mid.Use(as.Base, mid.RequireLogin))
router.HandleFunc("/login", as.Login)
router.HandleFunc("/login", mid.Use(as.Login, as.limiter.Limit))
router.HandleFunc("/logout", mid.Use(as.Logout, mid.RequireLogin))
router.HandleFunc("/reset_password", mid.Use(as.ResetPassword, mid.RequireLogin))
router.HandleFunc("/campaigns", mid.Use(as.Campaigns, mid.RequireLogin))
router.HandleFunc("/campaigns/{id:[0-9]+}", mid.Use(as.CampaignID, mid.RequireLogin))
router.HandleFunc("/templates", mid.Use(as.Templates, mid.RequireLogin))
@ -113,18 +137,25 @@ func (as *AdminServer) registerRoutes() {
router.HandleFunc("/settings", mid.Use(as.Settings, 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("/impersonate", mid.Use(as.Impersonate, mid.RequirePermission(models.PermissionModifySystem), mid.RequireLogin))
router.HandleFunc("/reported", mid.Use(as.Reported, mid.RequireLogin))
router.HandleFunc("/reported/attachment/{id:[0-9]+}", mid.Use(as.ReportedEmailAttachment, mid.RequireLogin))
// Create the API routes
api := api.NewServer(api.WithWorker(as.worker))
api := api.NewServer(
api.WithWorker(as.worker),
api.WithLimiter(as.limiter),
)
router.PathPrefix("/api/").Handler(api)
// Setup static file serving
router.PathPrefix("/").Handler(http.FileServer(unindexed.Dir("./static/")))
// 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.Secure(as.config.UseTLS))
adminHandler := csrfHandler(router)
@ -152,12 +183,14 @@ type templateParams struct {
// the CSRF token.
func newTemplateParams(r *http.Request) templateParams {
user := ctx.Get(r, "user").(models.User)
session := ctx.Get(r, "session").(*sessions.Session)
modifySystem, _ := user.HasPermission(models.PermissionModifySystem)
return templateParams{
Token: csrf.Token(r),
User: user,
ModifySystem: modifySystem,
Version: config.Version,
Flashes: session.Flashes(),
}
}
@ -216,22 +249,37 @@ func (as *AdminServer) Settings(w http.ResponseWriter, r *http.Request) {
case r.Method == "GET":
params := newTemplateParams(r)
params.Title = "Settings"
session := ctx.Get(r, "session").(*sessions.Session)
session.Save(r, w)
getTemplate(w, "settings").ExecuteTemplate(w, "base", params)
case r.Method == "POST":
err := auth.ChangePassword(r)
u := ctx.Get(r, "user").(models.User)
currentPw := r.FormValue("current_password")
newPassword := r.FormValue("new_password")
confirmPassword := r.FormValue("confirm_new_password")
// Check the current password
err := auth.ValidatePassword(currentPw, u.Hash)
msg := models.Response{Success: true, Message: "Settings Updated Successfully"}
if err == auth.ErrInvalidPassword {
msg.Message = "Invalid Password"
msg.Success = false
api.JSONResponse(w, msg, http.StatusBadRequest)
return
}
if err != nil {
msg.Message = err.Error()
msg.Success = false
api.JSONResponse(w, msg, http.StatusBadRequest)
return
}
newHash, err := auth.ValidatePasswordChange(u.Hash, newPassword, confirmPassword)
if err != nil {
msg.Message = err.Error()
msg.Success = false
api.JSONResponse(w, msg, http.StatusBadRequest)
return
}
u.Hash = string(newHash)
if err = models.PutUser(&u); err != nil {
msg.Message = err.Error()
msg.Success = false
api.JSONResponse(w, msg, http.StatusInternalServerError)
return
}
api.JSONResponse(w, msg, http.StatusOK)
}
}
@ -244,6 +292,39 @@ func (as *AdminServer) UserManagement(w http.ResponseWriter, r *http.Request) {
getTemplate(w, "users").ExecuteTemplate(w, "base", params)
}
func (as *AdminServer) nextOrIndex(w http.ResponseWriter, r *http.Request) {
next := "/"
url, err := url.Parse(r.FormValue("next"))
if err == nil {
path := url.Path
if path != "" {
next = path
}
}
http.Redirect(w, r, next, 302)
}
func (as *AdminServer) handleInvalidLogin(w http.ResponseWriter, r *http.Request) {
session := ctx.Get(r, "session").(*sessions.Session)
Flash(w, r, "danger", "Invalid Username/Password")
params := struct {
User models.User
Title string
Flashes []interface{}
Token string
}{Title: "Login", Token: csrf.Token(r)}
params.Flashes = session.Flashes()
session.Save(r, w)
templates := template.New("template")
_, err := templates.ParseFiles("templates/login.html", "templates/flashes.html")
if err != nil {
log.Error(err)
}
// w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusUnauthorized)
template.Must(templates, err).ExecuteTemplate(w, "base", params)
}
// Webhooks is an admin-only handler that handles webhooks
func (as *AdminServer) Webhooks(w http.ResponseWriter, r *http.Request) {
params := newTemplateParams(r)
@ -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,
// a session is created
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)
case r.Method == "POST":
//Attempt to login
succ, u, err := auth.Login(r)
// Find the user with the provided username
username, password := r.FormValue("username"), r.FormValue("password")
u, err := models.GetUserByUsername(username)
if err != nil {
log.Error(err)
as.handleInvalidLogin(w, r)
return
}
//If we've logged in, save the session and redirect to the dashboard
if succ {
// Validate the user's password
err = auth.ValidatePassword(password, u.Hash)
if err != nil {
log.Error(err)
as.handleInvalidLogin(w, r)
return
}
// If we've logged in, save the session and redirect to the dashboard
session.Values["id"] = u.Id
session.Save(r, w)
next := "/"
url, err := url.Parse(r.FormValue("next"))
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)
}
as.nextOrIndex(w, r)
}
}
@ -342,9 +429,72 @@ func (as *AdminServer) Logout(w http.ResponseWriter, r *http.Request) {
delete(session.Values, "id")
Flash(w, r, "success", "You have successfully logged out")
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 {
templates := template.New("template")
_, err := templates.ParseFiles("templates/base.html", "templates/nav.html", "templates/"+tmpl+".html", "templates/flashes.html")

View File

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

View File

@ -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
View File

@ -8,6 +8,8 @@ require (
github.com/PuerkitoBio/goquery v1.5.0
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751 // 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/gophish/gomail v0.0.0-20180314010319-cf7e1a5479be
github.com/gorilla/context v1.1.1
@ -17,15 +19,15 @@ require (
github.com/gorilla/securecookie v1.1.1
github.com/gorilla/sessions v1.2.0
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/kylelemons/go-gypsy v0.0.0-20160905020020-08cad365cd28 // indirect
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/sirupsen/logrus v1.4.2
github.com/ziutek/mymysql v1.5.4 // indirect
golang.org/x/crypto v0.0.0-20200128174031-69ecbb4d6d5d
golang.org/x/time v0.0.0-20200416051211-89c76fbcd5d1
gopkg.in/alecthomas/kingpin.v2 v2.2.6
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405

21
go.sum
View File

@ -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/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/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/go.mod h1:a2zkGnVExMxdzMo3M0Hi/3sEU+cWnZpSni0O6/Yb/P0=
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/now v1.0.1 h1:HjfetcXq097iXP0uoPCdnM4Efp5/9MsM0/M+XOTeR3M=
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-20200121133829-a0b5c5b58bb6/go.mod h1:1c7szIrayyPPB/987hsnvNzLushdWf4o/79s3P08L8A=
github.com/jordan-wright/email v0.0.0-20200602115436-fd8a7622303e h1:OGunVjqY7y4U4laftpEHv+mvZBlr7UGimJXKEGQtg48=
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/go.mod h1:eRt05O5haIXGKGodWjpQ2xdgBHTE7hg/pzsukNi9IRA=
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/lib/pq v1.1.1 h1:sJZmqHoEaY7f+NPP8pgLB/WxulyR3fewgCM2qaSlBb4=
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.3+incompatible h1:gXHsfypPkaMZrKbD5209QV9jbUTJKjyR5WD3HYQSd+U=
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/go.mod h1:DUJFucBg2cvqx42YmDa/+xHvb0elJtOm3o4aFQ/nb/w=
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/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/time v0.0.0-20200416051211-89c76fbcd5d1 h1:NusfzzA6yGQ+ua51ck7E3omNUX/JuqbFSaRGqU8CcLI=
golang.org/x/time v0.0.0-20200416051211-89c76fbcd5d1/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
gopkg.in/alecthomas/kingpin.v2 v2.2.6 h1:jMFz6MfLP0/4fUyZle81rXUoxOBFi19VUFKVDOQfozc=
gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=

View File

@ -26,6 +26,7 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
*/
import (
"fmt"
"io/ioutil"
"os"
"os/signal"
@ -40,9 +41,17 @@ import (
"github.com/gophish/gophish/models"
)
const (
modeAll string = "all"
modeAdmin string = "admin"
modePhish string = "phish"
)
var (
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()
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() {
@ -102,18 +111,25 @@ func main() {
phishServer := controllers.NewPhishingServer(phishConfig)
imapMonitor := imap.NewMonitor()
if *mode == "admin" || *mode == "all" {
go adminServer.Start()
go phishServer.Start()
go imapMonitor.Start()
}
if *mode == "phish" || *mode == "all" {
go phishServer.Start()
}
// Handle graceful shutdown
c := make(chan os.Signal, 1)
signal.Notify(c, os.Interrupt)
<-c
log.Info("CTRL+C Received... Gracefully shutting down servers")
if *mode == modeAdmin || *mode == modeAll {
adminServer.Shutdown()
phishServer.Shutdown()
imapMonitor.Shutdown()
}
if *mode == modePhish || *mode == modeAll {
phishServer.Shutdown()
}
}

View File

@ -4,7 +4,6 @@ import (
"bytes"
"crypto/tls"
"fmt"
"io"
"regexp"
"strconv"
"time"
@ -12,7 +11,6 @@ import (
"github.com/emersion/go-imap"
"github.com/emersion/go-imap/client"
"github.com/emersion/go-message/charset"
"github.com/emersion/go-message/mail"
log "github.com/gophish/gophish/logger"
"github.com/gophish/gophish/models"
@ -48,7 +46,6 @@ type Mailbox struct {
// Validate validates supplied IMAP model by connecting to the server
func Validate(s *models.IMAP) error {
err := s.Validate()
if err != nil {
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.
func (mbox *Mailbox) GetUnread(markAsRead, delete bool) ([]Email, error) {
imap.CharsetReader = charset.Reader
var emails []Email
@ -130,13 +126,16 @@ func (mbox *Mailbox) GetUnread(markAsRead, delete bool) ([]Email, error) {
// Search for unread emails
criteria := imap.NewSearchCriteria()
criteria.WithoutFlags = []string{"\\Seen"}
criteria.WithoutFlags = []string{imap.SeenFlag}
seqs, err := imapClient.Search(criteria)
if err != nil {
return emails, err
}
if len(seqs) > 0 {
if len(seqs) == 0 {
return emails, nil
}
seqset := new(imap.SeqSet)
seqset.AddNum(seqs...)
section := &imap.BodySectionName{}
@ -172,38 +171,9 @@ func (mbox *Mailbox) GetUnread(markAsRead, delete bool) ([]Email, error) {
return emails, err
}
// Reload the reader
rawBodyStream = bytes.NewReader(buf)
mr, err := mail.CreateReader(rawBodyStream)
if err != nil {
return emails, err
}
// Step over each part of the email, parsing attachments and attaching them to Jordan's email
for {
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
}
@ -214,15 +184,12 @@ func (mbox *Mailbox) newClient() (*client.Client, error) {
var err error
if mbox.TLS {
imapClient, err = client.DialTLS(mbox.Host, new(tls.Config))
if err != nil {
return imapClient, err
}
} else {
imapClient, err = client.Dial(mbox.Host)
}
if err != nil {
return imapClient, err
}
}
err = imapClient.Login(mbox.User, mbox.Pwd)
if err != nil {

View File

@ -11,6 +11,7 @@ import (
"context"
"encoding/base64"
"net/mail"
"path/filepath"
"regexp"
"strconv"
"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
// each, as well as keeping an eye on newly created user accounts.
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).
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.)
// 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) {
for {
select {
case <-ctx.Done():
@ -96,7 +95,6 @@ func monitor(uid int64, ctx context.Context) {
// NewMonitor returns a new instance of imap.Monitor
func NewMonitor() *Monitor {
im := &Monitor{}
return im
}
@ -120,7 +118,6 @@ func (im *Monitor) Shutdown() error {
// checkForNewEmails logs into an IMAP account and checks unread emails
// for the rid campaign identifier.
func checkForNewEmails(im models.IMAP) {
im.Host = im.Host + ":" + strconv.Itoa(int(im.Port)) // Append port
mailServer := Mailbox{
Host: im.Host,
@ -140,7 +137,7 @@ func checkForNewEmails(im models.IMAP) {
if len(msgs) > 0 {
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 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 {
// Check if sender is from company's domain, if enabled. TODO: Make this an IMAP filter
if im.RestrictDomain != "" { // e.g domainResitct = widgets.com
@ -152,12 +149,14 @@ 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 {
log.Errorf("Error searching email for rids from user '%s': %s", m.Email.From, err.Error())
} else {
continue
}
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
@ -185,6 +184,7 @@ func checkForNewEmails(im models.IMAP) {
Status: "Unknown"}
models.SaveReportedEmail(em)
}
for rid := range rids {
log.Infof("User '%s' reported email with rid %s", m.Email.From, rid)
@ -192,20 +192,19 @@ func checkForNewEmails(im models.IMAP) {
if err != nil {
log.Error("Error reporting GoPhish email with rid ", rid, ": ", err.Error())
reportingFailed = append(reportingFailed, m.SeqNum)
} else {
continue
}
err = result.HandleEmailReport(models.EventDetails{})
if err != nil {
log.Error("Error updating GoPhish email with rid ", rid, ": ", err.Error())
} else {
continue
}
if im.DeleteReportedCampaignEmail == true {
campaignEmails = append(campaignEmails, m.SeqNum)
}
}
}
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 {
log.Debugf("Marking %d emails as unread as failed to report", len(reportingFailed))
@ -215,55 +214,48 @@ func checkForNewEmails(im models.IMAP) {
}
}
// 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 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 {
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) (map[string]int, error) {
rids := make(map[string]int)
func checkRIDs(em *email.Email, rids map[string]bool) {
// Check Text and HTML
emailContent := string(em.Text) + string(em.HTML)
for _, r := range goPhishRegex.FindAllStringSubmatch(emailContent, -1) {
newrid := r[len(r)-1]
if _, ok := rids[newrid]; ok {
rids[newrid]++
} else {
rids[newrid] = 1
if !rids[newrid] {
rids[newrid] = true
}
}
}
//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 {
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)
attachementEmail, err := email.NewEmailFromReader(rawBodyStream)
attachmentEmail, err := email.NewEmailFromReader(rawBodyStream)
if err != nil {
return rids, err
}
emailContent := string(attachementEmail.Text) + string(attachementEmail.HTML)
for _, r := range goPhishRegex.FindAllStringSubmatch(emailContent, -1) {
newrid := r[len(r)-1]
if _, ok := rids[newrid]; ok {
rids[newrid]++
} else {
rids[newrid] = 1
}
}
checkRIDs(attachmentEmail, rids)
}
}

View File

@ -13,11 +13,6 @@ import (
// being 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
type mockDialer struct {
dialCount int
@ -137,10 +132,6 @@ func (mm *mockMessage) defaultDialer() (Dialer, error) {
return newMockDialer(), nil
}
func (mm *mockMessage) errorDialer() (Dialer, error) {
return nil, errDialerUnavailable
}
func (mm *mockMessage) GetDialer() (Dialer, error) {
return mm.getdialer()
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -27,7 +27,7 @@ type Campaign struct {
Status string `json:"status"`
Results []Result `json:"results,omitempty"`
Groups []Group `json:"groups,omitempty"`
Events []Event `json:"timeline,omitemtpy"`
Events []Event `json:"timeline,omitempty"`
SMTPId int64 `json:"-"`
SMTP SMTP `json:"smtp"`
URL string `json:"url"`

View File

@ -103,10 +103,6 @@ func (s *EmailRequest) Generate(msg *gomail.Message) error {
if err != nil {
return err
}
fn := f.Name
if fn == "" {
fn = f.Address
}
msg.SetAddressHeader("From", f.Address, f.Name)
ptx, err := NewPhishingTemplateContext(s, s.BaseRecipient, s.RId)

View File

@ -226,7 +226,6 @@ func PutGroup(g *Group) error {
return err
}
// Fetch group's existing targets from database.
ts := []Target{}
ts, err := GetTargets(g.Id)
if err != nil {
log.WithFields(logrus.Fields{

View File

@ -53,7 +53,7 @@ var ErrIMAPPasswordNotSpecified = errors.New("No Password specified")
// ErrInvalidIMAPFreq is thrown when the frequency for polling the
// 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
func (im IMAP) TableName() string {

View File

@ -125,7 +125,7 @@ func (m *MailLog) Success() error {
return err
}
err = db.Delete(m).Error
return nil
return err
}
// GetDialer returns a dialer based on the maillog campaign's SMTP configuration

View File

@ -7,13 +7,15 @@ import (
"fmt"
"io"
"io/ioutil"
"path/filepath"
"os"
"time"
"bitbucket.org/liamstask/goose/lib/goose"
mysql "github.com/go-sql-driver/mysql"
"github.com/gophish/gophish/auth"
"github.com/gophish/gophish/config"
log "github.com/gophish/gophish/logger"
"github.com/jinzhu/gorm"
_ "github.com/mattn/go-sqlite3" // Blank import needed to import sqlite3
@ -24,6 +26,19 @@ var conf *config.Config
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 (
CampaignInProgress string = "In progress"
CampaignQueued string = "Queued"
@ -83,8 +98,38 @@ func chooseDBDriver(name, openStr string) goose.DBDriver {
return d
}
// Setup initializes the Conn object
// It also populates the Gophish Config object
func createTemporaryPassword(u *User) error {
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 {
// Setup the package-scoped config
conf = c
@ -94,8 +139,6 @@ func Setup(c *config.Config) error {
Env: "production",
Driver: chooseDBDriver(conf.DBName, conf.DBPath),
}
abs, _ := filepath.Abs(migrateConf.MigrationsDir)
fmt.Println(abs)
// Get the latest possible migration
latest, err := goose.GetMostRecentDBVersion(migrateConf.MigrationsDir)
if err != nil {
@ -156,6 +199,7 @@ func Setup(c *config.Config) error {
}
// Create the admin user if it doesn't exist
var userCount int64
var adminUser User
db.Model(&User{}).Count(&userCount)
adminRole, err := GetRoleBySlug(RoleAdmin)
if err != nil {
@ -163,14 +207,44 @@ func Setup(c *config.Config) error {
return err
}
if userCount == 0 {
initUser := User{
Username: "admin",
Hash: "$2a$10$IYkPp0.QsM81lYYPrQx6W.U6oQGw7wMpozrKhKAHUBVL4mkm/EvAS", //gophish
adminUser := User{
Username: DefaultAdminUsername,
Role: adminRole,
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 {
log.Error(err)
return err

View File

@ -138,6 +138,9 @@ func PostPage(p *Page) error {
// Per the PUT Method RFC, it presumes all data for a page is provided.
func PutPage(p *Page) error {
err := p.Validate()
if err != nil {
return err
}
err = db.Where("id=?", p.Id).Save(p).Error
if err != nil {
log.Error(err)

View File

@ -19,6 +19,7 @@ type User struct {
ApiKey string `json:"api_key" sql:"not null;unique"`
Role Role `json:"role" gorm:"association_autoupdate:false;association_autocreate:false"`
RoleID int64 `json:"-"`
PasswordChangeRequired bool `json:"password_change_required"`
}
// GetUser returns the user that the given id corresponds to. If no user is found, an

View File

@ -27,7 +27,9 @@ func (s *ModelsSuite) TestGetUserByAPIKeyWithExistingAPIKey(c *check.C) {
u, err := GetUser(1)
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) {
@ -46,11 +48,12 @@ func (s *ModelsSuite) TestGetUserByUsernameWithNotExistingUser(c *check.C) {
}
func (s *ModelsSuite) TestPutUser(c *check.C) {
u, err := GetUser(1)
u, _ := GetUser(1)
u.Username = "admin_changed"
err = PutUser(&u)
err := PutUser(&u)
c.Assert(err, check.Equals, nil)
u, err = GetUser(1)
c.Assert(err, check.Equals, nil)
c.Assert(u.Username, check.Equals, "admin_changed")
}

View File

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

File diff suppressed because one or more lines are too long

12
static/css/main.css vendored
View File

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

BIN
static/images/favicon.ico vendored Normal file

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

View File

@ -1 +1 @@
var groups=[];function save(e){var t=[];$.each($("#targetsTable").DataTable().rows().data(),function(e,a){t.push({first_name:unescapeHtml(a[0]),last_name:unescapeHtml(a[1]),email:unescapeHtml(a[2]),position:unescapeHtml(a[3])})});var a={name:$("#name").val(),targets:t};-1!=e?(a.id=e,api.groupId.put(a).success(function(e){successFlash("Group updated successfully!"),load(),dismiss(),$("#modal").modal("hide")}).error(function(e){modalError(e.responseJSON.message)})):api.groups.post(a).success(function(e){successFlash("Group added successfully!"),load(),dismiss(),$("#modal").modal("hide")}).error(function(e){modalError(e.responseJSON.message)})}function dismiss(){$("#targetsTable").dataTable().DataTable().clear().draw(),$("#name").val(""),$("#modal\\.flashes").empty()}function edit(e){if(targets=$("#targetsTable").dataTable({destroy:!0,columnDefs:[{orderable:!1,targets:"no-sort"}]}),$("#modalSubmit").unbind("click").click(function(){save(e)}),-1==e);else api.groupId.get(e).success(function(e){$("#name").val(e.name),$.each(e.targets,function(e,a){targets.DataTable().row.add([escapeHtml(a.first_name),escapeHtml(a.last_name),escapeHtml(a.email),escapeHtml(a.position),'<span style="cursor:pointer;"><i class="fa fa-trash-o"></i></span>']).draw()})}).error(function(){errorFlash("Error fetching group")});$("#csvupload").fileupload({url:"/api/import/group",dataType:"json",beforeSend:function(e){e.setRequestHeader("Authorization","Bearer "+user.api_key)},add:function(e,a){$("#modal\\.flashes").empty();var t=a.originalFiles[0].name;if(t&&!/(csv|txt)$/i.test(t.split(".").pop()))return modalError("Unsupported file extension (use .csv or .txt)"),!1;a.submit()},done:function(e,a){$.each(a.result,function(e,a){addTarget(a.first_name,a.last_name,a.email,a.position)}),targets.DataTable().draw()}})}var downloadCSVTemplate=function(){var e="group_template.csv",a=Papa.unparse([{"First Name":"Example","Last Name":"User",Email:"foobar@example.com",Position:"Systems Administrator"}],{}),t=new Blob([a],{type:"text/csv;charset=utf-8;"});if(navigator.msSaveBlob)navigator.msSaveBlob(t,e);else{var 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

1
static/js/dist/app/passwords.min.js vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1 +1 @@
!function(e){var 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,'"'))})})})}});

View File

@ -162,15 +162,16 @@ function deleteCampaign(idx) {
}
function setupOptions() {
api.groups.get()
.success(function (groups) {
api.groups.summary()
.success(function (summaries) {
groups = summaries.groups
if (groups.length == 0) {
modalError("No groups found!")
return false;
} else {
var group_s2 = $.map(groups, function (obj) {
obj.text = obj.name
obj.title = obj.targets.length + " targets"
obj.title = obj.num_targets + " targets"
return obj
});
console.log(group_s2)
@ -360,6 +361,7 @@ $(document).ready(function () {
[1, "desc"]
]
});
campaignRows = []
$.each(campaigns, function (i, campaign) {
campaignTable = campaignTableOriginal
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
}
campaignTable.row.add([
campaignRows.push([
escapeHtml(campaign.name),
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>",
@ -391,9 +393,10 @@ $(document).ready(function () {
<button class='btn btn-danger' onclick='deleteCampaign(" + i + ")' data-toggle='tooltip' data-placement='left' title='Delete Campaign'>\
<i class='fa fa-trash-o'></i>\
</button></div>"
]).draw()
])
$('[data-toggle="tooltip"]').tooltip()
})
campaignTable.rows.add(campaignRows).draw()
} else {
$("#emptyMessage").show()
}

View File

@ -326,6 +326,7 @@ $(document).ready(function () {
[1, "desc"]
]
});
campaignRows = []
$.each(campaigns, function (i, campaign) {
var campaign_date = moment(campaign.created_date).format('MMMM Do YYYY, h:mm:ss a')
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')
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
campaignTable.row.add([
// Add it to the list
campaignRows.push([
escapeHtml(campaign.name),
campaign_date,
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'>\
<i class='fa fa-trash-o'></i>\
</button></div>"
]).draw()
])
$('[data-toggle="tooltip"]').tooltip()
})
campaignTable.rows.add(campaignRows).draw()
// Build the charts
generateStatsPieCharts(campaigns)
generateTimelineChart(campaigns)

View File

@ -69,17 +69,17 @@ function edit(id) {
api.groupId.get(id)
.success(function (group) {
$("#name").val(group.name)
targetRows = []
$.each(group.targets, function (i, record) {
targets.DataTable()
.row.add([
targetRows.push([
escapeHtml(record.first_name),
escapeHtml(record.last_name),
escapeHtml(record.email),
escapeHtml(record.position),
'<span style="cursor:pointer;"><i class="fa fa-trash-o"></i></span>'
]).draw()
])
});
targets.DataTable().rows.add(targetRows).draw()
})
.error(function () {
errorFlash("Error fetching group")
@ -233,8 +233,9 @@ function load() {
}]
});
groupTable.clear();
groupRows = []
$.each(groups, function (i, group) {
groupTable.row.add([
groupRows.push([
escapeHtml(group.name),
escapeHtml(group.num_targets),
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 + ")'>\
<i class='fa fa-trash-o'></i>\
</button></div>"
]).draw()
])
})
groupTable.rows.add(groupRows).draw()
} else {
$("#emptyMessage").show()
}

View File

@ -157,8 +157,9 @@ function load() {
}]
});
pagesTable.clear()
pageRows = []
$.each(pages, function (i, page) {
pagesTable.row.add([
pageRows.push([
escapeHtml(page.name),
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 + ")'>\
@ -170,8 +171,9 @@ function load() {
<button class='btn btn-danger' data-toggle='tooltip' data-placement='left' title='Delete Page' onclick='deletePage(" + i + ")'>\
<i class='fa fa-trash-o'></i>\
</button></div>"
]).draw()
])
})
pagesTable.rows.add(pageRows).draw()
$('[data-toggle="tooltip"]').tooltip()
} else {
$("#emptyMessage").show()

View File

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

View File

@ -200,8 +200,9 @@ function load() {
}]
});
profileTable.clear()
profileRows = []
$.each(profiles, function (i, profile) {
profileTable.row.add([
profileRows.push([
escapeHtml(profile.name),
profile.interface_type,
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 + ")'>\
<i class='fa fa-trash-o'></i>\
</button></div>"
]).draw()
])
})
profileTable.rows.add(profileRows).draw()
$('[data-toggle="tooltip"]').tooltip()
} else {
$("#emptyMessage").show()

View File

@ -191,17 +191,19 @@ function edit(idx) {
$("#subject").val(template.subject)
$("#html_editor").val(template.html)
$("#text_editor").val(template.text)
attachmentRows = []
$.each(template.attachments, function (i, file) {
var icon = icons[file.type] || "fa-file-o"
// Add the record to the modal
attachmentsTable.row.add([
attachmentRows.push([
'<i class="fa ' + icon + '"></i>',
escapeHtml(file.name),
'<span class="remove-row"><i class="fa fa-trash-o"></i></span>',
file.content,
file.type || "application/octet-stream"
]).draw()
])
})
attachmentsTable.rows.add(attachmentRows).draw()
if (template.html.indexOf("{{.Tracker}}") != -1) {
$("#use_tracker_checkbox").prop("checked", true)
} else {
@ -316,8 +318,9 @@ function load() {
}]
});
templateTable.clear()
templateRows = []
$.each(templates, function (i, template) {
templateTable.row.add([
templateRows.push([
escapeHtml(template.name),
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 + ")'>\
@ -329,8 +332,9 @@ function load() {
<button class='btn btn-danger' data-toggle='tooltip' data-placement='left' title='Delete Template' onclick='deleteTemplate(" + i + ")'>\
<i class='fa fa-trash-o'></i>\
</button></div>"
]).draw()
])
})
templateTable.rows.add(templateRows).draw()
$('[data-toggle="tooltip"]').tooltip()
} else {
$("#emptyMessage").show()

View File

@ -10,7 +10,8 @@ const save = (id) => {
let user = {
username: $("#username").val(),
password: $("#password").val(),
role: $("#role").val()
role: $("#role").val(),
password_change_required: $("#force_password_change_checkbox").prop('checked')
}
// Submit the user
if (id != -1) {
@ -18,26 +19,26 @@ const save = (id) => {
// we need to PUT /user/:id
user.id = id
api.userId.put(user)
.success(function (data) {
.success((data) => {
successFlash("User " + escapeHtml(user.username) + " updated successfully!")
load()
dismiss()
$("#modal").modal('hide')
})
.error(function (data) {
.error((data) => {
modalError(data.responseJSON.message)
})
} else {
// Else, if this is a new user, POST it
// to /user
api.users.post(user)
.success(function (data) {
.success((data) => {
successFlash("User " + escapeHtml(user.username) + " registered successfully!")
load()
dismiss()
$("#modal").modal('hide')
})
.error(function (data) {
.error((data) => {
modalError(data.responseJSON.message)
})
}
@ -61,10 +62,11 @@ const edit = (id) => {
$("#role").trigger("change")
} else {
api.userId.get(id)
.success(function (user) {
.success((user) => {
$("#username").val(user.username)
$("#role").val(user.role.slug)
$("#role").trigger("change")
$("#force_password_change_checkbox").prop('checked', false)
})
.error(function () {
errorFlash("Error fetching user")
@ -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 = () => {
$("#userTable").hide()
@ -132,18 +183,24 @@ const load = () => {
}]
});
userTable.clear();
userRows = []
$.each(users, (i, user) => {
userTable.row.add([
userRows.push([
escapeHtml(user.username),
escapeHtml(user.role.name),
"<div class='pull-right'><button class='btn btn-primary edit_button' data-toggle='modal' data-backdrop='static' data-target='#modal' data-user-id='" + user.id + "'>\
"<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>\
</button>\
<button class='btn btn-danger delete_button' data-user-id='" + user.id + "'>\
<i class='fa fa-trash-o'></i>\
</button></div>"
]).draw()
])
})
userTable.rows.add(userRows).draw();
})
.error(() => {
errorFlash("Error fetching users")
@ -180,4 +237,7 @@ $(document).ready(function () {
$("#userTable").on('click', '.delete_button', function (e) {
deleteUser($(this).attr('data-user-id'))
})
$("#userTable").on('click', '.impersonate_button', function (e) {
impersonate($(this).attr('data-user-id'))
})
});

View File

@ -16,13 +16,13 @@ const saveWebhook = (id) => {
is_active: $("#is_active").is(":checked"),
};
if (id != -1) {
wh.id = id;
wh.id = parseInt(id);
api.webhookId.put(wh)
.success(function(data) {
dismiss();
load();
$("#modal").modal("hide");
successFlash(`Webhook "${escape(wh.name)}" has been updated successfully!`);
successFlash(`Webhook "${escapeHtml(wh.name)}" has been updated successfully!`);
})
.error(function(data) {
modalError(data.responseJSON.message)
@ -33,7 +33,7 @@ const saveWebhook = (id) => {
load();
dismiss();
$("#modal").modal("hide");
successFlash(`Webhook "${escape(wh.name)}" has been created successfully!`);
successFlash(`Webhook "${escapeHtml(wh.name)}" has been created successfully!`);
})
.error(function(data) {
modalError(data.responseJSON.message)
@ -108,7 +108,7 @@ const deleteWebhook = (id) => {
}
Swal.fire({
title: "Are you sure?",
text: `This will delete the webhook '${escape(wh.name)}'`,
text: `This will delete the webhook '${escapeHtml(wh.name)}'`,
type: "warning",
animation: false,
showCancelButton: true,
@ -150,7 +150,7 @@ const pingUrl = (btn, whId) => {
api.webhookId.ping(whId)
.success(function(wh) {
btn.disabled = false;
successFlash(`Ping of "${escape(wh.name)}" webhook succeeded.`);
successFlash(`Ping of "${escapeHtml(wh.name)}" webhook succeeded.`);
})
.error(function(data) {
btn.disabled = false;
@ -158,7 +158,7 @@ const pingUrl = (btn, whId) => {
if (!wh) {
return
}
errorFlash(`Ping of "${escape(wh.name)}" webhook failed: "${data.responseJSON.message}"`)
errorFlash(`Ping of "${escapeHtml(wh.name)}" webhook failed: "${data.responseJSON.message}"`)
});
};

View File

@ -8,7 +8,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="Gophish - Phishing Toolkit">
<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>

View File

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

View File

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

View File

@ -8,7 +8,7 @@
<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">
<link rel="shortcut icon" href="/images/favicon.ico">
<title>Gophish - {{ .Title }}</title>

View File

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

View File

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

View File

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

View File

@ -21,7 +21,6 @@ import (
log "github.com/gophish/gophish/logger"
"github.com/gophish/gophish/models"
"github.com/jordan-wright/email"
"golang.org/x/crypto/bcrypt"
)
var (
@ -138,6 +137,9 @@ func CheckAndCreateSSL(cp string, kp string) error {
log.Infof("Creating new self-signed certificates for administration interface")
priv, err := ecdsa.GenerateKey(elliptic.P384(), rand.Reader)
if err != nil {
return fmt.Errorf("error generating tls private key: %v", err)
}
notBefore := time.Now()
// 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)
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{
@ -165,24 +167,24 @@ func CheckAndCreateSSL(cp string, kp string) error {
derBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, priv.Public(), priv)
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)
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})
certOut.Close()
keyOut, err := os.OpenFile(kp, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
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)
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})
@ -191,21 +193,3 @@ func CheckAndCreateSSL(cp string, kp string) error {
log.Info("TLS Certificate Generation complete")
return nil
}
// GenerateSecureKey creates a secure key to use as an API key
func GenerateSecureKey() string {
// Inspired from gorilla/securecookie
k := make([]byte, 32)
io.ReadFull(rand.Reader, k)
return fmt.Sprintf("%x", k)
}
// NewHash hashes the provided password and returns the bcrypt hash (using the
// default 10 rounds) as a string.
func NewHash(pass string) (string, error) {
hash, err := bcrypt.GenerateFromPassword([]byte(pass), bcrypt.DefaultCost)
if err != nil {
return "", err
}
return string(hash), nil
}

View File

@ -16,20 +16,24 @@ import (
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
// 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
// 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"
// 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"
)
// 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 {
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 {
URL string
Secret string
@ -58,12 +63,12 @@ func Send(endPoint EndPoint, data interface{}) error {
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{}) {
for _, ept := range endPoints {
go func(ept1 EndPoint) {
senderInstance.Send(ept1, data)
}(EndPoint{URL: ept.URL, Secret: ept.Secret})
for _, e := range endPoints {
go func(e EndPoint) {
senderInstance.Send(e, data)
}(e)
}
}
@ -76,7 +81,15 @@ func (ds defaultSender) Send(endPoint EndPoint, data interface{}) error {
}
req, err := http.NewRequest("POST", endPoint.URL, bytes.NewBuffer(jsonData))
if err != nil {
log.Error(err)
return err
}
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("Content-Type", "application/json")
resp, err := ds.client.Do(req)

View File

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

View File

@ -125,6 +125,11 @@ func (w *DefaultWorker) LaunchCampaign(c models.Campaign) {
// that implements an interface as a slice of that interface.
mailEntries := []mailer.Mail{}
currentTime := time.Now().UTC()
campaignMailCtx, err := models.GetCampaignMailContext(c.Id, c.UserId)
if err != nil {
log.Error(err)
return
}
for _, m := range ms {
// Only send the emails scheduled to be sent for the past minute to
// respect the campaign scheduling options
@ -132,7 +137,7 @@ func (w *DefaultWorker) LaunchCampaign(c models.Campaign) {
m.Unlock()
continue
}
err = m.CacheCampaign(&c)
err = m.CacheCampaign(&campaignMailCtx)
if err != nil {
log.Error(err)
return
@ -150,11 +155,3 @@ func (w *DefaultWorker) SendTestEmail(s *models.EmailRequest) error {
}()
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)
}
}

View File

@ -15,9 +15,7 @@ type logMailer struct {
queue chan []mailer.Mail
}
func (m *logMailer) Start(ctx context.Context) {
return
}
func (m *logMailer) Start(ctx context.Context) {}
func (m *logMailer) Queue(ms []mailer.Mail) {
m.queue <- ms

View File

@ -5242,3 +5242,8 @@ yargs@^7.1.0:
which-module "^1.0.0"
y18n "^3.2.1"
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=