Agreed by the owner on 2026-10-13 ("the e-mail must be confirmed");
notifications to an unconfirmed address settled on 2026-10-17: "Без
подтверждения слать". Mockups 2f5f5b2, route guard b7bbb2c, panel guard
0774da2.
Migration 0013: users.email_confirmed_at ('' for every address saved
before it) and user_links - the SHA-256 of a link's token, its user,
purpose (confirm, reset), address, created and expiry. A changed address
loses its mark (keepConfirmed, in UpdateUser and SaveUser).
internal/web/auth/links.go. A link is 256 bits from crypto/rand, mailed as
https://SELFPOST_HOSTNAME/... - never the request's Host. Confirmation:
24 h, sent when an address is saved (Account, or the global role's user
form) and by Account's Send the link again; its page /confirm/<token> has
a button (a GET changes nothing) and confirms only for a session of its
own account. Reset: /login/reset asks for a username and answers the same
for every name (the sign-in page, "If that account has a confirmed e-mail,
the link is on its way"), mailing after the answer; only a confirmed
address gets one, none within ten minutes of the last, five links an hour
per account, five requests in 15 minutes per client address;
/login/reset/<token> (30 min) sets the password with the link taken in
the same transaction that checks the address again
(store.TakeResetLink), then ends every session. Changing the e-mail on
Account asks for the current password. A password set on Account or the
user form removes the reset links; a restore removes every link; the stop
waits for links being mailed (Server.Drain); a rejected request's log line
hides a link's token (loggedPath).
Reviewed for security before the commit (2026-10-17): no high; two
medium and five low, all fixed here - confirming needs the account's
session and the mail does not name the account (a mistyped address was a
takeover); a reset request neither replaces the links already sent nor
spends the account's quota inside ten minutes; take-and-set atomic; the
confirmation mail sent after the answer; the stop waits for mail; a
restore deletes the links; the token kept out of the log.
Tests: store TestUserLinks, TestUserLinkTakenOnce, TestEmailConfirmation,
TestTakeResetLink; auth TestResetSendsALinkOnlyToAConfirmedAddress,
TestResetLinkSetsThePasswordOnce, TestResetLinkDiesWithTheAddress,
TestResetRequestsAreLimited, TestResetRequestsAreSpaced, TestConfirmLink,
TestLinkPathsWithoutAToken; handlers TestAccountEmailIsConfirmedByItsLink,
TestUserFormSendsANewAddressItsLink; web TestLoggedPathHidesTokens; the
view's pages and fixtures; e2e password_reset_by_e_mail runs it through
the sink (and checks a link from before the restore is dead). go vet +
go test (26 packages) and make e2e on the test server for this tree.
Docs: guide § Account, security.md (the bullet and what ends sessions),
architecture (routes), schema-migrations (row 13, users, user_links),
help #account (word for word with the mockup), roadmap row done,
CHANGELOG [Unreleased].
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
272 lines
8.4 KiB
Go
272 lines
8.4 KiB
Go
package auth
|
|
|
|
import (
|
|
"errors"
|
|
"net/http"
|
|
"strings"
|
|
"sync"
|
|
|
|
"github.com/mixeme/selfpost/internal/store"
|
|
"github.com/mixeme/selfpost/internal/web/validate"
|
|
"github.com/mixeme/selfpost/internal/web/view"
|
|
"golang.org/x/crypto/bcrypt"
|
|
)
|
|
|
|
// draft-ietf-httpbis-rfc6265bis § 4.1.3.2: a cookie named __Host- is accepted
|
|
// only with Secure, Path=/ and no Domain, from a secure origin - so no other
|
|
// host can set or overwrite it, and clearing it needs Secure as well.
|
|
const (
|
|
sessionCookieBase = "selfpost_session"
|
|
sessionCookiePrefixed = "__Host-" + sessionCookieBase
|
|
)
|
|
|
|
// BcryptCost is the work factor for every panel password hash — the setup
|
|
// account created here and the ones the settings and user pages write
|
|
// (security.md). bcrypt records the cost inside the hash, so raising it only
|
|
// affects passwords set from now on; existing hashes keep verifying at the
|
|
// cost they were written with.
|
|
const BcryptCost = 12
|
|
|
|
func (m *Module) sessionCookie() string {
|
|
if m.cfg.CookieSecure {
|
|
return sessionCookiePrefixed
|
|
}
|
|
return sessionCookieBase
|
|
}
|
|
|
|
func (m *Module) sessionToken(r *http.Request) (string, bool) {
|
|
name := m.sessionCookie()
|
|
var token string
|
|
var n int
|
|
for _, c := range r.Cookies() {
|
|
if c.Name == name {
|
|
n++
|
|
token = c.Value
|
|
}
|
|
}
|
|
switch n {
|
|
case 0:
|
|
return "", false
|
|
case 1:
|
|
return token, true
|
|
default:
|
|
// RFC 6265 § 5.4: the Cookie header carries every cookie that
|
|
// matches, a parent domain's of the same name included. The prefixed
|
|
// name cannot be planted that way (see sessionCookiePrefixed).
|
|
logf("panel: %s %s carries %d cookies named %q — treating the request as signed out; "+
|
|
"another host on this domain is overwriting the session cookie, clear the cookies for the parent domain",
|
|
r.Method, r.URL.Path, n, name)
|
|
return "", false
|
|
}
|
|
}
|
|
|
|
func (m *Module) clearSessionCookies(w http.ResponseWriter) {
|
|
for _, name := range []string{sessionCookieBase, sessionCookiePrefixed} {
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: name,
|
|
Value: "",
|
|
Path: "/",
|
|
MaxAge: -1,
|
|
HttpOnly: true,
|
|
Secure: m.cfg.CookieSecure || name == sessionCookiePrefixed,
|
|
SameSite: http.SameSiteLaxMode,
|
|
})
|
|
}
|
|
}
|
|
|
|
// HandleLogin serves the login form (GET) and authenticates (POST).
|
|
func (m *Module) HandleLogin(w http.ResponseWriter, r *http.Request) {
|
|
exists, err := m.store.UserExists()
|
|
if err != nil {
|
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if !exists {
|
|
m.view.Render(w, http.StatusOK, "login", view.NewLoginSetupHint(m.cfg.Hostname))
|
|
return
|
|
}
|
|
|
|
switch r.Method {
|
|
case http.MethodGet:
|
|
m.view.Render(w, http.StatusOK, "login", view.NewLogin(m.cfg.Hostname, "").WithInfo(loginInfo(r)))
|
|
case http.MethodPost:
|
|
m.submitLogin(w, r)
|
|
default:
|
|
w.Header().Set("Allow", "GET, POST")
|
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
}
|
|
}
|
|
|
|
func (m *Module) renderLogin(w http.ResponseWriter, status int, formErr string) {
|
|
m.view.Render(w, status, "login", view.NewLogin(m.cfg.Hostname, formErr))
|
|
}
|
|
|
|
func (m *Module) submitLogin(w http.ResponseWriter, r *http.Request) {
|
|
if !m.loginLimiter.Allow(clientIP(r, m.trustedProxies)) {
|
|
m.renderLogin(w, http.StatusTooManyRequests, "Too many attempts. Please wait and try again.")
|
|
return
|
|
}
|
|
if err := r.ParseForm(); err != nil {
|
|
m.renderLogin(w, http.StatusBadRequest, "Invalid form submission.")
|
|
return
|
|
}
|
|
username := strings.TrimSpace(r.PostFormValue("username"))
|
|
password := r.PostFormValue("password")
|
|
|
|
user, err := m.store.GetUserByUsername(username)
|
|
if err != nil {
|
|
if !errors.Is(err, store.ErrUserNotFound) {
|
|
logf("panel: login: get user failed: %v", err)
|
|
}
|
|
// A comparison anyway, so an unknown name is refused in the time
|
|
// a wrong password is: the answer's delay told which names exist
|
|
// (audit 2026-10-12, M8).
|
|
_ = bcrypt.CompareHashAndPassword(noSuchUserHash(), []byte(password))
|
|
m.renderLogin(w, http.StatusUnauthorized, "Invalid username or password.")
|
|
return
|
|
}
|
|
|
|
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)); err != nil {
|
|
m.renderLogin(w, http.StatusUnauthorized, "Invalid username or password.")
|
|
return
|
|
}
|
|
|
|
// The session the browser presents, if any, ends here: a sign-in issues
|
|
// a new token and leaves no other behind it - one planted before the
|
|
// sign-in is worth nothing after (audit 2026-10-12, L16).
|
|
name := m.sessionCookie()
|
|
for _, c := range r.Cookies() {
|
|
if c.Name == name {
|
|
m.sessions.Destroy(c.Value)
|
|
}
|
|
}
|
|
token, err := m.sessions.Create(user.ID)
|
|
if err != nil {
|
|
logf("panel: login: create session failed: %v", err)
|
|
m.renderLogin(w, http.StatusInternalServerError, "Internal error. Please try again.")
|
|
return
|
|
}
|
|
m.setSessionCookie(w, token)
|
|
http.Redirect(w, r, "/", http.StatusSeeOther)
|
|
}
|
|
|
|
// noSuchUserHash is a hash of the panel's work factor, compared against when
|
|
// a sign-in names no user.
|
|
var noSuchUserHash = sync.OnceValue(func() []byte {
|
|
h, err := bcrypt.GenerateFromPassword([]byte("selfpost: no such user"), BcryptCost)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
return h
|
|
})
|
|
|
|
// setSessionCookie sets the session cookie. SameSite=Lax withholds it from a
|
|
// cross-site request unless that is a top-level navigation with a safe method
|
|
// (rfc6265bis § 5.8.3, § 5.6.7.1), so a cross-site POST arrives without it.
|
|
func (m *Module) setSessionCookie(w http.ResponseWriter, token string) {
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: m.sessionCookie(),
|
|
Value: token,
|
|
Path: "/",
|
|
MaxAge: m.sessions.MaxAge(),
|
|
HttpOnly: true,
|
|
Secure: m.cfg.CookieSecure,
|
|
SameSite: http.SameSiteLaxMode,
|
|
})
|
|
}
|
|
|
|
// HandleLogout destroys the session and clears the cookie.
|
|
func (m *Module) HandleLogout(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
w.Header().Set("Allow", "POST")
|
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
name := m.sessionCookie()
|
|
for _, c := range r.Cookies() {
|
|
if c.Name == name {
|
|
m.sessions.Destroy(c.Value)
|
|
}
|
|
}
|
|
m.clearSessionCookies(w)
|
|
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
|
}
|
|
|
|
// HandleSetup serves the one-time administrator creation flow at
|
|
// /setup/<token> (security.md).
|
|
func (m *Module) HandleSetup(w http.ResponseWriter, r *http.Request) {
|
|
if !m.setupLimiter.Allow(clientIP(r, m.trustedProxies)) {
|
|
http.Error(w, "too many requests", http.StatusTooManyRequests)
|
|
return
|
|
}
|
|
|
|
token := strings.TrimPrefix(r.URL.Path, "/setup/")
|
|
if token == "" || strings.Contains(token, "/") {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
if !m.setup.validate(token) {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
|
|
switch r.Method {
|
|
case http.MethodGet:
|
|
m.renderSetupForm(w, http.StatusOK, token, "")
|
|
case http.MethodPost:
|
|
m.submitSetup(w, r, token)
|
|
default:
|
|
w.Header().Set("Allow", "GET, POST")
|
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
}
|
|
}
|
|
|
|
func (m *Module) renderSetupForm(w http.ResponseWriter, status int, token, formErr string) {
|
|
m.view.Render(w, status, "setup", view.NewSetup(token, formErr))
|
|
}
|
|
|
|
func (m *Module) submitSetup(w http.ResponseWriter, r *http.Request, token string) {
|
|
if err := r.ParseForm(); err != nil {
|
|
m.renderSetupForm(w, http.StatusBadRequest, token, "Invalid form submission.")
|
|
return
|
|
}
|
|
username := strings.TrimSpace(r.PostFormValue("username"))
|
|
password := r.PostFormValue("password")
|
|
confirm := r.PostFormValue("password_confirm")
|
|
|
|
if err := validate.Username(username); err != nil {
|
|
m.renderSetupForm(w, http.StatusBadRequest, token, err.Error())
|
|
return
|
|
}
|
|
if password != confirm {
|
|
m.renderSetupForm(w, http.StatusBadRequest, token, "Passwords do not match.")
|
|
return
|
|
}
|
|
if err := validate.AdminPassword(password); err != nil {
|
|
m.renderSetupForm(w, http.StatusBadRequest, token, err.Error())
|
|
return
|
|
}
|
|
|
|
hash, err := bcrypt.GenerateFromPassword([]byte(password), BcryptCost)
|
|
if err != nil {
|
|
logf("panel: setup: hashing password failed: %v", err)
|
|
m.renderSetupForm(w, http.StatusInternalServerError, token, "Internal error. Please try again.")
|
|
return
|
|
}
|
|
|
|
if err := m.store.CreateGlobalUser(username, string(hash)); err != nil {
|
|
if exists, _ := m.store.UserExists(); exists {
|
|
m.setup.complete()
|
|
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
|
return
|
|
}
|
|
logf("panel: setup: create user failed: %v", err)
|
|
m.renderSetupForm(w, http.StatusInternalServerError, token, "Internal error. Please try again.")
|
|
return
|
|
}
|
|
|
|
m.setup.complete()
|
|
logf("panel: administrator %q created; setup link is now disabled", username)
|
|
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
|
}
|