diff --git a/internal/tag/tag_test.go b/internal/tag/tag_test.go index e62868a..afd6902 100644 --- a/internal/tag/tag_test.go +++ b/internal/tag/tag_test.go @@ -52,6 +52,7 @@ import ( "github.com/greenpau/go-authcrunch/pkg/registry" "github.com/greenpau/go-authcrunch/pkg/requests" "github.com/greenpau/go-authcrunch/pkg/sso" + "github.com/greenpau/go-authcrunch/pkg/tagging" "github.com/greenpau/go-authcrunch/pkg/user" "github.com/greenpau/go-authcrunch/pkg/util" "github.com/greenpau/go-authcrunch/pkg/util/cfg" @@ -880,8 +881,8 @@ func TestTagCompliance(t *testing.T) { opts: &Options{}, }, { - name: "test identity.Tag struct", - entry: &identity.Tag{}, + name: "test tagging.Tag struct", + entry: &tagging.Tag{}, opts: &Options{}, }, } diff --git a/pkg/authn/handle_api_profile.go b/pkg/authn/handle_api_profile.go index 25c3522..fec6f1b 100644 --- a/pkg/authn/handle_api_profile.go +++ b/pkg/authn/handle_api_profile.go @@ -22,14 +22,25 @@ import ( "net/http" "time" + "github.com/greenpau/go-authcrunch/pkg/authn/enums/operator" "github.com/greenpau/go-authcrunch/pkg/identity" + "github.com/greenpau/go-authcrunch/pkg/identity/qr" "github.com/greenpau/go-authcrunch/pkg/requests" + "github.com/greenpau/go-authcrunch/pkg/tagging" + "github.com/greenpau/go-authcrunch/pkg/util" + + "regexp" "github.com/greenpau/go-authcrunch/pkg/user" addrutil "github.com/greenpau/go-authcrunch/pkg/util/addr" "go.uber.org/zap" ) +var tokenSecretRegexPattern = regexp.MustCompile(`^[A-Za-z0-9]{10,200}$`) +var tokenIssuerRegexPattern = regexp.MustCompile(`^[A-Za-z0-9]{3,50}$`) +var tokenDescriptionRegexPattern = regexp.MustCompile(`[\W\s]{3,255}$`) +var tokenPasscodeRegexPattern = regexp.MustCompile(`^[0-9]{4,8}$`) + func handleAPIProfileResponse(w http.ResponseWriter, rr *requests.Request, code int, resp map[string]interface{}) error { resp["status"] = code rr.Response.Code = code @@ -92,6 +103,9 @@ func (p *Portal) handleAPIProfile(ctx context.Context, w http.ResponseWriter, r case "fetch_user_dashboard_data": case "delete_user_multi_factor_verifier": case "fetch_user_multi_factor_verifiers": + case "fetch_user_app_multi_factor_verifier_code": + case "test_user_app_multi_factor_verifier": + case "add_user_app_multi_factor_verifier": default: resp["message"] = "Profile API recieved unsupported request type" return handleAPIProfileResponse(w, rr, http.StatusBadRequest, resp) @@ -291,8 +305,279 @@ func (p *Portal) handleAPIProfile(ctx context.Context, w http.ResponseWriter, r } resp["entry"] = verifierID return handleAPIProfileResponse(w, rr, http.StatusOK, resp) - default: - resp["message"] = fmt.Sprintf("unsupported %s request kind with profile API", reqKind) - return handleAPIProfileResponse(w, rr, http.StatusNotImplemented, resp) + + case "fetch_user_app_multi_factor_verifier_code": + var tokenLifetime, tokenDigits int + var tokenIssuer, tokenSecret string + + // Extract data. + if v, exists := bodyData["period"]; exists { + switch exp := v.(type) { + case float64: + tokenLifetime = int(exp) + case int: + tokenLifetime = exp + case int64: + tokenLifetime = int(exp) + case json.Number: + i, _ := exp.Int64() + tokenLifetime = int(i) + } + } else { + resp["message"] = "Profile API did not find period in the request payload" + return handleAPIProfileResponse(w, rr, http.StatusBadRequest, resp) + } + if v, exists := bodyData["digits"]; exists { + switch exp := v.(type) { + case float64: + tokenDigits = int(exp) + case int: + tokenDigits = exp + case int64: + tokenDigits = int(exp) + case json.Number: + i, _ := exp.Int64() + tokenDigits = int(i) + } + } else { + resp["message"] = "Profile API did not find digits in the request payload" + return handleAPIProfileResponse(w, rr, http.StatusBadRequest, resp) + } + if v, exists := bodyData["issuer"]; exists { + tokenIssuer = v.(string) + } else { + resp["message"] = "Profile API did not find issuer in the request payload" + return handleAPIProfileResponse(w, rr, http.StatusBadRequest, resp) + } + if v, exists := bodyData["secret"]; exists { + tokenSecret = v.(string) + } else { + resp["message"] = "Profile API did not find secret in the request payload" + return handleAPIProfileResponse(w, rr, http.StatusBadRequest, resp) + } + + // Validate data. + if !tokenIssuerRegexPattern.MatchString(tokenIssuer) { + resp["message"] = "Profile API found non-compliant token issuer value" + return handleAPIProfileResponse(w, rr, http.StatusBadRequest, resp) + } + if !tokenSecretRegexPattern.MatchString(tokenSecret) { + resp["message"] = "Profile API found non-compliant token secret value" + return handleAPIProfileResponse(w, rr, http.StatusBadRequest, resp) + } + if tokenLifetime != 15 && tokenLifetime != 30 && tokenLifetime != 60 && tokenLifetime != 90 { + resp["message"] = "Profile API found non-compliant token lifetime value" + return handleAPIProfileResponse(w, rr, http.StatusBadRequest, resp) + } + if tokenDigits != 4 && tokenDigits != 6 && tokenDigits != 8 { + resp["message"] = "Profile API found non-compliant token digits value" + return handleAPIProfileResponse(w, rr, http.StatusBadRequest, resp) + } + + code := qr.NewCode() + code.Secret = tokenSecret + code.Type = "totp" + code.Period = tokenLifetime + code.Issuer = fmt.Sprintf("AuthCrunch@%s", tokenIssuer) + code.Label = fmt.Sprintf("%s:%s", code.Issuer, usr.Claims.Email) + code.Digits = tokenDigits + if err := code.Build(); err != nil { + resp["message"] = "Profile API failed to build QR code" + return handleAPIProfileResponse(w, rr, http.StatusBadRequest, resp) + } + codeData := make(map[string]interface{}) + codeData["uri"] = code.Get() + codeData["uri_encoded"] = code.GetEncoded() + resp["entry"] = codeData + return handleAPIProfileResponse(w, rr, http.StatusOK, resp) + case "test_user_app_multi_factor_verifier": + var tokenLifetime, tokenDigits int + var tokenSecret, tokenPasscode string + + // Extract data. + if v, exists := bodyData["period"]; exists { + switch exp := v.(type) { + case float64: + tokenLifetime = int(exp) + case int: + tokenLifetime = exp + case int64: + tokenLifetime = int(exp) + case json.Number: + i, _ := exp.Int64() + tokenLifetime = int(i) + } + } else { + resp["message"] = "Profile API did not find period in the request payload" + return handleAPIProfileResponse(w, rr, http.StatusBadRequest, resp) + } + if v, exists := bodyData["digits"]; exists { + switch exp := v.(type) { + case float64: + tokenDigits = int(exp) + case int: + tokenDigits = exp + case int64: + tokenDigits = int(exp) + case json.Number: + i, _ := exp.Int64() + tokenDigits = int(i) + } + } else { + resp["message"] = "Profile API did not find digits in the request payload" + return handleAPIProfileResponse(w, rr, http.StatusBadRequest, resp) + } + if v, exists := bodyData["secret"]; exists { + tokenSecret = v.(string) + } else { + resp["message"] = "Profile API did not find secret in the request payload" + return handleAPIProfileResponse(w, rr, http.StatusBadRequest, resp) + } + if v, exists := bodyData["passcode"]; exists { + tokenPasscode = v.(string) + } else { + resp["message"] = "Profile API did not find passcode in the request payload" + return handleAPIProfileResponse(w, rr, http.StatusBadRequest, resp) + } + + // Validate data. + if !tokenSecretRegexPattern.MatchString(tokenSecret) { + resp["message"] = "Profile API found non-compliant token secret value" + return handleAPIProfileResponse(w, rr, http.StatusBadRequest, resp) + } + if !tokenPasscodeRegexPattern.MatchString(tokenPasscode) { + resp["message"] = "Profile API found non-compliant token passcode value" + return handleAPIProfileResponse(w, rr, http.StatusBadRequest, resp) + } + if tokenLifetime != 15 && tokenLifetime != 30 && tokenLifetime != 60 && tokenLifetime != 90 { + resp["message"] = "Profile API found non-compliant token lifetime value" + return handleAPIProfileResponse(w, rr, http.StatusBadRequest, resp) + } + if tokenDigits != 4 && tokenDigits != 6 && tokenDigits != 8 { + resp["message"] = "Profile API found non-compliant token digits value" + return handleAPIProfileResponse(w, rr, http.StatusBadRequest, resp) + } + + respData := make(map[string]interface{}) + appToken := identity.MfaToken{ + ID: util.GetRandomString(40), + CreatedAt: time.Now().UTC(), + Parameters: make(map[string]string), + Flags: make(map[string]bool), + Comment: "TBD", + Type: "totp", + Secret: tokenSecret, + Algorithm: "sha1", + Period: tokenLifetime, + Digits: tokenDigits, + } + if err := appToken.ValidateCodeWithTime(tokenPasscode, time.Now().Add(-time.Second*time.Duration(appToken.Period)).UTC()); err != nil { + respData["success"] = false + } else { + respData["success"] = true + } + resp["entry"] = respData + return handleAPIProfileResponse(w, rr, http.StatusOK, resp) + case "add_user_app_multi_factor_verifier": + var tokenTitle, tokenDescription, tokenSecret string + var tokenLifetime, tokenDigits int + var tokenLabels []string = []string{} + var tokenTags []tagging.Tag = []tagging.Tag{} + + // Extract data. + if v, exists := bodyData["period"]; exists { + switch exp := v.(type) { + case float64: + tokenLifetime = int(exp) + case int: + tokenLifetime = exp + case int64: + tokenLifetime = int(exp) + case json.Number: + i, _ := exp.Int64() + tokenLifetime = int(i) + } + } else { + resp["message"] = "Profile API did not find period in the request payload" + return handleAPIProfileResponse(w, rr, http.StatusBadRequest, resp) + } + if v, exists := bodyData["digits"]; exists { + switch exp := v.(type) { + case float64: + tokenDigits = int(exp) + case int: + tokenDigits = exp + case int64: + tokenDigits = int(exp) + case json.Number: + i, _ := exp.Int64() + tokenDigits = int(i) + } + } else { + resp["message"] = "Profile API did not find digits in the request payload" + return handleAPIProfileResponse(w, rr, http.StatusBadRequest, resp) + } + if v, exists := bodyData["title"]; exists { + tokenTitle = v.(string) + } else { + resp["message"] = "Profile API did not find title in the request payload" + return handleAPIProfileResponse(w, rr, http.StatusBadRequest, resp) + } + if v, exists := bodyData["description"]; exists { + tokenDescription = v.(string) + } else { + resp["message"] = "Profile API did not find description in the request payload" + return handleAPIProfileResponse(w, rr, http.StatusBadRequest, resp) + } + if v, exists := bodyData["secret"]; exists { + tokenSecret = v.(string) + } else { + resp["message"] = "Profile API did not find secret in the request payload" + return handleAPIProfileResponse(w, rr, http.StatusBadRequest, resp) + } + + // Validate data. + if !tokenIssuerRegexPattern.MatchString(tokenTitle) { + resp["message"] = "Profile API found non-compliant token title value" + return handleAPIProfileResponse(w, rr, http.StatusBadRequest, resp) + } + if !tokenDescriptionRegexPattern.MatchString(tokenDescription) && (tokenDescription != "") { + resp["message"] = "Profile API found non-compliant token description value" + return handleAPIProfileResponse(w, rr, http.StatusBadRequest, resp) + } + if !tokenSecretRegexPattern.MatchString(tokenSecret) { + resp["message"] = "Profile API found non-compliant token secret value" + return handleAPIProfileResponse(w, rr, http.StatusBadRequest, resp) + } + if tokenLifetime != 15 && tokenLifetime != 30 && tokenLifetime != 60 && tokenLifetime != 90 { + resp["message"] = "Profile API found non-compliant token lifetime value" + return handleAPIProfileResponse(w, rr, http.StatusBadRequest, resp) + } + if tokenDigits != 4 && tokenDigits != 6 && tokenDigits != 8 { + resp["message"] = "Profile API found non-compliant token digits value" + return handleAPIProfileResponse(w, rr, http.StatusBadRequest, resp) + } + + rr.MfaToken.SkipVerification = true + rr.MfaToken.Comment = tokenTitle + rr.MfaToken.Description = tokenDescription + rr.MfaToken.Secret = tokenSecret + rr.MfaToken.Type = "totp" + rr.MfaToken.Period = tokenLifetime + rr.MfaToken.Digits = tokenDigits + rr.MfaToken.Labels = tokenLabels + rr.MfaToken.Tags = tokenTags + + if err = backend.Request(operator.AddMfaToken, rr); err != nil { + resp["message"] = "Profile API failed to add token identity store" + return handleAPIProfileResponse(w, rr, http.StatusBadRequest, resp) + } + + resp["entry"] = "Created" + return handleAPIProfileResponse(w, rr, http.StatusOK, resp) } + + // Default response + resp["message"] = fmt.Sprintf("unsupported %s request kind with profile API", reqKind) + return handleAPIProfileResponse(w, rr, http.StatusNotImplemented, resp) } diff --git a/pkg/identity/mfa_token.go b/pkg/identity/mfa_token.go index 02d0d6a..7848212 100644 --- a/pkg/identity/mfa_token.go +++ b/pkg/identity/mfa_token.go @@ -36,6 +36,7 @@ import ( "github.com/greenpau/go-authcrunch/pkg/errors" "github.com/greenpau/go-authcrunch/pkg/requests" + "github.com/greenpau/go-authcrunch/pkg/tagging" "github.com/greenpau/go-authcrunch/pkg/util" ) @@ -51,6 +52,7 @@ type MfaToken struct { Type string `json:"type,omitempty" xml:"type,omitempty" yaml:"type,omitempty"` Algorithm string `json:"algorithm,omitempty" xml:"algorithm,omitempty" yaml:"algorithm,omitempty"` Comment string `json:"comment,omitempty" xml:"comment,omitempty" yaml:"comment,omitempty"` + Description string `json:"description,omitempty" xml:"description,omitempty" yaml:"description,omitempty"` Secret string `json:"secret,omitempty" xml:"secret,omitempty" yaml:"secret,omitempty"` Period int `json:"period,omitempty" xml:"period,omitempty" yaml:"period,omitempty"` Digits int `json:"digits,omitempty" xml:"digits,omitempty" yaml:"digits,omitempty"` @@ -63,7 +65,7 @@ type MfaToken struct { Parameters map[string]string `json:"parameters,omitempty" xml:"parameters,omitempty" yaml:"parameters,omitempty"` Flags map[string]bool `json:"flags,omitempty" xml:"flags,omitempty" yaml:"flags,omitempty"` SignatureCounter uint32 `json:"signature_counter,omitempty" xml:"signature_counter,omitempty" yaml:"signature_counter,omitempty"` - Tags []Tag `json:"tags,omitempty" xml:"tags,omitempty" yaml:"tags,omitempty"` + Tags []tagging.Tag `json:"tags,omitempty" xml:"tags,omitempty" yaml:"tags,omitempty"` Labels []string `json:"labels,omitempty" xml:"labels,omitempty" yaml:"labels,omitempty"` pubkeyECDSA *ecdsa.PublicKey @@ -103,12 +105,13 @@ func (b *MfaTokenBundle) Size() int { // NewMfaToken returns an instance of MfaToken. func NewMfaToken(req *requests.Request) (*MfaToken, error) { p := &MfaToken{ - ID: util.GetRandomString(40), - CreatedAt: time.Now().UTC(), - Parameters: make(map[string]string), - Flags: make(map[string]bool), - Comment: req.MfaToken.Comment, - Type: req.MfaToken.Type, + ID: util.GetRandomString(40), + CreatedAt: time.Now().UTC(), + Parameters: make(map[string]string), + Flags: make(map[string]bool), + Comment: req.MfaToken.Comment, + Type: req.MfaToken.Type, + Description: req.MfaToken.Description, } if req.MfaToken.Disabled { @@ -145,8 +148,10 @@ func NewMfaToken(req *requests.Request) (*MfaToken, error) { return nil, errors.ErrMfaTokenInvalidDigits.WithArgs(p.Digits) } // Codes - if err := p.ValidateCodeWithTime(req.MfaToken.Passcode, time.Now().Add(-time.Second*time.Duration(p.Period)).UTC()); err != nil { - return nil, err + if !req.MfaToken.SkipVerification { + if err := p.ValidateCodeWithTime(req.MfaToken.Passcode, time.Now().Add(-time.Second*time.Duration(p.Period)).UTC()); err != nil { + return nil, err + } } case "u2f": r := &WebAuthnRegisterRequest{} diff --git a/pkg/requests/requests.go b/pkg/requests/requests.go index 596675c..3b85cfa 100644 --- a/pkg/requests/requests.go +++ b/pkg/requests/requests.go @@ -15,8 +15,11 @@ package requests import ( - "go.uber.org/zap" "net/http" + + "github.com/greenpau/go-authcrunch/pkg/tagging" + + "go.uber.org/zap" ) // Request hold the data associated with identity database @@ -106,15 +109,19 @@ type Key struct { // MfaToken holds MFA token attributes. type MfaToken struct { - ID string `json:"id,omitempty" xml:"id,omitempty" yaml:"id,omitempty"` - Comment string `json:"comment,omitempty" xml:"comment,omitempty" yaml:"comment,omitempty"` - Type string `json:"type,omitempty" xml:"type,omitempty" yaml:"type,omitempty"` - Secret string `json:"secret,omitempty" xml:"secret,omitempty" yaml:"secret,omitempty"` - Algorithm string `json:"algorithm,omitempty" xml:"algorithm,omitempty" yaml:"algorithm,omitempty"` - Period int `json:"period,omitempty" xml:"period,omitempty" yaml:"period,omitempty"` - Digits int `json:"digits,omitempty" xml:"digits,omitempty" yaml:"digits,omitempty"` - Passcode string `json:"passcode,omitempty" xml:"passcode,omitempty" yaml:"passcode,omitempty"` - Disabled bool `json:"disabled,omitempty" xml:"disabled,omitempty" yaml:"disabled,omitempty"` + ID string `json:"id,omitempty" xml:"id,omitempty" yaml:"id,omitempty"` + Comment string `json:"comment,omitempty" xml:"comment,omitempty" yaml:"comment,omitempty"` + Description string `json:"description,omitempty" xml:"description,omitempty" yaml:"description,omitempty"` + Type string `json:"type,omitempty" xml:"type,omitempty" yaml:"type,omitempty"` + Secret string `json:"secret,omitempty" xml:"secret,omitempty" yaml:"secret,omitempty"` + Algorithm string `json:"algorithm,omitempty" xml:"algorithm,omitempty" yaml:"algorithm,omitempty"` + Period int `json:"period,omitempty" xml:"period,omitempty" yaml:"period,omitempty"` + Digits int `json:"digits,omitempty" xml:"digits,omitempty" yaml:"digits,omitempty"` + Passcode string `json:"passcode,omitempty" xml:"passcode,omitempty" yaml:"passcode,omitempty"` + Disabled bool `json:"disabled,omitempty" xml:"disabled,omitempty" yaml:"disabled,omitempty"` + SkipVerification bool `json:"skip_verification,omitempty" xml:"skip_verification,omitempty" yaml:"skip_verification,omitempty"` + Tags []tagging.Tag `json:"tags,omitempty" xml:"tags,omitempty" yaml:"tags,omitempty"` + Labels []string `json:"labels,omitempty" xml:"labels,omitempty" yaml:"labels,omitempty"` } // WebAuthn holds WebAuthn messages. diff --git a/pkg/identity/tag.go b/pkg/tagging/tag.go similarity index 98% rename from pkg/identity/tag.go rename to pkg/tagging/tag.go index 275289f..8ee7a09 100644 --- a/pkg/identity/tag.go +++ b/pkg/tagging/tag.go @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -package identity +package tagging // Tag represents key-value tag. type Tag struct { diff --git a/pkg/tagging/tag_test.go b/pkg/tagging/tag_test.go new file mode 100644 index 0000000..ee1632d --- /dev/null +++ b/pkg/tagging/tag_test.go @@ -0,0 +1,82 @@ +// Copyright 2022 Paul Greenberg greenpau@outlook.com +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package tagging + +import ( + "testing" +) + +func TestNewTag(t *testing.T) { + // testFailed := 0 + // tests := []struct { + // addr string + // hname string + // hvalue string + // result string + // }{ + // { + // addr: "192.168.99.40:23467", + // result: "192.168.99.40", + // }, + // { + // addr: "192.168.99.40:23467", + // hname: "x-real-ip", + // hvalue: "10.10.10.10", + // result: "10.10.10.10", + // }, + // { + // addr: "192.168.99.40:23467", + // hname: "X-real-IP", + // hvalue: "10.10.10.10", + // result: "10.10.10.10", + // }, + // { + // addr: "192.168.99.40:23467", + // hname: "X-Forwarded-For", + // hvalue: "100.100.2.2, 192.168.0.10", + // result: "100.100.2.2", + // }, + // { + // addr: "192.168.99.40:23467", + // hname: "X-Forwarded-For", + // hvalue: "192.168.0.10", + // result: "192.168.0.10", + // }, + // } + // for i, test := range tests { + // r, err := http.NewRequest("GET", "127.0.0.1", nil) + // if err != nil { + // t.Fatalf("Failed creating HTTP request") + // } + // r.RemoteAddr = test.addr + // testDescr := fmt.Sprintf("Test %d, addr: %s, result: %s", i, test.addr, test.result) + // if test.hname != "" { + // testDescr += fmt.Sprintf(", header: %s, value, %s", test.hname, test.hvalue) + // r.Header.Add(test.hname, test.hvalue) + // } + + // addr := GetSourceAddress(r) + // if addr != test.result { + // t.Logf("FAIL: %s, received: %s", testDescr, addr) + // testFailed++ + // continue + // } + // t.Logf("PASS: %s", testDescr) + // } + + // if testFailed > 0 { + // t.Fatalf("Failed %d tests", testFailed) + // } +}