diff --git a/README.md b/README.md index 0de5c99f..50bb93bf 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,8 @@ Remark42 is a self-hosted, lightweight, and simple (yet functional) comment engi | avatar.path | AVATAR_FS_PATH | `./var/avatars` | avatars location | | avatar.rsz-lmt | AVATAR_RSZ_LMT | 0 | max image size for resizing avatars on save | | max-comment | MAX_COMMENT_SIZE | 2048 | comment's size limit | +| auth.ttl.jwt | AUTH_TTL_JWT | 5m | jwt TTL | +| auth.ttl.cookie | AUTH_TTL_COOKIE | 200h | cookie TTL | | auth.google.cid | AUTH_GOOGLE_CID | | Google OAuth client ID | | auth.google.csec | AUTH_GOOGLE_CSEC | | Google OAuth client secret | | auth.facebook.cid | AUTH_FACEBOOK_CID | | Facebook OAuth client ID | @@ -56,9 +58,11 @@ Remark42 is a self-hosted, lightweight, and simple (yet functional) comment engi | auth.github.csec | AUTH_GITHUB_CSEC | | Github OAuth client secret | | auth.yandex.cid | AUTH_YANDEX_CID | | Yandex OAuth client ID | | auth.yandex.csec | AUTH_YANDEX_CSEC | | Yandex OAuth client secret | -| low-score | LOW_SCORE | `-5` | Low score threshold | -| critical-score | CRITICAL_SCORE | `-10` | Critical score threshold | -| img-proxy | IMG_PROXY | `false` | Enable http->https proxy for images | +| auth.dev | AUTH_DEV | false | local oauth2 server, development mode only | +| low-score | LOW_SCORE | `-5` | low score threshold | +| critical-score | CRITICAL_SCORE | `-10` | critical score threshold | +| edit-time | EDIT_TIME | `5m` | edit window | +| img-proxy | IMG_PROXY | `false` | enable http->https proxy for images | | dbg | DEBUG | `false` | debug mode | | dev-passwd | DEV_PASSWD | | password for `dev` user | diff --git a/backend/app/main.go b/backend/app/main.go index 4a6719b3..5b21fb07 100644 --- a/backend/app/main.go +++ b/backend/app/main.go @@ -36,26 +36,32 @@ type Opts struct { Avatar AvatarGroup `group:"avatar" namespace:"avatar" env-namespace:"AVATAR"` Cache CacheGroup `group:"cache" namespace:"cache" env-namespace:"CACHE"` - Sites []string `long:"site" env:"SITE" default:"remark" description:"site names" env-delim:","` - Admins []string `long:"admin" env:"ADMIN" description:"admin(s) names" env-delim:","` - AdminEmail string `long:"admin-email" env:"ADMIN_EMAIL" default:"" description:"admin email"` - DevPasswd string `long:"dev-passwd" env:"DEV_PASSWD" default:"" description:"development mode 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"` - MaxCommentSize int `long:"max-comment" env:"MAX_COMMENT_SIZE" default:"2048" description:"max comment size"` - LowScore int `long:"low-score" env:"LOW_SCORE" default:"-5" description:"low score threshold"` - CriticalScore int `long:"critical-score" env:"CRITICAL_SCORE" default:"-10" description:"critical score threshold"` - ReadOnlyAge int `long:"read-age" env:"READONLY_AGE" default:"0" description:"read-only age of comments"` - Port int `long:"port" env:"REMARK_PORT" default:"8080" description:"port"` - WebRoot string `long:"web-root" env:"REMARK_WEB_ROOT" default:"./web" description:"web root directory"` - Dbg bool `long:"dbg" env:"DEBUG" description:"debug mode"` + Sites []string `long:"site" env:"SITE" default:"remark" description:"site names" env-delim:","` + Admins []string `long:"admin" env:"ADMIN" description:"admin(s) names" env-delim:","` + AdminEmail string `long:"admin-email" env:"ADMIN_EMAIL" default:"" description:"admin email"` + DevPasswd string `long:"dev-passwd" env:"DEV_PASSWD" default:"" description:"development mode 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"` + MaxCommentSize int `long:"max-comment" env:"MAX_COMMENT_SIZE" default:"2048" description:"max comment size"` + LowScore int `long:"low-score" env:"LOW_SCORE" default:"-5" description:"low score threshold"` + CriticalScore int `long:"critical-score" env:"CRITICAL_SCORE" default:"-10" description:"critical score threshold"` + ReadOnlyAge int `long:"read-age" env:"READONLY_AGE" default:"0" description:"read-only age of comments"` + EditDuration time.Duration `long:"edit-time" env:"EDIT_TIME" default:"5m" description:"edit window"` + Port int `long:"port" env:"REMARK_PORT" default:"8080" description:"port"` + WebRoot string `long:"web-root" env:"REMARK_WEB_ROOT" default:"./web" description:"web root directory"` + Dbg bool `long:"dbg" env:"DEBUG" description:"debug mode"` Auth struct { + TTL struct { + JWT time.Duration `long:"jwt" env:"JWT" default:"5m" description:"jwt TTL"` + Cookie time.Duration `long:"cookie" env:"COOKIE" default:"200h" description:"auth cookie TTL"` + } `group:"ttl" namespace:"ttl" env-namespace:"TTL"` Google AuthGroup `group:"google" namespace:"google" env-namespace:"GOOGLE" description:"Google OAuth"` Github AuthGroup `group:"github" namespace:"github" env-namespace:"GITHUB" description:"Github OAuth"` Facebook AuthGroup `group:"facebook" namespace:"facebook" env-namespace:"FACEBOOK" description:"Facebook OAuth"` Yandex AuthGroup `group:"yandex" namespace:"yandex" env-namespace:"YANDEX" description:"Yandex OAuth"` + Dev bool `long:"dev" env:"DEV" description:"enable dev (local) oauth2"` } `group:"auth" namespace:"auth" env-namespace:"AUTH"` } @@ -101,6 +107,7 @@ type Application struct { restSrv *api.Rest migratorSrv *api.Migrator exporter migrator.Exporter + devAuth *auth.DevAuthServer terminated chan struct{} } @@ -150,11 +157,13 @@ func New(opts Opts) (*Application, error) { if err != nil { return nil, err } + dataService := &service.DataStore{ Interface: boltStore, - EditDuration: 5 * time.Minute, + EditDuration: opts.EditDuration, Secret: opts.SecretKey, MaxCommentSize: opts.MaxCommentSize, + Admins: opts.Admins, } loadingCache, err := cache.NewMemoryCache(cache.MaxCacheSize(opts.Cache.Max.Size), cache.MaxValSize(opts.Cache.Max.Value), @@ -163,7 +172,9 @@ func New(opts Opts) (*Application, error) { return nil, err } - jwtService := auth.NewJWT(opts.SecretKey, strings.HasPrefix(opts.RemarkURL, "https://"), 7*24*time.Hour) + // token TTL is 5 minutes, inactivity interval 7+ days by default + jwtService := auth.NewJWT(opts.SecretKey, strings.HasPrefix(opts.RemarkURL, "https://"), + opts.Auth.TTL.JWT, opts.Auth.TTL.Cookie) avatarStore, err := makeAvatarStore(opts.Avatar) if err != nil { @@ -186,6 +197,8 @@ func New(opts Opts) (*Application, error) { SecretKey: opts.SecretKey, } + authProviders := makeAuthProviders(jwtService, avatarProxy, dataService, opts) + srv := &api.Rest{ Version: revision, DataService: dataService, @@ -196,11 +209,11 @@ func New(opts Opts) (*Application, error) { AvatarProxy: avatarProxy, ReadOnlyAge: opts.ReadOnlyAge, Authenticator: auth.Authenticator{ - JWTService: jwtService, - Admins: opts.Admins, - AdminEmail: opts.AdminEmail, - Providers: makeAuthProviders(jwtService, avatarProxy, dataService, opts), - DevPasswd: opts.DevPasswd, + JWTService: jwtService, + AdminEmail: opts.AdminEmail, + Providers: authProviders, + DevPasswd: opts.DevPasswd, + PermissionChecker: dataService, }, Cache: loadingCache, } @@ -213,8 +226,14 @@ func New(opts Opts) (*Application, error) { } srv.ScoreThresholds.Low, srv.ScoreThresholds.Critical = opts.LowScore, opts.CriticalScore + + var devAuth *auth.DevAuthServer + if opts.Auth.Dev { + devAuth = &auth.DevAuthServer{Provider: authProviders[len(authProviders)-1]} + } + tch := make(chan struct{}) - return &Application{restSrv: srv, migratorSrv: migr, exporter: exporter, Opts: opts, terminated: tch}, nil + return &Application{restSrv: srv, migratorSrv: migr, exporter: exporter, devAuth: devAuth, Opts: opts, terminated: tch}, nil } // Run all application objects @@ -228,9 +247,15 @@ func (a *Application) Run(ctx context.Context) error { <-ctx.Done() a.restSrv.Shutdown() a.migratorSrv.Shutdown() + if a.devAuth != nil { + a.devAuth.Shutdown() + } }() a.activateBackup(ctx) // runs in goroutine for each site go a.migratorSrv.Run(a.Port + 1) + if a.Auth.Dev { + go a.devAuth.Run() + } a.restSrv.Run(a.Port) close(a.terminated) return nil @@ -318,14 +343,13 @@ func makeAuthProviders(jwtService *auth.JWT, avatarProxy *proxy.Avatar, ds *serv makeParams := func(cid, secret string) auth.Params { return auth.Params{ - JwtService: jwtService, - AvatarProxy: avatarProxy, - RemarkURL: opts.RemarkURL, - Cid: cid, - Csecret: secret, - Admins: opts.Admins, - SecretKey: opts.SecretKey, - IsVerifiedFn: ds.IsVerifiedFn(), + JwtService: jwtService, + AvatarProxy: avatarProxy, + RemarkURL: opts.RemarkURL, + Cid: cid, + Csecret: secret, + SecretKey: opts.SecretKey, + PermissionChecker: ds, } } @@ -342,6 +366,10 @@ func makeAuthProviders(jwtService *auth.JWT, avatarProxy *proxy.Avatar, ds *serv if opts.Auth.Yandex.CID != "" && opts.Auth.Yandex.CSEC != "" { providers = append(providers, auth.NewYandex(makeParams(opts.Auth.Yandex.CID, opts.Auth.Yandex.CSEC))) } + if opts.Auth.Dev { + providers = append(providers, auth.NewDev(makeParams("", ""))) + } + if len(providers) == 0 { log.Printf("[WARN] no auth providers defined") } diff --git a/backend/app/main_test.go b/backend/app/main_test.go index 2d9820f8..fb414217 100644 --- a/backend/app/main_test.go +++ b/backend/app/main_test.go @@ -18,7 +18,11 @@ import ( ) func TestApplication(t *testing.T) { - app, ctx := prepApp(t, 18080, 500*time.Millisecond) + app, ctx := prepApp(t, 500*time.Millisecond, func(o Opts) Opts { + o.Port = 18080 + return o + }) + go func() { _ = app.Run(ctx) }() time.Sleep(100 * time.Millisecond) // let server start @@ -44,12 +48,37 @@ func TestApplication(t *testing.T) { app.Wait() } +func TestApplicationDevMode(t *testing.T) { + app, ctx := prepApp(t, 500*time.Millisecond, func(o Opts) Opts { + o.Port = 18085 + o.DevPasswd = "password" + o.Auth.Dev = true + return o + }) + + 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") + // send ping + resp, err := http.Get("http://localhost:18085/api/v1/ping") + require.Nil(t, err) + defer resp.Body.Close() + assert.Equal(t, 200, resp.StatusCode) + body, err := ioutil.ReadAll(resp.Body) + assert.Nil(t, err) + assert.Equal(t, "pong", string(body)) + + app.Wait() +} func TestApplicationFailed(t *testing.T) { opts := Opts{} p := flags.NewParser(&opts, flags.Default) // RO bolt location - _, err := p.ParseArgs([]string{"--secret=123456", "--url=https://demo.remark42.com", "--store.bolt.path=/dev/null"}) + _, err := p.ParseArgs([]string{"--secret=123456", "--url=https://demo.remark42.com", "--backup=/tmp", + "--store.bolt.path=/dev/null"}) assert.Nil(t, err) _, err = New(opts) assert.EqualError(t, err, "can't initialize data store: failed to make boltdb for /dev/null/remark.db: "+ @@ -67,14 +96,14 @@ func TestApplicationFailed(t *testing.T) { // invalid url opts = Opts{} - _, err = p.ParseArgs([]string{"--secret=123456", "--url=demo.remark42.com", "----store.bolt.path=/tmp"}) + _, err = p.ParseArgs([]string{"--secret=123456", "--url=demo.remark42.com", "--backup=/tmp", "----store.bolt.path=/tmp"}) assert.Nil(t, err) _, err = New(opts) assert.EqualError(t, err, "invalid remark42 url demo.remark42.com") t.Log(err) opts = Opts{} - _, err = p.ParseArgs([]string{"--secret=123456", "--url=https://demo.remark42.com", "--store.type=mongo"}) + _, err = p.ParseArgs([]string{"--secret=123456", "--url=https://demo.remark42.com", "--backup=/tmp", "--store.type=mongo"}) assert.Nil(t, err) _, err = New(opts) assert.EqualError(t, err, "unsupported store type mongo") @@ -82,7 +111,10 @@ func TestApplicationFailed(t *testing.T) { } func TestApplicationShutdown(t *testing.T) { - app, ctx := prepApp(t, 18090, 500*time.Millisecond) + app, ctx := prepApp(t, 500*time.Millisecond, func(o Opts) Opts { + o.Port = 18090 + return o + }) st := time.Now() err := app.Run(ctx) assert.Nil(t, err) @@ -104,21 +136,21 @@ func TestApplicationMainSignal(t *testing.T) { assert.True(t, time.Since(st).Seconds() < 1, "should take about 500msec") } -func prepApp(t *testing.T, port int, duration time.Duration) (*Application, context.Context) { - // prepare options +func prepApp(t *testing.T, duration time.Duration, fn func(o Opts) Opts) (*Application, context.Context) { opts := Opts{} + // prepare options p := flags.NewParser(&opts, flags.Default) _, err := p.ParseArgs([]string{"--secret=123456", "--dev-passwd=password", "--url=https://demo.remark42.com"}) require.Nil(t, err) opts.Avatar.FS.Path, opts.Avatar.Type, opts.BackupLocation = "/tmp", "fs", "/tmp" - opts.Store.Bolt.Path = fmt.Sprintf("/tmp/%d", port) + opts.Store.Bolt.Path = fmt.Sprintf("/tmp/%d", opts.Port) opts.Store.Bolt.Timeout = 10 * time.Second opts.Auth.Github.CSEC, opts.Auth.Github.CID = "csec", "cid" opts.Auth.Google.CSEC, opts.Auth.Google.CID = "csec", "cid" opts.Auth.Facebook.CSEC, opts.Auth.Facebook.CID = "csec", "cid" opts.Auth.Yandex.CSEC, opts.Auth.Yandex.CID = "csec", "cid" - opts.Port = port opts.BackupLocation = "/tmp" + opts = fn(opts) os.Remove(opts.Store.Bolt.Path + "/remark.db") diff --git a/backend/app/rest/api/admin.go b/backend/app/rest/api/admin.go index 5bbcbc5c..56deb942 100644 --- a/backend/app/rest/api/admin.go +++ b/backend/app/rest/api/admin.go @@ -239,7 +239,7 @@ func (a *admin) alterComments(comments []store.Comment, r *http.Request) (res [] res = make([]store.Comment, len(comments)) user, err := rest.GetUserInfo(r) - isAdmin := err == nil && user.Admin // make separate cache key for admins + isAdmin := err == nil && user.Admin for i, c := range comments { diff --git a/backend/app/rest/api/rest.go b/backend/app/rest/api/rest.go index d5756520..37b17b71 100644 --- a/backend/app/rest/api/rest.go +++ b/backend/app/rest/api/rest.go @@ -66,8 +66,8 @@ type commentsWithInfo struct { func (s *Rest) Run(port int) { log.Printf("[INFO] activate rest server on port %d", port) - if len(s.Authenticator.Admins) > 0 { - log.Printf("[DEBUG] admins %+v", s.Authenticator.Admins) + if s.DataService != nil && len(s.DataService.Admins) > 0 { + log.Printf("[DEBUG] admins %+v", s.DataService.Admins) } router := s.routes() diff --git a/backend/app/rest/api/rest_private.go b/backend/app/rest/api/rest_private.go index b8bf0f35..e6445583 100644 --- a/backend/app/rest/api/rest_private.go +++ b/backend/app/rest/api/rest_private.go @@ -10,11 +10,11 @@ import ( "strings" "time" - jwt "github.com/dgrijalva/jwt-go" + "github.com/dgrijalva/jwt-go" "github.com/go-chi/chi" "github.com/go-chi/render" - multierror "github.com/hashicorp/go-multierror" - blackfriday "gopkg.in/russross/blackfriday.v2" + "github.com/hashicorp/go-multierror" + "gopkg.in/russross/blackfriday.v2" "github.com/umputun/remark/backend/app/rest" "github.com/umputun/remark/backend/app/rest/auth" diff --git a/backend/app/rest/api/rest_public.go b/backend/app/rest/api/rest_public.go index 0af6b476..b4fd0eda 100644 --- a/backend/app/rest/api/rest_public.go +++ b/backend/app/rest/api/rest_public.go @@ -10,7 +10,7 @@ import ( "github.com/go-chi/chi" "github.com/go-chi/render" - blackfriday "gopkg.in/russross/blackfriday.v2" + "gopkg.in/russross/blackfriday.v2" "github.com/umputun/remark/backend/app/rest" "github.com/umputun/remark/backend/app/rest/cache" @@ -219,7 +219,7 @@ func (s *Rest) configCtrl(w http.ResponseWriter, r *http.Request) { Version: s.Version, EditDuration: int(s.DataService.EditDuration.Seconds()), MaxCommentSize: s.DataService.MaxCommentSize, - Admins: s.Authenticator.Admins, + Admins: s.DataService.Admins, AdminEmail: s.Authenticator.AdminEmail, LowScore: s.ScoreThresholds.Low, CriticalScore: s.ScoreThresholds.Critical, diff --git a/backend/app/rest/api/rest_test.go b/backend/app/rest/api/rest_test.go index 6c736059..324ab9dd 100644 --- a/backend/app/rest/api/rest_test.go +++ b/backend/app/rest/api/rest_test.go @@ -53,15 +53,21 @@ func TestRest_Shutdown(t *testing.T) { func prep(t *testing.T) (srv *Rest, ts *httptest.Server) { b, err := engine.NewBoltDB(bolt.Options{}, engine.BoltSite{FileName: testDb, SiteID: "radio-t"}) require.Nil(t, err) - dataStore := &service.DataStore{Interface: b, EditDuration: 5 * time.Minute, MaxCommentSize: 4000, Secret: "123456"} + dataStore := &service.DataStore{ + Interface: b, + EditDuration: 5 * time.Minute, + MaxCommentSize: 4000, + Secret: "123456", + Admins: []string{"a1", "a2"}, + } srv = &Rest{ DataService: dataStore, Authenticator: auth.Authenticator{ - DevPasswd: "password", - Providers: nil, - Admins: []string{"a1", "a2"}, + DevPasswd: "password", + Providers: nil, + AdminEmail: "admin@remark-42.com", - JWTService: auth.NewJWT("12345", false, time.Minute), + JWTService: auth.NewJWT("12345", false, time.Minute, time.Hour), }, Exporter: &migrator.Remark{DataStore: dataStore}, Cache: &mockCache{}, diff --git a/backend/app/rest/auth/auth.go b/backend/app/rest/auth/auth.go index 9d52bc9a..a8eb2ae8 100644 --- a/backend/app/rest/auth/auth.go +++ b/backend/app/rest/auth/auth.go @@ -13,11 +13,11 @@ import ( // Authenticator is top level auth object providing middlewares type Authenticator struct { - JWTService *JWT - Providers []Provider - Admins []string - AdminEmail string - DevPasswd string + JWTService *JWT + Providers []Provider + AdminEmail string + DevPasswd string + PermissionChecker PermissionChecker } var devUser = store.User{ @@ -27,6 +27,13 @@ var devUser = store.User{ Admin: true, } +// PermissionChecker defines interface to get user flags +type PermissionChecker interface { + IsVerified(siteID, userID string) bool + IsBlocked(siteID, userID string) bool + IsAdmin(userID string) bool +} + // Auth middleware adds auth from session and populates user info func (a *Authenticator) Auth(reqAuth bool) func(http.Handler) http.Handler { @@ -59,14 +66,23 @@ func (a *Authenticator) Auth(reqAuth bool) func(http.Handler) http.Handler { } if claims.User != nil { // if uinfo in token populate it to context - user := *claims.User - user.Admin = isAdmin(user.ID, a.Admins) // dbl-check for admin to reset admin flag even if token has it - // refresh token if it close to expiration - if _, err := a.JWTService.Refresh(w, r); err != nil { - log.Printf("[DEBUG] can't refresh jwt, %s", err) + 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 } - r = rest.SetUserInfo(r, user) + + 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) @@ -74,6 +90,19 @@ func (a *Authenticator) Auth(reqAuth bool) func(http.Handler) http.Handler { return f } +func (a *Authenticator) refreshExpiredToken(w http.ResponseWriter, claims *CustomClaims) (*CustomClaims, error) { + if a.PermissionChecker != nil { + claims.User.Admin = a.PermissionChecker.IsAdmin(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 allows access to admins func (a *Authenticator) AdminOnly(next http.Handler) http.Handler { fn := func(w http.ResponseWriter, r *http.Request) { @@ -121,12 +150,3 @@ func (a *Authenticator) basicDevUser(w http.ResponseWriter, r *http.Request) boo return true } - -func isAdmin(userID string, admins []string) bool { - for _, admin := range admins { - if admin == userID { - return true - } - } - return false -} diff --git a/backend/app/rest/auth/auth_test.go b/backend/app/rest/auth/auth_test.go index ddfc2f92..1799e174 100644 --- a/backend/app/rest/auth/auth_test.go +++ b/backend/app/rest/auth/auth_test.go @@ -13,8 +13,11 @@ import ( "github.com/stretchr/testify/require" ) +var testJwtUserBlocked = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjI3ODkxOTE4MjIsImp0aSI6InJhbmRvbSBpZCIsImlzcyI6InJlbWFyazQyIiwibmJmIjoxNTI2ODg0MjIyLCJ1c2VyIjp7Im5hbWUiOiJuYW1lMSIsImlkIjoiaWQxIiwicGljdHVyZSI6IiIsImFkbWluIjpmYWxzZSwiYmxvY2siOnRydWV9LCJzdGF0ZSI6IjEyMzQ1NiIsImZyb20iOiJmcm9tIn0.6P_OwGf8CUJRtvNSlW20GmaMb5pFvCNemP94fHCqb5Q" + func TestAuthJWTCookie(t *testing.T) { - a := Authenticator{DevPasswd: "123456", JWTService: NewJWT("xyz 12345", false, time.Hour)} + a := Authenticator{DevPasswd: "123456", JWTService: NewJWT("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) @@ -47,11 +50,11 @@ func TestAuthJWTCookie(t *testing.T) { req.Header.Add("X-XSRF-TOKEN", "random id") resp, err = client.Do(req) require.NoError(t, err) - assert.Equal(t, 401, resp.StatusCode, "token expired") + assert.Equal(t, 201, resp.StatusCode, "token expired and refreshed") } func TestAuthJWTHeader(t *testing.T) { - a := Authenticator{DevPasswd: "123456", JWTService: NewJWT("xyz 12345", false, time.Hour)} + a := Authenticator{DevPasswd: "123456", JWTService: NewJWT("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) @@ -74,8 +77,29 @@ func TestAuthJWTHeader(t *testing.T) { req.Header.Add("X-JWT", testJwtExpired) resp, err = client.Do(req) require.NoError(t, err) - assert.Equal(t, 401, resp.StatusCode, "invalid auth token") + assert.Equal(t, 201, resp.StatusCode, "token expired and refreshed") } + +func TestAuthJWtBlocked(t *testing.T) { + a := Authenticator{DevPasswd: "123456", JWTService: NewJWT("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 TestAuthRequired(t *testing.T) { a := Authenticator{DevPasswd: "123456"} router := chi.NewRouter() @@ -164,6 +188,7 @@ func TestAdminRequired(t *testing.T) { assert.Equal(t, 403, resp.StatusCode, "valid auth user, not admin") } + func withBasicAuth(r *http.Request, username, password string) *http.Request { auth := username + ":" + password r.Header.Add("Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte(auth))) diff --git a/backend/app/rest/auth/dev_provider.go b/backend/app/rest/auth/dev_provider.go new file mode 100644 index 00000000..3dd4fea5 --- /dev/null +++ b/backend/app/rest/auth/dev_provider.go @@ -0,0 +1,116 @@ +package auth + +import ( + "context" + "fmt" + "log" + "net/http" + "strings" + "sync" + "time" + + "golang.org/x/oauth2" + + "github.com/umputun/remark/backend/app/store" +) + +const devAuthPort = 8084 + +// DevAuthServer is a fake oauth server for development +type DevAuthServer struct { + Provider Provider + + 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() + 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"): + 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"): + res := `{ + "id": "ignored", + "name":"ignored" + }` + w.Header().Set("Content-Type", "application/json; charset=utf-8") + if _, err := w.Write([]byte(res)); 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: "http://127.0.0.1:8080/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: "dev_user", + Name: "developer", + Picture: "", + } + return userInfo + }, + }) +} diff --git a/backend/app/rest/auth/dev_provider_test.go b/backend/app/rest/auth/dev_provider_test.go new file mode 100644 index 00000000..39bc36ad --- /dev/null +++ b/backend/app/rest/auth/dev_provider_test.go @@ -0,0 +1,68 @@ +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" +) + +func TestDevProvider(t *testing.T) { + params := Params{RemarkURL: "http://127.0.0.1:8080", SecretKey: "123456", Cid: "cid", Csecret: "csecret", + JwtService: NewJWT("12345", false, time.Hour, time.Hour*24*31), + PermissionChecker: &mockUserPermissions{admin: "dev_user"}, + } + srv := DevAuthServer{Provider: NewDev(params)} + + // 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: "developer", ID: "dev_user", Picture: "", IP: "", + Admin: true, Blocked: false, Verified: false}, u) + +} diff --git a/backend/app/rest/auth/jwt.go b/backend/app/rest/auth/jwt.go index 7d31203d..cf213a79 100644 --- a/backend/app/rest/auth/jwt.go +++ b/backend/app/rest/auth/jwt.go @@ -13,9 +13,10 @@ import ( // JWT wraps jwt operations // supports both header and cookie jwt type JWT struct { - secret string - secureCookies bool - exp time.Duration + secret string + secureCookies bool + tokenDuration time.Duration + cookieDuration time.Duration } // CustomClaims stores user info for auth and state & from from login @@ -36,11 +37,12 @@ const xsrfCookieName = "XSRF-TOKEN" const xsrfHeaderKey = "X-XSRF-TOKEN" // NewJWT makes JWT service -func NewJWT(secret string, secureCookies bool, exp time.Duration) *JWT { +func NewJWT(secret string, secureCookies bool, tokenDuration time.Duration, cookieDuration time.Duration) *JWT { res := JWT{ - secret: secret, - secureCookies: secureCookies, - exp: exp, + secret: secret, + secureCookies: secureCookies, + tokenDuration: tokenDuration, + cookieDuration: cookieDuration, } return &res } @@ -55,9 +57,10 @@ func (j *JWT) Token(claims *CustomClaims) (string, error) { return tokenString, nil } -// Parse token string and verify +// Parse token string and verify. Not checking for expiration func (j *JWT) Parse(tokenString string) (*CustomClaims, error) { - token, err := jwt.ParseWithClaims(tokenString, &CustomClaims{}, func(token *jwt.Token) (interface{}, error) { + parser := jwt.Parser{SkipClaimsValidation: true} // allow parsing of expired tokens + 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"]) } @@ -71,6 +74,7 @@ func (j *JWT) Parse(tokenString string) (*CustomClaims, error) { if !ok || !token.Valid { return nil, errors.New("invalid jwt") } + return claims, nil } @@ -78,7 +82,7 @@ func (j *JWT) Parse(tokenString string) (*CustomClaims, error) { // 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.exp).Unix() + claims.ExpiresAt = time.Now().Add(j.tokenDuration).Unix() } tokenString, err := j.Token(claims) @@ -88,7 +92,7 @@ func (j *JWT) Set(w http.ResponseWriter, claims *CustomClaims, sessionOnly bool) cookieExpiration := 0 // session cookie if !sessionOnly { - cookieExpiration = 365 * 24 * 3600 // 1 year + cookieExpiration = int(j.cookieDuration.Seconds()) } jwtCookie := http.Cookie{Name: jwtCookieName, Value: tokenString, HttpOnly: true, Path: "/", @@ -139,19 +143,9 @@ func (j *JWT) Get(r *http.Request) (*CustomClaims, error) { return claims, nil } -// Refresh gets jwt from request, checks if it will be expiring soon (1/2 of expiration) and create the new onw -func (j *JWT) Refresh(w http.ResponseWriter, r *http.Request) (*CustomClaims, error) { - claims, err := j.Get(r) - if err != nil { - return nil, err - } - untilExp := claims.ExpiresAt - time.Now().Unix() - if untilExp <= int64(j.exp.Seconds()/2) { - claims.ExpiresAt = time.Now().Add(j.exp).Unix() - e := j.Set(w, claims, claims.SessionOnly) - return claims, e - } - 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 diff --git a/backend/app/rest/auth/jwt_test.go b/backend/app/rest/auth/jwt_test.go index d77db413..bb5eb084 100644 --- a/backend/app/rest/auth/jwt_test.go +++ b/backend/app/rest/auth/jwt_test.go @@ -26,8 +26,14 @@ var testJwtExpired = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE1MjY4ODc4M "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("xyz 12345", false, time.Hour) + j := NewJWT("xyz 12345", false, time.Hour, days31) claims := &CustomClaims{ State: "123456", @@ -50,20 +56,25 @@ func TestJWT_Token(t *testing.T) { } func TestJWT_Parse(t *testing.T) { - j := NewJWT("xyz 12345", false, time.Hour) + j := NewJWT("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) - _, err = j.Parse(testJwtExpired) - assert.NotNil(t, err, "expired token") + 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("xyz 12345", false, time.Hour) + j := NewJWT("xyz 12345", false, time.Hour, days31) claims := &CustomClaims{ State: "123456", @@ -89,7 +100,7 @@ func TestJWT_Set(t *testing.T) { require.Equal(t, 2, len(cookies)) assert.Equal(t, "JWT", cookies[0].Name) assert.Equal(t, testJwtValid, cookies[0].Value) - assert.Equal(t, 31536000, cookies[0].MaxAge) + 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) @@ -108,20 +119,21 @@ func TestJWT_Set(t *testing.T) { } func TestJWT_GetFromHeader(t *testing.T) { - j := NewJWT("xyz 12345", false, time.Hour) + j := NewJWT("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) - _, err = j.Get(req) - assert.NotNil(t, err) - assert.True(t, strings.Contains(err.Error(), "can't parse jwt: token is expired by"), err.Error()) + 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") @@ -132,7 +144,7 @@ func TestJWT_GetFromHeader(t *testing.T) { } func TestJWT_SetAndGetWithCookies(t *testing.T) { - j := NewJWT("xyz 12345", false, time.Hour) + j := NewJWT("xyz 12345", false, time.Hour, days31) claims := &CustomClaims{ State: "123456", @@ -174,7 +186,7 @@ func TestJWT_SetAndGetWithCookies(t *testing.T) { } func TestJWT_SetAndGetWithXsrfMismatch(t *testing.T) { - j := NewJWT("xyz 12345", false, time.Hour) + j := NewJWT("xyz 12345", false, time.Hour, days31) claims := &CustomClaims{ State: "123456", @@ -211,7 +223,7 @@ func TestJWT_SetAndGetWithXsrfMismatch(t *testing.T) { } func TestJWT_SetAndGetWithCookiesExpired(t *testing.T) { - j := NewJWT("xyz 12345", false, time.Hour) + j := NewJWT("xyz 12345", false, time.Hour, days31) claims := &CustomClaims{ State: "123456", @@ -243,45 +255,7 @@ func TestJWT_SetAndGetWithCookiesExpired(t *testing.T) { req := httptest.NewRequest("GET", "/expired", nil) req.AddCookie(resp.Cookies()[0]) req.Header.Add(xsrfHeaderKey, "random id") - _, err = j.Get(req) - assert.NotNil(t, err) - assert.True(t, strings.Contains(err.Error(), "can't parse jwt: token is expired by"), err.Error()) -} - -func TestJWT_Refresh(t *testing.T) { - j := NewJWT("xyz 12345", false, 2*time.Second) - - claims := &CustomClaims{ - State: "123456", - From: "from", - User: &store.User{ - ID: "id1", - Name: "name1", - }, - StandardClaims: jwt.StandardClaims{ - Id: "random id", - Issuer: "remark42", - }, - } - // set token - rr := httptest.NewRecorder() - err := j.Set(rr, claims, true) + claims, err = j.Get(req) assert.Nil(t, err) - cookies := rr.Result().Cookies() - require.Equal(t, 2, len(cookies)) - - req, err := http.NewRequest("GET", "http://example.com/blah", nil) - require.Nil(t, err) - req.AddCookie(cookies[0]) - req.Header.Add(xsrfHeaderKey, "random id") - - claims2, err := j.Refresh(rr, req) - require.Nil(t, err) - assert.Equal(t, claims.ExpiresAt, claims2.ExpiresAt, "no refresh yet") - - time.Sleep(1 * time.Second) - claims2, err = j.Refresh(rr, req) - assert.Nil(t, err) - assert.True(t, claims.ExpiresAt < claims2.ExpiresAt, "refreshed") - t.Log(claims.ExpiresAt, claims2.ExpiresAt) + assert.True(t, j.IsExpired(claims)) } diff --git a/backend/app/rest/auth/provider.go b/backend/app/rest/auth/provider.go index 070b81ac..dddb8ab9 100644 --- a/backend/app/rest/auth/provider.go +++ b/backend/app/rest/auth/provider.go @@ -35,14 +35,13 @@ type Provider struct { // Params to make initialized and ready to use provider type Params struct { - RemarkURL string - AvatarProxy *proxy.Avatar - JwtService *JWT - IsVerifiedFn func(siteID string, userID string) bool - SecretKey string - Admins []string - Cid string - Csecret string + RemarkURL string + AvatarProxy *proxy.Avatar + JwtService *JWT + PermissionChecker PermissionChecker + SecretKey string + Cid string + Csecret string } type userData map[string]interface{} @@ -116,7 +115,6 @@ func (p Provider) loginHandler(w http.ResponseWriter, r *http.Request) { // 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") @@ -163,6 +161,7 @@ func (p Provider) authHandler(w http.ResponseWriter, r *http.Request) { log.Printf("[DEBUG] got raw user info %+v", jData) u := p.MapUser(jData, data) + u = p.setAvatar(u) u = p.alterUser(u, oauthClaims) authClaims := &CustomClaims{ @@ -189,8 +188,8 @@ func (p Provider) authHandler(w http.ResponseWriter, r *http.Request) { render.JSON(w, r, &u) } -// alterUser sets fields not handled by provider's MapUser, things like avatar, admin, verified -func (p Provider) alterUser(u store.User, oauthClaims *CustomClaims) store.User { +// 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 @@ -198,9 +197,15 @@ func (p Provider) alterUser(u store.User, oauthClaims *CustomClaims) store.User log.Printf("[WARN] failed to proxy avatar, %s", e) } } - u.Admin = isAdmin(u.ID, p.Admins) - if p.IsVerifiedFn != nil { - u.Verified = p.IsVerifiedFn(oauthClaims.SiteID, u.ID) + return u +} + +// alterUser sets fields not handled by provider's MapUser, things like admin, verified and blocked +func (p Provider) alterUser(u store.User, oauthClaims *CustomClaims) store.User { + if p.PermissionChecker != nil { + u.Admin = p.PermissionChecker.IsAdmin(u.ID) + u.Verified = p.PermissionChecker.IsVerified(oauthClaims.SiteID, u.ID) + u.Blocked = p.PermissionChecker.IsBlocked(oauthClaims.SiteID, u.ID) } return u } diff --git a/backend/app/rest/auth/provider_test.go b/backend/app/rest/auth/provider_test.go index 75d9e95f..14e83a8f 100644 --- a/backend/app/rest/auth/provider_test.go +++ b/backend/app/rest/auth/provider_test.go @@ -42,7 +42,7 @@ func TestLogin(t *testing.T) { 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, 31536000, resp.Cookies()[0].MaxAge) + 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") @@ -50,7 +50,7 @@ func TestLogin(t *testing.T) { 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: false, IP: ""}, u) + Admin: false, Blocked: true, IP: ""}, u) // check admin user resp, err = client.Get("http://localhost:8981/login?site=remark") @@ -58,6 +58,7 @@ func TestLogin(t *testing.T) { 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", @@ -93,7 +94,7 @@ func TestLoginSessionOnly(t *testing.T) { req.AddCookie(resp.Cookies()[1]) req.Header.Add("X-XSRF-TOKEN", resp.Cookies()[1].Value) - jwtService := NewJWT("12345", false, time.Hour) + jwtService := NewJWT("12345", false, time.Hour, time.Hour) res, err := jwtService.Get(req) require.Nil(t, err) assert.Equal(t, true, res.SessionOnly) @@ -160,15 +161,16 @@ func mockProvider(t *testing.T, loginPort, authPort int) (*http.Server, *http.Se } params := Params{RemarkURL: "url", SecretKey: "123456", Cid: "cid", Csecret: "csecret", - JwtService: NewJWT("12345", false, time.Hour), Admins: []string{"mock_myuser2"}, + JwtService: NewJWT("12345", false, time.Hour, time.Hour*24*31), // AvatarProxy: &proxy.Avatar{Store: &mockAvatarStore, RoutePath: "/v1/avatar"}, - IsVerifiedFn: func(siteID, userID string) bool { return userID == "mock_myuser2" }} + 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"} + useIds := []string{"myuser1", "myuser2"} // user for first ans second calls oauth := &http.Server{ Addr: fmt.Sprintf(":%d", authPort), @@ -216,3 +218,13 @@ func mockProvider(t *testing.T, loginPort, authPort int) (*http.Server, *http.Se time.Sleep(time.Millisecond * 100) // let them start return ts, oauth } + +type mockUserPermissions struct { + admin string + verified string + blocked string +} + +func (m *mockUserPermissions) IsAdmin(userID string) bool { return userID == m.admin } +func (m *mockUserPermissions) IsVerified(siteID, userID string) bool { return userID == m.verified } +func (m *mockUserPermissions) IsBlocked(siteID, userID string) bool { return userID == m.blocked } diff --git a/backend/app/store/comment.go b/backend/app/store/comment.go index a18854e9..3d082cc3 100644 --- a/backend/app/store/comment.go +++ b/backend/app/store/comment.go @@ -120,8 +120,8 @@ func shortenAutoLinks(commentHTML string, max int) (resHTML string) { if href != s.Text() || len(href) < max+3 || max < 3 { return } - url, err := url.Parse(href) - if err != nil { + url, e := url.Parse(href) + if e != nil { return } url.Path, url.RawQuery, url.Fragment = "", "", "" diff --git a/backend/app/store/service/service.go b/backend/app/store/service/service.go index 66dd9045..c7fd97ad 100644 --- a/backend/app/store/service/service.go +++ b/backend/app/store/service/service.go @@ -17,6 +17,7 @@ type DataStore struct { EditDuration time.Duration Secret string MaxCommentSize int + Admins []string // granular locks scopedLocks struct { @@ -163,14 +164,14 @@ func (s *DataStore) ValidateComment(c *store.Comment) error { return nil } -// IsVerifiedFn returns func to check if user verified or not -func (s *DataStore) IsVerifiedFn() func(siteID string, userID string) bool { - return func(siteID string, userID string) bool { - if siteID == "" { - return false +// IsAdmin checks if usesID in the list of admins +func (s *DataStore) IsAdmin(userID string) bool { + for _, admin := range s.Admins { + if admin == userID { + return true } - return s.IsVerified(siteID, userID) } + return false } // getsScopedLocks pull lock from the map if found or create a new one diff --git a/backend/app/store/service/service_test.go b/backend/app/store/service/service_test.go index e2a16280..f5a76f19 100644 --- a/backend/app/store/service/service_test.go +++ b/backend/app/store/service/service_test.go @@ -326,20 +326,6 @@ func TestService_Counts(t *testing.T) { }, res) } -func TestService_IsVerifiedFn(t *testing.T) { - defer os.Remove(testDb) - b := DataStore{Interface: prepStoreEngine(t)} - - fn := b.IsVerifiedFn() - verified := fn("radio-t", "user1") - assert.False(t, verified) - - err := b.Interface.SetVerified("radio-t", "user1", true) - assert.Nil(t, err) - verified = fn("radio-t", "user1") - assert.True(t, verified) -} - // makes new boltdb, put two records func prepStoreEngine(t *testing.T) engine.Interface { os.Remove(testDb) diff --git a/backend/remark.rest b/backend/remark.rest index c6679fce..c3430b57 100644 --- a/backend/remark.rest +++ b/backend/remark.rest @@ -113,5 +113,8 @@ PUT {{host}}/api/v1/rss/site?site=remark ### get default avatar GET {{host}}/api/v1/avatar/blah +### get config +GET {{host}}/api/v1/config?site=remark + ### ping GET {{host}}/ping diff --git a/compose-dev.yml b/compose-dev.yml new file mode 100644 index 00000000..38a77de1 --- /dev/null +++ b/compose-dev.yml @@ -0,0 +1,38 @@ +# compose file for local development +# starts backend with basic auth "dev:password" and Dev oauth2 provider on port 8080 +# UI on https://127.0.0.1:8080/web + +version: '2' + +services: + remark: + build: . + image: umputun/remark42:dev + container_name: "remark42-dev" + hostname: "remark42-dev" + + logging: + driver: json-file + options: + max-size: "10m" + max-file: "5" + + ports: + - "8080:8080" # promary rest server + - "8084:8084" # local oauth2 server + + environment: + - USER + - REMARK_URL=http://127.0.0.1:8080 + - SECRET=12345 + - STORE_BOLT_PATH=/srv/var/db + - BACKUP_PATH=/srv/var/backup + - DEBUG=true + - DEV_PASSWD=password + - AUTH_DEV=true # activate local oauth "dev" + - ADMIN=dev_user # set admin flag for local ouath2 + volumes: + - ./var:/srv/var + #- ./web:/srv/web # uncomment to map web directory directly. It will propagate local changes to container without redeploy + + command: /srv/start.sh diff --git a/web/app/common/constants.js b/web/app/common/constants.js index 8d178aab..f91fbbae 100644 --- a/web/app/common/constants.js +++ b/web/app/common/constants.js @@ -13,6 +13,7 @@ const PROVIDER_NAMES = { facebook: 'Facebook', github: 'GitHub', yandex: 'Yandex', + dev: 'Dev', }; const LS_COLLAPSE_KEY = '__remarkCollapsed'; const LS_SORT_KEY = '__remarkSort'; @@ -31,4 +32,4 @@ module.exports = { DEFAULT_SORT, LS_COLLAPSE_KEY, LS_SORT_KEY, -}; +}; \ No newline at end of file