Gracefull error messages on password mishap

This commit is contained in:
Simon Martens
2025-05-22 23:01:59 +02:00
parent 2a08e5fec7
commit b81d783e8c
7 changed files with 109 additions and 63 deletions

View File

@@ -55,12 +55,14 @@ func Authenticated(app core.App) func(*core.RequestEvent) error {
if session.IsExpired() { if session.IsExpired() {
slog.Warn("Session expired", "user", user.Id, "name", user.Name, "session", session.ID) slog.Warn("Session expired", "user", user.Id, "name", user.Name, "session", session.ID)
cache.Delete(cookie.Value) cache.Delete(cookie.Value)
go func() {
r, err := app.FindRecordById(dbmodels.SESSIONS_TABLE, session.ID) r, err := app.FindRecordById(dbmodels.SESSIONS_TABLE, session.ID)
e.SetCookie(deact_cookie) e.SetCookie(deact_cookie)
e.Response.Header().Set("Clear-Site-Data", "\"cookies\"") e.Response.Header().Set("Clear-Site-Data", "\"cookies\"")
if err == nil { if err == nil {
app.Delete(r) app.Delete(r)
} }
}()
return e.Next() return e.Next()
} }

View File

@@ -19,15 +19,17 @@ const (
TEMPLATE_LOGIN = "/login/" TEMPLATE_LOGIN = "/login/"
) )
var CSRF_CACHE *security.CSRFProtector
// TODO: // TODO:
// - rate limiting // - rate limiting
// - maybe csrf
func init() { func init() {
csrf_cache, err := security.NewCSRFProtector(time.Minute*5, time.Minute) csrf_cache, err := security.NewCSRFProtector(time.Minute*5, time.Minute)
if err != nil { if err != nil {
panic(err) panic(err)
} }
CSRF_CACHE = csrf_cache
lp := &LoginPage{ lp := &LoginPage{
StaticPage: pagemodels.StaticPage{ StaticPage: pagemodels.StaticPage{
@@ -36,47 +38,60 @@ func init() {
Template: TEMPLATE_LOGIN, Template: TEMPLATE_LOGIN,
URL: URL_LOGIN, URL: URL_LOGIN,
}, },
csrf_cache: csrf_cache,
} }
app.Register(lp) app.Register(lp)
} }
type LoginPage struct { type LoginPage struct {
pagemodels.StaticPage pagemodels.StaticPage
csrf_cache *security.CSRFProtector
} }
func (p *LoginPage) Setup(router *router.Router[*core.RequestEvent], app core.App, engine *templating.Engine) error { func (p *LoginPage) Setup(router *router.Router[*core.RequestEvent], app core.App, engine *templating.Engine) error {
router.GET(URL_LOGIN, p.GET(engine)) router.GET(URL_LOGIN, p.GET(engine, app))
router.POST(URL_LOGIN, p.POST(engine, app)) router.POST(URL_LOGIN, p.POST(engine, app))
return nil return nil
} }
func (p *LoginPage) GET(engine *templating.Engine) HandleFunc { func (p *LoginPage) GET(engine *templating.Engine, app core.App) HandleFunc {
return func(e *core.RequestEvent) error { return func(e *core.RequestEvent) error {
data := make(map[string]any) data := make(map[string]any)
data["record"] = p data["record"] = p
nonce, token, err := p.csrf_cache.GenerateTokenBundle() nonce, token, err := CSRF_CACHE.GenerateTokenBundle()
if err != nil { if err != nil {
return engine.Response500(e, err, data) return engine.Response500(e, err, data)
} }
data["csrf_nonce"] = nonce data["csrf_nonce"] = nonce
data["csrf_token"] = token data["csrf_token"] = token
// TODO: the function to delete tokens is not yet there Logout(e, &app)
// as of right now, the tokens get only deleted from the clients
// We need to delete the tokens from the cache + table.
e.SetCookie(&http.Cookie{
Name: dbmodels.SESSION_COOKIE_NAME,
Path: "/",
MaxAge: -1,
})
e.Response.Header().Set("Clear-Site-Data", "\"cookies\"")
return engine.Response200(e, p.Template, data, p.Layout) return engine.Response200(e, p.Template, data, p.Layout)
} }
} }
func Unauthorized(
engine *templating.Engine,
e *core.RequestEvent,
error error,
data map[string]any) error {
nonce, token, err := CSRF_CACHE.GenerateTokenBundle()
if err != nil {
return engine.Response500(e, err, data)
}
data["csrf_nonce"] = nonce
data["csrf_token"] = token
data["error"] = error.Error()
htm, err := engine.RenderToString(e, data, TEMPLATE_LOGIN, "blank")
if err != nil {
return engine.Response500(e, err, data)
}
return e.HTML(http.StatusUnauthorized, htm)
}
func (p *LoginPage) POST(engine *templating.Engine, app core.App) HandleFunc { func (p *LoginPage) POST(engine *templating.Engine, app core.App) HandleFunc {
return func(e *core.RequestEvent) error { return func(e *core.RequestEvent) error {
data := make(map[string]any) data := make(map[string]any)
@@ -94,17 +109,19 @@ func (p *LoginPage) POST(engine *templating.Engine, app core.App) HandleFunc {
return engine.Response500(e, err, data) return engine.Response500(e, err, data)
} }
if _, err := p.csrf_cache.ValidateTokenBundle(formdata.CsrfNonce, formdata.CsrfToken); err != nil { data["formdata"] = formdata
return engine.Response403(e, err, data)
if _, err := CSRF_CACHE.ValidateTokenBundle(formdata.CsrfNonce, formdata.CsrfToken); err != nil {
return Unauthorized(engine, e, fmt.Errorf("Ungültiges CSRF-Token oder Zeit abgelaufen. Bitte versuchen Sie es erneut."), data)
} }
if formdata.Username == "" || formdata.Password == "" { if formdata.Username == "" || formdata.Password == "" {
return engine.Response403(e, fmt.Errorf("Username and password are required"), data) return Unauthorized(engine, e, fmt.Errorf("Benuztername oder Passwort falsch. Bitte versuchen Sie es erneut."), data)
} }
record, err := app.FindFirstRecordByData(dbmodels.USERS_TABLE, dbmodels.USERS_EMAIL_FIELD, formdata.Username) record, err := app.FindFirstRecordByData(dbmodels.USERS_TABLE, dbmodels.USERS_EMAIL_FIELD, formdata.Username)
if err != nil || !record.ValidatePassword(formdata.Password) { if err != nil || !record.ValidatePassword(formdata.Password) {
return engine.Response403(e, err, data) return Unauthorized(engine, e, fmt.Errorf("Benuztername oder Passwort falsch. Bitte versuchen Sie es erneut."), data)
} }
duration := time.Minute * 60 duration := time.Minute * 60
@@ -138,10 +155,6 @@ func (p *LoginPage) POST(engine *templating.Engine, app core.App) HandleFunc {
}) })
} }
redirect := "/reihen" return RedirectTo(e)
if r := e.Request.URL.Query().Get("redirectTo"); r != "" {
redirect = r
}
return e.Redirect(303, redirect)
} }
} }

View File

@@ -28,25 +28,43 @@ type LogoutPage struct {
} }
func (p *LogoutPage) Setup(router *router.Router[*core.RequestEvent], app core.App, engine *templating.Engine) error { func (p *LogoutPage) Setup(router *router.Router[*core.RequestEvent], app core.App, engine *templating.Engine) error {
router.GET(p.URL, p.GET()) router.GET(p.URL, p.GET(app))
return nil return nil
} }
func (p *LogoutPage) GET() HandleFunc { func (p *LogoutPage) GET(app core.App) HandleFunc {
return func(e *core.RequestEvent) error { return func(e *core.RequestEvent) error {
// TODO: the function to delete tokens is not yet there Logout(e, &app)
// as of right now, the tokens get only deleted from the clients return RedirectTo(e)
// We need to delete the tokens from the cache + table. }
}
func Logout(e *core.RequestEvent, app *core.App) {
e.SetCookie(&http.Cookie{ e.SetCookie(&http.Cookie{
Name: dbmodels.SESSION_COOKIE_NAME, Name: dbmodels.SESSION_COOKIE_NAME,
Path: "/", Path: "/",
MaxAge: -1, MaxAge: -1,
}) })
e.Response.Header().Set("Clear-Site-Data", "\"cookies\"") e.Response.Header().Set("Clear-Site-Data", "\"cookies\"")
cookie, err := e.Request.Cookie(dbmodels.SESSION_COOKIE_NAME)
if err == nil && app != nil {
go func() {
app := *app
record, err := app.FindFirstRecordByData(dbmodels.SESSIONS_TABLE, dbmodels.SESSIONS_TOKEN_FIELD, cookie.Value)
if err == nil && record != nil {
app.Delete(record)
}
}()
}
}
func RedirectTo(e *core.RequestEvent) error {
redirect := "/reihen" redirect := "/reihen"
if r := e.Request.URL.Query().Get("redirectTo"); r != "" { if r := e.Request.URL.Query().Get("redirectTo"); r != "" {
redirect = r redirect = r
} }
return e.Redirect(303, redirect) return e.Redirect(303, redirect)
}
} }

View File

@@ -252,25 +252,28 @@ func (e *Engine) Render(out io.Writer, path string, ld map[string]any, layout ..
return nil return nil
} }
func (e *Engine) Response403(request *core.RequestEvent, err error, data map[string]any) error { func (e *Engine) RenderToString(request *core.RequestEvent, ld map[string]any, path string, layout ...string) (string, error) {
if data == nil { if ld == nil {
data = make(map[string]any) ld = make(map[string]any)
} }
var sb strings.Builder ld["page"] = requestData(request)
var builder strings.Builder
err := e.Render(&builder, path, ld, layout...)
if err != nil { if err != nil {
request.App.Logger().Error("Unauthorized 403 error fetching URL!", "error", err, "request", request.Request.URL) return "", e.Response500(request, err, ld)
data["Error"] = err.Error()
} }
data["page"] = requestData(request) tstring := builder.String()
if e.debug {
err2 := e.Render(&sb, "/errors/403/", data) idx := strings.LastIndex(tstring, "</body>")
if err2 != nil { if idx != -1 {
return e.Response500(request, errors.Join(err, err2), data) tstring = tstring[:idx] + RELOAD_TEMPLATE + tstring[idx:]
}
} }
return request.HTML(http.StatusNotFound, sb.String()) return tstring, nil
} }
func (e *Engine) Response404(request *core.RequestEvent, err error, data map[string]any) error { func (e *Engine) Response404(request *core.RequestEvent, err error, data map[string]any) error {

File diff suppressed because one or more lines are too long

View File

@@ -20,8 +20,11 @@
<span>&middot;</span> <span>&middot;</span>
{{ if .page.User }} {{ if .page.User }}
<i class="ri-user-3-line"></i> <i class="ri-user-3-line"></i>
Eingeloggt als {{ if .page.User.Name }}
{{ .page.User.Email }} <b>{{ .page.User.Name }}</b>
{{ else }}
<b>{{ .page.User.Email }}</b>
{{ end }}
| |
<i class="ri-logout-box-line"></i> <i class="ri-logout-box-line"></i>
<a href="/logout?redirectTo={{ .page.FullPath }}">Logout</a> <a href="/logout?redirectTo={{ .page.FullPath }}">Logout</a>

View File

@@ -11,8 +11,15 @@
<img class="h-20 w-20 border" src="/assets/favicon.png" /> <img class="h-20 w-20 border" src="/assets/favicon.png" />
</div> </div>
</div> </div>
<h1 class="text-4xl self-baseline text-center w-full mt-6">Musenalm | Login</h1> <h1 class="text-4xl self-baseline text-center w-full my-6">Musenalm | Login</h1>
<form class="mt-9 w-full grid grid-cols-3 gap-4" method="POST"> {{ if $model.error }}
<div
class="text-red-800 text-sm mt-2 rounded bg-red-200 p-2 font-bold border-red-700
border-2 mb-3">
{{ $model.error }}
</div>
{{ end }}
<form class="w-full grid grid-cols-3 gap-4" method="POST">
<div <div
class="col-span-3 border-2 border-transparent focus-within:border-slate-600 px-2 py-1 pb-1.5 class="col-span-3 border-2 border-transparent focus-within:border-slate-600 px-2 py-1 pb-1.5
bg-slate-200 focus-within:bg-slate-50 rounded-md transition-all duration-100"> bg-slate-200 focus-within:bg-slate-50 rounded-md transition-all duration-100">