feat(users): Multi-User-Management — DB-backed Login-Accounts + UI

- internal/services/users: Repo mit CRUD, bcrypt (cost 12), Upsert für
  setup-store-Admin-Migration, RecordLogin
- internal/handlers/users: GET/POST/PUT /users, POST /users/:id/password,
  DELETE /users/:id; Schutz gegen Selbst-Löschung
- auth.go Login: DB-Nutzer first, Fallback auf setup-store-Admin;
  bei erfolgreichem Fallback wird der Admin per Upsert in die DB
  migriert (kein manueller Eingriff nötig)
- management-ui: /users-Seite mit Tabelle, Anlegen-, Bearbeiten-,
  Passwort-setzen- und Löschen-Modals; "You"-Badge für eigenen Account
- Sidebar + Route + i18n (de/en) ergänzt

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Debian
2026-05-23 10:22:27 +02:00
parent 8293783fe6
commit a490576420
11 changed files with 701 additions and 10 deletions

View File

@@ -12,16 +12,20 @@ import (
"git.netcell-it.de/projekte/edgeguard-native/internal/services/audit"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/session"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/setup"
usersvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/users"
)
// AuthHandler exposes login / me / logout. v1 verifies against the
// setup-store (single admin); admin_users-table support comes when the
// users repo lands.
// AuthHandler exposes login / me / logout.
// Login checks the DB users table first; falls back to the setup-store
// admin for backwards compatibility. On a successful setup-store login
// the account is auto-migrated into the DB (Upsert) so it shows up in
// user management from that point on.
type AuthHandler struct {
Setup *setup.Store
Signer *session.Signer
Audit *audit.Repo
NodeID string
Users *usersvc.Repo // optional — nil on first boot before DB is ready
}
func NewAuthHandler(s *setup.Store, sig *session.Signer) *AuthHandler {
@@ -36,6 +40,12 @@ func (h *AuthHandler) WithAudit(a *audit.Repo, nodeID string) *AuthHandler {
return h
}
// WithUsers injects the users repo so Login can verify against the DB.
func (h *AuthHandler) WithUsers(u *usersvc.Repo) *AuthHandler {
h.Users = u
return h
}
// Register mounts /auth/login + /logout (public) and /auth/me
// (gated by requireAuth, passed in as a per-route middleware).
func (h *AuthHandler) Register(rg *gin.RouterGroup, requireAuth gin.HandlerFunc) {
@@ -73,13 +83,44 @@ func (h *AuthHandler) Login(c *gin.Context) {
response.Err(c, http.StatusServiceUnavailable, errors.New("setup_required"))
return
}
if !strings.EqualFold(st.AdminEmail, strings.TrimSpace(req.Email)) ||
!st.VerifyAdminPassword(req.Password) {
response.Unauthorized(c, errors.New("invalid_credentials"))
return
email := strings.TrimSpace(req.Email)
actor, role := "", "admin"
// 1. Try DB users table first.
if h.Users != nil {
u, hash, dbErr := h.Users.FindByEmail(c.Request.Context(), email)
if dbErr == nil {
if !u.Active {
response.Unauthorized(c, errors.New("account_disabled"))
return
}
if !usersvc.VerifyPassword(hash, req.Password) {
response.Unauthorized(c, errors.New("invalid_credentials"))
return
}
actor = u.Email
role = u.Role
h.Users.RecordLogin(c.Request.Context(), u.ID)
}
}
raw, tok, err := h.Signer.IssueWithRole(st.AdminEmail, "admin")
// 2. Fallback: setup-store admin (backwards compat for pre-DB installs).
if actor == "" {
if !strings.EqualFold(st.AdminEmail, email) || !st.VerifyAdminPassword(req.Password) {
response.Unauthorized(c, errors.New("invalid_credentials"))
return
}
actor = st.AdminEmail
role = "admin"
// Auto-migrate: insert the setup-store admin into the DB so it
// shows up in user management from this point on.
if h.Users != nil {
_, _ = h.Users.Upsert(c.Request.Context(), st.AdminEmail, req.Password, "admin", true)
}
}
raw, tok, err := h.Signer.IssueWithRole(actor, role)
if err != nil {
response.Internal(c, err)
return

166
internal/handlers/users.go Normal file
View File

@@ -0,0 +1,166 @@
package handlers
import (
"errors"
"net/http"
"github.com/gin-gonic/gin"
"git.netcell-it.de/projekte/edgeguard-native/internal/handlers/response"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/audit"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/users"
)
// UsersHandler exposes:
//
// GET /api/v1/users — list all users
// POST /api/v1/users — create user
// PUT /api/v1/users/:id — update email/role/active (not password)
// POST /api/v1/users/:id/password — set new password
// DELETE /api/v1/users/:id — delete user
type UsersHandler struct {
Repo *users.Repo
Audit *audit.Repo
NodeID string
}
func NewUsersHandler(repo *users.Repo, a *audit.Repo, nodeID string) *UsersHandler {
return &UsersHandler{Repo: repo, Audit: a, NodeID: nodeID}
}
func (h *UsersHandler) Register(rg *gin.RouterGroup) {
g := rg.Group("/users")
g.GET("", h.List)
g.POST("", h.Create)
g.PUT("/:id", h.Update)
g.POST("/:id/password", h.SetPassword)
g.DELETE("/:id", h.Delete)
}
func (h *UsersHandler) List(c *gin.Context) {
out, err := h.Repo.List(c.Request.Context())
if err != nil {
response.Internal(c, err)
return
}
if out == nil {
out = []users.User{}
}
response.OK(c, gin.H{"users": out})
}
type createUserReq struct {
Email string `json:"email" binding:"required,email"`
Password string `json:"password" binding:"required,min=12"`
Role string `json:"role" binding:"required,oneof=admin viewer"`
Active bool `json:"active"`
}
func (h *UsersHandler) Create(c *gin.Context) {
var req createUserReq
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, err)
return
}
u, err := h.Repo.Create(c.Request.Context(), req.Email, req.Password, req.Role, req.Active)
if errors.Is(err, users.ErrEmailTaken) {
response.Err(c, http.StatusConflict, err)
return
}
if err != nil {
response.Internal(c, err)
return
}
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "user.create", u.Email,
gin.H{"role": u.Role, "active": u.Active}, h.NodeID)
response.Created(c, u)
}
type updateUserReq struct {
Email string `json:"email" binding:"required,email"`
Role string `json:"role" binding:"required,oneof=admin viewer"`
Active bool `json:"active"`
}
func (h *UsersHandler) Update(c *gin.Context) {
id, ok := parseID(c)
if !ok {
return
}
var req updateUserReq
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, err)
return
}
u, err := h.Repo.Update(c.Request.Context(), id, req.Email, req.Role, req.Active)
if errors.Is(err, users.ErrNotFound) {
response.NotFound(c, err)
return
}
if errors.Is(err, users.ErrEmailTaken) {
response.Err(c, http.StatusConflict, err)
return
}
if err != nil {
response.Internal(c, err)
return
}
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "user.update", u.Email,
gin.H{"role": u.Role, "active": u.Active}, h.NodeID)
response.OK(c, u)
}
type setPasswordReq struct {
Password string `json:"password" binding:"required,min=12"`
}
func (h *UsersHandler) SetPassword(c *gin.Context) {
id, ok := parseID(c)
if !ok {
return
}
var req setPasswordReq
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, err)
return
}
if err := h.Repo.SetPassword(c.Request.Context(), id, req.Password); err != nil {
if errors.Is(err, users.ErrNotFound) {
response.NotFound(c, err)
return
}
response.Internal(c, err)
return
}
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "user.password.set",
c.Param("id"), nil, h.NodeID)
response.OK(c, gin.H{"ok": true})
}
func (h *UsersHandler) Delete(c *gin.Context) {
id, ok := parseID(c)
if !ok {
return
}
// Prevent deleting the own account.
tok := CurrentToken(c)
if tok != nil {
if u, _, err := h.Repo.FindByEmail(c.Request.Context(), tok.Actor); err == nil {
if u.ID == id {
response.Err(c, http.StatusBadRequest, errors.New("cannot delete own account"))
return
}
}
}
if err := h.Repo.Delete(c.Request.Context(), id); err != nil {
if errors.Is(err, users.ErrNotFound) {
response.NotFound(c, err)
return
}
response.Internal(c, err)
return
}
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "user.delete",
c.Param("id"), nil, h.NodeID)
response.OK(c, gin.H{"ok": true})
}

View File

@@ -0,0 +1,181 @@
// Package users manages the admin users table (login accounts).
// Passwords are bcrypt'd; minimum cost is 12 (≈300ms on modern hw).
// The users table is the authoritative source for login; the legacy
// setup-store admin is migrated into this table on first login.
package users
import (
"context"
"errors"
"strings"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"golang.org/x/crypto/bcrypt"
)
const passwordCost = 12
var (
ErrNotFound = errors.New("user not found")
ErrEmailTaken = errors.New("email already in use")
)
type User struct {
ID int64 `json:"id"`
Email string `json:"email"`
Role string `json:"role"`
Active bool `json:"active"`
LastLoginAt *time.Time `json:"last_login_at"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type Repo struct {
pool *pgxpool.Pool
}
func New(pool *pgxpool.Pool) *Repo { return &Repo{pool: pool} }
const selectCols = `id, email, role, active, last_login_at, created_at, updated_at`
func scan(row pgx.Row) (User, error) {
var u User
err := row.Scan(&u.ID, &u.Email, &u.Role, &u.Active,
&u.LastLoginAt, &u.CreatedAt, &u.UpdatedAt)
return u, err
}
func (r *Repo) List(ctx context.Context) ([]User, error) {
rows, err := r.pool.Query(ctx,
`SELECT `+selectCols+` FROM users ORDER BY created_at`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []User
for rows.Next() {
u, err := scan(rows)
if err != nil {
return nil, err
}
out = append(out, u)
}
return out, rows.Err()
}
// FindByEmail returns the user + bcrypt hash. ErrNotFound if absent.
func (r *Repo) FindByEmail(ctx context.Context, email string) (User, string, error) {
var u User
var hash string
err := r.pool.QueryRow(ctx,
`SELECT `+selectCols+`, password_hash FROM users WHERE lower(email)=lower($1)`,
email).Scan(&u.ID, &u.Email, &u.Role, &u.Active,
&u.LastLoginAt, &u.CreatedAt, &u.UpdatedAt, &hash)
if errors.Is(err, pgx.ErrNoRows) {
return u, "", ErrNotFound
}
return u, hash, err
}
func (r *Repo) Count(ctx context.Context) (int, error) {
var n int
err := r.pool.QueryRow(ctx, `SELECT COUNT(*) FROM users`).Scan(&n)
return n, err
}
func (r *Repo) Create(ctx context.Context, email, password, role string, active bool) (User, error) {
hash, err := bcrypt.GenerateFromPassword([]byte(password), passwordCost)
if err != nil {
return User{}, err
}
u, err := scan(r.pool.QueryRow(ctx,
`INSERT INTO users (email, password_hash, role, active)
VALUES ($1,$2,$3,$4)
RETURNING `+selectCols,
email, string(hash), role, active))
if isUnique(err) {
return User{}, ErrEmailTaken
}
return u, err
}
// Upsert: insert or update — used to migrate the legacy setup-store admin
// into the DB on first successful login without clobbering an existing row.
func (r *Repo) Upsert(ctx context.Context, email, password, role string, active bool) (User, error) {
hash, err := bcrypt.GenerateFromPassword([]byte(password), passwordCost)
if err != nil {
return User{}, err
}
u, err := scan(r.pool.QueryRow(ctx,
`INSERT INTO users (email, password_hash, role, active)
VALUES ($1,$2,$3,$4)
ON CONFLICT (email) DO NOTHING
RETURNING `+selectCols,
email, string(hash), role, active))
if errors.Is(err, pgx.ErrNoRows) {
// row already existed — return what's there without overwriting
u, _, err = r.FindByEmail(ctx, email)
}
return u, err
}
func (r *Repo) Update(ctx context.Context, id int64, email, role string, active bool) (User, error) {
u, err := scan(r.pool.QueryRow(ctx,
`UPDATE users SET email=$1, role=$2, active=$3, updated_at=NOW()
WHERE id=$4 RETURNING `+selectCols,
email, role, active, id))
if errors.Is(err, pgx.ErrNoRows) {
return u, ErrNotFound
}
if isUnique(err) {
return u, ErrEmailTaken
}
return u, err
}
func (r *Repo) SetPassword(ctx context.Context, id int64, password string) error {
hash, err := bcrypt.GenerateFromPassword([]byte(password), passwordCost)
if err != nil {
return err
}
tag, err := r.pool.Exec(ctx,
`UPDATE users SET password_hash=$1, updated_at=NOW() WHERE id=$2`,
string(hash), id)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
return nil
}
func (r *Repo) Delete(ctx context.Context, id int64) error {
tag, err := r.pool.Exec(ctx, `DELETE FROM users WHERE id=$1`, id)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
return nil
}
func (r *Repo) RecordLogin(ctx context.Context, id int64) {
_, _ = r.pool.Exec(ctx, `UPDATE users SET last_login_at=NOW() WHERE id=$1`, id)
}
// VerifyPassword is a constant-time bcrypt compare.
func VerifyPassword(hash, password string) bool {
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil
}
func isUnique(err error) bool {
if err == nil {
return false
}
s := err.Error()
return strings.Contains(s, "23505") || strings.Contains(s, "unique")
}