319 lines
9.7 KiB
Go
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
|
|
}
|