Browse Source

Add setup scenario and fix annoying bugs

- Fix token expiration issue, add setting to change the expiration
  time.
- Fix empty login page when entered password is empty
- Rearrange reading of configuration and add the basic support of
  setup scenario.
Alexey Edelev 2 years ago
parent
commit
3a8d9b2846
11 changed files with 248 additions and 104 deletions
  1. 2 0
      .gitignore
  2. 19 17
      auth/authenticator.go
  3. 4 4
      build.sh
  4. 75 44
      config/config.go
  5. 38 16
      config/main.ini.default
  6. 2 1
      go.mod
  7. 4 0
      go.sum
  8. 2 1
      main.go
  9. 0 3
      scanner/mailscanner.go
  10. 88 0
      setup/setup.go
  11. 14 18
      web/auth.go

+ 2 - 0
.gitignore

@@ -25,3 +25,5 @@ _testmain.go
 *.prof
 
 data/
+
+common/gostfix.pb.go

+ 19 - 17
auth/authenticator.go

@@ -210,28 +210,30 @@ func (a *Authenticator) checkToken(user, token string) error {
 				Expire int64
 			}
 		}{}
-
+		
 		err = cur.Decode(&result)
-
-		ok = err == nil && result.Token.Expire >= time.Now().Unix()
+		
+		ok = err == nil && (config.ConfigInstance().WebSessionExpireTime <= 0 || result.Token.Expire >= time.Now().Unix())
 	}
 
 	if ok {
-		opts := options.Update().SetArrayFilters(options.ArrayFilters{
-			Registry: bson.DefaultRegistry,
-			Filters: bson.A{
-				bson.M{"element.token": "b3a612c1-a56c-4465-8071-4250ec5de79d"},
-			}})
-		a.tokensCollection.UpdateOne(context.Background(),
-			bson.M{
-				"user": user,
-			},
-			bson.M{
-				"$set": bson.M{
-					"token.$[element].expire": time.Now().Add(time.Hour * 24).Unix(),
+		if config.ConfigInstance().WebSessionExpireTime > 0 {
+			opts := options.Update().SetArrayFilters(options.ArrayFilters{
+				Registry: bson.DefaultRegistry,
+				Filters: bson.A{
+					bson.M{"element.token": token},
+				}})
+			a.tokensCollection.UpdateOne(context.Background(),
+				bson.M{
+					"user": user,
 				},
-			},
-			opts)
+				bson.M{
+					"$set": bson.M{
+						"token.$[element].expire": time.Now().Add(config.ConfigInstance().WebSessionExpireTime).Unix(),
+					},
+				},
+				opts)
+		}
 		return nil
 	}
 

+ 4 - 4
build.sh

@@ -13,10 +13,10 @@ protoc -I$RPC_PATH --go_out=plugins=grpc:$PWD $RPC_PATH/gostfix.proto
 protoc -I$RPC_PATH --gotag_out=xxx="bson+\"-\"",output_path=$RPC_PATH:. $RPC_PATH/gostfix.proto
 
 #echo "Installing data"
-rm -rf data
-mkdir data
-cp -a main.ini data/
-cp -a main.cf data/
+#rm -rf data
+#mkdir data
+#cp -a main.ini data/
+#cp -a main.cf data/
 #cp -a vmailbox.db data/
 cp -a web/assets data/
 cp -a web/css data/

+ 75 - 44
config/config.go

@@ -29,6 +29,7 @@ import (
 	"log"
 	"strings"
 	"sync"
+	"time"
 
 	utils "git.semlanik.org/semlanik/gostfix/utils"
 	ini "gopkg.in/go-ini/ini.v1"
@@ -37,16 +38,27 @@ import (
 const configPath = "data/main.ini"
 
 const (
-	KeyWebPort             = "web_port"
-	KeySASLPort            = "sasl_port"
-	KeyPostfixConfig       = "postfix_config"
-	KeyMongoAddress        = "mongo_address"
-	KeyMongoUser           = "mongo_user"
-	KeyMongoPassword       = "mongo_password"
-	KeyAttachmentsPath     = "attachments_path"
-	KeyAttachmentsUser     = "attachments_user"
-	KeyAttachmentsPassword = "attachments_password"
-	KeyRegistrationEnabled = "registration_enabled"
+	KeyWebPort              = "web_port"
+	KeySASLPort             = "sasl_port"
+	KeyPostfixConfig        = "postfix_config"
+	KeyMongoAddress         = "mongo_address"
+	KeyMongoUser            = "mongo_user"
+	KeyMongoPassword        = "mongo_password"
+	KeyAttachmentsPath      = "attachments_path"
+	KeyAttachmentsUser      = "attachments_user"
+	KeyAttachmentsPassword  = "attachments_password"
+	KeyRegistrationEnabled  = "registration_enabled"
+)
+
+const (
+	SetupSection            = "intial_setup"
+	SetupKeyEnabled         = "enabled"
+	SetupKeyPassword        = "password"
+)
+
+const (
+	WebSection              = "web"
+	WebKeySessionExpireTime = "session_expire_time"
 )
 
 const (
@@ -73,27 +85,44 @@ func ConfigInstance() *GostfixConfig {
 }
 
 type gostfixConfig struct {
-	WebPort             string
-	SASLPort            string
-	MyDomain            string
-	VMailboxMaps        string
-	VMailboxBase        string
-	VMailboxDomains     []string
-	MongoUser           string
-	MongoPassword       string
-	MongoAddress        string
-	AttachmentsPath     string
-	RegistrationEnabled bool
+	WebPort              string
+	SASLPort             string
+	MyDomain             string
+	VMailboxMaps         string
+	VMailboxBase         string
+	VMailboxDomains      []string
+	MongoUser            string
+	MongoPassword        string
+	MongoAddress         string
+	AttachmentsPath      string
+	RegistrationEnabled  bool
+	WebSessionExpireTime time.Duration
+	SetupEnabled         bool
+	SetupPassword        string
 }
 
 func newConfig() (config *gostfixConfig, err error) {
-
 	cfg, err := ini.Load(configPath)
 	if err != nil {
 		log.Fatalf("Unable to load %s\n", configPath)
 		return
 	}
 
+	webPort := cfg.Section("").Key(KeyWebPort).String()
+	if webPort == "" {
+		log.Printf("Web server port is not specified in configuration file, use default 65200")
+		webPort = "65200"
+	}
+	
+	initialSetup, _ := cfg.Section(SetupSection).Key(SetupKeyEnabled).Bool()
+	initialPassword := cfg.Section(SetupSection).Key(SetupKeyPassword).String()
+	if initialSetup {
+		if len(initialPassword) < 8 {
+			log.Fatalf("Initial setup requires a temporary master password in the configuration file. The minimum lenght is 8 symbols.")
+			return
+		}
+	}
+
 	postfixConfigPath := cfg.Section("").Key(KeyPostfixConfig).String()
 	if !utils.FileExists(postfixConfigPath) {
 		log.Fatalf("Unable to find postfix config %s\n", postfixConfigPath)
@@ -122,11 +151,10 @@ func newConfig() (config *gostfixConfig, err error) {
 		return
 	}
 
-	// TODO: Trigger initial setup cycle instead of throwing an error
-	// if !utils.FileExists(mapsList[1] + ".db") {
-	// 	log.Fatalf("Virtual mailbox map %s doesn't exist, postfix is not configured proper way, check %s in %s\n", mapsList[1], PostfixKeyVirtualMailboxMaps, postfixConfigPath)
-	// 	return
-	// }
+	if !utils.FileExists(mapsList[1] + ".db") {
+		log.Fatalf("Virtual mailbox map %s doesn't exist, postfix is not configured proper way, check %s in %s\n", mapsList[1], PostfixKeyVirtualMailboxMaps, postfixConfigPath)
+		return
+	}
 
 	domains := postfixCfg.Section("").Key(PostfixKeyVirtualMailboxDomains).String()
 	domainsList := strings.Split(domains, " ")
@@ -166,30 +194,33 @@ func newConfig() (config *gostfixConfig, err error) {
 
 	registrationEnabled := cfg.Section("").Key(KeyRegistrationEnabled).String()
 
-	webPort := cfg.Section("").Key(KeyWebPort).String()
-	if webPort == "" {
-		log.Printf("Web server port is not specified in configuration file, use default 65200")
-		webPort = "65200"
-	}
-
 	saslPort := cfg.Section("").Key(KeySASLPort).String()
 	if saslPort == "" {
 		log.Printf("SASL server port is not specified in configuration file, use default 65201")
 		saslPort = "65201"
 	}
 
+	webSessionExpireTime, err := time.ParseDuration(cfg.Section(WebSection).Key(WebKeySessionExpireTime).String())
+	if err != nil {
+		webSessionExpireTime = time.Hour * 24
+		log.Printf("Unable to read web session expire time. 24h by default.");
+	}
+
 	config = &gostfixConfig{
-		WebPort:             webPort,
-		SASLPort:            saslPort,
-		MyDomain:            myDomain,
-		VMailboxBase:        baseDir,
-		VMailboxMaps:        mapsList[1] + ".db",
-		VMailboxDomains:     validDomains,
-		MongoUser:           mongoUser,
-		MongoPassword:       mongoPassword,
-		MongoAddress:        mongoAddress,
-		AttachmentsPath:     attachmentsPath,
-		RegistrationEnabled: registrationEnabled == "true",
+		WebPort:              webPort,
+		SASLPort:             saslPort,
+		MyDomain:             myDomain,
+		VMailboxBase:         baseDir,
+		VMailboxMaps:         mapsList[1] + ".db",
+		VMailboxDomains:      validDomains,
+		MongoUser:            mongoUser,
+		MongoPassword:        mongoPassword,
+		MongoAddress:         mongoAddress,
+		AttachmentsPath:      attachmentsPath,
+		RegistrationEnabled:  registrationEnabled == "true",
+		WebSessionExpireTime: webSessionExpireTime * 1000,
+		SetupEnabled:         initialSetup,
+		SetupPassword:        initialPassword,
 	}
 	return
 }

+ 38 - 16
config/main.ini.default

@@ -1,28 +1,50 @@
-# Port used by web interface server
-web_port = 65200
+; Web server port
+; Default: 65200
+;
+web_port=65200
 
-# Port used by SASL authentication server
-sasl_port = 65201
+; SASL authentication server port
+; Default: 65201
+;
+sasl_port=65201
 
-# Path to postfix service configuration.
-# Usualy placed in /etc/postfix/main.cf
-# If not set cause critical error.
+; Enables or disable registration functionality in web interface
+;
+registration_enabled=true
+
+; Path to postfix service configuration.
+; Usualy is located in /etc/postfix/main.cf
+; If not set cause critical error.
+;
 postfix_config = /etc/postfix/main.cf
 
-# Address of mongo database to store service data in following
-# format:
-#     host:[port]
-# By default is localhost:27017.
-mongo_address = localhost:27017
+; Address of mongo database to store service data in following
+; format:
+;     host:[port]
+; Default: localhost:27017
+;
+;mongo_address = localhost:27017
 
-# User name to access mongo database. Empty by default.
+; User name to access mongo database. Empty by default.
+;
 mongo_user =
 
-# Password to access mongo database. Empty by default.
+; Password to access mongo database. Empty by default.
+;
 mongo_password =
 
-# Path to attachments storage. By dafault "./attachments".
+; Path to attachments storage. By dafault "./attachments".
+;
 attachments_path = attachments
 
-# Enables registration functionality, disabled by default
+; Enables registration functionality, disabled by default
+;
 registration_enabled = false
+
+[web]
+; Duration while the user web session is valid. If user tryes to access the web
+; interface after the session is expired, a token that is used to access the web
+; interface is invalidated and removed from database.
+; Default: 24h
+;
+;session_expire_time=1m

+ 2 - 1
go.mod

@@ -4,6 +4,7 @@ go 1.14
 
 require (
 	github.com/fsnotify/fsnotify v1.4.9
+	github.com/golang/protobuf v1.5.2
 	github.com/google/uuid v1.1.2
 	github.com/gorilla/sessions v1.2.0
 	github.com/gorilla/websocket v1.4.2
@@ -18,7 +19,7 @@ require (
 	golang.org/x/net v0.0.0-20220421235706-1d1ef9303861 // indirect
 	golang.org/x/sys v0.0.0-20220422013727-9388b58f7150
 	golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect
-	google.golang.org/protobuf v1.28.0
+	google.golang.org/protobuf v1.28.0 // indirect
 	gopkg.in/go-ini/ini.v1 v1.57.0
 	gopkg.in/ini.v1 v1.57.0 // indirect
 )

+ 4 - 0
go.sum

@@ -39,7 +39,10 @@ github.com/gobuffalo/packr/v2 v2.2.0/go.mod h1:CaAwI0GPIAv+5wKLtv8Afwl+Cm78K/I/V
 github.com/gobuffalo/syncx v0.0.0-20190224160051-33c29581e754/go.mod h1:HhnNqWY95UYwwW3uSASeV7vtgYkT2t16hJgV3AEPUpw=
 github.com/gogs/chardet v0.0.0-20150115103509-2404f7772561 h1:aBzukfDxQlCTVS0NBUjI5YA3iVeaZ9Tb5PxNrrIP1xs=
 github.com/gogs/chardet v0.0.0-20150115103509-2404f7772561/go.mod h1:Pcatq5tYkCW2Q6yrR2VRHlbHpZ/R4/7qyL1TCF7vl14=
+github.com/golang/protobuf v1.5.0 h1:LUVKkCeviFUMKqHa4tXIIij/lbhnMbP7Fn5wKdKkRh4=
 github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
+github.com/golang/protobuf v1.5.2 h1:ROPKBNFfQgOUMifHyP+KYbvpjbdoFNs+aK7DXlji0Tw=
+github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
 github.com/golang/snappy v0.0.1 h1:Qgr9rKW7uDUkrbSmQeiDsGa8SjGyCOGtuasMWwvp2P4=
 github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
 github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
@@ -178,6 +181,7 @@ golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8T
 golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE=
 golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
+google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
 google.golang.org/protobuf v1.28.0 h1:w43yiav+6bVFTBQFZX0r7ipe9JQ1QsbMgHwbBziscLw=
 google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=

+ 2 - 1
main.go

@@ -43,12 +43,13 @@ type GofixEngine struct {
 func NewGofixEngine() (e *GofixEngine) {
 	mailScanner := scanner.NewMailScanner()
 	saslService, err := sasl.NewSaslServer()
+	webServer := web.NewServer(mailScanner)
 	if err != nil {
 		log.Fatalf("Unable to intialize sasl server %s\n", err)
 	}
 	e = &GofixEngine{
 		scanner: mailScanner,
-		web:     web.NewServer(mailScanner),
+		web:     webServer,
 		sasl:    saslService,
 	}
 	return

+ 0 - 3
scanner/mailscanner.go

@@ -49,15 +49,12 @@ type MailScanner struct {
 }
 
 func NewMailScanner() (ms *MailScanner) {
-	log.Print("test2")
 	watcher, err := fsnotify.NewWatcher()
 	if err != nil {
 		log.Fatal(err)
 		return
 	}
-	log.Print("test2")
 	storage, err := db.NewStorage()
-	log.Print("test3")
 	if err != nil {
 		log.Fatal(err)
 		return

+ 88 - 0
setup/setup.go

@@ -0,0 +1,88 @@
+/*
+ * MIT License
+ *
+ * Copyright (c) 2020 Alexey Edelev <semlanik@gmail.com>
+ *
+ * This file is part of gostfix project https://git.semlanik.org/semlanik/gostfix
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of this
+ * software and associated documentation files (the "Software"), to deal in the Software
+ * without restriction, including without limitation the rights to use, copy, modify,
+ * merge, publish, distribute, sublicense, and/or sell copies of the Software, and
+ * to permit persons to whom the Software is furnished to do so, subject to the following
+ * conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all copies
+ * or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
+ * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
+ * PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE
+ * FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+ * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+ * DEALINGS IN THE SOFTWARE.
+ */
+
+package setup
+
+import (
+	config "git.semlanik.org/semlanik/gostfix/config"
+)
+
+type Setup struct {
+	sessionStore      *sessions.CookieStore
+}
+
+func NewSetup() *Setup {
+	s := &Setup{
+		sessionStore:      sessions.NewCookieStore(make([]byte, 32)),
+	}
+	return s
+}
+
+func (s *Setup) Run() {
+	if !config.ConfigInstance().SetupEnabled {
+		return
+	}
+	http.Handle("/", s)
+	log.Fatal(http.ListenAndServe(":"+config.ConfigInstance().WebPort, nil))
+}
+
+func (s *Setup) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	urlParts := strings.Split(r.URL.Path, "/")[1:]
+	if len(urlParts) == 0 || urlParts[0] == "" {
+		// TODO: Welcome page with password
+		return
+	}
+	
+	if !checkPassword(r) {
+		http.Redirect(w, r, "/", http.StatusTemporaryRedirect)
+		return
+	}
+	
+	switch urlParts[1] {
+	case "config":
+		// TODO: Configure values
+	case "save":
+		// TODO: Confirm storing new config and reload server
+	case "recovery":
+		// TODO: Recovery mode, where user can resolve system inconsistency
+	case "admin":
+		// TODO: Admin panel handling
+	}
+}
+
+func (s *Server) checkPassword(r *http.Request) bool {
+	switch r.Method {
+	case "GET":
+		session, err := s.sessionStore.Get(r, CookieSessionToken)
+		if err != nil {
+			log.Printf("Unable to read user session %s\n", err)
+			return false
+		}
+		setupPassword, _ = session.Values["setupPassword"].(string)
+	case "POST":
+		setupPassword := r.FormValue("setupPassword")
+	}
+	return masterPassword == config.ConfigInstance().SetupPassword;
+}

+ 14 - 18
web/auth.go

@@ -37,10 +37,6 @@ import (
 )
 
 func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
-	// if session, err := s.sessionStore.Get(r, CookieSessionToken); err == nil && session.Values["user"] != nil && session.Values["token"] != nil {
-	// 	http.Redirect(w, r, "/m0", http.StatusTemporaryRedirect)
-	// 	return
-	// }
 	if !config.ConfigInstance().RegistrationEnabled {
 		s.error(http.StatusNotImplemented, "Registration is disabled on this server", w)
 		return
@@ -92,20 +88,6 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
 			http.Redirect(w, r, "/m/0", http.StatusTemporaryRedirect)
 			return
 		}
-
-		var signupTemplate template.HTML
-		if config.ConfigInstance().RegistrationEnabled {
-			signupTemplate = template.HTML(s.templater.ExecuteSignup(""))
-		} else {
-			signupTemplate = ""
-		}
-
-		//Otherwise make sure user logged out and show login page
-		s.logout(w, r)
-		fmt.Fprint(w, s.templater.ExecuteLogin(&struct {
-			Version string
-			Signup  template.HTML
-		}{common.Version, signupTemplate}))
 	case "POST":
 		//Check passed in form login/password pair first
 		user := r.FormValue("user")
@@ -116,6 +98,20 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
 			return
 		}
 	}
+
+	var signupTemplate template.HTML
+	if config.ConfigInstance().RegistrationEnabled {
+		signupTemplate = template.HTML(s.templater.ExecuteSignup(""))
+	} else {
+		signupTemplate = ""
+	}
+
+	//Otherwise make sure user logged out and show login page
+	s.logout(w, r)
+	fmt.Fprint(w, s.templater.ExecuteLogin(&struct {
+		Version string
+		Signup  template.HTML
+	}{common.Version, signupTemplate}))
 }
 
 func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {