diff --git a/README.md b/README.md index e275701f..191c18f6 100644 --- a/README.md +++ b/README.md @@ -133,8 +133,8 @@ _this is the recommended way to run remark42_ | edit-time | EDIT_TIME | `5m` | edit window | | read-age | READONLY_AGE | | read-only age of comments, days | | img-proxy | IMG_PROXY | `false` | enable http->https proxy for images | +| admin-passwd | ADMIN_PASSWD | | password for `admin` basic auth | | dbg | DEBUG | `false` | debug mode | -| dev-passwd | DEV_PASSWD | | password for `dev` user | * command line parameters are long form `--=value`, i.e. `--site=https://demo.remark42.com` * _multi_ parameters separated by `,` in the environment or repeated with command line key, like `--site=s1 --site=s2 ...` @@ -609,16 +609,16 @@ _all admin calls require auth and admin privilege_ * Data stored in [boltdb](https://github.com/coreos/bbolt) (embedded key/value database) files under `STORE_BOLT_PATH` * Each site stored in a separate boltbd file. -* In order to migrate/move remark42 to another host boltbd files as well as avatars directory `AVATAR_FS_PATH` should be transferred. +* In order to migrate/move remark42 to another host boltbd files as well as avatars directory `AVATAR_FS_PATH` should be transferred. Optionally, boltdb can be used to store avatars as well. * Automatic backup process runs every 24h and exports all content in json-like format to `backup-remark-YYYYMMDD.gz`. -* Authentication implemented with [jwt](https://github.com/dgrijalva/jwt-go) stored in a cookie. It uses HttpOnly, secure cookies. -* All heavy REST calls cached internally in LRU cache limited by `CACHE_MAX_ITEMS` and `CACHE_MAX_SIZE`. +* Authentication implemented with [go-pkgz/auth](https://github.com/go-pkgz/auth) stored in a cookie. It uses HttpOnly, secure cookies. +* All heavy REST calls cached internally in LRU cache limited by `CACHE_MAX_ITEMS` and `CACHE_MAX_SIZE` with [go-pkgz/rest](https://github.com/go-pkgz/rest) * User's activity throttled globally (up to 1000 simultaneous requests) and limited locally (per user, usually up to 10 req/sec) * Request timeout set to 60sec -* Development mode (`--dev-password` set) allows to test remark42 without social login and with admin privileges. Adds basic-auth for username: `dev`, password: `${DEV_PASSWD}`. **should not be used in production deployment** +* Admin authentication (`--admin-password` set) allows to hit remark42 API without social login and with admin privileges. Adds basic-auth for username: `admin`, password: `${ADMIN_PASSWD}`. * User can vote for the comment multiple times but only to change the vote. Double-voting not allowed. * User can edit comments in 5 mins (configurable) window after creation. * User ID hashed and prefixed by oauth provider name to avoid collisions and potential abuse. -* All avatars resized and cached locally to prevent rate limiters from oauth providers. +* All avatars resized and cached locally to prevent rate limiters from oauth providers, part of [go-pkgz/auth](https://github.com/go-pkgz/auth) functionality. * Images can be proxied (`IMG_PROXY=true`) to prevent mixed http/https. * Docker build uses [publicly available](https://github.com/umputun/baseimage) base images. diff --git a/backend/Gopkg.lock b/backend/Gopkg.lock index add74efb..a58fd36d 100644 --- a/backend/Gopkg.lock +++ b/backend/Gopkg.lock @@ -111,6 +111,21 @@ revision = "9f855fadd4b8cde7773f9ef51f6b2705af239519" version = "v1.0.0" +[[projects]] + digest = "1:a4ff2b649472abf046975396ac916b04527fde8d897857c2feea76498aeb762f" + name = "github.com/go-pkgz/auth" + packages = [ + ".", + "avatar", + "logger", + "middleware", + "provider", + "token", + ] + pruneopts = "UT" + revision = "855a238343c3bcea84b352fdeb4393576f9eb217" + version = "v0.2.0" + [[projects]] digest = "1:1212e114344a5cdcc834ea69e19d456eef230f9784659080fee67e02ba2cb574" name = "github.com/go-pkgz/mongo" @@ -131,7 +146,7 @@ version = "v1.0.0" [[projects]] - digest = "1:71dc1e5b19e179495d2e2ca63454a9204753c5ecb3faa4a842ea5859355a968f" + digest = "1:e133aa7be09588b02198e4ddb98df5033b0319b56533881d0163ee51b903305b" name = "github.com/go-pkgz/rest" packages = [ ".", @@ -139,8 +154,8 @@ "logger", ] pruneopts = "UT" - revision = "c0e09a7a640e54001aed8bad117d60ad8971958e" - version = "v1.1.1" + revision = "553c0e1b55b215f8f55da4682ac57aff9aec8b6d" + version = "v1.1.5" [[projects]] digest = "1:ffc060c551980d37ee9e428ef528ee2813137249ccebb0bfc412ef83071cac91" @@ -386,6 +401,11 @@ "github.com/go-chi/chi/middleware", "github.com/go-chi/cors", "github.com/go-chi/render", + "github.com/go-pkgz/auth", + "github.com/go-pkgz/auth/avatar", + "github.com/go-pkgz/auth/logger", + "github.com/go-pkgz/auth/provider", + "github.com/go-pkgz/auth/token", "github.com/go-pkgz/mongo", "github.com/go-pkgz/repeater", "github.com/go-pkgz/rest", @@ -397,19 +417,12 @@ "github.com/hashicorp/logutils", "github.com/jessevdk/go-flags", "github.com/microcosm-cc/bluemonday", - "github.com/nullrocks/identicon", "github.com/patrickmn/go-cache", "github.com/pkg/errors", "github.com/rakyll/statik/fs", "github.com/stretchr/testify/assert", "github.com/stretchr/testify/require", "golang.org/x/crypto/acme/autocert", - "golang.org/x/image/draw", - "golang.org/x/oauth2", - "golang.org/x/oauth2/facebook", - "golang.org/x/oauth2/github", - "golang.org/x/oauth2/google", - "golang.org/x/oauth2/yandex", "gopkg.in/russross/blackfriday.v2", ] solver-name = "gps-cdcl" diff --git a/backend/app/cmd/avatar.go b/backend/app/cmd/avatar.go index 52174fe5..82093821 100644 --- a/backend/app/cmd/avatar.go +++ b/backend/app/cmd/avatar.go @@ -5,11 +5,11 @@ import ( "path" "time" - "github.com/coreos/bbolt" - "github.com/go-pkgz/mongo" + bolt "github.com/coreos/bbolt" "github.com/pkg/errors" - "github.com/umputun/remark/backend/app/store/avatar" + "github.com/go-pkgz/auth/avatar" + "github.com/go-pkgz/mongo" ) // AvatarCommand set of flags and command for avatar migration @@ -76,19 +76,19 @@ func (ac *AvatarCommand) makeAvatarStore(gr AvatarGroup) (avatar.Store, error) { if err := makeDirs(gr.FS.Path); err != nil { return nil, err } - return avatar.NewLocalFS(gr.FS.Path, gr.RszLmt), nil + return avatar.NewLocalFS(gr.FS.Path), nil case "mongo": mgServer, err := ac.makeMongo() if err != nil { return nil, errors.Wrap(err, "failed to create mongo server") } conn := mongo.NewConnection(mgServer, ac.Mongo.DB, "") - return avatar.NewGridFS(conn, gr.RszLmt), nil + return avatar.NewGridFS(conn), nil case "bolt": if err := makeDirs(path.Dir(gr.Bolt.File)); err != nil { return nil, err } - return avatar.NewBoltDB(gr.Bolt.File, bolt.Options{}, gr.RszLmt) + return avatar.NewBoltDB(gr.Bolt.File, bolt.Options{}) } return nil, errors.Errorf("unsupported avatar store type %s", gr.Type) } diff --git a/backend/app/cmd/avatar_test.go b/backend/app/cmd/avatar_test.go index c7c62c91..e0df7193 100644 --- a/backend/app/cmd/avatar_test.go +++ b/backend/app/cmd/avatar_test.go @@ -5,10 +5,10 @@ import ( "os" "testing" + "github.com/go-pkgz/auth/avatar" flags "github.com/jessevdk/go-flags" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/umputun/remark/backend/app/store/avatar" ) func TestAvatar_Execute(t *testing.T) { diff --git a/backend/app/cmd/backup.go b/backend/app/cmd/backup.go index 584dab33..c0cae341 100644 --- a/backend/app/cmd/backup.go +++ b/backend/app/cmd/backup.go @@ -15,17 +15,18 @@ import ( // BackupCommand set of flags and command for export // ExportPath used as a separate element to leverage BACKUP_PATH. If ExportFile has a path (i.e. with /) BACKUP_PATH ignored. type BackupCommand struct { - ExportPath string `short:"p" long:"path" env:"BACKUP_PATH" default:"./var/backup" description:"export path"` - ExportFile string `short:"f" long:"file" default:"userbackup-{{.SITE}}-{{.TS}}.gz" description:"file name"` - Site string `short:"s" long:"site" env:"SITE" default:"remark" description:"site name"` - Timeout time.Duration `long:"timeout" default:"15m" description:"export (backup) timeout"` + ExportPath string `short:"p" long:"path" env:"BACKUP_PATH" default:"./var/backup" description:"export path"` + ExportFile string `short:"f" long:"file" default:"userbackup-{{.SITE}}-{{.TS}}.gz" description:"file name"` + Site string `short:"s" long:"site" env:"SITE" default:"remark" description:"site name"` + Timeout time.Duration `long:"timeout" default:"15m" description:"export (backup) timeout"` + AdminPasswd string `long:"admin-passwd" env:"ADMIN_PASSWD" required:"true" description:"admin basic auth password"` CommonOpts } // Execute runs export with ExportCommand parameters, entry point for "export" command func (ec *BackupCommand) Execute(args []string) error { log.Printf("[INFO] export to %s, site %s", ec.ExportPath, ec.Site) - resetEnv("SECRET") + resetEnv("SECRET", "ADMIN_PASSWD") fp := fileParser{site: ec.Site, path: ec.ExportPath, file: ec.ExportFile} fname, err := fp.parse(time.Now()) @@ -39,11 +40,12 @@ func (ec *BackupCommand) Execute(args []string) error { client := http.Client{} ctx, cancel := context.WithTimeout(context.Background(), ec.Timeout) defer cancel() - exportURL := fmt.Sprintf("%s/api/v1/admin/export?mode=file&site=%s&secret=%s", ec.RemarkURL, ec.Site, ec.SharedSecret) + exportURL := fmt.Sprintf("%s/api/v1/admin/export?mode=file&site=%s", ec.RemarkURL, ec.Site) req, err := http.NewRequest(http.MethodGet, exportURL, nil) if err != nil { return errors.Wrapf(err, "can't make export request for %s", exportURL) } + req.SetBasicAuth("admin", ec.AdminPasswd) // get with timeout resp, err := client.Do(req.WithContext(ctx)) diff --git a/backend/app/cmd/backup_test.go b/backend/app/cmd/backup_test.go index e163d0f4..612db960 100644 --- a/backend/app/cmd/backup_test.go +++ b/backend/app/cmd/backup_test.go @@ -8,7 +8,7 @@ import ( "os" "testing" - "github.com/jessevdk/go-flags" + flags "github.com/jessevdk/go-flags" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -24,7 +24,7 @@ func TestBackup_Execute(t *testing.T) { cmd := BackupCommand{} cmd.SetCommon(CommonOpts{RemarkURL: ts.URL, SharedSecret: "123456"}) p := flags.NewParser(&cmd, flags.Default) - _, err := p.ParseArgs([]string{"--site=remark", "--path=/tmp", "--file={{.SITE}}-test.export"}) + _, err := p.ParseArgs([]string{"--site=remark", "--path=/tmp", "--file={{.SITE}}-test.export", "--admin-passwd=secret"}) require.Nil(t, err) err = cmd.Execute(nil) assert.NoError(t, err) @@ -48,7 +48,7 @@ func TestBackup_ExecuteFailedStatus(t *testing.T) { cmd.SetCommon(CommonOpts{RemarkURL: ts.URL, SharedSecret: "123456"}) p := flags.NewParser(&cmd, flags.Default) - _, err := p.ParseArgs([]string{"--site=remark", "--path=/tmp", "--file={{.SITE}}-test.export"}) + _, err := p.ParseArgs([]string{"--site=remark", "--path=/tmp", "--file={{.SITE}}-test.export", "--admin-passwd=secret"}) require.Nil(t, err) err = cmd.Execute(nil) assert.EqualError(t, err, `error response "400 Bad Request", some error`) @@ -66,7 +66,8 @@ func TestBackup_ExecuteFailedWrite(t *testing.T) { cmd.SetCommon(CommonOpts{RemarkURL: ts.URL, SharedSecret: "123456"}) p := flags.NewParser(&cmd, flags.Default) - _, err := p.ParseArgs([]string{"--site=remark", "--path=/tmp", "--file=/tmp/no-such-dir/{{.SITE}}-test.export"}) + _, err := p.ParseArgs([]string{"--site=remark", "--path=/tmp", + "--file=/tmp/no-such-dir/{{.SITE}}-test.export", "--admin-passwd=secret"}) require.Nil(t, err) err = cmd.Execute(nil) assert.EqualError(t, err, `can't create backup file /tmp/no-such-dir/remark-test.export: open /tmp/no-such-dir/remark-test.export: no such file or directory`) diff --git a/backend/app/cmd/cleanup.go b/backend/app/cmd/cleanup.go index f1211bad..cb06dad9 100644 --- a/backend/app/cmd/cleanup.go +++ b/backend/app/cmd/cleanup.go @@ -15,12 +15,13 @@ import ( // CleanupCommand set of flags and command for cleanup type CleanupCommand struct { - Site string `short:"s" long:"site" env:"SITE" default:"remark" description:"site name"` - Dry bool `long:"dry" description:"dry mode, will not remove comments"` - From string `long:"from" description:"from yyyymmdd"` - To string `long:"to" description:"from yyyymmdd"` - BadWords []string `short:"w" long:"bword" description:"bad word(s)"` - BadUsers []string `short:"u" long:"buser" description:"bad user(s)"` + Site string `short:"s" long:"site" env:"SITE" default:"remark" description:"site name"` + Dry bool `long:"dry" description:"dry mode, will not remove comments"` + From string `long:"from" description:"from yyyymmdd"` + To string `long:"to" description:"from yyyymmdd"` + BadWords []string `short:"w" long:"bword" description:"bad word(s)"` + BadUsers []string `short:"u" long:"buser" description:"bad user(s)"` + AdminPasswd string `long:"admin-passwd" env:"ADMIN_PASSWD" required:"true" description:"admin basic auth password"` CommonOpts } @@ -160,12 +161,13 @@ func (cc *CleanupCommand) listComments(postURL string) ([]store.Comment, error) // deleteComment with DELETE /admin/comment/{id}?site=siteID&url=post-url func (cc *CleanupCommand) deleteComment(c store.Comment) error { - deleteURL := fmt.Sprintf("%s/api/v1/admin/comment/%s?site=%s&url=%s&format=plain&secret=%s", - cc.RemarkURL, c.ID, cc.Site, c.Locator.URL, cc.SharedSecret) + deleteURL := fmt.Sprintf("%s/api/v1/admin/comment/%s?site=%s&url=%s&format=plain", cc.RemarkURL, c.ID, cc.Site, c.Locator.URL) req, err := http.NewRequest("DELETE", deleteURL, nil) if err != nil { return errors.Wrapf(err, "failed to make delete request for comment %s, %s", c.ID, c.Locator.URL) } + req.SetBasicAuth("admin", cc.AdminPasswd) + client := http.Client{} r, err := client.Do(req) if err != nil { diff --git a/backend/app/cmd/cleanup_test.go b/backend/app/cmd/cleanup_test.go index 9cc8154c..1603ab40 100644 --- a/backend/app/cmd/cleanup_test.go +++ b/backend/app/cmd/cleanup_test.go @@ -67,7 +67,7 @@ func TestCleanup_postsInRange(t *testing.T) { cmd := CleanupCommand{} cmd.SetCommon(CommonOpts{RemarkURL: ts.URL, SharedSecret: "123456"}) p := flags.NewParser(&cmd, flags.Default) - _, err := p.ParseArgs([]string{"--site=remark", "--bword=bad1", "--bword=bad2", "--buser=bu_"}) + _, err := p.ParseArgs([]string{"--site=remark", "--bword=bad1", "--bword=bad2", "--buser=bu_", "--admin-passwd=secret"}) require.Nil(t, err) posts, err := cmd.postsInRange("20181218", "20181219") assert.NoError(t, err) @@ -90,7 +90,7 @@ func TestCleanup_listComments(t *testing.T) { cmd := CleanupCommand{} cmd.SetCommon(CommonOpts{RemarkURL: ts.URL, SharedSecret: "123456"}) p := flags.NewParser(&cmd, flags.Default) - _, err := p.ParseArgs([]string{"--site=remark", "--bword=bad1", "--bword=bad2", "--buser=bu_"}) + _, err := p.ParseArgs([]string{"--site=remark", "--bword=bad1", "--bword=bad2", "--buser=bu_", "--admin-passwd=secret"}) require.Nil(t, err) comments, err := cmd.listComments("http://test.com/post1") @@ -117,7 +117,7 @@ func TestCleanup_Execute(t *testing.T) { cmd.SetCommon(CommonOpts{RemarkURL: ts.URL, SharedSecret: "123456"}) p := flags.NewParser(&cmd, flags.Default) _, err := p.ParseArgs([]string{"--site=remark", "--bword=bad1", "--bword=bad2", "--buser=bu_", - "--from=20181217", "--to=20181218"}) + "--from=20181217", "--to=20181218", "--admin-passwd=secret"}) require.Nil(t, err) err = cmd.Execute(nil) assert.NoError(t, err) diff --git a/backend/app/cmd/import.go b/backend/app/cmd/import.go index 2e34c445..3b3d85ee 100644 --- a/backend/app/cmd/import.go +++ b/backend/app/cmd/import.go @@ -17,17 +17,18 @@ import ( // ImportCommand set of flags and command for import type ImportCommand struct { - InputFile string `short:"f" long:"file" description:"input file name" required:"true"` - Provider string `short:"p" long:"provider" default:"disqus" choice:"disqus" choice:"wordpress" description:"import format"` - Site string `short:"s" long:"site" env:"SITE" default:"remark" description:"site name"` - Timeout time.Duration `long:"timeout" default:"15m" description:"import timeout"` + InputFile string `short:"f" long:"file" description:"input file name" required:"true"` + Provider string `short:"p" long:"provider" default:"disqus" choice:"disqus" choice:"wordpress" description:"import format"` + Site string `short:"s" long:"site" env:"SITE" default:"remark" description:"site name"` + Timeout time.Duration `long:"timeout" default:"15m" description:"import timeout"` + AdminPasswd string `long:"admin-passwd" env:"ADMIN_PASSWD" required:"true" description:"admin basic auth password"` CommonOpts } // Execute runs import with ImportCommand parameters, entry point for "import" command func (ic *ImportCommand) Execute(args []string) error { log.Printf("[INFO] import %s (%s), site %s", ic.InputFile, ic.Provider, ic.Site) - resetEnv("SECRET") + resetEnv("SECRET", "ADMIN_PASSWD") reader, err := ic.reader(ic.InputFile) if err != nil { @@ -37,12 +38,12 @@ func (ic *ImportCommand) Execute(args []string) error { client := http.Client{} ctx, cancel := context.WithTimeout(context.Background(), ic.Timeout) defer cancel() - importURL := fmt.Sprintf("%s/api/v1/admin/import?site=%s&provider=%s&secret=%s", - ic.RemarkURL, ic.Site, ic.Provider, ic.SharedSecret) + importURL := fmt.Sprintf("%s/api/v1/admin/import?site=%s&provider=%s", ic.RemarkURL, ic.Site, ic.Provider) req, err := http.NewRequest(http.MethodPost, importURL, reader) if err != nil { return errors.Wrapf(err, "can't make import request for %s", importURL) } + req.SetBasicAuth("admin", ic.AdminPasswd) resp, err := client.Do(req.WithContext(ctx)) // closes request's reader if err != nil { diff --git a/backend/app/cmd/import_test.go b/backend/app/cmd/import_test.go index 7e5a9ddb..9c50355b 100644 --- a/backend/app/cmd/import_test.go +++ b/backend/app/cmd/import_test.go @@ -33,7 +33,7 @@ func TestImport_Execute(t *testing.T) { cmd.SetCommon(CommonOpts{RemarkURL: ts.URL, SharedSecret: "123456"}) p := flags.NewParser(&cmd, flags.Default) - _, err := p.ParseArgs([]string{"--site=remark", "--file=testdata/import.txt"}) + _, err := p.ParseArgs([]string{"--site=remark", "--file=testdata/import.txt", "--admin-passwd=secret"}) require.Nil(t, err) err = cmd.Execute(nil) assert.NoError(t, err) @@ -42,7 +42,7 @@ func TestImport_Execute(t *testing.T) { cmd.SetCommon(CommonOpts{RemarkURL: ts.URL, SharedSecret: "123456"}) p = flags.NewParser(&cmd, flags.Default) - _, err = p.ParseArgs([]string{"--site=remark", "--file=testdata/import.txt.gz"}) + _, err = p.ParseArgs([]string{"--site=remark", "--file=testdata/import.txt.gz", "--admin-passwd=secret"}) require.Nil(t, err) err = cmd.Execute(nil) assert.NoError(t, err) @@ -60,7 +60,7 @@ func TestImport_ExecuteFailed(t *testing.T) { cmd := ImportCommand{} cmd.SetCommon(CommonOpts{RemarkURL: ts.URL, SharedSecret: "123456"}) p := flags.NewParser(&cmd, flags.Default) - _, err := p.ParseArgs([]string{"--site=remark", "--file=testdata/import-no.txt"}) + _, err := p.ParseArgs([]string{"--site=remark", "--file=testdata/import-no.txt", "--admin-passwd=secret"}) require.Nil(t, err) err = cmd.Execute(nil) t.Log(err) @@ -70,7 +70,7 @@ func TestImport_ExecuteFailed(t *testing.T) { cmd = ImportCommand{} cmd.SetCommon(CommonOpts{RemarkURL: "http://127.0.0.1:12345", SharedSecret: "123456"}) p = flags.NewParser(&cmd, flags.Default) - _, err = p.ParseArgs([]string{"--site=remark", "--file=testdata/import.txt"}) + _, err = p.ParseArgs([]string{"--site=remark", "--file=testdata/import.txt", "--admin-passwd=secret"}) require.Nil(t, err) err = cmd.Execute(nil) t.Log(err) @@ -86,7 +86,7 @@ func TestImport_ExecuteFailed(t *testing.T) { cmd = ImportCommand{} cmd.SetCommon(CommonOpts{RemarkURL: ts2.URL, SharedSecret: "123456"}) p = flags.NewParser(&cmd, flags.Default) - _, err = p.ParseArgs([]string{"--site=remark", "--file=testdata/import.txt"}) + _, err = p.ParseArgs([]string{"--site=remark", "--file=testdata/import.txt", "--admin-passwd=secret"}) require.Nil(t, err) err = cmd.Execute(nil) t.Log(err) @@ -111,7 +111,7 @@ func TestImport_ExecuteTimeout(t *testing.T) { cmd.SetCommon(CommonOpts{RemarkURL: ts.URL, SharedSecret: "123456"}) p := flags.NewParser(&cmd, flags.Default) - _, err := p.ParseArgs([]string{"--site=remark", "--file=testdata/import.txt", "--timeout=300ms"}) + _, err := p.ParseArgs([]string{"--site=remark", "--file=testdata/import.txt", "--timeout=300ms", "--admin-passwd=secret"}) require.Nil(t, err) err = cmd.Execute(nil) assert.NotNil(t, err) diff --git a/backend/app/cmd/restore.go b/backend/app/cmd/restore.go index fe54ea33..708e1975 100644 --- a/backend/app/cmd/restore.go +++ b/backend/app/cmd/restore.go @@ -10,8 +10,9 @@ type RestoreCommand struct { ImportPath string `short:"p" long:"path" env:"BACKUP_PATH" default:"./var/backup" description:"export path"` ImportFile string `short:"f" long:"file" default:"userbackup-{{.SITE}}-{{.YYYYMMDD}}.gz" description:"file name" required:"true"` - Site string `short:"s" long:"site" env:"SITE" default:"remark" description:"site name"` - Timeout time.Duration `long:"timeout" default:"15m" description:"import timeout"` + Site string `short:"s" long:"site" env:"SITE" default:"remark" description:"site name"` + Timeout time.Duration `long:"timeout" default:"15m" description:"import timeout"` + AdminPasswd string `long:"admin-passwd" env:"ADMIN_PASSWD" required:"true" description:"admin basic auth password"` CommonOpts } @@ -19,7 +20,7 @@ type RestoreCommand struct { // uses ImportCommand with constructed full file name func (rc *RestoreCommand) Execute(args []string) error { log.Printf("[INFO] restore %s, site %s", rc.ImportFile, rc.Site) - resetEnv("SECRET") + resetEnv("SECRET", "ADMIN_PASSWD") fp := fileParser{site: rc.Site, path: rc.ImportPath, file: rc.ImportFile} fname, err := fp.parse(time.Now()) @@ -27,11 +28,12 @@ func (rc *RestoreCommand) Execute(args []string) error { return err } importer := ImportCommand{ - InputFile: fname, - Site: rc.Site, - Provider: "native", - Timeout: rc.Timeout, - CommonOpts: rc.CommonOpts, + InputFile: fname, + Site: rc.Site, + Provider: "native", + Timeout: rc.Timeout, + AdminPasswd: rc.AdminPasswd, + CommonOpts: rc.CommonOpts, } return importer.Execute(args) } diff --git a/backend/app/cmd/restore_test.go b/backend/app/cmd/restore_test.go index 5bd5e8fc..0e425476 100644 --- a/backend/app/cmd/restore_test.go +++ b/backend/app/cmd/restore_test.go @@ -31,7 +31,7 @@ func TestRestore_Execute(t *testing.T) { cmd.SetCommon(CommonOpts{RemarkURL: ts.URL, SharedSecret: "123456"}) p := flags.NewParser(&cmd, flags.Default) - _, err := p.ParseArgs([]string{"--site=remark", "--path=testdata", "--file=import.txt"}) + _, err := p.ParseArgs([]string{"--site=remark", "--path=testdata", "--file=import.txt", "--admin-passwd=secret"}) require.Nil(t, err) err = cmd.Execute(nil) assert.NoError(t, err) diff --git a/backend/app/cmd/server.go b/backend/app/cmd/server.go index 16bc019a..906a214d 100644 --- a/backend/app/cmd/server.go +++ b/backend/app/cmd/server.go @@ -13,18 +13,22 @@ import ( "time" bolt "github.com/coreos/bbolt" + "github.com/pkg/errors" + + "github.com/go-pkgz/auth" + "github.com/go-pkgz/auth/avatar" + "github.com/go-pkgz/auth/logger" + "github.com/go-pkgz/auth/provider" + "github.com/go-pkgz/auth/token" "github.com/go-pkgz/mongo" "github.com/go-pkgz/rest/cache" - "github.com/pkg/errors" "github.com/umputun/remark/backend/app/migrator" "github.com/umputun/remark/backend/app/notify" "github.com/umputun/remark/backend/app/rest/api" - "github.com/umputun/remark/backend/app/rest/auth" "github.com/umputun/remark/backend/app/rest/proxy" "github.com/umputun/remark/backend/app/store" "github.com/umputun/remark/backend/app/store/admin" - "github.com/umputun/remark/backend/app/store/avatar" "github.com/umputun/remark/backend/app/store/engine" "github.com/umputun/remark/backend/app/store/service" ) @@ -40,7 +44,7 @@ type ServerCommand struct { SSL SSLGroup `group:"ssl" namespace:"ssl" env-namespace:"SSL"` Sites []string `long:"site" env:"SITE" default:"remark" description:"site names" env-delim:","` - DevPasswd string `long:"dev-passwd" env:"DEV_PASSWD" default:"" description:"development mode password"` + AdminPasswd string `long:"admin-passwd" env:"ADMIN_PASSWD" default:"" description:"admin basic auth password"` BackupLocation string `long:"backup" env:"BACKUP_PATH" default:"./var/backup" description:"backups location"` MaxBackupFiles int `long:"max-back" env:"MAX_BACKUP_FILES" default:"10" description:"max backups to keep"` ImageProxy bool `long:"img-proxy" env:"IMG_PROXY" description:"enable image proxy"` @@ -148,7 +152,7 @@ type serverApp struct { restSrv *api.Rest migratorSrv *api.Migrator exporter migrator.Exporter - devAuth *auth.DevAuthServer + devAuth *provider.DevAuthServer dataService *service.DataStore avatarStore avatar.Store notifyService *notify.Service @@ -158,7 +162,7 @@ type serverApp struct { // Execute is the entry point for "server" command, called by flag parser func (s *ServerCommand) Execute(args []string) error { log.Printf("[INFO] start server on port %d", s.Port) - resetEnv("SECRET", "AUTH_GOOGLE_CSEC", "AUTH_GITHUB_CSEC", "AUTH_FACEBOOK_CSEC", "AUTH_YANDEX_CSEC") + resetEnv("SECRET", "AUTH_GOOGLE_CSEC", "AUTH_GITHUB_CSEC", "AUTH_FACEBOOK_CSEC", "AUTH_YANDEX_CSEC", "ADMIN_PASSWD") ctx, cancel := context.WithCancel(context.Background()) go func() { // catch signal and invoke graceful termination @@ -217,18 +221,11 @@ func (s *ServerCommand) newServerApp() (*serverApp, error) { return nil, errors.Wrap(err, "failed to make cache") } - // token TTL is 5 minutes, inactivity interval 7+ days by default - jwtService := auth.NewJWT(adminStore, strings.HasPrefix(s.RemarkURL, "https://"), s.Auth.TTL.JWT, s.Auth.TTL.Cookie) - avatarStore, err := s.makeAvatarStore() if err != nil { return nil, errors.Wrap(err, "failed to make avatar store") } - avatarProxy := &proxy.Avatar{ - Store: avatarStore, - RoutePath: "/api/v1/avatar", - RemarkURL: strings.TrimSuffix(s.RemarkURL, "/"), - } + authenticator := s.makeAuthenticator(dataService, avatarStore, adminStore) exporter := &migrator.Native{DataStore: dataService} @@ -247,7 +244,6 @@ func (s *ServerCommand) newServerApp() (*serverApp, error) { notifyService = notify.NopService // disable notifier } - authProviders := s.makeAuthProviders(jwtService, avatarProxy, dataService) imgProxy := &proxy.Image{Enabled: s.ImageProxy, RoutePath: "/api/v1/img", RemarkURL: s.RemarkURL} commentFormatter := store.NewCommentFormatter(imgProxy) @@ -263,27 +259,24 @@ func (s *ServerCommand) newServerApp() (*serverApp, error) { RemarkURL: s.RemarkURL, ImageProxy: imgProxy, CommentFormatter: commentFormatter, - AvatarProxy: avatarProxy, Migrator: migr, ReadOnlyAge: s.ReadOnlyAge, SharedSecret: s.SharedSecret, - Authenticator: auth.Authenticator{ - JWTService: jwtService, - KeyStore: adminStore, - Providers: authProviders, - DevPasswd: s.DevPasswd, - PermissionChecker: dataService, - }, - Cache: loadingCache, - NotifyService: notifyService, - SSLConfig: sslConfig, + Authenticator: authenticator, + Cache: loadingCache, + NotifyService: notifyService, + SSLConfig: sslConfig, } srv.ScoreThresholds.Low, srv.ScoreThresholds.Critical = s.LowScore, s.CriticalScore - var devAuth *auth.DevAuthServer + var devAuth *provider.DevAuthServer if s.Auth.Dev { - devAuth = &auth.DevAuthServer{Provider: authProviders[len(authProviders)-1]} + da, err := authenticator.DevAuth() + if err != nil { + return nil, errors.Wrap(err, "can't make dev oauth2 server") + } + devAuth = da } return &serverApp{ @@ -301,8 +294,8 @@ func (s *ServerCommand) newServerApp() (*serverApp, error) { // Run all application objects func (a *serverApp) run(ctx context.Context) error { - if a.DevPasswd != "" { - log.Printf("[WARN] running in dev mode") + if a.AdminPasswd != "" { + log.Printf("[WARN] admin basic auth enabled") } go func() { @@ -324,7 +317,7 @@ func (a *serverApp) run(ctx context.Context) error { }() a.activateBackup(ctx) // runs in goroutine for each site if a.Auth.Dev { - go a.devAuth.Run() // dev oauth2 server on :8084 + go a.devAuth.Run(context.Background()) // dev oauth2 server on :8084 } a.restSrv.Run(a.Port) close(a.terminated) @@ -385,19 +378,19 @@ func (s *ServerCommand) makeAvatarStore() (avatar.Store, error) { if err := makeDirs(s.Avatar.FS.Path); err != nil { return nil, err } - return avatar.NewLocalFS(s.Avatar.FS.Path, s.Avatar.RszLmt), nil + return avatar.NewLocalFS(s.Avatar.FS.Path), nil case "mongo": mgServer, err := s.makeMongo() if err != nil { return nil, errors.Wrap(err, "failed to create mongo server") } conn := mongo.NewConnection(mgServer, s.Mongo.DB, "") - return avatar.NewGridFS(conn, s.Avatar.RszLmt), nil + return avatar.NewGridFS(conn), nil case "bolt": if err := makeDirs(path.Dir(s.Avatar.Bolt.File)); err != nil { return nil, err } - return avatar.NewBoltDB(s.Avatar.Bolt.File, bolt.Options{}, s.Avatar.RszLmt) + return avatar.NewBoltDB(s.Avatar.Bolt.File, bolt.Options{}) } return nil, errors.Errorf("unsupported avatar store type %s", s.Avatar.Type) } @@ -452,40 +445,33 @@ func (s *ServerCommand) makeMongo() (result *mongo.Server, err error) { return mongo.NewServerWithURL(s.Mongo.URL, 10*time.Second) } -func (s *ServerCommand) makeAuthProviders(jwt *auth.JWT, ap *proxy.Avatar, ds *service.DataStore) []auth.Provider { +func (s *ServerCommand) addAuthProviders(authenticator *auth.Service) { - makeParams := func(cid, secret string) auth.Params { - return auth.Params{ - JwtService: jwt, - AvatarProxy: ap, - RemarkURL: s.RemarkURL, - Cid: cid, - Csecret: secret, - PermissionChecker: ds, - } - } - - providers := []auth.Provider{} + providers := 0 if s.Auth.Google.CID != "" && s.Auth.Google.CSEC != "" { - providers = append(providers, auth.NewGoogle(makeParams(s.Auth.Google.CID, s.Auth.Google.CSEC))) + authenticator.AddProvider("google", s.Auth.Google.CID, s.Auth.Google.CSEC) + providers++ } if s.Auth.Github.CID != "" && s.Auth.Github.CSEC != "" { - providers = append(providers, auth.NewGithub(makeParams(s.Auth.Github.CID, s.Auth.Github.CSEC))) + authenticator.AddProvider("github", s.Auth.Github.CID, s.Auth.Github.CSEC) + providers++ } if s.Auth.Facebook.CID != "" && s.Auth.Facebook.CSEC != "" { - providers = append(providers, auth.NewFacebook(makeParams(s.Auth.Facebook.CID, s.Auth.Facebook.CSEC))) + authenticator.AddProvider("facebook", s.Auth.Facebook.CID, s.Auth.Facebook.CSEC) + providers++ } if s.Auth.Yandex.CID != "" && s.Auth.Yandex.CSEC != "" { - providers = append(providers, auth.NewYandex(makeParams(s.Auth.Yandex.CID, s.Auth.Yandex.CSEC))) + authenticator.AddProvider("yandex", s.Auth.Yandex.CID, s.Auth.Yandex.CSEC) + providers++ } if s.Auth.Dev { - providers = append(providers, auth.NewDev(makeParams("", ""))) + authenticator.AddProvider("dev", "", "") + providers++ } - if len(providers) == 0 { + if providers == 0 { log.Printf("[WARN] no auth providers defined") } - return providers } func (s *ServerCommand) makeNotify(dataStore *service.DataStore) (*notify.Service, error) { @@ -533,3 +519,37 @@ func (s *ServerCommand) makeSSLConfig() (config api.SSLConfig, err error) { } return config, err } + +func (s *ServerCommand) makeAuthenticator(ds *service.DataStore, avas avatar.Store, admns admin.Store) *auth.Service { + authenticator := auth.NewService(auth.Opts{ + URL: strings.TrimSuffix(s.RemarkURL, "/"), + Issuer: "remark42", + TokenDuration: s.Auth.TTL.JWT, + CookieDuration: s.Auth.TTL.Cookie, + SecureCookies: strings.HasPrefix(s.RemarkURL, "https://"), + SecretReader: token.SecretFunc(func(id string) (string, error) { // get secret per site + return admns.Key(id) + }), + ClaimsUpd: token.ClaimsUpdFunc(func(c token.Claims) token.Claims { // set attributes, on new token or refresh + if c.User == nil { + return c + } + c.User.SetAdmin(ds.IsAdmin(c.Audience, c.User.ID)) + c.User.SetBoolAttr("blocked", ds.IsBlocked(c.Audience, c.User.ID)) + return c + }), + AdminPasswd: s.AdminPasswd, + Validator: token.ValidatorFunc(func(token string, claims token.Claims) bool { // check on each auth call (in middleware) + if claims.User == nil { + return false + } + return !claims.User.BoolAttr("blocked") + }), + AvatarStore: avas, + AvatarResizeLimit: s.Avatar.RszLmt, + AvatarRoutePath: "/api/v1/avatar", + Logger: logger.Std, + }) + s.addAuthProviders(authenticator) + return authenticator +} diff --git a/backend/app/cmd/server_test.go b/backend/app/cmd/server_test.go index 82284d54..7397aa5d 100644 --- a/backend/app/cmd/server_test.go +++ b/backend/app/cmd/server_test.go @@ -13,9 +13,12 @@ import ( "testing" "time" + jwt "github.com/dgrijalva/jwt-go" "github.com/globalsign/mgo" + "github.com/go-pkgz/auth/token" "github.com/go-pkgz/mongo" flags "github.com/jessevdk/go-flags" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -39,8 +42,12 @@ func TestServerApp(t *testing.T) { assert.Equal(t, "pong", string(body)) // add comment - resp, err = http.Post("http://dev:password@localhost:18080/api/v1/comment", "json", + client := http.Client{Timeout: 5 * time.Second} + req, err := http.NewRequest("POST", "http://localhost:18080/api/v1/comment", strings.NewReader(`{"text": "test 123", "locator":{"url": "https://radio-t.com/blah1", "site": "remark"}}`)) + req.SetBasicAuth("admin", "password") + require.Nil(t, err) + resp, err = client.Do(req) require.Nil(t, err) assert.Equal(t, http.StatusCreated, resp.StatusCode) body, _ = ioutil.ReadAll(resp.Body) @@ -54,7 +61,7 @@ func TestServerApp(t *testing.T) { func TestServerApp_DevMode(t *testing.T) { app, ctx := prepServerApp(t, 500*time.Millisecond, func(o ServerCommand) ServerCommand { o.Port = 18085 - o.DevPasswd = "password" + o.AdminPasswd = "password" o.Auth.Dev = true return o }) @@ -62,8 +69,8 @@ func TestServerApp_DevMode(t *testing.T) { go func() { _ = app.run(ctx) }() time.Sleep(100 * time.Millisecond) // let server start - assert.Equal(t, 4+1, len(app.restSrv.Authenticator.Providers), "extra auth provider") - assert.Equal(t, "dev", app.restSrv.Authenticator.Providers[4].Name, "dev auth provider") + assert.Equal(t, 4+1, len(app.restSrv.Authenticator.Providers()), "extra auth provider") + assert.Equal(t, "dev", app.restSrv.Authenticator.Providers()[4].Name(), "dev auth provider") // send ping resp, err := http.Get("http://localhost:18085/api/v1/ping") require.Nil(t, err) @@ -91,7 +98,7 @@ func TestServerApp_WithMongo(t *testing.T) { // prepare options p := flags.NewParser(&opts, flags.Default) - _, err := p.ParseArgs([]string{"--dev-passwd=password", "--cache.type=none", "--store.type=mongo", + _, err := p.ParseArgs([]string{"--admin-passwd=password", "--cache.type=none", "--store.type=mongo", "--avatar.type=mongo", "--mongo.url=" + mongoURL, "--mongo.db=test_remark", "--port=12345", "--admin.type=mongo"}) require.Nil(t, err) opts.Auth.Github.CSEC, opts.Auth.Github.CID = "csec", "cid" @@ -138,7 +145,7 @@ func TestServerApp_WithSSL(t *testing.T) { // prepare options p := flags.NewParser(&opts, flags.Default) - _, err := p.ParseArgs([]string{"--dev-passwd=password", "--port=18080", "--store.bolt.path=/tmp/xyz", "--backup=/tmp", "--avatar.type=bolt", "--avatar.bolt.file=/tmp/ava-test.db", "--notify.type=none", + _, err := p.ParseArgs([]string{"--admin-passwd=password", "--port=18080", "--store.bolt.path=/tmp/xyz", "--backup=/tmp", "--avatar.type=bolt", "--avatar.bolt.file=/tmp/ava-test.db", "--notify.type=none", "--ssl.type=static", "--ssl.cert=testdata/cert.pem", "--ssl.key=testdata/key.pem", "--ssl.port=18443"}) require.Nil(t, err) @@ -308,13 +315,80 @@ func Test_ACMEEmail(t *testing.T) { assert.Equal(t, "admin@remark.com", cfg.ACMEEmail) } +func TestServerAuthHooks(t *testing.T) { + app, ctx := prepServerApp(t, 2500*time.Millisecond, func(o ServerCommand) ServerCommand { + o.Port = 18080 + return o + }) + + go func() { _ = app.run(ctx) }() + time.Sleep(100 * time.Millisecond) // let server start + + // make a token for user dev + tkService := app.restSrv.Authenticator.TokenService() + tkService.TokenDuration = time.Second + + claims := token.Claims{ + StandardClaims: jwt.StandardClaims{ + Audience: "remark", + Issuer: "remark", + ExpiresAt: time.Now().Add(time.Second).Unix(), + NotBefore: time.Now().Add(-1 * time.Minute).Unix(), + }, + User: &token.User{ + ID: "dev", + Name: "developer one", + }, + } + tk, err := tkService.Token(claims) + require.NoError(t, err) + t.Log(tk) + + // add comment + client := http.Client{Timeout: 5 * time.Second} + req, err := http.NewRequest("POST", "http://localhost:18080/api/v1/comment", + strings.NewReader(`{"text": "test 123", "locator":{"url": "https://radio-t.com/blah1", "site": "remark"}}`)) + req.Header.Set("X-JWT", tk) + require.Nil(t, err) + resp, err := client.Do(req) + require.Nil(t, err) + defer resp.Body.Close() + assert.Equal(t, http.StatusCreated, resp.StatusCode, "non-blocked user able to post") + + // block user dev as admin + req, e := http.NewRequest(http.MethodPut, "http://localhost:18080/api/v1/admin/user/dev?site=remark&block=1&ttl=10d", nil) + assert.Nil(t, e) + req.SetBasicAuth("admin", "password") + resp, e = client.Do(req) + require.Nil(t, e) + defer resp.Body.Close() + assert.Equal(t, http.StatusOK, resp.StatusCode, "user dev blocked") + b, err := ioutil.ReadAll(resp.Body) + require.Nil(t, err) + t.Log(string(b)) + + time.Sleep(2 * time.Second) // make sure token expired and refresh happened + + // try add a comment with blocked user + req, err = http.NewRequest("POST", "http://localhost:18080/api/v1/comment", + strings.NewReader(`{"text": "test 123 blah", "locator":{"url": "https://radio-t.com/blah1", "site": "remark"}}`)) + req.Header.Set("X-JWT", tk) + require.Nil(t, err) + resp, err = client.Do(req) + require.Nil(t, err) + defer resp.Body.Close() + assert.Equal(t, http.StatusForbidden, resp.StatusCode, "blocked user can't post") + + app.Wait() +} + func prepServerApp(t *testing.T, duration time.Duration, fn func(o ServerCommand) ServerCommand) (*serverApp, context.Context) { cmd := ServerCommand{} - cmd.SetCommon(CommonOpts{RemarkURL: "https://demo.remark42.com", SharedSecret: "123456"}) + cmd.SetCommon(CommonOpts{RemarkURL: "https://demo.remark42.com", SharedSecret: "secret"}) // prepare options p := flags.NewParser(&cmd, flags.Default) - _, err := p.ParseArgs([]string{"--dev-passwd=password"}) + _, err := p.ParseArgs([]string{"--admin-passwd=password", "--site=remark"}) require.Nil(t, err) cmd.Avatar.FS.Path, cmd.Avatar.Type, cmd.BackupLocation = "/tmp", "fs", "/tmp" cmd.Store.Bolt.Path = fmt.Sprintf("/tmp/%d", cmd.Port) diff --git a/backend/app/rest/api/admin.go b/backend/app/rest/api/admin.go index d2be7c9e..b4a60988 100644 --- a/backend/app/rest/api/admin.go +++ b/backend/app/rest/api/admin.go @@ -9,12 +9,12 @@ import ( "github.com/go-chi/chi" "github.com/go-chi/render" + + "github.com/go-pkgz/auth" R "github.com/go-pkgz/rest" "github.com/go-pkgz/rest/cache" "github.com/umputun/remark/backend/app/rest" - "github.com/umputun/remark/backend/app/rest/auth" - "github.com/umputun/remark/backend/app/rest/proxy" "github.com/umputun/remark/backend/app/store" "github.com/umputun/remark/backend/app/store/service" ) @@ -23,9 +23,8 @@ import ( type admin struct { dataService *service.DataStore cache cache.LoadingCache - authenticator auth.Authenticator + authenticator *auth.Service readOnlyAge int - avatarProxy *proxy.Avatar migrator *Migrator } @@ -102,35 +101,36 @@ func (a *admin) deleteMeRequestCtrl(w http.ResponseWriter, r *http.Request) { token := r.URL.Query().Get("token") - claims, err := a.authenticator.JWTService.Parse(token) + claims, err := a.authenticator.TokenService().Parse(token) if err != nil { rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "can't process token") return } - log.Printf("[INFO] delete all user comments by request for %s, site %s", claims.User.ID, claims.SiteID) + log.Printf("[INFO] delete all user comments by request for %s, site %s", claims.User.ID, claims.Audience) // deleteme set by deleteMeCtrl, this check just to make sure we not trying to delete with leaked token - if !claims.Flags.DeleteMe { + if !claims.User.BoolAttr("delete_me") { rest.SendErrorJSON(w, r, http.StatusForbidden, errors.New("forbidden"), "can't use provided token") return } - if err := a.dataService.DeleteUser(claims.SiteID, claims.User.ID); err != nil { + if err := a.dataService.DeleteUser(claims.Audience, claims.User.ID); err != nil { rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "can't delete user") return } - if claims.User.Picture != "" { - if err := a.avatarProxy.Store.Remove(path.Base(claims.User.Picture)); err != nil { + if claims.User.Picture != "" && a.authenticator.AvatarProxy() != nil { + avatartStore := a.authenticator.AvatarProxy().Store + if err := avatartStore.Remove(path.Base(claims.User.Picture)); err != nil { rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "can't delete user's avatar") return } } - a.cache.Flush(cache.Flusher(claims.SiteID).Scopes(claims.SiteID, claims.User.ID, lastCommentsScope)) + a.cache.Flush(cache.Flusher(claims.Audience).Scopes(claims.Audience, claims.User.ID, lastCommentsScope)) render.Status(r, http.StatusOK) - render.JSON(w, r, R.JSON{"user_id": claims.User.ID, "site_id": claims.SiteID}) + render.JSON(w, r, R.JSON{"user_id": claims.User.ID, "site_id": claims.Audience}) } // PUT /user/{userid}?site=side-id&block=1&ttl=7d - block or unblock user diff --git a/backend/app/rest/api/admin_test.go b/backend/app/rest/api/admin_test.go index 2652ccb4..6bbde081 100644 --- a/backend/app/rest/api/admin_test.go +++ b/backend/app/rest/api/admin_test.go @@ -12,19 +12,19 @@ import ( "testing" "time" - "github.com/dgrijalva/jwt-go" + jwt "github.com/dgrijalva/jwt-go" + "github.com/go-pkgz/auth/token" R "github.com/go-pkgz/rest" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/umputun/remark/backend/app/rest/auth" "github.com/umputun/remark/backend/app/store" ) func TestAdmin_Delete(t *testing.T) { - srv, ts := prep(t) - assert.NotNil(t, srv) - defer cleanup(ts, srv) + ts, _, teardown := startupT(t) + defer teardown() c1 := store.Comment{Text: "test test #1", User: store.User{ID: "id", Name: "name"}, Locator: store.Locator{SiteID: "radio-t", URL: "https://radio-t.com/blah"}} @@ -38,12 +38,12 @@ func TestAdmin_Delete(t *testing.T) { req, err := http.NewRequest(http.MethodDelete, fmt.Sprintf("%s/api/v1/admin/comment/%s?site=radio-t&url=https://radio-t.com/blah", ts.URL, id1), nil) assert.Nil(t, err) - req.SetBasicAuth("dev", "password") + req.SetBasicAuth("admin", "password") resp, err := client.Do(req) assert.Nil(t, err) assert.Equal(t, 200, resp.StatusCode) - body, code := getWithAuth(t, fmt.Sprintf("%s/api/v1/id/%s?site=radio-t&url=https://radio-t.com/blah", ts.URL, id1)) + body, code := getWithDevAuth(t, fmt.Sprintf("%s/api/v1/id/%s?site=radio-t&url=https://radio-t.com/blah", ts.URL, id1)) assert.Equal(t, 200, code) cr := store.Comment{} err = json.Unmarshal([]byte(body), &cr) @@ -53,9 +53,8 @@ func TestAdmin_Delete(t *testing.T) { } func TestAdmin_DeleteUser(t *testing.T) { - srv, ts := prep(t) - assert.NotNil(t, srv) - defer cleanup(ts, srv) + ts, srv, teardown := startupT(t) + defer teardown() c1 := store.Comment{Text: "test test #1", Orig: "o test test #1", User: store.User{ID: "id1", Name: "name"}, Locator: store.Locator{SiteID: "radio-t", URL: "https://radio-t.com/blah"}} @@ -75,7 +74,7 @@ func TestAdmin_DeleteUser(t *testing.T) { client := http.Client{} req, err := http.NewRequest(http.MethodDelete, fmt.Sprintf("%s/api/v1/admin/user/%s?site=radio-t", ts.URL, "id2"), nil) assert.Nil(t, err) - req.SetBasicAuth("dev", "password") + req.SetBasicAuth("admin", "password") resp, err := client.Do(req) assert.Nil(t, err) assert.Equal(t, 200, resp.StatusCode) @@ -107,9 +106,8 @@ func TestAdmin_DeleteUser(t *testing.T) { } func TestAdmin_Pin(t *testing.T) { - srv, ts := prep(t) - assert.NotNil(t, srv) - defer cleanup(ts, srv) + ts, _, teardown := startupT(t) + defer teardown() c1 := store.Comment{Text: "test test #1", Locator: store.Locator{SiteID: "radio-t", URL: "https://radio-t.com/blah"}} @@ -124,7 +122,7 @@ func TestAdmin_Pin(t *testing.T) { req, err := http.NewRequest(http.MethodPut, fmt.Sprintf("%s/api/v1/admin/pin/%s?site=radio-t&url=https://radio-t.com/blah&pin=%d", ts.URL, id1, val), nil) assert.Nil(t, err) - req.SetBasicAuth("dev", "password") + req.SetBasicAuth("admin", "password") resp, err := client.Do(req) assert.Nil(t, err) return resp.StatusCode @@ -151,9 +149,8 @@ func TestAdmin_Pin(t *testing.T) { } func TestAdmin_Block(t *testing.T) { - srv, ts := prep(t) - assert.NotNil(t, srv) - defer cleanup(ts, srv) + ts, srv, teardown := startupT(t) + defer teardown() c1 := store.Comment{Text: "test test #1", Locator: store.Locator{SiteID: "radio-t", URL: "https://radio-t.com/blah"}, User: store.User{Name: "user1 name", ID: "user1"}} @@ -173,7 +170,7 @@ func TestAdmin_Block(t *testing.T) { } req, e := http.NewRequest(http.MethodPut, url, nil) assert.Nil(t, e) - req.SetBasicAuth("dev", "password") + req.SetBasicAuth("admin", "password") resp, e := client.Do(req) require.Nil(t, e) body, e = ioutil.ReadAll(resp.Body) @@ -232,9 +229,8 @@ func TestAdmin_Block(t *testing.T) { } func TestAdmin_BlockedList(t *testing.T) { - srv, ts := prep(t) - assert.NotNil(t, srv) - defer cleanup(ts, srv) + ts, _, teardown := startupT(t) + defer teardown() client := http.Client{} @@ -242,7 +238,7 @@ func TestAdmin_BlockedList(t *testing.T) { req, err := http.NewRequest(http.MethodPut, fmt.Sprintf("%s/api/v1/admin/user/%s?site=radio-t&block=%d", ts.URL, "user1", 1), nil) assert.Nil(t, err) - req.SetBasicAuth("dev", "password") + req.SetBasicAuth("admin", "password") _, err = client.Do(req) require.Nil(t, err) @@ -250,33 +246,41 @@ func TestAdmin_BlockedList(t *testing.T) { req, err = http.NewRequest(http.MethodPut, fmt.Sprintf("%s/api/v1/admin/user/%s?site=radio-t&block=%d&ttl=50ms", ts.URL, "user2", 1), nil) assert.Nil(t, err) - req.SetBasicAuth("dev", "password") + req.SetBasicAuth("admin", "password") _, err = client.Do(req) require.Nil(t, err) - res, code := getWithAuth(t, ts.URL+"/api/v1/admin/blocked?site=radio-t") - require.Equal(t, 200, code, res) + req, err = http.NewRequest("GET", ts.URL+"/api/v1/admin/blocked?site=radio-t", nil) + require.Nil(t, err) + req.SetBasicAuth("admin", "password") + res, err := client.Do(req) + require.Nil(t, err) + require.Equal(t, 200, res.StatusCode) users := []store.BlockedUser{} - err = json.Unmarshal([]byte(res), &users) + err = json.NewDecoder(res.Body).Decode(&users) assert.Nil(t, err) assert.Equal(t, 2, len(users), "two users blocked") assert.Equal(t, "user1", users[0].ID) assert.Equal(t, "user2", users[1].ID) time.Sleep(50 * time.Millisecond) - res, code = getWithAuth(t, ts.URL+"/api/v1/admin/blocked?site=radio-t") - require.Equal(t, 200, code, res) + + req, err = http.NewRequest("GET", ts.URL+"/api/v1/admin/blocked?site=radio-t", nil) + require.Nil(t, err) + req.SetBasicAuth("admin", "password") + res, err = client.Do(req) + require.Nil(t, err) + require.Equal(t, 200, res.StatusCode) users = []store.BlockedUser{} - err = json.Unmarshal([]byte(res), &users) + err = json.NewDecoder(res.Body).Decode(&users) assert.Nil(t, err) assert.Equal(t, 1, len(users), "one user left blocked") } func TestAdmin_ReadOnly(t *testing.T) { - srv, ts := prep(t) - assert.NotNil(t, srv) - defer cleanup(ts, srv) + ts, srv, teardown := startupT(t) + defer teardown() c1 := store.Comment{Text: "test test #1", Locator: store.Locator{SiteID: "radio-t", URL: "https://radio-t.com/blah"}, User: store.User{Name: "user1 name", ID: "user1"}} @@ -298,7 +302,7 @@ func TestAdmin_ReadOnly(t *testing.T) { req, err := http.NewRequest(http.MethodPut, fmt.Sprintf("%s/api/v1/admin/readonly?site=radio-t&url=https://radio-t.com/blah&ro=1", ts.URL), nil) assert.Nil(t, err) - req.SetBasicAuth("dev", "password") + req.SetBasicAuth("admin", "password") resp, err := client.Do(req) require.Nil(t, err) assert.Equal(t, 200, resp.StatusCode) @@ -313,7 +317,7 @@ func TestAdmin_ReadOnly(t *testing.T) { assert.Nil(t, err, "can't marshal comment %+v", c) req, err = http.NewRequest("POST", ts.URL+"/api/v1/comment", bytes.NewBuffer(b)) assert.Nil(t, err) - req.SetBasicAuth("dev", "password") + req.SetBasicAuth("admin", "password") resp, err = client.Do(req) assert.Nil(t, err) assert.Equal(t, http.StatusForbidden, resp.StatusCode) @@ -322,7 +326,7 @@ func TestAdmin_ReadOnly(t *testing.T) { req, err = http.NewRequest(http.MethodPut, fmt.Sprintf("%s/api/v1/admin/readonly?site=radio-t&url=https://radio-t.com/blah&ro=0", ts.URL), nil) assert.Nil(t, err) - req.SetBasicAuth("dev", "password") + req.SetBasicAuth("admin", "password") resp, err = client.Do(req) assert.Equal(t, 200, resp.StatusCode) require.Nil(t, err) @@ -337,16 +341,15 @@ func TestAdmin_ReadOnly(t *testing.T) { assert.Nil(t, err, "can't marshal comment %+v", c) req, err = http.NewRequest("POST", ts.URL+"/api/v1/comment", bytes.NewBuffer(b)) assert.Nil(t, err) - req.SetBasicAuth("dev", "password") + req.SetBasicAuth("admin", "password") resp, err = client.Do(req) assert.Nil(t, err) assert.Equal(t, http.StatusCreated, resp.StatusCode) } func TestAdmin_ReadOnlyWithAge(t *testing.T) { - srv, ts := prep(t) - assert.NotNil(t, srv) - defer cleanup(ts, srv) + ts, srv, teardown := startupT(t) + defer teardown() c1 := store.Comment{Text: "test test #1", Locator: store.Locator{SiteID: "radio-t", URL: "https://radio-t.com/blah"}, User: store.User{Name: "user1 name", ID: "user1"}, @@ -364,7 +367,7 @@ func TestAdmin_ReadOnlyWithAge(t *testing.T) { req, err := http.NewRequest(http.MethodPut, fmt.Sprintf("%s/api/v1/admin/readonly?site=radio-t&url=https://radio-t.com/blah&ro=1", ts.URL), nil) assert.Nil(t, err) - req.SetBasicAuth("dev", "password") + req.SetBasicAuth("admin", "password") resp, err := client.Do(req) require.Nil(t, err) assert.Equal(t, 200, resp.StatusCode) @@ -376,7 +379,7 @@ func TestAdmin_ReadOnlyWithAge(t *testing.T) { req, err = http.NewRequest(http.MethodPut, fmt.Sprintf("%s/api/v1/admin/readonly?site=radio-t&url=https://radio-t.com/blah&ro=0", ts.URL), nil) assert.Nil(t, err) - req.SetBasicAuth("dev", "password") + req.SetBasicAuth("admin", "password") resp, err = client.Do(req) assert.Equal(t, 403, resp.StatusCode) require.Nil(t, err) @@ -386,9 +389,8 @@ func TestAdmin_ReadOnlyWithAge(t *testing.T) { } func TestAdmin_Verify(t *testing.T) { - srv, ts := prep(t) - assert.NotNil(t, srv) - defer cleanup(ts, srv) + ts, srv, teardown := startupT(t) + defer teardown() c1 := store.Comment{Text: "test test #1", Locator: store.Locator{SiteID: "radio-t", URL: "https://radio-t.com/blah"}, User: store.User{Name: "user1 name", ID: "user1"}} @@ -407,7 +409,7 @@ func TestAdmin_Verify(t *testing.T) { req, err := http.NewRequest(http.MethodPut, fmt.Sprintf("%s/api/v1/admin/verify/user1?site=radio-t&verified=1", ts.URL), nil) assert.Nil(t, err) - req.SetBasicAuth("dev", "password") + req.SetBasicAuth("admin", "password") _, err = client.Do(req) require.Nil(t, err) verified = srv.DataService.IsVerified("radio-t", "user1") @@ -425,7 +427,7 @@ func TestAdmin_Verify(t *testing.T) { req, err = http.NewRequest(http.MethodPut, fmt.Sprintf("%s/api/v1/admin/verify/user1?site=radio-t&verified=0", ts.URL), nil) assert.Nil(t, err) - req.SetBasicAuth("dev", "password") + req.SetBasicAuth("admin", "password") _, err = client.Do(req) require.Nil(t, err) verified = srv.DataService.IsVerified("radio-t", "user1") @@ -442,9 +444,8 @@ func TestAdmin_Verify(t *testing.T) { } func TestAdmin_ExportStream(t *testing.T) { - srv, ts := prep(t) - assert.NotNil(t, srv) - defer cleanup(ts, srv) + ts, _, teardown := startupT(t) + defer teardown() c1 := store.Comment{Text: "test test #1", Locator: store.Locator{SiteID: "radio-t", URL: "https://radio-t.com/blah1"}} @@ -454,7 +455,7 @@ func TestAdmin_ExportStream(t *testing.T) { addComment(t, c1, ts) addComment(t, c2, ts) - body, code := getWithAuth(t, ts.URL+"/api/v1/admin/export?site=radio-t&mode=stream") + body, code := getWithAdminAuth(t, ts.URL+"/api/v1/admin/export?site=radio-t&mode=stream") assert.Equal(t, 200, code) assert.Equal(t, 3, strings.Count(body, "\n")) assert.Equal(t, 2, strings.Count(body, "\"text\"")) @@ -462,9 +463,8 @@ func TestAdmin_ExportStream(t *testing.T) { } func TestAdmin_ExportFile(t *testing.T) { - srv, ts := prep(t) - assert.NotNil(t, srv) - defer cleanup(ts, srv) + ts, _, teardown := startupT(t) + defer teardown() c1 := store.Comment{Text: "test test #1", Locator: store.Locator{SiteID: "radio-t", URL: "https://radio-t.com/blah1"}} @@ -477,7 +477,7 @@ func TestAdmin_ExportFile(t *testing.T) { client := &http.Client{Timeout: 5 * time.Second} req, err := http.NewRequest("GET", ts.URL+"/api/v1/admin/export?site=radio-t&mode=file", nil) require.Nil(t, err) - req.SetBasicAuth("dev", "password") + req.SetBasicAuth("admin", "password") resp, err := client.Do(req) require.Nil(t, err) @@ -494,9 +494,8 @@ func TestAdmin_ExportFile(t *testing.T) { } func TestAdmin_DeleteMeRequest(t *testing.T) { - srv, ts := prep(t) - assert.NotNil(t, srv) - defer cleanup(ts, srv) + ts, srv, teardown := startupT(t) + defer teardown() c1 := store.Comment{Text: "test test #1", Locator: store.Locator{SiteID: "radio-t", URL: "https://radio-t.com/blah"}, User: store.User{Name: "user1 name", ID: "user1"}} @@ -512,35 +511,36 @@ func TestAdmin_DeleteMeRequest(t *testing.T) { assert.Nil(t, err) assert.Equal(t, 1, len(comments), "a comment for user1") - claims := auth.CustomClaims{ - SiteID: "radio-t", + claims := token.Claims{ SessionOnly: true, StandardClaims: jwt.StandardClaims{ + Audience: "radio-t", Id: "1234567", Issuer: "remark42", NotBefore: time.Now().Add(-1 * time.Minute).Unix(), ExpiresAt: time.Now().Add(30 * time.Minute).Unix(), }, - User: &store.User{ + User: &token.User{ ID: "user1", Picture: "pic.image", + Attributes: map[string]interface{}{ + "delete_me": true, + }, }, } - claims.Flags.DeleteMe = true - _ = os.MkdirAll("/tmp/42", 0700) - defer func(){_ = os.RemoveAll("/tmp/42")}() - require.NoError(t,ioutil.WriteFile("/tmp/42/pic.image", []byte("some image data"), 0600)) + require.NoError(t, os.MkdirAll("/tmp/ava-remark42/42", 0700)) + require.NoError(t, ioutil.WriteFile("/tmp/ava-remark42/42/pic.image", []byte("some image data"), 0600)) - token, err := srv.Authenticator.JWTService.Token(&claims) + tkn, err := srv.Authenticator.TokenService().Token(claims) assert.Nil(t, err) client := http.Client{} - req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("%s/api/v1/admin/deleteme?token=%s", ts.URL, token), nil) + req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("%s/api/v1/admin/deleteme?token=%s", ts.URL, tkn), nil) assert.Nil(t, err) - req.SetBasicAuth("dev", "password") + req.SetBasicAuth("admin", "password") resp, err := client.Do(req) - assert.Nil(t, err) + require.Nil(t, err) assert.Equal(t, 200, resp.StatusCode) _, err = srv.DataService.User("radio-t", "user1", 0, 0) @@ -548,9 +548,8 @@ func TestAdmin_DeleteMeRequest(t *testing.T) { } func TestAdmin_DeleteMeRequestFailed(t *testing.T) { - srv, ts := prep(t) - assert.NotNil(t, srv) - defer cleanup(ts, srv) + ts, srv, teardown := startupT(t) + defer teardown() c1 := store.Comment{Text: "test test #1", Locator: store.Locator{SiteID: "radio-t", URL: "https://radio-t.com/blah"}, User: store.User{Name: "user1 name", ID: "user1"}} @@ -566,32 +565,34 @@ func TestAdmin_DeleteMeRequestFailed(t *testing.T) { client := http.Client{} req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("%s/api/v1/admin/deleteme?token=%s", ts.URL, "bad token"), nil) assert.Nil(t, err) - req.SetBasicAuth("dev", "password") + req.SetBasicAuth("admin", "password") resp, err := client.Do(req) assert.Nil(t, err) assert.Equal(t, 400, resp.StatusCode) // try with bad auth - claims := auth.CustomClaims{ - SiteID: "radio-t", + claims := token.Claims{ SessionOnly: true, StandardClaims: jwt.StandardClaims{ + Audience: "radio-t", Id: "1234567", Issuer: "remark42", NotBefore: time.Now().Add(-1 * time.Minute).Unix(), ExpiresAt: time.Now().Add(30 * time.Minute).Unix(), }, - User: &store.User{ + User: &token.User{ ID: "user1", + Attributes: map[string]interface{}{ + "delete_me": true, + }, }, } - claims.Flags.DeleteMe = true - token, err := srv.Authenticator.JWTService.Token(&claims) + tkn, err := srv.Authenticator.TokenService().Token(claims) assert.Nil(t, err) - req, err = http.NewRequest(http.MethodGet, fmt.Sprintf("%s/api/v1/admin/deleteme?token=%s", ts.URL, token), nil) + req, err = http.NewRequest(http.MethodGet, fmt.Sprintf("%s/api/v1/admin/deleteme?token=%s", ts.URL, tkn), nil) assert.Nil(t, err) - req.SetBasicAuth("dev", "bad-password") + req.SetBasicAuth("admin", "bad-password") resp, err = client.Do(req) assert.Nil(t, err) assert.Equal(t, 401, resp.StatusCode) @@ -599,23 +600,23 @@ func TestAdmin_DeleteMeRequestFailed(t *testing.T) { // try bad user badClaims := claims badClaims.User.ID = "no-such-id" - token, err = srv.Authenticator.JWTService.Token(&badClaims) + tkn, err = srv.Authenticator.TokenService().Token(badClaims) assert.Nil(t, err) - req, err = http.NewRequest(http.MethodGet, fmt.Sprintf("%s/api/v1/admin/deleteme?token=%s", ts.URL, token), nil) + req, err = http.NewRequest(http.MethodGet, fmt.Sprintf("%s/api/v1/admin/deleteme?token=%s", ts.URL, tkn), nil) assert.Nil(t, err) - req.SetBasicAuth("dev", "password") + req.SetBasicAuth("admin", "password") resp, err = client.Do(req) assert.Nil(t, err) assert.Equal(t, 400, resp.StatusCode, resp.Status) // try without deleteme flag badClaims2 := claims - badClaims2.Flags.DeleteMe = false - token, err = srv.Authenticator.JWTService.Token(&badClaims2) + badClaims2.User.SetBoolAttr("delete_me", false) + tkn, err = srv.Authenticator.TokenService().Token(badClaims2) assert.Nil(t, err) - req, err = http.NewRequest(http.MethodGet, fmt.Sprintf("%s/api/v1/admin/deleteme?token=%s", ts.URL, token), nil) + req, err = http.NewRequest(http.MethodGet, fmt.Sprintf("%s/api/v1/admin/deleteme?token=%s", ts.URL, tkn), nil) assert.Nil(t, err) - req.SetBasicAuth("dev", "password") + req.SetBasicAuth("admin", "password") resp, err = client.Do(req) assert.Nil(t, err) assert.Equal(t, 403, resp.StatusCode) @@ -625,9 +626,8 @@ func TestAdmin_DeleteMeRequestFailed(t *testing.T) { } func TestAdmin_GetUserInfo(t *testing.T) { - srv, ts := prep(t) - assert.NotNil(t, srv) - defer cleanup(ts, srv) + ts, srv, teardown := startupT(t) + defer teardown() c1 := store.Comment{Text: "test test #1", Locator: store.Locator{SiteID: "radio-t", URL: "https://radio-t.com/blah"}, User: store.User{Name: "user1 name", ID: "user1"}} @@ -639,7 +639,7 @@ func TestAdmin_GetUserInfo(t *testing.T) { _, err = srv.DataService.Create(c2) assert.Nil(t, err) - body, code := getWithAuth(t, fmt.Sprintf("%s/api/v1/admin/user/user1?site=radio-t&url=https://radio-t.com/blah", ts.URL)) + body, code := getWithAdminAuth(t, fmt.Sprintf("%s/api/v1/admin/user/user1?site=radio-t&url=https://radio-t.com/blah", ts.URL)) assert.Equal(t, 200, code) u := store.User{} err = json.Unmarshal([]byte(body), &u) @@ -650,6 +650,6 @@ func TestAdmin_GetUserInfo(t *testing.T) { _, code = get(t, fmt.Sprintf("%s/api/v1/admin/user/user1?site=radio-t&url=https://radio-t.com/blah", ts.URL)) assert.Equal(t, 401, code, "no auth") - _, code = getWithAuth(t, fmt.Sprintf("%s/api/v1/admin/user/userX?site=radio-t&url=https://radio-t.com/blah", ts.URL)) + _, code = getWithAdminAuth(t, fmt.Sprintf("%s/api/v1/admin/user/userX?site=radio-t&url=https://radio-t.com/blah", ts.URL)) assert.Equal(t, 400, code, "no info about user") } diff --git a/backend/app/rest/api/migrator.go b/backend/app/rest/api/migrator.go index 7dad7c04..90ec7bd6 100644 --- a/backend/app/rest/api/migrator.go +++ b/backend/app/rest/api/migrator.go @@ -14,6 +14,7 @@ import ( "github.com/go-chi/chi" "github.com/go-chi/render" + R "github.com/go-pkgz/rest" "github.com/go-pkgz/rest/cache" "github.com/pkg/errors" diff --git a/backend/app/rest/api/migrator_test.go b/backend/app/rest/api/migrator_test.go index b9440c5e..44642f30 100644 --- a/backend/app/rest/api/migrator_test.go +++ b/backend/app/rest/api/migrator_test.go @@ -15,14 +15,16 @@ import ( "testing" "time" + "github.com/go-pkgz/auth/token" + bolt "github.com/coreos/bbolt" "github.com/go-chi/chi" + "github.com/go-pkgz/auth" "github.com/go-pkgz/rest/cache" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/umputun/remark/backend/app/migrator" - "github.com/umputun/remark/backend/app/rest/auth" "github.com/umputun/remark/backend/app/store" adminstore "github.com/umputun/remark/backend/app/store/admin" "github.com/umputun/remark/backend/app/store/engine" @@ -38,7 +40,8 @@ func TestMigrator_Import(t *testing.T) { {"id":"83fd97fd-ff64-48d1-9fb7-ca7769c77037","pid":"p1","text":"

test test #2

","user":{"name":"developer one","id":"dev","picture":"/api/v1/avatar/remark.image","profile":"https://remark42.com","admin":true,"ip":"ae12fe3b5f129b5cc4cdd2b136b7b7947c4d2741"},"locator":{"site":"radio-t","url":"https://radio-t.com/blah2"},"score":0,"votes":{},"time":"2018-04-30T01:37:00.861387771-05:00"}`) client := &http.Client{Timeout: 1 * time.Second} - req, err := http.NewRequest("POST", ts.URL+"/import?site=radio-t&provider=native&secret=123456", r) + req, err := http.NewRequest("POST", ts.URL+"/import?site=radio-t&provider=native", r) + req.SetBasicAuth("admin", "password") assert.Nil(t, err) resp, err := client.Do(req) assert.Nil(t, err) @@ -50,7 +53,7 @@ func TestMigrator_Import(t *testing.T) { client = &http.Client{Timeout: 10 * time.Second} req, err = http.NewRequest("GET", ts.URL+"/import/wait?site=radio-t", nil) - req.SetBasicAuth("dev", "password") + req.SetBasicAuth("admin", "password") assert.NoError(t, err) resp, err = client.Do(req) assert.Equal(t, 200, resp.StatusCode) @@ -73,7 +76,8 @@ func TestMigrator_ImportForm(t *testing.T) { contentType := bodyWriter.FormDataContentType() require.NoError(t, bodyWriter.Close()) - resp, err := http.Post(ts.URL+"/import/form?site=radio-t&provider=native&secret=123456", contentType, bodyBuf) + authts := strings.Replace(ts.URL, "http://", "http://admin:password@", 1) + resp, err := http.Post(authts+"/import/form?site=radio-t&provider=native", contentType, bodyBuf) assert.Nil(t, err) assert.Equal(t, http.StatusAccepted, resp.StatusCode) @@ -83,7 +87,7 @@ func TestMigrator_ImportForm(t *testing.T) { client := &http.Client{Timeout: 10 * time.Second} req, err := http.NewRequest("GET", ts.URL+"/import/wait?site=radio-t", nil) - req.SetBasicAuth("dev", "password") + req.SetBasicAuth("admin", "password") assert.NoError(t, err) resp, err = client.Do(req) assert.Equal(t, 200, resp.StatusCode) @@ -96,9 +100,10 @@ func TestMigrator_ImportFromWP(t *testing.T) { r := strings.NewReader(strings.Replace(xmlTestWP, "'", "`", -1)) client := &http.Client{Timeout: 1 * time.Second} - req, err := http.NewRequest("POST", ts.URL+"/import?site=radio-t&provider=wordpress&secret=123456", r) + req, err := http.NewRequest("POST", ts.URL+"/import?site=radio-t&provider=wordpress", r) assert.Nil(t, err) req.Header.Add("Content-Type", "application/xml; charset=utf-8") + req.SetBasicAuth("admin", "password") resp, err := client.Do(req) assert.Nil(t, err) assert.Equal(t, http.StatusAccepted, resp.StatusCode) @@ -109,16 +114,15 @@ func TestMigrator_ImportFromWP(t *testing.T) { client = &http.Client{Timeout: 10 * time.Second} req, err = http.NewRequest("GET", ts.URL+"/import/wait?site=radio-t", nil) - req.SetBasicAuth("dev", "password") + req.SetBasicAuth("admin", "password") assert.NoError(t, err) resp, err = client.Do(req) assert.Equal(t, 200, resp.StatusCode) assert.NoError(t, ds.Interface.Close()) - srvAccess, tsAccess := prep(t) - require.NotNil(t, srvAccess) - defer cleanup(ts, srvAccess) + tsAccess, _, teardownAccess := startupT(t) + defer teardownAccess() res, code := get(t, tsAccess.URL+"/api/v1/last/10?site=radio-t") require.Equal(t, 200, code) @@ -159,14 +163,16 @@ func TestMigrator_ImportDouble(t *testing.T) { } r := strings.NewReader(`{"version":1}` + strings.Join(recs, "\n")) // reader with 10k records client := &http.Client{Timeout: 1 * time.Second} - req, err := http.NewRequest("POST", ts.URL+"/import?site=radio-t&provider=native&secret=123456", r) + req, err := http.NewRequest("POST", ts.URL+"/import?site=radio-t&provider=native", r) + req.SetBasicAuth("admin", "password") assert.Nil(t, err) resp, err := client.Do(req) assert.Nil(t, err) assert.Equal(t, http.StatusAccepted, resp.StatusCode) client = &http.Client{Timeout: 1 * time.Second} - req, err = http.NewRequest("POST", ts.URL+"/import?site=radio-t&provider=native&secret=123456", r) + req, err = http.NewRequest("POST", ts.URL+"/import?site=radio-t&provider=native", r) + req.SetBasicAuth("admin", "password") assert.Nil(t, err) resp, err = client.Do(req) assert.Nil(t, err) @@ -181,12 +187,13 @@ func TestMigrator_ImportWaitExpired(t *testing.T) { tmpl := `{"id":"%d","pid":"","text":"

test test #1

","user":{"name":"developer one","id":"dev","picture":"/api/v1/avatar/remark.image","profile":"https://remark42.com","admin":true,"ip":"ae12fe3b5f129b5cc4cdd2b136b7b7947c4d2741"},"locator":{"site":"radio-t","url":"https://radio-t.com/blah1"},"score":0,"votes":{},"time":"2018-04-30T01:37:00.849053725-05:00"}` recs := []string{} - for i := 0; i < 1000; i++ { + for i := 0; i < 5000; i++ { recs = append(recs, fmt.Sprintf(tmpl, i)) } r := strings.NewReader(`{"version":1}` + strings.Join(recs, "\n")) // reader with 10k records client := &http.Client{Timeout: 1 * time.Second} - req, err := http.NewRequest("POST", ts.URL+"/import?site=radio-t&provider=native&secret=123456", r) + req, err := http.NewRequest("POST", ts.URL+"/import?site=radio-t&provider=native", r) + req.SetBasicAuth("admin", "password") require.Nil(t, err) resp, err := client.Do(req) assert.Nil(t, err) @@ -194,7 +201,7 @@ func TestMigrator_ImportWaitExpired(t *testing.T) { client = &http.Client{Timeout: 10 * time.Second} req, err = http.NewRequest("GET", ts.URL+"/import/wait?site=radio-t&timeout=100ms", nil) - req.SetBasicAuth("dev", "password") + req.SetBasicAuth("admin", "password") assert.NoError(t, err) resp, err = client.Do(req) assert.Equal(t, http.StatusGatewayTimeout, resp.StatusCode) @@ -210,21 +217,23 @@ func TestMigrator_Export(t *testing.T) { // import comments first client := &http.Client{Timeout: 1 * time.Second} - req, err := http.NewRequest("POST", ts.URL+"/import?site=radio-t&provider=native&secret=123456", r) + req, err := http.NewRequest("POST", ts.URL+"/import?site=radio-t&provider=native", r) require.Nil(t, err) + req.SetBasicAuth("admin", "password") resp, err := client.Do(req) require.Nil(t, err) require.Equal(t, http.StatusAccepted, resp.StatusCode) client = &http.Client{Timeout: 10 * time.Second} req, err = http.NewRequest("GET", ts.URL+"/import/wait?site=radio-t", nil) - req.SetBasicAuth("dev", "password") + req.SetBasicAuth("admin", "password") assert.NoError(t, err) resp, err = client.Do(req) assert.Equal(t, 200, resp.StatusCode) // check file mode - req, err = http.NewRequest("GET", ts.URL+"/export?mode=file&site=radio-t&secret=123456", nil) + req, err = http.NewRequest("GET", ts.URL+"/export?mode=file&site=radio-t", nil) require.Nil(t, err) + req.SetBasicAuth("admin", "password") resp, err = client.Do(req) require.Nil(t, err) require.Equal(t, 200, resp.StatusCode) @@ -239,8 +248,9 @@ func TestMigrator_Export(t *testing.T) { t.Logf("%s", string(ungzBody)) // check stream mode - req, err = http.NewRequest("GET", ts.URL+"/export?mode=stream&site=radio-t&secret=123456", nil) + req, err = http.NewRequest("GET", ts.URL+"/export?mode=stream&site=radio-t", nil) require.Nil(t, err) + req.SetBasicAuth("admin", "password") resp, err = client.Do(req) require.Nil(t, err) require.Equal(t, 200, resp.StatusCode) @@ -252,7 +262,7 @@ func TestMigrator_Export(t *testing.T) { assert.Equal(t, 2, strings.Count(string(body), "\"text\"")) t.Logf("%s", string(body)) - req, err = http.NewRequest("GET", ts.URL+"/export?site=radio-t&secret=bad", nil) + req, err = http.NewRequest("GET", ts.URL+"/export?site=radio-t", nil) require.Nil(t, err) resp, err = client.Do(req) require.Nil(t, err) @@ -272,13 +282,15 @@ func prepImportSrv(t *testing.T) (svc *Migrator, ds *service.DataStore, ts *http Cache: &cache.Nop{}, KeyStore: adminStore, } - a := auth.Authenticator{ - DevPasswd: "password", - Providers: nil, - KeyStore: adminStore, - JWTService: auth.NewJWT(adminStore, false, time.Minute, time.Hour), - } - routes := svc.withRoutes(chi.NewRouter().With(a.Auth(true)).With(a.AdminOnly)) + + a := auth.NewService(auth.Opts{ + AdminPasswd: "password", + SecretReader: token.SecretFunc(func(id string) (string, error) { return "123456", nil }), + Issuer: "test", + }) + + am := a.Middleware() + routes := svc.withRoutes(chi.NewRouter().With(am.Auth).With(am.AdminOnly)) ts = httptest.NewServer(routes) return svc, dataStore, ts } diff --git a/backend/app/rest/api/rest.go b/backend/app/rest/api/rest.go index 5544e500..4ab25249 100644 --- a/backend/app/rest/api/rest.go +++ b/backend/app/rest/api/rest.go @@ -19,15 +19,16 @@ import ( "github.com/go-chi/chi/middleware" "github.com/go-chi/cors" "github.com/go-chi/render" - R "github.com/go-pkgz/rest" - "github.com/go-pkgz/rest/cache" - "github.com/go-pkgz/rest/logger" "github.com/pkg/errors" "github.com/rakyll/statik/fs" + "github.com/go-pkgz/auth" + R "github.com/go-pkgz/rest" + "github.com/go-pkgz/rest/cache" + "github.com/go-pkgz/rest/logger" + "github.com/umputun/remark/backend/app/notify" "github.com/umputun/remark/backend/app/rest" - "github.com/umputun/remark/backend/app/rest/auth" "github.com/umputun/remark/backend/app/rest/proxy" "github.com/umputun/remark/backend/app/store" "github.com/umputun/remark/backend/app/store/service" @@ -38,9 +39,8 @@ type Rest struct { Version string DataService *service.DataStore - Authenticator auth.Authenticator + Authenticator *auth.Service Cache cache.LoadingCache - AvatarProxy *proxy.Avatar ImageProxy *proxy.Image CommentFormatter *store.CommentFormatter Migrator *Migrator @@ -166,7 +166,6 @@ func (s *Rest) routes() chi.Router { cache: s.Cache, authenticator: s.Authenticator, readOnlyAge: s.ReadOnlyAge, - avatarProxy: s.AvatarProxy, } corsMiddleware := cors.New(cors.Options{ @@ -181,32 +180,53 @@ func (s *Rest) routes() chi.Router { ipFn := func(ip string) string { return store.HashValue(ip, s.SharedSecret)[:12] } // logger uses it for anonymization - // auth routes for all providers - router.Route("/auth", func(r chi.Router) { + authHandler, avatarHandler := s.Authenticator.Handlers() + + router.Group(func(r chi.Router) { l := logger.New(logger.Flags(logger.All), logger.IPfn(ipFn)) r.Use(l.Handler, tollbooth_chi.LimitHandler(tollbooth.NewLimiter(5, nil))) - for _, provider := range s.Authenticator.Providers { - r.Mount("/"+provider.Name, provider.Routes()) // mount auth providers as /auth/{name} - } - if len(s.Authenticator.Providers) > 0 { - // shortcut, can be any of providers, all logouts do the same - removes cookie - r.Get("/logout", s.Authenticator.Providers[0].LogoutHandler) - } + r.Mount("/auth", authHandler) }) - avatarMiddlewares := []func(http.Handler) http.Handler{ - logger.New(logger.Flags(logger.None)).Handler, - tollbooth_chi.LimitHandler(tollbooth.NewLimiter(100, nil)), - } - router.Mount(s.AvatarProxy.Routes(avatarMiddlewares...)) // mount avatars to /api/v1/avatar/{file.img} + router.Group(func(r chi.Router) { + r.Use(logger.New(logger.Flags(logger.None)).Handler, tollbooth_chi.LimitHandler(tollbooth.NewLimiter(100, nil))) + r.Mount("/avatar", avatarHandler) + }) + + authMiddleware := s.Authenticator.Middleware() + + //// auth routes for all providers + //router.Route("/auth", func(r chi.Router) { + // l := logger.New(logger.Flags(logger.All), logger.IPfn(ipFn)) + // r.Use(l.Handler, tollbooth_chi.LimitHandler(tollbooth.NewLimiter(5, nil))) + // + // for _, provider := range s.Authenticator.Providers { + // r.Mount("/"+provider.Name, provider.Routes()) // mount auth providers as /auth/{name} + // } + // if len(s.Authenticator.Providers) > 0 { + // // shortcut, can be any of providers, all logouts do the same - removes cookie + // r.Get("/logout", s.Authenticator.Providers[0].LogoutHandler) + // } + //}) + + //avatarMiddlewares := []func(http.Handler) http.Handler{ + // logger.New(logger.Flags(logger.None)).Handler, + // tollbooth_chi.LimitHandler(tollbooth.NewLimiter(100, nil)), + //} + //router.Mount(s.AvatarProxy.Routes(avatarMiddlewares...)) // mount avatars to /api/v1/avatar/{file.img} // api routes router.Route("/api/v1", func(rapi chi.Router) { - rapi.Use(tollbooth_chi.LimitHandler(tollbooth.NewLimiter(10, nil))) + + rapi.Group(func(rava chi.Router) { + rava.Use(logger.New(logger.Flags(logger.None)).Handler, tollbooth_chi.LimitHandler(tollbooth.NewLimiter(100, nil))) + rava.Mount("/avatar", avatarHandler) + }) // open routes rapi.Group(func(ropen chi.Router) { - ropen.Use(s.Authenticator.Auth(false)) + ropen.Use(tollbooth_chi.LimitHandler(tollbooth.NewLimiter(10, nil))) + ropen.Use(authMiddleware.Trace) ropen.Use(logger.New(logger.Flags(logger.All), logger.IPfn(ipFn)).Handler) ropen.Get("/find", s.findCommentsCtrl) ropen.Get("/id/{id}", s.commentByIDCtrl) @@ -225,7 +245,8 @@ func (s *Rest) routes() chi.Router { // protected routes, require auth rapi.Group(func(rauth chi.Router) { - rauth.Use(s.Authenticator.Auth(true)) + rauth.Use(tollbooth_chi.LimitHandler(tollbooth.NewLimiter(10, nil))) + rauth.Use(authMiddleware.Auth) rauth.Use(logger.New(logger.Flags(logger.All), logger.IPfn(ipFn)).Handler) rauth.Post("/comment", s.createCommentCtrl) rauth.Put("/comment/{id}", s.updateCommentCtrl) @@ -235,11 +256,11 @@ func (s *Rest) routes() chi.Router { rauth.Post("/deleteme", s.deleteMeCtrl) // admin routes, admin users only - rauth.Mount("/admin", s.adminService.routes(s.Authenticator.AdminOnly)) + rauth.Mount("/admin", s.adminService.routes(authMiddleware.AdminOnly)) }) }) - // respond to /robots.tx with the list of allowed paths + // respond to /robots.txt with the list of allowed paths router.With(tollbooth_chi.LimitHandler(tollbooth.NewLimiter(50, nil))). Get("/robots.txt", func(w http.ResponseWriter, r *http.Request) { allowed := []string{"/find", "/last", "/id", "/count", "/counts", "/list", "/config", "/img", "/avatar"} diff --git a/backend/app/rest/api/rest_private.go b/backend/app/rest/api/rest_private.go index ceb1c4ff..0006b01b 100644 --- a/backend/app/rest/api/rest_private.go +++ b/backend/app/rest/api/rest_private.go @@ -10,15 +10,16 @@ import ( "strings" "time" - "github.com/dgrijalva/jwt-go" + jwt "github.com/dgrijalva/jwt-go" "github.com/go-chi/chi" "github.com/go-chi/render" + multierror "github.com/hashicorp/go-multierror" + + "github.com/go-pkgz/auth/token" R "github.com/go-pkgz/rest" "github.com/go-pkgz/rest/cache" - "github.com/hashicorp/go-multierror" "github.com/umputun/remark/backend/app/rest" - "github.com/umputun/remark/backend/app/rest/auth" "github.com/umputun/remark/backend/app/store" "github.com/umputun/remark/backend/app/store/service" ) @@ -231,18 +232,23 @@ func (s *Rest) deleteMeCtrl(w http.ResponseWriter, r *http.Request) { user := rest.MustGetUserInfo(r) siteID := r.URL.Query().Get("site") - claims := auth.CustomClaims{ - SiteID: siteID, + claims := token.Claims{ StandardClaims: jwt.StandardClaims{ + Audience: siteID, Issuer: "remark42", ExpiresAt: time.Now().AddDate(0, 3, 0).Unix(), NotBefore: time.Now().Add(-1 * time.Minute).Unix(), }, - User: &user, + User: &token.User{ + ID: user.ID, + Name: user.Name, + Attributes: map[string]interface{}{ + "delete_me": true, // prevents this token from being used for login + }, + }, } - claims.Flags.DeleteMe = true // prevent this token from being used for login - tokenStr, err := s.Authenticator.JWTService.Token(&claims) + tokenStr, err := s.Authenticator.TokenService().Token(claims) if err != nil { rest.SendErrorJSON(w, r, http.StatusInternalServerError, err, "can't make token") return diff --git a/backend/app/rest/api/rest_private_test.go b/backend/app/rest/api/rest_private_test.go index 9f439580..fcf201d0 100644 --- a/backend/app/rest/api/rest_private_test.go +++ b/backend/app/rest/api/rest_private_test.go @@ -18,9 +18,8 @@ import ( ) func TestRest_Create(t *testing.T) { - srv, ts := prep(t) - require.NotNil(t, srv) - defer cleanup(ts, srv) + ts, _, teardown := startupT(t) + defer teardown() resp, err := post(t, ts.URL+"/api/v1/comment", `{"text": "test 123", "locator":{"url": "https://radio-t.com/blah1", "site": "radio-t"}}`) @@ -29,6 +28,7 @@ func TestRest_Create(t *testing.T) { assert.Nil(t, err) require.Equal(t, http.StatusCreated, resp.StatusCode, string(b)) + t.Log(string(b)) c := R.JSON{} err = json.Unmarshal(b, &c) assert.Nil(t, err) @@ -39,9 +39,8 @@ func TestRest_Create(t *testing.T) { } func TestRest_CreateOldPost(t *testing.T) { - srv, ts := prep(t) - require.NotNil(t, srv) - defer cleanup(ts, srv) + ts, srv, teardown := startupT(t) + defer teardown() // make old, but not too old comment old := store.Comment{Text: "test test old", ParentID: "", Timestamp: time.Now().AddDate(0, 0, -5), @@ -73,9 +72,8 @@ func TestRest_CreateOldPost(t *testing.T) { } func TestRest_CreateTooBig(t *testing.T) { - srv, ts := prep(t) - require.NotNil(t, srv) - defer cleanup(ts, srv) + ts, _, teardown := startupT(t) + defer teardown() longComment := fmt.Sprintf(`{"text": "%4001s", "locator":{"url": "https://radio-t.com/blah1", "site": "radio-t"}}`, "Щ") @@ -105,9 +103,8 @@ func TestRest_CreateTooBig(t *testing.T) { func TestRest_CreateRejected(t *testing.T) { - srv, ts := prep(t) - require.NotNil(t, srv) - defer cleanup(ts, srv) + ts, _, teardown := startupT(t) + defer teardown() body := `{"text": "test 123", "locator":{"url": "https://radio-t.com/blah1", "site": "radio-t"}}` // try to create without auth @@ -117,9 +114,8 @@ func TestRest_CreateRejected(t *testing.T) { } func TestRest_CreateAndGet(t *testing.T) { - srv, ts := prep(t) - assert.NotNil(t, srv) - defer cleanup(ts, srv) + ts, _, teardown := startupT(t) + defer teardown() // create comment resp, err := post(t, ts.URL+"/api/v1/comment", @@ -134,24 +130,31 @@ func TestRest_CreateAndGet(t *testing.T) { id := c["id"].(string) - // get created comment by id - res, code := getWithAuth(t, fmt.Sprintf("%s/api/v1/id/%s?site=radio-t&url=https://radio-t.com/blah1", ts.URL, id)) + // get created comment by id as admin + res, code := getWithAdminAuth(t, fmt.Sprintf("%s/api/v1/id/%s?site=radio-t&url=https://radio-t.com/blah1", ts.URL, id)) assert.Equal(t, 200, code) comment := store.Comment{} err = json.Unmarshal([]byte(res), &comment) assert.Nil(t, err) assert.Equal(t, "

test 123

\n\n

http://radio-t.com

\n", comment.Text) assert.Equal(t, "**test** *123*\n\n http://radio-t.com", comment.Orig) - assert.Equal(t, store.User{Name: "developer one", ID: "dev", - Picture: "/api/v1/avatar/remark.image", Admin: true, Blocked: false, IP: "dbc7c999343f003f189f70aaf52cc04443f90790"}, + assert.Equal(t, store.User{Name: "admin", ID: "admin", Admin: true, Blocked: false, + IP: "dbc7c999343f003f189f70aaf52cc04443f90790"}, comment.User) t.Logf("%+v", comment) + + // get created comment by id as non-admin + res, code = getWithDevAuth(t, fmt.Sprintf("%s/api/v1/id/%s?site=radio-t&url=https://radio-t.com/blah1", ts.URL, id)) + assert.Equal(t, 200, code) + comment = store.Comment{} + err = json.Unmarshal([]byte(res), &comment) + assert.Nil(t, err) + assert.Equal(t, store.User{Name: "admin", ID: "admin", Admin: true, Blocked: false, IP: ""}, comment.User, "no ip") } func TestRest_Update(t *testing.T) { - srv, ts := prep(t) - assert.NotNil(t, srv) - defer cleanup(ts, srv) + ts, _, teardown := startupT(t) + defer teardown() c1 := store.Comment{Text: "test test #1", ParentID: "p1", Locator: store.Locator{SiteID: "radio-t", URL: "https://radio-t.com/blah1"}} @@ -161,7 +164,7 @@ func TestRest_Update(t *testing.T) { req, err := http.NewRequest(http.MethodPut, ts.URL+"/api/v1/comment/"+id+"?site=radio-t&url=https://radio-t.com/blah1", strings.NewReader(`{"text":"updated text", "summary":"my edit"}`)) assert.Nil(t, err) - req.SetBasicAuth("dev", "password") + req.Header.Add("X-JWT", devToken) b, err := client.Do(req) assert.Nil(t, err) body, err := ioutil.ReadAll(b.Body) @@ -179,7 +182,7 @@ func TestRest_Update(t *testing.T) { assert.True(t, time.Since(c2.Edit.Timestamp) < 1*time.Second) // read updated comment - res, code := getWithAuth(t, fmt.Sprintf("%s/api/v1/id/%s?site=radio-t&url=https://radio-t.com/blah1", ts.URL, id)) + res, code := getWithAdminAuth(t, fmt.Sprintf("%s/api/v1/id/%s?site=radio-t&url=https://radio-t.com/blah1", ts.URL, id)) assert.Equal(t, 200, code) c3 := store.Comment{} err = json.Unmarshal([]byte(res), &c3) @@ -188,9 +191,8 @@ func TestRest_Update(t *testing.T) { } func TestRest_UpdateDelete(t *testing.T) { - srv, ts := prep(t) - assert.NotNil(t, srv) - defer cleanup(ts, srv) + ts, _, teardown := startupT(t) + defer teardown() c1 := store.Comment{Text: "test test #1", ParentID: "p1", Locator: store.Locator{SiteID: "radio-t", URL: "https://radio-t.com/blah1"}} @@ -200,7 +202,7 @@ func TestRest_UpdateDelete(t *testing.T) { req, err := http.NewRequest(http.MethodPut, ts.URL+"/api/v1/comment/"+id+"?site=radio-t&url=https://radio-t.com/blah1", strings.NewReader(`{"delete": true, "summary":"removed by user"}`)) assert.Nil(t, err) - req.SetBasicAuth("dev", "password") + req.Header.Add("X-JWT", devToken) b, err := client.Do(req) assert.Nil(t, err) body, err := ioutil.ReadAll(b.Body) @@ -215,7 +217,7 @@ func TestRest_UpdateDelete(t *testing.T) { assert.True(t, c2.Deleted) // read updated comment - res, code := getWithAuth(t, fmt.Sprintf("%s/api/v1/id/%s?site=radio-t&url=https://radio-t.com/blah1", ts.URL, id)) + res, code := getWithDevAuth(t, fmt.Sprintf("%s/api/v1/id/%s?site=radio-t&url=https://radio-t.com/blah1", ts.URL, id)) assert.Equal(t, 200, code) c3 := store.Comment{} err = json.Unmarshal([]byte(res), &c3) @@ -223,13 +225,11 @@ func TestRest_UpdateDelete(t *testing.T) { assert.Equal(t, "", c3.Text) assert.Equal(t, "", c3.Orig) assert.True(t, c3.Deleted) - } func TestRest_UpdateNotOwner(t *testing.T) { - srv, ts := prep(t) - assert.NotNil(t, srv) - defer cleanup(ts, srv) + ts, srv, teardown := startupT(t) + defer teardown() c1 := store.Comment{Text: "test test #1", ParentID: "p1", Locator: store.Locator{SiteID: "radio-t", URL: "https://radio-t.com/blah1"}, User: store.User{ID: "xyz"}} @@ -240,7 +240,7 @@ func TestRest_UpdateNotOwner(t *testing.T) { req, err := http.NewRequest(http.MethodPut, ts.URL+"/api/v1/comment/"+id1+ "?site=radio-t&url=https://radio-t.com/blah1", strings.NewReader(`{"text":"updated text", "summary":"my edit"}`)) assert.Nil(t, err) - req.SetBasicAuth("dev", "password") + req.Header.Add("X-JWT", devToken) b, err := client.Do(req) assert.Nil(t, err) body, err := ioutil.ReadAll(b.Body) @@ -252,16 +252,15 @@ func TestRest_UpdateNotOwner(t *testing.T) { req, err = http.NewRequest(http.MethodPut, ts.URL+"/api/v1/comment/"+id1+ "?site=radio-t&url=https://radio-t.com/blah1", strings.NewReader(`ERRR "text":"updated text", "summary":"my"}`)) assert.Nil(t, err) - req.SetBasicAuth("dev", "password") + req.Header.Add("X-JWT", devToken) b, err = client.Do(req) assert.Nil(t, err) assert.Equal(t, 400, b.StatusCode, string(body), "update is not json") } func TestRest_Vote(t *testing.T) { - srv, ts := prep(t) - assert.NotNil(t, srv) - defer cleanup(ts, srv) + ts, _, teardown := startupT(t) + defer teardown() c1 := store.Comment{Text: "test test #1", Locator: store.Locator{SiteID: "radio-t", URL: "https://radio-t.com/blah"}} @@ -276,7 +275,7 @@ func TestRest_Vote(t *testing.T) { req, err := http.NewRequest(http.MethodPut, fmt.Sprintf("%s/api/v1/vote/%s?site=radio-t&url=https://radio-t.com/blah&vote=%d", ts.URL, id1, val), nil) assert.Nil(t, err) - req.SetBasicAuth("dev", "password") + req.SetBasicAuth("admin", "password") resp, err := client.Do(req) assert.Nil(t, err) return resp.StatusCode @@ -290,7 +289,7 @@ func TestRest_Vote(t *testing.T) { err := json.Unmarshal([]byte(body), &cr) assert.Nil(t, err) assert.Equal(t, 1, cr.Score) - assert.Equal(t, map[string]bool{"dev": true}, cr.Votes) + assert.Equal(t, map[string]bool{"admin": true}, cr.Votes) assert.Equal(t, 200, vote(-1), "opposite vote allowed") body, code = get(t, fmt.Sprintf("%s/api/v1/id/%s?site=radio-t&url=https://radio-t.com/blah", ts.URL, id1)) @@ -303,9 +302,8 @@ func TestRest_Vote(t *testing.T) { } func TestRest_UserAllData(t *testing.T) { - srv, ts := prep(t) - assert.NotNil(t, srv) - defer cleanup(ts, srv) + ts, srv, teardown := startupT(t) + defer teardown() // write 3 comments user := store.User{ID: "dev", Name: "user name 1"} @@ -325,7 +323,7 @@ func TestRest_UserAllData(t *testing.T) { client := &http.Client{Timeout: 1 * time.Second} req, err := http.NewRequest("GET", ts.URL+"/api/v1/userdata?site=radio-t", nil) require.Nil(t, err) - req.SetBasicAuth("dev", "password") + req.Header.Add("X-JWT", devToken) resp, err := client.Do(req) require.Nil(t, err) require.Equal(t, 200, resp.StatusCode) @@ -336,7 +334,7 @@ func TestRest_UserAllData(t *testing.T) { ungzBody, err := ioutil.ReadAll(ungzReader) assert.NoError(t, err) assert.True(t, strings.HasPrefix(string(ungzBody), - `{"info": {"name":"developer one","id":"dev","picture":"/api/v1/avatar/remark.image","admin":true}, "comments":[{`)) + `{"info": {"name":"developer one","id":"dev","picture":"http://example.com/pic.png","ip":"127.0.0.1","admin":false}, "comments":[{`)) assert.Equal(t, 3, strings.Count(string(ungzBody), `"text":`), "3 comments inside") t.Logf("%s", string(ungzBody)) @@ -347,7 +345,8 @@ func TestRest_UserAllData(t *testing.T) { err = json.Unmarshal(ungzBody, &parsed) assert.Nil(t, err) - assert.Equal(t, store.User{Name: "developer one", ID: "dev", Picture: "/api/v1/avatar/remark.image", Admin: true}, parsed.Info) + assert.Equal(t, store.User{Name: "developer one", ID: "dev", + Picture: "http://example.com/pic.png", IP: "127.0.0.1"}, parsed.Info) assert.Equal(t, 3, len(parsed.Comments)) req, err = http.NewRequest("GET", ts.URL+"/api/v1/userdata?site=radio-t", nil) @@ -358,9 +357,8 @@ func TestRest_UserAllData(t *testing.T) { } func TestRest_UserAllDataManyComments(t *testing.T) { - srv, ts := prep(t) - assert.NotNil(t, srv) - defer cleanup(ts, srv) + ts, srv, teardown := startupT(t) + defer teardown() user := store.User{ID: "dev", Name: "user name 1"} c := store.Comment{User: user, Text: "test test #1", Locator: store.Locator{SiteID: "radio-t", @@ -376,7 +374,7 @@ func TestRest_UserAllDataManyComments(t *testing.T) { client := &http.Client{Timeout: 1 * time.Second} req, err := http.NewRequest("GET", ts.URL+"/api/v1/userdata?site=radio-t", nil) require.Nil(t, err) - req.SetBasicAuth("dev", "password") + req.Header.Add("X-JWT", devToken) resp, err := client.Do(req) require.Nil(t, err) require.Equal(t, 200, resp.StatusCode) @@ -387,19 +385,18 @@ func TestRest_UserAllDataManyComments(t *testing.T) { ungzBody, err := ioutil.ReadAll(ungzReader) assert.NoError(t, err) assert.True(t, strings.HasPrefix(string(ungzBody), - `{"info": {"name":"developer one","id":"dev","picture":"/api/v1/avatar/remark.image","admin":true}, "comments":[{`)) + `{"info": {"name":"developer one","id":"dev","picture":"http://example.com/pic.png","ip":"127.0.0.1","admin":false}, "comments":[{`)) assert.Equal(t, 478, strings.Count(string(ungzBody), `"text":`), "478 comments inside") } func TestRest_DeleteMe(t *testing.T) { - srv, ts := prep(t) - assert.NotNil(t, srv) - defer cleanup(ts, srv) + ts, srv, teardown := startupT(t) + defer teardown() client := http.Client{} req, err := http.NewRequest(http.MethodPost, fmt.Sprintf("%s/api/v1/deleteme?site=radio-t", ts.URL), nil) assert.Nil(t, err) - req.SetBasicAuth("dev", "password") + req.Header.Add("X-JWT", devToken) resp, err := client.Do(req) assert.Nil(t, err) assert.Equal(t, 200, resp.StatusCode) @@ -413,7 +410,7 @@ func TestRest_DeleteMe(t *testing.T) { assert.Equal(t, "dev", m["user_id"]) token := m["token"] - claims, err := srv.Authenticator.JWTService.Parse(token) + claims, err := srv.Authenticator.TokenService().Parse(token) assert.Nil(t, err) assert.Equal(t, "dev", claims.User.ID) assert.Equal(t, "https://demo.remark42.com/web/deleteme.html?token="+token, m["link"]) diff --git a/backend/app/rest/api/rest_public.go b/backend/app/rest/api/rest_public.go index 77644c3b..cfba32f4 100644 --- a/backend/app/rest/api/rest_public.go +++ b/backend/app/rest/api/rest_public.go @@ -10,6 +10,7 @@ import ( "github.com/go-chi/chi" "github.com/go-chi/render" + R "github.com/go-pkgz/rest" "github.com/go-pkgz/rest/cache" @@ -58,7 +59,7 @@ func (s *Rest) findCommentsCtrl(w http.ResponseWriter, r *http.Request) { } if err = R.RenderJSONFromBytes(w, r, data); err != nil { - log.Printf("[WARN] can't render comments for post %+v",locator) + log.Printf("[WARN] can't render comments for post %+v", locator) } } @@ -106,7 +107,7 @@ func (s *Rest) infoCtrl(w http.ResponseWriter, r *http.Request) { } if err = R.RenderJSONFromBytes(w, r, data); err != nil { - log.Printf("[WARN] can't render info for post %+v",locator) + log.Printf("[WARN] can't render info for post %+v", locator) } } @@ -138,7 +139,7 @@ func (s *Rest) lastCommentsCtrl(w http.ResponseWriter, r *http.Request) { } if err = R.RenderJSONFromBytes(w, r, data); err != nil { - log.Printf("[WARN] can't render last comments for site %s",siteID) + log.Printf("[WARN] can't render last comments for site %s", siteID) } } @@ -160,7 +161,7 @@ func (s *Rest) commentByIDCtrl(w http.ResponseWriter, r *http.Request) { render.Status(r, http.StatusOK) if err = R.RenderJSONWithHTML(w, r, comment); err != nil { - log.Printf("[WARN] can't render last comments for url=%s, id=%s",url, id) + log.Printf("[WARN] can't render last comments for url=%s, id=%s", url, id) } } @@ -236,8 +237,8 @@ func (s *Rest) configCtrl(w http.ResponseWriter, r *http.Request) { } cnf.Auth = []string{} - for _, ap := range s.Authenticator.Providers { - cnf.Auth = append(cnf.Auth, ap.Name) + for _, ap := range s.Authenticator.Providers() { + cnf.Auth = append(cnf.Auth, ap.Name()) } if cnf.Admins == nil { // prevent json serialization to nil @@ -290,7 +291,7 @@ func (s *Rest) countMultiCtrl(w http.ResponseWriter, r *http.Request) { } if err = R.RenderJSONFromBytes(w, r, data); err != nil { - log.Printf("[WARN] can't render comments counters site %s",siteID) + log.Printf("[WARN] can't render comments counters site %s", siteID) } } @@ -322,6 +323,6 @@ func (s *Rest) listCtrl(w http.ResponseWriter, r *http.Request) { } if err = R.RenderJSONFromBytes(w, r, data); err != nil { - log.Printf("[WARN] can't render posts lits for site %s",siteID) + log.Printf("[WARN] can't render posts lits for site %s", siteID) } } diff --git a/backend/app/rest/api/rest_public_test.go b/backend/app/rest/api/rest_public_test.go index 042ba13f..48760bdc 100644 --- a/backend/app/rest/api/rest_public_test.go +++ b/backend/app/rest/api/rest_public_test.go @@ -18,9 +18,8 @@ import ( ) func TestRest_Ping(t *testing.T) { - srv, ts := prep(t) - require.NotNil(t, srv) - defer cleanup(ts, srv) + ts, _, teardown := startupT(t) + defer teardown() res, code := get(t, ts.URL+"/api/v1/ping") assert.Equal(t, "pong", res) @@ -28,9 +27,8 @@ func TestRest_Ping(t *testing.T) { } func TestRest_Preview(t *testing.T) { - srv, ts := prep(t) - require.NotNil(t, srv) - defer cleanup(ts, srv) + ts, _, teardown := startupT(t) + defer teardown() resp, err := post(t, ts.URL+"/api/v1/preview", `{"text": "test 123", "locator":{"url": "https://radio-t.com/blah1", "site": "radio-t"}}`) assert.Nil(t, err) @@ -41,9 +39,8 @@ func TestRest_Preview(t *testing.T) { } func TestRest_PreviewWithMD(t *testing.T) { - srv, ts := prep(t) - require.NotNil(t, srv) - defer cleanup(ts, srv) + ts, _, teardown := startupT(t) + defer teardown() text := ` # h1 @@ -69,9 +66,8 @@ BKT } func TestRest_Find(t *testing.T) { - srv, ts := prep(t) - assert.NotNil(t, srv) - defer cleanup(ts, srv) + ts, _, teardown := startupT(t) + defer teardown() _, code := get(t, ts.URL+"/api/v1/find?site=radio-t&url=https://radio-t.com/blah1") assert.Equal(t, 400, code, "nothing in") @@ -123,9 +119,8 @@ func TestRest_Find(t *testing.T) { } func TestRest_FindAge(t *testing.T) { - srv, ts := prep(t) - assert.NotNil(t, srv) - defer cleanup(ts, srv) + ts, srv, teardown := startupT(t) + defer teardown() c1 := store.Comment{Text: "test test #1", ParentID: "", Timestamp: time.Now().AddDate(0, 0, -5), Locator: store.Locator{SiteID: "radio-t", URL: "https://radio-t.com/blah1"}, User: store.User{ID: "u1"}} @@ -155,9 +150,8 @@ func TestRest_FindAge(t *testing.T) { } func TestRest_FindReadOnly(t *testing.T) { - srv, ts := prep(t) - assert.NotNil(t, srv) - defer cleanup(ts, srv) + ts, srv, teardown := startupT(t) + defer teardown() c1 := store.Comment{Text: "test test #1", ParentID: "", Timestamp: time.Now().AddDate(0, 0, -1), Locator: store.Locator{SiteID: "radio-t", URL: "https://radio-t.com/blah1"}, User: store.User{ID: "u1"}} @@ -175,7 +169,7 @@ func TestRest_FindReadOnly(t *testing.T) { req, err := http.NewRequest(http.MethodPut, fmt.Sprintf("%s/api/v1/admin/readonly?site=radio-t&url=https://radio-t.com/blah1&ro=1", ts.URL), nil) assert.Nil(t, err) - req.SetBasicAuth("dev", "password") + req.SetBasicAuth("admin", "password") _, err = client.Do(req) require.Nil(t, err) @@ -197,9 +191,8 @@ func TestRest_FindReadOnly(t *testing.T) { } func TestRest_Last(t *testing.T) { - srv, ts := prep(t) - assert.NotNil(t, srv) - defer cleanup(ts, srv) + ts, srv, teardown := startupT(t) + defer teardown() c1 := store.Comment{Text: "test test #1", ParentID: "p1", Locator: store.Locator{SiteID: "radio-t", URL: "https://radio-t.com/blah1"}} @@ -243,9 +236,8 @@ func TestRest_Last(t *testing.T) { } func TestRest_FindUserComments(t *testing.T) { - srv, ts := prep(t) - assert.NotNil(t, srv) - defer cleanup(ts, srv) + ts, srv, teardown := startupT(t) + defer teardown() c1 := store.Comment{Text: "test test #1", Locator: store.Locator{SiteID: "radio-t", URL: "https://radio-t.com/blah1"}} @@ -280,23 +272,20 @@ func TestRest_FindUserComments(t *testing.T) { } func TestRest_UserInfo(t *testing.T) { - srv, ts := prep(t) - assert.NotNil(t, srv) - defer cleanup(ts, srv) + ts, _, teardown := startupT(t) + defer teardown() - body, code := getWithAuth(t, ts.URL+"/api/v1/user?site=radio-t") + body, code := getWithDevAuth(t, ts.URL+"/api/v1/user?site=radio-t") assert.Equal(t, 200, code) user := store.User{} err := json.Unmarshal([]byte(body), &user) assert.Nil(t, err) - assert.Equal(t, store.User{Name: "developer one", ID: "dev", - Picture: "/api/v1/avatar/remark.image", Admin: true, Blocked: false, IP: ""}, user) + assert.Equal(t, store.User{Name: "developer one", ID: "dev", Picture: "http://example.com/pic.png", IP: "127.0.0.1"}, user) } func TestRest_Count(t *testing.T) { - srv, ts := prep(t) - assert.NotNil(t, srv) - defer cleanup(ts, srv) + ts, _, teardown := startupT(t) + defer teardown() c1 := store.Comment{Text: "test test #1", Locator: store.Locator{SiteID: "radio-t", URL: "https://radio-t.com/blah1"}} @@ -324,9 +313,8 @@ func TestRest_Count(t *testing.T) { } func TestRest_Counts(t *testing.T) { - srv, ts := prep(t) - assert.NotNil(t, srv) - defer cleanup(ts, srv) + ts, _, teardown := startupT(t) + defer teardown() c1 := store.Comment{Text: "test test #1", Locator: store.Locator{SiteID: "radio-t", URL: "https://radio-t.com/blah1"}} @@ -354,9 +342,8 @@ func TestRest_Counts(t *testing.T) { } func TestRest_List(t *testing.T) { - srv, ts := prep(t) - assert.NotNil(t, srv) - defer cleanup(ts, srv) + ts, _, teardown := startupT(t) + defer teardown() c1 := store.Comment{Text: "test test #1", Locator: store.Locator{SiteID: "radio-t", URL: "https://radio-t.com/blah1"}} @@ -381,9 +368,8 @@ func TestRest_List(t *testing.T) { } func TestRest_Config(t *testing.T) { - srv, ts := prep(t) - assert.NotNil(t, srv) - defer cleanup(ts, srv) + ts, _, teardown := startupT(t) + defer teardown() body, code := get(t, ts.URL+"/api/v1/config?site=radio-t") assert.Equal(t, 200, code) @@ -401,9 +387,8 @@ func TestRest_Config(t *testing.T) { } func TestRest_Info(t *testing.T) { - srv, ts := prep(t) - assert.NotNil(t, srv) - defer cleanup(ts, srv) + ts, srv, teardown := startupT(t) + defer teardown() srv.ReadOnlyAge = 10000000 // make sure we don't hit read-only @@ -439,9 +424,8 @@ func TestRest_Info(t *testing.T) { } func TestRest_Robots(t *testing.T) { - srv, ts := prep(t) - assert.NotNil(t, srv) - defer cleanup(ts, srv) + ts, _, teardown := startupT(t) + defer teardown() body, code := get(t, ts.URL+"/robots.txt") assert.Equal(t, 200, code) diff --git a/backend/app/rest/api/rest_test.go b/backend/app/rest/api/rest_test.go index 4586299d..de740492 100644 --- a/backend/app/rest/api/rest_test.go +++ b/backend/app/rest/api/rest_test.go @@ -12,18 +12,19 @@ import ( "testing" "time" - "github.com/coreos/bbolt" + bolt "github.com/coreos/bbolt" + "github.com/go-pkgz/auth" + "github.com/go-pkgz/auth/avatar" + "github.com/go-pkgz/auth/token" R "github.com/go-pkgz/rest" "github.com/go-pkgz/rest/cache" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/umputun/remark/backend/app/migrator" - "github.com/umputun/remark/backend/app/rest/auth" "github.com/umputun/remark/backend/app/rest/proxy" "github.com/umputun/remark/backend/app/store" adminstore "github.com/umputun/remark/backend/app/store/admin" - "github.com/umputun/remark/backend/app/store/avatar" "github.com/umputun/remark/backend/app/store/engine" "github.com/umputun/remark/backend/app/store/service" ) @@ -32,10 +33,11 @@ var testDb = "/tmp/test-remark.db" var testHTML = "/tmp/test-remark.html" var getStartedHTML = "/tmp/getstarted.html" +var devToken = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJyZW1hcms0MiIsImV4cCI6Mzc4OTE5MTgyMiwianRpIjoicmFuZG9tIGlkIiwiaXNzIjoicmVtYXJrNDIiLCJuYmYiOjE1MjE4ODQyMjIsInVzZXIiOnsibmFtZSI6ImRldmVsb3BlciBvbmUiLCJpZCI6ImRldiIsInBpY3R1cmUiOiJodHRwOi8vZXhhbXBsZS5jb20vcGljLnBuZyIsImlwIjoiMTI3LjAuMC4xIiwiZW1haWwiOiJtZUBleGFtcGxlLmNvbSJ9fQ.aKUAXiZxXypgV7m1wEOgUcyPOvUDXHDi3A06YWKbcLg" + func TestRest_FileServer(t *testing.T) { - srv, ts := prep(t) - assert.NotNil(t, srv) - defer cleanup(ts, srv) + ts, _, teardown := startupT(t) + defer teardown() body, code := get(t, ts.URL+"/web/test-remark.html") assert.Equal(t, 200, code) @@ -43,9 +45,8 @@ func TestRest_FileServer(t *testing.T) { } func TestRest_GetStarted(t *testing.T) { - srv, ts := prep(t) - assert.NotNil(t, srv) - defer cleanup(ts, srv) + ts, _, teardown := startupT(t) + defer teardown() err := ioutil.WriteFile(getStartedHTML, []byte("some html blah"), 0700) assert.Nil(t, err) @@ -61,8 +62,7 @@ func TestRest_GetStarted(t *testing.T) { } func TestRest_Shutdown(t *testing.T) { - srv := Rest{Authenticator: auth.Authenticator{}, AvatarProxy: &proxy.Avatar{Store: avatar.NewLocalFS("/tmp", 300), - RoutePath: "/api/v1/avatar"}, ImageProxy: &proxy.Image{}} + srv := Rest{Authenticator: &auth.Service{}, ImageProxy: &proxy.Image{}} go func() { time.Sleep(100 * time.Millisecond) @@ -91,11 +91,11 @@ func TestRest_filterComments(t *testing.T) { func TestRest_RunStaticSSLMode(t *testing.T) { srv := Rest{ - Authenticator: auth.Authenticator{}, - AvatarProxy: &proxy.Avatar{ - Store: avatar.NewLocalFS("/tmp", 300), - RoutePath: "/api/v1/avatar", - }, + Authenticator: auth.NewService(auth.Opts{ + AvatarStore: avatar.NewLocalFS("/tmp"), + AvatarResizeLimit: 300, + }), + ImageProxy: &proxy.Image{}, SSLConfig: SSLConfig{ SSLMode: Static, @@ -143,12 +143,8 @@ func TestRest_RunStaticSSLMode(t *testing.T) { func TestRest_RunAutocertModeHTTPOnly(t *testing.T) { srv := Rest{ - Authenticator: auth.Authenticator{}, - AvatarProxy: &proxy.Avatar{ - Store: avatar.NewLocalFS("/tmp", 300), - RoutePath: "/api/v1/avatar", - }, - ImageProxy: &proxy.Image{}, + Authenticator: &auth.Service{}, + ImageProxy: &proxy.Image{}, SSLConfig: SSLConfig{ SSLMode: Auto, Port: 8443, @@ -179,7 +175,7 @@ func TestRest_RunAutocertModeHTTPOnly(t *testing.T) { srv.Shutdown() } -func prep(t *testing.T) (srv *Rest, ts *httptest.Server) { +func startupT(t *testing.T) (ts *httptest.Server, srv *Rest, teardown func()) { b, err := engine.NewBoltDB(bolt.Options{}, engine.BoltSite{FileName: testDb, SiteID: "radio-t"}) require.Nil(t, err) @@ -192,18 +188,18 @@ func prep(t *testing.T) (srv *Rest, ts *httptest.Server) { AdminStore: adminStore, MaxVotes: service.UnlimitedVotes, } + srv = &Rest{ DataService: dataStore, - Authenticator: auth.Authenticator{ - DevPasswd: "password", - Providers: nil, - KeyStore: adminStore, - JWTService: auth.NewJWT(adminStore, false, time.Minute, time.Hour), - }, - Cache: &cache.Nop{}, - WebRoot: "/tmp", - RemarkURL: "https://demo.remark42.com", - AvatarProxy: &proxy.Avatar{Store: avatar.NewLocalFS("/tmp", 300), RoutePath: "/api/v1/avatar"}, + Authenticator: auth.NewService(auth.Opts{ + AdminPasswd: "password", + SecretReader: token.SecretFunc(func(id string) (string, error) { return "secret", nil }), + AvatarStore: avatar.NewLocalFS("/tmp/ava-remark42"), + }), + Cache: &cache.Nop{}, + WebRoot: "/tmp", + RemarkURL: "https://demo.remark42.com", + ImageProxy: &proxy.Image{}, ReadOnlyAge: 10, CommentFormatter: store.NewCommentFormatter(&proxy.Image{}), @@ -220,8 +216,18 @@ func prep(t *testing.T) (srv *Rest, ts *httptest.Server) { err = ioutil.WriteFile(testHTML, []byte("some html"), 0700) assert.Nil(t, err) + ts = httptest.NewServer(srv.routes()) - return srv, ts + + teardown = func() { + ts.Close() + srv.DataService.Close() + os.Remove(testDb) + os.Remove(testHTML) + os.RemoveAll("/tmp/ava-remark42") + } + + return ts, srv, teardown } func get(t *testing.T, url string) (string, int) { @@ -233,11 +239,24 @@ func get(t *testing.T, url string) (string, int) { return string(body), r.StatusCode } -func getWithAuth(t *testing.T, url string) (string, int) { +func getWithDevAuth(t *testing.T, url string) (body string, code int) { client := &http.Client{Timeout: 5 * time.Second} req, err := http.NewRequest("GET", url, nil) require.Nil(t, err) - req.SetBasicAuth("dev", "password") + req.Header.Add("X-JWT", devToken) + r, err := client.Do(req) + require.Nil(t, err) + defer r.Body.Close() + b, err := ioutil.ReadAll(r.Body) + assert.Nil(t, err) + return string(b), r.StatusCode +} + +func getWithAdminAuth(t *testing.T, url string) (string, int) { + client := &http.Client{Timeout: 5 * time.Second} + req, err := http.NewRequest("GET", url, nil) + require.Nil(t, err) + req.SetBasicAuth("admin", "password") r, err := client.Do(req) require.Nil(t, err) defer r.Body.Close() @@ -245,33 +264,31 @@ func getWithAuth(t *testing.T, url string) (string, int) { assert.Nil(t, err) return string(body), r.StatusCode } - func post(t *testing.T, url string, body string) (*http.Response, error) { client := &http.Client{Timeout: 5 * time.Second} req, err := http.NewRequest("POST", url, strings.NewReader(body)) assert.Nil(t, err) - req.SetBasicAuth("dev", "password") + req.SetBasicAuth("admin", "password") return client.Do(req) } func addComment(t *testing.T, c store.Comment, ts *httptest.Server) string { - b, err := json.Marshal(c) - assert.Nil(t, err, "can't marshal comment %+v", c) + require.Nil(t, err, "can't marshal comment %+v", c) client := &http.Client{Timeout: 5 * time.Second} req, err := http.NewRequest("POST", ts.URL+"/api/v1/comment", bytes.NewBuffer(b)) - assert.Nil(t, err) - req.SetBasicAuth("dev", "password") + require.Nil(t, err) + req.Header.Add("X-JWT", devToken) resp, err := client.Do(req) - assert.Nil(t, err) - assert.Equal(t, http.StatusCreated, resp.StatusCode) + require.Nil(t, err) + require.Equal(t, http.StatusCreated, resp.StatusCode) b, err = ioutil.ReadAll(resp.Body) - assert.Nil(t, err) + require.Nil(t, err) crResp := R.JSON{} err = json.Unmarshal(b, &crResp) - assert.Nil(t, err) + require.Nil(t, err) time.Sleep(time.Nanosecond * 10) return crResp["id"].(string) } diff --git a/backend/app/rest/api/rss.go b/backend/app/rest/api/rss.go index 4c5b1256..003337bb 100644 --- a/backend/app/rest/api/rss.go +++ b/backend/app/rest/api/rss.go @@ -7,10 +7,11 @@ import ( "time" "github.com/go-chi/chi" - "github.com/go-pkgz/rest/cache" "github.com/gorilla/feeds" "github.com/pkg/errors" + "github.com/go-pkgz/rest/cache" + "github.com/umputun/remark/backend/app/rest" "github.com/umputun/remark/backend/app/store" ) diff --git a/backend/app/rest/api/rss_test.go b/backend/app/rest/api/rss_test.go index 3eaffe14..201ac3cd 100644 --- a/backend/app/rest/api/rss_test.go +++ b/backend/app/rest/api/rss_test.go @@ -12,9 +12,8 @@ import ( ) func TestServer_RssPost(t *testing.T) { - srv, ts := prep(t) - assert.NotNil(t, srv) - defer cleanup(ts, srv) + ts, _, teardown := startupT(t) + defer teardown() waitOnSecChange() @@ -53,9 +52,8 @@ func TestServer_RssPost(t *testing.T) { } func TestServer_RssSite(t *testing.T) { - srv, ts := prep(t) - assert.NotNil(t, srv) - defer cleanup(ts, srv) + ts, _, teardown := startupT(t) + defer teardown() waitOnSecChange() @@ -107,9 +105,8 @@ func TestServer_RssSite(t *testing.T) { } func TestServer_RssWithReply(t *testing.T) { - srv, ts := prep(t) - assert.NotNil(t, srv) - defer cleanup(ts, srv) + ts, _, teardown := startupT(t) + defer teardown() waitOnSecChange() @@ -159,9 +156,8 @@ func TestServer_RssWithReply(t *testing.T) { } func TestServer_RssReplies(t *testing.T) { - srv, ts := prep(t) - assert.NotNil(t, srv) - defer cleanup(ts, srv) + ts, srv, teardown := startupT(t) + defer teardown() waitOnSecChange() diff --git a/backend/app/rest/api/ssl.go b/backend/app/rest/api/ssl.go index a52771b2..3310a4a3 100644 --- a/backend/app/rest/api/ssl.go +++ b/backend/app/rest/api/ssl.go @@ -9,8 +9,9 @@ import ( "github.com/go-chi/chi" "github.com/go-chi/chi/middleware" - R "github.com/go-pkgz/rest" "golang.org/x/crypto/acme/autocert" + + R "github.com/go-pkgz/rest" ) // sslMode defines ssl mode for rest server diff --git a/backend/app/rest/auth/auth.go b/backend/app/rest/auth/auth.go deleted file mode 100644 index 61b0e35c..00000000 --- a/backend/app/rest/auth/auth.go +++ /dev/null @@ -1,200 +0,0 @@ -// Package auth provides oauth2 support as well as related middlewares. -package auth - -import ( - "encoding/base64" - "log" - "net/http" - "strings" - - "github.com/umputun/remark/backend/app/rest" - "github.com/umputun/remark/backend/app/store" -) - -// Authenticator is top level auth object providing middlewares -type Authenticator struct { - JWTService *JWT - Providers []Provider - KeyStore KeyStore - DevPasswd string - PermissionChecker PermissionChecker -} - -// KeyStore defines sub-interface for consumers needed just a key -type KeyStore interface { - Key(siteID string) (key string, err error) -} - -var devUser = store.User{ - ID: "dev", - Name: "developer one", - Picture: "/api/v1/avatar/remark.image", - Admin: true, -} - -var adminUser = store.User{ - ID: "admin", - Name: "admin", - Picture: "/api/v1/avatar/remark.image", - Admin: true, -} - -// PermissionChecker defines interface to check user flags -type PermissionChecker interface { - IsVerified(siteID, userID string) bool - IsBlocked(siteID, userID string) bool - IsAdmin(siteID, userID string) bool -} - -// Auth middleware adds auth from session and populates user info -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) { - - // if secret key matches for given site (from request) return admin user - if a.checkSecretKey(r) { - r = rest.SetUserInfo(r, adminUser) - h.ServeHTTP(w, r) - return - } - - // use dev user basic auth if enabled - if a.basicDevUser(r) { - r = rest.SetUserInfo(r, devUser) - h.ServeHTTP(w, r) - return - } - - claims, err := a.JWTService.Get(r) - if err != nil { - if reqAuth { // in full auth lack of token causes Unauthorized - log.Printf("[DEBUG] failed auth, %s", err) - http.Error(w, "Unauthorized", http.StatusUnauthorized) - return - } - // if !reqAuth just pass it to the next handler, used for information only, like logs - h.ServeHTTP(w, r) - return - } - - if claims.User == nil && reqAuth { - log.Print("[DEBUG] failed auth, no user info presented in the claim") - http.Error(w, "Unauthorized", http.StatusUnauthorized) - return - } - - if claims.User != nil { // if uinfo in token populate it to context - if claims.User.Blocked { - log.Printf("[DEBUG] user %s/%s blocked", claims.User.Name, claims.User.ID) - a.JWTService.Reset(w) - http.Error(w, "Unauthorized", http.StatusUnauthorized) - return - } - - if a.JWTService.HasFlags(claims) { // flags in token indicate special use cases, not for login - log.Printf("[DEBUG] invalid token flags for %s/%s", claims.User.Name, claims.User.ID) - http.Error(w, "Unauthorized", http.StatusUnauthorized) - return - } - - if a.JWTService.IsExpired(claims) { - if claims, err = a.refreshExpiredToken(w, claims); err != nil { - log.Printf("[DEBUG] can't refresh jwt, %s", err) - http.Error(w, "Unauthorized", http.StatusUnauthorized) - } - log.Printf("[DEBUG] token refreshed for %+v", claims.User) - } - r = rest.SetUserInfo(r, *claims.User) // populate user info to request context - } - - h.ServeHTTP(w, r) - } - return http.HandlerFunc(fn) - } - return f -} - -func (a *Authenticator) checkSecretKey(r *http.Request) bool { - if a.KeyStore == nil { - return false - } - - siteID := r.URL.Query().Get("site") - secret := r.URL.Query().Get("secret") - - skey, err := a.KeyStore.Key(siteID) - if err != nil { - return false - } - - if strings.TrimSpace(secret) == "" || secret != skey { - return false - } - return true -} - -// refreshExpiredToken makes new token with passed claims, but only if permission allowed -func (a *Authenticator) refreshExpiredToken(w http.ResponseWriter, claims *CustomClaims) (*CustomClaims, error) { - if a.PermissionChecker != nil { - claims.User.Admin = a.PermissionChecker.IsAdmin(claims.SiteID, claims.User.ID) - claims.User.Blocked = a.PermissionChecker.IsBlocked(claims.SiteID, claims.User.ID) - claims.User.Verified = a.PermissionChecker.IsVerified(claims.SiteID, claims.User.ID) - } - // refresh token - if err := a.JWTService.Set(w, claims, false); err != nil { - return nil, err - } - return claims, nil -} - -// AdminOnly middleware allows access for admins only -func (a *Authenticator) AdminOnly(next http.Handler) http.Handler { - fn := func(w http.ResponseWriter, r *http.Request) { - - user, err := rest.GetUserInfo(r) - if err != nil { - http.Error(w, "Unauthorized", http.StatusUnauthorized) - return - } - - if !user.Admin { - http.Error(w, "Access denied", http.StatusForbidden) - return - } - - next.ServeHTTP(w, r) - } - return http.HandlerFunc(fn) -} - -func (a *Authenticator) basicDevUser(r *http.Request) bool { - - if a.DevPasswd == "" { - return false - } - - s := strings.SplitN(r.Header.Get("Authorization"), " ", 2) - if len(s) != 2 { - return false - } - - b, err := base64.StdEncoding.DecodeString(s[1]) - if err != nil { - log.Printf("[WARN] dev user auth failed, failed to decode %s, %s", s[1], err) - return false - } - - pair := strings.SplitN(string(b), ":", 2) - if len(pair) != 2 { - log.Printf("[WARN] dev user auth failed, failed to split %s", string(b)) - return false - } - - if pair[0] != "dev" || pair[1] != a.DevPasswd { - log.Printf("[WARN] dev user auth failed, user/passwd mismatch %+v", pair) - return false - } - - return true -} diff --git a/backend/app/rest/auth/auth_test.go b/backend/app/rest/auth/auth_test.go deleted file mode 100644 index 7b901835..00000000 --- a/backend/app/rest/auth/auth_test.go +++ /dev/null @@ -1,247 +0,0 @@ -package auth - -import ( - "encoding/base64" - "net/http" - "net/http/cookiejar" - "net/http/httptest" - "testing" - "time" - - "github.com/go-chi/chi" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "github.com/umputun/remark/backend/app/store/admin" -) - -var testJwtUserBlocked = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjI3ODkxOTE4MjIsImp0aSI6InJhbmRvbSBpZCIsImlzcyI6InJlbWFyazQyIiwibmJmIjoxNTI2ODg0MjIyLCJ1c2VyIjp7Im5hbWUiOiJuYW1lMSIsImlkIjoiaWQxIiwicGljdHVyZSI6IiIsImFkbWluIjpmYWxzZSwiYmxvY2siOnRydWV9LCJzdGF0ZSI6IjEyMzQ1NiIsImZyb20iOiJmcm9tIn0.6P_OwGf8CUJRtvNSlW20GmaMb5pFvCNemP94fHCqb5Q" - -var testJwtDeleteMe = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjI3ODkxOTE4MjIsImp0aSI6InJhbmRvbSBpZCIsImlzcyI6InJlbWFyazQyIiwibmJmIjoxNTI2ODg0MjIyLCJ1c2VyIjp7Im5hbWUiOiJuYW1lMSIsImlkIjoiaWQxIiwicGljdHVyZSI6IiIsImFkbWluIjpmYWxzZSwiYmxvY2siOmZhbHNlfSwiZmxhZ3MiOnsiZGVsZXRlbWUiOnRydWV9fQ.SLh1QpFytWZqcT99VgcdAOtgFKhvpKCcZwqWTvAd63g" - -var testJwtNoUser = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjI3ODkxOTE4MjIsImp0aSI6InJhbmRvbSBpZCIsImlzcyI6InJlbWFyazQyIiwibmJmIjoxNTI2ODg0MjIyfQ.sBpblkbBRzZsBSPPNrTWqA5h7h54solrw5L4IypJT_o" - -func TestAuthJWTCookie(t *testing.T) { - a := Authenticator{DevPasswd: "123456", JWTService: NewJWT(admin.NewStaticKeyStore("xyz 12345"), false, time.Hour, time.Hour), - PermissionChecker: &mockUserPermissions{}} - router := chi.NewRouter() - router.With(a.Auth(true)).Get("/auth", func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(201) - }) - server := httptest.NewServer(router) - defer server.Close() - - expiration := int(time.Duration(365 * 24 * time.Hour).Seconds()) - req, err := http.NewRequest("GET", server.URL+"/auth", nil) - require.Nil(t, err) - req.AddCookie(&http.Cookie{Name: "JWT", Value: testJwtValid, HttpOnly: true, Path: "/", MaxAge: expiration, Secure: false}) - req.Header.Add("X-XSRF-TOKEN", "random id") - - client := &http.Client{Timeout: 5 * time.Second} - resp, err := client.Do(req) - require.NoError(t, err) - assert.Equal(t, 201, resp.StatusCode, "valid auth user") - - req, err = http.NewRequest("GET", server.URL+"/auth", nil) - require.Nil(t, err) - req.AddCookie(&http.Cookie{Name: "JWT", Value: testJwtValid, HttpOnly: true, Path: "/", MaxAge: expiration, Secure: false}) - req.Header.Add("X-XSRF-TOKEN", "wrong id") - resp, err = client.Do(req) - require.NoError(t, err) - assert.Equal(t, 401, resp.StatusCode, "xsrf mismatch") - - req, err = http.NewRequest("GET", server.URL+"/auth", nil) - require.Nil(t, err) - req.AddCookie(&http.Cookie{Name: "JWT", Value: testJwtExpired, HttpOnly: true, Path: "/", MaxAge: expiration, Secure: false}) - req.Header.Add("X-XSRF-TOKEN", "random id") - resp, err = client.Do(req) - require.NoError(t, err) - assert.Equal(t, 201, resp.StatusCode, "token expired and refreshed") - - req, err = http.NewRequest("GET", server.URL+"/auth", nil) - require.Nil(t, err) - req.AddCookie(&http.Cookie{Name: "JWT", Value: testJwtNoUser, HttpOnly: true, Path: "/", MaxAge: expiration, Secure: false}) - req.Header.Add("X-XSRF-TOKEN", "random id") - resp, err = client.Do(req) - require.NoError(t, err) - assert.Equal(t, 401, resp.StatusCode, "no user info in the token") -} - -func TestAuthJWTHeader(t *testing.T) { - a := Authenticator{DevPasswd: "123456", JWTService: NewJWT(admin.NewStaticKeyStore("xyz 12345"), false, time.Hour, time.Hour)} - router := chi.NewRouter() - router.With(a.Auth(true)).Get("/auth", func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(201) - }) - server := httptest.NewServer(router) - defer server.Close() - - jar, err := cookiejar.New(nil) - require.Nil(t, err) - client := &http.Client{Jar: jar, Timeout: 5 * time.Second} - req, err := http.NewRequest("GET", server.URL+"/auth", nil) - require.Nil(t, err) - req.Header.Add("X-JWT", testJwtValid) - resp, err := client.Do(req) - require.NoError(t, err) - assert.Equal(t, 201, resp.StatusCode, "valid auth user") - - req, err = http.NewRequest("GET", server.URL+"/auth", nil) - require.Nil(t, err) - req.Header.Add("X-JWT", testJwtExpired) - resp, err = client.Do(req) - require.NoError(t, err) - assert.Equal(t, 201, resp.StatusCode, "token expired and refreshed") -} - -func TestAuthJWtBlocked(t *testing.T) { - a := Authenticator{DevPasswd: "123456", JWTService: NewJWT(admin.NewStaticKeyStore("xyz 12345"), false, time.Hour, time.Hour)} - router := chi.NewRouter() - router.With(a.Auth(true)).Get("/auth", func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(201) - }) - server := httptest.NewServer(router) - defer server.Close() - - jar, err := cookiejar.New(nil) - require.Nil(t, err) - client := &http.Client{Jar: jar, Timeout: 5 * time.Second} - req, err := http.NewRequest("GET", server.URL+"/auth", nil) - require.Nil(t, err) - req.Header.Add("X-JWT", testJwtUserBlocked) - resp, err := client.Do(req) - require.NoError(t, err) - assert.Equal(t, 401, resp.StatusCode, "blocked user") -} - -func TestAuthJWtFlags(t *testing.T) { - a := Authenticator{DevPasswd: "123456", JWTService: NewJWT(admin.NewStaticKeyStore("xyz 12345"), false, time.Hour, time.Hour)} - router := chi.NewRouter() - router.With(a.Auth(true)).Get("/auth", func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(201) - }) - server := httptest.NewServer(router) - defer server.Close() - - jar, err := cookiejar.New(nil) - require.Nil(t, err) - client := &http.Client{Jar: jar, Timeout: 5 * time.Second} - req, err := http.NewRequest("GET", server.URL+"/auth", nil) - require.Nil(t, err) - req.Header.Add("X-JWT", testJwtDeleteMe) - resp, err := client.Do(req) - require.NoError(t, err) - assert.Equal(t, 401, resp.StatusCode, "blocked user") -} - -func TestAuthRequired(t *testing.T) { - a := Authenticator{DevPasswd: "123456"} - router := chi.NewRouter() - router.With(a.Auth(true)).Get("/auth", func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(201) - }) - server := httptest.NewServer(router) - defer server.Close() - - client := &http.Client{Timeout: 1 * time.Second} - req, err := http.NewRequest("GET", server.URL+"/auth", nil) - require.NoError(t, err) - req = withBasicAuth(req, "dev", "123456") - resp, err := client.Do(req) - require.NoError(t, err) - assert.Equal(t, 201, resp.StatusCode, "valid auth user") - - req, err = http.NewRequest("GET", server.URL+"/auth", nil) - require.NoError(t, err) - resp, err = client.Do(req) - require.NoError(t, err) - assert.Equal(t, 401, resp.StatusCode, "no auth user") - - req, err = http.NewRequest("GET", server.URL+"/auth", nil) - require.NoError(t, err) - req = withBasicAuth(req, "dev", "xyz") - resp, err = client.Do(req) - require.NoError(t, err) - assert.Equal(t, 401, resp.StatusCode, "wrong auth creds") -} - -func TestAuthNotRequired(t *testing.T) { - a := Authenticator{DevPasswd: "123456"} - router := chi.NewRouter() - router.With(a.Auth(false)).Get("/auth", func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(201) - }) - server := httptest.NewServer(router) - defer server.Close() - - client := &http.Client{Timeout: 1 * time.Second} - req, err := http.NewRequest("GET", server.URL+"/auth", nil) - require.NoError(t, err) - req = withBasicAuth(req, "dev", "123456") - resp, err := client.Do(req) - require.NoError(t, err) - assert.Equal(t, 201, resp.StatusCode, "valid auth user") - - req, err = http.NewRequest("GET", server.URL+"/auth", nil) - require.NoError(t, err) - resp, err = client.Do(req) - require.NoError(t, err) - assert.Equal(t, 201, resp.StatusCode, "no auth user") - - req, err = http.NewRequest("GET", server.URL+"/auth", nil) - require.NoError(t, err) - req = withBasicAuth(req, "dev", "ZZZZ123456") - resp, err = client.Do(req) - require.NoError(t, err) - assert.Equal(t, 201, resp.StatusCode, "wrong auth creds") -} - -func TestAdminRequired(t *testing.T) { - a := Authenticator{DevPasswd: "123456"} - router := chi.NewRouter() - router.With(a.Auth(true), a.AdminOnly).Get("/auth", func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(201) - }) - server := httptest.NewServer(router) - defer server.Close() - - client := &http.Client{Timeout: 1 * time.Second} - req, err := http.NewRequest("GET", server.URL+"/auth", nil) - require.NoError(t, err) - req = withBasicAuth(req, "dev", "123456") - resp, err := client.Do(req) - require.NoError(t, err) - assert.Equal(t, 201, resp.StatusCode, "valid auth user, admin") - - devUser.Admin = false - req, err = http.NewRequest("GET", server.URL+"/auth", nil) - require.NoError(t, err) - req = withBasicAuth(req, "dev", "123456") - resp, err = client.Do(req) - require.NoError(t, err) - assert.Equal(t, 403, resp.StatusCode, "valid auth user, not admin") - -} - -func TestAuthWithSecret(t *testing.T) { - a := Authenticator{DevPasswd: "123456", KeyStore: admin.NewStaticKeyStore("secretkey")} - router := chi.NewRouter() - router.With(a.Auth(true), a.AdminOnly).Get("/auth", func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(201) - }) - server := httptest.NewServer(router) - defer server.Close() - - resp, err := http.Get(server.URL + "/auth?secret=secretkey") - require.NoError(t, err) - assert.Equal(t, 201, resp.StatusCode, "valid auth user with secret, admin") - - resp, err = http.Get(server.URL + "/auth?secret=badsecret") - require.NoError(t, err) - assert.Equal(t, 401, resp.StatusCode, "invalid auth with bad secret") -} - -func withBasicAuth(r *http.Request, username, password string) *http.Request { - auth := username + ":" + password - r.Header.Add("Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte(auth))) - return r -} diff --git a/backend/app/rest/auth/dev_provider.go b/backend/app/rest/auth/dev_provider.go deleted file mode 100644 index 1c07ffe7..00000000 --- a/backend/app/rest/auth/dev_provider.go +++ /dev/null @@ -1,196 +0,0 @@ -package auth - -import ( - "bytes" - "context" - "fmt" - "log" - "net/http" - "strings" - "sync" - "time" - - "github.com/nullrocks/identicon" - "github.com/pkg/errors" - "golang.org/x/oauth2" - - "github.com/umputun/remark/backend/app/store" -) - -const devAuthPort = 8084 - -// DevAuthServer is a fake oauth server for development -// it provides stand-alone server running on its own port and pretending to be the real oauth2. It also provides -// Dev Provider the same way as normal providers do, i.e. like github, google and others. -// can run in interactive and non-interactive mode. In interactive mode login attempts will show login form to select -// desired user name, this is the mode used for development. Non-interactive mode for tests only. -type DevAuthServer struct { - Provider Provider - - username string // unsafe, but fine for dev - nonInteractive bool - iconGen *identicon.Generator - httpServer *http.Server - lock sync.Mutex -} - -// Run oauth2 dev server on port devAuthPort -func (d *DevAuthServer) Run() { - log.Printf("[INFO] run local oauth2 dev server on %d", devAuthPort) - d.lock.Lock() - var err error - d.iconGen, err = identicon.New("github", 5, 3) - if err != nil { - log.Printf("[WARN] can't create identicon, %s", err) - } - - d.httpServer = &http.Server{ - Addr: fmt.Sprintf(":%d", devAuthPort), - Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - log.Printf("[DEBUG] dev oauth request %s %s %+v", r.Method, r.URL, r.Header) - switch { - - case strings.HasPrefix(r.URL.Path, "/login/oauth/authorize"): - // first time it will be called without username and will ask for one - if !d.nonInteractive && (r.ParseForm() != nil || r.Form.Get("username") == "") { - if _, err = w.Write([]byte(fmt.Sprintf(devUserForm, r.URL.RawQuery))); err != nil { - log.Printf("[WARN] can't write, %s", err) - } - return - } - - if !d.nonInteractive { - d.username = r.Form.Get("username") - } - - state := r.URL.Query().Get("state") - callbackURL := fmt.Sprintf("%s?code=g0ZGZmNjVmOWI&state=%s", d.Provider.RedirectURL, state) - log.Printf("[DEBUG] callback url=%s", callbackURL) - w.Header().Add("Location", callbackURL) - w.WriteHeader(http.StatusFound) - - case strings.HasPrefix(r.URL.Path, "/login/oauth/access_token"): - res := `{ - "access_token":"MTQ0NjJkZmQ5OTM2NDE1ZTZjNGZmZjI3", - "token_type":"bearer", - "expires_in":3600, - "refresh_token":"IwOGYzYTlmM2YxOTQ5MGE3YmNmMDFkNTVk", - "scope":"create", - "state":"12345678" - }` - w.Header().Set("Content-Type", "application/json; charset=utf-8") - if _, err = w.Write([]byte(res)); err != nil { - w.WriteHeader(http.StatusInternalServerError) - return - } - - case strings.HasPrefix(r.URL.Path, "/user"): - ava := fmt.Sprintf("http://127.0.0.1:%d/avatar?user=%s", devAuthPort, d.username) - res := fmt.Sprintf(`{ - "id": "%s", - "name":"%s", - "picture":"%s" - }`, d.username, d.username, ava) - - w.Header().Set("Content-Type", "application/json; charset=utf-8") - if _, err = w.Write([]byte(res)); err != nil { - w.WriteHeader(http.StatusInternalServerError) - return - } - - case strings.HasPrefix(r.URL.Path, "/avatar"): - user := r.URL.Query().Get("user") - b, e := d.genAvatar(user) - if e != nil { - w.WriteHeader(http.StatusNotFound) - return - } - if _, err = w.Write(b); err != nil { - w.WriteHeader(http.StatusInternalServerError) - return - } - - default: - w.WriteHeader(http.StatusBadRequest) - } - }), - } - d.lock.Unlock() - - err = d.httpServer.ListenAndServe() - log.Printf("[WARN] dev oauth2 server terminated, %s", err) -} - -// Shutdown oauth2 dev server -func (d *DevAuthServer) Shutdown() { - log.Print("[WARN] shutdown oauth2 dev server") - ctx, cancel := context.WithTimeout(context.Background(), time.Second) - defer cancel() - d.lock.Lock() - if d.httpServer != nil { - if err := d.httpServer.Shutdown(ctx); err != nil { - log.Printf("[DEBUG] oauth2 dev shutdown error, %s", err) - } - } - log.Print("[DEBUG] shutdown dev oauth2 server completed") - d.lock.Unlock() -} - -// NewDev makes dev oauth2 provider for admin user -func NewDev(p Params) Provider { - return initProvider(p, Provider{ - Name: "dev", - Endpoint: oauth2.Endpoint{ - AuthURL: fmt.Sprintf("http://127.0.0.1:%d/login/oauth/authorize", devAuthPort), - TokenURL: fmt.Sprintf("http://127.0.0.1:%d/login/oauth/access_token", devAuthPort), - }, - RedirectURL: p.RemarkURL + "/auth/dev/callback", - Scopes: []string{"user:email"}, - InfoURL: fmt.Sprintf("http://127.0.0.1:%d/user", devAuthPort), - MapUser: func(data userData, _ []byte) store.User { - userInfo := store.User{ - ID: data.value("id"), - Name: data.value("name"), - Picture: data.value("picture"), - } - return userInfo - }, - }) -} - -func (d *DevAuthServer) genAvatar(user string) ([]byte, error) { - if d.iconGen == nil { - return nil, errors.Errorf("no iconGen, skip avatar generation for %s", user) - } - - ii, err := d.iconGen.Draw(user) // Generate an IdentIcon - if err != nil { - return nil, errors.Wrapf(err, "failed to draw avatar for %s", user) - } - - buf := &bytes.Buffer{} - err = ii.Png(300, buf) - return buf.Bytes(), err -} - -var devUserForm = ` - - - Remark42 Dev User - - - -
- username: - -
- - -` diff --git a/backend/app/rest/auth/dev_provider_test.go b/backend/app/rest/auth/dev_provider_test.go deleted file mode 100644 index 6a732bfe..00000000 --- a/backend/app/rest/auth/dev_provider_test.go +++ /dev/null @@ -1,77 +0,0 @@ -package auth - -import ( - "context" - "fmt" - "io/ioutil" - "net/http" - "net/http/cookiejar" - "testing" - "time" - - "github.com/go-chi/chi" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "github.com/umputun/remark/backend/app/store" - "github.com/umputun/remark/backend/app/store/admin" -) - -func TestDevProvider(t *testing.T) { - params := Params{RemarkURL: "http://127.0.0.1:8080", Cid: "cid", Csecret: "csecret", - JwtService: NewJWT(admin.NewStaticKeyStore("12345"), false, time.Hour, time.Hour*24*31), - PermissionChecker: &mockUserPermissions{admin: "dev_user"}, - } - srv := DevAuthServer{Provider: NewDev(params), nonInteractive: true, username: "dev_user"} - - // auth routes for all providers - router := chi.NewRouter() - router.Route("/auth", func(r chi.Router) { - r.Mount("/dev", srv.Provider.Routes()) // mount auth providers as /auth/{name} - }) - - ts := &http.Server{Addr: fmt.Sprintf("127.0.0.1:%d", 8080), Handler: router} - go srv.Run() - go ts.ListenAndServe() - defer func() { - srv.Shutdown() - _ = ts.Shutdown(context.TODO()) - }() - - time.Sleep(100 * time.Millisecond) - - jar, err := cookiejar.New(nil) - require.Nil(t, err) - client := &http.Client{Jar: jar, Timeout: 5 * time.Second} - - // check non-admin, permanent - resp, err := client.Get("http://127.0.0.1:8080/auth/dev/login?site=remark") - require.Nil(t, err) - assert.Equal(t, 200, resp.StatusCode) - body, err := ioutil.ReadAll(resp.Body) - assert.Nil(t, err) - t.Logf("resp %s", string(body)) - t.Logf("headers: %+v", resp.Header) - - assert.Equal(t, 2, len(resp.Cookies())) - assert.Equal(t, "JWT", resp.Cookies()[0].Name) - assert.NotEqual(t, "", resp.Cookies()[0].Value, "jwt set") - assert.Equal(t, 2678400, resp.Cookies()[0].MaxAge) - assert.Equal(t, "XSRF-TOKEN", resp.Cookies()[1].Name) - assert.NotEqual(t, "", resp.Cookies()[1].Value, "xsrf cookie set") - - claims, err := params.JwtService.Parse(resp.Cookies()[0].Value) - assert.Nil(t, err) - - u := *claims.User - assert.Equal(t, store.User{Name: "dev_user", ID: "dev_user", Picture: "http://127.0.0.1:8084/avatar?user=dev_user", IP: "", - Admin: true, Blocked: false, Verified: false}, u) - - // check avatar - resp, err = client.Get("http://127.0.0.1:8084/avatar?user=dev_user") - require.Nil(t, err) - assert.Equal(t, 200, resp.StatusCode) - body, err = ioutil.ReadAll(resp.Body) - assert.Nil(t, err) - assert.Equal(t, 985, len(body)) - t.Logf("headers: %+v", resp.Header) -} diff --git a/backend/app/rest/auth/jwt.go b/backend/app/rest/auth/jwt.go deleted file mode 100644 index 0326ce48..00000000 --- a/backend/app/rest/auth/jwt.go +++ /dev/null @@ -1,200 +0,0 @@ -package auth - -import ( - "net/http" - "time" - - "github.com/dgrijalva/jwt-go" - "github.com/pkg/errors" - - "github.com/umputun/remark/backend/app/store" -) - -// JWT wraps jwt operations -// supports both header and cookie jwt -type JWT struct { - keyStore KeyStore - secureCookies bool - tokenDuration time.Duration - cookieDuration time.Duration -} - -// CustomClaims stores user info for auth and state & from from login -type CustomClaims struct { - jwt.StandardClaims - User *store.User `json:"user,omitempty"` - - // used for oauth handshake - State string `json:"state,omitempty"` - From string `json:"from,omitempty"` - SiteID string `json:"site_id,omitempty"` - SessionOnly bool `json:"sess_only,omitempty"` - - // flags indicate different uses - Flags struct { - Login bool `json:"login,omitempty"` - DeleteMe bool `json:"deleteme,omitempty"` - } `json:"flags,omitempty"` -} - -const jwtCookieName = "JWT" -const jwtHeaderKey = "X-JWT" -const xsrfCookieName = "XSRF-TOKEN" -const xsrfHeaderKey = "X-XSRF-TOKEN" - -// NewJWT makes JWT service -func NewJWT(keyStore KeyStore, secureCookies bool, tokenDuration time.Duration, cookieDuration time.Duration) *JWT { - res := JWT{ - keyStore: keyStore, - secureCookies: secureCookies, - tokenDuration: tokenDuration, - cookieDuration: cookieDuration, - } - return &res -} - -// Token makes jwt with claims -func (j *JWT) Token(claims *CustomClaims) (string, error) { - token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) - - secret, err := j.keyStore.Key(claims.SiteID) - if err != nil { - return "", errors.Wrap(err, "can't get secret") - } - - tokenString, err := token.SignedString([]byte(secret)) - if err != nil { - return "", errors.Wrap(err, "can't sign jwt token") - } - return tokenString, nil -} - -// HasFlags indicates presence of special flags -func (j *JWT) HasFlags(claims *CustomClaims) bool { - return claims.Flags.DeleteMe || claims.Flags.Login -} - -// Parse token string and verify. Not checking for expiration -func (j *JWT) Parse(tokenString string) (*CustomClaims, error) { - parser := jwt.Parser{SkipClaimsValidation: true} // allow parsing of expired tokens - - getSiteID := func() (siteID string, err error) { // parse token without signature check to get siteID - preToken, _, err := parser.ParseUnverified(tokenString, &CustomClaims{}) - if err != nil { - return "", errors.Wrap(err, "can't pre-parse jwt") - } - preClaims, ok := preToken.Claims.(*CustomClaims) - if !ok { - return "", errors.New("invalid jwt") - } - return preClaims.SiteID, nil - } - - siteID, err := getSiteID() - if err != nil { - return nil, errors.Wrap(err, "failed to get siteID from jwt token") - } - - secret, err := j.keyStore.Key(siteID) - if err != nil { - return nil, errors.Wrap(err, "can't get secret") - } - - token, err := parser.ParseWithClaims(tokenString, &CustomClaims{}, func(token *jwt.Token) (interface{}, error) { - if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { - return nil, errors.Errorf("unexpected signing method: %v", token.Header["alg"]) - } - return []byte(secret), nil - }) - if err != nil { - return nil, errors.Wrap(err, "can't parse jwt") - } - - claims, ok := token.Claims.(*CustomClaims) - if !ok || !token.Valid { - return nil, errors.New("invalid jwt") - } - - return claims, nil -} - -// Set creates jwt cookie with xsrf cookie and put it to ResponseWriter -// accepts claims and sets expiration if none defined. permanent flag means long-living cookie, false makes it session only. -func (j *JWT) Set(w http.ResponseWriter, claims *CustomClaims, sessionOnly bool) error { - if claims.ExpiresAt == 0 { - claims.ExpiresAt = time.Now().Add(j.tokenDuration).Unix() - } - - tokenString, err := j.Token(claims) - if err != nil { - return errors.Wrap(err, "failed to make jwt token") - } - - cookieExpiration := 0 // session cookie - if !sessionOnly { - cookieExpiration = int(j.cookieDuration.Seconds()) - } - - jwtCookie := http.Cookie{Name: jwtCookieName, Value: tokenString, HttpOnly: true, Path: "/", - MaxAge: cookieExpiration, Secure: j.secureCookies} - http.SetCookie(w, &jwtCookie) - - xsrfCookie := http.Cookie{Name: xsrfCookieName, Value: claims.Id, HttpOnly: false, Path: "/", - MaxAge: cookieExpiration, Secure: j.secureCookies} - http.SetCookie(w, &xsrfCookie) - - return nil -} - -// Get jwt from header or cookie -// if cookie used, verify xsrf token to match -func (j *JWT) Get(r *http.Request) (*CustomClaims, error) { - - fromCookie := false - tokenString := "" - - // try to get from X-JWT header - if tokenHeader := r.Header.Get(jwtHeaderKey); tokenHeader != "" { - tokenString = tokenHeader - } - - // try to get from JWT cookie - if tokenString == "" { - fromCookie = true - jc, err := r.Cookie(jwtCookieName) - if err != nil { - return nil, errors.Wrap(err, "jwt cookie was not presented") - } - tokenString = jc.Value - } - - claims, err := j.Parse(tokenString) - if err != nil { - return nil, errors.Wrap(err, "failed to get jwt") - } - - if fromCookie && claims.User != nil { - xsrf := r.Header.Get(xsrfHeaderKey) - if claims.Id != xsrf { - return nil, errors.New("xsrf mismatch") - } - } - - return claims, nil -} - -// IsExpired returns true if claims expired -func (j *JWT) IsExpired(claims *CustomClaims) bool { - return !claims.VerifyExpiresAt(time.Now().Unix(), true) -} - -// Reset token's cookies -func (j *JWT) Reset(w http.ResponseWriter) { - jwtCookie := http.Cookie{Name: jwtCookieName, Value: "", HttpOnly: false, Path: "/", - MaxAge: -1, Expires: time.Unix(0, 0), Secure: j.secureCookies} - http.SetCookie(w, &jwtCookie) - - xsrfCookie := http.Cookie{Name: xsrfCookieName, Value: "", HttpOnly: false, Path: "/", - MaxAge: -1, Expires: time.Unix(0, 0), Secure: j.secureCookies} - http.SetCookie(w, &xsrfCookie) -} diff --git a/backend/app/rest/auth/jwt_test.go b/backend/app/rest/auth/jwt_test.go deleted file mode 100644 index f1e654d8..00000000 --- a/backend/app/rest/auth/jwt_test.go +++ /dev/null @@ -1,258 +0,0 @@ -package auth - -import ( - "net/http" - "net/http/httptest" - "strings" - "testing" - "time" - - "github.com/dgrijalva/jwt-go" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "github.com/umputun/remark/backend/app/store/admin" - - "github.com/umputun/remark/backend/app/store" -) - -var testJwtValid = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjI3ODkxOTE4MjIsImp0aSI6InJhbmRvbSBpZCIsImlzcyI6InJlb" + "WFyazQyIiwibmJmIjoxNTI2ODg0MjIyLCJ1c2VyIjp7Im5hbWUiOiJuYW1lMSIsImlkIjoiaWQxIiwicGljdHVyZSI6IiIsImFkbWluIjpmYWxzZX0" + "sInN0YXRlIjoiMTIzNDU2IiwiZnJvbSI6ImZyb20iLCJmbGFncyI6e319.E2Blxqo1wsY855q258c0obxFJ1lgJciv1av1ewzlJBs" - -var testJwtValidSess = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjI3ODkxOTE4MjIsImp0aSI6InJhbmRvbSBpZCIs" + "ImlzcyI6InJlbWFyazQyIiwibmJmIjoxNTI2ODg0MjIyLCJ1c2VyIjp7Im5hbWUiOiJuYW1lMSIsImlkIjoiaWQxIiwicGljdHVyZSI6IiIsImFk" + "bWluIjpmYWxzZX0sInN0YXRlIjoiMTIzNDU2IiwiZnJvbSI6ImZyb20iLCJzZXNzX29ubHkiOnRydWUsImZsYWdzIjp7fX0." + "nKhehF1Xiome1yK1ewfOiIsrATvq7Tx7p1BCSJqKHuo" - -var testJwtExpired = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE1MjY4ODc4MjIsImp0aSI6InJhbmRvbSBpZCIs" + - "ImlzcyI6InJlbWFyazQyIiwibmJmIjoxNTI2ODg0MjIyLCJ1c2VyIjp7Im5hbWUiOiJuYW1lMSIsImlkIjoiaWQxIiwicGljdHVyZSI6IiI" + - "sImFkbWluIjpmYWxzZX0sInN0YXRlIjoiMTIzNDU2IiwiZnJvbSI6ImZyb20ifQ.4_dCrY9ihyfZIedz-kZwBTxmxU1a52V7IqeJrOqTzE4" - -var testJwtBadSign = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjI3ODkxOTE4MjIsImp0aSI6InJhbmRvbSBpZCI" + - "sImlzcyI6InJlbWFyazQyIiwibmJmIjoxNTI2ODg0MjIyLCJ1c2VyIjp7Im5hbWUiOiJuYW1lMSIsImlkIjoiaWQxIiwicGljdHVyZS" + - "I6IiIsImFkbWluIjpmYWxzZX0sInN0YXRlIjoiMTIzNDU2IiwiZnJvbSI6ImZyb20ifQ._loFgh3g45gr9TtGqvM3N584I_6EHEOJnYb6Py84st" - -var days31 = time.Hour * 24 * 31 - -func TestJWT_Token(t *testing.T) { - j := NewJWT(admin.NewStaticKeyStore("xyz 12345"), false, time.Hour, days31) - - claims := &CustomClaims{ - State: "123456", - From: "from", - User: &store.User{ - ID: "id1", - Name: "name1", - }, - StandardClaims: jwt.StandardClaims{ - Id: "random id", - Issuer: "remark42", - ExpiresAt: time.Date(2058, 5, 21, 1, 30, 22, 0, time.Local).Unix(), - NotBefore: time.Date(2018, 5, 21, 1, 30, 22, 0, time.Local).Unix(), - }, - } - - res, err := j.Token(claims) - assert.Nil(t, err) - assert.Equal(t, testJwtValid, res) -} - -func TestJWT_Parse(t *testing.T) { - j := NewJWT(admin.NewStaticKeyStore("xyz 12345"), false, time.Hour, days31) - claims, err := j.Parse(testJwtValid) - assert.NoError(t, err) - assert.False(t, j.IsExpired(claims)) - assert.Equal(t, &store.User{Name: "name1", ID: "id1"}, claims.User) - - claims, err = j.Parse(testJwtExpired) - assert.NoError(t, err) - assert.True(t, j.IsExpired(claims)) - - _, err = j.Parse("bad") - assert.NotNil(t, err, "bad token") - - _, err = j.Parse(testJwtBadSign) - assert.EqualError(t, err, "can't parse jwt: signature is invalid") -} - -func TestJWT_Set(t *testing.T) { - j := NewJWT(admin.NewStaticKeyStore("xyz 12345"), false, time.Hour, days31) - - claims := &CustomClaims{ - State: "123456", - From: "from", - User: &store.User{ - ID: "id1", - Name: "name1", - }, - StandardClaims: jwt.StandardClaims{ - Id: "random id", - Issuer: "remark42", - ExpiresAt: time.Date(2058, 5, 21, 1, 30, 22, 0, time.Local).Unix(), - NotBefore: time.Date(2018, 5, 21, 1, 30, 22, 0, time.Local).Unix(), - }, - SessionOnly: false, - } - - rr := httptest.NewRecorder() - err := j.Set(rr, claims, claims.SessionOnly) - assert.Nil(t, err) - cookies := rr.Result().Cookies() - t.Log(cookies) - require.Equal(t, 2, len(cookies)) - assert.Equal(t, "JWT", cookies[0].Name) - assert.Equal(t, testJwtValid, cookies[0].Value) - assert.Equal(t, 31*24*3600, cookies[0].MaxAge) - assert.Equal(t, "XSRF-TOKEN", cookies[1].Name) - assert.Equal(t, "random id", cookies[1].Value) - - claims.SessionOnly = true - rr = httptest.NewRecorder() - err = j.Set(rr, claims, claims.SessionOnly) - assert.Nil(t, err) - cookies = rr.Result().Cookies() - t.Log(cookies) - require.Equal(t, 2, len(cookies)) - assert.Equal(t, "JWT", cookies[0].Name) - assert.Equal(t, testJwtValidSess, cookies[0].Value) - assert.Equal(t, 0, cookies[0].MaxAge) - assert.Equal(t, "XSRF-TOKEN", cookies[1].Name) - assert.Equal(t, "random id", cookies[1].Value) -} - -func TestJWT_GetFromHeader(t *testing.T) { - j := NewJWT(admin.NewStaticKeyStore("xyz 12345"), false, time.Hour, days31) - - req := httptest.NewRequest("GET", "/", nil) - req.Header.Add(jwtHeaderKey, testJwtValid) - claims, err := j.Get(req) - assert.Nil(t, err) - assert.False(t, j.IsExpired(claims)) - assert.Equal(t, &store.User{Name: "name1", ID: "id1", Picture: "", Admin: false, Blocked: false, IP: ""}, claims.User) - assert.Equal(t, "remark42", claims.Issuer) - - req = httptest.NewRequest("GET", "/", nil) - req.Header.Add(jwtHeaderKey, testJwtExpired) - claims, err = j.Get(req) - assert.Nil(t, err) - assert.True(t, j.IsExpired(claims)) - - req = httptest.NewRequest("GET", "/", nil) - req.Header.Add(jwtHeaderKey, "bad bad token") - _, err = j.Get(req) - require.NotNil(t, err) - assert.True(t, strings.Contains(err.Error(), "can't pre-parse jwt: token contains an invalid number of segments"), err.Error()) - -} - -func TestJWT_SetAndGetWithCookies(t *testing.T) { - j := NewJWT(admin.NewStaticKeyStore("xyz 12345"), false, time.Hour, days31) - - claims := &CustomClaims{ - State: "123456", - From: "from", - SessionOnly: true, - User: &store.User{ - ID: "id1", - Name: "name1", - }, - StandardClaims: jwt.StandardClaims{ - Id: "random id", - Issuer: "remark42", - ExpiresAt: time.Date(2058, 5, 21, 1, 30, 22, 0, time.Local).Unix(), - NotBefore: time.Date(2018, 5, 21, 1, 30, 22, 0, time.Local).Unix(), - }, - } - - ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path == "/valid" { - assert.Nil(t, j.Set(w, claims, true)) - w.WriteHeader(200) - } - })) - defer ts.Close() - - resp, err := http.Get(ts.URL + "/valid") - require.Nil(t, err) - assert.Equal(t, 200, resp.StatusCode) - - req := httptest.NewRequest("GET", "/valid", nil) - req.AddCookie(resp.Cookies()[0]) - req.Header.Add(xsrfHeaderKey, "random id") - claims, err = j.Get(req) - assert.Nil(t, err) - assert.Equal(t, &store.User{Name: "name1", ID: "id1", Picture: "", Admin: false, Blocked: false, IP: ""}, claims.User) - assert.Equal(t, "remark42", claims.Issuer) - assert.Equal(t, true, claims.SessionOnly) - t.Log(resp.Cookies()) -} - -func TestJWT_SetAndGetWithXsrfMismatch(t *testing.T) { - j := NewJWT(admin.NewStaticKeyStore("xyz 12345"), false, time.Hour, days31) - - claims := &CustomClaims{ - State: "123456", - From: "from", - User: &store.User{ - ID: "id1", - Name: "name1", - }, - StandardClaims: jwt.StandardClaims{ - Id: "random id", - Issuer: "remark42", - ExpiresAt: time.Date(2058, 5, 21, 1, 30, 22, 0, time.Local).Unix(), - NotBefore: time.Date(2018, 5, 21, 1, 30, 22, 0, time.Local).Unix(), - }, - } - - ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path == "/valid" { - assert.Nil(t, j.Set(w, claims, true)) - w.WriteHeader(200) - } - })) - defer ts.Close() - - resp, err := http.Get(ts.URL + "/valid") - require.Nil(t, err) - assert.Equal(t, 200, resp.StatusCode) - - req := httptest.NewRequest("GET", "/valid", nil) - req.AddCookie(resp.Cookies()[0]) - req.Header.Add(xsrfHeaderKey, "random id wrong") - claims, err = j.Get(req) - assert.EqualError(t, err, "xsrf mismatch") -} - -func TestJWT_SetAndGetWithCookiesExpired(t *testing.T) { - j := NewJWT(admin.NewStaticKeyStore("xyz 12345"), false, time.Hour, days31) - - claims := &CustomClaims{ - State: "123456", - From: "from", - User: &store.User{ - ID: "id1", - Name: "name1", - }, - StandardClaims: jwt.StandardClaims{ - Id: "random id", - Issuer: "remark42", - ExpiresAt: time.Date(2018, 5, 21, 1, 35, 22, 0, time.Local).Unix(), - NotBefore: time.Date(2018, 5, 21, 1, 30, 22, 0, time.Local).Unix(), - }, - } - - ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path == "/expired" { - assert.Nil(t, j.Set(w, claims, true)) - w.WriteHeader(200) - } - })) - defer ts.Close() - - resp, err := http.Get(ts.URL + "/expired") - require.Nil(t, err) - assert.Equal(t, 200, resp.StatusCode) - - req := httptest.NewRequest("GET", "/expired", nil) - req.AddCookie(resp.Cookies()[0]) - req.Header.Add(xsrfHeaderKey, "random id") - claims, err = j.Get(req) - assert.Nil(t, err) - assert.True(t, j.IsExpired(claims)) -} diff --git a/backend/app/rest/auth/provider.go b/backend/app/rest/auth/provider.go deleted file mode 100644 index 4558384c..00000000 --- a/backend/app/rest/auth/provider.go +++ /dev/null @@ -1,229 +0,0 @@ -package auth - -import ( - "context" - "crypto/rand" - "crypto/sha1" - "encoding/json" - "fmt" - "io/ioutil" - "log" - "net/http" - "time" - - "github.com/dgrijalva/jwt-go" - "github.com/go-chi/chi" - "github.com/go-chi/render" - "golang.org/x/oauth2" - - "github.com/umputun/remark/backend/app/rest" - "github.com/umputun/remark/backend/app/rest/proxy" - "github.com/umputun/remark/backend/app/store" -) - -// Provider represents oauth2 provider -type Provider struct { - Params - Name string - RedirectURL string - InfoURL string - Endpoint oauth2.Endpoint - Scopes []string - MapUser func(userData, []byte) store.User // map info from InfoURL to User - conf oauth2.Config -} - -// Params to make initialized and ready to use provider -type Params struct { - RemarkURL string - AvatarProxy *proxy.Avatar - JwtService *JWT - PermissionChecker PermissionChecker - Cid string - Csecret string -} - -type userData map[string]interface{} - -func (u userData) value(key string) string { - // json.Unmarshal converts json "null" value to go's "nil", in this case return empty string - if val, ok := u[key]; ok && val != nil { - return fmt.Sprintf("%v", val) - } - return "" -} - -// newProvider makes auth for given provider -func initProvider(p Params, provider Provider) Provider { - log.Printf("[INFO] init auth provider %s", provider.Name) - provider.Params = p - provider.conf = oauth2.Config{ - ClientID: provider.Cid, - ClientSecret: provider.Csecret, - RedirectURL: provider.RedirectURL, - Scopes: provider.Scopes, - Endpoint: provider.Endpoint, - } - - log.Printf("[DEBUG] created %s auth, id=%s, redir=%s, endpoint=%s", - provider.Name, provider.Cid, provider.Endpoint, provider.RedirectURL) - return provider -} - -// Routes returns auth routes for given provider -func (p Provider) Routes() chi.Router { - router := chi.NewRouter() - router.Get("/login", p.loginHandler) - router.Get("/callback", p.authHandler) - router.Get("/logout", p.LogoutHandler) - return router -} - -// loginHandler - GET /login?from=redirect-back-url&site=siteID&session=1 -func (p Provider) loginHandler(w http.ResponseWriter, r *http.Request) { - - log.Printf("[DEBUG] login with %s", p.Name) - // make state (random) and store in session - state := p.randToken() - - claims := CustomClaims{ - State: state, - From: r.URL.Query().Get("from"), - SiteID: r.URL.Query().Get("site"), - SessionOnly: r.URL.Query().Get("session") != "" && r.URL.Query().Get("session") != "0", - StandardClaims: jwt.StandardClaims{ - Id: p.randToken(), - Issuer: "remark42", - ExpiresAt: time.Now().Add(30 * time.Minute).Unix(), - NotBefore: time.Now().Add(-1 * time.Minute).Unix(), - }, - } - claims.Flags.Login = true - - if err := p.JwtService.Set(w, &claims, false); err != nil { - rest.SendErrorJSON(w, r, http.StatusInternalServerError, err, "failed to set jwt") - return - } - - // return login url - loginURL := p.conf.AuthCodeURL(state) - log.Printf("[DEBUG] login url %s, claims=%+v", loginURL, claims) - - http.Redirect(w, r, loginURL, http.StatusFound) -} - -// authHandler fills user info and redirects to "from" url. This is callback url redirected locally by browser -// GET /callback -func (p Provider) authHandler(w http.ResponseWriter, r *http.Request) { - oauthClaims, err := p.JwtService.Get(r) - if err != nil { - rest.SendErrorJSON(w, r, http.StatusInternalServerError, err, "failed to get jwt") - return - } - - retrievedState := oauthClaims.State - if retrievedState == "" || retrievedState != r.URL.Query().Get("state") { - http.Error(w, fmt.Sprintf("unexpected state %v", retrievedState), http.StatusUnauthorized) - return - } - - log.Printf("[DEBUG] auth with state %s", retrievedState) - tok, err := p.conf.Exchange(context.Background(), r.URL.Query().Get("code")) - if err != nil { - rest.SendErrorJSON(w, r, http.StatusInternalServerError, err, "exchange failed") - return - } - - client := p.conf.Client(context.Background(), tok) - uinfo, err := client.Get(p.InfoURL) - if err != nil { - rest.SendErrorJSON(w, r, http.StatusBadRequest, err, fmt.Sprintf("failed to get client info via %s", p.InfoURL)) - return - } - - defer func() { - if e := uinfo.Body.Close(); e != nil { - log.Printf("[WARN] failed to close response body, %s", e) - } - }() - - data, err := ioutil.ReadAll(uinfo.Body) - if err != nil { - rest.SendErrorJSON(w, r, http.StatusInternalServerError, err, "failed to read user info") - return - } - - jData := map[string]interface{}{} - if e := json.Unmarshal(data, &jData); e != nil { - rest.SendErrorJSON(w, r, http.StatusInternalServerError, err, "failed to unmarshal user info") - return - } - log.Printf("[DEBUG] got raw user info %+v", jData) - - u := p.MapUser(jData, data) - u = p.setPermissions(u, oauthClaims.SiteID) - u = p.setAvatar(u) - - claims := &CustomClaims{ - User: &u, - StandardClaims: jwt.StandardClaims{ - Issuer: "remark42", - Id: p.randToken(), - }, - SiteID: oauthClaims.SiteID, - SessionOnly: oauthClaims.SessionOnly, - } - - if err = p.JwtService.Set(w, claims, oauthClaims.SessionOnly); err != nil { - rest.SendErrorJSON(w, r, http.StatusInternalServerError, err, "failed to save user info") - return - } - - log.Printf("[DEBUG] user info %+v", u) - - // redirect to back url if presented in login query params - if oauthClaims.From != "" { - http.Redirect(w, r, oauthClaims.From, http.StatusTemporaryRedirect) - return - } - render.JSON(w, r, &u) -} - -// setAvatar saves avatar and puts proxied URL to u.Picture -func (p Provider) setAvatar(u store.User) store.User { - if p.AvatarProxy != nil { - if avatarURL, e := p.AvatarProxy.Put(u); e == nil { - u.Picture = avatarURL - } else { - log.Printf("[WARN] failed to proxy avatar, %s", e) - } - } - return u -} - -// setPermissions sets permission fields not handled by provider's MapUser, things like admin, verified and blocked -func (p Provider) setPermissions(u store.User, siteID string) store.User { - u.Admin = p.PermissionChecker.IsAdmin(siteID, u.ID) - u.Verified = p.PermissionChecker.IsVerified(siteID, u.ID) - u.Blocked = p.PermissionChecker.IsBlocked(siteID, u.ID) - log.Printf("[DEBUG] set permissions for user %s, site %s - %+v", u.ID, siteID, u) - return u -} - -// LogoutHandler - GET /logout -func (p Provider) LogoutHandler(w http.ResponseWriter, r *http.Request) { - p.JwtService.Reset(w) - log.Printf("[DEBUG] logout") -} - -func (p Provider) randToken() string { - b := make([]byte, 32) - if _, err := rand.Read(b); err != nil { - log.Fatalf("[ERROR] can't get randoms, %s", err) - } - s := sha1.New() - if _, err := s.Write(b); err != nil { - log.Printf("[WARN] can't write randoms, %s", err) - } - return fmt.Sprintf("%x", s.Sum(nil)) -} diff --git a/backend/app/rest/auth/provider_test.go b/backend/app/rest/auth/provider_test.go deleted file mode 100644 index c2b41616..00000000 --- a/backend/app/rest/auth/provider_test.go +++ /dev/null @@ -1,238 +0,0 @@ -package auth - -import ( - "encoding/json" - "fmt" - "io/ioutil" - "log" - "net/http" - "net/http/cookiejar" - "strings" - "testing" - "time" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "golang.org/x/oauth2" - - "github.com/umputun/remark/backend/app/store" - "github.com/umputun/remark/backend/app/store/admin" -) - -func TestLogin(t *testing.T) { - - ts, ots := mockProvider(t, 8981, 8982) - defer func() { - ts.Close() - ots.Close() - }() - - jar, err := cookiejar.New(nil) - require.Nil(t, err) - client := &http.Client{Jar: jar, Timeout: 5 * time.Second} - - // check non-admin, permanent - resp, err := client.Get("http://localhost:8981/login?site=remark") - require.Nil(t, err) - assert.Equal(t, 200, resp.StatusCode) - body, err := ioutil.ReadAll(resp.Body) - assert.Nil(t, err) - t.Logf("resp %s", string(body)) - t.Logf("headers: %+v", resp.Header) - - assert.Equal(t, 2, len(resp.Cookies())) - assert.Equal(t, "JWT", resp.Cookies()[0].Name) - assert.NotEqual(t, "", resp.Cookies()[0].Value, "jwt set") - assert.Equal(t, 2678400, resp.Cookies()[0].MaxAge) - assert.Equal(t, "XSRF-TOKEN", resp.Cookies()[1].Name) - assert.NotEqual(t, "", resp.Cookies()[1].Value, "xsrf cookie set") - - u := store.User{} - err = json.Unmarshal(body, &u) - assert.Nil(t, err) - assert.Equal(t, store.User{Name: "blah", ID: "mock_myuser1", Picture: "http://exmple.com/pic1.png", - Admin: false, Blocked: true, IP: ""}, u) - - token := resp.Cookies()[0].Value - jwtSvc := NewJWT(admin.NewStaticKeyStore("12345"), false, time.Hour, time.Hour*24*31) - - claims, err := jwtSvc.Parse(token) - require.NoError(t, err) - assert.Equal(t, "remark42", claims.Issuer) - assert.Equal(t, "remark", claims.SiteID) - - // check admin user - resp, err = client.Get("http://localhost:8981/login?site=remark") - assert.Nil(t, err) - assert.Equal(t, 200, resp.StatusCode) - body, err = ioutil.ReadAll(resp.Body) - assert.Nil(t, err) - u = store.User{} - err = json.Unmarshal(body, &u) - assert.Nil(t, err) - assert.Equal(t, store.User{Name: "blah", ID: "mock_myuser2", Picture: "http://exmple.com/pic1.png", - Admin: true, Blocked: false, IP: "", Verified: true}, u) -} - -func TestLoginSessionOnly(t *testing.T) { - - ts, ots := mockProvider(t, 8981, 8982) - defer func() { - ts.Close() - ots.Close() - }() - - jar, err := cookiejar.New(nil) - require.Nil(t, err) - client := &http.Client{Jar: jar, Timeout: 5 * time.Second} - - // check non-admin, session - resp, err := client.Get("http://localhost:8981/login?site=remark&session=1") - require.Nil(t, err) - assert.Equal(t, 200, resp.StatusCode) - assert.Equal(t, 2, len(resp.Cookies())) - assert.Equal(t, "JWT", resp.Cookies()[0].Name) - assert.NotEqual(t, "", resp.Cookies()[0].Value, "jwt set") - assert.Equal(t, 0, resp.Cookies()[0].MaxAge) - assert.Equal(t, "XSRF-TOKEN", resp.Cookies()[1].Name) - assert.NotEqual(t, "", resp.Cookies()[1].Value, "xsrf cookie set") - - req, err := http.NewRequest("GET", "http://example.com", nil) - require.Nil(t, err) - req.AddCookie(resp.Cookies()[0]) - req.AddCookie(resp.Cookies()[1]) - req.Header.Add("X-XSRF-TOKEN", resp.Cookies()[1].Value) - - jwtService := NewJWT(admin.NewStaticKeyStore("12345"), false, time.Hour, time.Hour) - res, err := jwtService.Get(req) - require.Nil(t, err) - assert.Equal(t, true, res.SessionOnly) - t.Logf("%+v", res) -} - -func TestLogout(t *testing.T) { - - ts, ots := mockProvider(t, 8691, 8692) - defer func() { - ts.Close() - ots.Close() - }() - - jar, err := cookiejar.New(nil) - require.Nil(t, err) - client := &http.Client{Jar: jar, Timeout: 5 * time.Second} - - resp, err := client.Get("http://localhost:8691/login") - require.Nil(t, err) - assert.Equal(t, 200, resp.StatusCode) - assert.Equal(t, 2, len(resp.Cookies())) - resp, err = client.Get("http://localhost:8691/logout") - require.Nil(t, err) - assert.Equal(t, 200, resp.StatusCode) - - assert.Equal(t, 2, len(resp.Cookies())) - assert.Equal(t, "JWT", resp.Cookies()[0].Name, "jwt cookie cleared") - assert.Equal(t, "", resp.Cookies()[0].Value) - assert.Equal(t, "XSRF-TOKEN", resp.Cookies()[1].Name, "xsrf cookie cleared") - assert.Equal(t, "", resp.Cookies()[1].Value) -} - -func TestInitProvider(t *testing.T) { - params := Params{RemarkURL: "url", Cid: "cid", Csecret: "csecret"} - provider := Provider{Name: "test", RedirectURL: "redir"} - res := initProvider(params, provider) - assert.Equal(t, "cid", res.conf.ClientID) - assert.Equal(t, "csecret", res.conf.ClientSecret) - assert.Equal(t, "redir", res.RedirectURL) - assert.Equal(t, "test", res.Name) -} - -func mockProvider(t *testing.T, loginPort, authPort int) (*http.Server, *http.Server) { - - provider := Provider{ - Name: "mock", - Endpoint: oauth2.Endpoint{ - AuthURL: fmt.Sprintf("http://localhost:%d/login/oauth/authorize", authPort), - TokenURL: fmt.Sprintf("http://localhost:%d/login/oauth/access_token", authPort), - }, - RedirectURL: fmt.Sprintf("http://localhost:%d/callback", loginPort), - Scopes: []string{"user:email"}, - InfoURL: fmt.Sprintf("http://localhost:%d/user", authPort), - MapUser: func(data userData, _ []byte) store.User { - userInfo := store.User{ - ID: "mock_" + data.value("id"), - Name: data.value("name"), - Picture: data.value("picture"), - } - return userInfo - }, - } - - params := Params{RemarkURL: "url", Cid: "cid", Csecret: "csecret", - JwtService: NewJWT(admin.NewStaticKeyStore("12345"), false, time.Hour, time.Hour*24*31), - // AvatarProxy: &proxy.Avatar{Store: &mockAvatarStore, RoutePath: "/v1/avatar"}, - PermissionChecker: &mockUserPermissions{admin: "mock_myuser2", verified: "mock_myuser2", blocked: "mock_myuser1"}, - } - provider = initProvider(params, provider) - - ts := &http.Server{Addr: fmt.Sprintf(":%d", loginPort), Handler: provider.Routes()} - - count := 0 - useIds := []string{"myuser1", "myuser2"} // user for first ans second calls - - oauth := &http.Server{ - Addr: fmt.Sprintf(":%d", authPort), - Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - log.Printf("[MOCK OAUTH] request %s %s %+v", r.Method, r.URL, r.Header) - switch { - case strings.HasPrefix(r.URL.Path, "/login/oauth/authorize"): - state := r.URL.Query().Get("state") - w.Header().Add("Location", fmt.Sprintf("http://localhost:%d/callback?code=g0ZGZmNjVmOWI&state=%s", - loginPort, state)) - w.WriteHeader(302) - case strings.HasPrefix(r.URL.Path, "/login/oauth/access_token"): - res := `{ - "access_token":"MTQ0NjJkZmQ5OTM2NDE1ZTZjNGZmZjI3", - "token_type":"bearer", - "expires_in":3600, - "refresh_token":"IwOGYzYTlmM2YxOTQ5MGE3YmNmMDFkNTVk", - "scope":"create", - "state":"12345678" - }` - w.Header().Set("Content-Type", "application/json; charset=utf-8") - w.WriteHeader(200) - _, err := w.Write([]byte(res)) - assert.NoError(t, err) - case strings.HasPrefix(r.URL.Path, "/user"): - res := fmt.Sprintf(`{ - "id": "%s", - "name":"blah", - "picture":"http://exmple.com/pic1.png" - }`, useIds[count]) - count++ - w.Header().Set("Content-Type", "application/json; charset=utf-8") - w.WriteHeader(200) - _, err := w.Write([]byte(res)) - assert.NoError(t, err) - default: - t.Fatalf("unexpected oauth request %s %s", r.Method, r.URL) - } - }), - } - - go func() { _ = oauth.ListenAndServe() }() - go func() { _ = ts.ListenAndServe() }() - - time.Sleep(time.Millisecond * 100) // let them start - return ts, oauth -} - -type mockUserPermissions struct { - admin string - verified string - blocked string -} - -func (m *mockUserPermissions) IsAdmin(siteID, userID string) bool { return userID == m.admin } -func (m *mockUserPermissions) IsVerified(siteID, userID string) bool { return userID == m.verified } -func (m *mockUserPermissions) IsBlocked(siteID, userID string) bool { return userID == m.blocked } diff --git a/backend/app/rest/auth/providers_test.go b/backend/app/rest/auth/providers_test.go deleted file mode 100644 index 9867d94c..00000000 --- a/backend/app/rest/auth/providers_test.go +++ /dev/null @@ -1,83 +0,0 @@ -package auth - -import ( - "testing" - - "github.com/stretchr/testify/assert" - "github.com/umputun/remark/backend/app/store" -) - -func TestProviders_NewGoogle(t *testing.T) { - r := NewGoogle(Params{RemarkURL: "http://demo.remark42.com", Cid: "cid", Csecret: "cs"}) - assert.Equal(t, "google", r.Name) - - udata := userData{"sub": "1234567890", "name": "test user", "picture": "http://demo.remark42.com/blah.png"} - user := r.MapUser(udata, nil) - assert.Equal(t, store.User{Name: "test user", ID: "google_01b307acba4f54f55aafc33bb06bbbf6ca803e9a", - Picture: "http://demo.remark42.com/blah.png", Admin: false, Blocked: false, IP: ""}, user, "got %+v", user) - - // no name in data - udata = userData{"sub": "1234567890", "picture": "http://demo.remark42.com/blah.png"} - user = r.MapUser(udata, nil) - assert.Equal(t, store.User{Name: "noname_1b30", ID: "google_01b307acba4f54f55aafc33bb06bbbf6ca803e9a", - Picture: "http://demo.remark42.com/blah.png", Admin: false, Blocked: false, IP: ""}, user, "got %+v", user) -} - -func TestProviders_NewGithub(t *testing.T) { - r := NewGithub(Params{RemarkURL: "http://demo.remark42.com", Cid: "cid", Csecret: "cs"}) - assert.Equal(t, "github", r.Name) - - udata := userData{"login": "lll", "name": "test user", "avatar_url": "http://demo.remark42.com/blah.png"} - user := r.MapUser(udata, nil) - assert.Equal(t, store.User{Name: "test user", ID: "github_e80b2d2608711cbb3312db7c4727a46fbad9601a", - Picture: "http://demo.remark42.com/blah.png", Admin: false, Blocked: false, IP: ""}, user, "got %+v", user) - - // nil name in data (json response contains `"name": null`); using login, it's always required - udata = userData{"login": "lll", "name": nil, "avatar_url": "http://demo.remark42.com/blah.png"} - user = r.MapUser(udata, nil) - assert.Equal(t, store.User{Name: "lll", ID: "github_e80b2d2608711cbb3312db7c4727a46fbad9601a", - Picture: "http://demo.remark42.com/blah.png", Admin: false, Blocked: false, IP: ""}, user, "got %+v", user) -} - -func TestProviders_NewFacebook(t *testing.T) { - r := NewFacebook(Params{RemarkURL: "http://demo.remark42.com", Cid: "cid", Csecret: "cs"}) - assert.Equal(t, "facebook", r.Name) - - udata := userData{"id": "myid", "name": "test user"} - user := r.MapUser(udata, []byte(`{"picture": {"data": {"url": "http://demo.remark42.com/blah.png"} }}`)) - assert.Equal(t, store.User{Name: "test user", ID: "facebook_6e34471f84557e1713012d64a7477c71bfdac631", - Picture: "http://demo.remark42.com/blah.png", Admin: false, Blocked: false, IP: ""}, user, "got %+v", user) - - udata = userData{"id": "myid", "name": ""} - user = r.MapUser(udata, []byte(`{"picture": {"data": {"url": "http://demo.remark42.com/blah.png"} }}`)) - assert.Equal(t, store.User{Name: "facebook_6e34471", ID: "facebook_6e34471f84557e1713012d64a7477c71bfdac631", - Picture: "http://demo.remark42.com/blah.png", Admin: false, Blocked: false, IP: ""}, user, "got %+v", user) -} - -func TestProviders_NewYandex(t *testing.T) { - r := NewYandex(Params{RemarkURL: "http://demo.remark42.com", Cid: "cid", Csecret: "cs"}) - assert.Equal(t, "yandex", r.Name) - - udata := userData{"id": "1234567890", "display_name": "Vasya P", "default_avatar_id": "131652443"} - user := r.MapUser(udata, nil) - assert.Equal(t, store.User{Name: "Vasya P", ID: "yandex_01b307acba4f54f55aafc33bb06bbbf6ca803e9a", - Picture: "https://avatars.yandex.net/get-yapic/131652443/islands-200", Admin: false, Blocked: false, IP: ""}, user, "got %+v", user) - - // "display_name": null, "default_avatar_id": null - udata = userData{"id": "1234567890", "login": "vasya", "display_name": nil, "real_name": "Vasya Pupkin", "default_avatar_id": nil} - user = r.MapUser(udata, nil) - assert.Equal(t, store.User{Name: "Vasya Pupkin", ID: "yandex_01b307acba4f54f55aafc33bb06bbbf6ca803e9a", - Picture: "", Admin: false, Blocked: false, IP: ""}, user, "got %+v", user) - - // empty "display_name", empty "default_avatar_id", empty "real_name" - udata = userData{"id": "1234567890", "login": "vasya", "display_name": "", "real_name": "", "default_avatar_id": ""} - user = r.MapUser(udata, nil) - assert.Equal(t, store.User{Name: "vasya", ID: "yandex_01b307acba4f54f55aafc33bb06bbbf6ca803e9a", - Picture: "", Admin: false, Blocked: false, IP: ""}, user, "got %+v", user) - - // "real_name": null - udata = userData{"id": "1234567890", "login": "vasya", "real_name": nil, "default_avatar_id": ""} - user = r.MapUser(udata, nil) - assert.Equal(t, store.User{Name: "vasya", ID: "yandex_01b307acba4f54f55aafc33bb06bbbf6ca803e9a", - Picture: "", Admin: false, Blocked: false, IP: ""}, user, "got %+v", user) -} diff --git a/backend/app/rest/proxy/avatar.go b/backend/app/rest/proxy/avatar.go deleted file mode 100644 index 7b71bcf1..00000000 --- a/backend/app/rest/proxy/avatar.go +++ /dev/null @@ -1,118 +0,0 @@ -package proxy - -import ( - "io" - "log" - "net/http" - "strconv" - "strings" - "time" - - "github.com/go-chi/chi" - "github.com/pkg/errors" - - "github.com/umputun/remark/backend/app/rest" - "github.com/umputun/remark/backend/app/store" - "github.com/umputun/remark/backend/app/store/avatar" -) - -// Avatar provides http handler for avatars from avatar.Store -// On user login auth will call Put and it will retrieve and save picture locally. -type Avatar struct { - Store avatar.Store - RoutePath string - RemarkURL string -} - -// Put stores retrieved avatar to avatar.Store. Gets image from user info. Returns proxied url -func (p *Avatar) Put(u store.User) (avatarURL string, err error) { - - // no picture for user, try default avatar - if u.Picture == "" { - return "", errors.Errorf("no picture for %s", u.ID) - } - - // load avatar from remote location - client := http.Client{Timeout: 10 * time.Second} - var resp *http.Response - err = retry(5, time.Second, func() error { - var e error - resp, e = client.Get(u.Picture) - return e - }) - if err != nil { - return "", errors.Wrap(err, "failed to fetch avatar from the orig") - } - - defer func() { - if e := resp.Body.Close(); e != nil { - log.Printf("[WARN] can't close response body, %s", e) - } - }() - - if resp.StatusCode != http.StatusOK { - return "", errors.Errorf("failed to get avatar from the orig, status %s", resp.Status) - } - - avatarID, err := p.Store.Put(u.ID, resp.Body) // put returns avatar base name, like 123456.image - if err != nil { - return "", err - } - - log.Printf("[DEBUG] saved avatar from %s to %s, user %q", u.Picture, avatarID, u.Name) - return p.RemarkURL + p.RoutePath + "/" + avatarID, nil -} - -// Routes returns auth routes for given provider -func (p *Avatar) Routes(middlewares ...func(http.Handler) http.Handler) (string, chi.Router) { - router := chi.NewRouter() - router.Use(middlewares...) - - // GET /123456789.image - router.Get("/{avatar}", func(w http.ResponseWriter, r *http.Request) { - - avatarID := chi.URLParam(r, "avatar") - - // enforce client-side caching - etag := `"` + p.Store.ID(avatarID) + `"` - w.Header().Set("Etag", etag) - w.Header().Set("Cache-Control", "max-age=604800") // 7 days - if match := r.Header.Get("If-None-Match"); match != "" { - if strings.Contains(match, etag) { - w.WriteHeader(http.StatusNotModified) - return - } - } - - avReader, size, err := p.Store.Get(avatarID) - if err != nil { - rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "can't load avatar") - return - } - - defer func() { - if e := avReader.Close(); e != nil { - log.Printf("[WARN] can't close avatar reader for %s, %s", avatarID, e) - } - }() - - w.Header().Set("Content-Type", "image/*") - w.Header().Set("Content-Length", strconv.Itoa(size)) - w.WriteHeader(http.StatusOK) - if _, err = io.Copy(w, avReader); err != nil { - log.Printf("[WARN] can't send response to %s, %s", r.RemoteAddr, err) - } - }) - - return p.RoutePath, router -} - -func retry(retries int, delay time.Duration, fn func() error) (err error) { - for i := 0; i < retries; i++ { - if err = fn(); err == nil { - return nil - } - time.Sleep(delay) - } - return errors.Wrap(err, "retry failed") -} diff --git a/backend/app/rest/proxy/avatar_test.go b/backend/app/rest/proxy/avatar_test.go deleted file mode 100644 index 53eba9a6..00000000 --- a/backend/app/rest/proxy/avatar_test.go +++ /dev/null @@ -1,172 +0,0 @@ -package proxy - -import ( - "bytes" - "errors" - "fmt" - "io" - "log" - "net/http" - "net/http/httptest" - "os" - "testing" - "time" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "github.com/umputun/remark/backend/app/store" - "github.com/umputun/remark/backend/app/store/avatar" -) - -func TestAvatar_Put(t *testing.T) { - - ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path == "/pic.png" { - w.Header().Set("Content-Type", "image/*") - fmt.Fprint(w, "some picture bin data") - return - } - http.Error(w, "not found", http.StatusNotFound) - })) - defer ts.Close() - - p := Avatar{RoutePath: "/avatar", RemarkURL: "http://localhost:8080", Store: avatar.NewLocalFS("/tmp/avatars.test", 300)} - os.MkdirAll("/tmp/avatars.test", 0700) - defer os.RemoveAll("/tmp/avatars.test") - - u := store.User{ID: "user1", Name: "user1 name", Picture: ts.URL + "/pic.png"} - res, err := p.Put(u) - assert.NoError(t, err) - assert.Equal(t, "http://localhost:8080/avatar/b3daa77b4c04a9551b8781d03191fe098f325e67.image", res) - fi, err := os.Stat("/tmp/avatars.test/30/b3daa77b4c04a9551b8781d03191fe098f325e67.image") - assert.NoError(t, err) - assert.Equal(t, int64(21), fi.Size()) - - u.ID = "user2" - res, err = p.Put(u) - assert.NoError(t, err) - assert.Equal(t, "http://localhost:8080/avatar/a1881c06eec96db9901c7bbfe41c42a3f08e9cb4.image", res) - fi, err = os.Stat("/tmp/avatars.test/84/a1881c06eec96db9901c7bbfe41c42a3f08e9cb4.image") - assert.NoError(t, err) - assert.Equal(t, int64(21), fi.Size()) -} - -func TestAvatar_PutFailed(t *testing.T) { - - ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - log.Print("request: ", r.URL.Path) - w.WriteHeader(http.StatusNotFound) - })) - defer ts.Close() - - p := Avatar{RoutePath: "/avatar", Store: avatar.NewLocalFS("/tmp/avatars.test", 300)} - - u := store.User{ID: "user1", Name: "user1 name"} - _, err := p.Put(u) - assert.EqualError(t, err, "no picture for user1") - - u = store.User{ID: "user1", Name: "user1 name", Picture: "http://127.0.0.1:22345/avater/pic"} - _, err = p.Put(u) - require.Error(t, err) - assert.Contains(t, err.Error(), "connect: connection refused") - - u = store.User{ID: "user1", Name: "user1 name", Picture: ts.URL + "/avatar/pic"} - _, err = p.Put(u) - require.Error(t, err) - assert.Contains(t, err.Error(), "failed to get avatar from the orig") -} - -func TestAvatar_Routes(t *testing.T) { - - ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path == "/pic.png" { - w.Header().Set("Content-Type", "image/*") - w.Header().Set("Custom-Header", "xyz") - fmt.Fprint(w, "some picture bin data") - return - } - http.Error(w, "not found", http.StatusNotFound) - })) - defer ts.Close() - - p := Avatar{RoutePath: "/avatar", Store: avatar.NewLocalFS("/tmp/avatars.test", 300)} - os.MkdirAll("/tmp/avatars.test", 0700) - defer os.RemoveAll("/tmp/avatars.test") - - u := store.User{ID: "user1", Name: "user1 name", Picture: ts.URL + "/pic.png"} - _, err := p.Put(u) - assert.NoError(t, err) - - // status 400 - req, err := http.NewRequest("GET", "/some_random_name.image", nil) - if err != nil { - t.Fatal(err) - } - - rr := httptest.NewRecorder() - _, routes := p.Routes() - handler := http.Handler(routes) - handler.ServeHTTP(rr, req) - - assert.Equal(t, http.StatusBadRequest, rr.Code) - - // status 200 - req, err = http.NewRequest("GET", "/b3daa77b4c04a9551b8781d03191fe098f325e67.image", nil) - if err != nil { - t.Fatal(err) - } - - rr = httptest.NewRecorder() - _, routes = p.Routes() - handler = http.Handler(routes) - handler.ServeHTTP(rr, req) - - assert.Equal(t, http.StatusOK, rr.Code) - - assert.Equal(t, []string{"image/*"}, rr.HeaderMap["Content-Type"]) - assert.Equal(t, []string{"21"}, rr.HeaderMap["Content-Length"]) - assert.Equal(t, []string(nil), rr.HeaderMap["Custom-Header"], "strip all custom headers") - assert.NotNil(t, rr.HeaderMap["Etag"]) - - bb := bytes.Buffer{} - sz, err := io.Copy(&bb, rr.Body) - assert.NoError(t, err) - assert.Equal(t, int64(21), sz) - assert.Equal(t, "some picture bin data", bb.String()) - - // status 304 - req, err = http.NewRequest("GET", "/some_random_name.image", nil) - if err != nil { - t.Fatal(err) - } - req.Header.Add("If-None-Match", `"a008de0a2ccb3308b5d99ffff66436e15538f701"`) // hash of `some_random_name.image` since the file doesn't exist - - rr = httptest.NewRecorder() - _, routes = p.Routes() - handler = http.Handler(routes) - handler.ServeHTTP(rr, req) - - assert.Equal(t, http.StatusNotModified, rr.Code) - assert.Equal(t, []string{`"a008de0a2ccb3308b5d99ffff66436e15538f701"`}, rr.HeaderMap["Etag"]) -} - -func TestAvatar_Retry(t *testing.T) { - i := 0 - err := retry(5, time.Millisecond, func() error { - if i == 3 { - return nil - } - i++ - return errors.New("err") - }) - assert.Nil(t, err) - assert.Equal(t, 3, i) - - st := time.Now() - err = retry(5, time.Millisecond, func() error { - return errors.New("err") - }) - assert.NotNil(t, err) - assert.True(t, time.Since(st) >= time.Microsecond*5) -} diff --git a/backend/app/rest/proxy/image.go b/backend/app/rest/proxy/image.go index 284c8e13..c66c7054 100644 --- a/backend/app/rest/proxy/image.go +++ b/backend/app/rest/proxy/image.go @@ -12,6 +12,8 @@ import ( "github.com/go-chi/chi" "github.com/pkg/errors" + "github.com/go-pkgz/repeater" + "github.com/umputun/remark/backend/app/rest" ) @@ -52,7 +54,7 @@ func (p Image) Routes() chi.Router { client := http.Client{Timeout: 30 * time.Second} var resp *http.Response - err = retry(5, time.Second, func() error { + err = repeater.NewDefault(5, time.Second).Do(func() error { var e error resp, e = client.Get(string(src)) return e diff --git a/backend/app/rest/user.go b/backend/app/rest/user.go index 98d0092c..7727e137 100644 --- a/backend/app/rest/user.go +++ b/backend/app/rest/user.go @@ -1,15 +1,14 @@ package rest import ( - "context" - "errors" "net/http" + "github.com/go-pkgz/auth/token" + "github.com/pkg/errors" + "github.com/umputun/remark/backend/app/store" ) -type contextKey string - // MustGetUserInfo fails if can't extract user data from the request. // should be called from authed controllers only func MustGetUserInfo(r *http.Request) store.User { @@ -23,20 +22,36 @@ func MustGetUserInfo(r *http.Request) store.User { // GetUserInfo returns user from request context func GetUserInfo(r *http.Request) (user store.User, err error) { - ctx := r.Context() - if ctx == nil { - return store.User{}, errors.New("no info about user") - } - if u, ok := ctx.Value(contextKey("user")).(store.User); ok { - return u, nil + u, err := token.GetUserInfo(r) + if err != nil { + return store.User{}, errors.Wrap(err, "can't extract user info from the token") } - return store.User{}, errors.New("user can't be parsed") + return store.User{ + Name: u.Name, + ID: u.ID, + IP: u.IP, + Picture: u.Picture, + Admin: u.IsAdmin(), + Verified: u.BoolAttr("verified"), + Blocked: u.BoolAttr("blocked"), + }, nil + } // SetUserInfo sets user into request context func SetUserInfo(r *http.Request, user store.User) *http.Request { - ctx := r.Context() - ctx = context.WithValue(ctx, contextKey("user"), user) - return r.WithContext(ctx) + u := token.User{ + ID: user.ID, + Name: user.Name, + Picture: user.Picture, + IP: user.IP, + Attributes: map[string]interface{}{ + "blocked": user.Blocked, + "verified": user.Verified, + }, + } + u.SetAdmin(user.Admin) + + return token.SetUserInfo(r, u) } diff --git a/backend/app/store/admin/mongo.go b/backend/app/store/admin/mongo.go index ddd98ac8..f519c482 100644 --- a/backend/app/store/admin/mongo.go +++ b/backend/app/store/admin/mongo.go @@ -5,8 +5,9 @@ import ( "github.com/globalsign/mgo" "github.com/globalsign/mgo/bson" - "github.com/go-pkgz/mongo" "github.com/pkg/errors" + + "github.com/go-pkgz/mongo" ) // MongoStore implements admin.Store with mongo backend diff --git a/backend/app/store/avatar/bolt_test.go b/backend/app/store/avatar/bolt_test.go deleted file mode 100644 index b9ddac23..00000000 --- a/backend/app/store/avatar/bolt_test.go +++ /dev/null @@ -1,99 +0,0 @@ -package avatar - -import ( - "io/ioutil" - "os" - "sort" - "strings" - "testing" - - "github.com/coreos/bbolt" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -var testDb = "/tmp/test-remark-avatars.db" - -func TestBoltDB_PutAndGet(t *testing.T) { - var b Store = prepBoltStore(t) - defer func() { - assert.Nil(t, b.Close()) - os.Remove(testDb) - }() - - avatar, err := b.Put("user1", strings.NewReader("some picture bin data")) - require.Nil(t, err) - assert.Equal(t, "b3daa77b4c04a9551b8781d03191fe098f325e67.image", avatar) - - rd, size, err := b.Get(avatar) - require.Nil(t, err) - assert.Equal(t, 21, size) - data, err := ioutil.ReadAll(rd) - require.Nil(t, err) - assert.Equal(t, "some picture bin data", string(data)) - - _, _, err = b.Get("bad avatar") - assert.NotNil(t, err) - - // check IDs - assert.Equal(t, "fddae9ce556712a6ece0e8951a6e7a05c51ed6bf", b.ID(avatar)) - assert.Equal(t, "70c881d4a26984ddce795f6f71817c9cf4480e79", b.ID("aaaa"), "no data, encoded avatar id") - - l, err := b.List() - require.Nil(t, err) - assert.Equal(t, 1, len(l)) - assert.Equal(t, "b3daa77b4c04a9551b8781d03191fe098f325e67.image", l[0]) -} - -func TestBoltDB_Remove(t *testing.T) { - b := prepBoltStore(t) - defer func() { - assert.Nil(t, b.Close()) - os.Remove(testDb) - }() - - assert.NotNil(t, b.Remove("no-such-thing.image")) - - avatar, err := b.Put("user1", strings.NewReader("some picture bin data")) - require.Nil(t, err) - assert.Equal(t, "b3daa77b4c04a9551b8781d03191fe098f325e67.image", avatar) - assert.NoError(t, b.Remove("b3daa77b4c04a9551b8781d03191fe098f325e67.image"), "remove real one") - assert.NotNil(t, b.Remove("b3daa77b4c04a9551b8781d03191fe098f325e67.image"), "already removed") -} - -func TestBoltDB_List(t *testing.T) { - b := prepBoltStore(t) - defer func() { - assert.Nil(t, b.Close()) - os.Remove(testDb) - }() - - // write some avatars - _, err := b.Put("user1", strings.NewReader("some picture bin data 1")) - require.Nil(t, err) - _, err = b.Put("user2", strings.NewReader("some picture bin data 2")) - require.Nil(t, err) - _, err = b.Put("user3", strings.NewReader("some picture bin data 3")) - require.Nil(t, err) - - l, err := b.List() - assert.NoError(t, err) - assert.Equal(t, 3, len(l), "3 avatars listed") - sort.Strings(l) - assert.Equal(t, []string{"0b7f849446d3383546d15a480966084442cd2193.image", "a1881c06eec96db9901c7bbfe41c42a3f08e9cb4.image", "b3daa77b4c04a9551b8781d03191fe098f325e67.image"}, l) - - r, size, err := b.Get("0b7f849446d3383546d15a480966084442cd2193.image") - assert.Nil(t, err) - assert.Equal(t, 23, size) - data, err := ioutil.ReadAll(r) - assert.Nil(t, err) - assert.Equal(t, "some picture bin data 3", string(data)) -} - -// makes new boltdb, put two records -func prepBoltStore(t *testing.T) *BoltDB { - os.Remove(testDb) - boltStore, err := NewBoltDB(testDb, bolt.Options{}, 0) - require.Nil(t, err) - return boltStore -} diff --git a/backend/app/store/avatar/gridfs_test.go b/backend/app/store/avatar/gridfs_test.go deleted file mode 100644 index 4ca28741..00000000 --- a/backend/app/store/avatar/gridfs_test.go +++ /dev/null @@ -1,97 +0,0 @@ -package avatar - -import ( - "io/ioutil" - "sort" - "strings" - "testing" - - "github.com/globalsign/mgo" - "github.com/go-pkgz/mongo" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestGridFS_PutAndGet(t *testing.T) { - p, skip := prepGFStore(t) - if skip { - return - } - avatar, err := p.Put("user1", strings.NewReader("some picture bin data")) - require.Nil(t, err) - assert.Equal(t, "b3daa77b4c04a9551b8781d03191fe098f325e67.image", avatar) - - rd, size, err := p.Get(avatar) - require.Nil(t, err) - assert.Equal(t, 21, size) - data, err := ioutil.ReadAll(rd) - require.Nil(t, err) - assert.Equal(t, "some picture bin data", string(data)) - - _, _, err = p.Get("bad avatar") - assert.NotNil(t, err) - - assert.Equal(t, "8ce5568f7f9a1c9da5b897bc8642e397", p.ID(avatar)) - assert.Equal(t, "70c881d4a26984ddce795f6f71817c9cf4480e79", p.ID("aaaa"), "no data, encode avatar id") - - l, err := p.List() - require.Nil(t, err) - assert.Equal(t, 1, len(l)) - assert.Equal(t, "b3daa77b4c04a9551b8781d03191fe098f325e67.image", l[0]) -} - -func TestGridFS_Remove(t *testing.T) { - p, skip := prepGFStore(t) - if skip { - return - } - - assert.NotNil(t, p.Remove("no-such-thing.image")) - - avatar, err := p.Put("user1", strings.NewReader("some picture bin data")) - require.Nil(t, err) - assert.Equal(t, "b3daa77b4c04a9551b8781d03191fe098f325e67.image", avatar) - assert.NoError(t, p.Remove("b3daa77b4c04a9551b8781d03191fe098f325e67.image"), "remove real one") - assert.NotNil(t, p.Remove("b3daa77b4c04a9551b8781d03191fe098f325e67.image"), "already removed") -} - -func TestGridFS_List(t *testing.T) { - p, skip := prepGFStore(t) - if skip { - return - } - // write some avatars - _, err := p.Put("user1", strings.NewReader("some picture bin data 1")) - require.Nil(t, err) - _, err = p.Put("user2", strings.NewReader("some picture bin data 2")) - require.Nil(t, err) - _, err = p.Put("user3", strings.NewReader("some picture bin data 3")) - require.Nil(t, err) - - l, err := p.List() - assert.NoError(t, err) - assert.Equal(t, 3, len(l), "3 avatars listed") - sort.Strings(l) - assert.Equal(t, []string{"0b7f849446d3383546d15a480966084442cd2193.image", "a1881c06eec96db9901c7bbfe41c42a3f08e9cb4.image", "b3daa77b4c04a9551b8781d03191fe098f325e67.image"}, l) - - r, size, err := p.Get("0b7f849446d3383546d15a480966084442cd2193.image") - assert.Nil(t, err) - assert.Equal(t, 23, size) - data, err := ioutil.ReadAll(r) - assert.Nil(t, err) - assert.Equal(t, "some picture bin data 3", string(data)) -} - -func prepGFStore(t *testing.T) (Store, bool) { - conn, err := mongo.MakeTestConnection(t) - if err != nil { - return nil, true - } - _ = conn.WithCustomCollection("fs.chunks", func(coll *mgo.Collection) error { - return coll.DropCollection() - }) - _ = conn.WithCustomCollection("fs.files", func(coll *mgo.Collection) error { - return coll.DropCollection() - }) - return NewGridFS(conn, 0), false -} diff --git a/backend/app/store/avatar/localfs_test.go b/backend/app/store/avatar/localfs_test.go deleted file mode 100644 index 922c420f..00000000 --- a/backend/app/store/avatar/localfs_test.go +++ /dev/null @@ -1,178 +0,0 @@ -package avatar - -import ( - "io/ioutil" - "os" - "sort" - "strings" - "testing" - "time" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestAvatarStoreFS_Put(t *testing.T) { - p := NewLocalFS("/tmp/avatars.test", 300) - err := os.MkdirAll("/tmp/avatars.test", 0700) - require.NoError(t, err) - defer os.RemoveAll("/tmp/avatars.test") - - avatar, err := p.Put("user1", nil) - assert.Equal(t, "", avatar) - assert.EqualError(t, err, "avatar resize reader is nil") - - avatar, err = p.Put("user1", strings.NewReader("some picture bin data")) - require.Nil(t, err) - assert.Equal(t, "b3daa77b4c04a9551b8781d03191fe098f325e67.image", avatar) - fi, err := os.Stat("/tmp/avatars.test/30/b3daa77b4c04a9551b8781d03191fe098f325e67.image") - assert.NoError(t, err) - assert.Equal(t, int64(21), fi.Size()) - - avatar, err = p.Put("user2", strings.NewReader("some picture bin data 123")) - require.Nil(t, err) - assert.Equal(t, "a1881c06eec96db9901c7bbfe41c42a3f08e9cb4.image", avatar) - fi, err = os.Stat("/tmp/avatars.test/84/a1881c06eec96db9901c7bbfe41c42a3f08e9cb4.image") - assert.NoError(t, err) - assert.Equal(t, int64(25), fi.Size()) - - // with encoded id - avatar, err = p.Put("f1881c06eec96db9901c7bbfe41c42a3f08e9cb8.image", strings.NewReader("some picture bin data 123")) - require.Nil(t, err) - assert.Equal(t, "f1881c06eec96db9901c7bbfe41c42a3f08e9cb8.image", avatar) - fi, err = os.Stat("/tmp/avatars.test/56/f1881c06eec96db9901c7bbfe41c42a3f08e9cb8.image") - assert.NoError(t, err) - assert.Equal(t, int64(25), fi.Size()) - - // with resize - file, e := os.Open("testdata/circles.png") - require.Nil(t, e) - avatar, err = p.Put("user3", file) - require.Nil(t, err) - assert.Equal(t, "0b7f849446d3383546d15a480966084442cd2193.image", avatar) - fi, err = os.Stat("/tmp/avatars.test/60/0b7f849446d3383546d15a480966084442cd2193.image") - assert.NoError(t, err) - assert.Equal(t, int64(6986), fi.Size()) - - p = NewLocalFS("/dev/null", 300) - _, err = p.Put("user1", strings.NewReader("some picture bin data")) - assert.EqualError(t, err, "can't create file /dev/null/30/b3daa77b4c04a9551b8781d03191fe098f325e67.image: open /dev/null/30/b3daa77b4c04a9551b8781d03191fe098f325e67.image: not a directory") -} - -func TestAvatarStoreFS_Get(t *testing.T) { - p := NewLocalFS("/tmp/avatars.test", 300) - err := os.MkdirAll("/tmp/avatars.test/30", 0700) - require.NoError(t, err) - defer os.RemoveAll("/tmp/avatars.test") - - // file not exists - r, size, err := p.Get("some_random_name.image") - // nil, 0, errors.Wrapf(err, "can't load avatar %s, id") - assert.Nil(t, r) - assert.Equal(t, 0, size) - assert.EqualError(t, err, "can't load avatar some_random_name.image, id: open /tmp/avatars.test/91/some_random_name.image: no such file or directory") - // file exists - err = ioutil.WriteFile("/tmp/avatars.test/30/b3daa77b4c04a9551b8781d03191fe098f325e67.image", []byte("something"), 0666) - assert.Nil(t, err) - r, size, err = p.Get("b3daa77b4c04a9551b8781d03191fe098f325e67.image") - - assert.Nil(t, err) - assert.Equal(t, 9, size) - data, err := ioutil.ReadAll(r) - assert.Nil(t, err) - assert.Equal(t, "something", string(data)) -} - -func TestAvatarStoreFS_Location(t *testing.T) { - p := NewLocalFS("/tmp/avatars.test", 300) - - tbl := []struct { - id string - res string - }{ - {"abc", "/tmp/avatars.test/35"}, - {"xyz", "/tmp/avatars.test/69"}, - {"blah blah", "/tmp/avatars.test/29"}, - {"f1881c06eec96db9901c7bbfe41c42a3f08e9cb8", "/tmp/avatars.test/56"}, - } - - for i, tt := range tbl { - assert.Equal(t, tt.res, p.location(tt.id), "test #%d", i) - } -} - -func TestAvatarStoreFS_ID(t *testing.T) { - p := NewLocalFS("/tmp/avatars.test", 300) - err := os.MkdirAll("/tmp/avatars.test/30", 0700) - require.NoError(t, err) - defer os.RemoveAll("/tmp/avatars.test") - - // file not exists - id := p.ID("some_random_name.image") - assert.Equal(t, "a008de0a2ccb3308b5d99ffff66436e15538f701", id) // store.EncodeID("some_random_name.image") - // file exists - err = ioutil.WriteFile("/tmp/avatars.test/30/b3daa77b4c04a9551b8781d03191fe098f325e67.image", []byte("something"), 0666) - require.NoError(t, err) - touch := time.Date(2017, 7, 14, 2, 40, 0, 0, time.UTC) // 1500000000 - err = os.Chtimes("/tmp/avatars.test/30/b3daa77b4c04a9551b8781d03191fe098f325e67.image", touch, touch) - require.NoError(t, err) - id = p.ID("b3daa77b4c04a9551b8781d03191fe098f325e67.image") - assert.Equal(t, "325d5b451f32c2f8e7f30a9fd65bff6a42954d9a", id) // store.EncodeID("b3daa77b4c04a9551b8781d03191fe098f325e67.image1500000000") -} - -func TestAvatarStoreFS_Remove(t *testing.T) { - p := NewLocalFS("/tmp/avatars.test", 300) - err := os.MkdirAll("/tmp/avatars.test/30", 0700) - require.NoError(t, err) - defer os.RemoveAll("/tmp/avatars.test") - - assert.NotNil(t, p.Remove("no-such-avatar"), "remove non-existing avatar") - err = ioutil.WriteFile("/tmp/avatars.test/30/b3daa77b4c04a9551b8781d03191fe098f325e67.image", []byte("something"), 0666) - require.NoError(t, err) - - assert.NoError(t, p.Remove("b3daa77b4c04a9551b8781d03191fe098f325e67.image")) - _, err = os.Stat("/tmp/avatars.test/30/b3daa77b4c04a9551b8781d03191fe098f325e67.image") - assert.NotNil(t, err, "removed for real") - t.Log(err) -} - -func TestAvatarStoreFS_List(t *testing.T) { - p := NewLocalFS("/tmp/avatars.test", 300) - err := os.MkdirAll("/tmp/avatars.test", 0700) - require.NoError(t, err) - defer os.RemoveAll("/tmp/avatars.test") - - // write some avatars - _, err = p.Put("user1", strings.NewReader("some picture bin data 1")) - require.Nil(t, err) - _, err = p.Put("user2", strings.NewReader("some picture bin data 2")) - require.Nil(t, err) - _, err = p.Put("user3", strings.NewReader("some picture bin data 3")) - require.Nil(t, err) - - l, err := p.List() - assert.NoError(t, err) - assert.Equal(t, 3, len(l), "3 avatars listed") - sort.Strings(l) - assert.Equal(t, []string{"0b7f849446d3383546d15a480966084442cd2193.image", "a1881c06eec96db9901c7bbfe41c42a3f08e9cb4.image", "b3daa77b4c04a9551b8781d03191fe098f325e67.image"}, l) - - r, size, err := p.Get("0b7f849446d3383546d15a480966084442cd2193.image") - assert.Nil(t, err) - assert.Equal(t, 23, size) - data, err := ioutil.ReadAll(r) - assert.Nil(t, err) - assert.Equal(t, "some picture bin data 3", string(data)) -} - -func BenchmarkAvatarStoreFS_ID(b *testing.B) { - p := NewLocalFS("/tmp/avatars.test", 300) - os.MkdirAll("/tmp/avatars.test/30", 0700) - defer os.RemoveAll("/tmp/avatars.test") - err := ioutil.WriteFile("/tmp/avatars.test/30/b3daa77b4c04a9551b8781d03191fe098f325e67.image", []byte("something"), 0666) - require.NoError(b, err) - - b.ResetTimer() - for i := 0; i < b.N; i++ { - p.ID("b3daa77b4c04a9551b8781d03191fe098f325e67.image") - } -} diff --git a/backend/app/store/avatar/store.go b/backend/app/store/avatar/store.go deleted file mode 100644 index eb9a3aed..00000000 --- a/backend/app/store/avatar/store.go +++ /dev/null @@ -1,110 +0,0 @@ -// Package avatar defines store interface and implements local (fs), gridfs (mongo) and boltdb stores. -// -package avatar - -//go:generate sh -c "mockery -inpkg -name Store -print > /tmp/mock.tmp && mv /tmp/mock.tmp store_mock.go" - -import ( - "bytes" - "image" - "strings" - - // Initializing packages for supporting GIF and JPEG formats. - _ "image/gif" - _ "image/jpeg" - "image/png" - "io" - "log" - "regexp" - - "github.com/umputun/remark/backend/app/store" - "golang.org/x/image/draw" -) - -// imgSfx for avatars -const imgSfx = ".image" - -var reValidAvatarID = regexp.MustCompile(`^[a-fA-F0-9]{40}\.image$`) - -// Store defines interface to store and and load avatars -type Store interface { - Put(userID string, reader io.Reader) (avatarID string, err error) // save avatar data from the reader and return base name - Get(avatarID string) (reader io.ReadCloser, size int, err error) // load avatar via reader - ID(avatarID string) (id string) // unique id of stored avatar's data - Remove(avatarID string) error // remove avatar data - List() (ids []string, err error) // list all avatar ids - Close() error // close store -} - -// Migrate avatars between stores -func Migrate(dst Store, src Store) (int, error) { - ids, err := src.List() - if err != nil { - return 0, err - } - for _, id := range ids { - srcReader, _, err := src.Get(id) - if err != nil { - log.Printf("[WARN] can't get reader for avatar %s", id) - continue - } - if _, err = dst.Put(id, srcReader); err != nil { - log.Printf("[WARN] can't put avatar %s", id) - } - if err = srcReader.Close(); err != nil { - log.Printf("[WARN] failed to close avatar %s", id) - } - } - return len(ids), nil -} - -// resize an image of supported format (PNG, JPG, GIF) to the size of "limit" px of the biggest side -// (width or height) preserving aspect ratio. -// Returns original reader if resizing is not needed or failed. -func resize(reader io.Reader, limit int) io.Reader { - if reader == nil { - log.Print("[WARN] avatar resize(): reader is nil") - return nil - } - if limit <= 0 { - log.Print("[DEBUG] avatar resize(): limit should be greater than 0") - return reader - } - - var teeBuf bytes.Buffer - tee := io.TeeReader(reader, &teeBuf) - src, _, err := image.Decode(tee) - if err != nil { - log.Printf("[WARN] avatar resize(): can't decode avatar image, %s", err) - return &teeBuf - } - - bounds := src.Bounds() - w, h := bounds.Dx(), bounds.Dy() - if w <= limit && h <= limit || w <= 0 || h <= 0 { - log.Print("[DEBUG] resizing image is smaller that the limit or has 0 size") - return &teeBuf - } - newW, newH := w*limit/h, limit - if w > h { - newW, newH = limit, h*limit/w - } - m := image.NewRGBA(image.Rect(0, 0, newW, newH)) - // Slower than `draw.ApproxBiLinear.Scale()` but better quality. - draw.BiLinear.Scale(m, m.Bounds(), src, src.Bounds(), draw.Src, nil) - - var out bytes.Buffer - if err = png.Encode(&out, m); err != nil { - log.Printf("[WARN] avatar resize(): can't encode resized avatar to PNG, %s", err) - return &teeBuf - } - return &out -} - -// encodeID converts string to encoded id unless already encoded and valid avatar id (with .image) passed -func encodeID(val string) string { - if reValidAvatarID.MatchString(val) { - return strings.TrimSuffix(val, imgSfx) // already encoded, strip .image - } - return store.EncodeID(val) -} diff --git a/backend/app/store/avatar/store_test.go b/backend/app/store/avatar/store_test.go deleted file mode 100644 index 377af11a..00000000 --- a/backend/app/store/avatar/store_test.go +++ /dev/null @@ -1,109 +0,0 @@ -package avatar - -import ( - "bytes" - "image" - "io" - "io/ioutil" - "os" - "sort" - "strings" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestAvatarStore_resize(t *testing.T) { - checkC := func(t *testing.T, r io.Reader, cExp []byte) { - content, err := ioutil.ReadAll(r) - require.NoError(t, err) - assert.Equal(t, cExp, content) - } - - // Reader is nil. - resizedR := resize(nil, 100) - // assert.EqualError(t, err, "limit should be greater than 0") - assert.Nil(t, resizedR) - - // Negative limit error. - resizedR = resize(strings.NewReader("some picture bin data"), -1) - require.NotNil(t, resizedR) - checkC(t, resizedR, []byte("some picture bin data")) - - // Decode error. - resizedR = resize(strings.NewReader("invalid image content"), 100) - assert.NotNil(t, resizedR) - checkC(t, resizedR, []byte("invalid image content")) - - cases := []struct { - file string - wr, hr int - }{ - {"testdata/circles.png", 400, 300}, // full size: 800x600 px - {"testdata/circles.jpg", 300, 400}, // full size: 600x800 px - } - - for _, c := range cases { - img, err := ioutil.ReadFile(c.file) - require.Nil(t, err, "can't open test file %s", c.file) - - // No need for resize, avatar dimensions are smaller than resize limit. - resizedR = resize(bytes.NewReader(img), 800) - assert.NotNilf(t, resizedR, "file %s", c.file) - checkC(t, resizedR, img) - - // Resizing to half of width. Check resizedR avatar format PNG. - resizedR = resize(bytes.NewReader(img), 400) - assert.NotNilf(t, resizedR, "file %s", c.file) - - imgRz, format, err := image.Decode(resizedR) - assert.Nilf(t, err, "file %s", c.file) - assert.Equalf(t, "png", format, "file %s", c.file) - bounds := imgRz.Bounds() - assert.Equalf(t, c.wr, bounds.Dx(), "file %s", c.file) - assert.Equalf(t, c.hr, bounds.Dy(), "file %s", c.file) - } -} - -func TestAvatarStore_Migrate(t *testing.T) { - // prep localfs - plocal := NewLocalFS("/tmp/avatars.test", 300) - err := os.MkdirAll("/tmp/avatars.test", 0700) - require.NoError(t, err) - defer os.RemoveAll("/tmp/avatars.test") - - // prep gridfs - pgfs, skip := prepGFStore(t) - if skip { - return - } - - // write to localfs - _, err = plocal.Put("user1", strings.NewReader("some picture bin data 1")) - require.Nil(t, err) - _, err = plocal.Put("user2", strings.NewReader("some picture bin data 2")) - require.Nil(t, err) - _, err = plocal.Put("user3", strings.NewReader("some picture bin data 3")) - require.Nil(t, err) - - // migrate and check reported count - count, err := Migrate(pgfs, plocal) - require.NoError(t, err) - assert.Equal(t, 3, count, "all 3 recs migrated") - - // list avatars - l, err := pgfs.List() - assert.NoError(t, err) - assert.Equal(t, 3, len(l), "3 avatars listed in destination store") - sort.Strings(l) - assert.Equal(t, []string{"0b7f849446d3383546d15a480966084442cd2193.image", "a1881c06eec96db9901c7bbfe41c42a3f08e9cb4.image", "b3daa77b4c04a9551b8781d03191fe098f325e67.image"}, l) - - // try to read one of migrated avatars - r, size, err := pgfs.Get("0b7f849446d3383546d15a480966084442cd2193.image") - assert.Nil(t, err) - assert.Equal(t, 23, size) - data, err := ioutil.ReadAll(r) - assert.Nil(t, err) - assert.Equal(t, "some picture bin data 3", string(data)) -} diff --git a/backend/app/store/avatar/testdata/circles.jpg b/backend/app/store/avatar/testdata/circles.jpg deleted file mode 100644 index 2c7048c3..00000000 Binary files a/backend/app/store/avatar/testdata/circles.jpg and /dev/null differ diff --git a/backend/app/store/avatar/testdata/circles.png b/backend/app/store/avatar/testdata/circles.png deleted file mode 100644 index fb30946d..00000000 Binary files a/backend/app/store/avatar/testdata/circles.png and /dev/null differ diff --git a/backend/app/store/engine/mongo.go b/backend/app/store/engine/mongo.go index 587e7bda..772b22eb 100644 --- a/backend/app/store/engine/mongo.go +++ b/backend/app/store/engine/mongo.go @@ -5,10 +5,11 @@ import ( "github.com/globalsign/mgo" "github.com/globalsign/mgo/bson" - "github.com/go-pkgz/mongo" multierror "github.com/hashicorp/go-multierror" "github.com/pkg/errors" + "github.com/go-pkgz/mongo" + "github.com/umputun/remark/backend/app/store" ) diff --git a/backend/app/store/formatter.go b/backend/app/store/formatter.go index 3543395a..61a386e6 100644 --- a/backend/app/store/formatter.go +++ b/backend/app/store/formatter.go @@ -44,9 +44,9 @@ func (f *CommentFormatter) FormatText(txt string) (res string) { blackfriday.Strikethrough | blackfriday.SpaceHeadings | blackfriday.HardLineBreak | blackfriday.BackslashLineBreak | blackfriday.Autolink res = string(blackfriday.Run([]byte(txt), blackfriday.WithExtensions(mdExt))) + for _, conv := range f.converters { res = conv.Convert(res) - } res = f.shortenAutoLinks(res, shortURLLen) return res diff --git a/backend/app/store/service/service.go b/backend/app/store/service/service.go index 6a5d94c3..c67ebe26 100644 --- a/backend/app/store/service/service.go +++ b/backend/app/store/service/service.go @@ -5,9 +5,8 @@ import ( "sync" "time" - multierror "github.com/hashicorp/go-multierror" - "github.com/google/uuid" + multierror "github.com/hashicorp/go-multierror" "github.com/pkg/errors" "github.com/umputun/remark/backend/app/store" diff --git a/backend/vendor/github.com/go-pkgz/auth/.gitignore b/backend/vendor/github.com/go-pkgz/auth/.gitignore new file mode 100644 index 00000000..4ee8214d --- /dev/null +++ b/backend/vendor/github.com/go-pkgz/auth/.gitignore @@ -0,0 +1,14 @@ +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary, build with `go test -c` +*.test + +# Output of the go coverage tool, specifically when used with LiteIDE +*.out +.vscode +*.cov \ No newline at end of file diff --git a/backend/vendor/github.com/go-pkgz/auth/.travis.yml b/backend/vendor/github.com/go-pkgz/auth/.travis.yml new file mode 100644 index 00000000..f8747c76 --- /dev/null +++ b/backend/vendor/github.com/go-pkgz/auth/.travis.yml @@ -0,0 +1,23 @@ +language: go + +services: + - mongodb + +go: + - "1.11.x" + +install: true + +before_install: + - export TZ=America/Chicago + - curl -L https://git.io/vp6lP | sh + - go get github.com/mattn/goveralls + - export MONGO_TEST=mongodb://127.0.0.1:27017 + - export PATH=$(pwd)/bin:$PATH + +script: + - GO111MODULE=on go get ./... + - GO111MODULE=on go mod vendor + - GO111MODULE=on go test -v -mod=vendor -covermode=count -coverprofile=profile.cov ./... || travis_terminate 1; + - ./bin/gometalinter --deadline=120s --exclude=test --exclude=mock --exclude=vendor --exclude=_example --disable-all --enable=errcheck --enable=vet --enable=vetshadow --enable=megacheck --enable=ineffassign --enable=varcheck --enable=unconvert --enable=deadcode --enable=interfacer --enable=gotype ./... || travis_terminate 1; + - $GOPATH/bin/goveralls -coverprofile=profile.cov -service=travis-ci diff --git a/backend/vendor/github.com/go-pkgz/auth/LICENSE b/backend/vendor/github.com/go-pkgz/auth/LICENSE new file mode 100644 index 00000000..ca125214 --- /dev/null +++ b/backend/vendor/github.com/go-pkgz/auth/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2018 Umputun + +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. diff --git a/backend/vendor/github.com/go-pkgz/auth/README.md b/backend/vendor/github.com/go-pkgz/auth/README.md new file mode 100644 index 00000000..836344b8 --- /dev/null +++ b/backend/vendor/github.com/go-pkgz/auth/README.md @@ -0,0 +1,258 @@ +# auth - authentication via oauth2 [![Build Status](https://travis-ci.org/go-pkgz/auth.svg?branch=master)](https://travis-ci.org/go-pkgz/auth) [![Coverage Status](https://coveralls.io/repos/github/go-pkgz/auth/badge.svg?branch=master)](https://coveralls.io/github/go-pkgz/auth?branch=master) [![godoc](https://godoc.org/github.com/go-pkgz/auth?status.svg)](https://godoc.org/github.com/go-pkgz/auth) + + + +This library provides "social login" with Github, Google, Facebook and Yandex as well as custom auth providers. + +- Multiple oauth2 providers can be used at the same time +- Special `dev` provider allows local testing and development +- JWT stored in a secure cookie with XSRF protection. Cookies can be session-only +- Minimal scopes with user name, id and picture (avatar) only +- Direct authentication with user's provided credential checker +- Integrated avatar proxy with FS, boltdb and gridfs storages +- Support of user-defined storages for avatars +- Black list with user-defined validator +- Multiple aud (audience) supported +- Secure key with customizable `SecretReader` +- Ability to store an extra information to token and retrieve on login +- Pre-auth and post-auth hooks to handle custom use cases. +- Middleware for easy integration into http routers +- Wrappers to extract user info from the request + +## Install + +`go install github.com/go-pkgz/auth` + +## Usage + +Example with chi router: + +```go + +func main() { + /// define options + options := auth.Opts{ + SecretReader: token.SecretFunc(func(id string) (string, error) { // secret key for JWT + return "secret", nil + }), + TokenDuration: time.Minute * 5, // token expires in 5 minutes + CookieDuration: time.Hour * 24, // cookie expires in 1 day and will enforce re-login + Issuer: "my-test-app", + URL: "http://127.0.0.1:8080", + AvatarStore: avatar.NewLocalFS("/tmp"), + Validator: token.ValidatorFunc(func(_ string, claims token.Claims) bool { + // allow only dev_* names + return claims.User != nil && strings.HasPrefix(claims.User.Name, "dev_") + }), + } + + // create auth service with providers + service := auth.NewService(options) + service.AddProvider("github", "", "") // add github provider + service.AddProvider("facebook", "", "") // add facebook provider + + // retrieve auth middleware + m := service.Middleware() + + // setup http server + router := chi.NewRouter() + router.Get("/open", openRouteHandler) // open api + router.With(m.Auth).Get("/private", protectedRouteHandler) // protected api + + // setup auth routes + authRoutes, avaRoutes := service.Handlers() + router.Mount("/auth", authRoutes) // add auth handlers + router.Mount("/avatar", avaRoutes) // add avatar handler + + log.Fatal(http.ListenAndServe(":8080", router)) +} +``` + +## Middleware + +`github.com/go-pkgz/auth/middleware` provides ready-to-use middleware. + +- `middleware.Auth` - requires authenticated user +- `middleware.Admin` - requires authenticated and admin user +- `middleware.Trace` - doesn't require authenticated user, but adds user info to request + +## Details + +Generally, adding support of `auth` includes a few relatively simple steps: + +1. Setup `auth.Opts` structure with all parameters. Each of them [documented](https://github.com/go-pkgz/auth/blob/master/auth.go#L29) and most of parameters are optional and have sane defaults. +2. [Create](https://github.com/go-pkgz/auth/blob/master/auth.go#L56) the new `auth.Service` with provided options. +3. [Add all](https://github.com/go-pkgz/auth/blob/master/auth.go#L149) desirable authentication providers. Currently supported Github, Google, Facebook and Yandex +4. Retrieve [middleware](https://github.com/go-pkgz/auth/blob/master/auth.go#L144) and [http handlers](https://github.com/go-pkgz/auth/blob/master/auth.go#L105) from `auth.Service` +5. Wire auth and avatar handlers into http router as sub–routes. + +### API + +For the example above authentication handlers wired as `/auth` and provides: + +- `/auth//login?id=&from=` - site_id used as `aud` claim for the token and can be processed by `SecretReader` to load/retrieve/define different secrets. redirect_url is the url to redirect after successful login. +- `/avatar/` - returns the avatar (image). Links to those pictures added into user info automatically, for details see "Avatar proxy" +- `/auth//logout` and `/auth/logout` - invalidate "session" by removing JWT cookie +- `/auth/list` - gives a json list of active providers +- `/auth/user` - returns `token.User` (json) + +### User info + +Middleware populates `token.User` to request's context. It can be loaded with `token.GetUserInfo(r *http.Request) (user User, err error)` or `token.MustGetUserInfo(r *http.Request) User` functions. + +`token.User` object includes all fields retrieved from oauth2 provider: +- `Name` - user name +- `ID` - hash of user id +- `Picture` - full link to proxied avatar (see "Avatar proxy") + +It also has placeholders for fields application can populate with custom `token.ClaimsUpdater` (see "Customization") + +- `IP` - hash of user's IP address +- `Email` - user's email +- `Attributes` - map of string:any-value. To simplify management of this map some setters and getters provided, for example `users.StrAttr`, `user.SetBoolAttr` and so on. See [user.go](https://github.com/go-pkgz/auth/blob/master/token/user.go) for more details. + + +### Avatar proxy + +Direct links to avatars won't survive any real-life usage if they linked from a public page. For example, page [like this](https://remark42.com/demo/) may have hundreds of avatars and, most likely, will trigger throttling on provider's side. To eliminate such restriction `auth` library provides an automatic proxy + +- On each login the proxy will retrieve user's picture and save it to `AvatarStore` +- Local (proxied) link to avatar included in user's info (jwt token) +- API for avatar removal provided as a part of `AvatarStore` +- User can leverage one of the provided stores: + - `avatar.LocalFS` - file system, each avatar in a separate file + - `avatar.BoltDB` - single [boltdb](https://github.com/coreos/bbolt) file (embedded KV store). + - `avatar.GridFS` - external [GridFS](https://docs.mongodb.com/manual/core/gridfs/) (mongo db). +- In case of need custom implementations of other stores can be passed in and used by `auth` library. Each store has to implement `avatar.Store` [interface](https://github.com/go-pkgz/auth/blob/master/avatar/store.go#L25). +- All avatar-related setup done as a part of `auth.Opts` and needs: + - `AvatarStore` - avatar store to use, i.e. `avatar.NewLocalFS("/tmp/avatars")` + - `AvatarRoutePath` - route prefix for direct links to proxied avatar. For example `/api/v1/avatars` will make full links like this - `http://example.com/api/v1/avatars/1234567890123.image`. The url will be stored in user's token and retrieved by middleware (see "User Info") + - `AvatarResizeLimit` - size (in pixels) used to resize the avatar. Pls note - resize happens once as a part of `Put` call, i.e. on login. 0 size (default) disables resizing. + +### Direct authentication + +In addition to oauth2 providers `auth.Service` allows to use direct user-defined authentication. This is done by adding direct provider with `auth.AddDirectProvider`. + +```go + service.AddDirectProvider("local", provider.CredCheckerFunc(func(user, password string) (ok bool, err error) { + ok, err := checkUserSomehow(user, password) + return ok, err + })) +``` + +Such provider acts like any other, i.e. will be registered as `/auth/local/login`. + +The API for this provider - `GET /auth//login?user=&passwd=&aud=&session=[1|0]` + +### Customization + +There are several ways to adjust functionality of the library: + +1. `SecretReader` - interface with a single method `Get(aud string) string` to return the secret used for JWT signing and verification +2. `ClaimsUpdater` - interface with `Update(claims Claims) Claims` method. This is the primary way to alter a token at login time and add any attributes, set ip, email, admin status and so on. +3. `Validator` - interface with `Validate(token string, claims Claims) bool` method. This is post-token hook and will be called on **each request** wrapped with `Auth` middleware. This will be the place for special logic to reject some tokens or users. + +All of the interfaces above have corresponding Func adapters - `SecretFunc`, `ClaimsUpdFunc` and `ValidatorFunc`. + +### Implementing black list logic or some other filters + +Restricting some users or some tokens is two step process: + +- `ClaimsUpdater` sets an attribute, like `blocked` (or `allowed`) +- `Validator` checks the attribute and returns true/false + +_This technic used in the [example](https://github.com/go-pkgz/auth/blob/master/_example/backend/main.go#L36) code_ + +The process can be simplified by doing all checks directly in `Validator`, but depends on particular case such solution +can be too expensive because `Validator` runs on each request as a part of auth middleware. In contrast, `ClaimsUpdater` called on token creation/refresh only. + + +### Dev provider + +Working with oauth2 providers can be a pain, especially during development phase. A special, development-only provider `dev` can make it less painful. This one can be registered directly, i.e. `service.AddProvider("dev", "", "")` and should be activated like this: + +```go + // runs dev oauth2 server on :8084 + go func() { + devAuthServer, err := service.DevAuth() + if err != nil { + log.Fatal(err) + } + devAuthServer.Run() + }() +``` + +It will run fake aouth2 "server" on port :8084 and user could login with any user name. See [example](https://github.com/go-pkgz/auth/blob/master/_example/backend/main.go) for more details. + +_Warning: this is not the real oauth2 server but just a small fake thing for development and testing only. Don't use `dev` provider with any production code._ + +### Other ways to authenticate + +In addition to the primary method (i.e. JWT cookie with XSRF header) there are two more ways to authenticate: + +1. Send JWT header as `X-JWT`. This shouldn't be used for web application, however can be helpful for service-to-service authentication. +2. [Basic access authentication](https://en.wikipedia.org/wiki/Basic_access_authentication). This mode disabled by default and will be enabled if `Opts.AdminPasswd` defined. This will allow access with basic auth admin: with user [admin](https://github.com/go-pkgz/auth/blob/master/middleware/auth.go#L24). Such method can be used for automation scripts. + +### Logging + +By default this library doesn't print anything to stdout/stderr, however user can pass a logger implementing `logger.L` interface with a single method `Logf(format string, args ...interface{})`. Functional adapter for this interface included as `logger.Func`. There are two predefined implementations in the `logger` package - `NoOp` (prints nothing, default) and `Std` wrapping `log.Printf` from stdlib. + + +## Register oauth2 providers + +Authentication handled by external providers. You should setup oauth2 for all (or some) of them to allow users to authenticate. It is not mandatory to have all of them, but at least one should be correctly configured. + +#### Google Auth Provider + +1. Create a new project: https://console.developers.google.com/project +2. Choose the new project from the top right project dropdown (only if another project is selected) +3. In the project Dashboard center pane, choose **"API Manager"** +4. In the left Nav pane, choose **"Credentials"** +5. In the center pane, choose **"OAuth consent screen"** tab. Fill in **"Product name shown to users"** and hit save. +6. In the center pane, choose **"Credentials"** tab. + * Open the **"New credentials"** drop down + * Choose **"OAuth client ID"** + * Choose **"Web application"** + * Application name is freeform, choose something appropriate + * Authorized origins is your domain ex: `https://example.mysite.com` + * Authorized redirect URIs is the location of oauth2/callback constructed as domain + `/auth/google/callback`, ex: `https://example.mysite.com/auth/google/callback` + * Choose **"Create"** +7. Take note of the **Client ID** and **Client Secret** + +_instructions for google oauth2 setup borrowed from [oauth2_proxy](https://github.com/bitly/oauth2_proxy)_ + +#### GitHub Auth Provider + +1. Create a new **"OAuth App"**: https://github.com/settings/developers +1. Fill **"Application Name"** and **"Homepage URL"** for your site +1. Under **"Authorization callback URL"** enter the correct url constructed as domain + `/auth/github/callback`. ie `https://example.mysite.com/auth/github/callback` +1. Take note of the **Client ID** and **Client Secret** + +#### Facebook Auth Provider + +1. From https://developers.facebook.com select **"My Apps"** / **"Add a new App"** +1. Set **"Display Name"** and **"Contact email"** +1. Choose **"Facebook Login"** and then **"Web"** +1. Set "Site URL" to your domain, ex: `https://example.mysite.com` +1. Under **"Facebook login"** / **"Settings"** fill "Valid OAuth redirect URIs" with your callback url constructed as domain + `/auth/facebook/callback` +1. Select **"App Review"** and turn public flag on. This step may ask you to provide a link to your privacy policy. + +#### Yandex Auth Provider + +1. Create a new **"OAuth App"**: https://oauth.yandex.com/client/new +1. Fill **"App name"** for your site +1. Under **Platforms** select **"Web services"** and enter **"Callback URI #1"** constructed as domain + `/auth/yandex/callback`. ie `https://example.mysite.com/auth/yandex/callback` +1. Select **Permissions**. You need following permissions only from the **"Yandex.Passport API"** section: + * Access to user avatar + * Access to username, first name and surname, gender +1. Fill out the rest of fields if needed +1. Take note of the **ID** and **Password** + +For more details refer to [Yandex OAuth](https://tech.yandex.com/oauth/doc/dg/concepts/about-docpage/) and [Yandex.Passport](https://tech.yandex.com/passport/doc/dg/index-docpage/) API documentation. + + +## Status + +The library extracted from [remark42](https://github.com/umputun/remark) project. The original code in production use on multiple sites and seems to work fine. + +`go-pkgz/auth` library still in development and until version 1 released some breaking changes possible. \ No newline at end of file diff --git a/backend/vendor/github.com/go-pkgz/auth/auth.go b/backend/vendor/github.com/go-pkgz/auth/auth.go new file mode 100644 index 00000000..47954a8b --- /dev/null +++ b/backend/vendor/github.com/go-pkgz/auth/auth.go @@ -0,0 +1,258 @@ +package auth + +import ( + "fmt" + "net/http" + "strings" + "time" + + "github.com/go-pkgz/rest" + "github.com/pkg/errors" + + "github.com/go-pkgz/auth/avatar" + "github.com/go-pkgz/auth/logger" + "github.com/go-pkgz/auth/middleware" + "github.com/go-pkgz/auth/provider" + "github.com/go-pkgz/auth/token" +) + +// Service provides higher level wrapper allowing to construct everything and get back token middleware +type Service struct { + logger logger.L + opts Opts + jwtService *token.Service + providers []provider.Service + authMiddleware middleware.Authenticator + avatarProxy *avatar.Proxy + issuer string +} + +// Opts is a full set of all parameters to initialize Service +type Opts struct { + SecretReader token.Secret // reader returns secret for given site id (aud), required + ClaimsUpd token.ClaimsUpdater // updater for jwt to add/modify values stored in the token + SecureCookies bool // makes jwt cookie secure + TokenDuration time.Duration // token's TTL, refreshed automatically + CookieDuration time.Duration // cookie's TTL. This cookie stores JWT token + + DisableXSRF bool // disable XSRF protection, useful for testing/debugging + DisableIAT bool // disable IssuedAt claim + + // optional (custom) names for cookies and headers + JWTCookieName string // default "JWT" + JWTHeaderKey string // default "X-JWT" + XSRFCookieName string // default "XSRF-TOKEN" + XSRFHeaderKey string // default "X-XSRF-TOKEN" + + Issuer string // optional value for iss claim, usually the application name, default "go-pkgz/auth" + + URL string // root url for the rest service, i.e. http://blah.example.com, required + Validator token.Validator // validator allows to reject some valid tokens with user-defined logic + + AvatarStore avatar.Store // store to save/load avatars, required + AvatarResizeLimit int // resize avatar's limit in pixels + AvatarRoutePath string // avatar routing prefix, i.e. "/api/v1/avatar", default `/avatar` + + AdminPasswd string // if presented, allows basic auth with user admin and given password + Logger logger.L // logger interface, default is no logging at all +} + +// NewService initializes everything +func NewService(opts Opts) (res *Service) { + + res = &Service{ + opts: opts, + logger: opts.Logger, + authMiddleware: middleware.Authenticator{ + Validator: opts.Validator, + AdminPasswd: opts.AdminPasswd, + }, + issuer: opts.Issuer, + } + + if opts.Issuer == "" { + res.issuer = "go-pkgz/auth" + } + + if opts.Logger == nil { + res.logger = logger.Func(func(fmt string, args ...interface{}) {}) // do-nothing logger + } + + jwtService := token.NewService(token.Opts{ + SecretReader: opts.SecretReader, + ClaimsUpd: opts.ClaimsUpd, + SecureCookies: opts.SecureCookies, + TokenDuration: opts.TokenDuration, + CookieDuration: opts.CookieDuration, + DisableXSRF: opts.DisableXSRF, + DisableIAT: opts.DisableIAT, + JWTCookieName: opts.JWTCookieName, + JWTHeaderKey: opts.JWTHeaderKey, + XSRFCookieName: opts.XSRFCookieName, + XSRFHeaderKey: opts.XSRFHeaderKey, + Issuer: res.issuer, + }) + + if opts.SecretReader == nil { + jwtService.SecretReader = token.SecretFunc(func(id string) (string, error) { + return "", errors.New("secrets reader not available") + }) + res.logger.Logf("[WARN] no secret reader defined") + } + + res.jwtService = jwtService + res.authMiddleware.JWTService = jwtService + res.authMiddleware.L = res.logger + + if opts.AvatarStore != nil { + res.avatarProxy = &avatar.Proxy{ + Store: opts.AvatarStore, + URL: opts.URL, + RoutePath: opts.AvatarRoutePath, + ResizeLimit: opts.AvatarResizeLimit, + L: res.logger, + } + if res.avatarProxy.RoutePath == "" { + res.avatarProxy.RoutePath = "/avatar" + } + } + + return res +} + +// Handlers gets http.Handler for all providers and avatars +func (s *Service) Handlers() (authHandler http.Handler, avatarHandler http.Handler) { + + ah := func(w http.ResponseWriter, r *http.Request) { + elems := strings.Split(r.URL.Path, "/") + if len(elems) < 2 { + w.WriteHeader(http.StatusBadRequest) + return + } + + // list all providers + if elems[len(elems)-1] == "list" { + list := []string{} + for _, p := range s.providers { + list = append(list, p.Name()) + } + rest.RenderJSON(w, r, list) + return + } + + // allow logout without specifying provider + if elems[len(elems)-1] == "logout" { + s.providers[0].Handler(w, r) + return + } + + // show user info + if elems[len(elems)-1] == "user" { + claims, _, err := s.jwtService.Get(r) + if err != nil { + w.WriteHeader(http.StatusUnauthorized) + rest.RenderJSON(w, r, rest.JSON{"error": err.Error()}) + return + } + rest.RenderJSON(w, r, claims.User) + return + } + + // regular auth handlers + provName := elems[len(elems)-2] + p, err := s.Provider(provName) + if err != nil { + w.WriteHeader(http.StatusBadRequest) + rest.RenderJSON(w, r, rest.JSON{"error": fmt.Sprintf("provider %s not supported", provName)}) + return + } + p.Handler(w, r) + } + + return http.HandlerFunc(ah), http.HandlerFunc(s.avatarProxy.Handler) +} + +// Middleware returns auth middleware +func (s *Service) Middleware() middleware.Authenticator { + return s.authMiddleware +} + +// AddProvider adds provider for given name +func (s *Service) AddProvider(name string, cid string, csecret string) { + + p := provider.Params{ + URL: s.opts.URL, + JwtService: s.jwtService, + Issuer: s.issuer, + AvatarSaver: s.avatarProxy, + Cid: cid, + Csecret: csecret, + L: s.logger, + } + + switch strings.ToLower(name) { + case "github": + s.providers = append(s.providers, provider.NewService(provider.NewGithub(p))) + case "google": + s.providers = append(s.providers, provider.NewService(provider.NewGoogle(p))) + case "facebook": + s.providers = append(s.providers, provider.NewService(provider.NewFacebook(p))) + case "yandex": + s.providers = append(s.providers, provider.NewService(provider.NewFacebook(p))) + case "dev": + s.providers = append(s.providers, provider.NewService(provider.NewDev(p))) + default: + return + } + + s.authMiddleware.Providers = s.providers +} + +// AddDirectProvider adds provider with direct check against data store +// it doesn't do any handshake and uses provided credChecker to verify user and password from the request +func (s *Service) AddDirectProvider(name string, credChecker provider.CredChecker) { + dh := provider.DirectHandler{ + L: s.logger, + ProviderName: name, + Issuer: s.issuer, + TokenService: s.jwtService, + CredChecker: credChecker, + } + s.providers = append(s.providers, provider.NewService(dh)) + s.authMiddleware.Providers = s.providers +} + +// DevAuth makes dev oauth2 server, for testing and development only! +func (s *Service) DevAuth() (*provider.DevAuthServer, error) { + p, err := s.Provider("dev") // peak dev provider + if err != nil { + return nil, errors.Wrap(err, "dev provider not registered") + } + // make and start dev auth server + return &provider.DevAuthServer{Provider: p.Provider.(provider.Oauth2Handler), L: s.logger}, nil +} + +// Provider gets provider by name +func (s *Service) Provider(name string) (provider.Service, error) { + for _, p := range s.providers { + if p.Name() == name { + return p, nil + } + } + return provider.Service{}, errors.Errorf("provider %s not found", name) +} + +// Providers gets all registered providers +func (s *Service) Providers() []provider.Service { + return s.providers +} + +// TokenService returns token.Service +func (s *Service) TokenService() *token.Service { + return s.jwtService +} + +// AvatarProxy returns stored in service +func (s *Service) AvatarProxy() *avatar.Proxy { + return s.avatarProxy +} diff --git a/backend/vendor/github.com/go-pkgz/auth/avatar/avatar.go b/backend/vendor/github.com/go-pkgz/auth/avatar/avatar.go new file mode 100644 index 00000000..645e744f --- /dev/null +++ b/backend/vendor/github.com/go-pkgz/auth/avatar/avatar.go @@ -0,0 +1,163 @@ +// Package avatar implements avatart proxy for oauth and +// defines store interface and implements local (fs), gridfs (mongo) and boltdb stores. +package avatar + +import ( + "bytes" + "image" + "image/png" + "io" + "net/http" + "strconv" + "strings" + "time" + + "github.com/go-pkgz/rest" + "github.com/pkg/errors" + "golang.org/x/image/draw" + + "github.com/go-pkgz/auth/logger" + "github.com/go-pkgz/auth/token" +) + +// Proxy provides http handler for avatars from avatar.Store +// On user login token will call Put and it will retrieve and save picture locally. +type Proxy struct { + logger.L + Store Store + RoutePath string + URL string + ResizeLimit int +} + +// Put stores retrieved avatar to avatar.Store. Gets image from user info. Returns proxied url +func (p *Proxy) Put(u token.User) (avatarURL string, err error) { + + // no picture for user, try default avatar + if u.Picture == "" { + return "", errors.Errorf("no picture for %s", u.ID) + } + + // load avatar from remote location + client := http.Client{Timeout: 10 * time.Second} + var resp *http.Response + err = retry(5, time.Second, func() error { + var e error + resp, e = client.Get(u.Picture) + return e + }) + if err != nil { + return "", errors.Wrap(err, "failed to fetch avatar from the orig") + } + + defer func() { + if e := resp.Body.Close(); e != nil { + p.Logf("[WARN] can't close response body, %s", e) + } + }() + + if resp.StatusCode != http.StatusOK { + return "", errors.Errorf("failed to get avatar from the orig, status %s", resp.Status) + } + + avatarID, err := p.Store.Put(u.ID, p.resize(resp.Body, p.ResizeLimit)) // put returns avatar base name, like 123456.image + if err != nil { + return "", err + } + + p.Logf("[DEBUG] saved avatar from %s to %s, user %q", u.Picture, avatarID, u.Name) + return p.URL + p.RoutePath + "/" + avatarID, nil +} + +// Handler returns token routes for given provider +func (p *Proxy) Handler(w http.ResponseWriter, r *http.Request) { + + if r.Method != "GET" { + w.WriteHeader(http.StatusMethodNotAllowed) + } + elems := strings.Split(r.URL.Path, "/") + avatarID := elems[len(elems)-1] + + // enforce client-side caching + etag := `"` + p.Store.ID(avatarID) + `"` + w.Header().Set("Etag", etag) + w.Header().Set("Cache-Control", "max-age=604800") // 7 days + if match := r.Header.Get("If-None-Match"); match != "" { + if strings.Contains(match, etag) { + w.WriteHeader(http.StatusNotModified) + return + } + } + + avReader, size, err := p.Store.Get(avatarID) + if err != nil { + + rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "can't load avatar") + return + } + + defer func() { + if e := avReader.Close(); e != nil { + p.Logf("[WARN] can't close avatar reader for %s, %s", avatarID, e) + } + }() + + w.Header().Set("Content-Type", "image/*") + w.Header().Set("Content-Length", strconv.Itoa(size)) + w.WriteHeader(http.StatusOK) + if _, err = io.Copy(w, avReader); err != nil { + p.Logf("[WARN] can't send response to %s, %s", r.RemoteAddr, err) + } +} + +// resize an image of supported format (PNG, JPG, GIF) to the size of "limit" px of the biggest side +// (width or height) preserving aspect ratio. +// Returns original reader if resizing is not needed or failed. +func (p *Proxy) resize(reader io.Reader, limit int) io.Reader { + if reader == nil { + p.Logf("[WARN] avatar resize(): reader is nil") + return nil + } + if limit <= 0 { + p.Logf("[DEBUG] avatar resize(): limit should be greater than 0") + return reader + } + + var teeBuf bytes.Buffer + tee := io.TeeReader(reader, &teeBuf) + src, _, err := image.Decode(tee) + if err != nil { + p.Logf("[WARN] avatar resize(): can't decode avatar image, %s", err) + return &teeBuf + } + + bounds := src.Bounds() + w, h := bounds.Dx(), bounds.Dy() + if w <= limit && h <= limit || w <= 0 || h <= 0 { + p.Logf("[DEBUG] resizing image is smaller that the limit or has 0 size") + return &teeBuf + } + newW, newH := w*limit/h, limit + if w > h { + newW, newH = limit, h*limit/w + } + m := image.NewRGBA(image.Rect(0, 0, newW, newH)) + // Slower than `draw.ApproxBiLinear.Scale()` but better quality. + draw.BiLinear.Scale(m, m.Bounds(), src, src.Bounds(), draw.Src, nil) + + var out bytes.Buffer + if err = png.Encode(&out, m); err != nil { + p.Logf("[WARN] avatar resize(): can't encode resized avatar to PNG, %s", err) + return &teeBuf + } + return &out +} +func retry(retries int, delay time.Duration, fn func() error) (err error) { + for i := 0; i < retries; i++ { + if err = fn(); err == nil { + return nil + } + time.Sleep(delay) + } + return errors.Wrap(err, "retry failed") +} diff --git a/backend/app/store/avatar/bolt.go b/backend/vendor/github.com/go-pkgz/auth/avatar/bolt.go similarity index 88% rename from backend/app/store/avatar/bolt.go rename to backend/vendor/github.com/go-pkgz/auth/avatar/bolt.go index 826a1ded..e1d3d1c2 100644 --- a/backend/app/store/avatar/bolt.go +++ b/backend/vendor/github.com/go-pkgz/auth/avatar/bolt.go @@ -8,26 +8,23 @@ import ( "io/ioutil" "log" - "github.com/coreos/bbolt" + bolt "github.com/coreos/bbolt" "github.com/pkg/errors" - - "github.com/umputun/remark/backend/app/store" ) // BoltDB implements avatar store with bolt // using separate db (file) with "avatars" bucket to keep image bin and "metas" bucket // to keep sha1 of picture. avatarID (base file name) used as a key for both. type BoltDB struct { - fileName string // full path to boltdb - resizeLimit int - db *bolt.DB + fileName string // full path to boltdb + db *bolt.DB } const avatarsBktName = "avatars" const metasBktName = "metas" // NewBoltDB makes bolt avatar store -func NewBoltDB(fileName string, options bolt.Options, resizeLimit int) (*BoltDB, error) { +func NewBoltDB(fileName string, options bolt.Options) (*BoltDB, error) { db, err := bolt.Open(fileName, 0600, &options) if err != nil { return nil, errors.Wrapf(err, "failed to make boltdb for %s", fileName) @@ -42,18 +39,13 @@ func NewBoltDB(fileName string, options bolt.Options, resizeLimit int) (*BoltDB, if err != nil { return nil, errors.Wrapf(err, "failed to initialize boltdb db %q buckets", fileName) } - return &BoltDB{db: db, fileName: fileName, resizeLimit: resizeLimit}, nil + return &BoltDB{db: db, fileName: fileName}, nil } // Put avatar to bolt, key by avatarID. Trying to resize image and lso calculates sha1 of the file for ID func func (b *BoltDB) Put(userID string, reader io.Reader) (avatar string, err error) { id := encodeID(userID) - // Trying to resize avatar. - if reader = resize(reader, b.resizeLimit); reader == nil { - return "", errors.New("avatar resize reader is nil") - } - avatarID := id + imgSfx err = b.db.Update(func(tx *bolt.Tx) error { buf := &bytes.Buffer{} @@ -96,7 +88,7 @@ func (b *BoltDB) ID(avatarID string) (id string) { if err != nil { // failed to get ID, use encoded avatarID log.Printf("[DEBUG] can't get avatar info '%s', %s", avatarID, err) - return store.EncodeID(avatarID) + return encodeID(avatarID) } return string(data) @@ -138,7 +130,7 @@ func (b *BoltDB) sha1(data []byte, avatarID string) (id string) { h := sha1.New() if _, err := h.Write(data); err != nil { log.Printf("[DEBUG] can't apply sha1 for content of '%s', %s", avatarID, err) - return store.EncodeID(avatarID) + return encodeID(avatarID) } return hex.EncodeToString(h.Sum(nil)) } diff --git a/backend/app/store/avatar/gridfs.go b/backend/vendor/github.com/go-pkgz/auth/avatar/gridfs.go similarity index 87% rename from backend/app/store/avatar/gridfs.go rename to backend/vendor/github.com/go-pkgz/auth/avatar/gridfs.go index 6a20253e..5eeb7214 100644 --- a/backend/app/store/avatar/gridfs.go +++ b/backend/vendor/github.com/go-pkgz/auth/avatar/gridfs.go @@ -10,19 +10,16 @@ import ( "github.com/globalsign/mgo" "github.com/go-pkgz/mongo" "github.com/pkg/errors" - - "github.com/umputun/remark/backend/app/store" ) // NewGridFS makes gridfs (mongo) avatar store -func NewGridFS(conn *mongo.Connection, resizeLimit int) *GridFS { - return &GridFS{Connection: conn, resizeLimit: resizeLimit} +func NewGridFS(conn *mongo.Connection) *GridFS { + return &GridFS{Connection: conn} } // GridFS implements Store for GridFS type GridFS struct { - Connection *mongo.Connection - resizeLimit int + Connection *mongo.Connection } // Put avatar to gridfs object, try to resize @@ -39,10 +36,6 @@ func (gf *GridFS) Put(userID string, reader io.Reader) (avatar string, err error } }() - // Trying to resize avatar. - if reader = resize(reader, gf.resizeLimit); reader == nil { - return errors.New("avatar resize reader is nil") - } _, e = io.Copy(fh, reader) return e }) @@ -78,7 +71,7 @@ func (gf *GridFS) ID(avatar string) (id string) { }) if err != nil { log.Printf("[DEBUG] can't get file info '%s', %s", avatar, err) - return store.EncodeID(avatar) + return encodeID(avatar) } return id } diff --git a/backend/app/store/avatar/localfs.go b/backend/vendor/github.com/go-pkgz/auth/avatar/localfs.go similarity index 77% rename from backend/app/store/avatar/localfs.go rename to backend/vendor/github.com/go-pkgz/auth/avatar/localfs.go index 9b34ac3d..1692a6bb 100644 --- a/backend/app/store/avatar/localfs.go +++ b/backend/vendor/github.com/go-pkgz/auth/avatar/localfs.go @@ -4,7 +4,6 @@ import ( "fmt" "hash/crc64" "io" - "log" "os" "path" "path/filepath" @@ -13,33 +12,31 @@ import ( "sync" "github.com/pkg/errors" - - "github.com/umputun/remark/backend/app/store" ) // LocalFS implements Store for local file system type LocalFS struct { - storePath string - resizeLimit int - ctcTable *crc64.Table - once sync.Once + storePath string + ctcTable *crc64.Table + once sync.Once } // NewLocalFS makes file-system avatar store -func NewLocalFS(storePath string, resizeLimit int) *LocalFS { - return &LocalFS{storePath: storePath, resizeLimit: resizeLimit} +func NewLocalFS(storePath string) *LocalFS { + return &LocalFS{storePath: storePath} } // Put avatar for userID to file and return avatar's file name (base), like 12345678.image // userID can be avatarID as well, in this case encoding just strip .image prefix func (fs *LocalFS) Put(userID string, reader io.Reader) (avatar string, err error) { + if reader == nil { + return "", errors.New("empty reader") + } id := encodeID(userID) location := fs.location(id) // location adds partition to path - if _, err = os.Stat(location); os.IsNotExist(err) { - if e := os.Mkdir(location, 0700); e != nil { - return "", errors.Wrapf(e, "failed to mkdir avatar location %s", location) - } + if e := os.MkdirAll(location, 0755); e != nil { + return "", errors.Wrapf(e, "failed to mkdir avatar location %s", location) } avFile := path.Join(location, id+imgSfx) @@ -49,15 +46,10 @@ func (fs *LocalFS) Put(userID string, reader io.Reader) (avatar string, err erro } defer func() { if e := fh.Close(); e != nil { - log.Printf("[WARN] can't close avatar file %s, %s", avFile, e) + err = errors.Wrapf(err, "can't close avatar file %s", avFile) } }() - // Trying to resize avatar. - if reader = resize(reader, fs.resizeLimit); reader == nil { - return "", errors.New("avatar resize reader is nil") - } - if _, err = io.Copy(fh, reader); err != nil { return "", errors.Wrapf(err, "can't save file %s", avFile) } @@ -84,10 +76,9 @@ func (fs *LocalFS) ID(avatar string) (id string) { avFile := path.Join(location, avatar) fi, err := os.Stat(avFile) if err != nil { - log.Printf("[DEBUG] can't get file info '%s', %s", avFile, err) - return store.EncodeID(avatar) + return encodeID(avatar) } - return store.EncodeID(avatar + strconv.FormatInt(fi.ModTime().Unix(), 10)) + return encodeID(avatar + strconv.FormatInt(fi.ModTime().Unix(), 10)) } // Remove avatar file diff --git a/backend/vendor/github.com/go-pkgz/auth/avatar/store.go b/backend/vendor/github.com/go-pkgz/auth/avatar/store.go new file mode 100644 index 00000000..8c19fbdb --- /dev/null +++ b/backend/vendor/github.com/go-pkgz/auth/avatar/store.go @@ -0,0 +1,62 @@ +package avatar + +//go:generate sh -c "mockery -inpkg -name Store -print > /tmp/mock.tmp && mv /tmp/mock.tmp store_mock.go" + +import ( + "crypto/sha1" + "strings" + + // Initializing packages for supporting GIF and JPEG formats. + _ "image/gif" + _ "image/jpeg" + "io" + "log" + "regexp" + + "github.com/go-pkgz/auth/token" +) + +// imgSfx for avatars +const imgSfx = ".image" + +var reValidAvatarID = regexp.MustCompile(`^[a-fA-F0-9]{40}\.image$`) + +// Store defines interface to store and and load avatars +type Store interface { + Put(userID string, reader io.Reader) (avatarID string, err error) // save avatar data from the reader and return base name + Get(avatarID string) (reader io.ReadCloser, size int, err error) // load avatar via reader + ID(avatarID string) (id string) // unique id of stored avatar's data + Remove(avatarID string) error // remove avatar data + List() (ids []string, err error) // list all avatar ids + Close() error // close store +} + +// Migrate avatars between stores +func Migrate(dst Store, src Store) (int, error) { + ids, err := src.List() + if err != nil { + return 0, err + } + for _, id := range ids { + srcReader, _, err := src.Get(id) + if err != nil { + log.Printf("[WARN] can't get reader for avatar %s", id) + continue + } + if _, err = dst.Put(id, srcReader); err != nil { + log.Printf("[WARN] can't put avatar %s", id) + } + if err = srcReader.Close(); err != nil { + log.Printf("[WARN] failed to close avatar %s", id) + } + } + return len(ids), nil +} + +// encodeID hashes id to sha1. Skip encoding for already processed +func encodeID(id string) string { + if reValidAvatarID.MatchString(id) { + return strings.TrimSuffix(id, imgSfx) // already encoded, strip .image + } + return token.HashID(sha1.New(), id) +} diff --git a/backend/vendor/github.com/go-pkgz/auth/go.mod b/backend/vendor/github.com/go-pkgz/auth/go.mod new file mode 100644 index 00000000..736ae648 --- /dev/null +++ b/backend/vendor/github.com/go-pkgz/auth/go.mod @@ -0,0 +1,15 @@ +module github.com/go-pkgz/auth + +require ( + cloud.google.com/go v0.34.0 // indirect + github.com/coreos/bbolt v1.3.0 + github.com/dgrijalva/jwt-go v3.2.0+incompatible + github.com/globalsign/mgo v0.0.0-20181015135952-eeefdecb41b8 + github.com/go-pkgz/mongo v1.0.0 + github.com/go-pkgz/rest v1.1.5 + github.com/nullrocks/identicon v0.0.0-20180626043057-7875f45b0022 + github.com/pkg/errors v0.8.0 + golang.org/x/image v0.0.0-20181116024801-cd38e8056d9b + golang.org/x/net v0.0.0-20181220203305-927f97764cc3 // indirect + golang.org/x/oauth2 v0.0.0-20181203162652-d668ce993890 +) diff --git a/backend/vendor/github.com/go-pkgz/auth/go.sum b/backend/vendor/github.com/go-pkgz/auth/go.sum new file mode 100644 index 00000000..ccb2a3f2 --- /dev/null +++ b/backend/vendor/github.com/go-pkgz/auth/go.sum @@ -0,0 +1,34 @@ +cloud.google.com/go v0.34.0 h1:eOI3/cP2VTU6uZLDYAoic+eyzzB9YyGmJ7eIjl8rOPg= +cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +github.com/coreos/bbolt v1.3.0 h1:HIgH5xUWXT914HCI671AxuTTqjj64UOFr7pHn48LUTI= +github.com/coreos/bbolt v1.3.0/go.mod h1:iRUV2dpdMOn7Bo10OQBFzIJO9kkE559Wcmn+qkEiiKk= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dgrijalva/jwt-go v3.2.0+incompatible h1:7qlOGliEKZXTDg6OTjfoBKDXWrumCAMpl/TFQ4/5kLM= +github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= +github.com/globalsign/mgo v0.0.0-20181015135952-eeefdecb41b8 h1:DujepqpGd1hyOd7aW59XpK7Qymp8iy83xq74fLr21is= +github.com/globalsign/mgo v0.0.0-20181015135952-eeefdecb41b8/go.mod h1:xkRDCp4j0OGD1HRkm4kmhM+pmpv3AKq5SU7GMg4oO/Q= +github.com/go-pkgz/mongo v1.0.0 h1:9jijAK7prCRMetiyTu3c1rv/2lMypzuf2DWcVpTlwzw= +github.com/go-pkgz/mongo v1.0.0/go.mod h1:R9si/F2aJsjz4MUxhzuppIHY8yLV3YCeuCpgcI50cu4= +github.com/go-pkgz/rest v1.1.3 h1:rMf+xJn8i1Ip9OKohusZsRxwntM0BwYu8OX8BuEwN80= +github.com/go-pkgz/rest v1.1.3/go.mod h1:DIxxm3vSt6e+IY+UQUOFsfB2YaHLmGoOfPLWN5pxQSA= +github.com/go-pkgz/rest v1.1.4 h1:/Lrg9kBWBjNah7nmCDHLszRAfVVBIy5ajf0vVgpHPi0= +github.com/go-pkgz/rest v1.1.4/go.mod h1:DIxxm3vSt6e+IY+UQUOFsfB2YaHLmGoOfPLWN5pxQSA= +github.com/go-pkgz/rest v1.1.5 h1:5br4mnscfLb27yxv5hJFLBVmAt09PrmIBP+meA3CfHc= +github.com/go-pkgz/rest v1.1.5/go.mod h1:DIxxm3vSt6e+IY+UQUOFsfB2YaHLmGoOfPLWN5pxQSA= +github.com/hashicorp/golang-lru v0.5.0 h1:CL2msUPvZTLb5O648aiLNJw3hnBxN2+1Jq8rCOH9wdo= +github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= +github.com/nullrocks/identicon v0.0.0-20180626043057-7875f45b0022 h1:Ys0rDzh8s4UMlGaDa1UTA0sfKgvF0hQZzTYX8ktjiDc= +github.com/nullrocks/identicon v0.0.0-20180626043057-7875f45b0022/go.mod h1:x4NsS+uc7ecH/Cbm9xKQ6XzmJM57rWTkjywjfB2yQ18= +github.com/pkg/errors v0.8.0 h1:WdK/asTD0HN+q6hsWO3/vpuAkAr+tw6aNJNDFFf0+qw= +github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.2.2 h1:bSDNvY7ZPG5RlJ8otE/7V6gMiyenm9RtJ7IUVIAoJ1w= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +golang.org/x/image v0.0.0-20181116024801-cd38e8056d9b h1:VHyIDlv3XkfCa5/a81uzaoDkHH4rr81Z62g+xlnO8uM= +golang.org/x/image v0.0.0-20181116024801-cd38e8056d9b/go.mod h1:ux5Hcp/YLpHSI86hEcLt0YII63i6oz57MZXIpbrjZUs= +golang.org/x/net v0.0.0-20181220203305-927f97764cc3 h1:eH6Eip3UpmR+yM/qI9Ijluzb1bNv/cAU/n+6l8tRSis= +golang.org/x/net v0.0.0-20181220203305-927f97764cc3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/oauth2 v0.0.0-20181203162652-d668ce993890 h1:uESlIz09WIHT2I+pasSXcpLYqYK8wHcdCetU3VuMBJE= +golang.org/x/oauth2 v0.0.0-20181203162652-d668ce993890/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= diff --git a/backend/vendor/github.com/go-pkgz/auth/logger/logger.go b/backend/vendor/github.com/go-pkgz/auth/logger/logger.go new file mode 100644 index 00000000..5c62407d --- /dev/null +++ b/backend/vendor/github.com/go-pkgz/auth/logger/logger.go @@ -0,0 +1,20 @@ +package logger + +import "log" + +// L defines minimal interface used to log things +type L interface { + Logf(format string, args ...interface{}) +} + +// Func type is an adapter to allow the use of ordinary functions as Logger. +type Func func(format string, args ...interface{}) + +// Logf calls f(id) +func (f Func) Logf(format string, args ...interface{}) { f(format, args...) } + +// NoOp logger +var NoOp = Func(func(format string, args ...interface{}) {}) + +// Std logger +var Std = Func(func(format string, args ...interface{}) { log.Printf(format, args...) }) diff --git a/backend/vendor/github.com/go-pkgz/auth/middleware/auth.go b/backend/vendor/github.com/go-pkgz/auth/middleware/auth.go new file mode 100644 index 00000000..f09432c1 --- /dev/null +++ b/backend/vendor/github.com/go-pkgz/auth/middleware/auth.go @@ -0,0 +1,165 @@ +// Package middleware provides oauth2 support as well as related middlewares. +package middleware + +import ( + "net/http" + + "github.com/pkg/errors" + + "github.com/go-pkgz/auth/logger" + "github.com/go-pkgz/auth/provider" + "github.com/go-pkgz/auth/token" +) + +// Authenticator is top level auth object providing middlewares +type Authenticator struct { + logger.L + JWTService TokenService + Providers []provider.Service + Validator token.Validator + AdminPasswd string +} + +// TokenService defines interface accessing tokens +type TokenService interface { + Parse(tokenString string) (claims token.Claims, err error) + Set(w http.ResponseWriter, claims token.Claims) error + Get(r *http.Request) (claims token.Claims, token string, err error) + IsExpired(claims token.Claims) bool + Reset(w http.ResponseWriter) +} + +var adminUser = token.User{ + ID: "admin", + Name: "admin", + Attributes: map[string]interface{}{ + "admin": true, + }, +} + +// Auth middleware adds auth from session and populates user info +func (a *Authenticator) Auth(next http.Handler) http.Handler { + return a.auth(true)(next) +} + +// Trace middleware doesn't require valid user but if user info presented populates info +func (a *Authenticator) Trace(next http.Handler) http.Handler { + return a.auth(false)(next) +} + +// auth implements all logic for authentication (reqAuth=true) and tracing (reqAuth=false) +func (a *Authenticator) auth(reqAuth bool) func(http.Handler) http.Handler { + + onError := func(h http.Handler, w http.ResponseWriter, r *http.Request, err error) { + if err == nil { + return + } + if !reqAuth { + h.ServeHTTP(w, r) + return + } + a.Logf("[DEBUG] auth failed, %v", err) + http.Error(w, "Unauthorized", http.StatusUnauthorized) + } + + f := func(h http.Handler) http.Handler { + fn := func(w http.ResponseWriter, r *http.Request) { + + // use admin user basic auth if enabled + if a.basicAdminUser(r) { + r = token.SetUserInfo(r, adminUser) + h.ServeHTTP(w, r) + return + } + + claims, tkn, err := a.JWTService.Get(r) + if err != nil { + onError(h, w, r, errors.Wrap(err, "can't get token")) + return + } + + if claims.Handshake != nil { // handshake in token indicate special use cases, not for login + onError(h, w, r, errors.New("invalid kind of token")) + return + } + + if claims.User == nil { + onError(h, w, r, errors.New("failed auth, no user info presented in the claim")) + return + } + + if claims.User != nil { // if uinfo in token populate it to context + // validator passed by client and performs check on token or/and claims + if a.Validator != nil && !a.Validator.Validate(tkn, claims) { + onError(h, w, r, errors.Errorf("user %s/%s blocked", claims.User.Name, claims.User.ID)) + a.JWTService.Reset(w) + return + } + + if a.JWTService.IsExpired(claims) { + if claims, err = a.refreshExpiredToken(w, claims); err != nil { + a.JWTService.Reset(w) + onError(h, w, r, errors.Wrap(err, "can't refresh token")) + return + } + a.Logf("[DEBUG] token refreshed for %+v", claims.User) + } + + r = token.SetUserInfo(r, *claims.User) // populate user info to request context + } + + h.ServeHTTP(w, r) + } + return http.HandlerFunc(fn) + } + return f +} + +// refreshExpiredToken makes a new token with passed claims +func (a *Authenticator) refreshExpiredToken(w http.ResponseWriter, claims token.Claims) (token.Claims, error) { + + claims.ExpiresAt = 0 // this will cause now+duration for refreshed token + if err := a.JWTService.Set(w, claims); err != nil { + return token.Claims{}, err + } + return claims, nil +} + +// AdminOnly middleware allows access for admins only +// this handler internally wrapped with auth(true) to avoid situation if AdminOnly defined without prior Auth +func (a *Authenticator) AdminOnly(next http.Handler) http.Handler { + fn := func(w http.ResponseWriter, r *http.Request) { + user, err := token.GetUserInfo(r) + if err != nil { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + if !user.IsAdmin() { + http.Error(w, "Access denied", http.StatusForbidden) + return + } + next.ServeHTTP(w, r) + } + return a.auth(true)(http.HandlerFunc(fn)) // enforce auth +} + +// basic auth for admin user +func (a *Authenticator) basicAdminUser(r *http.Request) bool { + + if a.AdminPasswd == "" { + return false + } + + user, passwd, ok := r.BasicAuth() + if !ok { + return false + } + + if user != "admin" || passwd != a.AdminPasswd { + a.Logf("[WARN] admin basic auth failed, user/passwd mismatch, %s:%s", user, passwd) + return false + } + + return true +} diff --git a/backend/vendor/github.com/go-pkgz/auth/provider/dev_provider.go b/backend/vendor/github.com/go-pkgz/auth/provider/dev_provider.go new file mode 100644 index 00000000..8d93fc96 --- /dev/null +++ b/backend/vendor/github.com/go-pkgz/auth/provider/dev_provider.go @@ -0,0 +1,306 @@ +package provider + +import ( + "bytes" + "context" + "fmt" + "net/http" + "strings" + "sync" + "text/template" + "time" + + "github.com/nullrocks/identicon" + "github.com/pkg/errors" + "golang.org/x/oauth2" + + "github.com/go-pkgz/auth/logger" + "github.com/go-pkgz/auth/token" +) + +const devAuthPort = 8084 + +// DevAuthServer is a fake oauth server for development +// it provides stand-alone server running on its own port and pretending to be the real oauth2. It also provides +// Dev Provider the same way as normal providers do, i.e. like github, google and others. +// can run in interactive and non-interactive mode. In interactive mode login attempts will show login form to select +// desired user name, this is the mode used for development. Non-interactive mode for tests only. +type DevAuthServer struct { + logger.L + Provider Oauth2Handler + Automatic bool + username string // unsafe, but fine for dev + + iconGen *identicon.Generator + httpServer *http.Server + lock sync.Mutex +} + +// Run oauth2 dev server on port devAuthPort +func (d *DevAuthServer) Run(ctx context.Context) { + d.username = "dev_user" + d.Logf("[INFO] run local oauth2 dev server on %d, redir url=%s", devAuthPort, d.Provider.redirectURL) + d.lock.Lock() + var err error + d.iconGen, err = identicon.New("github", 5, 3) + if err != nil { + d.Logf("[WARN] can't create identicon, %s", err) + } + + userFormTmpl, err := template.New("page").Parse(devUserFormTmpl) + if err != nil { + d.Logf("[WARN] can't parse user form template, %s", err) + return + } + + d.httpServer = &http.Server{ + Addr: fmt.Sprintf(":%d", devAuthPort), + Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + d.Logf("[DEBUG] dev oauth request %s %s %+v", r.Method, r.URL, r.Header) + switch { + + case strings.HasPrefix(r.URL.Path, "/login/oauth/authorize"): + + // first time it will be called without username and will ask for one + if !d.Automatic && (r.ParseForm() != nil || r.Form.Get("username") == "") { + + formData := struct{ Query string }{Query: r.URL.RawQuery} + + if err = userFormTmpl.Execute(w, formData); err != nil { + d.Logf("[WARN] can't write, %s", err) + } + return + } + + if !d.Automatic { + d.username = r.Form.Get("username") + } + + state := r.URL.Query().Get("state") + callbackURL := fmt.Sprintf("%s?code=g0ZGZmNjVmOWI&state=%s", d.Provider.redirectURL, state) + d.Logf("[DEBUG] callback url=%s", callbackURL) + w.Header().Add("Location", callbackURL) + w.WriteHeader(http.StatusFound) + + case strings.HasPrefix(r.URL.Path, "/login/oauth/access_token"): + res := `{ + "access_token":"MTQ0NjJkZmQ5OTM2NDE1ZTZjNGZmZjI3", + "token_type":"bearer", + "expires_in":3600, + "refresh_token":"IwOGYzYTlmM2YxOTQ5MGE3YmNmMDFkNTVk", + "scope":"create", + "state":"12345678" + }` + w.Header().Set("Content-Type", "application/json; charset=utf-8") + if _, err = w.Write([]byte(res)); err != nil { + w.WriteHeader(http.StatusInternalServerError) + return + } + + case strings.HasPrefix(r.URL.Path, "/user"): + ava := fmt.Sprintf("http://127.0.0.1:%d/avatar?user=%s", devAuthPort, d.username) + res := fmt.Sprintf(`{ + "id": "%s", + "name":"%s", + "picture":"%s" + }`, d.username, d.username, ava) + + w.Header().Set("Content-Type", "application/json; charset=utf-8") + if _, err = w.Write([]byte(res)); err != nil { + w.WriteHeader(http.StatusInternalServerError) + return + } + + case strings.HasPrefix(r.URL.Path, "/avatar"): + user := r.URL.Query().Get("user") + b, e := d.genAvatar(user) + if e != nil { + w.WriteHeader(http.StatusNotFound) + return + } + if _, err = w.Write(b); err != nil { + w.WriteHeader(http.StatusInternalServerError) + return + } + + default: + w.WriteHeader(http.StatusBadRequest) + } + }), + } + d.lock.Unlock() + + err = d.httpServer.ListenAndServe() + d.Logf("[WARN] dev oauth2 server terminated, %s", err) +} + +// Shutdown oauth2 dev server +func (d *DevAuthServer) Shutdown() { + d.Logf("[WARN] shutdown oauth2 dev server") + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + d.lock.Lock() + if d.httpServer != nil { + if err := d.httpServer.Shutdown(ctx); err != nil { + d.Logf("[DEBUG] oauth2 dev shutdown error, %s", err) + } + } + d.Logf("[DEBUG] shutdown dev oauth2 server completed") + d.lock.Unlock() +} + +// NewDev makes dev oauth2 provider for admin user +func NewDev(p Params) Oauth2Handler { + return initOauth2Handler(p, Oauth2Handler{ + name: "dev", + endpoint: oauth2.Endpoint{ + AuthURL: fmt.Sprintf("http://127.0.0.1:%d/login/oauth/authorize", devAuthPort), + TokenURL: fmt.Sprintf("http://127.0.0.1:%d/login/oauth/access_token", devAuthPort), + }, + redirectURL: p.URL + "/auth/dev/callback", + scopes: []string{"user:email"}, + infoURL: fmt.Sprintf("http://127.0.0.1:%d/user", devAuthPort), + mapUser: func(data userData, _ []byte) token.User { + userInfo := token.User{ + ID: data.value("id"), + Name: data.value("name"), + Picture: data.value("picture"), + } + return userInfo + }, + }) +} + +func (d *DevAuthServer) genAvatar(user string) ([]byte, error) { + if d.iconGen == nil { + return nil, errors.Errorf("no iconGen, skip avatar generation for %s", user) + } + + ii, err := d.iconGen.Draw(user) // Generate an IdentIcon + if err != nil { + return nil, errors.Wrapf(err, "failed to draw avatar for %s", user) + } + + buf := &bytes.Buffer{} + err = ii.Png(300, buf) + return buf.Bytes(), err +} + +var devUserFormTmpl = ` + + + Dev OAuth + + + +
+
+

GO-PKGZ/AUTH

+

Dev Provider

+
+ + +

Not for production use

+
+ + + +` diff --git a/backend/vendor/github.com/go-pkgz/auth/provider/direct.go b/backend/vendor/github.com/go-pkgz/auth/provider/direct.go new file mode 100644 index 00000000..1bad3879 --- /dev/null +++ b/backend/vendor/github.com/go-pkgz/auth/provider/direct.go @@ -0,0 +1,81 @@ +package provider + +import ( + "errors" + "net/http" + + jwt "github.com/dgrijalva/jwt-go" + "github.com/go-pkgz/rest" + + "github.com/go-pkgz/auth/logger" + "github.com/go-pkgz/auth/token" +) + +// DirectHandler implements non-oauth2 provider authorizing user in traditional way with storage +// with users and hashes +type DirectHandler struct { + logger.L + CredChecker CredChecker + ProviderName string + TokenService TokenService + Issuer string +} + +// CredChecker defines interface to check credentials +type CredChecker interface { + Check(user, password string) (ok bool, err error) +} + +// CredCheckerFunc type is an adapter to allow the use of ordinary functions as CredsChecker. +type CredCheckerFunc func(user, password string) (ok bool, err error) + +// Check calls f(user,passwd) +func (f CredCheckerFunc) Check(user, password string) (ok bool, err error) { + return f(user, password) +} + +// Name of the handler +func (p DirectHandler) Name() string { return p.ProviderName } + +// LoginHandler checks "user" and "passwd" against data store and makes jwt if all passed +// GET /something?user=name&password=xyz&sess=[0|1] +func (p DirectHandler) LoginHandler(w http.ResponseWriter, r *http.Request) { + user, password := r.URL.Query().Get("user"), r.URL.Query().Get("passwd") + aud := r.URL.Query().Get("aud") + sessOnly := r.URL.Query().Get("sess") == "1" + if p.CredChecker == nil { + rest.SendErrorJSON(w, r, http.StatusInternalServerError, errors.New("empty credential store"), "no credential store") + return + } + ok, err := p.CredChecker.Check(user, password) + if err != nil { + rest.SendErrorJSON(w, r, http.StatusInternalServerError, err, "failed to access creds store") + return + } + if !ok { + rest.SendErrorJSON(w, r, http.StatusForbidden, nil, "incorrect user or password") + return + } + claims := token.Claims{ + User: &token.User{Name: user}, + StandardClaims: jwt.StandardClaims{ + Issuer: p.Issuer, + Audience: aud, + }, + SessionOnly: sessOnly, + } + + if err = p.TokenService.Set(w, claims); err != nil { + rest.SendErrorJSON(w, r, http.StatusInternalServerError, err, "failed to set token") + return + } + rest.RenderJSON(w, r, claims.User) +} + +// AuthHandler doesn't do anyting for direct login as it has no callbacks +func (p DirectHandler) AuthHandler(w http.ResponseWriter, r *http.Request) {} + +// LogoutHandler - GET /logout +func (p DirectHandler) LogoutHandler(w http.ResponseWriter, r *http.Request) { + p.TokenService.Reset(w) +} diff --git a/backend/vendor/github.com/go-pkgz/auth/provider/oauth2.go b/backend/vendor/github.com/go-pkgz/auth/provider/oauth2.go new file mode 100644 index 00000000..b69dcad5 --- /dev/null +++ b/backend/vendor/github.com/go-pkgz/auth/provider/oauth2.go @@ -0,0 +1,213 @@ +package provider + +import ( + "context" + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + "time" + + jwt "github.com/dgrijalva/jwt-go" + "github.com/go-pkgz/rest" + "golang.org/x/oauth2" + + "github.com/go-pkgz/auth/logger" + "github.com/go-pkgz/auth/token" +) + +// Oauth2Handler implements /login, /callback and /logout handlers from aouth2 flow +type Oauth2Handler struct { + Params + + // all of these fields specific to particular oauth2 provider + name string + redirectURL string + infoURL string + endpoint oauth2.Endpoint + scopes []string + mapUser func(userData, []byte) token.User // map info from InfoURL to User + conf oauth2.Config +} + +// Params to make initialized and ready to use provider +type Params struct { + logger.L + URL string + JwtService TokenService + Cid string + Csecret string + Issuer string + AvatarSaver AvatarSaver +} + +type userData map[string]interface{} + +func (u userData) value(key string) string { + // json.Unmarshal converts json "null" value to go's "nil", in this case return empty string + if val, ok := u[key]; ok && val != nil { + return fmt.Sprintf("%v", val) + } + return "" +} + +// initOauth2Handler makes oauth2 handler for given provider +func initOauth2Handler(p Params, service Oauth2Handler) Oauth2Handler { + if p.L == nil { + p.L = logger.Func(func(fmt string, args ...interface{}) {}) + } + p.Logf("[INFO] init oauth2 service %s", service.name) + service.Params = p + service.conf = oauth2.Config{ + ClientID: service.Cid, + ClientSecret: service.Csecret, + RedirectURL: service.redirectURL, + Scopes: service.scopes, + Endpoint: service.endpoint, + } + + p.Logf("[DEBUG] created %s oauth2, id=%s, redir=%s, endpoint=%s", + service.name, service.Cid, service.endpoint, service.redirectURL) + return service +} + +// Name returns provider name +func (p Oauth2Handler) Name() string { return p.name } + +// LoginHandler - GET /login?from=redirect-back-url&site=siteID&session=1 +func (p Oauth2Handler) LoginHandler(w http.ResponseWriter, r *http.Request) { + + p.Logf("[DEBUG] login with %s", p.Name()) + // make state (random) and store in session + state, err := randToken() + if err != nil { + rest.SendErrorJSON(w, r, http.StatusInternalServerError, err, "failed to make oauth2 state") + return + } + + cid, err := randToken() + if err != nil { + rest.SendErrorJSON(w, r, http.StatusInternalServerError, err, "failed to make claim's id") + return + } + + claims := token.Claims{ + Handshake: &token.Handshake{ + State: state, + From: r.URL.Query().Get("from"), + }, + SessionOnly: r.URL.Query().Get("session") != "" && r.URL.Query().Get("session") != "0", + StandardClaims: jwt.StandardClaims{ + Id: cid, + Audience: r.URL.Query().Get("site"), + ExpiresAt: time.Now().Add(30 * time.Minute).Unix(), + NotBefore: time.Now().Add(-1 * time.Minute).Unix(), + }, + } + + if err := p.JwtService.Set(w, claims); err != nil { + rest.SendErrorJSON(w, r, http.StatusInternalServerError, err, "failed to set token") + return + } + + // return login url + loginURL := p.conf.AuthCodeURL(state) + p.Logf("[DEBUG] login url %s, claims=%+v", loginURL, claims) + + http.Redirect(w, r, loginURL, http.StatusFound) +} + +// AuthHandler fills user info and redirects to "from" url. This is callback url redirected locally by browser +// GET /callback +func (p Oauth2Handler) AuthHandler(w http.ResponseWriter, r *http.Request) { + oauthClaims, _, err := p.JwtService.Get(r) + if err != nil { + rest.SendErrorJSON(w, r, http.StatusInternalServerError, err, "failed to get token") + return + } + + if oauthClaims.Handshake == nil { + rest.SendErrorJSON(w, r, http.StatusForbidden, nil, "invalid handshake token") + return + } + + retrievedState := oauthClaims.Handshake.State + if retrievedState == "" || retrievedState != r.URL.Query().Get("state") { + rest.SendErrorJSON(w, r, http.StatusForbidden, nil, "unexpected state") + return + } + + p.Logf("[DEBUG] token with state %s", retrievedState) + tok, err := p.conf.Exchange(context.Background(), r.URL.Query().Get("code")) + if err != nil { + rest.SendErrorJSON(w, r, http.StatusInternalServerError, err, "exchange failed") + return + } + + client := p.conf.Client(context.Background(), tok) + uinfo, err := client.Get(p.infoURL) + if err != nil { + rest.SendErrorJSON(w, r, http.StatusServiceUnavailable, err, "failed to get client info") + return + } + + defer func() { + if e := uinfo.Body.Close(); e != nil { + p.Logf("[WARN] failed to close response body, %s", e) + } + }() + + data, err := ioutil.ReadAll(uinfo.Body) + if err != nil { + rest.SendErrorJSON(w, r, http.StatusInternalServerError, err, "failed to read user info") + return + } + + jData := map[string]interface{}{} + if e := json.Unmarshal(data, &jData); e != nil { + rest.SendErrorJSON(w, r, http.StatusInternalServerError, err, "failed to unmarshal user info") + return + } + p.Logf("[DEBUG] got raw user info %+v", jData) + + u := p.mapUser(jData, data) + u, err = setAvatar(p.AvatarSaver, u) + if err != nil { + rest.SendErrorJSON(w, r, http.StatusInternalServerError, err, "failed to save avatar to proxy") + return + } + + cid, err := randToken() + if err != nil { + rest.SendErrorJSON(w, r, http.StatusInternalServerError, err, "failed to make claim's id") + return + } + claims := token.Claims{ + User: &u, + StandardClaims: jwt.StandardClaims{ + Issuer: p.Issuer, + Id: cid, + Audience: oauthClaims.Audience, + }, + SessionOnly: oauthClaims.SessionOnly, + } + + if err = p.JwtService.Set(w, claims); err != nil { + rest.SendErrorJSON(w, r, http.StatusInternalServerError, err, "failed to set token") + return + } + + p.Logf("[DEBUG] user info %+v", u) + + // redirect to back url if presented in login query params + if oauthClaims.Handshake != nil && oauthClaims.Handshake.From != "" { + http.Redirect(w, r, oauthClaims.Handshake.From, http.StatusTemporaryRedirect) + return + } + rest.RenderJSON(w, r, &u) +} + +// LogoutHandler - GET /logout +func (p Oauth2Handler) LogoutHandler(w http.ResponseWriter, r *http.Request) { + p.JwtService.Reset(w) +} diff --git a/backend/app/rest/auth/providers.go b/backend/vendor/github.com/go-pkgz/auth/provider/providers.go similarity index 51% rename from backend/app/rest/auth/providers.go rename to backend/vendor/github.com/go-pkgz/auth/provider/providers.go index 4ed89a9b..9474bc7d 100644 --- a/backend/app/rest/auth/providers.go +++ b/backend/vendor/github.com/go-pkgz/auth/provider/providers.go @@ -1,6 +1,7 @@ -package auth +package provider import ( + "crypto/sha1" "encoding/json" "fmt" @@ -9,21 +10,21 @@ import ( "golang.org/x/oauth2/google" "golang.org/x/oauth2/yandex" - "github.com/umputun/remark/backend/app/store" + "github.com/go-pkgz/auth/token" ) // NewGoogle makes google oauth2 provider -func NewGoogle(p Params) Provider { - return initProvider(p, Provider{ - Name: "google", - Endpoint: google.Endpoint, - RedirectURL: p.RemarkURL + "/auth/google/callback", - Scopes: []string{"https://www.googleapis.com/auth/userinfo.profile"}, - InfoURL: "https://www.googleapis.com/oauth2/v3/userinfo", - MapUser: func(data userData, _ []byte) store.User { - userInfo := store.User{ +func NewGoogle(p Params) Oauth2Handler { + return initOauth2Handler(p, Oauth2Handler{ + name: "google", + endpoint: google.Endpoint, + redirectURL: p.URL + "/auth/google/callback", + scopes: []string{"https://www.googleapis.com/auth/userinfo.profile"}, + infoURL: "https://www.googleapis.com/oauth2/v3/userinfo", + mapUser: func(data userData, _ []byte) token.User { + userInfo := token.User{ // encode email with provider name to avoid collision if same id returned by other provider - ID: "google_" + store.EncodeID(data.value("sub")), + ID: "google_" + token.HashID(sha1.New(), data.value("sub")), Name: data.value("name"), Picture: data.value("picture"), } @@ -36,16 +37,16 @@ func NewGoogle(p Params) Provider { } // NewGithub makes github oauth2 provider -func NewGithub(p Params) Provider { - return initProvider(p, Provider{ - Name: "github", - Endpoint: github.Endpoint, - RedirectURL: p.RemarkURL + "/auth/github/callback", - Scopes: []string{}, - InfoURL: "https://api.github.com/user", - MapUser: func(data userData, _ []byte) store.User { - userInfo := store.User{ - ID: "github_" + store.EncodeID(data.value("login")), +func NewGithub(p Params) Oauth2Handler { + return initOauth2Handler(p, Oauth2Handler{ + name: "github", + endpoint: github.Endpoint, + redirectURL: p.URL + "/auth/github/callback", + scopes: []string{}, + infoURL: "https://api.github.com/user", + mapUser: func(data userData, _ []byte) token.User { + userInfo := token.User{ + ID: "github_" + token.HashID(sha1.New(), data.value("login")), Name: data.value("name"), Picture: data.value("avatar_url"), } @@ -59,7 +60,7 @@ func NewGithub(p Params) Provider { } // NewFacebook makes facebook oauth2 provider -func NewFacebook(p Params) Provider { +func NewFacebook(p Params) Oauth2Handler { // response format for fb /me call type uinfo struct { @@ -72,15 +73,15 @@ func NewFacebook(p Params) Provider { } `json:"picture"` } - return initProvider(p, Provider{ - Name: "facebook", - Endpoint: facebook.Endpoint, - RedirectURL: p.RemarkURL + "/auth/facebook/callback", - Scopes: []string{"public_profile"}, - InfoURL: "https://graph.facebook.com/me?fields=id,name,picture", - MapUser: func(data userData, bdata []byte) store.User { - userInfo := store.User{ - ID: "facebook_" + store.EncodeID(data.value("id")), + return initOauth2Handler(p, Oauth2Handler{ + name: "facebook", + endpoint: facebook.Endpoint, + redirectURL: p.URL + "/auth/facebook/callback", + scopes: []string{"public_profile"}, + infoURL: "https://graph.facebook.com/me?fields=id,name,picture", + mapUser: func(data userData, bdata []byte) token.User { + userInfo := token.User{ + ID: "facebook_" + token.HashID(sha1.New(), data.value("id")), Name: data.value("name"), } if userInfo.Name == "" { @@ -97,17 +98,17 @@ func NewFacebook(p Params) Provider { } // NewYandex makes yandex oauth2 provider -func NewYandex(p Params) Provider { - return initProvider(p, Provider{ - Name: "yandex", - Endpoint: yandex.Endpoint, - RedirectURL: p.RemarkURL + "/auth/yandex/callback", - Scopes: []string{}, +func NewYandex(p Params) Oauth2Handler { + return initOauth2Handler(p, Oauth2Handler{ + name: "yandex", + endpoint: yandex.Endpoint, + redirectURL: p.URL + "/auth/yandex/callback", + scopes: []string{}, // See https://tech.yandex.com/passport/doc/dg/reference/response-docpage/ - InfoURL: "https://login.yandex.ru/info?format=json", - MapUser: func(data userData, _ []byte) store.User { - userInfo := store.User{ - ID: "yandex_" + store.EncodeID(data.value("id")), + infoURL: "https://login.yandex.ru/info?format=json", + mapUser: func(data userData, _ []byte) token.User { + userInfo := token.User{ + ID: "yandex_" + token.HashID(sha1.New(), data.value("id")), Name: data.value("display_name"), // using Display Name by default } if userInfo.Name == "" { diff --git a/backend/vendor/github.com/go-pkgz/auth/provider/service.go b/backend/vendor/github.com/go-pkgz/auth/provider/service.go new file mode 100644 index 00000000..0915e2f1 --- /dev/null +++ b/backend/vendor/github.com/go-pkgz/auth/provider/service.go @@ -0,0 +1,91 @@ +package provider + +import ( + "crypto/rand" + "crypto/sha1" + "fmt" + "net/http" + "strings" + + "github.com/pkg/errors" + + "github.com/go-pkgz/auth/token" +) + +// Service represents oauth2 provider. Adds Handler method multiplexing login, auth and logout requests +type Service struct { + Provider +} + +// NewService makes service for given provider +func NewService(p Provider) Service { + return Service{Provider: p} +} + +// AvatarSaver defines minimal interface to save avatar +type AvatarSaver interface { + Put(u token.User) (avatarURL string, err error) +} + +// TokenService defines interface accessing tokens +type TokenService interface { + Parse(tokenString string) (claims token.Claims, err error) + Set(w http.ResponseWriter, claims token.Claims) error + Get(r *http.Request) (claims token.Claims, token string, err error) + Reset(w http.ResponseWriter) +} + +// Provider defines interface for auth handler +type Provider interface { + Name() string + LoginHandler(w http.ResponseWriter, r *http.Request) + AuthHandler(w http.ResponseWriter, r *http.Request) + LogoutHandler(w http.ResponseWriter, r *http.Request) +} + +// Handler returns auth routes for given provider +func (p Service) Handler(w http.ResponseWriter, r *http.Request) { + + if r.Method != "GET" { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + if strings.HasSuffix(r.URL.Path, "/login") { + p.LoginHandler(w, r) + return + } + if strings.HasSuffix(r.URL.Path, "/callback") { + p.AuthHandler(w, r) + return + } + if strings.HasSuffix(r.URL.Path, "/logout") { + p.LogoutHandler(w, r) + return + } + w.WriteHeader(http.StatusNotFound) +} + +// setAvatar saves avatar and puts proxied URL to u.Picture +func setAvatar(ava AvatarSaver, u token.User) (token.User, error) { + if ava != nil { + avatarURL, e := ava.Put(u) + if e != nil { + return u, errors.Wrap(e, "failed to save avatar for") + } + u.Picture = avatarURL + return u, nil + } + return u, nil // empty AvatarSaver ok, just skipped +} + +func randToken() (string, error) { + b := make([]byte, 32) + if _, err := rand.Read(b); err != nil { + return "", errors.Wrap(err, "can't get random") + } + s := sha1.New() + if _, err := s.Write(b); err != nil { + return "", errors.Wrap(err, "can't write randoms to sha1") + } + return fmt.Sprintf("%x", s.Sum(nil)), nil +} diff --git a/backend/vendor/github.com/go-pkgz/auth/token/jwt.go b/backend/vendor/github.com/go-pkgz/auth/token/jwt.go new file mode 100644 index 00000000..13ae2b4d --- /dev/null +++ b/backend/vendor/github.com/go-pkgz/auth/token/jwt.go @@ -0,0 +1,306 @@ +package token + +import ( + "encoding/json" + "fmt" + "net/http" + "time" + + jwt "github.com/dgrijalva/jwt-go" + "github.com/pkg/errors" +) + +// Service wraps jwt operations +// supports both header and cookie tokens +type Service struct { + Opts +} + +// Claims stores user info for token and state & from from login +type Claims struct { + jwt.StandardClaims + User *User `json:"user,omitempty"` // user info + SessionOnly bool `json:"sess_only,omitempty"` + Handshake *Handshake `json:"handshake,omitempty"` // used for oauth handshake +} + +// Handshake used for oauth handshake +type Handshake struct { + State string `json:"state,omitempty"` + From string `json:"from,omitempty"` + ID string `json:"id,omitempty"` +} + +// default names for cookies and headers +const ( + jwtCookieName = "JWT" + jwtHeaderKey = "X-JWT" + xsrfCookieName = "XSRF-TOKEN" + xsrfHeaderKey = "X-XSRF-TOKEN" + issuer = "go-pkgz/auth" + tokenDuration = time.Minute * 15 + cookieDuration = time.Hour * 24 * 31 +) + +// Opts holds constructor params +type Opts struct { + SecretReader Secret + ClaimsUpd ClaimsUpdater + SecureCookies bool + TokenDuration time.Duration + CookieDuration time.Duration + DisableXSRF bool + DisableIAT bool // disable IssuedAt claim + // optional (custom) names for cookies and headers + JWTCookieName string + JWTHeaderKey string + XSRFCookieName string + XSRFHeaderKey string + + Issuer string // optional value for iss claim, usually application name +} + +// NewService makes JWT service +func NewService(opts Opts) *Service { + res := Service{Opts: opts} + + setDefault := func(fld *string, def string) { + if *fld == "" { + *fld = def + } + } + + setDefault(&res.JWTCookieName, jwtCookieName) + setDefault(&res.JWTHeaderKey, jwtHeaderKey) + setDefault(&res.XSRFCookieName, xsrfCookieName) + setDefault(&res.XSRFHeaderKey, xsrfHeaderKey) + setDefault(&res.Issuer, issuer) + + if opts.TokenDuration == 0 { + res.TokenDuration = tokenDuration + } + + if opts.CookieDuration == 0 { + res.CookieDuration = cookieDuration + } + + return &res +} + +// Token makes token with claims +func (j *Service) Token(claims Claims) (string, error) { + + // update claims with ClaimsUpdFunc defined by consumer + if j.ClaimsUpd != nil { + claims = j.ClaimsUpd.Update(claims) + } + + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + + if j.SecretReader == nil { + return "", errors.New("secretreader not defined") + } + + secret, err := j.SecretReader.Get(claims.Audience) // get secret via consumer defined SecretReader + if err != nil { + return "", errors.Wrap(err, "can't get secret") + } + + tokenString, err := token.SignedString([]byte(secret)) + if err != nil { + return "", errors.Wrap(err, "can't sign token token") + } + return tokenString, nil +} + +// Parse token string and verify. Not checking for expiration +func (j *Service) Parse(tokenString string) (Claims, error) { + parser := jwt.Parser{SkipClaimsValidation: true} // allow parsing of expired tokens + + getAud := func() (aud string, err error) { // parse token without signature check to get id (aud) + preToken, _, err := parser.ParseUnverified(tokenString, &Claims{}) + if err != nil { + return "", errors.Wrap(err, "can't pre-parse token") + } + preClaims, ok := preToken.Claims.(*Claims) + if !ok { + return "", errors.New("invalid token") + } + return preClaims.Audience, nil + } + + aud, err := getAud() + if err != nil { + return Claims{}, errors.Wrap(err, "failed to get aud from token token") + } + + if j.SecretReader == nil { + return Claims{}, errors.New("secretreader not defined") + } + + secret, err := j.SecretReader.Get(aud) + if err != nil { + return Claims{}, errors.Wrap(err, "can't get secret") + } + + token, err := parser.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) { + if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, errors.Errorf("unexpected signing method: %v", token.Header["alg"]) + } + return []byte(secret), nil + }) + if err != nil { + return Claims{}, errors.Wrap(err, "can't parse token") + } + + claims, ok := token.Claims.(*Claims) + if !ok || !token.Valid { + return Claims{}, errors.New("invalid token") + } + + return *claims, nil +} + +// Set creates token cookie with xsrf cookie and put it to ResponseWriter +// accepts claims and sets expiration if none defined. permanent flag means long-living cookie, +// false makes it session only. +func (j *Service) Set(w http.ResponseWriter, claims Claims) error { + if claims.ExpiresAt == 0 { + claims.ExpiresAt = time.Now().Add(j.TokenDuration).Unix() + } + + if claims.Issuer == "" { + claims.Issuer = j.Issuer + } + + if !j.DisableIAT { + claims.IssuedAt = time.Now().Unix() + } + + tokenString, err := j.Token(claims) + if err != nil { + return errors.Wrap(err, "failed to make token token") + } + + cookieExpiration := 0 // session cookie + if !claims.SessionOnly && claims.Handshake == nil { + cookieExpiration = int(j.CookieDuration.Seconds()) + } + + jwtCookie := http.Cookie{Name: jwtCookieName, Value: tokenString, HttpOnly: true, Path: "/", + MaxAge: cookieExpiration, Secure: j.SecureCookies} + http.SetCookie(w, &jwtCookie) + + xsrfCookie := http.Cookie{Name: xsrfCookieName, Value: claims.Id, HttpOnly: false, Path: "/", + MaxAge: cookieExpiration, Secure: j.SecureCookies} + http.SetCookie(w, &xsrfCookie) + + return nil +} + +// Get token from header or cookie +// if cookie used, verify xsrf token to match +func (j *Service) Get(r *http.Request) (Claims, string, error) { + + fromCookie := false + tokenString := "" + + // try to get from X-JWT header + if tokenHeader := r.Header.Get(jwtHeaderKey); tokenHeader != "" { + tokenString = tokenHeader + } + + // try to get from JWT cookie + if tokenString == "" { + fromCookie = true + jc, err := r.Cookie(jwtCookieName) + if err != nil { + return Claims{}, "", errors.Wrap(err, "token cookie was not presented") + } + tokenString = jc.Value + } + + claims, err := j.Parse(tokenString) + if err != nil { + return Claims{}, "", errors.Wrap(err, "failed to get token") + } + + if j.DisableXSRF { + return claims, tokenString, nil + } + + if fromCookie && claims.User != nil { + xsrf := r.Header.Get(xsrfHeaderKey) + if claims.Id != xsrf { + return Claims{}, "", errors.New("xsrf mismatch") + } + } + return claims, tokenString, nil +} + +// IsExpired returns true if claims expired +func (j *Service) IsExpired(claims Claims) bool { + return !claims.VerifyExpiresAt(time.Now().Unix(), true) +} + +// Reset token's cookies +func (j *Service) Reset(w http.ResponseWriter) { + jwtCookie := http.Cookie{Name: jwtCookieName, Value: "", HttpOnly: false, Path: "/", + MaxAge: -1, Expires: time.Unix(0, 0), Secure: j.SecureCookies} + http.SetCookie(w, &jwtCookie) + + xsrfCookie := http.Cookie{Name: xsrfCookieName, Value: "", HttpOnly: false, Path: "/", + MaxAge: -1, Expires: time.Unix(0, 0), Secure: j.SecureCookies} + http.SetCookie(w, &xsrfCookie) +} + +// Secret defines interface returning secret key for given id (aud) +type Secret interface { + Get(id string) (string, error) +} + +// SecretFunc type is an adapter to allow the use of ordinary functions as Secret. If f is a function +// with the appropriate signature, SecretFunc(f) is a Handler that calls f. +type SecretFunc func(id string) (string, error) + +// Get calls f(id) +func (f SecretFunc) Get(id string) (string, error) { + return f(id) +} + +// ClaimsUpdater defines interface adding extras to claims +type ClaimsUpdater interface { + Update(claims Claims) Claims +} + +// ClaimsUpdFunc type is an adapter to allow the use of ordinary functions as ClaimsUpdater. If f is a function +// with the appropriate signature, ClaimsUpdFunc(f) is a Handler that calls f. +type ClaimsUpdFunc func(claims Claims) Claims + +// Update calls f(id) +func (f ClaimsUpdFunc) Update(claims Claims) Claims { + return f(claims) +} + +// Validator defines interface to accept o reject claims with consumer defined logic +// It works with valid token and allows to reject some, based on token match or user's fields +type Validator interface { + Validate(token string, claims Claims) bool +} + +// ValidatorFunc type is an adapter to allow the use of ordinary functions as Validator. If f is a function +// with the appropriate signature, ValidatorFunc(f) is a Validator that calls f. +type ValidatorFunc func(token string, claims Claims) bool + +// Validate calls f(id) +func (f ValidatorFunc) Validate(token string, claims Claims) bool { + return f(token, claims) +} + +func (c Claims) String() string { + b, err := json.Marshal(c) + if err != nil { + return fmt.Sprintf("%+v %+v", c.StandardClaims, c.User) + } + return string(b) +} diff --git a/backend/vendor/github.com/go-pkgz/auth/token/user.go b/backend/vendor/github.com/go-pkgz/auth/token/user.go new file mode 100644 index 00000000..d91bb433 --- /dev/null +++ b/backend/vendor/github.com/go-pkgz/auth/token/user.go @@ -0,0 +1,127 @@ +package token + +import ( + "context" + "encoding/hex" + "fmt" + "hash" + "hash/crc64" + "io" + "net/http" + "regexp" + + "github.com/pkg/errors" +) + +var reValidSha = regexp.MustCompile("^[a-fA-F0-9]{40}$") +var reValidCrc64 = regexp.MustCompile("^[a-fA-F0-9]{16}$") + +const adminAttr = "admin" // predefined attribute key for bool isAdmin status + +// User is the basic part of oauth data provided by service +type User struct { + Name string `json:"name"` + ID string `json:"id"` + Picture string `json:"picture"` + IP string `json:"ip,omitempty"` + Email string `json:"email,omitempty"` + + Attributes map[string]interface{} `json:"attrs,omitempty"` +} + +// SetBoolAttr sets boolean attribute +func (u *User) SetBoolAttr(key string, val bool) { + if u.Attributes == nil { + u.Attributes = map[string]interface{}{} + } + u.Attributes[key] = val +} + +// SetStrAttr sets string attribute +func (u *User) SetStrAttr(key string, val string) { + if u.Attributes == nil { + u.Attributes = map[string]interface{}{} + } + u.Attributes[key] = val +} + +// BoolAttr gets boolean attribute +func (u *User) BoolAttr(key string) bool { + r, ok := u.Attributes[key].(bool) + if !ok { + return false + } + return r +} + +// StrAttr gets string attribute +func (u *User) StrAttr(key string) string { + r, ok := u.Attributes[key].(string) + if !ok { + return "" + } + return r +} + +// SetAdmin is a shortcut to set "admin" attribute +func (u *User) SetAdmin(val bool) { + u.SetBoolAttr(adminAttr, val) +} + +// IsAdmin is a shortcut to get admin attribute +func (u *User) IsAdmin() bool { + return u.BoolAttr(adminAttr) +} + +// HashID tries to hash val with hash.Hash and fallback to crc if needed +func HashID(h hash.Hash, val string) string { + + if reValidSha.MatchString(val) { + return val // already hashed or empty + } + + if _, err := io.WriteString(h, val); err != nil { + // fail back to crc64 + if val == "" { + val = "!empty string!" + } + if reValidCrc64.MatchString(val) { + return val // already crced + } + return fmt.Sprintf("%x", crc64.Checksum([]byte(val), crc64.MakeTable(crc64.ECMA))) + } + return hex.EncodeToString(h.Sum(nil)) +} + +type contextKey string + +// MustGetUserInfo fails if can't extract user data from the request. +// should be called from authenticated controllers only +func MustGetUserInfo(r *http.Request) User { + user, err := GetUserInfo(r) + if err != nil { + panic(err) + } + return user +} + +// GetUserInfo returns user from request context +func GetUserInfo(r *http.Request) (user User, err error) { + + ctx := r.Context() + if ctx == nil { + return User{}, errors.New("no info about user") + } + if u, ok := ctx.Value(contextKey("user")).(User); ok { + return u, nil + } + + return User{}, errors.New("user can't be parsed") +} + +// SetUserInfo sets user into request context +func SetUserInfo(r *http.Request, user User) *http.Request { + ctx := r.Context() + ctx = context.WithValue(ctx, contextKey("user"), user) + return r.WithContext(ctx) +} diff --git a/backend/vendor/github.com/go-pkgz/rest/README.md b/backend/vendor/github.com/go-pkgz/rest/README.md index 6e80e899..8a6c6f05 100644 --- a/backend/vendor/github.com/go-pkgz/rest/README.md +++ b/backend/vendor/github.com/go-pkgz/rest/README.md @@ -1,4 +1,5 @@ -## REST helpers and middleware [![Build Status](https://travis-ci.org/go-pkgz/rest.svg?branch=master)](https://travis-ci.org/go-pkgz/rest) [![Go Report Card](https://goreportcard.com/badge/github.com/go-pkgz/rest)](https://goreportcard.com/report/github.com/go-pkgz/rest) [![Coverage Status](https://coveralls.io/repos/github/go-pkgz/rest/badge.svg?branch=master)](https://coveralls.io/github/go-pkgz/rest?branch=master) +## REST helpers and middleware [![Build Status](https://travis-ci.org/go-pkgz/rest.svg?branch=master)](https://travis-ci.org/go-pkgz/rest) [![Go Report Card](https://goreportcard.com/badge/github.com/go-pkgz/rest)](https://goreportcard.com/report/github.com/go-pkgz/rest) [![Coverage Status](https://coveralls.io/repos/github/go-pkgz/rest/badge.svg?branch=master)](https://coveralls.io/github/go-pkgz/rest?branch=master) [![godoc](https://godoc.org/github.com/go-pkgz/rest?status.svg)](https://godoc.org/github.com/go-pkgz/rest) + ## Install and update diff --git a/backend/vendor/github.com/go-pkgz/rest/httperrors.go b/backend/vendor/github.com/go-pkgz/rest/httperrors.go index c9730ffc..e5d20588 100644 --- a/backend/vendor/github.com/go-pkgz/rest/httperrors.go +++ b/backend/vendor/github.com/go-pkgz/rest/httperrors.go @@ -1,6 +1,7 @@ package rest import ( + "errors" "fmt" "log" "net/http" @@ -9,14 +10,14 @@ import ( "strings" ) -// SendErrorJSON makes {error: blah, details: blah} json body and responds with error code -func SendErrorJSON(w http.ResponseWriter, r *http.Request, code int, err error, details string) { - log.Printf("[DEBUG] %s", errDetailsMsg(r, code, err, details)) +// SendErrorJSON sends {error: msg} with error code and logging error and caller +func SendErrorJSON(w http.ResponseWriter, r *http.Request, code int, err error, msg string) { + log.Printf("[DEBUG] %s", errDetailsMsg(r, code, err, msg)) w.WriteHeader(code) - RenderJSON(w, r, map[string]interface{}{"error": err.Error(), "details": details}) + RenderJSON(w, r, JSON{"error": msg}) } -func errDetailsMsg(r *http.Request, code int, err error, details string) string { +func errDetailsMsg(r *http.Request, code int, err error, msg string) string { q := r.URL.String() if qun, e := url.QueryUnescape(q); e == nil { @@ -35,5 +36,8 @@ func errDetailsMsg(r *http.Request, code int, err error, details string) string if pos := strings.Index(remoteIP, ":"); pos >= 0 { remoteIP = remoteIP[:pos] } - return fmt.Sprintf("%s - %v - %d - %s - %s%s", details, err, code, remoteIP, q, srcFileInfo) + if err == nil { + err = errors.New("no error") + } + return fmt.Sprintf("%s - %v - %d - %s - %s%s", msg, err, code, remoteIP, q, srcFileInfo) } diff --git a/backend/vendor/github.com/go-pkgz/rest/logger/logger.go b/backend/vendor/github.com/go-pkgz/rest/logger/logger.go index d3746101..2bdbc882 100644 --- a/backend/vendor/github.com/go-pkgz/rest/logger/logger.go +++ b/backend/vendor/github.com/go-pkgz/rest/logger/logger.go @@ -36,6 +36,13 @@ const ( None ) +// Logger returns default logger middleware +func Logger(next http.Handler) http.Handler { + l := New(Flags(All), Prefix("[REST]")) + return l.Handler(next) + +} + // New makes rest Logger with given options func New(options ...Option) *Middleware { res := Middleware{ diff --git a/compose-dev-backend.yml b/compose-dev-backend.yml index a213f5ca..5cc72f6b 100644 --- a/compose-dev-backend.yml +++ b/compose-dev-backend.yml @@ -40,7 +40,7 @@ services: - STORE_BOLT_PATH=/srv/var/db - BACKUP_PATH=/srv/var/backup - DEBUG=true - - DEV_PASSWD=password + - ADMIN_PASSWD=password - AUTH_DEV=true # activate local oauth "dev" - ADMIN_SHARED_ID=dev_user # set admin flag for default user on local ouath2 - NOTIFY_TYPE diff --git a/compose-dev-frontend.yml b/compose-dev-frontend.yml index f2760302..f21c504a 100644 --- a/compose-dev-frontend.yml +++ b/compose-dev-frontend.yml @@ -31,9 +31,9 @@ services: - REMARK_URL=http://127.0.0.1:8080 - SECRET=12345 - STORE_BOLT_PATH=/srv/var/db - - BACKUP_PATH=/srv/var/backup + - BACKUP_PATH=/srv/var/backupang - DEBUG=true - - DEV_PASSWD=password + - ADMIN_PASSWD=password - AUTH_DEV=true # activate local oauth "dev" - ADMIN_SHARED_ID=dev_user # set admin flag for default user on local ouath2 volumes: diff --git a/docker-compose.yml b/docker-compose.yml index cce74474..87234c00 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -32,6 +32,6 @@ services: - AUTH_FACEBOOK_CSEC - AUTH_DISQUS_CID - AUTH_DISQUS_CSEC - # - DEV_PASSWD=password # development mode, be careful! + # - ADMIN_PASSWD=password volumes: - ./var:/srv/var