diff options
Diffstat (limited to 'cmd')
| -rw-r--r-- | cmd/server/app.go | 18 | ||||
| -rw-r--r-- | cmd/server/auth.go | 108 | ||||
| -rw-r--r-- | cmd/server/dashboard.go | 5 | ||||
| -rw-r--r-- | cmd/server/edit.go | 90 | ||||
| -rw-r--r-- | cmd/server/feed.go | 2 | ||||
| -rw-r--r-- | cmd/server/handlers.go | 7 | ||||
| -rw-r--r-- | cmd/server/main.go | 7 | ||||
| -rw-r--r-- | cmd/server/middleware.go | 142 |
8 files changed, 373 insertions, 6 deletions
diff --git a/cmd/server/app.go b/cmd/server/app.go index 4f62a09..d4977ab 100644 --- a/cmd/server/app.go +++ b/cmd/server/app.go @@ -16,11 +16,15 @@ type App struct { redisPool *redis.Pool RedisKey string - ReIndexPath string - StaticDirectory string - BaseTemplate string - DocumentSplit string - Suffix string + ReIndexPath string + StaticDirectory string + BaseTemplate string + TemplateDirectory string + DocumentSplit string + Suffix string + + // Related to user authentication + auth *Auth // Related to the Atom feed Title string @@ -59,6 +63,10 @@ func loadConf(fn string) (*App, error) { page.BaseTemplate = app.BaseTemplate } + if app.TemplateDirectory != "" { + page.TemplateDirectory = app.TemplateDirectory + } + if app.DocumentSplit != "" { page.DocumentSplit = app.DocumentSplit } diff --git a/cmd/server/auth.go b/cmd/server/auth.go new file mode 100644 index 0000000..caade97 --- /dev/null +++ b/cmd/server/auth.go @@ -0,0 +1,108 @@ +package main + +import ( + "crypto/rand" + "encoding/base64" + "encoding/json" + "fmt" + "net/http" + "os" + "strings" + + "riedstra.dev/mitch/go-website/users" +) + +type Auth struct { + Users []*users.SiteUser `json:"Users"` + // How long are JWTs valid? + LoginHours int `json:"LoginHours"` + // JWT secret + TokenKey string `json:"TokenKey"` + // Are cookies HTTP only? + HTTPOnly bool `json:"HTTPOnly"` + // Do they require HTTPs? + Secure bool `json:"SecureCookie"` + // See https://pkg.go.dev/net/http#SameSite + // You probably want this set to 3 + SameSiteStrict http.SameSite `json:"SameSiteStrict"` +} + +func GenTokenKey() string { + r := make([]byte, 16) // 128 bits + _, err := rand.Read(r) + + if err != nil { + // Not my favorite thing, but I consider this to be a + // critical issue + panic(fmt.Errorf("reading random bytes: %w", err)) + } + + return base64.RawURLEncoding.EncodeToString(r) +} + +func (a *App) ReadAuth(fn string) error { //nolint + auth := &Auth{ + Users: []*users.SiteUser{ + { + Username: "admin", + Password: "admin", + }, + }, + LoginHours: 1, + TokenKey: GenTokenKey(), + HTTPOnly: true, + Secure: true, + SameSiteStrict: http.SameSiteStrictMode, + } + + var dec *json.Decoder + + fh, err := os.Open(fn) + if err != nil { + if strings.Contains(err.Error(), "no such file") { + goto write + } + + return fmt.Errorf("opening %s: %w", fn, err) + } + + dec = json.NewDecoder(fh) + dec.DisallowUnknownFields() + + err = dec.Decode(auth) + if err != nil { + return fmt.Errorf("decoding file %s: %w", fn, err) + } + + err = fh.Close() + if err != nil { + return fmt.Errorf("closing file %s: %w", fn, err) + } + + for _, u := range auth.Users { + err = u.SetPasswordHashIfNecessary() + if err != nil { + return fmt.Errorf("setting password hash for: %s: %w", + u.Username, err) + } + } + +write: + fh, err = os.OpenFile(fn, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0600) + + if err != nil { + return fmt.Errorf("opening file %s: %w", fn, err) + } + + enc := json.NewEncoder(fh) + enc.SetIndent("", " ") + + err = enc.Encode(auth) + if err != nil { + return fmt.Errorf("encoding to file %s: %w", fn, err) + } + + a.auth = auth + + return nil +} diff --git a/cmd/server/dashboard.go b/cmd/server/dashboard.go new file mode 100644 index 0000000..a07e722 --- /dev/null +++ b/cmd/server/dashboard.go @@ -0,0 +1,5 @@ +package main + +// func (a *App) DashboardHandler(w http.ResponseWriter, r *http.Request) { +// page.Render(" +// } diff --git a/cmd/server/edit.go b/cmd/server/edit.go new file mode 100644 index 0000000..730e6e9 --- /dev/null +++ b/cmd/server/edit.go @@ -0,0 +1,90 @@ +package main + +import ( + "bytes" + "html" + "io" + "log" + "net/http" + "os" + "path/filepath" + + "riedstra.dev/mitch/go-website/page" +) + +func (a *App) EditPage(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" { + page.Render(w, r, page.TemplateDirectory+"/4xx", map[string]interface{}{ + "Title": "Method Not allowed", + "Description": "Method not allowed", + }, http.StatusMethodNotAllowed) + + return + } + + p := r.URL.Path + + p = filepath.Clean(p) + + fh, err := os.Open("./" + p + page.Suffix) + if err != nil { + log.Printf("opening page: %s", err) + a.Err500Default(w, r) + + return + } + + b, err := io.ReadAll(fh) + if err != nil { + log.Printf("opening page: %s", err) + a.Err500Default(w, r) + + return + } + + page.Render(w, r, page.TemplateDirectory+"/edit", map[string]interface{}{ + "Page": p, + "Content": html.EscapeString(string(b)), + }, http.StatusOK) +} + +func (a *App) SaveEditPage(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + a.Err500Default(w, r) + + return + } + + p := r.URL.Path + content := r.FormValue("content") + + content = html.UnescapeString(content) + p = filepath.Clean(p) + + fn := "./" + p + page.Suffix + + fh, err := os.OpenFile(fn, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0644) + if err != nil { + log.Printf("opening file %s for writing: %s", fn, err) + a.Err500Default(w, r) + + return + } + + c := bytes.ReplaceAll([]byte(content), []byte{'\r'}, []byte{}) + + _, err = fh.Write(c) + if err != nil { + log.Printf("opening file %s for writing: %s", fn, err) + a.Err500Default(w, r) + + return + } + + http.Redirect(w, r, "/"+r.URL.Path, http.StatusFound) + + err = a.ClearRedis() // Clear out the cache if any + if err != nil { + log.Printf("after editing %s: %s", fn, err) + } +} diff --git a/cmd/server/feed.go b/cmd/server/feed.go index 880eeb4..212e5da 100644 --- a/cmd/server/feed.go +++ b/cmd/server/feed.go @@ -203,7 +203,7 @@ func (a *App) FeedHandler(w http.ResponseWriter, r *http.Request) { //nolint:fun } entry := Entry{ - Title: p.Title, + Title: p.Title(), Updated: &p.Date.Time, Links: []Link{{Href: strings.Join([]string{a.SiteURL, p.Path()}, "/")}}, } diff --git a/cmd/server/handlers.go b/cmd/server/handlers.go index 205d462..1711ae3 100644 --- a/cmd/server/handlers.go +++ b/cmd/server/handlers.go @@ -15,6 +15,13 @@ func (a *App) ServeHTTP(w http.ResponseWriter, r *http.Request) { rtr.HandleFunc(a.ReIndexPath, a.RebuildIndexHandler) rtr.PathPrefix("/static/").Handler(a.StaticHandler()) + rtr.HandleFunc("/login", a.LoginHandler) + rtr.Handle("/logout", a.RequiresLogin(http.HandlerFunc(a.LogoutHandler))) + rtr.PathPrefix("/edit/").Handler( + a.RequiresLogin(http.StripPrefix("/edit/", http.HandlerFunc(a.EditPage)))).Methods("GET") + rtr.PathPrefix("/edit/").Handler( + a.RequiresLogin(http.StripPrefix("/edit/", http.HandlerFunc(a.SaveEditPage)))).Methods("POST") + if a.redisPool != nil { rtr.PathPrefix(fmt.Sprintf("/%s/{tag}", a.FeedPrefix)).Handler( rediscache.HandleWithParams(a.redisPool, a.RedisKey, diff --git a/cmd/server/main.go b/cmd/server/main.go index 912cba0..ee8cf6f 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -28,6 +28,8 @@ func main() { //nolint:funlen directory := fl.String("d", ".", "Directory to serve.") version := fl.Bool("v", false, "Print the version then exit") confFn := fl.String("c", "conf.yml", "Location for the config file") + authConfFn := fl.String("ac", "auth.json", + "Location for authorization configuration file") verbose := fl.Bool("V", false, "Be more verbose ( dump config, etc ) ") fl.StringVar(&page.TimeFormat, "T", page.TimeFormat, "Set the page time format, be careful with this") @@ -61,6 +63,11 @@ func main() { //nolint:funlen app = &App{} } + err = app.ReadAuth(*authConfFn) + if err != nil { + log.Println(err) + } + if app.ReIndexPath == "" || *indexPath != defaultIndexPath { app.ReIndexPath = *indexPath } diff --git a/cmd/server/middleware.go b/cmd/server/middleware.go new file mode 100644 index 0000000..5e4bf26 --- /dev/null +++ b/cmd/server/middleware.go @@ -0,0 +1,142 @@ +package main + +import ( + "log" + "net/http" + "time" + + jwt "github.com/dgrijalva/jwt-go" + "riedstra.dev/mitch/go-website/page" + "riedstra.dev/mitch/go-website/users" +) + +func (a *App) Err5xx(w http.ResponseWriter, r *http.Request, + statusCode int, title, desc string) { + page.Render5xx(w, r, map[string]interface{}{ + "Error": title, + "Description": desc, + }, statusCode) +} + +func (a *App) Err500Default(w http.ResponseWriter, r *http.Request) { + a.Err5xx(w, r, http.StatusInternalServerError, "Internal server error", + "Internal server error.") +} + +func (a *App) LogoutHandler(w http.ResponseWriter, r *http.Request) { + http.SetCookie(w, &http.Cookie{ + Name: "Auth", + HttpOnly: a.auth.HTTPOnly, + SameSite: a.auth.SameSiteStrict, + Secure: a.auth.Secure, + Value: "logout", + Expires: time.Now().Add(time.Second * 15), //nolint + }) + + http.Redirect(w, r, "/", http.StatusFound) +} + +func (a *App) LoginHandler(w http.ResponseWriter, r *http.Request) { //nolint + if r.Method == "GET" { + page.RenderForPath(w, r, "login") + + return + } + + if r.Method != "POST" { + a.Err500Default(w, r) + + return + } + + log.Printf("made it") + + username := r.FormValue("username") + password := r.FormValue("password") + + var ( + err error = nil + u *users.SiteUser + found = false + ) + + for _, u = range a.auth.Users { + if u.Username == username { + err = u.CheckPassword(password) + found = true + } + } + + if err != nil || !found { + page.Render(w, r, "login", map[string]interface{}{ + "Error": "Invalid username or password", + "Username": username, + }, http.StatusUnauthorized) + + return + } + + token := jwt.NewWithClaims(jwt.SigningMethodHS512, &jwt.StandardClaims{ + ExpiresAt: time.Now().Add( + time.Hour * time.Duration(a.auth.LoginHours)).Unix(), + Id: u.Username, + }) + + ss, err := token.SignedString([]byte(a.auth.TokenKey)) + if err != nil { + log.Println("login: encountered while setting up JWT: ", err) + a.Err500Default(w, r) + + return + } + + log.Println(ss) + + http.SetCookie(w, &http.Cookie{ + Name: "Auth", + HttpOnly: a.auth.HTTPOnly, + SameSite: a.auth.SameSiteStrict, + Secure: a.auth.Secure, + Value: ss, + }) + + http.Redirect(w, r, "/login", http.StatusFound) +} + +func (a *App) RequiresLogin(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mustLoginResp := func() { + page.Render(w, r, "login", map[string]interface{}{ + "Error": "You must login to view this page", + }, http.StatusUnauthorized) + } + + c, err := r.Cookie("Auth") + if err != nil { + mustLoginResp() + + return + } + + token, err := jwt.Parse(c.Value, + func(token *jwt.Token) (interface{}, error) { + return []byte(a.auth.TokenKey), nil + }, + ) + + if err != nil { + log.Printf("Unauthorized request %s %s", r.Method, r.URL.Path) + mustLoginResp() + + return + } + + if !token.Valid { + mustLoginResp() + + return + } + + next.ServeHTTP(w, r) + }) +} |
