Add change password feature
This commit is contained in:
parent
515142c055
commit
b9795be745
10 changed files with 228 additions and 96 deletions
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,20 +10,23 @@
|
|||
|
||||
{{ define "content" }}
|
||||
<div class="pure-u-1">
|
||||
|
||||
<div>
|
||||
Welcome {{ .User.User }}!
|
||||
<hr />
|
||||
</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>
|
||||
|
||||
{{ 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 }}
|
||||
|
|
@ -1,29 +1,3 @@
|
|||
{{ template "_base.html" . }}
|
||||
{{ define "title" }}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 }}
|
||||
{{ template "_form.html" . }}
|
||||
{{ define "form/title" }}Login Required{{ end }}
|
||||
{{ define "form/button" }}Login{{ end }}
|
||||
|
|
|
|||
9
internal/dis/component/auth/templates/password.html
Normal file
9
internal/dis/component/auth/templates/password.html
Normal 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 }}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta charset="utf-8">
|
||||
|
||||
<title>{{ block "block" . }}Distillery Control Page{{ end }}</title>
|
||||
<title>{{ block "block" . }}WissKI Distillery{{ end }}</title>
|
||||
{{ block "styles" . }}styles{{ end }}
|
||||
</head>
|
||||
|
||||
|
|
|
|||
32
internal/dis/component/control/static/templates/_form.html
Normal file
32
internal/dis/component/control/static/templates/_form.html
Normal 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 }}
|
||||
|
|
@ -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(`<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
|
||||
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(`<input type="{{.Type}}" value="{{.Value}}" name="{{.Name}}">`))
|
||||
|
||||
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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue