test server auth

master
Bel LaPointe 2022-02-18 09:16:23 -07:00
parent 09c06a4a0c
commit b951e057c4
6 changed files with 485 additions and 44 deletions

View File

@ -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
}

269
server/authenticate_test.go Normal file
View File

@ -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),
}
}

View File

@ -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
}

View File

@ -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)
}

View File

@ -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)
}

View File

@ -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