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", 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.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", 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.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", 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.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", 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.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", 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(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" }