1
0
forked from baron/baron-sso

ory-hosting 기본구동

This commit is contained in:
Lectom C Han
2026-01-27 22:58:49 +09:00
parent 41f0549435
commit c3f7b18afc
31 changed files with 1910 additions and 176 deletions

View File

@@ -7,6 +7,7 @@ import (
"log/slog"
"net/http"
"os"
"strings"
"time"
"github.com/descope/go-sdk/descope"
@@ -69,6 +70,81 @@ func (d *DescopeProvider) GetMetadata() (*domain.IDPMetadata, error) {
}, nil
}
// CreateUser는 Descope Management API를 사용해 사용자를 생성합니다.
func (d *DescopeProvider) CreateUser(user *domain.BrokerUser, password string) (string, error) {
if d.Client == nil {
return "", fmt.Errorf("descope provider: client is nil")
}
if user == nil {
return "", fmt.Errorf("descope provider: user payload is nil")
}
if user.Email == "" || password == "" {
return "", fmt.Errorf("descope provider: email and password are required")
}
normalizedPhone := user.PhoneNumber
normalizedPhone = strings.ReplaceAll(normalizedPhone, "-", "")
normalizedPhone = strings.ReplaceAll(normalizedPhone, " ", "")
if strings.HasPrefix(normalizedPhone, "010") {
normalizedPhone = "+82" + normalizedPhone[1:]
} else if strings.HasPrefix(normalizedPhone, "82") {
normalizedPhone = "+" + normalizedPhone
}
// 존재 여부 확인
exists, _ := d.Client.Management.User().Load(context.Background(), user.Email)
if exists != nil {
return "", fmt.Errorf("descope provider: user already exists")
}
descopeUser := &descope.UserRequest{}
descopeUser.Email = user.Email
descopeUser.Phone = normalizedPhone
descopeUser.Name = user.Name
descopeUser.CustomAttributes = map[string]any{}
for k, v := range user.Attributes {
descopeUser.CustomAttributes[k] = v
}
descopeUser.CustomAttributes["createdAt"] = time.Now().Format(time.RFC3339)
if _, err := d.Client.Management.User().Create(context.Background(), user.Email, descopeUser); err != nil {
return "", fmt.Errorf("descope provider: create user failed: %w", err)
}
if err := d.Client.Management.User().SetPassword(context.Background(), user.Email, password); err != nil {
_ = d.Client.Management.User().Delete(context.Background(), user.Email)
return "", fmt.Errorf("descope provider: set password failed: %w", err)
}
slog.Info("Descope user created", "email", user.Email)
return user.Email, nil
}
// SignIn은 Descope Password 로그인 후 세션 토큰을 반환합니다.
func (d *DescopeProvider) SignIn(loginID, password string) (*domain.AuthInfo, error) {
if d.Client == nil {
return nil, fmt.Errorf("descope provider: client is nil")
}
authInfo, err := d.Client.Auth.Password().SignIn(context.Background(), loginID, password, nil)
if err != nil {
return nil, err
}
res := &domain.AuthInfo{
SessionToken: &domain.Token{
JWT: authInfo.SessionToken.JWT,
Expiration: time.Unix(authInfo.SessionToken.Expiration, 0),
},
Subject: authInfo.User.UserID,
}
if authInfo.RefreshToken != nil {
res.RefreshToken = &domain.Token{
JWT: authInfo.RefreshToken.JWT,
Expiration: time.Unix(authInfo.RefreshToken.Expiration, 0),
}
}
return res, nil
}
func (d *DescopeProvider) InitiatePasswordReset(loginID, redirectUrl string) error {
ctx := context.Background()
err := d.Client.Auth.Password().SendPasswordReset(ctx, loginID, redirectUrl, nil)

View File

@@ -0,0 +1,331 @@
package service
import (
"baron-sso-backend/internal/domain"
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"net"
"net/http"
"net/url"
"os"
"time"
)
// 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", "email", "name", "phone_number",
"grade", "department", "affiliationType", "companyCode",
},
}, 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")
}
// 중복 확인
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)
}
traits := map[string]interface{}{
"email": user.Email,
"name": user.Name,
"phone_number": user.PhoneNumber,
}
for k, v := range user.Attributes {
traits[k] = v
}
payload := map[string]interface{}{
"schema_id": "default",
"traits": traits,
"credentials": map[string]interface{}{
"password": map[string]interface{}{
"config": map[string]string{
"password": password,
},
},
},
}
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 {
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,
},
Subject: result.Session.Identity.ID,
}, 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)
}
payload := map[string]interface{}{
"credentials": map[string]interface{}{
"password": map[string]interface{}{
"config": map[string]string{
"password": newPassword,
},
},
},
}
body, _ := json.Marshal(payload)
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 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
}
// 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"`
}
if err := json.NewDecoder(resp.Body).Decode(&identities); err != nil {
return "", fmt.Errorf("decode response failed: %w", err)
}
if len(identities) == 0 {
return "", nil
}
return identities[0].ID, 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() (string, error) {
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, fmt.Sprintf("%s/self-service/login/api", o.KratosPublicURL), 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
}

View File

@@ -0,0 +1,149 @@
package service
import (
"bytes"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
)
// clientForHandler returns an http.Client that routes requests to the given handler
// without real network sockets.
func clientForHandler(h http.Handler) *http.Client {
return &http.Client{
Transport: roundTripperFunc(func(req *http.Request) (*http.Response, error) {
// Clone request body for handler
var bodyBytes []byte
if req.Body != nil {
bodyBytes, _ = io.ReadAll(req.Body)
}
r := httptest.NewRequest(req.Method, req.URL.String(), bytes.NewReader(bodyBytes))
r.Header = req.Header.Clone()
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
return w.Result(), nil
}),
}
}
type roundTripperFunc func(req *http.Request) (*http.Response, error)
func (f roundTripperFunc) RoundTrip(req *http.Request) (*http.Response, error) { return f(req) }
func TestUpdateUserPassword_Success(t *testing.T) {
const (
loginID = "user@example.com"
identityID = "7f0dc8c3-9d5d-4f57-b3d1-123456789abc"
newPassword = "Sup3rStr0ng!Pass#2026"
)
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case strings.HasPrefix(r.URL.Path, "/admin/identities") && r.Method == http.MethodGet:
q := r.URL.Query()
if got := q.Get("credentials_identifier"); got != loginID {
t.Fatalf("expected credentials_identifier=%s, got=%s", loginID, got)
}
_ = json.NewEncoder(w).Encode([]map[string]string{
{"id": identityID},
})
return
case r.URL.Path == "/admin/identities/"+identityID && r.Method == http.MethodPatch:
body, _ := io.ReadAll(r.Body)
if !strings.Contains(string(body), newPassword) {
t.Fatalf("payload missing new password, body=%s", string(body))
}
w.WriteHeader(http.StatusOK)
return
default:
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.String())
}
})
provider := &OryProvider{
KratosAdminURL: "http://kratos-admin.local",
HTTPClient: clientForHandler(handler),
}
if err := provider.UpdateUserPassword(loginID, newPassword, nil); err != nil {
t.Fatalf("UpdateUserPassword returned error: %v", err)
}
}
func TestUpdateUserPassword_NotFound(t *testing.T) {
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasPrefix(r.URL.Path, "/admin/identities") && r.Method == http.MethodGet {
http.NotFound(w, r)
return
}
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.String())
})
provider := &OryProvider{
KratosAdminURL: "http://kratos-admin.local",
HTTPClient: clientForHandler(handler),
}
err := provider.UpdateUserPassword("user@example.com", "Sup3rStr0ng!Pass#2026", nil)
if err == nil || !strings.Contains(err.Error(), "identity not found") {
t.Fatalf("expected identity not found error, got: %v", err)
}
}
func TestUpdateUserPassword_ServerError(t *testing.T) {
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case strings.HasPrefix(r.URL.Path, "/admin/identities") && r.Method == http.MethodGet:
_ = json.NewEncoder(w).Encode([]map[string]string{
{"id": "abc"},
})
return
case r.URL.Path == "/admin/identities/abc" && r.Method == http.MethodPatch:
http.Error(w, "boom", http.StatusInternalServerError)
return
default:
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.String())
}
})
provider := &OryProvider{
KratosAdminURL: "http://kratos-admin.local",
HTTPClient: clientForHandler(handler),
}
err := provider.UpdateUserPassword("user@example.com", "Sup3rStr0ng!Pass#2026", nil)
if err == nil || !strings.Contains(err.Error(), "password update failed") {
t.Fatalf("expected server error, got: %v", err)
}
}
func TestFindIdentityID_QueryEncoding(t *testing.T) {
loginID := "user+alias@example.com"
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
values, _ := url.ParseQuery(r.URL.RawQuery)
if values.Get("credentials_identifier") != loginID {
t.Fatalf("expected credentials_identifier=%s, got=%s", loginID, values.Get("credentials_identifier"))
}
_ = json.NewEncoder(w).Encode([]map[string]string{
{"id": "id-123"},
})
})
provider := &OryProvider{
KratosAdminURL: "http://kratos-admin.local",
HTTPClient: clientForHandler(handler),
}
id, err := provider.findIdentityID(loginID)
if err != nil {
t.Fatalf("findIdentityID returned error: %v", err)
}
if id != "id-123" {
t.Fatalf("expected id-123, got %s", id)
}
}