From f5406ac92479ab983e60faf959c73ae91e33538e Mon Sep 17 00:00:00 2001 From: Cameron Steele Date: Sat, 16 Sep 2023 14:11:00 -0500 Subject: [PATCH 1/8] Add moodle Secrets --- .env.example | 15 +++++++++++++++ conf/conf.go | 4 ++++ 2 files changed, 19 insertions(+) create mode 100644 .env.example diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..5341761 --- /dev/null +++ b/.env.example @@ -0,0 +1,15 @@ +KEYCLOAK_URL= +KEYCLOAK_USER= +KEYCLOAK_PASSWORD= +KEYCLOAK_REALM= +KEYCLOAK_CLIENT_ID= +KEYCLOAK_CLIENT_SECRET= +KEYCLOAK_MEMBERS_GROUP_ID= + +SELF_URL=http://localhost:8080 + +TESTUSERID= + +MOODLE_URL= +MOODLE_WS_TOKEN= +MOODLE_SECRET= diff --git a/conf/conf.go b/conf/conf.go index 9142be6..da2496b 100644 --- a/conf/conf.go +++ b/conf/conf.go @@ -11,4 +11,8 @@ type Env struct { SelfURL string `split_words:"true" required:"true"` StripeKey string `split_words:"true"` StripeWebhookKey string `split_words:"true"` + + MoodleURL string `split_words:"true" required:"true"` + MoodleWSToken string `split_words:"true" required:"true"` + MoodleSecret string `split_words:"true" required:"true"` } From f1740c2ab914e6372a6584ae3001bd98809e99c0 Mon Sep 17 00:00:00 2001 From: Cameron Steele Date: Sat, 16 Sep 2023 14:11:28 -0500 Subject: [PATCH 2/8] Add moodle webhook handler --- main.go | 58 +++++++++++++++++++++++++++++++++++++++++++ moodle/moodle.go | 64 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 122 insertions(+) create mode 100644 moodle/moodle.go diff --git a/main.go b/main.go index 76a867b..e368243 100644 --- a/main.go +++ b/main.go @@ -22,6 +22,7 @@ import ( "github.com/TheLab-ms/profile/conf" "github.com/TheLab-ms/profile/keycloak" + "github.com/TheLab-ms/profile/moodle" "github.com/TheLab-ms/profile/stripeutil" ) @@ -50,6 +51,7 @@ func main() { stripe.Key = env.StripeKey kc := keycloak.New(env) + m := moodle.New(env) priceCache := stripeutil.StartPriceCache() // Redirect from / to /profile @@ -71,6 +73,7 @@ func main() { // Webhooks http.HandleFunc("/webhooks/docuseal", newDocusealWebhookHandler(kc)) http.HandleFunc("/webhooks/stripe", newStripeWebhookHandler(env, kc)) + http.HandleFunc("/webhooks/moodle", newMoodleWebhookHandler(env, kc, m)) // Embed (into the compiled binary) and serve any files from the assets directory http.Handle("/assets/", http.FileServer(http.FS(assets))) @@ -306,6 +309,61 @@ func newStripeWebhookHandler(env *conf.Env, kc *keycloak.Keycloak) http.HandlerF } } +func newMoodleWebhookHandler(env *conf.Env, kc *keycloak.Keycloak, m *moodle.Moodle) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + body := struct { + EventName string `json:"eventname"` + Host string `json:"host"` + Token string `json:"token"` + CourseID string `json:"courseid"` + UserID string `json:"relateduserid"` + }{} + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + log.Printf("invalid json sent to moodle webhook endpoint: %s", err) + w.WriteHeader(400) + return + } + if body.Token != env.MoodleSecret { + log.Printf("invalid moodle webhook secret") + w.WriteHeader(400) + return + } + switch body.EventName { + case "\\core\\event\\course_completed": + log.Printf("got moodle course completion submission webhook") + + // Lookup user by moodle ID to get email address + moodleUser, err := m.GetUserByID(body.UserID) + if err != nil { + log.Printf("error while looking up user by moodle ID: %s", err) + w.WriteHeader(500) + return + } + // Use email address to lookup user in Keycloak + user, err := kc.GetUserByEmail(r.Context(), moodleUser.Email) + if err != nil { + log.Printf("error while looking up user by email: %s", err) + w.WriteHeader(500) + return + } + if user != nil { + log.Printf("user %s completed moodle course %s", user.Email, body.CourseID) + } + + err = kc.AddUserToGroup(r.Context(), user.ID, "6e413212-c1d8-4bc9-abb3-51944ca35327") + if err != nil { + log.Printf("error while adding user to group: %s", err) + w.WriteHeader(500) + return + } + + default: + log.Printf("unhandled moodle webhook event type: %s, ignoring", body.EventName) + } + + } +} + // getUserID allows the oauth2proxy header to be overridden for testing. func getUserID(r *http.Request) string { user := r.Header.Get("X-Forwarded-Preferred-Username") diff --git a/moodle/moodle.go b/moodle/moodle.go new file mode 100644 index 0000000..e29c789 --- /dev/null +++ b/moodle/moodle.go @@ -0,0 +1,64 @@ +package moodle + +import ( + "encoding/json" + "fmt" + "log" + "net/http" + + "github.com/TheLab-ms/profile/conf" +) + +type Moodle struct { + url string + token string +} + +type User struct { + ID int `json:"id"` + UserName string `json:"username"` + FirstName string `json:"firstname"` + LastName string `json:"lastname"` + FullName string `json:"fullname"` + Email string `json:"email"` + Suspended bool `json:"suspended"` + Confirmed bool `json:"confirmed"` + ProfileImageURL string `json:"profileimageurl"` +} + +func New(c *conf.Env) *Moodle { + return &Moodle{url: c.MoodleURL, token: c.MoodleWSToken} +} + +func (m *Moodle) GetUserByID(id string) (*User, error) { + url := fmt.Sprintf("%s/webservice/rest/server.php?wstoken=%s&wsfunction=core_user_get_users_by_field&field=id&values[0]=%s&moodlewsrestformat=json", m.url, m.token, id) + + client := &http.Client{} + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return nil, err + } + + res, err := client.Do(req) + if err != nil { + return nil, err + } + + defer res.Body.Close() + + if res.StatusCode != http.StatusOK { + return nil, fmt.Errorf("received non-OK HTTP status: %s", res.Status) + } + + var users []User // Declare users as a slice of User + if err := json.NewDecoder(res.Body).Decode(&users); err != nil { + log.Println("Error decoding response. ", err) + return nil, err + } + + if len(users) == 0 { + return nil, fmt.Errorf("no user found with ID %s", id) + } + + return &users[0], nil // Return the first user in the array +} From 94288b5b5124913361cba2057e682cb4e67292ea Mon Sep 17 00:00:00 2001 From: Cameron Steele Date: Sat, 16 Sep 2023 14:12:35 -0500 Subject: [PATCH 3/8] Added GetUserByEmail and AddUserToGroup --- keycloak/keycloak.go | 45 +++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 44 insertions(+), 1 deletion(-) diff --git a/keycloak/keycloak.go b/keycloak/keycloak.go index d45e150..2b77735 100644 --- a/keycloak/keycloak.go +++ b/keycloak/keycloak.go @@ -100,6 +100,40 @@ func (k *Keycloak) GetUser(ctx context.Context, userID string) (*User, error) { } user := &User{ + ID: gocloak.PString(kcuser.ID), + First: gocloak.PString(kcuser.FirstName), + Last: gocloak.PString(kcuser.LastName), + Email: gocloak.PString(kcuser.Email), + SignedWaiver: safeGetAttr(kcuser, "waiverState") == "Signed", + ActivePayment: safeGetAttr(kcuser, "stripeID") != "", + StripeSubscriptionID: safeGetAttr(kcuser, "stripeSubscriptionID"), + } + user.FobID, _ = strconv.Atoi(safeGetAttr(kcuser, "keyfobID")) + user.StripeCancelationTime, _ = strconv.ParseInt(safeGetAttr(kcuser, "stripeCancelationTime"), 10, 0) + user.StripeETag = safeGetAttr(kcuser, "stripeCancelationTime") + + return user, nil +} + +func (k *Keycloak) GetUserByEmail(ctx context.Context, email string) (*User, error) { + token, err := k.ensureToken(ctx) + if err != nil { + return nil, fmt.Errorf("getting token: %w", err) + } + + kcusers, err := k.client.GetUsers(ctx, token.AccessToken, k.env.KeycloakRealm, gocloak.GetUsersParams{ + Email: &email, + }) + if err != nil { + return nil, fmt.Errorf("getting current user: %w", err) + } + if len(kcusers) == 0 { + return nil, errors.New("user not found") + } + kcuser := kcusers[0] + + user := &User{ + ID: gocloak.PString(kcuser.ID), First: gocloak.PString(kcuser.FirstName), Last: gocloak.PString(kcuser.LastName), Email: gocloak.PString(kcuser.Email), @@ -252,7 +286,7 @@ func (k *Keycloak) ensureToken(ctx context.Context) (*gocloak.JWT, error) { } type User struct { - First, Last, Email string + ID, First, Last, Email string FobID int SignedWaiver, ActivePayment bool @@ -280,3 +314,12 @@ func firstElOrZeroVal[T any](slice []T) (val T) { } return slice[0] } + +func (k *Keycloak) AddUserToGroup(ctx context.Context, userID, groupID string) error { + token, err := k.ensureToken(ctx) + if err != nil { + return fmt.Errorf("getting token: %w", err) + } + + return k.client.AddUserToGroup(ctx, token.AccessToken, k.env.KeycloakRealm, userID, groupID) +} From 9eabcdaedc6b97d5ff6b81aef16ac9d7268d6820 Mon Sep 17 00:00:00 2001 From: Cameron Steele Date: Sat, 16 Sep 2023 19:46:03 -0500 Subject: [PATCH 4/8] Update moodle/moodle.go Co-authored-by: Jordan Olshevski --- moodle/moodle.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/moodle/moodle.go b/moodle/moodle.go index e29c789..78083b0 100644 --- a/moodle/moodle.go +++ b/moodle/moodle.go @@ -47,7 +47,8 @@ func (m *Moodle) GetUserByID(id string) (*User, error) { defer res.Body.Close() if res.StatusCode != http.StatusOK { - return nil, fmt.Errorf("received non-OK HTTP status: %s", res.Status) + body, _ := io.ReadAll(res.Body) + return nil, fmt.Errorf("received non-OK HTTP status %d: %s", res.StatusCode, body) } var users []User // Declare users as a slice of User From 7a30f17ef78dab701f6e03f35e6c788015664aac Mon Sep 17 00:00:00 2001 From: Cameron Steele Date: Sat, 16 Sep 2023 19:49:02 -0500 Subject: [PATCH 5/8] Use default HTTP Client --- moodle/moodle.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/moodle/moodle.go b/moodle/moodle.go index 78083b0..aa7d4c6 100644 --- a/moodle/moodle.go +++ b/moodle/moodle.go @@ -3,6 +3,7 @@ package moodle import ( "encoding/json" "fmt" + "io" "log" "net/http" @@ -33,7 +34,7 @@ func New(c *conf.Env) *Moodle { func (m *Moodle) GetUserByID(id string) (*User, error) { url := fmt.Sprintf("%s/webservice/rest/server.php?wstoken=%s&wsfunction=core_user_get_users_by_field&field=id&values[0]=%s&moodlewsrestformat=json", m.url, m.token, id) - client := &http.Client{} + client := http.DefaultClient req, err := http.NewRequest("GET", url, nil) if err != nil { return nil, err From 4a09e965308ca82c5e66a9382a9ca90a358bb8fc Mon Sep 17 00:00:00 2001 From: Cameron Steele Date: Sat, 16 Sep 2023 19:50:18 -0500 Subject: [PATCH 6/8] Rename Moodle.Moodle to Moodle.Client --- main.go | 2 +- moodle/moodle.go | 10 ++++------ 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/main.go b/main.go index e368243..f7f3942 100644 --- a/main.go +++ b/main.go @@ -309,7 +309,7 @@ func newStripeWebhookHandler(env *conf.Env, kc *keycloak.Keycloak) http.HandlerF } } -func newMoodleWebhookHandler(env *conf.Env, kc *keycloak.Keycloak, m *moodle.Moodle) http.HandlerFunc { +func newMoodleWebhookHandler(env *conf.Env, kc *keycloak.Keycloak, m *moodle.Client) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { body := struct { EventName string `json:"eventname"` diff --git a/moodle/moodle.go b/moodle/moodle.go index aa7d4c6..a5692f5 100644 --- a/moodle/moodle.go +++ b/moodle/moodle.go @@ -4,13 +4,12 @@ import ( "encoding/json" "fmt" "io" - "log" "net/http" "github.com/TheLab-ms/profile/conf" ) -type Moodle struct { +type Client struct { url string token string } @@ -27,11 +26,11 @@ type User struct { ProfileImageURL string `json:"profileimageurl"` } -func New(c *conf.Env) *Moodle { - return &Moodle{url: c.MoodleURL, token: c.MoodleWSToken} +func New(c *conf.Env) *Client { + return &Client{url: c.MoodleURL, token: c.MoodleWSToken} } -func (m *Moodle) GetUserByID(id string) (*User, error) { +func (m *Client) GetUserByID(id string) (*User, error) { url := fmt.Sprintf("%s/webservice/rest/server.php?wstoken=%s&wsfunction=core_user_get_users_by_field&field=id&values[0]=%s&moodlewsrestformat=json", m.url, m.token, id) client := http.DefaultClient @@ -54,7 +53,6 @@ func (m *Moodle) GetUserByID(id string) (*User, error) { var users []User // Declare users as a slice of User if err := json.NewDecoder(res.Body).Decode(&users); err != nil { - log.Println("Error decoding response. ", err) return nil, err } From 2be7e99682abe03d56806e2acf1003a6ac519c78 Mon Sep 17 00:00:00 2001 From: Cameron Steele Date: Sat, 16 Sep 2023 19:53:00 -0500 Subject: [PATCH 7/8] Cleanup --- main.go | 3 --- 1 file changed, 3 deletions(-) diff --git a/main.go b/main.go index f7f3942..0e23a05 100644 --- a/main.go +++ b/main.go @@ -346,9 +346,6 @@ func newMoodleWebhookHandler(env *conf.Env, kc *keycloak.Keycloak, m *moodle.Cli w.WriteHeader(500) return } - if user != nil { - log.Printf("user %s completed moodle course %s", user.Email, body.CourseID) - } err = kc.AddUserToGroup(r.Context(), user.ID, "6e413212-c1d8-4bc9-abb3-51944ca35327") if err != nil { From 3f2f37b71726863f2344a431bc79f9194c099b44 Mon Sep 17 00:00:00 2001 From: Cameron Steele Date: Sat, 16 Sep 2023 21:01:02 -0500 Subject: [PATCH 8/8] Fix the Unlink Account button (#7) --- templates/profile.html | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/templates/profile.html b/templates/profile.html index bc0f4b1..3fd0212 100644 --- a/templates/profile.html +++ b/templates/profile.html @@ -149,7 +149,9 @@

Discord


- Unlink Account {{- else }}