chore(webui): rewrite the web session code again while preparing for csrf tokens
#60
This commit is contained in:
parent
3bb5e735c6
commit
895615ad6e
20 changed files with 162 additions and 149 deletions
|
@ -11,7 +11,6 @@ import (
|
|||
|
||||
type AccountsPage struct {
|
||||
Accounts []model.Account
|
||||
ActiveTab int
|
||||
IsAdmin string
|
||||
Page *Page
|
||||
Username string
|
||||
|
@ -45,11 +44,10 @@ func handleAccountsPOST(db *database.DB) http.Handler {
|
|||
accountUsername := r.FormValue("username")
|
||||
isAdmin := r.FormValue("isAdmin")
|
||||
page := AccountsPage{
|
||||
ActiveTab: 1,
|
||||
Page: makePage(r, &Page{Title: "New Account", Section: "accounts"}),
|
||||
Accounts: accounts,
|
||||
IsAdmin: isAdmin,
|
||||
Username: accountUsername,
|
||||
Page: makePage(r, &Page{Title: "New Account", Section: "accounts"}),
|
||||
Accounts: accounts,
|
||||
IsAdmin: isAdmin,
|
||||
Username: accountUsername,
|
||||
}
|
||||
if ok := validUsername.MatchString(accountUsername); !ok {
|
||||
page.UsernameInvalid = true
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
package webui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"html/template"
|
||||
"net/http"
|
||||
|
||||
|
@ -35,7 +36,7 @@ func handleAccountsIdGET(db *database.DB) http.Handler {
|
|||
return
|
||||
}
|
||||
if account == nil {
|
||||
errorResponse(w, r, http.StatusNotFound, err)
|
||||
errorResponse(w, r, http.StatusNotFound, fmt.Errorf("The account Id could not be found."))
|
||||
return
|
||||
}
|
||||
statePaths, err := db.LoadStatePaths()
|
||||
|
|
|
@ -19,37 +19,36 @@ type AccountsIdResetPasswordPage struct {
|
|||
|
||||
var accountsIdResetPasswordTemplates = template.Must(template.ParseFS(htmlFS, "html/base.html", "html/accountsIdResetPassword.html"))
|
||||
|
||||
func processAccountsIdResetPasswordPathValues(db *database.DB, w http.ResponseWriter, r *http.Request) (*model.Account, bool) {
|
||||
func processAccountsIdResetPasswordPathValues(db *database.DB, w http.ResponseWriter, r *http.Request) *model.Account {
|
||||
var accountId uuid.UUID
|
||||
if err := accountId.Parse(r.PathValue("id")); err != nil {
|
||||
errorResponse(w, r, http.StatusBadRequest, err)
|
||||
return nil, false
|
||||
return nil
|
||||
}
|
||||
var token uuid.UUID
|
||||
if err := token.Parse(r.PathValue("token")); err != nil {
|
||||
errorResponse(w, r, http.StatusBadRequest, err)
|
||||
return nil, false
|
||||
return nil
|
||||
}
|
||||
account, err := db.LoadAccountById(&accountId)
|
||||
if err != nil {
|
||||
errorResponse(w, r, http.StatusInternalServerError, err)
|
||||
return nil, false
|
||||
return nil
|
||||
}
|
||||
if account == nil || account.PasswordReset == nil {
|
||||
errorResponse(w, r, http.StatusBadRequest, err)
|
||||
return nil, false
|
||||
return nil
|
||||
}
|
||||
if !account.PasswordReset.Equal(token) {
|
||||
errorResponse(w, r, http.StatusBadRequest, err)
|
||||
return nil, false
|
||||
return nil
|
||||
}
|
||||
return account, true
|
||||
return account
|
||||
}
|
||||
|
||||
func handleAccountsIdResetPasswordGET(db *database.DB) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
account, valid := processAccountsIdResetPasswordPathValues(db, w, r)
|
||||
if !valid {
|
||||
account := processAccountsIdResetPasswordPathValues(db, w, r)
|
||||
if account == nil {
|
||||
return
|
||||
}
|
||||
render(w, accountsIdResetPasswordTemplates, http.StatusOK,
|
||||
|
@ -63,8 +62,8 @@ func handleAccountsIdResetPasswordGET(db *database.DB) http.Handler {
|
|||
|
||||
func handleAccountsIdResetPasswordPOST(db *database.DB) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
account, valid := processAccountsIdResetPasswordPathValues(db, w, r)
|
||||
if !valid {
|
||||
account := processAccountsIdResetPasswordPathValues(db, w, r)
|
||||
if account == nil {
|
||||
return
|
||||
}
|
||||
password := r.FormValue("password")
|
||||
|
|
|
@ -4,21 +4,14 @@ import (
|
|||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"git.adyxax.org/adyxax/tfstated/pkg/database"
|
||||
"git.adyxax.org/adyxax/tfstated/pkg/model"
|
||||
)
|
||||
|
||||
func adminMiddleware(db *database.DB, requireLogin func(http.Handler) http.Handler) func(http.Handler) http.Handler {
|
||||
func adminMiddleware(requireLogin func(http.Handler) http.Handler) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return requireLogin(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
account := r.Context().Value(model.AccountContextKey{})
|
||||
if account == nil {
|
||||
// this could happen if the account was deleted in the short
|
||||
// time between retrieving the session and here
|
||||
http.Redirect(w, r, "/login", http.StatusFound)
|
||||
return
|
||||
}
|
||||
if !account.(*model.Account).IsAdmin {
|
||||
session := r.Context().Value(model.SessionContextKey{}).(*model.Session)
|
||||
if !session.Data.Account.IsAdmin {
|
||||
errorResponse(w, r, http.StatusForbidden, fmt.Errorf("Only administrators can perform this request."))
|
||||
return
|
||||
}
|
||||
|
|
|
@ -7,7 +7,7 @@
|
|||
Use this page to inspect user accounts.
|
||||
</p>
|
||||
</div>
|
||||
{{ if .Page.IsAdmin }}
|
||||
{{ if .Page.Session.Data.Account.IsAdmin }}
|
||||
<form action="/accounts" enctype="multipart/form-data" method="post">
|
||||
<fieldset>
|
||||
<legend>New User Account</legend>
|
||||
|
|
|
@ -24,7 +24,7 @@
|
|||
{{ if .Account.IsAdmin }}
|
||||
<p>This accounts has <strong>admin</strong> privileges on TfStated.</p>
|
||||
{{ end }}
|
||||
{{ if .Page.IsAdmin }}
|
||||
{{ if .Page.Session.Data.Account.IsAdmin }}
|
||||
<h2>Operations</h2>
|
||||
<form action="/accounts/{{ .Account.Id }}" enctype="multipart/form-data" method="post">
|
||||
<div class="flex-row">
|
||||
|
@ -39,7 +39,7 @@
|
|||
value="{{ .Username }}">
|
||||
<label for="is-admin">Is Admin</label>
|
||||
<input {{ if .Account.IsAdmin }}checked{{ end }}
|
||||
{{ if eq .Page.AccountId.String .Account.Id.String }}disabled{{ end }}
|
||||
{{ if eq .Page.Session.Data.Account.Id.String .Account.Id.String }}disabled{{ end }}
|
||||
id="is-admin"
|
||||
name="is-admin"
|
||||
type="checkbox"
|
||||
|
@ -63,13 +63,13 @@
|
|||
</fieldset>
|
||||
<fieldset>
|
||||
<legend>Danger Zone</legend>
|
||||
<button {{ if eq .Page.AccountId.String .Account.Id.String }}disabled{{ end }}
|
||||
<button {{ if eq .Page.Session.Data.Account.Id.String .Account.Id.String }}disabled{{ end }}
|
||||
type="submit"
|
||||
value="delete">
|
||||
Delete User Account
|
||||
</button>
|
||||
<!--<button type="submit" value="lock">Lock User Account</button>-->
|
||||
<button {{ if or (ne .Account.PasswordReset nil) (eq .Page.AccountId.String .Account.Id.String) }}disabled{{ end }}
|
||||
<button {{ if or (ne .Account.PasswordReset nil) (eq .Page.Session.Data.Account.Id.String .Account.Id.String) }}disabled{{ end }}
|
||||
type="submit"
|
||||
value="reset-password">
|
||||
Reset Password
|
||||
|
|
|
@ -8,7 +8,7 @@
|
|||
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined" rel="stylesheet" />
|
||||
<title>TFSTATED - {{ .Page.Title }}</title>
|
||||
</head>
|
||||
<body class="{{ if .Page.LightMode }}light-theme{{ else }}black-theme{{ end }}">
|
||||
<body class="{{ if .Page.Session.Data.Settings.LightMode }}light-theme{{ else }}black-theme{{ end }}">
|
||||
<header>
|
||||
<h6>TFSTATED</h6>
|
||||
</header>
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
<legend>Account Settings</legend>
|
||||
<div style="align-items:center; display:grid; grid-template-columns:1fr 1fr;">
|
||||
<label for="dark-mode">Dark mode</label>
|
||||
<input {{ if not .Settings.LightMode }}checked{{ end }}
|
||||
<input {{ if not .Page.Session.Data.Settings.LightMode }}checked{{ end }}
|
||||
id="dark-mode"
|
||||
name="dark-mode"
|
||||
type="checkbox"
|
||||
|
|
|
@ -5,26 +5,16 @@ import (
|
|||
"net/http"
|
||||
|
||||
"git.adyxax.org/adyxax/tfstated/pkg/model"
|
||||
"go.n16f.net/uuid"
|
||||
)
|
||||
|
||||
type Page struct {
|
||||
AccountId *uuid.UUID
|
||||
IsAdmin bool
|
||||
LightMode bool
|
||||
Section string
|
||||
Title string
|
||||
Section string
|
||||
Session *model.Session
|
||||
Title string
|
||||
}
|
||||
|
||||
func makePage(r *http.Request, page *Page) *Page {
|
||||
accountCtx := r.Context().Value(model.AccountContextKey{})
|
||||
if accountCtx != nil {
|
||||
account := accountCtx.(*model.Account)
|
||||
page.AccountId = &account.Id
|
||||
page.IsAdmin = account.IsAdmin
|
||||
}
|
||||
settings := r.Context().Value(model.SettingsContextKey{}).(*model.Settings)
|
||||
page.LightMode = settings.LightMode
|
||||
page.Session = r.Context().Value(model.SessionContextKey{}).(*model.Session)
|
||||
return page
|
||||
}
|
||||
|
||||
|
|
|
@ -26,8 +26,8 @@ func handleLoginGET() http.Handler {
|
|||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Cache-Control", "no-store, no-cache")
|
||||
|
||||
account := r.Context().Value(model.AccountContextKey{})
|
||||
if account != nil {
|
||||
session := r.Context().Value(model.SessionContextKey{}).(*model.Session)
|
||||
if session.Data.Account != nil {
|
||||
http.Redirect(w, r, "/states", http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
@ -56,7 +56,7 @@ func handleLoginPOST(db *database.DB) http.Handler {
|
|||
password := r.FormValue("password")
|
||||
|
||||
if username == "" || password == "" {
|
||||
errorResponse(w, r, http.StatusBadRequest, nil)
|
||||
errorResponse(w, r, http.StatusBadRequest, fmt.Errorf("Invalid username or password"))
|
||||
return
|
||||
}
|
||||
if ok := validUsername.MatchString(username); !ok {
|
||||
|
@ -79,41 +79,31 @@ func handleLoginPOST(db *database.DB) http.Handler {
|
|||
return
|
||||
}
|
||||
session := r.Context().Value(model.SessionContextKey{}).(*model.Session)
|
||||
sessionId, err := db.MigrateSession(session, account)
|
||||
sessionId, session, err := db.MigrateSession(session, account)
|
||||
if err != nil {
|
||||
errorResponse(w, r, http.StatusInternalServerError,
|
||||
fmt.Errorf("failed to migrate session: %w", err))
|
||||
return
|
||||
}
|
||||
setSessionCookie(w, sessionId)
|
||||
ctx := context.WithValue(r.Context(), model.SessionContextKey{}, session)
|
||||
if err := db.DeleteExpiredSessions(); err != nil {
|
||||
slog.Error("failed to delete expired sessions after user login", "err", err, "accountId", account.Id)
|
||||
}
|
||||
http.Redirect(w, r, "/", http.StatusFound)
|
||||
http.Redirect(w, r.WithContext(ctx), "/", http.StatusFound)
|
||||
})
|
||||
}
|
||||
|
||||
func loginMiddleware(db *database.DB, requireSession func(http.Handler) http.Handler) func(http.Handler) http.Handler {
|
||||
func loginMiddleware(requireSession func(http.Handler) http.Handler) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return requireSession(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Cache-Control", "no-store, no-cache")
|
||||
session := r.Context().Value(model.SessionContextKey{}).(*model.Session)
|
||||
if session.AccountId == nil {
|
||||
if session.Data.Account == nil {
|
||||
http.Redirect(w, r, "/login", http.StatusFound)
|
||||
return
|
||||
}
|
||||
account, err := db.LoadAccountById(session.AccountId)
|
||||
if err != nil {
|
||||
errorResponse(w, r, http.StatusInternalServerError,
|
||||
fmt.Errorf("failed to load account by Id: %w", err))
|
||||
return
|
||||
}
|
||||
if account == nil {
|
||||
http.Redirect(w, r, "/login", http.StatusFound)
|
||||
return
|
||||
}
|
||||
ctx := context.WithValue(r.Context(), model.AccountContextKey{}, account)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
next.ServeHTTP(w, r)
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
package webui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"net/http"
|
||||
|
@ -17,15 +18,16 @@ func handleLogoutGET(db *database.DB) http.Handler {
|
|||
}
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
session := r.Context().Value(model.SessionContextKey{}).(*model.Session)
|
||||
sessionId, err := db.MigrateSession(session, nil)
|
||||
sessionId, session, err := db.MigrateSession(session, nil)
|
||||
if err != nil {
|
||||
errorResponse(w, r, http.StatusInternalServerError,
|
||||
fmt.Errorf("failed to migrate session: %w", err))
|
||||
return
|
||||
}
|
||||
setSessionCookie(w, sessionId)
|
||||
ctx := context.WithValue(r.Context(), model.SessionContextKey{}, session)
|
||||
render(w, logoutTemplate, http.StatusOK, logoutPage{
|
||||
Page: makePage(r, &Page{Title: "Logout", Section: "login"}),
|
||||
Page: makePage(r.WithContext(ctx), &Page{Title: "Logout", Section: "login"}),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
|
|
@ -11,8 +11,8 @@ func addRoutes(
|
|||
db *database.DB,
|
||||
) {
|
||||
requireSession := sessionsMiddleware(db)
|
||||
requireLogin := loginMiddleware(db, requireSession)
|
||||
requireAdmin := adminMiddleware(db, requireLogin)
|
||||
requireLogin := loginMiddleware(requireSession)
|
||||
requireAdmin := adminMiddleware(requireLogin)
|
||||
mux.Handle("GET /accounts", requireLogin(handleAccountsGET(db)))
|
||||
mux.Handle("GET /accounts/{id}", requireLogin(handleAccountsIdGET(db)))
|
||||
mux.Handle("GET /accounts/{id}/reset/{token}", requireSession(handleAccountsIdResetPasswordGET(db)))
|
||||
|
|
|
@ -2,10 +2,8 @@ package webui
|
|||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"git.adyxax.org/adyxax/tfstated/pkg/database"
|
||||
|
@ -40,26 +38,20 @@ func sessionsMiddleware(db *database.DB) func(http.Handler) http.Handler {
|
|||
}
|
||||
} else {
|
||||
ctx := context.WithValue(r.Context(), model.SessionContextKey{}, session)
|
||||
var settings model.Settings
|
||||
if err := json.Unmarshal(session.Settings, &settings); err != nil {
|
||||
slog.Error("failed to unmarshal session settings", "err", err)
|
||||
}
|
||||
ctx = context.WithValue(ctx, model.SettingsContextKey{}, &settings)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
sessionId, err := db.CreateSession(nil, nil)
|
||||
sessionId, session, err := db.CreateSession(nil)
|
||||
if err != nil {
|
||||
errorResponse(w, r, http.StatusInternalServerError,
|
||||
fmt.Errorf("failed to create session: %w", err))
|
||||
return
|
||||
}
|
||||
setSessionCookie(w, sessionId)
|
||||
var settings model.Settings
|
||||
ctx := context.WithValue(r.Context(), model.SettingsContextKey{}, &settings)
|
||||
ctx := context.WithValue(r.Context(), model.SessionContextKey{}, session)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
|
|
|
@ -18,10 +18,8 @@ var settingsTemplates = template.Must(template.ParseFS(htmlFS, "html/base.html",
|
|||
|
||||
func handleSettingsGET(db *database.DB) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
settings := r.Context().Value(model.SettingsContextKey{}).(*model.Settings)
|
||||
render(w, settingsTemplates, http.StatusOK, SettingsPage{
|
||||
Page: makePage(r, &Page{Title: "Settings", Section: "settings"}),
|
||||
Settings: settings,
|
||||
Page: makePage(r, &Page{Title: "Settings", Section: "settings"}),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
@ -36,16 +34,16 @@ func handleSettingsPOST(db *database.DB) http.Handler {
|
|||
settings := model.Settings{
|
||||
LightMode: darkMode != "1",
|
||||
}
|
||||
account := r.Context().Value(model.AccountContextKey{}).(*model.Account)
|
||||
err := db.SaveAccountSettings(account, &settings)
|
||||
session := r.Context().Value(model.SessionContextKey{}).(*model.Session)
|
||||
session.Data.Settings = &settings
|
||||
err := db.SaveAccountSettings(session.Data.Account, &settings)
|
||||
if err != nil {
|
||||
errorResponse(w, r, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
ctx := context.WithValue(r.Context(), model.SettingsContextKey{}, &settings)
|
||||
page := makePage(r.WithContext(ctx), &Page{Title: "Settings", Section: "settings"})
|
||||
ctx := context.WithValue(r.Context(), model.SessionContextKey{}, session)
|
||||
render(w, settingsTemplates, http.StatusOK, SettingsPage{
|
||||
Page: page,
|
||||
Page: makePage(r.WithContext(ctx), &Page{Title: "Settings", Section: "settings"}),
|
||||
Settings: &settings,
|
||||
})
|
||||
})
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue