The admin was a set of env credentials on its own loopback listener. That bought network isolation, and charged a second port to tunnel and proxy and a second credential in the password manager. It also sat outside the SameSite protection the member cookie already had, and left every ban and password reset with no actor to log. is_admin on users reuses what was already there: the session, the login rate limiter, ban-drops-sessions, CSRF. /admin is now a route on the member mux. A member without the flag gets 404 rather than 403 — the pages are none of their business, and "forbidden" confirms there is something to be forbidden from. Registration needs an invite and invites come from /admin, so an empty database cannot grow its first user. seedAdmin breaks that circle exactly once, from ADMIN_EMAIL and ADMIN_PASSWORD, and does nothing against a database that already has users. An admin cannot ban themselves: banning drops the target's sessions, and nothing would be left that could undo it. This reverses decision 8, which is rewritten rather than deleted, along with the admin entry in the CONTEXT.md vocabulary.
331 lines
10 KiB
Go
331 lines
10 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"database/sql"
|
|
"encoding/hex"
|
|
"errors"
|
|
"log/slog"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"golang.org/x/crypto/bcrypt"
|
|
"modernc.org/sqlite"
|
|
)
|
|
|
|
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
|
|
IsAdmin bool
|
|
CreatedAt time.Time
|
|
}
|
|
|
|
// Initials for the avatar circle: no default image on disk, no identicon generator.
|
|
func (m *member) Initials() string {
|
|
out, n := "", 0
|
|
for _, f := range strings.Fields(m.Name) {
|
|
out += strings.ToUpper(string([]rune(f)[0]))
|
|
// ponytail: count runes taken, not bytes — "Ä" is 2 bytes and used to end the loop early.
|
|
if n++; n == 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.db.ExecContext(ctx,
|
|
`insert into sessions (token, user_id, idle_ttl, expires_at) values ($1, $2, $3, $4)`,
|
|
tok, userID, int64(ttl.Seconds()), 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
|
|
ttlSeconds int64
|
|
)
|
|
err := a.db.QueryRowContext(r.Context(), `
|
|
select s.expires_at, s.idle_ttl,
|
|
u.id, u.name, u.email, u.avatar, u.banned, u.is_admin, u.created_at
|
|
from sessions s join users u on u.id = s.user_id
|
|
where s.token = $1 and s.expires_at > datetime('now')`, tok).
|
|
Scan(&expires, &ttlSeconds, &m.ID, &m.Name, &m.Email, &m.Avatar, &m.Banned, &m.IsAdmin, &m.CreatedAt)
|
|
if err != nil {
|
|
if !errors.Is(err, sql.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.db.ExecContext(r.Context(), `delete from sessions where user_id = $1`, m.ID)
|
|
return nil
|
|
}
|
|
ttl = time.Duration(ttlSeconds) * time.Second
|
|
if time.Until(expires) < ttl-extendAfter {
|
|
newExpiry := time.Now().Add(ttl)
|
|
if _, err := a.db.ExecContext(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", Narrow: true, 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", Narrow: true, Data: form})
|
|
return
|
|
}
|
|
|
|
var (
|
|
id int64
|
|
hash string
|
|
banned bool
|
|
)
|
|
err := a.db.QueryRowContext(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", Narrow: true, Data: form})
|
|
return
|
|
}
|
|
if banned {
|
|
form.Errors["form"] = "Tunnus on estetty."
|
|
a.render(w, r, http.StatusForbidden, "login.html", page{Title: "Kirjaudu", Narrow: true, 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.db.ExecContext(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", Narrow: true, 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", Narrow: true, 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.db.BeginTx(r.Context(), nil)
|
|
if err != nil {
|
|
slog.Error("begin", "ctx", "auth", "error", err)
|
|
http.Error(w, "virhe", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
defer tx.Rollback()
|
|
|
|
var inviteID int64
|
|
err = tx.QueryRowContext(r.Context(),
|
|
`update invites set is_valid = 0 where code = $1 and is_valid returning id`,
|
|
form.Code).Scan(&inviteID)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
form.Errors["code"] = "Kutsukoodi ei kelpaa."
|
|
a.render(w, r, http.StatusUnprocessableEntity, "register.html", page{Title: "Liity", Narrow: true, 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.QueryRowContext(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", Narrow: true, 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(); 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)
|
|
}
|
|
|
|
// SQLITE_CONSTRAINT_UNIQUE and SQLITE_CONSTRAINT_PRIMARYKEY, spelled out rather than pulled in from
|
|
// modernc.org/sqlite/lib — that package is the whole generated amalgamation, for two integers.
|
|
const (
|
|
sqliteConstraintUnique = 2067
|
|
sqliteConstraintPrimaryKey = 1555
|
|
)
|
|
|
|
func isUnique(err error) bool {
|
|
var e *sqlite.Error
|
|
return errors.As(err, &e) &&
|
|
(e.Code() == sqliteConstraintUnique || e.Code() == sqliteConstraintPrimaryKey)
|
|
}
|