2025-07-17 10:22:20 -04:00

298 lines
8.9 KiB
Go

package keycloak
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"ips-lacpass-backend/internal/core"
"ips-lacpass-backend/internal/errors"
"net/http"
"net/url"
"strconv"
"strings"
)
func NewKeycloakClient(baseURL, realm, clientId, clientSecret, emailRedirectUri, emailClientId string, emailLifeSpan int) core.UserRepository {
return &Client{
Client: &http.Client{},
BaseURL: baseURL,
Realm: realm,
AdminClientID: clientId,
ClientSecret: clientSecret,
EmailRedirectURI: emailRedirectUri,
EmailClientID: emailClientId,
EmailLifespan: emailLifeSpan,
}
}
func (kc *Client) getAccessToken() (*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, fmt.Errorf("failed to connect to Keycloak: %w", err)
}
defer func(Body io.ReadCloser) {
err := Body.Close()
if err != nil {
fmt.Errorf("failed to close response body: %s", err.Error())
}
}(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 *Client) 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.AccessToken))
resp, err := kc.Client.Do(req)
if err != nil {
return fmt.Errorf("failed to send request: %w", err)
}
defer resp.Body.Close()
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 *Client) CreateUser(ctx context.Context, user core.User, password string) (*core.User, error) {
r := UserRegistrationRequest{
Username: user.Identifier,
Email: user.Email,
FirstName: user.FirstName,
LastName: user.LastName,
Enabled: true,
Attributes: map[string][]string{
"locale": {user.Locale},
"document_type": {string(user.DocumentType)},
"identifier": {user.Identifier},
},
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.AccessToken))
resp, err := kc.Client.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to send request: %w", err)
}
defer func(Body io.ReadCloser) {
err := Body.Close()
if err != nil {
fmt.Println("failed to close response body: %v", err)
}
}(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]
fmt.Println("New user ID:", userID)
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.Println("Error sending validation email: %v", err)
}
}()
// Return the first user since we're querying by username/email
return &core.User{
ID: userID,
Username: user.Identifier,
Email: user.Email,
FirstName: user.FirstName,
LastName: user.LastName,
Locale: user.Locale,
DocumentType: user.DocumentType,
Identifier: user.Identifier,
}, nil
}
func (kc *Client) UpdateUser(ctx context.Context, userUUID string, ur core.UserUpdateRequest) (*core.User, error) {
// Get UserRepresentation from keycloack
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, 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.AccessToken))
resp, err := kc.Client.Do(req)
defer func(Body io.ReadCloser) {
err := Body.Close()
if err != nil {
fmt.Println("failed to close response body: %w", err)
}
}(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.FirstName != userRep.FirstName {
userRep.FirstName = ur.FirstName
changed = true
}
if ur.LastName != userRep.LastName {
userRep.LastName = ur.LastName
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.AccessToken))
resp, err = kc.Client.Do(req)
defer func(Body io.ReadCloser) {
err := Body.Close()
if err != nil {
fmt.Println("failed to close response body: %w", err)
}
}(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 &core.User{
ID: userRep.ID,
Username: userRep.Username,
Email: userRep.Email,
FirstName: ur.FirstName,
LastName: ur.LastName,
Locale: userRep.Attributes["locale"][0],
DocumentType: core.AllowedDocumenTypes[userRep.Attributes["document_type"][0]],
Identifier: userRep.Attributes["identifier"][0],
}, nil
}