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
1025 lines
30 KiB
Go
1025 lines
30 KiB
Go
package service
|
|
|
|
import (
|
|
"baron-sso-backend/internal/domain"
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"net"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"strings"
|
|
"time"
|
|
|
|
"golang.org/x/crypto/bcrypt"
|
|
)
|
|
|
|
// OryProvider는 Kratos/Hydra를 기반으로 하는 IDP 어댑터의 최소 스켈레톤입니다.
|
|
// 지금은 스키마 메타데이터만 반환하며, 나머지 동작은 후속 작업에서 구현합니다.
|
|
type OryProvider struct {
|
|
KratosAdminURL string
|
|
KratosPublicURL string
|
|
HydraAdminURL string
|
|
HTTPClient *http.Client
|
|
}
|
|
|
|
func NewOryProvider() *OryProvider {
|
|
return &OryProvider{
|
|
KratosAdminURL: getenv("KRATOS_ADMIN_URL", "http://kratos:4434"),
|
|
KratosPublicURL: getenv("KRATOS_PUBLIC_URL", "http://kratos:4433"),
|
|
HydraAdminURL: getenv("HYDRA_ADMIN_URL", "http://hydra:4445"),
|
|
}
|
|
}
|
|
|
|
func (o *OryProvider) Name() string {
|
|
return "Ory (Kratos/Hydra)"
|
|
}
|
|
|
|
// GetMetadata는 BrokerUser가 요구하는 필드를 Kratos traits에 매핑 가능하다는 가정으로 반환합니다.
|
|
func (o *OryProvider) GetMetadata() (*domain.IDPMetadata, error) {
|
|
return &domain.IDPMetadata{
|
|
SupportedFields: []string{
|
|
"id", "custom_login_ids", "login_id", "email", "name", "phone_number",
|
|
"grade", "department", "affiliationType", "tenant_id",
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
// CreateUser는 Kratos Admin API를 통해 identity를 생성합니다.
|
|
func (o *OryProvider) CreateUser(user *domain.BrokerUser, password string) (string, error) {
|
|
if user == nil {
|
|
return "", fmt.Errorf("ory provider: user payload is nil")
|
|
}
|
|
if user.Email == "" || password == "" {
|
|
return "", fmt.Errorf("ory provider: email and password are required")
|
|
}
|
|
|
|
// 중복 확인
|
|
if user.ID != "" {
|
|
existing, err := o.getIdentity(user.ID)
|
|
if err == nil && existing != nil {
|
|
return "", fmt.Errorf("ory provider: identity already exists for uuid=%s", user.ID)
|
|
}
|
|
}
|
|
|
|
existingID, err := o.findIdentityID(user.Email)
|
|
if err != nil {
|
|
return "", fmt.Errorf("ory provider: search identity failed: %w", err)
|
|
}
|
|
if existingID != "" {
|
|
return "", fmt.Errorf("ory provider: identity already exists for email=%s", user.Email)
|
|
}
|
|
|
|
// [New] Check all custom login IDs for collisions
|
|
for _, lid := range user.CustomLoginIDs {
|
|
if lid == "" {
|
|
continue
|
|
}
|
|
existing, err := o.findIdentityID(lid)
|
|
if err != nil {
|
|
return "", fmt.Errorf("ory provider: search identity failed for %s: %w", lid, err)
|
|
}
|
|
if existing != "" {
|
|
return "", fmt.Errorf("ory provider: identifier %s already exists", lid)
|
|
}
|
|
}
|
|
|
|
// [Legacy] check single LoginID
|
|
if user.LoginID != "" {
|
|
existingLoginID, err := o.findIdentityID(user.LoginID)
|
|
if err != nil {
|
|
return "", fmt.Errorf("ory provider: search identity failed: %w", err)
|
|
}
|
|
if existingLoginID != "" {
|
|
return "", fmt.Errorf("ory provider: identity already exists for login_id=%s", user.LoginID)
|
|
}
|
|
}
|
|
|
|
if user.PhoneNumber != "" {
|
|
existingPhoneID, err := o.findIdentityID(user.PhoneNumber)
|
|
if err != nil {
|
|
return "", fmt.Errorf("ory provider: search identity failed: %w", err)
|
|
}
|
|
if existingPhoneID != "" {
|
|
return "", fmt.Errorf("ory provider: identity already exists for phone=%s", user.PhoneNumber)
|
|
}
|
|
}
|
|
|
|
traits := map[string]any{
|
|
"email": user.Email,
|
|
"name": user.Name,
|
|
}
|
|
if len(user.CustomLoginIDs) > 0 {
|
|
traits["custom_login_ids"] = user.CustomLoginIDs
|
|
} else if user.LoginID != "" {
|
|
traits["custom_login_ids"] = []string{user.LoginID}
|
|
}
|
|
|
|
if user.PhoneNumber != "" {
|
|
traits["phone_number"] = user.PhoneNumber
|
|
}
|
|
for k, v := range user.Attributes {
|
|
// [SoT Fix] Don't let attributes overwrite core traits or use old 'id' trait
|
|
if k == "id" || k == "email" || k == "custom_login_ids" {
|
|
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,
|
|
},
|
|
},
|
|
},
|
|
}
|
|
if user.ID != "" {
|
|
// Use external_id as a fallback/mapping for the requested ID
|
|
payload["external_id"] = user.ID
|
|
// Also store in metadata_admin for audit/migration purposes
|
|
payload["metadata_admin"] = map[string]any{
|
|
"original_uuid": user.ID,
|
|
}
|
|
}
|
|
verifiable := []map[string]any{
|
|
{
|
|
"value": user.Email,
|
|
"verified": true,
|
|
"via": "email",
|
|
},
|
|
}
|
|
if user.PhoneNumber != "" {
|
|
verifiable = append(verifiable, map[string]any{
|
|
"value": user.PhoneNumber,
|
|
"verified": true,
|
|
"via": "sms",
|
|
})
|
|
}
|
|
payload["verifiable_addresses"] = verifiable
|
|
payload["recovery_addresses"] = []map[string]any{
|
|
{
|
|
"value": user.Email,
|
|
"via": "email",
|
|
},
|
|
}
|
|
|
|
body, _ := json.Marshal(payload)
|
|
req, err := http.NewRequestWithContext(context.Background(), http.MethodPost, fmt.Sprintf("%s/admin/identities", o.KratosAdminURL), bytes.NewReader(body))
|
|
if err != nil {
|
|
return "", fmt.Errorf("ory provider: build create request failed: %w", err)
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
resp, err := o.httpClient().Do(req)
|
|
if err != nil {
|
|
return "", fmt.Errorf("ory provider: create identity request failed: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode >= 300 {
|
|
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
|
|
return "", fmt.Errorf("ory provider: 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 "", fmt.Errorf("ory provider: decode create identity response failed: %w", err)
|
|
}
|
|
|
|
slog.Info("Ory identity created", "identity_id", created.ID, "email", user.Email)
|
|
return created.ID, nil
|
|
}
|
|
|
|
// SignIn은 Kratos Public API의 login API 플로우를 사용해 세션 토큰을 발급합니다.
|
|
func (o *OryProvider) SignIn(loginID, password string) (*domain.AuthInfo, error) {
|
|
if loginID == "" || password == "" {
|
|
return nil, fmt.Errorf("ory provider: loginID and password are required")
|
|
}
|
|
|
|
flowID, err := o.startLoginFlow("")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
body, _ := json.Marshal(map[string]string{
|
|
"identifier": loginID,
|
|
"password": password,
|
|
"method": "password",
|
|
})
|
|
loginURL := fmt.Sprintf("%s/self-service/login?flow=%s", o.KratosPublicURL, flowID)
|
|
req, err := http.NewRequestWithContext(context.Background(), http.MethodPost, loginURL, bytes.NewReader(body))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("ory provider: build login request failed: %w", err)
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
resp, err := o.httpClient().Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("ory provider: login request failed: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode >= 300 {
|
|
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
|
|
return nil, fmt.Errorf("ory provider: login failed status=%d body=%s", resp.StatusCode, string(respBody))
|
|
}
|
|
|
|
var result struct {
|
|
SessionToken string `json:"session_token"`
|
|
SessionTokenExpiresAt time.Time `json:"session_token_expires_at"`
|
|
Session struct {
|
|
ID string `json:"id"`
|
|
Identity struct {
|
|
ID string `json:"id"`
|
|
} `json:"identity"`
|
|
} `json:"session"`
|
|
}
|
|
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
|
return nil, fmt.Errorf("ory provider: decode login response failed: %w", err)
|
|
}
|
|
if result.SessionToken == "" {
|
|
return nil, fmt.Errorf("ory provider: empty session token returned")
|
|
}
|
|
|
|
slog.Info("Ory login successful",
|
|
"identity_id", result.Session.Identity.ID,
|
|
"loginID", loginID,
|
|
"expires_at", result.SessionTokenExpiresAt,
|
|
)
|
|
|
|
return &domain.AuthInfo{
|
|
SessionToken: &domain.Token{
|
|
JWT: result.SessionToken,
|
|
Expiration: result.SessionTokenExpiresAt,
|
|
SessionID: result.Session.ID,
|
|
},
|
|
Subject: result.Session.Identity.ID,
|
|
SetCookies: resp.Cookies(),
|
|
}, nil
|
|
}
|
|
|
|
// UserExists는 Kratos Admin API로 loginID 존재 여부를 확인합니다.
|
|
func (o *OryProvider) UserExists(loginID string) (bool, error) {
|
|
if loginID == "" {
|
|
return false, fmt.Errorf("ory provider: loginID is empty")
|
|
}
|
|
identityID, err := o.findIdentityID(loginID)
|
|
if err != nil {
|
|
return false, fmt.Errorf("ory provider: find identity failed: %w", err)
|
|
}
|
|
return identityID != "", nil
|
|
}
|
|
|
|
// IssueSession은 Ory에서 별도 세션 발급이 필요할 때 사용합니다. (현재 미지원)
|
|
func (o *OryProvider) IssueSession(loginID string) (*domain.AuthInfo, error) {
|
|
return nil, domain.ErrNotSupported
|
|
}
|
|
|
|
// InitiateLinkLogin은 Kratos Public API로 링크 로그인 플로우를 시작하고 이메일 전송을 트리거합니다.
|
|
func (o *OryProvider) InitiateLinkLogin(loginID, returnTo string) (*domain.LinkLoginInit, error) {
|
|
if loginID == "" {
|
|
return nil, fmt.Errorf("ory provider: loginID is required")
|
|
}
|
|
|
|
effectiveLoginID, err := o.resolveEffectiveLoginID(loginID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if err := o.ensureCodeLoginIdentifier(effectiveLoginID); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
init, err := o.submitLoginCodeInit(effectiveLoginID, returnTo)
|
|
if err == nil {
|
|
init.LoginID = effectiveLoginID
|
|
return init, nil
|
|
}
|
|
|
|
if shouldBootstrapCodeLogin(err) {
|
|
if ensureErr := o.ensureCodeLoginIdentifier(effectiveLoginID); ensureErr == nil {
|
|
init, initErr := o.submitLoginCodeInit(effectiveLoginID, returnTo)
|
|
if initErr == nil {
|
|
init.LoginID = effectiveLoginID
|
|
}
|
|
return init, initErr
|
|
} else {
|
|
slog.Warn("Ory code login bootstrap failed", "loginID", effectiveLoginID, "error", ensureErr)
|
|
}
|
|
}
|
|
|
|
return nil, err
|
|
}
|
|
|
|
func (o *OryProvider) resolveEffectiveLoginID(loginID string) (string, error) {
|
|
if strings.Contains(loginID, "@") {
|
|
return loginID, nil
|
|
}
|
|
|
|
identityID, err := o.findIdentityID(loginID)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if identityID == "" {
|
|
return "", fmt.Errorf("ory provider: identity not found for loginID=%s", loginID)
|
|
}
|
|
|
|
fullIdentity, err := o.fetchIdentityFull(identityID)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if fullIdentity != nil {
|
|
if emailRaw, ok := fullIdentity.Traits["email"]; ok {
|
|
if email, ok := emailRaw.(string); ok && email != "" {
|
|
return email, nil
|
|
}
|
|
}
|
|
}
|
|
|
|
return "", fmt.Errorf("ory provider: email trait missing for loginID=%s", loginID)
|
|
}
|
|
|
|
func (o *OryProvider) submitLoginCodeInit(loginID, returnTo string) (*domain.LinkLoginInit, error) {
|
|
flowID, err := o.startLoginFlow(returnTo)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
body, _ := json.Marshal(map[string]string{
|
|
"method": "code",
|
|
"identifier": loginID,
|
|
})
|
|
loginURL := fmt.Sprintf("%s/self-service/login?flow=%s", o.KratosPublicURL, flowID)
|
|
req, err := http.NewRequestWithContext(context.Background(), http.MethodPost, loginURL, bytes.NewReader(body))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("ory provider: build link login request failed: %w", err)
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
resp, err := o.httpClient().Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("ory provider: link login request failed: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
|
if resp.StatusCode >= 300 {
|
|
init, ok := parseKratosLinkLoginResponse(flowID, respBody)
|
|
if ok {
|
|
slog.Info("Ory link login initiated with non-2xx response", "loginID", loginID, "flow_id", flowID, "status", resp.StatusCode)
|
|
return init, nil
|
|
}
|
|
return nil, fmt.Errorf("ory provider: link login failed status=%d body=%s", resp.StatusCode, string(respBody))
|
|
}
|
|
|
|
var result struct {
|
|
ExpiresAt time.Time `json:"expires_at"`
|
|
}
|
|
_ = json.Unmarshal(respBody, &result)
|
|
|
|
slog.Info("Ory link login initiated", "loginID", loginID, "flow_id", flowID)
|
|
|
|
return &domain.LinkLoginInit{
|
|
FlowID: flowID,
|
|
ExpiresAt: result.ExpiresAt,
|
|
Mode: "link",
|
|
}, nil
|
|
}
|
|
|
|
func parseKratosLinkLoginResponse(flowID string, body []byte) (*domain.LinkLoginInit, bool) {
|
|
if len(body) == 0 {
|
|
return nil, false
|
|
}
|
|
var parsed struct {
|
|
ExpiresAt time.Time `json:"expires_at"`
|
|
State string `json:"state"`
|
|
Active string `json:"active"`
|
|
}
|
|
if err := json.Unmarshal(body, &parsed); err != nil {
|
|
return nil, false
|
|
}
|
|
state := strings.ToLower(parsed.State)
|
|
active := strings.ToLower(parsed.Active)
|
|
if strings.Contains(state, "sent") || active == "code" {
|
|
return &domain.LinkLoginInit{
|
|
FlowID: flowID,
|
|
ExpiresAt: parsed.ExpiresAt,
|
|
Mode: "link",
|
|
}, true
|
|
}
|
|
return nil, false
|
|
}
|
|
|
|
func shouldBootstrapCodeLogin(err error) bool {
|
|
if err == nil {
|
|
return false
|
|
}
|
|
msg := strings.ToLower(err.Error())
|
|
return strings.Contains(msg, "has not setup sign in with code") ||
|
|
strings.Contains(msg, "4000035")
|
|
}
|
|
|
|
type kratosVerifiableAddress struct {
|
|
Value string `json:"value"`
|
|
Via string `json:"via"`
|
|
Verified bool `json:"verified"`
|
|
Status string `json:"status,omitempty"`
|
|
}
|
|
|
|
func (o *OryProvider) ensureCodeLoginIdentifier(loginID string) error {
|
|
identityID, err := o.findIdentityID(loginID)
|
|
if err != nil {
|
|
return fmt.Errorf("ory provider: find identity failed: %w", err)
|
|
}
|
|
if identityID == "" {
|
|
return fmt.Errorf("ory provider: identity not found for loginID=%s", loginID)
|
|
}
|
|
|
|
identity, err := o.fetchIdentity(identityID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
via := "sms"
|
|
if strings.Contains(loginID, "@") {
|
|
via = "email"
|
|
}
|
|
|
|
exists := false
|
|
existingIndex := -1
|
|
addresses := make([]kratosVerifiableAddress, 0, len(identity.VerifiableAddresses)+1)
|
|
for idx, addr := range identity.VerifiableAddresses {
|
|
addresses = append(addresses, kratosVerifiableAddress{
|
|
Value: addr.Value,
|
|
Via: addr.Via,
|
|
Verified: addr.Verified,
|
|
Status: addr.Status,
|
|
})
|
|
if addr.Value == loginID && addr.Via == via {
|
|
exists = true
|
|
existingIndex = idx
|
|
}
|
|
}
|
|
ops := make([]map[string]any, 0, 2)
|
|
if !exists {
|
|
ops = append(ops, map[string]any{
|
|
"op": "add",
|
|
"path": "/verifiable_addresses/-",
|
|
"value": map[string]any{
|
|
"value": loginID,
|
|
"via": via,
|
|
"verified": true,
|
|
"status": "completed",
|
|
},
|
|
})
|
|
} else {
|
|
addr := identity.VerifiableAddresses[existingIndex]
|
|
if !addr.Verified {
|
|
ops = append(ops, map[string]any{
|
|
"op": "replace",
|
|
"path": fmt.Sprintf("/verifiable_addresses/%d/verified", existingIndex),
|
|
"value": true,
|
|
})
|
|
}
|
|
if addr.Status != "" && addr.Status != "completed" {
|
|
ops = append(ops, map[string]any{
|
|
"op": "replace",
|
|
"path": fmt.Sprintf("/verifiable_addresses/%d/status", existingIndex),
|
|
"value": "completed",
|
|
})
|
|
}
|
|
}
|
|
|
|
if len(ops) == 0 {
|
|
slog.Info("Ory identity verifiable address already ready", "identity_id", identityID, "loginID", loginID, "via", via)
|
|
return nil
|
|
}
|
|
|
|
if err := o.patchIdentity(identityID, ops); err != nil {
|
|
slog.Warn("Ory identity patch failed, trying full update", "identity_id", identityID, "error", err)
|
|
}
|
|
|
|
fullIdentity, err := o.fetchIdentityFull(identityID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
addresses = make([]kratosVerifiableAddress, 0, len(fullIdentity.VerifiableAddresses)+1)
|
|
found := false
|
|
for _, addr := range fullIdentity.VerifiableAddresses {
|
|
addresses = append(addresses, kratosVerifiableAddress{
|
|
Value: addr.Value,
|
|
Via: addr.Via,
|
|
Verified: addr.Verified,
|
|
Status: addr.Status,
|
|
})
|
|
if addr.Value == loginID && addr.Via == via {
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
addresses = append(addresses, kratosVerifiableAddress{
|
|
Value: loginID,
|
|
Via: via,
|
|
Verified: true,
|
|
Status: "completed",
|
|
})
|
|
}
|
|
|
|
payload := map[string]any{
|
|
"schema_id": fullIdentity.SchemaID,
|
|
"traits": fullIdentity.Traits,
|
|
"verifiable_addresses": addresses,
|
|
}
|
|
if len(fullIdentity.RecoveryAddresses) > 0 {
|
|
payload["recovery_addresses"] = fullIdentity.RecoveryAddresses
|
|
}
|
|
|
|
body, _ := json.Marshal(payload)
|
|
req, err := http.NewRequestWithContext(context.Background(), http.MethodPut, fmt.Sprintf("%s/admin/identities/%s", o.KratosAdminURL, identityID), bytes.NewReader(body))
|
|
if err != nil {
|
|
return fmt.Errorf("ory provider: build identity update failed: %w", err)
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
resp, err := o.httpClient().Do(req)
|
|
if err != nil {
|
|
return fmt.Errorf("ory provider: identity update failed: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode >= 300 {
|
|
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
|
|
return fmt.Errorf("ory provider: identity update failed status=%d body=%s", resp.StatusCode, string(respBody))
|
|
}
|
|
|
|
slog.Info("Ory identity updated with verifiable address", "identity_id", identityID, "loginID", loginID, "via", via)
|
|
return nil
|
|
}
|
|
|
|
type kratosIdentity struct {
|
|
VerifiableAddresses []kratosVerifiableAddress `json:"verifiable_addresses"`
|
|
}
|
|
|
|
type kratosRecoveryAddress struct {
|
|
Value string `json:"value"`
|
|
Via string `json:"via"`
|
|
}
|
|
|
|
type kratosIdentityFull struct {
|
|
SchemaID string `json:"schema_id"`
|
|
Traits map[string]any `json:"traits"`
|
|
VerifiableAddresses []kratosVerifiableAddress `json:"verifiable_addresses"`
|
|
RecoveryAddresses []kratosRecoveryAddress `json:"recovery_addresses"`
|
|
}
|
|
|
|
func (o *OryProvider) patchIdentity(identityID string, ops []map[string]any) error {
|
|
body, _ := json.Marshal(ops)
|
|
req, err := http.NewRequestWithContext(context.Background(), http.MethodPatch, fmt.Sprintf("%s/admin/identities/%s", o.KratosAdminURL, identityID), bytes.NewReader(body))
|
|
if err != nil {
|
|
return fmt.Errorf("ory provider: build identity patch failed: %w", err)
|
|
}
|
|
req.Header.Set("Content-Type", "application/json-patch+json")
|
|
|
|
resp, err := o.httpClient().Do(req)
|
|
if err != nil {
|
|
return fmt.Errorf("ory provider: identity patch failed: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode >= 300 {
|
|
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
|
|
return fmt.Errorf("ory provider: identity patch failed status=%d body=%s", resp.StatusCode, string(respBody))
|
|
}
|
|
|
|
slog.Info("Ory identity patched", "identity_id", identityID, "ops", len(ops))
|
|
return nil
|
|
}
|
|
|
|
func (o *OryProvider) fetchIdentity(identityID string) (*kratosIdentity, error) {
|
|
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, fmt.Sprintf("%s/admin/identities/%s", o.KratosAdminURL, identityID), nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("ory provider: build identity get failed: %w", err)
|
|
}
|
|
|
|
resp, err := o.httpClient().Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("ory provider: identity get failed: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode >= 300 {
|
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
|
|
return nil, fmt.Errorf("ory provider: identity get failed status=%d body=%s", resp.StatusCode, string(body))
|
|
}
|
|
|
|
var identity kratosIdentity
|
|
if err := json.NewDecoder(resp.Body).Decode(&identity); err != nil {
|
|
return nil, fmt.Errorf("ory provider: decode identity failed: %w", err)
|
|
}
|
|
return &identity, nil
|
|
}
|
|
|
|
func (o *OryProvider) fetchIdentityFull(identityID string) (*kratosIdentityFull, error) {
|
|
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, fmt.Sprintf("%s/admin/identities/%s", o.KratosAdminURL, identityID), nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("ory provider: build identity get failed: %w", err)
|
|
}
|
|
|
|
resp, err := o.httpClient().Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("ory provider: identity get failed: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode >= 300 {
|
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
|
|
return nil, fmt.Errorf("ory provider: identity get failed status=%d body=%s", resp.StatusCode, string(body))
|
|
}
|
|
|
|
var identity kratosIdentityFull
|
|
if err := json.NewDecoder(resp.Body).Decode(&identity); err != nil {
|
|
return nil, fmt.Errorf("ory provider: decode identity failed: %w", err)
|
|
}
|
|
return &identity, nil
|
|
}
|
|
|
|
// VerifyLoginCode는 Kratos 로그인 코드 제출로 세션을 발급합니다.
|
|
func (o *OryProvider) VerifyLoginCode(loginID, flowID, code string) (*domain.AuthInfo, error) {
|
|
if loginID == "" || flowID == "" || code == "" {
|
|
return nil, fmt.Errorf("ory provider: loginID, flowID and code are required")
|
|
}
|
|
|
|
body, _ := json.Marshal(map[string]string{
|
|
"method": "code",
|
|
"identifier": loginID,
|
|
"code": code,
|
|
})
|
|
loginURL := fmt.Sprintf("%s/self-service/login?flow=%s", o.KratosPublicURL, flowID)
|
|
req, err := http.NewRequestWithContext(context.Background(), http.MethodPost, loginURL, bytes.NewReader(body))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("ory provider: build login code request failed: %w", err)
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
resp, err := o.httpClient().Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("ory provider: login code request failed: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode >= 300 {
|
|
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
|
|
return nil, fmt.Errorf("ory provider: login code failed status=%d body=%s", resp.StatusCode, string(respBody))
|
|
}
|
|
|
|
var result struct {
|
|
SessionToken string `json:"session_token"`
|
|
SessionTokenExpiresAt time.Time `json:"session_token_expires_at"`
|
|
Session struct {
|
|
ID string `json:"id"`
|
|
Identity struct {
|
|
ID string `json:"id"`
|
|
} `json:"identity"`
|
|
} `json:"session"`
|
|
}
|
|
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
|
return nil, fmt.Errorf("ory provider: decode login code response failed: %w", err)
|
|
}
|
|
if result.SessionToken == "" {
|
|
return nil, fmt.Errorf("ory provider: empty session token returned")
|
|
}
|
|
|
|
slog.Info("Ory login code successful",
|
|
"identity_id", result.Session.Identity.ID,
|
|
"loginID", loginID,
|
|
"expires_at", result.SessionTokenExpiresAt,
|
|
)
|
|
|
|
return &domain.AuthInfo{
|
|
SessionToken: &domain.Token{
|
|
JWT: result.SessionToken,
|
|
Expiration: result.SessionTokenExpiresAt,
|
|
SessionID: result.Session.ID,
|
|
},
|
|
Subject: result.Session.Identity.ID,
|
|
SetCookies: resp.Cookies(),
|
|
}, nil
|
|
}
|
|
|
|
// GetPasswordPolicy는 Ory 환경에서 사용하는 기본 정책을 반환합니다.
|
|
func (o *OryProvider) GetPasswordPolicy() (*domain.PasswordPolicy, error) {
|
|
return &domain.PasswordPolicy{
|
|
MinLength: 12,
|
|
Lowercase: true,
|
|
Uppercase: false,
|
|
Number: true,
|
|
NonAlphanumeric: true,
|
|
MinCharacterTypes: 0,
|
|
}, nil
|
|
}
|
|
|
|
// InitiatePasswordReset는 현재 내부 토큰/메일 흐름을 사용하고 있으므로 NO-OP로 둡니다.
|
|
func (o *OryProvider) InitiatePasswordReset(loginID, redirectUrl string) error {
|
|
slog.Info("Ory InitiatePasswordReset bypassed (handled by app internal flow)", "loginID", loginID, "redirect", redirectUrl)
|
|
return nil
|
|
}
|
|
|
|
// VerifyPasswordResetToken는 내부 토큰 검증 흐름을 사용하므로 아직 구현하지 않습니다.
|
|
func (o *OryProvider) VerifyPasswordResetToken(token string) (*domain.AuthInfo, error) {
|
|
return nil, fmt.Errorf("ory provider: VerifyPasswordResetToken not implemented (internal token flow expected)")
|
|
}
|
|
|
|
// UpdateUserPassword: Kratos Admin API를 통해 비밀번호를 갱신합니다.
|
|
func (o *OryProvider) UpdateUserPassword(loginID, newPassword string, r *http.Request) error {
|
|
if loginID == "" || newPassword == "" {
|
|
return fmt.Errorf("ory provider: loginID or new password missing")
|
|
}
|
|
|
|
identityID, err := o.findIdentityID(loginID)
|
|
if err != nil {
|
|
return fmt.Errorf("ory provider: find identity failed: %w", err)
|
|
}
|
|
|
|
if identityID == "" {
|
|
return fmt.Errorf("ory provider: identity not found for loginID=%s", loginID)
|
|
}
|
|
|
|
identity, err := o.getIdentity(identityID)
|
|
if err != nil {
|
|
return fmt.Errorf("ory provider: load identity failed: %w", err)
|
|
}
|
|
if identity == nil {
|
|
return fmt.Errorf("ory provider: identity payload missing for loginID=%s", loginID)
|
|
}
|
|
|
|
hashedPassword, err := hashPasswordForKratos(newPassword)
|
|
if err != nil {
|
|
return fmt.Errorf("ory provider: hash password failed: %w", 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)
|
|
req, err := http.NewRequestWithContext(context.Background(), http.MethodPut, fmt.Sprintf("%s/admin/identities/%s", o.KratosAdminURL, identityID), bytes.NewReader(body))
|
|
if err != nil {
|
|
return fmt.Errorf("ory provider: build request failed: %w", err)
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
resp, err := o.httpClient().Do(req)
|
|
if err != nil {
|
|
return fmt.Errorf("ory provider: request failed: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode >= 300 {
|
|
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
|
|
return fmt.Errorf("ory provider: password update failed status=%d body=%s", resp.StatusCode, string(respBody))
|
|
}
|
|
|
|
slog.Info("Ory password updated via Kratos admin", "identity_id", identityID, "loginID", loginID)
|
|
return nil
|
|
}
|
|
|
|
func getenv(key, fallback string) string {
|
|
if v := os.Getenv(key); v != "" {
|
|
return v
|
|
}
|
|
return fallback
|
|
}
|
|
|
|
// findIdentityByID: Kratos Admin API에서 ID(UUID)로 직접 조회
|
|
func (o *OryProvider) findIdentityByID(id string) (string, error) {
|
|
identity, err := o.getIdentity(id)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if identity != nil {
|
|
return identity.ID, nil
|
|
}
|
|
return "", nil
|
|
}
|
|
|
|
// findIdentityByExternalID: external_id 필드로 identity 검색
|
|
func (o *OryProvider) findIdentityByExternalID(externalID string) (string, error) {
|
|
u, err := url.Parse(fmt.Sprintf("%s/admin/identities", o.KratosAdminURL))
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
query := u.Query()
|
|
// Kratos v1.1+ supports filtering by external_id
|
|
query.Set("external_id", externalID)
|
|
u.RawQuery = query.Encode()
|
|
|
|
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, u.String(), nil)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
resp, err := o.httpClient().Do(req)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode >= 300 {
|
|
return "", nil // Ignore errors for search
|
|
}
|
|
|
|
var identities []struct {
|
|
ID string `json:"id"`
|
|
}
|
|
if err := json.NewDecoder(resp.Body).Decode(&identities); err != nil {
|
|
return "", nil
|
|
}
|
|
if len(identities) == 0 {
|
|
return "", nil
|
|
}
|
|
return identities[0].ID, nil
|
|
}
|
|
|
|
// findIdentityID: Kratos Admin API에서 credentials_identifier로 검색 후 첫 번째 identity id 반환
|
|
func (o *OryProvider) findIdentityID(loginID string) (string, error) {
|
|
u, err := url.Parse(fmt.Sprintf("%s/admin/identities", o.KratosAdminURL))
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
query := u.Query()
|
|
query.Set("credentials_identifier", loginID)
|
|
u.RawQuery = query.Encode()
|
|
|
|
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, u.String(), nil)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
resp, err := o.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, 1024))
|
|
return "", fmt.Errorf("kratos admin search failed status=%d body=%s", resp.StatusCode, string(body))
|
|
}
|
|
|
|
var identities []struct {
|
|
ID string `json:"id"`
|
|
Traits map[string]any `json:"traits"`
|
|
}
|
|
if err := json.NewDecoder(resp.Body).Decode(&identities); err != nil {
|
|
return "", fmt.Errorf("decode response failed: %w", err)
|
|
}
|
|
if len(identities) == 0 {
|
|
return "", nil
|
|
}
|
|
|
|
// VERIFY: Double check traits to avoid Kratos ignoring the query param
|
|
candidate := identities[0]
|
|
if email, ok := candidate.Traits["email"].(string); ok && strings.EqualFold(email, loginID) {
|
|
return candidate.ID, nil
|
|
}
|
|
if phone, ok := candidate.Traits["phone_number"].(string); ok && strings.EqualFold(phone, loginID) {
|
|
return candidate.ID, nil
|
|
}
|
|
if lids, ok := candidate.Traits["custom_login_ids"].([]any); ok {
|
|
for _, lid := range lids {
|
|
if s, ok := lid.(string); ok && strings.EqualFold(s, loginID) {
|
|
return candidate.ID, nil
|
|
}
|
|
}
|
|
} else if lids, ok := candidate.Traits["custom_login_ids"].([]string); ok {
|
|
for _, lid := range lids {
|
|
if strings.EqualFold(lid, loginID) {
|
|
return candidate.ID, nil
|
|
}
|
|
}
|
|
}
|
|
|
|
return "", nil
|
|
}
|
|
|
|
func (o *OryProvider) getIdentity(identityID string) (*KratosIdentity, error) {
|
|
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, fmt.Sprintf("%s/admin/identities/%s", o.KratosAdminURL, identityID), nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
resp, err := o.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 {
|
|
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
|
|
return nil, fmt.Errorf("ory provider: get identity failed status=%d body=%s", resp.StatusCode, string(respBody))
|
|
}
|
|
|
|
var identity KratosIdentity
|
|
if err := json.NewDecoder(resp.Body).Decode(&identity); err != nil {
|
|
return nil, err
|
|
}
|
|
return &identity, nil
|
|
}
|
|
|
|
func hashPasswordForKratos(password string) (string, error) {
|
|
hashed, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return string(hashed), nil
|
|
}
|
|
|
|
func (o *OryProvider) httpClient() *http.Client {
|
|
if o.HTTPClient != nil {
|
|
return o.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,
|
|
},
|
|
}
|
|
}
|
|
|
|
// startLoginFlow는 Kratos Public API에서 login flow ID를 발급받습니다.
|
|
func (o *OryProvider) startLoginFlow(returnTo string) (string, error) {
|
|
loginURL := fmt.Sprintf("%s/self-service/login/api", o.KratosPublicURL)
|
|
if returnTo != "" {
|
|
loginURL = loginURL + "?return_to=" + url.QueryEscape(returnTo)
|
|
}
|
|
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, loginURL, nil)
|
|
if err != nil {
|
|
return "", fmt.Errorf("ory provider: build login flow request failed: %w", err)
|
|
}
|
|
|
|
resp, err := o.httpClient().Do(req)
|
|
if err != nil {
|
|
return "", fmt.Errorf("ory provider: login flow request failed: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode >= 300 {
|
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
|
|
return "", fmt.Errorf("ory provider: login flow failed status=%d body=%s", resp.StatusCode, string(body))
|
|
}
|
|
|
|
var result struct {
|
|
ID string `json:"id"`
|
|
}
|
|
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
|
return "", fmt.Errorf("ory provider: decode login flow failed: %w", err)
|
|
}
|
|
if result.ID == "" {
|
|
return "", fmt.Errorf("ory provider: empty login flow id")
|
|
}
|
|
return result.ID, nil
|
|
}
|