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)
|
||||
}
|
||||
Reference in New Issue
Block a user