From 7530016ece1a190387de6dd62a3c3c026d014153 Mon Sep 17 00:00:00 2001 From: Hiroki Terashima Date: Tue, 10 Nov 2020 13:58:19 -0800 Subject: [PATCH 1/8] Added SSO endpoint for Discourse --- .../controllers/DiscourseSSOController.java | 100 ++++++++++++++++++ .../DiscourseSSOControllerTest.java | 41 +++++++ 2 files changed, 141 insertions(+) create mode 100644 src/main/java/org/wise/portal/presentation/web/controllers/DiscourseSSOController.java create mode 100644 src/test/java/org/wise/portal/presentation/web/controllers/DiscourseSSOControllerTest.java diff --git a/src/main/java/org/wise/portal/presentation/web/controllers/DiscourseSSOController.java b/src/main/java/org/wise/portal/presentation/web/controllers/DiscourseSSOController.java new file mode 100644 index 0000000000..5d6af5e208 --- /dev/null +++ b/src/main/java/org/wise/portal/presentation/web/controllers/DiscourseSSOController.java @@ -0,0 +1,100 @@ +package org.wise.portal.presentation.web.controllers; + +import java.io.UnsupportedEncodingException; +import java.net.URLEncoder; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; +import java.util.Properties; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import javax.servlet.http.HttpServletRequest; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.core.Authentication; +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.servlet.ModelAndView; +import org.springframework.web.servlet.view.RedirectView; +import org.wise.portal.domain.authentication.MutableUserDetails; +import org.wise.portal.domain.user.User; +import org.wise.portal.presentation.util.http.Base64; +import org.wise.portal.service.user.UserService; + +@Controller +public class DiscourseSSOController { + + @Autowired + Properties appProperties; + + @Autowired + UserService userService; + + @GetMapping("/sso/discourse") + protected ModelAndView discourseSSOLogin(@RequestParam("sso") String base64EncodedSSO, + @RequestParam("sig") String sigParam, Authentication auth) throws Exception { + String secretKey = appProperties.getProperty("discourse_sso_secret_key"); + String discourseURL = appProperties.getProperty("discourse_url"); + if (secretKey == null || secretKey.isEmpty() || discourseURL == null || discourseURL.isEmpty()) { + return null; + } + String base64DecodedSSO = new String(Base64.decode(base64EncodedSSO), "UTF-8"); + if (!base64DecodedSSO.startsWith("nonce=")) { + return null; + } + String nonce = base64DecodedSSO.substring(6); + String algorithm = "HmacSHA256"; + String hMACSHA256Message = hmacDigest(base64EncodedSSO, secretKey, algorithm); + if (!hMACSHA256Message.equals(sigParam)) { + return null; + } + User user = userService.retrieveUserByUsername(auth.getName()); + return new ModelAndView(new RedirectView( + generateDiscourseSSOLoginURL(secretKey, discourseURL, nonce, algorithm, user))); + } + + private String generateDiscourseSSOLoginURL(String secretKey, String discourseURL, String nonce, + String algorithm, User user) throws UnsupportedEncodingException { + String wiseUserId = URLEncoder.encode(user.getId().toString(), "UTF-8"); + MutableUserDetails userDetails = user.getUserDetails(); + String username = URLEncoder.encode(userDetails.getUsername(), "UTF-8"); + String name = + URLEncoder.encode(userDetails.getFirstname() + " " + userDetails.getLastname(), "UTF-8"); + String email = URLEncoder.encode(userDetails.getEmailAddress(), "UTF-8"); + String payLoadString = "nonce=" + nonce + "&name=" + name + "&username=" + username + + "&email=" + email + "&external_id=" + wiseUserId; + String payLoadStringBase64Encoded = Base64.encodeBytes(payLoadString.getBytes()); + String payLoadStringBase64EncodedURLEncoded = + URLEncoder.encode(payLoadStringBase64Encoded, "UTF-8"); + String payLoadStringBase64EncodedHMACSHA256Signed = + hmacDigest(payLoadStringBase64Encoded, secretKey, algorithm); + String discourseSSOLoginURL = discourseURL + "/session/sso_login" + + "?sso=" + payLoadStringBase64EncodedURLEncoded + + "&sig=" + payLoadStringBase64EncodedHMACSHA256Signed; + return discourseSSOLoginURL; + } + + public static String hmacDigest(String msg, String secretKey, String algorithm) { + String digest = null; + try { + SecretKeySpec key = new SecretKeySpec((secretKey).getBytes("UTF-8"), algorithm); + Mac mac = Mac.getInstance(algorithm); + mac.init(key); + byte[] bytes = mac.doFinal(msg.getBytes("ASCII")); + StringBuffer hash = new StringBuffer(); + for (int i = 0; i < bytes.length; i++) { + String hex = Integer.toHexString(0xFF & bytes[i]); + if (hex.length() == 1) { + hash.append('0'); + } + hash.append(hex); + } + digest = hash.toString(); + } catch (UnsupportedEncodingException e) { + } catch (InvalidKeyException e) { + } catch (NoSuchAlgorithmException e) { + } + return digest; + } +} diff --git a/src/test/java/org/wise/portal/presentation/web/controllers/DiscourseSSOControllerTest.java b/src/test/java/org/wise/portal/presentation/web/controllers/DiscourseSSOControllerTest.java new file mode 100644 index 0000000000..077ffb33d2 --- /dev/null +++ b/src/test/java/org/wise/portal/presentation/web/controllers/DiscourseSSOControllerTest.java @@ -0,0 +1,41 @@ +package org.wise.portal.presentation.web.controllers; + +import static org.easymock.EasyMock.expect; +import static org.easymock.EasyMock.replay; +import static org.easymock.EasyMock.verify; +import static org.junit.Assert.assertEquals; + +import org.easymock.EasyMockRunner; +import org.easymock.TestSubject; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.springframework.web.servlet.ModelAndView; +import org.springframework.web.servlet.view.RedirectView; + +@RunWith(EasyMockRunner.class) +public class DiscourseSSOControllerTest extends APIControllerTest { + + @TestSubject + private DiscourseSSOController discourseSSOController = new DiscourseSSOController(); + + String base64EncodedSSO = "bm9uY2U9MWJmMDQwNzIzYmYwNDc2NzExZjAxMWY4MjYyNzQyMTQmcmV0dXJuX3Nzb191" + + "cmw9aHR0cCUzQSUyRiUyRmxvY2FsaG9zdCUzQTkyOTIlMkZzZXNzaW9uJTJGc3NvX2xvZ2lu"; + String sigParam = "13f83c3dc28af7c37fac8d40f7792d63bf727cc2e7b293f3669a526dd861f71d"; + String redirectURL = "http://localhost:9292/session/sso_login?sso=bm9uY2U9MWJmMDQwNzIzYmYwNDc2N" + + "zExZjAxMWY4MjYyNzQyMTQmcmV0dXJuX3Nzb191cmw9aHR0cCUzQSUyRiUyRmxvY2FsaG9zdCUzQTkyOTIlMkZzZXN" + + "zaW9uJTJGc3NvX2xvZ2luJm5hbWU9U3F1aWR3YXJkK1RlbnRhY2xlcyZ1c2VybmFtZT1TcXVpZHdhcmRUZW50YWNsZ" + + "XMmZW1haWw9JmV4dGVybmFsX2lkPTk0MjEw&sig=9e6d86e5ac58afe16acf62fe7aa11aec4ef3540a8eb7c0d56a" + + "a6c585b90bee61"; + + @Test + public void discourseSSOLogin_ValidArgs_ReturnRedirectURL() throws Exception { + expect(userService.retrieveUserByUsername(teacherAuth.getName())).andReturn(teacher1); + expect(appProperties.getProperty("discourse_sso_secret_key")).andReturn("do_the_right_thing"); + expect(appProperties.getProperty("discourse_url")).andReturn("http://localhost:9292"); + replay(userService, appProperties); + ModelAndView discourseSSOLoginRedirect = + discourseSSOController.discourseSSOLogin(base64EncodedSSO, sigParam, teacherAuth); + assertEquals(redirectURL, ((RedirectView) discourseSSOLoginRedirect.getView()).getUrl()); + verify(userService, appProperties); + } +} From ff90aaa5a11533aaa01eb55134c3d660c0523da2 Mon Sep 17 00:00:00 2001 From: Hiroki Terashima Date: Fri, 13 Nov 2020 09:17:15 -0800 Subject: [PATCH 2/8] Added DiscourseRecentActivityComponent to display latest 3 topics from a linked discourse instance. #2806 --- .../controllers/DiscourseSSOController.java | 94 +++++++++---------- .../controllers/user/UserAPIController.java | 1 + .../portal/spring/impl/WebSecurityConfig.java | 3 +- src/main/webapp/site/src/app/domain/config.ts | 1 + .../site/src/app/services/config.service.ts | 4 + .../discourse-recent-activity.component.html | 10 ++ ...iscourse-recent-activity.component.spec.ts | 42 +++++++++ .../discourse-recent-activity.component.ts | 39 ++++++++ .../teacher-home/teacher-home.component.html | 1 + .../teacher-home.component.spec.ts | 5 + .../teacher-home/teacher-home.component.ts | 4 +- .../site/src/app/teacher/teacher.module.ts | 2 + src/main/webapp/site/src/messages.xlf | 11 ++- .../DiscourseSSOControllerTest.java | 5 +- .../user/UserAPIControllerTest.java | 1 + 15 files changed, 168 insertions(+), 55 deletions(-) create mode 100644 src/main/webapp/site/src/app/teacher/discourse-recent-activity/discourse-recent-activity.component.html create mode 100644 src/main/webapp/site/src/app/teacher/discourse-recent-activity/discourse-recent-activity.component.spec.ts create mode 100644 src/main/webapp/site/src/app/teacher/discourse-recent-activity/discourse-recent-activity.component.ts diff --git a/src/main/java/org/wise/portal/presentation/web/controllers/DiscourseSSOController.java b/src/main/java/org/wise/portal/presentation/web/controllers/DiscourseSSOController.java index 5d6af5e208..93d1c766e5 100644 --- a/src/main/java/org/wise/portal/presentation/web/controllers/DiscourseSSOController.java +++ b/src/main/java/org/wise/portal/presentation/web/controllers/DiscourseSSOController.java @@ -8,14 +8,12 @@ import javax.crypto.Mac; import javax.crypto.spec.SecretKeySpec; -import javax.servlet.http.HttpServletRequest; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.security.core.Authentication; import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.servlet.ModelAndView; import org.springframework.web.servlet.view.RedirectView; import org.wise.portal.domain.authentication.MutableUserDetails; import org.wise.portal.domain.user.User; @@ -32,39 +30,39 @@ public class DiscourseSSOController { UserService userService; @GetMapping("/sso/discourse") - protected ModelAndView discourseSSOLogin(@RequestParam("sso") String base64EncodedSSO, - @RequestParam("sig") String sigParam, Authentication auth) throws Exception { - String secretKey = appProperties.getProperty("discourse_sso_secret_key"); - String discourseURL = appProperties.getProperty("discourse_url"); - if (secretKey == null || secretKey.isEmpty() || discourseURL == null || discourseURL.isEmpty()) { - return null; - } - String base64DecodedSSO = new String(Base64.decode(base64EncodedSSO), "UTF-8"); - if (!base64DecodedSSO.startsWith("nonce=")) { - return null; - } - String nonce = base64DecodedSSO.substring(6); - String algorithm = "HmacSHA256"; - String hMACSHA256Message = hmacDigest(base64EncodedSSO, secretKey, algorithm); - if (!hMACSHA256Message.equals(sigParam)) { - return null; - } - User user = userService.retrieveUserByUsername(auth.getName()); - return new ModelAndView(new RedirectView( - generateDiscourseSSOLoginURL(secretKey, discourseURL, nonce, algorithm, user))); - } + protected RedirectView discourseSSOLogin(@RequestParam("sso") String base64EncodedSSO, + @RequestParam("sig") String sigParam, Authentication auth) throws Exception { + String secretKey = appProperties.getProperty("discourse_sso_secret_key"); + String discourseURL = appProperties.getProperty("discourse_url"); + if (secretKey == null || secretKey.isEmpty() || discourseURL == null || discourseURL.isEmpty()) { + return null; + } + String base64DecodedSSO = new String(Base64.decode(base64EncodedSSO), "UTF-8"); + if (!base64DecodedSSO.startsWith("nonce=")) { + return null; + } + String nonce = base64DecodedSSO.substring(6); + String algorithm = "HmacSHA256"; + String hMACSHA256Message = hmacDigest(base64EncodedSSO, secretKey, algorithm); + if (!hMACSHA256Message.equals(sigParam)) { + return null; + } + User user = userService.retrieveUserByUsername(auth.getName()); + return new RedirectView( + generateDiscourseSSOLoginURL(secretKey, discourseURL, nonce, algorithm, user)); + } private String generateDiscourseSSOLoginURL(String secretKey, String discourseURL, String nonce, String algorithm, User user) throws UnsupportedEncodingException { String wiseUserId = URLEncoder.encode(user.getId().toString(), "UTF-8"); MutableUserDetails userDetails = user.getUserDetails(); - String username = URLEncoder.encode(userDetails.getUsername(), "UTF-8"); + String username = URLEncoder.encode(userDetails.getUsername(), "UTF-8"); String name = URLEncoder.encode(userDetails.getFirstname() + " " + userDetails.getLastname(), "UTF-8"); - String email = URLEncoder.encode(userDetails.getEmailAddress(), "UTF-8"); + String email = URLEncoder.encode(userDetails.getEmailAddress(), "UTF-8"); String payLoadString = "nonce=" + nonce + "&name=" + name + "&username=" + username + "&email=" + email + "&external_id=" + wiseUserId; - String payLoadStringBase64Encoded = Base64.encodeBytes(payLoadString.getBytes()); + String payLoadStringBase64Encoded = Base64.encodeBytes(payLoadString.getBytes()); String payLoadStringBase64EncodedURLEncoded = URLEncoder.encode(payLoadStringBase64Encoded, "UTF-8"); String payLoadStringBase64EncodedHMACSHA256Signed = @@ -75,26 +73,26 @@ private String generateDiscourseSSOLoginURL(String secretKey, String discourseUR return discourseSSOLoginURL; } - public static String hmacDigest(String msg, String secretKey, String algorithm) { - String digest = null; - try { - SecretKeySpec key = new SecretKeySpec((secretKey).getBytes("UTF-8"), algorithm); - Mac mac = Mac.getInstance(algorithm); - mac.init(key); - byte[] bytes = mac.doFinal(msg.getBytes("ASCII")); - StringBuffer hash = new StringBuffer(); - for (int i = 0; i < bytes.length; i++) { - String hex = Integer.toHexString(0xFF & bytes[i]); - if (hex.length() == 1) { - hash.append('0'); - } - hash.append(hex); - } - digest = hash.toString(); - } catch (UnsupportedEncodingException e) { - } catch (InvalidKeyException e) { - } catch (NoSuchAlgorithmException e) { - } - return digest; - } + public static String hmacDigest(String msg, String secretKey, String algorithm) { + String digest = null; + try { + SecretKeySpec key = new SecretKeySpec((secretKey).getBytes("UTF-8"), algorithm); + Mac mac = Mac.getInstance(algorithm); + mac.init(key); + byte[] bytes = mac.doFinal(msg.getBytes("ASCII")); + StringBuffer hash = new StringBuffer(); + for (int i = 0; i < bytes.length; i++) { + String hex = Integer.toHexString(0xFF & bytes[i]); + if (hex.length() == 1) { + hash.append('0'); + } + hash.append(hex); + } + digest = hash.toString(); + } catch (UnsupportedEncodingException e) { + } catch (InvalidKeyException e) { + } catch (NoSuchAlgorithmException e) { + } + return digest; + } } diff --git a/src/main/java/org/wise/portal/presentation/web/controllers/user/UserAPIController.java b/src/main/java/org/wise/portal/presentation/web/controllers/user/UserAPIController.java index 50c9eeb233..f1787c5e00 100644 --- a/src/main/java/org/wise/portal/presentation/web/controllers/user/UserAPIController.java +++ b/src/main/java/org/wise/portal/presentation/web/controllers/user/UserAPIController.java @@ -134,6 +134,7 @@ protected HashMap getConfig(HttpServletRequest request) { config.put("logOutURL", contextPath + "/logout"); config.put("recaptchaPublicKey", appProperties.get("recaptcha_public_key")); config.put("wise4Hostname", appProperties.get("wise4.hostname")); + config.put("discourseURL", appProperties.getOrDefault("discourse_url", null)); return config; } diff --git a/src/main/java/org/wise/portal/spring/impl/WebSecurityConfig.java b/src/main/java/org/wise/portal/spring/impl/WebSecurityConfig.java index e2a7392599..d792511379 100644 --- a/src/main/java/org/wise/portal/spring/impl/WebSecurityConfig.java +++ b/src/main/java/org/wise/portal/spring/impl/WebSecurityConfig.java @@ -94,6 +94,7 @@ protected void configure(HttpSecurity http) throws Exception { .antMatchers("/student/**").hasAnyRole("STUDENT") .antMatchers("/studentStatus").hasAnyRole("TEACHER,STUDENT") .antMatchers("/teacher/**").hasAnyRole("TEACHER") + .antMatchers("/sso/discourse").hasAnyRole("TEACHER,STUDENT") .antMatchers("/").permitAll(); http.formLogin().loginPage("/login").permitAll(); http.sessionManagement().maximumSessions(2).sessionRegistry(sessionRegistry()); @@ -182,7 +183,7 @@ public AuthenticationSuccessHandler authSuccessHandler() { @Bean public AuthenticationFailureHandler authFailureHandler() { WISEAuthenticationFailureHandler handler = new WISEAuthenticationFailureHandler(); - handler.setAuthenticationFailureUrl("/legacy/login?failed=true"); + handler.setAuthenticationFailureUrl("/login?failed=true"); return handler; } diff --git a/src/main/webapp/site/src/app/domain/config.ts b/src/main/webapp/site/src/app/domain/config.ts index 0e982145d4..855805ccc4 100644 --- a/src/main/webapp/site/src/app/domain/config.ts +++ b/src/main/webapp/site/src/app/domain/config.ts @@ -7,4 +7,5 @@ export class Config { logOutURL: string; currentTime: number; wise4Hostname?: string; + discourseURL?: string; } diff --git a/src/main/webapp/site/src/app/services/config.service.ts b/src/main/webapp/site/src/app/services/config.service.ts index dc5118b854..97862ec616 100644 --- a/src/main/webapp/site/src/app/services/config.service.ts +++ b/src/main/webapp/site/src/app/services/config.service.ts @@ -32,6 +32,10 @@ export class ConfigService { return this.config$.getValue().contextPath; } + getDiscourseURL() { + return this.config$.getValue().discourseURL; + } + getGoogleAnalyticsId() { return this.config$.getValue().googleAnalyticsId; } diff --git a/src/main/webapp/site/src/app/teacher/discourse-recent-activity/discourse-recent-activity.component.html b/src/main/webapp/site/src/app/teacher/discourse-recent-activity/discourse-recent-activity.component.html new file mode 100644 index 0000000000..0478120f55 --- /dev/null +++ b/src/main/webapp/site/src/app/teacher/discourse-recent-activity/discourse-recent-activity.component.html @@ -0,0 +1,10 @@ +Recent Posts in Teacher Community +
+ + {{topic.title}} + + + {{getName(poster.user_id)}} + + {{topic.last_posted_at | date:'yyyy-MM-dd'}} +
diff --git a/src/main/webapp/site/src/app/teacher/discourse-recent-activity/discourse-recent-activity.component.spec.ts b/src/main/webapp/site/src/app/teacher/discourse-recent-activity/discourse-recent-activity.component.spec.ts new file mode 100644 index 0000000000..de44617ab4 --- /dev/null +++ b/src/main/webapp/site/src/app/teacher/discourse-recent-activity/discourse-recent-activity.component.spec.ts @@ -0,0 +1,42 @@ +import { HttpClientTestingModule, HttpTestingController } from "@angular/common/http/testing"; +import { TestBed } from "@angular/core/testing"; +import { ConfigService } from "../../services/config.service"; +import { DiscourseRecentActivityComponent } from "./discourse-recent-activity.component"; + +describe('DiscourseRecentActivityComponent', () => { + let component: DiscourseRecentActivityComponent; + let configService: ConfigService; + let http: HttpTestingController; + const discourseURL = 'http://localhost:9292'; + const sampleLatestResponse = { + users: [], + topic_list: { + topics: [{id:1},{id:2},{id:3},{id:4},{id:5}] + } + }; + + class MockConfigService { + getDiscourseURL() { + return discourseURL; + } + } + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [ HttpClientTestingModule ], + providers: [ + DiscourseRecentActivityComponent, + { provide: ConfigService, useClass: MockConfigService } + ] + }); + component = TestBed.inject(DiscourseRecentActivityComponent); + configService = TestBed.inject(ConfigService); + http = TestBed.inject(HttpTestingController); + }); + + it('should create and show 3 latest topics', () => { + component.ngOnInit(); + http.expectOne(`${discourseURL}/latest.json?order=activity`).flush(sampleLatestResponse); + expect(component.topics.length).toEqual(3); + }); +}); diff --git a/src/main/webapp/site/src/app/teacher/discourse-recent-activity/discourse-recent-activity.component.ts b/src/main/webapp/site/src/app/teacher/discourse-recent-activity/discourse-recent-activity.component.ts new file mode 100644 index 0000000000..e264400b14 --- /dev/null +++ b/src/main/webapp/site/src/app/teacher/discourse-recent-activity/discourse-recent-activity.component.ts @@ -0,0 +1,39 @@ +import { HttpClient } from "@angular/common/http"; +import { Component } from "@angular/core"; +import { ConfigService } from "../../services/config.service"; + +@Component({ + selector: 'discourse-recent-activity', + templateUrl: 'discourse-recent-activity.component.html' +}) +export class DiscourseRecentActivityComponent { + + discourseURL: string; + topics: any; + users: any; + + constructor(private configService: ConfigService, private http: HttpClient) { + } + + ngOnInit() { + this.discourseURL = this.configService.getDiscourseURL(); + this.http.get(`${this.discourseURL}/latest.json?order=activity`) + .subscribe(({topic_list, users}: any)=> { + this.topics = topic_list.topics.slice(0,3); + this.users = users; + }); + } + + getName(userId: number): string { + for (const user of this.users) { + if (user.id === userId) { + return user.name; + } + } + return ""; + } + + launchDiscourse(): void { + window.open(`${this.discourseURL}/session/sso`); + } +} diff --git a/src/main/webapp/site/src/app/teacher/teacher-home/teacher-home.component.html b/src/main/webapp/site/src/app/teacher/teacher-home/teacher-home.component.html index d85b125f61..97421927e7 100644 --- a/src/main/webapp/site/src/app/teacher/teacher-home/teacher-home.component.html +++ b/src/main/webapp/site/src/app/teacher/teacher-home/teacher-home.component.html @@ -7,6 +7,7 @@ homeTeacher Home +