Merge pull request #239 from umputun/auth-lib

Auth lib
This commit is contained in:
Umputun
2019-01-02 12:05:14 -06:00
committed by GitHub
78 changed files with 2903 additions and 3198 deletions
+6 -6
View File
@@ -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.
+23 -10
View File
@@ -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"
+6 -6
View File
@@ -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)
}
+1 -1
View File
@@ -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) {
+8 -6
View File
@@ -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))
+5 -4
View File
@@ -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`)
+10 -8
View File
@@ -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 {
+3 -3
View File
@@ -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)
+8 -7
View File
@@ -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 {
+6 -6
View File
@@ -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
View File
@@ -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)
}
+1 -1
View File
@@ -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
View File
@@ -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
}
+82 -8
View File
@@ -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)
+12 -12
View File
@@ -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
+92 -92
View File
@@ -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")
}
+1
View File
@@ -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"
+39 -27
View File
@@ -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
}
+47 -26
View File
@@ -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"}
+14 -8
View File
@@ -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
+53 -56
View File
@@ -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"])
+9 -8
View File
@@ -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)
}
}
+33 -49
View File
@@ -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)
+63 -46
View File
@@ -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)
}
+2 -1
View File
@@ -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"
)
+8 -12
View File
@@ -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()
+2 -1
View File
@@ -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
-200
View File
@@ -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
}
-247
View File
@@ -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
}
-196
View File
@@ -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)
}
-200
View File
@@ -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)
}
-258
View File
@@ -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))
}
-229
View File
@@ -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))
}
-238
View File
@@ -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 }
-83
View File
@@ -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)
}
-118
View File
@@ -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")
}
-172
View File
@@ -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)
}
+3 -1
View File
@@ -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
View File
@@ -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)
}
+2 -1
View File
@@ -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
-99
View File
@@ -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
}
-97
View File
@@ -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
}
-178
View File
@@ -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")
}
}
-110
View File
@@ -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)
}
-109
View File
@@ -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))
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

+2 -1
View File
@@ -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"
)
+1 -1
View File
@@ -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
+1 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -0,0 +1,258 @@
# auth - authentication via oauth2 [![Build Status](https://travis-ci.org/go-pkgz/auth.svg?branch=master)](https://travis-ci.org/go-pkgz/auth) [![Coverage Status](https://coveralls.io/repos/github/go-pkgz/auth/badge.svg?branch=master)](https://coveralls.io/github/go-pkgz/auth?branch=master) [![godoc](https://godoc.org/github.com/go-pkgz/auth?status.svg)](https://godoc.org/github.com/go-pkgz/auth)
This library provides "social login" with Github, Google, Facebook and Yandex as well as custom auth providers.
- Multiple oauth2 providers can be used at the same time
- Special `dev` provider allows local testing and development
- JWT stored in a secure cookie with XSRF protection. Cookies can be session-only
- Minimal scopes with user name, id and picture (avatar) only
- Direct authentication with user's provided credential checker
- Integrated avatar proxy with FS, boltdb and gridfs storages
- Support of user-defined storages for avatars
- Black list with user-defined validator
- Multiple aud (audience) supported
- Secure key with customizable `SecretReader`
- Ability to store an extra information to token and retrieve on login
- Pre-auth and post-auth hooks to handle custom use cases.
- Middleware for easy integration into http routers
- Wrappers to extract user info from the request
## Install
`go install github.com/go-pkgz/auth`
## Usage
Example with chi router:
```go
func main() {
/// define options
options := auth.Opts{
SecretReader: token.SecretFunc(func(id string) (string, error) { // secret key for JWT
return "secret", nil
}),
TokenDuration: time.Minute * 5, // token expires in 5 minutes
CookieDuration: time.Hour * 24, // cookie expires in 1 day and will enforce re-login
Issuer: "my-test-app",
URL: "http://127.0.0.1:8080",
AvatarStore: avatar.NewLocalFS("/tmp"),
Validator: token.ValidatorFunc(func(_ string, claims token.Claims) bool {
// allow only dev_* names
return claims.User != nil && strings.HasPrefix(claims.User.Name, "dev_")
}),
}
// create auth service with providers
service := auth.NewService(options)
service.AddProvider("github", "<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 subroutes.
### 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
View File
@@ -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
View File
@@ -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")
}
@@ -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))
}
@@ -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
}
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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)
}
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -1,4 +1,5 @@
## REST helpers and middleware [![Build Status](https://travis-ci.org/go-pkgz/rest.svg?branch=master)](https://travis-ci.org/go-pkgz/rest) [![Go Report Card](https://goreportcard.com/badge/github.com/go-pkgz/rest)](https://goreportcard.com/report/github.com/go-pkgz/rest) [![Coverage Status](https://coveralls.io/repos/github/go-pkgz/rest/badge.svg?branch=master)](https://coveralls.io/github/go-pkgz/rest?branch=master)
## REST helpers and middleware [![Build Status](https://travis-ci.org/go-pkgz/rest.svg?branch=master)](https://travis-ci.org/go-pkgz/rest) [![Go Report Card](https://goreportcard.com/badge/github.com/go-pkgz/rest)](https://goreportcard.com/report/github.com/go-pkgz/rest) [![Coverage Status](https://coveralls.io/repos/github/go-pkgz/rest/badge.svg?branch=master)](https://coveralls.io/github/go-pkgz/rest?branch=master) [![godoc](https://godoc.org/github.com/go-pkgz/rest?status.svg)](https://godoc.org/github.com/go-pkgz/rest)
## Install and update
+10 -6
View File
@@ -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
View File
@@ -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{
+1 -1
View File
@@ -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
+2 -2
View File
@@ -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
View File
@@ -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