forked from baron/baron-sso
- Added support for fixed UUIDs during bulk registration (Search-first + ExternalID mapping) - Implemented idempotency and visibility restoration for soft-deleted users - Enhanced bulk upload UI to show 'New/Updated/Unchanged' status and modified fields - Added logic to reclaim identifiers (login_id) from colliding records - Added frontend E2E and backend unit tests for UUID integrity and conflict handling - Fixed i18n, formatting, and mock tests to satisfy code-check - Applied 'go fix' for 'omitzero' tags and general Go standards
527 lines
14 KiB
Go
527 lines
14 KiB
Go
package service
|
|
|
|
import (
|
|
"baron-sso-backend/internal/domain"
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"os"
|
|
"regexp"
|
|
"strings"
|
|
"time"
|
|
|
|
"golang.org/x/crypto/bcrypt"
|
|
)
|
|
|
|
type KratosIdentity struct {
|
|
ID string `json:"id"`
|
|
SchemaID string `json:"schema_id,omitempty"`
|
|
Traits map[string]any `json:"traits"`
|
|
State string `json:"state,omitempty"`
|
|
MetadataAdmin any `json:"metadata_admin,omitempty"`
|
|
MetadataPublic any `json:"metadata_public,omitempty"`
|
|
ExternalID string `json:"external_id,omitempty"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
}
|
|
|
|
type KratosSessionDevice struct {
|
|
UserAgent string `json:"user_agent,omitempty"`
|
|
IPAddress string `json:"ip_address,omitempty"`
|
|
}
|
|
|
|
type KratosSession struct {
|
|
ID string `json:"id"`
|
|
Active bool `json:"active"`
|
|
AuthenticatedAt time.Time `json:"authenticated_at"`
|
|
ExpiresAt time.Time `json:"expires_at"`
|
|
IssuedAt time.Time `json:"issued_at"`
|
|
Identity *KratosIdentity `json:"identity,omitempty"`
|
|
Devices []KratosSessionDevice `json:"devices,omitempty"`
|
|
}
|
|
|
|
type KratosAdminService interface {
|
|
ListIdentities(ctx context.Context) ([]KratosIdentity, error)
|
|
FindIdentityIDByIdentifier(ctx context.Context, identifier string) (string, error)
|
|
GetIdentity(ctx context.Context, identityID string) (*KratosIdentity, error)
|
|
UpdateIdentity(ctx context.Context, identityID string, traits map[string]any, state string) (*KratosIdentity, error)
|
|
UpdateIdentityPassword(ctx context.Context, identityID, newPassword string) error
|
|
DeleteIdentity(ctx context.Context, identityID string) error
|
|
CreateUser(ctx context.Context, user *domain.BrokerUser, password string) (string, error)
|
|
ListIdentitySessions(ctx context.Context, identityID string) ([]KratosSession, error)
|
|
GetSession(ctx context.Context, sessionID string) (*KratosSession, error)
|
|
DeleteSession(ctx context.Context, sessionID string) error
|
|
}
|
|
|
|
type kratosAdminService struct {
|
|
AdminURL string
|
|
HTTPClient *http.Client
|
|
}
|
|
|
|
func NewKratosAdminService() KratosAdminService {
|
|
return &kratosAdminService{
|
|
AdminURL: getenvKratos("KRATOS_ADMIN_URL", "http://kratos:4434"),
|
|
}
|
|
}
|
|
|
|
func (s *kratosAdminService) ListIdentities(ctx context.Context) ([]KratosIdentity, error) {
|
|
endpoint := strings.TrimRight(s.AdminURL, "/") + "/admin/identities"
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
resp, err := s.httpClient().Do(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode >= 300 {
|
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
|
|
return nil, fmt.Errorf("kratos admin list identities failed status=%d body=%s", resp.StatusCode, string(body))
|
|
}
|
|
|
|
var identities []KratosIdentity
|
|
if err := json.NewDecoder(resp.Body).Decode(&identities); err != nil {
|
|
return nil, err
|
|
}
|
|
return identities, nil
|
|
}
|
|
|
|
func (s *kratosAdminService) FindIdentityIDByIdentifier(ctx context.Context, identifier string) (string, error) {
|
|
identifier = strings.TrimSpace(identifier)
|
|
if identifier == "" {
|
|
return "", nil
|
|
}
|
|
|
|
endpoint := strings.TrimRight(s.AdminURL, "/") + "/admin/identities"
|
|
|
|
// 1. Try credentials_identifier (Email/LoginID/Phone)
|
|
id, err := s.searchIdentities(ctx, endpoint, "credentials_identifier", identifier)
|
|
if err == nil && id != "" {
|
|
// VERIFY: Kratos sometimes ignores unknown query params and returns the first identity.
|
|
if s.verifyIdentityMatch(ctx, id, identifier) {
|
|
return id, nil
|
|
}
|
|
}
|
|
|
|
// 2. If it looks like a UUID, try external_id
|
|
if matched, _ := regexp.MatchString(`^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`, strings.ToLower(identifier)); matched {
|
|
id, err = s.searchIdentities(ctx, endpoint, "external_id", identifier)
|
|
if err == nil && id != "" {
|
|
if s.verifyIdentityMatch(ctx, id, identifier) {
|
|
return id, nil
|
|
}
|
|
}
|
|
|
|
// 3. Also try direct ID lookup
|
|
identity, err := s.GetIdentity(ctx, identifier)
|
|
if err == nil && identity != nil {
|
|
return identity.ID, nil
|
|
}
|
|
}
|
|
|
|
return "", nil
|
|
}
|
|
|
|
func (s *kratosAdminService) verifyIdentityMatch(ctx context.Context, id, identifier string) bool {
|
|
identity, err := s.GetIdentity(ctx, id)
|
|
if err != nil || identity == nil {
|
|
return false
|
|
}
|
|
|
|
// Exact ID match
|
|
if strings.EqualFold(identity.ID, identifier) {
|
|
return true
|
|
}
|
|
// Exact ExternalID match
|
|
if strings.EqualFold(identity.ExternalID, identifier) {
|
|
return true
|
|
}
|
|
// Check traits (Email, CustomLoginIDs)
|
|
if email, ok := identity.Traits["email"].(string); ok && strings.EqualFold(email, identifier) {
|
|
return true
|
|
}
|
|
if phone, ok := identity.Traits["phone_number"].(string); ok && strings.EqualFold(phone, identifier) {
|
|
return true
|
|
}
|
|
if lids, ok := identity.Traits["custom_login_ids"].([]any); ok {
|
|
for _, lid := range lids {
|
|
if s, ok := lid.(string); ok && strings.EqualFold(s, identifier) {
|
|
return true
|
|
}
|
|
}
|
|
} else if lids, ok := identity.Traits["custom_login_ids"].([]string); ok {
|
|
for _, lid := range lids {
|
|
if strings.EqualFold(lid, identifier) {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
func (s *kratosAdminService) searchIdentities(ctx context.Context, endpoint, key, value string) (string, error) {
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
query := req.URL.Query()
|
|
query.Set(key, value)
|
|
req.URL.RawQuery = query.Encode()
|
|
|
|
resp, err := s.httpClient().Do(req)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode == http.StatusNotFound {
|
|
return "", nil
|
|
}
|
|
if resp.StatusCode >= 300 {
|
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
|
|
return "", fmt.Errorf("kratos admin search by %s failed status=%d body=%s", key, resp.StatusCode, string(body))
|
|
}
|
|
|
|
var identities []struct {
|
|
ID string `json:"id"`
|
|
}
|
|
if err := json.NewDecoder(resp.Body).Decode(&identities); err != nil {
|
|
return "", err
|
|
}
|
|
if len(identities) == 0 {
|
|
return "", nil
|
|
}
|
|
return identities[0].ID, nil
|
|
}
|
|
|
|
func (s *kratosAdminService) GetIdentity(ctx context.Context, identityID string) (*KratosIdentity, error) {
|
|
endpoint := fmt.Sprintf("%s/admin/identities/%s", strings.TrimRight(s.AdminURL, "/"), identityID)
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
resp, err := s.httpClient().Do(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode == http.StatusNotFound {
|
|
return nil, nil
|
|
}
|
|
if resp.StatusCode >= 300 {
|
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
|
|
return nil, fmt.Errorf("kratos admin get identity failed status=%d body=%s", resp.StatusCode, string(body))
|
|
}
|
|
|
|
var identity KratosIdentity
|
|
if err := json.NewDecoder(resp.Body).Decode(&identity); err != nil {
|
|
return nil, err
|
|
}
|
|
return &identity, nil
|
|
}
|
|
|
|
func (s *kratosAdminService) UpdateIdentity(ctx context.Context, identityID string, traits map[string]any, state string) (*KratosIdentity, error) {
|
|
payload := map[string]any{
|
|
"schema_id": "default",
|
|
"traits": traits,
|
|
}
|
|
if strings.TrimSpace(state) != "" {
|
|
payload["state"] = strings.TrimSpace(state)
|
|
}
|
|
|
|
body, _ := json.Marshal(payload)
|
|
endpoint := fmt.Sprintf("%s/admin/identities/%s", strings.TrimRight(s.AdminURL, "/"), identityID)
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPut, endpoint, bytes.NewReader(body))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
resp, err := s.httpClient().Do(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode >= 300 {
|
|
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
|
|
return nil, fmt.Errorf("kratos admin update identity failed status=%d body=%s", resp.StatusCode, string(respBody))
|
|
}
|
|
|
|
var updated KratosIdentity
|
|
if err := json.NewDecoder(resp.Body).Decode(&updated); err != nil {
|
|
return nil, err
|
|
}
|
|
return &updated, nil
|
|
}
|
|
|
|
func (s *kratosAdminService) UpdateIdentityPassword(ctx context.Context, identityID, newPassword string) error {
|
|
identity, err := s.GetIdentity(ctx, identityID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if identity == nil {
|
|
return fmt.Errorf("kratos admin identity not found: %s", identityID)
|
|
}
|
|
|
|
hashedPassword, err := hashPasswordForKratosAdmin(newPassword)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
payload := map[string]any{
|
|
"schema_id": identity.SchemaID,
|
|
"traits": identity.Traits,
|
|
"state": identity.State,
|
|
"credentials": map[string]any{
|
|
"password": map[string]any{
|
|
"config": map[string]string{
|
|
"hashed_password": hashedPassword,
|
|
},
|
|
},
|
|
},
|
|
}
|
|
if payload["schema_id"] == "" {
|
|
payload["schema_id"] = "default"
|
|
}
|
|
if payload["state"] == "" {
|
|
payload["state"] = "active"
|
|
}
|
|
if identity.MetadataAdmin != nil {
|
|
payload["metadata_admin"] = identity.MetadataAdmin
|
|
}
|
|
if identity.MetadataPublic != nil {
|
|
payload["metadata_public"] = identity.MetadataPublic
|
|
}
|
|
if identity.ExternalID != "" {
|
|
payload["external_id"] = identity.ExternalID
|
|
}
|
|
|
|
body, _ := json.Marshal(payload)
|
|
endpoint := fmt.Sprintf("%s/admin/identities/%s", strings.TrimRight(s.AdminURL, "/"), identityID)
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPut, endpoint, bytes.NewReader(body))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
resp, err := s.httpClient().Do(req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode >= 300 {
|
|
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
|
|
return fmt.Errorf("kratos admin update password failed status=%d body=%s", resp.StatusCode, string(respBody))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *kratosAdminService) CreateUser(ctx context.Context, user *domain.BrokerUser, password string) (string, error) {
|
|
if user == nil {
|
|
return "", fmt.Errorf("kratos admin: user payload is nil")
|
|
}
|
|
|
|
traits := map[string]any{
|
|
"email": user.Email,
|
|
"name": user.Name,
|
|
}
|
|
if user.PhoneNumber != "" {
|
|
traits["phone_number"] = user.PhoneNumber
|
|
}
|
|
for k, v := range user.Attributes {
|
|
if k == "id" || k == "email" {
|
|
continue
|
|
}
|
|
traits[k] = v
|
|
}
|
|
|
|
payload := map[string]any{
|
|
"schema_id": "default",
|
|
"traits": traits,
|
|
"credentials": map[string]any{
|
|
"password": map[string]any{
|
|
"config": map[string]string{
|
|
"password": password,
|
|
},
|
|
},
|
|
},
|
|
"state": "active",
|
|
}
|
|
|
|
body, _ := json.Marshal(payload)
|
|
endpoint := strings.TrimRight(s.AdminURL, "/") + "/admin/identities"
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
resp, err := s.httpClient().Do(req)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode >= 300 {
|
|
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
|
|
return "", fmt.Errorf("kratos admin create identity failed status=%d body=%s", resp.StatusCode, string(respBody))
|
|
}
|
|
|
|
var created struct {
|
|
ID string `json:"id"`
|
|
}
|
|
if err := json.NewDecoder(resp.Body).Decode(&created); err != nil {
|
|
return "", err
|
|
}
|
|
|
|
return created.ID, nil
|
|
}
|
|
|
|
func hashPasswordForKratosAdmin(password string) (string, error) {
|
|
hashed, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return string(hashed), nil
|
|
}
|
|
|
|
func (s *kratosAdminService) DeleteIdentity(ctx context.Context, identityID string) error {
|
|
endpoint := fmt.Sprintf("%s/admin/identities/%s", strings.TrimRight(s.AdminURL, "/"), identityID)
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodDelete, endpoint, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
resp, err := s.httpClient().Do(req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode >= 300 {
|
|
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
|
|
return fmt.Errorf("kratos admin delete identity failed status=%d body=%s", resp.StatusCode, string(respBody))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *kratosAdminService) httpClient() *http.Client {
|
|
if s.HTTPClient != nil {
|
|
return s.HTTPClient
|
|
}
|
|
return &http.Client{
|
|
Timeout: 10 * time.Second,
|
|
Transport: &http.Transport{
|
|
DialContext: (&net.Dialer{
|
|
Timeout: 5 * time.Second,
|
|
KeepAlive: 30 * time.Second,
|
|
}).DialContext,
|
|
TLSHandshakeTimeout: 5 * time.Second,
|
|
},
|
|
}
|
|
}
|
|
|
|
func getenvKratos(key, fallback string) string {
|
|
if v := os.Getenv(key); v != "" {
|
|
return v
|
|
}
|
|
return fallback
|
|
}
|
|
|
|
func (s *kratosAdminService) ListIdentitySessions(ctx context.Context, identityID string) ([]KratosSession, error) {
|
|
url := fmt.Sprintf("%s/admin/identities/%s/sessions", s.AdminURL, identityID)
|
|
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
client := s.HTTPClient
|
|
if client == nil {
|
|
client = http.DefaultClient
|
|
}
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode == http.StatusNotFound {
|
|
return []KratosSession{}, nil
|
|
}
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, fmt.Errorf("unexpected status: %d", resp.StatusCode)
|
|
}
|
|
|
|
var sessions []KratosSession
|
|
if err := json.NewDecoder(resp.Body).Decode(&sessions); err != nil {
|
|
return nil, err
|
|
}
|
|
return sessions, nil
|
|
}
|
|
|
|
func (s *kratosAdminService) GetSession(ctx context.Context, sessionID string) (*KratosSession, error) {
|
|
url := fmt.Sprintf("%s/admin/sessions/%s", s.AdminURL, sessionID)
|
|
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
client := s.HTTPClient
|
|
if client == nil {
|
|
client = http.DefaultClient
|
|
}
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode == http.StatusNotFound {
|
|
return nil, nil
|
|
}
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, fmt.Errorf("unexpected status: %d", resp.StatusCode)
|
|
}
|
|
|
|
var session KratosSession
|
|
if err := json.NewDecoder(resp.Body).Decode(&session); err != nil {
|
|
return nil, err
|
|
}
|
|
return &session, nil
|
|
}
|
|
|
|
func (s *kratosAdminService) DeleteSession(ctx context.Context, sessionID string) error {
|
|
url := fmt.Sprintf("%s/admin/sessions/%s", s.AdminURL, sessionID)
|
|
req, err := http.NewRequestWithContext(ctx, "DELETE", url, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
client := s.HTTPClient
|
|
if client == nil {
|
|
client = http.DefaultClient
|
|
}
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusOK {
|
|
return fmt.Errorf("unexpected status: %d", resp.StatusCode)
|
|
}
|
|
return nil
|
|
}
|