2025-08-21 23:47:15 -04:00

319 lines
9.7 KiB
Go

package client
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"ips-lacpass-backend/pkg/errors"
"ips-lacpass-backend/pkg/utils"
"net/http"
"net/url"
"strconv"
"strings"
"time"
)
type ClientInterface interface {
SendValidationEmail(ctx context.Context, userID string) error
CreateUser(ctx context.Context, user map[string]interface{}, password string) (*UserID, error)
UpdateUser(ctx context.Context, userUUID string, ur map[string]interface{}) (*UserRegistration, error)
}
func NewClient(baseURL, realm, clientId, clientSecret, emailRedirectUri, emailClientId string, emailLifeSpan int) UserClient {
return UserClient{
Client: &http.Client{},
BaseURL: baseURL,
Realm: realm,
AdminClientID: clientId,
ClientSecret: clientSecret,
EmailRedirectURI: emailRedirectUri,
EmailClientID: emailClientId,
EmailLifespan: emailLifeSpan,
TokenManager: &TokenManager{},
}
}
func fetchToken(kc *UserClient) (*TokenResponse, error) {
tokenEndpoint := fmt.Sprintf("%s/realms/%s/protocol/openid-connect/token", kc.BaseURL, kc.Realm)
data := url.Values{}
data.Set("client_id", kc.AdminClientID)
data.Set("client_secret", kc.ClientSecret)
data.Set("grant_type", "client_credentials")
resp, err := http.PostForm(tokenEndpoint, data)
if err != nil {
return nil, &errors.HttpError{
StatusCode: 502,
Body: []map[string]interface{}{{"error": "service_unavailable", "message": "Failed to connect to Keycloak service"}},
Err: err,
}
}
defer utils.CloseBody(resp.Body)
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("failed to obtain token: %s", string(body))
}
var token TokenResponse
if err := json.NewDecoder(resp.Body).Decode(&token); err != nil {
return nil, fmt.Errorf("failed to decode token response: %w", err)
}
return &token, nil
}
func (kc *UserClient) getAccessToken() (string, error) {
kc.TokenManager.mu.RLock()
if time.Now().After(kc.TokenManager.tokenExpiresAt.Add(-1 * time.Minute)) {
kc.TokenManager.mu.RUnlock()
kc.TokenManager.mu.Lock()
defer kc.TokenManager.mu.Unlock()
if time.Now().After(kc.TokenManager.tokenExpiresAt.Add(-1 * time.Minute)) {
token, err := fetchToken(kc)
if err != nil {
return "", fmt.Errorf("failed to get access token: %w", err)
}
kc.TokenManager = &TokenManager{
token: token.AccessToken,
tokenExpiresAt: time.Now().Add(time.Duration(token.ExpiresIn) * time.Second),
}
}
} else {
defer kc.TokenManager.mu.RUnlock()
}
return kc.TokenManager.token, nil
}
func (kc *UserClient) SendValidationEmail(ctx context.Context, userID string) error {
actions := []string{"VERIFY_EMAIL"}
body, err := json.Marshal(actions)
if err != nil {
return fmt.Errorf("failed to marshal actions: %w", err)
}
ku := fmt.Sprintf("%s/admin/realms/%s/users/%s/execute-actions-email", kc.BaseURL, kc.Realm, userID)
req, err := http.NewRequestWithContext(ctx, http.MethodPut, ku, bytes.NewBuffer(body))
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
q := req.URL.Query()
q.Add("client_id", kc.EmailClientID)
q.Add("lifespan", strconv.Itoa(kc.EmailLifespan))
q.Add("redirect_uri", kc.EmailRedirectURI)
req.URL.RawQuery = q.Encode()
t, err := kc.getAccessToken()
if err != nil {
return fmt.Errorf("failed to get access token: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", t))
resp, err := kc.Client.Do(req)
if err != nil {
return &errors.HttpError{
StatusCode: 502,
Body: []map[string]interface{}{{"error": "service_unavailable", "message": "Failed to connect to Auth service"}},
Err: err,
}
}
defer utils.CloseBody(resp.Body)
if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusOK {
var errorResponse map[string]interface{}
if err := json.NewDecoder(resp.Body).Decode(&errorResponse); err != nil {
return fmt.Errorf("failed to decode error response: %w", err)
}
return &errors.HttpError{
StatusCode: resp.StatusCode,
Body: []map[string]interface{}{errorResponse},
Err: err,
}
}
return nil
}
func (kc *UserClient) CreateUser(ctx context.Context, user map[string]interface{}, password string) (*UserID, error) {
r := UserRegistrationRequest{
Username: user["Identifier"].(string),
Email: user["Email"].(string),
FirstName: user["FirstName"].(string),
LastName: user["LastName"].(string),
Enabled: true,
Attributes: map[string][]string{
"locale": {user["Locale"].(string)},
"document_type": {user["DocumentType"].(string)},
"identifier": {user["Identifier"].(string)},
},
Credentials: []UserCredential{
{
Type: "password",
Value: password,
Temporary: false,
},
},
}
body, err := json.Marshal(r)
if err != nil {
return nil, fmt.Errorf("failed to marshal user payload: %w", err)
}
ku := fmt.Sprintf("%s/admin/realms/%s/users", kc.BaseURL, kc.Realm)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, ku, bytes.NewBuffer(body))
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
t, err := kc.getAccessToken()
if err != nil {
return nil, fmt.Errorf("failed to get access token: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", t))
resp, err := kc.Client.Do(req)
if err != nil {
return nil, &errors.HttpError{
StatusCode: 502,
Body: []map[string]interface{}{{"error": "service_unavailable", "message": "Failed to connect to Auth service"}},
Err: err,
}
}
defer utils.CloseBody(resp.Body)
if resp.StatusCode != http.StatusCreated {
var errorResponse map[string]interface{}
if err := json.NewDecoder(resp.Body).Decode(&errorResponse); err != nil {
return nil, fmt.Errorf("failed to decode error response: %w", err)
}
return nil, &errors.HttpError{
StatusCode: resp.StatusCode,
Body: []map[string]interface{}{errorResponse},
Err: err,
}
}
location := resp.Header.Get("Location")
if location == "" {
return nil, fmt.Errorf("user created but Location header is missing")
}
parts := strings.Split(location, "/")
userID := parts[len(parts)-1]
go func() {
newCtx := context.Background()
if err := kc.SendValidationEmail(newCtx, userID); err != nil {
// Log the error but don't return it since this is running asynchronously
fmt.Errorf("error sending validation email: %v", err)
}
}()
// Return the first user since we're querying by username/email
return &UserID{ID: userID}, nil
}
func (kc *UserClient) UpdateUser(ctx context.Context, userUUID string, ur map[string]interface{}) (*UserRegistration, error) {
// Get UserRepresentation from Keycloak
ku := fmt.Sprintf("%s/admin/realms/%s/users/%s", kc.BaseURL, kc.Realm, userUUID)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, ku, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
t, err := kc.getAccessToken()
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", t))
resp, err := kc.Client.Do(req)
if err != nil {
return nil, &errors.HttpError{
StatusCode: 502,
Body: []map[string]interface{}{{"error": "service_unavailable", "message": "Failed to connect to Auth service"}},
Err: err,
}
}
defer utils.CloseBody(resp.Body)
if resp.StatusCode != http.StatusOK {
var errorResponse map[string]interface{}
if err := json.NewDecoder(resp.Body).Decode(&errorResponse); err != nil {
return nil, fmt.Errorf("failed to decode error response: %w", err)
}
return nil, &errors.HttpError{
StatusCode: resp.StatusCode,
Body: []map[string]interface{}{errorResponse},
Err: err,
}
}
var userRep UserRegistration
if err := json.NewDecoder(resp.Body).Decode(&userRep); err != nil {
return nil, fmt.Errorf("failed to decode user response: %w", err)
}
// Edit User representation
// For now, only firstName and lastName can be edited
changed := false
if ur["first_name"].(string) != userRep.FirstName {
userRep.FirstName = ur["first_name"].(string)
changed = true
}
if ur["last_name"].(string) != userRep.LastName {
userRep.LastName = ur["last_name"].(string)
changed = true
}
if !changed {
return nil, &errors.HttpError{
StatusCode: http.StatusBadRequest,
Body: []map[string]interface{}{{"error": "update_user_no_change", "message": "update request does not change user attributes"}},
}
}
//save updated user representation
body, err := json.Marshal(userRep)
if err != nil {
return nil, fmt.Errorf("failed to marshal updated user payload: %w", err)
}
ku = fmt.Sprintf("%s/admin/realms/%s/users/%s", kc.BaseURL, kc.Realm, userUUID)
req, err = http.NewRequestWithContext(ctx, http.MethodPut, ku, bytes.NewBuffer(body))
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", t))
resp, err = kc.Client.Do(req)
if err != nil {
return nil, &errors.HttpError{
StatusCode: 502,
Body: []map[string]interface{}{{"error": "service_unavailable", "message": "Failed to connect to Auth service"}},
Err: err,
}
}
defer utils.CloseBody(resp.Body)
if resp.StatusCode != http.StatusNoContent {
var errorResponse map[string]interface{}
if err := json.NewDecoder(resp.Body).Decode(&errorResponse); err != nil {
return nil, fmt.Errorf("failed to decode error response: %w", err)
}
return nil, &errors.HttpError{
StatusCode: resp.StatusCode,
Body: []map[string]interface{}{errorResponse},
Err: err,
}
}
return &userRep, nil
}