160 lines
5.5 KiB
Go
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}
|
|
}
|