internal/users/users.go
Rama main · réplica de lectura del repositorio original.
package users
import (
"context"
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
"encoding/hex"
"errors"
"io"
"regexp"
"strings"
"sync"
"time"
)
type Role string
const (
RoleAdmin Role = "admin"
RoleUser Role = "user"
)
var (
ErrUserNotFound = errors.New("user not found")
ErrTokenNotFound = errors.New("token not found")
ErrDuplicateName = errors.New("user name already exists")
ErrDuplicateToken = errors.New("token digest already exists")
ErrUnauthorized = errors.New("valid bearer token required")
ErrInvalidName = errors.New("invalid user name")
ErrInvalidRole = errors.New("invalid user role")
ErrInvalidLabel = errors.New("token label must be at most 256 bytes")
ErrLastAdmin = errors.New("cannot remove the last admin")
ErrLastAdminToken = errors.New("cannot revoke the last token of the last admin")
)
type AuthError struct{ Reason string }
func (e AuthError) Error() string { return "authentication failed: " + e.Reason }
func (e AuthError) Unwrap() error { return ErrUnauthorized }
type Principal struct {
UserID string `json:"id"`
Name string `json:"name"`
TokenID string `json:"token_id"`
TokenLabel string `json:"token_label,omitempty"`
Role Role `json:"role"`
}
func (p Principal) IsAdmin() bool { return p.Role == RoleAdmin }
type Digest [32]byte
func (d Digest) String() string { return hex.EncodeToString(d[:]) }
func ParseDigest(raw string) (Digest, error) {
var d Digest
b, err := hex.DecodeString(raw)
if err != nil || len(b) != len(d) {
return d, errors.New("invalid token digest")
}
copy(d[:], b)
return d, nil
}
func HashToken(plaintext string) Digest { return sha256.Sum256([]byte(plaintext)) }
func NewToken() (plaintext string, d Digest, err error) {
b := make([]byte, 32)
if _, err = rand.Read(b); err != nil {
return "", d, err
}
plaintext = "hbt_" + base64.RawURLEncoding.EncodeToString(b)
return plaintext, HashToken(plaintext), nil
}
type User struct {
ID string `json:"id"`
Name string `json:"name"`
Role Role `json:"role"`
CreatedAt time.Time `json:"created_at"`
Disabled bool `json:"disabled"`
}
type Token struct {
ID string `json:"id"`
UserID string `json:"user_id"`
Label string `json:"label"`
Digest Digest `json:"-"`
CreatedAt time.Time `json:"created_at"`
ExpiresAt *time.Time `json:"expires_at"`
LastUsedAt *time.Time `json:"last_used_at"`
}
type Store interface {
GetUser(context.Context, string) (User, error)
ListUsers(context.Context) ([]User, error)
UpdateUser(context.Context, User) error
SetToken(context.Context, Token) error
GetTokenForUser(context.Context, string) (Token, error)
DeleteTokenForUser(context.Context, string) error
LookupToken(context.Context, Digest) (Token, User, error)
TouchToken(context.Context, string, time.Time) error
CreateUserWithToken(context.Context, User, Token) error
DeleteUserWithToken(context.Context, string) error
io.Closer
}
type Service struct {
store Store
now func() time.Time
mu sync.Mutex
}
func NewService(store Store) *Service {
return &Service{store: store, now: func() time.Time { return time.Now().UTC() }}
}
func (s *Service) Authenticate(ctx context.Context, plaintext string) (Principal, error) {
digest := HashToken(plaintext)
token, user, err := s.store.LookupToken(ctx, digest)
matched := subtle.ConstantTimeCompare(digest[:], token.Digest[:])
if err != nil || matched != 1 {
return Principal{}, AuthError{Reason: "unknown token"}
}
now := s.now()
if user.Disabled {
return Principal{}, AuthError{Reason: "disabled user"}
}
if token.ExpiresAt != nil && !now.Before(*token.ExpiresAt) {
return Principal{}, AuthError{Reason: "expired token"}
}
_ = s.store.TouchToken(ctx, token.ID, now)
return Principal{UserID: user.ID, Name: user.Name, Role: user.Role, TokenID: token.ID, TokenLabel: token.Label}, nil
}
// Bootstrap initializes an empty store with an admin and leaves existing users
// and tokens unchanged. generated is non-empty only for a newly minted secret.
func (s *Service) Bootstrap(ctx context.Context, plaintext string) (adminID, generated string, err error) {
s.mu.Lock()
defer s.mu.Unlock()
all, err := s.store.ListUsers(ctx)
if err != nil {
return "", "", err
}
if len(all) == 0 {
if plaintext == "" {
plaintext, _, err = NewToken()
if err != nil {
return "", "", err
}
generated = plaintext
}
now := s.now()
userID, err := newID("u_")
if err != nil {
return "", "", err
}
tokenID, err := newID("t_")
if err != nil {
return "", "", err
}
user := User{ID: userID, Name: "admin", Role: RoleAdmin, CreatedAt: now}
token := Token{ID: tokenID, UserID: user.ID, Label: "bootstrap", Digest: HashToken(plaintext), CreatedAt: now}
if err := s.store.CreateUserWithToken(ctx, user, token); err != nil {
return "", "", err
}
return user.ID, generated, nil
}
var admin User
for _, user := range all {
if user.Role == RoleAdmin {
admin = user
break
}
}
if admin.ID == "" {
return "", "", ErrLastAdmin
}
return admin.ID, "", nil
}
var namePattern = regexp.MustCompile(`^[a-z0-9_-]{1,32}$`)
func normalizeName(name string) (string, error) {
name = strings.ToLower(strings.TrimSpace(name))
if !namePattern.MatchString(name) {
return "", ErrInvalidName
}
return name, nil
}
func validRole(role Role) bool { return role == RoleAdmin || role == RoleUser }
// CreateUser registers a user together with their first token in one atomic
// store operation and returns the token's plaintext, which is never recoverable
// afterwards. An empty label and a zero ttl mean "default label, no expiry".
func (s *Service) CreateUser(ctx context.Context, name string, role Role, label string, ttl time.Duration) (User, Token, string, error) {
s.mu.Lock()
defer s.mu.Unlock()
name, err := normalizeName(name)
if err != nil {
return User{}, Token{}, "", err
}
if !validRole(role) {
return User{}, Token{}, "", ErrInvalidRole
}
userID, err := newID("u_")
if err != nil {
return User{}, Token{}, "", err
}
user := User{ID: userID, Name: name, Role: role, CreatedAt: s.now()}
token, plaintext, err := s.mintToken(userID, label, "initial", ttl)
if err != nil {
return User{}, Token{}, "", err
}
if err := s.store.CreateUserWithToken(ctx, user, token); err != nil {
return User{}, Token{}, "", err
}
return user, token, plaintext, nil
}
func (s *Service) mintToken(userID, label, defaultLabel string, ttl time.Duration) (Token, string, error) {
if len(label) > 256 {
return Token{}, "", ErrInvalidLabel
}
plaintext, digest, err := NewToken()
if err != nil {
return Token{}, "", err
}
tokenID, err := newID("t_")
if err != nil {
return Token{}, "", err
}
now := s.now()
token := Token{ID: tokenID, UserID: userID, Label: strings.TrimSpace(label), Digest: digest, CreatedAt: now}
if token.Label == "" {
token.Label = defaultLabel
}
if ttl > 0 {
expires := now.Add(ttl)
token.ExpiresAt = &expires
}
return token, plaintext, nil
}
func (s *Service) DeleteUser(ctx context.Context, id string) error {
s.mu.Lock()
defer s.mu.Unlock()
if err := s.validateDeleteUser(ctx, id); err != nil {
return err
}
return s.store.DeleteUserWithToken(ctx, id)
}
// ValidateDeleteUser performs the policy check separately so callers can
// safely resolve owned resources before committing the user deletion.
func (s *Service) ValidateDeleteUser(ctx context.Context, id string) error {
s.mu.Lock()
defer s.mu.Unlock()
return s.validateDeleteUser(ctx, id)
}
func (s *Service) validateDeleteUser(ctx context.Context, id string) error {
user, err := s.store.GetUser(ctx, id)
if err != nil {
return err
}
if user.Role != RoleAdmin {
return nil
}
reachable, err := s.reachableAdmins(ctx, id)
if err != nil {
return err
}
if reachable == 0 {
return ErrLastAdmin
}
return nil
}
func (s *Service) SetDisabled(ctx context.Context, id string, disabled bool) error {
s.mu.Lock()
defer s.mu.Unlock()
user, err := s.store.GetUser(ctx, id)
if err != nil {
return err
}
if disabled && !user.Disabled && user.Role == RoleAdmin {
reachable, err := s.reachableAdmins(ctx, id)
if err != nil {
return err
}
if reachable == 0 {
return ErrLastAdmin
}
}
user.Disabled = disabled
return s.store.UpdateUser(ctx, user)
}
// reachableAdmins counts the admins other than excludeUserID who could still
// administer this deployment: enabled, and holding an unexpired token. Every
// policy that could lock an operator out is expressed as "would this leave zero
// reachable admins?", so the answer is computed in exactly one place.
func (s *Service) reachableAdmins(ctx context.Context, excludeUserID string) (int, error) {
all, err := s.store.ListUsers(ctx)
if err != nil {
return 0, err
}
now := s.now()
count := 0
for _, user := range all {
if user.ID == excludeUserID || user.Role != RoleAdmin || user.Disabled {
continue
}
token, err := s.store.GetTokenForUser(ctx, user.ID)
if errors.Is(err, ErrTokenNotFound) {
continue
}
if err != nil {
return 0, err
}
if token.ExpiresAt == nil || now.Before(*token.ExpiresAt) {
count++
}
}
return count, nil
}
func (s *Service) RotateToken(ctx context.Context, userID, label string, ttl time.Duration) (Token, string, error) {
s.mu.Lock()
defer s.mu.Unlock()
if _, err := s.store.GetUser(ctx, userID); err != nil {
return Token{}, "", err
}
token, plaintext, err := s.mintToken(userID, label, "token", ttl)
if err != nil {
return Token{}, "", err
}
if err := s.store.SetToken(ctx, token); err != nil {
return Token{}, "", err
}
return token, plaintext, nil
}
func (s *Service) RevokeToken(ctx context.Context, userID string) error {
s.mu.Lock()
defer s.mu.Unlock()
if _, err := s.store.GetTokenForUser(ctx, userID); err != nil {
return err
}
user, err := s.store.GetUser(ctx, userID)
if err != nil {
return err
}
if user.Role == RoleAdmin {
reachable, err := s.reachableAdmins(ctx, userID)
if err != nil {
return err
}
if reachable == 0 {
return ErrLastAdminToken
}
}
return s.store.DeleteTokenForUser(ctx, userID)
}
func (s *Service) ListUsers(ctx context.Context) ([]User, error) { return s.store.ListUsers(ctx) }
func (s *Service) GetUser(ctx context.Context, id string) (User, error) {
return s.store.GetUser(ctx, id)
}
func (s *Service) GetToken(ctx context.Context, userID string) (Token, error) {
return s.store.GetTokenForUser(ctx, userID)
}
func newID(prefix string) (string, error) {
b := make([]byte, 6)
if _, err := rand.Read(b); err != nil {
return "", err
}
return prefix + hex.EncodeToString(b), nil
}