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:
@@ -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
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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{},
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)))
|
||||
|
||||
@@ -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)
|
||||
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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 = "", "", ""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user