diff --git a/backends/handle/client.go b/backends/handle/client.go index 18391160f..b9379198a 100644 --- a/backends/handle/client.go +++ b/backends/handle/client.go @@ -1,25 +1,41 @@ package handle import ( + "bytes" + "crypto" + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "encoding/base64" "encoding/json" + "errors" "fmt" + "io" "net/http" - "net/url" "github.com/ugent-library/biblio-backoffice/models" + "golang.org/x/crypto/ssh" ) type Config struct { BaseURL string FrontEndBaseURL string Prefix string - Username string - Password string + ADMID string + ADMPrivateKey string } type Client struct { - config Config - http *http.Client + config Config + http *http.Client + sessionId string +} + +type authResponse struct { + SessionID string `json:"sessionId,omitempty"` + Error string `json:"error,omitempty"` + ID string `json:"id,omitempty"` + Authenticated bool `json:"authenticated"` } func NewClient(c Config) *Client { @@ -29,46 +45,24 @@ func NewClient(c Config) *Client { } } -// func (c *Client) get(path string, qp url.Values, responseData any) (*http.Response, error) { -// req, err := c.newRequest("GET", path, qp) -// if err != nil { -// return nil, err -// } -// req.SetBasicAuth(c.config.Username, c.config.Password) -// return c.doRequest(req, responseData) -// } - -func (c *Client) put(path string, qp url.Values, responseData any) (*http.Response, error) { - req, err := c.newRequest("PUT", path, qp) +func (c *Client) put(path string, requestBody io.Reader, responseData any) (*http.Response, error) { + req, err := c.newRequest(http.MethodPut, path, requestBody) if err != nil { return nil, err } - req.SetBasicAuth(c.config.Username, c.config.Password) return c.doRequest(req, responseData) } -// func (c *Client) delete(path string, qp url.Values, responseData any) (*http.Response, error) { -// req, err := c.newRequest("DELETE", path, qp) -// if err != nil { -// return nil, err -// } -// req.SetBasicAuth(c.config.Username, c.config.Password) -// return c.doRequest(req, responseData) -// } - -func (c *Client) newRequest(method, path string, vals url.Values) (*http.Request, error) { +func (c *Client) newRequest(method, path string, body io.Reader) (*http.Request, error) { url := c.config.BaseURL + path - if vals != nil { - url = url + "?" + vals.Encode() - } - - req, err := http.NewRequest(method, url, nil) + req, err := http.NewRequest(method, url, body) if err != nil { return nil, err } - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Content-Type", "application/json") req.Header.Set("Accept", "application/json") + req.Header.Add("Authorization", fmt.Sprintf(`handle version="0",sessionId="%s"`, c.sessionId)) return req, nil } @@ -87,43 +81,127 @@ func (c *Client) doRequest(req *http.Request, responseData any) (*http.Response, return res, nil } -// func (client *Client) GetHandle(localId string) (*models.Handle, error) { -// h := &models.Handle{} -// _, err := client.get( -// fmt.Sprintf("/%s/LU-%s", client.config.Prefix, localId), -// nil, -// h, -// ) -// if err != nil { -// return nil, err -// } -// return h, nil -// } - -func (client *Client) UpsertHandle(localId string) (*models.Handle, error) { - h := &models.Handle{} - qp := url.Values{} - qp.Add("url", fmt.Sprintf("%s/%s", client.config.FrontEndBaseURL, localId)) - _, err := client.put( - fmt.Sprintf("/%s/LU-%s", client.config.Prefix, localId), - qp, - h, - ) +func (c *Client) UpsertHandle(localId string) (*models.UpsertHandleResponse, error) { + if !c.authenticated() { + if err := c.authenticate(); err != nil { + return nil, err + } + } + + handle := fmt.Sprintf("%s/LU-%s", c.config.Prefix, localId) + handleReq := &models.UpsertHandleRequest{ + ResponseCode: 1, + Handle: handle, + Values: []*models.HandleValue{ + { + Index: 1, + Type: "URL", + Data: map[string]any{ + "format": "string", + "value": fmt.Sprintf("%s/%s", c.config.FrontEndBaseURL, localId), + }, + }, + { + Index: 100, + Type: "HS_ADMIN", + Data: map[string]any{ + "format": "admin", + "value": map[string]any{ + "handle": c.config.ADMID, + "index": 200, + "permissions": "111111111111", //TODO + }, + }, + }, + }, + } + handleReqBytes, _ := json.MarshalIndent(handleReq, "", " ") + handleRes := &models.UpsertHandleResponse{} + + _, err := c.put("/api/handles/"+handle+"?overwrite=true", bytes.NewReader(handleReqBytes), handleRes) if err != nil { return nil, err } - return h, nil + + return handleRes, nil } -// func (client *Client) DeleteHandle(localId string) (*models.Handle, error) { -// h := &models.Handle{} -// _, err := client.delete( -// fmt.Sprintf("/%s/LU-%s", client.config.Prefix, localId), -// nil, -// h, -// ) -// if err != nil { -// return nil, err -// } -// return h, nil -// } +func (c *Client) authenticated() bool { + return c.sessionId != "" +} + +func (c *Client) authenticate() error { + rawPriv, err := ssh.ParseRawPrivateKey([]byte(c.config.ADMPrivateKey)) + if err != nil { + return err + } + priv := rawPriv.(*rsa.PrivateKey) + + var nonce string + var nonceBytes []byte + var cnonce string + var cnonceBytes []byte = make([]byte, 16) + var sessionId string + + // generate session (without authorization) + cnonce2Bytes := make([]byte, 16) + rand.Read(cnonce2Bytes) + reqBody, _ := json.Marshal(map[string]string{ + "version": "0", + "cnonce": base64.StdEncoding.EncodeToString(cnonce2Bytes), + }) + req, _ := http.NewRequest(http.MethodPost, c.config.BaseURL+"/api/sessions", bytes.NewReader(reqBody)) + res, err := c.http.Do(req) + if err != nil { + return fmt.Errorf("unable to create session: %w", err) + } + resH := make(map[string]string) + if err := json.NewDecoder(res.Body).Decode(&resH); err != nil { + return fmt.Errorf("failed to decode session response: %w", err) + } + nonce = resH["nonce"] + sessionId = resH["sessionId"] + nonceBytes, _ = base64.StdEncoding.DecodeString(nonce) + + //authenticate + rand.Read(cnonceBytes) + cnonce = base64.StdEncoding.EncodeToString(cnonceBytes) + msg := make([]byte, 0, len(nonceBytes)+len(cnonceBytes)) + msg = append(msg, nonceBytes...) + msg = append(msg, cnonceBytes...) + hashGen := sha256.New() + hashGen.Write(msg) + hash := hashGen.Sum(nil) + sig, err := rsa.SignPKCS1v15(rand.Reader, priv, crypto.SHA256, hash) + if err != nil { + return fmt.Errorf("unable to sign message: %w", err) + } + body, _ := json.MarshalIndent(map[string]string{ + "version": "0", + "sessionId": sessionId, + "id": c.config.ADMID, + "cnonce": cnonce, + "type": "HS_PUBKEY", + "alg": "SHA256", + "signature": base64.StdEncoding.EncodeToString(sig), + }, "", " ") + req, _ = http.NewRequest(http.MethodPost, c.config.BaseURL+"/api/sessions/this", bytes.NewReader(body)) + req.Header.Add("Content-Type", "application/json") + req.Header.Add("Accept", "application/json") + req.Header.Add("Content-Length", fmt.Sprint(len(body))) + res, err = c.http.Do(req) + if err != nil { + return fmt.Errorf("authentication failed: %w", err) + } + authRes := &authResponse{} + if err := json.NewDecoder(res.Body).Decode(authRes); err != nil { + return fmt.Errorf("unable to decode authentication response: %w", err) + } + + c.sessionId = authRes.SessionID + + if authRes.Authenticated { + return nil + } + return errors.New("authentication failed: " + authRes.Error) +} diff --git a/backends/types.go b/backends/types.go index e22fb9a1b..35926b0f5 100644 --- a/backends/types.go +++ b/backends/types.go @@ -267,7 +267,7 @@ type DatasetListExporter interface { type DatasetListExporterFactory func(io.Writer) DatasetListExporter type HandleService interface { - UpsertHandle(string) (*models.Handle, error) + UpsertHandle(string) (*models.UpsertHandleResponse, error) } const MissingValue = "missing" diff --git a/cli/backends.go b/cli/backends.go index dde80ff19..e68b750b9 100644 --- a/cli/backends.go +++ b/cli/backends.go @@ -69,8 +69,8 @@ func newServices() *backends.Services { BaseURL: config.Handle.URL, FrontEndBaseURL: fmt.Sprintf("%s/publication", config.Frontend.URL), Prefix: config.Handle.Prefix, - Username: config.Handle.Username, - Password: config.Handle.Password, + ADMID: config.Handle.ADMID, + ADMPrivateKey: config.Handle.ADMPrivateKey, }, ) } diff --git a/cli/config.go b/cli/config.go index 83ecd12fc..8b39bc3c3 100644 --- a/cli/config.go +++ b/cli/config.go @@ -71,11 +71,11 @@ type Config struct { MongoDBURL string `env:"MONGODB_URL"` APIKey string `env:"API_KEY"` Handle struct { - Enabled bool `env:"ENABLED"` - URL string `env:"URL"` - Prefix string `env:"PREFIX"` - Username string `env:"USERNAME"` - Password string `env:"PASSWORD"` + Enabled bool `env:"ENABLED"` + URL string `env:"URL"` + Prefix string `env:"PREFIX"` + ADMID string `env:"ADM_ID"` // e.g. 300:0.NA/1854 + ADMPrivateKey string `env:"ADM_PRIVATE_KEY"` // rsa private key } `envPrefix:"HDL_SRV_"` OAI struct { APIURL string `env:"API_URL"` diff --git a/cli/create_handles_cmd.go b/cli/create_handles_cmd.go index 099693997..80833fb20 100644 --- a/cli/create_handles_cmd.go +++ b/cli/create_handles_cmd.go @@ -10,6 +10,7 @@ import ( ) func init() { + createHandles.Flags().Bool("force", false, "force a recreation of all handle records") rootCmd.AddCommand(createHandles) } @@ -23,6 +24,12 @@ var createHandles = &cobra.Command{ return errors.New("handle server updates are not enabled") } + if force, _ := cmd.Flags().GetBool("force"); !force { + recreatePublicationHandles(services) + recreateDatasetHandles(services) + return nil + } + createPublicationHandles(services) createDatasetHandles(services) @@ -38,7 +45,7 @@ func createPublicationHandles(services *backends.Services) { repo.EachPublicationWithoutHandle(func(p *models.Publication) bool { h, e := services.HandleService.UpsertHandle(p.ID) - if err != nil { + if e != nil { err = fmt.Errorf("error adding handle for publication %s: %w", p.ID, e) return false } else if !h.IsSuccess() { @@ -71,7 +78,7 @@ func createDatasetHandles(services *backends.Services) { repo.EachDatasetWithoutHandle(func(d *models.Dataset) bool { h, e := services.HandleService.UpsertHandle(d.ID) - if err != nil { + if e != nil { err = fmt.Errorf("error adding handle for dataset %s: %w", d.ID, e) return false } else if !h.IsSuccess() { @@ -95,3 +102,55 @@ func createDatasetHandles(services *backends.Services) { logger.Info(fmt.Sprintf("created %d dataset handles", n)) } + +func recreatePublicationHandles(services *backends.Services) { + repo := services.Repo + + var n int + var err error + + repo.EachPublicationWithHandle(func(p *models.Publication) bool { + h, e := services.HandleService.UpsertHandle(p.ID) + if e != nil { + err = fmt.Errorf("error adding handle for publication %s: %w", p.ID, e) + return false + } else if !h.IsSuccess() { + err = fmt.Errorf("error adding handle for publication %s: %s", p.ID, h.Message) + return false + } + n++ + return true + }) + + if err != nil { + logger.Error(err.Error()) + } + + logger.Info(fmt.Sprintf("created %d publication handles", n)) +} + +func recreateDatasetHandles(services *backends.Services) { + repo := services.Repo + + var n int + var err error + + repo.EachDatasetWithHandle(func(d *models.Dataset) bool { + h, e := services.HandleService.UpsertHandle(d.ID) + if e != nil { + err = fmt.Errorf("error adding handle for dataset %s: %w", d.ID, e) + return false + } else if !h.IsSuccess() { + err = fmt.Errorf("error adding handle for dataset %s: %s", d.ID, h.Message) + return false + } + n++ + return true + }) + + if err != nil { + logger.Error(err.Error()) + } + + logger.Info(fmt.Sprintf("recreated %d dataset handles", n)) +} diff --git a/models/handle.go b/models/handle.go index 78f5b52d0..243ea1cd4 100644 --- a/models/handle.go +++ b/models/handle.go @@ -2,34 +2,31 @@ package models import "fmt" -/* -copy from handle-server-api -*/ -type HandleData struct { - Url string `json:"url"` - Format string `json:"format"` -} - type HandleValue struct { - Timestamp string `json:"timestamp"` - Type string `json:"type"` - Index int `json:"index"` - Ttl int `json:"ttl"` - Data *HandleData `json:"data"` + Timestamp string `json:"timestamp,omitempty"` + Type string `json:"type"` + Index int `json:"index"` + Ttl int `json:"ttl,omitempty"` + Data any `json:"data"` } -type Handle struct { +type UpsertHandleRequest struct { Handle string `json:"handle"` ResponseCode int `json:"responseCode"` Values []*HandleValue `json:"values,omitempty"` - Message string `json:"message,omitempty"` } -func (h *Handle) IsSuccess() bool { +type UpsertHandleResponse struct { + Handle string `json:"handle"` + ResponseCode int `json:"responseCode"` + Message string `json:"message,omitempty"` +} + +func (h *UpsertHandleResponse) IsSuccess() bool { return h.ResponseCode == 1 } -func (h *Handle) GetFullHandleURL() string { +func (h *UpsertHandleResponse) GetFullHandleURL() string { if !h.IsSuccess() { return "" } diff --git a/repositories/repo.go b/repositories/repo.go index 8af28248f..a00f82b87 100644 --- a/repositories/repo.go +++ b/repositories/repo.go @@ -478,6 +478,39 @@ func (s *Repo) EachPublicationWithStatus(status string, fn func(*models.Publicat return nil } +// TODO add handle with a listener, then this method isn't needed anymore +func (s *Repo) EachPublicationWithHandle(fn func(*models.Publication) bool) error { + sql := ` + SELECT * FROM publications WHERE date_until IS NULL AND + data->>'status' = 'public' AND + data ? 'handle' + ` + c, err := s.publicationStore.Select(sql, nil, s.opts) + if err != nil { + return fmt.Errorf("repo.EachPublicationWithHandle: %w", err) + } + defer c.Close() + for c.HasNext() { + snap, err := c.Next() + if err != nil { + return fmt.Errorf("repo.EachPublicationWithHandle: %w", err) + } + p, err := s.snapshotToPublication(snap) + if err != nil { + return fmt.Errorf("repo.EachPublicationWithHandle: %w", err) + } + if ok := fn(p); !ok { + break + } + } + + if c.Err() != nil { + return fmt.Errorf("repo.EachPublicationWithHandle: %w", c.Err()) + } + + return nil +} + // TODO add handle with a listener, then this method isn't needed anymore func (s *Repo) EachPublicationWithoutHandle(fn func(*models.Publication) bool) error { sql := ` @@ -940,6 +973,38 @@ func (s *Repo) EachDatasetSnapshot(fn func(*models.Dataset) bool) error { return nil } +func (s *Repo) EachDatasetWithHandle(fn func(*models.Dataset) bool) error { + sql := ` + SELECT * FROM datasets WHERE date_until IS NULL AND + data->>'status' = 'public' AND + data ? 'handle' + ` + c, err := s.datasetStore.Select(sql, nil, s.opts) + if err != nil { + return fmt.Errorf("repo.EachDatasetWithHandle: %w", err) + } + defer c.Close() + for c.HasNext() { + snap, err := c.Next() + if err != nil { + return fmt.Errorf("repo.EachDatasetWithHandle: %w", err) + } + d, err := s.snapshotToDataset(snap) + if err != nil { + return fmt.Errorf("repo.EachDatasetWithHandle: %w", err) + } + if ok := fn(d); !ok { + break + } + } + + if c.Err() != nil { + return fmt.Errorf("repo.EachDatasetWithHandle: %w", c.Err()) + } + + return nil +} + func (s *Repo) EachDatasetWithoutHandle(fn func(*models.Dataset) bool) error { sql := ` SELECT * FROM datasets WHERE date_until IS NULL AND