diff options
-rw-r--r-- | form/errors.go | 12 | ||||
-rw-r--r-- | main.go | 1 | ||||
-rw-r--r-- | model/users.sql.go | 39 | ||||
-rw-r--r-- | pages/base.templ | 4 | ||||
-rw-r--r-- | pages/base_templ.go | 4 | ||||
-rw-r--r-- | pages/chpw.go | 84 | ||||
-rw-r--r-- | pages/chpw.templ | 25 | ||||
-rw-r--r-- | pages/chpw_templ.go | 89 | ||||
-rw-r--r-- | pages/login.go | 33 | ||||
-rw-r--r-- | sql/users.sql | 12 |
10 files changed, 290 insertions, 13 deletions
diff --git a/form/errors.go b/form/errors.go index 52206c4..ab9abd4 100644 --- a/form/errors.go +++ b/form/errors.go @@ -5,6 +5,18 @@ import ( "fmt" ) +type FormErrors struct { + Errors []error `form:"-"` +} + +func (f *FormErrors) AddError(field, issue string) { + f.Errors = append(f.Errors, FieldError{field, issue}) +} + +func (f *FormErrors) HasError() bool { + return len(f.Errors) > 0 +} + type FieldError struct { Field string Issue string @@ -66,6 +66,7 @@ func main() { authRouter.Mount("/", pages.Init()) authRouter.Mount("/recur", pages.Recur()) authRouter.Mount("/categories", pages.Categories()) + authRouter.Mount("/cpw", pages.ChangePassword()) authRouter.NotFound(pages.NotFound()) log.Fatal(http.ListenAndServe(os.Getenv("GOSTEN_ADDRESS"), router)) diff --git a/model/users.sql.go b/model/users.sql.go index e63dacc..bd881b7 100644 --- a/model/users.sql.go +++ b/model/users.sql.go @@ -9,6 +9,24 @@ import ( "context" ) +const getPwdById = `-- name: GetPwdById :one + SELECT pwd + FROM users + WHERE id = $1 +` + +// GetPwdById +// +// SELECT pwd +// FROM users +// WHERE id = $1 +func (q *Queries) GetPwdById(ctx context.Context, id int32) (string, error) { + row := q.db.QueryRow(ctx, getPwdById, id) + var pwd string + err := row.Scan(&pwd) + return pwd, err +} + const getUserById = `-- name: GetUserById :one SELECT id, name, pwd, description FROM users @@ -88,3 +106,24 @@ func (q *Queries) GetUsers(ctx context.Context) ([]User, error) { } return items, nil } + +const updatePwd = `-- name: UpdatePwd :exec + UPDATE users + SET pwd = $1 + WHERE id = $2 +` + +type UpdatePwdParams struct { + Pwd string + ID int32 +} + +// UpdatePwd +// +// UPDATE users +// SET pwd = $1 +// WHERE id = $2 +func (q *Queries) UpdatePwd(ctx context.Context, arg UpdatePwdParams) error { + _, err := q.db.Exec(ctx, updatePwd, arg.Pwd, arg.ID) + return err +} diff --git a/pages/base.templ b/pages/base.templ index 33edd65..47de938 100644 --- a/pages/base.templ +++ b/pages/base.templ @@ -43,6 +43,7 @@ templ navlinks() { templ userlinks() { <li><h4 class="dropdown-header">{getUser(ctx).Name}</h4></li> + <li><a class="dropdown-item" href="/cpw">Passwort Ändern</a></li> <li><a class="dropdown-item" href="/logout">Logout</a></li> } @@ -55,6 +56,9 @@ templ navbar() { <symbol id="person" viewBox="0 0 16 16"> <path d="M8 8a3 3 0 1 0 0-6 3 3 0 0 0 0 6m2-3a2 2 0 1 1-4 0 2 2 0 0 1 4 0m4 8c0 1-1 1-1 1H3s-1 0-1-1 1-4 6-4 6 3 6 4m-1-.004c-.001-.246-.154-.986-.832-1.664C11.516 10.68 10.289 10 8 10s-3.516.68-4.168 1.332c-.678.678-.83 1.418-.832 1.664z"/> </symbol> + <symbol id="check-circle-fill" viewBox="0 0 16 16"> + <path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0zm-3.97-3.03a.75.75 0 0 0-1.08.022L7.477 9.417 5.384 7.323a.75.75 0 0 0-1.06 1.06L6.97 11.03a.75.75 0 0 0 1.079-.02l3.992-4.99a.75.75 0 0 0-.01-1.05z"/> + </symbol> </svg> /* The Navbar */ diff --git a/pages/base_templ.go b/pages/base_templ.go index d871263..f355b61 100644 --- a/pages/base_templ.go +++ b/pages/base_templ.go @@ -236,7 +236,7 @@ func userlinks() templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</h4></li><li><a class=\"dropdown-item\" href=\"/logout\">Logout</a></li>") + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</h4></li><li><a class=\"dropdown-item\" href=\"/cpw\">Passwort Ändern</a></li><li><a class=\"dropdown-item\" href=\"/logout\">Logout</a></li>") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -265,7 +265,7 @@ func navbar() templ.Component { templ_7745c5c3_Var11 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<svg xmlns=\"http://www.w3.org/2000/svg\" class=\"d-none\"><symbol id=\"search\" viewBox=\"0 0 16 16\"><path d=\"M11.742 10.344a6.5 6.5 0 1 0-1.397 1.398h-.001c.03.04.062.078.098.115l3.85 3.85a1 1 0 0 0 1.415-1.414l-3.85-3.85a1.007 1.007 0 0 0-.115-.1zM12 6.5a5.5 5.5 0 1 1-11 0 5.5 5.5 0 0 1 11 0z\"></path></symbol> <symbol id=\"person\" viewBox=\"0 0 16 16\"><path d=\"M8 8a3 3 0 1 0 0-6 3 3 0 0 0 0 6m2-3a2 2 0 1 1-4 0 2 2 0 0 1 4 0m4 8c0 1-1 1-1 1H3s-1 0-1-1 1-4 6-4 6 3 6 4m-1-.004c-.001-.246-.154-.986-.832-1.664C11.516 10.68 10.289 10 8 10s-3.516.68-4.168 1.332c-.678.678-.83 1.418-.832 1.664z\"></path></symbol></svg><nav class=\"navbar sticky-top bg-dark-subtle navbar-expand-lg shadow mb-2\"><div class=\"container-fluid\"><a class=\"navbar-brand\" href=\"/\"><img src=\"/static/euro-money.svg\" alt=\"Logo\" width=\"32\" height=\"32\" class=\"d-inline-block\"> Kosten</a> <button class=\"navbar-toggler\" type=\"button\" data-bs-toggle=\"collapse\" data-bs-target=\"#navbarNav\" aria-controls=\"navbarNav\" aria-expanded=\"false\" aria-label=\"Toggle navigation\"><span class=\"navbar-toggler-icon\"></span></button><div class=\"collapse navbar-collapse\" id=\"navbarNav\"><ul class=\"navbar-nav nav-underline me-auto\">") + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<svg xmlns=\"http://www.w3.org/2000/svg\" class=\"d-none\"><symbol id=\"search\" viewBox=\"0 0 16 16\"><path d=\"M11.742 10.344a6.5 6.5 0 1 0-1.397 1.398h-.001c.03.04.062.078.098.115l3.85 3.85a1 1 0 0 0 1.415-1.414l-3.85-3.85a1.007 1.007 0 0 0-.115-.1zM12 6.5a5.5 5.5 0 1 1-11 0 5.5 5.5 0 0 1 11 0z\"></path></symbol> <symbol id=\"person\" viewBox=\"0 0 16 16\"><path d=\"M8 8a3 3 0 1 0 0-6 3 3 0 0 0 0 6m2-3a2 2 0 1 1-4 0 2 2 0 0 1 4 0m4 8c0 1-1 1-1 1H3s-1 0-1-1 1-4 6-4 6 3 6 4m-1-.004c-.001-.246-.154-.986-.832-1.664C11.516 10.68 10.289 10 8 10s-3.516.68-4.168 1.332c-.678.678-.83 1.418-.832 1.664z\"></path></symbol> <symbol id=\"check-circle-fill\" viewBox=\"0 0 16 16\"><path d=\"M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0zm-3.97-3.03a.75.75 0 0 0-1.08.022L7.477 9.417 5.384 7.323a.75.75 0 0 0-1.06 1.06L6.97 11.03a.75.75 0 0 0 1.079-.02l3.992-4.99a.75.75 0 0 0-.01-1.05z\"></path></symbol></svg><nav class=\"navbar sticky-top bg-dark-subtle navbar-expand-lg shadow mb-2\"><div class=\"container-fluid\"><a class=\"navbar-brand\" href=\"/\"><img src=\"/static/euro-money.svg\" alt=\"Logo\" width=\"32\" height=\"32\" class=\"d-inline-block\"> Kosten</a> <button class=\"navbar-toggler\" type=\"button\" data-bs-toggle=\"collapse\" data-bs-target=\"#navbarNav\" aria-controls=\"navbarNav\" aria-expanded=\"false\" aria-label=\"Toggle navigation\"><span class=\"navbar-toggler-icon\"></span></button><div class=\"collapse navbar-collapse\" id=\"navbarNav\"><ul class=\"navbar-nav nav-underline me-auto\">") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/pages/chpw.go b/pages/chpw.go new file mode 100644 index 0000000..671c180 --- /dev/null +++ b/pages/chpw.go @@ -0,0 +1,84 @@ +package pages + +import ( + "context" + "fmt" + "gosten/csrf" + "gosten/form" + "gosten/model" + "net/http" + + "github.com/go-chi/chi/v5" + "golang.org/x/crypto/bcrypt" +) + +type chpw struct { + Password string `form:"type=password;options=required"` + NewPw1 string `form:"label=Neues Password;type=password;options=required"` + NewPw2 string `form:"label=Wiederholung;type=password;options=required"` + Success bool `form:"-"` + form.FormErrors + csrf.CsrfField +} + +func ChangePassword() Page { + r := chi.NewRouter() + + r.Get("/", func(w http.ResponseWriter, r *http.Request) { + c := chpw{} + c.SetCsrfField(r) + render(changePassword(c))(w, r) + }) + + r.Post("/", handleChPw) + + return r +} + +func handleChPw(w http.ResponseWriter, r *http.Request) { + c := chpw{} + form.Parse(r, &c) + + ctx := r.Context() + userId := getUser(ctx).ID + dbPwd, err := Q.GetPwdById(ctx, userId) + if err != nil { + panic(fmt.Sprintf("Q.GetPwdById: %v", err)) + } + + if c.NewPw1 != c.NewPw2 { + c.AddError("NewPw2", "Neues Passwort stimmt nicht überein!") + } + + if !validatePwd(dbPwd, c.Password) { + c.AddError("Password", "Passwort falsch!") + } + + if !c.HasError() { + updatePwd(ctx, userId, c.NewPw1) + + // update context + ctx, _ = setUserInContext(ctx, userId) + r = r.WithContext(ctx) + + // reset form + c = chpw{Success: true} + } + + c.SetCsrfField(r) + render(changePassword(c))(w, r) +} + +func updatePwd(ctx context.Context, userId int32, pwd string) { + hash, err := bcrypt.GenerateFromPassword([]byte(pwd), -1) + if err != nil { + panic(fmt.Sprintf("Generating password hash: %v", err)) + } + + err = Q.UpdatePwd(ctx, model.UpdatePwdParams{ + Pwd: string(hash), + ID: userId}) + if err != nil { + panic(fmt.Sprintf("Updating password: %v", err)) + } +} diff --git a/pages/chpw.templ b/pages/chpw.templ new file mode 100644 index 0000000..3cf5ded --- /dev/null +++ b/pages/chpw.templ @@ -0,0 +1,25 @@ +package pages + +import ( + "gosten/form" +) + +templ changePassword(chpw chpw) { + @content(){ + if chpw.Success { + <div class="alert alert-success d-flex align-items-center" role="alert"> + <svg class="svg me-2" role="img" aria-label="Success:"><use xlink:href="#check-circle-fill"/></svg> + <div> + Passwort ändern erfolgreich! + </div> + </div> + } + + <form action={templ.SafeURL(getCurrPath(ctx))} method="post" class="container mx-auto" style="max-width: 440px;"> + <h3>Passwort Ändern</h3> + @form.Form(chpw, chpw.Errors) + @chpw.Csrf() + <input class="btn btn-primary w-100" type="submit" /> + </form> + } +}
\ No newline at end of file diff --git a/pages/chpw_templ.go b/pages/chpw_templ.go new file mode 100644 index 0000000..0f402c2 --- /dev/null +++ b/pages/chpw_templ.go @@ -0,0 +1,89 @@ +// Code generated by templ - DO NOT EDIT. + +// templ: version: v0.2.778 +package pages + +//lint:file-ignore SA4006 This context is only used if a nested component is present. + +import "github.com/a-h/templ" +import templruntime "github.com/a-h/templ/runtime" + +import ( + "gosten/form" +) + +func changePassword(chpw chpw) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var1 := templ.GetChildren(ctx) + if templ_7745c5c3_Var1 == nil { + templ_7745c5c3_Var1 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Var2 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + if chpw.Success { + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<div class=\"alert alert-success d-flex align-items-center\" role=\"alert\"><svg class=\"svg me-2\" role=\"img\" aria-label=\"Success:\"><use xlink:href=\"#check-circle-fill\"></use></svg><div>Passwort ändern erfolgreich!</div></div>") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(" <form action=\"") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var3 templ.SafeURL = templ.SafeURL(getCurrPath(ctx)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(string(templ_7745c5c3_Var3))) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("\" method=\"post\" class=\"container mx-auto\" style=\"max-width: 440px;\"><h3>Passwort Ändern</h3>") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = form.Form(chpw, chpw.Errors).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = chpw.Csrf().Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<input class=\"btn btn-primary w-100\" type=\"submit\"></form>") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return templ_7745c5c3_Err + }) + templ_7745c5c3_Err = content().Render(templ.WithChildren(ctx, templ_7745c5c3_Var2), templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return templ_7745c5c3_Err + }) +} + +var _ = templruntime.GeneratedTemplate diff --git a/pages/login.go b/pages/login.go index 84119e9..d433937 100644 --- a/pages/login.go +++ b/pages/login.go @@ -27,15 +27,24 @@ const ( loginQueryMarker = "next" ) +func setUserInContext(ctx context.Context, uid int32) (context.Context, error) { + u, err := Q.GetUserById(ctx, uid) + if err != nil { + return ctx, err + } + + u.Pwd = "" // don't carry pwd around + return context.WithValue(ctx, userContextKey{}, u), nil +} + func RequireAuth(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { s := session.From(r) if !s.IsNew() && s.Authenticated { - u, err := Q.GetUserById(r.Context(), s.UserID) + ctx, err := setUserInContext(r.Context(), s.UserID) if err == nil { // authenticated --> done - ctx := context.WithValue(r.Context(), userContextKey{}, u) next.ServeHTTP(w, r.WithContext(ctx)) return } @@ -53,10 +62,10 @@ func RequireAuth(next http.Handler) http.Handler { } type user struct { - Name string `form:"options=required,autofocus"` - Password string `form:"type=password;options=required"` - RememberMe bool `form:"type=checkbox;value=y;options=checked"` - Errors []error `form:"-"` + Name string `form:"options=required,autofocus"` + Password string `form:"type=password;options=required"` + RememberMe bool `form:"type=checkbox;value=y;options=checked"` + form.FormErrors csrf.CsrfField } @@ -77,13 +86,17 @@ func Login() Page { return r } +func validatePwd(hash, pwd string) bool { + hashB := []byte(hash) + pwdB := []byte(pwd) + + return bcrypt.CompareHashAndPassword(hashB, pwdB) == nil +} + func checkLogin(ctx context.Context, user user) (bool, int32) { dbUser, err := Q.GetUserByName(ctx, user.Name) if err == nil { - hash := []byte(dbUser.Pwd) - pwd := []byte(user.Password) - - if bcrypt.CompareHashAndPassword(hash, pwd) != nil { + if !validatePwd(dbUser.Pwd, user.Password) { return false, 0 } } else if errors.Is(err, sql.ErrNoRows) { diff --git a/sql/users.sql b/sql/users.sql index 2e1d997..e37e782 100644 --- a/sql/users.sql +++ b/sql/users.sql @@ -10,4 +10,14 @@ SELECT * -- name: GetUserById :one SELECT * FROM users - WHERE id = $1;
\ No newline at end of file + WHERE id = $1; + +-- name: GetPwdById :one + SELECT pwd + FROM users + WHERE id = $1; + +-- name: UpdatePwd :exec + UPDATE users + SET pwd = $1 + WHERE id = $2;
\ No newline at end of file |