frontend, api, db: add single sign-on

Closes https://gitlab.com/commento/commento/issues/90
This commit is contained in:
Adhityaa Chandrasekar 2019-04-20 20:34:25 -04:00
parent 536ec14b93
commit 1d1cd46c2b
16 changed files with 410 additions and 11 deletions

View File

@ -173,6 +173,7 @@ func commentListHandler(w http.ResponseWriter, r *http.Request) {
"twitter": twitterConfigured && d.TwitterProvider, "twitter": twitterConfigured && d.TwitterProvider,
"github": githubConfigured && d.GithubProvider, "github": githubConfigured && d.GithubProvider,
"gitlab": gitlabConfigured && d.GitlabProvider, "gitlab": gitlabConfigured && d.GitlabProvider,
"sso": d.SsoProvider,
}, },
}) })
} }

View File

@ -22,4 +22,7 @@ type domain struct {
TwitterProvider bool `json:"twitterProvider"` TwitterProvider bool `json:"twitterProvider"`
GithubProvider bool `json:"githubProvider"` GithubProvider bool `json:"githubProvider"`
GitlabProvider bool `json:"gitlabProvider"` GitlabProvider bool `json:"gitlabProvider"`
SsoProvider bool `json:"ssoProvider"`
SsoSecret string `json:"ssoSecret"`
SsoUrl string `json:"ssoUrl"`
} }

View File

@ -24,7 +24,10 @@ func domainGet(dmn string) (domain, error) {
googleProvider, googleProvider,
twitterProvider, twitterProvider,
githubProvider, githubProvider,
gitlabProvider gitlabProvider,
ssoProvider,
ssoSecret,
ssoUrl
FROM domains FROM domains
WHERE domain = $1; WHERE domain = $1;
` `
@ -48,7 +51,10 @@ func domainGet(dmn string) (domain, error) {
&d.GoogleProvider, &d.GoogleProvider,
&d.TwitterProvider, &d.TwitterProvider,
&d.GithubProvider, &d.GithubProvider,
&d.GitlabProvider); err != nil { &d.GitlabProvider,
&d.SsoProvider,
&d.SsoSecret,
&d.SsoUrl); err != nil {
return d, errorNoSuchDomain return d, errorNoSuchDomain
} }

View File

@ -26,7 +26,10 @@ func domainList(ownerHex string) ([]domain, error) {
googleProvider, googleProvider,
twitterProvider, twitterProvider,
githubProvider, githubProvider,
gitlabProvider gitlabProvider,
ssoProvider,
ssoSecret,
ssoUrl
FROM domains FROM domains
WHERE ownerHex=$1; WHERE ownerHex=$1;
` `
@ -56,7 +59,10 @@ func domainList(ownerHex string) ([]domain, error) {
&d.GoogleProvider, &d.GoogleProvider,
&d.TwitterProvider, &d.TwitterProvider,
&d.GithubProvider, &d.GithubProvider,
&d.GitlabProvider); err != nil { &d.GitlabProvider,
&d.SsoProvider,
&d.SsoSecret,
&d.SsoUrl); err != nil {
logger.Errorf("cannot Scan domain: %v", err) logger.Errorf("cannot Scan domain: %v", err)
return nil, errorInternal return nil, errorInternal
} }

69
api/domain_sso.go Normal file
View File

@ -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})
}

View File

@ -5,6 +5,10 @@ import (
) )
func domainUpdate(d domain) error { func domainUpdate(d domain) error {
if d.SsoProvider && d.SsoUrl == "" {
return errorMissingField
}
statement := ` statement := `
UPDATE domains UPDATE domains
SET SET
@ -19,7 +23,9 @@ func domainUpdate(d domain) error {
googleProvider=$10, googleProvider=$10,
twitterProvider=$11, twitterProvider=$11,
githubProvider=$12, githubProvider=$12,
gitlabProvider=$13 gitlabProvider=$13,
ssoProvider=$14,
ssoUrl=$15
WHERE domain=$1; WHERE domain=$1;
` `
@ -36,7 +42,9 @@ func domainUpdate(d domain) error {
d.GoogleProvider, d.GoogleProvider,
d.TwitterProvider, d.TwitterProvider,
d.GithubProvider, d.GithubProvider,
d.GitlabProvider) d.GitlabProvider,
d.SsoProvider,
d.SsoUrl)
if err != nil { if err != nil {
logger.Errorf("cannot update non-moderators: %v", err) logger.Errorf("cannot update non-moderators: %v", err)
return errorInternal return errorInternal

10
api/oauth_sso.go Normal file
View File

@ -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"`
}

110
api/oauth_sso_callback.go Normal file
View File

@ -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, "<html><script>window.parent.close()</script></html>")
}

82
api/oauth_sso_redirect.go Normal file
View File

@ -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)
}

View File

@ -15,6 +15,7 @@ func apiRouterInit(router *mux.Router) error {
router.HandleFunc("/api/domain/new", domainNewHandler).Methods("POST") router.HandleFunc("/api/domain/new", domainNewHandler).Methods("POST")
router.HandleFunc("/api/domain/delete", domainDeleteHandler).Methods("POST") router.HandleFunc("/api/domain/delete", domainDeleteHandler).Methods("POST")
router.HandleFunc("/api/domain/clear", domainClearHandler).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/list", domainListHandler).Methods("POST")
router.HandleFunc("/api/domain/update", domainUpdateHandler).Methods("POST") router.HandleFunc("/api/domain/update", domainUpdateHandler).Methods("POST")
router.HandleFunc("/api/domain/moderator/new", domainModeratorNewHandler).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/redirect", gitlabRedirectHandler).Methods("GET")
router.HandleFunc("/api/oauth/gitlab/callback", gitlabCallbackHandler).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/new", commentNewHandler).Methods("POST")
router.HandleFunc("/api/comment/list", commentListHandler).Methods("POST") router.HandleFunc("/api/comment/list", commentListHandler).Methods("POST")
router.HandleFunc("/api/comment/count", commentCountHandler).Methods("POST") router.HandleFunc("/api/comment/count", commentCountHandler).Methods("POST")

10
db/20190420181913-sso.sql Normal file
View File

@ -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 '';

View File

@ -292,6 +292,29 @@
<label for="gitlab-provider">GitLab login</label> <label for="gitlab-provider">GitLab login</label>
</div> </div>
<div class="row no-border commento-round-check">
<input type="checkbox" v-model="domains[cd].ssoProvider" id="sso-provider" @change="window.commento.ssoProviderChangeHandler()">
<label for="sso-provider">Single sign-on</label>
</div>
<div class="indent" v-if="domains[cd].ssoProvider">
<div class="row">
<div class="label">HMAC shared secret key</div>
<input class="input gray-input monospace" id="sso-secret" readonly="true" type="text" placeholder="Loading..." v-model="domains[cd].ssoSecret">
</div>
<div class="row">
<div class="label">Redirect URL</div>
<input class="input gray-input" id="sso-url" type="text" :placeholder="domains[cd].ssoUrl" v-model="domains[cd].ssoUrl">
</div>
<div class="normal-text">
<div class="subtext-container">
<div class="subtext">
Read the Commento documentation <a href="https://docs.commento.io/configuration/frontend/sso.html">on single sign-on</a>.
</div>
</div>
</div>
</div>
<div class="warning" v-if="!domains[cd].allowAnonymous && !domains[cd].commentoProvider && !domains[cd].googleProvider && !domains[cd].twitterProvider && !domains[cd].githubProvider && !domains[cd].gitlabProvider"> <div class="warning" v-if="!domains[cd].allowAnonymous && !domains[cd].commentoProvider && !domains[cd].googleProvider && !domains[cd].twitterProvider && !domains[cd].githubProvider && !domains[cd].gitlabProvider">
You have disabled all authentication options. Your readers will not be able to login, create comments, or vote. You have disabled all authentication options. Your readers will not be able to login, create comments, or vote.
</div> </div>

View File

@ -1214,10 +1214,14 @@
global.popupRender = function(id) { global.popupRender = function(id) {
var loginBoxContainer = $(ID_LOGIN_BOX_CONTAINER); var loginBoxContainer = $(ID_LOGIN_BOX_CONTAINER);
var loginBox = create("div"); 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 oauthSubtitle = create("div");
var oauthButtonsContainer = create("div"); var oauthButtonsContainer = create("div");
var oauthButtons = create("div"); var oauthButtons = create("div");
var hr = create("hr"); var hr2 = create("hr");
var emailSubtitle = create("div"); var emailSubtitle = create("div");
var emailContainer = create("div"); var emailContainer = create("div");
var email = create("div"); var email = create("div");
@ -1233,7 +1237,7 @@
emailButton.id = ID_LOGIN_BOX_EMAIL_BUTTON; emailButton.id = ID_LOGIN_BOX_EMAIL_BUTTON;
loginLink.id = ID_LOGIN_BOX_LOGIN_LINK; loginLink.id = ID_LOGIN_BOX_LOGIN_LINK;
loginLinkContainer.id = ID_LOGIN_BOX_LOGIN_LINK_CONTAINER; 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; oauthSubtitle.id = ID_LOGIN_BOX_OAUTH_PRETEXT;
oauthButtonsContainer.id = ID_LOGIN_BOX_OAUTH_BUTTONS_CONTAINER; oauthButtonsContainer.id = ID_LOGIN_BOX_OAUTH_BUTTONS_CONTAINER;
@ -1246,6 +1250,9 @@
classAdd(emailButton, "email-button"); classAdd(emailButton, "email-button");
classAdd(loginLinkContainer, "login-link-container"); classAdd(loginLinkContainer, "login-link-container");
classAdd(loginLink, "login-link"); classAdd(loginLink, "login-link");
classAdd(ssoSubtitle, "login-box-subtitle");
classAdd(ssoButtonContainer, "oauth-buttons-container");
classAdd(ssoButton, "oauth-buttons");
classAdd(oauthSubtitle, "login-box-subtitle"); classAdd(oauthSubtitle, "login-box-subtitle");
classAdd(oauthButtonsContainer, "oauth-buttons-container"); classAdd(oauthButtonsContainer, "oauth-buttons-container");
classAdd(oauthButtons, "oauth-buttons"); classAdd(oauthButtons, "oauth-buttons");
@ -1256,6 +1263,7 @@
emailSubtitle.innerText = "Login with your email address"; emailSubtitle.innerText = "Login with your email address";
emailButton.innerText = "Continue"; emailButton.innerText = "Continue";
oauthSubtitle.innerText = "Proceed with social login"; oauthSubtitle.innerText = "Proceed with social login";
ssoSubtitle.innerText = "Proceed with " + parent.location.host + " authentication";
onclick(emailButton, global.passwordAsk, id); onclick(emailButton, global.passwordAsk, id);
onclick(loginLink, global.popupSwitch); onclick(loginLink, global.popupSwitch);
@ -1274,7 +1282,7 @@
var button = create("button"); var button = create("button");
classAdd(button, "button"); classAdd(button, "button");
classAdd(button, provider+ "-button"); classAdd(button, provider + "-button");
button.innerText = provider; 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) { if (numOauthConfigured > 0) {
append(loginBox, oauthSubtitle); append(loginBox, oauthSubtitle);
append(oauthButtonsContainer, oauthButtons); append(oauthButtonsContainer, oauthButtons);
@ -1301,7 +1329,7 @@
append(loginLinkContainer, loginLink); append(loginLinkContainer, loginLink);
if (numOauthConfigured > 0 && configuredOauths["commento"]) { if (numOauthConfigured > 0 && configuredOauths["commento"]) {
append(loginBox, hr); append(loginBox, hr2);
} }
if (configuredOauths["commento"]) { if (configuredOauths["commento"]) {

View File

@ -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)); } (window.commento, document));

View File

@ -37,5 +37,12 @@
font-size: 13px; font-size: 13px;
width: 70px; width: 70px;
} }
.commento-sso-button {
background: #000000;
text-transform: uppercase;
font-size: 13px;
width: 200px;
}
} }
} }

View File

@ -367,6 +367,10 @@ body {
font-size: 13px; font-size: 13px;
line-height: 17px; line-height: 17px;
text-align: center; text-align: center;
a {
border: none;
}
} }
} }
} }
@ -540,6 +544,11 @@ body {
.input::placeholder { .input::placeholder {
color: $gray-4; color: $gray-4;
} }
.monospace {
font-family: "Source Code Pro", monospace;
font-size: 11px;
}
} }
.theme { .theme {
@ -598,7 +607,7 @@ body {
.indent { .indent {
margin-top: 0px; margin-top: 0px;
padding-left: 32px; padding-left: 35px;
} }
.stat { .stat {