package device import ( "encoding/json" "fmt" "io" "net/http" "strings" "time" keyfunc "github.com/MicahParks/keyfunc/v2" "github.com/gin-gonic/gin" "github.com/golang-jwt/jwt/v5" "github.com/google/uuid" "github.com/gorilla/sessions" "golang.org/x/crypto/bcrypt" "edge-infra.dev/pkg/edge/iam/apperror" "edge-infra.dev/pkg/edge/iam/config" "edge-infra.dev/pkg/edge/iam/log" "edge-infra.dev/pkg/edge/iam/profile" "edge-infra.dev/pkg/edge/iam/prometheus" "edge-infra.dev/pkg/edge/iam/util" ) // todo @alain 4: probably not but, still could rename these // it can show us when a env switched no? var ( reqPIN = "pin" signInPin = "sign_in_pin" signUpPin = "sign_up_pin" ) // todo: implement the policies type loginForm struct { Username string `form:"username"` Password string `form:"password"` Reason string `form:"reason"` } type AuthMethod struct { service *CloudService storage Storage profileStorage profile.Storage sessionStore sessions.Store metrics *prometheus.Metrics } func NewAuthMethod( router *gin.Engine, service *CloudService, sessionStore sessions.Store, storage interface{}, metrics *prometheus.Metrics, ) *AuthMethod { am := &AuthMethod{ storage: storage.(Storage), profileStorage: storage.(profile.Storage), sessionStore: sessionStore, metrics: metrics, service: service, } router.POST("/idp/set/device", util.MakeHandlerFunc(am.selfService)) router.POST("/idp/login/device", util.MakeHandlerFunc(am.login)) if !config.IsProduction() { router.Any("/disrupt/device", util.MakeHandlerFunc(am.setDisrupt)) } return am } func (am *AuthMethod) login(c *gin.Context) error { am.metrics.IncHTTPRequestsTotal(reqPIN) logger := log.Get(c.Request.Context()).WithName("device-login") // grab the form fields // todo: make sure all is sanitized var form loginForm if err := c.ShouldBind(&form); err != nil { am.metrics.IncSignInRequestsTotal(signInPin, util.Failed) return apperror.NewAbortError( fmt.Errorf("failed to bind login based on method and content-type: %w", err), http.StatusBadRequest) } // try to log them in with NCR ID device up in the cloud tokenSet, err := am.service.Login(form.Username, form.Password) // if they got denied access, they should try again if err != nil && err == ErrDeviceLoginDenied { return apperror.NewStatusError(err, http.StatusUnauthorized) } // if they are forbidden, they are done for and dont have to try again if err != nil && err == ErrDeviceLoginForbidden { return apperror.NewStatusError(err, http.StatusForbidden) } var accessClaims, idClaims map[string]interface{} if len(tokenSet.AccessToken) > 0 { var validationErr error accessClaims, validationErr = am.ValidateToken(tokenSet.AccessToken) if validationErr != nil { return apperror.NewStatusError(fmt.Errorf("invalid access token from site security: %w", validationErr), http.StatusInternalServerError) } } if len(tokenSet.IDToken) > 0 { var validationErr error idClaims, validationErr = am.ValidateToken(tokenSet.IDToken) if validationErr != nil { return apperror.NewStatusError(fmt.Errorf("invalid id token from site security: %w", validationErr), http.StatusInternalServerError) } } // in case of any other error, hand this request of for retry // locally against our accounts database if err != nil { logger.Error(err, "falling back to local authentication for device login") return am.localLogin(c, form.Username, form.Password, form.Reason) } // great, we are in - almost, if we get through the warnings session, _ := am.sessionStore.Get(c.Request, "oauth2") session.Values["device_token"] = tokenSet.AccessToken if err = session.Save(c.Request, c.Writer); err != nil { return apperror.NewAbortError( fmt.Errorf("failed to save cookie session: %w", err), http.StatusInternalServerError) } // check if there are any warning to resolve before providing access if tokenSet.Warnings.PasswordExpired { return apperror.NewJSONError(err, http.StatusUnauthorized, "expired device password", gin.H{"error": "expired_device_password"}, ) } if tokenSet.Warnings.PasswordMustChange { return apperror.NewJSONError(err, http.StatusUnauthorized, "device password must change", gin.H{"error": "device_password_must_change"}, ) } alias, _ := util.RandomStringGenerator(8) existingProfile, err := am.profileStorage.GetIdentityProfile(c, accessClaims["sub"].(string)) if err != nil { logger.Error(err, "error fetching identity profile, creating new alias for profile") } else { if existingProfile != nil && existingProfile.Alias != "" { alias = existingProfile.Alias } } // todo: build up the profile from the ID token // idClaims := idtoken.Claims.(jwt.MapClaims) userProfile := profile.Profile{ Subject: accessClaims["sub"].(string), Organization: accessClaims["org"].(string), Roles: accessClaims["rls"].(string), GivenName: idClaims["given_name"].(string), FamilyName: idClaims["family_name"].(string), FullName: idClaims["name"].(string), Email: idClaims["email"].(string), DeviceLogin: form.Username, Alias: alias, } // reset IDToken in result, so its not written to database, all other required information will be captured into profile. tokenSet.IDToken = "" // use the idClaims in memory to populate the profile. setAgeInProfile(idClaims, &userProfile, time.Now, config.TimeZone()) // add user's address if exists if addressMap, exists := idClaims["address"]; exists { addressJSON, _ := json.Marshal(addressMap) addressClaim := profile.AddressClaim{} err := json.Unmarshal(addressJSON, &addressClaim) if err == nil { userProfile.Address = &addressClaim } } // save the profile if err = am.profileStorage.CreateIdentityProfile(c, userProfile); err != nil && !am.profileStorage.IsOffline() { return apperror.NewAbortError( fmt.Errorf("failed to store the identity: %w", err), http.StatusInternalServerError) } // save the device account hash, err := bcrypt.GenerateFromPassword([]byte(form.Password), config.BcryptCost()) if err != nil { return apperror.NewAbortError(fmt.Errorf("failed to hash password: %w", err), http.StatusInternalServerError) } account := Account{ TokenSet: tokenSet, Username: strings.ToLower(form.Username), Subject: userProfile.Subject, Hash: string(hash), LastUpdated: time.Now().Unix(), NumOfWrongAttempts: 0, } if err = am.storage.SaveDeviceAccount(c, account); err != nil && !am.profileStorage.IsOffline() { return apperror.NewAbortError( fmt.Errorf("failed to save device account: %w", err), http.StatusInternalServerError) } if err = am.profileStorage.CreateAlias(c, alias, accessClaims["sub"].(string)); err != nil { // we are allowing to sign in the user without being able to save the profile in the database. // this mean, that any updates to existing profile which we fetched as part of this login aren't persisted. // and the user needs to sign in again when we are online with database to update their local profile. logger.Error(err, "failed to create alias") } session.Values["method"] = "device" session.Values["reason"] = form.Reason continuation := uuid.New().String() session.Values["continuation"] = continuation am.setProfileOnSession(session, &userProfile) if err = session.Save(c.Request, c.Writer); err != nil { return apperror.NewAbortError( fmt.Errorf("failed to save cookie session: %w", err), http.StatusInternalServerError) } return util.WriteJSON(c.Writer, http.StatusOK, gin.H{ "challenge": continuation, }) } func setAgeInProfile(idClaims map[string]interface{}, userProfile *profile.Profile, nowFunc func() time.Time, timezone string) { // dob is currently in use by NCR-ID, while birthdate is standard claim, so is this check to ensure future compatibility when NCR-ID aligns to spec. _, dobExists := idClaims["dob"] _, birthDateExists := idClaims["birthdate"] if dobExists { // if dobExists for user, add to profile. if age, err := util.CalculateAge(idClaims["dob"].(string), nowFunc, timezone); err == nil { userProfile.Age = age } } else if birthDateExists { // if birthDateExists for user, add to profile. if age, err := util.CalculateAge(idClaims["birthdate"].(string), nowFunc, timezone); err == nil { userProfile.Age = age } } } func (am *AuthMethod) jwks() ([]byte, error) { client := &http.Client{} req, err := http.NewRequest("GET", config.DeviceBaseURL()+"/jwks", nil) if err != nil { return nil, err } res, err := client.Do(req) if err != nil { return nil, err } defer res.Body.Close() body, err := io.ReadAll(res.Body) if err != nil { return nil, err } am.service.jwks = body var decodedJWKS map[string]interface{} err = json.Unmarshal(am.service.jwks, &decodedJWKS) if err != nil { return nil, fmt.Errorf("invalid jwks") } am.service.keyIDs = nil keys := decodedJWKS["keys"].([]interface{}) for i := 0; i < len(keys); i++ { key := keys[i].(map[string]interface{}) keyID := key["kid"].(string) am.service.keyIDs = append(am.service.keyIDs, keyID) } return body, nil } func (am *AuthMethod) ValidateToken(token string) (map[string]interface{}, error) { // check if the keyID from incoming token header matches any of the ones in cached jwks. decodedToken, _, err := new(jwt.Parser).ParseUnverified(token, jwt.MapClaims{}) if err != nil { return nil, err } header := decodedToken.Header // check for kid in token header _, ok := header["kid"] if !ok { return nil, jwt.ErrTokenUnverifiable } // check if kid is a string keyIDInHeader, ok := header["kid"].(string) if !ok { return nil, jwt.ErrTokenUnverifiable } if !util.IsElementExist(am.service.keyIDs, keyIDInHeader) { // if not cached, make a call to Edge IAM to fetch latest jwks. am.service.jwks, err = am.jwks() if err != nil { return nil, err } } // Parse and verify the token signature before passing the claims back. jwk, err := keyfunc.NewJSON(am.service.jwks) if err != nil { return nil, err } res, err := jwt.Parse(token, jwk.Keyfunc, jwt.WithLeeway(config.GetLeeWayForDeviceTokenValidation())) if err != nil { return nil, err } // Read claims and return them. claims, ok := res.Claims.(jwt.MapClaims) if !ok { // verification successful, but invalid claims return nil, jwt.ErrTokenInvalidClaims } return claims, nil } func (*AuthMethod) setProfileOnSession(session *sessions.Session, userProfile *profile.Profile) { session.Values["alias"] = userProfile.Alias session.Values["sub"] = userProfile.Subject session.Values["org"] = userProfile.Organization session.Values["rls"] = userProfile.Roles session.Values["gn"] = userProfile.GivenName session.Values["fn"] = userProfile.FamilyName session.Values["n"] = userProfile.FullName session.Values["age"] = userProfile.Age session.Values["device_login"] = userProfile.DeviceLogin session.Values["email"] = userProfile.Email if userProfile.Address != nil { addressClaimJSON, err := json.Marshal(userProfile.Address) if err == nil { session.Values["address"] = string(addressClaimJSON) } } }