mirror of
https://github.com/gophish/gophish
synced 2024-11-14 08:17:17 +00:00
Initial Implementation of a Password Policy (#1867)
This PR adds the initial work to implement a password policy as defined in #1538. Specifically, this implements the following * Rate limiting for the login handler * Implementing the ability for system admins to require a user to reset their password * Implementing a password policy that requires passwords to be a minimum of 8 characters * Removes the default password (gophish) for admin users to instead have the password randomly generated when Gophish first starts up * Adds a password strength meter when choosing a new password Fixes #1538
This commit is contained in:
parent
0f6439de5a
commit
bb7de8df3e
36 changed files with 841 additions and 154 deletions
124
auth/auth.go
124
auth/auth.go
|
@ -1,69 +1,103 @@
|
||||||
package auth
|
package auth
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/rand"
|
||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"fmt"
|
||||||
|
"io"
|
||||||
|
|
||||||
ctx "github.com/gophish/gophish/context"
|
|
||||||
"github.com/gophish/gophish/models"
|
|
||||||
"golang.org/x/crypto/bcrypt"
|
"golang.org/x/crypto/bcrypt"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// MinPasswordLength is the minimum number of characters required in a password
|
||||||
|
const MinPasswordLength = 8
|
||||||
|
|
||||||
|
// APIKeyLength is the length of Gophish API keys
|
||||||
|
const APIKeyLength = 32
|
||||||
|
|
||||||
// ErrInvalidPassword is thrown when a user provides an incorrect password.
|
// ErrInvalidPassword is thrown when a user provides an incorrect password.
|
||||||
var ErrInvalidPassword = errors.New("Invalid Password")
|
var ErrInvalidPassword = errors.New("Invalid Password")
|
||||||
|
|
||||||
// ErrPasswordMismatch is thrown when a user provides a blank password to the register
|
// ErrPasswordMismatch is thrown when a user provides a mismatching password
|
||||||
// or change password functions
|
// and confirmation password.
|
||||||
var ErrPasswordMismatch = errors.New("Password cannot be blank")
|
var ErrPasswordMismatch = errors.New("Passwords do not match")
|
||||||
|
|
||||||
|
// ErrReusedPassword is thrown when a user attempts to change their password to
|
||||||
|
// the existing password
|
||||||
|
var ErrReusedPassword = errors.New("Cannot reuse existing password")
|
||||||
|
|
||||||
// ErrEmptyPassword is thrown when a user provides a blank password to the register
|
// ErrEmptyPassword is thrown when a user provides a blank password to the register
|
||||||
// or change password functions
|
// or change password functions
|
||||||
var ErrEmptyPassword = errors.New("No password provided")
|
var ErrEmptyPassword = errors.New("No password provided")
|
||||||
|
|
||||||
// Login attempts to login the user given a request.
|
// ErrPasswordTooShort is thrown when a user provides a password that is less
|
||||||
func Login(r *http.Request) (bool, models.User, error) {
|
// than MinPasswordLength
|
||||||
username, password := r.FormValue("username"), r.FormValue("password")
|
var ErrPasswordTooShort = fmt.Errorf("Password must be at least %d characters", MinPasswordLength)
|
||||||
u, err := models.GetUserByUsername(username)
|
|
||||||
if err != nil {
|
// GenerateSecureKey returns the hex representation of key generated from n
|
||||||
return false, models.User{}, err
|
// random bytes
|
||||||
}
|
func GenerateSecureKey(n int) string {
|
||||||
//If we've made it here, we should have a valid user stored in u
|
k := make([]byte, n)
|
||||||
//Let's check the password
|
io.ReadFull(rand.Reader, k)
|
||||||
err = bcrypt.CompareHashAndPassword([]byte(u.Hash), []byte(password))
|
return fmt.Sprintf("%x", k)
|
||||||
if err != nil {
|
|
||||||
return false, models.User{}, ErrInvalidPassword
|
|
||||||
}
|
|
||||||
return true, u, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ChangePassword verifies the current password provided in the request and,
|
// GeneratePasswordHash returns the bcrypt hash for the provided password using
|
||||||
// if it's valid, changes the password for the authenticated user.
|
// the default bcrypt cost.
|
||||||
func ChangePassword(r *http.Request) error {
|
func GeneratePasswordHash(password string) (string, error) {
|
||||||
u := ctx.Get(r, "user").(models.User)
|
h, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||||
currentPw := r.FormValue("current_password")
|
|
||||||
newPassword := r.FormValue("new_password")
|
|
||||||
confirmPassword := r.FormValue("confirm_new_password")
|
|
||||||
// Check the current password
|
|
||||||
err := bcrypt.CompareHashAndPassword([]byte(u.Hash), []byte(currentPw))
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ErrInvalidPassword
|
return "", err
|
||||||
}
|
}
|
||||||
// Check that the new password isn't blank
|
return string(h), nil
|
||||||
if newPassword == "" {
|
}
|
||||||
|
|
||||||
|
// CheckPasswordPolicy ensures the provided password is valid according to our
|
||||||
|
// password policy.
|
||||||
|
//
|
||||||
|
// The current password policy is simply a minimum of 8 characters, though this
|
||||||
|
// may change in the future (see #1538).
|
||||||
|
func CheckPasswordPolicy(password string) error {
|
||||||
|
switch {
|
||||||
|
// Admittedly, empty passwords are a subset of too short passwords, but it
|
||||||
|
// helps to provide a more specific error message
|
||||||
|
case password == "":
|
||||||
return ErrEmptyPassword
|
return ErrEmptyPassword
|
||||||
}
|
case len(password) < MinPasswordLength:
|
||||||
// Check that new passwords match
|
return ErrPasswordTooShort
|
||||||
if newPassword != confirmPassword {
|
|
||||||
return ErrPasswordMismatch
|
|
||||||
}
|
|
||||||
// Generate the new hash
|
|
||||||
h, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
u.Hash = string(h)
|
|
||||||
if err = models.PutUser(&u); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ValidatePassword validates that the provided password matches the provided
|
||||||
|
// bcrypt hash.
|
||||||
|
func ValidatePassword(password string, hash string) error {
|
||||||
|
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidatePasswordChange validates that the new password matches the
|
||||||
|
// configured password policy, that the new password and confirmation
|
||||||
|
// password match.
|
||||||
|
//
|
||||||
|
// Note that this assumes the current password has been confirmed by the
|
||||||
|
// caller.
|
||||||
|
//
|
||||||
|
// If all of the provided data is valid, then the hash of the new password is
|
||||||
|
// returned.
|
||||||
|
func ValidatePasswordChange(currentHash, newPassword, confirmPassword string) (string, error) {
|
||||||
|
// Ensure the new password passes our password policy
|
||||||
|
if err := CheckPasswordPolicy(newPassword); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
// Check that new passwords match
|
||||||
|
if newPassword != confirmPassword {
|
||||||
|
return "", ErrPasswordMismatch
|
||||||
|
}
|
||||||
|
// Make sure that the new password isn't the same as the old one
|
||||||
|
err := ValidatePassword(newPassword, currentHash)
|
||||||
|
if err == nil {
|
||||||
|
return "", ErrReusedPassword
|
||||||
|
}
|
||||||
|
// Generate the new hash
|
||||||
|
return GeneratePasswordHash(newPassword)
|
||||||
|
}
|
||||||
|
|
41
auth/auth_test.go
Normal file
41
auth/auth_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
|
@ -3,9 +3,9 @@ package api
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gophish/gophish/auth"
|
||||||
ctx "github.com/gophish/gophish/context"
|
ctx "github.com/gophish/gophish/context"
|
||||||
"github.com/gophish/gophish/models"
|
"github.com/gophish/gophish/models"
|
||||||
"github.com/gophish/gophish/util"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Reset (/api/reset) resets the currently authenticated user's API key
|
// Reset (/api/reset) resets the currently authenticated user's API key
|
||||||
|
@ -13,7 +13,7 @@ func (as *Server) Reset(w http.ResponseWriter, r *http.Request) {
|
||||||
switch {
|
switch {
|
||||||
case r.Method == "POST":
|
case r.Method == "POST":
|
||||||
u := ctx.Get(r, "user").(models.User)
|
u := ctx.Get(r, "user").(models.User)
|
||||||
u.ApiKey = util.GenerateSecureKey()
|
u.ApiKey = auth.GenerateSecureKey(auth.APIKeyLength)
|
||||||
err := models.PutUser(&u)
|
err := models.PutUser(&u)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "Error setting API Key", http.StatusInternalServerError)
|
http.Error(w, "Error setting API Key", http.StatusInternalServerError)
|
||||||
|
|
|
@ -4,6 +4,7 @@ import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
mid "github.com/gophish/gophish/middleware"
|
mid "github.com/gophish/gophish/middleware"
|
||||||
|
"github.com/gophish/gophish/middleware/ratelimit"
|
||||||
"github.com/gophish/gophish/models"
|
"github.com/gophish/gophish/models"
|
||||||
"github.com/gophish/gophish/worker"
|
"github.com/gophish/gophish/worker"
|
||||||
"github.com/gorilla/mux"
|
"github.com/gorilla/mux"
|
||||||
|
@ -19,14 +20,17 @@ type ServerOption func(*Server)
|
||||||
type Server struct {
|
type Server struct {
|
||||||
handler http.Handler
|
handler http.Handler
|
||||||
worker worker.Worker
|
worker worker.Worker
|
||||||
|
limiter *ratelimit.PostLimiter
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewServer returns a new instance of the API handler with the provided
|
// NewServer returns a new instance of the API handler with the provided
|
||||||
// options applied.
|
// options applied.
|
||||||
func NewServer(options ...ServerOption) *Server {
|
func NewServer(options ...ServerOption) *Server {
|
||||||
defaultWorker, _ := worker.New()
|
defaultWorker, _ := worker.New()
|
||||||
|
defaultLimiter := ratelimit.NewPostLimiter()
|
||||||
as := &Server{
|
as := &Server{
|
||||||
worker: defaultWorker,
|
worker: defaultWorker,
|
||||||
|
limiter: defaultLimiter,
|
||||||
}
|
}
|
||||||
for _, opt := range options {
|
for _, opt := range options {
|
||||||
opt(as)
|
opt(as)
|
||||||
|
@ -42,6 +46,12 @@ func WithWorker(w worker.Worker) ServerOption {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func WithLimiter(limiter *ratelimit.PostLimiter) ServerOption {
|
||||||
|
return func(as *Server) {
|
||||||
|
as.limiter = limiter
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (as *Server) registerRoutes() {
|
func (as *Server) registerRoutes() {
|
||||||
root := mux.NewRouter()
|
root := mux.NewRouter()
|
||||||
root = root.StrictSlash(true)
|
root = root.StrictSlash(true)
|
||||||
|
|
|
@ -6,18 +6,14 @@ import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/gophish/gophish/auth"
|
||||||
ctx "github.com/gophish/gophish/context"
|
ctx "github.com/gophish/gophish/context"
|
||||||
log "github.com/gophish/gophish/logger"
|
log "github.com/gophish/gophish/logger"
|
||||||
"github.com/gophish/gophish/models"
|
"github.com/gophish/gophish/models"
|
||||||
"github.com/gophish/gophish/util"
|
|
||||||
"github.com/gorilla/mux"
|
"github.com/gorilla/mux"
|
||||||
"github.com/jinzhu/gorm"
|
"github.com/jinzhu/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ErrEmptyPassword is thrown when a user provides a blank password to the register
|
|
||||||
// or change password functions
|
|
||||||
var ErrEmptyPassword = errors.New("No password provided")
|
|
||||||
|
|
||||||
// ErrUsernameTaken is thrown when a user attempts to register a username that is taken.
|
// ErrUsernameTaken is thrown when a user attempts to register a username that is taken.
|
||||||
var ErrUsernameTaken = errors.New("Username already taken")
|
var ErrUsernameTaken = errors.New("Username already taken")
|
||||||
|
|
||||||
|
@ -33,9 +29,10 @@ var ErrInsufficientPermission = errors.New("Permission denied")
|
||||||
|
|
||||||
// userRequest is the payload which represents the creation of a new user.
|
// userRequest is the payload which represents the creation of a new user.
|
||||||
type userRequest struct {
|
type userRequest struct {
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
Password string `json:"password"`
|
Password string `json:"password"`
|
||||||
Role string `json:"role"`
|
Role string `json:"role"`
|
||||||
|
PasswordChangeRequired bool `json:"password_change_required"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ur *userRequest) Validate(existingUser *models.User) error {
|
func (ur *userRequest) Validate(existingUser *models.User) error {
|
||||||
|
@ -89,11 +86,12 @@ func (as *Server) Users(w http.ResponseWriter, r *http.Request) {
|
||||||
JSONResponse(w, models.Response{Success: false, Message: err.Error()}, http.StatusBadRequest)
|
JSONResponse(w, models.Response{Success: false, Message: err.Error()}, http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if ur.Password == "" {
|
err = auth.CheckPasswordPolicy(ur.Password)
|
||||||
JSONResponse(w, models.Response{Success: false, Message: ErrEmptyPassword.Error()}, http.StatusBadRequest)
|
if err != nil {
|
||||||
|
JSONResponse(w, models.Response{Success: false, Message: err.Error()}, http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
hash, err := util.NewHash(ur.Password)
|
hash, err := auth.GeneratePasswordHash(ur.Password)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
JSONResponse(w, models.Response{Success: false, Message: err.Error()}, http.StatusInternalServerError)
|
JSONResponse(w, models.Response{Success: false, Message: err.Error()}, http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
|
@ -106,7 +104,7 @@ func (as *Server) Users(w http.ResponseWriter, r *http.Request) {
|
||||||
user := models.User{
|
user := models.User{
|
||||||
Username: ur.Username,
|
Username: ur.Username,
|
||||||
Hash: hash,
|
Hash: hash,
|
||||||
ApiKey: util.GenerateSecureKey(),
|
ApiKey: auth.GenerateSecureKey(auth.APIKeyLength),
|
||||||
Role: role,
|
Role: role,
|
||||||
RoleID: role.ID,
|
RoleID: role.ID,
|
||||||
}
|
}
|
||||||
|
@ -195,13 +193,20 @@ func (as *Server) User(w http.ResponseWriter, r *http.Request) {
|
||||||
// We don't force the password to be provided, since it may be an admin
|
// We don't force the password to be provided, since it may be an admin
|
||||||
// managing the user's account, and making a simple change like
|
// managing the user's account, and making a simple change like
|
||||||
// updating the username or role. However, if it _is_ provided, we'll
|
// updating the username or role. However, if it _is_ provided, we'll
|
||||||
// update the stored hash.
|
// update the stored hash after validating the new password meets our
|
||||||
|
// password policy.
|
||||||
//
|
//
|
||||||
// Note that we don't force the current password to be provided. The
|
// Note that we don't force the current password to be provided. The
|
||||||
// assumption here is that the API key is a proper bearer token proving
|
// assumption here is that the API key is a proper bearer token proving
|
||||||
// authenticated access to the account.
|
// authenticated access to the account.
|
||||||
|
existingUser.PasswordChangeRequired = ur.PasswordChangeRequired
|
||||||
if ur.Password != "" {
|
if ur.Password != "" {
|
||||||
hash, err := util.NewHash(ur.Password)
|
err = auth.CheckPasswordPolicy(ur.Password)
|
||||||
|
if err != nil {
|
||||||
|
JSONResponse(w, models.Response{Success: false, Message: err.Error()}, http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
hash, err := auth.GeneratePasswordHash(ur.Password)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
JSONResponse(w, models.Response{Success: false, Message: err.Error()}, http.StatusInternalServerError)
|
JSONResponse(w, models.Response{Success: false, Message: err.Error()}, http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
|
|
|
@ -66,7 +66,7 @@ func TestCreateUser(t *testing.T) {
|
||||||
testCtx := setupTest(t)
|
testCtx := setupTest(t)
|
||||||
payload := &userRequest{
|
payload := &userRequest{
|
||||||
Username: "foo",
|
Username: "foo",
|
||||||
Password: "bar",
|
Password: "validpassword",
|
||||||
Role: models.RoleUser,
|
Role: models.RoleUser,
|
||||||
}
|
}
|
||||||
body, err := json.Marshal(payload)
|
body, err := json.Marshal(payload)
|
||||||
|
|
|
@ -7,6 +7,7 @@ import (
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gophish/gophish/auth"
|
||||||
"github.com/gophish/gophish/config"
|
"github.com/gophish/gophish/config"
|
||||||
"github.com/gophish/gophish/models"
|
"github.com/gophish/gophish/models"
|
||||||
)
|
)
|
||||||
|
@ -41,6 +42,10 @@ func setupTest(t *testing.T) *testContext {
|
||||||
ctx.adminServer.Start()
|
ctx.adminServer.Start()
|
||||||
// Get the API key to use for these tests
|
// Get the API key to use for these tests
|
||||||
u, err := models.GetUser(1)
|
u, err := models.GetUser(1)
|
||||||
|
// Reset the temporary password for the admin user to a value we control
|
||||||
|
hash, err := auth.GeneratePasswordHash("gophish")
|
||||||
|
u.Hash = hash
|
||||||
|
models.PutUser(&u)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("error getting first user from database: %v", err)
|
t.Fatalf("error getting first user from database: %v", err)
|
||||||
}
|
}
|
||||||
|
|
|
@ -16,6 +16,7 @@ import (
|
||||||
"github.com/gophish/gophish/controllers/api"
|
"github.com/gophish/gophish/controllers/api"
|
||||||
log "github.com/gophish/gophish/logger"
|
log "github.com/gophish/gophish/logger"
|
||||||
mid "github.com/gophish/gophish/middleware"
|
mid "github.com/gophish/gophish/middleware"
|
||||||
|
"github.com/gophish/gophish/middleware/ratelimit"
|
||||||
"github.com/gophish/gophish/models"
|
"github.com/gophish/gophish/models"
|
||||||
"github.com/gophish/gophish/util"
|
"github.com/gophish/gophish/util"
|
||||||
"github.com/gophish/gophish/worker"
|
"github.com/gophish/gophish/worker"
|
||||||
|
@ -33,9 +34,10 @@ type AdminServerOption func(*AdminServer)
|
||||||
// AdminServer is an HTTP server that implements the administrative Gophish
|
// AdminServer is an HTTP server that implements the administrative Gophish
|
||||||
// handlers, including the dashboard and REST API.
|
// handlers, including the dashboard and REST API.
|
||||||
type AdminServer struct {
|
type AdminServer struct {
|
||||||
server *http.Server
|
server *http.Server
|
||||||
worker worker.Worker
|
worker worker.Worker
|
||||||
config config.AdminServer
|
config config.AdminServer
|
||||||
|
limiter *ratelimit.PostLimiter
|
||||||
}
|
}
|
||||||
|
|
||||||
var defaultTLSConfig = &tls.Config{
|
var defaultTLSConfig = &tls.Config{
|
||||||
|
@ -74,10 +76,12 @@ func NewAdminServer(config config.AdminServer, options ...AdminServerOption) *Ad
|
||||||
ReadTimeout: 10 * time.Second,
|
ReadTimeout: 10 * time.Second,
|
||||||
Addr: config.ListenURL,
|
Addr: config.ListenURL,
|
||||||
}
|
}
|
||||||
|
defaultLimiter := ratelimit.NewPostLimiter()
|
||||||
as := &AdminServer{
|
as := &AdminServer{
|
||||||
worker: defaultWorker,
|
worker: defaultWorker,
|
||||||
server: defaultServer,
|
server: defaultServer,
|
||||||
config: config,
|
limiter: defaultLimiter,
|
||||||
|
config: config,
|
||||||
}
|
}
|
||||||
for _, opt := range options {
|
for _, opt := range options {
|
||||||
opt(as)
|
opt(as)
|
||||||
|
@ -119,8 +123,9 @@ func (as *AdminServer) registerRoutes() {
|
||||||
router := mux.NewRouter()
|
router := mux.NewRouter()
|
||||||
// Base Front-end routes
|
// Base Front-end routes
|
||||||
router.HandleFunc("/", mid.Use(as.Base, mid.RequireLogin))
|
router.HandleFunc("/", mid.Use(as.Base, mid.RequireLogin))
|
||||||
router.HandleFunc("/login", as.Login)
|
router.HandleFunc("/login", mid.Use(as.Login, as.limiter.Limit))
|
||||||
router.HandleFunc("/logout", mid.Use(as.Logout, mid.RequireLogin))
|
router.HandleFunc("/logout", mid.Use(as.Logout, mid.RequireLogin))
|
||||||
|
router.HandleFunc("/reset_password", mid.Use(as.ResetPassword, mid.RequireLogin))
|
||||||
router.HandleFunc("/campaigns", mid.Use(as.Campaigns, mid.RequireLogin))
|
router.HandleFunc("/campaigns", mid.Use(as.Campaigns, mid.RequireLogin))
|
||||||
router.HandleFunc("/campaigns/{id:[0-9]+}", mid.Use(as.CampaignID, mid.RequireLogin))
|
router.HandleFunc("/campaigns/{id:[0-9]+}", mid.Use(as.CampaignID, mid.RequireLogin))
|
||||||
router.HandleFunc("/templates", mid.Use(as.Templates, mid.RequireLogin))
|
router.HandleFunc("/templates", mid.Use(as.Templates, mid.RequireLogin))
|
||||||
|
@ -132,7 +137,10 @@ func (as *AdminServer) registerRoutes() {
|
||||||
router.HandleFunc("/webhooks", mid.Use(as.Webhooks, mid.RequirePermission(models.PermissionModifySystem), mid.RequireLogin))
|
router.HandleFunc("/webhooks", mid.Use(as.Webhooks, mid.RequirePermission(models.PermissionModifySystem), mid.RequireLogin))
|
||||||
router.HandleFunc("/impersonate", mid.Use(as.Impersonate, mid.RequirePermission(models.PermissionModifySystem), mid.RequireLogin))
|
router.HandleFunc("/impersonate", mid.Use(as.Impersonate, mid.RequirePermission(models.PermissionModifySystem), mid.RequireLogin))
|
||||||
// Create the API routes
|
// Create the API routes
|
||||||
api := api.NewServer(api.WithWorker(as.worker))
|
api := api.NewServer(
|
||||||
|
api.WithWorker(as.worker),
|
||||||
|
api.WithLimiter(as.limiter),
|
||||||
|
)
|
||||||
router.PathPrefix("/api/").Handler(api)
|
router.PathPrefix("/api/").Handler(api)
|
||||||
|
|
||||||
// Setup static file serving
|
// Setup static file serving
|
||||||
|
@ -141,7 +149,7 @@ func (as *AdminServer) registerRoutes() {
|
||||||
// Setup CSRF Protection
|
// Setup CSRF Protection
|
||||||
csrfKey := []byte(as.config.CSRFKey)
|
csrfKey := []byte(as.config.CSRFKey)
|
||||||
if len(csrfKey) == 0 {
|
if len(csrfKey) == 0 {
|
||||||
csrfKey = []byte(util.GenerateSecureKey())
|
csrfKey = []byte(auth.GenerateSecureKey(auth.APIKeyLength))
|
||||||
}
|
}
|
||||||
csrfHandler := csrf.Protect(csrfKey,
|
csrfHandler := csrf.Protect(csrfKey,
|
||||||
csrf.FieldName("csrf_token"),
|
csrf.FieldName("csrf_token"),
|
||||||
|
@ -171,12 +179,14 @@ type templateParams struct {
|
||||||
// the CSRF token.
|
// the CSRF token.
|
||||||
func newTemplateParams(r *http.Request) templateParams {
|
func newTemplateParams(r *http.Request) templateParams {
|
||||||
user := ctx.Get(r, "user").(models.User)
|
user := ctx.Get(r, "user").(models.User)
|
||||||
|
session := ctx.Get(r, "session").(*sessions.Session)
|
||||||
modifySystem, _ := user.HasPermission(models.PermissionModifySystem)
|
modifySystem, _ := user.HasPermission(models.PermissionModifySystem)
|
||||||
return templateParams{
|
return templateParams{
|
||||||
Token: csrf.Token(r),
|
Token: csrf.Token(r),
|
||||||
User: user,
|
User: user,
|
||||||
ModifySystem: modifySystem,
|
ModifySystem: modifySystem,
|
||||||
Version: config.Version,
|
Version: config.Version,
|
||||||
|
Flashes: session.Flashes(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -235,22 +245,37 @@ func (as *AdminServer) Settings(w http.ResponseWriter, r *http.Request) {
|
||||||
case r.Method == "GET":
|
case r.Method == "GET":
|
||||||
params := newTemplateParams(r)
|
params := newTemplateParams(r)
|
||||||
params.Title = "Settings"
|
params.Title = "Settings"
|
||||||
|
session := ctx.Get(r, "session").(*sessions.Session)
|
||||||
|
session.Save(r, w)
|
||||||
getTemplate(w, "settings").ExecuteTemplate(w, "base", params)
|
getTemplate(w, "settings").ExecuteTemplate(w, "base", params)
|
||||||
case r.Method == "POST":
|
case r.Method == "POST":
|
||||||
err := auth.ChangePassword(r)
|
u := ctx.Get(r, "user").(models.User)
|
||||||
|
currentPw := r.FormValue("current_password")
|
||||||
|
newPassword := r.FormValue("new_password")
|
||||||
|
confirmPassword := r.FormValue("confirm_new_password")
|
||||||
|
// Check the current password
|
||||||
|
err := auth.ValidatePassword(currentPw, u.Hash)
|
||||||
msg := models.Response{Success: true, Message: "Settings Updated Successfully"}
|
msg := models.Response{Success: true, Message: "Settings Updated Successfully"}
|
||||||
if err == auth.ErrInvalidPassword {
|
|
||||||
msg.Message = "Invalid Password"
|
|
||||||
msg.Success = false
|
|
||||||
api.JSONResponse(w, msg, http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
msg.Message = err.Error()
|
msg.Message = err.Error()
|
||||||
msg.Success = false
|
msg.Success = false
|
||||||
api.JSONResponse(w, msg, http.StatusBadRequest)
|
api.JSONResponse(w, msg, http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
newHash, err := auth.ValidatePasswordChange(u.Hash, newPassword, confirmPassword)
|
||||||
|
if err != nil {
|
||||||
|
msg.Message = err.Error()
|
||||||
|
msg.Success = false
|
||||||
|
api.JSONResponse(w, msg, http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
u.Hash = string(newHash)
|
||||||
|
if err = models.PutUser(&u); err != nil {
|
||||||
|
msg.Message = err.Error()
|
||||||
|
msg.Success = false
|
||||||
|
api.JSONResponse(w, msg, http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
api.JSONResponse(w, msg, http.StatusOK)
|
api.JSONResponse(w, msg, http.StatusOK)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -263,6 +288,39 @@ func (as *AdminServer) UserManagement(w http.ResponseWriter, r *http.Request) {
|
||||||
getTemplate(w, "users").ExecuteTemplate(w, "base", params)
|
getTemplate(w, "users").ExecuteTemplate(w, "base", params)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (as *AdminServer) nextOrIndex(w http.ResponseWriter, r *http.Request) {
|
||||||
|
next := "/"
|
||||||
|
url, err := url.Parse(r.FormValue("next"))
|
||||||
|
if err == nil {
|
||||||
|
path := url.Path
|
||||||
|
if path != "" {
|
||||||
|
next = path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, next, 302)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (as *AdminServer) handleInvalidLogin(w http.ResponseWriter, r *http.Request) {
|
||||||
|
session := ctx.Get(r, "session").(*sessions.Session)
|
||||||
|
Flash(w, r, "danger", "Invalid Username/Password")
|
||||||
|
params := struct {
|
||||||
|
User models.User
|
||||||
|
Title string
|
||||||
|
Flashes []interface{}
|
||||||
|
Token string
|
||||||
|
}{Title: "Login", Token: csrf.Token(r)}
|
||||||
|
params.Flashes = session.Flashes()
|
||||||
|
session.Save(r, w)
|
||||||
|
templates := template.New("template")
|
||||||
|
_, err := templates.ParseFiles("templates/login.html", "templates/flashes.html")
|
||||||
|
if err != nil {
|
||||||
|
log.Error(err)
|
||||||
|
}
|
||||||
|
// w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
|
template.Must(templates, err).ExecuteTemplate(w, "base", params)
|
||||||
|
}
|
||||||
|
|
||||||
// Webhooks is an admin-only handler that handles webhooks
|
// Webhooks is an admin-only handler that handles webhooks
|
||||||
func (as *AdminServer) Webhooks(w http.ResponseWriter, r *http.Request) {
|
func (as *AdminServer) Webhooks(w http.ResponseWriter, r *http.Request) {
|
||||||
params := newTemplateParams(r)
|
params := newTemplateParams(r)
|
||||||
|
@ -309,37 +367,25 @@ func (as *AdminServer) Login(w http.ResponseWriter, r *http.Request) {
|
||||||
}
|
}
|
||||||
template.Must(templates, err).ExecuteTemplate(w, "base", params)
|
template.Must(templates, err).ExecuteTemplate(w, "base", params)
|
||||||
case r.Method == "POST":
|
case r.Method == "POST":
|
||||||
//Attempt to login
|
// Find the user with the provided username
|
||||||
succ, u, err := auth.Login(r)
|
username, password := r.FormValue("username"), r.FormValue("password")
|
||||||
|
u, err := models.GetUserByUsername(username)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error(err)
|
log.Error(err)
|
||||||
|
as.handleInvalidLogin(w, r)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
//If we've logged in, save the session and redirect to the dashboard
|
// Validate the user's password
|
||||||
if succ {
|
err = auth.ValidatePassword(password, u.Hash)
|
||||||
session.Values["id"] = u.Id
|
if err != nil {
|
||||||
session.Save(r, w)
|
log.Error(err)
|
||||||
next := "/"
|
as.handleInvalidLogin(w, r)
|
||||||
url, err := url.Parse(r.FormValue("next"))
|
return
|
||||||
if err == nil {
|
|
||||||
path := url.Path
|
|
||||||
if path != "" {
|
|
||||||
next = path
|
|
||||||
}
|
|
||||||
}
|
|
||||||
http.Redirect(w, r, next, http.StatusFound)
|
|
||||||
} else {
|
|
||||||
Flash(w, r, "danger", "Invalid Username/Password")
|
|
||||||
params.Flashes = session.Flashes()
|
|
||||||
session.Save(r, w)
|
|
||||||
templates := template.New("template")
|
|
||||||
_, err := templates.ParseFiles("templates/login.html", "templates/flashes.html")
|
|
||||||
if err != nil {
|
|
||||||
log.Error(err)
|
|
||||||
}
|
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
||||||
w.WriteHeader(http.StatusUnauthorized)
|
|
||||||
template.Must(templates, err).ExecuteTemplate(w, "base", params)
|
|
||||||
}
|
}
|
||||||
|
// If we've logged in, save the session and redirect to the dashboard
|
||||||
|
session.Values["id"] = u.Id
|
||||||
|
session.Save(r, w)
|
||||||
|
as.nextOrIndex(w, r)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -352,6 +398,69 @@ func (as *AdminServer) Logout(w http.ResponseWriter, r *http.Request) {
|
||||||
http.Redirect(w, r, "/login", http.StatusFound)
|
http.Redirect(w, r, "/login", http.StatusFound)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ResetPassword handles the password reset flow when a password change is
|
||||||
|
// required either by the Gophish system or an administrator.
|
||||||
|
//
|
||||||
|
// This handler is meant to be used when a user is required to reset their
|
||||||
|
// password, not just when they want to.
|
||||||
|
//
|
||||||
|
// This is an important distinction since in this handler we don't require
|
||||||
|
// the user to re-enter their current password, as opposed to the flow
|
||||||
|
// through the settings handler.
|
||||||
|
//
|
||||||
|
// To that end, if the user doesn't require a password change, we will
|
||||||
|
// redirect them to the settings page.
|
||||||
|
func (as *AdminServer) ResetPassword(w http.ResponseWriter, r *http.Request) {
|
||||||
|
u := ctx.Get(r, "user").(models.User)
|
||||||
|
session := ctx.Get(r, "session").(*sessions.Session)
|
||||||
|
if !u.PasswordChangeRequired {
|
||||||
|
Flash(w, r, "info", "Please reset your password through the settings page")
|
||||||
|
session.Save(r, w)
|
||||||
|
http.Redirect(w, r, "/settings", http.StatusTemporaryRedirect)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
params := newTemplateParams(r)
|
||||||
|
params.Title = "Reset Password"
|
||||||
|
switch {
|
||||||
|
case r.Method == http.MethodGet:
|
||||||
|
params.Flashes = session.Flashes()
|
||||||
|
session.Save(r, w)
|
||||||
|
getTemplate(w, "reset_password").ExecuteTemplate(w, "base", params)
|
||||||
|
return
|
||||||
|
case r.Method == http.MethodPost:
|
||||||
|
newPassword := r.FormValue("password")
|
||||||
|
confirmPassword := r.FormValue("confirm_password")
|
||||||
|
newHash, err := auth.ValidatePasswordChange(u.Hash, newPassword, confirmPassword)
|
||||||
|
if err != nil {
|
||||||
|
Flash(w, r, "danger", err.Error())
|
||||||
|
params.Flashes = session.Flashes()
|
||||||
|
session.Save(r, w)
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
getTemplate(w, "reset_password").ExecuteTemplate(w, "base", params)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
u.PasswordChangeRequired = false
|
||||||
|
u.Hash = newHash
|
||||||
|
if err = models.PutUser(&u); err != nil {
|
||||||
|
Flash(w, r, "danger", err.Error())
|
||||||
|
params.Flashes = session.Flashes()
|
||||||
|
session.Save(r, w)
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
getTemplate(w, "reset_password").ExecuteTemplate(w, "base", params)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// TODO: We probably want to flash a message here that the password was
|
||||||
|
// changed successfully. The problem is that when the user resets their
|
||||||
|
// password on first use, they will see two flashes on the dashboard-
|
||||||
|
// one for their password reset, and one for the "no campaigns created".
|
||||||
|
//
|
||||||
|
// The solution to this is to revamp the empty page to be more useful,
|
||||||
|
// like a wizard or something.
|
||||||
|
as.nextOrIndex(w, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Make this execute the template, too
|
||||||
func getTemplate(w http.ResponseWriter, tmpl string) *template.Template {
|
func getTemplate(w http.ResponseWriter, tmpl string) *template.Template {
|
||||||
templates := template.New("template")
|
templates := template.New("template")
|
||||||
_, err := templates.ParseFiles("templates/base.html", "templates/nav.html", "templates/"+tmpl+".html", "templates/flashes.html")
|
_, err := templates.ParseFiles("templates/base.html", "templates/nav.html", "templates/"+tmpl+".html", "templates/flashes.html")
|
||||||
|
|
|
@ -0,0 +1,9 @@
|
||||||
|
|
||||||
|
-- +goose Up
|
||||||
|
-- SQL in section 'Up' is executed when this migration is applied
|
||||||
|
ALTER TABLE `users` ADD COLUMN password_change_required BOOLEAN;
|
||||||
|
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
-- SQL section 'Down' is executed when this migration is rolled back
|
||||||
|
|
|
@ -0,0 +1,9 @@
|
||||||
|
|
||||||
|
-- +goose Up
|
||||||
|
-- SQL in section 'Up' is executed when this migration is applied
|
||||||
|
ALTER TABLE users ADD COLUMN password_change_required BOOLEAN;
|
||||||
|
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
-- SQL section 'Down' is executed when this migration is rolled back
|
||||||
|
|
1
go.mod
1
go.mod
|
@ -27,6 +27,7 @@ require (
|
||||||
github.com/sirupsen/logrus v1.4.2
|
github.com/sirupsen/logrus v1.4.2
|
||||||
github.com/ziutek/mymysql v1.5.4 // indirect
|
github.com/ziutek/mymysql v1.5.4 // indirect
|
||||||
golang.org/x/crypto v0.0.0-20200128174031-69ecbb4d6d5d
|
golang.org/x/crypto v0.0.0-20200128174031-69ecbb4d6d5d
|
||||||
|
golang.org/x/time v0.0.0-20200416051211-89c76fbcd5d1
|
||||||
gopkg.in/alecthomas/kingpin.v2 v2.2.6
|
gopkg.in/alecthomas/kingpin.v2 v2.2.6
|
||||||
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect
|
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405
|
||||||
|
|
2
go.sum
2
go.sum
|
@ -100,6 +100,8 @@ golang.org/x/sys v0.0.0-20191224085550-c709ea063b76/go.mod h1:h1NjWce9XRLGQEsW7w
|
||||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs=
|
golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs=
|
||||||
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||||
|
golang.org/x/time v0.0.0-20200416051211-89c76fbcd5d1 h1:NusfzzA6yGQ+ua51ck7E3omNUX/JuqbFSaRGqU8CcLI=
|
||||||
|
golang.org/x/time v0.0.0-20200416051211-89c76fbcd5d1/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
||||||
gopkg.in/alecthomas/kingpin.v2 v2.2.6 h1:jMFz6MfLP0/4fUyZle81rXUoxOBFi19VUFKVDOQfozc=
|
gopkg.in/alecthomas/kingpin.v2 v2.2.6 h1:jMFz6MfLP0/4fUyZle81rXUoxOBFi19VUFKVDOQfozc=
|
||||||
|
|
|
@ -114,6 +114,15 @@ func RequireAPIKey(handler http.Handler) http.Handler {
|
||||||
func RequireLogin(handler http.Handler) http.HandlerFunc {
|
func RequireLogin(handler http.Handler) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
if u := ctx.Get(r, "user"); u != nil {
|
if u := ctx.Get(r, "user"); u != nil {
|
||||||
|
// If a password change is required for the user, then redirect them
|
||||||
|
// to the login page
|
||||||
|
currentUser := u.(models.User)
|
||||||
|
if currentUser.PasswordChangeRequired && r.URL.Path != "/reset_password" {
|
||||||
|
q := r.URL.Query()
|
||||||
|
q.Set("next", r.URL.Path)
|
||||||
|
http.Redirect(w, r, fmt.Sprintf("/reset_password?%s", q.Encode()), http.StatusTemporaryRedirect)
|
||||||
|
return
|
||||||
|
}
|
||||||
handler.ServeHTTP(w, r)
|
handler.ServeHTTP(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
|
@ -162,3 +162,22 @@ func TestBearerToken(t *testing.T) {
|
||||||
t.Fatalf("incorrect status code received. expected %d got %d", expected, got)
|
t.Fatalf("incorrect status code received. expected %d got %d", expected, got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestPasswordResetRequired(t *testing.T) {
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||||
|
req = ctx.Set(req, "user", models.User{
|
||||||
|
PasswordChangeRequired: true,
|
||||||
|
})
|
||||||
|
response := httptest.NewRecorder()
|
||||||
|
RequireLogin(successHandler).ServeHTTP(response, req)
|
||||||
|
gotStatus := response.Code
|
||||||
|
expectedStatus := http.StatusTemporaryRedirect
|
||||||
|
if gotStatus != expectedStatus {
|
||||||
|
t.Fatalf("incorrect status code received. expected %d got %d", expectedStatus, gotStatus)
|
||||||
|
}
|
||||||
|
expectedLocation := "/reset_password?next=%2F"
|
||||||
|
gotLocation := response.Header().Get("Location")
|
||||||
|
if gotLocation != expectedLocation {
|
||||||
|
t.Fatalf("incorrect location header received. expected %s got %s", expectedLocation, gotLocation)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
15
middleware/ratelimit/doc.go
Normal file
15
middleware/ratelimit/doc.go
Normal 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
|
145
middleware/ratelimit/ratelimit.go
Normal file
145
middleware/ratelimit/ratelimit.go
Normal 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)
|
||||||
|
})
|
||||||
|
}
|
59
middleware/ratelimit/ratelimit_test.go
Normal file
59
middleware/ratelimit/ratelimit_test.go
Normal 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)
|
||||||
|
}
|
|
@ -12,7 +12,9 @@ import (
|
||||||
"bitbucket.org/liamstask/goose/lib/goose"
|
"bitbucket.org/liamstask/goose/lib/goose"
|
||||||
|
|
||||||
mysql "github.com/go-sql-driver/mysql"
|
mysql "github.com/go-sql-driver/mysql"
|
||||||
|
"github.com/gophish/gophish/auth"
|
||||||
"github.com/gophish/gophish/config"
|
"github.com/gophish/gophish/config"
|
||||||
|
|
||||||
log "github.com/gophish/gophish/logger"
|
log "github.com/gophish/gophish/logger"
|
||||||
"github.com/jinzhu/gorm"
|
"github.com/jinzhu/gorm"
|
||||||
_ "github.com/mattn/go-sqlite3" // Blank import needed to import sqlite3
|
_ "github.com/mattn/go-sqlite3" // Blank import needed to import sqlite3
|
||||||
|
@ -23,6 +25,9 @@ var conf *config.Config
|
||||||
|
|
||||||
const MaxDatabaseConnectionAttempts int = 10
|
const MaxDatabaseConnectionAttempts int = 10
|
||||||
|
|
||||||
|
// DefaultAdminUsername is the default username for the administrative user
|
||||||
|
const DefaultAdminUsername = "admin"
|
||||||
|
|
||||||
const (
|
const (
|
||||||
CampaignInProgress string = "In progress"
|
CampaignInProgress string = "In progress"
|
||||||
CampaignQueued string = "Queued"
|
CampaignQueued string = "Queued"
|
||||||
|
@ -82,8 +87,33 @@ func chooseDBDriver(name, openStr string) goose.DBDriver {
|
||||||
return d
|
return d
|
||||||
}
|
}
|
||||||
|
|
||||||
// Setup initializes the Conn object
|
func createTemporaryPassword(u *User) error {
|
||||||
// It also populates the Gophish Config object
|
// This will result in a 16 character password which could be viewed as an
|
||||||
|
// inconvenience, but it should be ok for now.
|
||||||
|
temporaryPassword := auth.GenerateSecureKey(auth.MinPasswordLength)
|
||||||
|
hash, err := auth.GeneratePasswordHash(temporaryPassword)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
u.Hash = hash
|
||||||
|
// Anytime a temporary password is created, we will force the user
|
||||||
|
// to change their password
|
||||||
|
u.PasswordChangeRequired = true
|
||||||
|
err = db.Save(u).Error
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
log.Infof("Please login with the username admin and the password %s", temporaryPassword)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Setup initializes the database and runs any needed migrations.
|
||||||
|
//
|
||||||
|
// First, it establishes a connection to the database, then runs any migrations
|
||||||
|
// newer than the version the database is on.
|
||||||
|
//
|
||||||
|
// Once the database is up-to-date, we create an admin user (if needed) that
|
||||||
|
// has a randomly generated API key and password.
|
||||||
func Setup(c *config.Config) error {
|
func Setup(c *config.Config) error {
|
||||||
// Setup the package-scoped config
|
// Setup the package-scoped config
|
||||||
conf = c
|
conf = c
|
||||||
|
@ -153,6 +183,7 @@ func Setup(c *config.Config) error {
|
||||||
}
|
}
|
||||||
// Create the admin user if it doesn't exist
|
// Create the admin user if it doesn't exist
|
||||||
var userCount int64
|
var userCount int64
|
||||||
|
var adminUser User
|
||||||
db.Model(&User{}).Count(&userCount)
|
db.Model(&User{}).Count(&userCount)
|
||||||
adminRole, err := GetRoleBySlug(RoleAdmin)
|
adminRole, err := GetRoleBySlug(RoleAdmin)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -160,14 +191,38 @@ func Setup(c *config.Config) error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if userCount == 0 {
|
if userCount == 0 {
|
||||||
initUser := User{
|
adminUser := User{
|
||||||
Username: "admin",
|
Username: DefaultAdminUsername,
|
||||||
Hash: "$2a$10$IYkPp0.QsM81lYYPrQx6W.U6oQGw7wMpozrKhKAHUBVL4mkm/EvAS", //gophish
|
Role: adminRole,
|
||||||
Role: adminRole,
|
RoleID: adminRole.ID,
|
||||||
RoleID: adminRole.ID,
|
PasswordChangeRequired: true,
|
||||||
}
|
}
|
||||||
initUser.ApiKey = generateSecureKey()
|
adminUser.ApiKey = auth.GenerateSecureKey(auth.APIKeyLength)
|
||||||
err = db.Save(&initUser).Error
|
err = db.Save(&adminUser).Error
|
||||||
|
if err != nil {
|
||||||
|
log.Error(err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// If this is the first time the user is installing Gophish, then we will
|
||||||
|
// generate a temporary password for the admin user.
|
||||||
|
//
|
||||||
|
// We do this here instead of in the block above where the admin is created
|
||||||
|
// since there's the chance the user executes Gophish and has some kind of
|
||||||
|
// error, then tries restarting it. If they didn't grab the password out of
|
||||||
|
// the logs, then they would have lost it.
|
||||||
|
//
|
||||||
|
// By doing the temporary password here, we will regenerate that temporary
|
||||||
|
// password until the user is able to reset the admin password.
|
||||||
|
if adminUser.Username == "" {
|
||||||
|
adminUser, err = GetUserByUsername(DefaultAdminUsername)
|
||||||
|
if err != nil {
|
||||||
|
log.Error(err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if adminUser.PasswordChangeRequired {
|
||||||
|
err = createTemporaryPassword(&adminUser)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error(err)
|
log.Error(err)
|
||||||
return err
|
return err
|
||||||
|
|
|
@ -13,12 +13,13 @@ var ErrModifyingOnlyAdmin = errors.New("Cannot remove the only administrator")
|
||||||
|
|
||||||
// User represents the user model for gophish.
|
// User represents the user model for gophish.
|
||||||
type User struct {
|
type User struct {
|
||||||
Id int64 `json:"id"`
|
Id int64 `json:"id"`
|
||||||
Username string `json:"username" sql:"not null;unique"`
|
Username string `json:"username" sql:"not null;unique"`
|
||||||
Hash string `json:"-"`
|
Hash string `json:"-"`
|
||||||
ApiKey string `json:"api_key" sql:"not null;unique"`
|
ApiKey string `json:"api_key" sql:"not null;unique"`
|
||||||
Role Role `json:"role" gorm:"association_autoupdate:false;association_autocreate:false"`
|
Role Role `json:"role" gorm:"association_autoupdate:false;association_autocreate:false"`
|
||||||
RoleID int64 `json:"-"`
|
RoleID int64 `json:"-"`
|
||||||
|
PasswordChangeRequired bool `json:"password_change_required"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetUser returns the user that the given id corresponds to. If no user is found, an
|
// GetUser returns the user that the given id corresponds to. If no user is found, an
|
||||||
|
|
|
@ -29,5 +29,8 @@
|
||||||
"jshint-stylish": "^2.2.1",
|
"jshint-stylish": "^2.2.1",
|
||||||
"webpack": "^4.32.2",
|
"webpack": "^4.32.2",
|
||||||
"webpack-cli": "^3.3.2"
|
"webpack-cli": "^3.3.2"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"zxcvbn": "^4.4.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
2
static/css/dist/gophish.css
vendored
2
static/css/dist/gophish.css
vendored
File diff suppressed because one or more lines are too long
12
static/css/main.css
vendored
12
static/css/main.css
vendored
|
@ -730,3 +730,15 @@ table.dataTable {
|
||||||
.cke_autocomplete_panel>li {
|
.cke_autocomplete_panel>li {
|
||||||
padding: 10px 5px !important;
|
padding: 10px 5px !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#password-strength {
|
||||||
|
margin-top: 20px;
|
||||||
|
margin-bottom: 0px;
|
||||||
|
height: 8px;
|
||||||
|
}
|
||||||
|
#password-strength-description {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
#password-strength-container {
|
||||||
|
height: 40px;
|
||||||
|
}
|
1
static/js/dist/app/passwords.min.js
vendored
Normal file
1
static/js/dist/app/passwords.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
2
static/js/dist/app/users.min.js
vendored
2
static/js/dist/app/users.min.js
vendored
File diff suppressed because one or more lines are too long
2
static/js/dist/app/webhooks.min.js
vendored
2
static/js/dist/app/webhooks.min.js
vendored
|
@ -1 +1 @@
|
||||||
!function(e){var t={};function n(o){if(t[o])return t[o].exports;var a=t[o]={i:o,l:!1,exports:{}};return e[o].call(a.exports,a,a.exports,n),a.l=!0,a.exports}n.m=e,n.c=t,n.d=function(e,t,o){n.o(e,t)||Object.defineProperty(e,t,{enumerable:!0,get:o})},n.r=function(e){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},n.t=function(e,t){if(1&t&&(e=n(e)),8&t)return e;if(4&t&&"object"==typeof e&&e&&e.__esModule)return e;var o=Object.create(null);if(n.r(o),Object.defineProperty(o,"default",{enumerable:!0,value:e}),2&t&&"string"!=typeof e)for(var a in e)n.d(o,a,function(t){return e[t]}.bind(null,a));return o},n.n=function(e){var t=e&&e.__esModule?function(){return e.default}:function(){return e};return n.d(t,"a",t),t},n.o=function(e,t){return Object.prototype.hasOwnProperty.call(e,t)},n.p="",n(n.s=1)}([,function(e,t){var n=[],o=function(){$("#name").val(""),$("#url").val(""),$("#secret").val(""),$("#is_active").prop("checked",!1),$("#flashes").empty()},a=function(){$("#webhookTable").hide(),$("#loading").show(),api.webhooks.get().success(function(e){n=e,$("#loading").hide(),$("#webhookTable").show();var t=$("#webhookTable").DataTable({destroy:!0,columnDefs:[{orderable:!1,targets:"no-sort"}]});t.clear(),$.each(n,function(e,n){t.row.add([escapeHtml(n.name),escapeHtml(n.url),escapeHtml(n.is_active),'\n <div class="pull-right">\n <button class="btn btn-primary ping_button" data-webhook-id="'.concat(n.id,'">\n Ping\n </button>\n <button class="btn btn-primary edit_button" data-toggle="modal" data-backdrop="static" data-target="#modal" data-webhook-id="').concat(n.id,'">\n <i class="fa fa-pencil"></i>\n </button>\n <button class="btn btn-danger delete_button" data-webhook-id="').concat(n.id,'">\n <i class="fa fa-trash-o"></i>\n </button>\n </div>\n ')]).draw()})}).error(function(){errorFlash("Error fetching webhooks")})},c=function(e){$("#modalSubmit").unbind("click").click(function(){!function(e){var t={name:$("#name").val(),url:$("#url").val(),secret:$("#secret").val(),is_active:$("#is_active").is(":checked")};-1!=e?(t.id=parseInt(e),api.webhookId.put(t).success(function(e){o(),a(),$("#modal").modal("hide"),successFlash('Webhook "'.concat(escapeHtml(t.name),'" has been updated successfully!'))}).error(function(e){modalError(e.responseJSON.message)})):api.webhooks.post(t).success(function(e){a(),o(),$("#modal").modal("hide"),successFlash('Webhook "'.concat(escapeHtml(t.name),'" has been created successfully!'))}).error(function(e){modalError(e.responseJSON.message)})}(e)}),-1!==e&&api.webhookId.get(e).success(function(e){$("#name").val(e.name),$("#url").val(e.url),$("#secret").val(e.secret),$("#is_active").prop("checked",e.is_active)}).error(function(){errorFlash("Error fetching webhook")})};$(document).ready(function(){a(),$("#modal").on("hide.bs.modal",function(){o()}),$("#new_button").on("click",function(){c(-1)}),$("#webhookTable").on("click",".edit_button",function(e){c($(this).attr("data-webhook-id"))}),$("#webhookTable").on("click",".delete_button",function(e){var t,o;t=$(this).attr("data-webhook-id"),(o=n.find(function(e){return e.id==t}))&&Swal.fire({title:"Are you sure?",text:"This will delete the webhook '".concat(escapeHtml(o.name),"'"),type:"warning",animation:!1,showCancelButton:!0,confirmButtonText:"Delete",confirmButtonColor:"#428bca",reverseButtons:!0,allowOutsideClick:!1,preConfirm:function(){return new Promise(function(e,n){api.webhookId.delete(t).success(function(t){e()}).error(function(e){n(e.responseJSON.message)})}).catch(function(e){Swal.showValidationMessage(e)})}}).then(function(e){e.value&&Swal.fire("Webhook Deleted!","The webhook has been deleted!","success"),$("button:contains('OK')").on("click",function(){location.reload()})})}),$("#webhookTable").on("click",".ping_button",function(e){var t,a;t=e.currentTarget,a=e.currentTarget.dataset.webhookId,o(),t.disabled=!0,api.webhookId.ping(a).success(function(e){t.disabled=!1,successFlash('Ping of "'.concat(escapeHtml(e.name),'" webhook succeeded.'))}).error(function(e){t.disabled=!1;var o=n.find(function(e){return e.id==a});o&&errorFlash('Ping of "'.concat(escapeHtml(o.name),'" webhook failed: "').concat(e.responseJSON.message,'"'))})})})}]);
|
!function(e){var t={};function n(o){if(t[o])return t[o].exports;var a=t[o]={i:o,l:!1,exports:{}};return e[o].call(a.exports,a,a.exports,n),a.l=!0,a.exports}n.m=e,n.c=t,n.d=function(e,t,o){n.o(e,t)||Object.defineProperty(e,t,{enumerable:!0,get:o})},n.r=function(e){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},n.t=function(e,t){if(1&t&&(e=n(e)),8&t)return e;if(4&t&&"object"==typeof e&&e&&e.__esModule)return e;var o=Object.create(null);if(n.r(o),Object.defineProperty(o,"default",{enumerable:!0,value:e}),2&t&&"string"!=typeof e)for(var a in e)n.d(o,a,function(t){return e[t]}.bind(null,a));return o},n.n=function(e){var t=e&&e.__esModule?function(){return e.default}:function(){return e};return n.d(t,"a",t),t},n.o=function(e,t){return Object.prototype.hasOwnProperty.call(e,t)},n.p="",n(n.s=9)}({9:function(e,t){var n=[],o=function(){$("#name").val(""),$("#url").val(""),$("#secret").val(""),$("#is_active").prop("checked",!1),$("#flashes").empty()},a=function(){$("#webhookTable").hide(),$("#loading").show(),api.webhooks.get().success(function(e){n=e,$("#loading").hide(),$("#webhookTable").show();var t=$("#webhookTable").DataTable({destroy:!0,columnDefs:[{orderable:!1,targets:"no-sort"}]});t.clear(),$.each(n,function(e,n){t.row.add([escapeHtml(n.name),escapeHtml(n.url),escapeHtml(n.is_active),'\n <div class="pull-right">\n <button class="btn btn-primary ping_button" data-webhook-id="'.concat(n.id,'">\n Ping\n </button>\n <button class="btn btn-primary edit_button" data-toggle="modal" data-backdrop="static" data-target="#modal" data-webhook-id="').concat(n.id,'">\n <i class="fa fa-pencil"></i>\n </button>\n <button class="btn btn-danger delete_button" data-webhook-id="').concat(n.id,'">\n <i class="fa fa-trash-o"></i>\n </button>\n </div>\n ')]).draw()})}).error(function(){errorFlash("Error fetching webhooks")})},c=function(e){$("#modalSubmit").unbind("click").click(function(){!function(e){var t={name:$("#name").val(),url:$("#url").val(),secret:$("#secret").val(),is_active:$("#is_active").is(":checked")};-1!=e?(t.id=parseInt(e),api.webhookId.put(t).success(function(e){o(),a(),$("#modal").modal("hide"),successFlash('Webhook "'.concat(escapeHtml(t.name),'" has been updated successfully!'))}).error(function(e){modalError(e.responseJSON.message)})):api.webhooks.post(t).success(function(e){a(),o(),$("#modal").modal("hide"),successFlash('Webhook "'.concat(escapeHtml(t.name),'" has been created successfully!'))}).error(function(e){modalError(e.responseJSON.message)})}(e)}),-1!==e&&api.webhookId.get(e).success(function(e){$("#name").val(e.name),$("#url").val(e.url),$("#secret").val(e.secret),$("#is_active").prop("checked",e.is_active)}).error(function(){errorFlash("Error fetching webhook")})};$(document).ready(function(){a(),$("#modal").on("hide.bs.modal",function(){o()}),$("#new_button").on("click",function(){c(-1)}),$("#webhookTable").on("click",".edit_button",function(e){c($(this).attr("data-webhook-id"))}),$("#webhookTable").on("click",".delete_button",function(e){var t,o;t=$(this).attr("data-webhook-id"),(o=n.find(function(e){return e.id==t}))&&Swal.fire({title:"Are you sure?",text:"This will delete the webhook '".concat(escapeHtml(o.name),"'"),type:"warning",animation:!1,showCancelButton:!0,confirmButtonText:"Delete",confirmButtonColor:"#428bca",reverseButtons:!0,allowOutsideClick:!1,preConfirm:function(){return new Promise(function(e,n){api.webhookId.delete(t).success(function(t){e()}).error(function(e){n(e.responseJSON.message)})}).catch(function(e){Swal.showValidationMessage(e)})}}).then(function(e){e.value&&Swal.fire("Webhook Deleted!","The webhook has been deleted!","success"),$("button:contains('OK')").on("click",function(){location.reload()})})}),$("#webhookTable").on("click",".ping_button",function(e){var t,a;t=e.currentTarget,a=e.currentTarget.dataset.webhookId,o(),t.disabled=!0,api.webhookId.ping(a).success(function(e){t.disabled=!1,successFlash('Ping of "'.concat(escapeHtml(e.name),'" webhook succeeded.'))}).error(function(e){t.disabled=!1;var o=n.find(function(e){return e.id==a});o&&errorFlash('Ping of "'.concat(escapeHtml(o.name),'" webhook failed: "').concat(e.responseJSON.message,'"'))})})})}});
|
54
static/js/src/app/passwords.js
Normal file
54
static/js/src/app/passwords.js
Normal 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)
|
|
@ -10,7 +10,8 @@ const save = (id) => {
|
||||||
let user = {
|
let user = {
|
||||||
username: $("#username").val(),
|
username: $("#username").val(),
|
||||||
password: $("#password").val(),
|
password: $("#password").val(),
|
||||||
role: $("#role").val()
|
role: $("#role").val(),
|
||||||
|
password_change_required: $("#force_password_change_checkbox").prop('checked')
|
||||||
}
|
}
|
||||||
// Submit the user
|
// Submit the user
|
||||||
if (id != -1) {
|
if (id != -1) {
|
||||||
|
@ -18,26 +19,26 @@ const save = (id) => {
|
||||||
// we need to PUT /user/:id
|
// we need to PUT /user/:id
|
||||||
user.id = id
|
user.id = id
|
||||||
api.userId.put(user)
|
api.userId.put(user)
|
||||||
.success(function (data) {
|
.success((data) => {
|
||||||
successFlash("User " + escapeHtml(user.username) + " updated successfully!")
|
successFlash("User " + escapeHtml(user.username) + " updated successfully!")
|
||||||
load()
|
load()
|
||||||
dismiss()
|
dismiss()
|
||||||
$("#modal").modal('hide')
|
$("#modal").modal('hide')
|
||||||
})
|
})
|
||||||
.error(function (data) {
|
.error((data) => {
|
||||||
modalError(data.responseJSON.message)
|
modalError(data.responseJSON.message)
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
// Else, if this is a new user, POST it
|
// Else, if this is a new user, POST it
|
||||||
// to /user
|
// to /user
|
||||||
api.users.post(user)
|
api.users.post(user)
|
||||||
.success(function (data) {
|
.success((data) => {
|
||||||
successFlash("User " + escapeHtml(user.username) + " registered successfully!")
|
successFlash("User " + escapeHtml(user.username) + " registered successfully!")
|
||||||
load()
|
load()
|
||||||
dismiss()
|
dismiss()
|
||||||
$("#modal").modal('hide')
|
$("#modal").modal('hide')
|
||||||
})
|
})
|
||||||
.error(function (data) {
|
.error((data) => {
|
||||||
modalError(data.responseJSON.message)
|
modalError(data.responseJSON.message)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
@ -61,10 +62,11 @@ const edit = (id) => {
|
||||||
$("#role").trigger("change")
|
$("#role").trigger("change")
|
||||||
} else {
|
} else {
|
||||||
api.userId.get(id)
|
api.userId.get(id)
|
||||||
.success(function (user) {
|
.success((user) => {
|
||||||
$("#username").val(user.username)
|
$("#username").val(user.username)
|
||||||
$("#role").val(user.role.slug)
|
$("#role").val(user.role.slug)
|
||||||
$("#role").trigger("change")
|
$("#role").trigger("change")
|
||||||
|
$("#force_password_change_checkbox").prop('checked', false)
|
||||||
})
|
})
|
||||||
.error(function () {
|
.error(function () {
|
||||||
errorFlash("Error fetching user")
|
errorFlash("Error fetching user")
|
||||||
|
|
|
@ -6,7 +6,8 @@
|
||||||
<div id="loading">
|
<div id="loading">
|
||||||
<i class="fa fa-spinner fa-spin fa-4x"></i>
|
<i class="fa fa-spinner fa-spin fa-4x"></i>
|
||||||
</div>
|
</div>
|
||||||
<div id="emptyMessage" class="row" style="display:none;">
|
{{template "flashes" .Flashes}}
|
||||||
|
<div id="emptyMessage" style="display:none;">
|
||||||
<div class="alert alert-info">
|
<div class="alert alert-info">
|
||||||
No campaigns created yet. Let's create one!
|
No campaigns created yet. Let's create one!
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -8,6 +8,8 @@
|
||||||
fa-exclamation-triangle
|
fa-exclamation-triangle
|
||||||
{{else if eq .Type "success"}}
|
{{else if eq .Type "success"}}
|
||||||
fa-check-circle
|
fa-check-circle
|
||||||
|
{{else if eq .Type "info"}}
|
||||||
|
fa-info-circle
|
||||||
{{end}}"></i>
|
{{end}}"></i>
|
||||||
{{.Message}}
|
{{.Message}}
|
||||||
</div>
|
</div>
|
||||||
|
|
75
templates/reset_password.html
Normal file
75
templates/reset_password.html
Normal 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="/"> gophish</a>
|
||||||
|
</div>
|
||||||
|
<div class="navbar-collapse collapse">
|
||||||
|
<ul class="nav navbar-nav navbar-right">
|
||||||
|
<li>
|
||||||
|
<div class="btn-group" id="navbar-dropdown">
|
||||||
|
<a class="btn btn-primary" href="/logout"><i class="fa fa-user"></i> {{.User.Username}}</a>
|
||||||
|
<a class="btn btn-primary dropdown-toggle" href="/logout">
|
||||||
|
<i class="fa fa-sign-out"></i>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="container">
|
||||||
|
<form class="form-signin" action="" method="POST">
|
||||||
|
<img id="logo" src="/images/logo_purple.png" />
|
||||||
|
<h2 class="form-signin-heading">Reset Your Password</h2>
|
||||||
|
{{template "flashes" .Flashes}}
|
||||||
|
<input type="password" id="password" name="password" class="form-control" placeholder="Password"
|
||||||
|
autocomplete="off" minlength="8" required autofocus>
|
||||||
|
<div class="" id="password-strength-container">
|
||||||
|
<div class="progress" id="password-strength">
|
||||||
|
<div id="password-strength-bar" class="progress-bar" role="progressbar" aria-valuenow="0"
|
||||||
|
aria-valuemin="0" aria-valuemax="100"></div>
|
||||||
|
</div>
|
||||||
|
<span id="password-strength-description"></span>
|
||||||
|
</div>
|
||||||
|
<input type="password" name="confirm_password" class="form-control" placeholder="Confirm Password"
|
||||||
|
autocomplete="off" minlength="8" required>
|
||||||
|
<input type="hidden" name="csrf_token" value="{{.Token}}" />
|
||||||
|
<br />
|
||||||
|
<button class="btn btn-lg btn-primary btn-block" type="submit">Save Password</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<!-- Placed at the end of the document so the pages load faster -->
|
||||||
|
<script src="/js/dist/app/passwords.min.js"></script>
|
||||||
|
<script src="/js/dist/vendor.min.js"></script>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
|
{{ end }}
|
|
@ -4,6 +4,7 @@
|
||||||
<h1 class="page-header">Settings</h1>
|
<h1 class="page-header">Settings</h1>
|
||||||
</div>
|
</div>
|
||||||
<div id="flashes" class="row"></div>
|
<div id="flashes" class="row"></div>
|
||||||
|
{{template "flashes" .Flashes}}
|
||||||
<!-- Nav tabs -->
|
<!-- Nav tabs -->
|
||||||
<ul class="nav nav-tabs" role="tablist">
|
<ul class="nav nav-tabs" role="tablist">
|
||||||
<li class="active" role="mainSettings"><a href="#mainSettings" aria-controls="mainSettings" role="tab"
|
<li class="active" role="mainSettings"><a href="#mainSettings" aria-controls="mainSettings" role="tab"
|
||||||
|
@ -58,8 +59,15 @@
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<label for="new_password" class="col-sm-2 control-label form-label">New Password:</label>
|
<label for="new_password" class="col-sm-2 control-label form-label">New Password:</label>
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<input type="password" id="new_password" name="new_password" autocomplete="off"
|
<input type="password" id="password" name="new_password" autocomplete="new-password"
|
||||||
class="form-control" />
|
class="form-control" />
|
||||||
|
<div class="hidden" id="password-strength-container">
|
||||||
|
<div class="progress" id="password-strength">
|
||||||
|
<div id="password-strength-bar" class="progress-bar" role="progressbar"
|
||||||
|
aria-valuenow="0" aria-valuemin="0" aria-valuemax="100"></div>
|
||||||
|
</div>
|
||||||
|
<span id="password-strength-description"></span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<br />
|
<br />
|
||||||
|
@ -225,5 +233,6 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{{end}} {{define "scripts"}}
|
{{end}} {{define "scripts"}}
|
||||||
|
<script src="/js/dist/app/passwords.min.js"></script>
|
||||||
<script src="/js/dist/app/settings.min.js"></script>
|
<script src="/js/dist/app/settings.min.js"></script>
|
||||||
{{end}}
|
{{end}}
|
|
@ -5,7 +5,9 @@
|
||||||
{{.Title}}
|
{{.Title}}
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
<div id="flashes" class="row"></div>
|
<div id="flashes" class="row">
|
||||||
|
{{template "flashes" .Flashes}}
|
||||||
|
</div>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<button type="button" class="btn btn-primary" id="new_button" data-toggle="modal" data-backdrop="static"
|
<button type="button" class="btn btn-primary" id="new_button" data-toggle="modal" data-backdrop="static"
|
||||||
data-user-id="-1" data-target="#modal">
|
data-user-id="-1" data-target="#modal">
|
||||||
|
@ -47,13 +49,23 @@
|
||||||
</div>
|
</div>
|
||||||
<label class="control-label" for="password">Password:</label>
|
<label class="control-label" for="password">Password:</label>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<input type="password" class="form-control" placeholder="Password" id="password" required />
|
<input type="password" class="form-control" autocomplete="new-password" placeholder="Password" id="password" required />
|
||||||
|
<div class="hidden" id="password-strength-container">
|
||||||
|
<div class="progress" id="password-strength">
|
||||||
|
<div id="password-strength-bar" class="progress-bar" role="progressbar" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100"></div>
|
||||||
|
</div>
|
||||||
|
<span id="password-strength-description"></span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<label class="control-label" for="confirm_password">Confirm Password:</label>
|
<label class="control-label" for="confirm_password">Confirm Password:</label>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<input type="password" class="form-control" placeholder="Confirm Password" id="confirm_password"
|
<input type="password" class="form-control" placeholder="Confirm Password" id="confirm_password"
|
||||||
required />
|
required />
|
||||||
</div>
|
</div>
|
||||||
|
<div class="checkbox checkbox-primary">
|
||||||
|
<input id="force_password_change_checkbox" type="checkbox" checked>
|
||||||
|
<label for="force_password_change_checkbox">Require the user to set a new password</label>
|
||||||
|
</div>
|
||||||
<label class="control-label" for="role">Role:</label>
|
<label class="control-label" for="role">Role:</label>
|
||||||
<div class="form-group" id="role-select">
|
<div class="form-group" id="role-select">
|
||||||
<select class="form-control" placeholder="" id="role" />
|
<select class="form-control" placeholder="" id="role" />
|
||||||
|
@ -70,5 +82,6 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{{end}} {{define "scripts"}}
|
{{end}} {{define "scripts"}}
|
||||||
|
<script src="/js/dist/app/passwords.min.js"></script>
|
||||||
<script src="/js/dist/app/users.min.js"></script>
|
<script src="/js/dist/app/users.min.js"></script>
|
||||||
{{end}}
|
{{end}}
|
19
util/util.go
19
util/util.go
|
@ -21,7 +21,6 @@ import (
|
||||||
log "github.com/gophish/gophish/logger"
|
log "github.com/gophish/gophish/logger"
|
||||||
"github.com/gophish/gophish/models"
|
"github.com/gophish/gophish/models"
|
||||||
"github.com/jordan-wright/email"
|
"github.com/jordan-wright/email"
|
||||||
"golang.org/x/crypto/bcrypt"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
|
@ -194,21 +193,3 @@ func CheckAndCreateSSL(cp string, kp string) error {
|
||||||
log.Info("TLS Certificate Generation complete")
|
log.Info("TLS Certificate Generation complete")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GenerateSecureKey creates a secure key to use as an API key
|
|
||||||
func GenerateSecureKey() string {
|
|
||||||
// Inspired from gorilla/securecookie
|
|
||||||
k := make([]byte, 32)
|
|
||||||
io.ReadFull(rand.Reader, k)
|
|
||||||
return fmt.Sprintf("%x", k)
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewHash hashes the provided password and returns the bcrypt hash (using the
|
|
||||||
// default 10 rounds) as a string.
|
|
||||||
func NewHash(pass string) (string, error) {
|
|
||||||
hash, err := bcrypt.GenerateFromPassword([]byte(pass), bcrypt.DefaultCost)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
return string(hash), nil
|
|
||||||
}
|
|
||||||
|
|
|
@ -3,6 +3,7 @@ const path = require('path');
|
||||||
module.exports = {
|
module.exports = {
|
||||||
context: path.resolve(__dirname, 'static', 'js', 'src', 'app'),
|
context: path.resolve(__dirname, 'static', 'js', 'src', 'app'),
|
||||||
entry: {
|
entry: {
|
||||||
|
passwords: './passwords',
|
||||||
users: './users',
|
users: './users',
|
||||||
webhooks: './webhooks',
|
webhooks: './webhooks',
|
||||||
},
|
},
|
||||||
|
|
|
@ -5242,3 +5242,8 @@ yargs@^7.1.0:
|
||||||
which-module "^1.0.0"
|
which-module "^1.0.0"
|
||||||
y18n "^3.2.1"
|
y18n "^3.2.1"
|
||||||
yargs-parser "^5.0.0"
|
yargs-parser "^5.0.0"
|
||||||
|
|
||||||
|
zxcvbn@^4.4.2:
|
||||||
|
version "4.4.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/zxcvbn/-/zxcvbn-4.4.2.tgz#28ec17cf09743edcab056ddd8b1b06262cc73c30"
|
||||||
|
integrity sha1-KOwXzwl0PtyrBW3dixsGJizHPDA=
|
||||||
|
|
Loading…
Reference in a new issue