Login / Auth flow

This commit is contained in:
soup 2024-11-15 11:59:51 -05:00
parent 41949c857a
commit 600f105b00
11 changed files with 368 additions and 45 deletions

73
backend/auth/auth.go Normal file
View file

@ -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)
}

View file

@ -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)

View file

@ -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,

View file

@ -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)
}

View file

@ -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) {

141
backend/routes/login.go Normal file
View file

@ -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, "/")
}

View file

@ -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)
}

View file

@ -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, "/")
}
}

15
backend/urls/urls.go Normal file
View file

@ -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
}

7
go.mod
View file

@ -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

14
go.sum
View file

@ -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=