@@ -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 `--<key>=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.
|
||||
|
||||
Generated
+23
-10
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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`)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
+74
-54
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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":"<p>test test #2</p>","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":"<p>test test #1</p>","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
|
||||
}
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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, "<p><strong>test</strong> <em>123</em></p>\n\n<p><a href=\"http://radio-t.com\" rel=\"nofollow\">http://radio-t.com</a></p>\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"])
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 = `
|
||||
<html>
|
||||
<head>
|
||||
<title>Remark42 Dev User</title>
|
||||
<style>
|
||||
form {
|
||||
margin: 100 auto;
|
||||
width: 300px;
|
||||
padding: 1em;
|
||||
border: 1px solid #CCC;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<form action="/login/oauth/authorize?%s" method="post">
|
||||
username: <input type="text" name="username" value="dev_user">
|
||||
<input type="submit" value="Login">
|
||||
</form>
|
||||
</body>
|
||||
</html>
|
||||
`
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
+29
-14
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 23 KiB |
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 11 KiB |
@@ -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"
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
+14
@@ -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
|
||||
+23
@@ -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
|
||||
+21
@@ -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.
|
||||
+258
@@ -0,0 +1,258 @@
|
||||
# auth - authentication via oauth2 [](https://travis-ci.org/go-pkgz/auth) [](https://coveralls.io/github/go-pkgz/auth?branch=master) [](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", "<Client ID>", "<Client Secret>") // add github provider
|
||||
service.AddProvider("facebook", "<Client ID>", "<Client Secret>") // 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/<provider>/login?id=<site_id>&from=<redirect_url>` - 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/<avatar_id>` - returns the avatar (image). Links to those pictures added into user info automatically, for details see "Avatar proxy"
|
||||
- `/auth/<provider>/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/<name>/login?user=<user>&passwd=<password>&aud=<site_id>&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:<Opts.AdminPasswd> 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.
|
||||
+258
@@ -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
|
||||
}
|
||||
+163
@@ -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")
|
||||
}
|
||||
Generated
Vendored
+7
-15
@@ -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))
|
||||
}
|
||||
Generated
Vendored
+4
-11
@@ -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
|
||||
}
|
||||
Generated
Vendored
+13
-22
@@ -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
|
||||
+62
@@ -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)
|
||||
}
|
||||
+15
@@ -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
|
||||
)
|
||||
+34
@@ -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=
|
||||
+20
@@ -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...) })
|
||||
+165
@@ -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
|
||||
}
|
||||
+306
@@ -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 = `
|
||||
<html>
|
||||
<head>
|
||||
<title>Dev OAuth</title>
|
||||
<style>
|
||||
body {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
a {
|
||||
color: hsl(200, 50%, 50%);
|
||||
text-decoration-color: hsla(200, 50%, 50%, 0.5);
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: hsl(200, 50%, 70%);
|
||||
text-decoration-color: hsla(200, 50%, 70%, 0.5);
|
||||
}
|
||||
|
||||
form {
|
||||
font-family: Helvetica, Arial, sans-serif;
|
||||
margin: 100px auto;
|
||||
display: inline-block;
|
||||
padding: 1em;
|
||||
box-shadow: 0 0 0.1rem rgba(0, 0, 0, 0.2), 0 0 0.4rem rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.form-header {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.form-header h1 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.form-header h1 a:not(:hover) {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.form-header p {
|
||||
opacity: 0.6;
|
||||
margin-top: 0;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.username-label {
|
||||
opacity: 0.6;
|
||||
font-size: 0.8em;
|
||||
}
|
||||
|
||||
.username-input {
|
||||
font-size: inherit;
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
text-align: inherit;
|
||||
}
|
||||
|
||||
.form-submit {
|
||||
border: none;
|
||||
background: hsl(200, 50%, 50%);
|
||||
color: white;
|
||||
font: inherit;
|
||||
padding: 0.4em 0.8em 0.3em 0.8em;
|
||||
border-radius: 0.2em;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.form-submit:hover,
|
||||
.form-submit:focus {
|
||||
background-color: hsl(200, 50%, 70%);
|
||||
}
|
||||
|
||||
.form-submit:active {
|
||||
background-color: hsl(200, 80%, 70%);
|
||||
}
|
||||
|
||||
.username-label,
|
||||
.username-input,
|
||||
.form-submit {
|
||||
display: block;
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
|
||||
.notice {
|
||||
margin: 0;
|
||||
margin-top: 2rem;
|
||||
font-size: 0.8em;
|
||||
opacity: 0.6;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<form action="/login/oauth/authorize?{{.Query}}" method="post">
|
||||
<header class="form-header">
|
||||
<h1><a href="https://github.com/go-pkgz/auth">GO-PKGZ/AUTH</a></h1>
|
||||
<p>Dev Provider</p>
|
||||
</header>
|
||||
<label>
|
||||
<span class="username-label">Username</span>
|
||||
<input
|
||||
class="username-input"
|
||||
type="text"
|
||||
name="username"
|
||||
value="dev_user"
|
||||
autofocus
|
||||
/>
|
||||
</label>
|
||||
<input type="submit" class="form-submit" value="Authorize" />
|
||||
<p class="notice">Not for production use</p>
|
||||
</form>
|
||||
</body>
|
||||
<script>
|
||||
var input = document.querySelector(".username-input");
|
||||
input.focus();
|
||||
input.setSelectionRange(0, input.value.length)
|
||||
</script>
|
||||
</html>
|
||||
`
|
||||
+81
@@ -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)
|
||||
}
|
||||
+213
@@ -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)
|
||||
}
|
||||
Generated
Vendored
+43
-42
@@ -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 == "" {
|
||||
+91
@@ -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
|
||||
}
|
||||
+306
@@ -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)
|
||||
}
|
||||
+127
@@ -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)
|
||||
}
|
||||
+2
-1
@@ -1,4 +1,5 @@
|
||||
## REST helpers and middleware [](https://travis-ci.org/go-pkgz/rest) [](https://goreportcard.com/report/github.com/go-pkgz/rest) [](https://coveralls.io/github/go-pkgz/rest?branch=master)
|
||||
## REST helpers and middleware [](https://travis-ci.org/go-pkgz/rest) [](https://goreportcard.com/report/github.com/go-pkgz/rest) [](https://coveralls.io/github/go-pkgz/rest?branch=master) [](https://godoc.org/github.com/go-pkgz/rest)
|
||||
|
||||
|
||||
## Install and update
|
||||
|
||||
|
||||
+10
-6
@@ -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)
|
||||
}
|
||||
|
||||
+7
@@ -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{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user