This commit is contained in:
@@ -25,6 +25,7 @@ func (app *application) showAdminEnvironmentHandler(w http.ResponseWriter, r *ht
|
||||
{Component: "API", Name: "GARDOMATIC_API_HOST", Value: app.config.Host},
|
||||
{Component: "API", Name: "GARDOMATIC_API_PORT", Value: fmt.Sprint(app.config.Port)},
|
||||
{Component: "API", Name: "GARDOMATIC_WEB_BASE_URL", Value: app.config.WebBaseURL},
|
||||
{Component: "API", Name: "GARDOMATIC_DEMO_ACCOUNT_EMAIL", Value: app.config.DemoAccountEmail},
|
||||
{Component: "API", Name: "GARDOMATIC_SESSION_COOKIE_NAME", Value: app.config.Session.CookieName},
|
||||
{Component: "API", Name: "GARDOMATIC_SESSION_LIFETIME", Value: app.config.Session.Lifetime.String()},
|
||||
{Component: "API", Name: "GARDOMATIC_SESSION_IDLE_TIMEOUT", Value: app.config.Session.IdleTimeout.String()},
|
||||
|
||||
+10
-9
@@ -28,15 +28,16 @@ var (
|
||||
|
||||
// Config contains API server, database, session, mail, and security settings.
|
||||
type Config struct {
|
||||
Host string
|
||||
Port int
|
||||
Env string
|
||||
WebBaseURL string
|
||||
DB DatabaseConfig
|
||||
Limiter LimiterConfig
|
||||
Session SessionConfig
|
||||
Mail mailer.Config
|
||||
Cors CORSConfig
|
||||
Host string
|
||||
Port int
|
||||
Env string
|
||||
WebBaseURL string
|
||||
DemoAccountEmail string
|
||||
DB DatabaseConfig
|
||||
Limiter LimiterConfig
|
||||
Session SessionConfig
|
||||
Mail mailer.Config
|
||||
Cors CORSConfig
|
||||
}
|
||||
|
||||
// DatabaseConfig controls the PostgreSQL connection pool.
|
||||
|
||||
@@ -76,7 +76,7 @@ func TestShowAdminEnvironmentMasksSecrets(t *testing.T) {
|
||||
if err := json.Unmarshal(response.Body.Bytes(), &result); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, name := range []string{"GARDOMATIC_DB_DSN", "GARDOMATIC_SMTP_PASSWORD", "GARDOMATIC_SMTP_SENDER"} {
|
||||
for _, name := range []string{"GARDOMATIC_DB_DSN", "GARDOMATIC_DEMO_ACCOUNT_EMAIL", "GARDOMATIC_SMTP_PASSWORD", "GARDOMATIC_SMTP_SENDER"} {
|
||||
found := false
|
||||
for _, variable := range result.Variables {
|
||||
found = found || variable.Name == name
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gardomatic.kleiax.de/internal/storage"
|
||||
)
|
||||
|
||||
type demoAccountTokenUserModel struct {
|
||||
sessionTestUserModel
|
||||
}
|
||||
|
||||
func (m demoAccountTokenUserModel) GetForToken(string, string) (storage.User, error) {
|
||||
return m.user, nil
|
||||
}
|
||||
|
||||
func TestRequireMutableAccountProtectsConfiguredDemoUser(t *testing.T) {
|
||||
app := &application{config: Config{DemoAccountEmail: "demo@example.com"}}
|
||||
called := false
|
||||
handler := app.requireMutableAccount(func(w http.ResponseWriter, _ *http.Request) {
|
||||
called = true
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
})
|
||||
|
||||
request := httptest.NewRequest(http.MethodPatch, "/v1/account", nil)
|
||||
request = app.contextSetAuthenticatedUser(request, storage.User{Email: "Demo@Example.com"})
|
||||
response := httptest.NewRecorder()
|
||||
handler.ServeHTTP(response, request)
|
||||
|
||||
if response.Code != http.StatusForbidden || called {
|
||||
t.Fatalf("protected account: got status %d and called=%t, want 403 and called=false", response.Code, called)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequireMutableAccountAllowsOtherUsers(t *testing.T) {
|
||||
app := &application{config: Config{DemoAccountEmail: "demo@example.com"}}
|
||||
handler := app.requireMutableAccount(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
})
|
||||
|
||||
request := httptest.NewRequest(http.MethodPatch, "/v1/account", nil)
|
||||
request = app.contextSetAuthenticatedUser(request, storage.User{Email: "alice@example.com"})
|
||||
response := httptest.NewRecorder()
|
||||
handler.ServeHTTP(response, request)
|
||||
|
||||
if response.Code != http.StatusNoContent {
|
||||
t.Fatalf("ordinary account: got status %d, want %d", response.Code, http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPasswordResetCannotChangeProtectedDemoAccount(t *testing.T) {
|
||||
app := &application{
|
||||
config: Config{DemoAccountEmail: "demo@example.com"},
|
||||
models: storage.Models{Users: demoAccountTokenUserModel{sessionTestUserModel: sessionTestUserModel{
|
||||
user: storage.User{ID: 42, Email: "demo@example.com", Activated: true},
|
||||
}}},
|
||||
}
|
||||
|
||||
t.Run("request token", func(t *testing.T) {
|
||||
request := httptest.NewRequest(http.MethodPost, "/v1/tokens/password-reset", bytes.NewBufferString(`{"email":"DEMO@example.com"}`))
|
||||
response := httptest.NewRecorder()
|
||||
app.createPasswordResetTokenHandler(response, request)
|
||||
|
||||
if response.Code != http.StatusForbidden || !strings.Contains(response.Body.String(), "shared demo account") {
|
||||
t.Fatalf("got status %d and body %q, want protected-account response", response.Code, response.Body.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("use existing token", func(t *testing.T) {
|
||||
request := httptest.NewRequest(http.MethodPut, "/v1/users/password", bytes.NewBufferString(`{"password":"a-new-demo-password","token":"abcdefghijklmnopqrstuvwxyz"}`))
|
||||
response := httptest.NewRecorder()
|
||||
app.updateUserPasswordHandler(response, request)
|
||||
|
||||
if response.Code != http.StatusForbidden || !strings.Contains(response.Body.String(), "shared demo account") {
|
||||
t.Fatalf("got status %d and body %q, want protected-account response", response.Code, response.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -93,6 +93,11 @@ func (app *application) permissionDeniedResponse(w http.ResponseWriter, r *http.
|
||||
app.errorResponse(w, r, http.StatusForbidden, message)
|
||||
}
|
||||
|
||||
func (app *application) demoAccountProtectedResponse(w http.ResponseWriter, r *http.Request) {
|
||||
message := "the shared demo account cannot be changed"
|
||||
app.errorResponse(w, r, http.StatusForbidden, message)
|
||||
}
|
||||
|
||||
func (app *application) untrustedOriginResponse(w http.ResponseWriter, r *http.Request) {
|
||||
message := "the request origin is not trusted"
|
||||
app.errorResponse(w, r, http.StatusForbidden, message)
|
||||
|
||||
@@ -164,6 +164,26 @@ func (app *application) requireActivatedUser(next http.HandlerFunc) http.Handler
|
||||
})
|
||||
}
|
||||
|
||||
func (app *application) requireMutableAccount(next http.HandlerFunc) http.HandlerFunc {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
user, found := app.contextGetAuthenticatedUser(r)
|
||||
if !found {
|
||||
app.authenticationRequiredResponse(w, r)
|
||||
return
|
||||
}
|
||||
if app.isProtectedDemoAccount(user.Email) {
|
||||
app.demoAccountProtectedResponse(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func (app *application) isProtectedDemoAccount(email string) bool {
|
||||
return app.config.DemoAccountEmail != "" && strings.EqualFold(strings.TrimSpace(email), app.config.DemoAccountEmail)
|
||||
}
|
||||
|
||||
func (app *application) requireGardenMember(next http.HandlerFunc) http.HandlerFunc {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
authenticatedUser, found := app.contextGetAuthenticatedUser(r)
|
||||
|
||||
@@ -19,12 +19,12 @@ func (app *application) routes() http.Handler {
|
||||
router.HandlerFunc(http.MethodPost, "/v1/users", app.registerUserHandler)
|
||||
router.HandlerFunc(http.MethodPut, "/v1/users/activated", app.activateUserHandler)
|
||||
router.HandlerFunc(http.MethodPut, "/v1/users/password", app.updateUserPasswordHandler)
|
||||
router.HandlerFunc(http.MethodPatch, "/v1/account", app.requireActivatedUser(app.updateAccountProfileHandler))
|
||||
router.HandlerFunc(http.MethodPut, "/v1/account/password", app.requireActivatedUser(app.updateAccountPasswordHandler))
|
||||
router.HandlerFunc(http.MethodPost, "/v1/account/email", app.requireActivatedUser(app.requestAccountEmailChangeHandler))
|
||||
router.HandlerFunc(http.MethodPost, "/v1/account/email/confirm", app.requireActivatedUser(app.confirmAccountEmailHandler))
|
||||
router.HandlerFunc(http.MethodPatch, "/v1/account", app.requireActivatedUser(app.requireMutableAccount(app.updateAccountProfileHandler)))
|
||||
router.HandlerFunc(http.MethodPut, "/v1/account/password", app.requireActivatedUser(app.requireMutableAccount(app.updateAccountPasswordHandler)))
|
||||
router.HandlerFunc(http.MethodPost, "/v1/account/email", app.requireActivatedUser(app.requireMutableAccount(app.requestAccountEmailChangeHandler)))
|
||||
router.HandlerFunc(http.MethodPost, "/v1/account/email/confirm", app.requireActivatedUser(app.requireMutableAccount(app.confirmAccountEmailHandler)))
|
||||
router.HandlerFunc(http.MethodGet, "/v1/account/sessions", app.requireActivatedUser(app.listAccountSessionsHandler))
|
||||
router.HandlerFunc(http.MethodDelete, "/v1/account/sessions/:sessionID", app.requireActivatedUser(app.deleteAccountSessionHandler))
|
||||
router.HandlerFunc(http.MethodDelete, "/v1/account/sessions/:sessionID", app.requireActivatedUser(app.requireMutableAccount(app.deleteAccountSessionHandler)))
|
||||
router.HandlerFunc(http.MethodGet, "/v1/admin/users", app.requireActivatedUser(app.requireApplicationPermission(storage.ApplicationPermissionUsersManage, app.listAdminUsersHandler)))
|
||||
router.HandlerFunc(http.MethodPost, "/v1/admin/users", app.requireActivatedUser(app.requireApplicationPermission(storage.ApplicationPermissionUsersManage, app.inviteAdminUserHandler)))
|
||||
router.HandlerFunc(http.MethodPatch, "/v1/admin/users/:id", app.requireActivatedUser(app.requireApplicationPermission(storage.ApplicationPermissionUsersManage, app.updateAdminUserRoleHandler)))
|
||||
|
||||
@@ -83,6 +83,10 @@ func (app *application) createPasswordResetTokenHandler(w http.ResponseWriter, r
|
||||
app.failedValidationResponse(w, r, v.Errors)
|
||||
return
|
||||
}
|
||||
if app.isProtectedDemoAccount(input.Email) {
|
||||
app.demoAccountProtectedResponse(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
user, err := app.models.Users.GetByEmail(input.Email)
|
||||
if err != nil {
|
||||
|
||||
@@ -177,6 +177,10 @@ func (app *application) updateUserPasswordHandler(w http.ResponseWriter, r *http
|
||||
}
|
||||
return
|
||||
}
|
||||
if app.isProtectedDemoAccount(user.Email) {
|
||||
app.demoAccountProtectedResponse(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
err = user.Password.Set(input.Password)
|
||||
if err != nil {
|
||||
|
||||
+10
-5
@@ -141,15 +141,20 @@ func (app *application) accountEmailConfirmPost(w http.ResponseWriter, r *http.R
|
||||
func (app *application) renderAccount(w http.ResponseWriter, r *http.Request, form accountForm, status int) {
|
||||
data := app.newTemplateData(r)
|
||||
data.Form = form
|
||||
if user, ok := userFromContext(r.Context()); ok {
|
||||
data.AccountProtected = app.config.DemoAccountEmail != "" && strings.EqualFold(strings.TrimSpace(user.Email), app.config.DemoAccountEmail)
|
||||
}
|
||||
if !app.loadOptionalGarden(w, r, data) {
|
||||
return
|
||||
}
|
||||
sessions, _, err := client.FromContext(r.Context()).AccountSessions(r.Context())
|
||||
if err != nil {
|
||||
app.handleAPIError(w, r, err)
|
||||
return
|
||||
if !data.AccountProtected {
|
||||
sessions, _, err := client.FromContext(r.Context()).AccountSessions(r.Context())
|
||||
if err != nil {
|
||||
app.handleAPIError(w, r, err)
|
||||
return
|
||||
}
|
||||
data.AccountSessions = sessions
|
||||
}
|
||||
data.AccountSessions = sessions
|
||||
app.render(w, status, "account.tmpl", data)
|
||||
}
|
||||
func (app *application) copyAccountError(form *accountForm, err error) {
|
||||
|
||||
@@ -204,6 +204,7 @@ func (app *application) webEnvironmentVariables() []client.EnvironmentVariable {
|
||||
{Component: "Web", Name: "GARDOMATIC_WEB_HOST", Value: app.config.Host},
|
||||
{Component: "Web", Name: "GARDOMATIC_WEB_PORT", Value: fmt.Sprint(app.config.Port)},
|
||||
{Component: "Web", Name: "GARDOMATIC_API_BASE_URL", Value: app.config.APIBaseURL},
|
||||
{Component: "Web", Name: "GARDOMATIC_DEMO_ACCOUNT_EMAIL", Value: app.config.DemoAccountEmail},
|
||||
{Component: "Web", Name: "GARDOMATIC_SESSION_COOKIE_NAME", Value: app.config.SessionCookieName},
|
||||
{Component: "Web", Name: "GARDOMATIC_COOKIE_SECURE", Value: fmt.Sprint(app.config.CookieSecure)},
|
||||
}
|
||||
|
||||
@@ -47,7 +47,10 @@ type CommonTemplateData struct {
|
||||
}
|
||||
|
||||
// AccountTemplateData contains account-management page data.
|
||||
type AccountTemplateData struct{ AccountSessions []client.AccountSession }
|
||||
type AccountTemplateData struct {
|
||||
AccountSessions []client.AccountSession
|
||||
AccountProtected bool
|
||||
}
|
||||
|
||||
// AdminTemplateData contains application-administration page data.
|
||||
type AdminTemplateData struct {
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
{{define "title"}}Benutzerkonto{{end}}
|
||||
{{define "main"}}
|
||||
<header class='page-heading'><div><p class='eyebrow'>Persönlich</p><h2>Benutzerkonto</h2></div></header>{{$form:=.Form}}{{with $form.Message}}<p class='form-message'>{{.}}</p>{{end}}
|
||||
{{if .AccountProtected}}<section class='panel'><h3>Gemeinsames Demokonto</h3><p>E-Mail-Adresse, Passwort, Profil und aktive Sitzungen sind geschützt, damit alle Besucher weiterhin Zugang zur Demo haben. Die Garteninhalte kannst du frei ausprobieren; sie werden jede Nacht zurückgesetzt.</p></section>{{else}}
|
||||
<div class='account-grid'><section class='panel'><h3>Profil</h3><form method='POST' action='{{with .Garden}}{{pathWithQuery (webPath "account.profile") "garden" .ID}}{{else}}{{webPath "account.profile"}}{{end}}'><input type='hidden' name='csrf_token' value='{{.CSRFToken}}'><label for='name'>Name</label><input id='name' name='name' value='{{$form.Name}}' required>{{with index $form.Errors "name"}}<p class='field-error'>{{.}}</p>{{end}}<label for='color'>Farbe für deine Einträge</label><input id='color' name='color' type='color' value='{{$form.Color}}' required><p class='muted'>Diese Farbe wird verwendet, um deine Tagebucheinträge schnell zu erkennen.</p>{{with index $form.Errors "color"}}<p class='field-error'>{{.}}</p>{{end}}<button>Profil speichern</button></form></section>
|
||||
<section class='panel'><h3>E-Mail-Adresse</h3><p>Aktuell: {{.CurrentUser.Email}}</p><form method='POST' action='{{with .Garden}}{{pathWithQuery (webPath "account.email") "garden" .ID}}{{else}}{{webPath "account.email"}}{{end}}'><input type='hidden' name='csrf_token' value='{{.CSRFToken}}'><label for='email'>Neue E-Mail-Adresse</label><input id='email' type='email' name='email' value='{{$form.Email}}' required><label for='email-password'>Aktuelles Passwort</label><input id='email-password' type='password' name='current_password' required>{{with index $form.Errors "email"}}<p class='field-error'>{{.}}</p>{{end}}{{with index $form.Errors "current_password"}}<p class='field-error'>{{.}}</p>{{end}}<button>Bestätigung senden</button></form></section>
|
||||
<section class='panel'><h3>Passwort</h3><form method='POST' action='{{with .Garden}}{{pathWithQuery (webPath "account.password") "garden" .ID}}{{else}}{{webPath "account.password"}}{{end}}'><input type='hidden' name='csrf_token' value='{{.CSRFToken}}'><label for='current-password'>Aktuelles Passwort</label><input id='current-password' type='password' name='current_password' required><label for='new-password'>Neues Passwort</label><input id='new-password' type='password' name='new_password' minlength='8' required>{{with index $form.Errors "new_password"}}<p class='field-error'>{{.}}</p>{{end}}<label for='confirm-password'>Neues Passwort bestätigen</label><input id='confirm-password' type='password' name='new_password_confirmation' required>{{with index $form.Errors "new_password_confirmation"}}<p class='field-error'>{{.}}</p>{{end}}{{with index $form.Errors "current_password"}}<p class='field-error'>{{.}}</p>{{end}}<button>Passwort ändern</button></form></section>
|
||||
<section class='panel'><h3>Aktive Sitzungen</h3>{{if .AccountSessions}}{{range .AccountSessions}}{{$session := .}}<div class='member-row'><div><strong>{{if .Current}}Dieses Gerät{{else}}Weitere Sitzung{{end}}</strong><br><span class='muted'>Angemeldet: {{humanDate .CreatedAt}} · gültig bis {{humanDate .ExpiresAt}}</span></div><form action='{{with $.Garden}}{{pathWithQuery (webPath "account.session.delete" $session.ID) "garden" .ID}}{{else}}{{webPath "account.session.delete" .ID}}{{end}}' method='POST'><input type='hidden' name='csrf_token' value='{{$.CSRFToken}}'><button class='danger'>Widerrufen</button></form></div>{{end}}{{else}}<p>Keine aktiven Sitzungen gefunden.</p>{{end}}</section></div>
|
||||
{{end}}
|
||||
{{end}}
|
||||
|
||||
@@ -128,6 +128,27 @@ func TestGardenContextIsKeptInAccountAndSettingsLinks(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestProtectedDemoAccountHidesAccountMutationForms(t *testing.T) {
|
||||
app := newTestApplication(t)
|
||||
user := client.User{ID: 1, Name: "Demo", Email: "demo@example.com", Activated: true}
|
||||
data := &templateData{
|
||||
commonTemplateData: commonTemplateData{IsAuthenticated: true, IsActivated: true, CurrentUser: &user, Form: accountForm{Errors: map[string]string{}}},
|
||||
accountTemplateData: accountTemplateData{AccountProtected: true},
|
||||
}
|
||||
response := httptest.NewRecorder()
|
||||
app.render(response, http.StatusOK, "account.tmpl", data)
|
||||
body := response.Body.String()
|
||||
|
||||
if !strings.Contains(body, "Gemeinsames Demokonto") {
|
||||
t.Fatalf("protected account notice is missing: %s", body)
|
||||
}
|
||||
for _, forbidden := range []string{"action='/account/profile'", "action='/account/password'", "action='/account/email'", "Widerrufen"} {
|
||||
if strings.Contains(body, forbidden) {
|
||||
t.Errorf("protected account still offers %q: %s", forbidden, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminTemplateKeepsSelectedGardenInNavigationAndForms(t *testing.T) {
|
||||
app := newTestApplication(t)
|
||||
user := client.User{ID: 1, Name: "Alice", Activated: true, Permissions: []string{"roles:manage"}}
|
||||
|
||||
+2
-1
@@ -15,8 +15,8 @@ import (
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"gardomatic.kleiax.de/lib/client"
|
||||
"gardomatic.kleiax.de/internal/vcs"
|
||||
"gardomatic.kleiax.de/lib/client"
|
||||
"github.com/go-playground/form/v4"
|
||||
)
|
||||
|
||||
@@ -30,6 +30,7 @@ type Config struct {
|
||||
Port int
|
||||
Env string
|
||||
APIBaseURL string
|
||||
DemoAccountEmail string
|
||||
SessionCookieName string
|
||||
CookieSecure bool
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user