Improve security and logging
This commit is contained in:
parent
affcef2eda
commit
f50f4f1919
10 changed files with 154 additions and 30 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
@ -23,4 +23,5 @@ hibiscus
|
|||
.idea/
|
||||
|
||||
# Because currently it's used in "prod"
|
||||
data/
|
||||
data/
|
||||
/config/log.txt
|
|
@ -13,6 +13,7 @@ As a result of this, it is also neither secure nor idiot-proof.
|
|||
* You can easily export everything in a zip file for backups
|
||||
|
||||
* Everything is plain(text) and simple. No databases, encryption, OAuth, or anything fancy. Even the password is plainte- *wait is this a feature?*
|
||||
* Telegram notifications support
|
||||
|
||||
## Data format:
|
||||
```
|
||||
|
|
3
TODO.md
3
TODO.md
|
@ -2,12 +2,11 @@
|
|||
List of things to add to this project
|
||||
|
||||
## Crucial
|
||||
* Handle all the w.Write errors somehow
|
||||
* Add export feature
|
||||
* Add missing frontend pages
|
||||
* More slog.Debug?
|
||||
|
||||
## Long-medium term considerations
|
||||
* Improve logging, log to files
|
||||
* Make the CLI better
|
||||
* Think about timezones
|
||||
* Consider more secure auth methods
|
||||
|
|
69
auth.go
69
auth.go
|
@ -3,9 +3,43 @@ package main
|
|||
import (
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type failedLogin struct {
|
||||
Username string
|
||||
Password string
|
||||
Timestamp time.Time
|
||||
}
|
||||
|
||||
var failedLogins []failedLogin
|
||||
|
||||
func NoteLoginFail(username string, password string, r *http.Request) {
|
||||
slog.Warn("failed auth", "username", username, "password", password, "address", r.RemoteAddr)
|
||||
NotifyTelegram(fmt.Sprintf("Failed auth attempt in hibiscus:\nusername=%s\npassword=%s\nremote=%s", username, password, r.RemoteAddr))
|
||||
|
||||
attempt := failedLogin{username, password, time.Now()}
|
||||
updatedLogins := []failedLogin{attempt}
|
||||
|
||||
for _, attempt := range failedLogins {
|
||||
if 100 > time.Now().Sub(attempt.Timestamp).Abs().Seconds() {
|
||||
updatedLogins = append(updatedLogins, attempt)
|
||||
}
|
||||
}
|
||||
|
||||
failedLogins = updatedLogins
|
||||
|
||||
// At least 3 failed attempts in last 100 seconds -> likely bruteforce
|
||||
if len(failedLogins) >= 3 {
|
||||
Scram()
|
||||
}
|
||||
}
|
||||
|
||||
// BasicAuth is a middleware that handles authentication & authorization for the app.
|
||||
// It uses BasicAuth because I doubt there is a need for something sophisticated in a small hobby project
|
||||
// Originally taken from https://www.alexedwards.net/blog/basic-authentication-in-go (13.03.2024)
|
||||
|
@ -26,8 +60,9 @@ func BasicAuth(next http.Handler) http.Handler {
|
|||
if usernameMatch && passwordMatch {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
} else {
|
||||
NoteLoginFail(username, password, r)
|
||||
}
|
||||
// TODO: Note failed login attempt?
|
||||
}
|
||||
|
||||
// Unauthorized, inform client that we have auth and return 401
|
||||
|
@ -35,3 +70,35 @@ func BasicAuth(next http.Handler) http.Handler {
|
|||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
})
|
||||
}
|
||||
|
||||
// Scram shuts down the service, useful in case of suspected attack
|
||||
func Scram() {
|
||||
slog.Warn("SCRAM triggered, shutting down")
|
||||
NotifyTelegram("Hibiscus SCRAM triggered, shutting down")
|
||||
os.Exit(0) // TODO: should this be 0 or 1?
|
||||
}
|
||||
|
||||
// NotifyTelegram attempts to send a message to admin through telegram
|
||||
func NotifyTelegram(msg string) {
|
||||
if Cfg.TelegramChat == "" || Cfg.TelegramToken == "" {
|
||||
slog.Warn("ignoring telegram request due to lack of credentials")
|
||||
return
|
||||
}
|
||||
client := &http.Client{}
|
||||
var data = strings.NewReader("chat_id=" + Cfg.TelegramChat + "&text=" + msg)
|
||||
req, err := http.NewRequest("POST", "https://api.telegram.org/bot"+Cfg.TelegramToken+"/sendMessage", data)
|
||||
if err != nil {
|
||||
slog.Error("failed telegram request", "error", err)
|
||||
return
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
slog.Error("failed telegram request", "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
slog.Error("failed telegram request", "status", resp.Status)
|
||||
}
|
||||
}
|
||||
|
|
10
config.go
10
config.go
|
@ -16,10 +16,16 @@ type Config struct {
|
|||
Username string
|
||||
Password string
|
||||
Port int
|
||||
|
||||
TelegramToken string
|
||||
TelegramChat string
|
||||
}
|
||||
|
||||
func (c *Config) Save() error {
|
||||
output := fmt.Sprintf("port=%d\nusername=%s\npassword=%s", c.Port, c.Username, c.Password)
|
||||
if c.TelegramToken != "" && c.TelegramChat != "" {
|
||||
output += fmt.Sprintf("\ntg_token=%s\ntg_chat=%s", c.TelegramToken, c.TelegramChat)
|
||||
}
|
||||
|
||||
f, err := os.OpenFile(ConfigFile, os.O_CREATE|os.O_WRONLY, 0644)
|
||||
if err != nil {
|
||||
|
@ -62,6 +68,10 @@ func (c *Config) Reload() error {
|
|||
if err == nil {
|
||||
c.Port = numVal
|
||||
}
|
||||
} else if key == "tg_token" {
|
||||
c.TelegramToken = value
|
||||
} else if key == "tg_chat" {
|
||||
c.TelegramChat = value
|
||||
}
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
|
|
41
files.go
41
files.go
|
@ -3,9 +3,9 @@ package main
|
|||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
|
@ -14,6 +14,13 @@ import (
|
|||
"time"
|
||||
)
|
||||
|
||||
// HandleWrite checks for error in ResponseWriter.Write output
|
||||
func HandleWrite(_ int, err error) {
|
||||
if err != nil {
|
||||
slog.Error("error writing response", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// GetFile returns raw contents of a txt file in data directory
|
||||
func GetFile(filename string, w http.ResponseWriter) {
|
||||
filename = "data/" + path.Clean(filename) + ".txt" // Does this sanitize the path?
|
||||
|
@ -25,6 +32,9 @@ func GetFile(filename string, w http.ResponseWriter) {
|
|||
|
||||
fileContents, err := os.ReadFile(filename)
|
||||
if err != nil {
|
||||
slog.Error("error reading file",
|
||||
"error", err,
|
||||
"file", filename)
|
||||
http.Error(w, "error reading file", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
@ -41,25 +51,30 @@ func PostFile(filename string, w http.ResponseWriter, r *http.Request) {
|
|||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
w.Write([]byte("error reading body"))
|
||||
HandleWrite(w.Write([]byte("error reading body")))
|
||||
return
|
||||
}
|
||||
|
||||
f, err := os.OpenFile("data/"+filename+".txt", os.O_CREATE|os.O_WRONLY, 0644)
|
||||
filename = "data/" + filename + ".txt"
|
||||
f, err := os.OpenFile(filename, os.O_CREATE|os.O_WRONLY, 0644)
|
||||
if err != nil {
|
||||
fmt.Println("error opening/making file")
|
||||
w.Write([]byte("error opening or creating file"))
|
||||
slog.Error("error opening/making file",
|
||||
"error", err,
|
||||
"file", filename)
|
||||
HandleWrite(w.Write([]byte("error opening or creating file")))
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := f.Write(body); err != nil {
|
||||
fmt.Println("error writing to the file")
|
||||
w.Write([]byte("error writing to file"))
|
||||
slog.Error("error writing to file",
|
||||
"error", err,
|
||||
"file", filename)
|
||||
HandleWrite(w.Write([]byte("error writing to file")))
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Write([]byte("wrote to file"))
|
||||
HandleWrite(w.Write([]byte("wrote to file")))
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
|
@ -75,7 +90,7 @@ func ListFiles(directory string, w http.ResponseWriter) {
|
|||
filenames[i] = file
|
||||
}
|
||||
filenamesJson, err := json.Marshal(filenames)
|
||||
w.Write(filenamesJson)
|
||||
HandleWrite(w.Write(filenamesJson))
|
||||
}
|
||||
|
||||
// GetDay returns a day specified in URL
|
||||
|
@ -83,14 +98,14 @@ func GetDay(w http.ResponseWriter, r *http.Request) {
|
|||
dayString := chi.URLParam(r, "day")
|
||||
if dayString == "" {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
w.Write([]byte("day not specified"))
|
||||
HandleWrite(w.Write([]byte("day not specified")))
|
||||
return
|
||||
}
|
||||
GetFile("day/"+dayString, w)
|
||||
}
|
||||
|
||||
// GetToday runs GetFile with today's daily txt
|
||||
func GetToday(w http.ResponseWriter) {
|
||||
func GetToday(w http.ResponseWriter, _ *http.Request) {
|
||||
GetFile("day/"+time.Now().Format("2006-01-02"), w)
|
||||
}
|
||||
|
||||
|
@ -104,7 +119,7 @@ func GetNote(w http.ResponseWriter, r *http.Request) {
|
|||
noteString := chi.URLParam(r, "note")
|
||||
if noteString == "" {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
w.Write([]byte("note not specified"))
|
||||
HandleWrite(w.Write([]byte("note not specified")))
|
||||
return
|
||||
}
|
||||
GetFile("notes/"+noteString, w)
|
||||
|
@ -115,7 +130,7 @@ func PostNote(w http.ResponseWriter, r *http.Request) {
|
|||
noteString := chi.URLParam(r, "note")
|
||||
if noteString == "" {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
w.Write([]byte("note not specified"))
|
||||
HandleWrite(w.Write([]byte("note not specified")))
|
||||
return
|
||||
}
|
||||
PostFile("notes/"+noteString, w, r)
|
||||
|
|
17
log.go
17
log.go
|
@ -1,8 +1,8 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
|
@ -12,20 +12,27 @@ import (
|
|||
// AppendLog adds the input string to the end of the log file with a timestamp
|
||||
func appendLog(input string) error {
|
||||
t := time.Now().Format("2006-01-02 15:04") // yyyy-mm-dd HH:MM
|
||||
filename := "data/log.txt"
|
||||
|
||||
f, err := os.OpenFile("./data/log.txt", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
|
||||
f, err := os.OpenFile(filename, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
|
||||
if err != nil {
|
||||
fmt.Println("Error opening/making file")
|
||||
slog.Error("error opening/making file",
|
||||
"error", err,
|
||||
"file", filename)
|
||||
return err
|
||||
}
|
||||
|
||||
input = strings.Replace(input, "\n", "", -1) // Remove newlines to maintain structure
|
||||
if _, err := f.Write([]byte(t + " | " + input + "\n")); err != nil {
|
||||
fmt.Println("Error appending to the file")
|
||||
slog.Error("error appending to file",
|
||||
"error", err,
|
||||
"file", filename)
|
||||
return err
|
||||
}
|
||||
if err := f.Close(); err != nil {
|
||||
fmt.Println("Error closing the file")
|
||||
slog.Error("error closing file",
|
||||
"error", err,
|
||||
"file", filename)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
|
|
23
logger.go
Normal file
23
logger.go
Normal file
|
@ -0,0 +1,23 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"io"
|
||||
"log"
|
||||
"log/slog"
|
||||
"os"
|
||||
)
|
||||
|
||||
var LogFile = "config/log.txt"
|
||||
|
||||
// LogInit makes slog output to both stdout and a file
|
||||
func LogInit() {
|
||||
f, err := os.OpenFile(LogFile, os.O_RDWR|os.O_CREATE|os.O_APPEND, 0666)
|
||||
if err != nil {
|
||||
log.Fatalf("error opening log file: %v", err)
|
||||
}
|
||||
// No defer f.Close() because that breaks the MultiWriter
|
||||
|
||||
w := io.MultiWriter(f, os.Stdout)
|
||||
|
||||
slog.SetDefault(slog.New(slog.NewTextHandler(w, nil)))
|
||||
}
|
1
main.go
1
main.go
|
@ -3,5 +3,6 @@ package main
|
|||
var Cfg = ConfigInit()
|
||||
|
||||
func main() {
|
||||
LogInit()
|
||||
Serve()
|
||||
}
|
||||
|
|
16
serve.go
16
serve.go
|
@ -1,10 +1,10 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
"log"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
)
|
||||
|
@ -29,17 +29,17 @@ func Serve() {
|
|||
apiRouter.Get("/readme", func(w http.ResponseWriter, r *http.Request) { GetFile("readme", w) })
|
||||
apiRouter.Post("/readme", func(w http.ResponseWriter, r *http.Request) { PostFile("readme", w, r) })
|
||||
apiRouter.Get("/log", func(w http.ResponseWriter, r *http.Request) { GetFile("log", w) })
|
||||
apiRouter.Post("/log", func(w http.ResponseWriter, r *http.Request) { PostLog(w, r) })
|
||||
apiRouter.Post("/log", PostLog)
|
||||
|
||||
apiRouter.Get("/day", func(w http.ResponseWriter, r *http.Request) { ListFiles("day", w) })
|
||||
apiRouter.Get("/day/{day}", func(w http.ResponseWriter, r *http.Request) { GetDay(w, r) })
|
||||
apiRouter.Get("/day/{day}", GetDay)
|
||||
|
||||
apiRouter.Get("/notes", func(w http.ResponseWriter, r *http.Request) { ListFiles("notes", w) })
|
||||
apiRouter.Get("/notes/{note}", func(w http.ResponseWriter, r *http.Request) { GetNote(w, r) })
|
||||
apiRouter.Post("/notes/{note}", func(w http.ResponseWriter, r *http.Request) { PostNote(w, r) })
|
||||
apiRouter.Get("/notes/{note}", GetNote)
|
||||
apiRouter.Post("/notes/{note}", PostNote)
|
||||
|
||||
apiRouter.Get("/today", func(w http.ResponseWriter, r *http.Request) { GetToday(w) })
|
||||
apiRouter.Post("/today", func(w http.ResponseWriter, r *http.Request) { PostToday(w, r) })
|
||||
apiRouter.Get("/today", GetToday)
|
||||
apiRouter.Post("/today", PostToday)
|
||||
|
||||
r.Mount("/api", apiRouter)
|
||||
|
||||
|
@ -47,6 +47,6 @@ func Serve() {
|
|||
fs := http.FileServer(http.Dir("public"))
|
||||
r.Handle("/public/*", http.StripPrefix("/public/", fs))
|
||||
|
||||
fmt.Println("Website working on port: ", Cfg.Port)
|
||||
slog.Info("🌺 Website working", "port", Cfg.Port)
|
||||
log.Fatal(http.ListenAndServe(":"+strconv.Itoa(Cfg.Port), r))
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue