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