diff --git a/go.mod b/go.mod index 15e43f6..273f859 100644 --- a/go.mod +++ b/go.mod @@ -4,15 +4,16 @@ go 1.20 require ( github.com/containrrr/shoutrrr v0.7.1 + github.com/google/uuid v1.3.0 github.com/spf13/viper v1.16.0 - modernc.org/sqlite v1.24.0 + golang.org/x/crypto v0.12.0 + modernc.org/sqlite v1.25.0 ) require ( github.com/dustin/go-humanize v1.0.1 // indirect github.com/fatih/color v1.13.0 // indirect github.com/fsnotify/fsnotify v1.6.0 // indirect - github.com/google/uuid v1.3.0 // indirect github.com/hashicorp/hcl v1.0.0 // indirect github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect github.com/magiconair/properties v1.8.7 // indirect @@ -27,17 +28,17 @@ require ( github.com/spf13/pflag v1.0.5 // indirect github.com/subosito/gotenv v1.4.2 // indirect golang.org/x/mod v0.8.0 // indirect - golang.org/x/sys v0.8.0 // indirect - golang.org/x/text v0.9.0 // indirect + golang.org/x/sys v0.11.0 // indirect + golang.org/x/text v0.12.0 // indirect golang.org/x/tools v0.6.0 // indirect gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect lukechampine.com/uint128 v1.2.0 // indirect modernc.org/cc/v3 v3.40.0 // indirect modernc.org/ccgo/v3 v3.16.13 // indirect - modernc.org/libc v1.22.5 // indirect + modernc.org/libc v1.24.1 // indirect modernc.org/mathutil v1.5.0 // indirect - modernc.org/memory v1.5.0 // indirect + modernc.org/memory v1.6.0 // indirect modernc.org/opt v0.1.3 // indirect modernc.org/strutil v1.1.3 // indirect modernc.org/token v1.0.1 // indirect diff --git a/go.sum b/go.sum index 2bcf4f0..552cd56 100644 --- a/go.sum +++ b/go.sum @@ -610,6 +610,8 @@ golang.org/x/crypto v0.0.0-20211108221036-ceb1ce70b4fa/go.mod h1:GvvjBRRGRdwPK5y golang.org/x/crypto v0.0.0-20220525230936-793ad666bf5e/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.1.0/go.mod h1:RecgLatLF4+eUMCP1PoPZQb+cVrJcOPbHkTkbkB9sbw= +golang.org/x/crypto v0.12.0 h1:tFM/ta59kqch6LlvYnPa0yx5a83cL2nHflFhYKvv9Yk= +golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= @@ -842,8 +844,8 @@ golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.8.0 h1:EBmGv8NaZBZTWvrbjNoL6HVt+IVy3QDQpJs7VRIw3tU= -golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.11.0 h1:eG7RXZHdqOJ1i+0lgLgCpSXAp6M3LYlAo6osgSi0xOM= +golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= @@ -857,8 +859,8 @@ golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/text v0.9.0 h1:2sjJmO8cDvYveuX97RDLsxlyUxLl+GHoLxBiRdHllBE= -golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.12.0 h1:k+n5B8goJNdU7hSvEtMUz3d1Q6D/XW4COJSJR6fN0mc= +golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= @@ -1181,16 +1183,16 @@ modernc.org/ccgo/v3 v3.16.13 h1:Mkgdzl46i5F/CNR/Kj80Ri59hC8TKAhZrYSaqvkwzUw= modernc.org/ccgo/v3 v3.16.13/go.mod h1:2Quk+5YgpImhPjv2Qsob1DnZ/4som1lJTodubIcoUkY= modernc.org/ccorpus v1.11.6 h1:J16RXiiqiCgua6+ZvQot4yUuUy8zxgqbqEEUuGPlISk= modernc.org/httpfs v1.0.6 h1:AAgIpFZRXuYnkjftxTAZwMIiwEqAfk8aVB2/oA6nAeM= -modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE= -modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY= +modernc.org/libc v1.24.1 h1:uvJSeCKL/AgzBo2yYIPPTy82v21KgGnizcGYfBHaNuM= +modernc.org/libc v1.24.1/go.mod h1:FmfO1RLrU3MHJfyi9eYYmZBfi/R+tqZ6+hQ3yQQUkak= modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ= modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E= -modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds= -modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU= +modernc.org/memory v1.6.0 h1:i6mzavxrE9a30whzMfwf7XWVODx2r5OYXvU46cirX7o= +modernc.org/memory v1.6.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU= modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4= modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0= -modernc.org/sqlite v1.24.0 h1:EsClRIWHGhLTCX44p+Ri/JLD+vFGo0QGjasg2/F9TlI= -modernc.org/sqlite v1.24.0/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk= +modernc.org/sqlite v1.25.0 h1:AFweiwPNd/b3BoKnBOfFm+Y260guGMF+0UFk0savqeA= +modernc.org/sqlite v1.25.0/go.mod h1:FL3pVXie73rg3Rii6V/u5BoHlSoyeZeIgKZEgHARyCU= modernc.org/strutil v1.1.3 h1:fNMm+oJklMGYfU9Ylcywl0CO5O6nTfaowNsh2wpPjzY= modernc.org/strutil v1.1.3/go.mod h1:MEHNA7PdEnEwLvspRMtWTNnp2nnyvMfkimT1NKNAGbw= modernc.org/tcl v1.15.2 h1:C4ybAYCGJw968e+Me18oW55kD/FexcHbqH2xak1ROSY= diff --git a/internal/auth/auth.go b/internal/auth/auth.go new file mode 100644 index 0000000..e213246 --- /dev/null +++ b/internal/auth/auth.go @@ -0,0 +1,35 @@ +package auth + +import ( + "net/http" + "time" +) + +// Auth - main auth func +func Auth(next http.HandlerFunc, conf *Conf) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + + if !conf.Auth || conf.User == "" || conf.Password == "" { + next.ServeHTTP(w, r) + return + } + + sessionToken := getTokenFromCookie(r) + + userSession, exists := allSessions[sessionToken] + if !exists { + http.Redirect(w, r, "/login/", 302) + return + } + if userSession.Before(time.Now()) { + delete(allSessions, sessionToken) + http.Redirect(w, r, "/login/", 302) + return + } + + userSession = time.Now().Add(conf.Expire) + allSessions[sessionToken] = userSession + + next.ServeHTTP(w, r) + } +} diff --git a/internal/auth/cookie.go b/internal/auth/cookie.go new file mode 100644 index 0000000..90c2b8d --- /dev/null +++ b/internal/auth/cookie.go @@ -0,0 +1,22 @@ +package auth + +import ( + "net/http" +) + +func setTokenCookie(w http.ResponseWriter, token string) { + + cookie := http.Cookie{Name: cookieName, Value: token, Path: "/"} + http.SetCookie(w, &cookie) +} + +func getTokenFromCookie(r *http.Request) string { + + cookie, err := r.Cookie(cookieName) + if err != nil { + + return "" + } + + return cookie.Value +} diff --git a/internal/auth/hash.go b/internal/auth/hash.go new file mode 100644 index 0000000..3ae9368 --- /dev/null +++ b/internal/auth/hash.go @@ -0,0 +1,27 @@ +package auth + +import ( + "log" + + "golang.org/x/crypto/bcrypt" +) + +// HashPassword - generate hash from password +func HashPassword(pw string) string { + + hashed, err := bcrypt.GenerateFromPassword([]byte(pw), 10) + if err != nil { + log.Println("PASSWORD ERROR:", err) + return "" + } + + return string(hashed) +} + +// MatchPasswords - check if password and hash matches +func MatchPasswords(hash, pw string) bool { + + err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(pw)) + + return err == nil +} diff --git a/internal/auth/models-vars.go b/internal/auth/models-vars.go new file mode 100644 index 0000000..1df8033 --- /dev/null +++ b/internal/auth/models-vars.go @@ -0,0 +1,18 @@ +package auth + +import ( + "time" +) + +// Conf - auth config +type Conf struct { + Auth bool + User string + Password string + ExpStr string + Expire time.Duration +} + +var allSessions = map[string]time.Time{} + +var cookieName = "wyl_session_token" diff --git a/internal/auth/session.go b/internal/auth/session.go new file mode 100644 index 0000000..ea7794f --- /dev/null +++ b/internal/auth/session.go @@ -0,0 +1,33 @@ +package auth + +import ( + "net/http" + "time" + + "github.com/google/uuid" +) + +// StartSession for new login +func StartSession(w http.ResponseWriter, r *http.Request) { + + sessionToken := uuid.NewString() + + allSessions[sessionToken] = time.Now().Add(60 * time.Second) + + setTokenCookie(w, sessionToken) + + http.Redirect(w, r, "/", 302) + +} + +// LogOut - log out +func LogOut(w http.ResponseWriter, r *http.Request) { + + sessionToken := getTokenFromCookie(r) + + delete(allSessions, sessionToken) + + setTokenCookie(w, "") + + http.Redirect(w, r, "/", 302) +} diff --git a/internal/auth/timeparse.go b/internal/auth/timeparse.go new file mode 100644 index 0000000..a6dd940 --- /dev/null +++ b/internal/auth/timeparse.go @@ -0,0 +1,46 @@ +package auth + +import ( + "errors" + "time" +) + +func timeParse(timeout string) (time.Duration, error) { + var err error + var t time.Duration + + length := len(timeout) + if length > 1 { + suffix := timeout[length-1] + + switch string(suffix) { + case "h": + t, err = time.ParseDuration(timeout) + case "m": + t, err = time.ParseDuration(timeout) + case "d": //day + t, err = time.ParseDuration(timeout[:length-1] + "h") + t = 24 * t + case "M": // month + t, err = time.ParseDuration(timeout[:length-1] + "h") + t = 730 * t + default: + err = errors.New("ERROR: TimeParse: wrong time format") + } + } else { + err = errors.New("ERROR: TimeParse: wrong time format") + } + + return t, err +} + +// ToTime - converts string (example: 3d) to time.Duration +func ToTime(s string) time.Duration { + + t, err := timeParse(s) + if err != nil { + t, _ = timeParse("7d") + } + + return t +} diff --git a/internal/conf/config.go b/internal/conf/config.go index 5b69bc4..fb4316e 100644 --- a/internal/conf/config.go +++ b/internal/conf/config.go @@ -3,12 +3,14 @@ package conf import ( "github.com/spf13/viper" + "github.com/aceberg/WatchYourLAN/internal/auth" "github.com/aceberg/WatchYourLAN/internal/check" "github.com/aceberg/WatchYourLAN/internal/models" ) // Get - get app config -func Get(path string) (config models.Conf) { +func Get(path string) (config models.Conf, authConf auth.Conf) { + viper.SetDefault("IFACE", "enp1s0") viper.SetDefault("DBPATH", "/data/db.sqlite") viper.SetDefault("GUIIP", "localhost") @@ -19,6 +21,9 @@ func Get(path string) (config models.Conf) { viper.SetDefault("COLOR", "light") viper.SetDefault("IGNOREIP", "no") viper.SetDefault("LOGLEVEL", "verbose") + viper.SetDefault("AUTH_USER", "") + viper.SetDefault("AUTH_PASSWORD", "") + viper.SetDefault("AUTH_EXPIRE", "7d") viper.SetConfigFile(path) viper.SetConfigType("yaml") @@ -37,12 +42,19 @@ func Get(path string) (config models.Conf) { config.Color = viper.Get("COLOR").(string) config.IgnoreIP = viper.Get("IGNOREIP").(string) config.LogLevel = viper.Get("LOGLEVEL").(string) + authConf.Auth = viper.GetBool("AUTH") + authConf.User, _ = viper.Get("AUTH_USER").(string) + authConf.Password, _ = viper.Get("AUTH_PASSWORD").(string) + authConf.ExpStr, _ = viper.Get("AUTH_EXPIRE").(string) - return config + authConf.Expire = auth.ToTime(authConf.ExpStr) + config.Auth = authConf.Auth + + return config, authConf } // Write - write config to file -func Write(path string, config models.Conf) { +func Write(path string, config models.Conf, authConf auth.Conf) { viper.SetConfigFile(path) viper.SetConfigType("yaml") @@ -58,6 +70,11 @@ func Write(path string, config models.Conf) { viper.Set("IGNOREIP", config.IgnoreIP) viper.Set("LOGLEVEL", config.LogLevel) + viper.Set("auth", authConf.Auth) + viper.Set("auth_user", authConf.User) + viper.Set("auth_password", authConf.Password) + viper.Set("auth_expire", authConf.ExpStr) + err := viper.WriteConfig() check.IfError(err) } diff --git a/internal/migrate/migrate.go b/internal/migrate/migrate.go index 4d6f471..e65d6ac 100644 --- a/internal/migrate/migrate.go +++ b/internal/migrate/migrate.go @@ -4,6 +4,7 @@ import ( "fmt" "log" + "github.com/aceberg/WatchYourLAN/internal/auth" "github.com/aceberg/WatchYourLAN/internal/check" "github.com/aceberg/WatchYourLAN/internal/conf" "github.com/aceberg/WatchYourLAN/internal/notify" @@ -37,9 +38,10 @@ func ToYAML(path string) string { } func migrateConfig(oldPath, newPath string) { + var authConf auth.Conf config := getOldConfig(oldPath) - conf.Write(newPath, config) + conf.Write(newPath, config, authConf) msg := fmt.Sprintf("Config file migrated from ENV to YAML format. \nOld config file %s is no longer used and can be deleted. \nNew config file is %s, please update your settings.", oldPath, newPath) diff --git a/internal/models/models.go b/internal/models/models.go index 736e9b5..50f96dc 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -1,5 +1,9 @@ package models +import ( + "github.com/aceberg/WatchYourLAN/internal/auth" +) + // Host - one host type Host struct { ID uint16 @@ -26,6 +30,7 @@ type Conf struct { LogLevel string NodePath string Icon string + Auth bool } // GuiData - all data sent to html page @@ -34,4 +39,5 @@ type GuiData struct { Hosts []Host Themes []string Version string + Auth auth.Conf } diff --git a/internal/web/auth-conf.go b/internal/web/auth-conf.go new file mode 100644 index 0000000..27452dc --- /dev/null +++ b/internal/web/auth-conf.go @@ -0,0 +1,52 @@ +package web + +import ( + "log" + "net/http" + + "github.com/aceberg/WatchYourLAN/internal/auth" + "github.com/aceberg/WatchYourLAN/internal/conf" + "github.com/aceberg/WatchYourLAN/internal/models" +) + +func authConfHandler(w http.ResponseWriter, r *http.Request) { + var guiData models.GuiData + + guiData.Config = AppConfig + guiData.Auth.Auth = authConf.Auth + guiData.Auth.User = authConf.User + guiData.Auth.ExpStr = authConf.ExpStr + + execTemplate(w, "auth", guiData) +} + +func saveAuthHandler(w http.ResponseWriter, r *http.Request) { + + authConf.User = r.FormValue("user") + authConf.ExpStr = r.FormValue("expire") + + authStr := r.FormValue("auth") + pw := r.FormValue("password") + + if authStr == "on" { + authConf.Auth = true + } else { + authConf.Auth = false + } + AppConfig.Auth = authConf.Auth + + if pw != "" { + authConf.Password = auth.HashPassword(pw) + } + + authConf.Expire = auth.ToTime(authConf.ExpStr) + + if authConf.Auth && (authConf.User == "" || authConf.Password == "") { + log.Println("WARNING: Auth won't work with empty login or password.") + authConf.Auth = false + } + + conf.Write(ConfigPath, AppConfig, authConf) + + http.Redirect(w, r, r.Header.Get("Referer"), 302) +} diff --git a/internal/web/config.go b/internal/web/config.go index 829ba38..ecf99e6 100644 --- a/internal/web/config.go +++ b/internal/web/config.go @@ -48,7 +48,7 @@ func saveConfigHandler(w http.ResponseWriter, r *http.Request) { close(QuitScan) - conf.Write(ConfigPath, AppConfig) + conf.Write(ConfigPath, AppConfig, authConf) log.Println("INFO: writing new config") diff --git a/internal/web/const-var.go b/internal/web/const-var.go index 183141d..fd3fbc0 100644 --- a/internal/web/const-var.go +++ b/internal/web/const-var.go @@ -3,10 +3,13 @@ package web import ( "embed" + "github.com/aceberg/WatchYourLAN/internal/auth" "github.com/aceberg/WatchYourLAN/internal/models" ) var ( + authConf auth.Conf + // AppConfig - app config AppConfig models.Conf diff --git a/internal/web/login.go b/internal/web/login.go new file mode 100644 index 0000000..94b14ec --- /dev/null +++ b/internal/web/login.go @@ -0,0 +1,31 @@ +package web + +import ( + "log" + "net/http" + + "github.com/aceberg/WatchYourLAN/internal/auth" + "github.com/aceberg/WatchYourLAN/internal/models" +) + +func loginHandler(w http.ResponseWriter, r *http.Request) { + var guiData models.GuiData + guiData.Config = AppConfig + + username := r.FormValue("username") + password := r.FormValue("password") + logout := r.FormValue("logout") + + if username == authConf.User && auth.MatchPasswords(authConf.Password, password) { + + log.Println("INFO: user '"+username+"' logged in. Session expire time", authConf.Expire) + + auth.StartSession(w, r) + } + if logout == "yes" { + + auth.LogOut(w, r) + } + + execTemplate(w, "login", guiData) +} diff --git a/internal/web/templates/auth.html b/internal/web/templates/auth.html new file mode 100644 index 0000000..817f227 --- /dev/null +++ b/internal/web/templates/auth.html @@ -0,0 +1,53 @@ +{{ define "auth" }} + + +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + +
Enable +
+ {{ if .Auth.Auth }} + + {{ else }} + + {{ end }} +
+
Expire after
Login
New password
+
+
+ +
+
+
+ + +{{ template "footer" }} +{{ end }} \ No newline at end of file diff --git a/internal/web/templates/header.html b/internal/web/templates/header.html index 636abab..78953a1 100644 --- a/internal/web/templates/header.html +++ b/internal/web/templates/header.html @@ -31,6 +31,14 @@ + + {{ if .Config.Auth }} + + {{ end }}