From cb4d9a5c2ea124be77ee9130f22b6054a9f622ab Mon Sep 17 00:00:00 2001 From: GRMrGecko Date: Thu, 10 Aug 2023 17:22:52 -0500 Subject: [PATCH] Initial commit --- License.txt | 19 ++ README.md | 61 +++++++ client.go | 178 ++++++++++++++++++ client_test.go | 239 ++++++++++++++++++++++++ errors.go | 162 +++++++++++++++++ go.mod | 16 ++ go.sum | 69 +++++++ request.go | 89 +++++++++ response.go | 340 +++++++++++++++++++++++++++++++++++ test/cert.pem | 14 ++ test/invalid_json.json | 14 ++ test/key.pem | 16 ++ test/user_add_response.json | 96 ++++++++++ test/user_find_response.json | 30 ++++ 14 files changed, 1343 insertions(+) create mode 100644 License.txt create mode 100644 README.md create mode 100644 client.go create mode 100644 client_test.go create mode 100644 errors.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 request.go create mode 100644 response.go create mode 100644 test/cert.pem create mode 100644 test/invalid_json.json create mode 100644 test/key.pem create mode 100644 test/user_add_response.json create mode 100644 test/user_find_response.json diff --git a/License.txt b/License.txt new file mode 100644 index 0000000..3c9e3c8 --- /dev/null +++ b/License.txt @@ -0,0 +1,19 @@ +Copyright (c) 2023 Mr. Gecko's Media (James Coleman). http://mrgeckosmedia.com/ + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..b53ef43 --- /dev/null +++ b/README.md @@ -0,0 +1,61 @@ +# go-freeipa +A FreeIPA API client library for GoLang. + +## Install +```bash +go get github.com/grmrgecko/go-freeipa +``` + +## Example +```go +import ( + "crypto/tls" + "log" + "net/http" + freeipa "github.com/grmrgecko/go-freeipa" +) + +func main() { + // Setup TLS configurations. + tlsConifg := tls.Config{InsecureSkipVerify: false} + transportConfig := &http.Transport{ + TLSClientConfig: &tlsConifg, + } + // Connect/login to FreeIPA server. + client, err := freeipa.Connect("ipa.example.com", transportConfig, "username", "password") + if err!=nil { + log.Fatalln(err) + } + + // Make a user. + params := make(map[string]interface{}) + params["pkey_only"] = true + params["sizelimit"] = 0 + req := freeipa.NewRequest( + "user_find", + []interface{}{""}, + params, + ) + + // Send the request to the test server. + resp, err := client.Do(req) + if err != nil { + log.Fatalln(err) + } + + // Print information about response. + log.Println("Found users:", resp.Result.Count) + + dn, ok := resp.GetStringAtIndex(0, "dn") + if !ok { + log.Fatalln("Unable to get dn value from FreeIPA") + } + + log.Println("Got first user DN:", dn) +} +``` + +## References +If you're looking for help on what API methods there are and the arguments they accept, the documentation at FreeIPA should help: + +[https://github.com/freeipa/freeipa/tree/master/doc/api](https://github.com/freeipa/freeipa/tree/master/doc/api) diff --git a/client.go b/client.go new file mode 100644 index 0000000..bc103ab --- /dev/null +++ b/client.go @@ -0,0 +1,178 @@ +package freeipa + +import ( + "fmt" + "io" + "net/http" + "net/http/cookiejar" + "net/url" + + krb5client "github.com/jcmturner/gokrb5/v8/client" + krb5config "github.com/jcmturner/gokrb5/v8/config" + "github.com/jcmturner/gokrb5/v8/keytab" + "github.com/jcmturner/gokrb5/v8/spnego" +) + +// Client: The base object for connections to FreeIPA API. +type Client struct { + uriBase string + client *http.Client + user string + password string + krb5 *krb5client.Client +} + +// init: Common init code for each connection type, mainly sets http.Client and uriBase. +func (c *Client) init(host string, transport *http.Transport) error { + // Create a cookie jar to store FreeIPA session cookies. + jar, err := cookiejar.New(&cookiejar.Options{}) + if err != nil { + return err + } + // Setup client using provided transport configurations and the cookie jar. + c.client = &http.Client{ + Transport: transport, + Jar: jar, + } + + // Set uriBase using the provided host and test to verify a valid URL is produced. + c.uriBase = fmt.Sprintf("https://%s/ipa", host) + _, err = url.Parse(c.uriBase) + if err != nil { + return err + } + return nil +} + +// Connect: Make a new client using standard username/password login. +func Connect(host string, transport *http.Transport, user, password string) (*Client, error) { + // Make the client config and save credentials. + client := &Client{ + user: user, + password: password, + } + + // Initialize common configurations. + err := client.init(host, transport) + if err != nil { + return nil, err + } + + // Login using credentials. + err = client.login() + if err != nil { + return nil, fmt.Errorf("login failed: %s", err) + } + + return client, nil +} + +// login: Login using standard credentials. +func (c *Client) login() error { + // If login is called, but kerberos client is configured, use kerberos login instead. + // This allows standard re-authentication calls to work with both kerbeos and standard authenciation. + if c.krb5 != nil { + return c.loginWithKerberos() + } + + // Setup form data with credentials. + data := url.Values{ + "user": []string{c.user}, + "password": []string{c.password}, + } + // Authenticate using standard credentials with the http client. + res, e := c.client.PostForm(c.uriBase+"/session/login_password", data) + if e != nil { + return e + } + + // If an error occurs, provide details if possible on why. + if res.StatusCode != http.StatusOK { + if res.StatusCode == http.StatusUnauthorized { + return unauthorizedHTTPError(res) + } + return fmt.Errorf("unexpected http status code: %d", res.StatusCode) + } + + // Successful authentication. + return nil +} + +// KerberosConnectOptions: Options for connecting to Kerberos. +type KerberosConnectOptions struct { + Krb5ConfigReader io.Reader + KeytabReader io.Reader + User string + Realm string +} + +// ConnectWithKerberos: Create a new client using Kerberos authentication. +func ConnectWithKerberos(host string, transport *http.Transport, options *KerberosConnectOptions) (*Client, error) { + // Read the kerberos configuration file for server connection information. + krb5Config, err := krb5config.NewFromReader(options.Krb5ConfigReader) + if err != nil { + return nil, fmt.Errorf("error reading kerberos configuration: %s", err) + } + + // Read the keytab data. + ktData, err := io.ReadAll(options.KeytabReader) + if err != nil { + return nil, fmt.Errorf("error reading keytab: %s", err) + } + + // Parse the keytab data. + kt := keytab.New() + err = kt.Unmarshal(ktData) + if err != nil { + return nil, fmt.Errorf("error parsing keytab: %s", err) + } + + // Setup kerberos client with keytab and config. + krb5 := krb5client.NewWithKeytab(options.User, options.Realm, kt, krb5Config) + + // Setup the client with kerberos's client for authentication. + client := &Client{ + user: options.User, + krb5: krb5, + } + + // Initialize the common configurations. + err = client.init(host, transport) + if err != nil { + return nil, err + } + + // Login using kerberos authentication. + err = client.login() + if err != nil { + return nil, fmt.Errorf("login failed: %s", err) + } + return client, nil +} + +// loginWithKerberos: Authenticate using kerberos client. +func (c *Client) loginWithKerberos() error { + // Wrapper for authenticating with Kerberos credentials. + spnegoCl := spnego.NewClient(c.krb5, c.client, "") + + // Setup request for authenticate. + req, err := http.NewRequest("POST", c.uriBase+"/session/login_kerberos", nil) + if err != nil { + return fmt.Errorf("error building login request: %s", err) + } + req.Header.Add("Referer", c.uriBase) + + // Perform authenticate using Kerberos. + res, err := spnegoCl.Do(req) + if err != nil { + return fmt.Errorf("error logging in using Kerberos: %s", err) + } + + // If an error occurs, return it. + if res.StatusCode != http.StatusOK { + return fmt.Errorf("unexpected http status code: %d", res.StatusCode) + } + + // Successful authentication. + return nil +} diff --git a/client_test.go b/client_test.go new file mode 100644 index 0000000..1665f9e --- /dev/null +++ b/client_test.go @@ -0,0 +1,239 @@ +package freeipa + +import ( + "crypto/tls" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "os" + "testing" + "time" +) + +// Unused port for testing. +const httpsPort = 8831 + +// handleLogin: Test login handler. +func handleLogin(w http.ResponseWriter, req *http.Request) { + // Logins are form data posts. + req.ParseForm() + + // Check username/password equals test credentials. + user := req.Form.Get("user") + password := req.Form.Get("password") + if user == "test" && password == "testpassword" { + // Successful login send session cookie. + cookie := http.Cookie{} + cookie.Name = "ipa_session" + cookie.Value = "correct-session-secret" + cookie.Expires = time.Now().Add(30 * time.Minute) + cookie.Secure = true + cookie.HttpOnly = true + cookie.Path = "/ipa" + http.SetCookie(w, &cookie) + w.Header().Set("IPASESSION", "correct-session-secret") + } else { + // Invalid login, send rejection. + w.Header().Set("X-IPA-Rejection-Reason", "invalid-password") + http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized) + fmt.Fprintf(w, ` + +401 Unauthorized + + +

Invalid Authentication

+

+kinit: Password incorrect while getting initial credentials + +

+ +`) + } +} + +// sendInvalidJSON: General invalid json error response for testing error handling. +func sendInvalidJSON(w http.ResponseWriter) { + f, err := os.Open("test/invalid_json.json") + if err != nil { + log.Fatalln(err) + } + defer f.Close() + io.Copy(w, f) +} + +// handleJSON: Handle the json session test request. +func handleJSON(w http.ResponseWriter, req *http.Request) { + // If session cookie doesn't exist, something is wrong. Send unauthenticated response. + cookie, err := req.Cookie("ipa_session") + if err != nil || cookie.Value != "correct-session-secret" { + http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized) + return + } + + // Generally json response from here. + w.Header().Set("Content-Type", "application/json") + + // Get the request body and parse it out. + res := new(Request) + err = json.NewDecoder(req.Body).Decode(res) + if err != nil { + // If the json decode fails, send the error. + sendInvalidJSON(w) + return + } + + // For testing, we'll consider user_add/user_find as an accepted method, all others will error. + if res.Method == "user_add" { + // Send user add response data. + f, err := os.Open("test/user_add_response.json") + if err != nil { + log.Fatalln(err) + } + defer f.Close() + io.Copy(w, f) + } else if res.Method == "user_find" { + // Send user add response data. + f, err := os.Open("test/user_find_response.json") + if err != nil { + log.Fatalln(err) + } + defer f.Close() + io.Copy(w, f) + } else { + // An unexpected method received for testing, send error message. + sendInvalidJSON(w) + } +} + +// TestLogin: General library tests with test server. +func TestLogin(t *testing.T) { + // Spin up test server using port specified above. + srvAddr := fmt.Sprintf("127.0.0.1:%d", httpsPort) + http.HandleFunc("/ipa/session/login_password", handleLogin) + http.HandleFunc("/ipa/session/json", handleJSON) + go func() { + err := http.ListenAndServeTLS(srvAddr, "test/cert.pem", "test/key.pem", nil) + if err != nil { + log.Fatal("ListenAndServe: ", err) + } + }() + // Allow the http server to initialize. + time.Sleep(100 * time.Millisecond) + + // Test server has a self signed certificate, ignore invalid certs. + transportConfig := &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, + } + + // Connect using wrong password to confirm invalid login responses are handled correctly. + _, err := Connect(srvAddr, transportConfig, "test", "wrong-password") + if err == nil || err.Error() != "login failed: unauthorized response (1201)" { + t.Fatalf("expected login failure") + } + + // Connect using correct password to confirm valid logins are handled correctly. + client, err := Connect(srvAddr, transportConfig, "test", "testpassword") + if err != nil { + t.Fatalf("error: %s", err) + } + + // Setup test user_add request. + params := make(map[string]interface{}) + params["givenname"] = "FreeIPA" + params["sn"] = "Test" + params["userpassword"] = "test-password" + req := NewRequest( + "user_add", + []interface{}{"username"}, + params, + ) + + // Send the request to the test server. + resp, err := client.Do(req) + if err != nil { + t.Fatalf("error: %s", err) + } + + // Test reading bool key from response. + v, _ := resp.GetBool("has_keytab") + if !v { + t.Errorf("expected true boolean") + } + + // Test reading string from response. + s, _ := resp.GetString("krbcanonicalname") + if s != "username@EXAMPLE.COM" { + t.Errorf("unexpected string: %s", s) + } + + // Test reading date from response. + d, _ := resp.GetDateTime("krblastpwdchange") + year, month, day := d.Date() + if year != 2023 || month != 8 || day != 10 { + t.Errorf("unexpected date: %s", d) + } + + // Test reading base64 data from response. + b, _ := resp.GetData("krbextradata") + if len(b) != 27 { + t.Errorf("unexpected data: %v", b) + } + + // Test reading a non-existant value from response. + s, ok := resp.GetString("non-existant") + if s != "" || ok { + t.Errorf("expected empty string: %s", s) + } + + a, ok := resp.GetStrings("objectclass") + if !ok || len(a) != 13 { + t.Errorf("unexpected data: %v", a) + } + + // Test receiving an error message from the test server. + req.Method = "invalid" + _, err = client.Do(req) + if err == nil || err.Error() != "JSONError (909): Invalid JSON-RPC request: Expecting property name enclosed in double quotes: line 4 column 4 (char 44)" { + t.Fatalf("unexpected error: %s", err) + } + + // Test user_find. + params = make(map[string]interface{}) + params["pkey_only"] = true + params["sizelimit"] = 0 + req = NewRequest( + "user_find", + []interface{}{""}, + params, + ) + + // Send the request to the test server. + resp, err = client.Do(req) + if err != nil { + t.Fatalf("error: %s", err) + } + + // The response should have a count as its an array. + if resp.Result.Count != 2 { + t.Error("expected 2 users") + } + + // Confirm the array actually counts the same. + if resp.CountResults() != 2 { + t.Error("expected 2 users") + } + + // Confirm an string at index works withou the array encapsulation. + dn, _ := resp.GetStringAtIndex(0, "dn") + if dn != "uid=admin,cn=users,cn=accounts,dc=example,dc=com" { + t.Errorf("unexpected string: %s", dn) + } + + // Confirm the UID string at index works because it is array encapsulated. + uid, _ := resp.GetStringAtIndex(1, "uid") + if uid != "johnny.bravo" { + t.Errorf("unexpected string: %s", uid) + } +} diff --git a/errors.go b/errors.go new file mode 100644 index 0000000..0c2aebd --- /dev/null +++ b/errors.go @@ -0,0 +1,162 @@ +package freeipa + +import ( + "fmt" + "net/http" +) + +// Standard FreeIPA error codes. +const ( + PublicErrorCode = 900 + VersionErrorCode = 901 + UnknownErrorCode = 902 + InternalErrorCode = 903 + ServerInternalErrorCode = 904 + CommandErrorCode = 905 + ServerCommandErrorCode = 906 + NetworkErrorCode = 907 + ServerNetworkErrorCode = 908 + JSONErrorCode = 909 + XMLRPCMarshallErrorCode = 910 + RefererErrorCode = 911 + EnvironmentErrorCode = 912 + SystemEncodingErrorCode = 913 + AuthenticationErrorCode = 1000 + KerberosErrorCode = 1100 + CCacheErrorCode = 1101 + ServiceErrorCode = 1102 + NoCCacheErrorCode = 1103 + TicketExpiredCode = 1104 + BadCCachePermsCode = 1105 + BadCCacheFormatCode = 1106 + CannotResolveKDCCode = 1107 + SessionErrorCode = 1200 + InvalidSessionPasswordCode = 1201 + PasswordExpiredCode = 1202 + KrbPrincipalExpiredCode = 1203 + UserLockedCode = 1204 + AuthorizationErrorCode = 2000 + ACIErrorCode = 2100 + InvocationErrorCode = 3000 + EncodingErrorCode = 3001 + BinaryEncodingErrorCode = 3002 + ZeroArgumentErrorCode = 3003 + MaxArgumentErrorCode = 3004 + OptionErrorCode = 3005 + OverlapErrorCode = 3006 + RequirementErrorCode = 3007 + ConversionErrorCode = 3008 + ValidationErrorCode = 3009 + NoSuchNamespaceErrorCode = 3010 + PasswordMismatchCode = 3011 + NotImplementedErrorCode = 3012 + NotConfiguredErrorCode = 3013 + PromptFailedCode = 3014 + DeprecationErrorCode = 3015 + NotAForestRootErrorCode = 3016 + ExecutionErrorCode = 4000 + NotFoundCode = 4001 + DuplicateEntryCode = 4002 + HostServiceCode = 4003 + MalformedServicePrincipalCode = 4004 + RealmMismatchCode = 4005 + RequiresRootCode = 4006 + AlreadyPosixGroupCode = 4007 + MalformedUserPrincipalCode = 4008 + AlreadyActiveCode = 4009 + AlreadyInactiveCode = 4010 + HasNSAccountLockCode = 4011 + NotGroupMemberCode = 4012 + RecursiveGroupCode = 4013 + AlreadyGroupMemberCode = 4014 + Base64DecodeErrorCode = 4015 + RemoteRetrieveErrorCode = 4016 + SameGroupErrorCode = 4017 + DefaultGroupErrorCode = 4018 + DNSNotARecordErrorCode = 4019 + ManagedGroupErrorCode = 4020 + ManagedPolicyErrorCode = 4021 + FileErrorCode = 4022 + NoCertificateErrorCode = 4023 + ManagedGroupExistsErrorCode = 4024 + ReverseMemberErrorCode = 4025 + AttrValueNotFoundCode = 4026 + SingleMatchExpectedCode = 4027 + AlreadyExternalGroupCode = 4028 + ExternalGroupViolationCode = 4029 + PosixGroupViolationCode = 4030 + EmptyResultCode = 4031 + InvalidDomainLevelErrorCode = 4032 + ServerRemovalErrorCode = 4033 + OperationNotSupportedForPrincipalTypeCode = 4034 + HTTPRequestErrorCode = 4035 + RedundantMappingRuleCode = 4036 + CSRTemplateErrorCode = 4037 + AlreadyContainsValueErrorCode = 4038 + BuiltinErrorCode = 4100 + HelpErrorCode = 4101 + LDAPErrorCode = 4200 + MidairCollisionCode = 4201 + EmptyModlistCode = 4202 + DatabaseErrorCode = 4203 + LimitsExceededCode = 4204 + ObjectclassViolationCode = 4205 + NotAllowedOnRDNCode = 4206 + OnlyOneValueAllowedCode = 4207 + InvalidSyntaxCode = 4208 + BadSearchFilterCode = 4209 + NotAllowedOnNonLeafCode = 4210 + DatabaseTimeoutCode = 4211 + DNSDataMismatchCode = 4212 + TaskTimeoutCode = 4213 + TimeLimitExceededCode = 4214 + SizeLimitExceededCode = 4215 + AdminLimitExceededCode = 4216 + CertificateErrorCode = 4300 + CertificateOperationErrorCode = 4301 + CertificateFormatErrorCode = 4302 + MutuallyExclusiveErrorCode = 4303 + NonFatalErrorCode = 4304 + AlreadyRegisteredErrorCode = 4305 + NotRegisteredErrorCode = 4306 + DependentEntryCode = 4307 + LastMemberErrorCode = 4308 + ProtectedEntryErrorCode = 4309 + CertificateInvalidErrorCode = 4310 + SchemaUpToDateCode = 4311 + DNSErrorCode = 4400 + DNSResolverErrorCode = 4401 + TrustErrorCode = 4500 + TrustTopologyConflictErrorCode = 4501 + GenericErrorCode = 5000 +) + +// Authentication rejection reasons. +const ( + passwordExpiredUnauthorizedReason = "password-expired" + invalidSessionPasswordUnauthorizedReason = "invalid-password" + krbPrincipalExpiredUnauthorizedReason = "krbprincipal-expired" + userLockedUnauthorizedReason = "user-locked" + rejectionReasonHTTPHeader = "X-Ipa-Rejection-Reason" +) + +// unauthorizedHTTPError: Add information from the rejection reason header to unauthorized error. +func unauthorizedHTTPError(resp *http.Response) error { + var errorCode int + rejectionReason := resp.Header.Get(rejectionReasonHTTPHeader) + + switch rejectionReason { + case passwordExpiredUnauthorizedReason: + errorCode = PasswordExpiredCode + case invalidSessionPasswordUnauthorizedReason: + errorCode = InvalidSessionPasswordCode + case krbPrincipalExpiredUnauthorizedReason: + errorCode = KrbPrincipalExpiredCode + case userLockedUnauthorizedReason: + errorCode = UserLockedCode + + default: + errorCode = GenericErrorCode + } + return fmt.Errorf("unauthorized response <%s> (%d)", rejectionReason, errorCode) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..51860f1 --- /dev/null +++ b/go.mod @@ -0,0 +1,16 @@ +module github.com/grmrgecko/go-freeipa + +go 1.20 + +require github.com/jcmturner/gokrb5/v8 v8.4.4 + +require ( + github.com/hashicorp/go-uuid v1.0.3 // indirect + github.com/jcmturner/aescts/v2 v2.0.0 // indirect + github.com/jcmturner/dnsutils/v2 v2.0.0 // indirect + github.com/jcmturner/gofork v1.7.6 // indirect + github.com/jcmturner/goidentity/v6 v6.0.1 // indirect + github.com/jcmturner/rpc/v2 v2.0.3 // indirect + golang.org/x/crypto v0.6.0 // indirect + golang.org/x/net v0.7.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..2e5892c --- /dev/null +++ b/go.sum @@ -0,0 +1,69 @@ +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ= +github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4= +github.com/gorilla/sessions v1.2.1 h1:DHd3rPN5lE3Ts3D8rKkQ8x/0kqfeNmBAaiSi+o7FsgI= +github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM= +github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= +github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8= +github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= +github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8= +github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs= +github.com/jcmturner/dnsutils/v2 v2.0.0 h1:lltnkeZGL0wILNvrNiVCR6Ro5PGU/SeBvVO/8c/iPbo= +github.com/jcmturner/dnsutils/v2 v2.0.0/go.mod h1:b0TnjGOvI/n42bZa+hmXL+kFJZsFT7G4t3HTlQ184QM= +github.com/jcmturner/gofork v1.7.6 h1:QH0l3hzAU1tfT3rZCnW5zXl+orbkNMMRGJfdJjHVETg= +github.com/jcmturner/gofork v1.7.6/go.mod h1:1622LH6i/EZqLloHfE7IeZ0uEJwMSUyQ/nDd82IeqRo= +github.com/jcmturner/goidentity/v6 v6.0.1 h1:VKnZd2oEIMorCTsFBnJWbExfNN7yZr3EhJAxwOkZg6o= +github.com/jcmturner/goidentity/v6 v6.0.1/go.mod h1:X1YW3bgtvwAXju7V3LCIMpY0Gbxyjn/mY9zx4tFonSg= +github.com/jcmturner/gokrb5/v8 v8.4.4 h1:x1Sv4HaTpepFkXbt2IkL29DXRf8sOfZXo8eRKh687T8= +github.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP+F6aCACiMrs= +github.com/jcmturner/rpc/v2 v2.0.3 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZY= +github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.6.0 h1:qfktjS5LUO+fFKeJXZ+ikTRijMmljikvG68fpMMruSc= +golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.7.0 h1:rJrUqqhjsgNp7KqAIc25s9pZnjU7TUcSY7HcVZjdn1g= +golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/request.go b/request.go new file mode 100644 index 0000000..e3c805b --- /dev/null +++ b/request.go @@ -0,0 +1,89 @@ +package freeipa + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" +) + +// Standard API version definitation. +var apiVersion = "2.237" + +// Request format. +type Request struct { + Method string `json:"method"` + Params []interface{} `json:"params"` +} + +// NewRequest: Create a new request providing method, args, and parameters. +func NewRequest(method string, args []interface{}, parms map[string]interface{}) *Request { + // Add API version to the parameters. + parms["version"] = apiVersion + + // Create the request. + req := &Request{ + Method: method, + Params: []interface{}{ + args, + parms, + }, + } + + // Return new request. + return req +} + +// Do: Have the client do the request. +func (c *Client) Do(req *Request) (*Response, error) { + // Send request. + res, err := c.sendRequest(req) + if err != nil { + return nil, err + } + defer res.Body.Close() + + // If request is unauthorized, attempt to re-authenticate. + if res.StatusCode == http.StatusUnauthorized { + // Login. + err = c.login() + if err != nil { + return nil, fmt.Errorf("renewed login failed: %s", err) + } + + // Re-send the request, now that we're authenticated. + res, err = c.sendRequest(req) + if err != nil { + return nil, err + } + } + + // We expect a 200 response, otherwise re-authentication failed or some other error occured. + if res.StatusCode != http.StatusOK { + return nil, fmt.Errorf("unexpected http status code: %d", res.StatusCode) + } + + // Parse the response from the body. + return ParseResponse(res.Body) +} + +// sendRequest: Encode and send the request to the session. +func (c *Client) sendRequest(request *Request) (*http.Response, error) { + // Encode to JSON. + data, err := json.Marshal(request) + if err != nil { + return nil, err + } + + // Make request with JSON data. + req, err := http.NewRequest("POST", c.uriBase+"/session/json", bytes.NewBuffer(data)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + req.Header.Set("Referer", c.uriBase) + + // Perform the request. + return c.client.Do(req) +} diff --git a/response.go b/response.go new file mode 100644 index 0000000..2bf8c2a --- /dev/null +++ b/response.go @@ -0,0 +1,340 @@ +package freeipa + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "io" + "time" +) + +// General date/time format in LDAP. +// https://github.com/freeipa/freeipa/blob/ipa-4-7/ipalib/constants.py#L271 +const LDAPGeneralizedTimeFormat = "20060102150405Z" + +// Message: Used in providing extra messages and error response. +type Message struct { + Type string `json:"type"` + Message string `json:"message"` + Code int `json:"code"` + Name string `json:"name"` +} + +// string: Convert the message into a combind string. +func (t *Message) string() string { + return fmt.Sprintf("%v (%v): %v", t.Name, t.Code, t.Message) +} + +// Result: Standard result in response from FreeIPA. +type Result struct { + Count int `json:"count"` + Truncated bool `json:"truncated"` + Messages []*Message `json:"messages,omitempty"` + // This result differs depending on response, + // read the API documentation below for information. + // https://github.com/freeipa/freeipa/tree/master/doc/api + Result interface{} `json:"result"` + Summary string `json:"summary,omitempty"` + Value string `json:"value,omitempty"` +} + +// Response: Standard response from FreeIPA. +type Response struct { + Error *Message `json:"error"` + Result *Result `json:"result"` + Version string `json:"version"` + Principal string `json:"principal"` +} + +// ParseResponse: Parse response from reader. +func ParseResponse(body io.Reader) (*Response, error) { + // Decode JSON response. + res := new(Response) + err := json.NewDecoder(body).Decode(res) + if err != nil { + return nil, err + } + // If an error was provided from the API, return it. + if res.Error != nil { + return nil, fmt.Errorf(res.Error.string()) + } + // We expect result to be provided on a valid response. + if res.Result == nil { + return nil, fmt.Errorf("no result in response") + } + // A valid response was decoded, return it. + return res, nil +} + +// BoolResult: Decode results which are boolean formatted, usually used to indicate success or state. +func (r *Response) BoolResult() bool { + if r.Result == nil { + return false + } + v, ok := r.Result.Result.(bool) + if !ok { + return false + } + return v +} + +func (r *Response) CountResults() int { + if r.Result == nil { + return -1 + } + a, ok := r.Result.Result.([]interface{}) + if !ok { + return -1 + } + return len(a) +} + +// GetAtIndex: Get an interface for a key. +func (r *Response) GetAtIndex(index int, key string) ([]interface{}, bool) { + if r.Result == nil { + return nil, false + } + a, ok := r.Result.Result.([]interface{}) + if !ok { + return nil, false + } + // Make sure we don't overflow. + if len(a) < index { + return nil, false + } + d := a[index] + dict, ok := d.(map[string]interface{}) + if !ok { + return nil, false + } + v, ok := dict[key] + if !ok { + return nil, false + } + a, ok = v.([]interface{}) + if !ok { + // Apparently FreeIPA sometimes returns a string outside of an array, so this catches that. + return []interface{}{v}, true + } + return a, true +} + +// Get: Get an interface for a key. +func (r *Response) Get(key string) ([]interface{}, bool) { + if r.Result == nil { + return nil, false + } + dict, ok := r.Result.Result.(map[string]interface{}) + if !ok { + return nil, false + } + v, ok := dict[key] + if !ok { + return nil, false + } + a, ok := v.([]interface{}) + if !ok { + // Apparently FreeIPA sometimes returns a string outside of an array, so this catches that. + return []interface{}{v}, true + } + return a, true +} + +// GetBoolProcess: Process bool element. +func (r *Response) GetBoolProcess(v interface{}) (bool, bool) { + a, ok := v.(bool) + if !ok { + return false, false + } + return a, true +} + +// GetBoolAtIndex: Get a boolean from a key at an index. +func (r *Response) GetBoolAtIndex(index int, key string) (bool, bool) { + v, ok := r.GetAtIndex(index, key) + if !ok || len(v) < 1 { + return false, false + } + return r.GetBoolProcess(v[0]) +} + +// GetBool: Get a boolean from a key. +func (r *Response) GetBool(key string) (bool, bool) { + v, ok := r.Get(key) + if !ok || len(v) < 1 { + return false, false + } + return r.GetBoolProcess(v[0]) +} + +// GetStringProcess: Process sub element with string. +func (r *Response) GetStringProcess(v []interface{}) ([]string, bool) { + var res []string + for _, p := range v { + s, ok := p.(string) + if !ok { + return res, false + } + res = append(res, s) + } + return res, true +} + +// GetStringsAtIndex: Get string value for key at an index. +func (r *Response) GetStringsAtIndex(index int, key string) ([]string, bool) { + v, ok := r.GetAtIndex(index, key) + if !ok { + return []string{}, false + } + return r.GetStringProcess(v) +} + +// GetStrings: Get string value for key. +func (r *Response) GetStrings(key string) ([]string, bool) { + v, ok := r.Get(key) + if !ok { + return []string{}, false + } + return r.GetStringProcess(v) +} + +// GetStringAtIndex: Get string value for key at an index. +func (r *Response) GetStringAtIndex(index int, key string) (string, bool) { + v, ok := r.GetStringsAtIndex(index, key) + if !ok || len(v) < 1 { + return "", false + } + return v[0], true +} + +// GetString: Get string value for key. +func (r *Response) GetString(key string) (string, bool) { + v, ok := r.GetStrings(key) + if !ok || len(v) < 1 { + return "", false + } + return v[0], true +} + +// GetDataProcess: Process a sub element with bytes. +func (r *Response) GetDataProcess(v []interface{}) ([][]byte, bool) { + var res [][]byte + for _, p := range v { + var bytes []byte + dict, ok := p.(map[string]interface{}) + if !ok { + return res, false + } + b, ok := dict["__base64__"] + if !ok { + return res, false + } + s, ok := b.(string) + if !ok { + return res, false + } + bytes, err := base64.StdEncoding.DecodeString(s) + if err != nil { + return res, false + } + res = append(res, bytes) + } + return res, true +} + +// GetDatasAtIndex: Get byte array for key at an index. +func (r *Response) GetDatasAtIndex(index int, key string) ([][]byte, bool) { + v, ok := r.GetAtIndex(index, key) + if !ok { + return [][]byte{}, false + } + return r.GetDataProcess(v) +} + +// GetDatas: Get byte array for key. +func (r *Response) GetDatas(key string) ([][]byte, bool) { + v, ok := r.Get(key) + if !ok { + return [][]byte{}, false + } + return r.GetDataProcess(v) +} + +// GetDataAtIndex: Get byte array for key at an index. +func (r *Response) GetDataAtIndex(index int, key string) ([]byte, bool) { + v, ok := r.GetDatasAtIndex(index, key) + if !ok || len(v) < 1 { + return []byte{}, false + } + return v[0], true +} + +// GetData: Get byte array for key. +func (r *Response) GetData(key string) ([]byte, bool) { + v, ok := r.GetDatas(key) + if !ok || len(v) < 1 { + return []byte{}, false + } + return v[0], true +} + +// GetDateTimeProcess: Process a sub element with a date/time value. +func (r *Response) GetDateTimeProcess(v []interface{}) ([]time.Time, bool) { + var res []time.Time + for _, p := range v { + dict, ok := p.(map[string]interface{}) + if !ok { + return res, false + } + d, ok := dict["__datetime__"] + if !ok { + return res, false + } + s, ok := d.(string) + if !ok { + return res, false + } + dateTime, err := time.Parse(LDAPGeneralizedTimeFormat, s) + if err != nil { + return res, false + } + res = append(res, dateTime) + } + return res, true +} + +// GetDateTimesAtIndex: Get date time value for key at an index. +func (r *Response) GetDateTimesAtIndex(index int, key string) ([]time.Time, bool) { + v, ok := r.GetAtIndex(index, key) + if !ok { + return []time.Time{}, false + } + return r.GetDateTimeProcess(v) +} + +// GetDateTimes: Get date time value for key. +func (r *Response) GetDateTimes(key string) ([]time.Time, bool) { + v, ok := r.Get(key) + if !ok { + return []time.Time{}, false + } + return r.GetDateTimeProcess(v) +} + +// GetDateTimeAtIndex: Get date time value for key at an index. +func (r *Response) GetDateTimeAtIndex(index int, key string) (time.Time, bool) { + v, ok := r.GetDateTimesAtIndex(index, key) + if !ok || len(v) < 1 { + return time.Time{}, false + } + return v[0], true +} + +// GetDateTime: Get date time value for key. +func (r *Response) GetDateTime(key string) (time.Time, bool) { + v, ok := r.GetDateTimes(key) + if !ok || len(v) < 1 { + return time.Time{}, false + } + return v[0], true +} diff --git a/test/cert.pem b/test/cert.pem new file mode 100644 index 0000000..569a428 --- /dev/null +++ b/test/cert.pem @@ -0,0 +1,14 @@ +-----BEGIN CERTIFICATE----- +MIICMjCCAZugAwIBAgIQEAkA4KUMlYMXTLf8HKWnNzANBgkqhkiG9w0BAQsFADAS +MRAwDgYDVQQKEwdBY21lIENvMCAXDTcwMDEwMTAwMDAwMFoYDzIwODQwMTI5MTYw +MDAwWjASMRAwDgYDVQQKEwdBY21lIENvMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCB +iQKBgQCuulmxBijxcvxHoK43bPjvsCcSjaItYouIYbfM1WKIm4GBMbKRwCYNSQav +CirwgSiIEHtF4Xtzz/8ObNCPE46o2/0p9C925KzXmdpNlVPiXGOEY4R0ReHF6FjE +u8oa/imgSxPsfd4rg4tY1YIdeT28+7nzTqnW9s64m539mpg+JwIDAQABo4GGMIGD +MA4GA1UdDwEB/wQEAwICpDATBgNVHSUEDDAKBggrBgEFBQcDATAPBgNVHRMBAf8E +BTADAQH/MB0GA1UdDgQWBBSh/VW/iZWe+Fvd2ZFHApB8uj8EczAsBgNVHREEJTAj +gglsb2NhbGhvc3SHBH8AAAGHEAAAAAAAAAAAAAAAAAAAAAEwDQYJKoZIhvcNAQEL +BQADgYEAMvOwyek82nbjgE2dUmh2pYuE115iRmCOv3NoxLqq0XWYTfyqi0I2PTGU +Q5fmi1KNY075KxMN9PHHDeJwmUb10tu7ghkKe/6Il71eOvjQmKtsATLpad6dmHFF +6ormGkTzz3OPiz5whzZrdlonFgGdHPwHJqy9MTlDw+8ZH/x5RfA= +-----END CERTIFICATE----- diff --git a/test/invalid_json.json b/test/invalid_json.json new file mode 100644 index 0000000..40da4bb --- /dev/null +++ b/test/invalid_json.json @@ -0,0 +1,14 @@ +{ + "result": null, + "version": "4.6.8", + "error": { + "message": "Invalid JSON-RPC request: Expecting property name enclosed in double quotes: line 4 column 4 (char 44)", + "code": 909, + "data": { + "error": "Expecting property name enclosed in double quotes: line 4 column 4 (char 44)" + }, + "name": "JSONError" + }, + "id": null, + "principal": "admin@EXAMPLE.COM" +} \ No newline at end of file diff --git a/test/key.pem b/test/key.pem new file mode 100644 index 0000000..03daa65 --- /dev/null +++ b/test/key.pem @@ -0,0 +1,16 @@ +-----BEGIN PRIVATE KEY----- +MIICdgIBADANBgkqhkiG9w0BAQEFAASCAmAwggJcAgEAAoGBAK66WbEGKPFy/Eeg +rjds+O+wJxKNoi1ii4hht8zVYoibgYExspHAJg1JBq8KKvCBKIgQe0Xhe3PP/w5s +0I8Tjqjb/Sn0L3bkrNeZ2k2VU+JcY4RjhHRF4cXoWMS7yhr+KaBLE+x93iuDi1jV +gh15Pbz7ufNOqdb2zribnf2amD4nAgMBAAECgYAs4C+pB6v8V0v0GZClK5fD97oR +Sc8dWPH9VRufwC5OZ6IbTGhQhsk/IEJXMoVUv9dpGtKOYBsU45beXZQzKxK4XsVK +6DTZT4+jgp+IJuM0GEguYHClTYjDjRlDSZe4SAq7pWEr/pFiE1u0MXbkRenDkXox +X4LXw/SKF3P/x06OAQJBAMK59BjxvATHaAYhABt2rDi9JjSBcceM6oO2y4hAZJCy +1NGG098xO4Ne3xuYuldBsnoLKk+G3bF5Vqws8mC0UisCQQDltW+RRIVFeo9rxNHC +NWEJbNZD9FJWsgrBo6vGG7ET3erdXxU3iWYx2Z986BVPhSTcPdRJSwPf6hi/xlkr +ElH1AkBOP2j+KQ1TokmDxPkFEC/ucNuMV8O/2zlVijvJWY7PsnzgYVx8II14ocPn +k/y1GXo9noT3BgvJyCdy8nDHOU6XAkEAo9wfcALvBrb85CWMc/tb8zs+RU9eBRYQ +cj1s5W8PjFp7ldqj6fALhHf3O0TbHtSdjLZWXsoyQ2JcsUCujvkMmQJAU+ivqOZP +evb4YZ23LLharBxMCSW4AkmYav5zlp4FE74ieqUsHOxGWt2mcdgERjZ0b7TyG5C+ +KRh6+NazFEwmww== +-----END PRIVATE KEY----- diff --git a/test/user_add_response.json b/test/user_add_response.json new file mode 100644 index 0000000..37d1a89 --- /dev/null +++ b/test/user_add_response.json @@ -0,0 +1,96 @@ +{ + "result": { + "result": { + "has_keytab": true, + "cn": [ + "FreeIPA Test" + ], + "krbcanonicalname": [ + "username@EXAMPLE.COM" + ], + "memberof_group": [ + "ipausers" + ], + "has_password": true, + "homedirectory": [ + "/home/example.com/username" + ], + "uid": [ + "username" + ], + "loginshell": [ + "/bin/bash" + ], + "uidnumber": [ + "866001000" + ], + "krbextradata": [ + { + "__base64__": "AAJ/ZHJvb3QvYWRtaW5ARVhBTVBMRS5DT00A" + } + ], + "mail": [ + "username@example.com" + ], + "dn": "uid=username,cn=users,cn=accounts,dc=example,dc=com", + "displayname": [ + "FreeIPA Test" + ], + "mepmanagedentry": [ + "cn=username,cn=groups,cn=accounts,dc=example,dc=com" + ], + "ipauniqueid": [ + "e8b12c28-3744-11ee-ad07-141877671fe2" + ], + "krbprincipalname": [ + "username@EXAMPLE.COM" + ], + "givenname": [ + "FreeIPA" + ], + "krbpasswordexpiration": [ + { + "__datetime__": "20230810061238Z" + } + ], + "objectclass": [ + "top", + "person", + "organizationalperson", + "inetorgperson", + "inetuser", + "posixaccount", + "krbprincipalaux", + "krbticketpolicyaux", + "ipaobject", + "ipasshuser", + "nexcessuser", + "ipaSshGroupOfPubKeys", + "mepOriginEntry" + ], + "gidnumber": [ + "866001000" + ], + "gecos": [ + "FreeIPA Test" + ], + "sn": [ + "Heath" + ], + "krblastpwdchange": [ + { + "__datetime__": "20230810061238Z" + } + ], + "initials": [ + "FH" + ] + }, + "value": "username", + "summary": "Added user \"username\"" + }, + "version": "4.6.8", + "error": null, + "id": null, + "principal": "admin@EXAMPLE.COM" + } \ No newline at end of file diff --git a/test/user_find_response.json b/test/user_find_response.json new file mode 100644 index 0000000..e743781 --- /dev/null +++ b/test/user_find_response.json @@ -0,0 +1,30 @@ +{ + "result": { + "count": 2, + "truncated": false, + "result": [ + { + "dn": "uid=admin,cn=users,cn=accounts,dc=example,dc=com", + "uid": [ + "admin" + ] + }, + { + "dn": "uid=johnny.bravo,cn=users,cn=accounts,dc=example,dc=com", + "ipasshpubkey": [ + { + "__base64__": "c3NoLXJzYSBBQUFBQjNOemFDMXljMkVBQUFBREFRQUJBQUFCZ1FDbTAwQ2sxc2l3eFY1N3lDb3R3V0VRd2lpbjhHK2tmeU9hWWkwUEtqQUgzNzN2VnJsY09qVEJCaEZTV2w4QUdlWmlqMXc3aEhka1VYTTZNbXdWVzVLQmJ4N3lGeFBmcU1lRjNsWVh1WlFpdW52VHlIZlRRK3lqVnVwOVNtVHJBT1NCWklsOHpUOFlXQmZPVVJwN2pPdDRPMUdPNW50VTRRTFVQTlUzdFdWdDFPUmZ6K3NEWVpmVGZ0bFpHbzc3RVJTdm9vd0t3ekFmTmd1SktMb2tJWm84bXJySm5SYlVJdDFIVGgxTU8wTk1yU2I1Rk1wd2JCMXdWNERINVdwMFJSR1BJV2VheE5SL2dtV2tNSURTYWpFZDZUTXpidThMZ2hqa0FDWWtYSThUZmNUMWl1Rlh4OGh0YWRuT2Jwc0pYTUNPYk5td0RRa2xwQ3NHSXVkZWpGQVQ0THhHcWZzZVZSbVprOG96WTlSKzhLRmtzMkEybG1HNFVmUERtRlVWSUVNYmV3SjhHN09rTFRPajVEQWV0b01wVEh1RE04SkdpNVpXaHV2bGh5L2w5NU5XUFBidE8rZ0tTMTVQTml1UGNORExMdGNSQ2NIbUg2TUZiVWUyTC9oSm5VS2NxUVNYMmhqcnJzdXhPVXkvNFZ4Q3BDQUJqQ244U2Z4c3J3Y1JLaU09IHJvb3RAZXhhbXBsZS5jb20=" + } + ], + "uid": [ + "johnny.bravo" + ] + } + ], + "summary": "2 users matched" + }, + "version": "4.6.8", + "error": null, + "id": null, + "principal": "admin@EXAMPLE.COM" + } \ No newline at end of file