forked from baron/baron-sso
Merge branch 'dev' into feat/id_login
This commit is contained in:
@@ -11,14 +11,20 @@ import (
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
type KratosIdentity struct {
|
||||
ID string `json:"id"`
|
||||
Traits map[string]interface{} `json:"traits"`
|
||||
State string `json:"state,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at,omitempty"`
|
||||
UpdatedAt time.Time `json:"updated_at,omitempty"`
|
||||
ID string `json:"id"`
|
||||
SchemaID string `json:"schema_id,omitempty"`
|
||||
Traits map[string]interface{} `json:"traits"`
|
||||
State string `json:"state,omitempty"`
|
||||
MetadataAdmin interface{} `json:"metadata_admin,omitempty"`
|
||||
MetadataPublic interface{} `json:"metadata_public,omitempty"`
|
||||
ExternalID string `json:"external_id,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at,omitempty"`
|
||||
UpdatedAt time.Time `json:"updated_at,omitempty"`
|
||||
}
|
||||
|
||||
type KratosAdminService interface {
|
||||
@@ -172,20 +178,54 @@ func (s *kratosAdminService) UpdateIdentity(ctx context.Context, identityID stri
|
||||
}
|
||||
|
||||
func (s *kratosAdminService) UpdateIdentityPassword(ctx context.Context, identityID, newPassword string) error {
|
||||
patchOps := []map[string]interface{}{
|
||||
{
|
||||
"op": "add",
|
||||
"path": "/credentials/password/config/password",
|
||||
"value": newPassword,
|
||||
},
|
||||
}
|
||||
body, _ := json.Marshal(patchOps)
|
||||
endpoint := fmt.Sprintf("%s/admin/identities/%s", strings.TrimRight(s.AdminURL, "/"), identityID)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPatch, endpoint, bytes.NewReader(body))
|
||||
identity, err := s.GetIdentity(ctx, identityID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json-patch+json")
|
||||
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]interface{}{
|
||||
"schema_id": identity.SchemaID,
|
||||
"traits": identity.Traits,
|
||||
"state": identity.State,
|
||||
"credentials": map[string]interface{}{
|
||||
"password": map[string]interface{}{
|
||||
"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 {
|
||||
@@ -199,6 +239,14 @@ func (s *kratosAdminService) UpdateIdentityPassword(ctx context.Context, identit
|
||||
return 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)
|
||||
|
||||
@@ -14,6 +14,8 @@ import (
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// OryProvider는 Kratos/Hydra를 기반으로 하는 IDP 어댑터의 최소 스켈레톤입니다.
|
||||
@@ -711,20 +713,53 @@ func (o *OryProvider) UpdateUserPassword(loginID, newPassword string, r *http.Re
|
||||
return fmt.Errorf("ory provider: identity not found for loginID=%s", loginID)
|
||||
}
|
||||
|
||||
patchOps := []map[string]interface{}{
|
||||
{
|
||||
"op": "add",
|
||||
"path": "/credentials/password/config/password",
|
||||
"value": newPassword,
|
||||
},
|
||||
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)
|
||||
}
|
||||
|
||||
body, _ := json.Marshal(patchOps)
|
||||
req, err := http.NewRequestWithContext(context.Background(), http.MethodPatch, fmt.Sprintf("%s/admin/identities/%s", o.KratosAdminURL, identityID), bytes.NewReader(body))
|
||||
hashedPassword, err := hashPasswordForKratos(newPassword)
|
||||
if err != nil {
|
||||
return fmt.Errorf("ory provider: hash password failed: %w", err)
|
||||
}
|
||||
|
||||
payload := map[string]interface{}{
|
||||
"schema_id": identity.SchemaID,
|
||||
"traits": identity.Traits,
|
||||
"state": identity.State,
|
||||
"credentials": map[string]interface{}{
|
||||
"password": map[string]interface{}{
|
||||
"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-patch+json")
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := o.httpClient().Do(req)
|
||||
if err != nil {
|
||||
@@ -789,6 +824,41 @@ func (o *OryProvider) findIdentityID(loginID string) (string, error) {
|
||||
return identities[0].ID, 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
|
||||
|
||||
@@ -45,18 +45,38 @@ func TestUpdateUserPassword_Success(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:
|
||||
q := r.URL.Query()
|
||||
if got := q.Get("credentials_identifier"); got != loginID {
|
||||
t.Fatalf("expected credentials_identifier=%s, got=%s", loginID, got)
|
||||
if r.URL.Path == "/admin/identities" {
|
||||
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
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode([]map[string]string{
|
||||
{"id": identityID},
|
||||
if r.URL.Path != "/admin/identities/"+identityID {
|
||||
t.Fatalf("unexpected identity lookup path: %s", r.URL.Path)
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"id": identityID,
|
||||
"schema_id": "default",
|
||||
"state": "active",
|
||||
"traits": map[string]interface{}{
|
||||
"email": loginID,
|
||||
},
|
||||
})
|
||||
return
|
||||
case r.URL.Path == "/admin/identities/"+identityID && r.Method == http.MethodPatch:
|
||||
case r.URL.Path == "/admin/identities/"+identityID && r.Method == http.MethodPut:
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
if !strings.Contains(string(body), newPassword) {
|
||||
t.Fatalf("payload missing new password, body=%s", string(body))
|
||||
if !strings.Contains(string(body), "\"hashed_password\"") {
|
||||
t.Fatalf("payload missing hashed_password, body=%s", string(body))
|
||||
}
|
||||
if strings.Contains(string(body), newPassword) {
|
||||
t.Fatalf("payload must not contain plain password, body=%s", string(body))
|
||||
}
|
||||
if !strings.Contains(string(body), "\"schema_id\":\"default\"") {
|
||||
t.Fatalf("payload missing schema_id, body=%s", string(body))
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
@@ -99,11 +119,25 @@ 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:
|
||||
if r.URL.Path == "/admin/identities" {
|
||||
_ = json.NewEncoder(w).Encode([]map[string]string{
|
||||
{"id": "abc"},
|
||||
})
|
||||
return
|
||||
}
|
||||
if r.URL.Path == "/admin/identities/abc" {
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"id": "abc",
|
||||
"schema_id": "default",
|
||||
"state": "active",
|
||||
"traits": map[string]interface{}{
|
||||
"email": "user@example.com",
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.String())
|
||||
case r.URL.Path == "/admin/identities/abc" && r.Method == http.MethodPut:
|
||||
http.Error(w, "boom", http.StatusInternalServerError)
|
||||
return
|
||||
default:
|
||||
|
||||
@@ -17,6 +17,8 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
const naverSMSMaxBytes = 90
|
||||
|
||||
type SmsServiceImpl struct {
|
||||
accessKey string
|
||||
secretKey string
|
||||
@@ -46,17 +48,11 @@ func (s *SmsServiceImpl) SendSms(to, content string) error {
|
||||
// Naver SENS API requires phone number without '+'
|
||||
sanitizedTo := strings.Replace(to, "+", "", 1)
|
||||
|
||||
reqBody := domain.NaverSmsRequest{
|
||||
Type: "SMS",
|
||||
ContentType: "COMM",
|
||||
CountryCode: "82",
|
||||
From: s.senderPhone,
|
||||
Content: content,
|
||||
Messages: []domain.SmsMessage{
|
||||
{
|
||||
To: sanitizedTo,
|
||||
},
|
||||
},
|
||||
reqBody := buildNaverSmsRequest(s.senderPhone, sanitizedTo, content)
|
||||
if reqBody.Type == "LMS" {
|
||||
slog.Info("[SmsService] Upgrading message type to LMS due to content length",
|
||||
"bytes", len([]byte(content)),
|
||||
)
|
||||
}
|
||||
|
||||
jsonBody, err := json.Marshal(reqBody)
|
||||
@@ -100,6 +96,29 @@ func (s *SmsServiceImpl) SendSms(to, content string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildNaverSmsRequest(senderPhone, sanitizedTo, content string) domain.NaverSmsRequest {
|
||||
requestType := "SMS"
|
||||
subject := ""
|
||||
if len([]byte(content)) > naverSMSMaxBytes {
|
||||
requestType = "LMS"
|
||||
subject = "[Baron 로그인]"
|
||||
}
|
||||
|
||||
return domain.NaverSmsRequest{
|
||||
Type: requestType,
|
||||
ContentType: "COMM",
|
||||
CountryCode: "82",
|
||||
From: senderPhone,
|
||||
Subject: subject,
|
||||
Content: content,
|
||||
Messages: []domain.SmsMessage{
|
||||
{
|
||||
To: sanitizedTo,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (s *SmsServiceImpl) makeSignature(method, url, timestamp string) (string, error) {
|
||||
space := " "
|
||||
newLine := "\n"
|
||||
|
||||
26
backend/internal/service/sms_service_test.go
Normal file
26
backend/internal/service/sms_service_test.go
Normal file
@@ -0,0 +1,26 @@
|
||||
package service
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestBuildNaverSmsRequest_UsesSMSForShortContent(t *testing.T) {
|
||||
req := buildNaverSmsRequest("0262857755", "821012345678", "123456")
|
||||
|
||||
if req.Type != "SMS" {
|
||||
t.Fatalf("expected SMS, got %s", req.Type)
|
||||
}
|
||||
if req.Subject != "" {
|
||||
t.Fatalf("expected empty subject for SMS, got %q", req.Subject)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildNaverSmsRequest_UsesLMSForLongContent(t *testing.T) {
|
||||
content := "[Baron 로그인] 비밀번호 재설정 링크: http://sso-test.hmac.kr/api/v1/auth/password/reset/v/1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"
|
||||
req := buildNaverSmsRequest("0262857755", "821012345678", content)
|
||||
|
||||
if req.Type != "LMS" {
|
||||
t.Fatalf("expected LMS, got %s", req.Type)
|
||||
}
|
||||
if req.Subject == "" {
|
||||
t.Fatal("expected LMS subject to be set")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user