From a559aaebf47a5753fd0d4417a6f7e2b0df2b7c43 Mon Sep 17 00:00:00 2001 From: Dominic Ferrando Date: Wed, 23 Apr 2025 14:13:43 -0400 Subject: [PATCH] Implement basic auth --- go.mod | 1 + go.sum | 2 + internal/database/handlers.go | 14 ++++++ internal/database/models.go | 5 +++ internal/database/seed.sql | 9 ++++ internal/util/admins.go | 67 +++++++++++++++++++++++++++++ internal/util/cooldowns.go | 7 ++- web/main.go | 80 ++++++++++++++++++++++++++++++++++- web/static/index.css | 18 ++++++++ web/views/admin/login.templ | 44 +++++++++++++++++++ web/views/shared/form.templ | 1 - 11 files changed, 242 insertions(+), 6 deletions(-) create mode 100644 internal/util/admins.go create mode 100644 web/views/admin/login.templ diff --git a/go.mod b/go.mod index 4e9ec63..8e9a2e4 100644 --- a/go.mod +++ b/go.mod @@ -10,5 +10,6 @@ require ( require ( github.com/disintegration/imaging v1.6.2 // indirect + golang.org/x/crypto v0.37.0 // indirect golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8 // indirect ) diff --git a/go.sum b/go.sum index 51281a6..0c11642 100644 --- a/go.sum +++ b/go.sum @@ -8,6 +8,8 @@ github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/mattn/go-sqlite3 v1.14.28 h1:ThEiQrnbtumT+QMknw63Befp/ce/nUPgBPMlRFEum7A= github.com/mattn/go-sqlite3 v1.14.28/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE= +golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8 h1:hVwzHzIUGRjiF7EcUjqNxk3NCfkPxbDKRdnNE1Rpg0U= golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= diff --git a/internal/database/handlers.go b/internal/database/handlers.go index f1703a3..eabf2c6 100644 --- a/internal/database/handlers.go +++ b/internal/database/handlers.go @@ -259,3 +259,17 @@ func DeleteThread(db Queryer, threadId int) error { return nil } + +func GetAdmin(db *sql.DB, username string) (Admin, error) { + row := db.QueryRow(` + SELECT username, password + FROM admins + WHERE username = ?`, username) + + var result Admin + if err := row.Scan(&result.Username, &result.Password); err != nil { + return Admin{}, nil + } + + return result, nil +} diff --git a/internal/database/models.go b/internal/database/models.go index 514f5e4..6a98077 100644 --- a/internal/database/models.go +++ b/internal/database/models.go @@ -28,3 +28,8 @@ type Post struct { IpHash string Number int } + +type Admin struct { + Username string + Password string +} diff --git a/internal/database/seed.sql b/internal/database/seed.sql index bd25f47..69cd003 100644 --- a/internal/database/seed.sql +++ b/internal/database/seed.sql @@ -33,6 +33,12 @@ CREATE TABLE IF NOT EXISTS posts ( FOREIGN KEY (thread_id) REFERENCES threads(id) ON DELETE CASCADE ); +CREATE TABLE IF NOT EXISTS admins ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL, + password TEXT NOT NULL +); + -- ====================== -- Seed data -- ====================== @@ -42,3 +48,6 @@ CREATE TABLE IF NOT EXISTS posts ( INSERT INTO boards (slug, name, tag) VALUES ('comfy', 'Comfy', 'Be comfy, fren'), ('g', 'Technology', 'Beep boop'); + +INSERT INTO admins (username, password) VALUES + ('admin', '$2a$10$vRP4/9O6SwyUziEUtBLQM.r9C2WujIIZ6yEgqGjhlBaFPvtpfdHPC'); diff --git a/internal/util/admins.go b/internal/util/admins.go new file mode 100644 index 0000000..7d93275 --- /dev/null +++ b/internal/util/admins.go @@ -0,0 +1,67 @@ +package util + +import ( + "crypto/rand" + "encoding/hex" + "sync" + "time" +) + +type AdminSession struct { + Expiration time.Time + Username string +} + +var ( + AdminSessions = make(map[string]AdminSession) + AdminMutex = sync.RWMutex{} +) + +func GenToken() (string, error) { + bytes := make([]byte, 32) + _, err := rand.Read(bytes) + if err != nil { + return "", err + } + + return hex.EncodeToString(bytes), nil +} + +func IsAdminSessionValid(token string) bool { + AdminMutex.RLock() + session, exists := AdminSessions[token] + AdminMutex.RUnlock() + + if !exists || time.Now().After(session.Expiration) { + AdminMutex.Lock() + delete(AdminSessions, token) + AdminMutex.Unlock() + return false + } + + return true +} + +func CreateAdminSession(token string, session AdminSession) { + AdminMutex.Lock() + AdminSessions[token] = session + AdminMutex.Unlock() +} + +func DeleteAdminSession(token string) { + AdminMutex.Lock() + delete(AdminSessions, token) + AdminMutex.Unlock() +} + +func HasExistingAdminSession(username string) bool { + AdminMutex.RLock() + defer AdminMutex.RUnlock() + for _, session := range AdminSessions { + if session.Username == username { + return true + } + } + + return false +} diff --git a/internal/util/cooldowns.go b/internal/util/cooldowns.go index afdb6a8..951da7b 100644 --- a/internal/util/cooldowns.go +++ b/internal/util/cooldowns.go @@ -15,7 +15,7 @@ var ( ThreadCooldowns = make(map[string]time.Time) ) -var CooldownMutex sync.Mutex +var CooldownMutex sync.RWMutex const POST_COOLDOWN = 15 * time.Second @@ -26,10 +26,9 @@ const THREAD_COOLDOWN = 2 * time.Minute // const THREAD_COOLDOWN = 0 * time.Minute func GetRemainingCooldown(ip string, m map[string]time.Time, duration time.Duration) time.Duration { - CooldownMutex.Lock() - defer CooldownMutex.Unlock() - + CooldownMutex.RLock() last, exists := m[ip] + CooldownMutex.RUnlock() if !exists { return 0 } diff --git a/web/main.go b/web/main.go index 1317693..ddd7c9b 100644 --- a/web/main.go +++ b/web/main.go @@ -16,9 +16,11 @@ import ( "github.com/dominicf2001/comfychan/internal/database" "github.com/dominicf2001/comfychan/internal/util" "github.com/dominicf2001/comfychan/web/views" + "github.com/dominicf2001/comfychan/web/views/admin" "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" _ "github.com/mattn/go-sqlite3" + "golang.org/x/crypto/bcrypt" ) var dev = true @@ -324,7 +326,7 @@ func main() { // ----------------- // ----------------- - // PARTIALS (htmx) + // PARTIAL ROUTES (htmx) // ----------------- // CATALOG @@ -402,6 +404,82 @@ func main() { // ----------------- + // ----------------- + // ADMIN ROUTES (htmx) + // ----------------- + + r.Get("/admin/login", func(w http.ResponseWriter, r *http.Request) { + fmt.Println(util.AdminSessions) + admin.AdminLogin().Render(r.Context(), w) + }) + + r.Post("/admin/login", func(w http.ResponseWriter, r *http.Request) { + username := r.FormValue("username") + password := r.FormValue("password") + + admin, err := database.GetAdmin(db, username) + if err != nil { + http.Error(w, "Invalid login", http.StatusUnauthorized) + return + } + + err = bcrypt.CompareHashAndPassword([]byte(admin.Password), []byte(password)) + if err != nil { + http.Error(w, "Invalid login", http.StatusUnauthorized) + return + } + + token, err := util.GenToken() + if err != nil { + http.Error(w, "Failed to generate token", http.StatusInternalServerError) + return + } + + tokenValidUntil := time.Now().Add(time.Hour) + util.CreateAdminSession(token, util.AdminSession{ + Username: username, + Expiration: tokenValidUntil, + }) + + http.SetCookie(w, &http.Cookie{ + Name: "comfy_admin", + Value: token, + HttpOnly: true, + Secure: !dev, + Expires: tokenValidUntil, + SameSite: http.SameSiteStrictMode, + Path: "/", + }) + + }) + + r.Post("/admin/logout", func(w http.ResponseWriter, r *http.Request) { + if c, err := r.Cookie("comfy_admin"); err == nil { + util.DeleteAdminSession(c.Value) + } + http.SetCookie(w, &http.Cookie{ + Name: "comfy_admin", + Value: "", + HttpOnly: true, + Secure: !dev, + Expires: time.Now(), + SameSite: http.SameSiteStrictMode, + Path: "/", + }) + }) + + r.Get("/admin/me", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + + if c, err := r.Cookie("comfy_admin"); err == nil && util.IsAdminSessionValid(c.Value) { + w.Write([]byte("true")) + } else { + w.Write([]byte("false")) + } + }) + + // ----------------- + // ----------------- // CLEANUP // ----------------- diff --git a/web/static/index.css b/web/static/index.css index 2d3b3b3..f76fa53 100644 --- a/web/static/index.css +++ b/web/static/index.css @@ -277,6 +277,24 @@ body { color: #D00; } +/* ADMIN */ + + +.admin-login-container { + width: max-content; + margin: auto; + margin-top: 32px; +} + +#adminLoginForm form { + margin: auto; +} + +#adminLoginForm button { + display: block; + margin-left: auto; +} + /* GENERAL LAYOUT */ .container { diff --git a/web/views/admin/login.templ b/web/views/admin/login.templ new file mode 100644 index 0000000..92b1be1 --- /dev/null +++ b/web/views/admin/login.templ @@ -0,0 +1,44 @@ +package admin + +import "github.com/dominicf2001/comfychan/web/views/shared" + +templ AdminLogin() { + @shared.Layout() { +
+ +
+ + + + + + + + + + + +
Username
Password
+ +
+
+ } +} diff --git a/web/views/shared/form.templ b/web/views/shared/form.templ index 6a7f95f..3a78acd 100644 --- a/web/views/shared/form.templ +++ b/web/views/shared/form.templ @@ -14,7 +14,6 @@ templ NewPostForm(board database.Board, endpoint string, isForThread bool) { hx-post={ endpoint } _=" on htmx:beforeRequest toggle @disabled on