diff --git a/api/comment_list.go b/api/comment_list.go index 663b75a..e3ef443 100644 --- a/api/comment_list.go +++ b/api/comment_list.go @@ -173,6 +173,7 @@ func commentListHandler(w http.ResponseWriter, r *http.Request) { "twitter": twitterConfigured && d.TwitterProvider, "github": githubConfigured && d.GithubProvider, "gitlab": gitlabConfigured && d.GitlabProvider, + "sso": d.SsoProvider, }, }) } diff --git a/api/domain.go b/api/domain.go index 1316ca3..4f1ba8c 100644 --- a/api/domain.go +++ b/api/domain.go @@ -22,4 +22,7 @@ type domain struct { TwitterProvider bool `json:"twitterProvider"` GithubProvider bool `json:"githubProvider"` GitlabProvider bool `json:"gitlabProvider"` + SsoProvider bool `json:"ssoProvider"` + SsoSecret string `json:"ssoSecret"` + SsoUrl string `json:"ssoUrl"` } diff --git a/api/domain_get.go b/api/domain_get.go index 4f38b90..d856425 100644 --- a/api/domain_get.go +++ b/api/domain_get.go @@ -24,7 +24,10 @@ func domainGet(dmn string) (domain, error) { googleProvider, twitterProvider, githubProvider, - gitlabProvider + gitlabProvider, + ssoProvider, + ssoSecret, + ssoUrl FROM domains WHERE domain = $1; ` @@ -48,7 +51,10 @@ func domainGet(dmn string) (domain, error) { &d.GoogleProvider, &d.TwitterProvider, &d.GithubProvider, - &d.GitlabProvider); err != nil { + &d.GitlabProvider, + &d.SsoProvider, + &d.SsoSecret, + &d.SsoUrl); err != nil { return d, errorNoSuchDomain } diff --git a/api/domain_list.go b/api/domain_list.go index 8a48d4c..85c654d 100644 --- a/api/domain_list.go +++ b/api/domain_list.go @@ -26,7 +26,10 @@ func domainList(ownerHex string) ([]domain, error) { googleProvider, twitterProvider, githubProvider, - gitlabProvider + gitlabProvider, + ssoProvider, + ssoSecret, + ssoUrl FROM domains WHERE ownerHex=$1; ` @@ -56,7 +59,10 @@ func domainList(ownerHex string) ([]domain, error) { &d.GoogleProvider, &d.TwitterProvider, &d.GithubProvider, - &d.GitlabProvider); err != nil { + &d.GitlabProvider, + &d.SsoProvider, + &d.SsoSecret, + &d.SsoUrl); err != nil { logger.Errorf("cannot Scan domain: %v", err) return nil, errorInternal } diff --git a/api/domain_sso.go b/api/domain_sso.go new file mode 100644 index 0000000..0b74976 --- /dev/null +++ b/api/domain_sso.go @@ -0,0 +1,69 @@ +package main + +import ( + "net/http" +) + +func domainSsoNew(domain string) (string, error) { + if domain == "" { + return "", errorMissingField + } + + ssoSecret, err := randomHex(32) + if err != nil { + logger.Errorf("error generating SSO secret hex: %v", err) + return "", errorInternal + } + + statement := ` + UPDATE domains + SET ssoSecret = $2 + WHERE domain = $1; + ` + _, err = db.Exec(statement, domain, ssoSecret) + if err != nil { + logger.Errorf("cannot update ssoSecret: %v", err) + return "", errorInternal + } + + return ssoSecret, nil +} + +func domainSsoNewHandler(w http.ResponseWriter, r *http.Request) { + type request struct { + OwnerToken *string `json:"ownerToken"` + Domain *string `json:"domain"` + } + + var x request + if err := bodyUnmarshal(r, &x); err != nil { + bodyMarshal(w, response{"success": false, "message": err.Error()}) + return + } + + o, err := ownerGetByOwnerToken(*x.OwnerToken) + if err != nil { + bodyMarshal(w, response{"success": false, "message": err.Error()}) + return + } + + domain := domainStrip(*x.Domain) + isOwner, err := domainOwnershipVerify(o.OwnerHex, domain) + if err != nil { + bodyMarshal(w, response{"success": false, "message": err.Error()}) + return + } + + if !isOwner { + bodyMarshal(w, response{"success": false, "message": errorNotAuthorised.Error()}) + return + } + + ssoSecret, err := domainSsoNew(domain) + if err != nil { + bodyMarshal(w, response{"success": false, "message": err.Error()}) + return + } + + bodyMarshal(w, response{"success": true, "ssoSecret": ssoSecret}) +} diff --git a/api/domain_update.go b/api/domain_update.go index a60fcca..c5a808e 100644 --- a/api/domain_update.go +++ b/api/domain_update.go @@ -5,6 +5,10 @@ import ( ) func domainUpdate(d domain) error { + if d.SsoProvider && d.SsoUrl == "" { + return errorMissingField + } + statement := ` UPDATE domains SET @@ -19,7 +23,9 @@ func domainUpdate(d domain) error { googleProvider=$10, twitterProvider=$11, githubProvider=$12, - gitlabProvider=$13 + gitlabProvider=$13, + ssoProvider=$14, + ssoUrl=$15 WHERE domain=$1; ` @@ -36,7 +42,9 @@ func domainUpdate(d domain) error { d.GoogleProvider, d.TwitterProvider, d.GithubProvider, - d.GitlabProvider) + d.GitlabProvider, + d.SsoProvider, + d.SsoUrl) if err != nil { logger.Errorf("cannot update non-moderators: %v", err) return errorInternal diff --git a/api/oauth_sso.go b/api/oauth_sso.go new file mode 100644 index 0000000..0b370dc --- /dev/null +++ b/api/oauth_sso.go @@ -0,0 +1,10 @@ +package main + +type ssoPayload struct { + Domain string `json:"domain"` + Token string `json:"token"` + Email string `json:"email"` + Name string `json:"name"` + Link string `json:"link"` + Photo string `json:"photo"` +} diff --git a/api/oauth_sso_callback.go b/api/oauth_sso_callback.go new file mode 100644 index 0000000..914f59a --- /dev/null +++ b/api/oauth_sso_callback.go @@ -0,0 +1,110 @@ +package main + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "net/http" +) + +func ssoCallbackHandler(w http.ResponseWriter, r *http.Request) { + payloadHex := r.FormValue("payload") + signature := r.FormValue("hmac") + + payloadBytes, err := hex.DecodeString(payloadHex) + if err != nil { + fmt.Fprintf(w, "Error: invalid JSON payload hex encoding: %s\n", err.Error()) + return + } + + signatureBytes, err := hex.DecodeString(signature) + if err != nil { + fmt.Fprintf(w, "Error: invalid HMAC signature hex encoding: %s\n", err.Error()) + return + } + + payload := ssoPayload{} + err = json.Unmarshal(payloadBytes, &payload) + if err != nil { + fmt.Fprintf(w, "Error: cannot unmarshal JSON payload: %s\n", err.Error()) + return + } + + if payload.Domain == "" || payload.Token == "" || payload.Email == "" || payload.Name == "" { + fmt.Fprintf(w, "Error: %s\n", errorMissingField.Error()) + return + } + + if payload.Link == "" { + payload.Link = "undefined" + } + + if payload.Photo == "" { + payload.Photo = "undefined" + } + + d, err := domainGet(payload.Domain) + if err != nil { + if err == errorNoSuchDomain { + fmt.Fprintf(w, "Error: %s\n", err.Error()) + } else { + logger.Errorf("cannot get domain for SSO: %v", err) + fmt.Fprintf(w, "Error: %s\n", errorInternal.Error()) + } + return + } + + if d.SsoSecret == "" || d.SsoUrl == "" { + fmt.Fprintf(w, "Error: %s\n", errorMissingConfig.Error()) + return + } + + key, err := hex.DecodeString(d.SsoSecret) + if err != nil { + logger.Errorf("cannot decode SSO secret as hex: %v", err) + fmt.Fprintf(w, "Error: %s\n", err.Error()) + return + } + + h := hmac.New(sha256.New, key) + h.Write(payloadBytes) + expectedSignatureBytes := h.Sum(nil) + if !hmac.Equal(expectedSignatureBytes, signatureBytes) { + fmt.Fprintf(w, "Error: HMAC signature verification failed\n") + return + } + + _, err = commenterGetByCommenterToken(payload.Token) + if err != nil && err != errorNoSuchToken { + fmt.Fprintf(w, "Error: %s\n", err.Error()) + return + } + + c, err := commenterGetByEmail("sso:"+d.Domain, payload.Email) + if err != nil && err != errorNoSuchCommenter { + fmt.Fprintf(w, "Error: %s\n", err.Error()) + return + } + + var commenterHex string + + // TODO: in case of returning users, update the information we have on record? + if err == errorNoSuchCommenter { + commenterHex, err = commenterNew(payload.Email, payload.Name, payload.Link, payload.Photo, "sso:"+d.Domain, "") + if err != nil { + fmt.Fprintf(w, "Error: %s", err.Error()) + return + } + } else { + commenterHex = c.CommenterHex + } + + if err = commenterSessionUpdate(payload.Token, commenterHex); err != nil { + fmt.Fprintf(w, "Error: %s\n", err.Error()) + return + } + + fmt.Fprintf(w, "") +} diff --git a/api/oauth_sso_redirect.go b/api/oauth_sso_redirect.go new file mode 100644 index 0000000..92f5112 --- /dev/null +++ b/api/oauth_sso_redirect.go @@ -0,0 +1,82 @@ +package main + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "fmt" + "net/http" + "net/url" +) + +func ssoRedirectHandler(w http.ResponseWriter, r *http.Request) { + commenterToken := r.FormValue("commenterToken") + domain := r.Header.Get("Referer") + + if commenterToken == "" { + fmt.Fprintf(w, "Error: %s\n", errorMissingField.Error()) + return + } + + domain = domainStrip(domain) + if domain == "" { + fmt.Fprintf(w, "Error: No Referer header found in request\n") + return + } + + _, err := commenterGetByCommenterToken(commenterToken) + if err != nil && err != errorNoSuchToken { + fmt.Fprintf(w, "Error: %s\n", err.Error()) + return + } + + d, err := domainGet(domain) + if err != nil { + fmt.Fprintf(w, "Error: %s\n", errorNoSuchDomain.Error()) + return + } + + if !d.SsoProvider { + fmt.Fprintf(w, "Error: SSO not configured for %s\n", domain) + return + } + + if d.SsoSecret == "" || d.SsoUrl == "" { + fmt.Fprintf(w, "Error: %s\n", errorMissingConfig.Error()) + return + } + + key, err := hex.DecodeString(d.SsoSecret) + if err != nil { + logger.Errorf("cannot decode SSO secret as hex: %v", err) + fmt.Fprintf(w, "Error: %s\n", err.Error()) + return + } + + tokenBytes, err := hex.DecodeString(commenterToken) + if err != nil { + logger.Errorf("cannot decode hex commenterToken: %v", err) + fmt.Fprintf(w, "Error: %s\n", errorInternal.Error()) + return + } + + h := hmac.New(sha256.New, key) + h.Write(tokenBytes) + signature := hex.EncodeToString(h.Sum(nil)) + + u, err := url.Parse(d.SsoUrl) + if err != nil { + // this should really not be happening; we're checking if the + // passed URL is valid at domain update + logger.Errorf("cannot parse URL: %v", err) + fmt.Fprintf(w, "Error: %s\n", errorInternal.Error()) + return + } + + q := u.Query() + q.Set("token", commenterToken) + q.Set("hmac", signature) + u.RawQuery = q.Encode() + + http.Redirect(w, r, u.String(), http.StatusFound) +} diff --git a/api/router_api.go b/api/router_api.go index a6b4963..ad196e1 100644 --- a/api/router_api.go +++ b/api/router_api.go @@ -15,6 +15,7 @@ func apiRouterInit(router *mux.Router) error { router.HandleFunc("/api/domain/new", domainNewHandler).Methods("POST") router.HandleFunc("/api/domain/delete", domainDeleteHandler).Methods("POST") router.HandleFunc("/api/domain/clear", domainClearHandler).Methods("POST") + router.HandleFunc("/api/domain/sso/new", domainSsoNewHandler).Methods("POST") router.HandleFunc("/api/domain/list", domainListHandler).Methods("POST") router.HandleFunc("/api/domain/update", domainUpdateHandler).Methods("POST") router.HandleFunc("/api/domain/moderator/new", domainModeratorNewHandler).Methods("POST") @@ -46,6 +47,9 @@ func apiRouterInit(router *mux.Router) error { router.HandleFunc("/api/oauth/gitlab/redirect", gitlabRedirectHandler).Methods("GET") router.HandleFunc("/api/oauth/gitlab/callback", gitlabCallbackHandler).Methods("GET") + router.HandleFunc("/api/oauth/sso/redirect", ssoRedirectHandler).Methods("GET") + router.HandleFunc("/api/oauth/sso/callback", ssoCallbackHandler).Methods("GET") + router.HandleFunc("/api/comment/new", commentNewHandler).Methods("POST") router.HandleFunc("/api/comment/list", commentListHandler).Methods("POST") router.HandleFunc("/api/comment/count", commentCountHandler).Methods("POST") diff --git a/db/20190420181913-sso.sql b/db/20190420181913-sso.sql new file mode 100644 index 0000000..21f3d53 --- /dev/null +++ b/db/20190420181913-sso.sql @@ -0,0 +1,10 @@ +-- Single Sign-On (SSO) + +ALTER TABLE domains + ADD ssoProvider BOOLEAN NOT NULL DEFAULT false; + +ALTER TABLE domains + ADD ssoSecret TEXT NOT NULL DEFAULT ''; + +ALTER TABLE domains + ADD ssoUrl TEXT NOT NULL DEFAULT ''; diff --git a/frontend/dashboard.html b/frontend/dashboard.html index e6bfccc..9080d79 100644 --- a/frontend/dashboard.html +++ b/frontend/dashboard.html @@ -292,6 +292,29 @@ +
+ + +
+ +
+
+
HMAC shared secret key
+ +
+
+
Redirect URL
+ +
+
+
+
+ Read the Commento documentation on single sign-on. +
+
+
+
+
You have disabled all authentication options. Your readers will not be able to login, create comments, or vote.
diff --git a/frontend/js/commento.js b/frontend/js/commento.js index bbb59ab..48379e4 100644 --- a/frontend/js/commento.js +++ b/frontend/js/commento.js @@ -1214,10 +1214,14 @@ global.popupRender = function(id) { var loginBoxContainer = $(ID_LOGIN_BOX_CONTAINER); var loginBox = create("div"); + var ssoSubtitle = create("div"); + var ssoButtonContainer = create("div"); + var ssoButton = create("div"); + var hr1 = create("hr"); var oauthSubtitle = create("div"); var oauthButtonsContainer = create("div"); var oauthButtons = create("div"); - var hr = create("hr"); + var hr2 = create("hr"); var emailSubtitle = create("div"); var emailContainer = create("div"); var email = create("div"); @@ -1233,7 +1237,7 @@ emailButton.id = ID_LOGIN_BOX_EMAIL_BUTTON; loginLink.id = ID_LOGIN_BOX_LOGIN_LINK; loginLinkContainer.id = ID_LOGIN_BOX_LOGIN_LINK_CONTAINER; - hr.id = ID_LOGIN_BOX_HR; + hr2.id = ID_LOGIN_BOX_HR; oauthSubtitle.id = ID_LOGIN_BOX_OAUTH_PRETEXT; oauthButtonsContainer.id = ID_LOGIN_BOX_OAUTH_BUTTONS_CONTAINER; @@ -1246,6 +1250,9 @@ classAdd(emailButton, "email-button"); classAdd(loginLinkContainer, "login-link-container"); classAdd(loginLink, "login-link"); + classAdd(ssoSubtitle, "login-box-subtitle"); + classAdd(ssoButtonContainer, "oauth-buttons-container"); + classAdd(ssoButton, "oauth-buttons"); classAdd(oauthSubtitle, "login-box-subtitle"); classAdd(oauthButtonsContainer, "oauth-buttons-container"); classAdd(oauthButtons, "oauth-buttons"); @@ -1256,6 +1263,7 @@ emailSubtitle.innerText = "Login with your email address"; emailButton.innerText = "Continue"; oauthSubtitle.innerText = "Proceed with social login"; + ssoSubtitle.innerText = "Proceed with " + parent.location.host + " authentication"; onclick(emailButton, global.passwordAsk, id); onclick(loginLink, global.popupSwitch); @@ -1274,7 +1282,7 @@ var button = create("button"); classAdd(button, "button"); - classAdd(button, provider+ "-button"); + classAdd(button, provider + "-button"); button.innerText = provider; @@ -1285,6 +1293,26 @@ } }); + if (configuredOauths["sso"]) { + var button = create("button"); + + classAdd(button, "button"); + classAdd(button, "sso-button"); + + button.innerText = "Login with Single Sign-On"; + + onclick(button, global.commentoAuth, {"provider": "sso", "id": id}); + + append(ssoButton, button); + append(ssoButtonContainer, ssoButton); + append(loginBox, ssoSubtitle); + append(loginBox, ssoButtonContainer); + + if (numOauthConfigured > 0 || configuredOauths["commento"]) { + append(loginBox, hr1); + } + } + if (numOauthConfigured > 0) { append(loginBox, oauthSubtitle); append(oauthButtonsContainer, oauthButtons); @@ -1301,7 +1329,7 @@ append(loginLinkContainer, loginLink); if (numOauthConfigured > 0 && configuredOauths["commento"]) { - append(loginBox, hr); + append(loginBox, hr2); } if (configuredOauths["commento"]) { diff --git a/frontend/js/dashboard-general.js b/frontend/js/dashboard-general.js index cff7117..e834c4b 100644 --- a/frontend/js/dashboard-general.js +++ b/frontend/js/dashboard-general.js @@ -19,4 +19,27 @@ }); }; + global.ssoProviderChangeHandler = function() { + var data = global.dashboard.$data; + + if (data.domains[data.cd].ssoSecret === "") { + var json = { + "ownerToken": global.cookieGet("commentoOwnerToken"), + "domain": data.domains[data.cd].domain, + }; + + global.post(global.origin + "/api/domain/sso/new", json, function(resp) { + if (!resp.success) { + global.globalErrorShow(resp.message); + return; + } + + data.domains[data.cd].ssoSecret = resp.ssoSecret; + $("#sso-secret").val(data.domains[data.cd].ssoSecret); + }); + } else { + $("#sso-secret").val(data.domains[data.cd].ssoSecret); + } + }; + } (window.commento, document)); diff --git a/frontend/sass/commento-oauth.scss b/frontend/sass/commento-oauth.scss index e8accef..5f8df97 100644 --- a/frontend/sass/commento-oauth.scss +++ b/frontend/sass/commento-oauth.scss @@ -37,5 +37,12 @@ font-size: 13px; width: 70px; } + + .commento-sso-button { + background: #000000; + text-transform: uppercase; + font-size: 13px; + width: 200px; + } } } diff --git a/frontend/sass/dashboard-main.scss b/frontend/sass/dashboard-main.scss index 6a1fcd3..9bc57e9 100644 --- a/frontend/sass/dashboard-main.scss +++ b/frontend/sass/dashboard-main.scss @@ -367,6 +367,10 @@ body { font-size: 13px; line-height: 17px; text-align: center; + + a { + border: none; + } } } } @@ -540,6 +544,11 @@ body { .input::placeholder { color: $gray-4; } + + .monospace { + font-family: "Source Code Pro", monospace; + font-size: 11px; + } } .theme { @@ -598,7 +607,7 @@ body { .indent { margin-top: 0px; - padding-left: 32px; + padding-left: 35px; } .stat {