diff --git a/app/main.go b/app/main.go index e9008aeb..db1c69ee 100644 --- a/app/main.go +++ b/app/main.go @@ -27,8 +27,9 @@ var opts struct { RemarkURL string `long:"url" env:"REMARK_URL" default:"https://remark42.com" description:"url to remark"` Admins []string `long:"admin" env:"ADMIN" description:"admin(s) names" env-delim:","` - DevMode bool `long:"dev" env:"DEV" description:"development mode, no auth enforced"` - Dbg bool `long:"dbg" env:"DEBUG" description:"debug mode"` + DevMode bool `long:"dev" env:"DEV" description:"development mode, no auth enforced"` + DevPasswd string `long:"dev-passwd" env:"DEV_PASSWD" default:"password" description:"development mode password"` + Dbg bool `long:"dbg" env:"DEBUG" description:"debug mode"` BackupLocation string `long:"backup" env:"BACKUP_PATH" default:"./var" description:"backups location"` MaxBackupFiles int `long:"max-back" env:"MAX_BACKUP_FILES" default:"10" description:"max backups to keep"` @@ -108,13 +109,14 @@ func main() { srv := server.Rest{ Version: revision, DataService: dataService, - DevMode: opts.DevMode, Exporter: &exporter, Authenticator: auth.Authenticator{ Admins: opts.Admins, SessionStore: sessionStore, Providers: makeAuthProviders(sessionStore, avatarProxy), AvatarProxy: avatarProxy, + DevEnabled: opts.DevMode, + DevPasswd: opts.DevPasswd, }, Cache: rest.NewLoadingCache(4*time.Hour, 15*time.Minute, postFlushFn), Notifier: notifier.NewNoOperation(), diff --git a/app/rest/auth/auth.go b/app/rest/auth/auth.go index 52f00f03..06543bd7 100644 --- a/app/rest/auth/auth.go +++ b/app/rest/auth/auth.go @@ -2,7 +2,9 @@ package auth import ( "context" + "encoding/base64" "net/http" + "strings" "github.com/gorilla/sessions" @@ -16,18 +18,11 @@ type Authenticator struct { AvatarProxy *AvatarProxy Admins []string Providers []Provider + + DevEnabled bool + DevPasswd string } -// Mode defines behavior of Auth middleware -type Mode int - -// auth modes -const ( - Anonymous Mode = iota // propagates user info only, doesn't protect resource - Developer // fake dev auth, admin too - Full // real auth -) - var devUser = store.User{ ID: "dev", Name: "developer one", @@ -37,22 +32,13 @@ var devUser = store.User{ } // Auth middleware adds auth from session and populates user info -func (a *Authenticator) Auth(modes []Mode) func(http.Handler) http.Handler { - - inModes := func(mode Mode) bool { - for _, m := range modes { - if m == mode { - return true - } - } - return false - } +func (a *Authenticator) Auth(reqAuth bool) func(http.Handler) http.Handler { f := func(h http.Handler) http.Handler { fn := func(w http.ResponseWriter, r *http.Request) { - // for dev mode skip all real auth, make dev admin user - if inModes(Developer) { + // dev user - skip regular auth check and populate dev to context + if a.basicDevUser(w, r) { user := devUser ctx := r.Context() ctx = context.WithValue(ctx, rest.ContextKey("user"), user) @@ -62,18 +48,18 @@ func (a *Authenticator) Auth(modes []Mode) func(http.Handler) http.Handler { } session, err := a.SessionStore.Get(r, "remark") - if err != nil && inModes(Full) { // in full auth lack of session causes Unauthorized - http.Error(w, err.Error(), http.StatusUnauthorized) + if err != nil && reqAuth { // in full auth lack of session causes Unauthorized + http.Error(w, "Unauthorized", http.StatusUnauthorized) return } - if err != nil { // in any other mode just pass it to next handler + if err != nil { // in anonymous mode just pass it to next handler h.ServeHTTP(w, r) return } uinfoData, ok := session.Values["uinfo"] - if !ok && inModes(Full) { // return StatusUnauthorized for full auth mode only + if !ok && reqAuth { http.Error(w, "Unauthorized", http.StatusUnauthorized) return } @@ -117,3 +103,33 @@ func (a *Authenticator) AdminOnly(next http.Handler) http.Handler { } return http.HandlerFunc(fn) } + +func (a *Authenticator) basicDevUser(w http.ResponseWriter, r *http.Request) bool { + + if a.DevPasswd == "" || !a.DevEnabled { + return false + } + + w.Header().Set("WWW-Authenticate", `Basic realm="Restricted"`) + + s := strings.SplitN(r.Header.Get("Authorization"), " ", 2) + if len(s) != 2 { + return false + } + + b, err := base64.StdEncoding.DecodeString(s[1]) + if err != nil { + return false + } + + pair := strings.SplitN(string(b), ":", 2) + if len(pair) != 2 { + return false + } + + if pair[0] != "dev" || pair[1] != a.DevPasswd { + return false + } + + return true +} diff --git a/app/rest/server/admin.go b/app/rest/server/admin.go index b2bcf504..02e69d09 100644 --- a/app/rest/server/admin.go +++ b/app/rest/server/admin.go @@ -130,10 +130,8 @@ func (a *admin) checkBlocked(siteID string, user store.User) bool { func (a *admin) maskInfo(comments []store.Comment, r *http.Request) (res []store.Comment) { res = make([]store.Comment, len(comments)) - isAdmin := false - if user, err := rest.GetUserInfo(r); err == nil && user.Admin { // make seprate cache key for admins - isAdmin = true - } + user, err := rest.GetUserInfo(r) + isAdmin := (err == nil && user.Admin) // make seprate cache key for admins for i, c := range comments { diff --git a/app/rest/server/admin_test.go b/app/rest/server/admin_test.go index 73106d11..69d4113b 100644 --- a/app/rest/server/admin_test.go +++ b/app/rest/server/admin_test.go @@ -29,7 +29,8 @@ func TestAdmin_Delete(t *testing.T) { client := http.Client{} req, err := http.NewRequest(http.MethodDelete, - fmt.Sprintf("http://127.0.0.1:%d/api/v1/admin/comment/%s?site=radio-t&url=https://radio-t.com/blah", port, id1), nil) + fmt.Sprintf("http://dev:password@127.0.0.1:%d/api/v1/admin/comment/%s?site=radio-t&url=https://radio-t.com/blah", + port, id1), nil) assert.Nil(t, err) resp, err := client.Do(req) assert.Nil(t, err) @@ -60,8 +61,7 @@ func TestAdmin_Pin(t *testing.T) { pin := func(val int) int { client := http.Client{} req, err := http.NewRequest(http.MethodPut, - fmt.Sprintf("http://127.0.0.1:%d/api/v1/admin/pin/%s?site=radio-t&url=https://radio-t.com/blah&pin=%d", port, id1, val), - nil) + fmt.Sprintf("http://dev:password@127.0.0.1:%d/api/v1/admin/pin/%s?site=radio-t&url=https://radio-t.com/blah&pin=%d", port, id1, val), nil) assert.Nil(t, err) resp, err := client.Do(req) assert.Nil(t, err) @@ -106,8 +106,7 @@ func TestAdmin_Block(t *testing.T) { block := func(val int) (code int, body []byte) { client := http.Client{} req, err := http.NewRequest(http.MethodPut, - fmt.Sprintf("http://127.0.0.1:%d/api/v1/admin/user/%s?site=radio-t&block=%d", - port, "user1", val), nil) + fmt.Sprintf("http://dev:password@127.0.0.1:%d/api/v1/admin/user/%s?site=radio-t&block=%d", port, "user1", val), nil) assert.Nil(t, err) resp, err := client.Do(req) require.Nil(t, err) @@ -154,7 +153,7 @@ func TestAdmin_Export(t *testing.T) { addComment(t, c1, port) addComment(t, c2, port) - body, code := get(t, fmt.Sprintf("http://127.0.0.1:%d/api/v1/admin/export?site=radio-t&mode=stream", port)) + body, code := get(t, fmt.Sprintf("http://dev:password@127.0.0.1:%d/api/v1/admin/export?site=radio-t&mode=stream", port)) assert.Equal(t, 200, code) assert.Equal(t, 2, strings.Count(body, "\n")) assert.Equal(t, 2, strings.Count(body, "\"text\"")) diff --git a/app/rest/server/rest.go b/app/rest/server/rest.go index 249e7646..576dc9ed 100644 --- a/app/rest/server/rest.go +++ b/app/rest/server/rest.go @@ -31,7 +31,6 @@ import ( // Rest is a rest access server type Rest struct { Version string - DevMode bool DataService store.Service Authenticator auth.Authenticator @@ -47,15 +46,6 @@ type Rest struct { func (s *Rest) Run(port int) { log.Print("[INFO] activate rest server") - // add auth.Developer flag if dev mode is active - maybeWithDevMode := func(mode auth.Mode) (modes []auth.Mode) { - modes = append(modes, mode) - if s.DevMode { - modes = append(modes, auth.Developer) - } - return modes - } - if len(s.Authenticator.Admins) > 0 { log.Printf("[DEBUG] admins %+v", s.Authenticator.Admins) } @@ -66,7 +56,7 @@ func (s *Rest) Run(port int) { router.Use(tollbooth_chi.LimitHandler(tollbooth.NewLimiter(10, nil))) // all request by default allow anonymous access - router.Use(s.Authenticator.Auth(maybeWithDevMode(auth.Anonymous))) + router.Use(s.Authenticator.Auth(false)) router.Use(AppInfo("remark42", s.Version), Ping, Logger(LogAll)) router.Use(context.ClearHandler) // if you aren't using gorilla/mux, you need to wrap your handlers with context.ClearHandler @@ -97,7 +87,7 @@ func (s *Rest) Run(port int) { rapi.Get("/config", s.configCtrl) // protected routes, require auth - rapi.With(s.Authenticator.Auth(maybeWithDevMode(auth.Full))).Group(func(rauth chi.Router) { + rapi.With(s.Authenticator.Auth(true)).Group(func(rauth chi.Router) { rauth.Post("/comment", s.createCommentCtrl) rauth.Put("/comment/{id}", s.updateCommentCtrl) rauth.Get("/user", s.userInfoCtrl) diff --git a/app/rest/server/rest_test.go b/app/rest/server/rest_test.go index 826cd62a..1b8a9be5 100644 --- a/app/rest/server/rest_test.go +++ b/app/rest/server/rest_test.go @@ -13,6 +13,7 @@ import ( "testing" "time" + "github.com/gorilla/sessions" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -40,7 +41,7 @@ func TestServer_Create(t *testing.T) { defer cleanup(srv) r := strings.NewReader(`{"text": "test 123", "locator":{"url": "https://radio-t.com/blah1", "site": "radio-t"}}`) - resp, err := http.Post(fmt.Sprintf("http://127.0.0.1:%d/api/v1/comment", port), "application/json", r) + resp, err := http.Post(fmt.Sprintf("http://dev:password@127.0.0.1:%d/api/v1/comment", port), "application/json", r) assert.Nil(t, err) assert.Equal(t, http.StatusCreated, resp.StatusCode) @@ -62,7 +63,7 @@ func TestServer_CreateAndGet(t *testing.T) { // create comment r := strings.NewReader(`{"text": "**test** *123* http://radio-t.com", "locator":{"url": "https://radio-t.com/blah1", "site": "radio-t"}}`) - resp, err := http.Post(fmt.Sprintf("http://127.0.0.1:%d/api/v1/comment", port), "application/json", r) + resp, err := http.Post(fmt.Sprintf("http://dev:password@127.0.0.1:%d/api/v1/comment", port), "application/json", r) assert.Nil(t, err) assert.Equal(t, http.StatusCreated, resp.StatusCode) b, err := ioutil.ReadAll(resp.Body) @@ -74,7 +75,7 @@ func TestServer_CreateAndGet(t *testing.T) { id := c["id"].(string) // get created comment by id - res, code := get(t, fmt.Sprintf("http://127.0.0.1:%d/api/v1/id/%s?site=radio-t&url=https://radio-t.com/blah1", port, id)) + res, code := get(t, fmt.Sprintf("http://dev:password@127.0.0.1:%d/api/v1/id/%s?site=radio-t&url=https://radio-t.com/blah1", port, id)) assert.Equal(t, 200, code) comment := store.Comment{} err = json.Unmarshal([]byte(res), &comment) @@ -135,7 +136,7 @@ func TestServer_Update(t *testing.T) { client := http.Client{} req, err := http.NewRequest(http.MethodPut, - fmt.Sprintf("http://127.0.0.1:%d/api/v1/comment/"+id+"?site=radio-t&url=https://radio-t.com/blah1", port), + fmt.Sprintf("http://dev:password@127.0.0.1:%d/api/v1/comment/"+id+"?site=radio-t&url=https://radio-t.com/blah1", port), strings.NewReader(`{"text":"updated text", "summary":"my edit"}`)) assert.Nil(t, err) b, err := client.Do(req) @@ -154,7 +155,7 @@ func TestServer_Update(t *testing.T) { assert.True(t, time.Since(c2.Edit.Timestamp) < 1*time.Second) // read updated comment - res, code := get(t, fmt.Sprintf("http://127.0.0.1:%d/api/v1/id/%s?site=radio-t&url=https://radio-t.com/blah1", port, id)) + res, code := get(t, fmt.Sprintf("http://dev:password@127.0.0.1:%d/api/v1/id/%s?site=radio-t&url=https://radio-t.com/blah1", port, id)) assert.Equal(t, 200, code) c3 := store.Comment{} err = json.Unmarshal([]byte(res), &c3) @@ -230,7 +231,7 @@ func TestServer_UserInfo(t *testing.T) { assert.NotNil(t, srv) defer cleanup(srv) - body, code := get(t, fmt.Sprintf("http://127.0.0.1:%d/api/v1/user?site=radio-t", port)) + body, code := get(t, fmt.Sprintf("http://dev:password@127.0.0.1:%d/api/v1/user?site=radio-t", port)) assert.Equal(t, 200, code) user := store.User{} err := json.Unmarshal([]byte(body), &user) @@ -256,8 +257,8 @@ func TestServer_Vote(t *testing.T) { vote := func(val int) int { client := http.Client{} req, err := http.NewRequest(http.MethodPut, - fmt.Sprintf("http://127.0.0.1:%d/api/v1/vote/%s?site=radio-t&url=https://radio-t.com/blah&vote=%d", port, id1, val), - nil) + fmt.Sprintf("http://dev:password@127.0.0.1:%d/api/v1/vote/%s?site=radio-t&url=https://radio-t.com/blah&vote=%d", + port, id1, val), nil) assert.Nil(t, err) resp, err := client.Do(req) assert.Nil(t, err) @@ -344,10 +345,12 @@ func prep(t *testing.T) (srv *Rest, port int) { require.Nil(t, err) srv = &Rest{ DataService: store.Service{Interface: dataStore, EditDuration: 5 * time.Minute}, - DevMode: true, Authenticator: auth.Authenticator{ - Providers: nil, - AvatarProxy: &auth.AvatarProxy{StorePath: "/tmp", RoutePath: "/api/v1/avatar"}, + SessionStore: sessions.NewFilesystemStore("/tmp", []byte("blah")), + DevEnabled: true, + DevPasswd: "password", + Providers: nil, + AvatarProxy: &auth.AvatarProxy{StorePath: "/tmp", RoutePath: "/api/v1/avatar"}, }, Exporter: &migrator.Remark{DataStore: dataStore}, Cache: &mockCache{}, @@ -374,7 +377,7 @@ func addComment(t *testing.T, c store.Comment, port int) string { b, err := json.Marshal(c) assert.Nil(t, err, "can't marshal comment %+v", c) - resp, err := http.Post(fmt.Sprintf("http://127.0.0.1:%d/api/v1/comment", port), "application/json", bytes.NewBuffer(b)) + resp, err := http.Post(fmt.Sprintf("http://dev:password@127.0.0.1:%d/api/v1/comment", port), "application/json", bytes.NewBuffer(b)) assert.Nil(t, err) assert.Equal(t, http.StatusCreated, resp.StatusCode) b, err = ioutil.ReadAll(resp.Body)