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.
174 lines
4.8 KiB
Go
174 lines
4.8 KiB
Go
package main
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/url"
|
|
"strconv"
|
|
"time"
|
|
|
|
"golang.org/x/crypto/bcrypt"
|
|
)
|
|
|
|
type adminInvite struct {
|
|
ID int64
|
|
Code string
|
|
IsValid bool
|
|
CreatedAt time.Time
|
|
Link string
|
|
}
|
|
|
|
type adminMember struct {
|
|
ID int64
|
|
Name string
|
|
Email string
|
|
Banned bool
|
|
CreatedAt time.Time
|
|
}
|
|
|
|
type dashboard struct {
|
|
Invites []adminInvite
|
|
Members []adminMember
|
|
}
|
|
|
|
func (a *app) adminDashboard(w http.ResponseWriter, r *http.Request) {
|
|
var d dashboard
|
|
|
|
rows, err := a.pool.Query(r.Context(),
|
|
`select id, code, is_valid, created_at from invites order by created_at desc limit 50`)
|
|
if err != nil {
|
|
adminError(w, "invites", err)
|
|
return
|
|
}
|
|
for rows.Next() {
|
|
var i adminInvite
|
|
if err := rows.Scan(&i.ID, &i.Code, &i.IsValid, &i.CreatedAt); err != nil {
|
|
adminError(w, "invites", err)
|
|
return
|
|
}
|
|
i.Link = a.inviteLink(i.Code)
|
|
d.Invites = append(d.Invites, i)
|
|
}
|
|
rows.Close()
|
|
if err := rows.Err(); err != nil {
|
|
adminError(w, "invites", err)
|
|
return
|
|
}
|
|
|
|
rows, err = a.pool.Query(r.Context(),
|
|
`select id, name, email, banned, created_at from users order by created_at`)
|
|
if err != nil {
|
|
adminError(w, "users", err)
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
for rows.Next() {
|
|
var m adminMember
|
|
if err := rows.Scan(&m.ID, &m.Name, &m.Email, &m.Banned, &m.CreatedAt); err != nil {
|
|
adminError(w, "users", err)
|
|
return
|
|
}
|
|
d.Members = append(d.Members, m)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
adminError(w, "users", err)
|
|
return
|
|
}
|
|
|
|
a.render(w, r, http.StatusOK, "admin.html", page{Title: "Ylläpito", Admin: true, Data: d})
|
|
}
|
|
|
|
// 128 bits of entropy. The code is shown once on the dashboard and pasted to whoever is joining.
|
|
func inviteCode() string {
|
|
b := make([]byte, 16)
|
|
rand.Read(b)
|
|
return hex.EncodeToString(b)
|
|
}
|
|
|
|
// The link is what actually gets sent to someone: the register form reads ?code= and prefills it,
|
|
// so the recipient clicks and fills in their name. PUBLIC_URL unset falls back to a relative path,
|
|
// which is enough locally.
|
|
func (a *app) inviteLink(code string) string {
|
|
return a.cfg.publicURL + "/register?code=" + url.QueryEscape(code)
|
|
}
|
|
|
|
func (a *app) createInvite(w http.ResponseWriter, r *http.Request) {
|
|
code := inviteCode()
|
|
if _, err := a.pool.Exec(r.Context(), `insert into invites (code) values ($1)`, code); err != nil {
|
|
adminError(w, "invites", err)
|
|
return
|
|
}
|
|
slog.Info("invite minted", "ctx", "invites")
|
|
// The dashboard lists it as a clickable link immediately below, newest first, so the flash
|
|
// doesn't repeat the URL as unclickable text.
|
|
a.flash(w, "Uusi kutsulinkki luotu.")
|
|
http.Redirect(w, r, "/admin", http.StatusSeeOther)
|
|
}
|
|
|
|
// Ban is a reversible toggle. It drops live sessions immediately — checking `banned` only at login
|
|
// would leave a banned member browsing until their session expired.
|
|
func (a *app) toggleBan(w http.ResponseWriter, r *http.Request) {
|
|
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
|
if err != nil {
|
|
http.Error(w, "not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
var banned bool
|
|
err = a.pool.QueryRow(r.Context(),
|
|
`update users set banned = not banned where id = $1 returning banned`, id).Scan(&banned)
|
|
if err != nil {
|
|
adminError(w, "users", err)
|
|
return
|
|
}
|
|
if banned {
|
|
if _, err := a.pool.Exec(r.Context(), `delete from sessions where user_id = $1`, id); err != nil {
|
|
adminError(w, "users", err)
|
|
return
|
|
}
|
|
a.flash(w, "Jäsen estetty.")
|
|
} else {
|
|
a.flash(w, "Esto poistettu.")
|
|
}
|
|
slog.Info("ban toggled", "ctx", "auth", "user", id, "banned", banned)
|
|
http.Redirect(w, r, "/admin", http.StatusSeeOther)
|
|
}
|
|
|
|
// The admin reset is the only password recovery there is, so it also drops the member's sessions.
|
|
func (a *app) resetPassword(w http.ResponseWriter, r *http.Request) {
|
|
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
|
if err != nil {
|
|
http.Error(w, "not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
password := r.FormValue("password")
|
|
if password == "" {
|
|
a.flash(w, "Salasana on pakollinen.")
|
|
http.Redirect(w, r, "/admin", http.StatusSeeOther)
|
|
return
|
|
}
|
|
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
|
if err != nil {
|
|
adminError(w, "auth", err)
|
|
return
|
|
}
|
|
if _, err := a.pool.Exec(r.Context(),
|
|
`update users set password_hash = $2 where id = $1`, id, string(hash)); err != nil {
|
|
adminError(w, "auth", err)
|
|
return
|
|
}
|
|
if _, err := a.pool.Exec(r.Context(), `delete from sessions where user_id = $1`, id); err != nil {
|
|
adminError(w, "auth", err)
|
|
return
|
|
}
|
|
slog.Info("password reset by admin", "ctx", "auth", "user", id)
|
|
a.flash(w, "Salasana vaihdettu.")
|
|
http.Redirect(w, r, "/admin", http.StatusSeeOther)
|
|
}
|
|
|
|
func adminError(w http.ResponseWriter, ctx string, err error) {
|
|
slog.Error("admin", "ctx", ctx, "error", err)
|
|
http.Error(w, "virhe", http.StatusInternalServerError)
|
|
}
|