switch session store to jwt, no refresh yet #30

This commit is contained in:
Umputun
2018-05-21 19:16:16 -05:00
parent 60794baedd
commit 07ea6e5b4e
32 changed files with 2068 additions and 195 deletions
+15 -29
View File
@@ -9,7 +9,6 @@ import (
"time"
"github.com/coreos/bbolt"
"github.com/gorilla/sessions"
"github.com/hashicorp/logutils"
"github.com/jessevdk/go-flags"
"github.com/pkg/errors"
@@ -34,17 +33,15 @@ var opts struct {
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"`
SessionStore string `long:"session" env:"SESSION_STORE" default:"./var/session" description:"session store location"`
AvatarStore string `long:"avatars" env:"AVATAR_STORE" default:"./var/avatars" description:"avatars location"`
MaxCommentSize int `long:"max-comment" env:"MAX_COMMENT_SIZE" default:"2048" description:"max comment size"`
SecretKey string `long:"secret" env:"SECRET" required:"true" description:"secret key"`
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"`
MaxCachedItems int `long:"max-cache-items" env:"MAX_CACHE_ITEMS" default:"1000" description:"max cached items"`
MaxCachedValue int `long:"max-cache-value" env:"MAX_CACHE_VALUE" default:"65536" description:"max size of cached value"`
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"`
SecretKey string `long:"secret" env:"SECRET" required:"true" description:"secret key"`
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"`
GoogleCID string `long:"google-cid" env:"REMARK_GOOGLE_CID" description:"Google OAuth client ID"`
GoogleCSEC string `long:"google-csec" env:"REMARK_GOOGLE_CSEC" description:"Google OAuth client secret"`
@@ -71,7 +68,7 @@ func main() {
setupLog(opts.Dbg)
log.Print("[INFO] started remark")
if err := makeDirs(opts.BoltPath, opts.SessionStore, opts.BackupLocation, opts.AvatarStore); err != nil {
if err := makeDirs(opts.BoltPath, opts.BackupLocation, opts.AvatarStore); err != nil {
log.Fatalf("[ERROR] can't create directories, %+v", err)
}
@@ -88,15 +85,6 @@ func main() {
MaxCommentSize: opts.MaxCommentSize,
}
sessionStore := func() sessions.Store {
sess := sessions.NewFilesystemStore(opts.SessionStore, []byte(opts.SecretKey))
sess.Options.HttpOnly = true
sess.Options.Secure = true
sess.Options.MaxAge = 3600 * 24 * 365
sess.Options.Path = "/"
return sess
}()
exporter := migrator.Remark{DataStore: &dataService}
cache := rest.NewLoadingCache(rest.MaxValueSize(opts.MaxCachedValue), rest.MaxKeys(opts.MaxCachedItems),
rest.PostFlushFn(postFlushFn))
@@ -125,11 +113,10 @@ func main() {
WebRoot: opts.WebRoot,
ImageProxy: proxy.Image{Enabled: opts.ImageProxy, RoutePath: "/api/v1/img", RemarkURL: opts.RemarkURL},
Authenticator: auth.Authenticator{
Admins: opts.Admins,
SessionStore: sessionStore,
Providers: makeAuthProviders(sessionStore, avatarProxy),
AvatarProxy: avatarProxy,
DevPasswd: opts.DevPasswd,
Admins: opts.Admins,
Providers: makeAuthProviders(avatarProxy),
AvatarProxy: avatarProxy,
DevPasswd: opts.DevPasswd,
},
Cache: cache,
}
@@ -193,15 +180,14 @@ func makeDirs(dirs ...string) error {
return nil
}
func makeAuthProviders(sessionStore sessions.Store, avatarProxy *proxy.Avatar) (providers []auth.Provider) {
func makeAuthProviders(avatarProxy *proxy.Avatar) (providers []auth.Provider) {
makeParams := func(cid, secret string) auth.Params {
return auth.Params{
AvatarProxy: avatarProxy,
SessionStore: sessionStore,
RemarkURL: opts.RemarkURL,
Cid: cid,
Csecret: secret,
AvatarProxy: avatarProxy,
RemarkURL: opts.RemarkURL,
Cid: cid,
Csecret: secret,
}
}
-2
View File
@@ -17,7 +17,6 @@ import (
"github.com/go-chi/chi"
"github.com/go-chi/chi/middleware"
"github.com/go-chi/render"
"github.com/gorilla/context"
"github.com/pkg/errors"
"gopkg.in/russross/blackfriday.v2"
@@ -80,7 +79,6 @@ func (s *Rest) routes() chi.Router {
router.Use(middleware.RealIP, Recoverer)
router.Use(middleware.Throttle(1000), middleware.Timeout(60*time.Second))
router.Use(AppInfo("remark42", s.Version), Ping)
router.Use(context.ClearHandler) // if you aren't using gorilla/mux, you need to wrap your handlers with context.ClearHandler
s.adminService = admin{
dataService: s.DataService,
+4 -6
View File
@@ -14,7 +14,6 @@ import (
"time"
"github.com/coreos/bbolt"
"github.com/gorilla/sessions"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -469,11 +468,10 @@ func prep(t *testing.T) (srv *Rest, ts *httptest.Server) {
srv = &Rest{
DataService: dataStore,
Authenticator: auth.Authenticator{
SessionStore: sessions.NewFilesystemStore("/tmp", []byte("blah")),
DevPasswd: "password",
Providers: nil,
AvatarProxy: &proxy.Avatar{StorePath: "/tmp", RoutePath: "/api/v1/avatar"},
Admins: []string{"a1", "a2"},
DevPasswd: "password",
Providers: nil,
AvatarProxy: &proxy.Avatar{StorePath: "/tmp", RoutePath: "/api/v1/avatar"},
Admins: []string{"a1", "a2"},
},
Exporter: &migrator.Remark{DataStore: &dataStore},
Cache: &mockCache{},
+10 -36
View File
@@ -7,9 +7,6 @@ import (
"net/http"
"strings"
"github.com/gorilla/sessions"
"github.com/pkg/errors"
"github.com/umputun/remark/app/rest"
"github.com/umputun/remark/app/rest/proxy"
"github.com/umputun/remark/app/store"
@@ -17,11 +14,11 @@ import (
// Authenticator is top level auth object providing middlewares
type Authenticator struct {
SessionStore sessions.Store
AvatarProxy *proxy.Avatar
Admins []string
Providers []Provider
DevPasswd string
AvatarProxy *proxy.Avatar
Admins []string
Providers []Provider
DevPasswd string
JWTService JWT
}
var devUser = store.User{
@@ -44,8 +41,9 @@ func (a *Authenticator) Auth(reqAuth bool) func(http.Handler) http.Handler {
return
}
session, err := a.SessionStore.Get(r, "remark")
claims, err := a.JWTService.Get(r)
if err != nil && reqAuth { // in full auth lack of session causes Unauthorized
log.Printf("[WARN] failed auth, %s", err)
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
@@ -55,24 +53,13 @@ func (a *Authenticator) Auth(reqAuth bool) func(http.Handler) http.Handler {
return
}
uinfoData, ok := session.Values["uinfo"]
if !ok && reqAuth {
if claims.User == nil && reqAuth {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
if xsrfError := a.checkXSRF(r, session); xsrfError != nil {
if reqAuth {
log.Printf("[WARN] %s", xsrfError.Error())
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
h.ServeHTTP(w, r) // in anonymous mode just pass it to next handler
return
}
if ok { // if uinfo in session, populate to context
user := uinfoData.(store.User)
if claims.User != nil { // if uinfo in session, populate to context
user := *claims.User
for _, admin := range a.Admins {
if admin == user.ID {
user.Admin = true
@@ -89,19 +76,6 @@ func (a *Authenticator) Auth(reqAuth bool) func(http.Handler) http.Handler {
return f
}
func (a *Authenticator) checkXSRF(r *http.Request, session *sessions.Session) error {
xsrfToken := r.Header.Get("X-XSRF-TOKEN")
sessionToken, headerOk := session.Values["xsrf_token"]
if !headerOk || xsrfToken == "" || sessionToken == nil {
return errors.New(" no xsrf_token in session")
}
if xsrfToken != sessionToken {
return errors.Errorf("xsrf header not matched session token, %q != %q", xsrfToken, sessionToken)
}
return nil
}
// AdminOnly allows access to admins
func (a *Authenticator) AdminOnly(next http.Handler) http.Handler {
fn := func(w http.ResponseWriter, r *http.Request) {
+69 -6
View File
@@ -3,6 +3,7 @@ package auth
import (
"encoding/base64"
"net/http"
"net/http/cookiejar"
"net/http/httptest"
"testing"
"time"
@@ -13,9 +14,73 @@ import (
"github.com/stretchr/testify/require"
)
func TestAuthJWTCookie(t *testing.T) {
a := Authenticator{DevPasswd: "123456", JWTService: JWT{secret: "xyz 12345", secureCookies: false}}
router := chi.NewRouter()
router.With(a.Auth(true)).Get("/auth", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(201)
})
server := httptest.NewServer(router)
defer server.Close()
expiration := int(time.Duration(365 * 24 * time.Hour).Seconds())
req, err := http.NewRequest("GET", server.URL+"/auth", nil)
require.Nil(t, err)
req.AddCookie(&http.Cookie{Name: "JWT", Value: testJwtValid, HttpOnly: true, Path: "/", MaxAge: expiration, Secure: false})
req.Header.Add("X-XSRF-TOKEN", "random id")
client := &http.Client{Timeout: 5 * time.Second}
resp, err := client.Do(req)
require.NoError(t, err)
assert.Equal(t, 201, resp.StatusCode, "valid auth user")
req, err = http.NewRequest("GET", server.URL+"/auth", nil)
require.Nil(t, err)
req.AddCookie(&http.Cookie{Name: "JWT", Value: testJwtValid, HttpOnly: true, Path: "/", MaxAge: expiration, Secure: false})
req.Header.Add("X-XSRF-TOKEN", "wrong id")
resp, err = client.Do(req)
require.NoError(t, err)
assert.Equal(t, 401, resp.StatusCode, "xsrf mismatch")
req, err = http.NewRequest("GET", server.URL+"/auth", nil)
require.Nil(t, err)
req.AddCookie(&http.Cookie{Name: "JWT", Value: testJwtExpired, HttpOnly: true, Path: "/", MaxAge: expiration, Secure: false})
req.Header.Add("X-XSRF-TOKEN", "random id")
resp, err = client.Do(req)
require.NoError(t, err)
assert.Equal(t, 401, resp.StatusCode, "token expired")
}
func TestAuthJWTHeader(t *testing.T) {
a := Authenticator{DevPasswd: "123456", JWTService: JWT{secret: "xyz 12345", secureCookies: false}}
router := chi.NewRouter()
router.With(a.Auth(true)).Get("/auth", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(201)
})
server := httptest.NewServer(router)
defer server.Close()
jar, err := cookiejar.New(nil)
require.Nil(t, err)
client := &http.Client{Jar: jar, Timeout: 5 * time.Second}
req, err := http.NewRequest("GET", server.URL+"/auth", nil)
require.Nil(t, err)
req.Header.Add("X-JWT", testJwtValid)
resp, err := client.Do(req)
require.NoError(t, err)
assert.Equal(t, 201, resp.StatusCode, "valid auth user")
req, err = http.NewRequest("GET", server.URL+"/auth", nil)
require.Nil(t, err)
req.Header.Add("X-JWT", testJwtExpired)
resp, err = client.Do(req)
require.NoError(t, err)
assert.Equal(t, 401, resp.StatusCode, "invalid auth token")
}
func TestAuthRequired(t *testing.T) {
store := mockStore{}
a := Authenticator{SessionStore: &store, DevPasswd: "123456"}
a := Authenticator{DevPasswd: "123456"}
router := chi.NewRouter()
router.With(a.Auth(true)).Get("/auth", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(201)
@@ -43,8 +108,7 @@ func TestAuthRequired(t *testing.T) {
}
func TestAuthNotRequired(t *testing.T) {
store := mockStore{}
a := Authenticator{SessionStore: &store, DevPasswd: "123456"}
a := Authenticator{DevPasswd: "123456"}
router := chi.NewRouter()
router.With(a.Auth(false)).Get("/auth", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(201)
@@ -72,8 +136,7 @@ func TestAuthNotRequired(t *testing.T) {
}
func TestAdminRequired(t *testing.T) {
store := mockStore{}
a := Authenticator{SessionStore: &store, DevPasswd: "123456"}
a := Authenticator{DevPasswd: "123456"}
router := chi.NewRouter()
router.With(a.Auth(true), a.AdminOnly).Get("/auth", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(201)
+124
View File
@@ -0,0 +1,124 @@
package auth
import (
"log"
"net/http"
"time"
jwt "github.com/dgrijalva/jwt-go"
"github.com/pkg/errors"
"github.com/umputun/remark/app/store"
)
// JWT wraps jwt operations
// supports both header and cookie jwt
type JWT struct {
secret string
secureCookies bool
}
// CustomClaims stores user info for auth and state & from from login
type CustomClaims struct {
jwt.StandardClaims
User *store.User `json:"user,omitempty"`
State string `json:"state,omitempty"`
From string `json:"from,omitempty"`
}
const jwtCookieName = "JWT"
const jwtHeaderKey = "X-JWT"
const xsrfCookieName = "XSRF-TOKEN"
const xsrfHeaderKey = "X-XSRF-TOKEN"
// Set creates jwt cookie with xsrf cookie and put it to ResponseWriter
func (j *JWT) Set(w http.ResponseWriter, claims *CustomClaims) error {
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
tokenString, err := token.SignedString([]byte([]byte(j.secret)))
if err != nil {
return errors.Wrap(err, "can't sign jwt token")
}
expiration := int(time.Duration(365 * 24 * time.Hour).Seconds())
jwtCookie := http.Cookie{Name: jwtCookieName, Value: tokenString, HttpOnly: true, Path: "/",
MaxAge: expiration, Secure: j.secureCookies}
http.SetCookie(w, &jwtCookie)
jti := claims.Id
xsrfCookie := http.Cookie{Name: xsrfCookieName, Value: jti, HttpOnly: false, Path: "/",
MaxAge: expiration, Secure: j.secureCookies}
http.SetCookie(w, &xsrfCookie)
return nil
}
// Get jwt from header or cookie
// if cookie used verify xsrf token to match
func (j *JWT) Get(r *http.Request) (*CustomClaims, error) {
fromCookie := false
tokenString := ""
if tokenHeader := r.Header.Get(jwtHeaderKey); tokenHeader != "" {
tokenString = tokenHeader
}
if tokenString == "" {
fromCookie = true
jc, err := r.Cookie(jwtCookieName)
if err != nil {
return nil, errors.Wrap(err, "jwt cookie was not presented")
}
tokenString = jc.Value
}
token, err := jwt.ParseWithClaims(tokenString, &CustomClaims{}, func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, errors.Errorf("unexpected signing method: %v", token.Header["alg"])
}
return []byte(j.secret), nil
})
if err != nil {
return nil, errors.Wrap(err, "can't parse jwt")
}
claims, ok := token.Claims.(*CustomClaims)
if !ok || !token.Valid {
return nil, errors.New("invalid jwt")
}
if fromCookie && claims.User != nil {
xsrf := r.Header.Get(xsrfHeaderKey)
if claims.Id != xsrf {
log.Printf("[WARN] xsrf not matched jti, %s != %s", xsrf, claims.Id)
return nil, errors.New("xsrf mismatch")
}
}
return claims, nil
}
// Reset token's cookies
func (j *JWT) Reset(w http.ResponseWriter) {
jwtCookie := http.Cookie{Name: jwtCookieName, Value: "", HttpOnly: false, Path: "/",
MaxAge: -1, Expires: time.Unix(0, 0), Secure: true}
http.SetCookie(w, &jwtCookie)
xsrfCookie := http.Cookie{Name: xsrfCookieName, Value: "", HttpOnly: false, Path: "/",
MaxAge: -1, Expires: time.Unix(0, 0), Secure: true}
http.SetCookie(w, &xsrfCookie)
}
func (j *JWT) verify(claims CustomClaims) error {
if time.Now().Unix() > claims.ExpiresAt {
return errors.Errorf("token exp failed %d:%d", claims.ExpiresAt, time.Now().Unix())
}
if time.Now().Unix() < claims.NotBefore {
return errors.Errorf("token nbf failed %d:%d", claims.NotBefore, time.Now().Unix())
}
return nil
}
+155
View File
@@ -0,0 +1,155 @@
package auth
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/stretchr/testify/require"
"github.com/stretchr/testify/assert"
"github.com/umputun/remark/app/store"
jwt "github.com/dgrijalva/jwt-go"
)
var testJwtValid = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjI3ODkxOTE4MjIsImp0aSI6InJhbmRvbSBpZCI" +
"sImlzcyI6InJlbWFyazQyIiwibmJmIjoxNTI2ODg0MjIyLCJ1c2VyIjp7Im5hbWUiOiJuYW1lMSIsImlkIjoiaWQxIiwicGljdHVyZS" +
"I6IiIsImFkbWluIjpmYWxzZX0sInN0YXRlIjoiMTIzNDU2IiwiZnJvbSI6ImZyb20ifQ._loFgh3g45gr9TtGqvM3N584I_6EHEOJnYb6Py84stQ"
var testJwtExpired = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE1MjY4ODc4MjIsImp0aSI6InJhbmRvbSBpZCIs" + "ImlzcyI6InJlbWFyazQyIiwibmJmIjoxNTI2ODg0MjIyLCJ1c2VyIjp7Im5hbWUiOiJuYW1lMSIsImlkIjoiaWQxIiwicGljdHVyZSI6IiI" +
"sImFkbWluIjpmYWxzZX0sInN0YXRlIjoiMTIzNDU2IiwiZnJvbSI6ImZyb20ifQ.4_dCrY9ihyfZIedz-kZwBTxmxU1a52V7IqeJrOqTzE4"
func TestJWT_Set(t *testing.T) {
j := JWT{secret: "xyz 12345"}
claims := &CustomClaims{
State: "123456",
From: "from",
User: &store.User{
ID: "id1",
Name: "name1",
},
StandardClaims: jwt.StandardClaims{
Id: "random id",
Issuer: "remark42",
ExpiresAt: time.Date(2058, 5, 21, 1, 30, 22, 0, time.Local).Unix(),
NotBefore: time.Date(2018, 5, 21, 1, 30, 22, 0, time.Local).Unix(),
},
}
rr := httptest.NewRecorder()
err := j.Set(rr, claims)
assert.Nil(t, err)
cookies := rr.Result().Cookies()
t.Log(cookies)
require.Equal(t, 2, len(cookies))
assert.Equal(t, "JWT", cookies[0].Name)
assert.Equal(t, testJwtValid, cookies[0].Value)
assert.Equal(t, "XSRF-TOKEN", cookies[1].Name)
assert.Equal(t, "random id", cookies[1].Value)
}
func TestJWT_GetFromHeader(t *testing.T) {
j := JWT{secret: "xyz 12345"}
req := httptest.NewRequest("GET", "/", nil)
req.Header.Add(jwtHeaderKey, testJwtValid)
claims, err := j.Get(req)
assert.Nil(t, err)
assert.Equal(t, &store.User{Name: "name1", ID: "id1", Picture: "", Admin: false, Blocked: false, IP: ""}, claims.User)
assert.Equal(t, "remark42", claims.Issuer)
req = httptest.NewRequest("GET", "/", nil)
req.Header.Add(jwtHeaderKey, testJwtExpired)
_, err = j.Get(req)
assert.NotNil(t, err)
assert.True(t, strings.HasPrefix(err.Error(), "can't parse jwt: token is expired by"), err.Error())
req = httptest.NewRequest("GET", "/", nil)
req.Header.Add(jwtHeaderKey, "bad bad token")
_, err = j.Get(req)
assert.NotNil(t, err)
assert.True(t, strings.HasPrefix(err.Error(), "can't parse jwt: token contains an invalid number of segments"), err.Error())
}
func TestJWT_SetAndGetWithCookies(t *testing.T) {
j := JWT{secret: "xyz 12345"}
claims := &CustomClaims{
State: "123456",
From: "from",
User: &store.User{
ID: "id1",
Name: "name1",
},
StandardClaims: jwt.StandardClaims{
Id: "random id",
Issuer: "remark42",
ExpiresAt: time.Date(2058, 5, 21, 1, 30, 22, 0, time.Local).Unix(),
NotBefore: time.Date(2018, 5, 21, 1, 30, 22, 0, time.Local).Unix(),
},
}
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/valid" {
j.Set(w, claims)
w.WriteHeader(200)
}
}))
defer ts.Close()
resp, err := http.Get(ts.URL + "/valid")
require.Nil(t, err)
assert.Equal(t, 200, resp.StatusCode)
req := httptest.NewRequest("GET", "/valid", nil)
req.AddCookie(resp.Cookies()[0])
req.Header.Add(xsrfHeaderKey, "random id")
claims, err = j.Get(req)
assert.Nil(t, err)
assert.Equal(t, &store.User{Name: "name1", ID: "id1", Picture: "", Admin: false, Blocked: false, IP: ""}, claims.User)
assert.Equal(t, "remark42", claims.Issuer)
}
func TestJWT_SetAndGetWithCookiesExpired(t *testing.T) {
j := JWT{secret: "xyz 12345"}
claims := &CustomClaims{
State: "123456",
From: "from",
User: &store.User{
ID: "id1",
Name: "name1",
},
StandardClaims: jwt.StandardClaims{
Id: "random id",
Issuer: "remark42",
ExpiresAt: time.Date(2018, 5, 21, 1, 35, 22, 0, time.Local).Unix(),
NotBefore: time.Date(2018, 5, 21, 1, 30, 22, 0, time.Local).Unix(),
},
}
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/expired" {
j.Set(w, claims)
w.WriteHeader(200)
}
}))
defer ts.Close()
resp, err := http.Get(ts.URL + "/expired")
require.Nil(t, err)
assert.Equal(t, 200, resp.StatusCode)
req := httptest.NewRequest("GET", "/expired", nil)
req.AddCookie(resp.Cookies()[0])
req.Header.Add(xsrfHeaderKey, "random id")
_, err = j.Get(req)
assert.NotNil(t, err)
assert.True(t, strings.HasPrefix(err.Error(), "can't parse jwt: token is expired by"), err.Error())
}
+42 -63
View File
@@ -10,11 +10,12 @@ import (
"io/ioutil"
"log"
"net/http"
"strings"
"time"
jwt "github.com/dgrijalva/jwt-go"
"github.com/go-chi/chi"
"github.com/go-chi/render"
"github.com/gorilla/sessions"
"golang.org/x/oauth2"
"github.com/umputun/remark/app/rest"
@@ -24,26 +25,25 @@ import (
// Provider represents oauth2 provider
type Provider struct {
sessions.Store
Name string
RedirectURL string
InfoURL string
Endpoint oauth2.Endpoint
Scopes []string
MapUser func(userData, []byte) store.User // map info from InfoURL to User
Secret string
avatarProxy *proxy.Avatar
conf *oauth2.Config
jwtService *JWT
}
// Params to make initialized and ready to use provider
type Params struct {
Cid string
Csecret string
SessionStore sessions.Store
RemarkURL string
AvatarProxy *proxy.Avatar
Cid string
Csecret string
RemarkURL string
AvatarProxy *proxy.Avatar
}
type userData map[string]interface{}
@@ -68,8 +68,9 @@ func initProvider(p Params, provider Provider) Provider {
}
provider.conf = &conf
provider.Store = p.SessionStore
provider.avatarProxy = p.AvatarProxy
provider.jwtService = &JWT{secret: provider.Secret, secureCookies: strings.HasPrefix(p.RemarkURL, "https://")}
return provider
}
@@ -87,52 +88,47 @@ func (p Provider) loginHandler(w http.ResponseWriter, r *http.Request) {
// make state (random) and store in session
state := p.randToken()
session, err := p.Get(r, "remark")
if err != nil {
log.Printf("[DEBUG] can't get session, %s", err)
claims := &CustomClaims{
State: state,
From: r.URL.Query().Get("from"),
StandardClaims: jwt.StandardClaims{
Id: p.randToken(),
Issuer: "remark42",
ExpiresAt: time.Now().Add(30 * time.Minute).Unix(),
NotBefore: time.Now().Add(-1 * time.Minute).Unix(),
},
}
session.Values["state"] = state
if from := r.URL.Query().Get("from"); from != "" {
session.Values["from"] = from
}
log.Printf("[DEBUG] login, %+v", session.Values)
if err := session.Save(r, w); err != nil {
rest.SendErrorJSON(w, r, http.StatusInternalServerError, err, "failed to save state")
if err := p.jwtService.Set(w, claims); err != nil {
rest.SendErrorJSON(w, r, http.StatusInternalServerError, err, "failed to set jwt")
return
}
// return login url
loginURL := p.conf.AuthCodeURL(state)
log.Printf("[DEBUG] login url %s", loginURL)
http.Redirect(w, r, loginURL, http.StatusTemporaryRedirect)
http.Redirect(w, r, loginURL, http.StatusFound)
}
// authHandler fills user info and redirects to "from" url. This is callback url redirected locally by browser
// GET /callback
func (p Provider) authHandler(w http.ResponseWriter, r *http.Request) {
session, err := p.Get(r, "remark")
oauthClaims, err := p.jwtService.Get(r)
if err != nil {
rest.SendErrorJSON(w, r, http.StatusInternalServerError, err, "failed to get session")
return
}
// compare saved state to the one from redirect url
retrievedState, ok := session.Values["state"]
if !ok {
http.Error(w, "missing state in store", http.StatusUnauthorized)
rest.SendErrorJSON(w, r, http.StatusInternalServerError, err, "failed to get jwt")
return
}
retrievedState := oauthClaims.State
if retrievedState == "" || retrievedState != r.URL.Query().Get("state") {
http.Error(w, fmt.Sprintf("unexpected state %v", retrievedState), http.StatusUnauthorized)
return
}
log.Printf("[DEBUG] auth, %+v", session.Values)
log.Printf("[DEBUG] auth with state %s", retrievedState)
tok, err := p.conf.Exchange(context.Background(), r.URL.Query().Get("code"))
if err != nil {
rest.SendErrorJSON(w, r, http.StatusInternalServerError, err, "exchange failed")
@@ -173,26 +169,27 @@ func (p Provider) authHandler(w http.ResponseWriter, r *http.Request) {
log.Printf("[WARN] failed to proxy avatar, %s", e)
}
}
session.Values["uinfo"] = u
xsrfToken := p.randToken()
session.Values["xsrf_token"] = xsrfToken
xsrfCookie := http.Cookie{Name: "XSRF-TOKEN", Value: xsrfToken, HttpOnly: false, Path: "/",
MaxAge: 3600 * 24 * 365, Secure: true,
authClaims := &CustomClaims{
User: &u,
StandardClaims: jwt.StandardClaims{
Issuer: "remark42",
Id: p.randToken(),
ExpiresAt: time.Now().Add(7 * 24 * time.Hour).Unix(),
NotBefore: time.Now().Add(-1 * time.Minute).Unix(),
},
}
http.SetCookie(w, &xsrfCookie)
if err = session.Save(r, w); err != nil {
if err = p.jwtService.Set(w, authClaims); err != nil {
rest.SendErrorJSON(w, r, http.StatusInternalServerError, err, "failed to save user info")
return
}
log.Printf("[DEBUG] user info %+v", session.Values["uinfo"])
log.Printf("[DEBUG] user info %+v", u)
// redirect to back url if presented in login query params
if fromURL, ok := session.Values["from"]; ok {
http.Redirect(w, r, fromURL.(string), http.StatusTemporaryRedirect)
if oauthClaims.From != "" {
http.Redirect(w, r, oauthClaims.From, http.StatusTemporaryRedirect)
return
}
render.JSON(w, r, jData)
@@ -200,26 +197,8 @@ func (p Provider) authHandler(w http.ResponseWriter, r *http.Request) {
// LogoutHandler - GET /logout
func (p Provider) LogoutHandler(w http.ResponseWriter, r *http.Request) {
session, err := p.Get(r, "remark")
if err != nil {
rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "failed to get session")
return
}
session.Values["uinfo"], session.Values["from"], session.Values["state"] = "", "", ""
delete(session.Values, "uinfo")
delete(session.Values, "from")
delete(session.Values, "state")
delete(session.Values, "xsrf_token")
xsrfCookie := http.Cookie{Name: "XSRF-TOKEN", Value: "", HttpOnly: false, Path: "/",
MaxAge: -1, Expires: time.Unix(0, 0), Secure: true}
http.SetCookie(w, &xsrfCookie)
if err = session.Save(r, w); err != nil {
rest.SendErrorJSON(w, r, http.StatusInternalServerError, err, "failed to reset user info")
return
}
log.Printf("[DEBUG] logout, %+v", session.Values)
p.jwtService.Reset(w)
log.Printf("[DEBUG] logout")
}
func (p Provider) randToken() string {
+30 -44
View File
@@ -6,11 +6,11 @@ import (
"io/ioutil"
"log"
"net/http"
"net/http/cookiejar"
"strings"
"testing"
"time"
"github.com/gorilla/sessions"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/oauth2"
@@ -20,21 +20,28 @@ import (
func TestLogin(t *testing.T) {
sessionStore := &mockStore{values: make(map[interface{}]interface{})}
_, ts, ots := mockProvider(t, sessionStore, 8981, 8982)
_, ts, ots := mockProvider(t, 8981, 8982)
defer func() {
ts.Close()
ots.Close()
}()
resp, err := http.Get("http://localhost:8981/login")
jar, err := cookiejar.New(nil)
client := &http.Client{Jar: jar, Timeout: 5 * time.Second}
resp, err := client.Get("http://localhost:8981/login")
assert.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, "XSRF-TOKEN", resp.Cookies()[1].Name)
assert.NotEqual(t, "", resp.Cookies()[1].Value, "xsrf cookie set")
u := store.User{}
err = json.Unmarshal(body, &u)
assert.Nil(t, err)
@@ -43,29 +50,33 @@ func TestLogin(t *testing.T) {
}
func TestLogout(t *testing.T) {
sessionStore := &mockStore{values: make(map[interface{}]interface{})}
_, ts, ots := mockProvider(t, sessionStore, 8691, 8692)
_, ts, ots := mockProvider(t, 8691, 8692)
defer func() {
ts.Close()
ots.Close()
}()
resp, err := http.Get("http://localhost:8691/login")
jar, err := cookiejar.New(nil)
require.Nil(t, err)
client := &http.Client{Jar: jar, Timeout: 5 * time.Second}
resp, err := client.Get("http://localhost:8691/login")
require.Nil(t, err)
assert.Equal(t, 200, resp.StatusCode)
assert.Equal(t, 2, len(resp.Cookies()))
resp, err = client.Get("http://localhost:8691/logout")
require.Nil(t, err)
assert.Equal(t, 200, resp.StatusCode)
_, err = http.Get("http://localhost:8691/logout")
require.Nil(t, err)
assert.Equal(t, 200, resp.StatusCode)
s, err := sessionStore.Get(nil, "remark")
assert.Nil(t, err)
t.Log(s.Values)
assert.Equal(t, 0, len(s.Values))
assert.Equal(t, 2, len(resp.Cookies()))
assert.Equal(t, "JWT", resp.Cookies()[0].Name, "jwt cookie cleared")
assert.Equal(t, "", resp.Cookies()[0].Value)
assert.Equal(t, "XSRF-TOKEN", resp.Cookies()[1].Name, "xsrf cookie cleared")
assert.Equal(t, "", resp.Cookies()[1].Value)
}
func mockProvider(t *testing.T, sessStore sessions.Store, loginPort, authPort int) (provider Provider, ts *http.Server, oauth *http.Server) {
func mockProvider(t *testing.T, loginPort, authPort int) (provider Provider, ts *http.Server, oauth *http.Server) {
provider = Provider{
Name: "mock",
@@ -84,9 +95,10 @@ func mockProvider(t *testing.T, sessStore sessions.Store, loginPort, authPort in
}
return userInfo
},
jwtService: &JWT{secret: "12345", secureCookies: false},
}
provider = initProvider(Params{SessionStore: sessStore, Cid: "cid", Csecret: "csecret"}, provider)
provider = initProvider(Params{Cid: "cid", Csecret: "csecret"}, provider)
ts = &http.Server{Addr: fmt.Sprintf(":%d", loginPort), Handler: provider.Routes()}
@@ -132,29 +144,3 @@ func mockProvider(t *testing.T, sessStore sessions.Store, loginPort, authPort in
time.Sleep(time.Millisecond * 100) // let the start
return provider, ts, oauth
}
type mockStore struct {
values map[interface{}]interface{}
}
func (ms *mockStore) Get(r *http.Request, name string) (*sessions.Session, error) {
if ms.values == nil {
ms.values = make(map[interface{}]interface{})
}
s := sessions.NewSession(ms, name)
s.Values = ms.values
return s, nil
}
func (ms *mockStore) New(r *http.Request, name string) (*sessions.Session, error) {
ms.values = make(map[interface{}]interface{})
return &sessions.Session{Values: ms.values}, nil
}
func (ms *mockStore) Save(r *http.Request, w http.ResponseWriter, s *sessions.Session) error {
if ms.values == nil {
ms.values = make(map[interface{}]interface{})
}
ms.values = s.Values
return nil
}
-4
View File
@@ -20,7 +20,6 @@ func NewGoogle(p Params) Provider {
RedirectURL: p.RemarkURL + "/auth/google/callback",
Scopes: []string{"https://www.googleapis.com/auth/userinfo.email"},
InfoURL: "https://www.googleapis.com/oauth2/v3/userinfo",
Store: p.SessionStore,
MapUser: func(data userData, _ []byte) store.User {
userInfo := store.User{
// encode email with provider name to avoid collision if same id returned by other provider
@@ -44,7 +43,6 @@ func NewGithub(p Params) Provider {
RedirectURL: p.RemarkURL + "/auth/github/callback",
Scopes: []string{"user:email"},
InfoURL: "https://api.github.com/user",
Store: p.SessionStore,
MapUser: func(data userData, _ []byte) store.User {
userInfo := store.User{
ID: "github_" + store.EncodeID(data.value("login")),
@@ -83,7 +81,6 @@ func NewFacebook(p Params) Provider {
RedirectURL: p.RemarkURL + "/auth/facebook/callback",
Scopes: []string{"public_profile"},
InfoURL: "https://graph.facebook.com/me?fields=id,name,picture",
Store: p.SessionStore,
MapUser: func(data userData, bdata []byte) store.User {
userInfo := store.User{
ID: "facebook_" + store.EncodeID(data.value("id")),
@@ -113,7 +110,6 @@ func NewDisqus(p Params) Provider {
RedirectURL: p.RemarkURL + "/auth/disqus/callback",
Scopes: []string{"read"},
InfoURL: "https://disqus.com/api/3.0/users/details.json",
Store: p.SessionStore,
MapUser: func(data userData, _ []byte) store.User {
userInfo := store.User{
ID: "disqus_" + store.EncodeID(data.value("login")),