169 lines
4.4 KiB
Go
169 lines
4.4 KiB
Go
package client
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/rand"
|
|
"crypto/rsa"
|
|
"crypto/x509"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"encoding/pem"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"go-backend-template/config"
|
|
"go-backend-template/internal/contract"
|
|
"go-backend-template/internal/logger"
|
|
)
|
|
|
|
// DukcapilClient performs HTTPS calls to the Dukcapil 1:N face recognition
|
|
// endpoint (CALL_FN). It loads and caches the configured RSA public key used
|
|
// to encrypt sensitive credentials.
|
|
type DukcapilClient struct {
|
|
cfg config.Dukcapil
|
|
http *http.Client
|
|
pubKey *rsa.PublicKey
|
|
keyMu sync.Mutex
|
|
}
|
|
|
|
func NewDukcapilClient(cfg config.Dukcapil) *DukcapilClient {
|
|
return &DukcapilClient{
|
|
cfg: cfg,
|
|
http: &http.Client{
|
|
Timeout: cfg.Timeout(),
|
|
},
|
|
}
|
|
}
|
|
|
|
// FaceMatch performs a 1:N face match call. user_id/password are encrypted
|
|
// with the configured public key (RSA PKCS1v15 -> base64). The image must
|
|
// already be base64 encoded.
|
|
func (c *DukcapilClient) FaceMatch(ctx context.Context, req *contract.FaceMatchRequest) (*contract.DukcapilFaceResponse, error) {
|
|
if c.cfg.BaseURL == "" || c.cfg.CustomerID == "" || c.cfg.Methode == "" {
|
|
return nil, errors.New("dukcapil: incomplete configuration")
|
|
}
|
|
|
|
pub, err := c.loadPublicKey()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("dukcapil: load public key: %w", err)
|
|
}
|
|
|
|
encUserID, err := encryptAndEncode(pub, []byte(c.cfg.UserID))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("dukcapil: encrypt user_id: %w", err)
|
|
}
|
|
encPassword, err := encryptAndEncode(pub, []byte(c.cfg.Password))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("dukcapil: encrypt password: %w", err)
|
|
}
|
|
|
|
ip := req.IP
|
|
if strings.TrimSpace(ip) == "" {
|
|
ip = c.cfg.DefaultIP
|
|
}
|
|
|
|
body := contract.DukcapilFaceRequest{
|
|
TransactionID: req.TransactionID,
|
|
TransactionSource: req.TransactionSource,
|
|
Threshold: req.Threshold,
|
|
Image: req.Image,
|
|
UserID: encUserID,
|
|
Password: encPassword,
|
|
IP: ip,
|
|
}
|
|
|
|
payload, err := json.Marshal(body)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("dukcapil: marshal request: %w", err)
|
|
}
|
|
|
|
url := fmt.Sprintf("%s/%s/%s",
|
|
strings.TrimRight(c.cfg.BaseURL, "/"),
|
|
c.cfg.CustomerID,
|
|
c.cfg.Methode,
|
|
)
|
|
|
|
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(payload))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("dukcapil: build request: %w", err)
|
|
}
|
|
httpReq.Header.Set("Accept", "application/json")
|
|
httpReq.Header.Set("Content-Type", "application/json")
|
|
|
|
start := time.Now()
|
|
resp, err := c.http.Do(httpReq)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("dukcapil: do request: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
respBytes, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("dukcapil: read response: %w", err)
|
|
}
|
|
|
|
logger.FromContext(ctx).Infof("DukcapilClient::FaceMatch -> status=%d duration=%s", resp.StatusCode, time.Since(start))
|
|
|
|
if resp.StatusCode >= 500 {
|
|
return nil, fmt.Errorf("dukcapil: upstream status %d: %s", resp.StatusCode, string(respBytes))
|
|
}
|
|
|
|
var out contract.DukcapilFaceResponse
|
|
if err := json.Unmarshal(respBytes, &out); err != nil {
|
|
return nil, fmt.Errorf("dukcapil: decode response: %w (body=%s)", err, string(respBytes))
|
|
}
|
|
return &out, nil
|
|
}
|
|
|
|
func (c *DukcapilClient) loadPublicKey() (*rsa.PublicKey, error) {
|
|
c.keyMu.Lock()
|
|
defer c.keyMu.Unlock()
|
|
if c.pubKey != nil {
|
|
return c.pubKey, nil
|
|
}
|
|
if c.cfg.PublicKeyPath == "" {
|
|
return nil, errors.New("public key path not configured")
|
|
}
|
|
raw, err := os.ReadFile(c.cfg.PublicKeyPath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
block, _ := pem.Decode(raw)
|
|
if block == nil {
|
|
return nil, errors.New("invalid PEM file")
|
|
}
|
|
|
|
// Try PKIX first (BEGIN PUBLIC KEY) then PKCS1 (BEGIN RSA PUBLIC KEY).
|
|
if pub, err := x509.ParsePKIXPublicKey(block.Bytes); err == nil {
|
|
rsaPub, ok := pub.(*rsa.PublicKey)
|
|
if !ok {
|
|
return nil, errors.New("public key is not RSA")
|
|
}
|
|
c.pubKey = rsaPub
|
|
return rsaPub, nil
|
|
}
|
|
rsaPub, err := x509.ParsePKCS1PublicKey(block.Bytes)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("parse rsa public key: %w", err)
|
|
}
|
|
c.pubKey = rsaPub
|
|
return rsaPub, nil
|
|
}
|
|
|
|
// encryptAndEncode mimics PHP openssl_public_encrypt (default padding =
|
|
// PKCS1v15) and base64-encodes the ciphertext.
|
|
func encryptAndEncode(pub *rsa.PublicKey, plaintext []byte) (string, error) {
|
|
cipherBytes, err := rsa.EncryptPKCS1v15(rand.Reader, pub, plaintext)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return base64.StdEncoding.EncodeToString(cipherBytes), nil
|
|
}
|