From 357e0d06697d41d244c4d7a9f2ff0a9fbe181253 Mon Sep 17 00:00:00 2001 From: Jason Ernst Date: Sat, 3 Oct 2026 09:01:49 -0700 Subject: [PATCH] Install without GitHub: a setup code, then an admin account with a password Finishing an install meant creating a GitHub OAuth app, and until that was done the wizard worked for anyone who found the site. Setup code. While a site has no admin, goblog prints a random code to its log at startup and the wizard asks for it before anything else. The database endpoints, the GitHub step and the pre-admin settings and upload endpoints all require a browser that has entered it (#658). Admin password. The wizard's last step now creates an admin account with an email and a password (bcrypt), signs the browser in and ends the install; GitHub is still offered as the alternative. The login page gets a password form, a shared partial, and goblog renders its own login page when a password is the only way in, so a theme whose login.html predates this cannot lock the admin out. Sign-in attempts are rate limited. `goblog reset-admin-password` prints a new password from the server. Also: the session key is no longer written to the log, and the Docker image has a WORKDIR so the reset command finds .env under docker exec. The install smoke test now reads the setup code from the log, checks the wizard refuses without it, creates the admin, signs in with the password after the container is replaced, and resets the password. Closes #654. Closes #658. Co-Authored-By: Claude Fable 5.1 --- Dockerfile | 3 + README.md | 21 +- auth/auth.go | 24 +- auth/auth_test.go | 12 +- auth/export_test.go | 7 + auth/otp.go | 21 +- auth/password.go | 237 ++++++++++++++++++ auth/setup.go | 111 +++++++++ auth/setup_password_test.go | 256 ++++++++++++++++++++ auth/user.go | 14 +- blog/blog.go | 27 ++- cmd_reset_password.go | 43 ++++ go.mod | 2 +- goblog.go | 163 +++++++++++-- plugins/docs/content/writing-a-theme.md | 2 +- plugins/docs/content_test.go | 3 + scripts/install-smoke-test.sh | 145 ++++++++--- templates/shared/_password_login.html | 45 ++++ themes/default/templates/login.html | 6 + themes/default/templates/wizard_auth.html | 36 ++- themes/default/templates/wizard_unlock.html | 38 +++ wizard_install_only_test.go | 54 ++++- 22 files changed, 1166 insertions(+), 104 deletions(-) create mode 100644 auth/export_test.go create mode 100644 auth/password.go create mode 100644 auth/setup.go create mode 100644 auth/setup_password_test.go create mode 100644 cmd_reset_password.go create mode 100644 templates/shared/_password_login.html create mode 100644 themes/default/templates/wizard_unlock.html diff --git a/Dockerfile b/Dockerfile index 7ce7ba57..17be83a0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -12,6 +12,9 @@ ARG VERSION RUN cd /go/src/github.com/compscidr/goblog/ && go build -ldflags="-X 'main.Version=$VERSION'" -v . # Run the outyet command by default when the container starts. +# So `docker exec ./goblog reset-admin-password` finds .env. +WORKDIR /go/src/github.com/compscidr/goblog + ENTRYPOINT cd /go/src/github.com/compscidr/goblog && ./goblog # Document that the service listens on port 7000. diff --git a/README.md b/README.md index 8b2b7b0d..0055a131 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,10 @@ go build ``` Visit http://localhost:7000 and follow the install wizard. +The wizard first asks for a **setup code**, which goblog prints to its log at startup (`GoBlog setup code: XXXX-XXXX-XXXX`; with Docker, `docker logs 2>&1 | grep "setup code"`). Being able to read the server's log is the proof that you run the server, so nobody else who finds a half-installed site can finish the install for you. The code changes on every restart and stops being printed once the site has an admin. + +It then takes three steps: the database, the site's title and images, and an **admin account** with an email and a password. No GitHub OAuth app or mail server is needed; the wizard still offers GitHub as the alternative for the last step. + ### Docker ```bash docker run -p 7000:7000 -e GOBLOG_DATA_DIR=/data -v goblog-data:/data compscidr/goblog:latest @@ -93,8 +97,19 @@ TRUSTED_PROXIES=172.16.0.0/12 ./goblog The session cookie is `HttpOnly`, `SameSite=Lax` and `Secure`, so it is only sent over HTTPS (browsers exempt `localhost`, so local development on `http://localhost:7000` still works). If you serve goblog over plain HTTP on any other host, set `SESSION_SECURE=false` or logins will not stick. Mutating `/api/v1` requests must be sent as `application/json` (`/api/v1/upload` as `multipart/form-data`); anything else gets `415 Unsupported Media Type`. +### Admin Password +The admin account the wizard creates signs in on the login page with its email and password. The email is only a sign-in name; goblog sends nothing to it. Passwords are at least 10 characters and stored as bcrypt hashes, and sign-in attempts are rate limited per client address. + +If you forget the password, reset it from the server. The command prints a new random password and signs out any browser logged in as that account: +```bash +./goblog reset-admin-password # or: docker exec ./goblog reset-admin-password +``` +Run it from goblog's working directory, or with `GOBLOG_DATA_DIR` set as it is for the server, so that it finds `.env`. + +There is one password account, the one the wizard creates. To add GitHub login to such a site later, put `client_id` and `client_secret` in `.env` and restart; the login page then offers both. + ### Pinning the Admin Account -On a fresh install the first GitHub account to complete login becomes the admin. If you pre-populate `.env` (e.g. from configuration management) and skip the wizard, anyone could win that race. Pin it to your own account by adding either or both of these to `.env`: +If you chose GitHub in the wizard, or pre-populate `.env` with GitHub credentials and skip it, the first GitHub account to complete login becomes the admin. If you pre-populate `.env` (e.g. from configuration management) and skip the wizard, anyone could win that race. Pin it to your own account by adding either or both of these to `.env`: ```bash admin_login=your-github-username # case-insensitive admin_github_id=12345 # numeric id: https://api.github.com/users/your-github-username @@ -102,7 +117,7 @@ admin_github_id=12345 # numeric id: https://api.github.com/users Other accounts can still log in as regular users but are never promoted. Leave both unset to keep the first-to-login behaviour. ### Managing Admins -The pin above only decides who becomes the *first* admin. After that, admins are managed from the **Users** page in the admin area (`/admin/users`), which lists everyone who has logged in. An existing admin can promote any GitHub user to admin or demote another admin; the last remaining admin can't be demoted, so the site never ends up with none. Email-login users can't be made admin (see #565). +The pin above only decides who becomes the *first* admin. After that, admins are managed from the **Users** page in the admin area (`/admin/users`), which lists everyone who has logged in. An existing admin can promote any GitHub user to admin or demote another admin (including the wizard's password account); the last remaining admin can't be demoted, so the site never ends up with none. Email-login users can't be made admin (see #565). To hand the site over to a different GitHub account: log in with the new account once so it appears in the list, promote it from your current admin account, then log in as the new account and demote the old one. @@ -115,7 +130,7 @@ smtp_user=postmaster@example.com # omit for an unauthenticated relay smtp_password=... smtp_from=blog@example.com ``` -When `smtp_host` and `smtp_from` are both set the login page offers "sign in with email"; otherwise it shows GitHub only. Codes expire after 10 minutes, allow 5 wrong attempts, and can be re-requested once a minute. Email users are regular users — the admin account is still GitHub-only (see above). +When `smtp_host` and `smtp_from` are both set the login page offers "sign in with email"; otherwise it shows GitHub only. Codes expire after 10 minutes, allow 5 wrong attempts, and can be re-requested once a minute. Email users are regular users — they cannot be made admin (see above). SMTP settings are read once at startup, so restart goblog after changing any `smtp_*` value in `.env` for the change to take effect. Go's SMTP client only sends `smtp_user`/`smtp_password` over an encrypted connection (STARTTLS, or implicit TLS on port 465) unless the host is `localhost`, so if you need an unencrypted remote relay, use it without credentials. diff --git a/auth/auth.go b/auth/auth.go index 1a096205..22e66a14 100644 --- a/auth/auth.go +++ b/auth/auth.go @@ -45,11 +45,14 @@ type Auth struct { // around by value (New returns a value, wizard constructs its own); every // copy of a given Auth shares the same limiter. sendLimiter *ipLimiter + // passwordLimiter throttles password logins per client IP. + passwordLimiter *ipLimiter } // New constructs an Auth API func New(db *gorm.DB, version string) Auth { - api := Auth{db: &db, version: version, sendLimiter: newIPLimiter()} + api := Auth{db: &db, version: version, sendLimiter: newIPLimiter(), + passwordLimiter: newLimiter(passwordAttemptsPerIP, passwordAttemptsWindow)} return api } @@ -485,15 +488,20 @@ func (a *Auth) IsAdmin(c *gin.Context) bool { return true } -// IsWizardMode returns true when the install wizard has not yet completed, -// detected by the absence of any row in the admin_users table. The wizard's -// own pre-admin endpoints (image upload, initial settings) gate on this so -// that fresh-install setup can complete before an admin user exists, without -// IsAdmin itself being permissive to anonymous traffic. +// IsWizardMode returns true when the install wizard has not yet completed +// (no row in the admin_users table) and this browser has entered the setup +// code (see SetupUnlocked). The wizard's own pre-admin endpoints (image +// upload, initial settings) gate on this so that fresh-install setup can +// complete before an admin user exists, without being open to whoever else +// finds the site in the meantime (#658). func (a *Auth) IsWizardMode(c *gin.Context) bool { + return !a.AdminExists() && SetupUnlocked(c) +} + +// AdminExists reports whether the site has an admin yet. +func (a *Auth) AdminExists() bool { var adminUser AdminUser - err := (*a.db).First(&adminUser).Error - return err != nil + return (*a.db).First(&adminUser).Error == nil } // CurrentUser returns the user whose session token is in the request's diff --git a/auth/auth_test.go b/auth/auth_test.go index 0436a38c..1892f561 100644 --- a/auth/auth_test.go +++ b/auth/auth_test.go @@ -55,10 +55,16 @@ func TestIsAdmin_NoAdminUser_ReturnsFalse(t *testing.T) { } } -func TestIsWizardMode_NoAdminUser_ReturnsTrue(t *testing.T) { +// With no admin, wizard mode additionally needs the setup code; see +// TestSetupCode in setup_password_test.go. +func TestIsWizardMode_NoAdminUser_LockedWithoutSetupCode(t *testing.T) { a, _ := newAuth(t) - if !a.IsWizardMode(newCtx()) { - t.Fatal("IsWizardMode must be true when no admin_users row exists") + if _, err := auth.NewSetupCode(); err != nil { + t.Fatal(err) + } + t.Cleanup(auth.ClearSetupCode) + if a.IsWizardMode(newCtx()) { + t.Fatal("IsWizardMode must be false for a browser that has not entered the setup code") } } diff --git a/auth/export_test.go b/auth/export_test.go new file mode 100644 index 00000000..0dd6705b --- /dev/null +++ b/auth/export_test.go @@ -0,0 +1,7 @@ +package auth + +import "time" + +// ResetSetupLimiter gives tests a fresh setup-code rate limiter: it is +// package state, and every test request comes from the same address. +func ResetSetupLimiter() { setupLimiter = newLimiter(10, 10*time.Minute) } diff --git a/auth/otp.go b/auth/otp.go index da13a889..adc59401 100644 --- a/auth/otp.go +++ b/auth/otp.go @@ -40,28 +40,35 @@ const ( // It is referenced from Auth via a pointer (see Auth.sendLimiter) because // Auth values are copied around, and every copy must share one limiter. type ipLimiter struct { - mu sync.Mutex - hits map[string][]time.Time + mu sync.Mutex + hits map[string][]time.Time + limit int + window time.Duration } +// newIPLimiter returns the limiter for POST /api/login/email. func newIPLimiter() *ipLimiter { - return &ipLimiter{hits: make(map[string][]time.Time)} + return newLimiter(loginCodeSendsPerIP, loginCodeSendsWindow) +} + +func newLimiter(limit int, window time.Duration) *ipLimiter { + return &ipLimiter{hits: make(map[string][]time.Time), limit: limit, window: window} } // allow reports whether ip may make another request at now, recording the -// attempt if so. Timestamps older than loginCodeSendsWindow are pruned first, -// so the limit only ever reflects the trailing window. +// attempt if so. Timestamps older than the window are pruned first, so the +// limit only ever reflects the trailing window. func (l *ipLimiter) allow(ip string, now time.Time) bool { l.mu.Lock() defer l.mu.Unlock() - cutoff := now.Add(-loginCodeSendsWindow) + cutoff := now.Add(-l.window) kept := l.hits[ip][:0] for _, t := range l.hits[ip] { if t.After(cutoff) { kept = append(kept, t) } } - if len(kept) >= loginCodeSendsPerIP { + if len(kept) >= l.limit { l.hits[ip] = kept return false } diff --git a/auth/password.go b/auth/password.go new file mode 100644 index 00000000..c40cb7e9 --- /dev/null +++ b/auth/password.go @@ -0,0 +1,237 @@ +package auth + +import ( + "crypto/rand" + "errors" + "fmt" + "log" + "net/http" + "net/url" + "time" + "unicode/utf8" + + "github.com/gin-contrib/sessions" + "github.com/gin-gonic/gin" + "golang.org/x/crypto/bcrypt" + "gorm.io/gorm" +) + +// Password login (#654): an admin account that needs nothing outside the +// server, so a fresh install can be finished without a GitHub OAuth app or +// an SMTP relay. The install wizard creates the one password user there is; +// there is no sign-up. +const ( + // MinPasswordLength is in characters. bcrypt reads at most 72 bytes and + // silently ignores the rest, so longer passwords are refused rather than + // truncated. + MinPasswordLength = 10 + maxPasswordBytes = 72 + + passwordAttemptsPerIP = 10 + passwordAttemptsWindow = 10 * time.Minute +) + +// ErrAdminExists is returned by CreatePasswordAdmin once the site has an +// admin: the wizard creates the first one and nothing more. +var ErrAdminExists = errors.New("this site already has an admin") + +// ErrNoPasswordUser is returned by ResetPassword when there is no password +// user to reset (or none with the given email). +var ErrNoPasswordUser = errors.New("no matching password user") + +// dummyHash is compared against when the email matches no user, so a login +// for an unknown address takes as long as one for a known address. +var dummyHash, _ = bcrypt.GenerateFromPassword([]byte("not a real password"), bcrypt.DefaultCost) + +// CheckPassword says whether password is acceptable as a new password. +func CheckPassword(password string) error { + if utf8.RuneCountInString(password) < MinPasswordLength { + return fmt.Errorf("the password must be at least %d characters", MinPasswordLength) + } + if len(password) > maxPasswordBytes { + return fmt.Errorf("the password must be at most %d bytes", maxPasswordBytes) + } + return nil +} + +// CreatePasswordAdmin creates the site's first admin as a password user and +// returns it with a fresh session token. It refuses with ErrAdminExists when +// there already is an admin; the check and both inserts share a transaction +// so two concurrent requests cannot both succeed. +func (a *Auth) CreatePasswordAdmin(email, password string) (*BlogUser, error) { + email, ok := normalizeEmail(email) + if !ok { + return nil, errors.New("that does not look like an email address") + } + if err := CheckPassword(password); err != nil { + return nil, err + } + hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return nil, err + } + token, err := newSessionToken() + if err != nil { + return nil, err + } + user := &BlogUser{ + Provider: ProviderPassword, + ProviderID: email, + Login: email, + Email: email, + AccessToken: token, + PasswordHash: string(hash), + } + err = (*a.db).Transaction(func(tx *gorm.DB) error { + var admin AdminUser + err := tx.First(&admin).Error + if err == nil { + return ErrAdminExists + } + if !errors.Is(err, gorm.ErrRecordNotFound) { + return err + } + // A row left by an install that got this far and no further is + // taken over rather than tripping the unique index. + var existing BlogUser + err = tx.Where("provider = ? AND provider_id = ?", ProviderPassword, email).First(&existing).Error + switch { + case err == nil: + user.ID = existing.ID + if err := tx.Save(user).Error; err != nil { + return err + } + case errors.Is(err, gorm.ErrRecordNotFound): + if err := tx.Create(user).Error; err != nil { + return err + } + default: + return err + } + return tx.Create(&AdminUser{BlogUserID: user.ID}).Error + }) + if err != nil { + return nil, err + } + return user, nil +} + +// PasswordLoginEnabled reports whether the login page should offer a +// password form: whether any password user exists. +func (a *Auth) PasswordLoginEnabled() bool { + var user BlogUser + return (*a.db).Where("provider = ?", ProviderPassword).First(&user).Error == nil +} + +// StartSession logs user in on this browser, exactly as the other login +// flows do: the user's AccessToken goes into the session. +func (a *Auth) StartSession(c *gin.Context, user *BlogUser) error { + session := sessions.Default(c) + session.Set("token", user.AccessToken) + return session.Save() +} + +// PasswordLogin handles the login page's password form (fields: email, +// password) and redirects: to next on success, back to the password login +// page with login_error set otherwise. next must already be a safe same-site path. +// A wrong password and an unknown email are indistinguishable, in the +// answer and in how long it takes. +func (a *Auth) PasswordLogin(c *gin.Context, next string) { + back := func(reason string) { + c.Redirect(http.StatusSeeOther, "/login?password=1&login_error="+reason+"&next="+url.QueryEscape(next)) + } + if !a.passwordLimiter.allow(c.ClientIP(), time.Now()) { + back("rate_limit") + return + } + email, _ := normalizeEmail(c.PostForm("email")) + password := c.PostForm("password") + + var user BlogUser + found := email != "" && + (*a.db).Where("provider = ? AND provider_id = ?", ProviderPassword, email).First(&user).Error == nil + hash := dummyHash + if found && user.PasswordHash != "" { + hash = []byte(user.PasswordHash) + } + matches := len(password) <= maxPasswordBytes && bcrypt.CompareHashAndPassword(hash, []byte(password)) == nil + if !found || user.PasswordHash == "" || !matches { + back("invalid") + return + } + + token, err := newSessionToken() + if err != nil { + log.Printf("generating session token: %v", err) + back("server") + return + } + user.AccessToken = token + if err := (*a.db).Model(&user).UpdateColumn("access_token", token).Error; err != nil { + log.Printf("storing session token for %s: %v", email, err) + back("server") + return + } + if err := a.StartSession(c, &user); err != nil { + log.Printf("saving session: %v", err) + back("server") + return + } + c.Redirect(http.StatusSeeOther, next) +} + +// ResetPassword gives a password user a new random password and returns the +// user's email with it. With email "" it resets the only password user and +// fails if there is more than one. The user's session token is replaced too, +// so anyone logged in as them is logged out. For the reset-admin-password +// command: without SMTP there is no other way back in. +func (a *Auth) ResetPassword(email string) (string, string, error) { + var users []BlogUser + q := (*a.db).Where("provider = ?", ProviderPassword) + if email != "" { + normalized, ok := normalizeEmail(email) + if !ok { + return "", "", ErrNoPasswordUser + } + q = q.Where("provider_id = ?", normalized) + } + if err := q.Find(&users).Error; err != nil { + return "", "", err + } + if len(users) == 0 { + return "", "", ErrNoPasswordUser + } + if len(users) > 1 { + return "", "", errors.New("more than one password user; name one by email") + } + password, err := randomPassword() + if err != nil { + return "", "", err + } + hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return "", "", err + } + token, err := newSessionToken() + if err != nil { + return "", "", err + } + err = (*a.db).Model(&users[0]).Updates(map[string]any{"password_hash": string(hash), "access_token": token}).Error + if err != nil { + return "", "", err + } + return users[0].ProviderID, password, nil +} + +// randomPassword returns 20 characters from the setup-code alphabet (no +// look-alikes), about 99 bits. +func randomPassword() (string, error) { + raw := make([]byte, 20) + if _, err := rand.Read(raw); err != nil { + return "", err + } + for i, b := range raw { + raw[i] = setupAlphabet[int(b)%len(setupAlphabet)] + } + return string(raw), nil +} diff --git a/auth/setup.go b/auth/setup.go new file mode 100644 index 00000000..de14e77f --- /dev/null +++ b/auth/setup.go @@ -0,0 +1,111 @@ +package auth + +import ( + "crypto/rand" + "crypto/subtle" + "errors" + "strings" + "sync" + "time" + + "github.com/gin-contrib/sessions" + "github.com/gin-gonic/gin" +) + +// The setup code (#654, #658). Until a site has an admin, its install +// wizard has to work for somebody who cannot log in, and without a check it +// works for anybody who finds the site first. So goblog prints a random +// code to its log at startup and the wizard asks for it: being able to read +// the server's log is the proof of owning the server. +// +// The code lives in memory only. A restart makes a new one, which also +// invalidates every browser that was unlocked with the old one. It is +// package state rather than a field of Auth because Auth values are copied +// (the wizard builds its own) and they must all agree. + +const ( + setupSessionKey = "setup_code" + // No 0/O, 1/I/L: the code is read off a terminal and typed. + setupAlphabet = "ABCDEFGHJKMNPQRSTUVWXYZ23456789" + setupLength = 12 +) + +var setup struct { + mu sync.RWMutex + code string // "" means locked: nothing unlocks the wizard +} + +// NewSetupCode makes and remembers a new setup code and returns it +// formatted for printing (XXXX-XXXX-XXXX). +func NewSetupCode() (string, error) { + raw := make([]byte, setupLength) + if _, err := rand.Read(raw); err != nil { + return "", err + } + code := make([]byte, setupLength) + for i, b := range raw { + // 31 symbols: the modulo bias is far below what a 12-symbol code + // with a rate limit in front of it could ever care about. + code[i] = setupAlphabet[int(b)%len(setupAlphabet)] + } + setup.mu.Lock() + setup.code = string(code) + setup.mu.Unlock() + return string(code[0:4]) + "-" + string(code[4:8]) + "-" + string(code[8:12]), nil +} + +// ClearSetupCode forgets the setup code, locking the wizard again. Called +// once the site has an admin. +func ClearSetupCode() { + setup.mu.Lock() + setup.code = "" + setup.mu.Unlock() +} + +func currentSetupCode() string { + setup.mu.RLock() + defer setup.mu.RUnlock() + return setup.code +} + +// normalizeSetupCode makes what a person typed comparable: case, dashes and +// spaces do not matter. +func normalizeSetupCode(s string) string { + return strings.ToUpper(strings.NewReplacer("-", "", " ", "").Replace(strings.TrimSpace(s))) +} + +func setupCodeMatches(given string) bool { + want := currentSetupCode() + return want != "" && subtle.ConstantTimeCompare([]byte(normalizeSetupCode(given)), []byte(want)) == 1 +} + +// ErrSetupCodeWrong and ErrSetupRateLimited are UnlockSetup's refusals. +var ( + ErrSetupCodeWrong = errors.New("that is not the setup code") + ErrSetupRateLimited = errors.New("too many attempts; wait a few minutes and try again") +) + +// setupLimiter throttles guesses at the setup code per client IP. +var setupLimiter = newLimiter(10, 10*time.Minute) + +// UnlockSetup checks code and, when it is right, remembers that in the +// browser's session. +func UnlockSetup(c *gin.Context, code string) error { + if !setupLimiter.allow(c.ClientIP(), time.Now()) { + return ErrSetupRateLimited + } + if !setupCodeMatches(code) { + return ErrSetupCodeWrong + } + session := sessions.Default(c) + session.Set(setupSessionKey, currentSetupCode()) + return session.Save() +} + +// SetupUnlocked reports whether this browser has entered the current setup +// code. The session holds the code itself, not a flag, so a code from before +// a restart no longer counts. +func SetupUnlocked(c *gin.Context) bool { + given, _ := sessions.Default(c).Get(setupSessionKey).(string) + return given != "" && setupCodeMatches(given) +} diff --git a/auth/setup_password_test.go b/auth/setup_password_test.go new file mode 100644 index 00000000..7409c642 --- /dev/null +++ b/auth/setup_password_test.go @@ -0,0 +1,256 @@ +package auth_test + +import ( + "errors" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + + "goblog/auth" + + "github.com/gin-contrib/sessions" + "github.com/gin-contrib/sessions/cookie" + "github.com/gin-gonic/gin" +) + +// browser is a cookie-keeping client for a gin router: enough of a browser +// to carry a session from one request to the next. +type browser struct { + t *testing.T + router *gin.Engine + cookies map[string]string +} + +func (b *browser) do(method, path string, form url.Values) *httptest.ResponseRecorder { + b.t.Helper() + var req *http.Request + if form != nil { + req = httptest.NewRequest(method, path, strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + } else { + req = httptest.NewRequest(method, path, nil) + } + for name, value := range b.cookies { + req.AddCookie(&http.Cookie{Name: name, Value: value}) + } + w := httptest.NewRecorder() + b.router.ServeHTTP(w, req) + for _, c := range w.Result().Cookies() { + b.cookies[c.Name] = c.Value + } + return w +} + +// authApp is a router exposing just what these tests poke at. +func authApp(t *testing.T, a *auth.Auth) func() *browser { + t.Helper() + gin.SetMode(gin.TestMode) + r := gin.New() + r.Use(sessions.Sessions("session", cookie.NewStore([]byte("test")))) + yes := func(ok bool) int { + if ok { + return http.StatusOK + } + return http.StatusForbidden + } + r.POST("/unlock", func(c *gin.Context) { + err := auth.UnlockSetup(c, c.PostForm("setup_code")) + switch { + case err == nil: + c.Status(http.StatusOK) + case errors.Is(err, auth.ErrSetupRateLimited): + c.Status(http.StatusTooManyRequests) + default: + c.Status(http.StatusForbidden) + } + }) + r.GET("/wizard-mode", func(c *gin.Context) { c.Status(yes(a.IsWizardMode(c))) }) + r.GET("/is-admin", func(c *gin.Context) { c.Status(yes(a.IsAdmin(c))) }) + r.POST("/login/password", func(c *gin.Context) { a.PasswordLogin(c, "/done") }) + return func() *browser { return &browser{t: t, router: r, cookies: map[string]string{}} } +} + +func setupCode(t *testing.T) string { + t.Helper() + code, err := auth.NewSetupCode() + if err != nil { + t.Fatal(err) + } + auth.ResetSetupLimiter() + t.Cleanup(auth.ClearSetupCode) + return code +} + +// TestSetupCode: wizard mode is for the browser that entered the code the +// server printed, and for nobody else. +func TestSetupCode(t *testing.T) { + a, _ := newAuth(t) + newBrowser := authApp(t, a) + code := setupCode(t) + + owner, stranger := newBrowser(), newBrowser() + if got := owner.do("GET", "/wizard-mode", nil).Code; got != http.StatusForbidden { + t.Fatalf("wizard mode before the code was entered: %d, want 403", got) + } + if got := stranger.do("POST", "/unlock", url.Values{"setup_code": {"AAAA-AAAA-AAAA"}}).Code; got != http.StatusForbidden { + t.Fatalf("unlock with a wrong code: %d, want 403", got) + } + // As typed by a person: lower case, spaces instead of dashes. + typed := strings.ToLower(strings.ReplaceAll(code, "-", " ")) + if got := owner.do("POST", "/unlock", url.Values{"setup_code": {typed}}).Code; got != http.StatusOK { + t.Fatalf("unlock with the right code: %d, want 200", got) + } + if got := owner.do("GET", "/wizard-mode", nil).Code; got != http.StatusOK { + t.Errorf("wizard mode after unlocking: %d, want 200", got) + } + if got := stranger.do("GET", "/wizard-mode", nil).Code; got != http.StatusForbidden { + t.Errorf("wizard mode in another browser: %d, want 403", got) + } + + // A restart prints a new code; a browser unlocked with the old one is + // locked again. + setupCode(t) + if got := owner.do("GET", "/wizard-mode", nil).Code; got != http.StatusForbidden { + t.Errorf("wizard mode after the code changed: %d, want 403", got) + } + + // No code at all (an installed site) unlocks nothing, including "". + auth.ClearSetupCode() + if got := newBrowser().do("POST", "/unlock", url.Values{"setup_code": {""}}).Code; got != http.StatusForbidden { + t.Errorf("unlock with no code set: %d, want 403", got) + } +} + +func TestSetupCodeGuessesAreRateLimited(t *testing.T) { + a, _ := newAuth(t) + b := authApp(t, a)() + setupCode(t) + limited := false + for i := 0; i < 40 && !limited; i++ { + limited = b.do("POST", "/unlock", url.Values{"setup_code": {"AAAA-AAAA-AAAA"}}).Code == http.StatusTooManyRequests + } + if !limited { + t.Fatal("40 wrong setup codes in a row were all answered") + } +} + +func TestCreatePasswordAdmin(t *testing.T) { + a, db := newAuth(t) + for name, tc := range map[string]struct{ email, password string }{ + "short password": {"me@example.com", "too short"}, + "over 72 bytes": {"me@example.com", strings.Repeat("x", 73)}, + "not an email": {"me", "a long enough password"}, + "two addresses": {"a@example.com,b@example.com", "a long enough password"}, + "empty everything": {"", ""}, + } { + if _, err := a.CreatePasswordAdmin(tc.email, tc.password); err == nil { + t.Errorf("%s: accepted", name) + } + } + if a.AdminExists() { + t.Fatal("a refused request created an admin") + } + + user, err := a.CreatePasswordAdmin(" Me@Example.com ", "a long enough password") + if err != nil { + t.Fatalf("create: %v", err) + } + if user.Provider != auth.ProviderPassword || user.ProviderID != "me@example.com" || user.AccessToken == "" { + t.Errorf("unexpected user: %+v", user) + } + var stored auth.BlogUser + db.First(&stored, user.ID) + if stored.PasswordHash == "" || strings.Contains(stored.PasswordHash, "a long enough password") { + t.Errorf("password not stored as a hash: %q", stored.PasswordHash) + } + if !a.AdminExists() { + t.Fatal("no admin after CreatePasswordAdmin") + } + // The wizard creates the first admin and nothing more. + if _, err := a.CreatePasswordAdmin("other@example.com", "another long password"); !errors.Is(err, auth.ErrAdminExists) { + t.Errorf("second admin: err = %v, want ErrAdminExists", err) + } +} + +func TestPasswordLogin(t *testing.T) { + a, _ := newAuth(t) + newBrowser := authApp(t, a) + const email, password = "me@example.com", "a long enough password" + if _, err := a.CreatePasswordAdmin(email, password); err != nil { + t.Fatal(err) + } + + for name, form := range map[string]url.Values{ + "wrong password": {"email": {email}, "password": {"not the password"}}, + "unknown email": {"email": {"you@example.com"}, "password": {password}}, + "no password": {"email": {email}}, + "over 72 bytes": {"email": {email}, "password": {password + strings.Repeat("x", 80)}}, + } { + b := newBrowser() + w := b.do("POST", "/login/password", form) + if loc := w.Header().Get("Location"); !strings.HasPrefix(loc, "/login?password=1&login_error=invalid") { + t.Errorf("%s: redirected to %q, want the login page with login_error=invalid", name, loc) + } + if b.do("GET", "/is-admin", nil).Code == http.StatusOK { + t.Errorf("%s: logged in", name) + } + } + + b := newBrowser() + w := b.do("POST", "/login/password", url.Values{"email": {"ME@example.com"}, "password": {password}}) + if w.Code != http.StatusSeeOther || w.Header().Get("Location") != "/done" { + t.Fatalf("right password: %d to %q, want 303 to /done", w.Code, w.Header().Get("Location")) + } + if got := b.do("GET", "/is-admin", nil).Code; got != http.StatusOK { + t.Fatalf("not an admin after logging in: %d", got) + } +} + +func TestPasswordLoginIsRateLimited(t *testing.T) { + a, _ := newAuth(t) + b := authApp(t, a)() + const email, password = "me@example.com", "a long enough password" + if _, err := a.CreatePasswordAdmin(email, password); err != nil { + t.Fatal(err) + } + limited := false + for i := 0; i < 40 && !limited; i++ { + w := b.do("POST", "/login/password", url.Values{"email": {email}, "password": {"guess"}}) + limited = strings.Contains(w.Header().Get("Location"), "login_error=rate_limit") + } + if !limited { + t.Fatal("40 wrong passwords in a row were all checked") + } + // The right password does not get through a limited client either. + w := b.do("POST", "/login/password", url.Values{"email": {email}, "password": {password}}) + if !strings.Contains(w.Header().Get("Location"), "login_error=rate_limit") { + t.Errorf("limited client logged in: %q", w.Header().Get("Location")) + } +} + +func TestResetPassword(t *testing.T) { + a, _ := newAuth(t) + if _, _, err := a.ResetPassword(""); !errors.Is(err, auth.ErrNoPasswordUser) { + t.Fatalf("reset with no password user: %v, want ErrNoPasswordUser", err) + } + const email, old = "me@example.com", "a long enough password" + if _, err := a.CreatePasswordAdmin(email, old); err != nil { + t.Fatal(err) + } + if _, _, err := a.ResetPassword("you@example.com"); !errors.Is(err, auth.ErrNoPasswordUser) { + t.Fatalf("reset for an unknown email: %v, want ErrNoPasswordUser", err) + } + who, fresh, err := a.ResetPassword("") + if err != nil || who != email || len(fresh) < auth.MinPasswordLength { + t.Fatalf("reset: who=%q password=%q err=%v", who, fresh, err) + } + newBrowser := authApp(t, a) + if loc := newBrowser().do("POST", "/login/password", url.Values{"email": {email}, "password": {old}}).Header().Get("Location"); loc == "/done" { + t.Error("the old password still works") + } + if loc := newBrowser().do("POST", "/login/password", url.Values{"email": {email}, "password": {fresh}}).Header().Get("Location"); loc != "/done" { + t.Errorf("the new password does not work: redirected to %q", loc) + } +} diff --git a/auth/user.go b/auth/user.go index 1b11d15d..3cdff117 100644 --- a/auth/user.go +++ b/auth/user.go @@ -7,13 +7,15 @@ import ( // Values for BlogUser.Provider. const ( - ProviderGitHub = "github" - ProviderEmail = "email" + ProviderGitHub = "github" + ProviderEmail = "email" + ProviderPassword = "password" ) // BlogUser is a user of the blog from any login provider. The (Provider, // ProviderID) pair identifies the user in the external system: the GitHub -// numeric id as a string, or the normalised email address. ID is an internal +// numeric id as a string, or the normalised email address (for both email +// and password users). ID is an internal // key assigned by the database. The only role that matters is admin (see // AdminUser); otherwise users exist for comments. type BlogUser struct { @@ -25,8 +27,12 @@ type BlogUser struct { Name string `json:"name"` Email string `json:"email"` // AccessToken is the session credential: the GitHub OAuth token for - // GitHub users, a random token for email users. Never sent to clients. + // GitHub users, a random token for email and password users. Never sent + // to clients. AccessToken string `json:"-"` + // PasswordHash is the bcrypt hash of a password user's password; empty + // for every other provider. Never sent to clients. + PasswordHash string `json:"-"` } // DisplayName is the name to show for the user where one is needed, e.g. as diff --git a/blog/blog.go b/blog/blog.go index cca0b7f7..4c97a4dc 100644 --- a/blog/blog.go +++ b/blog/blog.go @@ -1418,7 +1418,21 @@ func (b *Blog) Login(c *gin.Context) { clientID := os.Getenv("client_id") next := SafeNext(c.Query("next")) - b.Render(c, http.StatusOK, "login.html", gin.H{ + // Asked through an optional interface so the IAuth mocks need not know + // about password login. + passwordLogin := false + if p, ok := b.auth.(interface{ PasswordLoginEnabled() bool }); ok { + passwordLogin = p.PasswordLoginEnabled() + } + // When a password is the only way in, the page is goblog's own rather + // than the theme's login.html, which may predate password login. For the + // same reason /login?password=1 always reaches the form, and a failed + // attempt comes back to it. + page := "login.html" + if passwordLogin && (c.Query("password") != "" || (clientID == "" && !b.auth.EmailLoginEnabled())) { + page = "_password_login_page" + } + b.Render(c, http.StatusOK, page, gin.H{ "logged_in": b.auth.IsLoggedIn(c), "is_admin": b.auth.IsAdmin(c), // The only page whose markup uses .btn-social, so the only one that @@ -1432,10 +1446,13 @@ func (b *Blog) Login(c *gin.Context) { "version": b.Version, "title": "Login", "email_login_enabled": b.auth.EmailLoginEnabled(), - "recent": b.GetLatest(), - "admin_page": false, - "settings": b.GetSettings(), - "nav_pages": b.GetNavPages(), + // The password form is the shared _password_login partial. + "password_login_enabled": passwordLogin, + "login_error": c.Query("login_error"), + "recent": b.GetLatest(), + "admin_page": false, + "settings": b.GetSettings(), + "nav_pages": b.GetNavPages(), }) } diff --git a/cmd_reset_password.go b/cmd_reset_password.go new file mode 100644 index 00000000..3dd4f574 --- /dev/null +++ b/cmd_reset_password.go @@ -0,0 +1,43 @@ +package main + +import ( + "errors" + "fmt" + "io" + + "goblog/auth" +) + +// runResetAdminPassword implements `goblog reset-admin-password [email]`: +// it gives the password admin a new random password and prints it. Without +// SMTP there is no "forgot my password" email, so the way back in is being +// able to run a command on the server. Run it from goblog's working +// directory (or with GOBLOG_DATA_DIR set) so it finds .env. Returns the +// process exit code. +func runResetAdminPassword(args []string, stdout, stderr io.Writer) int { + if len(args) > 1 { + fmt.Fprintln(stderr, "usage: goblog reset-admin-password [email]") + return 2 + } + email := "" + if len(args) == 1 { + email = args[0] + } + db := attemptConnectDb() + if db == nil { + fmt.Fprintln(stderr, "could not connect to the database configured in .env") + return 1 + } + a := auth.New(db, Version) + who, password, err := a.ResetPassword(email) + if errors.Is(err, auth.ErrNoPasswordUser) { + fmt.Fprintln(stderr, "there is no password login for that email on this site") + return 1 + } + if err != nil { + fmt.Fprintf(stderr, "could not reset the password: %v\n", err) + return 1 + } + fmt.Fprintf(stdout, "New password for %s: %s\n", who, password) + return 0 +} diff --git a/go.mod b/go.mod index 41f590d3..f949d033 100644 --- a/go.mod +++ b/go.mod @@ -23,6 +23,7 @@ require ( github.com/tetratelabs/wazero v1.12.0 github.com/traefik/yaegi v0.16.1 github.com/yuin/goldmark v1.8.6 + golang.org/x/crypto v0.56.0 gorm.io/driver/postgres v1.6.3 ) @@ -71,7 +72,6 @@ require ( go.opentelemetry.io/proto/otlp v1.3.1 // indirect go.yaml.in/yaml/v3 v3.0.5 // indirect golang.org/x/arch v0.29.0 // indirect - golang.org/x/crypto v0.56.0 // indirect golang.org/x/net v0.58.0 // indirect golang.org/x/sync v0.22.0 // indirect golang.org/x/sys v0.47.0 // indirect diff --git a/goblog.go b/goblog.go index 2b8d834e..b7ebb9f1 100644 --- a/goblog.go +++ b/goblog.go @@ -89,10 +89,104 @@ func attemptConnectDb() *gorm.DB { } // depending on if the env file is present or not, we will show the wizard or the main site +// installed reports whether the install is finished: GitHub login is +// configured in .env, or the site has an admin (a password admin needs +// nothing in .env). +func (g *goblog) installed() bool { + return isAuthConfigured() || (!g._wizard.IsDbNil() && g._auth.AdminExists()) +} + +// wizardPage renders one of the install wizard's pages or, for a browser +// that has not entered the setup code, the page that asks for it. +func (g *goblog) wizardPage(c *gin.Context, name string, data gin.H) { + if !auth.SetupUnlocked(c) { + g.unlockPage(c, "") + return + } + c.HTML(http.StatusOK, name, data) +} + +func (g *goblog) unlockPage(c *gin.Context, errors string) { + c.HTML(http.StatusOK, "wizard_unlock.html", gin.H{ + "version": Version, + "title": "GoBlog Install Wizard", + "errors": errors, + }) +} + +// wizardLanding is wizardPage for the settings and auth steps, which the +// wizard package renders. +func (g *goblog) wizardLanding(c *gin.Context) { + if !auth.SetupUnlocked(c) { + g.unlockPage(c, "") + return + } + g._wizard.Landing(c) +} + +// unlockSetup handles the setup code form (POST /wizard/unlock). +func (g *goblog) unlockSetup(c *gin.Context) { + if err := auth.UnlockSetup(c, c.PostForm("setup_code")); err != nil { + g.unlockPage(c, err.Error()) + return + } + c.Redirect(http.StatusSeeOther, "/") +} + +// setupOnly lets a wizard request through only from a browser that has +// entered the setup code. +func (g *goblog) setupOnly(c *gin.Context) { + if !auth.SetupUnlocked(c) { + c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "Enter the setup code first"}) + } +} + +// createAdmin handles the wizard's last step (POST /wizard/admin): the +// site's first admin, with a password, which needs no GitHub OAuth app +// (#654). It is the end of the install: the browser is logged in and sent +// to the admin dashboard. +func (g *goblog) createAdmin(c *gin.Context) { + fail := func(msg string) { + g.wizardPage(c, "wizard_auth.html", gin.H{ + "version": Version, + "title": "GoBlog Install Wizard", + "errors": msg, + "email": c.PostForm("email"), + }) + } + if g._wizard.IsDbNil() { + c.Redirect(http.StatusSeeOther, "/") + return + } + if g._auth.AdminExists() { + c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "This site is already installed"}) + return + } + if !auth.SetupUnlocked(c) { + g.unlockPage(c, "") + return + } + if c.PostForm("password") != c.PostForm("password_confirm") { + fail("The two passwords do not match.") + return + } + user, err := g._auth.CreatePasswordAdmin(c.PostForm("email"), c.PostForm("password")) + if err != nil { + fail("Couldn't create the admin account: " + err.Error()) + return + } + if err := g._auth.StartSession(c, user); err != nil { + log.Println("Couldn't save the session: " + err.Error()) + } + auth.ClearSetupCode() + g.addRoutes() + c.Redirect(http.StatusSeeOther, "/admin") +} + func (g *goblog) rootHandler(c *gin.Context) { if !envFilePresent() { log.Println("Root handler: No .env file found") - c.HTML(http.StatusOK, "wizard_db.html", gin.H{ + g.wizardPage(c, "wizard_db.html", gin.H{ "version": Version, "title": "GoBlog Install Wizard", }) @@ -101,7 +195,7 @@ func (g *goblog) rootHandler(c *gin.Context) { log.Println("Root handler: Found .env file") envFile, err := godotenv.Read(datadir.Path(".env")) if err != nil { - c.HTML(http.StatusOK, "wizard_db.html", gin.H{ + g.wizardPage(c, "wizard_db.html", gin.H{ "version": Version, "title": "GoBlog Install Wizard", "errors": "Couldn't read the .env file: " + err.Error(), @@ -111,7 +205,7 @@ func (g *goblog) rootHandler(c *gin.Context) { // detect if the database hasn't be configured yet if !validDatabaseType(envFile["database"]) { log.Println("Root handler: Database is not configured, redirecting to db wizard") - c.HTML(http.StatusOK, "wizard_db.html", gin.H{ + g.wizardPage(c, "wizard_db.html", gin.H{ "version": Version, "title": "GoBlog Install Wizard", }) @@ -124,7 +218,7 @@ func (g *goblog) rootHandler(c *gin.Context) { if db == nil { log.Println("Root handler: Couldn't connect to the database, showing db wizard") // show the wizard and get them to re-enter the db info with an error message - c.HTML(http.StatusOK, "wizard_db.html", gin.H{ + g.wizardPage(c, "wizard_db.html", gin.H{ "version": Version, "title": "GoBlog Install Wizard", "errors": "Couldn't connect to the database", @@ -135,7 +229,7 @@ func (g *goblog) rootHandler(c *gin.Context) { if err != nil { log.Println("Root handler: Couldn't migrate the database: " + err.Error()) // show the wizard with an error message saying the db isn't compatible and let them file a gh ticket - c.HTML(http.StatusOK, "wizard_db.html", gin.H{ + g.wizardPage(c, "wizard_db.html", gin.H{ "version": Version, "title": "GoBlog Install Wizard", "errors": "Failed to Migrate the database: " + err.Error(), @@ -153,15 +247,15 @@ func (g *goblog) rootHandler(c *gin.Context) { } g._registry.StartScheduledJobs() - if !isAuthConfigured() { - g._wizard.Landing(c) + if !g.installed() { + g.wizardLanding(c) return } g.addRoutes() g._blog.Home(c) } else { - if !isAuthConfigured() { - g._wizard.Landing(c) + if !g.installed() { + g.wizardLanding(c) return } g._blog.Home(c) @@ -173,7 +267,7 @@ func (g *goblog) rootHandler(c *gin.Context) { func (g *goblog) loginHandler(c *gin.Context) { if !envFilePresent() { log.Println("Root handler: No .env file found") - c.HTML(http.StatusOK, "wizard_db.html", gin.H{ + g.wizardPage(c, "wizard_db.html", gin.H{ "version": Version, "title": "GoBlog Install Wizard", }) @@ -181,23 +275,28 @@ func (g *goblog) loginHandler(c *gin.Context) { } if g._wizard.IsDbNil() { log.Println("Wizard db is nil in loginHandler") - c.HTML(http.StatusOK, "wizard_db.html", gin.H{ + g.wizardPage(c, "wizard_db.html", gin.H{ "version": Version, "title": "GoBlog Install Wizard", "errors": "Database is not configured", }) return } - if !isAuthConfigured() { + if !g.installed() { + if !auth.SetupUnlocked(c) { + g.wizardPage(c, "wizard_auth.html", nil) // asks for the setup code + return + } err := g._wizard.LoginCode(c) if err != nil { - c.HTML(http.StatusOK, "wizard_auth.html", gin.H{ + g.wizardPage(c, "wizard_auth.html", gin.H{ "version": Version, "title": "GoBlog Install Wizard", "errors": "Couldn't get the login code: " + err.Error(), }) return } else { + auth.ClearSetupCode() // the wizard just created the admin g.addRoutes() g._blog.Home(c) } @@ -216,6 +315,9 @@ func main() { if len(os.Args) > 1 && os.Args[1] == "validate-plugin" { os.Exit(runValidatePlugin(os.Args[2:], os.Stdout, os.Stderr)) } + if len(os.Args) > 1 && os.Args[1] == "reset-admin-password" { + os.Exit(runResetAdminPassword(os.Args[2:], os.Stdout, os.Stderr)) + } log.Println("Starting blog version: ", Version) if dir := datadir.Dir(); dir != "" { log.Println("Data directory: " + dir) @@ -262,9 +364,9 @@ func main() { log.Println("Couldn't close the .env file: " + err.Error()) return } - log.Println("New Session key: ", sessionKey) + log.Println("Created a new session key") } else { - log.Println("Found Session key: ", sessionKey) + log.Println("Found the session key") } // database is configured, lets try to connect now @@ -296,6 +398,19 @@ func main() { log.Println("SMTP configured; email login enabled") _auth.Mailer = sender } + // No admin yet means the install wizard is (or will be) open, and it + // asks for this code: reading the log is the proof of owning the server. + if db == nil || !_auth.AdminExists() { + code, err := auth.NewSetupCode() + if err != nil { + log.Println("Couldn't make a setup code: " + err.Error()) + return + } + log.Println("==========================================================") + log.Println(" GoBlog setup code: " + code) + log.Println(" The install wizard asks for it. It changes on restart.") + log.Println("==========================================================") + } _blog := blog.New(db, &_auth, Version) _admin := admin.New(db, &_auth, &_blog, Version) _wizard := wizard.New(db, Version) @@ -367,7 +482,6 @@ func main() { store.Options(sessionOptions(os.Getenv("SESSION_SECURE"))) hostname, err := os.Hostname() router.Use(sessions.Sessions(hostname, store)) - log.Println("Session key: ", sessionKey) log.Println("Hostname: ", hostname) // Load templates from the active theme directory, falling back to "default". // activeTheme is read by the static handler on every /theme/* request and @@ -450,7 +564,9 @@ func main() { // Starting GitHub's OAuth flow belongs here rather than in each theme's // inline script, where the redirect_uri escaping was wrong (#631). router.GET("/login/github", goblog._blog.GithubLogin) - router.GET("/wizard", goblog._wizard.SaveToken) + router.GET("/wizard", goblog.setupOnly, goblog._wizard.SaveToken) + router.POST("/wizard/unlock", goblog.unlockSetup) + router.POST("/wizard/admin", goblog.createAdmin) router.POST("/wizard_db", goblog.installOnly, updateDB) router.POST("/test_db", goblog.installOnly, testDB) router.POST("/api/v1/upload", goblog._admin.UploadFile) @@ -515,6 +631,9 @@ func (g *goblog) addRoutesInner() { // itself, against a state it minted (#637). g.router.POST("/api/login/email", g._auth.SendLoginCodeHandler) g.router.POST("/api/login/email/verify", g._auth.VerifyLoginCodeHandler) + g.router.POST("/login/password", func(c *gin.Context) { + g._auth.PasswordLogin(c, blog.SafeNext(c.PostForm("next"))) + }) g.router.POST("/api/v1/posts", g._admin.CreatePost) g.router.PATCH("/api/v1/posts", g._admin.UpdatePost) g.router.PATCH("/api/v1/publish/:id", g._admin.PublishPost) @@ -642,15 +761,17 @@ func requireJSON() gin.HandlerFunc { } // installOnly refuses the database wizard's endpoints once the site is -// installed, that is, once it has a database and an admin. They take no -// credentials because during an install there is nobody to hold any, so -// without this they stay open for good: /wizard_db rewrites .env with +// installed, that is, once it has a database and an admin; until then it +// asks for the setup code. The endpoints take no login because during an +// install there is nobody to hold one: /wizard_db rewrites .env with // whatever database the request names, and /test_db opens any file or host // it is given. func (g *goblog) installOnly(c *gin.Context) { - if !g._wizard.IsDbNil() && !g._auth.IsWizardMode(c) { + if !g._wizard.IsDbNil() && g._auth.AdminExists() { c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "This site is already installed"}) + return } + g.setupOnly(c) } // parse the form which should have passed the db info diff --git a/plugins/docs/content/writing-a-theme.md b/plugins/docs/content/writing-a-theme.md index f34ca42e..c8d0daa9 100644 --- a/plugins/docs/content/writing-a-theme.md +++ b/plugins/docs/content/writing-a-theme.md @@ -86,7 +86,7 @@ Beyond those, each template gets its own data. This is the contract of the runni | `page_content.html` | a custom page, and every plugin page (`/docs`, `/plugins`, …) | `page` (`.HTML` is the rendered body; `.Content` is raw markdown); a plugin page adds what the plugin returns, normally `has_plugin_content` and `plugin_content`, and may replace `title` | | `search.html` | `/search` | `query`, `results` (posts first, then plugin hits; each has `.Title`, `.URL`, `.Summary`, and `.Kind` — `""` for a post — plus `.Date` and `.Tags` for posts), `result_count`; render them with `{{ template "_search_results" . }}`. `posts` and `plugin_results` remain for a theme that renders the list itself. | | `tag.html` | `/tag/` | `posts`, `tag` | -| `login.html` | `/login` | `client_id`, `next`, `email_login_enabled` | +| `login.html` | `/login` | `client_id`, `next`, `email_login_enabled`, `password_login_enabled`, `login_error`; when `password_login_enabled` is set, include the shared form with `{{ template "_password_login" . }}`. If a password is the only way to sign in, or the URL is `/login?password=1`, goblog renders its own `_password_login_page` instead of this template, so a theme that does not include the form cannot lock the admin out. | | `error.html` | 404s, a disabled or uninstalled plugin's page, unauthorized | `error`, `description` | | `admin*.html` | `/admin/…` | per page: `posts`, `post_types`, `pages`, `comments`, `users`, `themes`, `setting_groups`, `plugin`, … — read `admin/admin.go` and `admin/plugins.go` before overriding one | | `wizard_*.html` | the install wizard, before there is a database | only `version` and `title` (and `errors`) — none of the common keys exist yet | diff --git a/plugins/docs/content_test.go b/plugins/docs/content_test.go index 089a6687..b199b463 100644 --- a/plugins/docs/content_test.go +++ b/plugins/docs/content_test.go @@ -133,6 +133,9 @@ func knownIdentifiers() map[string]bool { "plugin_directory_url", "theme_directory_url", "refresh_minutes", "github_token", "site_url", "site_description", "site_image", "custom_header_code", "custom_footer_code", "show_powered_by", + // Template data keys for login.html (blog/blog.go Login). + "password_login_enabled", "login_error", + // comments_require_login is both a template data key (blog/blog.go // post.html render calls) and the setting blog.CommentsRequireLogin // reads (blog/blog.go, blog/comment.go). diff --git a/scripts/install-smoke-test.sh b/scripts/install-smoke-test.sh index 8cbfab89..c35ecb98 100755 --- a/scripts/install-smoke-test.sh +++ b/scripts/install-smoke-test.sh @@ -14,10 +14,9 @@ # GOBLOG_SMOKE_LEGACY=1 there is no data directory and the container is # restarted instead: the layout of installs that predate GOBLOG_DATA_DIR. # -# What it cannot do is the wizard's last step, authorising a GitHub OAuth -# app. It writes placeholder client_id/client_secret into the container's -# .env instead, so GitHub's callback and the first admin login are not -# covered. +# It finishes the install the way the wizard offers first: an admin account +# with a password. The other way, authorising a GitHub OAuth app, cannot run +# in CI and is not covered. set -euo pipefail DB=${1:-} @@ -35,10 +34,8 @@ DBC="goblog-smoke-$DB-db" VOL="goblog-smoke-$DB-data" LEGACY=${GOBLOG_SMOKE_LEGACY:-} if [ -n "$LEGACY" ]; then - ENV_FILE=/go/src/github.com/compscidr/goblog/.env run_args=() else - ENV_FILE=/data/.env run_args=(-e GOBLOG_DATA_DIR=/data -v "$VOL:/data") fi TITLE="Smoke Test Blog" @@ -123,73 +120,145 @@ esac step "starting goblog ($DB${LEGACY:+, no data directory})" start_app -step "a fresh install shows the database wizard" +# The session cookie is Secure, which curl will not send over plain http, so +# it is carried by hand. login stores whatever cookies the last response set. +COOKIE="" +keep_cookies() { + set_cookies=$(grep -i '^set-cookie:' "$TMP/headers" | sed -E 's/^[^:]*: *([^;]*).*/\1/' | paste -sd ';' - | sed 's/;/; /g' || true) + [ -z "$set_cookies" ] || COOKIE=$set_cookies +} +# as_owner is request with the session cookie; the response's cookies are kept. +as_owner() { + desc=$1 want=$2; shift 2 + request "$desc" "$want" -D "$TMP/headers" -H "Cookie: $COOKIE" "$@" + keep_cookies +} +header_has() { grep -qiF -- "$1" "$TMP/headers" || { cat "$TMP/headers" >&2; fail "$2: response headers do not contain '$1'"; }; } + +ADMIN_EMAIL=admin@example.com +ADMIN_PASSWORD="smoke test password" +SETTINGS_JSON="[{\"key\":\"site_title\",\"value\":\"$TITLE\",\"type\":\"text\"},{\"key\":\"site_subtitle\",\"value\":\"installed by the smoke test\",\"type\":\"text\"}]" + +step "a fresh install asks for the setup code" request "GET /" 200 "$BASE/" body_has "Install Wizard" "GET /" -body_has 'name="dbtype"' "GET /" +body_has 'name="setup_code"' "GET /" +body_lacks 'name="dbtype"' "GET /" +request "POST /test_db without the setup code" 403 -X POST "$BASE/test_db" "${form[@]}" +request "POST /wizard_db without the setup code" 403 -X POST "$BASE/wizard_db" "${form[@]}" +request "POST /wizard/unlock, wrong code" 200 -X POST "$BASE/wizard/unlock" -d setup_code=AAAA-AAAA-AAAA +body_has "not the setup code" "POST /wizard/unlock, wrong code" + +step "entering the setup code from the log" +SETUP_CODE=$(docker logs "$APP" 2>&1 | sed -n 's/.*GoBlog setup code: \([A-Z0-9-]*\).*/\1/p' | tail -1) +[ -n "$SETUP_CODE" ] || fail "no setup code in the log" +as_owner "POST /wizard/unlock" 303 -X POST "$BASE/wizard/unlock" -d "setup_code=$SETUP_CODE" +[ -n "$COOKIE" ] || fail "POST /wizard/unlock set no cookie" +as_owner "GET /" 200 "$BASE/" +body_has 'name="dbtype"' "GET / with the setup code" step "Test Database" -request "POST /test_db, no database type" 400 -X POST "$BASE/test_db" -d dbtype= +as_owner "POST /test_db, no database type" 400 -X POST "$BASE/test_db" -d dbtype= if [ "$DB" != sqlite ]; then - request "POST /test_db, wrong password" 400 -X POST "$BASE/test_db" "${badform[@]}" + as_owner "POST /test_db, wrong password" 400 -X POST "$BASE/test_db" "${badform[@]}" fi -request "POST /test_db" 200 -X POST "$BASE/test_db" "${form[@]}" +as_owner "POST /test_db" 200 -X POST "$BASE/test_db" "${form[@]}" body_has success "POST /test_db" step "saving the database step connects and migrates" -request "POST /wizard_db" 303 -X POST "$BASE/wizard_db" "${form[@]}" -request "GET / after the database step" 200 "$BASE/" +as_owner "POST /wizard_db" 303 -X POST "$BASE/wizard_db" "${form[@]}" +as_owner "GET / after the database step" 200 "$BASE/" body_has 'id="settings-form"' "GET / after the database step" step "settings step" -request "PATCH /api/v1/settings" 202 -X PATCH "$BASE/api/v1/settings" -H 'Content-Type: application/json' \ - -d "[{\"key\":\"site_title\",\"value\":\"$TITLE\",\"type\":\"text\"},{\"key\":\"site_subtitle\",\"value\":\"installed by the smoke test\",\"type\":\"text\"}]" +request "PATCH /api/v1/settings without the setup code" 401 -X PATCH "$BASE/api/v1/settings" -H 'Content-Type: application/json' -d "$SETTINGS_JSON" +as_owner "PATCH /api/v1/settings" 202 -X PATCH "$BASE/api/v1/settings" -H 'Content-Type: application/json' -d "$SETTINGS_JSON" echo "smoke test upload" >"$TMP/smoke-upload.txt" -request "POST /api/v1/upload" 200 -X POST "$BASE/api/v1/upload" -F "file=@$TMP/smoke-upload.txt" +request "POST /api/v1/upload without the setup code" 401 -X POST "$BASE/api/v1/upload" -F "file=@$TMP/smoke-upload.txt" +as_owner "POST /api/v1/upload" 200 -X POST "$BASE/api/v1/upload" -F "file=@$TMP/smoke-upload.txt" body_has "/uploads/smoke-upload.txt" "POST /api/v1/upload" -request "GET /?page=auth" 200 "$BASE/?page=auth" -body_has 'name="client_id"' "GET /?page=auth" -# The real last step is GitHub redirecting back to /login, whose handler -# stores the credentials and registers the site's routes. With placeholders -# there is no callback, so the routes arrive with the next start instead. -step "auth step (placeholder GitHub credentials written to .env)" -docker exec "$APP" sh -c "printf 'client_id=smoke\nclient_secret=smoke\n' >> $ENV_FILE" +step "admin account step" +as_owner "GET /?page=auth" 200 "$BASE/?page=auth" +body_has 'action="/wizard/admin"' "GET /?page=auth" +request "POST /wizard/admin without the setup code" 200 -X POST "$BASE/wizard/admin" \ + --data-urlencode "email=$ADMIN_EMAIL" --data-urlencode "password=$ADMIN_PASSWORD" --data-urlencode "password_confirm=$ADMIN_PASSWORD" +body_has 'name="setup_code"' "POST /wizard/admin without the setup code" +as_owner "POST /wizard/admin, passwords differ" 200 -X POST "$BASE/wizard/admin" \ + --data-urlencode "email=$ADMIN_EMAIL" --data-urlencode "password=$ADMIN_PASSWORD" --data-urlencode "password_confirm=something else" +body_has "do not match" "POST /wizard/admin, passwords differ" +as_owner "POST /wizard/admin, short password" 200 -X POST "$BASE/wizard/admin" \ + --data-urlencode "email=$ADMIN_EMAIL" --data-urlencode "password=short" --data-urlencode "password_confirm=short" +body_has "at least 10 characters" "POST /wizard/admin, short password" +as_owner "POST /wizard/admin" 303 -X POST "$BASE/wizard/admin" \ + --data-urlencode "email=$ADMIN_EMAIL" --data-urlencode "password=$ADMIN_PASSWORD" --data-urlencode "password_confirm=$ADMIN_PASSWORD" +header_has "location: /admin" "POST /wizard/admin" -if [ -n "$LEGACY" ]; then - step "restarting the container" - docker restart "$APP" >/dev/null - wait_for "goblog after restart" 60 app_up -else - # Everything the install wrote has to be on the volume: .env, the SQLite - # file, the upload. - step "replacing the container, keeping only the data volume" - docker logs "$APP" >"$TMP/first.log" 2>&1 - docker rm -f "$APP" >/dev/null - start_app -fi +step "the wizard leaves the browser logged in as the admin, and is now closed" +as_owner "GET /admin/dashboard" 200 "$BASE/admin/dashboard" +as_owner "POST /wizard_db after install" 403 -X POST "$BASE/wizard_db" "${form[@]}" +as_owner "POST /wizard/admin after install" 403 -X POST "$BASE/wizard/admin" \ + --data-urlencode "email=other@example.com" --data-urlencode "password=$ADMIN_PASSWORD" --data-urlencode "password_confirm=$ADMIN_PASSWORD" check_site() { request "GET /" 200 "$BASE/" body_has "$TITLE" "GET /" body_lacks "Install Wizard" "GET /" - for path in /login /search?q=goblog /robots.txt /rss.xml /sitemap.xml /theme/css/goblog.css; do + for path in /search?q=goblog /robots.txt /rss.xml /sitemap.xml /theme/css/goblog.css; do request "GET $path" 200 "$BASE$path" done + request "GET /login" 200 "$BASE/login" + body_has 'action="/login/password"' "GET /login" request "GET /sitemap.xml" 200 "$BASE/sitemap.xml" body_has "/dev/null + wait_for "goblog after restart" 60 app_up +else + # Everything the install wrote has to be on the volume: .env, the SQLite + # file, the upload. + step "replacing the container, keeping only the data volume" + docker logs "$APP" >"$TMP/first.log" 2>&1 + docker rm -f "$APP" >/dev/null + start_app +fi +check_site + +step "signing in with the password" +login "not the password" "/login?password=1&login_error=invalid" +as_owner "GET /admin/dashboard after a failed login" 302 "$BASE/admin/dashboard" +login "$ADMIN_PASSWORD" "/admin" +as_owner "GET /admin/dashboard" 200 "$BASE/admin/dashboard" + +step "goblog reset-admin-password" +NEW_PASSWORD=$(docker exec "$APP" ./goblog reset-admin-password 2>/dev/null | sed -n "s/^New password for $ADMIN_EMAIL: //p") +[ -n "$NEW_PASSWORD" ] || fail "reset-admin-password printed no password" +as_owner "GET /admin/dashboard with the session from before the reset" 302 "$BASE/admin/dashboard" +login "$ADMIN_PASSWORD" "/login?password=1&login_error=invalid" +login "$NEW_PASSWORD" "/admin" +as_owner "GET /admin/dashboard" 200 "$BASE/admin/dashboard" + # A query the database rejects is logged but often still answers 200 (a # setting silently reads as its default), so the log is part of the result. -# The wrong-password check above is the one failure that is meant to be there. +# The wrong database password above is the one failure that is meant to be there. step "no panics or SQL errors in the log" if { cat "$TMP/first.log" 2>/dev/null; docker logs "$APP" 2>&1; } | grep -v -e "Couldn't connect to the database" -e "failed to initialize database" \ | grep -E "panic|Error [0-9]{4} \(|SQLSTATE|syntax error|SQL logic error|no such (table|column)" >"$TMP/errors"; then diff --git a/templates/shared/_password_login.html b/templates/shared/_password_login.html new file mode 100644 index 00000000..8924022e --- /dev/null +++ b/templates/shared/_password_login.html @@ -0,0 +1,45 @@ +{{/* +_password_login renders the email-and-password sign-in form, with the +message for a failed attempt (.login_error). It posts to /login/password, +which redirects to .next. A theme's login.html includes it with +{{ if .password_login_enabled }}{{ template "_password_login" . }}{{ end }}. + +_password_login_page is a whole login page around that form, in the theme's +header and footer. goblog renders it instead of login.html when a password +is the only way to sign in, so a site whose theme's login.html predates +password login cannot lock its admin out. +*/}} +{{ define "_password_login" }} + +{{ end }} + +{{ define "_password_login_page" }} +{{ template "header.html" . }} +
+
+

Site Login

+
 
+ {{ template "_password_login" . }} +
+
+{{ template "footer.html" . }} +{{ end }} diff --git a/themes/default/templates/login.html b/themes/default/templates/login.html index c8224ce8..1f6f1039 100644 --- a/themes/default/templates/login.html +++ b/themes/default/templates/login.html @@ -56,6 +56,12 @@

Site Login

Sign in with GitHub + {{ if .password_login_enabled }} +
 
+

or sign in with a password

+ {{ template "_password_login" . }} + {{ end }} + {{ if .email_login_enabled }}
 

or sign in with email

diff --git a/themes/default/templates/wizard_auth.html b/themes/default/templates/wizard_auth.html index d22ab1aa..bfa4609b 100644 --- a/themes/default/templates/wizard_auth.html +++ b/themes/default/templates/wizard_auth.html @@ -13,15 +13,36 @@

Goblog Install Wizard

-
GitHub OAuth
+
Create your admin account
{{ if .errors }} {{ end }} -
-

You must go to https://github.com/settings/developers - and create a "New OAuth App" to hook this site up to github for logins. +

This is the account you will sign in with to write posts and manage the site.

+ +
+ + +
+
+ + +
+
+ + +
+ +
+

The email is only your sign-in name; goblog does not send mail to it. If you forget the password, run goblog reset-admin-password on the server.

+ +
+
+ Or sign in with GitHub instead +
+

Go to https://github.com/settings/developers + and create a "New OAuth App" to hook this site up to GitHub for logins.

Set the callback url to the same URL as this page with /login appended to it.

@@ -34,14 +55,13 @@
GitHub OAuth
- -
Create Admin Account
-

Once you setup the OAuth app, login with GitHub to test it out. The user you login with will be the - admin user for this site.

+

The GitHub user you sign in with becomes the admin of this site.

+ +
{{ template "_powered_by" . }}
diff --git a/themes/default/templates/wizard_unlock.html b/themes/default/templates/wizard_unlock.html new file mode 100644 index 00000000..20b9972a --- /dev/null +++ b/themes/default/templates/wizard_unlock.html @@ -0,0 +1,38 @@ + + + Goblog Install Wizard + + + + + + + + + +
+
+
+

Goblog Install Wizard

+
Setup code
+ {{ if .errors }} + + {{ end }} +

To make sure this site is being set up by whoever runs the server, goblog printed a setup code to its log when it started. Look for a line like GoBlog setup code: ABCD-EFGH-JKMN.

+

With Docker: docker logs <container> 2>&1 | grep "setup code"

+
+
+ + +
+ +
+

The code changes every time goblog restarts. If this page comes back after you enter the right code, your browser is not keeping the session cookie: goblog marks it Secure, which browsers only accept over https or on localhost. Use one of those, or start goblog with SESSION_SECURE=false.

+
{{ template "_powered_by" . }}
+
+
+
+ + diff --git a/wizard_install_only_test.go b/wizard_install_only_test.go index 634c3f23..25b29ea6 100644 --- a/wizard_install_only_test.go +++ b/wizard_install_only_test.go @@ -11,6 +11,8 @@ import ( "goblog/tools" "goblog/wizard" + "github.com/gin-contrib/sessions" + "github.com/gin-contrib/sessions/cookie" "github.com/gin-gonic/gin" "gorm.io/driver/sqlite" "gorm.io/gorm" @@ -18,21 +20,45 @@ import ( // TestInstallOnly: the database wizard's endpoints are for installing. On a // site that has a database and an admin, a request to them must change -// nothing; before that (no database yet, or a database with no admin) the -// wizard still has to work. +// nothing. Before that (no database yet, or a database with no admin) the +// wizard has to work, but only for a browser that entered the setup code. func TestInstallOnly(t *testing.T) { gin.SetMode(gin.TestMode) - post := func(g *goblog, path string) *httptest.ResponseRecorder { + code, err := auth.NewSetupCode() + if err != nil { + t.Fatal(err) + } + t.Cleanup(auth.ClearSetupCode) + // post sends the wizard's database form, first entering the setup code + // when unlock is true. + postAs := func(g *goblog, path string, unlock bool) *httptest.ResponseRecorder { router := gin.New() + router.Use(sessions.Sessions("session", cookie.NewStore([]byte("test")))) router.SetHTMLTemplate(templateWithErrors(t)) + router.POST("/wizard/unlock", g.unlockSetup) router.POST("/wizard_db", g.installOnly, updateDB) router.POST("/test_db", g.installOnly, testDB) - req := httptest.NewRequest(http.MethodPost, path, strings.NewReader("dbtype=sqlite&sqlite_file=other.db")) - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + form := func(path, body string) *http.Request { + req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(body)) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + return req + } + req := form(path, "dbtype=sqlite&sqlite_file=other.db") + if unlock { + w := httptest.NewRecorder() + router.ServeHTTP(w, form("/wizard/unlock", "setup_code="+code)) + if w.Code != http.StatusSeeOther { + t.Fatalf("entering the setup code: status %d, want 303", w.Code) + } + for _, c := range w.Result().Cookies() { + req.AddCookie(c) + } + } w := httptest.NewRecorder() router.ServeHTTP(w, req) return w } + post := func(g *goblog, path string) *httptest.ResponseRecorder { return postAs(g, path, true) } app := func(db *gorm.DB) *goblog { a := auth.New(db, "test") wz := wizard.New(db, "test") @@ -93,4 +119,22 @@ func TestInstallOnly(t *testing.T) { t.Errorf("POST /wizard_db mid-install: status %d, want 303", w.Code) } }) + + // Somebody else who finds the site before its owner has finished. + t.Run("without the setup code", func(t *testing.T) { + t.Chdir(t.TempDir()) + for name, g := range map[string]*goblog{"no database yet": app(nil), "mid-install": app(openDB())} { + for _, path := range []string{"/wizard_db", "/test_db"} { + if w := postAs(g, path, false); w.Code != http.StatusForbidden { + t.Errorf("%s: POST %s without the setup code: status %d, want 403", name, path, w.Code) + } + } + } + if _, err := os.Stat(".env"); err == nil { + t.Error(".env was written without the setup code") + } + if _, err := os.Stat("other.db"); err == nil { + t.Error("/test_db created a file without the setup code") + } + }) }