feat: add independent Bell authentication and RBAC (#12)
This commit is contained in:
@@ -0,0 +1,56 @@
|
||||
package audit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
type Entry struct {
|
||||
ID int64 `json:"id"`
|
||||
OccurredAt string `json:"occurred_at"`
|
||||
ActorUserID *string `json:"actor_user_id,omitempty"`
|
||||
Action string `json:"action"`
|
||||
TargetType string `json:"target_type"`
|
||||
TargetID *string `json:"target_id,omitempty"`
|
||||
Outcome string `json:"outcome"`
|
||||
Details map[string]any `json:"details"`
|
||||
}
|
||||
|
||||
type Store struct{ DB *pgxpool.Pool }
|
||||
|
||||
func (s Store) Record(ctx context.Context, actorUserID *string, action, targetType string, targetID *string, outcome string, details map[string]any) error {
|
||||
data, err := json.Marshal(details)
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode audit details: %w", err)
|
||||
}
|
||||
_, err = s.DB.Exec(ctx, `INSERT INTO bell_audit_log(actor_user_id,action,target_type,target_id,outcome,details) VALUES($1,$2,$3,$4,$5,$6)`, actorUserID, action, targetType, targetID, outcome, data)
|
||||
if err != nil {
|
||||
return fmt.Errorf("append audit entry: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s Store) List(ctx context.Context, limit int) ([]Entry, error) {
|
||||
if limit < 1 || limit > 200 {
|
||||
limit = 50
|
||||
}
|
||||
rows, err := s.DB.Query(ctx, `SELECT id,occurred_at::text,actor_user_id::text,action,target_type,target_id,outcome,details FROM bell_audit_log ORDER BY id DESC LIMIT $1`, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
entries := make([]Entry, 0, limit)
|
||||
for rows.Next() {
|
||||
var e Entry
|
||||
var data []byte
|
||||
if err := rows.Scan(&e.ID, &e.OccurredAt, &e.ActorUserID, &e.Action, &e.TargetType, &e.TargetID, &e.Outcome, &data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_ = json.Unmarshal(data, &e.Details)
|
||||
entries = append(entries, e)
|
||||
}
|
||||
return entries, rows.Err()
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.ilapage.cn/ila/yovision/Bell/server/app/audit"
|
||||
"git.ilapage.cn/ila/yovision/Bell/server/app/rbac"
|
||||
)
|
||||
|
||||
const CookieName = "bell_session"
|
||||
|
||||
type HTTP struct {
|
||||
Service Service
|
||||
Store Store
|
||||
Audit audit.Store
|
||||
SecureCookie bool
|
||||
}
|
||||
|
||||
func (h HTTP) Register(mux *http.ServeMux) {
|
||||
mux.HandleFunc("POST /api/v1/auth/login", h.login)
|
||||
mux.Handle("GET /api/v1/auth/me", h.authenticated(http.HandlerFunc(h.me)))
|
||||
mux.Handle("POST /api/v1/auth/logout", h.authenticated(http.HandlerFunc(h.logout)))
|
||||
mux.Handle("GET /api/v1/users", h.Require(rbac.UsersRead, http.HandlerFunc(h.users)))
|
||||
mux.Handle("POST /api/v1/users", h.Require(rbac.UsersWrite, http.HandlerFunc(h.createUser)))
|
||||
mux.Handle("PUT /api/v1/users/{id}/roles", h.Require(rbac.UsersWrite, http.HandlerFunc(h.replaceRoles)))
|
||||
mux.Handle("GET /api/v1/audit", h.Require(rbac.AuditRead, http.HandlerFunc(h.auditEntries)))
|
||||
}
|
||||
|
||||
func (h HTTP) login(w http.ResponseWriter, r *http.Request) {
|
||||
var input LoginInput
|
||||
if err := decodeJSON(w, r, &input); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
result, err := h.Service.Login(r.Context(), input)
|
||||
if err != nil {
|
||||
status := http.StatusInternalServerError
|
||||
if errors.Is(err, ErrInvalidCredentials) {
|
||||
status = http.StatusUnauthorized
|
||||
}
|
||||
writeJSON(w, status, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
h.setCookie(w, result.Token, time.Now().Add(8*time.Hour))
|
||||
result.User.PasswordHash = ""
|
||||
writeJSON(w, http.StatusOK, map[string]any{"user": result.User, "expires_at": result.ExpiresAt})
|
||||
}
|
||||
|
||||
func (h HTTP) me(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, principal(r.Context()))
|
||||
}
|
||||
func (h HTTP) logout(w http.ResponseWriter, r *http.Request) {
|
||||
cookie, _ := r.Cookie(CookieName)
|
||||
token := ""
|
||||
if cookie != nil {
|
||||
token = cookie.Value
|
||||
}
|
||||
user := principal(r.Context())
|
||||
if err := h.Service.Logout(r.Context(), token, user); err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "退出失败"})
|
||||
return
|
||||
}
|
||||
h.setCookie(w, "", time.Unix(0, 0))
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
func (h HTTP) users(w http.ResponseWriter, r *http.Request) {
|
||||
users, err := h.Store.ListUsers(r.Context())
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "读取用户失败"})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"items": users})
|
||||
}
|
||||
func (h HTTP) createUser(w http.ResponseWriter, r *http.Request) {
|
||||
var input struct {
|
||||
Username string `json:"username"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Password string `json:"password"`
|
||||
Role string `json:"role"`
|
||||
}
|
||||
if err := decodeJSON(w, r, &input); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
input.Username = strings.ToLower(strings.TrimSpace(input.Username))
|
||||
if input.Username == "" || input.DisplayName == "" {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "用户名和显示名称必填"})
|
||||
return
|
||||
}
|
||||
hash, err := hashPassword(input.Password)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
id, err := h.Store.CreateUser(r.Context(), input.Username, input.DisplayName, hash, input.Role)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusConflict, map[string]string{"error": "用户或角色无效"})
|
||||
return
|
||||
}
|
||||
actor := principal(r.Context())
|
||||
_ = h.Audit.Record(r.Context(), &actor.ID, "rbac.user_created", "user", &id, "success", map[string]any{"role": input.Role})
|
||||
writeJSON(w, http.StatusCreated, map[string]string{"id": id})
|
||||
}
|
||||
func (h HTTP) replaceRoles(w http.ResponseWriter, r *http.Request) {
|
||||
var input struct {
|
||||
Roles []string `json:"roles"`
|
||||
}
|
||||
if err := decodeJSON(w, r, &input); err != nil || len(input.Roles) == 0 {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "至少选择一个角色"})
|
||||
return
|
||||
}
|
||||
id := r.PathValue("id")
|
||||
if err := h.Store.ReplaceRoles(r.Context(), id, input.Roles); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "角色更新失败"})
|
||||
return
|
||||
}
|
||||
actor := principal(r.Context())
|
||||
_ = h.Audit.Record(r.Context(), &actor.ID, "rbac.roles_replaced", "user", &id, "success", map[string]any{"roles": input.Roles})
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
func (h HTTP) auditEntries(w http.ResponseWriter, r *http.Request) {
|
||||
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
|
||||
entries, err := h.Audit.List(r.Context(), limit)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "读取审计失败"})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"items": entries})
|
||||
}
|
||||
|
||||
func (h HTTP) authenticated(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
cookie, err := r.Cookie(CookieName)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": ErrUnauthorized.Error()})
|
||||
return
|
||||
}
|
||||
user, err := h.Service.Authenticate(r.Context(), cookie.Value)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": ErrUnauthorized.Error()})
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), principalKey{}, user)))
|
||||
})
|
||||
}
|
||||
func (h HTTP) Require(permission string, next http.Handler) http.Handler {
|
||||
return h.authenticated(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
user := principal(r.Context())
|
||||
if !user.Permissions.Has(permission) {
|
||||
_ = h.Audit.Record(r.Context(), &user.ID, "rbac.denied", "permission", &permission, "denied", map[string]any{})
|
||||
writeJSON(w, http.StatusForbidden, map[string]string{"error": "没有执行此操作的权限"})
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
}))
|
||||
}
|
||||
|
||||
func (h HTTP) setCookie(w http.ResponseWriter, value string, expires time.Time) {
|
||||
http.SetCookie(w, &http.Cookie{Name: CookieName, Value: value, Path: "/", HttpOnly: true, Secure: h.SecureCookie, SameSite: http.SameSiteStrictMode, Expires: expires, MaxAge: int(time.Until(expires).Seconds())})
|
||||
}
|
||||
func principal(ctx context.Context) User { user, _ := ctx.Value(principalKey{}).(User); return user }
|
||||
func decodeJSON(w http.ResponseWriter, r *http.Request, value any) error {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
|
||||
decoder := json.NewDecoder(r.Body)
|
||||
decoder.DisallowUnknownFields()
|
||||
return decoder.Decode(value)
|
||||
}
|
||||
func writeJSON(w http.ResponseWriter, status int, value any) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(value)
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
func validatePassword(value string) error {
|
||||
if utf8.RuneCountInString(value) < 12 {
|
||||
return errors.New("密码至少需要 12 个字符")
|
||||
}
|
||||
var lower, upper, digit bool
|
||||
for _, r := range value {
|
||||
lower = lower || unicode.IsLower(r)
|
||||
upper = upper || unicode.IsUpper(r)
|
||||
digit = digit || unicode.IsDigit(r)
|
||||
}
|
||||
if !lower || !upper || !digit {
|
||||
return errors.New("密码必须同时包含大写字母、小写字母和数字")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func hashPassword(value string) (string, error) {
|
||||
if err := validatePassword(value); err != nil {
|
||||
return "", err
|
||||
}
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(value), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("hash password: %w", err)
|
||||
}
|
||||
return string(hash), nil
|
||||
}
|
||||
|
||||
func passwordMatches(hash, value string) bool {
|
||||
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(value)) == nil
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package auth
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestPasswordPolicy(t *testing.T) {
|
||||
for _, value := range []string{"short", "alllowercase123", "ALLUPPERCASE123"} {
|
||||
if validatePassword(value) == nil {
|
||||
t.Fatalf("expected %q to fail policy", value)
|
||||
}
|
||||
}
|
||||
if err := validatePassword("Bell-Safe-2026"); err != nil {
|
||||
t.Fatalf("valid password rejected: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPasswordHashRoundTrip(t *testing.T) {
|
||||
hash, err := hashPassword("Bell-Safe-2026")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !passwordMatches(hash, "Bell-Safe-2026") {
|
||||
t.Fatal("password did not match")
|
||||
}
|
||||
if passwordMatches(hash, "Wrong-Safe-2026") {
|
||||
t.Fatal("wrong password matched")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.ilapage.cn/ila/yovision/Bell/server/app/audit"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
var ErrInvalidCredentials = errors.New("用户名或密码错误")
|
||||
var ErrUnauthorized = errors.New("会话无效或已过期")
|
||||
|
||||
type Service struct {
|
||||
Store Store
|
||||
Audit audit.Store
|
||||
Secret []byte
|
||||
SessionTTL time.Duration
|
||||
}
|
||||
|
||||
func (s Service) digest(token string) []byte {
|
||||
mac := hmac.New(sha256.New, s.Secret)
|
||||
_, _ = mac.Write([]byte(token))
|
||||
return mac.Sum(nil)
|
||||
}
|
||||
|
||||
func (s Service) Login(ctx context.Context, input LoginInput) (LoginResult, error) {
|
||||
username := strings.ToLower(strings.TrimSpace(input.Username))
|
||||
user, err := s.Store.UserByUsername(ctx, username)
|
||||
if err != nil || !user.Enabled || !passwordMatches(user.PasswordHash, input.Password) {
|
||||
_ = s.Audit.Record(ctx, nil, "auth.login", "user", nil, "failure", map[string]any{"reason": "invalid_credentials"})
|
||||
return LoginResult{}, ErrInvalidCredentials
|
||||
}
|
||||
bytes := make([]byte, 32)
|
||||
if _, err := rand.Read(bytes); err != nil {
|
||||
return LoginResult{}, err
|
||||
}
|
||||
token := base64.RawURLEncoding.EncodeToString(bytes)
|
||||
expires := time.Now().UTC().Add(s.SessionTTL)
|
||||
if s.SessionTTL == 0 {
|
||||
expires = time.Now().UTC().Add(8 * time.Hour)
|
||||
}
|
||||
if err := s.Store.CreateSession(ctx, user.ID, s.digest(token), expires); err != nil {
|
||||
return LoginResult{}, err
|
||||
}
|
||||
_ = s.Audit.Record(ctx, &user.ID, "auth.login", "user", &user.ID, "success", map[string]any{})
|
||||
return LoginResult{Token: token, User: user, ExpiresAt: expires.Format(time.RFC3339)}, nil
|
||||
}
|
||||
|
||||
func (s Service) Authenticate(ctx context.Context, token string) (User, error) {
|
||||
if token == "" {
|
||||
return User{}, ErrUnauthorized
|
||||
}
|
||||
user, err := s.Store.UserBySessionDigest(ctx, s.digest(token))
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return User{}, ErrUnauthorized
|
||||
}
|
||||
return User{}, err
|
||||
}
|
||||
if !user.Enabled {
|
||||
return User{}, ErrUnauthorized
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
func (s Service) Logout(ctx context.Context, token string, user User) error {
|
||||
if token != "" {
|
||||
if err := s.Store.RevokeSession(ctx, s.digest(token)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return s.Audit.Record(ctx, &user.ID, "auth.logout", "user", &user.ID, "success", map[string]any{})
|
||||
}
|
||||
func (s Service) BootstrapAdministrator(ctx context.Context, username, displayName, password string) error {
|
||||
username = strings.ToLower(strings.TrimSpace(username))
|
||||
if username == "" {
|
||||
return errors.New("username is required")
|
||||
}
|
||||
hash, err := hashPassword(password)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
id, err := s.Store.BootstrapAdministrator(ctx, username, displayName, hash)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.Audit.Record(ctx, &id, "rbac.bootstrap_administrator", "user", &id, "success", map[string]any{})
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"git.ilapage.cn/ila/yovision/Bell/server/app/rbac"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
type Store struct{ DB *pgxpool.Pool }
|
||||
|
||||
func (s Store) UserByUsername(ctx context.Context, username string) (User, error) {
|
||||
var user User
|
||||
err := s.DB.QueryRow(ctx, `SELECT id::text,username,display_name,password_hash,enabled FROM bell_users WHERE username=$1`, username).Scan(&user.ID, &user.Username, &user.DisplayName, &user.PasswordHash, &user.Enabled)
|
||||
if err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
if err = s.loadAccess(ctx, &user); err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (s Store) UserBySessionDigest(ctx context.Context, digest []byte) (User, error) {
|
||||
var user User
|
||||
err := s.DB.QueryRow(ctx, `SELECT u.id::text,u.username,u.display_name,u.password_hash,u.enabled FROM bell_sessions s JOIN bell_users u ON u.id=s.user_id WHERE s.token_digest=$1 AND s.revoked_at IS NULL AND s.expires_at>now()`, digest).Scan(&user.ID, &user.Username, &user.DisplayName, &user.PasswordHash, &user.Enabled)
|
||||
if err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
if err = s.loadAccess(ctx, &user); err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (s Store) loadAccess(ctx context.Context, user *User) error {
|
||||
rows, err := s.DB.Query(ctx, `SELECT DISTINCT r.code,p.permission_code FROM bell_user_roles ur JOIN bell_roles r ON r.id=ur.role_id LEFT JOIN bell_role_permissions p ON p.role_id=r.id WHERE ur.user_id=$1 ORDER BY r.code,p.permission_code`, user.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
user.Permissions = rbac.Set{}
|
||||
roleSeen := map[string]bool{}
|
||||
for rows.Next() {
|
||||
var role string
|
||||
var permission *string
|
||||
if err := rows.Scan(&role, &permission); err != nil {
|
||||
return err
|
||||
}
|
||||
if !roleSeen[role] {
|
||||
user.Roles = append(user.Roles, role)
|
||||
roleSeen[role] = true
|
||||
}
|
||||
if permission != nil {
|
||||
user.Permissions[*permission] = struct{}{}
|
||||
}
|
||||
}
|
||||
return rows.Err()
|
||||
}
|
||||
|
||||
func (s Store) CreateSession(ctx context.Context, userID string, digest []byte, expires time.Time) error {
|
||||
_, err := s.DB.Exec(ctx, `INSERT INTO bell_sessions(user_id,token_digest,expires_at) VALUES($1,$2,$3)`, userID, digest, expires)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s Store) RevokeSession(ctx context.Context, digest []byte) error {
|
||||
_, err := s.DB.Exec(ctx, `UPDATE bell_sessions SET revoked_at=COALESCE(revoked_at,now()) WHERE token_digest=$1`, digest)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s Store) BootstrapAdministrator(ctx context.Context, username, displayName, passwordHash string) (string, error) {
|
||||
tx, err := s.DB.Begin(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
var id string
|
||||
err = tx.QueryRow(ctx, `INSERT INTO bell_users(username,display_name,password_hash) VALUES($1,$2,$3) ON CONFLICT(username) DO NOTHING RETURNING id::text`, username, displayName, passwordHash).Scan(&id)
|
||||
if err != nil {
|
||||
if err == pgx.ErrNoRows {
|
||||
return "", fmt.Errorf("administrator %q already exists", username)
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
if _, err = tx.Exec(ctx, `INSERT INTO bell_user_roles(user_id,role_id) SELECT $1,id FROM bell_roles WHERE code='administrator'`, id); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err = tx.Commit(ctx); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
func (s Store) ListUsers(ctx context.Context) ([]User, error) {
|
||||
rows, err := s.DB.Query(ctx, `SELECT id::text,username,display_name,enabled FROM bell_users ORDER BY username LIMIT 200`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
users := []User{}
|
||||
for rows.Next() {
|
||||
var u User
|
||||
if err := rows.Scan(&u.ID, &u.Username, &u.DisplayName, &u.Enabled); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
users = append(users, u)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for i := range users {
|
||||
if err := s.loadAccess(ctx, &users[i]); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return users, nil
|
||||
}
|
||||
|
||||
func (s Store) CreateUser(ctx context.Context, username, displayName, passwordHash, role string) (string, error) {
|
||||
tx, err := s.DB.Begin(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
var id string
|
||||
if err = tx.QueryRow(ctx, `INSERT INTO bell_users(username,display_name,password_hash) VALUES($1,$2,$3) RETURNING id::text`, username, displayName, passwordHash).Scan(&id); err != nil {
|
||||
return "", err
|
||||
}
|
||||
result, err := tx.Exec(ctx, `INSERT INTO bell_user_roles(user_id,role_id) SELECT $1,id FROM bell_roles WHERE code=$2`, id, role)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if result.RowsAffected() != 1 {
|
||||
return "", fmt.Errorf("unknown role %q", role)
|
||||
}
|
||||
if err = tx.Commit(ctx); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
func (s Store) ReplaceRoles(ctx context.Context, userID string, roles []string) error {
|
||||
tx, err := s.DB.Begin(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
if _, err = tx.Exec(ctx, `DELETE FROM bell_user_roles WHERE user_id=$1`, userID); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, role := range roles {
|
||||
result, err := tx.Exec(ctx, `INSERT INTO bell_user_roles(user_id,role_id) SELECT $1,id FROM bell_roles WHERE code=$2`, userID, role)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if result.RowsAffected() != 1 {
|
||||
return fmt.Errorf("unknown role %q", role)
|
||||
}
|
||||
}
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package auth
|
||||
|
||||
import "git.ilapage.cn/ila/yovision/Bell/server/app/rbac"
|
||||
|
||||
type User struct {
|
||||
ID string `json:"id"`
|
||||
Username string `json:"username"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Roles []string `json:"roles"`
|
||||
Permissions rbac.Set `json:"permissions"`
|
||||
PasswordHash string `json:"-"`
|
||||
}
|
||||
|
||||
type LoginInput struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
type LoginResult struct {
|
||||
Token string
|
||||
User User
|
||||
ExpiresAt string
|
||||
}
|
||||
|
||||
type principalKey struct{}
|
||||
@@ -0,0 +1,31 @@
|
||||
package rbac
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"sort"
|
||||
)
|
||||
|
||||
const (
|
||||
DashboardRead = "dashboard:read"
|
||||
AlertsRead = "alerts:read"
|
||||
AlertsHandle = "alerts:handle"
|
||||
EventsRead = "events:read"
|
||||
RulesRead = "rules:read"
|
||||
RulesWrite = "rules:write"
|
||||
UsersRead = "users:read"
|
||||
UsersWrite = "users:write"
|
||||
AuditRead = "audit:read"
|
||||
)
|
||||
|
||||
type Set map[string]struct{}
|
||||
|
||||
func (s Set) Has(permission string) bool { _, ok := s[permission]; return ok }
|
||||
|
||||
func (s Set) MarshalJSON() ([]byte, error) {
|
||||
values := make([]string, 0, len(s))
|
||||
for value := range s {
|
||||
values = append(values, value)
|
||||
}
|
||||
sort.Strings(values)
|
||||
return json.Marshal(values)
|
||||
}
|
||||
@@ -8,10 +8,15 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"git.ilapage.cn/ila/yovision/Bell/server/app/audit"
|
||||
"git.ilapage.cn/ila/yovision/Bell/server/app/auth"
|
||||
"git.ilapage.cn/ila/yovision/Bell/server/config"
|
||||
"git.ilapage.cn/ila/yovision/Bell/server/internal/platform"
|
||||
"git.ilapage.cn/ila/yovision/Bell/server/migrations"
|
||||
)
|
||||
|
||||
const Version = "0.1.0"
|
||||
@@ -25,8 +30,8 @@ func Run(ctx context.Context, args []string) error {
|
||||
fmt.Println(Version)
|
||||
return nil
|
||||
}
|
||||
if command != "serve" && command != "migrate" {
|
||||
return fmt.Errorf("unknown command %q (use serve, migrate, or version)", command)
|
||||
if command != "serve" && command != "migrate" && command != "create-admin" {
|
||||
return fmt.Errorf("unknown command %q (use serve, migrate, create-admin, or version)", command)
|
||||
}
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
@@ -37,13 +42,28 @@ func Run(ctx context.Context, args []string) error {
|
||||
return err
|
||||
}
|
||||
defer db.Close()
|
||||
if err := platform.Migrate(ctx, db); err != nil {
|
||||
if err := migrations.Apply(ctx, db); err != nil {
|
||||
return err
|
||||
}
|
||||
if command == "migrate" {
|
||||
return nil
|
||||
}
|
||||
auditStore := audit.Store{DB: db}
|
||||
authStore := auth.Store{DB: db}
|
||||
authService := auth.Service{Store: authStore, Audit: auditStore, Secret: []byte(cfg.SessionSecret), SessionTTL: 8 * time.Hour}
|
||||
if command == "create-admin" {
|
||||
username, displayName := argument(args, "--username"), argument(args, "--display-name")
|
||||
password := os.Getenv("BELL_BOOTSTRAP_PASSWORD")
|
||||
if displayName == "" {
|
||||
displayName = username
|
||||
}
|
||||
if password == "" {
|
||||
return fmt.Errorf("BELL_BOOTSTRAP_PASSWORD is required for create-admin")
|
||||
}
|
||||
return authService.BootstrapAdministrator(ctx, username, displayName, password)
|
||||
}
|
||||
app := platform.NewHTTPApp(db)
|
||||
auth.HTTP{Service: authService, Store: authStore, Audit: auditStore, SecureCookie: cfg.CookieSecure}.Register(app.Router())
|
||||
server := &http.Server{Addr: cfg.HTTPAddress, Handler: app.Handler()}
|
||||
serverCtx, stop := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
@@ -59,3 +79,12 @@ func Run(ctx context.Context, args []string) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func argument(args []string, name string) string {
|
||||
for i := 1; i < len(args)-1; i++ {
|
||||
if strings.EqualFold(args[i], name) {
|
||||
return args[i+1]
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -11,6 +11,8 @@ type Config struct {
|
||||
Environment string
|
||||
HTTPAddress string
|
||||
DatabaseURL string
|
||||
SessionSecret string
|
||||
CookieSecure bool
|
||||
ShutdownTimeout time.Duration
|
||||
}
|
||||
|
||||
@@ -19,6 +21,7 @@ func Load() (Config, error) {
|
||||
Environment: value("BELL_ENV", "development"),
|
||||
HTTPAddress: value("BELL_HTTP_ADDRESS", "127.0.0.1:8082"),
|
||||
DatabaseURL: os.Getenv("BELL_DATABASE_URL"),
|
||||
SessionSecret: os.Getenv("BELL_SESSION_SECRET"),
|
||||
ShutdownTimeout: 10 * time.Second,
|
||||
}
|
||||
if raw := os.Getenv("BELL_SHUTDOWN_SECONDS"); raw != "" {
|
||||
@@ -31,6 +34,10 @@ func Load() (Config, error) {
|
||||
if cfg.DatabaseURL == "" {
|
||||
return Config{}, fmt.Errorf("BELL_DATABASE_URL is required")
|
||||
}
|
||||
if len(cfg.SessionSecret) < 32 {
|
||||
return Config{}, fmt.Errorf("BELL_SESSION_SECRET must contain at least 32 characters")
|
||||
}
|
||||
cfg.CookieSecure = cfg.Environment == "production"
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
|
||||
+4
-2
@@ -2,13 +2,15 @@ module git.ilapage.cn/ila/yovision/Bell/server
|
||||
|
||||
go 1.26.5
|
||||
|
||||
require github.com/jackc/pgx/v5 v5.7.6
|
||||
require (
|
||||
github.com/jackc/pgx/v5 v5.7.6
|
||||
golang.org/x/crypto v0.37.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
golang.org/x/crypto v0.37.0 // indirect
|
||||
golang.org/x/sync v0.13.0 // indirect
|
||||
golang.org/x/text v0.24.0 // indirect
|
||||
)
|
||||
|
||||
@@ -28,6 +28,8 @@ func NewHTTPApp(db *pgxpool.Pool) *HTTPApp {
|
||||
return app
|
||||
}
|
||||
|
||||
func (a *HTTPApp) Router() *http.ServeMux { return a.Mux }
|
||||
|
||||
func (a *HTTPApp) Handler() http.Handler {
|
||||
return securityHeaders(a.Mux)
|
||||
}
|
||||
|
||||
-1
@@ -2,4 +2,3 @@ CREATE TABLE IF NOT EXISTS bell_runtime_probe (
|
||||
singleton boolean PRIMARY KEY DEFAULT true CHECK (singleton),
|
||||
started_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
||||
|
||||
CREATE TABLE bell_roles (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
code text NOT NULL UNIQUE CHECK (code IN ('administrator','operator','viewer')),
|
||||
name text NOT NULL
|
||||
);
|
||||
CREATE TABLE bell_permissions (code text PRIMARY KEY, description text NOT NULL);
|
||||
CREATE TABLE bell_role_permissions (
|
||||
role_id uuid NOT NULL REFERENCES bell_roles(id),
|
||||
permission_code text NOT NULL REFERENCES bell_permissions(code),
|
||||
PRIMARY KEY (role_id, permission_code)
|
||||
);
|
||||
CREATE TABLE bell_users (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
username text NOT NULL UNIQUE CHECK (username = lower(username)),
|
||||
display_name text NOT NULL,
|
||||
password_hash text NOT NULL,
|
||||
enabled boolean NOT NULL DEFAULT true,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE TABLE bell_user_roles (
|
||||
user_id uuid NOT NULL REFERENCES bell_users(id),
|
||||
role_id uuid NOT NULL REFERENCES bell_roles(id),
|
||||
PRIMARY KEY (user_id, role_id)
|
||||
);
|
||||
CREATE TABLE bell_sessions (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id uuid NOT NULL REFERENCES bell_users(id),
|
||||
token_digest bytea NOT NULL UNIQUE,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
expires_at timestamptz NOT NULL,
|
||||
revoked_at timestamptz,
|
||||
CHECK (expires_at > created_at)
|
||||
);
|
||||
CREATE INDEX bell_sessions_user_active_idx ON bell_sessions(user_id, expires_at) WHERE revoked_at IS NULL;
|
||||
CREATE TABLE bell_audit_log (
|
||||
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
occurred_at timestamptz NOT NULL DEFAULT now(),
|
||||
actor_user_id uuid REFERENCES bell_users(id),
|
||||
action text NOT NULL,
|
||||
target_type text NOT NULL,
|
||||
target_id text,
|
||||
outcome text NOT NULL CHECK (outcome IN ('success','failure','denied')),
|
||||
details jsonb NOT NULL DEFAULT '{}'::jsonb
|
||||
);
|
||||
|
||||
CREATE OR REPLACE FUNCTION bell_reject_audit_mutation() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
BEGIN RAISE EXCEPTION 'bell audit facts are append-only'; END $$;
|
||||
CREATE TRIGGER bell_audit_no_update BEFORE UPDATE OR DELETE ON bell_audit_log FOR EACH ROW EXECUTE FUNCTION bell_reject_audit_mutation();
|
||||
|
||||
INSERT INTO bell_roles(code,name) VALUES ('administrator','管理员'),('operator','处置员'),('viewer','只读用户');
|
||||
INSERT INTO bell_permissions(code,description) VALUES
|
||||
('dashboard:read','查看工作台'),('alerts:read','查看预警'),('alerts:handle','处置预警'),
|
||||
('events:read','查看事件'),('rules:read','查看规则'),('rules:write','管理规则'),
|
||||
('users:read','查看用户'),('users:write','管理用户与角色'),('audit:read','查看审计');
|
||||
INSERT INTO bell_role_permissions(role_id,permission_code)
|
||||
SELECT r.id,p.code FROM bell_roles r CROSS JOIN bell_permissions p WHERE r.code='administrator';
|
||||
INSERT INTO bell_role_permissions(role_id,permission_code)
|
||||
SELECT r.id,p.code FROM bell_roles r JOIN bell_permissions p ON p.code IN ('dashboard:read','alerts:read','alerts:handle','events:read','rules:read') WHERE r.code='operator';
|
||||
INSERT INTO bell_role_permissions(role_id,permission_code)
|
||||
SELECT r.id,p.code FROM bell_roles r JOIN bell_permissions p ON p.code IN ('dashboard:read','alerts:read','events:read','rules:read') WHERE r.code='viewer';
|
||||
@@ -1,4 +1,4 @@
|
||||
package platform
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -10,20 +10,20 @@ import (
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
//go:embed migrations/*.sql
|
||||
var migrationFiles embed.FS
|
||||
//go:embed *.sql
|
||||
var files embed.FS
|
||||
|
||||
func Migrate(ctx context.Context, pool *pgxpool.Pool) error {
|
||||
func Apply(ctx context.Context, pool *pgxpool.Pool) error {
|
||||
if _, err := pool.Exec(ctx, `CREATE TABLE IF NOT EXISTS bell_schema_migrations (name text PRIMARY KEY, applied_at timestamptz NOT NULL DEFAULT now())`); err != nil {
|
||||
return fmt.Errorf("create migration ledger: %w", err)
|
||||
}
|
||||
entries, err := fs.ReadDir(migrationFiles, "migrations")
|
||||
entries, err := fs.ReadDir(files, ".")
|
||||
if err != nil {
|
||||
return fmt.Errorf("read embedded migrations: %w", err)
|
||||
}
|
||||
sort.Slice(entries, func(i, j int) bool { return entries[i].Name() < entries[j].Name() })
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
if entry.IsDir() || len(entry.Name()) < 4 || entry.Name()[len(entry.Name())-4:] != ".sql" {
|
||||
continue
|
||||
}
|
||||
var applied bool
|
||||
@@ -33,7 +33,7 @@ func Migrate(ctx context.Context, pool *pgxpool.Pool) error {
|
||||
if applied {
|
||||
continue
|
||||
}
|
||||
sqlBytes, err := migrationFiles.ReadFile("migrations/" + entry.Name())
|
||||
sqlBytes, err := files.ReadFile(entry.Name())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
Reference in New Issue
Block a user