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 }