Skip to content

Commit

Permalink
feat: add handshake process between Brightspace and Unlocked - Reside…
Browse files Browse the repository at this point in the history
…nt-to-Student Mapping #522

* feat: add handshake process between Brightspace and Unlocked for secure data exchange and integration, related to #522 also allow usernames to contain numbers and last-first names to contain spaces

* fix: add test client id and test client secret

* feat: add environment variable for brightspace temporary CSV download directory path

* feat: add alt name to course

* fix: remove forward slash from api call
  • Loading branch information
carddev81 authored Dec 5, 2024
1 parent a81ab48 commit b275dae
Show file tree
Hide file tree
Showing 21 changed files with 581 additions and 143 deletions.
1 change: 1 addition & 0 deletions backend/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ require (
go.uber.org/multierr v1.11.0 // indirect
golang.org/x/crypto v0.26.0 // indirect
golang.org/x/net v0.27.0 // indirect
golang.org/x/oauth2 v0.24.0 // indirect
golang.org/x/sync v0.8.0 // indirect
golang.org/x/sys v0.25.0 // indirect
golang.org/x/text v0.18.0 // indirect
Expand Down
2 changes: 2 additions & 0 deletions backend/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,8 @@ golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8 h1:yixxcjnhBmY0nkL253HFVIm0J
golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8/go.mod h1:jj3sYF3dwk5D+ghuXyeI3r5MFf+NT2An6/9dOA95KSI=
golang.org/x/net v0.27.0 h1:5K3Njcw06/l2y9vpGCSdcxWOYHOUk3dVNGDXN+FvAys=
golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE=
golang.org/x/oauth2 v0.24.0 h1:KTBBxWqUa0ykRPLtV69rRto9TLXcqYkeswu48x/gvNE=
golang.org/x/oauth2 v0.24.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ=
golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
Expand Down
21 changes: 16 additions & 5 deletions backend/seeder/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,14 @@ func seedTestData(db *gorm.DB) {
Type: models.Kolibri,
State: models.Enabled,
AccessKey: "testing_key_replace_me",
},
{
Name: "Brightspace",
BaseUrl: "https://unlocked.brightspacedemo.com",
AccountID: "testing_client_id_replace_me", //clientID
Type: models.Brightspace,
State: models.Disabled,
AccessKey: "testing_client_secret_replace_me", //ClientSecret;refresh-token
}}
for idx := range platforms {
if err := db.Create(&platforms[idx]).Error; err != nil {
Expand Down Expand Up @@ -146,12 +154,15 @@ func seedTestData(db *gorm.DB) {
if err := testServer.HandleCreateUserKratos(users[idx].Username, "ChangeMe!"); err != nil {
log.Fatalf("unable to create test user in kratos")
}
var mapping models.ProviderUserMapping
for i := 0; i < len(platforms); i++ {
mapping := models.ProviderUserMapping{
UserID: users[idx].ID,
ProviderPlatformID: platforms[i].ID,
ExternalUsername: users[idx].Username,
ExternalUserID: strconv.Itoa(idx),
if platforms[i].Type != models.Brightspace { //omitting brightspace here, we don't want bad users in the seeded data...we want real users
mapping = models.ProviderUserMapping{
UserID: users[idx].ID,
ProviderPlatformID: platforms[i].ID,
ExternalUsername: users[idx].Username,
ExternalUserID: strconv.Itoa(idx),
}
}
if err = db.Create(&mapping).Error; err != nil {
log.Printf("Failed to create provider user mapping: %v", err)
Expand Down
158 changes: 155 additions & 3 deletions backend/src/handlers/provider_platform_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,16 @@ package handlers

import (
"UnlockEdv2/src/models"
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"slices"
"strconv"
"strings"

"golang.org/x/oauth2"
)

func (srv *Server) registerProviderPlatformRoutes() []routeDef {
Expand All @@ -14,6 +20,8 @@ func (srv *Server) registerProviderPlatformRoutes() []routeDef {
{"GET /api/provider-platforms", srv.handleIndexProviders, true, axx},
{"GET /api/provider-platforms/{id}", srv.handleShowProvider, true, axx},
{"POST /api/provider-platforms", srv.handleCreateProvider, true, axx},
{"GET /api/provider-platforms/callback", srv.handleOAuthProviderCallback, true, axx},
{"GET /api/provider-platforms/{id}/refresh", srv.handleOAuthRefreshToken, true, axx},
{"PATCH /api/provider-platforms/{id}", srv.handleUpdateProvider, true, axx},
{"DELETE /api/provider-platforms/{id}", srv.handleDeleteProvider, true, axx},
}
Expand Down Expand Up @@ -72,30 +80,150 @@ func (srv *Server) handleCreateProvider(w http.ResponseWriter, r *http.Request,
return newJSONReqBodyServiceError(err)
}
defer r.Body.Close()
if platform.Type == models.Brightspace {
oauthURL, err := srv.getOAuthUrl(&platform)
if err != nil {
return err
}
return writeJsonResponse(w, http.StatusCreated, map[string]interface{}{
"platform": platform,
"oauth2Url": oauthURL,
})
}
err = srv.Db.CreateProviderPlatform(&platform)
if err != nil {
return newDatabaseServiceError(err)
}
return writeJsonResponse(w, http.StatusCreated, platform)
return writeJsonResponse(w, http.StatusCreated, map[string]interface{}{
"platform": platform,
})
}

// if errors occur within this handler they will be handled by passing the message and status via query parameters
// in the redirect url, therefore this method will only return nil as its error
func (srv *Server) handleOAuthProviderCallback(w http.ResponseWriter, r *http.Request, log sLog) error {
const (
successRedirectUrl = "/provider-platform-management?status=success&message=Provider platform %s successfully"
errorRedirectUrl = "/provider-platform-management?status=error&message=Failed to configure provider platform"
)
stateFromClient := r.FormValue("state")
if stateFromClient == "" { //state is an opaque value used by the client to maintain state between the request and callback
log.error("state value not found from oauth provider, unable to process request") //error
http.Redirect(w, r, errorRedirectUrl, http.StatusTemporaryRedirect)
return nil
}
oauthBucket := srv.buckets[OAuthState] //bucket will exist here
entry, err := oauthBucket.Get(stateFromClient)
if err != nil {
log.errorf("invalid oauth state value found from oauth provider and UnlockEd, state value found %s", stateFromClient)
http.Redirect(w, r, errorRedirectUrl, http.StatusTemporaryRedirect)
return nil
}
var provider models.ProviderPlatform
err = json.Unmarshal(entry.Value(), &provider)
if err != nil {
log.errorf("unable to unmarshal value from bucket, error is %s", err)
http.Redirect(w, r, errorRedirectUrl, http.StatusTemporaryRedirect)
return nil
}
log.info("Deleting provider platform from bucket")
if err := oauthBucket.Delete(stateFromClient); err != nil {
log.errorf("unable to delete entry in bucket, error is %s", err) //error
}
code := r.FormValue("code")
config := provider.GetOAuth2Config()
token, err := config.Exchange(context.Background(), code) // Exchange the authorization code for an access token
if err != nil {
log.errorf("unable to make oauth token exchange with provider platform, error is %s", err) //error
http.Redirect(w, r, errorRedirectUrl, http.StatusTemporaryRedirect)
return nil
}
//verfying that the BaseUrl used is correct and checking user role
apiURL := fmt.Sprintf("%s/d2l/api/lp/%s/dataExport/bds/list", provider.BaseUrl, models.BrightspaceApiVersion)
client := config.Client(context.Background(), token)
resp, err := client.Get(apiURL)
if err != nil || resp.StatusCode != http.StatusOK {
log.errorf("unable to execute api call, most likely due to provider BaseUrl %s not being correct, error is %s", provider.BaseUrl, err) //error
http.Redirect(w, r, errorRedirectUrl, http.StatusTemporaryRedirect)
return nil
}
defer resp.Body.Close()
provider.AccessKey = config.ClientSecret + ";" + token.RefreshToken
var action string
if provider.ID > 0 {
if _, err := srv.Db.UpdateProviderPlatform(&provider, provider.ID); err != nil {
log.errorf("unable to update provider platform, error is %s", err) //error
http.Redirect(w, r, errorRedirectUrl, http.StatusTemporaryRedirect)
return nil
}
action = "updated"
} else {
if err := srv.Db.CreateProviderPlatform(&provider); err != nil {
log.errorf("unable to save provider platform, error is %s", err) //error
http.Redirect(w, r, errorRedirectUrl, http.StatusTemporaryRedirect)
return nil
}
action = "created"
}
http.Redirect(w, r, fmt.Sprintf(successRedirectUrl, action), http.StatusTemporaryRedirect)
return nil
}

func (srv *Server) handleOAuthRefreshToken(w http.ResponseWriter, r *http.Request, log sLog) error {
id, err := strconv.Atoi(r.PathValue("id"))
if err != nil {
return newInvalidIdServiceError(err, "provider platform ID")
}
log.add("provider_platform_id", id)
platform, err := srv.Db.GetProviderPlatformByID(id)
if err != nil {
return newDatabaseServiceError(err)
}
oauthURL, err := srv.getOAuthUrl(platform)
if err != nil {
return err
}
return writeJsonResponse(w, http.StatusOK, map[string]interface{}{
"oauth2Url": oauthURL,
})
}

func (srv *Server) handleUpdateProvider(w http.ResponseWriter, r *http.Request, log sLog) error {
id, err := strconv.Atoi(r.PathValue("id"))
if err != nil {
return newInvalidIdServiceError(err, "provider platform ID")
}
log.add("providerPlatformId", id)
log.add("provider_platform_id", id)
var platform models.ProviderPlatform
err = json.NewDecoder(r.Body).Decode(&platform)
if err != nil {
return newJSONReqBodyServiceError(err)
}
defer r.Body.Close()
if platform.BaseUrl != "" || platform.AccessKey != "" || platform.AccountID != "" || (platform.State != "" && platform.State == models.Enabled) {
existingPlatform, err := srv.Db.GetProviderPlatformByID(id)
if err != nil {
return newDatabaseServiceError(err)
}
if existingPlatform.Type == models.Brightspace && (platform.State == models.Enabled || existingPlatform.State == models.Enabled) {
models.UpdateStruct(&existingPlatform, &platform)
oauthURL, err := srv.getOAuthUrl(existingPlatform)
if err != nil {
return err
}
return writeJsonResponse(w, http.StatusOK, map[string]interface{}{
"platform": existingPlatform,
"oauth2Url": oauthURL,
})
}
}
updated, err := srv.Db.UpdateProviderPlatform(&platform, uint(id))
if err != nil {
return newDatabaseServiceError(err)
}
return writeJsonResponse(w, http.StatusOK, *updated)
return writeJsonResponse(w, http.StatusOK, map[string]interface{}{
"platform": *updated,
})
}

func (srv *Server) handleDeleteProvider(w http.ResponseWriter, r *http.Request, log sLog) error {
Expand All @@ -109,3 +237,27 @@ func (srv *Server) handleDeleteProvider(w http.ResponseWriter, r *http.Request,
}
return writeJsonResponse(w, http.StatusNoContent, "Provider platform deleted successfully")
}

func (srv *Server) getOAuthUrl(platform *models.ProviderPlatform) (string, error) {
var (
brightspaceConfig = platform.GetOAuth2Config()
oauthState = models.CreateOAuth2UrlState()
oauthURL string
)
if !strings.HasPrefix(brightspaceConfig.RedirectURL, "https") {
return oauthURL, newInternalServerServiceError(errors.New("web server does not use proper scheme https"), "Server not configured to use https scheme, unable to process oauth2 flow.")
}
if srv.buckets == nil {
return oauthURL, newInternalServerServiceError(errors.New("server not configured with NATS KeyValue buckets"), "Server not configured with buckets, unable to process oauth2 flow.")
}
oauthBucket := srv.buckets[OAuthState]
platformBytes, err := json.Marshal(platform) //temporarily put provider platform in bucket to process in callback.
if err != nil {
return oauthURL, newInternalServerServiceError(err, "unable to marshal platform")
}
if _, err := oauthBucket.Put(oauthState, platformBytes); err != nil {
return oauthURL, newInternalServerServiceError(err, "could not add platform to bucket")
}
oauthURL = brightspaceConfig.AuthCodeURL(oauthState, oauth2.AccessTypeOffline)
return oauthURL, nil
}
40 changes: 29 additions & 11 deletions backend/src/handlers/provider_platform_handler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -109,11 +109,20 @@ func TestHandleCreateProvider(t *testing.T) {
rr := executeRequest(t, req, handler, test)
if test.expectedStatusCode == http.StatusCreated {
received := rr.Body.String()
data := models.Resource[models.ProviderPlatform]{}
if err := json.Unmarshal([]byte(received), &data); err != nil {
t.Errorf("failed to unmarshal resource, error is %v", err)
resource := models.Resource[map[string]interface{}]{}
if err := json.Unmarshal([]byte(received), &resource); err != nil {
t.Errorf("unable to unmarshal resource, error is %v", err)
}
jsonStr, err := json.Marshal(resource.Data["platform"])
if err != nil {
t.Error("unable to marshal response provider platform, error is ", err)
}
var responsePlatform models.ProviderPlatform
err = json.Unmarshal(jsonStr, &responsePlatform)
if err != nil {
t.Error("unable to unmarshal provider platform, error is ", err)
}
providerPlatform, err := server.Db.GetProviderPlatformByID(int(data.Data.ID))
providerPlatform, err := server.Db.GetProviderPlatformByID(int(responsePlatform.ID))
if err != nil {
t.Fatalf("unable to get provider platform from db, error is %v", err)
}
Expand All @@ -123,7 +132,7 @@ func TestHandleCreateProvider(t *testing.T) {
fmt.Println(err)
}
})
if diff := cmp.Diff(providerPlatform, &data.Data, cmpopts.IgnoreFields(models.ProviderPlatform{}, "AccessKey")); diff != "" {
if diff := cmp.Diff(providerPlatform, &responsePlatform, cmpopts.IgnoreFields(models.ProviderPlatform{}, "AccessKey")); diff != "" {
t.Errorf("handler returned unexpected results: %v", diff)
}
}
Expand All @@ -133,8 +142,8 @@ func TestHandleCreateProvider(t *testing.T) {

func TestHandleUpdateProvider(t *testing.T) {
httpTests := []httpTest{
{"TestAdminCanUpdateFacility", "admin", getProviderPlatform(), http.StatusOK, ""},
{"TestUserCannotUpdateFacility", "student", nil, http.StatusUnauthorized, ""},
{"TestAdminCanUpdateProvider", "admin", getProviderPlatform(), http.StatusOK, ""},
{"TestUserCannotUpdateProvider", "student", nil, http.StatusUnauthorized, ""},
}
for _, test := range httpTests {
t.Run(test.testName, func(t *testing.T) {
Expand Down Expand Up @@ -171,11 +180,20 @@ func TestHandleUpdateProvider(t *testing.T) {
t.Fatalf("unable to get provider platform from db, error is %v", err)
}
received := rr.Body.String()
data := models.Resource[models.ProviderPlatform]{}
if err := json.Unmarshal([]byte(received), &data); err != nil {
t.Errorf("failed to unmarshal resource, error is %v", err)
resource := models.Resource[map[string]interface{}]{}
if err := json.Unmarshal([]byte(received), &resource); err != nil {
t.Errorf("unable to unmarshal resource, error is %v", err)
}
jsonStr, err := json.Marshal(resource.Data["platform"])
if err != nil {
t.Error("unable to marshal response provider platform, error is ", err)
}
var responsePlatform models.ProviderPlatform
err = json.Unmarshal(jsonStr, &responsePlatform)
if err != nil {
t.Error("unable to unmarshal provider platform, error is ", err)
}
if diff := cmp.Diff(providerPlatform, &data.Data, cmpopts.IgnoreFields(models.ProviderPlatform{}, "AccessKey")); diff != "" {
if diff := cmp.Diff(providerPlatform, &responsePlatform, cmpopts.IgnoreFields(models.ProviderPlatform{}, "AccessKey")); diff != "" {
t.Errorf("handler returned unexpected results: %v", diff)
}
}
Expand Down
22 changes: 15 additions & 7 deletions backend/src/handlers/provider_user_management.go
Original file line number Diff line number Diff line change
Expand Up @@ -74,12 +74,12 @@ type ImportUserResponse struct {
Error string `json:"error"`
}

func stripNonAlphaChars(str string) string {
func stripNonAlphaChars(str string, keepCharacter func(char rune) bool) string {
return strings.Map(func(r rune) rune {
if !unicode.IsLetter(r) {
return -1
if keepCharacter(r) {
return r
}
return r
return -1
}, str)
}

Expand Down Expand Up @@ -108,12 +108,20 @@ func (srv *Server) handleImportProviderUsers(w http.ResponseWriter, r *http.Requ
return newJSONReqBodyServiceError(err)
}
toReturn := make([]ImportUserResponse, 0)
var (
isLetterOrNumber = func(char rune) bool {
return unicode.IsLetter(char) || unicode.IsDigit(char)
}
isLetterOrSpace = func(char rune) bool {
return unicode.IsLetter(char) || unicode.IsSpace(char)
}
)
for _, user := range users.Users {
newUser := models.User{
Username: stripNonAlphaChars(user.Username),
Username: stripNonAlphaChars(user.Username, isLetterOrNumber),
Email: user.Email,
NameFirst: stripNonAlphaChars(user.NameFirst),
NameLast: stripNonAlphaChars(user.NameLast),
NameFirst: stripNonAlphaChars(user.NameFirst, isLetterOrSpace),
NameLast: stripNonAlphaChars(user.NameLast, isLetterOrSpace),
FacilityID: facilityId,
Role: models.Student,
}
Expand Down
Loading

0 comments on commit b275dae

Please sign in to comment.