diff --git a/README.md b/README.md index 7e6f218..b8060f8 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,30 @@ togo install togo-framework/auth ``` +## Admin user management + +Every app that mounts auth gets a guarded user-admin + impersonation API out of +the box, under `/api/auth/admin/*` (behind `RequireRole("admin")`; writes also +carry the CSRF guard). It reuses the same `users` table, bcrypt hashing, and +`IssueToken`/`IssueSession` the login flow uses. + +| Method | Path | Body | Returns | +|--------|------|------|---------| +| GET | `/api/auth/admin/users` `?q=` | — | `[]user` | +| GET | `/api/auth/admin/users/{id}` | — | `user` | +| POST | `/api/auth/admin/users` | `{email, password?, roles?[], permissions?[]}` | `{user, note}` (201) | +| PATCH | `/api/auth/admin/users/{id}` | `{email?, roles?[], permissions?[]}` | `user` | +| DELETE | `/api/auth/admin/users/{id}` | — | `{deleted, id}` (409 on last admin) | +| POST | `/api/auth/admin/users/{id}/impersonate` | — | `{token, identity}` | +| POST | `/api/auth/admin/users/{id}/reset-password` | `{password?}` | `{reset:true}` or `{link, emailed:false}` | +| POST | `/api/auth/admin/users/{id}/magic-link` | — | `{link, emailed:false}` | +| GET | `/api/auth/magic` `?token=` | — | 302 → signs the user in | + +Mail is decoupled: when no mailer is wired the signed (HMAC over `AUTH_SECRET`, +~1h TTL) magic/reset link is returned in the response and an +`auth.magic_link_issued` / `auth.password_reset` event is fired so a mail plugin +can deliver it. + ## Frontend UI lives in the separate [dashboard](https://github.com/togo-framework/dashboard) diff --git a/admin.go b/admin.go new file mode 100644 index 0000000..836fb97 --- /dev/null +++ b/admin.go @@ -0,0 +1,392 @@ +package auth + +import ( + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "fmt" + "net/http" + "strconv" + "strings" + "time" + + "github.com/go-chi/chi/v5" +) + +// Admin user-management surface. Every togo app that mounts auth gets a guarded +// CRUD + impersonation + reset/magic-link API out of the box, backed by the same +// `users` table, password hashing (bcrypt) and IssueToken the login flow uses. +// +// Routes mount under /api/auth/admin/* behind RequireRole("admin"); writes also +// carry the double-submit CSRF guard (bearer requests are exempt, like the rest +// of the plugin). The signed magic/reset links consume at the public +// /api/auth/magic endpoint and are HMAC'd with the plugin's existing AUTH_SECRET +// — no new secret, no SMTP dependency: when no mailer is wired the link is +// returned in the response (emailed:false) and an event is fired so a mail +// plugin can deliver it. + +// adminUser is the admin-facing shape of an account: roles/permissions are parsed +// from the comma-encoded TEXT columns into arrays. +type adminUser struct { + ID string `json:"id"` + Email string `json:"email"` + Roles []string `json:"roles"` + Permissions []string `json:"permissions"` + CreatedAt string `json:"created_at"` +} + +func listOf(s string) []string { + if v := splitCSV(s); v != nil { + return v + } + return []string{} +} + +func writeErr(w http.ResponseWriter, status int, msg string) { + writeJSON(w, status, map[string]string{"error": msg}) +} + +// mountAdminRoutes wires the admin user-management API + the public magic-link +// consume endpoint. Called from mountRoutes. +func (s *Service) mountAdminRoutes(r chi.Router) { + r.Route("/api/auth/admin", func(ar chi.Router) { + ar.Use(s.RequireRole("admin")) + ar.Get("/users", s.adminListUsersHandler) + ar.Get("/users/{id}", s.adminGetUserHandler) + ar.With(s.csrfGuard).Post("/users", s.adminCreateUser) + ar.With(s.csrfGuard).Patch("/users/{id}", s.adminUpdateUser) + ar.With(s.csrfGuard).Delete("/users/{id}", s.adminDeleteUser) + ar.With(s.csrfGuard).Post("/users/{id}/impersonate", s.adminImpersonate) + ar.With(s.csrfGuard).Post("/users/{id}/reset-password", s.adminResetPassword) + ar.With(s.csrfGuard).Post("/users/{id}/magic-link", s.adminMagicLink) + }) + // Public auto-login target for magic + admin-issued reset links. + r.Get("/api/auth/magic", s.handleMagicConsume) +} + +// ---- lookups ---- + +// adminListUsers reads the users table directly (so created_at ordering and the +// raw roles/permissions columns are available for the admin surface). +func (s *Service) adminListUsers(ctx context.Context, q string) []adminUser { + db, err := s.k.SQL(ctx) + if err != nil || db == nil { + return []adminUser{} + } + rows, err := db.QueryContext(ctx, `SELECT id, email, COALESCE(roles,''), COALESCE(permissions,''), created_at FROM users ORDER BY created_at DESC LIMIT 500`) + if err != nil { + return []adminUser{} + } + defer rows.Close() + out := []adminUser{} + ql := strings.ToLower(strings.TrimSpace(q)) + for rows.Next() { + var u adminUser + var roles, perms, created string + if err := rows.Scan(&u.ID, &u.Email, &roles, &perms, &created); err != nil { + continue + } + u.Roles = listOf(roles) + u.Permissions = listOf(perms) + u.CreatedAt = created + if ql != "" && !strings.Contains(strings.ToLower(u.Email), ql) { + continue + } + out = append(out, u) + } + return out +} + +// adminFindUser resolves a user by id or email. +func (s *Service) adminFindUser(ctx context.Context, idOrEmail string) (adminUser, bool) { + for _, u := range s.adminListUsers(ctx, "") { + if u.ID == idOrEmail || u.Email == idOrEmail { + return u, true + } + } + return adminUser{}, false +} + +// ---- handlers ---- + +func (s *Service) adminListUsersHandler(w http.ResponseWriter, r *http.Request) { + writeJSON(w, http.StatusOK, s.adminListUsers(r.Context(), r.URL.Query().Get("q"))) +} + +func (s *Service) adminGetUserHandler(w http.ResponseWriter, r *http.Request) { + u, ok := s.adminFindUser(r.Context(), chi.URLParam(r, "id")) + if !ok { + writeErr(w, http.StatusNotFound, "user not found") + return + } + writeJSON(w, http.StatusOK, u) +} + +func (s *Service) adminCreateUser(w http.ResponseWriter, r *http.Request) { + var body struct { + Email string `json:"email"` + Password string `json:"password"` + Roles []string `json:"roles"` + Permissions []string `json:"permissions"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeErr(w, http.StatusBadRequest, err.Error()) + return + } + body.Email = strings.TrimSpace(strings.ToLower(body.Email)) + if body.Email == "" { + writeErr(w, http.StatusBadRequest, "email is required") + return + } + ctx := r.Context() + if _, exists := s.adminFindUser(ctx, body.Email); exists { + writeErr(w, http.StatusConflict, "a user with that email already exists") + return + } + // Create the passwordless account via the auth service, then set roles/pw. + if _, err := s.FindOrCreateByEmail(ctx, body.Email); err != nil { + writeErr(w, http.StatusInternalServerError, err.Error()) + return + } + db, err := s.k.SQL(ctx) + if err != nil || db == nil { + writeErr(w, http.StatusInternalServerError, "no database") + return + } + p := s.ph + _, _ = db.ExecContext(ctx, "UPDATE users SET roles="+p(1)+", permissions="+p(2)+" WHERE email="+p(3), + strings.Join(body.Roles, ","), strings.Join(body.Permissions, ","), body.Email) + note := "" + if body.Password != "" { + if h, herr := hashPassword(body.Password); herr == nil { + _, _ = db.ExecContext(ctx, "UPDATE users SET password_hash="+p(1)+" WHERE email="+p(2), h, body.Email) + } + } else { + note = "no password set — send a reset or magic link so the user can sign in" + } + u, _ := s.adminFindUser(ctx, body.Email) + s.fire(ctx, EventUserCreated, u) + writeJSON(w, http.StatusCreated, map[string]any{"user": u, "note": note}) +} + +func (s *Service) adminUpdateUser(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + u, ok := s.adminFindUser(ctx, chi.URLParam(r, "id")) + if !ok { + writeErr(w, http.StatusNotFound, "user not found") + return + } + var body struct { + Email *string `json:"email"` + Roles *[]string `json:"roles"` + Permissions *[]string `json:"permissions"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeErr(w, http.StatusBadRequest, err.Error()) + return + } + db, err := s.k.SQL(ctx) + if err != nil || db == nil { + writeErr(w, http.StatusInternalServerError, "no database") + return + } + p := s.ph + if body.Email != nil && *body.Email != "" { + _, _ = db.ExecContext(ctx, "UPDATE users SET email="+p(1)+" WHERE id="+p(2), strings.ToLower(*body.Email), u.ID) + } + if body.Roles != nil { + _, _ = db.ExecContext(ctx, "UPDATE users SET roles="+p(1)+" WHERE id="+p(2), strings.Join(*body.Roles, ","), u.ID) + } + if body.Permissions != nil { + _, _ = db.ExecContext(ctx, "UPDATE users SET permissions="+p(1)+" WHERE id="+p(2), strings.Join(*body.Permissions, ","), u.ID) + } + updated, _ := s.adminFindUser(ctx, u.ID) + s.fire(ctx, EventUserUpdated, updated) + writeJSON(w, http.StatusOK, updated) +} + +func (s *Service) adminDeleteUser(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + u, ok := s.adminFindUser(ctx, chi.URLParam(r, "id")) + if !ok { + writeErr(w, http.StatusNotFound, "user not found") + return + } + // Never delete the last admin. + admins := 0 + for _, x := range s.adminListUsers(ctx, "") { + if contains(x.Roles, "admin") { + admins++ + } + } + if contains(u.Roles, "admin") && admins <= 1 { + writeErr(w, http.StatusConflict, "cannot delete the last admin") + return + } + db, err := s.k.SQL(ctx) + if err != nil || db == nil { + writeErr(w, http.StatusInternalServerError, "no database") + return + } + p := s.ph + if _, err := db.ExecContext(ctx, "DELETE FROM users WHERE id="+p(1), u.ID); err != nil { + writeErr(w, http.StatusInternalServerError, err.Error()) + return + } + // Best-effort cleanup of the user's personal access tokens. + _, _ = db.ExecContext(ctx, "DELETE FROM personal_access_tokens WHERE user_id="+p(1), u.ID) + s.fire(ctx, EventUserDeleted, u) + writeJSON(w, http.StatusOK, map[string]any{"deleted": true, "id": u.ID}) +} + +func (s *Service) adminImpersonate(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + u, ok := s.adminFindUser(ctx, chi.URLParam(r, "id")) + if !ok { + writeErr(w, http.StatusNotFound, "user not found") + return + } + tok, err := s.IssueToken(Identity{ID: u.ID, Email: u.Email, Roles: u.Roles, Permissions: u.Permissions, Guard: s.def}) + if err != nil { + writeErr(w, http.StatusInternalServerError, err.Error()) + return + } + s.fire(ctx, EventUserImpersonated, u) + writeJSON(w, http.StatusOK, map[string]any{"token": tok, "identity": u}) +} + +func (s *Service) adminResetPassword(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + u, ok := s.adminFindUser(ctx, chi.URLParam(r, "id")) + if !ok { + writeErr(w, http.StatusNotFound, "user not found") + return + } + var body struct { + Password string `json:"password"` + } + _ = json.NewDecoder(r.Body).Decode(&body) + if body.Password != "" { + if err := validatePassword(body.Password); err != nil { + writeErr(w, http.StatusUnprocessableEntity, err.Error()) + return + } + db, err := s.k.SQL(ctx) + if err != nil || db == nil { + writeErr(w, http.StatusInternalServerError, "no database") + return + } + h, herr := hashPassword(body.Password) + if herr != nil { + writeErr(w, http.StatusInternalServerError, herr.Error()) + return + } + if _, err := db.ExecContext(ctx, "UPDATE users SET password_hash="+s.ph(1)+" WHERE id="+s.ph(2), h, u.ID); err != nil { + writeErr(w, http.StatusInternalServerError, err.Error()) + return + } + s.fire(ctx, EventPasswordChanged, u) + writeJSON(w, http.StatusOK, map[string]any{"reset": true}) + return + } + // No password → return (or, if a mailer is wired, email) a magic login link. + s.issueLinkResponse(w, r, u, EventPasswordReset) +} + +func (s *Service) adminMagicLink(w http.ResponseWriter, r *http.Request) { + u, ok := s.adminFindUser(r.Context(), chi.URLParam(r, "id")) + if !ok { + writeErr(w, http.StatusNotFound, "user not found") + return + } + s.issueLinkResponse(w, r, u, EventMagicLinkIssued) +} + +// issueLinkResponse builds a signed magic link, fires an event (so a mail plugin +// can deliver it), and returns {link, emailed}. Mail is out of scope here, so +// emailed is always false unless a listener sets a future delivery flag. +func (s *Service) issueLinkResponse(w http.ResponseWriter, r *http.Request, u adminUser, event string) { + link := s.magicLinkURL(r, u.ID) + s.fire(r.Context(), event, map[string]any{"email": u.Email, "user_id": u.ID, "link": link}) + writeJSON(w, http.StatusOK, map[string]any{"link": link, "emailed": false}) +} + +// handleMagicConsume verifies a signed link, issues a session for the target +// user, and redirects to the post-login URL. +func (s *Service) handleMagicConsume(w http.ResponseWriter, r *http.Request) { + uid, ok := s.verifyMagicToken(r.URL.Query().Get("token")) + if !ok { + writeErr(w, http.StatusUnauthorized, "invalid or expired link") + return + } + u, found := s.adminFindUser(r.Context(), uid) + if !found { + writeErr(w, http.StatusNotFound, "user not found") + return + } + if _, err := s.IssueSession(w, Identity{ID: u.ID, Email: u.Email, Roles: u.Roles, Permissions: u.Permissions, Guard: s.def}); err != nil { + writeErr(w, http.StatusInternalServerError, err.Error()) + return + } + http.Redirect(w, r, postLoginURL(), http.StatusFound) +} + +// ---- signed links (HMAC over the plugin's AUTH_SECRET) ---- + +const magicPurpose = "magic" + +func (s *Service) signMagicToken(uid string, ttl time.Duration) string { + payload := fmt.Sprintf("%s|%s|%d", uid, magicPurpose, time.Now().Add(ttl).Unix()) + mac := hmac.New(sha256.New, s.secret) + mac.Write([]byte(payload)) + sig := base64.RawURLEncoding.EncodeToString(mac.Sum(nil)) + return base64.RawURLEncoding.EncodeToString([]byte(payload)) + "." + sig +} + +func (s *Service) verifyMagicToken(tok string) (string, bool) { + parts := strings.SplitN(tok, ".", 2) + if len(parts) != 2 { + return "", false + } + payload, err := base64.RawURLEncoding.DecodeString(parts[0]) + if err != nil { + return "", false + } + mac := hmac.New(sha256.New, s.secret) + mac.Write(payload) + want := base64.RawURLEncoding.EncodeToString(mac.Sum(nil)) + if !hmac.Equal([]byte(want), []byte(parts[1])) { + return "", false + } + f := strings.Split(string(payload), "|") + if len(f) != 3 || f[1] != magicPurpose { + return "", false + } + exp, _ := strconv.ParseInt(f[2], 10, 64) + if time.Now().Unix() > exp { + return "", false + } + return f[0], true +} + +func (s *Service) magicLinkURL(r *http.Request, uid string) string { + base := firstEnv("AUTH_PUBLIC_URL", "APP_URL", "PUBLIC_URL") + if base == "" { + scheme := "http" + if r.TLS != nil { + scheme = "https" + } + base = scheme + "://" + r.Host + } + base = strings.TrimRight(base, "/") + return base + "/api/auth/magic?token=" + s.signMagicToken(uid, time.Hour) +} + +func postLoginURL() string { + if v := firstEnv("AUTH_POST_LOGIN_URL", "DASHBOARD_URL", "APP_URL"); v != "" { + return v + } + return "/" +} diff --git a/admin_test.go b/admin_test.go new file mode 100644 index 0000000..72e4cba --- /dev/null +++ b/admin_test.go @@ -0,0 +1,216 @@ +package auth + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/go-chi/chi/v5" + "github.com/togo-framework/togo" + + _ "modernc.org/sqlite" // registers the "sqlite" database/sql driver for tests +) + +// newTestService builds a Service backed by a fresh in-memory SQLite DB and the +// real admin routes mounted on a chi router. An admin bearer token is returned +// for authenticating against the guarded /api/auth/admin/* surface. +func newTestService(t *testing.T) (*Service, chi.Router, string) { + t.Helper() + dsn := fmt.Sprintf("file:authadmin_%d?mode=memory&cache=shared", time.Now().UnixNano()) + k := &togo.Kernel{ + Config: &togo.Config{DBDriver: "sqlite", DatabaseURL: dsn}, + Router: chi.NewMux(), + } + s := &Service{ + k: k, + secret: []byte("a-sufficiently-long-test-secret-string!!"), + ttl: time.Hour, + guards: map[string]*Guard{}, + def: "api", + } + s.RegisterGuard("api", &dbAuthenticator{s: s}) + if err := s.ensureSchema(context.Background()); err != nil { + t.Fatalf("ensureSchema: %v", err) + } + if err := s.ensurePATSchema(context.Background()); err != nil { + t.Fatalf("ensurePATSchema: %v", err) + } + s.mountAdminRoutes(k.Router) + // An admin caller (its identity carries the admin role for RequireRole). + adminTok, err := s.IssueToken(Identity{ID: "admin-caller", Email: "root@togo.dev", Roles: []string{"admin"}, Guard: "api"}) + if err != nil { + t.Fatalf("issue admin token: %v", err) + } + return s, k.Router, adminTok +} + +func do(t *testing.T, r chi.Router, method, path, bearer string, body any) (*httptest.ResponseRecorder, map[string]any) { + t.Helper() + var buf bytes.Buffer + if body != nil { + _ = json.NewEncoder(&buf).Encode(body) + } + req := httptest.NewRequest(method, path, &buf) + if bearer != "" { + req.Header.Set("Authorization", "Bearer "+bearer) + } + rec := httptest.NewRecorder() + r.ServeHTTP(rec, req) + out := map[string]any{} + _ = json.Unmarshal(rec.Body.Bytes(), &out) + return rec, out +} + +func TestAdminCreateListDelete(t *testing.T) { + s, r, admin := newTestService(t) + + // create + rec, out := do(t, r, http.MethodPost, "/api/auth/admin/users", admin, map[string]any{ + "email": "Alice@Example.com", "password": "supersecret123", "roles": []string{"editor"}, + }) + if rec.Code != http.StatusCreated { + t.Fatalf("create: want 201 got %d body=%s", rec.Code, rec.Body.String()) + } + u, _ := out["user"].(map[string]any) + if u["email"] != "alice@example.com" { + t.Fatalf("email not normalized: %v", u["email"]) + } + id, _ := u["id"].(string) + if id == "" { + t.Fatal("created user has no id") + } + + // the created password must authenticate via the same guard login uses + if _, err := s.Guard("").Auth.Attempt(context.Background(), "alice@example.com", "supersecret123"); err != nil { + t.Fatalf("created password does not authenticate: %v", err) + } + + // duplicate create → 409 + if rec2, _ := do(t, r, http.MethodPost, "/api/auth/admin/users", admin, map[string]any{"email": "alice@example.com"}); rec2.Code != http.StatusConflict { + t.Fatalf("duplicate: want 409 got %d", rec2.Code) + } + + // list + rec3 := httptest.NewRecorder() + req3 := httptest.NewRequest(http.MethodGet, "/api/auth/admin/users", nil) + req3.Header.Set("Authorization", "Bearer "+admin) + r.ServeHTTP(rec3, req3) + var list []adminUser + if err := json.Unmarshal(rec3.Body.Bytes(), &list); err != nil { + t.Fatalf("list decode: %v", err) + } + if len(list) != 1 || list[0].Email != "alice@example.com" { + t.Fatalf("list mismatch: %+v", list) + } + + // delete + if rec4, _ := do(t, r, http.MethodDelete, "/api/auth/admin/users/"+id, admin, nil); rec4.Code != http.StatusOK { + t.Fatalf("delete: want 200 got %d", rec4.Code) + } + rec5 := httptest.NewRecorder() + req5 := httptest.NewRequest(http.MethodGet, "/api/auth/admin/users", nil) + req5.Header.Set("Authorization", "Bearer "+admin) + r.ServeHTTP(rec5, req5) + var after []adminUser + _ = json.Unmarshal(rec5.Body.Bytes(), &after) + if len(after) != 0 { + t.Fatalf("user not deleted: %+v", after) + } +} + +func TestAdminDeleteLastAdminRefused(t *testing.T) { + _, r, admin := newTestService(t) + rec, out := do(t, r, http.MethodPost, "/api/auth/admin/users", admin, map[string]any{ + "email": "boss@example.com", "roles": []string{"admin"}, + }) + if rec.Code != http.StatusCreated { + t.Fatalf("create admin: %d", rec.Code) + } + id := out["user"].(map[string]any)["id"].(string) + if rec2, _ := do(t, r, http.MethodDelete, "/api/auth/admin/users/"+id, admin, nil); rec2.Code != http.StatusConflict { + t.Fatalf("delete last admin: want 409 got %d", rec2.Code) + } +} + +func TestAdminImpersonateTokenVerifies(t *testing.T) { + s, r, admin := newTestService(t) + _, out := do(t, r, http.MethodPost, "/api/auth/admin/users", admin, map[string]any{ + "email": "target@example.com", "roles": []string{"editor"}, "permissions": []string{"posts.write"}, + }) + id := out["user"].(map[string]any)["id"].(string) + + rec, imp := do(t, r, http.MethodPost, "/api/auth/admin/users/"+id+"/impersonate", admin, nil) + if rec.Code != http.StatusOK { + t.Fatalf("impersonate: want 200 got %d", rec.Code) + } + tok, _ := imp["token"].(string) + identity, err := s.Verify(tok) + if err != nil { + t.Fatalf("impersonation token does not verify: %v", err) + } + if identity.ID != id || identity.Email != "target@example.com" || !identity.HasRole("editor") || !identity.Can("posts.write") { + t.Fatalf("impersonation identity mismatch: %+v", identity) + } +} + +func TestAdminMagicLinkRoundTrip(t *testing.T) { + _, r, admin := newTestService(t) + _, out := do(t, r, http.MethodPost, "/api/auth/admin/users", admin, map[string]any{"email": "magic@example.com"}) + id := out["user"].(map[string]any)["id"].(string) + + rec, link := do(t, r, http.MethodPost, "/api/auth/admin/users/"+id+"/magic-link", admin, nil) + if rec.Code != http.StatusOK { + t.Fatalf("magic-link: want 200 got %d", rec.Code) + } + if link["emailed"] != false { + t.Fatalf("emailed should be false with no mailer: %v", link["emailed"]) + } + linkURL, _ := link["link"].(string) + if linkURL == "" { + t.Fatal("no link returned") + } + // Consume the link: it should issue a session cookie and redirect. + req := httptest.NewRequest(http.MethodGet, linkURL, nil) + rec2 := httptest.NewRecorder() + r.ServeHTTP(rec2, req) + if rec2.Code != http.StatusFound { + t.Fatalf("magic consume: want 302 got %d body=%s", rec2.Code, rec2.Body.String()) + } + var sessionSet bool + for _, c := range rec2.Result().Cookies() { + if c.Name == SessionCookie && c.Value != "" { + sessionSet = true + } + } + if !sessionSet { + t.Fatal("magic consume did not set a session cookie") + } + + // A tampered token must be rejected. + bad := httptest.NewRequest(http.MethodGet, "/api/auth/magic?token=deadbeef.deadbeef", nil) + recBad := httptest.NewRecorder() + r.ServeHTTP(recBad, bad) + if recBad.Code != http.StatusUnauthorized { + t.Fatalf("tampered token: want 401 got %d", recBad.Code) + } +} + +func TestAdminRequiresAdminRole(t *testing.T) { + s, r, _ := newTestService(t) + // A non-admin bearer is forbidden. + plain, _ := s.IssueToken(Identity{ID: "u9", Email: "nobody@example.com", Roles: []string{"editor"}, Guard: "api"}) + if rec, _ := do(t, r, http.MethodGet, "/api/auth/admin/users", plain, nil); rec.Code != http.StatusForbidden { + t.Fatalf("non-admin: want 403 got %d", rec.Code) + } + // No token at all is unauthorized. + rec := httptest.NewRecorder() + r.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/auth/admin/users", nil)) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("anon: want 401 got %d", rec.Code) + } +} diff --git a/events.go b/events.go index 60d873d..38254b7 100644 --- a/events.go +++ b/events.go @@ -11,6 +11,15 @@ const ( EventLogout = "auth.logout" EventPasswordChanged = "auth.password_changed" EventLoginFailed = "auth.login_failed" + + // Admin user-management events. Apps/plugins (audit, mail) subscribe to react — + // e.g. the mail plugin delivers the magic/reset link from EventMagicLinkIssued. + EventUserCreated = "auth.user_created" + EventUserUpdated = "auth.user_updated" + EventUserDeleted = "auth.user_deleted" + EventUserImpersonated = "auth.user_impersonated" + EventMagicLinkIssued = "auth.magic_link_issued" + EventPasswordReset = "auth.password_reset" ) // fire dispatches an auth event on the kernel hook bus (no-op if unavailable). diff --git a/go.mod b/go.mod index e98500d..319ad3c 100644 --- a/go.mod +++ b/go.mod @@ -9,4 +9,19 @@ require ( golang.org/x/crypto v0.27.0 ) -require github.com/go-chi/chi/v5 v5.1.0 +require ( + github.com/go-chi/chi/v5 v5.1.0 + modernc.org/sqlite v1.53.0 +) + +require ( + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + golang.org/x/sys v0.44.0 // indirect + modernc.org/libc v1.73.4 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect +) diff --git a/go.sum b/go.sum index e03832d..9d78089 100644 --- a/go.sum +++ b/go.sum @@ -1,10 +1,61 @@ +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw= github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/togo-framework/orm v0.1.0 h1:ip3QCIMv0VSsUIqAXdrayqJvK+eAyF/DPZy5sZKjNJk= github.com/togo-framework/orm v0.1.0/go.mod h1:1Z207HME5xC5PX3NqJjOPRiOTqsgW9q8YrqVCjJCTOc= github.com/togo-framework/togo v0.18.0 h1:f1ImZ2vF0/jgGEBrXViztjz4Ra9HPOdpv7qOdXVZsIs= github.com/togo-framework/togo v0.18.0/go.mod h1:/ybNefL1VWo0Pod32+YwW7ZgyInyXzOq3jGPBjUJYtc= golang.org/x/crypto v0.27.0 h1:GXm2NjJrPaiv/h1tb2UH8QfgC/hOf/+z0p6PT8o1w7A= golang.org/x/crypto v0.27.0/go.mod h1:1Xngt8kV6Dvbssa53Ziq6Eqn0HqbZi5Z6R0ZpwQzt70= +golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4= +golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= +golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8= +golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0= +modernc.org/cc/v4 v4.28.4 h1:Hd/4Es+MBj+/7hSdZaisNyu6bv3V0Dp2MdllyfqaH+c= +modernc.org/cc/v4 v4.28.4/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= +modernc.org/ccgo/v4 v4.34.4 h1:OVnSOWQjVKOYkFxoHYB+qQmSHK5gqMqARM+K9DpR/Ws= +modernc.org/ccgo/v4 v4.34.4/go.mod h1:qdKqE8FNIYyysougB1RX9MxCzp5oJOcQXSobANJ4TuE= +modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= +modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/gc/v3 v3.1.3 h1:6QAplYyVO+KdPW3pGnqmJDUxtkec8ooEWvks/hhU3lc= +modernc.org/gc/v3 v3.1.3/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/libc v1.73.4 h1:+ra4Ui8ngyt8HDcO1FTDPWlkAh6yOdaO2yAoh8MddQA= +modernc.org/libc v1.73.4/go.mod h1:DXZ3eO8qMCNn2SnmTNCiC71nJ9Rcq3PsnpU6Vc4rWK8= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg= +modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.53.0 h1:20WG8N9q4ji/dEqGk4uiI0c6OPjSeLTNYGFCc3+7c1M= +modernc.org/sqlite v1.53.0/go.mod h1:xoEpOIpGrgT48H5iiyt/YXPCZPEzlfmfFwtk8Lklw8s= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/routes.go b/routes.go index 65d2667..ed25f7c 100644 --- a/routes.go +++ b/routes.go @@ -53,6 +53,10 @@ func (s *Service) mountRoutes() { r.With(s.Middleware, s.csrfGuard).Post("/api/auth/tokens", s.handleCreateToken) r.With(s.Middleware).Get("/api/auth/tokens", s.handleListTokens) r.With(s.Middleware, s.csrfGuard).Delete("/api/auth/tokens/{id}", s.handleRevokeToken) + + // Admin user management + impersonation (guarded by RequireRole("admin")) and + // the public magic-link consume endpoint. + s.mountAdminRoutes(r) } // minPasswordLen is the enforced minimum (override via AUTH_MIN_PASSWORD).