Add member accounts: invites, registration, login, sessions, ban
Step 2 of the build order. The admin mints an invite link, the recipient registers with it, and from then on has a session. - The invite is spent in the same transaction that creates the account, so a failed signup leaves the code usable - Sessions are idle timeouts, 24h or 30 days with remember me, read from a cookie or a bearer header, extended at most once a minute - Ban is a reversible toggle that drops the member's live sessions - No password minimum; login is rate limited instead, 10 failures per email in 15 minutes, cleared by a correct password - Invite codes render as links carrying ?code=, which the register form prefills; PUBLIC_URL makes them pasteable from the loopback admin panel Tests cover invite spending, the idle timeout, ban, and the rate limiter.
This commit is contained in:
@@ -0,0 +1,319 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
const (
|
||||
sessionCookie = "session"
|
||||
idleShort = 24 * time.Hour
|
||||
idleRemember = 30 * 24 * time.Hour
|
||||
// Skip the extending UPDATE unless the session has aged at least this much, so a sliding
|
||||
// session is not a write on every request.
|
||||
extendAfter = time.Minute
|
||||
)
|
||||
|
||||
type member struct {
|
||||
ID int64
|
||||
Name string
|
||||
Email string
|
||||
Avatar *string
|
||||
Banned bool
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// Initials for the avatar circle: no default image on disk, no identicon generator.
|
||||
func (m *member) Initials() string {
|
||||
out := ""
|
||||
for _, f := range strings.Fields(m.Name) {
|
||||
out += strings.ToUpper(string([]rune(f)[0]))
|
||||
if len(out) == 2 {
|
||||
break
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
type ctxKey int
|
||||
|
||||
const memberKey ctxKey = 0
|
||||
|
||||
func memberFrom(ctx context.Context) *member {
|
||||
m, _ := ctx.Value(memberKey).(*member)
|
||||
return m
|
||||
}
|
||||
|
||||
func token() string {
|
||||
b := make([]byte, 32)
|
||||
rand.Read(b)
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
// A bearer header as well as the cookie, so something that isn't a browser can authenticate
|
||||
// without a second concept. SameSite=Lax still guards the cookie path, and a cross-origin page
|
||||
// cannot set Authorization without CORS, which is not enabled.
|
||||
func sessionToken(r *http.Request) string {
|
||||
if h := r.Header.Get("Authorization"); strings.HasPrefix(h, "Bearer ") {
|
||||
return strings.TrimPrefix(h, "Bearer ")
|
||||
}
|
||||
if c, err := r.Cookie(sessionCookie); err == nil {
|
||||
return c.Value
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (a *app) startSession(ctx context.Context, userID int64, remember bool) (string, time.Time, error) {
|
||||
ttl := idleShort
|
||||
if remember {
|
||||
ttl = idleRemember
|
||||
}
|
||||
tok := token()
|
||||
expires := time.Now().Add(ttl)
|
||||
_, err := a.pool.Exec(ctx,
|
||||
`insert into sessions (token, user_id, idle_ttl, expires_at) values ($1, $2, $3, $4)`,
|
||||
tok, userID, ttl, expires)
|
||||
return tok, expires, err
|
||||
}
|
||||
|
||||
func (a *app) setSessionCookie(w http.ResponseWriter, tok string, expires time.Time) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: sessionCookie, Value: tok, Path: "/", Expires: expires,
|
||||
HttpOnly: true, Secure: a.cfg.secureCookies, SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
}
|
||||
|
||||
// session loads the member behind a token, extends the idle timeout, and treats a banned or
|
||||
// expired session as no session at all.
|
||||
func (a *app) session(w http.ResponseWriter, r *http.Request) *member {
|
||||
tok := sessionToken(r)
|
||||
if tok == "" {
|
||||
return nil
|
||||
}
|
||||
var (
|
||||
m member
|
||||
expires time.Time
|
||||
ttl time.Duration
|
||||
ttlMicros int64
|
||||
)
|
||||
err := a.pool.QueryRow(r.Context(), `
|
||||
select s.expires_at, extract(epoch from s.idle_ttl) * 1000000,
|
||||
u.id, u.name, u.email, u.avatar, u.banned, u.created_at
|
||||
from sessions s join users u on u.id = s.user_id
|
||||
where s.token = $1 and s.expires_at > now()`, tok).
|
||||
Scan(&expires, &ttlMicros, &m.ID, &m.Name, &m.Email, &m.Avatar, &m.Banned, &m.CreatedAt)
|
||||
if err != nil {
|
||||
if !errors.Is(err, pgx.ErrNoRows) {
|
||||
slog.Error("session lookup", "ctx", "auth", "error", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if m.Banned {
|
||||
// Banning deletes sessions, so this is belt and braces for a row that outlived one.
|
||||
a.pool.Exec(r.Context(), `delete from sessions where user_id = $1`, m.ID)
|
||||
return nil
|
||||
}
|
||||
ttl = time.Duration(ttlMicros) * time.Microsecond
|
||||
if time.Until(expires) < ttl-extendAfter {
|
||||
newExpiry := time.Now().Add(ttl)
|
||||
if _, err := a.pool.Exec(r.Context(),
|
||||
`update sessions set expires_at = $2 where token = $1`, tok, newExpiry); err == nil {
|
||||
a.setSessionCookie(w, tok, newExpiry)
|
||||
}
|
||||
}
|
||||
return &m
|
||||
}
|
||||
|
||||
func (a *app) withMember(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if m := a.session(w, r); m != nil {
|
||||
r = r.WithContext(context.WithValue(r.Context(), memberKey, m))
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func (a *app) requireMember(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if memberFrom(r.Context()) == nil {
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
next(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
// --- pages ---
|
||||
|
||||
type authForm struct {
|
||||
Name, Email, Code string
|
||||
Errors map[string]string
|
||||
}
|
||||
|
||||
func (a *app) loginPage(w http.ResponseWriter, r *http.Request) {
|
||||
a.render(w, r, http.StatusOK, "login.html", page{Title: "Kirjaudu", Data: authForm{}})
|
||||
}
|
||||
|
||||
func (a *app) login(w http.ResponseWriter, r *http.Request) {
|
||||
email := strings.TrimSpace(strings.ToLower(r.FormValue("email")))
|
||||
form := authForm{Email: email, Errors: map[string]string{}}
|
||||
|
||||
if a.logins.locked(email) {
|
||||
form.Errors["form"] = "Liian monta yritystä. Yritä hetken kuluttua uudelleen."
|
||||
a.render(w, r, http.StatusTooManyRequests, "login.html", page{Title: "Kirjaudu", Data: form})
|
||||
return
|
||||
}
|
||||
|
||||
var (
|
||||
id int64
|
||||
hash string
|
||||
banned bool
|
||||
)
|
||||
err := a.pool.QueryRow(r.Context(),
|
||||
`select id, password_hash, banned from users where email = $1`, email).Scan(&id, &hash, &banned)
|
||||
if err != nil || bcrypt.CompareHashAndPassword([]byte(hash), []byte(r.FormValue("password"))) != nil {
|
||||
a.logins.fail(email)
|
||||
// One message for both cases: a distinct "no such account" tells anyone who asks which
|
||||
// addresses are members.
|
||||
form.Errors["form"] = "Sähköposti tai salasana ei täsmää."
|
||||
a.render(w, r, http.StatusUnauthorized, "login.html", page{Title: "Kirjaudu", Data: form})
|
||||
return
|
||||
}
|
||||
if banned {
|
||||
form.Errors["form"] = "Tunnus on estetty."
|
||||
a.render(w, r, http.StatusForbidden, "login.html", page{Title: "Kirjaudu", Data: form})
|
||||
return
|
||||
}
|
||||
|
||||
tok, expires, err := a.startSession(r.Context(), id, r.FormValue("remember") != "")
|
||||
if err != nil {
|
||||
slog.Error("start session", "ctx", "auth", "error", err)
|
||||
http.Error(w, "virhe", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
a.logins.succeed(email)
|
||||
a.setSessionCookie(w, tok, expires)
|
||||
slog.Info("login", "ctx", "auth", "user", id)
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (a *app) logout(w http.ResponseWriter, r *http.Request) {
|
||||
if tok := sessionToken(r); tok != "" {
|
||||
a.pool.Exec(r.Context(), `delete from sessions where token = $1`, tok)
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: sessionCookie, Value: "", Path: "/", MaxAge: -1,
|
||||
HttpOnly: true, Secure: a.cfg.secureCookies, SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (a *app) registerPage(w http.ResponseWriter, r *http.Request) {
|
||||
a.render(w, r, http.StatusOK, "register.html",
|
||||
page{Title: "Liity", Data: authForm{Code: r.URL.Query().Get("code")}})
|
||||
}
|
||||
|
||||
// register spends the invite only when the account is actually created: both statements are in one
|
||||
// transaction, so a failed signup leaves the code usable.
|
||||
func (a *app) register(w http.ResponseWriter, r *http.Request) {
|
||||
form := authForm{
|
||||
Name: strings.TrimSpace(r.FormValue("name")),
|
||||
Email: strings.TrimSpace(strings.ToLower(r.FormValue("email"))),
|
||||
Code: strings.TrimSpace(r.FormValue("code")),
|
||||
Errors: map[string]string{},
|
||||
}
|
||||
password := r.FormValue("password")
|
||||
|
||||
if form.Name == "" || len([]rune(form.Name)) > 50 {
|
||||
form.Errors["name"] = "Nimi on pakollinen, enintään 50 merkkiä."
|
||||
}
|
||||
if !strings.Contains(form.Email, "@") {
|
||||
form.Errors["email"] = "Tarkista sähköpostiosoite."
|
||||
}
|
||||
// ponytail: no length policy. Invite-only, ten friends, bcrypt, and the admin is the reset
|
||||
// path — a minimum buys nothing here and makes dev accounts tedious.
|
||||
if password == "" {
|
||||
form.Errors["password"] = "Salasana on pakollinen."
|
||||
}
|
||||
if form.Code == "" {
|
||||
form.Errors["code"] = "Kutsukoodi on pakollinen."
|
||||
}
|
||||
if len(form.Errors) > 0 {
|
||||
a.render(w, r, http.StatusUnprocessableEntity, "register.html", page{Title: "Liity", Data: form})
|
||||
return
|
||||
}
|
||||
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
slog.Error("hash password", "ctx", "auth", "error", err)
|
||||
http.Error(w, "virhe", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
tx, err := a.pool.Begin(r.Context())
|
||||
if err != nil {
|
||||
slog.Error("begin", "ctx", "auth", "error", err)
|
||||
http.Error(w, "virhe", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer tx.Rollback(r.Context())
|
||||
|
||||
var inviteID int64
|
||||
err = tx.QueryRow(r.Context(),
|
||||
`update invites set is_valid = false where code = $1 and is_valid returning id`,
|
||||
form.Code).Scan(&inviteID)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
form.Errors["code"] = "Kutsukoodi ei kelpaa."
|
||||
a.render(w, r, http.StatusUnprocessableEntity, "register.html", page{Title: "Liity", Data: form})
|
||||
return
|
||||
} else if err != nil {
|
||||
slog.Error("burn invite", "ctx", "invites", "error", err)
|
||||
http.Error(w, "virhe", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
var userID int64
|
||||
err = tx.QueryRow(r.Context(),
|
||||
`insert into users (name, email, password_hash) values ($1, $2, $3) returning id`,
|
||||
form.Name, form.Email, string(hash)).Scan(&userID)
|
||||
if isUnique(err) {
|
||||
// Rolls back, so the invite is still valid.
|
||||
form.Errors["email"] = "Sähköpostiosoite on jo käytössä."
|
||||
a.render(w, r, http.StatusUnprocessableEntity, "register.html", page{Title: "Liity", Data: form})
|
||||
return
|
||||
} else if err != nil {
|
||||
slog.Error("create user", "ctx", "auth", "error", err)
|
||||
http.Error(w, "virhe", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if err := tx.Commit(r.Context()); err != nil {
|
||||
slog.Error("commit registration", "ctx", "auth", "error", err)
|
||||
http.Error(w, "virhe", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
slog.Info("registered", "ctx", "auth", "user", userID, "invite", inviteID)
|
||||
|
||||
tok, expires, err := a.startSession(r.Context(), userID, false)
|
||||
if err != nil {
|
||||
slog.Error("start session", "ctx", "auth", "error", err)
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
a.setSessionCookie(w, tok, expires)
|
||||
a.flash(w, "Tervetuloa mukaan!")
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func isUnique(err error) bool {
|
||||
var pgErr interface{ SQLState() string }
|
||||
return errors.As(err, &pgErr) && pgErr.SQLState() == "23505"
|
||||
}
|
||||
Reference in New Issue
Block a user