From b951e057c40b7b1c4446654ca051729f4f408e69 Mon Sep 17 00:00:00 2001 From: Bel LaPointe Date: Fri, 18 Feb 2022 09:16:23 -0700 Subject: [PATCH] test server auth --- server/authenticate.go | 184 +++++++++++++++++++----- server/authenticate_test.go | 269 ++++++++++++++++++++++++++++++++++++ server/main.go | 30 +++- server/server.go | 37 ++++- server/server_test.go | 4 +- todo.yaml | 5 +- 6 files changed, 485 insertions(+), 44 deletions(-) create mode 100644 server/authenticate_test.go diff --git a/server/authenticate.go b/server/authenticate.go index efad610..e7a1621 100644 --- a/server/authenticate.go +++ b/server/authenticate.go @@ -1,75 +1,191 @@ package main import ( + "encoding/base64" + "encoding/json" "errors" + "hash/crc32" "net/http" + "os" "time" + + "github.com/google/uuid" ) +var cookieSecret = os.Getenv("COOKIE_SECRET") + +type User struct { + User string + Groups []string +} + +type Cookie struct { + Hash string + Salt string + Value string +} + func (server *Server) authenticate(w http.ResponseWriter, r *http.Request) (*Server, bool, error) { - if err := server.parseLogin(w, r); err != nil { + if done, err := server.parseLogin(w, r); err != nil { return nil, false, err - } - if ok, err := server.needsLogin(r); err != nil { - return nil, false, err - } else if ok { - w.Header().Set("WWW-Authenticate", "Basic") - w.WriteHeader(http.StatusUnauthorized) + } else if done { return nil, true, nil } - // TODO: if bad cookie OR no cookie: https://blog.stevensanderson.com/2008/08/25/using-the-browsers-native-login-prompt/ - // TODO: prompt for user-pass if nothing supplied - // TODO: login - // TODO: logged in - // TODO: get namespaces - // TODO: verify cookie namespace is OK - // TODO: ~~logout~~ // client side - return server.WithLoggedIn("", "", []string{}), false, errors.New("not impl") + + if ok, err := needsLogin(r); err != nil { + return nil, false, err + } else if ok { + promptLogin(w) + return nil, true, nil + } + + user, _ := loginCookie(r) + namespace, _ := namespaceCookie(r) + return server.WithLoggedIn(user.User, namespace, user.Groups), false, nil } -func (server *Server) parseLogin(w http.ResponseWriter, r *http.Request) error { +func promptLogin(w http.ResponseWriter) { + w.Header().Set("WWW-Authenticate", "Basic") + w.WriteHeader(http.StatusUnauthorized) +} + +func (server *Server) parseLogin(w http.ResponseWriter, r *http.Request) (bool, error) { username, password, ok := r.BasicAuth() if !ok { - return nil + return false, nil } - _, _ = username, password - server.setLoginCookie(w, r, "abc") - return errors.New("todo: use username+password to set cookie") + ok, err := server.auth.Login(username, password) + if err != nil { + return false, err + } + if !ok { + promptLogin(w) + return true, nil + } + groups, err := server.auth.Groups(username) + if err != nil { + return false, err + } + if len(groups) == 0 { + return false, errors.New("user has no groups") + } + setLoginCookie(w, r, User{ + User: username, + Groups: groups, + }) + setNamespaceCookie(w, r, groups[0]) + return false, nil } -func (server *Server) needsLogin(r *http.Request) (bool, error) { - _, ok := server.loginCookie(r) +func needsLogin(r *http.Request) (bool, error) { + user, ok := loginCookie(r) if !ok { return true, nil } - // TODO compare namespace + cookie groups - return false, errors.New("not impl") + group, ok := namespaceCookie(r) + if !ok { + return true, nil + } + for i := range user.Groups { + if group == user.Groups[i] { + return false, nil + } + } + return true, nil } -func (server *Server) setLoginCookie(w http.ResponseWriter, r *http.Request, value string) { +func setLoginCookie(w http.ResponseWriter, r *http.Request, user User) { cookie := &http.Cookie{ Name: "login", - Value: server.encodeCookie(value), + Value: encodeUserCookie(user), Expires: time.Now().Add(time.Hour * 24), } - w.Header().Set("Set-Cookie", cookie.String()) + w.Header().Add("Set-Cookie", cookie.String()) r.AddCookie(cookie) } -func (server *Server) loginCookie(r *http.Request) (string, bool) { +func loginCookie(r *http.Request) (User, bool) { + c, ok := getCookie("login", r) + if !ok { + return User{}, false + } + return decodeUserCookie(c) +} + +func setNamespaceCookie(w http.ResponseWriter, r *http.Request, s string) { + cookie := &http.Cookie{ + Name: "namespace", + Value: s, + Expires: time.Now().Add(time.Hour * 24), + } + w.Header().Add("Set-Cookie", cookie.String()) + r.AddCookie(cookie) +} + +func namespaceCookie(r *http.Request) (string, bool) { + return getCookie("namespace", r) +} + +func getCookie(key string, r *http.Request) (string, bool) { cookies := r.Cookies() for i := range cookies { - if cookies[i].Name == "login" && time.Now().Before(cookies[i].Expires) { - return server.decodeCookie(cookies[i].Value) + if cookies[i].Name == key && (cookies[i].Expires.IsZero() || time.Now().Before(cookies[i].Expires)) { + return cookies[i].Value, true } } return "", false } -func (server *Server) decodeCookie(s string) (string, bool) { - panic("not impl") +func decodeUserCookie(raw string) (User, bool) { + decoded, ok := decodeCookie(raw) + if !ok { + return User{}, ok + } + var user User + err := json.Unmarshal([]byte(decoded), &user) + return user, err == nil } -func (server *Server) encodeCookie(s string) string { - panic("not impl") +func encodeUserCookie(user User) string { + b, err := json.Marshal(user) + if err != nil { + panic(err) + } + return encodeCookie(string(b)) +} + +func encodeCookie(s string) string { + cookie := Cookie{ + Salt: uuid.New().String(), + Value: s, + } + hash := crc32.NewIEEE() + hash.Write([]byte(cookieSecret)) + hash.Write([]byte(cookie.Salt)) + hash.Write([]byte(cookie.Value)) + cookie.Hash = base64.StdEncoding.EncodeToString(hash.Sum(nil)) + b, err := json.Marshal(cookie) + if err != nil { + panic(err) + } + return base64.StdEncoding.EncodeToString(b) +} + +func decodeCookie(s string) (string, bool) { + b, err := base64.StdEncoding.DecodeString(s) + if err != nil { + return "", false + } + var cookie Cookie + if err := json.Unmarshal(b, &cookie); err != nil { + return "", false + } + hash := crc32.NewIEEE() + hash.Write([]byte(cookieSecret)) + hash.Write([]byte(cookie.Salt)) + hash.Write([]byte(cookie.Value)) + if got := base64.StdEncoding.EncodeToString(hash.Sum(nil)); cookie.Hash != got { + return "", false + } + return cookie.Value, true } diff --git a/server/authenticate_test.go b/server/authenticate_test.go new file mode 100644 index 0000000..ae72a64 --- /dev/null +++ b/server/authenticate_test.go @@ -0,0 +1,269 @@ +package main + +import ( + "fmt" + "net/http" + "net/http/httptest" + "path" + "testing" + "time" + + "github.com/google/uuid" +) + +func TestEncodeDecodeCookie(t *testing.T) { + newTestServer(t) + + for i := 0; i < 5; i++ { + value := uuid.New().String() + encoded := encodeCookie(value) + for j := 0; j < 5; j++ { + decoded, ok := decodeCookie(encoded) + if !ok || decoded != value { + t.Errorf("value=%s, encoded=%s, decoded=%s", value, encoded, decoded) + } + } + } +} + +func TestEncodeDecodeUserCookie(t *testing.T) { + newTestServer(t) + + user := User{ + User: "abc", + Groups: []string{"def", "ghi"}, + } + encoded := encodeUserCookie(user) + decoded, ok := decodeUserCookie(encoded) + if !ok { + t.Fatal(ok) + } + if fmt.Sprint(user) != fmt.Sprint(decoded) { + t.Fatal(user, decoded) + } +} + +func TestGetCookie(t *testing.T) { + r := httptest.NewRequest(http.MethodGet, "/", nil) + r.AddCookie(&http.Cookie{ + Name: "abc", + Value: "def", + Expires: time.Now().Add(time.Hour), + }) + got, _ := getCookie("abc", r) + if got != "def" { + t.Fatal(r.Cookies(), got) + } +} + +func TestGetSetLoginCookie(t *testing.T) { + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodGet, "/", nil) + user := User{User: "a", Groups: []string{"g"}} + + setLoginCookie(w, r, user) + if w.Header().Get("Set-Cookie") == "" { + t.Error(w.Header()) + } + if r.Cookies()[0].Name != "login" { + t.Error(r.Cookies()) + } + + got, ok := loginCookie(r) + if !ok { + t.Error(ok) + } + if fmt.Sprint(user) != fmt.Sprint(got) { + t.Error(user, got) + } +} + +func TestNeedsLogin(t *testing.T) { + w := httptest.NewRecorder() + user := User{User: "user", Groups: []string{"group0", "group1"}} + + t.Run("no login provided", func(t *testing.T) { + r := httptest.NewRequest(http.MethodGet, "/", nil) + if ok, err := needsLogin(r); err != nil { + t.Fatal(err) + } else if !ok { + t.Fatal(ok) + } + }) + + t.Run("no namespace provided", func(t *testing.T) { + r := httptest.NewRequest(http.MethodGet, "/", nil) + setLoginCookie(w, r, user) + if ok, err := needsLogin(r); err != nil { + t.Fatal(err) + } else if !ok { + t.Fatal(ok) + } + }) + + t.Run("cookie tampered", func(t *testing.T) { + r := httptest.NewRequest(http.MethodGet, "/", nil) + setLoginCookie(w, r, user) + setNamespaceCookie(w, r, user.Groups[0]) + cookieSecret += "modified" + if ok, err := needsLogin(r); err != nil { + t.Fatal(err) + } else if !ok { + t.Fatal(ok) + } + }) + + t.Run("bad namespace", func(t *testing.T) { + r := httptest.NewRequest(http.MethodGet, "/", nil) + setLoginCookie(w, r, user) + setNamespaceCookie(w, r, "teehee") + if ok, err := needsLogin(r); err != nil { + t.Fatal(err) + } else if !ok { + t.Fatal(ok) + } + }) + + t.Run("ok", func(t *testing.T) { + r := httptest.NewRequest(http.MethodGet, "/", nil) + setLoginCookie(w, r, user) + setNamespaceCookie(w, r, user.Groups[0]) + if ok, err := needsLogin(r); err != nil { + t.Fatal(err) + } else if ok { + t.Fatal(ok) + } + }) +} + +func TestServerParseLogin(t *testing.T) { + server := newTestServer(t) + + t.Run("no basic auth", func(t *testing.T) { + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodGet, "/", nil) + if done, err := server.parseLogin(w, r); done || err != nil { + t.Fatal(done, err) + } + if w.Code == http.StatusUnauthorized { + t.Error(w.Code) + } + }) + + t.Run("bad basic auth", func(t *testing.T) { + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodGet, "/", nil) + r.SetBasicAuth("junk", "junk") + if done, err := server.parseLogin(w, r); !done || err != nil { + t.Fatal(done, err) + } + if w.Code != http.StatusUnauthorized { + t.Error(w.Code) + } + }) + + t.Run("ok", func(t *testing.T) { + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodGet, "/", nil) + r.SetBasicAuth("user", "passw") + if done, err := server.parseLogin(w, r); done || err != nil { + t.Fatal(done, err) + } + if w.Code == http.StatusUnauthorized { + t.Error(w.Code) + } + if len(w.Header()["Set-Cookie"]) != 2 { + t.Error(w.Header()) + } + if len(r.Cookies()) != 2 { + t.Error(r.Cookies()) + } + if v, ok := namespaceCookie(r); !ok || v != "group" { + t.Error(r.Cookies()) + } + if user, ok := loginCookie(r); !ok || user.User != "user" || user.Groups[0] != "group" || user.Groups[1] != "othergroup" { + t.Error(user) + } + }) +} + +func TestServerAuthenticate(t *testing.T) { + server := newTestServer(t) + + t.Run("ok: already logged in", func(t *testing.T) { + r := httptest.NewRequest(http.MethodGet, "/", nil) + setLoginCookie(httptest.NewRecorder(), r, User{User: "user", Groups: []string{"group", "othergroup"}}) + setNamespaceCookie(httptest.NewRecorder(), r, "othergroup") + s2, done, err := server.authenticate(nil, r) + if err != nil { + t.Error(err) + } + if done { + t.Error(done) + } + if server == s2 { + t.Error(done) + } + if server.loggedIn != nil { + t.Error(server.loggedIn) + } + if s2.loggedIn == nil { + t.Error(s2.loggedIn) + } + if s2.loggedIn.user != "user" { + t.Error(s2.loggedIn) + } + if s2.loggedIn.group != "othergroup" { + t.Error(s2.loggedIn) + } + if fmt.Sprint(s2.loggedIn.groups) != fmt.Sprint([]string{"group", "othergroup"}) { + t.Error(s2.loggedIn) + } + }) + + t.Run("ok: basic auth", func(t *testing.T) { + r := httptest.NewRequest(http.MethodGet, "/", nil) + w := httptest.NewRecorder() + r.SetBasicAuth("user", "passw") + s2, done, err := server.authenticate(w, r) + if err != nil { + t.Error(err) + } + if done { + t.Error(done) + } + if server == s2 { + t.Error(done) + } + if server.loggedIn != nil { + t.Error(server.loggedIn) + } + if s2.loggedIn == nil { + t.Error(s2.loggedIn) + } + if s2.loggedIn.user != "user" { + t.Error(s2.loggedIn) + } + if s2.loggedIn.group != "group" { + t.Error(s2.loggedIn) + } + if fmt.Sprint(s2.loggedIn.groups) != fmt.Sprint([]string{"group", "othergroup"}) { + t.Error(s2.loggedIn) + } + if w.Code != http.StatusOK { + t.Error(w.Code) + } + if len(w.Header()["Set-Cookie"]) != 2 { + t.Error(w.Header()) + } + }) +} + +func newTestServer(t *testing.T) *Server { + cookieSecret = uuid.New().String() + p := path.Join(t.TempDir(), "auth.yaml") + ensureAndWrite(p, []byte(`{"users":{"user":{"password":"passw", "groups":["group", "othergroup"]}}}`)) + return &Server{ + auth: NewFileAuth(p), + } +} diff --git a/server/main.go b/server/main.go index b09b4c6..b40663c 100644 --- a/server/main.go +++ b/server/main.go @@ -1,20 +1,28 @@ package main import ( + "errors" "local/args" "net/http" + "os" + "path" "strconv" + "strings" ) func main() { as := args.NewArgSet() as.Append(args.INT, "p", "port to listen on", 3004) as.Append(args.STRING, "d", "root dir with /index.html and /media and /files", "./public") - as.Append(args.BOOL, "ldap", "ldap features", false) + as.Append(args.STRING, "auth", "auth mode [none, path/to/some.yaml, ldap", "none") if err := as.Parse(); err != nil { panic(err) } - s := NewServer(as.GetString("d"), as.GetBool("ldap")) + auth, err := authFactory(as.GetString("auth")) + if err != nil { + panic(err) + } + s := NewServer(as.GetString("d"), auth) if err := s.Routes(); err != nil { panic(err) } @@ -22,3 +30,21 @@ func main() { panic(err) } } + +func authFactory(key string) (auth, error) { + switch path.Base(strings.ToLower(key)) { + case "none", "": + return nil, nil + case "ldap": + return nil, errors.New("not impl ldap auth") + } + stat, err := os.Stat(key) + if os.IsNotExist(err) { + return nil, errors.New("looks like auth path does not exist") + } else if err != nil { + return nil, err + } else if stat.IsDir() { + return nil, errors.New("looks like auth path is a dir") + } + return NewFileAuth(key), nil +} diff --git a/server/server.go b/server/server.go index 32617d4..04ad180 100644 --- a/server/server.go +++ b/server/server.go @@ -26,19 +26,36 @@ import ( ) type Server struct { - router *router.Router - root string - ldap bool + router *router.Router + root string + auth auth + loggedIn *loggedIn } -func NewServer(root string, ldap bool) *Server { +type loggedIn struct { + user string + group string + groups []string +} + +func NewServer(root string, auth auth) *Server { return &Server{ router: router.New(), root: root, - ldap: ldap, + auth: auth, } } +func (server *Server) WithLoggedIn(user, group string, groups []string) *Server { + s2 := *server + s2.loggedIn = &loggedIn{ + user: user, + group: group, + groups: groups, + } + return &s2 +} + func (server *Server) Routes() error { wildcard := func(s string) string { return strings.TrimSuffix(s, "/") + "/" + router.Wildcard @@ -67,6 +84,16 @@ func (server *Server) Routes() error { } func (server *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if server.auth != nil { + if s2, done, err := server.authenticate(w, r); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } else if done { + return + } else if s2 != nil { + server = s2 + } + } server.router.ServeHTTP(w, r) } diff --git a/server/server_test.go b/server/server_test.go index c6e300e..b961bc2 100644 --- a/server/server_test.go +++ b/server/server_test.go @@ -12,7 +12,7 @@ import ( ) func TestServerRoutes(t *testing.T) { - server := NewServer(t.TempDir()) + server := NewServer(t.TempDir(), nil) if err := server.Routes(); err != nil { t.Fatal(err) } @@ -153,7 +153,7 @@ func TestServerRoutes(t *testing.T) { } func TestServerPutTreeGetFile(t *testing.T) { - server := NewServer(t.TempDir()) + server := NewServer(t.TempDir(), nil) if err := server.Routes(); err != nil { t.Fatal(err) } diff --git a/todo.yaml b/todo.yaml index 5f22a77..1839b77 100644 --- a/todo.yaml +++ b/todo.yaml @@ -1,10 +1,13 @@ todo: -- LDAP login +- encrypt files at docker build time, put decrypt key in vault +- create fileauth login file +- secret for cookie encrypt+decrypt - secrets - team-specific deployment;; prob grab a VM - mark generated via meta so other files in the dir can be created, deleted, replaced safely - links like `/Smoktests` in user-files home wiki don't rewrite - map fullURLScraped->internalURL for relative links sometimes +- LDAP login - scrape odo - rewrite links if available to local - anchor per line