diff --git a/.gitignore b/.gitignore index 62ca6b86..c1294457 100644 --- a/.gitignore +++ b/.gitignore @@ -29,6 +29,8 @@ application-local*.properties +google-cloud-credentials.json + # Locally generated certificates *.key *.pem diff --git a/README.md b/README.md index 9c88f78f..25264a35 100644 --- a/README.md +++ b/README.md @@ -106,3 +106,95 @@ Using Ngrok For Local SSL Cert 3. Utilize the https url from ngrok when registering your tool with the platform Note: Each time you restart ngrok, you will need to change the url of your tool in your registration with the LMS. However, you may restart the tool as much as you like while leaving ngrok running without issue. + +Google Classroom Adapter +------------------------ +------------------------ +Developer/Admin Setup in Google Cloud +------------------------------------- +Source: https://developers.google.com/classroom/quickstart/java +1. Go to the Google Cloud Console: https://console.cloud.google.com/ +2. Click on the dropdown to the right of "Google Cloud" to open the pop-up to select a project. +3. Click on NEW PROJECT in the upper right hand corner. +4. Enter a project name and ensure the other fields have valid values, then click CREATE. +5. Wait for the project to be created, then click SELECT PROJECT. +6. Your project's name should now appear to the right of "Google Cloud" at the top of the page. +7. Go here: https://console.cloud.google.com/flows/enableapi?apiid=classroom.googleapis.com +8. Ensure that you still see your project's name at the top of the page and then click NEXT. +9. Click ENABLE. +10. In the Google Cloud console, go to Menu menu > APIs & Services > Credentials. +11. Click CREATE CREDENTIALS > OAuth client ID. +12. Click CONFIGURE CONSENT SCREEN. +13. Select your User Type (I chose Internal) and click CREATE. +14. Enter a name for your application, a User Support email, and a Developer email address. I left the remaining fields with blank/default values. Click SAVE & CONTINUE. +15. Click ADD OR REMOVE SCOPES. +16. Enter the following list of scopes: +``` +https://www.googleapis.com/auth/classroom.announcements +https://www.googleapis.com/auth/classroom.announcements.readonly +https://www.googleapis.com/auth/classroom.courses +https://www.googleapis.com/auth/classroom.courses.readonly +https://www.googleapis.com/auth/classroom.coursework.me +https://www.googleapis.com/auth/classroom.coursework.me.readonly +https://www.googleapis.com/auth/classroom.coursework.students +https://www.googleapis.com/auth/classroom.coursework.students.readonly +https://www.googleapis.com/auth/classroom.courseworkmaterials +https://www.googleapis.com/auth/classroom.courseworkmaterials.readonly +https://www.googleapis.com/auth/classroom.guardianlinks.me.readonly +https://www.googleapis.com/auth/classroom.guardianlinks.students +https://www.googleapis.com/auth/classroom.guardianlinks.students.readonly +https://www.googleapis.com/auth/classroom.profile.emails +https://www.googleapis.com/auth/classroom.profile.photos +https://www.googleapis.com/auth/classroom.push-notifications +https://www.googleapis.com/auth/classroom.rosters +https://www.googleapis.com/auth/classroom.rosters.readonly +https://www.googleapis.com/auth/classroom.student-submissions.me.readonly +https://www.googleapis.com/auth/classroom.student-submissions.students.readonly +https://www.googleapis.com/auth/classroom.topics +https://www.googleapis.com/auth/classroom.topics.readonly +``` +17. Use the left/right arrow buttons to navigate through the scopes (I see 48 total). I also checked the boxes for service.management and service.management.readonly, userinfo.email, userinfo.profile, and openid. Click UPDATE. +18. Click SAVE AND CONTINUE. +19. Review the summary then click BACK TO DASHBOARD. +20. In the Google Cloud console, go to Menu menu > APIs & Services > Credentials. +21. Click CREATE CREDENTIALS > OAuth client ID. +22. Click Application type > Web application. +23. In the Name field, type a name for the credential. This name is only shown in the Google Cloud console. +24. For Authorized Redirect URIs, enter `http://localhost:8888` (Ensure in your properties file that you have `management.port=8888`) +25. Click Create. The OAuth client created screen appears, showing your new Client ID and Client secret. +26. Click OK. The newly created credential appears under OAuth 2.0 Client IDs. +27. Save the downloaded JSON file as google-cloud-credentials.json, and move the file to src/main/resources. + +Add Hardcoded Self PlatformDeployment to DB +------------------------------------------- +In Postman, use the `/config` endpoint and the following request body to add the self platform deployment. Ensure to change out the domain with your own ngrok domain. +```json +{ + "iss": "https://7ae3-184-101-4-99.ngrok.io", + "clientId": "self-client-id", + "oidcEndpoint": "https://7ae3-184-101-4-99.ngrok.io/app/platform-oidc-authorize", + "jwksEndpoint": "https://7ae3-184-101-4-99.ngrok.io/jwks/jwk", + "oAuth2TokenUrl": "", + "oAuth2TokenAud": "", + "deploymentId": "self-deployment-id" +} +``` + +Overview +-------- +Hopefully after completing the steps in the previous 2 sections, you should be ready to use the Google Classroom Adapter. +The Google Classroom adapter supports Classwork insertion (Deep Linking) and standard LTI launches from Google Classroom. It does not support grade passback or memberships (NRPS). +Replacing the example ngrok domain with your own ngrok domain, the flow should be as follows: +1. In your browser, go to https://7ae3-184-101-4-99.ngrok.io/app +2. If this is your first time using the app, then there will be a Google Auth link in the logs. Copy/paste that link into a separate tab in your browser. +3. Complete the Google Auth process and close the tab for it. +4. Return to your App tab. +5. Select your Google Classroom class from the dropdown (if you don't have one, create one and then refresh this page). +6. Click the "Add Content to LMS" button. +7. Complete the demo LTI flow to get to the Deep Linking menu. +8. Select which link(s) you would like to insert into your Google Classroom class. +9. A success page should appear. +10. In another tab, open your Google Classroom class and click on the Classwork tab. +11. Your link(s) should be there. +12. Click on one of the links. +13. Complete the demo LTI flow to receive a "Google Classroom" LTI id_token. \ No newline at end of file diff --git a/pom.xml b/pom.xml index 2b97c800..575b80a6 100644 --- a/pom.xml +++ b/pom.xml @@ -130,6 +130,23 @@ nimbus-jose-jwt 9.9 + + + + com.google.api-client + google-api-client + 2.0.0 + + + com.google.oauth-client + google-oauth-client-jetty + 1.34.1 + + + com.google.apis + google-api-services-classroom + v1-rev20220323-2.0.0 + diff --git a/src/main/java/net/unicon/lti/config/WebSecurityConfig.java b/src/main/java/net/unicon/lti/config/WebSecurityConfig.java index 1abe86d5..73755c9b 100644 --- a/src/main/java/net/unicon/lti/config/WebSecurityConfig.java +++ b/src/main/java/net/unicon/lti/config/WebSecurityConfig.java @@ -58,6 +58,7 @@ protected void configure(HttpSecurity http) throws Exception { .antMatchers("/registration/**") .antMatchers("/jwks/**") .antMatchers("/ags/**") + .antMatchers("/app/**") .and() .authorizeRequests().anyRequest().permitAll().and().csrf().disable().headers().frameOptions().disable(); } diff --git a/src/main/java/net/unicon/lti/controller/app/AppController.java b/src/main/java/net/unicon/lti/controller/app/AppController.java new file mode 100644 index 00000000..ed7a52c9 --- /dev/null +++ b/src/main/java/net/unicon/lti/controller/app/AppController.java @@ -0,0 +1,126 @@ +package net.unicon.lti.controller.app; + +import com.google.api.services.classroom.model.Course; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jws; +import lombok.extern.slf4j.Slf4j; +import net.unicon.lti.model.GcCourseEntity; +import net.unicon.lti.model.GcLinkEntity; +import net.unicon.lti.model.GcUserEntity; +import net.unicon.lti.model.lti.dto.LoginInitiationDTO; +import net.unicon.lti.service.gc.GoogleClassroomService; +import net.unicon.lti.service.lti.LTIDataService; +import net.unicon.lti.service.lti.LTIJWTService; +import net.unicon.lti.utils.TextConstants; +import net.unicon.lti.utils.lti.LtiOidcUtils; +import org.apache.commons.lang3.StringUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Scope; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; + +import javax.servlet.http.HttpServletRequest; +import java.io.IOException; +import java.security.GeneralSecurityException; +import java.util.List; + +import static net.unicon.lti.utils.TextConstants.LTI3_SUFFIX; + +@Controller +@Scope("session") +@RequestMapping("/app") +@Slf4j +public class AppController { + @Autowired + LTIDataService ltiDataService; + + @Autowired + LTIJWTService ltijwtService; + + @Autowired + GoogleClassroomService googleClassroomService; + + // Launches a mock standalone home page with a button to start the LTI DL --> GC CW flow. + @RequestMapping({"", "/{linkUuid}"}) + public String appInit(HttpServletRequest req, Model model, @PathVariable(value = "linkUuid", required = false) String linkUuid, @RequestParam(value = "link", required = false) String link) throws GeneralSecurityException, IOException { + String targetUri = ltiDataService.getLocalUrl() + TextConstants.LTI3_SUFFIX; + if (linkUuid != null) { + targetUri = targetUri + "?gcLink=" + linkUuid; + if (link != null) { + // TODO: Ultimately I end up letting the original link id just fall by the wayside. That should probably change in the end. + targetUri = targetUri + "&link=" + link; + } + } + + LoginInitiationDTO loginInitiationDTO = new LoginInitiationDTO( + ltiDataService.getLocalUrl(), + "uuid or user's gc course id", + targetUri, + LtiOidcUtils.generateLtiMessageHint(ltiDataService, linkUuid, link), + "self-client-id", + "self-deployment-id" + ); + model.addAttribute("oidcRequestUrl", ltiDataService.getLocalUrl() + "/oidc/login_initiations"); + model.addAttribute("oidcLoginRequest", loginInitiationDTO); + + if (StringUtils.isBlank(linkUuid)) { // if no linkUuid then go to "home page" to pick course for content selection (Deep Linking) launch + List courses = googleClassroomService.getCoursesFromGoogleClassroom(); + model.addAttribute("courses", courses); + model.addAttribute("title", "App!"); + log.debug("Going to home page to pick course for content selection..."); + return "app"; + } else { // if linkUuid present, do LTI launch to specific link + log.debug("Sending platform OIDC Login Request..."); + return "platformOIDCLoginRequest"; + } + } + + // Acts as a platform's LTI auth response containing an id_token with a Deep Linking message type. + @RequestMapping({"/platform-oidc-authorize"}) + public String generatePlatformOidcAuthorization(HttpServletRequest req, Model model) throws GeneralSecurityException { + log.debug("Received platform authorization request."); + // TODO validate nonce + Jws jwt = ltijwtService.validateState(req.getParameter("lti_message_hint")); + String linkUuid = jwt.getBody().get("linkUuid") != null ? jwt.getBody().get("linkUuid").toString() : null; + model.addAttribute("state", req.getParameter("state")); + String target = ltiDataService.getLocalUrl() + LTI3_SUFFIX; + + GcUserEntity gcUserEntity = googleClassroomService.getCurrentUser(linkUuid); + GcLinkEntity gcLinkEntity = googleClassroomService.getGcLinkByUuid(linkUuid); + + if (linkUuid == null) { // deep linking flow + log.debug("Preparing deep linking flow..."); + GcCourseEntity gcCourseEntity = googleClassroomService.getGcCourseByGcCourseId(req.getParameter("login_hint")); + model.addAttribute("id_token", LtiOidcUtils.generateLtiIdToken(ltiDataService, req.getParameter("nonce"), gcUserEntity, gcCourseEntity, gcLinkEntity, true)); + } else { // resource link flow + log.debug("Preparing resource link flow..."); + GcCourseEntity gcCourseEntity = googleClassroomService.getGcCourseFromLinkId(linkUuid); + target = target + "?link=" + linkUuid; + // TODO: May want to query GC to ensure that gcCourse is up to date in db before generating id_token + model.addAttribute("id_token", LtiOidcUtils.generateLtiIdToken(ltiDataService, req.getParameter("nonce"), gcUserEntity, gcCourseEntity, gcLinkEntity, false)); + } + model.addAttribute("target", target); + log.debug("Sending platform auth response with id_token..."); + return "platformAuthResponse"; + } + + // Handles deep linking responses, converting them into Google Classroom Coursework + @RequestMapping({"/gccoursework/{gcCourseId}"}) + public String gcCourseWork(HttpServletRequest req, Model model, @PathVariable("gcCourseId") String gcCourseId) throws GeneralSecurityException, IOException { + // validate and parse jwt + Jws jwt = ltijwtService.validateState(req.getParameter("JWT")); + if (jwt == null) { + model.addAttribute("Error", "Could not validate or parse JWT."); + return "app"; + } + + // insert the links into that course + googleClassroomService.addClassworkMaterials(gcCourseId, jwt); + + model.addAttribute("title", "Your assignments have been added to Google Classroom!"); + return "gcAddMaterialsResponse"; + } +} diff --git a/src/main/java/net/unicon/lti/model/GcCourseEntity.java b/src/main/java/net/unicon/lti/model/GcCourseEntity.java new file mode 100644 index 00000000..022d43fa --- /dev/null +++ b/src/main/java/net/unicon/lti/model/GcCourseEntity.java @@ -0,0 +1,92 @@ +package net.unicon.lti.model; + +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import javax.persistence.Basic; +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.GeneratedValue; +import javax.persistence.GenerationType; +import javax.persistence.Id; +import javax.persistence.Table; + +@Entity +@Table(name = "gc_course") +@Setter +@Getter +@NoArgsConstructor +public class GcCourseEntity { + // Internal id for the course + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id", nullable = false) + private long id; + + // Google's id for the course + @Basic + @Column(name = "gc_course_id", nullable = false, length = 4096) + private String gcCourseId; + + @Basic + @Column(name = "name", length = 4096) + private String name; + @Basic + @Column(name = "section") + private String section; + @Basic + @Column(name = "description_heading") + private String descriptionHeading; + @Basic + @Column(name = "description") + private String description; + @Basic + @Column(name = "room") + private String room; + @Basic + @Column(name = "owner_id") + private String ownerId; + @Basic + @Column(name = "creation_time") + private String creationTime; + @Basic + @Column(name = "update_time") + private String updateTime; + @Basic + @Column(name = "enrollment_code") + private String enrollmentCode; + @Basic + @Column(name = "course_state") + private String courseState; + @Basic + @Column(name = "alternate_link") + private String alternateLink; + @Basic + @Column(name = "teacher_group_email") + private String teacherGroupEmail; + @Basic + @Column(name = "course_group_email") + private String courseGroupEmail; + @Basic + @Column(name = "guardians_enabled") + private boolean guardiansEnabled; + + public GcCourseEntity(String gcCourseId, String name, String section, String descriptionHeading, String description, String room, String ownerId, String creationTime, String updateTime, String enrollmentCode, String courseState, String alternateLink, String teacherGroupEmail, String courseGroupEmail, boolean guardiansEnabled) { + this.gcCourseId = gcCourseId; + this.name = name; + this.section = section; + this.descriptionHeading = descriptionHeading; + this.description = description; + this.room = room; + this.ownerId = ownerId; + this.creationTime = creationTime; + this.updateTime = updateTime; + this.enrollmentCode = enrollmentCode; + this.courseState = courseState; + this.alternateLink = alternateLink; + this.teacherGroupEmail = teacherGroupEmail; + this.courseGroupEmail = courseGroupEmail; + this.guardiansEnabled = guardiansEnabled; + } +} diff --git a/src/main/java/net/unicon/lti/model/GcLinkEntity.java b/src/main/java/net/unicon/lti/model/GcLinkEntity.java new file mode 100644 index 00000000..5c8c96c9 --- /dev/null +++ b/src/main/java/net/unicon/lti/model/GcLinkEntity.java @@ -0,0 +1,46 @@ +package net.unicon.lti.model; + +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import javax.persistence.Basic; +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.FetchType; +import javax.persistence.GeneratedValue; +import javax.persistence.GenerationType; +import javax.persistence.Id; +import javax.persistence.JoinColumn; +import javax.persistence.ManyToOne; +import javax.persistence.Table; +import java.util.UUID; + +@Entity +@Table(name = "gc_link") +@Getter +@Setter +@NoArgsConstructor +public class GcLinkEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id", nullable = false) + private long id; + @Column(name = "uuid", nullable = false, length = 4096) + private String uuid; + @Basic + @Column(name = "title", length = 4096) + private String title; + @Basic + @Column(name = "url") + private String url; + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "course_id") + private GcCourseEntity gcCourseEntity; + + public GcLinkEntity(String title, GcCourseEntity gcCourseEntity) { + this.uuid = UUID.randomUUID().toString(); + this.title = title; + this.gcCourseEntity = gcCourseEntity; + } +} diff --git a/src/main/java/net/unicon/lti/model/GcUserEntity.java b/src/main/java/net/unicon/lti/model/GcUserEntity.java new file mode 100644 index 00000000..8562adec --- /dev/null +++ b/src/main/java/net/unicon/lti/model/GcUserEntity.java @@ -0,0 +1,66 @@ +package net.unicon.lti.model; + +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import javax.persistence.Basic; +import javax.persistence.CollectionTable; +import javax.persistence.Column; +import javax.persistence.ElementCollection; +import javax.persistence.Entity; +import javax.persistence.GeneratedValue; +import javax.persistence.GenerationType; +import javax.persistence.Id; +import javax.persistence.Table; +import java.util.List; + +@Entity +@Table(name = "gc_user") +@Getter +@Setter +@NoArgsConstructor +public class GcUserEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id", nullable = false) + private long id; + @Column(name = "gc_user_id", nullable = false, length = 4096) + private String gcUserId; + @Basic + @Column(name = "email") + private String email; + @Basic + @Column(name = "photo_url") + private String photoUrl; + @Basic + @Column(name = "given_name") + private String givenName; + @Basic + @Column(name = "family_name") + private String familyName; + @Basic + @Column(name = "full_name") + private String fullName; + @Basic + @Column(name = "permissions") + private String permissions; + @Basic + @Column(name = "verified_teacher") + private boolean verifiedTeacher; + @ElementCollection + @CollectionTable(name="gc_lti_roles") + private List ltiRoles; + + public GcUserEntity(String gcUserId, String email, String photoUrl, String givenName, String familyName, String fullName, String permissions, boolean verifiedTeacher, List ltiRoles) { + this.gcUserId = gcUserId; + this.email = email; + this.photoUrl = photoUrl; + this.givenName = givenName; + this.familyName = familyName; + this.fullName = fullName; + this.permissions = permissions; + this.verifiedTeacher = verifiedTeacher; + this.ltiRoles = ltiRoles; + } +} diff --git a/src/main/java/net/unicon/lti/repository/GcCourseRepository.java b/src/main/java/net/unicon/lti/repository/GcCourseRepository.java new file mode 100644 index 00000000..05bb4bef --- /dev/null +++ b/src/main/java/net/unicon/lti/repository/GcCourseRepository.java @@ -0,0 +1,26 @@ +/** + * Copyright 2021 Unicon (R) + * 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 net.unicon.lti.repository; + +import net.unicon.lti.model.GcCourseEntity; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.transaction.annotation.Transactional; + +/** + * NOTE: use of this interface magic makes all subclass-based (CGLIB) proxies fail + */ +@Transactional +public interface GcCourseRepository extends JpaRepository { + + GcCourseEntity getByGcCourseId(String gcCourseId); +} diff --git a/src/main/java/net/unicon/lti/repository/GcLinkRepository.java b/src/main/java/net/unicon/lti/repository/GcLinkRepository.java new file mode 100644 index 00000000..736f6700 --- /dev/null +++ b/src/main/java/net/unicon/lti/repository/GcLinkRepository.java @@ -0,0 +1,26 @@ +/** + * Copyright 2021 Unicon (R) + * 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 net.unicon.lti.repository; + +import net.unicon.lti.model.GcLinkEntity; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.transaction.annotation.Transactional; + +/** + * NOTE: use of this interface magic makes all subclass-based (CGLIB) proxies fail + */ +@Transactional +public interface GcLinkRepository extends JpaRepository { + + GcLinkEntity getByUuid(String uuid); +} diff --git a/src/main/java/net/unicon/lti/repository/GcUserRepository.java b/src/main/java/net/unicon/lti/repository/GcUserRepository.java new file mode 100644 index 00000000..8bdfd558 --- /dev/null +++ b/src/main/java/net/unicon/lti/repository/GcUserRepository.java @@ -0,0 +1,26 @@ +/** + * Copyright 2021 Unicon (R) + * 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 net.unicon.lti.repository; + +import net.unicon.lti.model.GcUserEntity; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.transaction.annotation.Transactional; + +/** + * NOTE: use of this interface magic makes all subclass-based (CGLIB) proxies fail + */ +@Transactional +public interface GcUserRepository extends JpaRepository { + + GcUserEntity getByGcUserId(String gcUserId); +} diff --git a/src/main/java/net/unicon/lti/service/gc/GoogleClassroomService.java b/src/main/java/net/unicon/lti/service/gc/GoogleClassroomService.java new file mode 100644 index 00000000..7bf94159 --- /dev/null +++ b/src/main/java/net/unicon/lti/service/gc/GoogleClassroomService.java @@ -0,0 +1,24 @@ +package net.unicon.lti.service.gc; + +import com.google.api.services.classroom.model.Course; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jws; +import net.unicon.lti.model.GcCourseEntity; +import net.unicon.lti.model.GcLinkEntity; +import net.unicon.lti.model.GcUserEntity; + +import java.util.List; + +public interface GoogleClassroomService { + GcUserEntity getCurrentUser(String linkUuid); + + void addClassworkMaterials(String courseId, Jws jwt); + + List getCoursesFromGoogleClassroom(); + + GcLinkEntity getGcLinkByUuid(String linkUuid); + + GcCourseEntity getGcCourseFromLinkId(String linkUuid); + + GcCourseEntity getGcCourseByGcCourseId(String gcCourseId); +} diff --git a/src/main/java/net/unicon/lti/service/gc/impl/GoogleClassroomServiceImpl.java b/src/main/java/net/unicon/lti/service/gc/impl/GoogleClassroomServiceImpl.java new file mode 100644 index 00000000..7fe88e41 --- /dev/null +++ b/src/main/java/net/unicon/lti/service/gc/impl/GoogleClassroomServiceImpl.java @@ -0,0 +1,354 @@ +package net.unicon.lti.service.gc.impl; + +import com.google.api.client.auth.oauth2.Credential; +import com.google.api.client.extensions.java6.auth.oauth2.AuthorizationCodeInstalledApp; +import com.google.api.client.extensions.jetty.auth.oauth2.LocalServerReceiver; +import com.google.api.client.googleapis.auth.oauth2.GoogleAuthorizationCodeFlow; +import com.google.api.client.googleapis.auth.oauth2.GoogleClientSecrets; +import com.google.api.client.googleapis.javanet.GoogleNetHttpTransport; +import com.google.api.client.googleapis.json.GoogleJsonError; +import com.google.api.client.googleapis.json.GoogleJsonResponseException; +import com.google.api.client.http.javanet.NetHttpTransport; +import com.google.api.client.json.JsonFactory; +import com.google.api.client.json.gson.GsonFactory; +import com.google.api.client.util.store.FileDataStoreFactory; +import com.google.api.services.classroom.Classroom; +import com.google.api.services.classroom.ClassroomScopes; +import com.google.api.services.classroom.model.Course; +import com.google.api.services.classroom.model.CourseWork; +import com.google.api.services.classroom.model.Link; +import com.google.api.services.classroom.model.ListCoursesResponse; +import com.google.api.services.classroom.model.Material; +import com.google.api.services.classroom.model.Student; +import com.google.api.services.classroom.model.Teacher; +import com.google.api.services.classroom.model.UserProfile; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jws; +import lombok.extern.slf4j.Slf4j; +import net.unicon.lti.model.GcCourseEntity; +import net.unicon.lti.model.GcLinkEntity; +import net.unicon.lti.model.GcUserEntity; +import net.unicon.lti.repository.GcCourseRepository; +import net.unicon.lti.repository.GcLinkRepository; +import net.unicon.lti.repository.GcUserRepository; +import net.unicon.lti.service.gc.GoogleClassroomService; +import org.apache.commons.lang3.StringUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.web.util.UriComponentsBuilder; + +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.security.GeneralSecurityException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.List; + +import static net.unicon.lti.utils.LtiStrings.DEEP_LINK_TITLE; +import static net.unicon.lti.utils.LtiStrings.LTI_CONTENT_ITEMS; +import static net.unicon.lti.utils.LtiStrings.LTI_ROLE_INSTRUCTOR; +import static net.unicon.lti.utils.LtiStrings.LTI_ROLE_OTHER; +import static net.unicon.lti.utils.LtiStrings.LTI_ROLE_STUDENT; + +@Slf4j +@Service +public class GoogleClassroomServiceImpl implements GoogleClassroomService { + @Autowired + GcCourseRepository gcCourseRepository; + + @Autowired + GcLinkRepository gcLinkRepository; + + @Autowired + GcUserRepository gcUserRepository; + + protected static final String APPLICATION_NAME = "Google Classroom API Java Quickstart"; + protected static final JsonFactory JSON_FACTORY = GsonFactory.getDefaultInstance(); + protected static final String TOKENS_DIRECTORY_PATH = "tokens"; + + /** + * Global instance of the scopes required by this quickstart. + * If modifying these scopes, delete your previously saved tokens/ folder. + */ + private static final List SCOPES = Arrays.asList( + ClassroomScopes.CLASSROOM_COURSES_READONLY, + ClassroomScopes.CLASSROOM_COURSES, + ClassroomScopes.CLASSROOM_COURSEWORK_ME, + ClassroomScopes.CLASSROOM_COURSEWORK_STUDENTS, + ClassroomScopes.CLASSROOM_ROSTERS, + ClassroomScopes.CLASSROOM_ROSTERS_READONLY, + ClassroomScopes.CLASSROOM_PROFILE_EMAILS, + ClassroomScopes.CLASSROOM_PROFILE_PHOTOS, + "openid", + "https://www.googleapis.com/auth/userinfo.profile", + "https://www.googleapis.com/auth/userinfo.email" + ); + private static final String CREDENTIALS_FILE_PATH = "/google-cloud-credentials.json"; + + /** + * Creates an authorized Credential object. + * + * @param HTTP_TRANSPORT The network HTTP Transport. + * @return An authorized Credential object. + * @throws IOException If the credentials.json file cannot be found. + */ + protected Credential getCredentials(final NetHttpTransport HTTP_TRANSPORT) { + try { + // Load client secrets. + InputStream in = GoogleClassroomServiceImpl.class.getResourceAsStream(CREDENTIALS_FILE_PATH); + if (in == null) { + throw new FileNotFoundException("Resource not found: " + CREDENTIALS_FILE_PATH); + } + GoogleClientSecrets clientSecrets = + GoogleClientSecrets.load(JSON_FACTORY, new InputStreamReader(in)); + + // Build flow and trigger user authorization request. + GoogleAuthorizationCodeFlow flow = new GoogleAuthorizationCodeFlow.Builder( + HTTP_TRANSPORT, JSON_FACTORY, clientSecrets, SCOPES) + .setDataStoreFactory(new FileDataStoreFactory(new java.io.File(TOKENS_DIRECTORY_PATH))) + .setAccessType("offline") + .build(); + LocalServerReceiver receiver = new LocalServerReceiver.Builder().setPort(8888).build(); + // TODO: CHANGE THIS SO THAT YOU DON'T HAVE TO COPY/PASTE LINKS OUT OF THE LOGS TO CONTINUE + // TODO: FIND OUT IF THIS REALLY HAS TO BE ON A DIFFERENT PORT THAN EVERYTHING ELSE AND WHY THAT IS + // TODO: FIND OUT IF IT WOULD BE A PROBLEM IN PROD THAT THIS DIRECTS TO LOCALHOST, HOW TO FIX + return new AuthorizationCodeInstalledApp(flow, receiver).authorize("user"); + } catch (IOException e) { + log.error(e.getStackTrace().toString()); + } + return null; + } + + public GcUserEntity getCurrentUser(String linkUuid) { + try { + // Build a new authorized API client service. + final NetHttpTransport HTTP_TRANSPORT = GoogleNetHttpTransport.newTrustedTransport(); + Classroom service = + new Classroom.Builder(HTTP_TRANSPORT, JSON_FACTORY, getCredentials(HTTP_TRANSPORT)) + .setApplicationName(APPLICATION_NAME) + .build(); + + UserProfile userProfile = service.userProfiles().get("me").execute(); + if (userProfile == null) { + log.error("Could not retrieve current Google Classroom user."); + return null; + } + log.debug("Found user in GC"); + + GcUserEntity gcUserEntity = gcUserRepository.getByGcUserId(userProfile.getId()); + boolean gcUserEntityDoesNotExist = gcUserEntity == null || gcUserEntity.getGcUserId() == null; + ArrayList ltiRoles = new ArrayList<>(); + if ((gcUserEntityDoesNotExist || gcUserEntity.getLtiRoles().isEmpty()) && StringUtils.isNotBlank(linkUuid)) { + log.debug("User does not exist or does not have roles and a link uuid is available for gc course lookup"); + GcCourseEntity gcCourseEntity = gcLinkRepository.getByUuid(linkUuid).getGcCourseEntity(); + if (gcCourseEntity != null && StringUtils.isNotBlank(gcCourseEntity.getGcCourseId())) { + log.debug("Associated course was found in db."); + // TODO: See if this can be handled without the try-catches. + try { + Teacher teacher = service.courses().teachers().get(gcCourseEntity.getGcCourseId(), userProfile.getId()).execute(); + if (teacher != null) { + ltiRoles.add(LTI_ROLE_INSTRUCTOR); + } + } catch (GoogleJsonResponseException e) { + if (e.getDetails().getCode() != 404) { // if response is 404, that just means the user is not a teacher + e.printStackTrace(); + return null; + } + log.debug("User is not a teacher."); + } + try { + Student student = service.courses().students().get(gcCourseEntity.getGcCourseId(), userProfile.getId()).execute(); + if (student != null) { + ltiRoles.add(LTI_ROLE_STUDENT); + } + } catch (GoogleJsonResponseException e) { + if (e.getDetails().getCode() != 404) { // if response is 404, that just means the user is not a student + e.printStackTrace(); + return null; + } + log.debug("User is not a student."); + } + if (ltiRoles.isEmpty()) { + ltiRoles.add(LTI_ROLE_OTHER); + } + log.debug("ltiRoles are {}", ltiRoles); + } else { + log.debug("Could not find course for linkUuid {}", linkUuid); + } + } + if (gcUserEntityDoesNotExist) { + gcUserEntity = new GcUserEntity( + userProfile.getId(), + userProfile.getEmailAddress(), + userProfile.getPhotoUrl(), + userProfile.getName().getGivenName(), + userProfile.getName().getFamilyName(), + userProfile.getName().getFullName(), + userProfile.getPermissions().toString(), + userProfile.getVerifiedTeacher() != null ? userProfile.getVerifiedTeacher() : false, + ltiRoles.isEmpty() ? null : ltiRoles + ); + log.debug("Created user: {} - {} - {}", gcUserEntity.getGcUserId(), gcUserEntity.getEmail(), gcUserEntity.getFullName()); + gcUserRepository.save(gcUserEntity); + } else if (!ltiRoles.isEmpty()) { + gcUserEntity.setLtiRoles(ltiRoles); + log.debug("Updating user roles to {}", ltiRoles); + gcUserRepository.save(gcUserEntity); + } + + return gcUserEntity; + } catch (GeneralSecurityException | IOException e) { + e.printStackTrace(); + return null; + } + } + + public void addClassworkMaterials(String gcCourseId, Jws jwt) { + try { + String linkPath = "/app"; + // Build a new authorized API client service. + final NetHttpTransport HTTP_TRANSPORT = GoogleNetHttpTransport.newTrustedTransport(); + Classroom service = + new Classroom.Builder(HTTP_TRANSPORT, JSON_FACTORY, getCredentials(HTTP_TRANSPORT)) + .setApplicationName(APPLICATION_NAME) + .build(); + + GcCourseEntity gcCourseEntity = gcCourseRepository.getByGcCourseId(gcCourseId); + if (gcCourseEntity == null || gcCourseEntity.getGcCourseId() == null) { + throw new AssertionError("GC course entity must exist."); + } + + CourseWork courseWork; + ArrayList contentItems = jwt.getBody().get(LTI_CONTENT_ITEMS, ArrayList.class); + for (LinkedHashMap contentItem : contentItems) { + GcLinkEntity gcLinkEntity = new GcLinkEntity( + contentItem.get("title").toString(), + gcCourseEntity + ); + + String contentItemUrl = UriComponentsBuilder.fromUriString(contentItem.get("url").toString()).replacePath(linkPath + "/" + gcLinkEntity.getUuid()).build().toUriString(); + gcLinkEntity.setUrl(contentItemUrl); + + // Create a link to add as a material on course work. + Link link = new Link() + .setTitle(contentItem.get(DEEP_LINK_TITLE).toString()) + .setUrl(contentItemUrl); + + // Create a list of Materials to add to course work. + List materials = Arrays.asList(new Material().setLink(link)); + + CourseWork content = new CourseWork() + .setTitle(contentItem.get(DEEP_LINK_TITLE).toString()) + .setMaterials(materials) + .setWorkType("ASSIGNMENT") + .setState("PUBLISHED"); + + log.debug("Creating course work in gc: gcCourseId: {}, content: {}", gcCourseId, content); + courseWork = service.courses().courseWork().create(gcCourseId, content).execute(); + + log.debug("Saving gc link to db..."); + gcLinkRepository.save(gcLinkEntity); + + /* Prints the created courseWork. */ + log.debug("CourseWork created: {}\n", courseWork.getTitle()); + } + } catch (GoogleJsonResponseException e) { + //TODO (developer) - handle error appropriately + GoogleJsonError error = e.getDetails(); + if (error.getCode() == 404) { + log.error("The gcCourseId does not exist: {}.\n", gcCourseId); + } else { + log.error(e.getStackTrace().toString()); + } + } catch (Exception e) { + log.error(e.getStackTrace().toString()); + } + } + + public List getCoursesFromGoogleClassroom() { + try { + // Build a new authorized API client service. + final NetHttpTransport HTTP_TRANSPORT = GoogleNetHttpTransport.newTrustedTransport(); + Classroom service = + new Classroom.Builder(HTTP_TRANSPORT, JSON_FACTORY, getCredentials(HTTP_TRANSPORT)) + .setApplicationName(APPLICATION_NAME) + .build(); + + // List the first 10 courses that the user has access to. + ListCoursesResponse response = service.courses().list().execute(); + List courses = response.getCourses(); + if (courses == null || courses.size() == 0) { + log.error("No courses found."); + } else { + log.debug("Courses:"); + for (Course course : courses) { + log.debug("{}\n", course.getName()); + } + } + + return courses; + } catch(IOException | GeneralSecurityException e) { + log.error(e.getStackTrace().toString()); + } + return null; + } + + public GcLinkEntity getGcLinkByUuid(String linkUuid) { + return gcLinkRepository.getByUuid(linkUuid); + } + + public GcCourseEntity getGcCourseFromLinkId(String linkUuid) { + GcLinkEntity gcLinkEntity = gcLinkRepository.getByUuid(linkUuid); + return gcLinkEntity.getGcCourseEntity(); + } + + public GcCourseEntity getGcCourseByGcCourseId(String gcCourseId) { + try { + GcCourseEntity gcCourseEntity = gcCourseRepository.getByGcCourseId(gcCourseId); + if (gcCourseEntity != null && StringUtils.isNotBlank(gcCourseEntity.getGcCourseId())) { + return gcCourseEntity; + } + + // Build a new authorized API client service. + final NetHttpTransport HTTP_TRANSPORT = GoogleNetHttpTransport.newTrustedTransport(); + Classroom service = + new Classroom.Builder(HTTP_TRANSPORT, JSON_FACTORY, getCredentials(HTTP_TRANSPORT)) + .setApplicationName(APPLICATION_NAME) + .build(); + + Course course = service.courses().get(gcCourseId).execute(); + if (course == null) { + log.error("Course should not be null"); + return null; + } + + gcCourseEntity = new GcCourseEntity( + course.getId(), + course.getName(), + course.getSection(), + course.getDescriptionHeading(), + course.getDescription(), + course.getRoom(), + course.getOwnerId(), + course.getCreationTime(), + course.getUpdateTime(), + course.getEnrollmentCode(), + course.getCourseState(), + course.getAlternateLink(), + course.getTeacherGroupEmail(), + course.getCourseGroupEmail(), + course.getGuardiansEnabled() + ); + + gcCourseRepository.save(gcCourseEntity); + + return gcCourseEntity; + } catch(IOException | GeneralSecurityException e) { + log.error(e.getStackTrace().toString()); + } + return null; + } +} diff --git a/src/main/java/net/unicon/lti/utils/lti/LtiOidcUtils.java b/src/main/java/net/unicon/lti/utils/lti/LtiOidcUtils.java index f19acc4a..1a617dd5 100644 --- a/src/main/java/net/unicon/lti/utils/lti/LtiOidcUtils.java +++ b/src/main/java/net/unicon/lti/utils/lti/LtiOidcUtils.java @@ -12,25 +12,52 @@ */ package net.unicon.lti.utils.lti; +import io.jsonwebtoken.JwtBuilder; import io.jsonwebtoken.Jwts; import io.jsonwebtoken.SignatureAlgorithm; -import net.unicon.lti.model.PlatformDeployment; +import net.unicon.lti.model.GcCourseEntity; +import net.unicon.lti.model.GcLinkEntity; +import net.unicon.lti.model.GcUserEntity; import net.unicon.lti.model.lti.dto.LoginInitiationDTO; import net.unicon.lti.service.lti.LTIDataService; import net.unicon.lti.utils.TextConstants; import net.unicon.lti.utils.oauth.OAuthUtils; import org.apache.commons.lang3.time.DateUtils; +import org.json.JSONObject; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import java.io.IOException; import java.security.GeneralSecurityException; import java.security.Key; -import java.time.LocalDateTime; -import java.time.ZoneId; +import java.util.ArrayList; +import java.util.Collections; import java.util.Date; +import java.util.HashMap; +import java.util.List; import java.util.Map; +import static net.unicon.lti.utils.LtiStrings.DEEP_LINKING_SETTINGS; +import static net.unicon.lti.utils.LtiStrings.LTI_CONTEXT; +import static net.unicon.lti.utils.LtiStrings.LTI_CONTEXT_ID; +import static net.unicon.lti.utils.LtiStrings.LTI_CONTEXT_LABEL; +import static net.unicon.lti.utils.LtiStrings.LTI_CONTEXT_TITLE; +import static net.unicon.lti.utils.LtiStrings.LTI_CONTEXT_TYPE; +import static net.unicon.lti.utils.LtiStrings.LTI_CONTEXT_TYPE_COURSE_OFFERING; +import static net.unicon.lti.utils.LtiStrings.LTI_DEPLOYMENT_ID; +import static net.unicon.lti.utils.LtiStrings.LTI_EMAIL; +import static net.unicon.lti.utils.LtiStrings.LTI_FAMILY_NAME; +import static net.unicon.lti.utils.LtiStrings.LTI_GIVEN_NAME; +import static net.unicon.lti.utils.LtiStrings.LTI_LINK_ID; +import static net.unicon.lti.utils.LtiStrings.LTI_LINK_TITLE; +import static net.unicon.lti.utils.LtiStrings.LTI_MESSAGE_TYPE; +import static net.unicon.lti.utils.LtiStrings.LTI_NAME; +import static net.unicon.lti.utils.LtiStrings.LTI_NONCE; +import static net.unicon.lti.utils.LtiStrings.LTI_ROLES; +import static net.unicon.lti.utils.LtiStrings.LTI_TARGET_LINK_URI; +import static net.unicon.lti.utils.LtiStrings.LTI_VERSION; +import static net.unicon.lti.utils.LtiStrings.LTI_VERSION_3; +import static net.unicon.lti.utils.TextConstants.LTI3_SUFFIX; + public class LtiOidcUtils { static final Logger log = LoggerFactory.getLogger(LtiOidcUtils.class); @@ -69,4 +96,83 @@ public static String generateState(LTIDataService ltiDataService, Map deepLinkingSettings = new HashMap<>(); + deepLinkingSettings.put("deep_link_return_url", ltiDataService.getLocalUrl() + "/app/gccoursework/" + gcCourseEntity.getGcCourseId()); + List deepLinkingResponseAcceptTypes = new ArrayList<>(); + deepLinkingResponseAcceptTypes.add("ltiResourceLink"); + deepLinkingSettings.put("accept_types", deepLinkingResponseAcceptTypes); + List deepLinkingAcceptPresentationDocumentTargets = new ArrayList<>(); + deepLinkingAcceptPresentationDocumentTargets.add("iframe"); + deepLinkingAcceptPresentationDocumentTargets.add("window"); + deepLinkingSettings.put("accept_presentation_document_targets", deepLinkingAcceptPresentationDocumentTargets); + + jwtBuilder.claim(LTI_MESSAGE_TYPE, "LtiDeepLinkingRequest"); + jwtBuilder.claim(LTI_TARGET_LINK_URI, baseTargetUri); + jwtBuilder.claim(DEEP_LINKING_SETTINGS, deepLinkingSettings); + } + + String ltiToken = jwtBuilder.signWith(SignatureAlgorithm.RS256, issPrivateKey) + .compact(); + log.debug("Internal LTI id_token: \n {} \n", ltiToken); + return ltiToken; + } } diff --git a/src/main/resources/templates/app.html b/src/main/resources/templates/app.html new file mode 100644 index 00000000..0364c897 --- /dev/null +++ b/src/main/resources/templates/app.html @@ -0,0 +1,50 @@ + + + + + + Unicon Demo App + + + + + +

+

+ +

Select the course for which you would like to add content.

+ +
+ + + + + + + +
+ + + + + + + + diff --git a/src/main/resources/templates/gcAddMaterialsResponse.html b/src/main/resources/templates/gcAddMaterialsResponse.html new file mode 100644 index 00000000..4a6ecac2 --- /dev/null +++ b/src/main/resources/templates/gcAddMaterialsResponse.html @@ -0,0 +1,34 @@ + + + + + + Unicon Demo App + + + + + +

+

+ + + + + + + + diff --git a/src/main/resources/templates/platformAuthResponse.html b/src/main/resources/templates/platformAuthResponse.html new file mode 100644 index 00000000..bd7f4fb8 --- /dev/null +++ b/src/main/resources/templates/platformAuthResponse.html @@ -0,0 +1,30 @@ + + + + + +
+ + +
+ + + + diff --git a/src/main/resources/templates/platformOIDCLoginRequest.html b/src/main/resources/templates/platformOIDCLoginRequest.html new file mode 100644 index 00000000..3ea07d55 --- /dev/null +++ b/src/main/resources/templates/platformOIDCLoginRequest.html @@ -0,0 +1,34 @@ + + + + + +
+ + + + + + +
+ + + +