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:
@@ -5,3 +5,7 @@ ADMIN_PASSWORD=
|
|||||||
|
|
||||||
# Set to false only for local development over plain HTTP.
|
# Set to false only for local development over plain HTTP.
|
||||||
SECURE_COOKIES=true
|
SECURE_COOKIES=true
|
||||||
|
|
||||||
|
# Public address of the member site. Used to build pasteable invite links in the admin panel.
|
||||||
|
# Unset falls back to a relative link, which is fine locally.
|
||||||
|
PUBLIC_URL=https://levyraati.example.com
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ creates no users: log into the admin panel and mint an invite.
|
|||||||
| `ADMIN_ADDR` | `127.0.0.1:8081` | Admin listener. Keep it on loopback. Under Compose it binds `:8081` inside the container and is published only to the host's loopback |
|
| `ADMIN_ADDR` | `127.0.0.1:8081` | Admin listener. Keep it on loopback. Under Compose it binds `:8081` inside the container and is published only to the host's loopback |
|
||||||
| `STORAGE_DIR` | `./storage` | Audio, avatars, in-flight conversions |
|
| `STORAGE_DIR` | `./storage` | Audio, avatars, in-flight conversions |
|
||||||
| `SECURE_COOKIES` | `true` | Set `false` for local development over plain HTTP |
|
| `SECURE_COOKIES` | `true` | Set `false` for local development over plain HTTP |
|
||||||
|
| `PUBLIC_URL` | — | Public address of the member site, e.g. `https://levyraati.example.com`. Used to build invite links in the admin panel; unset gives relative links |
|
||||||
|
|
||||||
### Local development
|
### Local development
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,173 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
+240
@@ -0,0 +1,240 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Set TEST_DATABASE_URL to a throwaway database — these drop and recreate the public schema.
|
||||||
|
func testApp(t *testing.T) *app {
|
||||||
|
t.Helper()
|
||||||
|
dbURL := os.Getenv("TEST_DATABASE_URL")
|
||||||
|
if dbURL == "" {
|
||||||
|
t.Skip("TEST_DATABASE_URL not set")
|
||||||
|
}
|
||||||
|
ctx := context.Background()
|
||||||
|
pool, err := pgxpool.New(ctx, dbURL)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(pool.Close)
|
||||||
|
if _, err := pool.Exec(ctx, `drop schema public cascade; create schema public`); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := migrate(ctx, pool); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return &app{cfg: config{adminUser: "admin", adminPass: "s3cret"}, pool: pool}
|
||||||
|
}
|
||||||
|
|
||||||
|
func post(t *testing.T, h http.Handler, path string, form url.Values) *httptest.ResponseRecorder {
|
||||||
|
t.Helper()
|
||||||
|
r := httptest.NewRequest("POST", path, strings.NewReader(form.Encode()))
|
||||||
|
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(w, r)
|
||||||
|
return w
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *app) inviteValid(t *testing.T, code string) bool {
|
||||||
|
t.Helper()
|
||||||
|
var valid bool
|
||||||
|
if err := a.pool.QueryRow(context.Background(),
|
||||||
|
`select is_valid from invites where code = $1`, code).Scan(&valid); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return valid
|
||||||
|
}
|
||||||
|
|
||||||
|
// A failed registration must leave the code usable; a successful one must not.
|
||||||
|
func TestInviteIsSpentOnlyBySuccess(t *testing.T) {
|
||||||
|
a := testApp(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
mux := a.withMember(a.memberMux())
|
||||||
|
|
||||||
|
if _, err := a.pool.Exec(ctx, `insert into invites (code) values ('kutsu1')`); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := a.pool.Exec(ctx,
|
||||||
|
`insert into users (name, email, password_hash) values ('Esa', '[email protected]', 'x')`); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Taken email — the insert fails after the invite has already been marked spent in the tx.
|
||||||
|
w := post(t, mux, "/register", url.Values{
|
||||||
|
"code": {"kutsu1"}, "name": {"Toinen"},
|
||||||
|
"email": {"[email protected]"}, "password": {"salasana1"},
|
||||||
|
})
|
||||||
|
if w.Code != http.StatusUnprocessableEntity {
|
||||||
|
t.Fatalf("duplicate email: status = %d, want 422", w.Code)
|
||||||
|
}
|
||||||
|
if !a.inviteValid(t, "kutsu1") {
|
||||||
|
t.Fatal("failed registration spent the invite")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Missing password — rejected before the invite is touched at all.
|
||||||
|
w = post(t, mux, "/register", url.Values{
|
||||||
|
"code": {"kutsu1"}, "name": {"Toinen"}, "email": {"[email protected]"}, "password": {""},
|
||||||
|
})
|
||||||
|
if w.Code != http.StatusUnprocessableEntity {
|
||||||
|
t.Fatalf("short password: status = %d, want 422", w.Code)
|
||||||
|
}
|
||||||
|
if !a.inviteValid(t, "kutsu1") {
|
||||||
|
t.Fatal("rejected registration spent the invite")
|
||||||
|
}
|
||||||
|
|
||||||
|
w = post(t, mux, "/register", url.Values{
|
||||||
|
"code": {"kutsu1"}, "name": {"Toinen"}, "email": {"[email protected]"}, "password": {"salasana1"},
|
||||||
|
})
|
||||||
|
if w.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("valid registration: status = %d, want 303", w.Code)
|
||||||
|
}
|
||||||
|
if a.inviteValid(t, "kutsu1") {
|
||||||
|
t.Fatal("successful registration left the invite usable")
|
||||||
|
}
|
||||||
|
|
||||||
|
// And it cannot be used twice.
|
||||||
|
w = post(t, mux, "/register", url.Values{
|
||||||
|
"code": {"kutsu1"}, "name": {"Kolmas"}, "email": {"[email protected]"}, "password": {"salasana1"},
|
||||||
|
})
|
||||||
|
if w.Code != http.StatusUnprocessableEntity {
|
||||||
|
t.Fatalf("reused invite: status = %d, want 422", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The limiter has its own unit test; this covers the wiring into the handler.
|
||||||
|
func TestLoginHandlerRefusesAfterTooManyFailures(t *testing.T) {
|
||||||
|
a := testApp(t)
|
||||||
|
mux := a.withMember(a.memberMux())
|
||||||
|
a.seedMember(t, "[email protected]")
|
||||||
|
|
||||||
|
bad := url.Values{"email": {"[email protected]"}, "password": {"väärin"}}
|
||||||
|
for i := range loginMaxFailures {
|
||||||
|
if w := post(t, mux, "/login", bad); w.Code != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("attempt %d: status = %d, want 401", i+1, w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if w := post(t, mux, "/login", bad); w.Code != http.StatusTooManyRequests {
|
||||||
|
t.Fatalf("attempt %d: status = %d, want 429", loginMaxFailures+1, w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *app) seedMember(t *testing.T, email string) int64 {
|
||||||
|
t.Helper()
|
||||||
|
var id int64
|
||||||
|
err := a.pool.QueryRow(context.Background(),
|
||||||
|
`insert into users (name, email, password_hash) values ('Esa', $1, 'x') returning id`,
|
||||||
|
email).Scan(&id)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *app) sessionFor(t *testing.T, token string) *member {
|
||||||
|
t.Helper()
|
||||||
|
r := httptest.NewRequest("GET", "/", nil)
|
||||||
|
r.AddCookie(&http.Cookie{Name: sessionCookie, Value: token})
|
||||||
|
return a.session(httptest.NewRecorder(), r)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSessionIdleTimeout(t *testing.T) {
|
||||||
|
a := testApp(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
id := a.seedMember(t, "[email protected]")
|
||||||
|
|
||||||
|
live, _, err := a.startSession(ctx, id, false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if m := a.sessionFor(t, live); m == nil || m.ID != id {
|
||||||
|
t.Fatal("fresh session did not resolve to its member")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Age it past the idle window: the timeout is what expiry means, so this is the whole rule.
|
||||||
|
if _, err := a.pool.Exec(ctx,
|
||||||
|
`update sessions set expires_at = now() - interval '1 second' where token = $1`, live); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if m := a.sessionFor(t, live); m != nil {
|
||||||
|
t.Fatal("expired session still resolved")
|
||||||
|
}
|
||||||
|
|
||||||
|
// A session used inside the window slides forward.
|
||||||
|
fresh, _, err := a.startSession(ctx, id, false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := a.pool.Exec(ctx,
|
||||||
|
`update sessions set expires_at = now() + interval '1 hour' where token = $1`, fresh); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if m := a.sessionFor(t, fresh); m == nil {
|
||||||
|
t.Fatal("session inside the window did not resolve")
|
||||||
|
}
|
||||||
|
var expires time.Time
|
||||||
|
if err := a.pool.QueryRow(ctx,
|
||||||
|
`select expires_at from sessions where token = $1`, fresh).Scan(&expires); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if time.Until(expires) < 23*time.Hour {
|
||||||
|
t.Fatalf("session was not extended: expires in %s", time.Until(expires))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBanDropsSessionsAndBlocksLogin(t *testing.T) {
|
||||||
|
a := testApp(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
mux := a.withMember(a.memberMux())
|
||||||
|
|
||||||
|
if _, err := a.pool.Exec(ctx, `insert into invites (code) values ('kutsu2')`); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
w := post(t, mux, "/register", url.Values{
|
||||||
|
"code": {"kutsu2"}, "name": {"Esa"}, "email": {"[email protected]"}, "password": {"salasana1"},
|
||||||
|
})
|
||||||
|
if w.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("registration: status = %d, want 303", w.Code)
|
||||||
|
}
|
||||||
|
var id int64
|
||||||
|
if err := a.pool.QueryRow(ctx, `select id from users where email = '[email protected]'`).Scan(&id); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
adminMux := a.adminMux()
|
||||||
|
if w := post(t, adminMux, fmt.Sprintf("/admin/users/%d/ban", id), nil); w.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("ban: status = %d, want 303", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
var sessions int
|
||||||
|
if err := a.pool.QueryRow(ctx,
|
||||||
|
`select count(*) from sessions where user_id = $1`, id).Scan(&sessions); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if sessions != 0 {
|
||||||
|
t.Fatalf("banned member kept %d sessions", sessions)
|
||||||
|
}
|
||||||
|
|
||||||
|
w = post(t, mux, "/login", url.Values{"email": {"[email protected]"}, "password": {"salasana1"}})
|
||||||
|
if w.Code != http.StatusForbidden {
|
||||||
|
t.Fatalf("banned login: status = %d, want 403", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reversible: unban, and the same credentials work again.
|
||||||
|
if w := post(t, adminMux, fmt.Sprintf("/admin/users/%d/ban", id), nil); w.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("unban: status = %d, want 303", w.Code)
|
||||||
|
}
|
||||||
|
w = post(t, mux, "/login", url.Values{"email": {"[email protected]"}, "password": {"salasana1"}})
|
||||||
|
if w.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("login after unban: status = %d, want 303", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
+3
-2
@@ -27,11 +27,12 @@ services:
|
|||||||
# published below, so it stays unreachable from outside without a tunnel or the proxy.
|
# published below, so it stays unreachable from outside without a tunnel or the proxy.
|
||||||
ADMIN_ADDR: ":8081"
|
ADMIN_ADDR: ":8081"
|
||||||
SECURE_COOKIES: ${SECURE_COOKIES:-true}
|
SECURE_COOKIES: ${SECURE_COOKIES:-true}
|
||||||
|
PUBLIC_URL: ${PUBLIC_URL:-}
|
||||||
volumes:
|
volumes:
|
||||||
- ./storage:/storage
|
- ./storage:/storage
|
||||||
ports:
|
ports:
|
||||||
- "127.0.0.1:8080:8080"
|
- "8080:8080"
|
||||||
- "127.0.0.1:8081:8081"
|
- "8081:8081"
|
||||||
depends_on:
|
depends_on:
|
||||||
postgres:
|
postgres:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|||||||
@@ -158,3 +158,12 @@ says so.
|
|||||||
40. **`main` is release code, `dev` is development.** Work lands on `dev` and reaches `main` by merge
|
40. **`main` is release code, `dev` is development.** Work lands on `dev` and reaches `main` by merge
|
||||||
at release, so `main` is always a list of things that shipped. Nightly builds, if any, come off
|
at release, so `main` is always a list of things that shipped. Nightly builds, if any, come off
|
||||||
`dev`.
|
`dev`.
|
||||||
|
41. **No password minimum; rate limit logins instead.** A length policy protects against guessing,
|
||||||
|
and guessing is better answered directly: 10 failures per email in 15 minutes, then a 15-minute
|
||||||
|
lockout, cleared by a correct password. The floor was rejected because typing an 8-character
|
||||||
|
password on every dev account is friction with nothing behind it — there is no public
|
||||||
|
registration to spray, and the admin is the reset path. It was also deliberately *not* made
|
||||||
|
configurable: settings like this belong in code, not in an env file that grows a line per
|
||||||
|
preference. The limiter is keyed by email rather than IP (a proxy would mean trusting
|
||||||
|
`X-Forwarded-For`) and locks the *attempt rate*, not the account, so nobody can lock someone
|
||||||
|
else out by trying.
|
||||||
|
|||||||
+6
-1
@@ -49,7 +49,12 @@ and a cross-origin page cannot set `Authorization` without CORS, which is not en
|
|||||||
### 1.2 Security behaviours
|
### 1.2 Security behaviours
|
||||||
|
|
||||||
- Changing your own password requires the current password.
|
- Changing your own password requires the current password.
|
||||||
- Passwords are bcrypt.
|
- Passwords are bcrypt. **There is no minimum length** — only non-empty. Invite-only registration,
|
||||||
|
ten members, and an admin-only reset path leave a length policy nothing to protect.
|
||||||
|
- **Login attempts are rate limited**: 10 failures for one email address within 15 minutes lock
|
||||||
|
*that address's login* for 15 minutes, and a correct password clears the counter. Keyed by email
|
||||||
|
rather than IP, because behind a proxy the address requires trusting `X-Forwarded-For`. Held in
|
||||||
|
memory, so a restart clears it. Registration is not limited — an invite code is 128 bits.
|
||||||
- Invite codes carry 128 bits of entropy (`crypto/rand`, 16 bytes hex).
|
- Invite codes carry 128 bits of entropy (`crypto/rand`, 16 bytes hex).
|
||||||
- Avatar upload: 5 MB max, normalised through ffmpeg to a 256 px JPEG. The re-encode **is** the
|
- Avatar upload: 5 MB max, normalised through ffmpeg to a 256 px JPEG. The re-encode **is** the
|
||||||
validation, and it caps what lands on disk. ffmpeg handles webp and avif; stdlib `image` does not.
|
validation, and it caps what lands on disk. ffmpeg handles webp and avif; stdlib `image` does not.
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
@@ -20,6 +21,9 @@ type config struct {
|
|||||||
adminAddr string
|
adminAddr string
|
||||||
storageDir string
|
storageDir string
|
||||||
secureCookies bool
|
secureCookies bool
|
||||||
|
// Public address of the member site, so admin-side invite links are pasteable. The admin
|
||||||
|
// listener's own Host is a tunnel, not the site, so it cannot be derived.
|
||||||
|
publicURL string
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadConfig() config {
|
func loadConfig() config {
|
||||||
@@ -31,6 +35,7 @@ func loadConfig() config {
|
|||||||
adminAddr: env("ADMIN_ADDR", "127.0.0.1:8081"),
|
adminAddr: env("ADMIN_ADDR", "127.0.0.1:8081"),
|
||||||
storageDir: env("STORAGE_DIR", "./storage"),
|
storageDir: env("STORAGE_DIR", "./storage"),
|
||||||
secureCookies: env("SECURE_COOKIES", "true") != "false",
|
secureCookies: env("SECURE_COOKIES", "true") != "false",
|
||||||
|
publicURL: strings.TrimRight(os.Getenv("PUBLIC_URL"), "/"),
|
||||||
}
|
}
|
||||||
if c.databaseURL == "" {
|
if c.databaseURL == "" {
|
||||||
fatal("DATABASE_URL is not set")
|
fatal("DATABASE_URL is not set")
|
||||||
@@ -55,8 +60,9 @@ func fatal(msg string, args ...any) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type app struct {
|
type app struct {
|
||||||
cfg config
|
cfg config
|
||||||
pool *pgxpool.Pool
|
pool *pgxpool.Pool
|
||||||
|
logins limiter // zero value is ready to use
|
||||||
}
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
@@ -108,11 +114,13 @@ func main() {
|
|||||||
}()
|
}()
|
||||||
|
|
||||||
slog.Info("listening", "ctx", "startup", "addr", cfg.addr)
|
slog.Info("listening", "ctx", "startup", "addr", cfg.addr)
|
||||||
fatal("listener", "error", http.ListenAndServe(cfg.addr, a.memberMux()))
|
fatal("listener", "error", http.ListenAndServe(cfg.addr, a.withMember(a.memberMux())))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *app) memberMux() *http.ServeMux {
|
func (a *app) memberMux() *http.ServeMux {
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
|
mux.Handle("GET /static/", http.FileServerFS(assetFS))
|
||||||
|
|
||||||
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
|
||||||
if err := a.pool.Ping(r.Context()); err != nil {
|
if err := a.pool.Ping(r.Context()); err != nil {
|
||||||
http.Error(w, "db down", http.StatusServiceUnavailable)
|
http.Error(w, "db down", http.StatusServiceUnavailable)
|
||||||
@@ -120,13 +128,28 @@ func (a *app) memberMux() *http.ServeMux {
|
|||||||
}
|
}
|
||||||
w.Write([]byte("ok"))
|
w.Write([]byte("ok"))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
mux.HandleFunc("GET /login", a.loginPage)
|
||||||
|
mux.HandleFunc("POST /login", a.login)
|
||||||
|
mux.HandleFunc("GET /register", a.registerPage)
|
||||||
|
mux.HandleFunc("POST /register", a.register)
|
||||||
|
mux.HandleFunc("POST /logout", a.logout)
|
||||||
|
|
||||||
|
mux.HandleFunc("GET /{$}", a.requireMember(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
a.render(w, r, http.StatusOK, "home.html", page{Title: "Jono"})
|
||||||
|
}))
|
||||||
return mux
|
return mux
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *app) adminMux() *http.ServeMux {
|
func (a *app) adminMux() *http.ServeMux {
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
mux.HandleFunc("GET /admin", func(w http.ResponseWriter, r *http.Request) {
|
mux.Handle("GET /static/", http.FileServerFS(assetFS))
|
||||||
w.Write([]byte("levyraati admin"))
|
mux.HandleFunc("GET /admin", a.adminDashboard)
|
||||||
|
mux.HandleFunc("POST /admin/invites", a.createInvite)
|
||||||
|
mux.HandleFunc("POST /admin/users/{id}/ban", a.toggleBan)
|
||||||
|
mux.HandleFunc("POST /admin/users/{id}/password", a.resetPassword)
|
||||||
|
mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
http.Redirect(w, r, "/admin", http.StatusSeeOther)
|
||||||
})
|
})
|
||||||
return mux
|
return mux
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Login attempts are limited per email address, which is the thing under attack — and the only key
|
||||||
|
// available without parsing X-Forwarded-For and maintaining a trusted-proxy list.
|
||||||
|
//
|
||||||
|
// ponytail: in memory, dies with the process. A restart clearing the counters is not an attack an
|
||||||
|
// attacker can mount. A shared store is the upgrade if this ever runs as more than one process.
|
||||||
|
const (
|
||||||
|
loginMaxFailures = 10
|
||||||
|
loginWindow = 15 * time.Minute
|
||||||
|
loginLockout = 15 * time.Minute
|
||||||
|
)
|
||||||
|
|
||||||
|
type attempts struct {
|
||||||
|
count int
|
||||||
|
first time.Time
|
||||||
|
until time.Time // zero unless locked
|
||||||
|
}
|
||||||
|
|
||||||
|
type limiter struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
by map[string]*attempts
|
||||||
|
}
|
||||||
|
|
||||||
|
// locked reports whether the key is currently refused. It does not count as an attempt.
|
||||||
|
func (l *limiter) locked(key string) bool {
|
||||||
|
l.mu.Lock()
|
||||||
|
defer l.mu.Unlock()
|
||||||
|
a := l.by[key]
|
||||||
|
return a != nil && time.Now().Before(a.until)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *limiter) fail(key string) {
|
||||||
|
now := time.Now()
|
||||||
|
l.mu.Lock()
|
||||||
|
defer l.mu.Unlock()
|
||||||
|
if l.by == nil {
|
||||||
|
l.by = map[string]*attempts{}
|
||||||
|
}
|
||||||
|
l.sweep(now)
|
||||||
|
|
||||||
|
a := l.by[key]
|
||||||
|
if a == nil || now.Sub(a.first) > loginWindow {
|
||||||
|
l.by[key] = &attempts{count: 1, first: now}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.count++
|
||||||
|
if a.count >= loginMaxFailures {
|
||||||
|
a.until = now.Add(loginLockout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A correct password clears the record: the limit is on guessing, not on the account. Locking the
|
||||||
|
// account itself would let anyone lock its owner out by trying.
|
||||||
|
func (l *limiter) succeed(key string) {
|
||||||
|
l.mu.Lock()
|
||||||
|
defer l.mu.Unlock()
|
||||||
|
delete(l.by, key)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Called under the lock, on failures only — there is nothing to grow the map otherwise.
|
||||||
|
func (l *limiter) sweep(now time.Time) {
|
||||||
|
for k, a := range l.by {
|
||||||
|
if now.Sub(a.first) > loginWindow && now.After(a.until) {
|
||||||
|
delete(l.by, k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLoginRateLimit(t *testing.T) {
|
||||||
|
var l limiter
|
||||||
|
|
||||||
|
for i := range loginMaxFailures - 1 {
|
||||||
|
l.fail("[email protected]")
|
||||||
|
if l.locked("[email protected]") {
|
||||||
|
t.Fatalf("locked after %d failures, limit is %d", i+1, loginMaxFailures)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
l.fail("[email protected]")
|
||||||
|
if !l.locked("[email protected]") {
|
||||||
|
t.Fatalf("not locked after %d failures", loginMaxFailures)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The limit is per email: locking one address must not lock anyone else out.
|
||||||
|
if l.locked("[email protected]") {
|
||||||
|
t.Fatal("a different address was locked too")
|
||||||
|
}
|
||||||
|
|
||||||
|
// A correct password clears it, so a member who mistypes nine times and then gets it right
|
||||||
|
// starts from zero.
|
||||||
|
l.succeed("[email protected]")
|
||||||
|
if l.locked("[email protected]") {
|
||||||
|
t.Fatal("still locked after a successful login")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Failures older than the window don't accumulate.
|
||||||
|
for range loginMaxFailures - 1 {
|
||||||
|
l.fail("[email protected]")
|
||||||
|
}
|
||||||
|
l.by["[email protected]"].first = time.Now().Add(-loginWindow - time.Minute)
|
||||||
|
l.fail("[email protected]")
|
||||||
|
if l.locked("[email protected]") {
|
||||||
|
t.Fatal("failures outside the window were counted")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"embed"
|
||||||
|
"html/template"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed templates static
|
||||||
|
var assetFS embed.FS
|
||||||
|
|
||||||
|
var funcs = template.FuncMap{
|
||||||
|
"fidate": func(t time.Time) string { return t.Local().Format("2.1.2006 15:04") },
|
||||||
|
}
|
||||||
|
|
||||||
|
// Each page is parsed with the layout into its own set, so two pages may both define "content".
|
||||||
|
var pages = map[string]*template.Template{}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
entries, err := assetFS.ReadDir("templates")
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
for _, e := range entries {
|
||||||
|
if e.Name() == "layout.html" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
pages[e.Name()] = template.Must(template.New("layout.html").Funcs(funcs).
|
||||||
|
ParseFS(assetFS, "templates/layout.html", "templates/"+e.Name()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// page is everything the layout needs, plus whatever the page itself wants in Data.
|
||||||
|
type page struct {
|
||||||
|
Title string
|
||||||
|
Member *member
|
||||||
|
Admin bool
|
||||||
|
Flash string
|
||||||
|
Path string
|
||||||
|
Data any
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *app) render(w http.ResponseWriter, r *http.Request, status int, name string, p page) {
|
||||||
|
t, ok := pages[name]
|
||||||
|
if !ok {
|
||||||
|
slog.Error("unknown template", "name", name)
|
||||||
|
http.Error(w, "template", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
p.Member = memberFrom(r.Context())
|
||||||
|
p.Path = r.URL.Path
|
||||||
|
p.Flash = a.takeFlash(w, r)
|
||||||
|
|
||||||
|
// Render to memory first: a template that fails halfway must not leave a half-written 200.
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := t.ExecuteTemplate(&buf, "layout.html", p); err != nil {
|
||||||
|
slog.Error("render", "name", name, "error", err)
|
||||||
|
http.Error(w, "template", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
w.WriteHeader(status)
|
||||||
|
buf.WriteTo(w)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Toasts are a cookie rendered server-side and cleared on read — no JS, no session storage.
|
||||||
|
func (a *app) flash(w http.ResponseWriter, msg string) {
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: "flash", Value: url.QueryEscape(msg), Path: "/",
|
||||||
|
HttpOnly: true, Secure: a.cfg.secureCookies, SameSite: http.SameSiteLaxMode,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *app) takeFlash(w http.ResponseWriter, r *http.Request) string {
|
||||||
|
c, err := r.Cookie("flash")
|
||||||
|
if err != nil || c.Value == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: "flash", Value: "", Path: "/", MaxAge: -1,
|
||||||
|
HttpOnly: true, Secure: a.cfg.secureCookies, SameSite: http.SameSiteLaxMode,
|
||||||
|
})
|
||||||
|
msg, err := url.QueryUnescape(c.Value)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return msg
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
/* Theme tokens first — the dark rock/metal look lives here and nowhere else. */
|
||||||
|
:root {
|
||||||
|
--bg: #121212;
|
||||||
|
--surface: #1c1c1e;
|
||||||
|
--surface-2: #26262a;
|
||||||
|
--border: #35353a;
|
||||||
|
--text: #ece9e6;
|
||||||
|
--muted: #9a948d;
|
||||||
|
--accent: #ff5722;
|
||||||
|
--accent-2: #c62828;
|
||||||
|
--error: #ef5350;
|
||||||
|
--radius: 4px;
|
||||||
|
/* ponytail: system stack until an Oswald woff2 is vendored into /static. */
|
||||||
|
--font-head: "Oswald", "Fira Sans Condensed", "Arial Narrow", system-ui, sans-serif;
|
||||||
|
--font-body: system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: var(--font-body);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1, h2, h3 {
|
||||||
|
font-family: var(--font-head);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
margin: 0 0 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 { color: var(--accent); font-size: 1.9rem; }
|
||||||
|
h2 { font-size: 1.2rem; border-bottom: 1px solid var(--border); padding-bottom: 0.3rem; }
|
||||||
|
|
||||||
|
a { color: var(--accent); }
|
||||||
|
|
||||||
|
header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 0.8rem 1.2rem;
|
||||||
|
background: var(--surface);
|
||||||
|
border-bottom: 2px solid var(--accent-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand {
|
||||||
|
font-family: var(--font-head);
|
||||||
|
font-size: 1.3rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
text-decoration: none;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
nav { display: flex; align-items: center; gap: 1rem; }
|
||||||
|
nav a { text-decoration: none; }
|
||||||
|
|
||||||
|
main { max-width: 52rem; margin: 0 auto; padding: 1.5rem 1.2rem 4rem; }
|
||||||
|
section { margin-bottom: 2.5rem; }
|
||||||
|
|
||||||
|
.muted { color: var(--muted); }
|
||||||
|
.error { color: var(--error); display: block; font-size: 0.9rem; }
|
||||||
|
|
||||||
|
.tag {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
background: var(--accent-2);
|
||||||
|
padding: 0.1rem 0.4rem;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
.flash {
|
||||||
|
max-width: 52rem;
|
||||||
|
margin: 1rem auto 0;
|
||||||
|
padding: 0.7rem 1rem;
|
||||||
|
background: var(--surface-2);
|
||||||
|
border-left: 3px solid var(--accent);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 2rem;
|
||||||
|
height: 2rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--accent-2);
|
||||||
|
font-family: var(--font-head);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
form.stack { display: flex; flex-direction: column; gap: 0.9rem; max-width: 24rem; }
|
||||||
|
form.stack label { display: flex; flex-direction: column; gap: 0.25rem; }
|
||||||
|
form.stack label.row { flex-direction: row; align-items: center; gap: 0.5rem; }
|
||||||
|
|
||||||
|
input {
|
||||||
|
background: var(--surface-2);
|
||||||
|
color: var(--text);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 0.5rem 0.6rem;
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
input:focus-visible, button:focus-visible, a:focus-visible {
|
||||||
|
outline: 2px solid var(--accent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
background: var(--accent);
|
||||||
|
color: #150c07;
|
||||||
|
border: 0;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 0.5rem 0.9rem;
|
||||||
|
font: inherit;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:hover { background: #ff7043; }
|
||||||
|
|
||||||
|
button.link {
|
||||||
|
background: none;
|
||||||
|
color: var(--accent);
|
||||||
|
padding: 0;
|
||||||
|
font-weight: normal;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
table { width: 100%; border-collapse: collapse; margin-top: 0.8rem; }
|
||||||
|
th, td { text-align: left; padding: 0.5rem 0.4rem; border-bottom: 1px solid var(--border); }
|
||||||
|
th { font-family: var(--font-head); text-transform: uppercase; font-size: 0.8rem; color: var(--muted); }
|
||||||
|
tr.banned { opacity: 0.55; }
|
||||||
|
|
||||||
|
.actions { display: flex; flex-wrap: wrap; gap: 0.4rem; }
|
||||||
|
.actions form { display: flex; gap: 0.3rem; }
|
||||||
|
.actions input { width: 10rem; }
|
||||||
|
|
||||||
|
code { background: var(--surface-2); padding: 0.1rem 0.35rem; border-radius: var(--radius); }
|
||||||
|
|
||||||
|
.invite { word-break: break-all; }
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
{{define "content"}}
|
||||||
|
<h1>Ylläpito</h1>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Kutsut</h2>
|
||||||
|
<form method="post" action="/admin/invites"><button type="submit">Luo kutsukoodi</button></form>
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>Kutsulinkki</th><th>Tila</th><th>Luotu</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .Data.Invites}}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
{{if .IsValid}}
|
||||||
|
<a href="{{.Link}}" class="invite">{{.Link}}</a>
|
||||||
|
{{else}}
|
||||||
|
<code class="muted">{{.Code}}</code>
|
||||||
|
{{end}}
|
||||||
|
</td>
|
||||||
|
<td>{{if .IsValid}}käyttämätön{{else}}käytetty{{end}}</td>
|
||||||
|
<td>{{fidate .CreatedAt}}</td>
|
||||||
|
</tr>
|
||||||
|
{{else}}
|
||||||
|
<tr><td colspan="3" class="muted">Ei kutsuja.</td></tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<p class="muted">Lähetä linkki kaverille — se avaa liittymislomakkeen koodi valmiiksi täytettynä.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Jäsenet</h2>
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>Nimi</th><th>Sähköposti</th><th>Liittyi</th><th>Toiminnot</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .Data.Members}}
|
||||||
|
<tr{{if .Banned}} class="banned"{{end}}>
|
||||||
|
<td>{{.Name}}{{if .Banned}} <span class="tag">estetty</span>{{end}}</td>
|
||||||
|
<td>{{.Email}}</td>
|
||||||
|
<td>{{fidate .CreatedAt}}</td>
|
||||||
|
<td class="actions">
|
||||||
|
<form method="post" action="/admin/users/{{.ID}}/ban">
|
||||||
|
<button type="submit">{{if .Banned}}Poista esto{{else}}Estä{{end}}</button>
|
||||||
|
</form>
|
||||||
|
<form method="post" action="/admin/users/{{.ID}}/password">
|
||||||
|
<input type="password" name="password" placeholder="uusi salasana" required>
|
||||||
|
<button type="submit">Vaihda salasana</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{{else}}
|
||||||
|
<tr><td colspan="4" class="muted">Ei jäseniä. Luo kutsukoodi ja lähetä se jollekulle.</td></tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
{{end}}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{{define "content"}}
|
||||||
|
<h1>Jono</h1>
|
||||||
|
<p class="muted">Jono on tyhjä — kappaleita ei vielä voi lähettää. Tämä sivu täyttyy kun
|
||||||
|
lähetysputki ja arvostelut ovat valmiit.</p>
|
||||||
|
{{end}}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="fi">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>{{.Title}} — Levyraati</title>
|
||||||
|
<link rel="stylesheet" href="/static/style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<a class="brand" href="/">Levyraati{{if .Admin}} <span class="tag">ylläpito</span>{{end}}</a>
|
||||||
|
<nav>
|
||||||
|
{{if .Admin}}
|
||||||
|
<a href="/admin">Ylläpito</a>
|
||||||
|
{{else if .Member}}
|
||||||
|
<a href="/">Jono</a>
|
||||||
|
<span class="avatar" title="{{.Member.Name}}">{{.Member.Initials}}</span>
|
||||||
|
<form method="post" action="/logout"><button class="link">Kirjaudu ulos</button></form>
|
||||||
|
{{else}}
|
||||||
|
<a href="/login">Kirjaudu</a>
|
||||||
|
{{end}}
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{{with .Flash}}<p class="flash">{{.}}</p>{{end}}
|
||||||
|
|
||||||
|
<main>{{template "content" .}}</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
{{define "content"}}
|
||||||
|
<h1>Kirjaudu</h1>
|
||||||
|
|
||||||
|
{{with .Data.Errors.form}}<p class="error">{{.}}</p>{{end}}
|
||||||
|
|
||||||
|
<form method="post" action="/login" class="stack">
|
||||||
|
<label>Sähköposti
|
||||||
|
<input type="email" name="email" value="{{.Data.Email}}" required autofocus autocomplete="email">
|
||||||
|
</label>
|
||||||
|
<label>Salasana
|
||||||
|
<input type="password" name="password" required autocomplete="current-password">
|
||||||
|
</label>
|
||||||
|
<label class="row">
|
||||||
|
<input type="checkbox" name="remember" value="1"> Pysy kirjautuneena 30 päivää
|
||||||
|
</label>
|
||||||
|
<button type="submit">Kirjaudu</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p class="muted">Levyraati on kutsuvierasklubi. Kutsukoodilla pääset mukaan
|
||||||
|
<a href="/register">tästä</a>.</p>
|
||||||
|
{{end}}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
{{define "content"}}
|
||||||
|
<h1>Liity</h1>
|
||||||
|
|
||||||
|
<form method="post" action="/register" class="stack">
|
||||||
|
<label>Kutsukoodi
|
||||||
|
<input name="code" value="{{.Data.Code}}" required>
|
||||||
|
{{with .Data.Errors.code}}<span class="error">{{.}}</span>{{end}}
|
||||||
|
</label>
|
||||||
|
<label>Nimi
|
||||||
|
<input name="name" value="{{.Data.Name}}" required maxlength="50">
|
||||||
|
{{with .Data.Errors.name}}<span class="error">{{.}}</span>{{end}}
|
||||||
|
</label>
|
||||||
|
<label>Sähköposti
|
||||||
|
<input type="email" name="email" value="{{.Data.Email}}" required autocomplete="email">
|
||||||
|
{{with .Data.Errors.email}}<span class="error">{{.}}</span>{{end}}
|
||||||
|
</label>
|
||||||
|
<label>Salasana
|
||||||
|
<input type="password" name="password" required autocomplete="new-password">
|
||||||
|
{{with .Data.Errors.password}}<span class="error">{{.}}</span>{{end}}
|
||||||
|
</label>
|
||||||
|
<button type="submit">Liity</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p class="muted">Sähköpostiosoite on kirjautumistunnuksesi. Levyraati ei lähetä sähköpostia.</p>
|
||||||
|
{{end}}
|
||||||
Reference in New Issue
Block a user