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.
320 lines
9.8 KiB
Go
320 lines
9.8 KiB
Go
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"
|
|
}
|