package services import ( "context" "database/sql" "errors" "fmt" "net/http" "strconv" "strings" "time" "edge-infra.dev/pkg/edge/api/apierror" "edge-infra.dev/pkg/edge/api/bsl/types" "edge-infra.dev/pkg/edge/api/graph/mapper" "edge-infra.dev/pkg/edge/api/graph/model" "edge-infra.dev/pkg/edge/api/middleware" edgetypes "edge-infra.dev/pkg/edge/api/types" "edge-infra.dev/pkg/edge/api/utils" "edge-infra.dev/pkg/edge/bsl" "edge-infra.dev/pkg/edge/okta" ) const ( bspUsersPath = "/provisioning/users" bspGetUsersPath = "/provisioning/users?pageNumber=%d&pageSize=%d&userType=PRIMARY" bspUserProfilePath = "/provisioning/user-profiles" resetPasswordURLPath = "/provisioning/users/reset-password" ResetPasswordWithTokenURLPath = "/provisioning/user-profiles/reset-password" exchangeTokenURLPath = "/security/security-tokens/exchange" bslGetUserPath = "/provisioning/users/%s" oktaIntrospectionPath = "/v1/introspect?client_id=%s" oktaExchangePath = "/security/authentication/okta-exchange" oktaTokenRefreshPath = "/v1/token" oktaUserInfoPath = "/v1/userinfo" effectiveRoles = "/provisioning/role-grants/user-grants/self/effective-roles" ) //go:generate mockgen -destination=../mocks/mock_user_management_service.go -package=mocks edge-infra.dev/pkg/edge/api/services UserManagementService type UserManagementService interface { Login(ctx context.Context, username, password, organization string) (*model.AuthPayload, error) Register(ctx context.Context, firstName, lastName, username, email, password, organization string) (string, error) TokenExchange(ctx context.Context, organization string, user *types.AuthUser, provider string) (string, error) GetUserProfile(ctx context.Context, token string) (*model.User, error) GetUsers(ctx context.Context) ([]*model.User, error) GetUsersForOrgBanner(ctx context.Context, bannerName string) ([]*model.User, error) Delete(ctx context.Context, username string) error ResetPassword(ctx context.Context, username, newPassword, organization string) error UpdateUserPasswordWithToken(ctx context.Context, token, organization, newPassword string) error WhoAmI(ctx context.Context, username, organization, token, provider string) (*model.User, error) UpdateUserProfile(ctx context.Context, user *model.UpdateUser) (*model.User, error) GetUser(ctx context.Context, username, organization, token, provider string) (*model.User, error) VerifyOktaToken(ctx context.Context, token string) (*okta.IntrospectionResponse, error) LoginWithOktaToken(ctx context.Context, token, refreshToken, organization string) (*model.OktaAuthPayload, error) GetSessionUserEdgeRoles(ctx context.Context, username, token, organization, authProvider string) ([]string, error) UserData(ctx context.Context, username string) (*model.UserData, error) } type userManagementService struct { Config edgetypes.Config BSLClient *bsl.Client OktaClient *okta.Client RoleService RoleService BannerService BannerService } func (u *userManagementService) Login(ctx context.Context, username, password, organization string) (*model.AuthPayload, error) { client, secTokenData, err := u.BSLClient.WithAuthentication(ctx, organization, username, password) if err != nil { return nil, err } userProfile, err := u.GetUserProfile(ctx, secTokenData.Token) if err != nil { return nil, err } edgeRoles := make([]string, 0) if !secTokenData.CredentialsExpired && utils.Contains(secTokenData.Authorities, "NEP_IDENTITY_VIEWER") { //add edge groups to roles groups, err := GetGroupsForUser(ctx, client, username) if err != nil { return nil, err } edgeRoles = append(edgeRoles, groups...) } token, err := middleware.CreateToken(username, utils.CheckString(userProfile.Email), secTokenData.OrganizationName, u.Config.App.AppSecret, edgeRoles, secTokenData.Token, "bsl", "") if err != nil { return nil, err } sessionTime, err := time.ParseDuration(fmt.Sprintf("%ds", secTokenData.MaxSessionTime)) if err != nil { return nil, err } return &model.AuthPayload{ Token: token, FirstName: &userProfile.GivenName, FullName: userProfile.FullName, Roles: edgeRoles, CredentialsExpired: secTokenData.CredentialsExpired, SessionTime: sessionTime.Minutes(), Organization: secTokenData.OrganizationName, }, nil } func (u *userManagementService) Register(ctx context.Context, firstName, lastName, username, email, password, organization string) (string, error) { client, err := u.BSLClient.WithBackendOrgAccessKey(ctx, organization) if err != nil { return "", err } User := &model.User{} genericErr := client. SetPayload(mapper.ToCreateUserRequest(firstName, lastName, username, email, password, "ACTIVE")). JSON("Post", bspUsersPath, User) if genericErr != nil { return "", genericErr } return User.Username, nil } func (u *userManagementService) TokenExchange(ctx context.Context, organization string, user *types.AuthUser, provider string) (string, error) { var ( err error bslResponse = &model.AuthPayload{} oktaResponse = &okta.RefreshResponse{} responseToken string ) switch provider { case model.AuthProviderBsl.String(): err = u.BSLClient.WithAccessToken(ctx, user.Token). SetOrg(organization). SetPayload(map[string]string{"token": user.Token}). JSON(http.MethodPost, exchangeTokenURLPath, bslResponse) if err != nil { return "", err } responseToken = bslResponse.Token case model.AuthProviderOkta.String(): err = u.OktaClient.WithFormData(ctx, map[string]string{ "grant_type": "refresh_token", "scope": "offline_access openid", "refresh_token": user.RefreshToken, "client_id": u.Config.Okta.ClientID, }).Post(oktaTokenRefreshPath, oktaResponse) if err != nil { return "", err } responseToken = oktaResponse.AccessToken default: return "", errors.New("unsupported auth provider") } token, err := middleware.CreateToken(user.Username, user.Email, user.Organization, u.Config.App.AppSecret, user.Roles, responseToken, user.AuthProvider, oktaResponse.RefreshToken) if err != nil { return "", err } return token, nil } func (u *userManagementService) GetUserProfile(ctx context.Context, token string) (*model.User, error) { akd := &model.User{} err := u.BSLClient.WithAccessToken(ctx, token).JSON("Get", bspUserProfilePath, akd) if err != nil { return nil, err } return akd, nil } func (u *userManagementService) GetUsers(ctx context.Context) ([]*model.User, error) { client := u.BSLClient.WithUserTokenCredentials(ctx) data := &types.FindUsersResponse{} users := make([]*model.User, 0) for pageNumber := 0; !data.LastPage; pageNumber++ { if err := client.JSON("Get", fmt.Sprintf(bspGetUsersPath, pageNumber, pageSize), data); err != nil { if errors.Is(err, sql.ErrNoRows) { continue } return nil, err } for _, user := range data.PageContent { if strings.HasSuffix(user.Username, fmt.Sprintf("-%s", edgetypes.BFFUsername)) { continue } userEmail := utils.ConvertToString(user.Email) users = append(users, &model.User{ Username: user.Username, FamilyName: user.FamilyName, FullName: user.FullName, Status: user.Status, Email: &userEmail, }) } } return users, nil } func (u *userManagementService) GetUsersForOrgBanner(ctx context.Context, bannerName string) ([]*model.User, error) { user := middleware.ForContext(ctx) subOrg := user.Organization + bannerName client := u.BSLClient.WithUserTokenCredentials(ctx) client.SetQueryParam("userType", "EXTERNAL") client.SetOrg(subOrg) data := &types.FindUsersResponse{} userNames := make([]types.UserName, 0) pageNumber := 0 for !data.LastPage { client.SetQueryParams(map[string]string{ "pageNumber": strconv.Itoa(pageNumber), "pageSize": strconv.Itoa(pageSize), }) if err := client.JSON(http.MethodGet, bspUsersPath, data); err != nil { return nil, err } for _, d := range data.PageContent { userNames = append(userNames, types.UserName{Username: d.Username}) // fullName still in the acct: + org_name + @ + qdlid here } pageNumber++ } client = u.BSLClient.WithUserTokenCredentials(ctx) payload := types.GetUserDetailsRequest{UserIDs: userNames} res := &types.GetUserDetailsResponse{} err := client.SetPayload(payload).JSON(http.MethodPost, bspUserDetailsPath, res) if err != nil { return nil, err } // Filtering out any FullName that is also a service account filteredRes := make([]*model.User, 0) // Appending list of user without `acct:` pattern to a new filtered list for _, user := range res.Users { if !strings.HasPrefix(user.FullName, "acct:") { filteredRes = append(filteredRes, user) } } return filteredRes, nil } func (u *userManagementService) Delete(ctx context.Context, username string) error { return u.BSLClient.WithUserTokenCredentials(ctx).Delete(fmt.Sprintf("%s/%s", bspUsersPath, username)) } func (u *userManagementService) ResetPassword(ctx context.Context, username, newPassword, organization string) error { client, err := u.BSLClient.WithBackendOrgAccessKey(ctx, organization) if err != nil { return apierror.Wrap(err).AddGenericErrorExtension("verbose", err) } resetRequest := types.ResetPasswordRequest{ Username: bsl.CreateFullAccountName(&types.AuthUser{Organization: organization, Username: username}), Password: newPassword, } return client.SetPayload(resetRequest).Put(resetPasswordURLPath) } // WhoAmI todo figure this one out func (u userManagementService) WhoAmI(ctx context.Context, username, organization, token, provider string) (*model.User, error) { return u.GetUser(ctx, username, organization, token, provider) } // UpdateUserPasswordWithToken Updates a users password in bsl using a one time use token func (u userManagementService) UpdateUserPasswordWithToken(ctx context.Context, token, organization, newPassword string) error { req := types.ResetPasswordWithTokenRequest{ NewPassword: newPassword, } return u.BSLClient.WithAccessToken(ctx, token).SetPayload(req).SetOrg(organization).Put(ResetPasswordWithTokenURLPath) } func (u *userManagementService) UpdateUserProfile(ctx context.Context, input *model.UpdateUser) (*model.User, error) { user := &model.User{} return user, u.BSLClient.WithUserTokenCredentials(ctx).SetPayload(input).JSON("Put", bspUsersPath, user) } func (u *userManagementService) GetUser(ctx context.Context, username, organization, token, provider string) (*model.User, error) { fullyQualifiedUsername := fmt.Sprintf("acct:%s@%s", organization, username) if provider != "bsl" { response, err := u.getOktaUserInfo(ctx, token) if err != nil { return nil, err } fullyQualifiedUsername = fmt.Sprintf("acct:commerce@%s-%s", username, response.PreferredUsername) } user := &model.User{ UserData: &model.UserData{}, } if err := u.BSLClient.WithUserTokenCredentials(ctx).JSON(http.MethodGet, fmt.Sprintf(bslGetUserPath, fullyQualifiedUsername), user); err != nil { return nil, err } return user, nil } func (u *userManagementService) UserData(ctx context.Context, username string) (*model.UserData, error) { userData := &model.UserData{} roles, err := u.RoleService.GetEdgeGroupsForUserUser(ctx, username) if err != nil && !errors.Is(err, sql.ErrNoRows) { return userData, err } userData.Roles = roles banners, err := u.BannerService.GetUserAssignedBanners(ctx, username) if err != nil && !errors.Is(err, sql.ErrNoRows) { return userData, err } userData.AssignedBanners = banners return userData, nil } // VerifyOktaToken accepts an okta access token and verifies that it's valid and active func (u *userManagementService) VerifyOktaToken(ctx context.Context, token string) (*okta.IntrospectionResponse, error) { response := &okta.IntrospectionResponse{} err := u.OktaClient.WithFormData(ctx, map[string]string{ "token": token, "token_type_hint": "access_token", }).Post(fmt.Sprintf(oktaIntrospectionPath, u.Config.Okta.ClientID), response) return response, err } func (u *userManagementService) LoginWithOktaToken(ctx context.Context, token, refreshToken, organization string) (*model.OktaAuthPayload, error) { oktaResponse, err := u.VerifyOktaToken(ctx, token) if err != nil { return nil, err } if !oktaResponse.Active { invalidToken := errors.New("invalid token") return nil, invalidToken } response, err := u.getOktaUserInfo(ctx, token) if err != nil { return nil, err } org := fmt.Sprintf("%s%s/", u.Config.BSP.Root, organization) fullyQualifiedUsername := fmt.Sprintf("acct:commerce@%s-%s", response.Sub, response.PreferredUsername) roles, err := u.GetOktaRoles(ctx, token, fullyQualifiedUsername, org) if err != nil { return nil, err } return &model.OktaAuthPayload{ Token: token, RefreshToken: refreshToken, FirstName: &response.GivenName, LastName: &response.FamilyName, FullName: response.Name, Username: response.Sub, Email: response.Email, Valid: oktaResponse.Active, SessionTime: float64(oktaResponse.Expires), Organization: org, Roles: roles, }, nil } func (u *userManagementService) GetOktaRoles(ctx context.Context, token, username, org string) ([]string, error) { resp := &types.EffectiveRolesResponse{} roles := make([]string, 0) client := u.BSLClient.WithOktaToken(ctx, token).SetOrg(org) err := client.JSON(http.MethodGet, effectiveRoles, resp) tempRoles := make(map[string]bool) for _, effectiveRole := range resp.Content { tempRoles[effectiveRole.RoleName] = true } if _, exists := tempRoles["NEP_IDENTITY_VIEWER"]; exists { groups, err := GetGroupsForUser(ctx, client, username) if err != nil { return nil, err } roles = append(roles, groups...) } return roles, err } func (u *userManagementService) GetSessionUserEdgeRoles(ctx context.Context, username, token, organization, authProvider string) ([]string, error) { fullyQualifiedUsername := username if authProvider != "bsl" { response, err := u.getOktaUserInfo(ctx, token) if err != nil { return nil, err } fullyQualifiedUsername = fmt.Sprintf("acct:commerce@%s-%s", username, response.PreferredUsername) } roles, err := u.GetOktaRoles(ctx, token, fullyQualifiedUsername, organization) if err != nil { return nil, err } return roles, err } func (u *userManagementService) getOktaUserInfo(ctx context.Context, token string) (*okta.UserInfo, error) { response := &okta.UserInfo{} err := u.OktaClient.WithNoData(ctx).WithAccessToken(token).Get(oktaUserInfoPath, response) if err != nil { return nil, err } return response, nil } func NewUserManagementService(config edgetypes.Config, client *bsl.Client, oktaClient *okta.Client, roleService RoleService, bannerService BannerService) UserManagementService { return &userManagementService{ Config: config, BSLClient: client, OktaClient: oktaClient, RoleService: roleService, BannerService: bannerService, } }