diff --git a/README.md b/README.md index e4f13a90..e0540e44 100644 --- a/README.md +++ b/README.md @@ -26,23 +26,6 @@ Please open an issue, tell us you database version & configuration. --- -``` -PING to redis unsuccessful -... panic here ... -``` - -The program cannot reach your redis instance. Check the configuration and test manually with: - -``` -nc 6379 -PING -+PONG -``` - -if `netcat` returns immediately, the port is not reachable. - ---- - ## [Milestones](https://github.com/ansible-semaphore/semaphore/milestones) ## [Releases](https://github.com/ansible-semaphore/semaphore/releases) diff --git a/database/mysql.go b/database/mysql.go index 59f0be56..5d0503e2 100644 --- a/database/mysql.go +++ b/database/mysql.go @@ -12,25 +12,49 @@ var Mysql *gorp.DbMap // Mysql database func Connect() error { - url := util.Config.MySQL.Username + ":" + util.Config.MySQL.Password + "@tcp(" + util.Config.MySQL.Hostname + ")/?parseTime=true&interpolateParams=true" + db, err := connect() + if err != nil { + return err + } + + if err := db.Ping(); err != nil { + if err := createDb(); err != nil { + return err + } + + db, err = connect() + if err != nil { + return err + } + + if err := db.Ping(); err != nil { + return err + } + } + + Mysql = &gorp.DbMap{Db: db, Dialect: gorp.MySQLDialect{Engine: "InnoDB", Encoding: "UTF8"}} + return nil +} + +func createDb() error { + cfg := util.Config.MySQL + url := cfg.Username + ":" + cfg.Password + "@tcp(" + cfg.Hostname + ")/?parseTime=true&interpolateParams=true" db, err := sql.Open("mysql", url) if err != nil { return err } - if err := db.Ping(); err != nil { + if _, err := db.Exec("create database if not exists " + cfg.DbName); err != nil { return err } - if _, err := db.Exec("create database if not exists " + util.Config.MySQL.DbName); err != nil { - return err - } - - if _, err := db.Exec("use " + util.Config.MySQL.DbName); err != nil { - return err - } - - Mysql = &gorp.DbMap{Db: db, Dialect: gorp.MySQLDialect{Engine: "InnoDB", Encoding: "UTF8"}} return nil } + +func connect() (*sql.DB, error) { + cfg := util.Config.MySQL + url := cfg.Username + ":" + cfg.Password + "@tcp(" + cfg.Hostname + ")/" + cfg.DbName + "?parseTime=true&interpolateParams=true" + + return sql.Open("mysql", url) +} diff --git a/database/redis.go b/database/redis.go deleted file mode 100644 index e03a0b1f..00000000 --- a/database/redis.go +++ /dev/null @@ -1,27 +0,0 @@ -package database - -import ( - "fmt" - "time" - - "github.com/ansible-semaphore/semaphore/util" - "gopkg.in/redis.v3" -) - -// Redis pool -var Redis *redis.Client - -func init() { - Redis = redis.NewClient(&redis.Options{ - MaxRetries: 2, - DialTimeout: 10 * time.Second, - Addr: util.Config.SessionDb, - }) -} - -func RedisPing() { - if _, err := Redis.Ping().Result(); err != nil { - fmt.Println("PING to redis unsuccessful") - panic(err) - } -} diff --git a/database/sql_migrations/v1.5.0.sql b/database/sql_migrations/v1.5.0.sql new file mode 100644 index 00000000..de0de086 --- /dev/null +++ b/database/sql_migrations/v1.5.0.sql @@ -0,0 +1,12 @@ +CREATE TABLE `session` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `user_id` int(11) NOT NULL, + `created` datetime NOT NULL, + `last_active` datetime NOT NULL, + `ip` varchar(15) NOT NULL DEFAULT '', + `user_agent` text NOT NULL, + `expired` tinyint(1) NOT NULL DEFAULT '0', + PRIMARY KEY (`id`), + KEY `user_id` (`user_id`), + KEY `expired` (`expired`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8; \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index b76e4699..d4913cdc 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,11 +1,3 @@ -redis: - name: redis:latest - image: redis - expose: - - 6379 - volumes: - - /tmp/redis:/data - mongodb: image: mongo:latest command: mongod --smallfiles --directoryperdb --noprealloc diff --git a/main.go b/main.go index ed1f8ae1..66317a1d 100644 --- a/main.go +++ b/main.go @@ -37,7 +37,6 @@ func main() { fmt.Printf("Semaphore %v\n", util.Version) fmt.Printf("Port %v\n", util.Config.Port) fmt.Printf("MySQL %v@%v %v\n", util.Config.MySQL.Username, util.Config.MySQL.Hostname, util.Config.MySQL.DbName) - fmt.Printf("Redis %v\n", util.Config.SessionDb) fmt.Printf("Tmp Path (projects home) %v\n", util.Config.TmpPath) if err := database.Connect(); err != nil { @@ -47,7 +46,6 @@ func main() { models.SetupDBLink() defer database.Mysql.Db.Close() - database.RedisPing() if util.Migration { fmt.Println("\n Running DB Migrations") @@ -78,16 +76,17 @@ func doSetup() int { Hello! You will now be guided through a setup to: 1. Set up configuration for a MySQL/MariaDB database - 2. Set up redis for session storage - 3. Set up a path for your playbooks (auto-created) - 4. Run database Migrations - 5. Set up initial seamphore user & password + 2. Set up a path for your playbooks (auto-created) + 3. Run database Migrations + 4. Set up initial seamphore user & password `) var b []byte - setup := util.ScanSetup() + setup := util.NewConfig() for true { + setup.Scan() + var err error b, err = json.MarshalIndent(&setup, " ", "\t") if err != nil { @@ -104,9 +103,11 @@ func doSetup() int { } fmt.Println() - setup = util.ScanSetup() + setup = util.NewConfig() } + setup.GenerateCookieSecrets() + fmt.Printf(" Running: mkdir -p %v..\n", setup.TmpPath) os.MkdirAll(setup.TmpPath, 0755) @@ -124,9 +125,6 @@ func doSetup() int { os.Exit(1) } - fmt.Println(" Pinging redis..") - database.RedisPing() - fmt.Println("\n Running DB Migrations..") if err := migration.MigrateAll(); err != nil { fmt.Printf("\n Database migrations failed!\n %v\n", err.Error()) diff --git a/migration/versionHistory.go b/migration/versionHistory.go index a9e5bae7..081f4d57 100644 --- a/migration/versionHistory.go +++ b/migration/versionHistory.go @@ -60,5 +60,6 @@ func init() { {Major: 1, Minor: 2}, {Major: 1, Minor: 3}, {Major: 1, Minor: 4}, + {Major: 1, Minor: 5}, } } diff --git a/models/Session.go b/models/Session.go index 488df630..c7f7ce60 100644 --- a/models/Session.go +++ b/models/Session.go @@ -1,29 +1,13 @@ package models -import ( - "encoding/json" -) +import "time" type Session struct { - ID string `json:"-"` - - UserID *int `json:"user_id"` -} - -func (session *Session) Encode() []byte { - js, err := json.Marshal(session) - if err != nil { - panic(err) - } - - return js -} - -func DecodeSession(ID string, sess string) (Session, error) { - var session Session - err := json.Unmarshal([]byte(sess), &session) - - session.ID = ID - - return session, err + ID int `db:"id" json:"id"` + UserID int `db:"user_id" json:"user_id"` + Created time.Time `db:"created" json:"created"` + LastActive time.Time `db:"last_active" json:"last_active"` + IP string `db:"ip" json:"ip"` + UserAgent string `db:"user_agent" json:"user_agent"` + Expired bool `db:"expired" json:"expired"` } diff --git a/models/models.go b/models/models.go index b682a4b3..898c97d0 100644 --- a/models/models.go +++ b/models/models.go @@ -13,4 +13,5 @@ func SetupDBLink() { database.Mysql.AddTableWithName(TaskOutput{}, "task__output").SetUniqueTogether("task_id", "time") database.Mysql.AddTableWithName(Template{}, "project__template").SetKeys(true, "id") database.Mysql.AddTableWithName(User{}, "user").SetKeys(true, "id") + database.Mysql.AddTableWithName(Session{}, "session").SetKeys(true, "id") } diff --git a/playbooks/playbook.yml b/playbooks/playbook.yml index f5a764ec..b53c88ab 100644 --- a/playbooks/playbook.yml +++ b/playbooks/playbook.yml @@ -17,7 +17,6 @@ - nodejs - npm - mongodb-server - - redis-server - ansible - runit - npm: name={{ item }} global=yes diff --git a/routes/auth.go b/routes/auth.go index 2ccd65b5..791b2676 100644 --- a/routes/auth.go +++ b/routes/auth.go @@ -1,12 +1,8 @@ package routes import ( - "crypto/rand" - "encoding/base64" + "database/sql" "fmt" - "io" - "net/http" - "net/url" "strings" "time" @@ -14,93 +10,76 @@ import ( "github.com/ansible-semaphore/semaphore/models" "github.com/ansible-semaphore/semaphore/util" "github.com/gin-gonic/gin" - "gopkg.in/redis.v3" ) -func resetSessionExpiry(sessionID string, ttl time.Duration) { - var cmd *redis.BoolCmd - - if ttl == 0 { - cmd = database.Redis.Persist(sessionID) - } else { - cmd = database.Redis.Expire(sessionID, ttl) - } - - if err := cmd.Err(); err != nil { - fmt.Println("Cannot reset session expiry:", err) - } -} - func authentication(c *gin.Context) { - var redisKey string - ttl := 7 * 24 * time.Hour + var userID int if authHeader := strings.ToLower(c.Request.Header.Get("authorization")); len(authHeader) > 0 { - redisKey = "token-session:" + strings.Replace(authHeader, "bearer ", "", 1) - ttl = 0 - } else { - cookie, err := c.Request.Cookie("semaphore") - if err != nil { - // create cookie - new_cookie := make([]byte, 32) - if _, err := io.ReadFull(rand.Reader, new_cookie); err != nil { - panic(err) + var token models.APIToken + if err := database.Mysql.SelectOne(&token, "select * from user__token where id=? and expired=0", strings.Replace(authHeader, "bearer ", "", 1)); err != nil { + if err == sql.ErrNoRows { + c.AbortWithStatus(403) + return } - cookie_value := url.QueryEscape(base64.URLEncoding.EncodeToString(new_cookie)) - cookie = &http.Cookie{Name: "semaphore", Value: cookie_value, Path: "/", HttpOnly: true} - http.SetCookie(c.Writer, cookie) - } - - redisKey = "session:" + cookie.Value - } - - s, err := database.Redis.Get(redisKey).Result() - if err == redis.Nil { - // create a session - temp_session := models.Session{} - s = string(temp_session.Encode()) - - if err := database.Redis.Set(redisKey, s, 0).Err(); err != nil { panic(err) } - } else if err != nil { - fmt.Println("Cannot get session from redis:", err) - c.AbortWithStatus(500) - return - } - - sess, err := models.DecodeSession(redisKey, s) - if err != nil { - fmt.Println("Cannot decode session:", err) - util.AuthFailed(c) - return - } - - if sess.UserID != nil { - user, err := models.FetchUser(*sess.UserID) + userID = token.UserID + } else { + // fetch session from cookie + cookie, err := c.Request.Cookie("semaphore") if err != nil { - fmt.Println("Can't find user", err) c.AbortWithStatus(403) return } - c.Set("user", user) + value := make(map[string]interface{}) + if err = util.Cookie.Decode("semaphore", cookie.Value, &value); err != nil { + c.AbortWithStatus(403) + panic(err) + } + + user, ok := value["user"] + sessionVal, okSession := value["session"] + if !ok || !okSession { + c.AbortWithStatus(403) + return + } + + userID = user.(int) + sessionID := sessionVal.(int) + + // fetch session + var session models.Session + if err := database.Mysql.SelectOne(&session, "select * from session where id=? and user_id=? and expired=0", sessionID, userID); err != nil { + c.AbortWithStatus(403) + return + } + + if time.Now().Sub(session.LastActive).Hours() > 7*24 { + // more than week old unused session + // destroy. + if _, err := database.Mysql.Exec("update session set expired=1 where id=?", sessionID); err != nil { + panic(err) + } + + c.AbortWithStatus(403) + return + } + + if _, err := database.Mysql.Exec("update session set last_active=NOW() where id=?", sessionID); err != nil { + panic(err) + } } - // reset session expiry - go resetSessionExpiry(redisKey, ttl) - - c.Set("session", sess) - c.Next() -} - -func MustAuthenticate(c *gin.Context) { - if _, exists := c.Get("user"); !exists { - util.AuthFailed(c) + user, err := models.FetchUser(userID) + if err != nil { + fmt.Println("Can't find user", err) + c.AbortWithStatus(403) return } - c.Next() + c.Set("user", user) } diff --git a/routes/login.go b/routes/login.go index 150ebfca..738c97a4 100644 --- a/routes/login.go +++ b/routes/login.go @@ -2,16 +2,17 @@ package routes import ( "database/sql" + "net/http" "net/mail" "strings" "time" - "golang.org/x/crypto/bcrypt" - "github.com/ansible-semaphore/semaphore/database" "github.com/ansible-semaphore/semaphore/models" + "github.com/ansible-semaphore/semaphore/util" "github.com/gin-gonic/gin" sq "github.com/masterminds/squirrel" + "golang.org/x/crypto/bcrypt" ) func login(c *gin.Context) { @@ -53,22 +54,36 @@ func login(c *gin.Context) { return } - session := c.MustGet("session").(models.Session) - session.UserID = &user.ID - - status := database.Redis.Set(session.ID, string(session.Encode()), 7*24*time.Hour) - if err := status.Err(); err != nil { + session := models.Session{ + UserID: user.ID, + Created: time.Now(), + LastActive: time.Now(), + IP: c.ClientIP(), + UserAgent: c.Request.Header.Get("user-agent"), + Expired: false, + } + if err := database.Mysql.Insert(&session); err != nil { panic(err) } + encoded, err := util.Cookie.Encode("semaphore", map[string]interface{}{ + "user": user.ID, + "session": session.ID, + }) + if err != nil { + panic(err) + } + + http.SetCookie(c.Writer, &http.Cookie{ + Name: "semaphore", + Value: encoded, + Path: "/", + }) + c.AbortWithStatus(204) } func logout(c *gin.Context) { - session := c.MustGet("session").(models.Session) - if err := database.Redis.Del(session.ID).Err(); err != nil { - panic(err) - } - + c.SetCookie("semaphore", "", -1, "/", "", false, true) c.AbortWithStatus(204) } diff --git a/routes/router.go b/routes/router.go index e9a16556..7b3e4884 100644 --- a/routes/router.go +++ b/routes/router.go @@ -21,14 +21,12 @@ func Route(r *gin.Engine) { // set up the namespace api := r.Group("/api") - api.Use(authentication) - func(api *gin.RouterGroup) { api.POST("/login", login) api.POST("/logout", logout) }(api.Group("/auth")) - api.Use(MustAuthenticate) + api.Use(authentication) api.GET("/ws", sockets.Handler) diff --git a/routes/user.go b/routes/user.go index 7623b7b9..41b1da6b 100644 --- a/routes/user.go +++ b/routes/user.go @@ -45,13 +45,6 @@ func createAPIToken(c *gin.Context) { panic(err) } - temp_session := models.Session{ - UserID: &user.ID, - } - if err := database.Redis.Set("token-session:"+token.ID, temp_session.Encode(), 0).Err(); err != nil { - panic(err) - } - c.JSON(201, token) } @@ -69,11 +62,9 @@ func expireAPIToken(c *gin.Context) { panic(err) } - if affected > 0 { - // remove from redis - if err := database.Redis.Del("token-session:" + tokenID).Err(); err != nil { - panic(err) - } + if affected == 0 { + c.AbortWithStatus(400) + return } c.AbortWithStatus(204) diff --git a/util/config.go b/util/config.go index f28645d9..01c2d07e 100644 --- a/util/config.go +++ b/util/config.go @@ -1,20 +1,20 @@ package util import ( + "encoding/base64" "encoding/json" "flag" "fmt" "os" "path" - "golang.org/x/crypto/bcrypt" - "github.com/bugsnag/bugsnag-go" "github.com/gin-gonic/gin" - "github.com/mattbaird/gochimp" + "github.com/gorilla/securecookie" + "golang.org/x/crypto/bcrypt" ) -var mandrillAPI *gochimp.MandrillAPI +var Cookie *securecookie.SecureCookie var Migration bool var InteractiveSetup bool var Upgrade bool @@ -26,25 +26,25 @@ type mySQLConfig struct { DbName string `json:"name"` } -type mandrillConfig struct { - Username string `json:"username"` - Password string `json:"password"` -} - type configType struct { MySQL mySQLConfig `json:"mysql"` - // Format as is with net.Dial - SessionDb string `json:"session_db"` - Mandrill mandrillConfig `json:"mandrill"` // Format `:port_num` eg, :3000 Port string `json:"port"` BugsnagKey string `json:"bugsnag_key"` // semaphore stores projects here TmpPath string `json:"tmp_path"` + + // cookie hashing & encryption + CookieHash string `json:"cookie_hash"` + CookieEncryption string `json:"cookie_encryption"` } -var Config configType +var Config *configType + +func NewConfig() *configType { + return &configType{} +} func init() { flag.BoolVar(&InteractiveSetup, "setup", false, "perform interactive setup") @@ -61,16 +61,18 @@ func init() { flag.Parse() if printConfig { - b, _ := json.MarshalIndent(&configType{ + cfg := &configType{ MySQL: mySQLConfig{ Hostname: "127.0.0.1:3306", Username: "root", DbName: "semaphore", }, - SessionDb: "127.0.0.1:6379", - Port: ":3000", - TmpPath: "/tmp/semaphore", - }, "", "\t") + Port: ":3000", + TmpPath: "/tmp/semaphore", + } + cfg.GenerateCookieSecrets() + + b, _ := json.MarshalIndent(cfg, "", "\t") fmt.Println(string(b)) os.Exit(0) @@ -114,15 +116,20 @@ func init() { Config.Port = ":3000" } - if len(Config.Mandrill.Password) > 0 { - api, _ := gochimp.NewMandrill(Config.Mandrill.Password) - mandrillAPI = api - } - if len(Config.TmpPath) == 0 { Config.TmpPath = "/tmp/semaphore" } + var encryption []byte + encryption = nil + + hash, _ := base64.StdEncoding.DecodeString(Config.CookieHash) + if len(Config.CookieEncryption) > 0 { + encryption, _ = base64.StdEncoding.DecodeString(Config.CookieEncryption) + } + + Cookie = securecookie.New(hash, encryption) + stage := "" if gin.Mode() == "release" { stage = "production" @@ -138,33 +145,15 @@ func init() { }) } -// encapsulate mandrill providing some defaults +func (conf *configType) GenerateCookieSecrets() { + hash := securecookie.GenerateRandomKey(32) + encryption := securecookie.GenerateRandomKey(32) -func MandrillMessage(important bool) gochimp.Message { - return gochimp.Message{ - AutoText: true, - InlineCss: true, - Important: important, - FromName: "Semaphore Daemon", - FromEmail: "noreply@semaphore.local", - } + conf.CookieHash = base64.StdEncoding.EncodeToString(hash) + conf.CookieEncryption = base64.StdEncoding.EncodeToString(encryption) } -func MandrillRecipient(name string, email string) gochimp.Recipient { - return gochimp.Recipient{ - Email: email, - Name: name, - Type: "to", - } -} - -func MandrillSend(message gochimp.Message) ([]gochimp.SendResponse, error) { - return mandrillAPI.MessageSend(message, false) -} - -func ScanSetup() configType { - var conf configType - +func (conf *configType) Scan() { fmt.Print(" > DB Hostname (default 127.0.0.1:3306): ") fmt.Scanln(&conf.MySQL.Hostname) if len(conf.MySQL.Hostname) == 0 { @@ -186,12 +175,6 @@ func ScanSetup() configType { conf.MySQL.DbName = "semaphore" } - fmt.Print(" > Redis Connection (default 127.0.0.1:6379): ") - fmt.Scanln(&conf.SessionDb) - if len(conf.SessionDb) == 0 { - conf.SessionDb = "127.0.0.1:6379" - } - fmt.Print(" > Playbook path: ") fmt.Scanln(&conf.TmpPath) @@ -199,6 +182,4 @@ func ScanSetup() configType { conf.TmpPath = "/tmp/semaphore" } conf.TmpPath = path.Clean(conf.TmpPath) - - return conf }