diff options
author | sanine <sanine.not@pm.me> | 2023-05-14 20:12:06 -0500 |
---|---|---|
committer | sanine <sanine.not@pm.me> | 2023-05-14 20:12:06 -0500 |
commit | 5b4251fd39c43e4cfed27e032a4efb2bbba28e38 (patch) | |
tree | f51840d5607eba0db9262045e330a1c8b8393449 | |
parent | 9571ccc4d87907067df98edeaa78f0c167fcff43 (diff) |
add auth & pages
-rw-r--r-- | auth/auth.go | 33 | ||||
-rw-r--r-- | auth/session.go | 112 | ||||
-rw-r--r-- | config.json | 15 | ||||
-rw-r--r-- | config/config.go | 1 | ||||
-rw-r--r-- | go.mod | 7 | ||||
-rw-r--r-- | go.sum | 18 | ||||
-rw-r--r-- | login.go | 98 | ||||
-rw-r--r-- | main.go | 112 | ||||
-rw-r--r-- | page/default.go | 88 | ||||
-rw-r--r-- | page/page.go | 110 | ||||
-rw-r--r-- | proxy.go | 112 |
11 files changed, 698 insertions, 8 deletions
diff --git a/auth/auth.go b/auth/auth.go new file mode 100644 index 0000000..ac908ab --- /dev/null +++ b/auth/auth.go @@ -0,0 +1,33 @@ +package auth + +import ( + "golang.org/x/crypto/argon2" + "crypto/rand" + "sanine.net/git/phlox/config" +) + + +func GenerateSalt() ([]byte, error) { + salt := make([]byte, 10) + _, err := rand.Read(salt) + return salt, err +} + + +func HashPassword(password string, salt []byte) []byte { + return argon2.IDKey( + []byte(password), salt, + 1, 64*1024, 4, 32, + ) +} + + +func AuthenticateUser(user config.User, password string) bool { + hash := HashPassword(password, user.Salt) + for i, v := range user.PasswordHash { + if v != hash[i] { + return false; + } + } + return true +} diff --git a/auth/session.go b/auth/session.go new file mode 100644 index 0000000..1524e6b --- /dev/null +++ b/auth/session.go @@ -0,0 +1,112 @@ +package auth + +import ( + "fmt" + "time" + "sync" + "crypto/rand" + "encoding/base64" +) + + +type Session struct { + Created time.Time + Modified time.Time +} + + +type Sessions struct { + s map[string]Session + lock sync.Mutex +} + + +func NewSessionContainer() *Sessions { + return &Sessions{ + s: make(map[string]Session), + } +} + + +func createSessionId() (string, error) { + bytes := make([]byte, 32) + _, err := rand.Read(bytes) + if err != nil { + return "", err + } + + return base64.StdEncoding.EncodeToString(bytes), nil +} + + +func (s *Sessions) NewSession() (string, error) { + s.lock.Lock() + defer s.lock.Unlock() + + id, err := createSessionId() + if err != nil { + return "", err + } + + session := Session{ + Created: time.Now(), + Modified: time.Now(), + } + + s.s[id] = session + return id, nil +} + + +func (s *Sessions) IsSessionValid(id string) bool { + s.lock.Lock() + defer s.lock.Unlock() + + _, ok := s.s[id] + return ok +} + + +func (s *Sessions) GetSession(id string) (Session, error) { + s.lock.Lock() + defer s.lock.Unlock() + + session, ok := s.s[id] + if !ok { + return Session{}, fmt.Errorf("invalid session id: %v", id) + } + return session, nil +} + + +func (s *Sessions) TouchSession(id string) { + s.lock.Lock() + defer s.lock.Unlock() + + session := s.s[id] + session.Modified = time.Now() + s.s[id] = session +} + + +func (s *Sessions) DeleteSession(id string) { + s.lock.Lock() + defer s.lock.Unlock() + + delete(s.s, id) +} + + +func (s *Sessions) CleanSessions(maxIdle time.Duration) { + s.lock.Lock() + defer s.lock.Unlock() + + expire := time.Now().Add(-maxIdle) + for id, session := range s.s { + if session.Modified.Before(expire) { + // last modified before the expiration time + // so this session is expired + delete(s.s, id) + } + } +} diff --git a/config.json b/config.json index 16de309..cb1b493 100644 --- a/config.json +++ b/config.json @@ -1,6 +1,17 @@ { "ListenAddress": "localhost:3333", "AssetDirectory": "", - "Users": [], - "Endpoints": [] + "Users": [ + { + "Name": "kate", + "PasswordHash": "dxkkRedmREicKUNku0tM46vBfEIdnsCMkPX2Wvhhy2k=", + "Salt": "XaRgOwSnNZfoFA==" + } + ], + "Endpoints": [ + { + "Path": "/files", + "Address": "http://localhost:8000" + } + ] } diff --git a/config/config.go b/config/config.go index 8a22f9d..fe574a7 100644 --- a/config/config.go +++ b/config/config.go @@ -7,7 +7,6 @@ import ( type User struct { - Id string Name string PasswordHash []byte Salt []byte @@ -1,3 +1,10 @@ module sanine.net/git/phlox go 1.20 + +require ( + github.com/sirupsen/logrus v1.9.0 + golang.org/x/crypto v0.9.0 +) + +require golang.org/x/sys v0.8.0 // indirect @@ -0,0 +1,18 @@ +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/sirupsen/logrus v1.9.0 h1:trlNQbNUG3OdDrDil03MCb1H2o9nJ1x4/5LYw7byDE0= +github.com/sirupsen/logrus v1.9.0/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +golang.org/x/crypto v0.9.0 h1:LF6fAI+IutBocDJ2OT0Q1g8plpYljMZ4+lty+dsqw3g= +golang.org/x/crypto v0.9.0/go.mod h1:yrmDGqONDYtNj3tH8X9dzUun2m2lzPa9ngI6/RUPGR0= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0 h1:EBmGv8NaZBZTWvrbjNoL6HVt+IVy3QDQpJs7VRIw3tU= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/login.go b/login.go new file mode 100644 index 0000000..d8db817 --- /dev/null +++ b/login.go @@ -0,0 +1,98 @@ +package main + +import ( + "strings" + "net/http" + "sanine.net/git/phlox/page" + "sanine.net/git/phlox/config" + "sanine.net/git/phlox/auth" + log "github.com/sirupsen/logrus" +) + + +func Login( + w http.ResponseWriter, + r *http.Request, + users map[string]config.User, + s *auth.Sessions, + pages page.Pages, +) { + if r.Method == "POST" { + LoginPostHandler(w, r, users, s, pages) + } else { + LoginGetHandler(w, r, s, pages) + } +} + + +func Logout( + w http.ResponseWriter, + r *http.Request, + s *auth.Sessions, +) { + if authenticateRequest(r, s) { + cookie, _ := r.Cookie("phlox-session") + s.DeleteSession(cookie.Value) + } + + w.Header().Add("Location", "/phlox/login") + w.WriteHeader(http.StatusTemporaryRedirect) +} + + +func LoginPostHandler( + w http.ResponseWriter, + r *http.Request, + users map[string]config.User, + s *auth.Sessions, + pages page.Pages, +) { + loggedIn := authenticateRequest(r, s) + if loggedIn { + pages.ServeLoggedIn(w) + return + } + + r.ParseForm() + username := r.Form.Get("username") + password := strings.TrimSpace(r.Form.Get("password")) + + user, ok := users[username] + if !ok { + w.WriteHeader(http.StatusUnauthorized) + w.Write([]byte("bad username or password")) + } + + ok = auth.AuthenticateUser(user, password) + if !ok { + w.WriteHeader(http.StatusUnauthorized) + w.Write([]byte("bad username or password")) + } + + session, err := s.NewSession() + if err != nil { + pages.ServeError500(w) + log.Error(err) + } + + http.SetCookie(w, &http.Cookie{ + Name: "phlox-session", + Value: session, + SameSite: http.SameSiteLaxMode, + }) + pages.ServeLoggedIn(w) +} + + +func LoginGetHandler( + w http.ResponseWriter, + r *http.Request, + s *auth.Sessions, + pages page.Pages, +) { + if authenticateRequest(r, s) { + pages.ServeLoggedIn(w) + } else { + pages.ServeLogin(w) + } +} @@ -3,15 +3,81 @@ package main import ( "os" "fmt" + "flag" + "errors" + "net/http" + "encoding/json" "sanine.net/git/phlox/config" + "sanine.net/git/phlox/auth" + "sanine.net/git/phlox/page" + log "github.com/sirupsen/logrus" ) func main() { - conf := loadConfig("config.json") - fmt.Println(conf.ListenAddress) - fmt.Println(conf.AssetDirectory) - fmt.Println(len(conf.Users)) - fmt.Println(len(conf.Endpoints)) + configFile := parseFlags() + + conf := loadConfig(configFile) + sessions := auth.NewSessionContainer() + pages := loadPages(conf) + users := getUsers(conf) + + // add phlox endpoints + http.HandleFunc("/phlox/login", func(w http.ResponseWriter, r *http.Request) { + Login(w, r, users, sessions, pages) + }) + + http.HandleFunc("/phlox/logout", func(w http.ResponseWriter, r *http.Request) { + Logout(w, r, sessions) + }) + + // add reverse proxy endpoints + for _, e := range conf.Endpoints { + addEndpoint(sessions, pages, e) + } + + log.Infof("listening on %v", conf.ListenAddress) + log.Fatal(http.ListenAndServe(conf.ListenAddress, nil)) +} + + +func parseFlags() string { + var configFile string + var username string + var passwd string + flag.StringVar(&configFile, "c", "./config.json", "the configuration file to use") + flag.StringVar(&passwd, "passwd", "", "hash a password") + flag.StringVar(&username, "user", "", "optional username for the JSON output of --passwd") + flag.Parse() + + if passwd != "" { + // generate a user JSON block with the password hash + // and random salt, then exit without launching the + // phlox daemon + showHash(username, passwd) + os.Exit(0) + } + + return configFile +} + + +func showHash(username, passwd string) { + salt, err := auth.GenerateSalt() + if err != nil { + fmt.Fprintf(os.Stderr, "failed to generate salt: %v\n", err.Error()) + } + + user := config.User{ + Name: username, + PasswordHash: auth.HashPassword(passwd, salt), + Salt: salt, + } + + blob, err := json.MarshalIndent(user, "", "\t") + if err != nil { + fmt.Fprintf(os.Stderr, "failed to generate JSON: %v\n", err.Error()) + } + fmt.Println(string(blob)) } @@ -23,3 +89,39 @@ func loadConfig(filename string) config.Config { } return conf } + + +func loadPages(c config.Config) page.Pages { + pages, err := page.LoadPages(c) + if err != nil { + fmt.Fprintf(os.Stderr, "failed to load html pages: %v\n", err.Error()) + os.Exit(1) + } + return pages +} + + +func getUsers(c config.Config) map[string]config.User { + users := make(map[string]config.User) + for _, user := range c.Users { + users[user.Name] = user + } + return users +} + + +func authenticateRequest(r *http.Request, s *auth.Sessions) bool { + cookie, err := r.Cookie("phlox-session") + if errors.Is(err, http.ErrNoCookie) { + return false + } + + id := cookie.Value + valid := s.IsSessionValid(id) + if !valid { + return false + } + + s.TouchSession(id) + return true +} diff --git a/page/default.go b/page/default.go new file mode 100644 index 0000000..ea51acc --- /dev/null +++ b/page/default.go @@ -0,0 +1,88 @@ +package page + +import ( + "strings" + "text/template" +) + + +type page struct { + Title string + Body string +} + + +func buildPage(p page) (string, error) { + t, err := template.New("").Parse(` +<!doctype html> +<html> + <head> + <meta charset="utf-8"> + <meta name="viewport" content="width=device-width, initial-scale=1"> + <title>{{ .Title }}</title> + </head> + <body> + {{ .Body }} + </body> +</html> + `) + if err != nil { + return "", err + } + + var b strings.Builder + + err = t.Execute(&b, p) + if err != nil { + return "", err + } + + return b.String(), nil +} + + +func DefaultLogin() (string, error) { + return buildPage(page{ + Title: "Login", + Body: ` + <form method="post"> + <label for="username">Username</label> + <input type="text" id="username" name="username"> + <br> + <label for="password">Password</label> + <input type="text" id="password" name="password"> + <br> + <input type="submit" value="Submit"> + </form> + `, + }) +} + + +func DefaultLoggedIn() (string, error) { + return buildPage(page{ + Title: "Logged In", + Body: ` + <h1>You are logged in.</h1> + <form method="get" action="/phlox/logout"> + <input type="submit" value="Log Out"> + </form> + `, + }) +} + + +func DefaultError404() (string, error) { + return buildPage(page{ + Title: "404 Not Found", + Body: "<h1>Error 404: Page not found</h1>", + }) +} + + +func DefaultError500() (string, error) { + return buildPage(page{ + Title: "500 Not Found", + Body: "<h1>Error 500: Page not found</h1>", + }) +} diff --git a/page/page.go b/page/page.go new file mode 100644 index 0000000..c58a344 --- /dev/null +++ b/page/page.go @@ -0,0 +1,110 @@ +package page + +import ( + "os" + "io/fs" + "errors" + "path/filepath" + "net/http" + "sanine.net/git/phlox/config" +) + + +type Pages struct { + Login string + LoggedIn string + Error404 string + Error500 string +} + + +func (p Pages) ServeLogin(w http.ResponseWriter) { + w.Write([]byte(p.Login)) +} + + +func (p Pages) ServeLoggedIn(w http.ResponseWriter) { + w.Write([]byte(p.LoggedIn)) +} + + +func (p Pages) ServeError404(w http.ResponseWriter) { + w.WriteHeader(http.StatusNotFound) + w.Write([]byte(p.Error404)) +} + + +func (p Pages) ServeError500(w http.ResponseWriter) { + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte(p.Error500)) +} + + +type DefaultPage func() (string, error) + + +func fileExists(filename string) (bool, error) { + _, err := os.Stat(filename) + if err == nil { + return true, nil + } else if errors.Is(err, fs.ErrNotExist) { + return false, nil + } else { + // unknown error + return false, err + } +} + + +func loadPage(c config.Config, filename string, fallback DefaultPage) (string, error) { + fullPath := filepath.Join(c.AssetDirectory, filename) + exist, err := fileExists(fullPath) + if err != nil { + return "", err + } + + if exist { + bytes, err := os.ReadFile(fullPath) + if err != nil { + return "", err + } + return string(bytes), nil + } else { + // file does not exist + // use built-in + str, err := fallback() + if err != nil { + return "", err + } + return str, nil + } +} + + +func LoadPages(c config.Config) (Pages, error) { + var pages Pages + var err error + + pages.Login, err = loadPage(c, "login.html", DefaultLogin) + if err != nil { + return Pages{}, err + } + + pages.LoggedIn, err = loadPage(c, "logged_in.html", DefaultLoggedIn) + if err != nil { + return Pages{}, err + } + + + pages.Error404, err = loadPage(c, "404.html", DefaultError404) + if err != nil { + return Pages{}, err + } + + pages.Error500, err = loadPage(c, "500.html", DefaultError500) + if err != nil { + return Pages{}, err + } + + return pages, nil +} diff --git a/proxy.go b/proxy.go new file mode 100644 index 0000000..7be9419 --- /dev/null +++ b/proxy.go @@ -0,0 +1,112 @@ +package main + +import ( + "io" + "strings" + "net" + "net/http" + "net/url" + "sanine.net/git/phlox/auth" + "sanine.net/git/phlox/config" + "sanine.net/git/phlox/page" + log "github.com/sirupsen/logrus" +) + + +type Endpoint struct { + Path string + Origin *url.URL +} + + +func addEndpoint(s *auth.Sessions, pages page.Pages, e config.Endpoint) { + log.Infof("proxying endpoint %v to %v", e.Path, e.Address) + origin, err := url.Parse(e.Address) + if err != nil { + log.Fatal(err) + } + + end := Endpoint{ + Path: e.Path, + Origin: origin, + } + + http.HandleFunc(e.Path + "/", func(w http.ResponseWriter, r *http.Request) { + log.Infof("REQ: %v", r.URL.Path) + proxy(w, r, s, pages, end) + }) +} + + +func proxy(w http.ResponseWriter, r *http.Request, s *auth.Sessions, pages page.Pages, end Endpoint) { + ok := authenticateRequest(r, s) + if !ok { + w.Header().Set("Location", "/phlox/login") + w.WriteHeader(http.StatusTemporaryRedirect) + return + } + + response := proxyRequest(w, r, pages, end) + if response != nil { + proxyResponse(w, response) + } +} + + +func proxyRequest(w http.ResponseWriter, r *http.Request, pages page.Pages, end Endpoint) *http.Response { + // configure host address + r.Host = end.Origin.Host + r.URL.Host = end.Origin.Host + + // strip proxy endpoint path from request path + r.URL.Path = strings.TrimPrefix(r.URL.Path, end.Path) + + // set X-Forwarded-For + forwardedFor, _, _ := net.SplitHostPort(r.RemoteAddr) + r.Header.Set("X-Forwarded-For", forwardedFor) + + // misc request cleanups + r.URL.Scheme = end.Origin.Scheme + r.RequestURI = "" + + // make request + response, err := http.DefaultClient.Do(r) + if err != nil { + pages.ServeError500(w) + log.Error(err) + return nil + } + + return response +} + + +func proxyResponse(w http.ResponseWriter, response *http.Response) { + // copy header + for key, values := range response.Header { + for _, value := range values { + w.Header().Add(key, value) + } + } + + // get trailer keys + trailerKeys := []string{} + for key := range response.Trailer { + trailerKeys = append(trailerKeys, key) + } + if (len(trailerKeys) > 0) { + w.Header().Set("Trailer", strings.Join(trailerKeys, ",")) + } + + w.WriteHeader(response.StatusCode) + + // copy body to client + io.Copy(w, response.Body) + + // write trailers + for key, values := range response.Trailer { + for _, value := range values { + w.Header().Set(key, value) + } + } +} |