From b9795be74521b28ed91080d14ac17e9ff60f922f Mon Sep 17 00:00:00 2001 From: Tom Wiesing Date: Wed, 28 Dec 2022 17:58:29 +0100 Subject: [PATCH] Add change password feature --- internal/dis/component/auth/auth.go | 15 ++- internal/dis/component/auth/home.go | 79 ++++++++++++++++ internal/dis/component/auth/session.go | 91 ++++++++++--------- .../dis/component/auth/templates/home.html | 27 +++--- .../dis/component/auth/templates/login.html | 32 +------ .../component/auth/templates/password.html | 9 ++ internal/dis/component/auth/user.go | 9 +- .../control/static/templates/_base.html | 2 +- .../control/static/templates/_form.html | 32 +++++++ pkg/httpx/form.go | 28 ++++-- 10 files changed, 228 insertions(+), 96 deletions(-) create mode 100644 internal/dis/component/auth/templates/password.html create mode 100644 internal/dis/component/control/static/templates/_form.html diff --git a/internal/dis/component/auth/auth.go b/internal/dis/component/auth/auth.go index 6af2636..ce50b40 100644 --- a/internal/dis/component/auth/auth.go +++ b/internal/dis/component/auth/auth.go @@ -43,12 +43,19 @@ func (auth *Auth) HandleRoute(ctx context.Context, route string) (http.Handler, router.Handler(http.MethodGet, route, auth.authHome(ctx)) - login := auth.loginForm() + { + login := auth.authLogin(ctx) + router.Handler(http.MethodGet, route+"login", login) + router.Handler(http.MethodPost, route+"login", login) + } - router.Handler(http.MethodGet, route+"login", login) - router.Handler(http.MethodPost, route+"login", login) + router.Handler(http.MethodGet, route+"logout", auth.authLogout(ctx)) - router.HandlerFunc(http.MethodGet, route+"logout", auth.logoutRoute) + { + password := auth.authPassword(ctx) + router.Handler(http.MethodGet, route+"password", password) + router.Handler(http.MethodPost, route+"password", password) + } return router, nil } diff --git a/internal/dis/component/auth/home.go b/internal/dis/component/auth/home.go index afbd154..ef2a7a5 100644 --- a/internal/dis/component/auth/home.go +++ b/internal/dis/component/auth/home.go @@ -3,6 +3,8 @@ package auth import ( "context" _ "embed" + "errors" + "html/template" "net/http" "github.com/FAU-CDI/wisski-distillery/internal/dis/component/control/static" @@ -22,3 +24,80 @@ func (auth *Auth) authHome(ctx context.Context) http.Handler { Template: homeTemplate, }, nil) } + +//go:embed "templates/password.html" +var passwordHTMLString string +var passwordTemplate = static.AssetsAuthLogin.MustParseShared("password.html", passwordHTMLString) + +type authpasswordContext struct { + Message string + Form template.HTML +} + +var ( + errPasswordsNotIdentical = errors.New("passwords are not identical") + errPasswordIsEmpty = errors.New("password is empty") + errPasswordIncorrect = errors.New("old password is not correct") + errPasswordSetFailure = errors.New("error saving new password") + errPasswordSet = errors.New("password was updated") +) + +func (auth *Auth) authPassword(ctx context.Context) http.Handler { + return &httpx.Form[struct{}]{ + Fields: []httpx.Field{ + {Name: "old", Type: httpx.PasswordField, EmptyOnError: true, Label: "Current Password"}, + {Name: "new", Type: httpx.PasswordField, EmptyOnError: true, Label: "New Password"}, + {Name: "new2", Type: httpx.PasswordField, EmptyOnError: true, Label: "New Password (again)"}, + }, + FieldTemplate: httpx.PureCSSFieldTemplate, + + CSRF: auth.csrf.Get(nil), + + RenderForm: func(template template.HTML, err error, w http.ResponseWriter, r *http.Request) { + ctx := authpasswordContext{ + Message: "", + Form: template, + } + if err != nil { + ctx.Message = err.Error() + } + httpx.WriteHTML(ctx, nil, passwordTemplate, "", w, r) + }, + + Validate: func(r *http.Request, values map[string]string) (struct{}, error) { + old, new, new2 := values["old"], values["new"], values["new2"] + + if new != new2 { + return struct{}{}, errPasswordsNotIdentical + } + + if new == "" { + return struct{}{}, errPasswordIsEmpty + } + + user, err := auth.UserOf(r) + if err != nil { + return struct{}{}, err + } + + { + err := user.CheckPassword(r.Context(), []byte(old)) + if err != nil { + return struct{}{}, errPasswordIncorrect + } + } + { + err := user.SetPassword(r.Context(), []byte(new)) + if err != nil { + return struct{}{}, errPasswordSetFailure + } + } + + return struct{}{}, nil + }, + + RenderSuccess: func(_ struct{}, values map[string]string, w http.ResponseWriter, r *http.Request) error { + return errPasswordSet + }, + } +} diff --git a/internal/dis/component/auth/session.go b/internal/dis/component/auth/session.go index bfa4208..9395b1f 100644 --- a/internal/dis/component/auth/session.go +++ b/internal/dis/component/auth/session.go @@ -13,22 +13,6 @@ import ( _ "embed" ) -type contextUserKey struct{} - -var ctxUserKey = contextUserKey{} - -const ( - sessionCookieName = "distillery-session" - sessionUserKey = "user" -) - -// session returns the session belonging to a request -func (auth *Auth) session(r *http.Request) (*sessions.Session, error) { - return auth.store.Get(func() sessions.Store { - return sessions.NewCookieStore([]byte(auth.Config.SessionSecret)) - }).Get(r, sessionCookieName) -} - // UserOf returns the user logged into the given request. // If there is no user associated with the given user, user and error will be nil. // @@ -74,8 +58,29 @@ func (auth *Auth) UserOf(r *http.Request) (user *AuthUser, err error) { return user, nil } -// writeLogin marks the user as logged in on the given writer -func (auth *Auth) writeLogin(w http.ResponseWriter, r *http.Request, user *AuthUser) error { +const sessionCookieName = "distillery-session" + +// session returns the session that belongs to a given request. +// If the session is not set, creates a new session. +func (auth *Auth) session(r *http.Request) (*sessions.Session, error) { + return auth.store.Get(func() sessions.Store { + return sessions.NewCookieStore([]byte(auth.Config.SessionSecret)) + }).Get(r, sessionCookieName) +} + +const sessionUserKey = "user" + +type contextUserKey struct{} + +var ctxUserKey = contextUserKey{} + +// Login logs a user into the given request. +// +// If a user was previously logged into this session, +// UserOf may not return the correct user until the user makes a new request. +// +// It is recommended to send a HTTP redirect to make sure a new request is made. +func (auth *Auth) Login(w http.ResponseWriter, r *http.Request, user *AuthUser) error { sess, err := auth.session(r) if err != nil { return err @@ -84,8 +89,11 @@ func (auth *Auth) writeLogin(w http.ResponseWriter, r *http.Request, user *AuthU return sess.Save(r, w) } -// writeLogout logs out the user form the given session -func (auth *Auth) writeLogout(w http.ResponseWriter, r *http.Request) error { +// Logout logs out the user from the given session. +// +// UserOf may return incorrect results until the user makes a new request. +// It is recommended to send a HTTP redirect to make sure a new request is made. +func (auth *Auth) Logout(w http.ResponseWriter, r *http.Request) error { sess, err := auth.session(r) if err != nil { return err @@ -103,7 +111,7 @@ var loginResponse = httpx.Response{ Body: []byte("user is signed in"), } -type loginContext struct { +type authloginContext struct { Message string Form template.HTML } @@ -160,30 +168,29 @@ func (auth *Auth) Protect(handler http.Handler, perm func(user *AuthUser, r *htt }) } -// loginForm returns the login form handler. -// auth.csrf must have been populated -func (auth *Auth) loginForm() *httpx.Form[*AuthUser] { +// authLogin implements a view to login a user +func (auth *Auth) authLogin(ctx context.Context) http.Handler { return &httpx.Form[*AuthUser]{ Fields: []httpx.Field{ - {Name: "username", Type: httpx.TextField}, - {Name: "password", Type: httpx.PasswordField}, + {Name: "username", Type: httpx.TextField, Label: "Username"}, + {Name: "password", Type: httpx.PasswordField, EmptyOnError: true, Label: "Password"}, }, + FieldTemplate: httpx.PureCSSFieldTemplate, CSRF: auth.csrf.Get(nil), RenderForm: func(template template.HTML, err error, w http.ResponseWriter, r *http.Request) { - ctx := loginContext{ + ctx := authloginContext{ Message: "", Form: template, } if err != nil { ctx.Message = "Login Failed" - } httpx.WriteHTML(ctx, nil, loginTemplate, "", w, r) }, - Validate: func(ctx context.Context, values map[string]string) (*AuthUser, error) { + Validate: func(r *http.Request, values map[string]string) (*AuthUser, error) { username, password := values["username"], values["password"] // make sure that the user exists @@ -206,7 +213,7 @@ func (auth *Auth) loginForm() *httpx.Form[*AuthUser] { }, RenderSuccess: func(user *AuthUser, _ map[string]string, w http.ResponseWriter, r *http.Request) error { - if err := auth.writeLogin(w, r, user); err != nil { + if err := auth.Login(w, r, user); err != nil { return err } @@ -224,17 +231,19 @@ func (auth *Auth) loginForm() *httpx.Form[*AuthUser] { } } -func (auth *Auth) logoutRoute(w http.ResponseWriter, r *http.Request) { - // do the logout - auth.writeLogout(w, r) +// authLogout implements the authLogout view to logout a user +func (auth *Auth) authLogout(ctx context.Context) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // do the logout + auth.Logout(w, r) - // get the destination - next := r.URL.Query().Get("next") - if next == "" || next[0] != '/' { - next = "/" - } - - // and redirect to it! - http.Redirect(w, r, next, http.StatusSeeOther) + // get the destination + next := r.URL.Query().Get("next") + if next == "" || next[0] != '/' { + next = "/" + } + // and redirect to it! + http.Redirect(w, r, next, http.StatusSeeOther) + }) } diff --git a/internal/dis/component/auth/templates/home.html b/internal/dis/component/auth/templates/home.html index ef5c94c..cd77003 100644 --- a/internal/dis/component/auth/templates/home.html +++ b/internal/dis/component/auth/templates/home.html @@ -10,20 +10,23 @@ {{ define "content" }}
-
Welcome {{ .User.User }}! +
- -
- -
- - - -
- -
-{{ end }} +
+
+ Logout +
+
+
+ +
+ +
+ +{{ end }} \ No newline at end of file diff --git a/internal/dis/component/auth/templates/login.html b/internal/dis/component/auth/templates/login.html index c23141e..5112a19 100644 --- a/internal/dis/component/auth/templates/login.html +++ b/internal/dis/component/auth/templates/login.html @@ -1,29 +1,3 @@ -{{ template "_base.html" . }} -{{ define "title" }}Login{{ end }} - -{{ define "header/time" }} - -{{ end }} -{{ define "header"}} - -{{ end }} - -{{ define "content" }} -
- - {{ if .Message }} -
- {{ .Message }} -
- {{ end }} - -
-
- Login Required - {{ .Form }} - -
-
-
- -{{ end }} +{{ template "_form.html" . }} +{{ define "form/title" }}Login Required{{ end }} +{{ define "form/button" }}Login{{ end }} diff --git a/internal/dis/component/auth/templates/password.html b/internal/dis/component/auth/templates/password.html new file mode 100644 index 0000000..3d6342b --- /dev/null +++ b/internal/dis/component/auth/templates/password.html @@ -0,0 +1,9 @@ +{{ template "_form.html" . }} +{{ define "form/title" }}Change Password{{ end }} +{{ define "form/button" }}Update{{ end }} +{{ define "form/extra" }} +
+ Back +
+
+{{ end }} diff --git a/internal/dis/component/auth/user.go b/internal/dis/component/auth/user.go index 6d0bc4c..344112f 100644 --- a/internal/dis/component/auth/user.go +++ b/internal/dis/component/auth/user.go @@ -104,7 +104,10 @@ type AuthUser struct { models.User } -func (au AuthUser) String() string { +func (au *AuthUser) String() string { + if au == nil { + return "User{nil}" + } hasPassword := len(au.PasswordHash) > 0 return fmt.Sprintf("User{Name:%q,Enabled:%t,HasPassword:%t,Admin:%t}", au.User.User, au.User.Enabled, hasPassword, au.User.Admin) } @@ -126,12 +129,16 @@ func (au *AuthUser) UnsetPassword(ctx context.Context) error { return au.Save(ctx) } +var ErrNoUser = errors.New("user is nil") var ErrUserDisabled = errors.New("user is disabled") var ErrUserBlank = errors.New("user has no password set") // CheckPassword checks if this user can login with the provided password. // Returns nil on success, an error otherwise. func (au *AuthUser) CheckPassword(ctx context.Context, password []byte) error { + if au == nil { + return ErrNoUser + } if !au.User.Enabled { return ErrUserDisabled } diff --git a/internal/dis/component/control/static/templates/_base.html b/internal/dis/component/control/static/templates/_base.html index b1c40c1..2a8d0eb 100644 --- a/internal/dis/component/control/static/templates/_base.html +++ b/internal/dis/component/control/static/templates/_base.html @@ -4,7 +4,7 @@ - {{ block "block" . }}Distillery Control Page{{ end }} + {{ block "block" . }}WissKI Distillery{{ end }} {{ block "styles" . }}styles{{ end }} diff --git a/internal/dis/component/control/static/templates/_form.html b/internal/dis/component/control/static/templates/_form.html new file mode 100644 index 0000000..0bfbf39 --- /dev/null +++ b/internal/dis/component/control/static/templates/_form.html @@ -0,0 +1,32 @@ +{{ template "_base.html" . }} +{{ define "title" }}{{ block "form/title" . }}Form{{ end }}{{ end }} + +{{ define "header/time" }} + +{{ end }} +{{ define "header"}} + +{{ end }} + +{{ define "content" }} +
+ + {{ block "form/message" . }} + {{ if .Message }} +
+ {{ .Message }} +
+ {{ end }} + {{ end }} + {{ block "form/extra" . }}{{ end }} + +
+
+ {{ template "form/title" . }} + {{ .Form }} + +
+
+
+ +{{ end }} diff --git a/pkg/httpx/form.go b/pkg/httpx/form.go index 0963ac3..473f430 100644 --- a/pkg/httpx/form.go +++ b/pkg/httpx/form.go @@ -1,7 +1,6 @@ package httpx import ( - "context" "html/template" "io" "net/http" @@ -11,10 +10,19 @@ import ( "github.com/gorilla/csrf" ) +// DefaultFieldTemplate is the default template to render fields. +var DefaultFieldTemplate = template.Must(template.New("").Parse(``)) +var PureCSSFieldTemplate = template.Must(template.New("").Parse(` +
`)) + // Form implements a user-submittable form type Form[D any] struct { Fields []Field + // FieldTemplate is executed for each field. + // Defaults to a [DefaultFieldTemplate] + FieldTemplate *template.Template + // CSRF holds an optional reference to a CSRF.Protect call. // It must be set before any other functions on this Form are called, and may not be changed. CSRF func(http.Handler) http.Handler @@ -33,7 +41,7 @@ type Form[D any] struct { // Validate, if non-nil, validates the given submitted values. // There is no guarantee that the values are set. - Validate func(context context.Context, values map[string]string) (D, error) + Validate func(r *http.Request, values map[string]string) (D, error) // RenderSuccess handles rendering a success result into a response. RenderSuccess func(data D, values map[string]string, w http.ResponseWriter, r *http.Request) error @@ -49,7 +57,7 @@ func (form *Form[D]) Template(values map[string]string, isError bool) template.H value = "" } - field.WriteTo(&builder, value) + field.WriteTo(&builder, form.FieldTemplate, value) } return template.HTML(builder.String()) @@ -70,7 +78,7 @@ func (form *Form[D]) Values(r *http.Request) (v map[string]string, d D, err erro // validate the form if form.Validate != nil { - d, err = form.Validate(r.Context(), values) + d, err = form.Validate(r, values) if err != nil { return nil, d, err } @@ -135,18 +143,22 @@ type Field struct { Name string Type InputType + Placeholder string // Optional placeholder + Label string // Label for the template. Not used by the default template. + EmptyOnError bool // indicates if the field should be reset on error } -var inputTemplate = template.Must(template.New("").Parse(``)) - type fieldContext struct { Field Value string } -func (field Field) WriteTo(w io.Writer, value string) { - inputTemplate.Execute(w, fieldContext{Field: field, Value: value}) +func (field Field) WriteTo(w io.Writer, template *template.Template, value string) { + if template == nil { + template = DefaultFieldTemplate + } + template.Execute(w, fieldContext{Field: field, Value: value}) } // InputType represents the type of input