Add change password feature

This commit is contained in:
Tom Wiesing 2022-12-28 17:58:29 +01:00
parent 515142c055
commit b9795be745
No known key found for this signature in database
10 changed files with 228 additions and 96 deletions

View file

@ -43,12 +43,19 @@ func (auth *Auth) HandleRoute(ctx context.Context, route string) (http.Handler,
router.Handler(http.MethodGet, route, auth.authHome(ctx)) 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.MethodGet, route+"logout", auth.authLogout(ctx))
router.Handler(http.MethodPost, route+"login", login)
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 return router, nil
} }

View file

@ -3,6 +3,8 @@ package auth
import ( import (
"context" "context"
_ "embed" _ "embed"
"errors"
"html/template"
"net/http" "net/http"
"github.com/FAU-CDI/wisski-distillery/internal/dis/component/control/static" "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, Template: homeTemplate,
}, nil) }, 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
},
}
}

View file

@ -13,22 +13,6 @@ import (
_ "embed" _ "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. // 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. // 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 return user, nil
} }
// writeLogin marks the user as logged in on the given writer const sessionCookieName = "distillery-session"
func (auth *Auth) writeLogin(w http.ResponseWriter, r *http.Request, user *AuthUser) error {
// 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) sess, err := auth.session(r)
if err != nil { if err != nil {
return err return err
@ -84,8 +89,11 @@ func (auth *Auth) writeLogin(w http.ResponseWriter, r *http.Request, user *AuthU
return sess.Save(r, w) return sess.Save(r, w)
} }
// writeLogout logs out the user form the given session // Logout logs out the user from the given session.
func (auth *Auth) writeLogout(w http.ResponseWriter, r *http.Request) error { //
// 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) sess, err := auth.session(r)
if err != nil { if err != nil {
return err return err
@ -103,7 +111,7 @@ var loginResponse = httpx.Response{
Body: []byte("user is signed in"), Body: []byte("user is signed in"),
} }
type loginContext struct { type authloginContext struct {
Message string Message string
Form template.HTML 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. // authLogin implements a view to login a user
// auth.csrf must have been populated func (auth *Auth) authLogin(ctx context.Context) http.Handler {
func (auth *Auth) loginForm() *httpx.Form[*AuthUser] {
return &httpx.Form[*AuthUser]{ return &httpx.Form[*AuthUser]{
Fields: []httpx.Field{ Fields: []httpx.Field{
{Name: "username", Type: httpx.TextField}, {Name: "username", Type: httpx.TextField, Label: "Username"},
{Name: "password", Type: httpx.PasswordField}, {Name: "password", Type: httpx.PasswordField, EmptyOnError: true, Label: "Password"},
}, },
FieldTemplate: httpx.PureCSSFieldTemplate,
CSRF: auth.csrf.Get(nil), CSRF: auth.csrf.Get(nil),
RenderForm: func(template template.HTML, err error, w http.ResponseWriter, r *http.Request) { RenderForm: func(template template.HTML, err error, w http.ResponseWriter, r *http.Request) {
ctx := loginContext{ ctx := authloginContext{
Message: "", Message: "",
Form: template, Form: template,
} }
if err != nil { if err != nil {
ctx.Message = "Login Failed" ctx.Message = "Login Failed"
} }
httpx.WriteHTML(ctx, nil, loginTemplate, "", w, r) 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"] username, password := values["username"], values["password"]
// make sure that the user exists // 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 { 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 return err
} }
@ -224,17 +231,19 @@ func (auth *Auth) loginForm() *httpx.Form[*AuthUser] {
} }
} }
func (auth *Auth) logoutRoute(w http.ResponseWriter, r *http.Request) { // authLogout implements the authLogout view to logout a user
// do the logout func (auth *Auth) authLogout(ctx context.Context) http.Handler {
auth.writeLogout(w, r) return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// do the logout
auth.Logout(w, r)
// get the destination // get the destination
next := r.URL.Query().Get("next") next := r.URL.Query().Get("next")
if next == "" || next[0] != '/' { if next == "" || next[0] != '/' {
next = "/" next = "/"
} }
// and redirect to it!
http.Redirect(w, r, next, http.StatusSeeOther)
// and redirect to it!
http.Redirect(w, r, next, http.StatusSeeOther)
})
} }

View file

@ -10,20 +10,23 @@
{{ define "content" }} {{ define "content" }}
<div class="pure-u-1"> <div class="pure-u-1">
<div> <div>
Welcome {{ .User.User }}! Welcome {{ .User.User }}!
<hr />
</div> </div>
<form method="GET" action="/auth/password/">
<input type="submit" value="Change Password"></input>
</form>
<!-- TODO: A logout button only for now -->
<form method="GET" action="/auth/logout/">
<input type="submit" value="Logout"></input>
</form>
</div> </div>
{{ end }} <div class="pure-u-1">
<div class="pure-button-group" role="group" role="Actions">
<a class="pure-button" href="/auth/logout/">Logout</a>
</div>
<hr />
</div>
<div class="pure-u-1">
<div class="pure-button-group" role="group" role="Actions">
<a class="pure-button" href="/auth/password/">Change Password</a>
</div>
</div>
{{ end }}

View file

@ -1,29 +1,3 @@
{{ template "_base.html" . }} {{ template "_form.html" . }}
{{ define "title" }}Login{{ end }} {{ define "form/title" }}Login Required{{ end }}
{{ define "form/button" }}Login{{ end }}
{{ define "header/time" }}
<!-- no header/time -->
{{ end }}
{{ define "header"}}
<!-- no header -->
{{ end }}
{{ define "content" }}
<div class="pure-u-1">
{{ if .Message }}
<div>
{{ .Message }}
</div>
{{ end }}
<form class="pure-form" method="POST">
<fieldset>
<legend>Login Required</legend>
{{ .Form }}
<input type="submit" value="Login">
</fieldset>
</form>
</div>
{{ end }}

View file

@ -0,0 +1,9 @@
{{ template "_form.html" . }}
{{ define "form/title" }}Change Password{{ end }}
{{ define "form/button" }}Update{{ end }}
{{ define "form/extra" }}
<div>
<a class="pure-button" href="/auth/">Back</a>
<hr />
</div>
{{ end }}

View file

@ -104,7 +104,10 @@ type AuthUser struct {
models.User models.User
} }
func (au AuthUser) String() string { func (au *AuthUser) String() string {
if au == nil {
return "User{nil}"
}
hasPassword := len(au.PasswordHash) > 0 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) 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) return au.Save(ctx)
} }
var ErrNoUser = errors.New("user is nil")
var ErrUserDisabled = errors.New("user is disabled") var ErrUserDisabled = errors.New("user is disabled")
var ErrUserBlank = errors.New("user has no password set") var ErrUserBlank = errors.New("user has no password set")
// CheckPassword checks if this user can login with the provided password. // CheckPassword checks if this user can login with the provided password.
// Returns nil on success, an error otherwise. // Returns nil on success, an error otherwise.
func (au *AuthUser) CheckPassword(ctx context.Context, password []byte) error { func (au *AuthUser) CheckPassword(ctx context.Context, password []byte) error {
if au == nil {
return ErrNoUser
}
if !au.User.Enabled { if !au.User.Enabled {
return ErrUserDisabled return ErrUserDisabled
} }

View file

@ -4,7 +4,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<meta charset="utf-8"> <meta charset="utf-8">
<title>{{ block "block" . }}Distillery Control Page{{ end }}</title> <title>{{ block "block" . }}WissKI Distillery{{ end }}</title>
{{ block "styles" . }}styles{{ end }} {{ block "styles" . }}styles{{ end }}
</head> </head>

View file

@ -0,0 +1,32 @@
{{ template "_base.html" . }}
{{ define "title" }}{{ block "form/title" . }}Form{{ end }}{{ end }}
{{ define "header/time" }}
<!-- no header/time -->
{{ end }}
{{ define "header"}}
<!-- no header -->
{{ end }}
{{ define "content" }}
<div class="pure-u-1">
{{ block "form/message" . }}
{{ if .Message }}
<div>
{{ .Message }}
</div>
{{ end }}
{{ end }}
{{ block "form/extra" . }}<!-- no extra -->{{ end }}
<form class="pure-form pure-form-aligned" method="POST">
<fieldset>
<legend>{{ template "form/title" . }}</legend>
{{ .Form }}
<input type="submit" value="{{ block "form/button" .}}Submit{{ end }}" class="pure-button">
</fieldset>
</form>
</div>
{{ end }}

View file

@ -1,7 +1,6 @@
package httpx package httpx
import ( import (
"context"
"html/template" "html/template"
"io" "io"
"net/http" "net/http"
@ -11,10 +10,19 @@ import (
"github.com/gorilla/csrf" "github.com/gorilla/csrf"
) )
// DefaultFieldTemplate is the default template to render fields.
var DefaultFieldTemplate = template.Must(template.New("").Parse(`<input type="{{.Type}}" value="{{.Value}}" name="{{.Name}}" placeholder={{.Placeholder}}>`))
var PureCSSFieldTemplate = template.Must(template.New("").Parse(`
<div class="pure-control-group"><label for="{{.Name}}">{{.Label}}</label><input type="{{.Type}}" value="{{.Value}}" name="{{.Name}}" id="{{.Name}}" placeholder="{{.Placeholder}}"></div>`))
// Form implements a user-submittable form // Form implements a user-submittable form
type Form[D any] struct { type Form[D any] struct {
Fields []Field 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. // 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. // It must be set before any other functions on this Form are called, and may not be changed.
CSRF func(http.Handler) http.Handler CSRF func(http.Handler) http.Handler
@ -33,7 +41,7 @@ type Form[D any] struct {
// Validate, if non-nil, validates the given submitted values. // Validate, if non-nil, validates the given submitted values.
// There is no guarantee that the values are set. // 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 handles rendering a success result into a response.
RenderSuccess func(data D, values map[string]string, w http.ResponseWriter, r *http.Request) error 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 = "" value = ""
} }
field.WriteTo(&builder, value) field.WriteTo(&builder, form.FieldTemplate, value)
} }
return template.HTML(builder.String()) 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 // validate the form
if form.Validate != nil { if form.Validate != nil {
d, err = form.Validate(r.Context(), values) d, err = form.Validate(r, values)
if err != nil { if err != nil {
return nil, d, err return nil, d, err
} }
@ -135,18 +143,22 @@ type Field struct {
Name string Name string
Type InputType 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 EmptyOnError bool // indicates if the field should be reset on error
} }
var inputTemplate = template.Must(template.New("").Parse(`<input type="{{.Type}}" value="{{.Value}}" name="{{.Name}}">`))
type fieldContext struct { type fieldContext struct {
Field Field
Value string Value string
} }
func (field Field) WriteTo(w io.Writer, value string) { func (field Field) WriteTo(w io.Writer, template *template.Template, value string) {
inputTemplate.Execute(w, fieldContext{Field: field, Value: value}) if template == nil {
template = DefaultFieldTemplate
}
template.Execute(w, fieldContext{Field: field, Value: value})
} }
// InputType represents the type of input // InputType represents the type of input