feat: seed script, forgot-password, hover cards, shortcuts, OpenAPI docs, runbook (phase 12)
- scripts/seed.go: idempotent demo data per §11.11 (make seed) - forgot/reset password: SMTP-gated, one-shot TTL tokens, uniform responses against enumeration, sessions revoked on reset; login page link + pages - profile hover cards on [data-user-card] elements (§11.13) - keyboard shortcuts: g b/m/t/h navigation, / focuses search (§10) - bulk archive endpoint (§11.9) - hand-written OpenAPI 3.1 covering §6, served at /api/docs + yaml download - make backup / make restore (mongodump archive via the mongo container) - README: quick start, demo data, runbook, breaker/job operations, working Caddy + nginx reverse-proxy samples (WS block, client_max_body_size), documented later-stubs (§11.24) - smoke.sh now exercises register → logout → login → me → board → pages Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,43 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/smtp"
|
||||
"strings"
|
||||
|
||||
"bountyboard/internal/config"
|
||||
)
|
||||
|
||||
// Mailer sends plain-text email via SMTP (net/smtp, STARTTLS when offered).
|
||||
// All features depending on it stay disabled until SMTP_HOST is set (§7).
|
||||
type Mailer struct {
|
||||
cfg config.SMTP
|
||||
}
|
||||
|
||||
func NewMailer(cfg config.SMTP) *Mailer { return &Mailer{cfg: cfg} }
|
||||
|
||||
func (m *Mailer) Enabled() bool { return m.cfg.Enabled() }
|
||||
|
||||
func (m *Mailer) Send(to, subject, body string) error {
|
||||
if !m.Enabled() {
|
||||
return fmt.Errorf("smtp is not configured")
|
||||
}
|
||||
addr := fmt.Sprintf("%s:%d", m.cfg.Host, m.cfg.Port)
|
||||
msg := strings.Join([]string{
|
||||
"From: " + m.cfg.From,
|
||||
"To: " + to,
|
||||
"Subject: " + subject,
|
||||
"MIME-Version: 1.0",
|
||||
"Content-Type: text/plain; charset=utf-8",
|
||||
"",
|
||||
body,
|
||||
}, "\r\n")
|
||||
var a smtp.Auth
|
||||
if m.cfg.User != "" {
|
||||
a = smtp.PlainAuth("", m.cfg.User, m.cfg.Password, m.cfg.Host)
|
||||
}
|
||||
if err := smtp.SendMail(addr, a, m.cfg.From, []string{to}, []byte(msg)); err != nil {
|
||||
return fmt.Errorf("smtp send: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -24,6 +24,79 @@ func (s *Server) routesAuth(mux *http.ServeMux) {
|
||||
mux.Handle("GET /api/v1/auth/me", s.requireAuth(http.HandlerFunc(s.handleMe)))
|
||||
mux.HandleFunc("GET /api/v1/auth/oidc/login", s.handleOIDCLogin)
|
||||
mux.HandleFunc("GET /api/v1/auth/oidc/callback", s.handleOIDCCallback)
|
||||
mux.HandleFunc("POST /api/v1/auth/forgot", s.handleForgotPassword)
|
||||
mux.HandleFunc("POST /api/v1/auth/reset", s.handleResetPassword)
|
||||
}
|
||||
|
||||
// handleForgotPassword issues a one-shot reset token by email (§11.22).
|
||||
// Active only when SMTP is configured; never reveals account existence.
|
||||
func (s *Server) handleForgotPassword(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Email string `json:"email"`
|
||||
}
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
if s.mailer == nil || !s.mailer.Enabled() {
|
||||
writeError(w, http.StatusServiceUnavailable, "smtp_disabled",
|
||||
"password reset by email is not configured on this server")
|
||||
return
|
||||
}
|
||||
if !s.loginLimiter.Allow("forgot|" + ClientIP(r.Context()).String()) {
|
||||
writeError(w, http.StatusTooManyRequests, "rate_limited", "too many attempts")
|
||||
return
|
||||
}
|
||||
u, err := s.store.UserByEmail(r.Context(), req.Email)
|
||||
if err == nil && u.Auth.Local != nil && !u.Disabled {
|
||||
token := auth.NewToken()
|
||||
if err := s.store.CreatePasswordReset(r.Context(), token, u.ID, time.Hour); err != nil {
|
||||
s.internalError(w, r, "create reset", err)
|
||||
return
|
||||
}
|
||||
link := s.cfg.AppBaseURL + "/reset-password?token=" + token
|
||||
go func() {
|
||||
if err := s.mailer.Send(u.Email, "Reset your Bounty Board password",
|
||||
"Use this link within one hour to set a new password:\n\n"+link+
|
||||
"\n\nIf you did not request this, ignore this email."); err != nil {
|
||||
s.log.Error("send reset mail", "err", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
// uniform response regardless of account existence
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "sent_if_account_exists"})
|
||||
}
|
||||
|
||||
func (s *Server) handleResetPassword(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Token string `json:"token"`
|
||||
NewPassword string `json:"newPassword"`
|
||||
}
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
if len(req.NewPassword) < auth.MinPasswordLen {
|
||||
writeError(w, http.StatusBadRequest, "weak_password",
|
||||
fmt.Sprintf("password must be at least %d characters", auth.MinPasswordLen))
|
||||
return
|
||||
}
|
||||
userID, err := s.store.ConsumePasswordReset(r.Context(), req.Token)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_token", "this reset link is invalid or expired")
|
||||
return
|
||||
}
|
||||
hash, err := auth.HashPassword(req.NewPassword)
|
||||
if err != nil {
|
||||
s.internalError(w, r, "hash password", err)
|
||||
return
|
||||
}
|
||||
if err := s.store.SetPassword(r.Context(), userID, hash, false); err != nil {
|
||||
s.internalError(w, r, "set password", err)
|
||||
return
|
||||
}
|
||||
if _, err := s.store.DeleteUserSessions(r.Context(), userID); err != nil {
|
||||
s.log.Warn("revoke sessions after reset", "err", err)
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// decodeJSON reads a bounded JSON body into dst, rejecting unknown fields.
|
||||
|
||||
@@ -28,6 +28,7 @@ type Server struct {
|
||||
files *files.Store
|
||||
templates map[string]*template.Template
|
||||
oidc *auth.OIDCClient
|
||||
mailer *auth.Mailer
|
||||
loginLimiter *auth.RateLimiter
|
||||
checks []ReadinessCheck
|
||||
startedAt time.Time
|
||||
@@ -72,6 +73,7 @@ func New(cfg *config.Config, log *slog.Logger, reg *metrics.Registry, st *store.
|
||||
files: fs,
|
||||
templates: templates,
|
||||
oidc: auth.NewOIDCClient(cfg, log),
|
||||
mailer: auth.NewMailer(cfg.SMTP),
|
||||
loginLimiter: auth.NewRateLimiter(10, 15*time.Minute),
|
||||
startedAt: time.Now(),
|
||||
}
|
||||
@@ -89,6 +91,7 @@ func New(cfg *config.Config, log *slog.Logger, reg *metrics.Registry, st *store.
|
||||
s.routesWorkResults(mux)
|
||||
s.routesMessages(mux)
|
||||
s.routesMetrics(mux)
|
||||
s.routesDocs(mux)
|
||||
mux.HandleFunc("GET /ws", s.handleWS)
|
||||
s.routesWeb(mux)
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ func (s *Server) routesTasks(mux *http.ServeMux) {
|
||||
mux.Handle("POST /api/v1/tasks/{id}/publish", s.authedRole("consultant", s.handlePublishOne))
|
||||
mux.Handle("POST /api/v1/tasks/publish", s.authedRole("consultant", s.handlePublishBulk))
|
||||
mux.Handle("POST /api/v1/tasks/{id}/archive", s.authed(s.handleArchiveTask))
|
||||
mux.Handle("POST /api/v1/tasks/archive", s.authedRole("consultant", s.handleArchiveBulk))
|
||||
mux.Handle("GET /api/v1/tasks/{id}", s.requireAuth(http.HandlerFunc(s.handleTaskDetail)))
|
||||
mux.Handle("GET /api/v1/service-health", s.requireAuth(http.HandlerFunc(s.handleServiceHealth)))
|
||||
}
|
||||
@@ -405,6 +406,44 @@ func (s *Server) handleArchiveTask(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// handleArchiveBulk implements §11.9 bulk archive.
|
||||
func (s *Server) handleArchiveBulk(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
IDs []string `json:"ids"`
|
||||
}
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
if len(req.IDs) == 0 || len(req.IDs) > 100 {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "ids must contain 1..100 entries")
|
||||
return
|
||||
}
|
||||
u := CurrentUser(r.Context())
|
||||
archived := []string{}
|
||||
failed := map[string]string{}
|
||||
for _, id := range req.IDs {
|
||||
t, err := s.store.TaskByID(r.Context(), id)
|
||||
if err != nil {
|
||||
failed[id] = "not found"
|
||||
continue
|
||||
}
|
||||
c, err := s.store.CustomerByID(r.Context(), t.CustomerID)
|
||||
if err != nil || (!u.Roles.Admin && !c.HasConsultant(u.ID)) {
|
||||
failed[id] = "not your customer"
|
||||
continue
|
||||
}
|
||||
if err := s.store.TransitionTask(r.Context(), t, domain.StatusArchived, u.ID, "archived", nil, nil); err != nil {
|
||||
failed[id] = err.Error()
|
||||
continue
|
||||
}
|
||||
archived = append(archived, id)
|
||||
}
|
||||
if len(archived) > 0 {
|
||||
s.Publish("board", "task.archived_bulk", map[string]any{"taskIds": archived})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"archived": archived, "failed": failed})
|
||||
}
|
||||
|
||||
// handleTaskDetail is role-scoped: admins and customer consultants always;
|
||||
// developers when they are the assignee, have a claim, or the task is on the
|
||||
// public board (published/claim_requested).
|
||||
|
||||
@@ -2,12 +2,14 @@ package httpx
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"html"
|
||||
"html/template"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"bountyboard/api"
|
||||
"bountyboard/internal/domain"
|
||||
"bountyboard/web"
|
||||
)
|
||||
@@ -155,6 +157,17 @@ func (s *Server) routesWeb(mux *http.ServeMux) {
|
||||
})
|
||||
})
|
||||
|
||||
for _, p := range []struct{ path, tmpl, title, script string }{
|
||||
{"/forgot-password", "forgot-password.html", "Forgot password", "/static/js/forgot-password.js"},
|
||||
{"/reset-password", "reset-password.html", "Reset password", "/static/js/reset-password.js"},
|
||||
} {
|
||||
mux.HandleFunc("GET "+p.path, func(w http.ResponseWriter, r *http.Request) {
|
||||
s.render(w, r, p.tmpl, &pageData{
|
||||
Title: p.title, Narrow: true, Scripts: []string{p.script},
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
mux.HandleFunc("GET /change-password", s.page(func(w http.ResponseWriter, r *http.Request, u *domain.User) {
|
||||
s.render(w, r, "change-password.html", &pageData{
|
||||
Title: "Change password", User: u, Narrow: true,
|
||||
@@ -223,6 +236,24 @@ func (s *Server) routesWeb(mux *http.ServeMux) {
|
||||
rolePage("/metrics", "metrics.html", "Metrics", "metrics", "/static/js/metrics.js", nil)
|
||||
}
|
||||
|
||||
// routesDocs serves the OpenAPI document and a minimal viewer (§6).
|
||||
func (s *Server) routesDocs(mux *http.ServeMux) {
|
||||
mux.HandleFunc("GET /api/openapi.yaml", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/yaml")
|
||||
w.Write(api.OpenAPI)
|
||||
})
|
||||
mux.HandleFunc("GET /api/docs", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Write([]byte(`<!doctype html><html lang="en"><head><meta charset="utf-8">
|
||||
<title>Bounty Board API</title><link rel="stylesheet" href="/static/css/app.css">
|
||||
<script src="/static/js/theme.js"></script></head>
|
||||
<body><main class="container"><h1>Bounty Board API</h1>
|
||||
<p><a class="btn" href="/api/openapi.yaml" download>Download openapi.yaml</a></p>
|
||||
<pre style="white-space:pre-wrap;background:var(--surface);border:1px solid var(--border);padding:16px;border-radius:var(--radius)">` +
|
||||
html.EscapeString(string(api.OpenAPI)) + `</pre></main></body></html>`))
|
||||
})
|
||||
}
|
||||
|
||||
func loginErrorMessage(code string) string {
|
||||
switch code {
|
||||
case "":
|
||||
|
||||
@@ -51,10 +51,6 @@ func EnsureIndexes(ctx context.Context, db *mongo.Database) error {
|
||||
{Keys: bson.D{{Key: "participantIds", Value: 1}, {Key: "lastMessageAt", Value: -1}}},
|
||||
{Keys: bson.D{{Key: "kind", Value: 1}, {Key: "customerId", Value: 1}}},
|
||||
},
|
||||
"messages": {
|
||||
// ULID _id is time-ordered, so this serves history pagination.
|
||||
{Keys: bson.D{{Key: "conversationId", Value: 1}, {Key: "_id", Value: 1}}},
|
||||
},
|
||||
"notifications": {
|
||||
{Keys: bson.D{{Key: "userId", Value: 1}, {Key: "readAt", Value: 1}, {Key: "createdAt", Value: -1}}},
|
||||
},
|
||||
@@ -62,6 +58,14 @@ func EnsureIndexes(ctx context.Context, db *mongo.Database) error {
|
||||
{Keys: bson.D{{Key: "expiresAt", Value: 1}}, Options: options.Index().SetExpireAfterSeconds(0)},
|
||||
{Keys: bson.D{{Key: "userId", Value: 1}}},
|
||||
},
|
||||
"passwordResets": {
|
||||
{Keys: bson.D{{Key: "expiresAt", Value: 1}}, Options: options.Index().SetExpireAfterSeconds(0)},
|
||||
},
|
||||
"messages": {
|
||||
// ULID _id is time-ordered, so this serves history pagination.
|
||||
{Keys: bson.D{{Key: "conversationId", Value: 1}, {Key: "_id", Value: 1}}},
|
||||
{Keys: bson.D{{Key: "attachments.fileId", Value: 1}}, Options: options.Index().SetSparse(true)},
|
||||
},
|
||||
"auditLog": {
|
||||
{Keys: bson.D{{Key: "at", Value: -1}}},
|
||||
{Keys: bson.D{{Key: "entityId", Value: 1}, {Key: "at", Value: -1}}},
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||
)
|
||||
|
||||
// PasswordReset is a one-shot token (§11.22), reaped by TTL index.
|
||||
type PasswordReset struct {
|
||||
Token string `bson:"_id"`
|
||||
UserID string `bson:"userId"`
|
||||
ExpiresAt time.Time `bson:"expiresAt"`
|
||||
}
|
||||
|
||||
func (s *Store) CreatePasswordReset(ctx context.Context, token, userID string, ttl time.Duration) error {
|
||||
_, err := s.DB.Collection("passwordResets").InsertOne(ctx, PasswordReset{
|
||||
Token: token, UserID: userID, ExpiresAt: time.Now().UTC().Add(ttl),
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("create password reset: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ConsumePasswordReset returns the userId for a live token and deletes it
|
||||
// atomically (single use).
|
||||
func (s *Store) ConsumePasswordReset(ctx context.Context, token string) (string, error) {
|
||||
var pr PasswordReset
|
||||
err := s.DB.Collection("passwordResets").FindOneAndDelete(ctx, bson.M{
|
||||
"_id": token,
|
||||
"expiresAt": bson.M{"$gt": time.Now().UTC()},
|
||||
}).Decode(&pr)
|
||||
if errors.Is(err, mongo.ErrNoDocuments) {
|
||||
return "", ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("consume password reset: %w", err)
|
||||
}
|
||||
return pr.UserID, nil
|
||||
}
|
||||
Reference in New Issue
Block a user