1
0
forked from baron/baron-sso

audit 로그 개선. kratos 코드발급 링크로 전송까지 진행 완료 #104

This commit is contained in:
Lectom C Han
2026-01-29 01:20:19 +09:00
parent ff17259117
commit b88de7ec91
46 changed files with 2843 additions and 585 deletions

View File

@@ -145,6 +145,101 @@ func (d *DescopeProvider) SignIn(loginID, password string) (*domain.AuthInfo, er
return res, nil
}
// UserExists는 loginID(이메일/전화번호) 기준으로 사용자가 있는지 확인합니다.
func (d *DescopeProvider) UserExists(loginID string) (bool, error) {
if d.Client == nil {
return false, fmt.Errorf("descope provider: client is nil")
}
ctx := context.Background()
if strings.Contains(loginID, "@") {
user, err := d.Client.Management.User().Load(ctx, loginID)
if err != nil {
if isDescopeNotFound(err) {
return false, nil
}
return false, err
}
return user != nil, nil
}
phone := normalizePhone(loginID)
searchOptions := &descope.UserSearchOptions{
Phones: []string{phone},
Limit: 1,
}
users, _, err := d.Client.Management.User().SearchAll(ctx, searchOptions)
if err != nil {
return false, err
}
return len(users) > 0, nil
}
// IssueSession은 비밀번호 없이 로그인 세션을 발급합니다.
func (d *DescopeProvider) IssueSession(loginID string) (*domain.AuthInfo, error) {
if d.Client == nil {
return nil, fmt.Errorf("descope provider: client is nil")
}
ctx := context.Background()
targetLoginID, err := d.resolveLoginID(loginID)
if err != nil {
return nil, err
}
embeddedToken, err := d.Client.Management.User().GenerateEmbeddedLink(ctx, targetLoginID, nil, 0)
if err != nil {
return nil, fmt.Errorf("descope provider: generate embedded link failed: %w", err)
}
authInfo, err := d.Client.Auth.MagicLink().Verify(ctx, embeddedToken, nil)
if err != nil {
return nil, fmt.Errorf("descope provider: magic link verify failed: %w", 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) InitiateLinkLogin(loginID, returnTo string) (*domain.LinkLoginInit, error) {
return nil, domain.ErrNotSupported
}
func (d *DescopeProvider) VerifyLoginCode(loginID, flowID, code string) (*domain.AuthInfo, error) {
return nil, domain.ErrNotSupported
}
// GetPasswordPolicy는 Descope 비밀번호 정책을 반환합니다.
func (d *DescopeProvider) GetPasswordPolicy() (*domain.PasswordPolicy, error) {
if d.Client == nil {
return nil, fmt.Errorf("descope provider: client is nil")
}
policy, err := d.Client.Auth.Password().GetPasswordPolicy(context.Background())
if err != nil {
return nil, err
}
return &domain.PasswordPolicy{
MinLength: int(policy.MinLength),
Lowercase: policy.Lowercase,
Uppercase: policy.Uppercase,
Number: policy.Number,
NonAlphanumeric: policy.NonAlphanumeric,
MinCharacterTypes: 0,
}, nil
}
func (d *DescopeProvider) InitiatePasswordReset(loginID, redirectUrl string) error {
ctx := context.Background()
err := d.Client.Auth.Password().SendPasswordReset(ctx, loginID, redirectUrl, nil)
@@ -197,3 +292,57 @@ func (d *DescopeProvider) UpdateUserPassword(loginID, newPassword string, r *htt
ctx := context.Background()
return d.Client.Auth.Password().UpdateUserPassword(ctx, loginID, newPassword, r)
}
func (d *DescopeProvider) resolveLoginID(loginID string) (string, error) {
if strings.Contains(loginID, "@") {
return loginID, nil
}
phone := normalizePhone(loginID)
searchOptions := &descope.UserSearchOptions{
Phones: []string{phone},
Limit: 1,
}
users, _, err := d.Client.Management.User().SearchAll(context.Background(), searchOptions)
if err != nil {
return "", fmt.Errorf("descope provider: user search failed: %w", err)
}
if len(users) == 0 {
return "", fmt.Errorf("descope provider: user not found")
}
if len(users[0].LoginIDs) > 0 {
return users[0].LoginIDs[0], nil
}
if users[0].UserID != "" {
return users[0].UserID, nil
}
return "", fmt.Errorf("descope provider: user found but login id missing")
}
func normalizePhone(phone string) string {
normalized := strings.ReplaceAll(phone, "-", "")
normalized = strings.ReplaceAll(normalized, " ", "")
if strings.HasPrefix(normalized, "010") {
return "+82" + normalized[1:]
}
if strings.HasPrefix(normalized, "82") {
return "+" + normalized
}
return normalized
}
func isDescopeNotFound(err error) bool {
if de, ok := err.(*descope.Error); ok {
if rawStatus, ok := de.Info[descope.ErrorInfoKeys.HTTPResponseStatusCode]; ok {
switch v := rawStatus.(type) {
case int:
return v == http.StatusNotFound
case float64:
return int(v) == http.StatusNotFound
case string:
return v == fmt.Sprintf("%d", http.StatusNotFound)
}
}
}
return false
}

View File

@@ -12,6 +12,7 @@ import (
"net/http"
"net/url"
"os"
"strings"
"time"
)
@@ -63,6 +64,15 @@ func (o *OryProvider) CreateUser(user *domain.BrokerUser, password string) (stri
if existingID != "" {
return "", fmt.Errorf("ory provider: identity already exists for email=%s", user.Email)
}
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]interface{}{
"email": user.Email,
@@ -84,6 +94,27 @@ func (o *OryProvider) CreateUser(user *domain.BrokerUser, password string) (stri
},
},
}
verifiable := []map[string]interface{}{
{
"value": user.Email,
"verified": true,
"via": "email",
},
}
if user.PhoneNumber != "" {
verifiable = append(verifiable, map[string]interface{}{
"value": user.PhoneNumber,
"verified": true,
"via": "sms",
})
}
payload["verifiable_addresses"] = verifiable
payload["recovery_addresses"] = []map[string]interface{}{
{
"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))
@@ -119,7 +150,7 @@ func (o *OryProvider) SignIn(loginID, password string) (*domain.AuthInfo, error)
return nil, fmt.Errorf("ory provider: loginID and password are required")
}
flowID, err := o.startLoginFlow()
flowID, err := o.startLoginFlow("")
if err != nil {
return nil, err
}
@@ -178,6 +209,326 @@ func (o *OryProvider) SignIn(loginID, password string) (*domain.AuthInfo, error)
}, 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")
}
init, err := o.submitLoginCodeInit(loginID, returnTo)
if err == nil {
return init, nil
}
if shouldBootstrapCodeLogin(err) {
if ensureErr := o.ensureCodeLoginIdentifier(loginID); ensureErr == nil {
return o.submitLoginCodeInit(loginID, returnTo)
} else {
slog.Warn("Ory code login bootstrap failed", "loginID", loginID, "error", ensureErr)
}
}
return nil, err
}
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]interface{}, 0, 2)
if !exists {
ops = append(ops, map[string]interface{}{
"op": "add",
"path": "/verifiable_addresses/-",
"value": map[string]interface{}{
"value": loginID,
"via": via,
"verified": true,
"status": "completed",
},
})
} else {
addr := identity.VerifiableAddresses[existingIndex]
if !addr.Verified {
ops = append(ops, map[string]interface{}{
"op": "replace",
"path": fmt.Sprintf("/verifiable_addresses/%d/verified", existingIndex),
"value": true,
})
}
if addr.Status != "" && addr.Status != "completed" {
ops = append(ops, map[string]interface{}{
"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
}
return o.patchIdentity(identityID, ops)
}
type kratosIdentity struct {
VerifiableAddresses []kratosVerifiableAddress `json:"verifiable_addresses"`
}
func (o *OryProvider) patchIdentity(identityID string, ops []map[string]interface{}) 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
}
// 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 {
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,
},
Subject: result.Session.Identity.ID,
}, 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)
@@ -301,8 +652,12 @@ func (o *OryProvider) httpClient() *http.Client {
}
// 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)
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)
}