diff --git a/app/main.go b/app/main.go index ea985bf9..0c8561b1 100644 --- a/app/main.go +++ b/app/main.go @@ -5,8 +5,10 @@ import ( "log" "os" + "github.com/gorilla/sessions" "github.com/hashicorp/logutils" "github.com/jessevdk/go-flags" + "github.com/umputun/remark/app/rest/auth" "github.com/umputun/remark/app/store" "github.com/umputun/remark/app/rest" @@ -14,7 +16,17 @@ import ( var opts struct { DBFile string `long:"db" env:"BOLTDB_FILE" default:"/tmp/remark.db" description:"bolt file name"` - Dbg bool `long:"dbg" env:"DEBUG" description:"debug mode"` + + SessionStore string `long:"session" env:"SESSION_STORE" default:"/tmp" description:"path to session store directory"` + StoreKey string `long:"store-key" env:"STORE_KEY" default:"secure-store-key" description:"store key"` + + GoogleCID string `long:"google-cid" env:"REMARK_GOOGLE_CID" description:"Google OAuth client ID"` + GoogleCSEC string `long:"google-csec" env:"REMARK_GOOGLE_CSEC" description:"Google OAuth client secret"` + GithubCID string `long:"github-cid" env:"REMARK_GITHUB_CID" description:"Github OAuth client ID"` + GithubCSEC string `long:"github-csec" env:"REMARK_GITHUB_CSEC" description:"Github OAuth client secret"` + + Admins []string `long:"admin" env:"ADMIN" default:"umputun@gmail.com" description:"admin(s) names" env-delim:","` + Dbg bool `long:"dbg" env:"DEBUG" description:"debug mode"` } var revision = "unknown" @@ -32,10 +44,24 @@ func main() { log.Fatalf("[ERROR] can't initialize data store, %+v", err) } + sessionStore := sessions.NewFilesystemStore(opts.SessionStore, []byte(opts.StoreKey)) srv := rest.Server{ Version: revision, Store: dataStore, + AuthGoogle: auth.NewGoogle(auth.Params{ + Cid: opts.GoogleCID, + Csecret: opts.GoogleCSEC, + SessionStore: sessionStore, + Admins: opts.Admins, + }), + AuthGithub: auth.NewGithub(auth.Params{ + Cid: opts.GithubCID, + Csecret: opts.GithubCSEC, + SessionStore: sessionStore, + Admins: opts.Admins, + }), } + srv.Run() } diff --git a/app/rest/auth/auth.go b/app/rest/auth/auth.go new file mode 100644 index 00000000..b50c409f --- /dev/null +++ b/app/rest/auth/auth.go @@ -0,0 +1,43 @@ +package auth + +import ( + "crypto/rand" + "crypto/sha1" + "encoding/gob" + "fmt" + "sync" + + "github.com/gorilla/sessions" + "github.com/umputun/remark/app/store" +) + +type Params struct { + Cid string + Csecret string + SessionStore *sessions.FilesystemStore + Admins []string +} + +type SessionStore struct { + StorePath string + StoreKey string + + store *sessions.FilesystemStore + once sync.Once +} + +func (s *SessionStore) GetSession(name string) { + +} + +func randToken() string { + b := make([]byte, 32) + rand.Read(b) + s := sha1.New() + s.Write(b) + return fmt.Sprintf("%x", s.Sum(nil)) +} + +func init() { + gob.Register(store.User{}) +} diff --git a/app/rest/auth/github.go b/app/rest/auth/github.go new file mode 100644 index 00000000..c1e92b06 --- /dev/null +++ b/app/rest/auth/github.go @@ -0,0 +1,121 @@ +package auth + +import ( + "context" + "encoding/json" + "fmt" + "io/ioutil" + "log" + "net/http" + + "github.com/go-chi/render" + "github.com/gorilla/sessions" + "github.com/umputun/remark/app/store" + "golang.org/x/oauth2" + "golang.org/x/oauth2/github" +) + +// Github provides oauth2 and session store +type Github struct { + *sessions.FilesystemStore + conf *oauth2.Config +} + +// NewGithub makes auth with github +func NewGithub(p Params) *Github { + log.Printf("[INFO] create gihub auth, id=%s", p.Cid) + + conf := oauth2.Config{ + ClientID: p.Cid, + ClientSecret: p.Csecret, + RedirectURL: "http://remark.umputun.com:8080/auth/github", + Scopes: []string{ + "user:email", + }, + Endpoint: github.Endpoint, + } + + return &Github{conf: &conf, FilesystemStore: p.SessionStore} +} + +// LoginHandler - GET /login/github +func (a Github) LoginHandler(w http.ResponseWriter, r *http.Request) { + + // make state (random) and store in session + state := randToken() + session, err := a.Get(r, "remark") + if err != nil { + log.Printf("[WARN] %s", err) + } + session.Values["state-github"] = state + session.Save(r, w) + + // return login url + log.Printf("[DEBUG] login url %s", a.conf.AuthCodeURL(state)) + http.Redirect(w, r, a.conf.AuthCodeURL(state), http.StatusTemporaryRedirect) +} + +// AuthHandler is redirect url. Should check state to prevent CSRF. +func (a Github) AuthHandler(w http.ResponseWriter, r *http.Request) { + session, err := a.Get(r, "remark") + if err != nil { + http.Error(w, fmt.Sprintf("failed to get session, %s", err.Error()), http.StatusInternalServerError) + return + } + + // compare saved state to the one from redirect url + retrievedState, ok := session.Values["state-github"] + if !ok || retrievedState != r.URL.Query().Get("state") { + http.Error(w, fmt.Sprintf("unexpected state %s", retrievedState.(string)), http.StatusUnauthorized) + return + } + + tok, err := a.conf.Exchange(context.Background(), r.URL.Query().Get("code")) + if err != nil { + http.Error(w, fmt.Sprintf("exchange failed, %s", err), http.StatusInternalServerError) + return + } + + client := a.conf.Client(context.Background(), tok) + uinfo, err := client.Get("https://api.github.com/user") + if err != nil { + http.Error(w, fmt.Sprintf("failed to get client info, %s", err), http.StatusBadRequest) + return + } + + defer uinfo.Body.Close() + data, err := ioutil.ReadAll(uinfo.Body) + if err != nil { + http.Error(w, fmt.Sprintf("failed to read user info, %s", err), http.StatusInternalServerError) + return + } + + jData := map[string]interface{}{} + if e := json.Unmarshal(data, &jData); e != nil { + http.Error(w, fmt.Sprintf("failed to unmarshal user info, %s", err), http.StatusInternalServerError) + return + } + log.Printf("[DEBUG] got raw user info %+v", jData) + + session.Values["uinfo"] = a.makeUserInfo(jData) + if err = session.Save(r, w); err != nil { + http.Error(w, fmt.Sprintf("failed to save user info, %s", err), http.StatusInternalServerError) + return + } + + log.Printf("[DEBUG] %+v", jData) + render.JSON(w, r, jData) +} + +func (a Github) makeUserInfo(jData map[string]interface{}) store.User { + userInfo := store.User{ + ID: jData["login"].(string), + Name: jData["name"].(string), + Picture: jData["avatar_url"].(string), + Profile: jData["html_url"].(string), + } + if userInfo.Name == "" { + userInfo.Name = userInfo.ID + } + return userInfo +} diff --git a/app/rest/auth/google.go b/app/rest/auth/google.go new file mode 100644 index 00000000..e098dfb7 --- /dev/null +++ b/app/rest/auth/google.go @@ -0,0 +1,123 @@ +package auth + +import ( + "context" + "encoding/json" + "fmt" + "io/ioutil" + "log" + "net/http" + "strings" + + "github.com/go-chi/render" + "github.com/gorilla/sessions" + "golang.org/x/oauth2" + "golang.org/x/oauth2/google" + + "github.com/umputun/remark/app/store" +) + +// Google provides oauth2 and session store +type Google struct { + *sessions.FilesystemStore + conf *oauth2.Config +} + +// NewGoogle makes auth with google +func NewGoogle(p Params) *Google { + log.Printf("[INFO] create google auth, id=%s", p.Cid) + + conf := oauth2.Config{ + ClientID: p.Cid, + ClientSecret: p.Csecret, + RedirectURL: "http://remark.umputun.com:8080/auth/google", + Scopes: []string{ + "https://www.googleapis.com/auth/userinfo.email", + }, + Endpoint: google.Endpoint, + } + + return &Google{conf: &conf, FilesystemStore: p.SessionStore} +} + +// LoginHandler - GET /login/google +func (a Google) LoginHandler(w http.ResponseWriter, r *http.Request) { + + // make state (random) and store in session + state := randToken() + session, err := a.Get(r, "remark") + if err != nil { + log.Printf("[WARN] %s", err) + } + session.Values["state-google"] = state + session.Save(r, w) + + // return login url + log.Printf("[DEBUG] login url %s", a.conf.AuthCodeURL(state)) + http.Redirect(w, r, a.conf.AuthCodeURL(state), http.StatusTemporaryRedirect) +} + +// AuthHandler is redirect url. Should check state to prevent CSRF. +func (a Google) AuthHandler(w http.ResponseWriter, r *http.Request) { + session, err := a.Get(r, "remark") + if err != nil { + http.Error(w, fmt.Sprintf("failed to get session, %s", err.Error()), http.StatusInternalServerError) + return + } + + // compare saved state to the one from redirect url + retrievedState, ok := session.Values["state-google"] + if !ok || retrievedState != r.URL.Query().Get("state") { + http.Error(w, fmt.Sprintf("unexpected state %s", retrievedState.(string)), http.StatusUnauthorized) + return + } + + tok, err := a.conf.Exchange(context.Background(), r.URL.Query().Get("code")) + if err != nil { + http.Error(w, fmt.Sprintf("exchange failed, %s", err), http.StatusInternalServerError) + return + } + + client := a.conf.Client(context.Background(), tok) + uinfo, err := client.Get("https://www.googleapis.com/oauth2/v3/userinfo") + if err != nil { + http.Error(w, fmt.Sprintf("failed to get client info, %s", err), http.StatusBadRequest) + return + } + + defer uinfo.Body.Close() + data, err := ioutil.ReadAll(uinfo.Body) + if err != nil { + http.Error(w, fmt.Sprintf("failed to read user info, %s", err), http.StatusInternalServerError) + return + } + + jData := map[string]interface{}{} + if e := json.Unmarshal(data, &jData); e != nil { + http.Error(w, fmt.Sprintf("failed to unmarshal user info, %s", err), http.StatusInternalServerError) + return + } + log.Printf("[DEBUG] got raw user info %+v", jData) + + session.Values["uinfo"] = a.makeUserInfo(jData) + if err = session.Save(r, w); err != nil { + http.Error(w, fmt.Sprintf("failed to save user info, %s", err), http.StatusInternalServerError) + return + } + + log.Printf("[DEBUG] %+v", jData) + render.JSON(w, r, jData) +} + +func (a Google) makeUserInfo(jData map[string]interface{}) store.User { + userInfo := store.User{ + Name: jData["name"].(string), + ID: jData["email"].(string), + Picture: jData["picture"].(string), + Profile: jData["profile"].(string), + } + if userInfo.Name == "" { + userInfo.Name = strings.Split(userInfo.ID, "@")[0] + } + return userInfo +} diff --git a/app/rest/server.go b/app/rest/server.go index 1adcb432..cc90920a 100644 --- a/app/rest/server.go +++ b/app/rest/server.go @@ -11,13 +11,16 @@ import ( "github.com/go-chi/chi" "github.com/go-chi/chi/middleware" "github.com/go-chi/render" + "github.com/umputun/remark/app/rest/auth" "github.com/umputun/remark/app/store" ) // Server is a rest access server type Server struct { - Version string - Store store.Interface + Version string + Store store.Interface + AuthGoogle *auth.Google + AuthGithub *auth.Github } // Run the lister and request's router, activate rest server @@ -28,11 +31,18 @@ func (s *Server) Run() { router.Use(middleware.Throttle(100), middleware.Timeout(60*time.Second)) router.Use(Limiter(10), AppInfo("remark", s.Version), Ping) + router.Get("/login/google", s.AuthGoogle.LoginHandler) + router.Get("/auth/google", s.AuthGoogle.AuthHandler) + + router.Get("/login/github", s.AuthGithub.LoginHandler) + router.Get("/auth/github", s.AuthGithub.AuthHandler) + router.Post("/comment", s.createCommentCtrl) router.Delete("/comment/{id}", s.deleteCommentCtrl) router.Get("/find", s.getURLComments) router.Get("/last/{max}", s.getLastComments) router.Get("/id/{id}", s.getByID) + log.Fatal(http.ListenAndServe(":8080", router)) } @@ -101,7 +111,7 @@ func (s *Server) getURLComments(w http.ResponseWriter, r *http.Request) { render.JSON(w, r, comments) } -// GET /last/{max} +// GET /last/{max}?url=abc func (s *Server) getLastComments(w http.ResponseWriter, r *http.Request) { max, err := strconv.Atoi(chi.URLParam(r, "max")) @@ -109,8 +119,18 @@ func (s *Server) getLastComments(w http.ResponseWriter, r *http.Request) { max = 0 } - url := r.URL.Query().Get("url") - log.Printf("[INFO] get comments for %s", url) + session, err := s.AuthGoogle.Get(r, "remark") + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + uinfoData, ok := session.Values["uinfo"] + if !ok { + http.Error(w, "login required", http.StatusUnauthorized) + return + } + log.Printf("[DEBUG] user: %+v", uinfoData.(store.User)) comments, err := s.Store.Last(store.Locator{}, max) if err != nil { diff --git a/app/store/bolt.go b/app/store/bolt.go index 1d07c220..302c7fda 100644 --- a/app/store/bolt.go +++ b/app/store/bolt.go @@ -26,9 +26,9 @@ var lastBucketName = "last" func NewBoltDB(dbFile string) (*BoltDB, error) { log.Printf("[INFO] bolt store, %s", dbFile) result := BoltDB{} - db, err := bolt.Open(dbFile, 0600, &bolt.Options{Timeout: 1 * time.Second}) + db, err := bolt.Open(dbFile, 0600, &bolt.Options{Timeout: 5 * time.Second}) if err != nil { - return nil, err + return nil, errors.Wrapf(err, "failed to make boltdb for %s", dbFile) } result.DB = db return &result, err diff --git a/app/store/store.go b/app/store/store.go index 4f21fa56..31c29ffb 100644 --- a/app/store/store.go +++ b/app/store/store.go @@ -4,13 +4,10 @@ import "time" // Comment represents a single comment with reference to its parent type Comment struct { - ID int64 `json:"id"` - ParentID int64 `json:"pid"` - - Text string `json:"text"` - - User User `json:"user"` - + ID int64 `json:"id"` + ParentID int64 `json:"pid"` + Text string `json:"text"` + User User `json:"user"` Locator Locator `json:"locator"` Score int `json:"score"` Timestamp time.Time `json:"time"` @@ -24,9 +21,11 @@ type Locator struct { // User holds user-related info type User struct { - Name string `json:"name"` - ID string `json:"id"` - IP string `json:"-"` + Name string `json:"name"` + ID string `json:"id"` + Picture string `json:"picture"` + Profile string `json:"profile"` + IP string `json:"-"` } // Request is a container for all finds diff --git a/docker-compose.yml b/docker-compose.yml index 07935045..9ff14f76 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,6 +3,7 @@ version: '2' services: remark: + build: . image: umputun/remark:develop container_name: "remark" hostname: "remark"