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,
"github": githubConfigured && d.GithubProvider,
"gitlab": gitlabConfigured && d.GitlabProvider,
"sso": d.SsoProvider,
},
})
}

View File

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

View File

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

View File

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

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 {
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

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/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")

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>
</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">
You have disabled all authentication options. Your readers will not be able to login, create comments, or vote.
</div>

View File

@ -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);
@ -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"]) {

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));

View File

@ -37,5 +37,12 @@
font-size: 13px;
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;
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 {