feature/jwtcleanup (#113)

* remove extra dmin check, add blocking check via jwt and auth middleware
* short jwt and refresh for expired
* lint: missing comment on UserFlager
* simplify user management in auth refresh
* allow custom max cookie age
* test blocked user
* reset cookie for blocked user
* move admin perm detection to data service
* customizable ttl with opts as a part of auth group
* add local auth provider dev
* main minimal test for dev auth mode
* add comments and update docs with current params
* add admin and auth_dev flags
* comments for dev compose
* lint: shadow err
This commit is contained in:
Umputun
2018-06-27 01:43:13 -05:00
committed by GitHub
parent c316b27fe1
commit 7a6eca56b1
22 changed files with 512 additions and 199 deletions
+7 -3
View File
@@ -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 |
+58 -30
View File
@@ -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")
}
+41 -9
View File
@@ -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")
+1 -1
View File
@@ -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 {
+2 -2
View File
@@ -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()
+3 -3
View File
@@ -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"
+2 -2
View File
@@ -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,
+11 -5
View File
@@ -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{},
+40 -20
View File
@@ -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
}
+29 -4
View File
@@ -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)))
+116
View File
@@ -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
},
})
}
@@ -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)
}
+18 -24
View File
@@ -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
+27 -53
View File
@@ -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))
}
+19 -14
View File
@@ -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
}
+18 -6
View File
@@ -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 }
+2 -2
View File
@@ -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 = "", "", ""
+7 -6
View File
@@ -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
-14
View File
@@ -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)
+3
View File
@@ -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
+38
View File
@@ -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
+2 -1
View File
@@ -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,
};
};