Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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 <container> ./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.
Expand Down
21 changes: 18 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <container> 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
Expand Down Expand Up @@ -93,16 +97,27 @@ 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 <container> ./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
```
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.

Expand All @@ -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.

Expand Down
24 changes: 16 additions & 8 deletions auth/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down Expand Up @@ -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
Expand Down
12 changes: 9 additions & 3 deletions auth/auth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
}

Expand Down
7 changes: 7 additions & 0 deletions auth/export_test.go
Original file line number Diff line number Diff line change
@@ -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) }
21 changes: 14 additions & 7 deletions auth/otp.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
Loading
Loading