auth: Improve login parts

This commit is contained in:
Tom Wiesing 2022-12-25 11:54:16 +01:00
parent 1af9d0d83f
commit 515142c055
No known key found for this signature in database
13 changed files with 382 additions and 101 deletions

File diff suppressed because one or more lines are too long

View file

@ -1,12 +1,15 @@
package auth
import (
"context"
"net/http"
"github.com/FAU-CDI/wisski-distillery/internal/dis/component"
"github.com/FAU-CDI/wisski-distillery/internal/dis/component/sql"
"github.com/FAU-CDI/wisski-distillery/pkg/lazy"
"github.com/gorilla/csrf"
"github.com/gorilla/sessions"
"github.com/julienschmidt/httprouter"
)
type Auth struct {
@ -22,3 +25,30 @@ type Auth struct {
var (
_ component.Routeable = (*Auth)(nil)
)
func (auth *Auth) Routes() []string { return []string{"/auth/"} }
func (auth *Auth) HandleRoute(ctx context.Context, route string) (http.Handler, error) {
router := httprouter.New()
// setup the csrf handler (if needed)
auth.csrf.Get(func() func(http.Handler) http.Handler {
var opts []csrf.Option
if !auth.Config.HTTPSEnabled() {
opts = append(opts, csrf.Secure(false))
}
opts = append(opts, csrf.Path(route))
return csrf.Protect(auth.Config.CSRFSecret(), opts...)
})
router.Handler(http.MethodGet, route, auth.authHome(ctx))
login := auth.loginForm()
router.Handler(http.MethodGet, route+"login", login)
router.Handler(http.MethodPost, route+"login", login)
router.HandlerFunc(http.MethodGet, route+"logout", auth.logoutRoute)
return router, nil
}

View file

@ -0,0 +1,24 @@
package auth
import (
"context"
_ "embed"
"net/http"
"github.com/FAU-CDI/wisski-distillery/internal/dis/component/control/static"
"github.com/FAU-CDI/wisski-distillery/pkg/httpx"
)
//go:embed "templates/home.html"
var homeHTMLStr string
var homeTemplate = static.AssetsAuthHome.MustParseShared(
"home.html",
homeHTMLStr,
)
func (auth *Auth) authHome(ctx context.Context) http.Handler {
return auth.Protect(&httpx.HTMLHandler[*AuthUser]{
Handler: auth.UserOf,
Template: homeTemplate,
}, nil)
}

View file

@ -8,17 +8,11 @@ import (
"github.com/FAU-CDI/wisski-distillery/internal/dis/component/control/static"
"github.com/FAU-CDI/wisski-distillery/pkg/httpx"
"github.com/gorilla/csrf"
"github.com/gorilla/sessions"
"github.com/julienschmidt/httprouter"
_ "embed"
)
func (auth *Auth) Routes() []string {
return []string{"/auth/"}
}
type contextUserKey struct{}
var ctxUserKey = contextUserKey{}
@ -109,32 +103,9 @@ var loginResponse = httpx.Response{
Body: []byte("user is signed in"),
}
// HandleRoute returns the handler for the requested route
func (auth *Auth) HandleRoute(ctx context.Context, route string) (http.Handler, error) {
router := httprouter.New()
csrf := auth.csrf.Get(func() func(http.Handler) http.Handler {
var opts []csrf.Option
if !auth.Config.HTTPSEnabled() {
opts = append(opts, csrf.Secure(false))
}
opts = append(opts, csrf.Path(route))
return csrf.Protect(auth.Config.CSRFSecret(), opts...)
})
router.Handler(http.MethodGet, route, auth.Protect(loginResponse, nil))
router.HandlerFunc(http.MethodGet, route+"login", auth.loginRoute)
router.HandlerFunc(http.MethodPost, route+"login", auth.loginRoute)
router.HandlerFunc(http.MethodGet, route+"logout", auth.logoutRoute)
return csrf(router), nil
}
type loginContext struct {
Message string
CSRF template.HTML
Form template.HTML
}
// Protect returns a new handler which requires a user to be logged in and pass the perm function.
@ -152,7 +123,6 @@ func (auth *Auth) Protect(handler http.Handler, perm func(user *AuthUser, r *htt
// if there is no user in the session
// we need to login the user
if user == nil {
// we can't redirect anything other than GET
// (because it might be a form)
// => so we just return a forbidden
@ -190,70 +160,68 @@ func (auth *Auth) Protect(handler http.Handler, perm func(user *AuthUser, r *htt
})
}
func (auth *Auth) loginRoute(w http.ResponseWriter, r *http.Request) {
var message string
// loginForm returns the login form handler.
// auth.csrf must have been populated
func (auth *Auth) loginForm() *httpx.Form[*AuthUser] {
return &httpx.Form[*AuthUser]{
Fields: []httpx.Field{
{Name: "username", Type: httpx.TextField},
{Name: "password", Type: httpx.PasswordField},
},
// try to read a user from the session
user, err := auth.UserOf(r)
if err != nil {
httpx.HTMLInterceptor.Fallback.ServeHTTP(w, r)
return
CSRF: auth.csrf.Get(nil),
RenderForm: func(template template.HTML, err error, w http.ResponseWriter, r *http.Request) {
ctx := loginContext{
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) {
username, password := values["username"], values["password"]
// make sure that the user exists
user, err := auth.User(ctx, username)
if err != nil {
return nil, err
}
// check the password (TODO: Support TOTP)
err = user.CheckPassword(ctx, []byte(password))
if err != nil {
return nil, err
}
return user, nil
},
SkipForm: func(r *http.Request) (user *AuthUser, skip bool) {
user, err := auth.UserOf(r)
return user, err == nil && user != nil
},
RenderSuccess: func(user *AuthUser, _ map[string]string, w http.ResponseWriter, r *http.Request) error {
if err := auth.writeLogin(w, r, user); err != nil {
return err
}
// 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)
return nil
},
}
if user != nil {
goto success
}
switch r.Method {
default:
panic("never reached")
case http.MethodGet:
goto form
case http.MethodPost:
// parse the form!
if err := r.ParseForm(); err != nil {
message = "Login failed"
goto form
}
// get the username and password
username := r.Form.Get("username")
password := r.Form.Get("password")
// make sure that the user exists
user, err := auth.User(r.Context(), username)
if err != nil {
message = "Login failed"
goto form
}
// check the password (TODO: Support TOTP)
err = user.CheckPassword(r.Context(), []byte(password))
if err != nil {
message = "Login failed"
goto form
}
// and we logged the user in!
auth.writeLogin(w, r, user)
goto success
}
form:
httpx.WriteHTML(loginContext{
Message: message,
CSRF: csrf.TemplateField(r),
}, nil, loginTemplate, "", w, r)
return
success:
// 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)
}
func (auth *Auth) logoutRoute(w http.ResponseWriter, r *http.Request) {

View file

@ -0,0 +1,29 @@
{{ template "_base.html" . }}
{{ define "title" }}Distillery User{{ end }}
{{ define "header/time" }}
<!-- no header/time -->
{{ end }}
{{ define "header"}}
<!-- no header -->
{{ end }}
{{ define "content" }}
<div class="pure-u-1">
<div>
Welcome {{ .User.User }}!
</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 }}

View file

@ -20,10 +20,7 @@
<form class="pure-form" method="POST">
<fieldset>
<legend>Login Required</legend>
<input type="text" name="username">
<input type="password" name="password">
{{ .CSRF }}
{{ .Form }}
<input type="submit" value="Login">
</fieldset>
</form>

View file

@ -21,7 +21,7 @@ type Assets struct {
Styles string // <link> tags inserted by the asset
}
//go:generate node build.mjs HomeHome ComponentsIndex ControlIndex ControlInstance InstanceComponentsIndex AuthLogin
//go:generate node build.mjs HomeHome ComponentsIndex ControlIndex ControlInstance InstanceComponentsIndex AuthLogin AuthHome
// MustParse parses a new template from the given source
// and calls [RegisterAssoc] on it.

View file

@ -37,3 +37,9 @@ var AssetsAuthLogin = Assets{
Scripts: `<script type="module" src="/static/HomeHome.38d394c2.js"></script><script src="/static/HomeHome.38d394c2.js" nomodule="" defer></script><script type="module" src="/static/AuthLogin.38d394c2.js"></script><script src="/static/AuthLogin.38d394c2.js" nomodule="" defer></script>`,
Styles: `<link rel="stylesheet" href="/static/HomeHome.a75f04fa.css"><link rel="stylesheet" href="/static/AuthLogin.38d394c2.css">`,
}
// AssetsAuthHome contains assets for the 'AuthHome' entrypoint.
var AssetsAuthHome = Assets{
Scripts: `<script type="module" src="/static/HomeHome.38d394c2.js"></script><script src="/static/HomeHome.38d394c2.js" nomodule="" defer></script><script type="module" src="/static/AuthHome.38d394c2.js"></script><script src="/static/AuthHome.38d394c2.js" nomodule="" defer></script>`,
Styles: `<link rel="stylesheet" href="/static/HomeHome.a75f04fa.css"><link rel="stylesheet" href="/static/AuthHome.38d394c2.css">`,
}

View file

@ -0,0 +1 @@
/* nothing for now */

View file

@ -0,0 +1 @@
// nothing for now

158
pkg/httpx/form.go Normal file
View file

@ -0,0 +1,158 @@
package httpx
import (
"context"
"html/template"
"io"
"net/http"
"strings"
"github.com/FAU-CDI/wisski-distillery/pkg/lazy"
"github.com/gorilla/csrf"
)
// Form implements a user-submittable form
type Form[D any] struct {
Fields []Field
// 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
csrf lazy.Lazy[http.Handler]
// SkipForm, if non-nil, is called on every get request to determine if form parsing should be skipped entirely.
// If skip is true, RenderSuccess is directly called with the given values map.
SkipForm func(r *http.Request) (data D, skip bool)
// RenderForm handles rendering a form into a request.
//
// template holds pre-rendered html fields.
// err is a non-nil error returned from Validate, or the r.ParseForm() method.
// It is nil on the initial render.
RenderForm func(template template.HTML, err error, w http.ResponseWriter, r *http.Request)
// 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)
// RenderSuccess handles rendering a success result into a response.
RenderSuccess func(data D, values map[string]string, w http.ResponseWriter, r *http.Request) error
}
// Template renders this form as a HTML string for insertion into a template.
func (form *Form[D]) Template(values map[string]string, isError bool) template.HTML {
var builder strings.Builder
for _, field := range form.Fields {
value := values[field.Name]
if isError && field.EmptyOnError {
value = ""
}
field.WriteTo(&builder, value)
}
return template.HTML(builder.String())
}
// Values returns (validated) form values contained in the given request
func (form *Form[D]) Values(r *http.Request) (v map[string]string, d D, err error) {
// parse the form
if err := r.ParseForm(); err != nil {
return nil, d, err
}
// pick each of the values
values := make(map[string]string, len(form.Fields))
for _, field := range form.Fields {
values[field.Name] = r.PostForm.Get(field.Name)
}
// validate the form
if form.Validate != nil {
d, err = form.Validate(r.Context(), values)
if err != nil {
return nil, d, err
}
}
// and return them
return values, d, nil
}
func (form *Form[D]) ServeHTTP(w http.ResponseWriter, r *http.Request) {
handler := form.csrf.Get(func() (handler http.Handler) {
handler = http.HandlerFunc(form.serveHTTP)
if form.CSRF != nil {
handler = form.CSRF(handler)
}
return
})
handler.ServeHTTP(w, r)
}
func (form *Form[D]) serveHTTP(w http.ResponseWriter, r *http.Request) {
switch {
default:
TextInterceptor.Intercept(w, r, ErrMethodNotAllowed)
return
case r.Method == http.MethodPost:
values, data, err := form.Values(r)
if err != nil {
form.renderForm(err, values, w, r)
} else {
form.renderSuccess(data, values, w, r)
}
case r.Method == http.MethodGet && form.SkipForm != nil:
if data, skip := form.SkipForm(r); skip {
form.renderSuccess(data, nil, w, r)
return
}
fallthrough
case r.Method == http.MethodGet:
form.renderForm(nil, nil, w, r)
}
}
func (form *Form[D]) renderForm(err error, values map[string]string, w http.ResponseWriter, r *http.Request) {
template := form.Template(values, err != nil)
if form.CSRF != nil {
template += csrf.TemplateField(r)
}
form.RenderForm(template, err, w, r)
}
func (form *Form[D]) renderSuccess(data D, values map[string]string, w http.ResponseWriter, r *http.Request) {
err := form.RenderSuccess(data, values, w, r)
if err == nil {
return
}
form.renderForm(err, values, w, r)
}
// Field represents a field
type Field struct {
Name string
Type InputType
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})
}
// InputType represents the type of input
type InputType string
const (
TextField InputType = "text"
PasswordField InputType = "password"
)