From 02d555983e1078a6d2d5d9e96a747f55e632e519 Mon Sep 17 00:00:00 2001 From: Jonni Liljamo Date: Fri, 13 Oct 2023 00:12:26 +0300 Subject: [PATCH] feat: sessions, login, default admin user --- components.templ | 65 +++++++++- components_templ.go | 286 ++++++++++++++++++++++++++++++++++++++++++-- config/config.go | 8 ++ db/db.go | 19 +++ db/users.go | 79 ++++++++++++ felu.go | 32 ++++- handlers/login.go | 41 +++++++ middlewares/auth.go | 36 ++++++ static/felu.js | 9 ++ static/styles.css | 63 ++++++++++ 10 files changed, 626 insertions(+), 12 deletions(-) create mode 100644 db/users.go create mode 100644 handlers/login.go create mode 100644 middlewares/auth.go create mode 100644 static/felu.js diff --git a/components.templ b/components.templ index 6952f4b..6096a8a 100644 --- a/components.templ +++ b/components.templ @@ -1,6 +1,12 @@ package main -templ base(title string) { +import "git.src.quest/~skye/felu-ddns/config" + +func serviceName() string { + return config.FeluConfig.ServiceName +} + +templ baseBase(title string) { @@ -16,12 +22,67 @@ templ base(title string) {
{ children... }
+ } +templ base(title string) { + @baseBase(title) { +
+ +
+ { children... } +
+
+ } +} + +templ manageBase(title string) { + @baseBase(title) { +
+ +
+ { children... } +
+
+ } +} + templ index() { @base("Index") { -
testing, testing
+
+ have info about the service here, + with a login button somewhere. +
and also something something, basic index page info, bla bla +
+ + } +} + +templ pageLogin() { + @manageBase("Login") { +
+
+ + + + +
+ +
+
+ } +} + +templ pageManage() { + @manageBase("Manage") { +
+ something something manaag +
} } diff --git a/components_templ.go b/components_templ.go index b1da71b..a6c6252 100644 --- a/components_templ.go +++ b/components_templ.go @@ -9,7 +9,13 @@ import "context" import "io" import "bytes" -func base(title string) templ.Component { +import "git.src.quest/~skye/felu-ddns/config" + +func serviceName() string { + return config.FeluConfig.ServiceName +} + +func baseBase(title string) templ.Component { return templ.ComponentFunc(func(ctx context.Context, w io.Writer) (err error) { templBuffer, templIsBuffer := w.(*bytes.Buffer) if !templIsBuffer { @@ -48,7 +54,128 @@ func base(title string) templ.Component { if err != nil { return err } - _, err = templBuffer.WriteString("") + _, err = templBuffer.WriteString("") + if err != nil { + return err + } + if !templIsBuffer { + _, err = templBuffer.WriteTo(w) + } + return err + }) +} + +func base(title string) templ.Component { + return templ.ComponentFunc(func(ctx context.Context, w io.Writer) (err error) { + templBuffer, templIsBuffer := w.(*bytes.Buffer) + if !templIsBuffer { + templBuffer = templ.GetBuffer() + defer templ.ReleaseBuffer(templBuffer) + } + ctx = templ.InitializeContext(ctx) + var_5 := templ.GetChildren(ctx) + if var_5 == nil { + var_5 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + var_6 := templ.ComponentFunc(func(ctx context.Context, w io.Writer) (err error) { + templBuffer, templIsBuffer := w.(*bytes.Buffer) + if !templIsBuffer { + templBuffer = templ.GetBuffer() + defer templ.ReleaseBuffer(templBuffer) + } + _, err = templBuffer.WriteString("
") + if err != nil { + return err + } + err = var_5.Render(ctx, templBuffer) + if err != nil { + return err + } + _, err = templBuffer.WriteString("
") + if err != nil { + return err + } + if !templIsBuffer { + _, err = io.Copy(w, templBuffer) + } + return err + }) + err = baseBase(title).Render(templ.WithChildren(ctx, var_6), templBuffer) + if err != nil { + return err + } + if !templIsBuffer { + _, err = templBuffer.WriteTo(w) + } + return err + }) +} + +func manageBase(title string) templ.Component { + return templ.ComponentFunc(func(ctx context.Context, w io.Writer) (err error) { + templBuffer, templIsBuffer := w.(*bytes.Buffer) + if !templIsBuffer { + templBuffer = templ.GetBuffer() + defer templ.ReleaseBuffer(templBuffer) + } + ctx = templ.InitializeContext(ctx) + var_8 := templ.GetChildren(ctx) + if var_8 == nil { + var_8 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + var_9 := templ.ComponentFunc(func(ctx context.Context, w io.Writer) (err error) { + templBuffer, templIsBuffer := w.(*bytes.Buffer) + if !templIsBuffer { + templBuffer = templ.GetBuffer() + defer templ.ReleaseBuffer(templBuffer) + } + _, err = templBuffer.WriteString("
") + if err != nil { + return err + } + err = var_8.Render(ctx, templBuffer) + if err != nil { + return err + } + _, err = templBuffer.WriteString("
") + if err != nil { + return err + } + if !templIsBuffer { + _, err = io.Copy(w, templBuffer) + } + return err + }) + err = baseBase(title).Render(templ.WithChildren(ctx, var_9), templBuffer) if err != nil { return err } @@ -67,12 +194,12 @@ func index() templ.Component { defer templ.ReleaseBuffer(templBuffer) } ctx = templ.InitializeContext(ctx) - var_4 := templ.GetChildren(ctx) - if var_4 == nil { - var_4 = templ.NopComponent + var_11 := templ.GetChildren(ctx) + if var_11 == nil { + var_11 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - var_5 := templ.ComponentFunc(func(ctx context.Context, w io.Writer) (err error) { + var_12 := templ.ComponentFunc(func(ctx context.Context, w io.Writer) (err error) { templBuffer, templIsBuffer := w.(*bytes.Buffer) if !templIsBuffer { templBuffer = templ.GetBuffer() @@ -82,8 +209,149 @@ func index() templ.Component { if err != nil { return err } - var_6 := `testing, testing` - _, err = templBuffer.WriteString(var_6) + var_13 := `have info about the service here,` + _, err = templBuffer.WriteString(var_13) + if err != nil { + return err + } + _, err = templBuffer.WriteString(" ") + if err != nil { + return err + } + var_14 := `with a login button somewhere.` + _, err = templBuffer.WriteString(var_14) + if err != nil { + return err + } + _, err = templBuffer.WriteString("
") + if err != nil { + return err + } + var_15 := `and also something something, basic index page info, bla bla` + _, err = templBuffer.WriteString(var_15) + if err != nil { + return err + } + _, err = templBuffer.WriteString(" ") + if err != nil { + return err + } + if !templIsBuffer { + _, err = io.Copy(w, templBuffer) + } + return err + }) + err = base("Index").Render(templ.WithChildren(ctx, var_12), templBuffer) + if err != nil { + return err + } + if !templIsBuffer { + _, err = templBuffer.WriteTo(w) + } + return err + }) +} + +func pageLogin() templ.Component { + return templ.ComponentFunc(func(ctx context.Context, w io.Writer) (err error) { + templBuffer, templIsBuffer := w.(*bytes.Buffer) + if !templIsBuffer { + templBuffer = templ.GetBuffer() + defer templ.ReleaseBuffer(templBuffer) + } + ctx = templ.InitializeContext(ctx) + var_17 := templ.GetChildren(ctx) + if var_17 == nil { + var_17 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + var_18 := templ.ComponentFunc(func(ctx context.Context, w io.Writer) (err error) { + templBuffer, templIsBuffer := w.(*bytes.Buffer) + if !templIsBuffer { + templBuffer = templ.GetBuffer() + defer templ.ReleaseBuffer(templBuffer) + } + _, err = templBuffer.WriteString("
") + if err != nil { + return err + } + if !templIsBuffer { + _, err = io.Copy(w, templBuffer) + } + return err + }) + err = manageBase("Login").Render(templ.WithChildren(ctx, var_18), templBuffer) + if err != nil { + return err + } + if !templIsBuffer { + _, err = templBuffer.WriteTo(w) + } + return err + }) +} + +func pageManage() templ.Component { + return templ.ComponentFunc(func(ctx context.Context, w io.Writer) (err error) { + templBuffer, templIsBuffer := w.(*bytes.Buffer) + if !templIsBuffer { + templBuffer = templ.GetBuffer() + defer templ.ReleaseBuffer(templBuffer) + } + ctx = templ.InitializeContext(ctx) + var_22 := templ.GetChildren(ctx) + if var_22 == nil { + var_22 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + var_23 := templ.ComponentFunc(func(ctx context.Context, w io.Writer) (err error) { + templBuffer, templIsBuffer := w.(*bytes.Buffer) + if !templIsBuffer { + templBuffer = templ.GetBuffer() + defer templ.ReleaseBuffer(templBuffer) + } + _, err = templBuffer.WriteString("
") + if err != nil { + return err + } + var_24 := `something something manaag` + _, err = templBuffer.WriteString(var_24) if err != nil { return err } @@ -96,7 +364,7 @@ func index() templ.Component { } return err }) - err = base("Index").Render(templ.WithChildren(ctx, var_5), templBuffer) + err = manageBase("Manage").Render(templ.WithChildren(ctx, var_23), templBuffer) if err != nil { return err } diff --git a/config/config.go b/config/config.go index 948be17..939fe00 100644 --- a/config/config.go +++ b/config/config.go @@ -13,6 +13,11 @@ var FeluConfig *config type config struct { ServiceName string + // Initial email for the admin user, only used if no admin account (e.g. first boot) + InitialAdminEmail string + // Initial password for the admin user, only used if no admin account (e.g. first boot) + InitialAdminPwd string + // Data directory, with trailing slash DataDir string @@ -29,6 +34,9 @@ func InitConfig() { FeluConfig = &config { ServiceName: util.LoadEnvStr("FELU_SERVICE_NAME", "FeluDDNS"), + InitialAdminEmail: util.LoadEnvStr("FELU_INITIAL_ADMIN_EMAIL", "admin@example.com"), + InitialAdminPwd: util.LoadEnvStr("FELU_INITIAL_ADMIN_PWD", "feluadmin"), + DataDir: util.LoadEnvStr("FELU_DB_PATH", "/var/felu/"), FrontendBindAddr: util.LoadEnvStr("FELU_FRONTEND_BIND_ADDR", "0.0.0.0:8080"), diff --git a/db/db.go b/db/db.go index f6e2dd1..9f2bd26 100644 --- a/db/db.go +++ b/db/db.go @@ -26,3 +26,22 @@ func InitDB() error { return nil } + +func InitAdminUser() error { + rows, err := DBConn.Query(`SELECT id FROM users WHERE is_admin = TRUE`) + if err != nil { + return err + } + defer rows.Close() + if rows.Next() { + // There is at least one... + return nil + } + + // Since we're here, it's assumed no admin accounts exist + err = CreateAdmin(config.FeluConfig.InitialAdminEmail, config.FeluConfig.InitialAdminPwd) + if err != nil { + return err + } + return nil +} diff --git a/db/users.go b/db/users.go new file mode 100644 index 0000000..dbf139a --- /dev/null +++ b/db/users.go @@ -0,0 +1,79 @@ +/* + * Copyright (C) 2023 Jonni Liljamo + * + * This file is licensed under AGPL-3.0-or-later, see NOTICE and LICENSE for + * more information. + */ +package db + +import ( + "database/sql" + "errors" + + "github.com/matthewhartstonge/argon2" + "github.com/oklog/ulid/v2" +) + +func CreateUser(email string, pwd string) error { + argon := argon2.DefaultConfig() + encoded, err := argon.HashEncoded([]byte(pwd)) + if err != nil { + return err + } + + ulid := ulid.Make().String() + + _, err = DBConn.Exec(`INSERT INTO users(ulid, email, pwd) VALUES ($1, $2, $3)`, + ulid, email, string(encoded)) + if err != nil { + return err + } + + return nil +} + +func CreateAdmin(email string, pwd string) error { + argon := argon2.DefaultConfig() + encoded, err := argon.HashEncoded([]byte(pwd)) + if err != nil { + return err + } + + ulid := ulid.Make().String() + + _, err = DBConn.Exec(`INSERT INTO users(ulid, email, pwd, is_admin) VALUES ($1, $2, $3, TRUE)`, + ulid, email, string(encoded)) + if err != nil { + return err + } + + return nil +} + +type User struct { + Ulid string + Email string +} + +func FetchUser(email string, pwd string) (*User, error) { + user := User{ Email: email } + var encodedPwd string + err := DBConn.QueryRow(`SELECT ulid, pwd FROM users WHERE email = $1`, + email).Scan(&user.Ulid, &encodedPwd) + if err == sql.ErrNoRows { + return nil, errors.New("User not found") + } + if err != nil { + return nil, errors.New("User query failed") + } + + ok, err := argon2.VerifyEncoded([]byte(pwd), []byte(encodedPwd)) + if err != nil { + return nil, errors.New("User not found") + } + if !ok { + return nil, errors.New("User not found") + } + + return &user, nil +} diff --git a/felu.go b/felu.go index c128a25..28e252d 100644 --- a/felu.go +++ b/felu.go @@ -16,6 +16,9 @@ import ( "git.src.quest/~skye/felu-ddns/config" "git.src.quest/~skye/felu-ddns/db" "git.src.quest/~skye/felu-ddns/dns" + "git.src.quest/~skye/felu-ddns/handlers" + "git.src.quest/~skye/felu-ddns/middlewares" + "github.com/alexedwards/scs/v2" "github.com/gin-gonic/gin" "golang.org/x/sync/errgroup" ) @@ -24,6 +27,7 @@ var version = "notset-builtin" var ( g errgroup.Group + sessionManager *scs.SessionManager ) func ginLogFormat(param gin.LogFormatterParams, router string) string { @@ -62,6 +66,26 @@ func setupFrontendRouter() *gin.Engine { c.HTML(http.StatusOK, "", index()) }) + r.GET("/login", middlewares.SessionExists(sessionManager), func(c *gin.Context) { + c.HTML(http.StatusOK, "", pageLogin()) + }) + + auth := r.Group("/auth") + { + auth.POST("/login", func(c *gin.Context) { + handlers.AuthLogin(sessionManager, c) + }) + } + + // routes for the actual application frontend + manage := r.Group("/manage").Use(middlewares.SessionExists(sessionManager)) + { + manage.GET("/", func(c *gin.Context) { + c.HTML(http.StatusOK, "", pageManage()) + }) + manage.GET("/settings") + } + return r } @@ -96,10 +120,16 @@ func main() { log.Fatalf("[felu] Failed to initialize database: '%s'", err) } defer db.DBConn.Close() + if err := db.InitAdminUser(); err != nil { + log.Fatalf("[felu] Failed to initialize admin user: '%s'", err) + } + + sessionManager = scs.New() + sessionManager.Lifetime = 6 * time.Hour frontend := &http.Server{ Addr: config.FeluConfig.FrontendBindAddr, - Handler: setupFrontendRouter(), + Handler: sessionManager.LoadAndSave(setupFrontendRouter()), ReadTimeout: 5 * time.Second, WriteTimeout: 10 * time.Second, } diff --git a/handlers/login.go b/handlers/login.go new file mode 100644 index 0000000..c9a1298 --- /dev/null +++ b/handlers/login.go @@ -0,0 +1,41 @@ +/* + * Copyright (C) 2023 Jonni Liljamo + * + * This file is licensed under AGPL-3.0-or-later, see NOTICE and LICENSE for + * more information. + */ +package handlers + +import ( + "log" + "net/http" + + "git.src.quest/~skye/felu-ddns/db" + "github.com/alexedwards/scs/v2" + "github.com/gin-gonic/gin" +) + +type postLoginDetails struct { + Email string `form:"email"` + Password string `form:"password"` +} + +func AuthLogin(sm *scs.SessionManager, c *gin.Context) { + data := &postLoginDetails{} + if err := c.Bind(data); err != nil { + log.Printf("[felu] ERROR: Could not bind login details: %v", err) + c.String(http.StatusBadRequest, "Could not bind login details") + return + } + + user, err := db.FetchUser(data.Email, data.Password) + if err != nil { + c.String(http.StatusUnauthorized, err.Error()) + return + } + + sm.Put(c.Request.Context(), "user_id", user.Ulid) + + c.Header("HX-Redirect", "/manage") + //c.Redirect(http.StatusFound, "/manage") +} diff --git a/middlewares/auth.go b/middlewares/auth.go new file mode 100644 index 0000000..464deab --- /dev/null +++ b/middlewares/auth.go @@ -0,0 +1,36 @@ +/* + * Copyright (C) 2023 Jonni Liljamo + * + * This file is licensed under AGPL-3.0-or-later, see NOTICE and LICENSE for + * more information. + */ +package middlewares + +import ( + "net/http" + + "github.com/alexedwards/scs/v2" + "github.com/gin-gonic/gin" +) + +func SessionExists(sm *scs.SessionManager) gin.HandlerFunc { + return func(c *gin.Context) { + user_id := sm.Get(c.Request.Context(), "user_id") + if user_id != nil { + if c.Request.URL.Path == "/login" { + c.Redirect(http.StatusTemporaryRedirect, "/manage") + c.Abort() + } else { + // TODO: Validate in db? + c.Next() + } + } else { + if c.Request.URL.Path == "/login" { + c.Next() + } else { + c.Redirect(http.StatusTemporaryRedirect, "/login") + c.Abort() + } + } + } +} diff --git a/static/felu.js b/static/felu.js new file mode 100644 index 0000000..bdf3fe9 --- /dev/null +++ b/static/felu.js @@ -0,0 +1,9 @@ +document.body.addEventListener('htmx:beforeSwap', function(evt) { + if (evt.detail.xhr.status === 400) { + evt.detail.shouldSwap = true; + evt.detail.isError = false; + } else if (evt.detail.xhr.status === 401) { + evt.detail.shouldSwap = true; + evt.detail.isError = false; + } +}) diff --git a/static/styles.css b/static/styles.css index 29ed5c5..6e612a6 100644 --- a/static/styles.css +++ b/static/styles.css @@ -534,7 +534,70 @@ video { --tw-backdrop-sepia: ; } +.flex { + display: flex; +} + +.w-full { + width: 100%; +} + +.max-w-5xl { + max-width: 64rem; +} + +.flex-col { + flex-direction: column; +} + +.items-center { + align-items: center; +} + +.gap-2 { + gap: 0.5rem; +} + +.gap-4 { + gap: 1rem; +} + +.border { + border-width: 1px; +} + +.bg-lime-200 { + --tw-bg-opacity: 1; + background-color: rgb(217 249 157 / var(--tw-bg-opacity)); +} + .bg-rose-200 { --tw-bg-opacity: 1; background-color: rgb(254 205 211 / var(--tw-bg-opacity)); } + +.p-1 { + padding: 0.25rem; +} + +.p-2 { + padding: 0.5rem; +} + +.p-4 { + padding: 1rem; +} + +.text-center { + text-align: center; +} + +.text-4xl { + font-size: 2.25rem; + line-height: 2.5rem; +} + +.text-rose-600 { + --tw-text-opacity: 1; + color: rgb(225 29 72 / var(--tw-text-opacity)); +} -- 2.44.1