From 600f105b00132c339e7316f585486f848dc36943 Mon Sep 17 00:00:00 2001 From: soup Date: Fri, 15 Nov 2024 11:59:51 -0500 Subject: [PATCH] Login / Auth flow --- backend/auth/auth.go | 73 +++++++++++++++++++ backend/bin/shelves.go | 2 - backend/db/db.go | 2 +- backend/httpx/httpx.go | 29 ++++++++ backend/httpx/middleware.go | 22 +++--- backend/routes/login.go | 141 ++++++++++++++++++++++++++++++++++++ backend/routes/routes.go | 5 +- backend/routes/setup.go | 103 ++++++++++++++++++++------ backend/urls/urls.go | 15 ++++ go.mod | 7 +- go.sum | 14 ++-- 11 files changed, 368 insertions(+), 45 deletions(-) create mode 100644 backend/auth/auth.go create mode 100644 backend/routes/login.go create mode 100644 backend/urls/urls.go diff --git a/backend/auth/auth.go b/backend/auth/auth.go new file mode 100644 index 0000000..f9503d8 --- /dev/null +++ b/backend/auth/auth.go @@ -0,0 +1,73 @@ +package auth + +import ( + "bytes" + "database/sql" + "math/rand/v2" + + "golang.org/x/crypto/argon2" +) + +type passwordInfo struct { + hash []byte + salt []byte +} + +func queryGetOwnerSettingsPasswordInfo(db *sql.DB) (passwordInfo, error) { + out := passwordInfo{} + err := db.QueryRow(`select password_hash, password_salt from owner_settings`).Scan(&out.hash, &out.salt) + + return out, err +} + +func queryCreateSession(db *sql.DB, sessionId string) error { + _, err := db.Exec(`insert into session (session_id) values (?)`, sessionId) + + return err +} + +func HashPassword(password []byte, salt []byte) []byte { + passwordHash := argon2.IDKey(password, salt, 10, 7, 1, 64) + + return passwordHash +} + +const charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789" +const charsetLen = len(charset) + +func randomString(n int) string { + b := make([]byte, n) + for i := range b { + ciIdx := rand.IntN(charsetLen) + + b[i] = charset[ciIdx] + } + + return string(b) +} + +func Login(db *sql.DB, password string) (bool, string, error) { + info, err := queryGetOwnerSettingsPasswordInfo(db) + if err != nil { + return false, "", err + } + + incomingHash := HashPassword([]byte(password), info.salt) + if !bytes.Equal(incomingHash, info.hash) { + return false, "", nil + } + + sessionId := randomString(256) + err = queryCreateSession(db, sessionId) + + return true, sessionId, err +} + +func queryDeleteSession(db *sql.DB, sessionId string) error { + _, err := db.Exec(`delete from session where session_id = ?`, sessionId) + return err +} + +func SessionDelete(db *sql.DB, sessionId string) error { + return queryDeleteSession(db, sessionId) +} diff --git a/backend/bin/shelves.go b/backend/bin/shelves.go index 38f2ad3..e021bbe 100644 --- a/backend/bin/shelves.go +++ b/backend/bin/shelves.go @@ -4,7 +4,6 @@ import ( "fmt" "log" "net/http" - "os" _ "github.com/mattn/go-sqlite3" @@ -14,7 +13,6 @@ import ( ) func main() { - os.Remove("./shelves.db") db, migrateResult, err := db.Open("./shelves.db") if err != nil { log.Fatalf("Failed to open DB: %v", err) diff --git a/backend/db/db.go b/backend/db/db.go index 068bdb8..fcc6869 100644 --- a/backend/db/db.go +++ b/backend/db/db.go @@ -10,7 +10,7 @@ import ( var _MIGRATIONS = [...]string{ ` create table owner_settings ( - id integer primary key default 1, + id integer primary key check (id = 1), display_name text not null, diff --git a/backend/httpx/httpx.go b/backend/httpx/httpx.go index c2acabf..fb19626 100644 --- a/backend/httpx/httpx.go +++ b/backend/httpx/httpx.go @@ -1,10 +1,39 @@ package httpx import ( + "log" "net/http" + "net/url" + "shelves/backend/errorsx" + "shelves/backend/urls" ) func SeeOther(w http.ResponseWriter, location string) { w.Header().Add("Location", location) w.WriteHeader(http.StatusSeeOther) } + +func TemporaryRedirect(w http.ResponseWriter, location string) { + w.Header().Add("Location", location) + w.WriteHeader(http.StatusTemporaryRedirect) +} + +func Unauthorized(w http.ResponseWriter) { + w.WriteHeader(http.StatusUnauthorized) +} + +func BadRequest(w http.ResponseWriter) { + w.WriteHeader(http.StatusBadRequest) +} + +func InternalServerError(w http.ResponseWriter, err error) { + log.Printf("Internal server error: %v", err) + + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte(errorsx.String(err))) +} + +func LoginRedirect(w http.ResponseWriter, redirectTo url.URL) { + location := urls.Login(redirectTo.Path) + TemporaryRedirect(w, location) +} diff --git a/backend/httpx/middleware.go b/backend/httpx/middleware.go index cf828f6..2fb9d1e 100644 --- a/backend/httpx/middleware.go +++ b/backend/httpx/middleware.go @@ -32,7 +32,18 @@ func Log(next http.Handler) http.Handler { } type AuthInfo struct { - is_admin bool + IsAdmin bool + SessionId string +} + +func queryCheckAuth(db *sql.DB, sessionId string) (AuthInfo, error) { + sessionCount := 0 + err := db.QueryRow("select count(*) from session where session_id = ?", sessionId).Scan(&sessionCount) + if err != nil { + return AuthInfo{}, err + } + + return AuthInfo{IsAdmin: sessionCount == 1, SessionId: sessionId}, nil } func checkAuthed(db *sql.DB, r *http.Request) (AuthInfo, error) { @@ -45,14 +56,7 @@ func checkAuthed(db *sql.DB, r *http.Request) (AuthInfo, error) { } sessionId := cookie.Value - - sessionCount := 0 - err = db.QueryRow("select count(*) from session where session_id = ?", sessionId).Scan(&sessionCount) - if err != nil { - return AuthInfo{}, err - } - - return AuthInfo{is_admin: sessionCount == 1}, nil + return queryCheckAuth(db, sessionId) } func queryNeedsOwnerSetup(db *sql.DB) (bool, error) { diff --git a/backend/routes/login.go b/backend/routes/login.go new file mode 100644 index 0000000..a0ecac4 --- /dev/null +++ b/backend/routes/login.go @@ -0,0 +1,141 @@ +package routes + +import ( + "errors" + "html/template" + "net/http" + "net/url" + "shelves/backend/auth" + "shelves/backend/errorsx" + "shelves/backend/forms" + "shelves/backend/httpx" + "shelves/backend/templates" +) + +type loginForm struct { + password string + redirectTo string +} + +type loginFormErrors struct { + password error +} + +func loginRenderView(f loginForm, e loginFormErrors) template.HTML { + body := templates.Form{ + Action: "/login", + Fields: []templates.Field{ + templates.Field{ + Label: "Password", + Type: "password", + Placeholder: "Password", + Name: "password", + Error: errorsx.String(e.password), + }, + templates.Field{ + Type: "hidden", + Name: "redirectTo", + Value: f.redirectTo, + }, + }, + } + + return templates.PageBase{Title: "Login", Body: body.HTML()}.HTML() +} + +func getRedirectTo(r *http.Request) string { + redirectTo := r.URL.Query().Get("redirectTo") + if redirectTo == "" { + redirectTo = "/" + } + + redirectTo, err := url.QueryUnescape(redirectTo) + if err != nil { + redirectTo = "" + } + + return redirectTo +} + +func LoginGet(w http.ResponseWriter, r *http.Request) { + ctx := httpx.GetCtx(r) + redirectTo := getRedirectTo(r) + + if ctx.Auth.IsAdmin { + httpx.SeeOther(w, redirectTo) + return + } + + html(w, loginRenderView(loginForm{redirectTo: redirectTo}, loginFormErrors{})) +} + +func loginParseForm(f *loginForm, e *loginFormErrors, vs url.Values, v *forms.Validator) { + f.password = vs.Get("password") + f.redirectTo = vs.Get("redirectTo") +} + +func LoginPost(w http.ResponseWriter, r *http.Request) { + ctx := httpx.GetCtx(r) + + form := loginForm{} + errs := loginFormErrors{} + failed := forms.ParseFormData(r, func(vs url.Values, v *forms.Validator) { + loginParseForm(&form, &errs, vs, v) + }) + if failed { + httpx.BadRequest(w) + html(w, loginRenderView(form, errs)) + return + } + + if ctx.Auth.IsAdmin { + httpx.SeeOther(w, form.redirectTo) + return + } + + success, sessionId, err := auth.Login(ctx.DB, form.password) + if err != nil { + httpx.InternalServerError(w, err) + return + } + if !success { + httpx.BadRequest(w) + + errs.password = errors.New("Incorrect password") + html(w, loginRenderView(form, errs)) + return + } + + cookie := http.Cookie{Name: "SHELVES_OWNER_SESSION_ID", Value: sessionId} + cookie.Secure = true + cookie.HttpOnly = true + cookie.SameSite = http.SameSiteStrictMode + cookie.MaxAge = 60 * 60 * 24 * 7 // 7 days + + http.SetCookie(w, &cookie) + httpx.SeeOther(w, form.redirectTo) +} + +func LoginDelete(w http.ResponseWriter, r *http.Request) { + ctx := httpx.GetCtx(r) + if !ctx.Auth.IsAdmin { + httpx.SeeOther(w, "/") + return + } + + sessionId := ctx.Auth.SessionId + err := auth.SessionDelete(ctx.DB, sessionId) + if err != nil { + httpx.InternalServerError(w, err) + return + } + + cookie := http.Cookie{Name: "SHELVES_OWNER_SESSION_ID", Value: ""} + cookie.Secure = true + cookie.HttpOnly = true + cookie.SameSite = http.SameSiteStrictMode + cookie.MaxAge = -1 + + http.SetCookie(w, &cookie) + httpx.SeeOther(w, "/") +} diff --git a/backend/routes/routes.go b/backend/routes/routes.go index c9abf06..f2e0261 100644 --- a/backend/routes/routes.go +++ b/backend/routes/routes.go @@ -31,9 +31,12 @@ func redirectSetup(next http.Handler) http.Handler { func Routes() http.Handler { mux := http.NewServeMux() mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServerFS(shelves.Frontend))) - mux.HandleFunc("GET /", HomeGet) + mux.HandleFunc("GET /{$}", HomeGet) mux.HandleFunc("GET /setup", SetupGet) mux.HandleFunc("POST /setup", SetupPost) + mux.HandleFunc("GET /login", LoginGet) + mux.HandleFunc("POST /login", LoginPost) + mux.HandleFunc("DELETE /login", LoginDelete) return redirectSetup(mux) } diff --git a/backend/routes/setup.go b/backend/routes/setup.go index 6a564dc..c8e4add 100644 --- a/backend/routes/setup.go +++ b/backend/routes/setup.go @@ -1,30 +1,41 @@ package routes import ( + "crypto/rand" + "database/sql" "html/template" "net/http" "net/url" + "shelves/backend/auth" "shelves/backend/errorsx" "shelves/backend/forms" "shelves/backend/httpx" "shelves/backend/templates" ) -func renderView(f setupForm, e setupFormErrors) template.HTML { - +func setupRenderView(f setupForm, e setupFormErrors) template.HTML { body := templates.Form{ Action: "/setup", Fields: []templates.Field{ templates.Field{ - Label: "Password", - Type: "password", - Name: "password", - Error: errorsx.String(e.password), + Label: "Display name", + Name: "displayName", + Placeholder: "Jane Doe", + Value: f.displayName, + Error: errorsx.String(e.displayName), }, templates.Field{ - Label: "Confirm password", - Type: "password", - Name: "passwordConfirmation", + Label: "Password", + Type: "password", + Placeholder: "Password", + Name: "password", + Error: errorsx.String(e.password), + }, + templates.Field{ + Label: "Confirm password", + Placeholder: "Confirm Password", + Type: "password", + Name: "passwordConfirmation", }, }, } @@ -32,40 +43,80 @@ func renderView(f setupForm, e setupFormErrors) template.HTML { return templates.PageBase{Title: "Set up", Body: body.HTML()}.HTML() } +func queryGetOwnerSettings(db *sql.DB) (setupForm, error) { + form := setupForm{} + err := db.QueryRow(`select display_name from owner_settings`).Scan(&form.displayName) + if err == sql.ErrNoRows { + err = nil + } + + return form, err +} + func SetupGet(w http.ResponseWriter, r *http.Request) { ctx := httpx.GetCtx(r) - if !ctx.NeedsOwnerSetup { - httpx.SeeOther(w, "/") + if !ctx.Auth.IsAdmin && !ctx.NeedsOwnerSetup { + httpx.LoginRedirect(w, *r.URL) return } - html(w, renderView(setupForm{}, setupFormErrors{})) + form, err := queryGetOwnerSettings(ctx.DB) + if err != nil { + httpx.InternalServerError(w, err) + return + } + + html(w, setupRenderView(form, setupFormErrors{})) } type setupForm struct { - password string + password string + displayName string } type setupFormErrors struct { - password error + password error + displayName error } func parseForm(f *setupForm, e *setupFormErrors, vs url.Values, v *forms.Validator) { f.password = vs.Get("password") passwordConfirmation := vs.Get("passwordConfirmation") - - if f.password != passwordConfirmation { - e.password = v.Fail("Passwords did not match") - return - } + f.displayName = vs.Get("displayName") e.password = v.MinLength(f.password, 1) + if e.password == nil && f.password != passwordConfirmation { + e.password = v.Fail("Passwords did not match") + } + + e.displayName = v.MinLength(f.displayName, 1) +} + +func queryUpdateOwnerSettings(db *sql.DB, display_name string, password_salt []byte, password_hash []byte) error { + _, err := db.Exec(` + insert or replace into owner_settings (display_name, password_salt, password_hash) + values (?, ?, ?) + `, display_name, password_salt, password_hash) + return err +} + +func updateOwnerSettings(db *sql.DB, f setupForm) error { + salt := make([]byte, 32) + _, err := rand.Read(salt) + if err != nil { + return err + } + + hash := auth.HashPassword([]byte(f.password), salt) + + return queryUpdateOwnerSettings(db, f.displayName, salt, hash) } func SetupPost(w http.ResponseWriter, r *http.Request) { ctx := httpx.GetCtx(r) - if !ctx.NeedsOwnerSetup { - httpx.SeeOther(w, "/") + + if !ctx.Auth.IsAdmin && !ctx.NeedsOwnerSetup { + httpx.Unauthorized(w) return } @@ -77,8 +128,14 @@ func SetupPost(w http.ResponseWriter, r *http.Request) { if failed { w.WriteHeader(http.StatusBadRequest) - html(w, renderView(form, errs)) + html(w, setupRenderView(form, errs)) + return + } + + err := updateOwnerSettings(ctx.DB, form) + if err != nil { + httpx.InternalServerError(w, err) } else { - httpx.SeeOther(w, "/setup") + httpx.SeeOther(w, "/") } } diff --git a/backend/urls/urls.go b/backend/urls/urls.go new file mode 100644 index 0000000..a481ed7 --- /dev/null +++ b/backend/urls/urls.go @@ -0,0 +1,15 @@ +package urls + +import ( + "fmt" + "net/url" +) + +func Login(redirectTo string) string { + out := "/login" + if redirectTo != "" { + out = fmt.Sprintf("%s?redirectTo=%s", out, url.QueryEscape(redirectTo)) + } + + return out +} diff --git a/go.mod b/go.mod index 63280e3..fba7ffa 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.23 require ( github.com/mattn/go-sqlite3 v1.14.24 + golang.org/x/crypto v0.29.0 golang.org/x/tools/gopls v0.16.2 ) @@ -12,10 +13,10 @@ require ( github.com/google/go-cmp v0.6.0 // indirect golang.org/x/exp/typeparams v0.0.0-20221212164502-fae10dda9338 // indirect golang.org/x/mod v0.20.0 // indirect - golang.org/x/sync v0.8.0 // indirect - golang.org/x/sys v0.23.0 // indirect + golang.org/x/sync v0.9.0 // indirect + golang.org/x/sys v0.27.0 // indirect golang.org/x/telemetry v0.0.0-20240829154258-f29ab539cc98 // indirect - golang.org/x/text v0.16.0 // indirect + golang.org/x/text v0.20.0 // indirect golang.org/x/tools v0.22.1-0.20240829175637-39126e24d653 // indirect golang.org/x/vuln v1.0.4 // indirect honnef.co/go/tools v0.4.7 // indirect diff --git a/go.sum b/go.sum index ea8fd80..0ea52ef 100644 --- a/go.sum +++ b/go.sum @@ -16,18 +16,20 @@ github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBW github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= +golang.org/x/crypto v0.29.0 h1:L5SG1JTTXupVV3n6sUqMTeWbjAyfPwoda2DLX8J8FrQ= +golang.org/x/crypto v0.29.0/go.mod h1:+F4F4N5hv6v38hfeYwTdx20oUvLLc+QfrE9Ax9HtgRg= golang.org/x/exp/typeparams v0.0.0-20221212164502-fae10dda9338 h1:2O2DON6y3XMJiQRAS1UWU+54aec2uopH3x7MAiqGW6Y= golang.org/x/exp/typeparams v0.0.0-20221212164502-fae10dda9338/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk= golang.org/x/mod v0.20.0 h1:utOm6MM3R3dnawAiJgn0y+xvuYRsm1RKM/4giyfDgV0= golang.org/x/mod v0.20.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= -golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ= -golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sys v0.23.0 h1:YfKFowiIMvtgl1UERQoTPPToxltDeZfbj4H7dVUCwmM= -golang.org/x/sys v0.23.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sync v0.9.0 h1:fEo0HyrW1GIgZdpbhCRO0PkJajUS5H9IFUztCgEo2jQ= +golang.org/x/sync v0.9.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.27.0 h1:wBqf8DvsY9Y/2P8gAfPDEYNuS30J4lPHJxXSb/nJZ+s= +golang.org/x/sys v0.27.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/telemetry v0.0.0-20240829154258-f29ab539cc98 h1:Wm3cG5X6sZ0RSVRc/H1/sciC4AT6HAKgLCSH2lbpR/c= golang.org/x/telemetry v0.0.0-20240829154258-f29ab539cc98/go.mod h1:m7R/r+o5h7UvF2JD9n2iLSGY4v8v+zNSyTJ6xynLrqs= -golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= -golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= +golang.org/x/text v0.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug= +golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4= golang.org/x/tools v0.22.1-0.20240829175637-39126e24d653 h1:6bJEg2w2kUHWlfdJaESYsmNfI1LKAZQi6zCa7LUn7eI= golang.org/x/tools v0.22.1-0.20240829175637-39126e24d653/go.mod h1:aCwcsjqvq7Yqt6TNyX7QMU2enbQ/Gt0bo6krSeEri+c= golang.org/x/tools/gopls v0.16.2 h1:K1z03MlikHfaMTtG01cUeL5FAOTJnITuNe0TWOcg8tM=