Files
yovision/Sense/server/app/sense/identity/service.go
T

160 lines
5.5 KiB
Go

package identity
import (
"context"
"crypto/subtle"
"errors"
"fmt"
"strings"
"time"
)
type Config struct {
SigningKey []byte
BootstrapToken string
SecureCookie bool
SessionTTL time.Duration
}
type Service struct {
store Store
cfg Config
now func() time.Time
}
func NewService(store Store, cfg Config) (*Service, error) {
if len(cfg.SigningKey) < 32 {
return nil, fmt.Errorf("Sense identity signing key must contain at least 32 bytes")
}
if cfg.SessionTTL <= 0 {
cfg.SessionTTL = 8 * time.Hour
}
return &Service{store: store, cfg: cfg, now: time.Now}, nil
}
func (s *Service) Bootstrap(ctx context.Context, token, username, displayName, password string) (User, error) {
if s.cfg.BootstrapToken == "" || subtle.ConstantTimeCompare([]byte(token), []byte(s.cfg.BootstrapToken)) != 1 {
return User{}, errors.New("bootstrap denied")
}
username = strings.TrimSpace(username)
if username == "" || len(username) > 64 {
return User{}, fmt.Errorf("用户名格式不正确")
}
if err := ValidatePassword(username, password); err != nil {
return User{}, err
}
hash, err := HashPassword(password)
if err != nil {
return User{}, err
}
now := s.now().UTC()
user := User{ID: newID("usr"), Username: username, DisplayName: strings.TrimSpace(displayName), Role: RoleAdministrator, Enabled: true, PasswordHash: hash, CreatedAt: now, UpdatedAt: now}
if err := s.store.BootstrapUser(ctx, user); err != nil {
return User{}, err
}
s.audit(ctx, user.ID, "identity.bootstrap", user.ID, "success", nil)
return user, nil
}
func (s *Service) Login(ctx context.Context, username, password string) (Principal, string, error) {
user, err := s.store.FindUserByUsername(ctx, strings.TrimSpace(username))
if err != nil || !user.Enabled || !VerifyPassword(user.PasswordHash, password) {
s.audit(ctx, "", "identity.login", "", "failure", map[string]any{"reason": "invalid_credentials"})
return Principal{}, "", errors.New("invalid credentials")
}
now := s.now().UTC()
session := Session{ID: newID("ses"), UserID: user.ID, ExpiresAt: now.Add(s.cfg.SessionTTL)}
if err := s.store.CreateSession(ctx, session); err != nil {
return Principal{}, "", err
}
raw, err := issueToken(s.cfg.SigningKey, user, session, now)
if err != nil {
return Principal{}, "", err
}
s.audit(ctx, user.ID, "identity.login", user.ID, "success", nil)
return principalFor(user, session.ID), raw, nil
}
func (s *Service) Authenticate(ctx context.Context, raw string) (Principal, error) {
claims, err := parseToken(s.cfg.SigningKey, raw, s.now())
if err != nil {
return Principal{}, err
}
active, err := s.store.SessionActive(ctx, claims.ID, s.now())
if err != nil || !active {
return Principal{}, errors.New("inactive Sense session")
}
user, err := s.store.FindUserByID(ctx, claims.Subject)
if err != nil || !user.Enabled || user.Role != claims.Role {
return Principal{}, errors.New("invalid Sense principal")
}
return principalFor(user, claims.ID), nil
}
func (s *Service) Logout(ctx context.Context, principal Principal) error {
if err := s.store.RevokeSession(ctx, principal.SessionID, s.now().UTC()); err != nil && !errors.Is(err, ErrNotFound) {
return err
}
s.audit(ctx, principal.UserID, "identity.logout", principal.UserID, "success", nil)
return nil
}
func (s *Service) ListUsers(ctx context.Context) ([]User, error) { return s.store.ListUsers(ctx) }
func (s *Service) ListAudit(ctx context.Context) ([]AuditEntry, error) {
return s.store.ListAudit(ctx, 100)
}
func (s *Service) CreateUser(ctx context.Context, actor Principal, username, displayName, role, password string) (User, error) {
if !ValidRole(role) {
return User{}, fmt.Errorf("角色不正确")
}
username = strings.TrimSpace(username)
if username == "" || len(username) > 64 {
return User{}, fmt.Errorf("用户名格式不正确")
}
if err := ValidatePassword(username, password); err != nil {
return User{}, err
}
hash, err := HashPassword(password)
if err != nil {
return User{}, err
}
now := s.now().UTC()
user := User{ID: newID("usr"), Username: username, DisplayName: strings.TrimSpace(displayName), Role: role, Enabled: true, PasswordHash: hash, CreatedAt: now, UpdatedAt: now}
if err := s.store.CreateUser(ctx, user); err != nil {
return User{}, err
}
s.audit(ctx, actor.UserID, "identity.user.create", user.ID, "success", map[string]any{"role": role})
return user, nil
}
func (s *Service) UpdateUser(ctx context.Context, actor Principal, id, displayName, role string, enabled bool) (User, error) {
if !ValidRole(role) {
return User{}, fmt.Errorf("角色不正确")
}
user, err := s.store.FindUserByID(ctx, id)
if err != nil {
return User{}, err
}
if actor.UserID == id && !enabled {
return User{}, fmt.Errorf("不能停用当前登录账户")
}
user.DisplayName = strings.TrimSpace(displayName)
user.Role = role
user.Enabled = enabled
user.UpdatedAt = s.now().UTC()
if err := s.store.UpdateUser(ctx, user); err != nil {
return User{}, err
}
s.audit(ctx, actor.UserID, "identity.user.update", user.ID, "success", map[string]any{"role": role, "enabled": enabled})
return user, nil
}
func (s *Service) audit(ctx context.Context, actor, action, target, outcome string, metadata map[string]any) {
_ = s.store.AppendAudit(ctx, AuditEntry{ID: newID("aud"), ActorID: actor, Action: action, Target: target, Outcome: outcome, Metadata: metadata, CreatedAt: s.now().UTC()})
}
func principalFor(user User, sessionID string) Principal {
return Principal{UserID: user.ID, Username: user.Username, DisplayName: user.DisplayName, Role: user.Role, Permissions: PermissionsForRole(user.Role), SessionID: sessionID}
}